"""
Tests for M5 Cornerstone Evolution — propose_evolution() + apply_evolution().

All storage operations use an in-memory MockStorageAdapter extended with
evolution proposal support — no real SQLite required.
"""
from __future__ import annotations

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

from cortex.identity.cornerstone import CornerstoneGuardian, CornerstoneMemory, _sha256
from cortex.types import EvolutionProposal


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

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


def _now() -> str:
    from datetime import datetime, timezone
    return datetime.now(timezone.utc).isoformat()


# ---------------------------------------------------------------------------
# Extended Mock Storage (includes evolution proposal support)
# ---------------------------------------------------------------------------

class MockStorageAdapter:
    """Full mock supporting both cornerstones and evolution proposals."""

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

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

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

    # --- Cornerstone ops ---

    async def get_cornerstones(self, owner_id: str = "default") -> List[CornerstoneMemory]:
        if owner_id == "":
            return list(self._cornerstones.values())
        return [c for c in self._cornerstones.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._cornerstones:
            existing = self._cornerstones[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_cs_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=len(self._cornerstones),
            sealed_by=sealed_by,
            revision=revision,
            revision_history=revision_history or [],
        )
        self._cornerstones[cid] = cs
        return cs

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

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

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

    # --- Evolution proposal ops ---

    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:
        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=_now(),
            applied_at=None,
        )
        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:
        if proposal_id not in self._proposals:
            raise ValueError(f"Evolution proposal not found: {proposal_id}")
        p = self._proposals[proposal_id]
        p.status = status
        if applied_at:
            p.applied_at = applied_at
        return p

    def _tamper_content(self, cs_id: str, new_content: str) -> None:
        self._cornerstones[cs_id].content = new_content


# ---------------------------------------------------------------------------
# Mock config / LLM
# ---------------------------------------------------------------------------

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


def make_mock_llm(proposed: str, reasoning: str, confidence: float):
    """Return a mock LLMProvider that returns deterministic extraction results."""
    llm = MagicMock()
    resp = MagicMock()
    resp.data = {
        "proposed_content": proposed,
        "reasoning": reasoning,
        "confidence": confidence,
    }
    llm.extract = AsyncMock(return_value=resp)
    return llm


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

@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)


@pytest.fixture
def guardian_with_llm(storage, config) -> CornerstoneGuardian:
    llm = make_mock_llm(
        proposed="I am Eva, AI engineering lead. New context incorporated.",
        reasoning="Added new context while preserving core identity.",
        confidence=0.88,
    )
    return CornerstoneGuardian(storage=storage, config=config, llm=llm)


# ---------------------------------------------------------------------------
# propose_evolution() — no LLM (fallback mode)
# ---------------------------------------------------------------------------

class TestProposeEvolutionNoLLM:
    @pytest.mark.asyncio
    async def test_returns_queued_by_default(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail about Eva.")
        assert result["action"] == "queued"

    @pytest.mark.asyncio
    async def test_requires_operator_review(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        assert result["requires_operator_review"] is True

    @pytest.mark.asyncio
    async def test_proposal_stored_in_storage(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        proposal_id = result["proposal_id"]
        stored = await storage.get_evolution_proposal(proposal_id)
        assert stored is not None
        assert stored.cornerstone_id == cs.id
        assert stored.status == "pending"

    @pytest.mark.asyncio
    async def test_proposal_content_includes_evidence(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        assert "New detail." in result["proposed_content"]

    @pytest.mark.asyncio
    async def test_diff_is_returned(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        assert "diff" in result
        assert isinstance(result["diff"], str)

    @pytest.mark.asyncio
    async def test_reasoning_is_returned(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        assert "reasoning" in result
        assert isinstance(result["reasoning"], str)

    @pytest.mark.asyncio
    async def test_confidence_is_returned(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        assert "confidence" in result
        assert 0.0 <= result["confidence"] <= 1.0

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

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

    @pytest.mark.asyncio
    async def test_no_auto_apply_when_threshold_not_set(self, guardian, config, storage):
        config.cornerstone_auto_apply_threshold = None
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        assert result["action"] == "queued"
        # Cornerstone content unchanged
        stored_cs = await storage.get_cornerstone_by_id(cs.id)
        assert stored_cs.content == "I am Eva."

    @pytest.mark.asyncio
    async def test_multiple_proposals_for_same_cornerstone(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        r1 = await guardian.propose_evolution(cs.id, "Evidence 1")
        r2 = await guardian.propose_evolution(cs.id, "Evidence 2")
        proposals = await storage.get_evolution_proposals(cs.id, status="pending")
        assert len(proposals) == 2
        ids = {p.id for p in proposals}
        assert r1["proposal_id"] in ids
        assert r2["proposal_id"] in ids


# ---------------------------------------------------------------------------
# propose_evolution() — with LLM
# ---------------------------------------------------------------------------

class TestProposeEvolutionWithLLM:
    @pytest.mark.asyncio
    async def test_llm_proposed_content_used(self, storage, config):
        llm = make_mock_llm(
            proposed="I am Eva, AI lead. Plus new detail.",
            reasoning="Added new detail while preserving core identity.",
            confidence=0.90,
        )
        guardian = CornerstoneGuardian(storage=storage, config=config, llm=llm)
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail about Eva.")
        assert result["proposed_content"] == "I am Eva, AI lead. Plus new detail."
        assert result["confidence"] == pytest.approx(0.90)

    @pytest.mark.asyncio
    async def test_llm_reasoning_used(self, storage, config):
        llm = make_mock_llm(
            proposed="Revised content.",
            reasoning="Because reasons.",
            confidence=0.85,
        )
        guardian = CornerstoneGuardian(storage=storage, config=config, llm=llm)
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        assert result["reasoning"] == "Because reasons."

    @pytest.mark.asyncio
    async def test_llm_extract_called_with_evidence(self, storage, config):
        llm = make_mock_llm(
            proposed="Revised.", reasoning="Reason.", confidence=0.80,
        )
        guardian = CornerstoneGuardian(storage=storage, config=config, llm=llm)
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        await guardian.propose_evolution(cs.id, "Important new evidence.")
        call_kwargs = llm.extract.call_args
        user_arg = call_kwargs.kwargs.get("user") or call_kwargs.args[1]
        assert "Important new evidence." in user_arg

    @pytest.mark.asyncio
    async def test_llm_failure_uses_fallback(self, storage, config):
        llm = MagicMock()
        llm.extract = AsyncMock(side_effect=RuntimeError("LLM unavailable"))
        guardian = CornerstoneGuardian(storage=storage, config=config, llm=llm)
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New evidence.")
        # Should not crash — fallback kicks in
        assert result["action"] == "queued"
        assert "New evidence." in result["proposed_content"]

    @pytest.mark.asyncio
    async def test_auto_apply_when_threshold_met(self, storage, config):
        config.cornerstone_auto_apply_threshold = 0.80
        llm = make_mock_llm(
            proposed="I am Eva, AI engineering lead.",
            reasoning="Additive only.",
            confidence=0.95,
        )
        guardian = CornerstoneGuardian(storage=storage, config=config, llm=llm)
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        assert result["action"] == "auto_applied"
        assert result["requires_operator_review"] is False
        assert result["cornerstone"] is not None

    @pytest.mark.asyncio
    async def test_auto_apply_updates_cornerstone_content(self, storage, config):
        config.cornerstone_auto_apply_threshold = 0.80
        llm = make_mock_llm(
            proposed="I am Eva, AI engineering lead.",
            reasoning="Additive only.",
            confidence=0.95,
        )
        guardian = CornerstoneGuardian(storage=storage, config=config, llm=llm)
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        await guardian.propose_evolution(cs.id, "New detail.")
        updated = await storage.get_cornerstone_by_id(cs.id)
        assert updated.content == "I am Eva, AI engineering lead."

    @pytest.mark.asyncio
    async def test_auto_apply_marks_proposal_as_applied(self, storage, config):
        config.cornerstone_auto_apply_threshold = 0.80
        llm = make_mock_llm(
            proposed="I am Eva, AI engineering lead.",
            reasoning="Additive.",
            confidence=0.95,
        )
        guardian = CornerstoneGuardian(storage=storage, config=config, llm=llm)
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        proposal = await storage.get_evolution_proposal(result["proposal_id"])
        assert proposal.status == "applied"

    @pytest.mark.asyncio
    async def test_no_auto_apply_when_confidence_below_threshold(self, storage, config):
        config.cornerstone_auto_apply_threshold = 0.95
        llm = make_mock_llm(
            proposed="I am Eva, AI engineering lead.",
            reasoning="Additive.",
            confidence=0.70,  # below threshold
        )
        guardian = CornerstoneGuardian(storage=storage, config=config, llm=llm)
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        assert result["action"] == "queued"
        assert result["requires_operator_review"] is True


# ---------------------------------------------------------------------------
# apply_evolution()
# ---------------------------------------------------------------------------

class TestApplyEvolution:
    @pytest.mark.asyncio
    async def test_apply_updates_cornerstone_content(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        pid = result["proposal_id"]
        updated = await guardian.apply_evolution(cs.id, pid)
        assert updated.content == result["proposed_content"]

    @pytest.mark.asyncio
    async def test_apply_bumps_revision(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        pid = result["proposal_id"]
        updated = await guardian.apply_evolution(cs.id, pid)
        assert updated.revision == 2

    @pytest.mark.asyncio
    async def test_apply_marks_proposal_applied(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        pid = result["proposal_id"]
        await guardian.apply_evolution(cs.id, pid)
        proposal = await storage.get_evolution_proposal(pid)
        assert proposal.status == "applied"

    @pytest.mark.asyncio
    async def test_apply_stores_history(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        pid = result["proposal_id"]
        updated = await guardian.apply_evolution(cs.id, pid)
        import json
        history = updated.revision_history if isinstance(updated.revision_history, list) \
            else json.loads(updated.revision_history)
        assert len(history) >= 1
        original_texts = [h.get("text", "") for h in history]
        assert "I am Eva." in original_texts

    @pytest.mark.asyncio
    async def test_apply_wrong_cornerstone_raises(self, guardian, storage):
        cs1 = await guardian.seal(label="A", content="Content A")
        cs2 = await guardian.seal(label="B", content="Content B")
        result = await guardian.propose_evolution(cs1.id, "Evidence.")
        pid = result["proposal_id"]
        with pytest.raises(ValueError, match="belongs to cornerstone"):
            await guardian.apply_evolution(cs2.id, pid)

    @pytest.mark.asyncio
    async def test_apply_nonexistent_proposal_raises(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        with pytest.raises(ValueError, match="not found"):
            await guardian.apply_evolution(cs.id, "nonexistent-prop-id")

    @pytest.mark.asyncio
    async def test_apply_already_applied_proposal_raises(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "Evidence.")
        pid = result["proposal_id"]
        await guardian.apply_evolution(cs.id, pid)
        with pytest.raises(ValueError, match="status is 'applied'"):
            await guardian.apply_evolution(cs.id, pid)

    @pytest.mark.asyncio
    async def test_apply_updates_golden_hash(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        pid = result["proposal_id"]
        updated = await guardian.apply_evolution(cs.id, pid)
        new_content = result["proposed_content"]
        assert updated.golden_hash == sha256(new_content)

    @pytest.mark.asyncio
    async def test_apply_resets_drift_score(self, guardian, storage):
        cs = await guardian.seal(label="Identity", content="I am Eva.")
        result = await guardian.propose_evolution(cs.id, "New detail.")
        pid = result["proposal_id"]
        updated = await guardian.apply_evolution(cs.id, pid)
        # After revision the hash should match content, drift should be 0
        assert updated.current_hash == updated.golden_hash


# ---------------------------------------------------------------------------
# config.cornerstone_auto_apply_threshold
# ---------------------------------------------------------------------------

class TestAutoApplyThresholdConfig:
    def test_default_is_none(self):
        from cortex.config import CortexConfig
        cfg = CortexConfig()
        assert cfg.cornerstone_auto_apply_threshold is None

    def test_can_set_threshold(self):
        from cortex.config import CortexConfig
        cfg = CortexConfig(cornerstone_auto_apply_threshold=0.90)
        assert cfg.cornerstone_auto_apply_threshold == pytest.approx(0.90)

    def test_toml_loads_threshold(self, tmp_path):
        from cortex.config import CortexConfig
        toml_path = tmp_path / "test.toml"
        toml_path.write_text(
            "[cortex.cornerstones]\nauto_apply_threshold = 0.92\n",
            encoding="utf-8",
        )
        cfg = CortexConfig.from_toml(str(toml_path))
        assert cfg.cornerstone_auto_apply_threshold == pytest.approx(0.92)

    def test_toml_none_disabled(self, tmp_path):
        from cortex.config import CortexConfig
        toml_path = tmp_path / "test.toml"
        toml_path.write_text("[cortex]\n", encoding="utf-8")
        cfg = CortexConfig.from_toml(str(toml_path))
        assert cfg.cornerstone_auto_apply_threshold is None
