"""
tests/test_extraction_dedup.py — Unit tests for cortex.extraction.dedup

Covers:
    - Normalization (articles, case, whitespace)
    - Exact hash dedup (same claims hash identically)
    - Hash distinguishes genuinely different claims
    - find_similar_claims: exact match, fuzzy match, no match
    - deduplicate_claims: skipped / to_merge / to_store buckets
    - Confidence update path (merge stores the to_merge pair)
    - DeduplicationResult.total_deduped helper
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import List, Optional

import pytest

from cortex.extraction.dedup import (
    DeduplicationResult,
    _normalize,
    compute_claim_hash,
    deduplicate_claims,
    find_similar_claims,
)
from cortex.extraction.extractor import ClaimExtraction


# ---------------------------------------------------------------------------
# Helpers: lightweight fakes so we don't need a real DB
# ---------------------------------------------------------------------------

@dataclass
class FakeSemanticClaim:
    """Minimal stand-in for cortex.types.SemanticClaim."""
    id: str
    subject: str
    predicate: str
    object_value: str
    confidence: float = 0.5
    status: str = "active"
    owner_id: str = "test"


def make_claim(
    subject: str,
    predicate: str,
    object_value: str,
    action: str = "ADD",
    confidence: float = 0.7,
) -> ClaimExtraction:
    return ClaimExtraction(
        action=action,
        subject=subject,
        predicate=predicate,
        object_value=object_value,
        confidence=confidence,
    )


def make_existing(
    subject: str,
    predicate: str,
    object_value: str,
    claim_id: str = "existing-1",
    confidence: float = 0.6,
) -> FakeSemanticClaim:
    return FakeSemanticClaim(
        id=claim_id,
        subject=subject,
        predicate=predicate,
        object_value=object_value,
        confidence=confidence,
    )


class FakeStorage:
    """Mock storage that returns pre-seeded existing claims."""

    def __init__(self, claims_by_subject: dict[str, list[FakeSemanticClaim]] | None = None):
        self._claims = claims_by_subject or {}
        self.update_calls: list[dict] = []

    async def get_claims_by_subject(self, subject: str, owner_id: str = "default"):
        return self._claims.get(subject, [])

    async def update_claim(self, claim_id: str, **kwargs):
        self.update_calls.append({"claim_id": claim_id, **kwargs})
        # Return the updated claim stub
        return FakeSemanticClaim(id=claim_id, subject="", predicate="", object_value="")


# ===========================================================================
# 1. Normalization tests
# ===========================================================================

class TestNormalize:
    """Tests for _normalize() helper."""

    def test_lowercase(self):
        assert _normalize("Eva") == "eva"

    def test_strips_whitespace(self):
        assert _normalize("  hello world  ") == "hello world"

    def test_collapses_internal_whitespace(self):
        assert _normalize("uses  email") == "uses email"

    def test_removes_leading_the(self):
        assert _normalize("the quick fox") == "quick fox"

    def test_removes_leading_a(self):
        assert _normalize("a helpful agent") == "helpful agent"

    def test_removes_leading_an(self):
        assert _normalize("an AI system") == "ai system"

    def test_no_article(self):
        # No article → unchanged (beyond case/whitespace)
        assert _normalize("OpenAI model") == "openai model"

    def test_does_not_remove_embedded_article(self):
        # "the" in the middle should not be removed
        result = _normalize("Andrew and the team")
        assert "the" in result  # article is mid-string, not leading

    def test_combined_normalization(self):
        assert _normalize("  The  EMAIL address  ") == "email address"


# ===========================================================================
# 2. Hash tests
# ===========================================================================

class TestComputeClaimHash:
    """Tests for compute_claim_hash()."""

    def test_deterministic(self):
        h1 = compute_claim_hash("Eva", "uses email", "eva+agent-ts@100yen.org")
        h2 = compute_claim_hash("Eva", "uses email", "eva+agent-ts@100yen.org")
        assert h1 == h2

    def test_case_insensitive(self):
        h1 = compute_claim_hash("eva", "uses email", "eva+agent-ts@100yen.org")
        h2 = compute_claim_hash("EVA", "USES EMAIL", "eva+agent-ts@100yen.org")
        assert h1 == h2

    def test_whitespace_insensitive(self):
        h1 = compute_claim_hash("Eva", "uses email", "eva+agent-ts@100yen.org")
        h2 = compute_claim_hash("  Eva  ", "  uses email  ", "  eva+agent-ts@100yen.org  ")
        assert h1 == h2

    def test_article_stripped(self):
        h1 = compute_claim_hash("Eva", "uses", "the email service")
        h2 = compute_claim_hash("Eva", "uses", "email service")
        assert h1 == h2

    def test_different_subject_differs(self):
        h1 = compute_claim_hash("Eva", "uses email", "eva@example.com")
        h2 = compute_claim_hash("Andrew", "uses email", "eva@example.com")
        assert h1 != h2

    def test_different_predicate_differs(self):
        h1 = compute_claim_hash("Eva", "uses email", "eva@example.com")
        h2 = compute_claim_hash("Eva", "prefers email", "eva@example.com")
        assert h1 != h2

    def test_different_object_differs(self):
        h1 = compute_claim_hash("Eva", "uses email", "eva@example.com")
        h2 = compute_claim_hash("Eva", "uses email", "other@example.com")
        assert h1 != h2

    def test_returns_hex_string(self):
        h = compute_claim_hash("Eva", "uses", "email")
        assert isinstance(h, str)
        assert len(h) == 64  # SHA-256 hex = 64 chars
        assert all(c in "0123456789abcdef" for c in h)

    # Near-duplicate email variations (the soak-test scenario from the issue)
    def test_near_duplicate_email_variants_same_hash(self):
        """Slight wording variations that normalize to the same thing."""
        h1 = compute_claim_hash("Eva", "uses email", "eva+agent-ts@100yen.org")
        h2 = compute_claim_hash("eva", "uses email", "eva+agent-ts@100yen.org")
        h3 = compute_claim_hash("Eva", "uses email", "eva+agent-ts@100yen.org")
        assert h1 == h2 == h3


# ===========================================================================
# 3. find_similar_claims tests
# ===========================================================================

class TestFindSimilarClaims:

    def test_exact_match_returns_pair(self):
        nc = make_claim("Eva", "uses email", "eva+agent-ts@100yen.org")
        ec = make_existing("Eva", "uses email", "eva+agent-ts@100yen.org")
        pairs = find_similar_claims([ec], [nc])
        assert len(pairs) == 1
        assert pairs[0][0] is nc
        assert pairs[0][1] is ec

    def test_fuzzy_match_above_threshold(self):
        nc = make_claim("Eva", "uses email", "Eva uses the address eva+agent-ts@100yen.org")
        ec = make_existing("Eva", "uses email", "Eva uses address eva+agent-ts@100yen.org")
        pairs = find_similar_claims([ec], [nc], threshold=0.85)
        assert len(pairs) == 1

    def test_below_threshold_no_match(self):
        nc = make_claim("Eva", "uses email", "eva@example.com")
        ec = make_existing("Eva", "uses email", "andrew@completely-different-domain.org")
        pairs = find_similar_claims([ec], [nc], threshold=0.85)
        assert len(pairs) == 0

    def test_different_predicate_no_match(self):
        """Same subject but different predicate → not a near-duplicate."""
        nc = make_claim("Eva", "prefers", "dark mode")
        ec = make_existing("Eva", "uses email", "eva@example.com")
        pairs = find_similar_claims([ec], [nc])
        assert len(pairs) == 0

    def test_different_subject_no_match(self):
        nc = make_claim("Andrew", "uses email", "andrew@example.com")
        ec = make_existing("Eva", "uses email", "eva@example.com")
        pairs = find_similar_claims([ec], [nc])
        assert len(pairs) == 0

    def test_empty_existing_returns_empty(self):
        nc = make_claim("Eva", "uses email", "eva@example.com")
        pairs = find_similar_claims([], [nc])
        assert pairs == []

    def test_empty_new_returns_empty(self):
        ec = make_existing("Eva", "uses email", "eva@example.com")
        pairs = find_similar_claims([ec], [])
        assert pairs == []

    def test_genuinely_different_object_passes_through(self):
        nc = make_claim("Eva", "prefers language", "Python")
        ec = make_existing("Eva", "prefers language", "JavaScript")
        pairs = find_similar_claims([ec], [nc], threshold=0.85)
        # "Python" vs "JavaScript" should be below threshold
        assert len(pairs) == 0

    def test_multiple_candidates_picks_first_match(self):
        nc = make_claim("Eva", "uses email", "eva+agent-ts@100yen.org")
        ec1 = make_existing("Eva", "uses email", "eva+agent-ts@100yen.org", claim_id="id-1")
        ec2 = make_existing("Eva", "uses email", "eva+agent-ts@100yen.org", claim_id="id-2")
        pairs = find_similar_claims([ec1, ec2], [nc])
        assert len(pairs) == 1  # only one match returned per new claim


# ===========================================================================
# 4. deduplicate_claims integration tests
# ===========================================================================

class TestDeduplicateClaims:
    """Async integration tests for deduplicate_claims()."""

    async def test_exact_hash_claim_is_skipped(self):
        existing = make_existing("Eva", "uses email", "eva+agent-ts@100yen.org")
        storage = FakeStorage({"Eva": [existing]})
        nc = make_claim("Eva", "uses email", "eva+agent-ts@100yen.org")
        result = await deduplicate_claims(storage, [nc], owner_id="test")

        assert len(result.skipped) == 1
        assert len(result.to_store) == 0
        assert len(result.to_merge) == 0

    async def test_similar_claim_goes_to_merge(self):
        existing = make_existing("Eva", "uses email", "Eva agent email eva+agent-ts@100yen.org")
        storage = FakeStorage({"Eva": [existing]})
        nc = make_claim("Eva", "uses email", "Eva's agent email is eva+agent-ts@100yen.org")
        result = await deduplicate_claims(storage, [nc], owner_id="test")

        # Either skipped (exact hash) or merged (fuzzy) or to_store — total must be 1
        assert len(result.skipped) + len(result.to_merge) + len(result.to_store) == 1

    async def test_new_claim_passes_through(self):
        storage = FakeStorage({})  # no existing claims
        nc = make_claim("Eva", "prefers", "dark mode")
        result = await deduplicate_claims(storage, [nc], owner_id="test")

        assert len(result.to_store) == 1
        assert len(result.skipped) == 0
        assert len(result.to_merge) == 0

    async def test_mixed_batch(self):
        """One duplicate + one new claim in same batch."""
        existing = make_existing("Eva", "uses email", "eva+agent-ts@100yen.org")
        storage = FakeStorage({"Eva": [existing]})
        dup = make_claim("Eva", "uses email", "eva+agent-ts@100yen.org")
        new_c = make_claim("Eva", "prefers", "Python")
        result = await deduplicate_claims(storage, [dup, new_c], owner_id="test")

        assert len(result.skipped) == 1
        assert len(result.to_store) == 1
        assert result.to_store[0].predicate == "prefers"

    async def test_total_deduped(self):
        existing1 = make_existing("Eva", "uses email", "eva+agent-ts@100yen.org", claim_id="id-1")
        storage = FakeStorage({"Eva": [existing1]})
        nc = make_claim("Eva", "uses email", "eva+agent-ts@100yen.org")
        result = await deduplicate_claims(storage, [nc], owner_id="test")

        assert result.total_deduped == 1

    async def test_storage_error_is_non_fatal(self):
        """If storage query fails, claim is treated as new (non-fatal)."""
        class BrokenStorage:
            async def get_claims_by_subject(self, subject, owner_id="default"):
                raise RuntimeError("DB is down")

        nc = make_claim("Eva", "uses email", "eva@example.com")
        result = await deduplicate_claims(BrokenStorage(), [nc], owner_id="test")

        # Graceful fallback: claim passes through as to_store
        assert len(result.to_store) == 1

    async def test_empty_input_returns_empty_result(self):
        storage = FakeStorage({})
        result = await deduplicate_claims(storage, [], owner_id="test")

        assert result.to_store == []
        assert result.skipped == []
        assert result.to_merge == []
        assert result.total_deduped == 0

    async def test_confidence_update_on_merge(self):
        """When a near-duplicate merge happens, existing claim confidence is bumped."""
        # We test deduplicate_claims returns the pair in to_merge;
        # the plugin.py caller does the actual storage.update_claim().
        existing = make_existing(
            "Eva", "uses email", "eva agent email is eva+agent-ts@100yen.org",
            claim_id="claim-abc",
            confidence=0.5,
        )
        storage = FakeStorage({"Eva": [existing]})
        # New claim with slightly different wording but same high similarity
        nc = make_claim(
            "Eva", "uses email", "eva+agent email is eva+agent-ts@100yen.org",
            confidence=0.8,
        )
        result = await deduplicate_claims(storage, [nc], owner_id="test")

        # Either skipped (exact) or merged (fuzzy) — not stored as new
        assert len(result.to_store) == 0
        if result.to_merge:
            new_c, existing_c = result.to_merge[0]
            assert existing_c.id == "claim-abc"

    async def test_genuinely_different_claims_all_pass_through(self):
        """Three different claims with no stored history → all go to to_store."""
        storage = FakeStorage({})
        claims = [
            make_claim("Eva", "prefers language", "Python"),
            make_claim("Andrew", "works at", "100Yen Org"),
            make_claim("Eva", "timezone", "Asia/Bangkok"),
        ]
        result = await deduplicate_claims(storage, claims, owner_id="test")
        assert len(result.to_store) == 3
        assert len(result.skipped) == 0
        assert len(result.to_merge) == 0


# ===========================================================================
# 5. DeduplicationResult helper tests
# ===========================================================================

class TestDeduplicationResult:

    def test_total_deduped_empty(self):
        r = DeduplicationResult()
        assert r.total_deduped == 0

    def test_total_deduped_counts_skipped_and_merge(self):
        nc = make_claim("Eva", "x", "y")
        ec = make_existing("Eva", "x", "y")
        r = DeduplicationResult(
            to_store=[nc],
            skipped=[nc],
            to_merge=[(nc, ec)],
        )
        assert r.total_deduped == 2
