"""
Tests for semantic similarity gate in _supersede_stale_claims (#1430).

Verifies that claims with same subject+predicate but semantically different
object_value are NOT superseded, while semantically similar ones still are.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from cortex.core.reconciliation.storage_helpers import (
    _cosine_similarity,
    _normalize_subject,
    _GENERIC_PREDICATES,
)


# ---------------------------------------------------------------------------
# Stub types
# ---------------------------------------------------------------------------


@dataclass
class FakeSemanticClaim:
    id: str
    subject: str
    predicate: str
    object_value: str
    confidence: float = 0.7
    status: str = "active"
    owner_id: str = "default"


@dataclass
class FakeEmbedResponse:
    embeddings: list = field(default_factory=list)


# ---------------------------------------------------------------------------
# _cosine_similarity unit tests
# ---------------------------------------------------------------------------


class TestCosineSimilarity:
    def test_identical_vectors(self):
        v = [1.0, 0.0, 0.0]
        assert _cosine_similarity(v, v) == pytest.approx(1.0)

    def test_orthogonal_vectors(self):
        a = [1.0, 0.0]
        b = [0.0, 1.0]
        assert _cosine_similarity(a, b) == pytest.approx(0.0)

    def test_opposite_vectors(self):
        a = [1.0, 0.0]
        b = [-1.0, 0.0]
        assert _cosine_similarity(a, b) == pytest.approx(-1.0)

    def test_zero_vector(self):
        assert _cosine_similarity([0.0, 0.0], [1.0, 1.0]) == 0.0

    def test_similar_vectors(self):
        a = [1.0, 0.9, 0.8]
        b = [0.95, 0.85, 0.75]
        sim = _cosine_similarity(a, b)
        assert sim > 0.99  # nearly identical direction


# ---------------------------------------------------------------------------
# _supersede_stale_claims integration tests
# ---------------------------------------------------------------------------


def _make_engine(embed_fn=None, threshold=None, generic_threshold=None):
    """Build a minimal object that has the StorageHelpersMixin methods."""
    from cortex.core.reconciliation.storage_helpers import StorageHelpersMixin

    class FakeEngine(StorageHelpersMixin):
        pass

    engine = FakeEngine()
    engine.storage = AsyncMock()
    engine.storage.update_claim = AsyncMock()
    engine.storage.get_changelog = AsyncMock(return_value=[])
    engine.storage.log_changelog = AsyncMock()

    # Config
    engine.config = MagicMock()
    if threshold is not None:
        engine.config.supersession_similarity_threshold = threshold
    else:
        # Make getattr fall through to default
        del engine.config.supersession_similarity_threshold

    if generic_threshold is not None:
        engine.config.supersession_generic_predicate_threshold = generic_threshold
    else:
        del engine.config.supersession_generic_predicate_threshold

    # LLM with embed
    if embed_fn is not None:
        engine.llm = MagicMock()
        engine.llm.embed = embed_fn
    else:
        engine.llm = MagicMock(spec=[])  # no embed method

    return engine


@pytest.mark.asyncio
async def test_blocks_supersession_when_low_similarity():
    """Claims with same subject+predicate but different meaning should coexist."""
    # Embeddings: orthogonal => cosine ~ 0
    async def fake_embed(texts):
        return FakeEmbedResponse(embeddings=[[1, 0, 0], [0, 1, 0]])

    engine = _make_engine(embed_fn=fake_embed)

    existing = FakeSemanticClaim(
        id="old-1", subject="user", predicate="decides",
        object_value="to use dark mode",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="user", predicate="decides",
        object_value="to adopt SemVer",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Should NOT have been superseded
    engine.storage.update_claim.assert_not_called()


@pytest.mark.asyncio
async def test_allows_supersession_when_high_similarity():
    """Claims with same subject+predicate and similar meaning should supersede."""
    # Embeddings: nearly identical => cosine ~ 1.0
    async def fake_embed(texts):
        return FakeEmbedResponse(embeddings=[[1, 0.1, 0], [1, 0.12, 0]])

    engine = _make_engine(embed_fn=fake_embed)

    existing = FakeSemanticClaim(
        id="old-1", subject="user", predicate="prefers",
        object_value="dark mode theme",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="user", predicate="prefers",
        object_value="dark mode with blue accent",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Should have been superseded
    engine.storage.update_claim.assert_called_once()
    call_kwargs = engine.storage.update_claim.call_args
    assert call_kwargs[1].get("status") == "superseded" or call_kwargs[0][1] == "superseded" if len(call_kwargs[0]) > 1 else call_kwargs[1].get("status") == "superseded"


@pytest.mark.asyncio
async def test_embed_failure_blocks_supersession():
    """If embed() raises, supersession should be BLOCKED (fail-closed).

    Missing embeddings are caught by the backfill worker (#1443).
    Fail-closed protects against destroying valid memories during outages.
    """
    async def broken_embed(texts):
        raise RuntimeError("API down")

    engine = _make_engine(embed_fn=broken_embed)

    existing = FakeSemanticClaim(
        id="old-1", subject="user", predicate="likes",
        object_value="cats",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="user", predicate="likes",
        object_value="dogs",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Should NOT supersede (fail-closed)
    engine.storage.update_claim.assert_not_called()


@pytest.mark.asyncio
async def test_no_embed_method_skips_check():
    """If self.llm has no embed(), skip similarity and supersede normally."""
    engine = _make_engine(embed_fn=None)  # no embed

    existing = FakeSemanticClaim(
        id="old-1", subject="user", predicate="likes",
        object_value="cats",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="user", predicate="likes",
        object_value="dogs",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Should supersede (no embed available)
    engine.storage.update_claim.assert_called_once()


@pytest.mark.asyncio
async def test_configurable_threshold():
    """Custom threshold from config should be respected."""
    # Embeddings with cosine ~ 0.5 (between tight and loose thresholds)
    import math
    async def fake_embed(texts):
        # angle ~ 60 degrees => cosine ~ 0.5
        return FakeEmbedResponse(embeddings=[
            [1.0, 0.0],
            [0.5, math.sqrt(3) / 2],
        ])

    # With threshold 0.3 => should supersede (0.5 > 0.3)
    # Use a non-generic predicate so the base threshold applies
    engine = _make_engine(embed_fn=fake_embed, threshold=0.3)
    existing = FakeSemanticClaim(
        id="old-1", subject="user", predicate="deployed_version",
        object_value="A",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="user", predicate="deployed_version",
        object_value="B",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])
    await engine._supersede_stale_claims(new_claim, owner_id="default")
    engine.storage.update_claim.assert_called_once()

    # With threshold 0.9 => should block (0.5 <= 0.9)
    engine2 = _make_engine(embed_fn=fake_embed, threshold=0.9)
    engine2.storage.get_claims_by_subject = AsyncMock(return_value=[existing])
    await engine2._supersede_stale_claims(new_claim, owner_id="default")
    engine2.storage.update_claim.assert_not_called()


@pytest.mark.asyncio
async def test_dimension_mismatch_returns_zero():
    """Mismatched embedding dimensions should return 0.0 (block supersession)."""
    from cortex.core.reconciliation.storage_helpers import _cosine_similarity
    assert _cosine_similarity([1.0, 0.0], [1.0, 0.0, 0.0]) == 0.0


@pytest.mark.asyncio
async def test_empty_object_value_allows_supersession():
    """Claims with empty object_value are garbage — should be superseded normally."""
    async def fake_embed(texts):
        return FakeEmbedResponse(embeddings=[[0.9, 0.1]] * len(texts))

    engine = _make_engine(embed_fn=fake_embed)

    existing = FakeSemanticClaim(
        id="old-1", subject="user", predicate="likes",
        object_value="",  # empty = garbage data
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="user", predicate="likes",
        object_value="dogs",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Empty object_value = garbage claim, supersession should proceed
    engine.storage.update_claim.assert_called_once()


# ---------------------------------------------------------------------------
# Subject normalization tests (#1430)
# ---------------------------------------------------------------------------


class TestNormalizeSubject:
    def test_lowercase(self):
        assert _normalize_subject("Andrew") == "andrew"

    def test_strip_whitespace(self):
        assert _normalize_subject("  user  ") == "user"

    def test_collapse_multiple_spaces(self):
        assert _normalize_subject("the   user   name") == "the user name"

    def test_combined(self):
        assert _normalize_subject("  The   User  ") == "the user"

    def test_empty(self):
        assert _normalize_subject("") == ""

    def test_tabs_and_newlines(self):
        assert _normalize_subject("the\t\nuser") == "the user"


# ---------------------------------------------------------------------------
# Predicate specificity gate tests (#1430)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_generic_predicate_uses_higher_threshold():
    """Generic predicate 'prefers' should use higher threshold (0.92 default).

    Cosine ~ 0.85 should BLOCK for generic predicates but ALLOW for specific ones.
    """
    # Embeddings with cosine ~ 0.85
    async def fake_embed(texts):
        return FakeEmbedResponse(embeddings=[
            [1.0, 0.0, 0.0],
            [0.85, 0.527, 0.0],  # cosine ~ 0.85
        ])

    engine = _make_engine(embed_fn=fake_embed)

    existing = FakeSemanticClaim(
        id="old-1", subject="user", predicate="prefers",
        object_value="code reviews before merge",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="user", predicate="prefers",
        object_value="documentation updates",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Should be BLOCKED — 0.85 <= 0.92 (generic threshold)
    engine.storage.update_claim.assert_not_called()


@pytest.mark.asyncio
async def test_specific_predicate_keeps_lower_threshold():
    """Non-generic predicate should use standard 0.80 threshold.

    Cosine ~ 0.85 should ALLOW supersession for specific predicates.
    """
    async def fake_embed(texts):
        return FakeEmbedResponse(embeddings=[
            [1.0, 0.0, 0.0],
            [0.85, 0.527, 0.0],  # cosine ~ 0.85
        ])

    engine = _make_engine(embed_fn=fake_embed)

    existing = FakeSemanticClaim(
        id="old-1", subject="user", predicate="deployed_version",
        object_value="v1.2.3",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="user", predicate="deployed_version",
        object_value="v1.3.0",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Should ALLOW — 0.85 > 0.80 (standard threshold)
    engine.storage.update_claim.assert_called_once()


@pytest.mark.asyncio
async def test_generic_predicate_allows_genuinely_similar():
    """Generic predicate should still supersede genuinely duplicate claims.

    'user prefers dark mode' vs 'user prefers light mode' — very high cosine.
    """
    # Embeddings with cosine > 0.95
    async def fake_embed(texts):
        return FakeEmbedResponse(embeddings=[
            [1.0, 0.1, 0.0],
            [1.0, 0.12, 0.0],  # cosine ~ 0.9998
        ])

    engine = _make_engine(embed_fn=fake_embed)

    existing = FakeSemanticClaim(
        id="old-1", subject="user", predicate="prefers",
        object_value="light mode theme",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="user", predicate="prefers",
        object_value="dark mode theme",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Should ALLOW — cosine > 0.92 even for generic predicate
    engine.storage.update_claim.assert_called_once()


@pytest.mark.asyncio
async def test_generic_predicate_blocks_unrelated():
    """'user requires code reviews' should NOT supersede 'user requires documentation'.

    Same generic predicate, but different topics → blocked.
    """
    # Embeddings: low-ish similarity (orthogonal-ish)
    async def fake_embed(texts):
        return FakeEmbedResponse(embeddings=[
            [1.0, 0.0, 0.0],
            [0.3, 0.954, 0.0],  # cosine ~ 0.3
        ])

    engine = _make_engine(embed_fn=fake_embed)

    existing = FakeSemanticClaim(
        id="old-1", subject="user", predicate="requires",
        object_value="always wait for code reviews",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="user", predicate="requires",
        object_value="documentation updates after core changes",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Should be BLOCKED — different topics
    engine.storage.update_claim.assert_not_called()


@pytest.mark.asyncio
async def test_generic_threshold_configurable():
    """Custom generic predicate threshold from config should be respected."""
    # Embeddings with cosine ~ 0.85
    async def fake_embed(texts):
        return FakeEmbedResponse(embeddings=[
            [1.0, 0.0, 0.0],
            [0.85, 0.527, 0.0],
        ])

    # Set generic threshold LOW so 0.85 passes
    engine = _make_engine(embed_fn=fake_embed, generic_threshold=0.70)

    existing = FakeSemanticClaim(
        id="old-1", subject="user", predicate="prefers",
        object_value="dark mode",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="user", predicate="prefers",
        object_value="light mode",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Should ALLOW — 0.85 > 0.70 (custom generic threshold)
    engine.storage.update_claim.assert_called_once()


@pytest.mark.asyncio
async def test_subject_normalization_matches_case_variants():
    """'Andrew' and 'andrew' should match via subject normalization."""
    async def fake_embed(texts):
        return FakeEmbedResponse(embeddings=[
            [1.0, 0.1, 0.0],
            [1.0, 0.12, 0.0],  # cosine ~ 0.9998
        ])

    engine = _make_engine(embed_fn=fake_embed)

    existing = FakeSemanticClaim(
        id="old-1", subject="Andrew", predicate="deployed_version",
        object_value="v1.0",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="andrew", predicate="deployed_version",
        object_value="v2.0",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Should supersede — subjects normalize to same value
    engine.storage.update_claim.assert_called_once()


@pytest.mark.asyncio
async def test_subject_normalization_rejects_different_subjects():
    """Different subjects should NOT match even with similar predicates/objects."""
    async def fake_embed(texts):
        return FakeEmbedResponse(embeddings=[
            [1.0, 0.1, 0.0],
            [1.0, 0.12, 0.0],
        ])

    engine = _make_engine(embed_fn=fake_embed)

    existing = FakeSemanticClaim(
        id="old-1", subject="project_alpha", predicate="deployed_version",
        object_value="v1.0",
    )
    new_claim = FakeSemanticClaim(
        id="new-1", subject="project_beta", predicate="deployed_version",
        object_value="v2.0",
    )
    engine.storage.get_claims_by_subject = AsyncMock(return_value=[existing])

    await engine._supersede_stale_claims(new_claim, owner_id="default")

    # Should NOT supersede — different subjects
    engine.storage.update_claim.assert_not_called()


@pytest.mark.asyncio
async def test_generic_predicates_set_coverage():
    """Verify the generic predicates set contains expected high-collision predicates."""
    expected = {
        "prefers", "decided", "has", "plans", "uses", "learned",
        "requires", "wants", "needs", "believes", "thinks",
        "values", "likes", "dislikes", "chose", "maintains",
        "adopted", "established", "considers", "knows",
    }
    assert _GENERIC_PREDICATES == frozenset(expected)
