"""Tests for the llmdebug MCP server tools and snapshot loader."""

from __future__ import annotations

import json
import time
from pathlib import Path

import pytest

from llmdebug._snapshot_loader import (
    find_all_snapshots,
    load_snapshot,
    load_snapshot_from_file,
    load_snapshot_from_file_raw,
    load_snapshot_raw,
)
from llmdebug.compact_keys import compact_keys
from llmdebug.config import SnapshotConfig
from llmdebug.mcp_server import (
    llmdebug_diagnose,
    llmdebug_diff,
    llmdebug_frame,
    llmdebug_git_context,
    llmdebug_list,
    llmdebug_rca_advance,
    llmdebug_rca_history,
    llmdebug_rca_status,
    llmdebug_show,
)
from llmdebug.output import write_bundle


@pytest.fixture
def sample_snapshot() -> dict:
    """A representative expanded snapshot dict."""
    return {
        "schema_version": "1.0",
        "name": "test_example",
        "timestamp_utc": "2026-02-07T21:00:00Z",
        "llmdebug_version": "2.0.0",
        "exception": {
            "type": "ValueError",
            "qualified_type": "builtins.ValueError",
            "message": "invalid literal for int(): 'abc'",
            "error_category": {"category": "value_error", "suggestion": "Check input types"},
        },
        "traceback": (
            "Traceback (most recent call last):\n"
            '  File "app.py", line 42\n'
            "    return int(value)\n"
            "ValueError: invalid literal"
        ),
        "frames": [
            {
                "file": "/code/app.py",
                "file_rel": "app.py",
                "line": 42,
                "function": "parse_input",
                "code": "    return int(value)",
                "source": {
                    "start": 40,
                    "end": 44,
                    "snippet": [
                        {"lineno": 40, "code": "def parse_input(value):"},
                        {"lineno": 41, "code": '    """Parse user input."""'},
                        {"lineno": 42, "code": "    return int(value)"},
                        {"lineno": 43, "code": ""},
                    ],
                },
                "locals": {"value": "'abc'", "self": "<App>"},
                "locals_meta": {
                    "value": {"type": "builtins.str", "len": 3},
                    "self": {"type": "app.App"},
                },
            },
            {
                "file": "/code/main.py",
                "file_rel": "main.py",
                "line": 10,
                "function": "run",
                "code": "    result = parse_input(data)",
                "source": {
                    "start": 8,
                    "end": 12,
                    "snippet": [
                        {"lineno": 8, "code": "def run():"},
                        {"lineno": 9, "code": "    data = get_input()"},
                        {"lineno": 10, "code": "    result = parse_input(data)"},
                        {"lineno": 11, "code": "    return result"},
                    ],
                },
                "locals": {"data": "'abc'"},
                "locals_meta": {"data": {"type": "builtins.str", "len": 3}},
            },
        ],
        "crash_frame_index": 0,
        "env": {"python": "3.13.0", "platform": "Linux", "cwd": "/code"},
        "git": {"commit": "abc1234", "branch": "main", "dirty": True},
        "pytest": {
            "nodeid": "tests/test_app.py::test_parse",
            "repro": ["python", "-m", "pytest", "tests/test_app.py::test_parse", "-q"],
        },
    }


@pytest.fixture
def snapshot_dir(tmp_path: Path, sample_snapshot: dict) -> Path:
    """Temp dir with 3 sample snapshots + latest.json."""
    cfg = SnapshotConfig(out_dir=str(tmp_path), output_format="json", max_snapshots=50)
    for i in range(3):
        payload = {**sample_snapshot, "name": f"test_{i}"}
        write_bundle(payload, cfg)
        time.sleep(0.05)
    return tmp_path


@pytest.fixture
def snapshot_dir_str(snapshot_dir: Path) -> str:
    return str(snapshot_dir)


# ---------------------------------------------------------------------------
# _snapshot_loader tests
# ---------------------------------------------------------------------------


class TestSnapshotLoader:
    def test_load_latest(self, snapshot_dir: Path) -> None:
        result = load_snapshot(None, str(snapshot_dir))
        assert result["exception"]["type"] == "ValueError"

    def test_load_from_path(self, snapshot_dir: Path) -> None:
        files = sorted(
            (
                p
                for p in snapshot_dir.iterdir()
                if p.suffix == ".json" and not p.name.startswith("latest")
            ),
            key=lambda p: p.stat().st_mtime,
        )
        result = load_snapshot(str(files[0]), str(snapshot_dir))
        assert result["exception"]["type"] == "ValueError"

    def test_load_compact_auto_expands(self, tmp_path: Path) -> None:
        snapshot = {
            "schema_version": "1.0",
            "name": "compact_test",
            "exception": {"type": "E"},
            "frames": [],
        }
        compacted = compact_keys(snapshot)
        assert isinstance(compacted, dict)
        compacted["__llmdebug_compact__"] = 1
        path = tmp_path / "compact.json"
        path.write_text(json.dumps(compacted), encoding="utf-8")

        loaded = load_snapshot_from_file(path)
        assert "schema_version" in loaded
        assert "__llmdebug_compact__" not in loaded

    def test_load_raw_latest(self, tmp_path: Path) -> None:
        cfg = SnapshotConfig(out_dir=str(tmp_path), output_format="json")
        payload = {
            "schema_version": "2.0",
            "kind": "llmdebug.debug_session",
            "session": {
                "name": "raw_latest",
                "timestamp_utc": "2026-02-10T14:00:00Z",
                "llmdebug_version": "2.1.0",
            },
            "snapshot": {
                "exception": {"type": "RuntimeError", "message": "boom"},
                "frames": [],
                "traceback": "tb",
                "capture_config": {"frames": 5},
            },
        }
        write_bundle(payload, cfg)
        loaded = load_snapshot_raw(None, str(tmp_path))
        assert loaded["kind"] == "llmdebug.debug_session"
        assert loaded["session"]["name"] == "raw_latest"

    def test_load_raw_from_path(self, tmp_path: Path) -> None:
        payload = {
            "schema_version": "2.0",
            "kind": "llmdebug.debug_session",
            "session": {
                "name": "raw_path",
                "timestamp_utc": "2026-02-10T14:05:00Z",
                "llmdebug_version": "2.1.0",
            },
            "snapshot": {"exception": {"type": "RuntimeError"}, "frames": []},
        }
        path = tmp_path / "raw_path.json"
        path.write_text(json.dumps(payload), encoding="utf-8")
        loaded = load_snapshot_raw(str(path), str(tmp_path))
        assert loaded["kind"] == "llmdebug.debug_session"
        assert loaded["session"]["name"] == "raw_path"

    def test_load_raw_path_validation(self, tmp_path: Path) -> None:
        out_dir = tmp_path / "allowed"
        out_dir.mkdir()
        outside = tmp_path / "outside.json"
        outside.write_text(json.dumps({"name": "x"}), encoding="utf-8")
        with pytest.raises(ValueError, match="outside snapshot directory"):
            load_snapshot_raw(str(outside), str(out_dir))

    def test_load_snapshot_from_file_raw_expands_compact(self, tmp_path: Path) -> None:
        snapshot = {
            "schema_version": "1.0",
            "name": "compact_raw",
            "exception": {"type": "E"},
            "frames": [],
        }
        compacted = compact_keys(snapshot)
        assert isinstance(compacted, dict)
        compacted["__llmdebug_compact__"] = 1
        path = tmp_path / "compact_raw.json"
        path.write_text(json.dumps(compacted), encoding="utf-8")
        loaded = load_snapshot_from_file_raw(path)
        assert "schema_version" in loaded
        assert "__llmdebug_compact__" not in loaded

    def test_load_debug_session_normalizes(self, tmp_path: Path) -> None:
        session_payload = {
            "schema_version": "2.0",
            "kind": "llmdebug.debug_session",
            "session": {
                "name": "debug_session_test",
                "timestamp_utc": "2026-02-10T12:00:00Z",
                "llmdebug_version": "2.1.0",
            },
            "snapshot": {
                "exception": {"type": "RuntimeError", "message": "boom"},
                "frames": [],
                "traceback": "tb",
                "capture_config": {"frames": 5},
            },
            "context": {"hook": "sys.excepthook"},
        }
        path = tmp_path / "debug_session.json"
        path.write_text(json.dumps(session_payload), encoding="utf-8")

        loaded = load_snapshot_from_file(path)
        assert loaded["schema_version"] == "2.0"
        assert loaded["name"] == "debug_session_test"
        assert loaded["exception"]["type"] == "RuntimeError"
        assert loaded["hook"] == "sys.excepthook"

    def test_file_not_found_raises(self, tmp_path: Path) -> None:
        with pytest.raises(FileNotFoundError):
            load_snapshot_from_file(tmp_path / "nonexistent.json")

    def test_load_no_snapshots_raises(self, tmp_path: Path) -> None:
        with pytest.raises(FileNotFoundError, match="No snapshots found"):
            load_snapshot(None, str(tmp_path))

    def test_find_all_empty(self, tmp_path: Path) -> None:
        result = find_all_snapshots(str(tmp_path))
        assert result == []

    def test_find_all_nonexistent_dir(self) -> None:
        result = find_all_snapshots("/nonexistent/path")
        assert result == []

    def test_find_all_returns_sorted(self, snapshot_dir: Path) -> None:
        entries = find_all_snapshots(str(snapshot_dir))
        assert len(entries) == 3
        for i in range(len(entries) - 1):
            assert entries[i]["mtime"] >= entries[i + 1]["mtime"]


# ---------------------------------------------------------------------------
# llmdebug_diagnose tests
# ---------------------------------------------------------------------------


class TestDiagnose:
    def test_returns_exception_info(self, snapshot_dir_str: str) -> None:
        result = llmdebug_diagnose(out_dir=snapshot_dir_str)
        assert "ValueError" in result
        assert "invalid literal" in result

    def test_includes_crash_frame(self, snapshot_dir_str: str) -> None:
        result = llmdebug_diagnose(out_dir=snapshot_dir_str)
        assert "parse_input" in result
        assert "app.py" in result

    def test_includes_source_snippet(self, snapshot_dir_str: str) -> None:
        result = llmdebug_diagnose(out_dir=snapshot_dir_str)
        assert "return int(value)" in result

    def test_includes_locals(self, snapshot_dir_str: str) -> None:
        result = llmdebug_diagnose(out_dir=snapshot_dir_str)
        assert "value" in result
        assert "'abc'" in result

    def test_includes_repro_command(self, snapshot_dir_str: str) -> None:
        result = llmdebug_diagnose(out_dir=snapshot_dir_str)
        assert "pytest" in result
        assert "test_app.py" in result

    def test_includes_error_category(self, snapshot_dir_str: str) -> None:
        result = llmdebug_diagnose(out_dir=snapshot_dir_str)
        assert "value_error" in result
        assert "Check input types" in result

    def test_includes_git(self, snapshot_dir_str: str) -> None:
        result = llmdebug_diagnose(out_dir=snapshot_dir_str)
        assert "abc1234" in result
        assert "main" in result
        assert "*dirty*" in result

    def test_no_snapshots_returns_message(self, tmp_path: Path) -> None:
        result = llmdebug_diagnose(out_dir=str(tmp_path))
        assert "No snapshots found" in result
        assert "llmdebug installed" in result

    def test_includes_rca_block(self, snapshot_dir_str: str) -> None:
        result = llmdebug_diagnose(out_dir=snapshot_dir_str)
        assert "## RCA" in result
        assert "failure_signature" in result

    def test_handles_invalid_crash_frame_index(self, tmp_path: Path, sample_snapshot: dict) -> None:
        malformed = {**sample_snapshot, "crash_frame_index": "abc"}
        snapshot_path = tmp_path / "bad.json"
        snapshot_path.write_text(json.dumps(malformed), encoding="utf-8")
        (tmp_path / "latest.json").write_text(json.dumps(malformed), encoding="utf-8")

        result = llmdebug_diagnose(out_dir=str(tmp_path))
        assert "parse_input" in result
        assert "## RCA" in result


# ---------------------------------------------------------------------------
# llmdebug_show tests
# ---------------------------------------------------------------------------


class TestShow:
    def test_returns_valid_json(self, snapshot_dir_str: str) -> None:
        result = llmdebug_show(out_dir=snapshot_dir_str)
        data = json.loads(result)
        assert data["exception"]["type"] == "ValueError"

    def test_show_specific_path(self, snapshot_dir: Path) -> None:
        files = [
            p
            for p in snapshot_dir.iterdir()
            if p.suffix == ".json" and not p.name.startswith("latest")
        ]
        result = llmdebug_show(
            out_dir=str(snapshot_dir),
            snapshot_path=str(files[0]),
        )
        data = json.loads(result)
        assert "exception" in data

    def test_raw_session_returns_envelope(self, tmp_path: Path) -> None:
        cfg = SnapshotConfig(out_dir=str(tmp_path), output_format="json")
        payload = {
            "schema_version": "2.0",
            "kind": "llmdebug.debug_session",
            "session": {
                "name": "mcp_raw_test",
                "timestamp_utc": "2026-02-10T15:00:00Z",
                "llmdebug_version": "2.1.0",
            },
            "snapshot": {
                "exception": {"type": "ValueError", "message": "boom"},
                "frames": [],
                "traceback": "tb",
                "capture_config": {"frames": 5},
            },
            "context": {"hook": "sys.excepthook"},
        }
        write_bundle(payload, cfg)

        result = llmdebug_show(out_dir=str(tmp_path), raw_session=True)
        data = json.loads(result)
        assert data["kind"] == "llmdebug.debug_session"
        assert data["session"]["name"] == "mcp_raw_test"
        assert data["snapshot"]["exception"]["type"] == "ValueError"

    def test_not_found(self, tmp_path: Path) -> None:
        result = llmdebug_show(out_dir=str(tmp_path))
        assert "No snapshots found" in result

    def test_show_with_rca_envelope(self, snapshot_dir_str: str) -> None:
        result = llmdebug_show(out_dir=snapshot_dir_str, with_rca=True)
        data = json.loads(result)
        assert "snapshot" in data
        assert "rca" in data


# ---------------------------------------------------------------------------
# llmdebug_list tests
# ---------------------------------------------------------------------------


class TestList:
    def test_returns_entries(self, snapshot_dir_str: str) -> None:
        result = llmdebug_list(out_dir=snapshot_dir_str)
        assert "test_" in result
        assert "ValueError" in result

    def test_empty_dir(self, tmp_path: Path) -> None:
        result = llmdebug_list(out_dir=str(tmp_path))
        assert "No snapshots found" in result

    def test_respects_limit(self, snapshot_dir_str: str) -> None:
        result = llmdebug_list(out_dir=snapshot_dir_str, limit=1)
        assert "1 snapshot(s)" in result

    def test_includes_path(self, snapshot_dir_str: str) -> None:
        result = llmdebug_list(out_dir=snapshot_dir_str)
        assert "path:" in result


# ---------------------------------------------------------------------------
# llmdebug_frame tests
# ---------------------------------------------------------------------------


class TestFrame:
    def test_returns_crash_frame(self, snapshot_dir_str: str) -> None:
        result = llmdebug_frame(index=0, out_dir=snapshot_dir_str)
        assert "parse_input" in result
        assert "[CRASH]" in result

    def test_returns_caller_frame(self, snapshot_dir_str: str) -> None:
        result = llmdebug_frame(index=1, out_dir=snapshot_dir_str)
        assert "run" in result
        assert "[CRASH]" not in result

    def test_out_of_range(self, snapshot_dir_str: str) -> None:
        result = llmdebug_frame(index=99, out_dir=snapshot_dir_str)
        assert "out of range" in result

    def test_includes_locals(self, snapshot_dir_str: str) -> None:
        result = llmdebug_frame(index=0, out_dir=snapshot_dir_str)
        assert "value" in result
        assert "'abc'" in result

    def test_includes_source(self, snapshot_dir_str: str) -> None:
        result = llmdebug_frame(index=0, out_dir=snapshot_dir_str)
        assert "return int(value)" in result

    def test_no_snapshots(self, tmp_path: Path) -> None:
        result = llmdebug_frame(index=0, out_dir=str(tmp_path))
        assert "No snapshots found" in result

    def test_malformed_frame_entry(self, tmp_path: Path, sample_snapshot: dict) -> None:
        malformed = {**sample_snapshot, "frames": [sample_snapshot["frames"][0], "bad-frame"]}
        snapshot_path = tmp_path / "bad.json"
        snapshot_path.write_text(json.dumps(malformed), encoding="utf-8")
        (tmp_path / "latest.json").write_text(json.dumps(malformed), encoding="utf-8")

        result = llmdebug_frame(index=1, out_dir=str(tmp_path))
        assert "malformed" in result.lower()


class TestGitContext:
    def test_returns_enhanced_context(self, snapshot_dir_str: str, monkeypatch) -> None:
        import llmdebug.mcp_server as server

        monkeypatch.setattr(
            server,
            "get_enhanced_git_context",
            lambda *_args, **_kwargs: {
                "head": {
                    "commit": "abc1234",
                    "branch": "main",
                    "dirty": False,
                    "detached": False,
                },
                "crash_file": {
                    "file_rel": "app.py",
                    "line": 42,
                    "in_repo": True,
                    "blame": {
                        "commit": "def5678",
                        "author": "Alice",
                        "author_time": "2026-02-10T10:00:00Z",
                        "summary": "Fix parser",
                    },
                },
                "recent_commits": [
                    {
                        "commit": "abc1234",
                        "author": "Alice",
                        "author_time": "2026-02-10T10:00:00+00:00",
                        "summary": "Fix parser",
                        "files_changed": 1,
                        "insertions": 2,
                        "deletions": 1,
                    }
                ],
            },
        )
        result = llmdebug_git_context(out_dir=snapshot_dir_str, max_commits=3)
        assert "## Git Head" in result
        assert "abc1234" in result
        assert "## RCA" in result

    def test_unavailable_returns_message(self, snapshot_dir_str: str, monkeypatch) -> None:
        import llmdebug.mcp_server as server

        monkeypatch.setattr(server, "get_enhanced_git_context", lambda *_args, **_kwargs: None)
        result = llmdebug_git_context(out_dir=snapshot_dir_str)
        assert "unavailable" in result.lower()
        assert "## RCA" in result

    def test_no_snapshots(self, tmp_path: Path) -> None:
        result = llmdebug_git_context(out_dir=str(tmp_path))
        assert "No snapshots found" in result

    def test_invalid_max_commits(self, snapshot_dir_str: str) -> None:
        result = llmdebug_git_context(
            out_dir=snapshot_dir_str,
            max_commits="abc",  # type: ignore[arg-type]
        )
        assert "Invalid max_commits value" in result


# ---------------------------------------------------------------------------
# llmdebug_diff tests
# ---------------------------------------------------------------------------


@pytest.fixture
def diff_snapshot_dir(tmp_path: Path) -> Path:
    """Dir with two *different* snapshots for diff testing."""
    snap_old = {
        "schema_version": "1.0",
        "name": "test_old",
        "timestamp_utc": "2026-02-08T10:00:00Z",
        "llmdebug_version": "2.0.0",
        "exception": {
            "type": "ValueError",
            "message": "invalid literal for int(): 'abc'",
            "error_category": {"category": "value_error"},
        },
        "frames": [
            {
                "file": "/code/app.py",
                "file_rel": "app.py",
                "line": 42,
                "function": "parse_input",
                "locals": {"value": "'abc'"},
                "locals_meta": {"value": {"type": "str"}},
            }
        ],
        "crash_frame_index": 0,
        "git": {"commit": "aaa1111", "branch": "main"},
        "pytest": {"nodeid": "tests/test_app.py::test_parse"},
    }
    snap_new = {
        "schema_version": "1.0",
        "name": "test_new",
        "timestamp_utc": "2026-02-08T10:05:00Z",
        "llmdebug_version": "2.0.0",
        "exception": {
            "type": "TypeError",
            "message": "unsupported operand type(s)",
            "error_category": {"category": "type_error"},
        },
        "frames": [
            {
                "file": "/code/app.py",
                "file_rel": "app.py",
                "line": 38,
                "function": "transform_data",
                "locals": {"value": 42},
                "locals_meta": {"value": {"type": "int"}},
            }
        ],
        "crash_frame_index": 0,
        "git": {"commit": "bbb2222", "branch": "main"},
        "pytest": {"nodeid": "tests/test_app.py::test_parse"},
    }
    old_path = tmp_path / "snap_old.json"
    old_path.write_text(json.dumps(snap_old), encoding="utf-8")
    time.sleep(0.05)
    new_path = tmp_path / "snap_new.json"
    new_path.write_text(json.dumps(snap_new), encoding="utf-8")
    (tmp_path / "latest.json").write_text(json.dumps(snap_new), encoding="utf-8")
    return tmp_path


class TestDiff:
    def test_diff_default(self, diff_snapshot_dir: Path) -> None:
        result = llmdebug_diff(out_dir=str(diff_snapshot_dir))
        assert "CHANGED" in result
        assert "ValueError" in result
        assert "TypeError" in result
        assert "## Structured Diff" in result
        assert "## RCA" in result

    def test_diff_by_path(self, diff_snapshot_dir: Path) -> None:
        old = str(diff_snapshot_dir / "snap_old.json")
        new = str(diff_snapshot_dir / "snap_new.json")
        result = llmdebug_diff(old_ref=old, new_ref=new, out_dir=str(diff_snapshot_dir))
        assert "CHANGED" in result
        assert "MOVED" in result

    def test_diff_not_found(self, tmp_path: Path) -> None:
        result = llmdebug_diff(out_dir=str(tmp_path))
        # Should return error message, not raise
        assert "snapshot" in result.lower() or "found" in result.lower()

    def test_diff_one_snapshot_error(self, tmp_path: Path) -> None:
        snap = {
            "schema_version": "1.0",
            "name": "only",
            "exception": {"type": "E", "message": ""},
            "frames": [],
            "crash_frame_index": 0,
        }
        (tmp_path / "only.json").write_text(json.dumps(snap), encoding="utf-8")
        (tmp_path / "latest.json").write_text(json.dumps(snap), encoding="utf-8")
        result = llmdebug_diff(out_dir=str(tmp_path))
        assert "at least 2" in result


class TestRCAStateTools:
    def test_rca_status_and_history(self, snapshot_dir_str: str) -> None:
        _ = llmdebug_diagnose(out_dir=snapshot_dir_str)
        status_raw = llmdebug_rca_status(out_dir=snapshot_dir_str)
        status = json.loads(status_raw)
        assert "state" in status
        assert "attempt_id" in status

        history_raw = llmdebug_rca_history(out_dir=snapshot_dir_str, limit=5)
        history = json.loads(history_raw)
        assert isinstance(history, list)
        assert len(history) >= 1
        assert "state" in history[0]

    def test_rca_advance_manual(self, snapshot_dir_str: str) -> None:
        output = llmdebug_rca_advance(
            action="hypothesize",
            out_dir=snapshot_dir_str,
            evidence_refs="crash_frame:app.py:42:parse_input",
            hypothesis="Input is not numeric",
            experiment_plan="Guard with str.isdigit() before int conversion",
            gate_mode="soft",
        )
        data = json.loads(output)
        assert data["state"] in {"hypothesized", "stalled"}
        assert data["hypothesis"]
