"""Tests for git context capture."""

import os
import subprocess
from typing import Any, cast

import pytest

import llmdebug.git_context as git_context_module
from llmdebug.git_context import _GitError, get_enhanced_git_context, get_git_context


@pytest.fixture(autouse=True)
def _clear_git_context_cache():
    """Prevent cross-test leakage from git context cache."""
    git_context_module._git_context_cache = None
    yield
    git_context_module._git_context_cache = None


def test_git_context_in_repo():
    """Test git context capture when in a git repository."""
    # This test runs from the llmdebug repo itself
    context = get_git_context()

    # Should have context since we're in a git repo
    assert context is not None
    assert "commit" in context
    assert "commit_full" in context
    assert "branch" in context
    assert "dirty" in context

    # Commit should be a short hash (7+ chars)
    assert len(context["commit"]) >= 7
    # Full commit should be 40 chars
    assert len(context["commit_full"]) == 40


def test_git_context_not_in_repo(tmp_path, monkeypatch):
    """Test git context capture when not in a git repository."""
    # Change to a non-git directory
    monkeypatch.chdir(tmp_path)

    context = get_git_context()

    # Should return None when not in a git repo
    assert context is None


def test_git_context_dirty_status(tmp_path):
    """Test that dirty status is correctly detected."""
    # Initialize a new git repo
    subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
    subprocess.run(
        ["git", "config", "user.email", "test@test.com"],
        cwd=tmp_path,
        capture_output=True,
        check=True,
    )
    subprocess.run(
        ["git", "config", "user.name", "Test"],
        cwd=tmp_path,
        capture_output=True,
        check=True,
    )

    # Create and commit a file
    test_file = tmp_path / "test.txt"
    test_file.write_text("content")
    subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True, check=True)
    subprocess.run(
        ["git", "commit", "-m", "Initial commit"],
        cwd=tmp_path,
        capture_output=True,
        check=True,
    )

    # Save current dir and change to temp repo
    old_cwd = os.getcwd()
    try:
        os.chdir(tmp_path)

        # Should be clean
        context = get_git_context()
        assert context is not None
        assert context["dirty"] is False

        # Untracked files should count as dirty.
        untracked_file = tmp_path / "untracked.txt"
        untracked_file.write_text("new")
        context = get_git_context()
        assert context is not None
        assert context["dirty"] is True

        # Return to clean before testing tracked-file edits.
        untracked_file.unlink()
        context = get_git_context()
        assert context is not None
        assert context["dirty"] is False

        # Tracked-file edits should also count as dirty.
        test_file.write_text("modified")
        context = get_git_context()
        assert context is not None
        assert context["dirty"] is True
    finally:
        os.chdir(old_cwd)


def test_git_context_detached_head(tmp_path):
    """Test git context when HEAD is detached."""
    # Initialize a new git repo
    subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
    subprocess.run(
        ["git", "config", "user.email", "test@test.com"],
        cwd=tmp_path,
        capture_output=True,
        check=True,
    )
    subprocess.run(
        ["git", "config", "user.name", "Test"],
        cwd=tmp_path,
        capture_output=True,
        check=True,
    )

    # Create and commit a file
    test_file = tmp_path / "test.txt"
    test_file.write_text("content")
    subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True, check=True)
    subprocess.run(
        ["git", "commit", "-m", "Initial commit"],
        cwd=tmp_path,
        capture_output=True,
        check=True,
    )

    # Detach HEAD
    result = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=tmp_path,
        capture_output=True,
        text=True,
        check=True,
    )
    commit_hash = result.stdout.strip()
    subprocess.run(
        ["git", "checkout", commit_hash],
        cwd=tmp_path,
        capture_output=True,
        check=True,
    )

    # Save current dir and change to temp repo
    old_cwd = os.getcwd()
    try:
        os.chdir(tmp_path)

        context = get_git_context()
        assert context is not None
        assert context.get("detached") is True
    finally:
        os.chdir(old_cwd)


def test_parse_head_info_from_log_preserves_commas_in_branch_name() -> None:
    commit_short, commit_full, branch, detached = git_context_module._parse_head_info_from_log(
        "abc1234\n" + ("a" * 40) + "\nHEAD -> feat,comma, origin/feat,comma"
    )
    assert commit_short == "abc1234"
    assert commit_full == "a" * 40
    assert branch == "feat,comma"
    assert detached is False


def test_enhanced_git_context_parses_metadata(monkeypatch):
    snapshot = {
        "crash_frame_index": 0,
        "frames": [{"file_rel": "app.py", "line": 42}],
    }

    def fake_run(args, timeout=2.0, repo_root=None):
        _ = timeout
        _ = repo_root
        if args[:2] == ["rev-parse", "--show-toplevel"]:
            return "/repo"
        if args[:2] == ["log", "-1"]:
            return "abc1234\nabc1234567890abcdef1234567890abcdef1234\nHEAD -> main"
        if args[:2] == ["status", "--porcelain"]:
            return ""
        if args and args[0] == "blame":
            return (
                "def56789abcdef0123456789abcdef0123456789 42 42 1\n"
                "author Alice\n"
                "author-time 1700000000\n"
                "summary Fix parser line\n"
                "filename app.py\n"
                "\treturn int(value)\n"
            )
        if args and args[0] == "log":
            return (
                "__LLMDEBUG_COMMIT__\n"
                "abc1234\n"
                "abc1234567890abcdef1234567890abcdef1234\n"
                "Alice\n"
                "2026-02-10T10:00:00+00:00\n"
                "Fix parser\n"
                " 1 file changed, 2 insertions(+), 1 deletion(-)\n"
            )
        if args and args[0] == "show":
            return "12\t3\tapp.py\n"
        return _GitError("unexpected")

    monkeypatch.setattr(git_context_module, "_run_git_command", fake_run)
    context = get_enhanced_git_context(
        snapshot,
        out_dir=".llmdebug",
        max_commits=5,
        per_cmd_timeout_sec=1.0,
        total_budget_sec=5.0,
    )
    assert context is not None
    assert context["head"]["commit"] == "abc1234"
    assert context["crash_file"]["file_rel"] == "app.py"
    assert context["crash_file"]["blame"]["author"] == "Alice"
    assert len(context["recent_commits"]) == 1
    assert context["crash_file_diffstat"]["files_changed"] == 1


def test_enhanced_git_context_discovers_repo_from_out_dir(monkeypatch, tmp_path):
    target_repo = (tmp_path / "target_repo").resolve()
    target_repo_str = target_repo.as_posix()
    target_out_dir = (target_repo / ".llmdebug").resolve()
    target_out_dir_str = target_out_dir.as_posix()
    wrong_repo_str = (tmp_path / "wrong_repo").resolve().as_posix()
    snapshot = {
        "crash_frame_index": 0,
        "frames": [{"file_rel": "app.py"}],
    }

    def fake_run(args, timeout=2.0, repo_root=None):
        _ = timeout
        repo = str(repo_root) if repo_root is not None else None
        if args[:2] == ["rev-parse", "--show-toplevel"]:
            if repo in {target_out_dir_str, target_repo_str}:
                return target_repo_str
            return wrong_repo_str
        if args[:2] == ["log", "-1"]:
            if repo == target_repo_str:
                return "abc1234\n" + ("a" * 40) + "\nHEAD -> main"
            return "badcafe\n" + ("b" * 40) + "\nHEAD -> wrong-branch"
        if args[:2] == ["status", "--porcelain"]:
            return ""
        if args and args[0] == "log":
            return ""
        return _GitError("unexpected")

    monkeypatch.setattr(git_context_module, "_run_git_command", fake_run)
    context = get_enhanced_git_context(snapshot, out_dir=target_out_dir_str)
    assert context is not None
    assert context["repo_root"] == target_repo_str
    assert context["head"]["commit"] == "abc1234"
    assert context["head"]["branch"] == "main"


def test_enhanced_git_context_marks_outside_repo(monkeypatch):
    snapshot = {
        "crash_frame_index": 0,
        "frames": [{"file": "/tmp/outside.py", "line": 8}],
    }
    calls: list[tuple[list[str], str | None]] = []

    def fake_run(args, timeout=2.0, repo_root=None):
        _ = timeout
        calls.append((args, repo_root))
        if args[:2] == ["rev-parse", "--show-toplevel"]:
            return "/repo"
        if args[:2] == ["log", "-1"]:
            return "abc1234\nabc1234567890abcdef1234567890abcdef1234\nHEAD -> main"
        if args[:2] == ["status", "--porcelain"]:
            return ""
        if args and args[0] == "log":
            return ""
        return _GitError("unexpected")

    monkeypatch.setattr(git_context_module, "_run_git_command", fake_run)
    context = get_enhanced_git_context(snapshot, out_dir=".llmdebug")
    assert context is not None
    assert context["crash_file"]["in_repo"] is False
    assert "blame" not in context["crash_file"]
    assert all(args[0] not in {"blame", "show"} for args, _ in calls)


def test_enhanced_git_context_budget_exhausted(monkeypatch):
    snapshot = {"frames": [{"file_rel": "app.py", "line": 1}], "crash_frame_index": 0}

    def fake_run(args, timeout=2.0, repo_root=None):
        _ = args
        _ = timeout
        _ = repo_root
        return "/repo"

    monkeypatch.setattr(git_context_module, "_run_git_command", fake_run)
    context = get_enhanced_git_context(snapshot, out_dir=".llmdebug", total_budget_sec=0.0)
    assert context is None


def test_git_context_cache_reuses_static_data(monkeypatch, tmp_path):
    repo_root = tmp_path / "repo"
    git_dir = repo_root / ".git"
    git_dir.mkdir(parents=True)
    (git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
    (git_dir / "index").write_bytes(b"")
    monkeypatch.chdir(repo_root)
    monkeypatch.setattr(git_context_module, "_GIT_CONTEXT_CACHE_TTL_SEC", 60.0)

    run_calls: list[tuple[list[str], str | None]] = []
    rc_calls: list[tuple[list[str], str | None]] = []

    def fake_run(args, timeout=2.0, repo_root=None):
        _ = timeout
        run_calls.append((args, repo_root))
        if args[:2] == ["rev-parse", "--show-toplevel"]:
            return str(repo_root_path)
        if args[:2] == ["log", "-1"]:
            return "abc1234\n" + ("a" * 40) + "\nHEAD -> main"
        return _GitError("unexpected")

    def fake_rc(args, timeout=2.0, repo_root=None):
        _ = timeout
        rc_calls.append((args, repo_root))
        return 0

    repo_root_path = repo_root.resolve()
    monkeypatch.setattr(git_context_module, "_run_git_command", fake_run)
    monkeypatch.setattr(git_context_module, "_run_git_command_rc", fake_rc)

    first = get_git_context()
    second = get_git_context()

    assert first is not None
    assert second is not None
    assert first["commit"] == "abc1234"
    assert second["commit"] == "abc1234"

    # Static metadata should be fetched once (cache hit on second call).
    static_calls = [args for args, _ in run_calls if args[:2] == ["rev-parse", "--show-toplevel"]]
    assert len(static_calls) == 1

    # Dirty checks still run on each access to avoid stale status.
    diff_calls = [args for args, _ in rc_calls if args[:1] == ["diff-files"]]
    assert len(diff_calls) == 2


def test_git_context_cache_invalidates_on_head_change(monkeypatch, tmp_path):
    repo_root = tmp_path / "repo"
    git_dir = repo_root / ".git"
    git_dir.mkdir(parents=True)
    head_path = git_dir / "HEAD"
    head_path.write_text("ref: refs/heads/main\n", encoding="utf-8")
    (git_dir / "index").write_bytes(b"")
    monkeypatch.chdir(repo_root)
    monkeypatch.setattr(git_context_module, "_GIT_CONTEXT_CACHE_TTL_SEC", 60.0)

    commit_state = {"short": "aaa1111", "full": "a" * 40}
    show_toplevel_calls = 0

    def fake_run(args, timeout=2.0, repo_root=None):
        _ = timeout
        nonlocal show_toplevel_calls
        if args[:2] == ["rev-parse", "--show-toplevel"]:
            show_toplevel_calls += 1
            return str(repo_root_path)
        if args[:2] == ["log", "-1"]:
            return f"{commit_state['short']}\n{commit_state['full']}\nHEAD -> main"
        return _GitError("unexpected")

    def fake_rc(args, timeout=2.0, repo_root=None):
        _ = args
        _ = timeout
        _ = repo_root
        return 0

    repo_root_path = repo_root.resolve()
    monkeypatch.setattr(git_context_module, "_run_git_command", fake_run)
    monkeypatch.setattr(git_context_module, "_run_git_command_rc", fake_rc)

    first = get_git_context()
    assert first is not None
    assert first["commit"] == "aaa1111"
    assert show_toplevel_calls == 1

    commit_state["short"] = "bbb2222"
    commit_state["full"] = "b" * 40
    # Head mtime change should invalidate cache and trigger refresh.
    head_path.write_text("ref: refs/heads/main\n# changed\n", encoding="utf-8")
    second = get_git_context()
    assert second is not None
    assert second["commit"] == "bbb2222"
    assert show_toplevel_calls == 2


def test_git_context_dirty_mode_off_skips_dirty_detection(monkeypatch, tmp_path):
    repo_root = tmp_path / "repo"
    git_dir = repo_root / ".git"
    git_dir.mkdir(parents=True)
    (git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
    (git_dir / "index").write_bytes(b"")
    monkeypatch.chdir(repo_root)
    monkeypatch.setattr(git_context_module, "_GIT_CONTEXT_CACHE_TTL_SEC", 60.0)

    run_calls: list[list[str]] = []
    rc_calls: list[list[str]] = []

    def fake_run(args, timeout=2.0, repo_root=None):
        _ = timeout
        _ = repo_root
        run_calls.append(args)
        if args[:2] == ["rev-parse", "--show-toplevel"]:
            return str(repo_root_path)
        if args[:2] == ["log", "-1"]:
            return "abc1234\n" + ("a" * 40) + "\nHEAD -> main"
        return _GitError("unexpected")

    def fake_rc(args, timeout=2.0, repo_root=None):
        _ = timeout
        _ = repo_root
        rc_calls.append(args)
        return 0

    repo_root_path = repo_root.resolve()
    monkeypatch.setattr(git_context_module, "_run_git_command", fake_run)
    monkeypatch.setattr(git_context_module, "_run_git_command_rc", fake_rc)

    context = get_git_context(dirty_mode="off")
    assert context is not None
    assert "dirty" not in context
    assert rc_calls == []
    assert all(call[:2] != ["status", "--porcelain"] for call in run_calls)


def test_git_context_dirty_mode_cached_reuses_dirty(monkeypatch, tmp_path):
    repo_root = tmp_path / "repo"
    git_dir = repo_root / ".git"
    git_dir.mkdir(parents=True)
    (git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
    (git_dir / "index").write_bytes(b"")
    monkeypatch.chdir(repo_root)
    monkeypatch.setattr(git_context_module, "_GIT_CONTEXT_CACHE_TTL_SEC", 60.0)

    rc_calls: list[list[str]] = []

    def fake_run(args, timeout=2.0, repo_root=None):
        _ = timeout
        _ = repo_root
        if args[:2] == ["rev-parse", "--show-toplevel"]:
            return str(repo_root_path)
        if args[:2] == ["log", "-1"]:
            return "abc1234\n" + ("a" * 40) + "\nHEAD -> main"
        return _GitError("unexpected")

    def fake_rc(args, timeout=2.0, repo_root=None):
        _ = timeout
        _ = repo_root
        rc_calls.append(args)
        # Mark repo dirty on first detection path.
        return 1

    repo_root_path = repo_root.resolve()
    monkeypatch.setattr(git_context_module, "_run_git_command", fake_run)
    monkeypatch.setattr(git_context_module, "_run_git_command_rc", fake_rc)

    first = get_git_context(dirty_mode="cached")
    second = get_git_context(dirty_mode="cached")
    assert first is not None
    assert second is not None
    assert first.get("dirty") is True
    assert second.get("dirty") is True

    diff_files_calls = [call for call in rc_calls if call[:1] == ["diff-files"]]
    assert len(diff_files_calls) == 1


def test_git_context_dirty_mode_cached_after_off_recomputes_dirty(monkeypatch, tmp_path):
    repo_root = tmp_path / "repo"
    git_dir = repo_root / ".git"
    git_dir.mkdir(parents=True)
    (git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
    (git_dir / "index").write_bytes(b"")
    monkeypatch.chdir(repo_root)
    monkeypatch.setattr(git_context_module, "_GIT_CONTEXT_CACHE_TTL_SEC", 60.0)

    run_calls: list[list[str]] = []
    rc_calls: list[list[str]] = []

    def fake_run(args, timeout=2.0, repo_root=None):
        _ = timeout
        _ = repo_root
        run_calls.append(args)
        if args[:2] == ["rev-parse", "--show-toplevel"]:
            return str(repo_root_path)
        if args[:2] == ["log", "-1"]:
            return "abc1234\n" + ("a" * 40) + "\nHEAD -> main"
        return _GitError("unexpected")

    def fake_rc(args, timeout=2.0, repo_root=None):
        _ = timeout
        _ = repo_root
        rc_calls.append(args)
        if args[:1] == ["diff-files"]:
            return 0
        if args[:1] == ["diff-index"]:
            return 0
        if args[:1] == ["ls-files"]:
            return 1
        return 0

    repo_root_path = repo_root.resolve()
    monkeypatch.setattr(git_context_module, "_run_git_command", fake_run)
    monkeypatch.setattr(git_context_module, "_run_git_command_rc", fake_rc)

    off_context = get_git_context(dirty_mode="off")
    cached_context = get_git_context(dirty_mode="cached")

    assert off_context is not None
    assert "dirty" not in off_context
    assert cached_context is not None
    assert cached_context.get("dirty") is False

    show_toplevel_calls = [
        call for call in run_calls if call[:2] == ["rev-parse", "--show-toplevel"]
    ]
    assert len(show_toplevel_calls) == 2
    ls_files_calls = [call for call in rc_calls if call[:1] == ["ls-files"]]
    assert len(ls_files_calls) == 1


def test_git_context_rejects_invalid_dirty_mode():
    with pytest.raises(ValueError, match="dirty_mode must be"):
        get_git_context(dirty_mode=cast(Any, "invalid"))
