"""Tests for ``a360_mcp.utils.snapshot_store.LocalSnapshotStore``.

Exercises the Phase 7 contract: save on first diagnosis, serve from cache
on repeat (skipping CR), mark expired past the 90-day retention window,
and never raise on I/O failures. Uses ``tmp_path`` via the autouse
``isolate_state_dir`` fixture so no test touches the real state directory.
"""

from __future__ import annotations

import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock

from a360_mcp.services.bot import BotNode
from a360_mcp.services.execution import ExecutionSummary
from a360_mcp.utils.snapshot_store import (
    CR_EXECUTION_RETENTION_DAYS,
    SNAPSHOT_SCHEMA_VERSION,
    LocalSnapshotStore,
    is_payload_expired,
)


# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _execution(
    *,
    id="exec-1",
    status="RUN_FAILED",
    message="boom",
    file_id="file-9",
    completed_on=None,
    started_on=None,
) -> ExecutionSummary:
    return ExecutionSummary(
        id=id,
        status=status,
        message=message,
        file_id=file_id,
        deployment_id="dep-1",
        bot_name="MyBot",
        started_on=started_on,
        completed_on=completed_on,
        error={"message": "boom"},
        raw={"id": id, "status": status},
    )


def _nodes() -> list[BotNode]:
    return [
        BotNode(
            order=0,
            path=(0,),
            command="Open",
            name="Open file",
            line_number=1,
            index=0,
            kind="action",
            depth=0,
            has_children=False,
        ),
        BotNode(
            order=1,
            path=(1,),
            command="Read",
            name=None,
            line_number=2,
            index=1,
            kind="action",
            depth=0,
            has_children=False,
        ),
    ]


def _diagnosis() -> dict:
    return {
        "verdict": "action at line 1 'Open file' failed: boom.",
        "execution_id": "exec-1",
        "status": "RUN_FAILED",
        "bot_name": "MyBot",
        "file_id": "file-9",
        "failure": {"message": "boom", "source": "error", "line_number": 1},
        "failed_action": {"order": 0, "command": "Open"},
        "upstream_context": [],
        "downstream_context": [{"order": 1, "command": "Read"}],
        "has_error_handler": False,
        "prescription": "wrap in Try/Catch",
        "action_count": 2,
        "bot_path": None,
        "snapshot_path": None,
    }


def _iso(dt: datetime) -> str:
    return dt.isoformat()


def _store(base_dir: Path, *, url="https://cr.example.com", user="svc_mcp") -> LocalSnapshotStore:
    return LocalSnapshotStore(control_room_url=url, username=user, base_dir=base_dir)


# --------------------------------------------------------------------------- #
# Construction
# --------------------------------------------------------------------------- #
class TestConstruction:
    def test_account_dir_under_snapshots_subdir(self, tmp_path):
        store = _store(tmp_path)
        assert store.account_dir == tmp_path / "snapshots" / store.account_dir.name
        # account dir name starts with the username label
        assert store.account_dir.name.startswith("svc_mcp-")

    def test_different_accounts_isolate(self, tmp_path):
        s1 = _store(tmp_path, user="alice")
        s2 = _store(tmp_path, user="bob")
        assert s1.account_dir != s2.account_dir

    def test_snapshot_path_no_io(self, tmp_path):
        store = _store(tmp_path)
        path = store.snapshot_path("exec-123")
        assert path.name == "exec_exec-123.json"
        assert path.parent == store.account_dir

    def test_snapshot_path_sanitizes_unsafe_chars(self, tmp_path):
        store = _store(tmp_path)
        path = store.snapshot_path("exec/../../etc/passwd")
        # slashes become dashes — no directory traversal
        assert path.name == "exec_exec-..-..-etc-passwd.json"
        assert path.parent == store.account_dir

    def test_snapshot_path_empty_id(self, tmp_path):
        store = _store(tmp_path)
        path = store.snapshot_path("")
        assert path.name == "exec_unknown.json"


# --------------------------------------------------------------------------- #
# save_diagnosis
# --------------------------------------------------------------------------- #
class TestSaveDiagnosis:
    def test_writes_file_and_returns_path(self, tmp_path):
        store = _store(tmp_path)
        path = store.save_diagnosis(
            auth=MagicMock(),
            execution=_execution(),
            bot_nodes=_nodes(),
            diagnosis=_diagnosis(),
        )
        assert path is not None
        assert Path(path).exists()
        payload = json.loads(Path(path).read_text())
        assert payload["schema_version"] == SNAPSHOT_SCHEMA_VERSION
        assert payload["execution_id"] == "exec-1"
        assert payload["execution"]["status"] == "RUN_FAILED"
        assert payload["execution"]["id"] == "exec-1"
        # raw is NOT persisted
        assert "raw" not in payload["execution"]
        assert len(payload["bot_nodes"]) == 2
        # path tuple → list for JSON
        assert payload["bot_nodes"][0]["path"] == [0]
        assert payload["diagnosis"]["verdict"].startswith("action at line 1")

    def test_saved_at_is_iso(self, tmp_path):
        store = _store(tmp_path)
        path = store.save_diagnosis(
            auth=MagicMock(),
            execution=_execution(),
            bot_nodes=[],
            diagnosis=_diagnosis(),
        )
        payload = json.loads(Path(path).read_text())
        saved_at = payload["saved_at"]
        # parseable ISO with timezone
        parsed = datetime.fromisoformat(saved_at.replace("Z", "+00:00"))
        assert parsed.tzinfo is not None

    def test_idempotent_overwrite(self, tmp_path):
        store = _store(tmp_path)
        d1 = _diagnosis()
        d1["verdict"] = "first"
        store.save_diagnosis(auth=MagicMock(), execution=_execution(), bot_nodes=[], diagnosis=d1)
        d2 = _diagnosis()
        d2["verdict"] = "second"
        path = store.save_diagnosis(
            auth=MagicMock(), execution=_execution(), bot_nodes=[], diagnosis=d2
        )
        payload = json.loads(Path(path).read_text())
        assert payload["diagnosis"]["verdict"] == "second"

    def test_empty_bot_nodes(self, tmp_path):
        store = _store(tmp_path)
        path = store.save_diagnosis(
            auth=MagicMock(),
            execution=_execution(),
            bot_nodes=[],
            diagnosis=_diagnosis(),
        )
        payload = json.loads(Path(path).read_text())
        assert payload["bot_nodes"] == []

    def test_write_failure_returns_none(self, tmp_path, monkeypatch):
        store = _store(tmp_path)
        import a360_mcp.utils.snapshot_store as mod

        def boom(*args, **kwargs):
            raise OSError("disk full")

        monkeypatch.setattr(mod, "write_json_atomic", boom)
        path = store.save_diagnosis(
            auth=MagicMock(),
            execution=_execution(),
            bot_nodes=[],
            diagnosis=_diagnosis(),
        )
        assert path is None

    def test_file_permissions_restrictive(self, tmp_path):
        store = _store(tmp_path)
        path = store.save_diagnosis(
            auth=MagicMock(),
            execution=_execution(),
            bot_nodes=[],
            diagnosis=_diagnosis(),
        )
        mode = Path(path).stat().st_mode & 0o777
        assert mode == 0o600


# --------------------------------------------------------------------------- #
# get_diagnosis
# --------------------------------------------------------------------------- #
class TestGetDiagnosis:
    def test_miss_returns_none(self, tmp_path):
        store = _store(tmp_path)
        assert store.get_diagnosis("exec-nope") is None

    def test_hit_returns_augmented_dict(self, tmp_path):
        store = _store(tmp_path)
        store.save_diagnosis(
            auth=MagicMock(),
            execution=_execution(),
            bot_nodes=_nodes(),
            diagnosis=_diagnosis(),
        )
        result = store.get_diagnosis("exec-1")
        assert result is not None
        assert result["from_snapshot"] is True
        assert result["execution_id"] == "exec-1"
        assert result["verdict"].startswith("action at line 1")
        assert "snapshot_path" in result
        assert "snapshot_saved_at" in result
        assert "original_expired" in result
        assert "source_note" in result

    def test_corrupt_file_returns_none(self, tmp_path):
        store = _store(tmp_path)
        path = store.snapshot_path("exec-1")
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text("{not valid json")
        assert store.get_diagnosis("exec-1") is None

    def test_wrong_schema_version_returns_none(self, tmp_path):
        store = _store(tmp_path)
        path = store.snapshot_path("exec-1")
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps({"schema_version": 999, "diagnosis": {}}))
        assert store.get_diagnosis("exec-1") is None

    def test_missing_diagnosis_key_returns_none(self, tmp_path):
        store = _store(tmp_path)
        path = store.snapshot_path("exec-1")
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps({"schema_version": SNAPSHOT_SCHEMA_VERSION, "execution": {}}))
        assert store.get_diagnosis("exec-1") is None

    def test_does_not_mutate_cached_blob(self, tmp_path):
        store = _store(tmp_path)
        store.save_diagnosis(
            auth=MagicMock(),
            execution=_execution(),
            bot_nodes=[],
            diagnosis=_diagnosis(),
        )
        result = store.get_diagnosis("exec-1")
        assert result is not None
        result["verdict"] = "tampered"
        # second read still has the original verdict
        result2 = store.get_diagnosis("exec-1")
        assert result2 is not None
        assert result2["verdict"].startswith("action at line 1")


# --------------------------------------------------------------------------- #
# Expiry detection
# --------------------------------------------------------------------------- #
class TestExpiry:
    def _payload_with_timestamp(self, ts: str | None) -> dict:
        return {
            "schema_version": SNAPSHOT_SCHEMA_VERSION,
            "saved_at": _iso(datetime.now(UTC)),
            "execution": {"completed_on": ts} if ts else {},
            "diagnosis": {"verdict": "x"},
        }

    def test_recent_not_expired(self):
        now = datetime.now(UTC)
        recent = _iso(now - timedelta(days=10))
        assert is_payload_expired(self._payload_with_timestamp(recent), now=now) is False

    def test_past_90_days_expired(self):
        now = datetime.now(UTC)
        old = _iso(now - timedelta(days=CR_EXECUTION_RETENTION_DAYS + 1))
        assert is_payload_expired(self._payload_with_timestamp(old), now=now) is True

    def test_exactly_90_days_not_expired(self):
        now = datetime.now(UTC)
        cutoff = _iso(now - timedelta(days=CR_EXECUTION_RETENTION_DAYS))
        assert is_payload_expired(self._payload_with_timestamp(cutoff), now=now) is False

    def test_no_timestamp_not_expired(self):
        now = datetime.now(UTC)
        assert is_payload_expired(self._payload_with_timestamp(None), now=now) is False

    def test_falls_back_to_saved_at(self):
        now = datetime.now(UTC)
        old_saved = _iso(now - timedelta(days=100))
        payload = {
            "schema_version": SNAPSHOT_SCHEMA_VERSION,
            "saved_at": old_saved,
            "execution": {},
            "diagnosis": {"verdict": "x"},
        }
        assert is_payload_expired(payload, now=now) is True

    def test_force_expired_override(self):
        now = datetime.now(UTC)
        recent = _iso(now - timedelta(days=1))
        payload = self._payload_with_timestamp(recent)
        payload["force_expired"] = True
        assert is_payload_expired(payload, now=now) is True

    def test_naive_timestamp_treated_as_utc(self):
        now = datetime.now(UTC)
        old_naive = (now - timedelta(days=100)).replace(tzinfo=None)
        payload = self._payload_with_timestamp(old_naive.isoformat())
        assert is_payload_expired(payload, now=now) is True

    def test_get_diagnosis_sets_expired_flag(self, tmp_path):
        store = _store(tmp_path)
        old = _iso(datetime.now(UTC) - timedelta(days=100))
        execution = _execution(completed_on=old)
        store.save_diagnosis(
            auth=MagicMock(),
            execution=execution,
            bot_nodes=[],
            diagnosis=_diagnosis(),
        )
        result = store.get_diagnosis("exec-1")
        assert result is not None
        assert result["original_expired"] is True
        assert "90-day retention" in result["source_note"]

    def test_get_diagnosis_not_expired_source_note(self, tmp_path):
        store = _store(tmp_path)
        store.save_diagnosis(
            auth=MagicMock(),
            execution=_execution(),
            bot_nodes=[],
            diagnosis=_diagnosis(),
        )
        result = store.get_diagnosis("exec-1")
        assert result is not None
        assert result["original_expired"] is False
        assert "still in Control Room" in result["source_note"]


# --------------------------------------------------------------------------- #
# mark_expired
# --------------------------------------------------------------------------- #
class TestMarkExpired:
    def test_sets_force_expired(self, tmp_path):
        store = _store(tmp_path)
        store.save_diagnosis(
            auth=MagicMock(),
            execution=_execution(),
            bot_nodes=[],
            diagnosis=_diagnosis(),
        )
        assert store.mark_expired("exec-1") is True
        result = store.get_diagnosis("exec-1")
        assert result is not None
        assert result["original_expired"] is True

    def test_missing_snapshot_returns_false(self, tmp_path):
        store = _store(tmp_path)
        assert store.mark_expired("exec-nope") is False


# --------------------------------------------------------------------------- #
# list_snapshots
# --------------------------------------------------------------------------- #
class TestListSnapshots:
    def test_empty_when_no_dir(self, tmp_path):
        store = _store(tmp_path)
        assert store.list_snapshots() == []

    def test_lists_multiple(self, tmp_path):
        store = _store(tmp_path)
        for eid in ("exec-3", "exec-1", "exec-2"):
            store.save_diagnosis(
                auth=MagicMock(),
                execution=_execution(id=eid),
                bot_nodes=[],
                diagnosis=_diagnosis(),
            )
        items = store.list_snapshots()
        ids = {item["execution_id"] for item in items}
        assert ids == {"exec-1", "exec-2", "exec-3"}
        for item in items:
            assert "path" in item
            assert "saved_at" in item
            assert "expired" in item

    def test_list_marks_expired(self, tmp_path):
        store = _store(tmp_path)
        old = _iso(datetime.now(UTC) - timedelta(days=100))
        store.save_diagnosis(
            auth=MagicMock(),
            execution=_execution(completed_on=old),
            bot_nodes=[],
            diagnosis=_diagnosis(),
        )
        items = store.list_snapshots()
        assert len(items) == 1
        assert items[0]["expired"] is True


# --------------------------------------------------------------------------- #
# Protocol satisfaction
# --------------------------------------------------------------------------- #
class TestProtocol:
    def test_satisfies_snapshot_store_protocol(self, tmp_path):
        store = _store(tmp_path)
        # Structural check: both Protocol methods exist and are callable.
        assert callable(store.get_diagnosis)
        assert callable(store.save_diagnosis)


# --------------------------------------------------------------------------- #
# Account isolation
# --------------------------------------------------------------------------- #
class TestAccountIsolation:
    def test_two_accounts_no_cross_read(self, tmp_path):
        s1 = _store(tmp_path, user="alice")
        s2 = _store(tmp_path, user="bob")
        s1.save_diagnosis(
            auth=MagicMock(),
            execution=_execution(),
            bot_nodes=[],
            diagnosis=_diagnosis(),
        )
        # bob cannot see alice's snapshot
        assert s2.get_diagnosis("exec-1") is None
        assert s1.get_diagnosis("exec-1") is not None
