"""Git repository context capture for debug snapshots."""

from __future__ import annotations

import re
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


class _GitError:
    """Sentinel to distinguish 'command failed' from 'not applicable'."""

    __slots__ = ("reason",)

    def __init__(self, reason: str) -> None:
        self.reason = reason


def _run_git_command(
    args: list[str],
    timeout: float = 2.0,
    repo_root: str | None = None,
) -> str | _GitError:
    """Run a git command with timeout.

    Returns:
        stdout string on success, _GitError on failure.
    """
    cmd = ["git"]
    if repo_root:
        cmd.extend(["-C", repo_root])
    cmd.extend(args)
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
        )
        if result.returncode == 0:
            return result.stdout.strip()
        return _GitError(f"exit code {result.returncode}")
    except subprocess.TimeoutExpired:
        return _GitError("timeout")
    except FileNotFoundError:
        return _GitError("git not found")
    except OSError as e:
        return _GitError(f"OSError: {e}")


def _remaining_budget(deadline: float) -> float:
    return max(0.0, deadline - time.monotonic())


def _run_git_budgeted(
    args: list[str],
    *,
    repo_root: str | None,
    label: str,
    warnings: list[str],
    per_cmd_timeout_sec: float,
    deadline: float,
) -> str | None:
    remaining = _remaining_budget(deadline)
    if remaining <= 0:
        warnings.append(f"{label}: budget exhausted")
        return None

    timeout = min(max(per_cmd_timeout_sec, 0.01), remaining)
    result = _run_git_command(args, timeout=timeout, repo_root=repo_root)
    if isinstance(result, str):
        return result
    if isinstance(result, _GitError):
        warnings.append(f"{label}: {result.reason}")
    return None


def _extract_crash_frame(snapshot: dict[str, Any]) -> dict[str, Any] | None:
    frames = snapshot.get("frames")
    if not isinstance(frames, list) or not frames:
        return None

    crash_idx_raw = snapshot.get("crash_frame_index", 0)
    try:
        crash_idx = int(crash_idx_raw)
    except (TypeError, ValueError):
        crash_idx = 0

    if 0 <= crash_idx < len(frames) and isinstance(frames[crash_idx], dict):
        return frames[crash_idx]
    if isinstance(frames[0], dict):
        return frames[0]
    return None


def _extract_crash_line(crash_frame: dict[str, Any]) -> int | None:
    line_raw = crash_frame.get("line")
    if isinstance(line_raw, int):
        line = line_raw
    elif isinstance(line_raw, str):
        try:
            line = int(line_raw)
        except ValueError:
            return None
    else:
        return None
    return line if line > 0 else None


def _resolve_repo_relative_file(
    crash_frame: dict[str, Any],
    *,
    repo_root: str,
) -> tuple[str | None, bool]:
    repo_root_path = Path(repo_root).resolve()

    file_rel_raw = crash_frame.get("file_rel")
    if isinstance(file_rel_raw, str) and file_rel_raw:
        rel_candidate = file_rel_raw.replace("\\", "/")
        if not rel_candidate.startswith(".."):
            try:
                joined = (repo_root_path / rel_candidate).resolve()
                rel = joined.relative_to(repo_root_path).as_posix()
                return rel, True
            except (OSError, ValueError):
                pass
        return rel_candidate, False

    file_abs_raw = crash_frame.get("file")
    if isinstance(file_abs_raw, str) and file_abs_raw:
        try:
            file_abs = Path(file_abs_raw).resolve()
            rel = file_abs.relative_to(repo_root_path).as_posix()
            return rel, True
        except (OSError, ValueError):
            return file_abs_raw, False

    return None, False


def _parse_blame_porcelain(text: str, line: int) -> dict[str, Any] | None:
    if not text:
        return None

    result: dict[str, Any] = {"line": line}
    for raw_line in text.splitlines():
        line_text = raw_line.strip()
        if not line_text:
            continue

        if "commit" not in result and re.match(r"^[0-9a-f]{40}\s+\d+\s+\d+\s+\d+$", line_text):
            result["commit"] = line_text.split()[0]
            continue

        if line_text.startswith("author "):
            result["author"] = line_text.removeprefix("author ").strip()
            continue

        if line_text.startswith("author-time "):
            ts_raw = line_text.removeprefix("author-time ").strip()
            try:
                ts = int(ts_raw)
                result["author_time"] = (
                    datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
                )
            except ValueError:
                result["author_time"] = ts_raw
            continue

        if line_text.startswith("summary "):
            result["summary"] = line_text.removeprefix("summary ").strip()
            continue

    if "commit" not in result:
        return None
    return result


_SHORTSTAT_RE = re.compile(
    r"(?P<files>\d+)\s+files?\s+changed"
    r"(?:,\s+(?P<insertions>\d+)\s+insertions?\(\+\))?"
    r"(?:,\s+(?P<deletions>\d+)\s+deletions?\(-\))?"
)
_LOG_SEPARATOR = "__LLMDEBUG_COMMIT__"


def _parse_shortstat(line: str) -> tuple[int, int, int]:
    m = _SHORTSTAT_RE.search(line)
    if m is None:
        return 0, 0, 0

    files_changed = int(m.group("files") or 0)
    insertions = int(m.group("insertions") or 0)
    deletions = int(m.group("deletions") or 0)
    return files_changed, insertions, deletions


def _parse_recent_commits(log_text: str) -> list[dict[str, Any]]:
    if not log_text:
        return []

    lines = [line.rstrip() for line in log_text.splitlines()]
    commits: list[dict[str, Any]] = []
    i = 0
    while i < len(lines):
        if lines[i].strip() != _LOG_SEPARATOR:
            i += 1
            continue

        if i + 5 >= len(lines):
            break

        short_commit = lines[i + 1].strip()
        full_commit = lines[i + 2].strip()
        author = lines[i + 3].strip()
        author_time = lines[i + 4].strip()
        summary = lines[i + 5].strip()

        i += 6
        files_changed = 0
        insertions = 0
        deletions = 0
        while i < len(lines) and lines[i].strip() != _LOG_SEPARATOR:
            line = lines[i].strip()
            if "file changed" in line or "files changed" in line:
                files_changed, insertions, deletions = _parse_shortstat(line)
                break
            i += 1

        commits.append(
            {
                "commit": short_commit,
                "commit_full": full_commit,
                "author": author,
                "author_time": author_time,
                "summary": summary,
                "files_changed": files_changed,
                "insertions": insertions,
                "deletions": deletions,
            }
        )

    return commits


def _parse_numstat(text: str) -> dict[str, Any]:
    files_changed = 0
    insertions = 0
    deletions = 0

    for raw_line in text.splitlines():
        line = raw_line.strip()
        if not line:
            continue
        parts = line.split("\t")
        if len(parts) < 3:
            continue
        files_changed += 1
        ins_raw, del_raw = parts[0], parts[1]
        if ins_raw.isdigit():
            insertions += int(ins_raw)
        if del_raw.isdigit():
            deletions += int(del_raw)

    return {
        "files_changed": files_changed,
        "insertions": insertions,
        "deletions": deletions,
        "touched": files_changed > 0,
    }


def _candidate_repo_dirs(snapshot: dict[str, Any], *, out_dir: str) -> list[str]:
    """Build an ordered candidate list to discover the snapshot's git repo."""
    candidates: list[str] = []
    seen: set[str] = set()

    def _add(raw_path: str | Path | None, *, from_file: bool = False) -> None:
        if raw_path is None:
            return
        raw_text = str(raw_path).strip()
        if not raw_text:
            return

        try:
            path = Path(raw_text).expanduser()
            if from_file:
                path = path.parent
            resolved = path.resolve()
        except (OSError, RuntimeError, ValueError):
            return

        key = resolved.as_posix()
        if key in seen:
            return
        seen.add(key)
        candidates.append(key)

    crash_frame = _extract_crash_frame(snapshot)
    if crash_frame is not None:
        file_abs_raw = crash_frame.get("file")
        if isinstance(file_abs_raw, str) and file_abs_raw:
            file_path = Path(file_abs_raw)
            if file_path.is_absolute():
                _add(file_path, from_file=True)

    _add(out_dir)

    try:
        _add(Path(out_dir).expanduser().resolve().parent)
    except (OSError, RuntimeError, ValueError):
        pass

    env = snapshot.get("env")
    if isinstance(env, dict):
        cwd_raw = env.get("cwd")
        if isinstance(cwd_raw, str) and cwd_raw:
            _add(cwd_raw)

    _add(Path.cwd())
    return candidates


def _discover_repo_root(
    snapshot: dict[str, Any],
    *,
    out_dir: str,
    warnings: list[str],
    per_cmd_timeout_sec: float,
    deadline: float,
) -> str | None:
    candidates = _candidate_repo_dirs(snapshot, out_dir=out_dir)
    if not candidates:
        warnings.append("repo root: no discovery candidates")
        return None

    failures: list[str] = []
    for candidate in candidates:
        remaining = _remaining_budget(deadline)
        if remaining <= 0:
            warnings.append("repo root: budget exhausted")
            return None

        timeout = min(max(per_cmd_timeout_sec, 0.01), remaining)
        result = _run_git_command(
            ["rev-parse", "--show-toplevel"],
            timeout=timeout,
            repo_root=candidate,
        )
        if isinstance(result, str):
            return result
        if isinstance(result, _GitError):
            failures.append(f"{candidate} ({result.reason})")

    preview = "; ".join(failures[:2])
    if len(failures) > 2:
        preview = f"{preview}; +{len(failures) - 2} more"
    warnings.append(f"repo root: not found from candidates: {preview}")
    return None


def get_enhanced_git_context(
    snapshot: dict[str, Any],
    *,
    out_dir: str,
    max_commits: int = 5,
    per_cmd_timeout_sec: float = 0.4,
    total_budget_sec: float = 1.2,
) -> dict[str, Any] | None:
    """Compute enhanced git metadata on-demand for a specific snapshot.

    This is intentionally metadata-only and budgeted:
    - crash-line blame metadata (no code hunk)
    - recent commit metadata + shortstats
    - crash-file diffstat metadata
    """
    warnings: list[str] = []
    deadline = time.monotonic() + max(total_budget_sec, 0.0)

    repo_root = _discover_repo_root(
        snapshot,
        out_dir=out_dir,
        warnings=warnings,
        per_cmd_timeout_sec=per_cmd_timeout_sec,
        deadline=deadline,
    )
    if repo_root is None:
        return None

    context: dict[str, Any] = {"repo_root": repo_root}
    head: dict[str, Any] = {}

    commit_short = _run_git_budgeted(
        ["rev-parse", "--short", "HEAD"],
        repo_root=repo_root,
        label="head commit",
        warnings=warnings,
        per_cmd_timeout_sec=per_cmd_timeout_sec,
        deadline=deadline,
    )
    if commit_short is not None:
        head["commit"] = commit_short

    commit_full = _run_git_budgeted(
        ["rev-parse", "HEAD"],
        repo_root=repo_root,
        label="head commit full",
        warnings=warnings,
        per_cmd_timeout_sec=per_cmd_timeout_sec,
        deadline=deadline,
    )
    if commit_full is not None:
        head["commit_full"] = commit_full

    branch = _run_git_budgeted(
        ["rev-parse", "--abbrev-ref", "HEAD"],
        repo_root=repo_root,
        label="branch",
        warnings=warnings,
        per_cmd_timeout_sec=per_cmd_timeout_sec,
        deadline=deadline,
    )
    if branch is not None:
        head["branch"] = branch
        if branch == "HEAD":
            head["detached"] = True

    status = _run_git_budgeted(
        ["status", "--porcelain"],
        repo_root=repo_root,
        label="dirty status",
        warnings=warnings,
        per_cmd_timeout_sec=per_cmd_timeout_sec,
        deadline=deadline,
    )
    if status is not None:
        head["dirty"] = len(status) > 0

    if head:
        context["head"] = head

    crash_frame = _extract_crash_frame(snapshot)
    if crash_frame is not None:
        crash_line = _extract_crash_line(crash_frame)
        file_rel, in_repo = _resolve_repo_relative_file(crash_frame, repo_root=repo_root)
        crash_file: dict[str, Any] = {"file_rel": file_rel, "in_repo": in_repo}
        if crash_line is not None:
            crash_file["line"] = crash_line

        if file_rel and in_repo and crash_line is not None:
            blame_text = _run_git_budgeted(
                ["blame", "-L", f"{crash_line},{crash_line}", "--porcelain", "--", file_rel],
                repo_root=repo_root,
                label="blame",
                warnings=warnings,
                per_cmd_timeout_sec=per_cmd_timeout_sec,
                deadline=deadline,
            )
            if blame_text is not None:
                blame_meta = _parse_blame_porcelain(blame_text, crash_line)
                if blame_meta is not None:
                    crash_file["blame"] = blame_meta

            diffstat_text = _run_git_budgeted(
                ["show", "--numstat", "--format=", "--", file_rel],
                repo_root=repo_root,
                label="crash file diffstat",
                warnings=warnings,
                per_cmd_timeout_sec=per_cmd_timeout_sec,
                deadline=deadline,
            )
            if diffstat_text is not None:
                context["crash_file_diffstat"] = _parse_numstat(diffstat_text)

        context["crash_file"] = crash_file

    log_text = _run_git_budgeted(
        [
            "log",
            "-n",
            str(max(1, max_commits)),
            "--date=iso-strict",
            f"--pretty=format:{_LOG_SEPARATOR}%n%h%n%H%n%an%n%ad%n%s",
            "--shortstat",
        ],
        repo_root=repo_root,
        label="recent commits",
        warnings=warnings,
        per_cmd_timeout_sec=per_cmd_timeout_sec,
        deadline=deadline,
    )
    if log_text is not None:
        recent = _parse_recent_commits(log_text)
        if recent:
            context["recent_commits"] = recent

    if warnings:
        context["_git_warnings"] = warnings
    return context


def get_git_context() -> dict[str, Any] | None:
    """Capture git repository context.

    Returns:
        Dict with commit, branch, dirty status, or None if not in a git repo.

    Example output:
        {
            "commit": "abc123",
            "commit_full": "abc123def456...",
            "branch": "main",
            "dirty": True
        }
    """
    repo_root_result = _run_git_command(["rev-parse", "--show-toplevel"])
    if isinstance(repo_root_result, _GitError):
        # Not a git repo, or git not available
        return None
    repo_root: str = repo_root_result  # type: ignore[assignment]

    context: dict[str, Any] = {}
    _warnings: list[str] = []

    # Get commit hash (short and full)
    commit_short = _run_git_command(["rev-parse", "--short", "HEAD"], repo_root=repo_root)
    if isinstance(commit_short, str):
        context["commit"] = commit_short
    elif isinstance(commit_short, _GitError):
        _warnings.append(f"commit hash: {commit_short.reason}")

    commit_full = _run_git_command(["rev-parse", "HEAD"], repo_root=repo_root)
    if isinstance(commit_full, str):
        context["commit_full"] = commit_full

    # Get branch name
    branch = _run_git_command(["rev-parse", "--abbrev-ref", "HEAD"], repo_root=repo_root)
    if isinstance(branch, str):
        context["branch"] = branch
        # Check for detached HEAD
        if branch == "HEAD":
            context["detached"] = True

    # Check if working directory is dirty
    status = _run_git_command(["status", "--porcelain"], repo_root=repo_root)
    if isinstance(status, str):
        context["dirty"] = len(status) > 0

    if _warnings:
        context["_git_warnings"] = _warnings

    return context if context else None
