"""
Tests for MODULE 5: CornerstoneGuardian

All storage operations use a MockStorageAdapter — no real SQLite required.
Tests verify: seal, unseal, verify_integrity, get_all, check_drift, token budget,
and the cornerstone cap enforcement.
"""

from __future__ import annotations

import hashlib
import pytest
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from unittest.mock import AsyncMock, patch

from cortex.identity.cornerstone import (
    CornerstoneGuardian,
    CornerstoneMemory,
    DriftReport,
    _sha256,
    _count_tokens,
    DEFAULT_MAX_CORNERSTONES,
)
from cortex.types import EvolutionProposal


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def sha256(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).digest().hex()


# ---------------------------------------------------------------------------
# Mock storage adapter
# ---------------------------------------------------------------------------

class MockStorageAdapter:
    """
    In-memory storage adapter for tests.
    Mimics the StorageAdapter ABC contract used by CornerstoneGuardian.
    """

    def _next_id(self) -> str:
        self._counter += 1
        return f"cs-{self._counter:04d}"

    async def get_cornerstones(self, owner_id: str = "default") -> List[CornerstoneMemory]:
        if owner_id == "":
            # Return all regardless of owner
            return list(self._store.values())
        return [c for c in self._store.values() if c.owner_id == owner_id]

    async def seal_cornerstone(
        self,
        label: str,
        content: str,
        owner_id: str = "default",
        sealed_by: str = "operator",
        cs_id: Optional[str] = None,
        revision: int = 1,
        revision_history: Optional[list] = None,
        **kwargs,
    ) -> CornerstoneMemory:
        content_hash = sha256(content)
        if cs_id is not None and cs_id in self._store:
            # Revision / update in-place
            existing = self._store[cs_id]
            existing.content = content
            existing.golden_copy = content
            existing.golden_hash = content_hash
            existing.current_hash = content_hash
            existing.revision = revision
            existing.revision_history = revision_history or existing.revision_history
            existing.sealed_by = sealed_by
            return existing

        cid = self._next_id()
        cs = CornerstoneMemory(
            id=cid,
            owner_id=owner_id,
            label=label,
            content=content,
            golden_copy=content,
            golden_hash=content_hash,
            current_hash=content_hash,
            token_count=0,
            injection_order=0,
            sealed_by=sealed_by,
            revision=revision,
            revision_history=revision_history or [],
        )
        self._store[cid] = cs
        return cs

    async def update_cornerstone_drift(self, cs_id: str, drift_score: float) -> None:
        if cs_id in self._store:
            self._store[cs_id].drift_score = drift_score

    async def restore_cornerstone(self, cs_id: str) -> CornerstoneMemory:
        if cs_id not in self._store:
            raise ValueError(f"Cornerstone not found: {cs_id}")
        cs = self._store[cs_id]
        cs.status = "retired"
        return cs

    def _tamper_content(self, cs_id: str, new_content: str) -> None:
        """Test helper: silently change stored content to simulate drift."""
        self._store[cs_id].content = new_content

    # --- Evolution proposal support ---

    def __init__(self):
        self._store: Dict[str, CornerstoneMemory] = {}
        self._proposals: Dict[str, EvolutionProposal] = {}
        self._counter = 0
        self._prop_counter = 0

    def _next_prop_id(self) -> str:
        self._prop_counter += 1
        return f"prop-{self._prop_counter:04d}"

    async def get_cornerstone_by_id(self, cs_id: str) -> Optional[CornerstoneMemory]:
        return self._store.get(cs_id)

    async def create_evolution_proposal(
        self,
        cornerstone_id: str,
        owner_id: str,
        new_evidence: str,
        proposed_content: str,
        diff: str,
        reasoning: str,
        confidence: float = 0.0,
    ) -> EvolutionProposal:
        from datetime import datetime, timezone
        pid = self._next_prop_id()
        proposal = EvolutionProposal(
            id=pid,
            cornerstone_id=cornerstone_id,
            owner_id=owner_id,
            new_evidence=new_evidence,
            proposed_content=proposed_content,
            diff=diff,
            reasoning=reasoning,
            confidence=confidence,
            status="pending",
            created_at=datetime.now(timezone.utc).isoformat(),
        )
        self._proposals[pid] = proposal
        return proposal

    async def get_evolution_proposals(
        self, cornerstone_id: str, status: str = "pending"
    ) -> List[EvolutionProposal]:
        return [
            p for p in self._proposals.values()
            if p.cornerstone_id == cornerstone_id and p.status == status
        ]

    async def get_evolution_proposal(self, proposal_id: str) -> Optional[EvolutionProposal]:
        return self._proposals.get(proposal_id)

    async def update_evolution_proposal_status(
        self, proposal_id: str, status: str, applied_at: Optional[str] = None
    ) -> EvolutionProposal:
        p = self._proposals[proposal_id]
        p.status = status
        if applied_at:
            p.applied_at = applied_at
        return p


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@dataclass
class MockConfig:
    embedding_model: str = "gpt-4o"
    cornerstone_auto_apply: bool = False
    cornerstone_auto_apply_threshold: Optional[float] = None
    max_cornerstones: int = DEFAULT_MAX_CORNERSTONES


@pytest.fixture
def storage() -> MockStorageAdapter:
    return MockStorageAdapter()


@pytest.fixture
def config() -> MockConfig:
    return MockConfig()


@pytest.fixture
def guardian(storage, config) -> CornerstoneGuardian:
    return CornerstoneGuardian(storage=storage, config=config)


# ---------------------------------------------------------------------------
# Unit helpers
# ---------------------------------------------------------------------------

class TestHelpers:
    def test_sha256_deterministic(self):
        text = "I am Eva, engineering lead."
        assert _sha256(text) == sha256(text)

    def test_sha256_sensitivity(self):
        """Even a single character change produces a different hash."""
        assert _sha256("hello") != _sha256("Hello")

    def test_count_tokens_returns_positive(self):
        count = _count_tokens("Hello, world!")
        assert count > 0

    def test_count_tokens_longer_text_more_tokens(self):
        short = _count_tokens("hi")
        long = _count_tokens("hi " * 100)
        assert long > short


# ---------------------------------------------------------------------------
# seal()
# ---------------------------------------------------------------------------

class TestSeal:
    @pytest.mark.asyncio
    async def test_seal_returns_cornerstone_memory(self, guardian):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        assert isinstance(cs, CornerstoneMemory)

    @pytest.mark.asyncio
    async def test_seal_sets_golden_hash(self, guardian):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        assert cs.golden_hash == sha256("I am Eva.")

    @pytest.mark.asyncio
    async def test_seal_hash_matches_stored_content(self, guardian):
        """Critical: hash is computed on STORED content, not input."""
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        assert cs.golden_hash == sha256(cs.content)
        assert cs.current_hash == sha256(cs.content)

    @pytest.mark.asyncio
    async def test_seal_sets_token_count(self, guardian):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        assert cs.token_count > 0

    @pytest.mark.asyncio
    async def test_seal_status_is_active(self, guardian):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        assert cs.status == "active"

    @pytest.mark.asyncio
    async def test_seal_assigns_injection_order(self, guardian):
        cs1 = await guardian.seal(label="First", content="Content one.")
        cs2 = await guardian.seal(label="Second", content="Content two.")
        assert cs1.injection_order == 0
        assert cs2.injection_order == 1

    @pytest.mark.asyncio
    async def test_seal_enforces_cap(self, guardian, config):
        config.max_cornerstones = 2
        guardian._max_cornerstones = 2
        await guardian.seal(label="A", content="one")
        await guardian.seal(label="B", content="two")
        with pytest.raises(ValueError, match="Cornerstone cap reached"):
            await guardian.seal(label="C", content="three")

    @pytest.mark.asyncio
    async def test_seal_golden_copy_never_changes(self, guardian, storage):
        """golden_copy must survive even if storage.content is later mutated."""
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        original_golden = cs.golden_copy
        # Simulate external content mutation (e.g., Mem0 rewrite)
        storage._tamper_content(cs.id, "I am something else.")
        # golden_copy on the sealed object is unchanged
        assert cs.golden_copy == original_golden


# ---------------------------------------------------------------------------
# unseal()
# ---------------------------------------------------------------------------

class TestUnseal:
    @pytest.mark.asyncio
    async def test_unseal_sets_status_retired(self, guardian):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        retired = await guardian.unseal(cs.id)
        assert retired.status == "retired"

    @pytest.mark.asyncio
    async def test_unseal_removes_from_active_get_all(self, guardian):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        await guardian.unseal(cs.id)
        active = await guardian.get_all()
        assert all(c.id != cs.id for c in active)

    @pytest.mark.asyncio
    async def test_unseal_not_found_raises(self, guardian):
        with pytest.raises(ValueError, match="not found"):
            await guardian.unseal("nonexistent-id")

    @pytest.mark.asyncio
    async def test_unseal_already_retired_raises(self, guardian):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        await guardian.unseal(cs.id)
        with pytest.raises(ValueError, match="already retired"):
            await guardian.unseal(cs.id)


# ---------------------------------------------------------------------------
# get_all()
# ---------------------------------------------------------------------------

class TestGetAll:
    @pytest.mark.asyncio
    async def test_get_all_empty_initially(self, guardian):
        result = await guardian.get_all()
        assert result == []

    @pytest.mark.asyncio
    async def test_get_all_returns_only_active(self, guardian):
        cs1 = await guardian.seal(label="A", content="one")
        cs2 = await guardian.seal(label="B", content="two")
        await guardian.unseal(cs1.id)
        active = await guardian.get_all()
        assert len(active) == 1
        assert active[0].id == cs2.id

    @pytest.mark.asyncio
    async def test_get_all_sorted_by_injection_order(self, guardian):
        await guardian.seal(label="First", content="1")
        await guardian.seal(label="Second", content="2")
        await guardian.seal(label="Third", content="3")
        result = await guardian.get_all()
        orders = [c.injection_order for c in result]
        assert orders == sorted(orders)

    @pytest.mark.asyncio
    async def test_get_all_scoped_by_owner(self, guardian):
        await guardian.seal(label="A", content="one", owner_id="alice")
        await guardian.seal(label="B", content="two", owner_id="bob")
        alice_cs = await guardian.get_all(owner_id="alice")
        assert len(alice_cs) == 1
        assert alice_cs[0].label == "A"


# ---------------------------------------------------------------------------
# check_drift() / verify_integrity()
# ---------------------------------------------------------------------------

class TestCheckDrift:
    @pytest.mark.asyncio
    async def test_no_drift_when_content_unchanged(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        reports = await guardian.check_drift()
        assert len(reports) == 1
        assert reports[0].drifted is False
        assert reports[0].action_taken == "none"

    @pytest.mark.asyncio
    async def test_drift_detected_when_content_changes(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        # Simulate Mem0 quietly rewriting stored content
        storage._tamper_content(cs.id, "I am something else entirely.")
        reports = await guardian.check_drift()
        assert len(reports) == 1
        assert reports[0].drifted is True
        assert reports[0].action_taken == "alert"

    @pytest.mark.asyncio
    async def test_drift_never_auto_corrects(self, guardian, storage):
        """The CRITICAL invariant: drift → alert, never auto-correct."""
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        original_golden = cs.golden_copy
        storage._tamper_content(cs.id, "I am overwritten.")
        await guardian.check_drift()
        # Golden copy must be untouched
        assert storage._store[cs.id].golden_copy == original_golden

    @pytest.mark.asyncio
    async def test_drift_score_set_to_1_on_mismatch(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        storage._tamper_content(cs.id, "tampered content")
        await guardian.check_drift()
        assert storage._store[cs.id].drift_score == 1.0

    @pytest.mark.asyncio
    async def test_drift_score_reset_to_0_when_clean(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        await guardian.check_drift()
        assert storage._store[cs.id].drift_score == 0.0

    @pytest.mark.asyncio
    async def test_check_drift_single_cs_id(self, guardian, storage):
        cs1 = await guardian.seal(label="A", content="one")
        cs2 = await guardian.seal(label="B", content="two")
        storage._tamper_content(cs2.id, "tampered")
        reports = await guardian.check_drift(cs_id=cs2.id)
        assert len(reports) == 1
        assert reports[0].cornerstone_id == cs2.id
        assert reports[0].drifted is True

    @pytest.mark.asyncio
    async def test_verify_integrity_alias(self, guardian):
        """verify_integrity() must be equivalent to check_drift()."""
        await guardian.seal(label="Identity", content="I am Eva.")
        via_drift = await guardian.check_drift()
        via_integrity = await guardian.verify_integrity()
        assert len(via_drift) == len(via_integrity)
        for d, i in zip(via_drift, via_integrity):
            assert d.cornerstone_id == i.cornerstone_id
            assert d.drifted == i.drifted

    @pytest.mark.asyncio
    async def test_multiple_cornerstones_all_checked(self, guardian, storage):
        cs1 = await guardian.seal(label="A", content="clean")
        cs2 = await guardian.seal(label="B", content="also clean")
        cs3 = await guardian.seal(label="C", content="will drift")
        storage._tamper_content(cs3.id, "drifted!")
        reports = await guardian.check_drift()
        assert len(reports) == 3
        drift_flags = {r.cornerstone_id: r.drifted for r in reports}
        assert drift_flags[cs1.id] is False
        assert drift_flags[cs2.id] is False
        assert drift_flags[cs3.id] is True


# ---------------------------------------------------------------------------
# Token budget reservation
# ---------------------------------------------------------------------------

class TestTokenBudget:
    @pytest.mark.asyncio
    async def test_get_total_token_count_empty(self, guardian):
        total = await guardian.get_total_token_count()
        assert total == 0

    @pytest.mark.asyncio
    async def test_get_total_token_count_sum(self, guardian):
        cs1 = await guardian.seal(label="A", content="short")
        cs2 = await guardian.seal(label="B", content="a much longer piece of text here")
        total = await guardian.get_total_token_count()
        assert total == cs1.token_count + cs2.token_count

    @pytest.mark.asyncio
    async def test_retired_cornerstones_excluded_from_budget(self, guardian):
        cs1 = await guardian.seal(label="A", content="short")
        cs2 = await guardian.seal(label="B", content="also short")
        await guardian.unseal(cs2.id)
        total = await guardian.get_total_token_count()
        assert total == cs1.token_count

    @pytest.mark.asyncio
    async def test_token_count_increases_with_content(self, guardian):
        cs1 = await guardian.seal(label="A", content="hi")
        cs2 = await guardian.seal(label="B", content="hi " * 50)
        assert cs2.token_count > cs1.token_count


# ---------------------------------------------------------------------------
# format_injection_block()
# ---------------------------------------------------------------------------

class TestFormatInjectionBlock:
    @pytest.mark.asyncio
    async def test_empty_block_when_no_cornerstones(self, guardian):
        result = await guardian.format_injection_block()
        assert result == ""

    @pytest.mark.asyncio
    async def test_block_contains_cornerstones_header(self, guardian):
        await guardian.seal(label="Identity", content="I am Eva.")
        block = await guardian.format_injection_block()
        assert "[CORNERSTONES — WHO I AM]" in block

    @pytest.mark.asyncio
    async def test_block_contains_content(self, guardian):
        await guardian.seal(label="Identity", content="I am Eva.")
        block = await guardian.format_injection_block()
        assert "I am Eva." in block

    @pytest.mark.asyncio
    async def test_block_contains_token_count(self, guardian):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        block = await guardian.format_injection_block()
        assert f"{cs.token_count} tokens" in block


# ---------------------------------------------------------------------------
# is_cornerstone_subject()
# ---------------------------------------------------------------------------

class TestIsCornerstoneSubject:
    @pytest.mark.asyncio
    async def test_matches_label(self, guardian):
        await guardian.seal(label="Eva", content="I am the assistant.")
        assert await guardian.is_cornerstone_subject("Eva") is True

    @pytest.mark.asyncio
    async def test_matches_content(self, guardian):
        await guardian.seal(label="Identity", content="Andrew is my creator.")
        assert await guardian.is_cornerstone_subject("Andrew") is True

    @pytest.mark.asyncio
    async def test_no_match_unknown_subject(self, guardian):
        await guardian.seal(label="Identity", content="I am Eva.")
        assert await guardian.is_cornerstone_subject("unrelated_thing_xyz") is False

    @pytest.mark.asyncio
    async def test_case_insensitive(self, guardian):
        await guardian.seal(label="Eva", content="Engineering lead.")
        assert await guardian.is_cornerstone_subject("eva") is True


# ---------------------------------------------------------------------------
# revise()
# ---------------------------------------------------------------------------

class TestRevise:
    @pytest.mark.asyncio
    async def test_revise_updates_content(self, guardian):
        cs = await guardian.seal(label="Identity", content="I am Eva v1.")
        revised = await guardian.revise(cs.id, "I am Eva v2.", reason="Version bump")
        assert revised.content == "I am Eva v2."

    @pytest.mark.asyncio
    async def test_revise_bumps_version(self, guardian):
        cs = await guardian.seal(label="Identity", content="I am Eva v1.")
        revised = await guardian.revise(cs.id, "I am Eva v2.", reason="Version bump")
        assert revised.revision == 2

    @pytest.mark.asyncio
    async def test_revise_stores_old_in_history(self, guardian):
        cs = await guardian.seal(label="Identity", content="old content")
        revised = await guardian.revise(cs.id, "new content", reason="test")
        assert len(revised.revision_history) == 1
        assert revised.revision_history[0]["text"] == "old content"

    @pytest.mark.asyncio
    async def test_revise_recomputes_hash(self, guardian):
        cs = await guardian.seal(label="Identity", content="old content")
        revised = await guardian.revise(cs.id, "new content", reason="test")
        assert revised.golden_hash == sha256("new content")

    @pytest.mark.asyncio
    async def test_revise_not_found_raises(self, guardian):
        with pytest.raises(ValueError, match="not found"):
            await guardian.revise("bogus-id", "new", reason="test")

    @pytest.mark.asyncio
    async def test_revise_retired_raises(self, guardian):
        cs = await guardian.seal(label="Identity", content="content")
        await guardian.unseal(cs.id)
        with pytest.raises(ValueError, match="retired"):
            await guardian.revise(cs.id, "new content", reason="test")


# ---------------------------------------------------------------------------
# propose_evolution()
# ---------------------------------------------------------------------------

class TestProposeEvolution:
    @pytest.mark.asyncio
    async def test_queued_by_default(self, guardian):
        """Without auto_apply_threshold, proposals are always queued."""
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "Addition.")
        assert result["action"] == "queued"
        assert result["requires_operator_review"] is True

    @pytest.mark.asyncio
    async def test_no_auto_apply_when_threshold_disabled(self, guardian, config):
        """auto_apply_threshold=None means always queue."""
        config.cornerstone_auto_apply_threshold = None
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "Addition.")
        assert result["action"] == "queued"

    @pytest.mark.asyncio
    async def test_auto_apply_when_threshold_met(self, storage, config):
        """When threshold is set and LLM confidence meets it, auto-apply."""
        from unittest.mock import AsyncMock, MagicMock
        config.cornerstone_auto_apply_threshold = 0.80
        llm = MagicMock()
        resp = MagicMock()
        resp.data = {
            "proposed_content": "I am Eva, AI lead. New detail.",
            "reasoning": "Additive only.",
            "confidence": 0.99,
        }
        llm.extract = AsyncMock(return_value=resp)
        g = CornerstoneGuardian(storage=storage, config=config, llm=llm)
        cs = await g.seal(label="Identity", content="I am Eva.")
        result = await g.propose_evolution(cs.id, "New detail.")
        assert result["action"] == "auto_applied"
        assert result["cornerstone"] is not None
        assert "New detail." in result["cornerstone"].content
