"""Tests for snapshot diffing logic and resolve_snapshot_ref."""

from __future__ import annotations

import json
import time
from pathlib import Path
from typing import Any

import pytest

from llmdebug._snapshot_loader import resolve_snapshot_ref
from llmdebug.snapshot_diff import (
    diff_exception,
    diff_locals,
    diff_location,
    diff_snapshots,
    format_diff_text,
    structured_diff_summary,
)

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


@pytest.fixture
def snapshot_a() -> dict[str, Any]:
    """Base snapshot — ValueError at app.py:42 in parse_input."""
    return {
        "schema_version": "1.0",
        "name": "test_parse_a",
        "timestamp_utc": "2026-02-08T10: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"},
        },
        "frames": [
            {
                "file": "/code/app.py",
                "file_rel": "app.py",
                "line": 42,
                "function": "parse_input",
                "locals": {
                    "value": "'abc'",
                    "x": 10,
                    "old_var": "hello",
                    "@py_builtins": "<module>",
                },
                "locals_meta": {
                    "value": {"type": "builtins.str"},
                    "x": {"type": "builtins.int"},
                    "old_var": {"type": "builtins.str"},
                },
            },
        ],
        "crash_frame_index": 0,
        "git": {"commit": "abc1234", "branch": "main", "dirty": False},
        "pytest": {"nodeid": "tests/test_app.py::test_parse"},
    }


@pytest.fixture
def snapshot_b() -> dict[str, Any]:
    """Modified snapshot — TypeError at app.py:38 in transform_data, changed locals."""
    return {
        "schema_version": "1.0",
        "name": "test_parse_b",
        "timestamp_utc": "2026-02-08T10:05:00Z",
        "llmdebug_version": "2.0.0",
        "exception": {
            "type": "TypeError",
            "qualified_type": "builtins.TypeError",
            "message": "unsupported operand type(s) for +: 'int' and 'str'",
            "error_category": {"category": "type_error", "suggestion": "Check operand types"},
        },
        "frames": [
            {
                "file": "/code/app.py",
                "file_rel": "app.py",
                "line": 38,
                "function": "transform_data",
                "locals": {
                    "value": 42,
                    "x": 10,
                    "new_var": [1, 2, 3],
                    "@py_assert0": True,
                },
                "locals_meta": {
                    "value": {"type": "builtins.int"},
                    "x": {"type": "builtins.int"},
                    "new_var": {"type": "builtins.list"},
                },
            },
        ],
        "crash_frame_index": 0,
        "git": {"commit": "def5678", "branch": "main", "dirty": True},
        "pytest": {"nodeid": "tests/test_app.py::test_parse"},
    }


def _write_snapshot(directory: Path, data: dict[str, Any], name: str) -> Path:
    """Write a snapshot JSON file to a directory."""
    path = directory / f"{name}.json"
    path.write_text(json.dumps(data, default=str), encoding="utf-8")
    return path


def _as_debug_session(snapshot: dict[str, Any]) -> dict[str, Any]:
    """Wrap a legacy snapshot as DebugSession for compatibility checks."""
    return {
        "schema_version": "2.0",
        "kind": "llmdebug.debug_session",
        "session": {
            "name": snapshot.get("name", "?"),
            "timestamp_utc": snapshot.get("timestamp_utc", "2026-02-08T10:00:00Z"),
            "llmdebug_version": snapshot.get("llmdebug_version", "2.0.0"),
        },
        "snapshot": {
            "exception": snapshot.get("exception", {}),
            "traceback": snapshot.get("traceback", ""),
            "frames": snapshot.get("frames", []),
            "capture_config": snapshot.get("capture_config", {}),
            "crash_frame_index": snapshot.get("crash_frame_index", 0),
        },
        "context": {
            "git": snapshot.get("git", {}),
            "pytest": snapshot.get("pytest", {}),
        },
    }


@pytest.fixture
def two_snapshot_dir(tmp_path: Path, snapshot_a: dict, snapshot_b: dict) -> Path:
    """Dir with two different snapshots; snapshot_a is older, snapshot_b is newer."""
    _write_snapshot(tmp_path, snapshot_a, "snap_old")
    time.sleep(0.05)
    _write_snapshot(tmp_path, snapshot_b, "snap_new")
    # Also write latest.json pointing to the newer one
    latest_path = tmp_path / "latest.json"
    latest_path.write_text(json.dumps(snapshot_b, default=str), encoding="utf-8")
    return tmp_path


# ---------------------------------------------------------------------------
# TestResolveSnapshotRef
# ---------------------------------------------------------------------------


class TestResolveSnapshotRef:
    def test_latest(self, two_snapshot_dir: Path) -> None:
        result = resolve_snapshot_ref("latest", str(two_snapshot_dir))
        assert result["name"] == "test_parse_b"

    def test_latest_none(self, two_snapshot_dir: Path) -> None:
        result = resolve_snapshot_ref(None, str(two_snapshot_dir))
        assert result["name"] == "test_parse_b"

    def test_previous(self, two_snapshot_dir: Path) -> None:
        result = resolve_snapshot_ref("previous", str(two_snapshot_dir))
        assert result["name"] == "test_parse_a"

    def test_index_ref_1(self, two_snapshot_dir: Path) -> None:
        result = resolve_snapshot_ref("#1", str(two_snapshot_dir))
        # #1 = newest (find_all_snapshots is newest-first)
        assert result["name"] == "test_parse_b"

    def test_index_ref_2(self, two_snapshot_dir: Path) -> None:
        result = resolve_snapshot_ref("#2", str(two_snapshot_dir))
        assert result["name"] == "test_parse_a"

    def test_file_path(self, two_snapshot_dir: Path) -> None:
        path = two_snapshot_dir / "snap_old.json"
        result = resolve_snapshot_ref(str(path), str(two_snapshot_dir))
        assert result["name"] == "test_parse_a"

    def test_previous_with_one_snapshot(self, tmp_path: Path, snapshot_a: dict) -> None:
        _write_snapshot(tmp_path, snapshot_a, "only_one")
        with pytest.raises(FileNotFoundError, match="at least 2 snapshots"):
            resolve_snapshot_ref("previous", str(tmp_path))

    def test_index_out_of_range(self, two_snapshot_dir: Path) -> None:
        with pytest.raises(ValueError, match="Snapshot #99 not found"):
            resolve_snapshot_ref("#99", str(two_snapshot_dir))

    def test_invalid_index_format(self, two_snapshot_dir: Path) -> None:
        with pytest.raises(ValueError, match="Invalid snapshot reference"):
            resolve_snapshot_ref("#abc", str(two_snapshot_dir))

    def test_nonexistent_file_path(self, tmp_path: Path) -> None:
        with pytest.raises(FileNotFoundError):
            resolve_snapshot_ref(str(tmp_path / "nope.json"), str(tmp_path))


# ---------------------------------------------------------------------------
# TestDiffException
# ---------------------------------------------------------------------------


class TestDiffException:
    def test_same_exception(self) -> None:
        exc = {
            "type": "ValueError",
            "message": "bad",
            "error_category": {"category": "value_error"},
        }
        result = diff_exception(exc, exc)
        assert not result.changed

    def test_different_type(self) -> None:
        old = {"type": "ValueError", "message": "bad"}
        new = {"type": "TypeError", "message": "bad"}
        result = diff_exception(old, new)
        assert result.changed
        assert result.old_type == "ValueError"
        assert result.new_type == "TypeError"

    def test_different_message(self) -> None:
        old = {"type": "ValueError", "message": "bad"}
        new = {"type": "ValueError", "message": "worse"}
        result = diff_exception(old, new)
        assert result.changed
        assert result.old_message == "bad"
        assert result.new_message == "worse"

    def test_different_category(self) -> None:
        old = {"type": "E", "message": "", "error_category": {"category": "a"}}
        new = {"type": "E", "message": "", "error_category": {"category": "b"}}
        result = diff_exception(old, new)
        assert result.changed
        assert result.old_category == "a"
        assert result.new_category == "b"

    def test_empty_dicts(self) -> None:
        result = diff_exception({}, {})
        assert not result.changed


# ---------------------------------------------------------------------------
# TestDiffLocation
# ---------------------------------------------------------------------------


class TestDiffLocation:
    def test_same_location(self) -> None:
        frame = {"file_rel": "app.py", "line": 42, "function": "f"}
        result = diff_location([frame], [frame], 0, 0)
        assert not result.changed
        assert result.old_location == "app.py:42 in f"

    def test_different_file(self) -> None:
        old = [{"file_rel": "a.py", "line": 1, "function": "f"}]
        new = [{"file_rel": "b.py", "line": 1, "function": "f"}]
        result = diff_location(old, new, 0, 0)
        assert result.changed

    def test_different_line(self) -> None:
        old = [{"file_rel": "a.py", "line": 1, "function": "f"}]
        new = [{"file_rel": "a.py", "line": 2, "function": "f"}]
        result = diff_location(old, new, 0, 0)
        assert result.changed

    def test_different_function(self) -> None:
        old = [{"file_rel": "a.py", "line": 1, "function": "f"}]
        new = [{"file_rel": "a.py", "line": 1, "function": "g"}]
        result = diff_location(old, new, 0, 0)
        assert result.changed

    def test_no_frames(self) -> None:
        result = diff_location([], [], 0, 0)
        assert not result.changed
        assert result.old_location == "?"


# ---------------------------------------------------------------------------
# TestDiffLocals
# ---------------------------------------------------------------------------


class TestDiffLocals:
    def test_identical_locals(self) -> None:
        frame = {"locals": {"x": 1, "y": 2}, "locals_meta": {}}
        result = diff_locals(frame, frame)
        assert len(result.changes) == 0
        assert result.unchanged_count == 2

    def test_modified_value(self) -> None:
        old = {"locals": {"x": 1}, "locals_meta": {"x": {"type": "int"}}}
        new = {"locals": {"x": 2}, "locals_meta": {"x": {"type": "int"}}}
        result = diff_locals(old, new)
        assert len(result.changes) == 1
        assert result.changes[0].kind == "modified"
        assert result.changes[0].name == "x"
        assert result.changes[0].old_value == 1
        assert result.changes[0].new_value == 2

    def test_added_variable(self) -> None:
        old = {"locals": {}, "locals_meta": {}}
        new = {"locals": {"y": 42}, "locals_meta": {"y": {"type": "int"}}}
        result = diff_locals(old, new)
        assert len(result.changes) == 1
        assert result.changes[0].kind == "added"
        assert result.changes[0].name == "y"

    def test_removed_variable(self) -> None:
        old = {"locals": {"z": "gone"}, "locals_meta": {"z": {"type": "str"}}}
        new = {"locals": {}, "locals_meta": {}}
        result = diff_locals(old, new)
        assert len(result.changes) == 1
        assert result.changes[0].kind == "removed"
        assert result.changes[0].name == "z"

    def test_type_changed(self) -> None:
        old = {"locals": {"v": "'abc'"}, "locals_meta": {"v": {"type": "str"}}}
        new = {"locals": {"v": 42}, "locals_meta": {"v": {"type": "int"}}}
        result = diff_locals(old, new)
        assert len(result.changes) == 1
        assert result.changes[0].kind == "type_changed"

    def test_array_shape_changed(self) -> None:
        old_arr = {"__array__": True, "shape": [100, 50], "dtype": "float64"}
        new_arr = {"__array__": True, "shape": [100, 30], "dtype": "float64"}
        old = {"locals": {"matrix": old_arr}, "locals_meta": {}}
        new = {"locals": {"matrix": new_arr}, "locals_meta": {}}
        result = diff_locals(old, new)
        assert len(result.changes) == 1
        assert result.changes[0].kind == "shape_changed"
        assert result.changes[0].old_value == [100, 50]
        assert result.changes[0].new_value == [100, 30]

    def test_array_became_empty(self) -> None:
        old_arr = {"__array__": True, "shape": [100, 50], "dtype": "float64"}
        new_arr = {"__array__": True, "shape": [0, 50], "dtype": "float64"}
        old = {"locals": {"matrix": old_arr}, "locals_meta": {}}
        new = {"locals": {"matrix": new_arr}, "locals_meta": {}}
        result = diff_locals(old, new)
        assert len(result.changes) == 1
        assert result.changes[0].kind == "shape_changed"
        assert result.changes[0].new_value == [0, 50]

    def test_skips_pytest_internals(self) -> None:
        old = {"locals": {"@py_builtins": "x", "real": 1}, "locals_meta": {}}
        new = {"locals": {"@py_assert0": "y", "real": 1}, "locals_meta": {}}
        result = diff_locals(old, new)
        assert len(result.changes) == 0
        assert result.unchanged_count == 1

    def test_empty_frames(self) -> None:
        result = diff_locals({}, {})
        assert len(result.changes) == 0
        assert result.unchanged_count == 0


# ---------------------------------------------------------------------------
# TestDiffSnapshots
# ---------------------------------------------------------------------------


class TestDiffSnapshots:
    def test_identical_snapshots(self, snapshot_a: dict) -> None:
        result = diff_snapshots(snapshot_a, snapshot_a)
        assert not result.exception.changed
        assert not result.location.changed
        assert len(result.locals_diff.changes) == 0
        assert not result.git_changed
        assert result.same_test
        assert result.failure_signature_same

    def test_full_diff(self, snapshot_a: dict, snapshot_b: dict) -> None:
        result = diff_snapshots(snapshot_a, snapshot_b)
        assert result.exception.changed
        assert result.exception.old_type == "ValueError"
        assert result.exception.new_type == "TypeError"
        assert result.location.changed
        assert result.git_changed
        assert result.same_test  # Same nodeid

        # Locals: value modified, old_var removed, new_var added, x unchanged
        kinds = {c.name: c.kind for c in result.locals_diff.changes}
        assert "old_var" in kinds
        assert kinds["old_var"] == "removed"
        assert "new_var" in kinds
        assert kinds["new_var"] == "added"
        # value changed type (str -> int)
        assert kinds["value"] == "type_changed"
        # x stayed the same
        assert result.locals_diff.unchanged_count == 1
        assert not result.failure_signature_same
        assert result.evidence_delta.message_changed
        assert "app.py" in result.change_delta.touched_files

    def test_same_test_detection(self, snapshot_a: dict, snapshot_b: dict) -> None:
        # Same nodeid
        assert diff_snapshots(snapshot_a, snapshot_b).same_test
        # Different nodeid
        modified = {**snapshot_b, "pytest": {"nodeid": "other::test"}}
        assert not diff_snapshots(snapshot_a, modified).same_test

    def test_no_frames(self) -> None:
        old = {"exception": {"type": "E"}, "frames": []}
        new = {"exception": {"type": "E"}, "frames": []}
        result = diff_snapshots(old, new)
        assert not result.location.changed
        assert len(result.locals_diff.changes) == 0

    def test_accepts_debug_session_inputs(
        self, snapshot_a: dict[str, Any], snapshot_b: dict[str, Any]
    ) -> None:
        old = _as_debug_session(snapshot_a)
        new = _as_debug_session(snapshot_b)
        result = diff_snapshots(old, new)
        assert result.exception.changed
        assert result.old_name == "test_parse_a"
        assert result.new_name == "test_parse_b"

    def test_hypothesis_delta_classification(self, snapshot_a: dict, snapshot_b: dict) -> None:
        old = {**snapshot_a, "rca": {"hypothesis": "Input parsing fails."}}
        new = {**snapshot_b, "rca": {"hypothesis": "Input parsing fails. Guard int conversion."}}
        result = diff_snapshots(old, new)
        assert result.hypothesis_delta == "refined"

    def test_structured_summary_shape(self, snapshot_a: dict, snapshot_b: dict) -> None:
        result = diff_snapshots(snapshot_a, snapshot_b)
        structured = structured_diff_summary(result)
        assert "failure_signature_same" in structured
        assert "evidence_delta" in structured
        assert "change_delta" in structured
        assert "verification_delta" in structured


# ---------------------------------------------------------------------------
# TestFormatDiffText
# ---------------------------------------------------------------------------


class TestFormatDiffText:
    def test_identical_shows_no_changes(self, snapshot_a: dict) -> None:
        result = diff_snapshots(snapshot_a, snapshot_a)
        text = format_diff_text(result)
        assert text == "No changes detected. Snapshots are identical."

    def test_all_sections_present(self, snapshot_a: dict, snapshot_b: dict) -> None:
        result = diff_snapshots(snapshot_a, snapshot_b)
        text = format_diff_text(result)
        assert "## Snapshot Diff" in text
        assert "## Exception" in text
        assert "CHANGED" in text
        assert "## Crash Location" in text
        assert "MOVED" in text
        assert "## Locals (crash frame)" in text
        assert "## Git" in text
        assert "abc1234" in text
        assert "def5678" in text
        assert "## Structured Delta" in text

    def test_empty_shape_flagged(self) -> None:
        old_arr = {"__array__": True, "shape": [100, 50], "dtype": "float64"}
        new_arr = {"__array__": True, "shape": [0, 50], "dtype": "float64"}
        old = {
            "exception": {"type": "E", "message": ""},
            "frames": [
                {
                    "file_rel": "a.py",
                    "line": 1,
                    "function": "f",
                    "locals": {"m": old_arr},
                    "locals_meta": {},
                }
            ],
            "crash_frame_index": 0,
        }
        new = {
            "exception": {"type": "E", "message": ""},
            "frames": [
                {
                    "file_rel": "a.py",
                    "line": 1,
                    "function": "f",
                    "locals": {"m": new_arr},
                    "locals_meta": {},
                }
            ],
            "crash_frame_index": 0,
        }
        result = diff_snapshots(old, new)
        text = format_diff_text(result)
        assert "SHAPE CHANGED" in text
        assert "!! EMPTY" in text

    def test_unchanged_exception_section(self) -> None:
        snap = {
            "exception": {"type": "ValueError", "message": "bad"},
            "frames": [
                {
                    "file_rel": "a.py",
                    "line": 1,
                    "function": "f",
                    "locals": {"x": 1},
                    "locals_meta": {},
                }
            ],
            "crash_frame_index": 0,
            "git": {"commit": "aaa"},
        }
        snap2 = {
            "exception": {"type": "ValueError", "message": "bad"},
            "frames": [
                {
                    "file_rel": "a.py",
                    "line": 1,
                    "function": "f",
                    "locals": {"x": 2},
                    "locals_meta": {},
                }
            ],
            "crash_frame_index": 0,
            "git": {"commit": "aaa"},
        }
        result = diff_snapshots(snap, snap2)
        text = format_diff_text(result)
        assert "UNCHANGED: ValueError: bad" in text
        assert "MODIFIED: x" in text
