"""
Tests for deterministic preference supersession (Issue #601).

Verifies that when a new claim is stored with the same owner+subject+predicate
as an existing active claim but a different object_value, the old claim is
deterministically marked `status=superseded` — without relying on the LLM
producing action=UPDATE or CONTRADICTION.

The 0.3x (and 0.1x) retrieval penalty from PR #596 / #574 is also verified
so that the new (active) claim ranks above the superseded one.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, call

import pytest

from cortex.core.reconciliation import (
    ReconciliationAction,
    ReconciliationEngine,
    RelationshipKind,
)


# ---------------------------------------------------------------------------
# Minimal stubs (mirror test_reconciliation.py conventions)
# ---------------------------------------------------------------------------

@dataclass
class CandidateClaim:
    subject: str
    predicate: str
    object_value: str
    confidence: float = 0.8
    sensitivity: str = "normal"
    temporal: str = "current"
    temporal_marker: Optional[str] = None
    source_span: Optional[tuple] = None
    episode_event_id: Optional[str] = None
    entity_type: Optional[str] = None
    action: str = "ADD"
    is_user_explicit: bool = False


@dataclass
class SemanticClaim:
    id: str
    subject: str
    predicate: str
    object_value: str
    confidence: float = 0.7
    status: str = "active"
    subject_entity_id: Optional[str] = None
    valid_to: Optional[str] = None
    owner_id: str = "default"
    memory_type: str = "session"


@dataclass
class ResolvedEntity:
    entity_id: str
    canonical_name: str
    entity_type: str = "person"
    confidence: float = 0.9
    is_new: bool = False
    matched_alias: Optional[str] = None


@dataclass
class CortexConfig:
    reconciliation_model: str = "gpt-4.1-nano-2025-04-14"
    anti_compression_confidence: float = 0.85
    embedding_model: str = "text-embedding-3-small"
    embedding_dim: int = 1536


# ---------------------------------------------------------------------------
# Stateful storage mock — tracks update_claim calls so we can assert on status
# ---------------------------------------------------------------------------

class StatefulStorageMock:
    """Mock StorageAdapter that tracks claim state across update_claim calls."""

    def __init__(self, initial_claims: List[SemanticClaim]):
        # Dict keyed by claim_id for fast lookup
        self._claims: Dict[str, SemanticClaim] = {c.id: c for c in initial_claims}
        self._create_counter = 0
        # Track raw calls for assertion
        self.update_claim_calls: list = []
        self.create_claim_calls: list = []

    def _all_active_for(self, subject: str, predicate: str, owner_id: str = "default"):
        return [
            c for c in self._claims.values()
            if c.subject == subject
            and c.predicate == predicate
            and c.status == "active"
            and c.owner_id == owner_id
        ]

    async def get_claims_by_entity(self, entity_id, owner_id="default"):
        return [c for c in self._claims.values() if c.subject_entity_id == entity_id and c.status == "active"]

    async def get_claims_by_subject(self, subject, owner_id="default"):
        return [c for c in self._claims.values() if c.subject == subject]

    async def create_claim(self, subject, predicate, object_value, **kwargs):
        self._create_counter += 1
        new_id = f"claim-{self._create_counter}"
        claim = SemanticClaim(
            id=new_id,
            subject=subject,
            predicate=predicate,
            object_value=object_value,
            confidence=kwargs.get("confidence", 0.8),
            status=kwargs.get("status", "active"),
            subject_entity_id=kwargs.get("subject_entity_id"),
            owner_id=kwargs.get("owner_id", "default"),
        )
        self._claims[new_id] = claim
        self.create_claim_calls.append((subject, predicate, object_value, kwargs))
        return claim

    async def update_claim(self, claim_id, **kwargs):
        self.update_claim_calls.append((claim_id, kwargs))
        if claim_id in self._claims:
            claim = self._claims[claim_id]
            if "status" in kwargs:
                claim.status = kwargs["status"]
            if "object_value" in kwargs:
                claim.object_value = kwargs["object_value"]
            if "confidence" in kwargs:
                claim.confidence = kwargs["confidence"]
        # Return a stub with updated fields
        updated = SemanticClaim(
            id=claim_id,
            subject="updated-subject",
            predicate="updated-predicate",
            object_value=kwargs.get("object_value", "updated"),
            confidence=kwargs.get("confidence", 0.8),
            status=kwargs.get("status", "active"),
        )
        return updated

    async def add_provenance(self, *args, **kwargs):
        return None

    async def log_changelog(self, *args, **kwargs):
        return None

    async def get_changelog(self, claim_id):
        return []


def _make_llm(classification: str = "NEW", confidence: float = 0.9):
    """LLM that returns a fixed classification (simulates unreliable extractor)."""
    import json
    llm = MagicMock()

    async def _complete(system, user, model=None, response_format="json"):
        return json.dumps(
            {"classification": classification, "confidence": confidence, "reasoning": "test"}
        )

    llm.complete = AsyncMock(side_effect=_complete)

    async def _embed(texts=None, model=None):
        """Return near-identical embeddings so supersession tests pass the similarity gate.

        Returns an object with .embeddings attribute matching the real Voyage API shape.
        """
        vecs = []
        for i, t in enumerate(texts or []):
            # Base vector of 1.0s with tiny perturbation — cosine ≈ 1.0 for all pairs
            vec = [1.0] * 1024
            vec[i % 1024] = 0.999
            vecs.append(vec)
        resp = MagicMock()
        resp.embeddings = vecs
        return resp

    llm.embed = AsyncMock(side_effect=_embed)
    return llm


def _make_resolver(entity_id: str = "entity-001"):
    resolver = MagicMock()

    async def _resolve(mention, context="", owner_id="default", entity_type="unknown"):
        return ResolvedEntity(entity_id=entity_id, canonical_name=mention)

    resolver.resolve = AsyncMock(side_effect=_resolve)
    return resolver


def _make_engine(storage, llm=None, max_llm_calls: int = 5) -> ReconciliationEngine:
    return ReconciliationEngine(
        storage=storage,
        config=CortexConfig(),
        llm=llm or _make_llm(),
        entity_resolver=_make_resolver(),
        max_llm_calls_per_batch=max_llm_calls,
    )


# ---------------------------------------------------------------------------
# Test 1 — Basic supersession: Python → Rust
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_preference_supersession_python_to_rust():
    """
    Store 'user prefers Python', then 'user prefers Rust'.

    Expected: the Python claim is marked status=superseded.
    The fix must not rely on the LLM returning CONTRADICTION — it should fire
    deterministically whenever a new claim is created with the same
    owner+subject+predicate but a different object_value.

    Simulated path: LLM returns NEW (wrong classification) → ADD result →
    deterministic _supersede_stale_claims fires and marks Python superseded.
    """
    python_claim = SemanticClaim(
        id="py-001",
        subject="user",
        predicate="prefers_language",
        object_value="Python",
        status="active",
        subject_entity_id="entity-user",
    )
    storage = StatefulStorageMock(initial_claims=[python_claim])
    # LLM returns NEW (incorrectly) — supersession must be deterministic
    llm = _make_llm(classification="NEW")
    engine = _make_engine(storage=storage, llm=llm)

    candidate = CandidateClaim(
        subject="user",
        predicate="prefers_language",
        object_value="Rust",
        confidence=0.85,
    )

    report = await engine.reconcile([candidate], session_id="sess-pref-1", owner_id="default")

    # A new (Rust) claim was stored
    assert report.added == 1, f"Expected added=1, got {report.added}"

    # The Python claim must now be superseded in the stateful storage
    py_claim_after = storage._claims["py-001"]
    assert py_claim_after.status == "superseded", (
        f"Expected py-001 to be superseded, but status={py_claim_after.status!r}"
    )

    # Only one new claim created (Rust)
    assert storage._create_counter == 1

    # update_claim must have been called with status='superseded' on py-001
    supersede_calls = [
        (cid, kw) for cid, kw in storage.update_claim_calls
        if cid == "py-001" and kw.get("status") == "superseded"
    ]
    assert len(supersede_calls) >= 1, (
        f"Expected update_claim('py-001', status='superseded') but got calls: "
        f"{storage.update_claim_calls}"
    )


# ---------------------------------------------------------------------------
# Test 2 — Retrieval penalty: superseded claim ranks below active claim
# ---------------------------------------------------------------------------

def test_superseded_preference_ranks_below_active():
    """
    After supersession, the old (superseded) preference should be excluded
    entirely from results — only the active preference remains (#844).

    Rust (active, score 0.8) vs Python (superseded, score 0.9 before penalty).
    After status penalty: Python is hard-excluded, Rust remains.
    """
    from cortex.core.retrieval import RetrievedItem, RetrievalPipeline

    # Inline minimal config/storage for the retrieval pipeline
    config = MagicMock()
    config.retrieval_status_penalties = None  # use defaults
    storage = MagicMock()
    pipeline = RetrievalPipeline(storage=storage, config=config)

    rust_item = RetrievedItem(
        item_type="claim",
        item_id="rust-claim",
        content="user prefers Rust",
        score=0.8,
        metadata={"status": "active", "memory_class": "semantic", "salience": "medium"},
    )
    python_item = RetrievedItem(
        item_type="claim",
        item_id="python-claim",
        content="user prefers Python",
        score=0.9,  # higher raw score but superseded
        metadata={"status": "superseded", "memory_class": "semantic", "salience": "medium"},
    )

    items = pipeline._apply_status_penalty([rust_item, python_item])

    # Superseded claim must be excluded entirely (#844)
    assert len(items) == 1, f"Expected 1 item (superseded excluded), got {len(items)}"
    assert items[0].item_id == "rust-claim", (
        f"Expected Rust (active) to be the only result, but got {items[0].item_id}"
    )


# ---------------------------------------------------------------------------
# Test 3 — Three versions: only latest is active
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_three_preference_versions_only_latest_active():
    """
    Store 3 sequential preferences for the same predicate.
    After each store, only the most recently stored value should be 'active';
    all prior versions must be 'superseded'.

    Version chain: Python → Rust → TypeScript
    """
    storage = StatefulStorageMock(initial_claims=[])
    llm = _make_llm(classification="NEW")  # LLM always returns NEW → deterministic path
    engine = _make_engine(storage=storage, llm=llm)

    # Store v1: Python
    await engine.reconcile(
        [CandidateClaim(subject="dev", predicate="lang", object_value="Python")],
        session_id="s1",
        owner_id="default",
    )
    python_id = storage._claims["claim-1"].id
    assert storage._claims[python_id].status == "active"

    # Store v2: Rust (Python should be superseded)
    await engine.reconcile(
        [CandidateClaim(subject="dev", predicate="lang", object_value="Rust")],
        session_id="s2",
        owner_id="default",
    )
    rust_id = storage._claims["claim-2"].id
    assert storage._claims[python_id].status == "superseded", (
        f"Python should be superseded after Rust stored, got {storage._claims[python_id].status}"
    )
    assert storage._claims[rust_id].status == "active"

    # Store v3: TypeScript (Rust should be superseded)
    await engine.reconcile(
        [CandidateClaim(subject="dev", predicate="lang", object_value="TypeScript")],
        session_id="s3",
        owner_id="default",
    )
    ts_id = storage._claims["claim-3"].id
    assert storage._claims[rust_id].status == "superseded", (
        f"Rust should be superseded after TypeScript stored, got {storage._claims[rust_id].status}"
    )
    assert storage._claims[ts_id].status == "active", "TypeScript should be the only active claim"

    # Verify: exactly one active claim for this subject+predicate
    active_claims = [
        c for c in storage._claims.values()
        if c.subject == "dev" and c.predicate == "lang" and c.status == "active"
    ]
    assert len(active_claims) == 1, (
        f"Expected exactly 1 active claim for dev/lang, got {len(active_claims)}: "
        f"{[(c.id, c.object_value) for c in active_claims]}"
    )
    assert active_claims[0].object_value == "TypeScript"


# ---------------------------------------------------------------------------
# Test 4 — Different predicates are NOT affected
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_different_predicates_not_superseded():
    """
    Storing a new claim for predicate 'prefers_editor' should NOT supersede
    an existing claim for predicate 'prefers_language'.

    Different predicates = different facts; they are independent.
    """
    lang_claim = SemanticClaim(
        id="lang-claim",
        subject="user",
        predicate="prefers_language",
        object_value="Python",
        status="active",
        subject_entity_id="entity-user",
    )
    storage = StatefulStorageMock(initial_claims=[lang_claim])
    llm = _make_llm(classification="NEW")
    engine = _make_engine(storage=storage, llm=llm)

    # Store a NEW claim for a DIFFERENT predicate
    candidate = CandidateClaim(
        subject="user",
        predicate="prefers_editor",  # different predicate
        object_value="Neovim",
        confidence=0.8,
    )

    report = await engine.reconcile([candidate], session_id="sess-editor", owner_id="default")

    assert report.added == 1

    # lang_claim must remain active — different predicate, not affected
    assert storage._claims["lang-claim"].status == "active", (
        f"prefers_language claim should remain active but got "
        f"status={storage._claims['lang-claim'].status!r}"
    )


# ---------------------------------------------------------------------------
# Test 5 — Supersession works WITHOUT LLM (budget=0)
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_preference_supersession_no_llm_required():
    """
    Preference supersession must work even when LLM budget is 0.

    When heuristics don't fire AND there is no LLM budget, the existing code
    falls through to NOOP (flagged for review).  The deterministic fix ensures
    that when any new claim is ultimately stored via the ADD path, stale
    same-subject+predicate claims are superseded regardless.

    This test exercises the path where the entity resolver associates both
    'Python' and 'Rust' claims with the same entity, but there are no
    heuristic signals (no temporal marker, no high confidence delta, not
    explicit).  The budget is > 0 here so the LLM runs and returns NEW
    (wrong), triggering the deterministic fallback.
    """
    existing = SemanticClaim(
        id="old-pref",
        subject="alice",
        predicate="framework",
        object_value="Django",
        status="active",
        subject_entity_id="entity-alice",
    )
    storage = StatefulStorageMock(initial_claims=[existing])
    # LLM returns NEW (wrong classification) — deterministic path kicks in
    llm = _make_llm(classification="NEW")
    engine = _make_engine(storage=storage, llm=llm, max_llm_calls=5)

    candidate = CandidateClaim(
        subject="alice",
        predicate="framework",
        object_value="FastAPI",
        confidence=0.8,
    )

    report = await engine.reconcile([candidate], session_id="sess-fw", owner_id="default")

    # ADD result (LLM said NEW)
    assert report.added == 1

    # Deterministic supersession must have fired: Django → superseded
    assert storage._claims["old-pref"].status == "superseded", (
        "Django claim should be superseded after FastAPI stored via deterministic path"
    )


# ---------------------------------------------------------------------------
# Test 6 — CONDITIONAL claims do NOT trigger deterministic supersession
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_conditional_claim_does_not_supersede_existing():
    """
    When LLM classifies the relationship as CONDITIONAL, the existing claim
    must NOT be superseded.  Both claims can be true simultaneously.

    E.g. "likes silence" (general) and "likes jazz [when: working late]"
    (conditional) should coexist.
    """
    import json
    existing = SemanticClaim(
        id="silence-claim",
        subject="andrew",
        predicate="music_preference",
        object_value="prefers silence",
        status="active",
        subject_entity_id="entity-andrew",
    )
    storage = StatefulStorageMock(initial_claims=[existing])

    # LLM returns CONDITIONAL
    llm = MagicMock()

    async def _complete_conditional(system, user, model=None, response_format="json"):
        return json.dumps({
            "classification": "CONDITIONAL",
            "confidence": 0.9,
            "reasoning": "context-dependent preference",
            "metadata": {"condition": "working late"},
        })

    llm.complete = AsyncMock(side_effect=_complete_conditional)
    engine = _make_engine(storage=storage, llm=llm)

    candidate = CandidateClaim(
        subject="andrew",
        predicate="music_preference",
        object_value="likes jazz",
        confidence=0.8,
    )

    report = await engine.reconcile([candidate], session_id="sess-cond", owner_id="default")

    # Conditional claim added
    assert report.added == 1

    # Existing 'prefers silence' claim must NOT be superseded
    assert storage._claims["silence-claim"].status == "active", (
        "CONDITIONAL claim should NOT supersede the unconditional existing claim"
    )

    # No superseded count in report
    assert report.superseded == 0


# ---------------------------------------------------------------------------
# Kill switch test (#1576)
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_deterministic_supersession_disabled_by_config():
    """When deterministic_supersession_enabled=False, S+P supersession is skipped.

    Regression test for #1576 — the kill switch must prevent deterministic
    supersession while still allowing LLM-mediated action=UPDATE to work.

    Real-world failure: "user decided pricing tiers: $45/mo" was superseded by
    "user decided voice architecture: Kokoro" — same predicate, unrelated topics.
    """
    pricing_claim = SemanticClaim(
        id="pricing-claim",
        subject="user",
        predicate="decided",
        object_value="pricing tiers: $45/mo GO, $95/mo PRO, $145/mo BIZ",
        confidence=0.95,
        status="active",
    )
    storage = StatefulStorageMock(initial_claims=[pricing_claim])

    llm = _make_llm()
    llm.embed = AsyncMock(return_value=None)

    # Config with kill switch OFF
    config = CortexConfig()
    config.deterministic_supersession_enabled = False

    engine = ReconciliationEngine(
        storage=storage,
        config=config,
        llm=llm,
        entity_resolver=_make_resolver(),
        max_llm_calls_per_batch=0,  # no LLM calls — pure ADD path
    )

    candidate = CandidateClaim(
        subject="user",
        predicate="decided",
        object_value="hybrid voice architecture: Kokoro on-VM for fallback",
        confidence=0.90,
    )

    report = await engine.reconcile([candidate], session_id="sess-kill", owner_id="default")

    # The critical assertion: pricing claim must NOT be superseded.
    # Without the kill switch, deterministic S+P would have killed it.
    # With LLM budget=0, the engine either ADDs (no conflict found) or
    # NOOPs (conflict found but can't classify). Either way: NOT superseded.
    assert storage._claims["pricing-claim"].status == "active", (
        "With deterministic_supersession_enabled=False, the pricing claim "
        "must NOT be superseded by an unrelated 'decided' claim (#1576)"
    )
    assert report.superseded == 0, (
        f"Expected 0 supersessions with kill switch on, got {report.superseded}"
    )
