"""
cortex/core/reconciliation/conflict_detection.py — Conflict finding and classification.

Mixin class providing conflict detection, heuristic classification, LLM-based
classification, and anti-compression guard methods for ReconciliationEngine.

Extracted from the monolithic reconciliation.py (#946).
"""

from __future__ import annotations

import json
import logging
from typing import List, Optional, TYPE_CHECKING

from cortex.core.reconciliation.models import (
    RECONCILIATION_PROMPT,
    RelationshipKind,
)

if TYPE_CHECKING:
    from cortex.storage.adapter import SemanticClaim

logger = logging.getLogger(__name__)


class ConflictDetectionMixin:
    """Mixin providing conflict detection and classification methods.

    Intended to be mixed into ReconciliationEngine. Expects the following
    attributes on ``self``:
        - storage: StorageAdapter
        - llm: LLMProvider
        - config: CortexConfig
        - CONFIDENCE_SUPERSEDE_THRESHOLD: float
        - ANTI_COMPRESSION_LENGTH_RATIO: float
    """

    # ------------------------------------------------------------------
    # Conflict finding
    # ------------------------------------------------------------------

    async def _find_conflicts(
        self,
        candidate: "CandidateClaim",
        subject_entity_id: Optional[str],
        owner_id: str,
    ) -> List["SemanticClaim"]:
        """Find existing claims that potentially conflict with the candidate.

        Search strategy:
            1. If entity_id available → search by entity FK + predicate match
            2. Fallback → search by exact subject text + predicate match
            3. Only 'active' status claims are returned (not superseded/archived)

        Args:
            candidate: The new claim to check against.
            subject_entity_id: Resolved entity ID (may be None if resolution failed).
            owner_id: Multi-tenancy scope key.

        Returns:
            List of active SemanticClaim objects that share entity+predicate.
            Empty list means no conflict → candidate should be ADD'd.
        """
        candidates_by_entity: List["SemanticClaim"] = []

        if subject_entity_id:
            try:
                entity_claims = await self.storage.get_claims_by_entity(
                    subject_entity_id,
                    owner_id=owner_id,
                )
            except TypeError:
                # Legacy adapters/mocks without owner_id arg — log a warning
                # since unscoped queries are a multi-tenant security risk (#1542).
                logger.warning(
                    "get_claims_by_entity does not accept owner_id — "
                    "falling back to unscoped query (adapter=%s)",
                    type(self.storage).__name__,
                )
                entity_claims = await self.storage.get_claims_by_entity(subject_entity_id)
                # Filter client-side to approximate owner scoping
                entity_claims = [
                    c for c in entity_claims
                    if getattr(c, "owner_id", None) == owner_id
                ]
            candidates_by_entity = [
                c for c in entity_claims
                if c.predicate == candidate.predicate
                and c.status == "active"
            ]

        if not candidates_by_entity:
            # Fall back to exact subject-text match
            text_claims = await self.storage.get_claims_by_subject(
                candidate.subject, owner_id=owner_id
            )
            candidates_by_entity = [
                c for c in text_claims
                if c.predicate == candidate.predicate
                and c.status == "active"
            ]

        # If both exact-match strategies return nothing, try semantic similarity.
        # This handles pronoun-vs-named-entity mismatches (e.g. "I" vs "Jordan")
        # and predicate normalisation differences across LLM calls.
        if not candidates_by_entity:
            candidates_by_entity = await self._find_conflicts_semantic(
                candidate, owner_id
            )

        return candidates_by_entity

    async def _find_conflicts_semantic(
        self,
        candidate: "CandidateClaim",
        owner_id: str,
        similarity_threshold: float = 0.80,
    ) -> List["SemanticClaim"]:
        """Semantic similarity fallback for conflict detection.

        Searches existing claims by content similarity using vector embeddings.
        Used when exact entity+predicate matching fails — e.g. when the subject
        pronoun ("I") differs from a stored named entity ("Jordan"), or when the
        LLM normalises the predicate differently across turns.

        Args:
            candidate: The new claim to check for conflicts.
            owner_id: Multi-tenancy scope key.
            similarity_threshold: Minimum cosine similarity to consider a match.

        Returns:
            List of active SemanticClaim objects with high content similarity.
            Empty list on any failure (graceful degradation).
        """
        try:
            # Build content string in the same format used at storage time:
            # "{subject} {predicate} {object_value}"
            candidate_content = (
                f"{candidate.subject} {candidate.predicate} {candidate.object_value}"
            )

            # Embed the candidate content using the LLM provider.
            if not hasattr(self.llm, "embed"):
                logger.debug("Semantic conflict fallback: LLM provider has no embed()")
                return []

            embed_resp = await self.llm.embed(texts=[candidate_content])
            query_embedding: List[float] = embed_resp.embeddings[0]

            # Vector search over the claim index.
            raw_results = await self.storage.vector_search(
                query_embedding,
                item_type="claim",
                top_k=5,
                owner_id=owner_id,
            )

            # Filter by similarity threshold and hydrate to full SemanticClaim objects.
            similar_claims: List["SemanticClaim"] = []
            for result in raw_results:
                if result.score < similarity_threshold:
                    continue
                claim = await self.storage.get_claim(result.item_id)
                if claim is not None and claim.status == "active":
                    similar_claims.append(claim)

            if similar_claims:
                logger.debug(
                    "Semantic conflict fallback found %d candidate(s) for predicate %r "
                    "(threshold=%.2f)",
                    len(similar_claims),
                    candidate.predicate,
                    similarity_threshold,
                )

            return similar_claims

        except Exception as exc:
            logger.debug("Semantic conflict fallback failed: %s", exc)
            return []

    # ------------------------------------------------------------------
    # Heuristic classification (no LLM cost)
    # ------------------------------------------------------------------

    def _heuristic_classify(
        self,
        candidate: "CandidateClaim",
        existing: "SemanticClaim",
    ) -> Optional[RelationshipKind]:
        """Fast, free classification of candidate vs. existing claim.

        Rules (in order):
            - Identical values → EQUIVALENT (NOOP, no change needed)
            - W4 recency: is_user_explicit=True on candidate + existing is older → CONTRADICTION
            - Temporal marker 'current'/'past' → CONTRADICTION (newer wins)
            - Confidence gap ≥ 0.2 in candidate's favour → CONTRADICTION
            - All else → None (ambiguous, requires LLM adjudication)

        Args:
            candidate: The new claim.
            existing: The best-matching existing claim.

        Returns:
            RelationshipKind if classification is clear, None if ambiguous.
            When None is returned, the caller should invoke _classify_relationship()
            (LLM) if budget permits.
        """
        cand_val = (candidate.object_value or "").strip().lower()
        exist_val = (existing.object_value or "").strip().lower()

        # Identical values → EQUIVALENT (NOOP)
        if cand_val == exist_val:
            return RelationshipKind.EQUIVALENT

        # W4 recency precedence: explicit user statement supersedes older inference.
        # Checked before temporal_marker so explicit user voice always wins.
        is_user_explicit = getattr(candidate, "is_user_explicit", False)
        if is_user_explicit and cand_val != exist_val:
            # Only supersede when the existing claim is older / inferred.
            # We cannot compare timestamps reliably here (existing may lack
            # created_at), so we unconditionally trust the explicit user statement.
            logger.debug(
                "Recency precedence: explicit user statement supersedes older inference "
                "for predicate %r", candidate.predicate,
            )
            return RelationshipKind.CONTRADICTION

        # Temporal class 'current'/'past' on candidate → supersede (newer wins)
        # Only fires when temporal_marker is present (evidence that the LLM
        # made an explicit temporal judgment). Without a marker, temporal
        # defaults to "current" on every claim, which would make this
        # heuristic overly aggressive.
        # Fixed in #573: was previously reading temporal_marker (raw phrase)
        # instead of temporal (classification: current/past/future/permanent).
        temporal = getattr(candidate, "temporal", None)
        has_temporal_evidence = getattr(candidate, "temporal_marker", None) is not None
        if has_temporal_evidence and temporal in ("current", "past"):
            # 'current' always supersedes; 'past' supersedes only if existing is also 'past'
            existing_valid_to = getattr(existing, "valid_to", None)
            if temporal == "current" or existing_valid_to:
                return RelationshipKind.CONTRADICTION

        # Large confidence gap in candidate's favour → supersede
        if candidate.confidence - existing.confidence >= self.CONFIDENCE_SUPERSEDE_THRESHOLD:
            return RelationshipKind.CONTRADICTION

        # Values are clearly different but we cannot tell why without context → None (use LLM)
        if cand_val != exist_val:
            return None

        return None

    # ------------------------------------------------------------------
    # LLM classification
    # ------------------------------------------------------------------

    async def _classify_relationship(
        self,
        candidate: "CandidateClaim",
        existing: "SemanticClaim",
    ) -> tuple[RelationshipKind, Optional[dict]]:
        """Use LLM to classify the relationship between candidate and existing claim.

        This is the expensive fallback — only called when _heuristic_classify()
        returns None (ambiguous case). The LLM is prompted with both claims and
        asked to classify using the enriched M4 relationship taxonomy:

            EQUIVALENT / COMPLEMENTARY / ELABORATES / CONTRADICTS /
            TEMPORAL_UPDATE / CONDITIONAL / NEW

        Args:
            candidate: The new claim.
            existing: The best-matching existing claim.

        Returns:
            Tuple of (RelationshipKind, metadata_dict).
            RelationshipKind defaults to NEW on any LLM/parse error.
            metadata_dict carries structured details for richer kinds
            (e.g. condition for CONDITIONAL, previous_value for TEMPORAL_UPDATE).
        """
        prompt = RECONCILIATION_PROMPT.format(
            existing_subject=existing.subject,
            existing_predicate=existing.predicate,
            existing_value=existing.object_value,
            candidate_subject=candidate.subject,
            candidate_predicate=candidate.predicate,
            candidate_value=candidate.object_value,
        )
        try:
            from cortex.llm.router import resolve_routed_model
            _routed_model = resolve_routed_model(
                self.llm, "reconciliation", self.config.reconciliation_model,
            )
            _resp = await self.llm.complete(
                system="You are a memory reconciliation classifier. Output JSON only.",
                user=prompt,
                model=_routed_model,
                response_format="json",
            )
            raw = _resp.text if hasattr(_resp, "text") else str(_resp)
            data = json.loads(raw)
            classification = data.get("classification", "NEW").upper()
            rel = RelationshipKind(classification)
            # Extract structured metadata for enriched relationship kinds.
            metadata: Optional[dict] = data.get("metadata") or {}
            # Normalise: only keep non-empty metadata values
            metadata = {k: v for k, v in metadata.items() if v} or None
            return rel, metadata
        except (json.JSONDecodeError, ValueError) as exc:
            logger.warning("LLM classification parse error: %s — defaulting to NEW", exc)
            return RelationshipKind.NEW, None
        except Exception as exc:  # noqa: BLE001
            logger.error("LLM classification failed: %s — defaulting to NEW", exc)
            return RelationshipKind.NEW, None

    # ------------------------------------------------------------------
    # Anti-compression check
    # ------------------------------------------------------------------

    async def _check_anti_compression(
        self,
        candidate: "CandidateClaim",
        existing: "SemanticClaim",
    ) -> bool:
        """Guard against updates that compress rich detail into shorter text.

        This protects against a common LLM failure mode: summarizing a detailed
        preference into a terse version that loses personality and context.
        Example: "loves dark mode with deep purple accents for late coding" →
                 "prefers dark mode" (detail lost!)

        An update is BLOCKED (returns False) when BOTH conditions are true:
            - New value length < 80% of existing value length
            - Candidate confidence < anti_compression_confidence threshold

        Args:
            candidate: The new claim (potential replacement).
            existing: The existing claim (would be updated/superseded).

        Returns:
            True if the update is safe to proceed, False if blocked.
        """
        existing_len = len(existing.object_value or "")
        candidate_len = len(candidate.object_value or "")
        if existing_len == 0:
            return True  # Nothing to compress

        ratio = candidate_len / existing_len
        if ratio < self.ANTI_COMPRESSION_LENGTH_RATIO:
            threshold = getattr(self.config, "anti_compression_confidence", 0.9)
            if candidate.confidence < threshold:
                logger.info(
                    "Anti-compression blocked update for %s/%s "
                    "(ratio=%.2f confidence=%.2f threshold=%.2f)",
                    candidate.subject, candidate.predicate,
                    ratio, candidate.confidence, threshold,
                )
                return False
        return True
