"""
cortex/core/reconciliation/engine.py — ReconciliationEngine (core orchestration).

The main engine class that orchestrates claim reconciliation. Method clusters
for conflict detection and action application live in separate mixin files.

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

from __future__ import annotations

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

from cortex.core.contradictions import ContradictionTracker
from cortex.extraction.dedup import check_near_duplicate

from cortex.core.reconciliation.models import (
    ReconciliationAction,
    ReconciliationReport,
    ReconciliationResult,
    RelationshipKind,
    _now,
)
from cortex.core.reconciliation.conflict_detection import ConflictDetectionMixin
from cortex.core.reconciliation.actions import ActionsMixin

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

logger = logging.getLogger(__name__)


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

class ReconciliationEngine(ConflictDetectionMixin, ActionsMixin):
    """
    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.DELETE:
                report.deleted += 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 deleted=%d nooped=%d "
            "llm_calls=%d/%d duration_ms=%d",
            report.added, report.updated, report.superseded, report.deleted, 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
        # If upstream (wake_pipeline step 2) already resolved the subject
        # entity via scope_entity_id, reuse it to avoid a redundant call.
        subject_entity_id: Optional[str] = getattr(candidate, "scope_entity_id", None)
        if subject_entity_id is None and self.entity_resolver is not 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)
        elif subject_entity_id is None:
            logger.warning(
                "EntityResolver not available — skipping entity resolution for %r",
                candidate.subject,
            )

        # 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 action=DELETE but no matching claim exists, NOOP (#583).
        if not existing_matches and getattr(candidate, "action", "ADD") == "DELETE":
            return ReconciliationResult(
                candidate_subject=candidate.subject,
                candidate_predicate=candidate.predicate,
                candidate_value=candidate.object_value,
                action=ReconciliationAction.NOOP,
                confidence=candidate.confidence,
                notes="action=DELETE but no matching claim found — nothing to delete",
            )

        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:
                # Bump mentions counter atomically — same claim re-extracted (#924)
                try:
                    if hasattr(self.storage, "increment_claim_mentions"):
                        await self.storage.increment_claim_mentions(dup_id, owner_id=owner_id)
                    else:
                        dup_claim = next((c for c in active_claims if c.id == dup_id), None)
                        new_mentions = getattr(dup_claim, "mentions", 1) + 1 if dup_claim else 2
                        await self.storage.update_claim(
                            dup_id, owner_id=owner_id,
                            mentions=new_mentions,
                            last_mentioned_at=_now(),
                        )
                except Exception as exc:  # noqa: BLE001
                    logger.warning("Failed to bump mentions for near-dup claim %s: %s", dup_id, exc)
                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 4c: Real-time deletion (#583)
        # When the extraction pipeline signals action=DELETE, deactivate the
        # matching existing claim immediately without LLM classification.
        if candidate_action == "DELETE":
            return await self._delete_claim(
                candidate=candidate,
                existing=existing,
                reason="action=DELETE: LLM-signalled deletion 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,
        )

    # _supersede_claim and _delete_claim live in ActionsMixin (actions.py)
