"""
cortex/core/reconciliation.py — Reconciliation Engine (Module 4 / M4).

The decision-maker of the remember() pipeline. Takes extracted claims from
ExtractionPipeline (M2) and decides what to do with each one:

    ADD       — New fact, no existing match → create new SemanticClaim
    UPDATE    — Complements existing claim → merge values
    SUPERSEDE — Contradicts existing claim → mark old as superseded, create new
    NOOP      — Duplicate of existing claim → skip

Per-claim resolution pipeline:
    1. EntityResolver.resolve() — resolve subject mention to canonical entity
    2. _find_conflicts() — look for existing claims with same entity+predicate
    3. _heuristic_classify() — fast, free classification (identical, temporal, confidence)
    4. _classify_relationship() — LLM adjudication (only if heuristics inconclusive)
    5. _check_anti_compression() — guard against merges that lose detail
    6. _apply_action() — write to storage via StorageAdapter (M1)

Guard rails:
    - max_llm_calls_per_batch (default 5) caps LLM spend per reconcile() call
    - Anti-compression safeguard: blocks UPDATE/SUPERSEDE when new value is <80%
      the length of existing AND candidate confidence is below threshold
    - Unresolvable claims flagged for human review (NOOP with note)

Dependencies:
    - cortex.storage.adapter.StorageAdapter (M1) — claim CRUD, provenance
    - cortex.llm.provider.LLMProvider — for ambiguous classification
    - cortex.core.entity_resolution.EntityResolver (M3) — subject resolution
    - cortex.config.CortexConfig — model selection, thresholds

Data flow:
    CortexPlugin.remember()
      → ExtractionPipeline.extract() → List[ClaimExtraction]
      → ReconciliationEngine.reconcile(candidates)
          → for each candidate:
              EntityResolver.resolve() → entity_id
              StorageAdapter.get_claims_by_entity() → existing matches
              classify → action → StorageAdapter.create_claim() / update_claim()
      → ReconciliationReport (aggregate stats)

Relationship kinds (M4 LLM classification — Issue #82):
    EQUIVALENT     — Identical information; no change needed (NOOP)
    COMPLEMENTARY  — New info adds breadth to existing (UPDATE / merge)
    ELABORATES     — New claim deepens detail on existing (UPDATE / merge-detail)
    CONTRADICTS    — New claim directly conflicts with existing (SUPERSEDE)
    CONTRADICTION  — Alias kept for heuristic compatibility (SUPERSEDE)
    TEMPORAL_UPDATE— Time-based change ("moved from X to Y") (SUPERSEDE + temporal metadata)
    CONDITIONAL    — Context-dependent claim ("likes X when Y") (ADD with condition metadata)
    NEW            — Unrelated to existing; store separately (ADD)
"""

from __future__ import annotations

import json
import logging
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import List, Optional, TYPE_CHECKING

if TYPE_CHECKING:
    from cortex.storage.adapter import StorageAdapter, SemanticClaim
    from cortex.llm import LLMProvider
    from cortex.core.entity_resolution import EntityResolver
    from cortex.config import CortexConfig

# W4: contradiction tracking + near-duplicate check
from cortex.core.contradictions import ContradictionTracker
from cortex.core.contradiction_store import insert_contradiction as _persist_contradiction
from cortex.extraction.dedup import check_near_duplicate

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Enums & result types
# ---------------------------------------------------------------------------

class ReconciliationAction(str, Enum):
    """Action the reconciliation engine takes for an incoming claim."""

    ADD = "ADD"
    UPDATE = "UPDATE"
    SUPERSEDE = "SUPERSEDE"
    NOOP = "NOOP"


class RelationshipKind(str, Enum):
    """Relationship between a new claim and an existing one."""
    # --- Existing (kept for backward compatibility) ---
    EQUIVALENT = "EQUIVALENT"
    COMPLEMENTARY = "COMPLEMENTARY"
    CONTRADICTION = "CONTRADICTION"
    NEW = "NEW"
    # --- M4 enriched relationship types (Issue #82) ---
    ELABORATES = "ELABORATES"          # new claim adds detail to existing → UPDATE
    CONTRADICTS = "CONTRADICTS"        # new claim directly conflicts → SUPERSEDE
    TEMPORAL_UPDATE = "TEMPORAL_UPDATE"  # time-based change ("moved from X to Y") → SUPERSEDE
    CONDITIONAL = "CONDITIONAL"        # context-dependent claim ("likes X when Y") → ADD


@dataclass
class ReconciliationResult:
    """Result of reconciling a single candidate claim."""
    candidate_subject: str
    candidate_predicate: str
    candidate_value: str
    action: ReconciliationAction = ReconciliationAction.NOOP
    # Set when an existing claim was found
    existing_claim_id: Optional[str] = None
    # Set on ADD / UPDATE — the new/updated claim's ID
    resulting_claim_id: Optional[str] = None
    # Relationship classification that drove the decision
    relationship: Optional[RelationshipKind] = None
    confidence: float = 0.0
    # True if update was blocked by anti-compression safeguard
    compression_blocked: bool = False
    # True if an LLM call was consumed for this result
    used_llm: bool = False
    notes: str = ""
    # Structured metadata from the relationship classification (M4 / Issue #82).
    # Populated for richer relationship kinds:
    #   ELABORATES     → {"detail_added": "<new aspect>"}
    #   TEMPORAL_UPDATE→ {"previous_value": "<old>", "new_value": "<new>"}
    #   CONDITIONAL    → {"condition": "<context string>"}
    #   CONTRADICTS    → {"conflict_reason": "<brief reason>"}
    relationship_metadata: Optional[dict] = None


@dataclass
class ReconciliationReport:
    """Aggregate stats for a full reconcile() call."""
    added: int = 0
    updated: int = 0
    superseded: int = 0
    nooped: int = 0
    compression_blocked: int = 0
    cornerstone_guarded: int = 0
    entities_resolved: int = 0
    entities_created: int = 0
    edges_created: int = 0
    duration_ms: int = 0
    llm_calls_used: int = 0
    flagged_for_review: int = 0
    results: List[ReconciliationResult] = field(default_factory=list)


# ---------------------------------------------------------------------------
# LLM prompt
# ---------------------------------------------------------------------------

RECONCILIATION_PROMPT = """Compare these two memory claims and classify their relationship.

EXISTING claim:
Subject: {existing_subject}
Predicate: {existing_predicate}
Value: {existing_value}

NEW candidate:
Subject: {candidate_subject}
Predicate: {candidate_predicate}
Value: {candidate_value}

Classify as ONE of:
- EQUIVALENT: Identical information — no update needed.
- COMPLEMENTARY: New info extends or enriches existing in a general way (merge/update).
- ELABORATES: New claim adds specific detail or nuance to existing (e.g. "likes coffee"
  → "likes strong black coffee in the morning"). Prefer over COMPLEMENTARY when the new
  value deepens a specific aspect rather than adding breadth.
- CONTRADICTS: New claim directly conflicts with or negates existing info (supersede old).
  Use when the two values cannot both be true at the same time.
- TEMPORAL_UPDATE: The change is explicitly time-based — the subject moved, changed role,
  or updated a preference over time (e.g. "lives in Tokyo" → "moved to Bangkok"). The old
  value was once true but is now outdated.
- CONDITIONAL: The new claim is explicitly context-dependent and should be stored
  separately from any unconditional version (e.g. "likes jazz when working late").
  It is NOT a contradiction — both can be true.
- NEW: Completely different topic — add as a separate claim.

Also assess: would merging lose important personality, emotional, or contextual detail?
If so, prefer ELABORATES or NEW over COMPLEMENTARY.

Output JSON only:
{{"classification": "EQUIVALENT|COMPLEMENTARY|ELABORATES|CONTRADICTS|TEMPORAL_UPDATE|CONDITIONAL|NEW",
  "confidence": 0.0-1.0,
  "reasoning": "brief explanation",
  "metadata": {{
    "detail_added": "(ELABORATES only) the new specific aspect added",
    "previous_value": "(TEMPORAL_UPDATE only) the old value being replaced",
    "new_value": "(TEMPORAL_UPDATE only) the new current value",
    "condition": "(CONDITIONAL only) the context/condition string",
    "conflict_reason": "(CONTRADICTS only) why the two values conflict"
  }}
}}"""


# ---------------------------------------------------------------------------
# Main engine
# ---------------------------------------------------------------------------

class ReconciliationEngine:
    """
    Reconciles CandidateClaim objects against existing SemanticClaims.

    Resolution strategy (applied in order):
      1. Heuristic: if same entity+predicate, compare values
         a. Identical → NOOP
         b. Newer timestamp + temporal_marker 'current'/'past' → SUPERSEDE (temporal)
         c. Candidate confidence significantly higher → SUPERSEDE (confidence-weighted)
      2. LLM adjudication for ambiguous cases (capped at max_llm_calls_per_batch)
      3. Unresolvable → flag for human review (NOOP + note)
    """

    DEFAULT_MAX_LLM_CALLS = 5
    # Confidence delta to trigger heuristic supersede without LLM
    CONFIDENCE_SUPERSEDE_THRESHOLD = 0.2
    # Anti-compression: block UPDATE when new value is <80% length of existing
    ANTI_COMPRESSION_LENGTH_RATIO = 0.80

    def __init__(
        self,
        storage: "StorageAdapter",
        config: "CortexConfig",
        llm: "LLMProvider",
        entity_resolver: "EntityResolver",
        max_llm_calls_per_batch: Optional[int] = None,
        contradiction_tracker: Optional[ContradictionTracker] = None,
    ) -> None:
        self.storage = storage
        self.config = config
        self.llm = llm
        self.entity_resolver = entity_resolver
        self.max_llm_calls_per_batch = (
            max_llm_calls_per_batch
            if max_llm_calls_per_batch is not None
            else self.DEFAULT_MAX_LLM_CALLS
        )
        # W4: contradiction tracker — created automatically if not injected
        self.contradiction_tracker: ContradictionTracker = (
            contradiction_tracker
            if contradiction_tracker is not None
            else ContradictionTracker()
        )

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    async def reconcile(
        self,
        candidates: List["CandidateClaim"],
        session_id: str,
        owner_id: str = "default",
    ) -> ReconciliationReport:
        """Reconcile a batch of candidate claims against existing memory.

        This is the main entry point — called by CortexPlugin.remember() after
        extraction. Each candidate is processed independently, sharing a
        per-batch LLM call budget.

        For each candidate:
            1. Resolve subject entity (EntityResolver → entity_id)
            2. Find conflicting claims (same entity + same predicate)
            3. Classify relationship (heuristic → LLM if ambiguous)
            4. Anti-compression check (guard against detail loss on UPDATE)
            5. Apply action (NOOP / ADD / UPDATE / SUPERSEDE)
            6. Record provenance links (claim → source episode)

        Args:
            candidates: List of CandidateClaim objects from ExtractionPipeline.
            session_id: Session record ID (FK for source_session).
            owner_id: Multi-tenancy scope key.

        Returns:
            ReconciliationReport with per-claim results and aggregate stats
            (added, updated, superseded, nooped, LLM calls used, etc.).
        """
        t_start = time.monotonic()
        report = ReconciliationReport()
        llm_calls_remaining = self.max_llm_calls_per_batch

        for candidate in candidates:
            result = await self._reconcile_one(
                candidate=candidate,
                session_id=session_id,
                owner_id=owner_id,
                llm_calls_remaining=llm_calls_remaining,
            )

            # Deduct LLM calls used
            if result.used_llm:
                llm_calls_remaining -= 1
                report.llm_calls_used += 1

            # Aggregate stats
            if result.action == ReconciliationAction.ADD:
                report.added += 1
            elif result.action == ReconciliationAction.UPDATE:
                report.updated += 1
            elif result.action == ReconciliationAction.SUPERSEDE:
                report.superseded += 1
            elif result.action == ReconciliationAction.NOOP:
                report.nooped += 1

            if result.compression_blocked:
                report.compression_blocked += 1

            if "human_review" in result.notes.lower() or "unresolvable" in result.notes.lower():
                report.flagged_for_review += 1

            report.results.append(result)

        elapsed_ms = int((time.monotonic() - t_start) * 1000)
        report.duration_ms = elapsed_ms
        logger.info(
            "Reconciliation complete: added=%d updated=%d superseded=%d nooped=%d "
            "llm_calls=%d/%d duration_ms=%d",
            report.added, report.updated, report.superseded, report.nooped,
            report.llm_calls_used, self.max_llm_calls_per_batch, elapsed_ms,
        )
        return report

    # ------------------------------------------------------------------
    # Internal per-claim logic
    # ------------------------------------------------------------------

    async def _reconcile_one(
        self,
        candidate: "CandidateClaim",
        session_id: str,
        owner_id: str,
        llm_calls_remaining: int,
    ) -> ReconciliationResult:
        """Process a single candidate claim through the full reconciliation pipeline.

        Steps: entity resolution → conflict search → heuristic classify →
        (optional LLM classify) → anti-compression check → apply action.

        Args:
            candidate: The extracted claim to reconcile.
            session_id: Session record ID for provenance.
            owner_id: Multi-tenancy scope key.
            llm_calls_remaining: How many LLM calls are still available in this batch.

        Returns:
            ReconciliationResult with action taken and metadata.
        """

        # Step 1: Resolve entity for the subject
        subject_entity_id: Optional[str] = None
        try:
            resolved = await self.entity_resolver.resolve(
                mention=candidate.subject,
                context=f"{candidate.predicate} {candidate.object_value}",
                owner_id=owner_id,
                entity_type=candidate.entity_type or "unknown",
            )
            subject_entity_id = resolved.entity_id
        except Exception as exc:  # noqa: BLE001
            logger.warning("Entity resolution failed for %r: %s", candidate.subject, exc)

        # Step 2: Find candidate conflicts — claims with same entity+predicate
        existing_matches: List["SemanticClaim"] = await self._find_conflicts(
            candidate=candidate,
            subject_entity_id=subject_entity_id,
            owner_id=owner_id,
        )

        # Step 3: No conflict → near-duplicate check, then ADD
        if not existing_matches:
            # W4: check for near-duplicates across ALL active claims for this
            # owner before issuing an ADD. Uses a tighter threshold (0.92) than
            # extraction-time dedup (0.85) to catch reconciliation-time dupes.
            try:
                all_owner_claims = await self.storage.get_claims_by_subject(
                    candidate.subject, owner_id=owner_id
                )
                active_claims = [c for c in all_owner_claims if c.status == "active"]
                is_dup, dup_id, dup_score = check_near_duplicate(
                    candidate, active_claims, threshold=0.92
                )
            except Exception as exc:  # noqa: BLE001
                logger.warning("Near-duplicate check failed for %r: %s", candidate.subject, exc)
                is_dup, dup_id, dup_score = False, None, 0.0

            if is_dup and dup_id:
                return ReconciliationResult(
                    candidate_subject=candidate.subject,
                    candidate_predicate=candidate.predicate,
                    candidate_value=candidate.object_value,
                    action=ReconciliationAction.NOOP,
                    existing_claim_id=dup_id,
                    relationship=RelationshipKind.EQUIVALENT,
                    confidence=candidate.confidence,
                    notes=(
                        f"near-duplicate of claim {dup_id} "
                        f"(similarity={dup_score:.3f}, threshold=0.92)"
                    ),
                )

            new_claim = await self._add_claim(
                candidate=candidate,
                session_id=session_id,
                owner_id=owner_id,
                subject_entity_id=subject_entity_id,
            )
            return ReconciliationResult(
                candidate_subject=candidate.subject,
                candidate_predicate=candidate.predicate,
                candidate_value=candidate.object_value,
                action=ReconciliationAction.ADD,
                resulting_claim_id=new_claim.id,
                relationship=RelationshipKind.NEW,
                confidence=candidate.confidence,
                notes="new claim added",
            )

        # Step 4: Evaluate against best existing match (highest confidence)
        existing = max(existing_matches, key=lambda c: c.confidence)

        # Step 4b: Real-time supersession (#514)
        # When the extraction pipeline explicitly signals action=UPDATE, bypass
        # heuristic/LLM classification and supersede the matching active claim
        # immediately.  This keeps ingestion latency low — no LLM call required
        # for claims that the extractor already determined are authoritative updates.
        candidate_action = getattr(candidate, "action", "ADD")
        if candidate_action == "UPDATE":
            return await self._supersede_claim(
                candidate=candidate,
                existing=existing,
                session_id=session_id,
                owner_id=owner_id,
                subject_entity_id=subject_entity_id,
                reason="action=UPDATE: real-time supersession during ingestion",
            )

        # Step 5: Heuristic classification
        heuristic_rel = self._heuristic_classify(candidate, existing)

        if heuristic_rel is not None:
            rel = heuristic_rel
            rel_metadata: Optional[dict] = None
            used_llm = False
        elif llm_calls_remaining > 0:
            # Step 6: LLM adjudication (returns enriched kind + structured metadata)
            rel, rel_metadata = await self._classify_relationship(candidate, existing)
            used_llm = True
        else:
            # LLM budget exhausted — flag for human review
            logger.warning(
                "LLM budget exhausted; flagging claim for human review: %s %s",
                candidate.subject, candidate.predicate,
            )
            return ReconciliationResult(
                candidate_subject=candidate.subject,
                candidate_predicate=candidate.predicate,
                candidate_value=candidate.object_value,
                action=ReconciliationAction.NOOP,
                existing_claim_id=existing.id,
                relationship=None,
                confidence=candidate.confidence,
                used_llm=False,
                notes="unresolvable: LLM budget exhausted — flagged for human review",
            )

        # Step 7: Apply action based on classification
        return await self._apply_action(
            candidate=candidate,
            existing=existing,
            relationship=rel,
            relationship_metadata=rel_metadata,
            session_id=session_id,
            owner_id=owner_id,
            subject_entity_id=subject_entity_id,
            used_llm=used_llm,
        )

    # ------------------------------------------------------------------
    # Real-time supersession (#514)
    # ------------------------------------------------------------------

    async def _supersede_claim(
        self,
        candidate: "CandidateClaim",
        existing: "SemanticClaim",
        session_id: str,
        owner_id: str,
        subject_entity_id: Optional[str],
        reason: str = "real-time supersession during ingestion",
    ) -> ReconciliationResult:
        """Supersede an existing active claim with the incoming candidate.

        Used when the extraction pipeline signals ``action=UPDATE`` on a
        candidate, indicating an authoritative in-session override that should
        take effect immediately without waiting for dream-cycle reconciliation
        or spending an LLM classification call.

        Steps:
            1. Anti-compression check (guard against lossy replacement).
            2. Mark existing claim ``status=superseded``.
            3. Create new claim (active) with ``supersedes_id`` in notes.
            4. Set ``superseded_by=new_claim.id`` on the old claim — closes
               the bidirectional supersession chain.
            5. Log changelog entries for both claims.
            6. Record contradiction for the audit trail.

        Args:
            candidate: The incoming claim (the replacement).
            existing:  The current active claim being superseded.
            session_id: Session record ID for provenance.
            owner_id:  Multi-tenancy scope key.
            subject_entity_id: Resolved entity FK (may be None).
            reason:    Human-readable reason stored in the changelog.

        Returns:
            ReconciliationResult with action=SUPERSEDE on success, or
            action=NOOP if anti-compression safeguard blocks the update.
        """
        # Anti-compression guard: don't lose rich detail for terse updates.
        ok = await self._check_anti_compression(candidate, existing)
        if not ok:
            logger.info(
                "Real-time supersession blocked by anti-compression guard for %s/%s",
                candidate.subject, candidate.predicate,
            )
            return ReconciliationResult(
                candidate_subject=candidate.subject,
                candidate_predicate=candidate.predicate,
                candidate_value=candidate.object_value,
                action=ReconciliationAction.NOOP,
                existing_claim_id=existing.id,
                relationship=RelationshipKind.CONTRADICTION,
                confidence=candidate.confidence,
                compression_blocked=True,
                notes="real-time supersession blocked by anti-compression safeguard",
            )

        # Mark old claim as superseded (status only; superseded_by set after new ID known).
        await self.storage.update_claim(
            existing.id,
            status="superseded",
            updated_at=_now(),
        )

        # Changelog: record the supersession on the old claim.
        try:
            history = await self.storage.get_changelog(existing.id)
            next_ver = len(history) + 1
            await self.storage.log_changelog(
                claim_id=existing.id,
                version=next_ver,
                content_snapshot=existing.object_value,
                action="superseded",
                reason=reason,
                changed_by="reconciliation",
                diff_summary=f"'{existing.object_value}' → '{candidate.object_value}'",
            )
        except Exception as exc:  # noqa: BLE001
            logger.warning("Failed to log changelog for superseded claim %s: %s", existing.id, exc)

        # Create the new (active) claim.
        new_claim = await self._add_claim(
            candidate=candidate,
            session_id=session_id,
            owner_id=owner_id,
            subject_entity_id=subject_entity_id,
            supersedes_id=existing.id,
        )

        # #514: Set superseded_by FK on the old claim — closes the bidirectional chain.
        try:
            await self.storage.update_claim(
                existing.id,
                superseded_by=new_claim.id,
            )
        except Exception as exc:  # noqa: BLE001
            logger.warning(
                "Failed to set superseded_by on claim %s → %s: %s",
                existing.id, new_claim.id, exc,
            )

        # Audit trail — in-memory + DB persistence.
        self.contradiction_tracker.record(
            claim_id=new_claim.id,
            contradicts_id=existing.id,
            resolution="superseded",
            reason=reason,
        )
        try:
            await _persist_contradiction(
                self.storage,
                claim_a_id=new_claim.id,
                claim_b_id=existing.id,
                owner_id=owner_id,
                reason=reason,
                resolution="kept_a",  # new claim wins
            )
        except Exception as _exc:  # noqa: BLE001
            logger.warning("Failed to persist contradiction to DB: %s", _exc)

        logger.info(
            "Real-time supersession: claim %s superseded by %s (subject=%r predicate=%r)",
            existing.id[:8], new_claim.id[:8], candidate.subject, candidate.predicate,
        )

        return ReconciliationResult(
            candidate_subject=candidate.subject,
            candidate_predicate=candidate.predicate,
            candidate_value=candidate.object_value,
            action=ReconciliationAction.SUPERSEDE,
            existing_claim_id=existing.id,
            resulting_claim_id=new_claim.id,
            relationship=RelationshipKind.CONTRADICTION,
            confidence=candidate.confidence,
            used_llm=False,
            notes=f"real-time supersession: superseded claim {existing.id} ({reason})",
        )

    # ------------------------------------------------------------------
    # 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:
                # Backward compatibility for legacy adapters/mocks without owner_id arg.
                entity_claims = await self.storage.get_claims_by_entity(subject_entity_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"
            ]

        return candidates_by_entity

    # ------------------------------------------------------------------
    # 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

    # ------------------------------------------------------------------
    # Action application
    # ------------------------------------------------------------------

    async def _apply_action(
        self,
        candidate: "CandidateClaim",
        existing: "SemanticClaim",
        relationship: RelationshipKind,
        session_id: str,
        owner_id: str,
        subject_entity_id: Optional[str],
        used_llm: bool,
        relationship_metadata: Optional[dict] = None,
    ) -> ReconciliationResult:
        """Map relationship → action and write to storage.

        Relationship → Action mapping (M4 enriched, Issue #82):
            EQUIVALENT     → NOOP   (identical, skip)
            COMPLEMENTARY  → UPDATE (merge values, breadth extension)
            ELABORATES     → UPDATE (merge values, depth/detail extension)
            CONTRADICTION  → SUPERSEDE (heuristic-driven conflict)
            CONTRADICTS    → SUPERSEDE (LLM-identified explicit conflict)
            TEMPORAL_UPDATE→ SUPERSEDE (time-based change, temporal metadata stored)
            CONDITIONAL    → ADD    (context-dependent, stored separately with condition)
            NEW / other    → ADD    (unrelated, add independently)
        """

        base = ReconciliationResult(
            candidate_subject=candidate.subject,
            candidate_predicate=candidate.predicate,
            candidate_value=candidate.object_value,
            relationship=relationship,
            existing_claim_id=existing.id,
            confidence=candidate.confidence,
            used_llm=used_llm,
            relationship_metadata=relationship_metadata,
        )

        # ------------------------------------------------------------------
        # EQUIVALENT → NOOP
        # ------------------------------------------------------------------
        if relationship == RelationshipKind.EQUIVALENT:
            base.action = ReconciliationAction.NOOP
            base.notes = "equivalent claim already exists"
            return base

        # ------------------------------------------------------------------
        # COMPLEMENTARY → UPDATE (merge: breadth extension)
        # ELABORATES    → UPDATE (merge: depth/detail extension)
        # Both merge existing + new values; ELABORATES notes the new detail.
        # ------------------------------------------------------------------
        if relationship in (RelationshipKind.COMPLEMENTARY, RelationshipKind.ELABORATES):
            ok = await self._check_anti_compression(candidate, existing)
            if not ok:
                base.action = ReconciliationAction.NOOP
                base.compression_blocked = True
                base.notes = "update blocked by anti-compression safeguard"
                return base

            merged_value = f"{existing.object_value}; {candidate.object_value}"
            extra_notes = ""
            if relationship == RelationshipKind.ELABORATES and relationship_metadata:
                detail = relationship_metadata.get("detail_added", "")
                if detail:
                    extra_notes = f" [elaborates: {detail}]"

            updated = await self.storage.update_claim(
                existing.id,
                object_value=merged_value,
                confidence=max(existing.confidence, candidate.confidence),
                updated_at=_now(),
            )
            await self._record_provenance(updated.id, session_id, candidate)

            # Changelog: version N+1 for the updated claim
            try:
                history = await self.storage.get_changelog(existing.id)
                next_ver = len(history) + 1
                diff_summary = (
                    f"merged '{existing.object_value[:100]}' + '{candidate.object_value[:100]}'"
                )
                await self.storage.log_changelog(
                    claim_id=updated.id,
                    version=next_ver,
                    content_snapshot=merged_value,
                    action="updated",
                    reason="reconciliation merge",
                    changed_by="reconciliation",
                    diff_summary=diff_summary,
                )
            except Exception as exc:  # noqa: BLE001
                logger.warning("Failed to log changelog for updated claim %s: %s", existing.id, exc)

            base.action = ReconciliationAction.UPDATE
            base.resulting_claim_id = updated.id
            if extra_notes:
                base.notes = extra_notes.strip()
            else:
                rel_label = relationship.value.lower()
                base.notes = f"merged existing claim {existing.id} ({rel_label})"
            return base

        # ------------------------------------------------------------------
        # CONTRADICTION (heuristic) / CONTRADICTS (LLM) → SUPERSEDE
        # ------------------------------------------------------------------
        if relationship in (RelationshipKind.CONTRADICTION, RelationshipKind.CONTRADICTS):
            ok = await self._check_anti_compression(candidate, existing)
            if not ok:
                base.action = ReconciliationAction.NOOP
                base.compression_blocked = True
                base.notes = "supersede blocked by anti-compression safeguard"
                return base

            await self.storage.update_claim(
                existing.id,
                status="superseded",
                updated_at=_now(),
            )

            # Changelog: mark old claim as superseded
            try:
                history = await self.storage.get_changelog(existing.id)
                next_ver = len(history) + 1
                await self.storage.log_changelog(
                    claim_id=existing.id,
                    version=next_ver,
                    content_snapshot=existing.object_value,
                    action="superseded",
                    reason="superseded by contradicting claim",
                    changed_by="reconciliation",
                    diff_summary=None,
                )
            except Exception as exc:  # noqa: BLE001
                logger.warning("Failed to log changelog for superseded claim %s: %s", existing.id, exc)

            conflict_note = ""
            if relationship == RelationshipKind.CONTRADICTS and relationship_metadata:
                reason = relationship_metadata.get("conflict_reason", "")
                if reason:
                    conflict_note = f" [conflict: {reason}]"

            new_claim = await self._add_claim(
                candidate=candidate,
                session_id=session_id,
                owner_id=owner_id,
                subject_entity_id=subject_entity_id,
                supersedes_id=existing.id,
                relationship_metadata=relationship_metadata,
            )

            # #514: Set superseded_by FK on the old claim now that we have the
            # new claim's ID.  This closes the bidirectional link so queries
            # can walk the supersession chain in either direction.
            try:
                await self.storage.update_claim(
                    existing.id,
                    superseded_by=new_claim.id,
                )
            except Exception as exc:  # noqa: BLE001
                logger.warning(
                    "Failed to set superseded_by on claim %s → %s: %s",
                    existing.id, new_claim.id, exc,
                )

            base.action = ReconciliationAction.SUPERSEDE
            base.resulting_claim_id = new_claim.id
            base.notes = f"superseded claim {existing.id}{conflict_note}"
            # W4: record contradiction for audit trail — in-memory + DB persistence
            _contradiction_reason = "contradiction"
            if getattr(candidate, "is_user_explicit", False):
                _contradiction_reason = "recency: explicit user statement supersedes older inference"
            elif conflict_note:
                _contradiction_reason = conflict_note.strip(" []")
            self.contradiction_tracker.record(
                claim_id=new_claim.id,
                contradicts_id=existing.id,
                resolution="superseded",
                reason=_contradiction_reason,
            )
            try:
                await _persist_contradiction(
                    self.storage,
                    claim_a_id=new_claim.id,
                    claim_b_id=existing.id,
                    owner_id=owner_id,
                    reason=_contradiction_reason,
                    resolution="kept_a",
                )
            except Exception as _exc:  # noqa: BLE001
                logger.warning("Failed to persist contradiction to DB: %s", _exc)
            return base

        # ------------------------------------------------------------------
        # TEMPORAL_UPDATE → SUPERSEDE with temporal metadata
        # Records previous_value and temporal transition in the new claim.
        # ------------------------------------------------------------------
        if relationship == RelationshipKind.TEMPORAL_UPDATE:
            ok = await self._check_anti_compression(candidate, existing)
            if not ok:
                base.action = ReconciliationAction.NOOP
                base.compression_blocked = True
                base.notes = "supersede blocked by anti-compression safeguard"
                return base

            # Mark existing as superseded and set its valid_to timestamp
            await self.storage.update_claim(
                existing.id,
                status="superseded",
                valid_to=_now(),
                updated_at=_now(),
            )

            # Changelog: mark old claim as superseded (temporal update)
            try:
                history = await self.storage.get_changelog(existing.id)
                next_ver = len(history) + 1
                await self.storage.log_changelog(
                    claim_id=existing.id,
                    version=next_ver,
                    content_snapshot=existing.object_value,
                    action="superseded",
                    reason="temporal update — replaced by newer value",
                    changed_by="reconciliation",
                    diff_summary=f"'{existing.object_value}' → '{candidate.object_value}'",
                )
            except Exception as exc:  # noqa: BLE001
                logger.warning("Failed to log changelog for temporal supersede %s: %s", existing.id, exc)

            # Enrich metadata with the temporal transition
            temporal_meta = dict(relationship_metadata or {})
            if "previous_value" not in temporal_meta:
                temporal_meta["previous_value"] = existing.object_value
            if "new_value" not in temporal_meta:
                temporal_meta["new_value"] = candidate.object_value

            new_claim = await self._add_claim(
                candidate=candidate,
                session_id=session_id,
                owner_id=owner_id,
                subject_entity_id=subject_entity_id,
                supersedes_id=existing.id,
                relationship_metadata=temporal_meta,
            )

            # #514: Set superseded_by FK on the old claim now that we have the
            # new claim's ID (bidirectional supersession chain).
            try:
                await self.storage.update_claim(
                    existing.id,
                    superseded_by=new_claim.id,
                )
            except Exception as exc:  # noqa: BLE001
                logger.warning(
                    "Failed to set superseded_by on claim %s → %s: %s",
                    existing.id, new_claim.id, exc,
                )

            prev = temporal_meta.get("previous_value", existing.object_value)
            base.action = ReconciliationAction.SUPERSEDE
            base.resulting_claim_id = new_claim.id
            base.relationship_metadata = temporal_meta
            base.notes = f"temporal update: '{prev}' → '{candidate.object_value}'; superseded claim {existing.id}"
            # W4: record contradiction for temporal supersede — in-memory + DB persistence
            _temporal_reason = f"temporal update: '{prev}' → '{candidate.object_value}'"
            self.contradiction_tracker.record(
                claim_id=new_claim.id,
                contradicts_id=existing.id,
                resolution="superseded",
                reason=_temporal_reason,
            )
            try:
                await _persist_contradiction(
                    self.storage,
                    claim_a_id=new_claim.id,
                    claim_b_id=existing.id,
                    owner_id=owner_id,
                    reason=_temporal_reason,
                    resolution="kept_a",
                )
            except Exception as _exc:  # noqa: BLE001
                logger.warning("Failed to persist contradiction to DB (temporal): %s", _exc)
            return base

        # ------------------------------------------------------------------
        # CONDITIONAL → ADD (separate claim; condition preserved in metadata)
        # The existing claim is NOT superseded — both can be true.
        # ------------------------------------------------------------------
        if relationship == RelationshipKind.CONDITIONAL:
            condition_meta = dict(relationship_metadata or {})
            condition_str = condition_meta.get("condition", "")

            # Encode condition inline in object_value for retrieval clarity
            conditional_value = candidate.object_value
            if condition_str and condition_str.lower() not in conditional_value.lower():
                conditional_value = f"{candidate.object_value} [when: {condition_str}]"

            # Build a modified candidate-like object with the enriched value
            # without mutating the original (use a simple wrapper approach)
            class _CondCandidateProxy:  # lightweight proxy — not stored
                subject = candidate.subject
                predicate = candidate.predicate
                object_value = conditional_value
                confidence = candidate.confidence
                sensitivity = getattr(candidate, "sensitivity", "normal")
                temporal_marker = getattr(candidate, "temporal_marker", None)
                source_span = getattr(candidate, "source_span", None)

            new_claim = await self._add_claim(
                candidate=_CondCandidateProxy(),  # type: ignore[arg-type]
                session_id=session_id,
                owner_id=owner_id,
                subject_entity_id=subject_entity_id,
                relationship_metadata=condition_meta,
            )
            base.action = ReconciliationAction.ADD
            base.resulting_claim_id = new_claim.id
            base.candidate_value = conditional_value  # reflect the encoded value
            if condition_str:
                base.notes = f"conditional claim stored separately [when: {condition_str}]"
            else:
                base.notes = "conditional claim stored separately"
            return base

        # ------------------------------------------------------------------
        # NEW or unrecognised → ADD separately
        # ------------------------------------------------------------------
        new_claim = await self._add_claim(
            candidate=candidate,
            session_id=session_id,
            owner_id=owner_id,
            subject_entity_id=subject_entity_id,
        )
        base.action = ReconciliationAction.ADD
        base.resulting_claim_id = new_claim.id
        base.notes = f"new claim added (relationship: {relationship.value.lower()})"
        return base

    # ------------------------------------------------------------------
    # Storage helpers
    # ------------------------------------------------------------------

    async def _add_claim(
        self,
        candidate: "CandidateClaim",
        session_id: str,
        owner_id: str,
        subject_entity_id: Optional[str] = None,
        supersedes_id: Optional[str] = None,
        relationship_metadata: Optional[dict] = None,
    ) -> "SemanticClaim":
        """Create a new SemanticClaim for this candidate and record provenance.

        Args:
            candidate: The claim to persist.
            session_id: Source session FK.
            owner_id: Multi-tenancy scope key.
            subject_entity_id: Resolved entity FK (if available).
            supersedes_id: ID of the claim this new claim replaces (if any).
            relationship_metadata: Structured relationship metadata from M4
                classification (e.g. condition, previous_value). Encoded as
                JSON in the `notes` kwarg for adapters that support it.
        """
        # Build structured notes string: supersedes ref + relationship metadata
        notes_parts: list[str] = []
        if supersedes_id:
            notes_parts.append(f"supersedes:{supersedes_id}")
        if relationship_metadata:
            notes_parts.append(f"relationship_metadata:{json.dumps(relationship_metadata, separators=(',', ':'))}")

        # Use session_confidence_default from config when candidate has no
        # explicit confidence (fallback 0.0 means LLM didn't assign one).
        effective_confidence = candidate.confidence
        if effective_confidence <= 0.0:
            effective_confidence = getattr(
                self.config, "session_confidence_default", 0.5
            )

        kwargs: dict = {
            "owner_id": owner_id,
            "memory_type": "session",
            "confidence": effective_confidence,
            "sensitivity": getattr(candidate, "sensitivity", "normal"),
            "category": getattr(candidate, "category", "misc"),
            "source_session": session_id,
            "created_at": _now(),
            "updated_at": _now(),
        }
        # Persist v8 envelope fields when available (#355/#357).
        _intrinsic = getattr(candidate, "intrinsic_type", None)
        if _intrinsic:
            kwargs["intrinsic_type"] = _intrinsic
        _human_family = getattr(candidate, "human_family", None)
        if _human_family:
            kwargs["human_family"] = _human_family
        # LoCoMo fields (v10, #516) — forward from CandidateClaim
        _memory_class = getattr(candidate, "memory_class", None)
        if _memory_class:
            kwargs["memory_class"] = _memory_class
        _event_date = getattr(candidate, "event_date", None)
        if _event_date:
            kwargs["event_date"] = _event_date
        _sequence_order = getattr(candidate, "sequence_order", None)
        if _sequence_order is not None:  # 0 is valid
            kwargs["sequence_order"] = _sequence_order
        _salience = getattr(candidate, "salience", None)
        if _salience:
            kwargs["salience"] = _salience
        # Status: CandidateClaim may have "completed" or "blocked" from extraction
        _status = getattr(candidate, "status", None)
        if _status and _status != "active":  # Only override default if non-default
            kwargs["status"] = _status
        # Causal linking (#518)
        _cause = getattr(candidate, "cause", None)
        if _cause:
            kwargs["cause"] = _cause
        _effect = getattr(candidate, "effect", None)
        if _effect:
            kwargs["effect"] = _effect
        # State transitions (#519)
        _state_before = getattr(candidate, "state_before", None)
        if _state_before:
            kwargs["state_before"] = _state_before
        _state_after = getattr(candidate, "state_after", None)
        if _state_after:
            kwargs["state_after"] = _state_after
        # Temporal fields (#573, v12)
        _temporal = getattr(candidate, "temporal", None)
        if _temporal and _temporal != "current":  # Only override default if non-default
            kwargs["temporal"] = _temporal
        _temporal_marker = getattr(candidate, "temporal_marker", None)
        if _temporal_marker:
            kwargs["temporal_marker"] = _temporal_marker
        if subject_entity_id:
            kwargs["subject_entity_id"] = subject_entity_id
        if notes_parts:
            kwargs["notes"] = "; ".join(notes_parts)

        claim = await self.storage.create_claim(
            subject=candidate.subject,
            predicate=candidate.predicate,
            object_value=candidate.object_value,
            **kwargs,
        )
        await self._record_provenance(claim.id, session_id, candidate)

        # Changelog: every new claim starts at version 1
        try:
            await self.storage.log_changelog(
                claim_id=claim.id,
                version=1,
                content_snapshot=candidate.object_value,
                action="created",
                reason=f"{candidate.subject} {candidate.predicate}",
                changed_by="extraction",
                diff_summary=None,
            )
        except Exception as exc:  # noqa: BLE001
            logger.warning("Failed to log changelog for new claim %s: %s", claim.id, exc)

        return claim

    async def _record_provenance(
        self,
        claim_id: str,
        session_id: str,
        candidate: "CandidateClaim",
    ) -> None:
        """Write a claim_provenance row linking this claim to its source episode."""
        source_span = getattr(candidate, "source_span", None)
        span_start = source_span[0] if source_span else None
        span_end = source_span[1] if source_span else None

        # Only record provenance if candidate has a real episode_event_id.
        # In CLI / direct-API flows there are no episode_event records, so
        # using session_id would violate the FK constraint.
        episode_id = getattr(candidate, "episode_event_id", None)
        if not episode_id:
            logger.debug(
                "Skipping provenance for claim %s — no episode_event_id", claim_id[:8]
            )
            return

        try:
            await self.storage.add_provenance(
                claim_id=claim_id,
                episode_event_id=episode_id,
                span_start=span_start,
                span_end=span_end,
                extraction_method="llm_extract",
            )
        except Exception as exc:  # noqa: BLE001
            logger.warning("Failed to record provenance for claim %s: %s", claim_id, exc)


# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------

def _now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
