"""
Tests for W4 Real-Time Consolidation (Issue #359)

Covers:
    1. ContradictionTracker: record, list_pending, resolve
    2. check_near_duplicate: various thresholds, edge cases
    3. Recency precedence: is_user_explicit wins over older inference
    4. SUPERSEDE creates ContradictionRecord in ReconciliationEngine
    5. Near-duplicate NOOP at reconciliation time

Run: pytest tests/test_w4_consolidation.py -v
"""

from __future__ import annotations

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

import pytest

from cortex.core.contradictions import ContradictionRecord, ContradictionTracker
from cortex.extraction.dedup import check_near_duplicate
from cortex.core.reconciliation import (
    ReconciliationAction,
    ReconciliationEngine,
    RelationshipKind,
)


# ---------------------------------------------------------------------------
# Minimal stub types (mirrors test_reconciliation.py pattern)
# ---------------------------------------------------------------------------

@dataclass
class CandidateClaim:
    subject: str
    predicate: str
    object_value: str
    confidence: float = 0.8
    sensitivity: str = "normal"
    temporal_marker: Optional[str] = None
    source_span: Optional[tuple] = None
    episode_event_id: Optional[str] = None
    is_user_explicit: bool = False  # W4


@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


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _make_claim(
    claim_id: str,
    subject: str,
    predicate: str,
    value: str,
    confidence: float = 0.7,
    status: str = "active",
) -> SemanticClaim:
    return SemanticClaim(
        id=claim_id,
        subject=subject,
        predicate=predicate,
        object_value=value,
        confidence=confidence,
        status=status,
    )


def _make_storage(
    existing_by_entity: List[SemanticClaim] = None,
    existing_by_subject: List[SemanticClaim] = None,
) -> MagicMock:
    storage = MagicMock()
    storage.get_claims_by_entity = AsyncMock(return_value=existing_by_entity or [])
    storage.get_claims_by_subject = AsyncMock(return_value=existing_by_subject or [])

    async def _create_claim(subject, predicate, object_value, **kwargs):
        return SemanticClaim(
            id="new-claim-id",
            subject=subject,
            predicate=predicate,
            object_value=object_value,
            confidence=kwargs.get("confidence", 0.8),
        )

    storage.create_claim = AsyncMock(side_effect=_create_claim)

    async def _update_claim(claim_id, **kwargs):
        return 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"),
        )

    storage.update_claim = AsyncMock(side_effect=_update_claim)
    storage.add_provenance = AsyncMock(return_value=None)
    storage.log_changelog = AsyncMock(return_value=None)
    storage.get_changelog = AsyncMock(return_value=[])
    return storage


def _make_llm(classification: str = "NEW", confidence: float = 0.9) -> MagicMock:
    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)
    return llm


def _make_resolver(entity_id: str = "entity-001", is_new: bool = False) -> MagicMock:
    resolver = MagicMock()

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

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


def _make_engine(
    existing_by_entity=None,
    existing_by_subject=None,
    llm_classification="NEW",
    contradiction_tracker=None,
) -> ReconciliationEngine:
    storage = _make_storage(
        existing_by_entity=existing_by_entity or [],
        existing_by_subject=existing_by_subject or [],
    )
    engine = ReconciliationEngine(
        storage=storage,
        config=CortexConfig(),
        llm=_make_llm(llm_classification),
        entity_resolver=_make_resolver(),
        contradiction_tracker=contradiction_tracker,
    )
    return engine


# ===========================================================================
# 1. ContradictionTracker tests
# ===========================================================================

class TestContradictionTracker:

    def test_record_creates_entry(self):
        tracker = ContradictionTracker()
        rec = tracker.record("claim-a", "claim-b", reason="conflict detected")
        assert rec.claim_id == "claim-a"
        assert rec.contradicts_id == "claim-b"
        assert rec.resolution == "superseded"
        assert rec.reason == "conflict detected"
        assert rec.detected_at
        assert rec.resolved_at is None
        assert len(tracker) == 1

    def test_record_default_resolution_is_superseded(self):
        tracker = ContradictionTracker()
        rec = tracker.record("c1", "c2")
        assert rec.resolution == "superseded"

    def test_record_pending_resolution(self):
        tracker = ContradictionTracker()
        rec = tracker.record("c1", "c2", resolution="pending")
        assert rec.resolution == "pending"

    def test_record_idempotent(self):
        """Recording the same pair twice returns the same record, no duplicate."""
        tracker = ContradictionTracker()
        r1 = tracker.record("c1", "c2", reason="first")
        r2 = tracker.record("c1", "c2", reason="second")  # should be idempotent
        assert r1 is r2  # same object returned
        assert len(tracker) == 1
        assert r1.reason == "first"  # unchanged

    def test_list_pending_filters_correctly(self):
        tracker = ContradictionTracker()
        tracker.record("c1", "c0", resolution="pending")
        tracker.record("c2", "c0", resolution="superseded")
        tracker.record("c3", "c0", resolution="pending")

        pending = tracker.list_pending()
        assert len(pending) == 2
        claim_ids = {r.claim_id for r in pending}
        assert claim_ids == {"c1", "c3"}

    def test_list_pending_empty_when_none(self):
        tracker = ContradictionTracker()
        tracker.record("c1", "c2", resolution="superseded")
        assert tracker.list_pending() == []

    def test_resolve_updates_record(self):
        tracker = ContradictionTracker()
        tracker.record("c1", "c2", resolution="pending")
        updated = tracker.resolve("c1", "user_overridden")
        assert updated is not None
        assert updated.resolution == "user_overridden"
        assert updated.resolved_at is not None

    def test_resolve_returns_none_for_missing(self):
        tracker = ContradictionTracker()
        result = tracker.resolve("nonexistent", "superseded")
        assert result is None

    def test_resolve_invalid_resolution_raises(self):
        tracker = ContradictionTracker()
        with pytest.raises(ValueError, match="Invalid resolution"):
            tracker.resolve("c1", "bad_value")

    def test_list_all_returns_all_records(self):
        tracker = ContradictionTracker()
        tracker.record("c1", "c0", resolution="pending")
        tracker.record("c2", "c0", resolution="superseded")
        all_recs = tracker.list_all()
        assert len(all_recs) == 2

    def test_clear_empties_tracker(self):
        tracker = ContradictionTracker()
        tracker.record("c1", "c2")
        tracker.clear()
        assert len(tracker) == 0
        assert tracker.list_pending() == []

    def test_record_invalid_resolution_raises(self):
        with pytest.raises(ValueError, match="Invalid resolution"):
            ContradictionRecord(
                claim_id="c1",
                contradicts_id="c2",
                resolution="invalid_state",
            )


# ===========================================================================
# 2. check_near_duplicate tests
# ===========================================================================

class TestCheckNearDuplicate:

    def _make_semantic_claim(self, id_, subject, predicate, value, status="active"):
        return SemanticClaim(
            id=id_,
            subject=subject,
            predicate=predicate,
            object_value=value,
            status=status,
        )

    def _make_candidate(self, subject, predicate, value):
        return CandidateClaim(subject=subject, predicate=predicate, object_value=value)

    def test_exact_match_is_duplicate(self):
        existing = [self._make_semantic_claim("e1", "Eva", "likes", "coffee")]
        candidate = self._make_candidate("Eva", "likes", "coffee")
        is_dup, matched_id, score = check_near_duplicate(candidate, existing, threshold=0.92)
        assert is_dup is True
        assert matched_id == "e1"
        assert score == pytest.approx(1.0)

    def test_near_duplicate_above_threshold(self):
        existing = [
            self._make_semantic_claim("e1", "Eva", "lives in", "Bangkok, Thailand")
        ]
        candidate = self._make_candidate("Eva", "lives in", "Bangkok Thailand")
        is_dup, matched_id, score = check_near_duplicate(candidate, existing, threshold=0.85)
        assert is_dup is True
        assert matched_id == "e1"
        assert score >= 0.85

    def test_different_value_below_threshold(self):
        existing = [self._make_semantic_claim("e1", "Eva", "lives in", "Tokyo")]
        candidate = self._make_candidate("Eva", "lives in", "Bangkok")
        is_dup, matched_id, score = check_near_duplicate(candidate, existing, threshold=0.92)
        assert is_dup is False
        assert matched_id is None

    def test_different_predicate_not_duplicate(self):
        existing = [self._make_semantic_claim("e1", "Eva", "works at", "ElectricSheep")]
        candidate = self._make_candidate("Eva", "lives in", "ElectricSheep")
        is_dup, _, score = check_near_duplicate(candidate, existing, threshold=0.50)
        # Different predicate → never a dup even with permissive threshold
        assert is_dup is False

    def test_different_subject_not_duplicate(self):
        existing = [self._make_semantic_claim("e1", "Andrew", "likes", "coffee")]
        candidate = self._make_candidate("Eva", "likes", "coffee")
        is_dup, _, _ = check_near_duplicate(candidate, existing, threshold=0.50)
        assert is_dup is False

    def test_empty_existing_returns_no_dup(self):
        candidate = self._make_candidate("Eva", "likes", "coffee")
        is_dup, matched_id, score = check_near_duplicate(candidate, [], threshold=0.92)
        assert is_dup is False
        assert matched_id is None
        assert score == 0.0

    def test_returns_best_match_id(self):
        """When multiple near-duplicates exist, return the best one."""
        existing = [
            self._make_semantic_claim("e1", "Eva", "likes", "strong black coffee"),
            self._make_semantic_claim("e2", "Eva", "likes", "totally unrelated thing"),
        ]
        candidate = self._make_candidate("Eva", "likes", "strong black coffee")
        is_dup, matched_id, score = check_near_duplicate(candidate, existing, threshold=0.90)
        assert is_dup is True
        # e1 is the better match (exact match)
        assert matched_id == "e1"

    def test_strict_threshold_filters_weak_match(self):
        existing = [self._make_semantic_claim("e1", "Eva", "likes", "coffee sometimes")]
        candidate = self._make_candidate("Eva", "likes", "loves coffee every morning")
        is_dup_strict, _, _ = check_near_duplicate(candidate, existing, threshold=0.98)
        is_dup_loose, _, _ = check_near_duplicate(candidate, existing, threshold=0.30)
        assert is_dup_strict is False
        assert is_dup_loose is True

    def test_case_insensitive_normalization(self):
        """Normalization should make 'The Coffee' == 'the coffee'."""
        existing = [self._make_semantic_claim("e1", "Eva", "drinks", "The Coffee")]
        candidate = self._make_candidate("Eva", "drinks", "the coffee")
        is_dup, matched_id, score = check_near_duplicate(candidate, existing, threshold=0.92)
        assert is_dup is True
        assert score == pytest.approx(1.0)


# ===========================================================================
# 3. Recency precedence (is_user_explicit) tests
# ===========================================================================

class TestRecencyPrecedence:

    def test_heuristic_classify_explicit_user_supersedes(self):
        """is_user_explicit=True + different value → CONTRADICTION heuristic."""
        engine = _make_engine()
        candidate = CandidateClaim(
            subject="Eva",
            predicate="lives in",
            object_value="Bangkok",
            confidence=0.7,
            is_user_explicit=True,
        )
        existing = _make_claim("old-1", "Eva", "lives in", "Tokyo", confidence=0.7)
        result = engine._heuristic_classify(candidate, existing)
        assert result == RelationshipKind.CONTRADICTION

    def test_heuristic_classify_non_explicit_does_not_auto_supersede(self):
        """Without is_user_explicit, same confidence + no temporal marker → None (LLM needed)."""
        engine = _make_engine()
        candidate = CandidateClaim(
            subject="Eva",
            predicate="lives in",
            object_value="Bangkok",
            confidence=0.7,
            is_user_explicit=False,
        )
        existing = _make_claim("old-1", "Eva", "lives in", "Tokyo", confidence=0.7)
        result = engine._heuristic_classify(candidate, existing)
        # No temporal marker, no confidence gap → ambiguous → None
        assert result is None

    def test_heuristic_classify_explicit_user_identical_value_noop(self):
        """is_user_explicit=True but same value → EQUIVALENT (no change needed)."""
        engine = _make_engine()
        candidate = CandidateClaim(
            subject="Eva",
            predicate="lives in",
            object_value="Bangkok",
            confidence=0.7,
            is_user_explicit=True,
        )
        existing = _make_claim("old-1", "Eva", "lives in", "Bangkok", confidence=0.5)
        result = engine._heuristic_classify(candidate, existing)
        assert result == RelationshipKind.EQUIVALENT

    @pytest.mark.asyncio
    async def test_reconcile_explicit_user_produces_supersede(self):
        """Full reconcile() with is_user_explicit=True → SUPERSEDE action."""
        existing = _make_claim("old-1", "Eva", "lives in", "Tokyo", confidence=0.7)
        storage = _make_storage(
            existing_by_entity=[existing],
            existing_by_subject=[existing],
        )
        tracker = ContradictionTracker()
        engine = ReconciliationEngine(
            storage=storage,
            config=CortexConfig(),
            llm=_make_llm("CONTRADICTS"),
            entity_resolver=_make_resolver(),
            contradiction_tracker=tracker,
        )

        candidate = CandidateClaim(
            subject="Eva",
            predicate="lives in",
            object_value="Bangkok",
            confidence=0.7,
            is_user_explicit=True,
        )
        report = await engine.reconcile([candidate], session_id="sess-1")
        assert report.superseded == 1
        assert report.results[0].action == ReconciliationAction.SUPERSEDE


# ===========================================================================
# 4. SUPERSEDE creates ContradictionRecord
# ===========================================================================

class TestSupersedecreatesContradiction:

    @pytest.mark.asyncio
    async def test_contradiction_heuristic_records_contradiction(self):
        """CONTRADICTION (heuristic) → SUPERSEDE → ContradictionRecord created."""
        existing = _make_claim("old-1", "Eva", "lives in", "Tokyo", confidence=0.7)
        storage = _make_storage(
            existing_by_entity=[existing],
            existing_by_subject=[existing],
        )
        tracker = ContradictionTracker()
        engine = ReconciliationEngine(
            storage=storage,
            config=CortexConfig(),
            llm=_make_llm("NEW"),
            entity_resolver=_make_resolver(),
            contradiction_tracker=tracker,
        )

        # Trigger via confidence gap heuristic (gap ≥ 0.2 → CONTRADICTION)
        candidate = CandidateClaim(
            subject="Eva",
            predicate="lives in",
            object_value="Bangkok",
            confidence=0.95,  # gap = 0.95 - 0.7 = 0.25 → CONTRADICTION
        )
        report = await engine.reconcile([candidate], session_id="sess-1")
        assert report.superseded == 1

        all_recs = tracker.list_all()
        assert len(all_recs) == 1
        rec = all_recs[0]
        assert rec.contradicts_id == "old-1"
        assert rec.resolution == "superseded"

    @pytest.mark.asyncio
    async def test_temporal_update_records_contradiction(self):
        """TEMPORAL_UPDATE → SUPERSEDE → ContradictionRecord with temporal reason."""
        existing = _make_claim("old-2", "Eva", "lives in", "Tokyo", confidence=0.7)
        existing.valid_to = None

        storage = _make_storage(
            existing_by_entity=[existing],
            existing_by_subject=[existing],
        )
        tracker = ContradictionTracker()
        engine = ReconciliationEngine(
            storage=storage,
            config=CortexConfig(),
            llm=_make_llm("TEMPORAL_UPDATE"),
            entity_resolver=_make_resolver(),
            contradiction_tracker=tracker,
        )

        candidate = CandidateClaim(
            subject="Eva",
            predicate="lives in",
            object_value="Bangkok",
            confidence=0.8,
        )
        report = await engine.reconcile([candidate], session_id="sess-1")
        assert report.superseded == 1

        recs = tracker.list_all()
        assert len(recs) == 1
        assert "temporal" in recs[0].reason.lower()

    @pytest.mark.asyncio
    async def test_noop_does_not_create_contradiction_record(self):
        """NOOP (equivalent) → no ContradictionRecord."""
        existing = _make_claim("old-3", "Eva", "lives in", "Bangkok", confidence=0.7)
        storage = _make_storage(
            existing_by_entity=[existing],
            existing_by_subject=[existing],
        )
        tracker = ContradictionTracker()
        engine = ReconciliationEngine(
            storage=storage,
            config=CortexConfig(),
            llm=_make_llm("EQUIVALENT"),
            entity_resolver=_make_resolver(),
            contradiction_tracker=tracker,
        )

        candidate = CandidateClaim(
            subject="Eva",
            predicate="lives in",
            object_value="Bangkok",
            confidence=0.7,
        )
        report = await engine.reconcile([candidate], session_id="sess-1")
        assert report.nooped == 1
        assert len(tracker) == 0

    @pytest.mark.asyncio
    async def test_supersede_reason_includes_explicit_user(self):
        """When is_user_explicit=True, contradiction reason mentions recency."""
        existing = _make_claim("old-4", "Eva", "works at", "Google", confidence=0.7)
        storage = _make_storage(
            existing_by_entity=[existing],
            existing_by_subject=[existing],
        )
        tracker = ContradictionTracker()
        engine = ReconciliationEngine(
            storage=storage,
            config=CortexConfig(),
            llm=_make_llm("NEW"),
            entity_resolver=_make_resolver(),
            contradiction_tracker=tracker,
        )

        candidate = CandidateClaim(
            subject="Eva",
            predicate="works at",
            object_value="ElectricSheep",
            confidence=0.7,
            is_user_explicit=True,
        )
        await engine.reconcile([candidate], session_id="sess-1")
        recs = tracker.list_all()
        assert len(recs) == 1
        assert "recency" in recs[0].reason.lower() or "explicit" in recs[0].reason.lower()


# ===========================================================================
# 5. Near-duplicate NOOP at reconciliation time
# ===========================================================================

class TestNearDuplicateNOOPAtReconcile:

    @pytest.mark.asyncio
    async def test_near_duplicate_candidate_produces_noop(self):
        """Candidate nearly identical to existing → NOOP via either dup path.

        When entity-level lookup returns empty but subject-text lookup finds a
        matching claim, the near-dup check fires. Here we use two separate call
        sequences so _find_conflicts returns [] (first call) while the near-dup
        check's own get_claims_by_subject call (second call) returns the match.
        """
        existing = _make_claim(
            "ex-1", "Eva", "email", "eva+agent@100yen.org", confidence=0.9
        )

        # First call to get_claims_by_subject (inside _find_conflicts) returns []
        # Second call (inside near-dup check) returns the existing claim
        storage = MagicMock()
        storage.get_claims_by_entity = AsyncMock(return_value=[])
        storage.get_claims_by_subject = AsyncMock(side_effect=[
            [],       # _find_conflicts fallback → no match
            [existing],  # near-dup check lookup → existing found
        ])

        async def _create_claim(subject, predicate, object_value, **kwargs):
            return SemanticClaim(
                id="new-claim-id",
                subject=subject,
                predicate=predicate,
                object_value=object_value,
                confidence=kwargs.get("confidence", 0.8),
            )

        storage.create_claim = AsyncMock(side_effect=_create_claim)
        storage.add_provenance = AsyncMock(return_value=None)
        storage.log_changelog = AsyncMock(return_value=None)
        storage.get_changelog = AsyncMock(return_value=[])

        engine = ReconciliationEngine(
            storage=storage,
            config=CortexConfig(),
            llm=_make_llm("NEW"),
            entity_resolver=_make_resolver(),
        )

        candidate = CandidateClaim(
            subject="Eva",
            predicate="email",
            object_value="eva+agent@100yen.org",  # exact match → score=1.0
            confidence=0.8,
        )
        report = await engine.reconcile([candidate], session_id="sess-1")
        # Near-dup check fires because _find_conflicts returned []
        assert report.results[0].action == ReconciliationAction.NOOP
        assert "near-duplicate" in report.results[0].notes
        assert "ex-1" in report.results[0].notes

    @pytest.mark.asyncio
    async def test_genuinely_new_claim_produces_add(self):
        """Genuinely new claim (no conflict, no near-dup) → ADD."""
        storage = _make_storage(
            existing_by_entity=[],
            existing_by_subject=[],  # empty → no near-dups
        )
        engine = ReconciliationEngine(
            storage=storage,
            config=CortexConfig(),
            llm=_make_llm("NEW"),
            entity_resolver=_make_resolver(),
        )

        candidate = CandidateClaim(
            subject="Eva",
            predicate="hobby",
            object_value="building AI systems",
            confidence=0.8,
        )
        report = await engine.reconcile([candidate], session_id="sess-1")
        assert report.added == 1
        assert report.results[0].action == ReconciliationAction.ADD


# ===========================================================================
# 6. ContradictionRecord dataclass validation
# ===========================================================================

class TestContradictionRecord:

    def test_default_fields(self):
        rec = ContradictionRecord(claim_id="c1", contradicts_id="c2")
        assert rec.resolution == "pending"
        assert rec.detected_at
        assert rec.resolved_at is None
        assert rec.reason == ""

    def test_all_valid_resolutions(self):
        for res in ("pending", "superseded", "user_overridden"):
            rec = ContradictionRecord(
                claim_id="c1", contradicts_id="c2", resolution=res
            )
            assert rec.resolution == res

    def test_invalid_resolution_raises(self):
        with pytest.raises(ValueError):
            ContradictionRecord(
                claim_id="c1", contradicts_id="c2", resolution="rejected"
            )
