"""
Tests for MODULE 4: ReconciliationEngine
cortex/core/reconciliation.py

All storage and LLM dependencies are mocked.
Run: pytest tests/test_reconciliation.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.reconciliation import (
    ReconciliationAction,
    ReconciliationEngine,
    ReconciliationReport,
    RelationshipKind,
)


# ---------------------------------------------------------------------------
# Minimal stub types (avoid importing unimplemented modules)
# ---------------------------------------------------------------------------

@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"  # #514: ADD|UPDATE|DELETE


@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


# ---------------------------------------------------------------------------
# Mock helpers
# ---------------------------------------------------------------------------

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


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 [])

    # create_claim returns a SemanticClaim stub
    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):
        claim = 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 claim

    storage.update_claim = AsyncMock(side_effect=_update_claim)
    storage.add_provenance = AsyncMock(return_value=None)
    storage.log_changelog = AsyncMock(return_value=None)
    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", entity_type="unknown"):
        return ResolvedEntity(
            entity_id=entity_id,
            canonical_name=mention,
            is_new=is_new,
        )

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


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


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_add_new_claim_no_existing():
    """Candidate with no matching existing claim → ADD."""
    engine = _make_engine()
    candidate = CandidateClaim(subject="Andrew", predicate="lives_in", object_value="Bangkok")

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

    assert report.added == 1
    assert report.nooped == 0
    assert len(report.results) == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.ADD
    assert result.resulting_claim_id == "new-claim-id"


@pytest.mark.asyncio
async def test_entity_type_propagated_to_resolver():
    """Entity type from candidate should be passed to entity_resolver.resolve()."""
    resolver = _make_resolver()
    engine = _make_engine(resolver=resolver)
    candidate = CandidateClaim(
        subject="Andrew", predicate="lives_in", object_value="Bangkok",
        entity_type="person",
    )

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

    # Verify resolve() was called with entity_type="person"
    resolver.resolve.assert_called_once()
    call_kwargs = resolver.resolve.call_args
    assert call_kwargs.kwargs.get("entity_type") == "person" or \
        (len(call_kwargs.args) > 3 and call_kwargs.args[3] == "person")


@pytest.mark.asyncio
async def test_entity_type_defaults_to_unknown_when_none():
    """When candidate has no entity_type, resolver gets 'unknown'."""
    resolver = _make_resolver()
    engine = _make_engine(resolver=resolver)
    candidate = CandidateClaim(
        subject="Andrew", predicate="lives_in", object_value="Bangkok",
    )

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

    resolver.resolve.assert_called_once()
    call_kwargs = resolver.resolve.call_args
    assert call_kwargs.kwargs.get("entity_type") == "unknown"


@pytest.mark.asyncio
async def test_noop_on_equivalent_value():
    """Candidate with identical value to existing → NOOP."""
    existing = _make_claim("ex-1", "Andrew", "lives_in", "Bangkok", entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    engine = _make_engine(storage=storage)
    candidate = CandidateClaim(subject="Andrew", predicate="lives_in", object_value="Bangkok")

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

    assert report.nooped == 1
    assert report.added == 0
    result = report.results[0]
    assert result.action == ReconciliationAction.NOOP
    assert result.relationship == RelationshipKind.EQUIVALENT


@pytest.mark.asyncio
async def test_supersede_on_temporal_marker_current():
    """Candidate with temporal_marker='current' supersedes existing active claim."""
    existing = _make_claim("ex-1", "Andrew", "employer", "Old Corp", entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    engine = _make_engine(storage=storage)
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="employer",
        object_value="New Corp",
        temporal="current",
        temporal_marker="now",
        confidence=0.8,
    )

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

    assert report.superseded == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.SUPERSEDE
    assert result.existing_claim_id == "ex-1"
    # Existing should have been marked superseded (may include extra kwargs like updated_at)
    supersede_calls = [
        c for c in storage.update_claim.await_args_list
        if c.args == ("ex-1",) and c.kwargs.get("status") == "superseded"
    ]
    assert len(supersede_calls) >= 1, "update_claim('ex-1', status='superseded') not called"


@pytest.mark.asyncio
async def test_supersede_on_high_confidence_delta():
    """Candidate with much higher confidence → SUPERSEDE without LLM."""
    existing = _make_claim("ex-1", "Andrew", "age", "29", confidence=0.4, entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    engine = _make_engine(storage=storage)
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="age",
        object_value="30",
        confidence=0.95,  # delta = 0.55, well above threshold (0.2)
    )

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

    assert report.superseded == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.SUPERSEDE
    # No LLM needed for this clear case
    assert result.used_llm is False


@pytest.mark.asyncio
async def test_llm_adjudicated_contradiction():
    """Ambiguous contradiction — LLM returns CONTRADICTION → SUPERSEDE."""
    existing = _make_claim("ex-1", "Andrew", "hobby", "hiking", confidence=0.7, entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm(classification="CONTRADICTION")
    engine = _make_engine(storage=storage, llm=llm)
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="hobby",
        object_value="swimming",
        confidence=0.75,
    )

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

    assert report.superseded == 1
    assert report.llm_calls_used == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.SUPERSEDE
    assert result.used_llm is True


@pytest.mark.asyncio
async def test_llm_adjudicated_complementary():
    """LLM returns COMPLEMENTARY → UPDATE (merge values)."""
    existing = _make_claim("ex-1", "Andrew", "skills", "Python", confidence=0.7, entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm(classification="COMPLEMENTARY")
    engine = _make_engine(storage=storage, llm=llm)
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="skills",
        object_value="TypeScript",
        confidence=0.8,
    )

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

    assert report.updated == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.UPDATE
    assert result.used_llm is True


@pytest.mark.asyncio
async def test_anti_compression_blocks_update():
    """UPDATE is blocked when new value would compress existing by >20%."""
    long_existing_value = "Andrew is an experienced senior software engineer " \
                          "with deep expertise in distributed systems, Python, " \
                          "and has been working remotely since 2020."
    existing = _make_claim("ex-1", "Andrew", "bio", long_existing_value,
                            confidence=0.9, entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm(classification="COMPLEMENTARY")
    config = CortexConfig(anti_compression_confidence=0.85)
    engine = _make_engine(storage=storage, llm=llm, config=config)

    # Short replacement (<<80% of original) + low confidence
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="bio",
        object_value="software engineer",
        confidence=0.55,  # below anti_compression_confidence
    )

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

    assert report.compression_blocked == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.NOOP
    assert result.compression_blocked is True


@pytest.mark.asyncio
async def test_provenance_recorded_on_add():
    """Provenance row created after ADD."""
    storage = _make_storage()
    engine = _make_engine(storage=storage)
    candidate = CandidateClaim(
        subject="Eva",
        predicate="persona",
        object_value="helpful assistant",
        source_span=(10, 50),
        episode_event_id="evt-prov-001",
    )

    await engine.reconcile([candidate], session_id="sess-prov")

    storage.add_provenance.assert_awaited_once()
    call_kwargs = storage.add_provenance.call_args
    assert call_kwargs.kwargs.get("claim_id") == "new-claim-id" or \
           call_kwargs.args[0] == "new-claim-id"


@pytest.mark.asyncio
async def test_llm_budget_exceeded_flags_for_review():
    """When LLM calls are exhausted, ambiguous claims flagged for review (NOOP)."""
    existing = _make_claim("ex-1", "Andrew", "location", "Tokyo", confidence=0.6, entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm(classification="CONTRADICTION")
    # 0 LLM calls allowed
    engine = _make_engine(storage=storage, llm=llm, max_llm_calls=0)

    candidate = CandidateClaim(
        subject="Andrew",
        predicate="location",
        object_value="Osaka",
        confidence=0.65,  # not a big enough delta for heuristic
    )

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

    assert report.nooped == 1
    assert report.flagged_for_review == 1
    assert report.llm_calls_used == 0
    result = report.results[0]
    assert result.action == ReconciliationAction.NOOP
    assert "human review" in result.notes.lower()
    # LLM should NOT have been called
    llm.complete.assert_not_awaited()


@pytest.mark.asyncio
async def test_batch_mixed_actions():
    """Multiple candidates in one batch: ADD + NOOP + SUPERSEDE."""
    existing_employer = _make_claim("ex-emp", "Andrew", "employer", "OldCo",
                                     confidence=0.5, entity_id="entity-001")
    existing_city = _make_claim("ex-city", "Andrew", "city", "Tokyo",
                                 confidence=0.7, entity_id="entity-001")

    async def _get_by_entity(entity_id):
        if entity_id == "entity-001":
            return [existing_employer, existing_city]
        return []

    storage = _make_storage()
    storage.get_claims_by_entity = AsyncMock(side_effect=_get_by_entity)
    storage.get_claims_by_subject = AsyncMock(return_value=[])

    engine = _make_engine(storage=storage)
    candidates = [
        # Brand new predicate → ADD
        CandidateClaim(subject="Andrew", predicate="github", object_value="100yenadmin"),
        # Exact match → NOOP
        CandidateClaim(subject="Andrew", predicate="city", object_value="Tokyo"),
        # Temporal supersede
        CandidateClaim(subject="Andrew", predicate="employer", object_value="NewCo",
                       temporal_marker="current", confidence=0.9),
    ]

    report = await engine.reconcile(candidates, session_id="sess-batch")

    assert report.added == 1
    assert report.nooped == 1
    assert report.superseded == 1
    assert len(report.results) == 3


@pytest.mark.asyncio
async def test_report_aggregates_llm_call_count():
    """Report accurately counts LLM calls used."""
    existing = _make_claim("ex-1", "A", "p", "v1", confidence=0.6, entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm(classification="CONTRADICTION")
    engine = _make_engine(storage=storage, llm=llm, max_llm_calls=5)

    # 3 ambiguous candidates (not heuristically resolvable)
    candidates = [
        CandidateClaim(subject="A", predicate="p", object_value=f"v{i}", confidence=0.65)
        for i in range(2, 5)
    ]

    report = await engine.reconcile(candidates, session_id="sess-1")

    # Each ambiguous claim should have consumed exactly one LLM call
    assert report.llm_calls_used == len(candidates)
    assert llm.complete.await_count == len(candidates)


@pytest.mark.asyncio
async def test_entity_resolution_fallback_to_subject():
    """When entity resolver raises, engine falls back to subject-text lookup."""

    async def _failing_resolve(mention, context="", owner_id="default"):
        raise RuntimeError("resolution service unavailable")

    resolver = MagicMock()
    resolver.resolve = AsyncMock(side_effect=_failing_resolve)

    fallback_claim = _make_claim("fb-1", "Eva", "role", "assistant")
    storage = _make_storage(existing_by_entity=[], existing_by_subject=[fallback_claim])
    engine = _make_engine(storage=storage, resolver=resolver)

    candidate = CandidateClaim(subject="Eva", predicate="role", object_value="assistant")
    report = await engine.reconcile([candidate], session_id="sess-1")

    # Should still work (NOOP on identical match) despite resolver failure
    assert report.nooped == 1


# ===========================================================================
# M4 Enriched Relationship Kind Tests (Issue #82)
# ===========================================================================

def _make_llm_with_metadata(
    classification: str,
    confidence: float = 0.9,
    metadata: Optional[dict] = None,
) -> MagicMock:
    """LLM mock that returns classification + structured metadata."""
    llm = MagicMock()

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

    llm.complete = AsyncMock(side_effect=_complete)
    return llm


# ---------------------------------------------------------------------------
# ELABORATES
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_elaborates_merges_detail_into_existing():
    """LLM returns ELABORATES → UPDATE (merge new detail into existing value)."""
    existing = _make_claim(
        "ex-1", "Andrew", "coffee_preference", "likes coffee",
        confidence=0.7, entity_id="entity-001",
    )
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm_with_metadata(
        classification="ELABORATES",
        metadata={"detail_added": "strong black coffee in the morning"},
    )
    engine = _make_engine(storage=storage, llm=llm)
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="coffee_preference",
        object_value="strong black coffee in the morning",
        confidence=0.8,
    )

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

    assert report.updated == 1
    assert report.added == 0
    result = report.results[0]
    assert result.action == ReconciliationAction.UPDATE
    assert result.relationship == RelationshipKind.ELABORATES
    assert result.used_llm is True
    # metadata should carry the detail
    assert result.relationship_metadata is not None
    assert "detail_added" in result.relationship_metadata
    # merged value should contain both original and new detail
    update_call = storage.update_claim.call_args
    merged = update_call.kwargs.get("object_value", "")
    assert "likes coffee" in merged
    assert "strong black coffee in the morning" in merged


@pytest.mark.asyncio
async def test_elaborates_blocked_by_anti_compression():
    """ELABORATES is blocked when short new value would compress existing rich detail."""
    long_value = (
        "Andrew loves strong, dark-roasted coffee brewed with a moka pot, "
        "always black, usually two cups before 9am to start the day."
    )
    existing = _make_claim("ex-1", "Andrew", "coffee_preference", long_value,
                            confidence=0.9, entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm_with_metadata(classification="ELABORATES")
    config = CortexConfig(anti_compression_confidence=0.85)
    engine = _make_engine(storage=storage, llm=llm, config=config)
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="coffee_preference",
        object_value="likes coffee",
        confidence=0.4,  # low confidence, short replacement → blocked
    )

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

    assert report.compression_blocked == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.NOOP
    assert result.compression_blocked is True


# ---------------------------------------------------------------------------
# CONTRADICTS
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_contradicts_supersedes_existing():
    """LLM returns CONTRADICTS → SUPERSEDE with conflict metadata."""
    existing = _make_claim("ex-1", "Andrew", "diet", "vegan", confidence=0.75, entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm_with_metadata(
        classification="CONTRADICTS",
        metadata={"conflict_reason": "new claim explicitly states eats meat"},
    )
    engine = _make_engine(storage=storage, llm=llm)
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="diet",
        object_value="eats meat",
        confidence=0.8,
    )

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

    assert report.superseded == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.SUPERSEDE
    assert result.relationship == RelationshipKind.CONTRADICTS
    assert result.used_llm is True
    assert result.relationship_metadata is not None
    assert "conflict_reason" in result.relationship_metadata
    # Existing should have been marked superseded
    supersede_calls = [
        c for c in storage.update_claim.await_args_list
        if c.args == ("ex-1",) and c.kwargs.get("status") == "superseded"
    ]
    assert len(supersede_calls) >= 1
    # Notes should mention the conflict
    assert "conflict" in result.notes or "superseded" in result.notes


@pytest.mark.asyncio
async def test_contradicts_blocked_by_anti_compression():
    """CONTRADICTS is blocked when new value would compress existing rich detail."""
    long_value = (
        "Andrew follows a strict whole-food plant-based diet, avoids oils, "
        "eats primarily legumes, whole grains, and seasonal vegetables."
    )
    existing = _make_claim("ex-1", "Andrew", "diet", long_value,
                            confidence=0.9, entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm_with_metadata(classification="CONTRADICTS")
    config = CortexConfig(anti_compression_confidence=0.85)
    engine = _make_engine(storage=storage, llm=llm, config=config)
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="diet",
        object_value="omnivore",
        confidence=0.4,
    )

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

    assert report.compression_blocked == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.NOOP
    assert result.compression_blocked is True


# ---------------------------------------------------------------------------
# TEMPORAL_UPDATE
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_temporal_update_supersedes_and_records_previous_value():
    """LLM returns TEMPORAL_UPDATE → SUPERSEDE; metadata records previous_value."""
    existing = _make_claim(
        "ex-1", "Andrew", "city", "Tokyo", confidence=0.8, entity_id="entity-001"
    )
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm_with_metadata(
        classification="TEMPORAL_UPDATE",
        metadata={"previous_value": "Tokyo", "new_value": "Bangkok"},
    )
    engine = _make_engine(storage=storage, llm=llm)
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="city",
        object_value="Bangkok",
        confidence=0.85,
    )

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

    assert report.superseded == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.SUPERSEDE
    assert result.relationship == RelationshipKind.TEMPORAL_UPDATE
    assert result.relationship_metadata is not None
    assert result.relationship_metadata.get("previous_value") == "Tokyo"
    assert result.relationship_metadata.get("new_value") == "Bangkok"
    # Notes should describe the transition
    assert "Tokyo" in result.notes or "temporal" in result.notes.lower()
    assert "Bangkok" in result.notes
    # Existing claim should have been marked superseded with valid_to set
    supersede_calls = [
        c for c in storage.update_claim.await_args_list
        if c.args == ("ex-1",) and c.kwargs.get("status") == "superseded"
    ]
    assert len(supersede_calls) >= 1
    # valid_to should have been set on the superseded claim
    valid_to_set = any(
        c.args == ("ex-1",) and c.kwargs.get("valid_to") is not None
        for c in storage.update_claim.await_args_list
    )
    assert valid_to_set, "valid_to not set on superseded claim for TEMPORAL_UPDATE"


@pytest.mark.asyncio
async def test_temporal_update_auto_fills_previous_value_from_existing():
    """If LLM omits previous_value in metadata, engine fills it from existing claim."""
    existing = _make_claim(
        "ex-1", "Andrew", "employer", "OldCorp", confidence=0.7, entity_id="entity-001"
    )
    storage = _make_storage(existing_by_entity=[existing])
    # LLM returns TEMPORAL_UPDATE without previous_value in metadata
    llm = _make_llm_with_metadata(classification="TEMPORAL_UPDATE", metadata={})
    engine = _make_engine(storage=storage, llm=llm)
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="employer",
        object_value="NewCorp",
        confidence=0.85,
    )

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

    assert report.superseded == 1
    result = report.results[0]
    assert result.relationship_metadata is not None
    # Engine should have auto-populated previous_value from existing
    assert result.relationship_metadata.get("previous_value") == "OldCorp"
    assert result.relationship_metadata.get("new_value") == "NewCorp"


# ---------------------------------------------------------------------------
# CONDITIONAL
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_conditional_adds_separate_claim_without_superseding():
    """LLM returns CONDITIONAL → ADD new claim; existing unchanged."""
    existing = _make_claim(
        "ex-1", "Andrew", "music_preference", "prefers silence",
        confidence=0.7, entity_id="entity-001",
    )
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm_with_metadata(
        classification="CONDITIONAL",
        metadata={"condition": "working late"},
    )
    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-1")

    # Should ADD the conditional claim; existing should NOT be superseded
    assert report.added == 1
    assert report.superseded == 0
    result = report.results[0]
    assert result.action == ReconciliationAction.ADD
    assert result.relationship == RelationshipKind.CONDITIONAL
    assert result.used_llm is True
    # Notes should describe the condition
    assert "when" in result.notes.lower() or "condition" in result.notes.lower()
    # The stored value should encode the condition for retrieval clarity
    create_call = storage.create_claim.call_args
    stored_value = create_call.args[2] if create_call.args else create_call.kwargs.get("object_value", "")
    assert "jazz" in stored_value
    assert "working late" in stored_value or "when:" in stored_value
    # Existing was NOT superseded
    storage.update_claim.assert_not_awaited()


@pytest.mark.asyncio
async def test_conditional_stores_condition_inline_when_not_present():
    """CONDITIONAL encodes condition into object_value when not already present."""
    existing = _make_claim("ex-1", "Andrew", "food", "eats lightly", entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm_with_metadata(
        classification="CONDITIONAL",
        metadata={"condition": "stressed"},
    )
    engine = _make_engine(storage=storage, llm=llm)
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="food",
        object_value="craves junk food",
        confidence=0.75,
    )

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

    assert report.added == 1
    create_call = storage.create_claim.call_args
    stored_value = create_call.args[2] if create_call.args else create_call.kwargs.get("object_value", "")
    assert "junk food" in stored_value
    assert "stressed" in stored_value


@pytest.mark.asyncio
async def test_conditional_does_not_double_encode_condition():
    """CONDITIONAL doesn't duplicate condition if already present in value."""
    existing = _make_claim("ex-1", "Andrew", "sleep", "sleeps 8h", entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm_with_metadata(
        classification="CONDITIONAL",
        metadata={"condition": "traveling"},
    )
    engine = _make_engine(storage=storage, llm=llm)
    # Value already contains the condition
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="sleep",
        object_value="sleeps 6h when traveling",
        confidence=0.75,
    )

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

    assert report.added == 1
    create_call = storage.create_claim.call_args
    stored_value = create_call.args[2] if create_call.args else create_call.kwargs.get("object_value", "")
    # "traveling" should appear only once (not appended again)
    assert stored_value.lower().count("traveling") == 1


# ---------------------------------------------------------------------------
# relationship_metadata propagation in ReconciliationResult
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_relationship_metadata_in_result_for_elaborates():
    """relationship_metadata field is populated on ELABORATES result."""
    existing = _make_claim("ex-1", "Andrew", "sport", "runs", entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm_with_metadata(
        classification="ELABORATES",
        metadata={"detail_added": "runs 5km every morning"},
    )
    engine = _make_engine(storage=storage, llm=llm)
    candidate = CandidateClaim(
        subject="Andrew", predicate="sport", object_value="runs 5km every morning",
        confidence=0.8,
    )

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

    result = report.results[0]
    assert result.relationship_metadata is not None
    assert result.relationship_metadata.get("detail_added") == "runs 5km every morning"


@pytest.mark.asyncio
async def test_heuristic_path_has_no_relationship_metadata():
    """Heuristic-classified results (no LLM) have relationship_metadata=None."""
    existing = _make_claim("ex-1", "Andrew", "city", "Bangkok",
                            confidence=0.5, entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    engine = _make_engine(storage=storage)
    # High confidence delta → heuristic CONTRADICTION, no LLM
    candidate = CandidateClaim(
        subject="Andrew", predicate="city", object_value="Chiang Mai",
        confidence=0.95,
    )

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

    result = report.results[0]
    assert result.used_llm is False
    assert result.relationship_metadata is None


# ---------------------------------------------------------------------------
# New RelationshipKind values present in enum
# ---------------------------------------------------------------------------

def test_new_relationship_kinds_in_enum():
    """All four new RelationshipKind values from Issue #82 are present."""
    assert RelationshipKind.ELABORATES == "ELABORATES"
    assert RelationshipKind.CONTRADICTS == "CONTRADICTS"
    assert RelationshipKind.TEMPORAL_UPDATE == "TEMPORAL_UPDATE"
    assert RelationshipKind.CONDITIONAL == "CONDITIONAL"


# ---------------------------------------------------------------------------
# Issue #514: Real-time supersession during ingestion
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_action_update_triggers_immediate_supersession():
    """Candidate with action=UPDATE supersedes existing claim without LLM (#514)."""
    existing = _make_claim("ex-1", "Andrew", "lives_in", "Bangkok", entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm()  # should NOT be called
    engine = _make_engine(storage=storage, llm=llm)

    candidate = CandidateClaim(
        subject="Andrew",
        predicate="lives_in",
        object_value="Chiang Mai",
        confidence=0.85,
        action="UPDATE",
    )

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

    assert report.superseded == 1
    assert report.added == 0
    assert report.nooped == 0
    result = report.results[0]
    assert result.action == ReconciliationAction.SUPERSEDE
    assert result.existing_claim_id == "ex-1"
    assert result.resulting_claim_id == "new-claim-id"
    assert result.used_llm is False  # no LLM call for action=UPDATE

    # Old claim must have been marked superseded
    supersede_status_calls = [
        c for c in storage.update_claim.await_args_list
        if c.args == ("ex-1",) and c.kwargs.get("status") == "superseded"
    ]
    assert len(supersede_status_calls) >= 1, "update_claim(ex-1, status='superseded') not called"

    # superseded_by FK must have been set on old claim
    superseded_by_calls = [
        c for c in storage.update_claim.await_args_list
        if c.args == ("ex-1",) and c.kwargs.get("superseded_by") == "new-claim-id"
    ]
    assert len(superseded_by_calls) >= 1, "update_claim(ex-1, superseded_by='new-claim-id') not called"


@pytest.mark.asyncio
async def test_action_update_no_existing_falls_through_to_add():
    """Candidate with action=UPDATE but no existing match → ADD as normal (#514)."""
    storage = _make_storage(existing_by_entity=[], existing_by_subject=[])
    engine = _make_engine(storage=storage)

    candidate = CandidateClaim(
        subject="Andrew",
        predicate="hobby",
        object_value="photography",
        confidence=0.8,
        action="UPDATE",
    )

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

    assert report.added == 1
    assert report.superseded == 0
    result = report.results[0]
    assert result.action == ReconciliationAction.ADD


@pytest.mark.asyncio
async def test_superseded_by_set_on_contradiction():
    """CONTRADICTION (heuristic) path sets superseded_by FK on old claim (#514)."""
    existing = _make_claim("old-claim", "Andrew", "city", "Bangkok",
                            confidence=0.5, entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    engine = _make_engine(storage=storage)

    # High confidence delta → heuristic CONTRADICTION without LLM
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="city",
        object_value="Phuket",
        confidence=0.95,
    )

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

    assert report.superseded == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.SUPERSEDE

    # superseded_by FK must have been set
    superseded_by_calls = [
        c for c in storage.update_claim.await_args_list
        if c.args == ("old-claim",) and c.kwargs.get("superseded_by") is not None
    ]
    assert len(superseded_by_calls) >= 1, (
        "update_claim(old-claim, superseded_by=<new_id>) not called for CONTRADICTION"
    )
    # The value set should be the new claim's ID
    assert superseded_by_calls[0].kwargs["superseded_by"] == "new-claim-id"


@pytest.mark.asyncio
async def test_superseded_by_set_on_temporal_update():
    """TEMPORAL_UPDATE path sets superseded_by FK on old claim (#514)."""
    existing = _make_claim("old-claim", "Andrew", "role", "engineer", entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm_with_metadata(
        classification="TEMPORAL_UPDATE",
        metadata={"previous_value": "engineer", "new_value": "CTO"},
    )
    engine = _make_engine(storage=storage, llm=llm)

    candidate = CandidateClaim(
        subject="Andrew",
        predicate="role",
        object_value="CTO",
        confidence=0.9,
    )

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

    assert report.superseded == 1

    # superseded_by FK must have been set on old claim
    superseded_by_calls = [
        c for c in storage.update_claim.await_args_list
        if c.args == ("old-claim",) and c.kwargs.get("superseded_by") == "new-claim-id"
    ]
    assert len(superseded_by_calls) >= 1, (
        "update_claim(old-claim, superseded_by='new-claim-id') not called for TEMPORAL_UPDATE"
    )


@pytest.mark.asyncio
async def test_superseded_by_set_on_contradicts():
    """CONTRADICTS (LLM) path sets superseded_by FK on old claim (#514)."""
    existing = _make_claim("old-claim", "Andrew", "diet", "vegan", entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm_with_metadata(
        classification="CONTRADICTS",
        metadata={"conflict_reason": "now eats meat"},
    )
    engine = _make_engine(storage=storage, llm=llm)

    candidate = CandidateClaim(
        subject="Andrew",
        predicate="diet",
        object_value="omnivore",
        confidence=0.85,
    )

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

    assert report.superseded == 1

    # superseded_by FK must have been set
    superseded_by_calls = [
        c for c in storage.update_claim.await_args_list
        if c.args == ("old-claim",) and c.kwargs.get("superseded_by") == "new-claim-id"
    ]
    assert len(superseded_by_calls) >= 1, (
        "update_claim(old-claim, superseded_by='new-claim-id') not called for CONTRADICTS"
    )


@pytest.mark.asyncio
async def test_action_update_anti_compression_blocked():
    """action=UPDATE supersession blocked when new value is much shorter (#514)."""
    long_existing = _make_claim(
        "old-claim", "Andrew", "personality",
        "Very detailed and extensive description of Andrew's personality covering many aspects",
        confidence=0.9, entity_id="entity-001",
    )
    storage = _make_storage(existing_by_entity=[long_existing])
    engine = _make_engine(storage=storage)

    # New value is very short → anti-compression guard triggers
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="personality",
        object_value="nice",
        confidence=0.5,  # below anti_compression_confidence threshold
        action="UPDATE",
    )

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

    # Should be blocked
    assert report.superseded == 0
    assert report.nooped == 1
    result = report.results[0]
    assert result.action == ReconciliationAction.NOOP
    assert result.compression_blocked is True


@pytest.mark.asyncio
async def test_action_add_with_existing_uses_normal_path():
    """Candidate with action=ADD (default) and existing match → normal heuristic/LLM path."""
    existing = _make_claim("ex-1", "Andrew", "sport", "runs", entity_id="entity-001")
    storage = _make_storage(existing_by_entity=[existing])
    llm = _make_llm("COMPLEMENTARY")
    engine = _make_engine(storage=storage, llm=llm)

    # Default action=ADD — should go through normal reconciliation, not immediate SUPERSEDE
    candidate = CandidateClaim(
        subject="Andrew",
        predicate="sport",
        object_value="runs and cycles",
        confidence=0.8,
        action="ADD",
    )

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

    # COMPLEMENTARY → UPDATE (merge), not SUPERSEDE
    assert report.updated == 1
    assert report.superseded == 0
    result = report.results[0]
    assert result.action == ReconciliationAction.UPDATE


@pytest.mark.asyncio
async def test_superseded_by_fk_links_to_new_claim_id():
    """Verify superseded_by FK matches the ID of the newly created claim (#514)."""
    existing = _make_claim("old-id", "Eva", "location", "VM", entity_id="entity-eva")

    created_ids: list[str] = []

    async def _create_claim(subject, predicate, object_value, **kwargs):
        import uuid
        new_id = f"new-{uuid.uuid4().hex[:8]}"
        created_ids.append(new_id)
        from tests.test_reconciliation import SemanticClaim
        return SemanticClaim(
            id=new_id,
            subject=subject,
            predicate=predicate,
            object_value=object_value,
            confidence=kwargs.get("confidence", 0.8),
        )

    storage = _make_storage(existing_by_entity=[existing])
    storage.create_claim = AsyncMock(side_effect=_create_claim)

    engine = _make_engine(storage=storage)

    candidate = CandidateClaim(
        subject="Eva",
        predicate="location",
        object_value="Mac Mini",
        confidence=0.9,
        action="UPDATE",
    )

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

    assert report.superseded == 1
    assert len(created_ids) == 1
    new_id = created_ids[0]

    # superseded_by must exactly match the new claim's ID
    superseded_by_calls = [
        c for c in storage.update_claim.await_args_list
        if c.args == ("old-id",) and c.kwargs.get("superseded_by") == new_id
    ]
    assert len(superseded_by_calls) == 1, (
        f"Expected update_claim(old-id, superseded_by={new_id!r}), "
        f"got: {storage.update_claim.await_args_list}"
    )
