"""Tests for the llmdebug CLI."""

from __future__ import annotations

import json
import time
from pathlib import Path

import pytest
from click.testing import CliRunner

from llmdebug.cli import main
from llmdebug.compact_keys import compact_keys
from llmdebug.config import SnapshotConfig
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)

    # Write 3 snapshots with slight time gaps
    for i in range(3):
        payload = {**sample_snapshot, "name": f"test_{i}"}
        write_bundle(payload, cfg)
        time.sleep(0.05)  # Ensure different mtimes

    return tmp_path


# ---------------------------------------------------------------------------
# show command
# ---------------------------------------------------------------------------


class TestShow:
    def test_show_latest(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["show", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "ValueError" in result.output
        assert "invalid literal" in result.output

    def test_show_specific_path(self, snapshot_dir: Path) -> None:
        # Find a specific snapshot file
        files = sorted(snapshot_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
        files = [f for f in files if not f.name.startswith("latest")]
        assert len(files) >= 1

        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["show", str(files[0])])
        assert result.exit_code == 0
        assert "ValueError" in result.output

    def test_show_full_flag(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["show", "--full", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        # Both frames should appear
        assert "parse_input" in result.output
        assert "run" in result.output

    def test_show_json_flag(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["show", "--json", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        # Should be valid JSON
        data = json.loads(result.output)
        assert data["exception"]["type"] == "ValueError"

    def test_show_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": "raw_test",
                "timestamp_utc": "2026-02-10T13: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)

        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["show", "--raw-session", "--dir", str(tmp_path)])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["kind"] == "llmdebug.debug_session"
        assert data["session"]["name"] == "raw_test"
        assert data["snapshot"]["exception"]["type"] == "ValueError"

    def test_show_no_snapshot(self, tmp_path: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["show", "--dir", str(tmp_path)])
        assert result.exit_code == 1
        assert "No snapshots found" in result.stderr

    def test_show_default_invocation(self, snapshot_dir: Path) -> None:
        """Bare `llmdebug` (no subcommand) should equal `llmdebug show`."""
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "ValueError" in result.output

    def test_show_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")

        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["show", "--dir", str(tmp_path)])
        assert result.exit_code == 0
        assert "parse_input" in result.output

    def test_show_hides_frame_hint_in_non_tty(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["show", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "more frame" not in result.output

    def test_show_displays_frame_hint_in_tty(self, snapshot_dir: Path, monkeypatch) -> None:
        monkeypatch.setattr("llmdebug.cli.HAS_RICH", False)
        monkeypatch.setattr("llmdebug.cli._stdout_is_tty", lambda: True)
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["show", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "more frame" in result.output


# ---------------------------------------------------------------------------
# list command
# ---------------------------------------------------------------------------


class TestList:
    def test_list_snapshots(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["list", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        # Should show test_0, test_1, test_2 snapshot names
        assert "test_" in result.output

    def test_list_limit(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["list", "--limit", "2", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "2 shown" in result.output
        assert "UTC" in result.output

    def test_list_empty_dir(self, tmp_path: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["list", "--dir", str(tmp_path)])
        assert result.exit_code == 0
        assert "No snapshots found" in result.output

    def test_list_truncates_long_fields(self, monkeypatch: pytest.MonkeyPatch) -> None:
        now = time.time()

        def _fake_find_all_snapshots(_out_dir: str, *, limit: int = 10) -> list[dict[str, object]]:
            entries = [
                {
                    "mtime": now,
                    "name": "test_" + ("x" * 80),
                    "exc_type": "VeryLongExceptionTypeName" + ("y" * 40),
                    "file": "/a/very/long/path/for/snapshot/location/module_with_long_name.py",
                    "line": 1234,
                }
            ]
            return entries[:limit]

        monkeypatch.setattr("llmdebug.cli._find_all_snapshots_impl", _fake_find_all_snapshots)
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["list", "--dir", "/unused"])
        assert result.exit_code == 0
        rows = [line for line in result.output.splitlines() if line.strip().startswith("1.")]
        assert rows
        assert "..." in rows[0]

    def test_list_empty_dir_shows_guidance_in_tty(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setattr("llmdebug.cli._stdout_is_tty", lambda: True)
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["list", "--dir", str(tmp_path)])
        assert result.exit_code == 0
        assert "Run a failing test to create a snapshot" in result.output


# ---------------------------------------------------------------------------
# clean command
# ---------------------------------------------------------------------------


class TestClean:
    def test_clean_keep(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["clean", "--keep", "1", "--yes", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "Deleted" in result.output
        # Should have 1 snapshot left (+ latest symlink)
        remaining = [
            p
            for p in snapshot_dir.iterdir()
            if p.is_file()
            and p.suffix == ".json"
            and not p.name.startswith("latest")
            and not p.name.endswith(".tmp")
            and ".traceback" not in p.name
        ]
        assert len(remaining) == 1

    def test_clean_all(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["clean", "--all", "--yes", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "Deleted" in result.output
        # Directory may still exist but should have no snapshot files
        remaining = [
            p for p in snapshot_dir.iterdir() if p.is_file() and p.suffix in {".json", ".toon"}
        ]
        assert len(remaining) == 0

    def test_clean_confirmation(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(
            main, ["clean", "--keep", "1", "--dir", str(snapshot_dir)], input="y\n"
        )
        assert result.exit_code == 0
        assert "Deleted" in result.output

    def test_clean_rejects_negative_keep(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["clean", "--keep", "-1", "--yes", "--dir", str(snapshot_dir)])
        assert result.exit_code != 0
        assert "Invalid value for '--keep'" in result.stderr

    def test_clean_missing_directory_prints_error_to_stderr(self, tmp_path: Path) -> None:
        missing_dir = tmp_path / "missing"
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["clean", "--dir", str(missing_dir)])
        assert result.exit_code == 0
        assert "Directory not found" in result.stderr


# ---------------------------------------------------------------------------
# frames command
# ---------------------------------------------------------------------------


class TestFrames:
    def test_frames_all(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["frames", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "parse_input" in result.output
        assert "run" in result.output

    def test_frames_index(self, snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["frames", "--index", "0", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "parse_input" in result.output

    def test_frames_plain_sections_preserved(
        self, snapshot_dir: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setattr("llmdebug.cli.HAS_RICH", False)
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["frames", "--index", "0", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "Source:" in result.output
        assert "Locals:" in result.output

    def test_frames_rejects_malformed_frame(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")

        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["frames", "--index", "1", "--dir", str(tmp_path)])
        assert result.exit_code == 1
        assert "malformed" in result.stderr.lower()

    def test_frames_skips_malformed_frame_when_listing(
        self,
        tmp_path: Path,
        sample_snapshot: dict,
    ) -> None:
        malformed = {
            **sample_snapshot,
            "frames": [
                sample_snapshot["frames"][0],
                "bad-frame",
                sample_snapshot["frames"][1],
            ],
        }
        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")

        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["frames", "--dir", str(tmp_path)])
        assert result.exit_code == 0
        assert "parse_input" in result.output
        assert "run" in result.output


# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------


class TestHelpers:
    def test_load_snapshot_from_file_compact(self, tmp_path: Path) -> None:
        """Compact JSON should be auto-expanded when loaded."""
        from llmdebug.cli import _load_snapshot_from_file

        snapshot = {
            "schema_version": "1.0",
            "name": "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)
        # Keys should be expanded
        assert "schema_version" in loaded
        assert "name" in loaded
        assert "__llmdebug_compact__" not in loaded

    def test_find_all_snapshots_sorted(self, snapshot_dir: Path) -> None:
        """Snapshots should be sorted newest-first."""
        from llmdebug.cli import _find_all_snapshots

        entries = _find_all_snapshots(str(snapshot_dir))
        assert len(entries) == 3
        # Verify newest first
        for i in range(len(entries) - 1):
            assert entries[i]["mtime"] >= entries[i + 1]["mtime"]

    def test_format_timestamp(self) -> None:
        from llmdebug.cli import _format_timestamp

        assert _format_timestamp("2026-02-07T21:00:00Z") == "2026-02-07 21:00:00"
        assert _format_timestamp(None) == "?"
        assert _format_timestamp("bad") == "bad"

    def test_iter_valid_frame_items_handles_non_list_frames(self) -> None:
        from llmdebug.cli import _iter_valid_frame_items

        assert _iter_valid_frame_items({"frames": None}) == []
        assert _iter_valid_frame_items({"frames": "not-a-list"}) == []
        assert _iter_valid_frame_items({"frames": [{"function": "ok"}, "bad"]}) == [
            (0, {"function": "ok"})
        ]

    def test_format_enhanced_git_context_text_sections(self) -> None:
        from llmdebug._formatting import format_enhanced_git_context_text

        context = {
            "head": {
                "commit": "abc1234",
                "branch": "main",
                "dirty": True,
                "detached": False,
            },
            "crash_file": {
                "file_rel": "app.py",
                "line": 42,
                "in_repo": True,
                "blame": {"commit": "def5678", "author": "Alice"},
            },
            "recent_commits": [
                {
                    "commit": "def5678",
                    "author": "Alice",
                    "author_time": "2026-02-10T12:00:00Z",
                    "summary": "Fix crash",
                    "files_changed": 1,
                    "insertions": 3,
                    "deletions": 1,
                },
                "skip-me",
            ],
            "crash_file_diffstat": {
                "files_changed": 1,
                "insertions": 3,
                "deletions": 1,
                "touched": True,
            },
            "_git_warnings": ["git timeout"],
        }

        output = format_enhanced_git_context_text(context)
        assert "## Git Head" in output
        assert "## Crash File" in output
        assert "## Recent Commits" in output
        assert "## Crash File Diffstat" in output
        assert "## Warnings" in output
        assert "git timeout" in output


# ---------------------------------------------------------------------------
# diff command
# ---------------------------------------------------------------------------


@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'", "x": 10},
                "locals_meta": {"value": {"type": "str"}, "x": {"type": "int"}},
            }
        ],
        "crash_frame_index": 0,
        "git": {"commit": "aaa1111", "branch": "main", "dirty": False},
        "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, "x": 10},
                "locals_meta": {"value": {"type": "int"}, "x": {"type": "int"}},
            }
        ],
        "crash_frame_index": 0,
        "git": {"commit": "bbb2222", "branch": "main", "dirty": True},
        "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")
    # latest.json
    (tmp_path / "latest.json").write_text(json.dumps(snap_new), encoding="utf-8")
    return tmp_path


class TestDiff:
    def test_diff_latest_vs_previous(self, diff_snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["diff", "--dir", str(diff_snapshot_dir)])
        assert result.exit_code == 0
        assert "ValueError" in result.output or "TypeError" in result.output

    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")
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["diff", old, new, "--dir", str(diff_snapshot_dir)])
        assert result.exit_code == 0

    def test_diff_json_output(self, diff_snapshot_dir: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["diff", "--json", "--dir", str(diff_snapshot_dir)])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["exception"]["changed"] is True
        assert data["location"]["changed"] is True

    def test_diff_no_snapshots(self, tmp_path: Path) -> None:
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["diff", "--dir", str(tmp_path)])
        assert result.exit_code == 1

    def test_diff_one_snapshot(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")
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["diff", "--dir", str(tmp_path)])
        assert result.exit_code == 1
        assert "at least 2" in result.stderr


# ---------------------------------------------------------------------------
# git-context command
# ---------------------------------------------------------------------------


class TestGitContext:
    def test_git_context_json_output(self, snapshot_dir: Path, monkeypatch) -> None:
        import llmdebug.git_context as git_context_module

        monkeypatch.setattr(
            git_context_module,
            "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},
            },
        )
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["git-context", "--json", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        payload = json.loads(result.output)
        assert payload["head"]["commit"] == "abc1234"
        assert payload["crash_file"]["file_rel"] == "app.py"

    def test_git_context_text_output(self, snapshot_dir: Path, monkeypatch) -> None:
        import llmdebug.git_context as git_context_module

        monkeypatch.setattr(
            git_context_module,
            "get_enhanced_git_context",
            lambda *_args, **_kwargs: {
                "head": {"commit": "abc1234", "branch": "main", "dirty": True, "detached": False},
                "crash_file": {
                    "file_rel": "app.py",
                    "line": 42,
                    "in_repo": True,
                    "blame": {"commit": "def5678", "author": "Alice"},
                },
            },
        )
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["git-context", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "## Git Head" in result.output
        assert "abc1234" in result.output
        assert "## Crash File" in result.output

    def test_git_context_unavailable(self, snapshot_dir: Path, monkeypatch) -> None:
        import llmdebug.git_context as git_context_module

        monkeypatch.setattr(
            git_context_module,
            "get_enhanced_git_context",
            lambda *_args, **_kwargs: None,
        )
        runner = CliRunner(mix_stderr=False)
        result = runner.invoke(main, ["git-context", "--dir", str(snapshot_dir)])
        assert result.exit_code == 0
        assert "unavailable" in result.output.lower()
