"""
cortex/extraction/dedup.py — Extraction-time deduplication helpers.

Prevents near-duplicate claims from being stored when the same fact is
extracted multiple times (e.g. from overlapping conversation windows or
slightly re-worded repetition).

Two-level strategy
------------------
1. **Exact hash** — SHA-256 of normalized "{subject}|{predicate}|{object_value}".
   Identical facts (modulo articles / case / whitespace) hash to the same value
   and are skipped entirely.

2. **Fuzzy similarity** — SequenceMatcher ratio on the *object_value* of claims
   that share the same subject + predicate.  When ≥ ``threshold`` the new claim
   is considered a near-duplicate of the existing one; the caller should update
   the existing claim's confidence/timestamp rather than inserting a new row.

Normalization rules (applied before hashing AND before similarity comparison):
    * Lowercase
    * Strip leading/trailing whitespace; collapse internal runs of whitespace
      to a single space
    * Remove leading articles: "a", "an", "the" (word-boundary aware)

Public API
----------
    compute_claim_hash(subject, predicate, object_value) -> str
    find_similar_claims(storage, new_claims, owner_id, threshold) -> list[tuple]
    deduplicate_claims(storage, new_claims, owner_id, threshold) -> DeduplicationResult

The ``DeduplicationResult`` returned by ``deduplicate_claims`` separates claims
into three buckets:
    * ``to_store``  — genuinely new; safe to pass to ReconciliationEngine
    * ``skipped``   — exact hash match; silently dropped
    * ``to_merge``  — (new_claim, existing_claim) pairs; caller should update
                      the existing claim's confidence instead of inserting
"""

from __future__ import annotations

import hashlib
import logging
import re
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from typing import TYPE_CHECKING, List, Optional, Tuple

if TYPE_CHECKING:
    from cortex.extraction.extractor import ClaimExtraction
    from cortex.types import SemanticClaim

logger = logging.getLogger(__name__)

# Regex for removing leading articles (word-boundary, case-insensitive).
_ARTICLE_RE = re.compile(r"^\s*\b(a|an|the)\b\s*", re.IGNORECASE)

# Collapse internal whitespace.
_WS_RE = re.compile(r"\s+")


# ---------------------------------------------------------------------------
# Normalization
# ---------------------------------------------------------------------------

def _normalize(text: str) -> str:
    """Normalize a claim field for comparison / hashing.

    Steps:
        1. Lowercase
        2. Strip outer whitespace
        3. Collapse internal whitespace runs to a single space
        4. Remove a single leading article (a / an / the) if present

    Args:
        text: Raw claim field string.

    Returns:
        Normalized string suitable for hashing or similarity comparison.

    Examples:
        >>> _normalize("  The Quick Brown Fox  ")
        'quick brown fox'
        >>> _normalize("An AI assistant")
        'ai assistant'
        >>> _normalize("Eva")
        'eva'
    """
    text = text.strip().lower()
    text = _WS_RE.sub(" ", text)
    text = _ARTICLE_RE.sub("", text).strip()
    return text


# ---------------------------------------------------------------------------
# Hash
# ---------------------------------------------------------------------------

def compute_claim_hash(subject: str, predicate: str, object_value: str) -> str:
    """Compute a deterministic SHA-256 hash for a claim triple.

    Normalization is applied before hashing so that trivial variations
    (case, whitespace, articles) map to the same hash.

    Args:
        subject: Claim subject.
        predicate: Claim predicate / relationship.
        object_value: Claim object value.

    Returns:
        64-character lowercase hex SHA-256 digest.

    Examples:
        >>> compute_claim_hash("Eva", "uses email", "eva+agent-ts@100yen.org")
        # deterministic hex string
        >>> compute_claim_hash("eva", "uses email", "eva+agent-ts@100yen.org")
        # same hash as above (case-insensitive normalization)
    """
    key = f"{_normalize(subject)}|{_normalize(predicate)}|{_normalize(object_value)}"
    return hashlib.sha256(key.encode("utf-8")).hexdigest()


# ---------------------------------------------------------------------------
# Similarity
# ---------------------------------------------------------------------------

def _similarity(a: str, b: str) -> float:
    """Return SequenceMatcher similarity ratio between two strings (0.0-1.0).

    Both strings are normalized before comparison.

    Args:
        a: First string.
        b: Second string.

    Returns:
        Float in [0.0, 1.0]; 1.0 means identical after normalization.
    """
    na, nb = _normalize(a), _normalize(b)
    if na == nb:
        return 1.0
    return SequenceMatcher(None, na, nb).ratio()


def find_similar_claims(
    existing_claims: List["SemanticClaim"],
    new_claims: List["ClaimExtraction"],
    threshold: float = 0.85,
) -> List[Tuple["ClaimExtraction", "SemanticClaim"]]:
    """Find (new_claim, existing_claim) pairs that are near-duplicates.

    Strategy: for each new claim, look for an existing *active* claim with
    the same subject (normalized) **and** same predicate (normalized).
    If found, compare the object_values via ``_similarity``.  Pairs that
    score ≥ ``threshold`` are returned as near-duplicates.

    Note: exact-hash matches are a subset of what this function finds (ratio
    will be 1.0).  The caller should check for exact hashes first and skip
    those before calling this function, to avoid redundant work.

    Args:
        existing_claims: Active claims already in storage for this owner.
                         Typically the result of querying by subject.
        new_claims: Freshly extracted ClaimExtraction objects (before storage).
        threshold: Minimum SequenceMatcher ratio to consider a match.
                   Default 0.85. Must be in (0.0, 1.0].

    Returns:
        List of (new_claim, existing_claim) tuples where the pair is
        considered similar enough to be a near-duplicate.
    """
    if not existing_claims or not new_claims:
        return []

    # Build a lookup: (norm_subject, norm_predicate) → list[SemanticClaim]
    existing_index: dict[tuple[str, str], list["SemanticClaim"]] = {}
    for ec in existing_claims:
        key = (_normalize(ec.subject), _normalize(ec.predicate))
        existing_index.setdefault(key, []).append(ec)

    pairs: List[Tuple["ClaimExtraction", "SemanticClaim"]] = []
    for nc in new_claims:
        key = (_normalize(nc.subject), _normalize(nc.predicate))
        candidates = existing_index.get(key, [])
        for ec in candidates:
            score = _similarity(nc.object_value, ec.object_value)
            if score >= threshold:
                pairs.append((nc, ec))
                break  # match found — no need to compare against more candidates

    return pairs


# ---------------------------------------------------------------------------
# DeduplicationResult
# ---------------------------------------------------------------------------

@dataclass
class DeduplicationResult:
    """Output of ``deduplicate_claims``.

    Attributes:
        to_store: Claims that are genuinely new and safe to pass downstream.
        skipped: Claims dropped due to exact hash match with an existing claim.
        to_merge: (new_claim, existing_claim) pairs — near-duplicates. The
                  caller should update the existing claim's confidence and
                  updated_at instead of inserting a new row.
    """
    to_store: List["ClaimExtraction"] = field(default_factory=list)
    skipped: List["ClaimExtraction"] = field(default_factory=list)
    to_merge: List[Tuple["ClaimExtraction", "SemanticClaim"]] = field(default_factory=list)

    @property
    def total_deduped(self) -> int:
        """Total number of claims that were deduplicated (skipped + merged)."""
        return len(self.skipped) + len(self.to_merge)


# ---------------------------------------------------------------------------
# Main deduplication entry point
# ---------------------------------------------------------------------------

async def deduplicate_claims(
    storage,
    new_claims: List["ClaimExtraction"],
    owner_id: str,
    threshold: float = 0.85,
) -> DeduplicationResult:
    """Run extraction-time deduplication against existing stored claims.

    For each new claim:
        1. Compute its hash.
        2. Fetch existing active claims with the same subject from storage.
        3. If any existing claim has the same hash → skip (exact duplicate).
        4. Else if any existing claim has similarity ≥ threshold → to_merge.
        5. Otherwise → to_store (genuinely new).

    Args:
        storage: StorageAdapter instance with ``get_claims_by_subject`` method.
        new_claims: Freshly extracted ClaimExtraction objects.
        owner_id: Owner/tenant scope for storage queries.
        threshold: Fuzzy similarity threshold. Default 0.85.

    Returns:
        DeduplicationResult with three buckets: to_store, skipped, to_merge.

    Note:
        This function is intentionally storage-agnostic: it only requires a
        ``get_claims_by_subject(subject, owner_id)`` method, making it easy
        to test with mock objects.
    """
    result = DeduplicationResult()

    # Group new claims by subject to batch storage queries.
    subjects_needed: set[str] = {nc.subject for nc in new_claims}

    # Fetch existing claims per subject (one query per unique subject).
    existing_by_subject: dict[str, List["SemanticClaim"]] = {}
    for subject in subjects_needed:
        try:
            existing_by_subject[subject] = await storage.get_claims_by_subject(
                subject, owner_id=owner_id
            )
        except Exception as exc:
            logger.warning(
                "dedup: failed to fetch existing claims for subject=%r: %s — treating as new",
                subject,
                exc,
            )
            existing_by_subject[subject] = []

    # Classify each new claim.
    for nc in new_claims:
        existing = existing_by_subject.get(nc.subject, [])
        new_hash = compute_claim_hash(nc.subject, nc.predicate, nc.object_value)

        # --- Exact hash check ---
        exact_match: Optional["SemanticClaim"] = None
        for ec in existing:
            ec_hash = compute_claim_hash(ec.subject, ec.predicate, ec.object_value)
            if ec_hash == new_hash:
                exact_match = ec
                break

        if exact_match is not None:
            logger.info(
                "Skipped duplicate claim: %s %s", nc.subject, nc.predicate
            )
            result.skipped.append(nc)
            continue

        # --- Fuzzy similarity check ---
        similar_pairs = find_similar_claims(existing, [nc], threshold=threshold)
        if similar_pairs:
            _, existing_match = similar_pairs[0]
            logger.info(
                "Merged similar claim: %s %s %r → existing id=%s %r",
                nc.subject,
                nc.predicate,
                nc.object_value,
                existing_match.id,
                existing_match.object_value,
            )
            result.to_merge.append((nc, existing_match))
            continue

        # --- Genuinely new ---
        result.to_store.append(nc)

    return result


# ---------------------------------------------------------------------------
# W4: Reconciliation-time near-duplicate check
# ---------------------------------------------------------------------------

def check_near_duplicate(
    new_claim: "ClaimExtraction",
    existing_claims: List["SemanticClaim"],
    threshold: float = 0.92,
) -> Tuple[bool, Optional[str], float]:
    """Check whether *new_claim* is a near-duplicate of any existing claim.

    Used by the ReconciliationEngine (W4) to detect near-duplicate candidates
    **before** issuing an ADD action.  Unlike ``find_similar_claims`` (which
    operates on batches), this function checks a single new claim against a
    list of existing claims and returns the best match.

    Strategy:
        1. Require same subject + predicate (normalized) — different predicates
           are never duplicates even if values are similar.
        2. Compare object_values via SequenceMatcher (via ``_similarity``).
        3. Return the highest-scoring pair if it meets the threshold.

    Args:
        new_claim:       The freshly extracted claim (must have .subject,
                         .predicate, .object_value attributes).
        existing_claims: Active SemanticClaim objects to compare against.
                         Only 'active' claims should be passed in.
        threshold:       Minimum similarity ratio to declare a duplicate.
                         Default 0.92 (tighter than find_similar_claims default
                         of 0.85 to reduce false positives at reconcile-time).

    Returns:
        A 3-tuple:
            is_dup (bool):            True if a near-duplicate was found.
            matched_claim_id (str|None): ID of the best-matching existing claim,
                                         or None if no duplicate found.
            similarity (float):       SequenceMatcher ratio of the best match
                                      (0.0 if no match found).

    Examples::

        is_dup, claim_id, score = check_near_duplicate(candidate, existing_claims)
        if is_dup:
            # NOOP — near-duplicate of claim_id (score)
    """
    if not existing_claims:
        return False, None, 0.0

    norm_subj = _normalize(new_claim.subject)
    norm_pred = _normalize(new_claim.predicate)
    new_val = new_claim.object_value or ""

    best_score = 0.0
    best_id: Optional[str] = None

    for ec in existing_claims:
        # Must share subject + predicate (normalized)
        if _normalize(ec.subject) != norm_subj:
            continue
        if _normalize(ec.predicate) != norm_pred:
            continue

        score = _similarity(new_val, ec.object_value or "")
        if score > best_score:
            best_score = score
            best_id = ec.id

    if best_score >= threshold:
        logger.debug(
            "Near-duplicate detected: new=%r existing_id=%s similarity=%.3f",
            new_val, best_id, best_score,
        )
        return True, best_id, best_score

    return False, None, best_score
