"""Low-level git operations for code phases. All low-level logic lives in adw_modules."""

from __future__ import annotations

import subprocess
from pathlib import Path


def _git(*args: str, ok_returncodes: tuple[int, ...] = (0,)) -> str:
    result = subprocess.run(["git", *args], capture_output=True, text=True)
    if result.returncode not in ok_returncodes:
        raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
    # Porcelain status encodes its two-character state in leading whitespace;
    # only discard the command's trailing newline, never that state.
    return result.stdout.rstrip()


def current_branch() -> str:
    return _git("rev-parse", "--abbrev-ref", "HEAD")


def create_branch(name: str) -> str:
    _git("checkout", "-b", name)
    return name


def _is_shared_primary_checkout() -> bool:
    """A primary checkout with linked worktrees is the owner's shared tree."""
    git_dir = Path(_git("rev-parse", "--absolute-git-dir")).resolve()
    common_dir = Path(_git("rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
    worktrees = [line for line in _git("worktree", "list", "--porcelain").splitlines()
                 if line.startswith("worktree ")]
    return git_dir == common_dir and len(worktrees) > 1


def preflight(adw_id: str, branch_mode: str = "require_isolated", *,
              spec_output_dir: str | None = None,
              known_adw_ids: set[str] | None = None) -> str:
    """Prove this run owns a clean, isolated tree before agents can mutate it."""
    if not is_repo():
        raise RuntimeError("git preflight refused the run: target is not a git repository")
    dirty = [path for path in changed_files()
             if not _is_factory_spec(path, spec_output_dir, known_adw_ids or set())]
    if dirty:
        raise RuntimeError(
            "git preflight refused the run: target tree already has changes the run "
            f"does not own ({', '.join(dirty)}). Commit, move, or discard them, then rerun.")
    if _is_shared_primary_checkout():
        raise RuntimeError(
            "git preflight refused the run: target is the shared primary checkout. "
            "Create and run from a dedicated git worktree instead.")

    branch = current_branch()
    if branch_mode == "create":
        return create_branch(f"factory/{adw_id}")
    if branch == "HEAD":
        raise RuntimeError(
            "git preflight refused the run: target has a detached HEAD. "
            "Check out a dedicated branch, or set defaults.git_branch_mode: create.")
    if branch in {"main", "master"}:
        raise RuntimeError(
            f"git preflight refused the run: branch {branch!r} is protected from factory "
            "commits. Check out a dedicated branch, or set "
            "defaults.git_branch_mode: create.")
    return branch


def is_repo() -> bool:
    result = subprocess.run(["git", "rev-parse", "--git-dir"],
                            capture_output=True, text=True)
    return result.returncode == 0


def derive_repo_name(checkout: str | Path | None) -> str | None:
    """Canonical repository name from git common-dir metadata.

    Linked worktrees share the main checkout's .git directory; the name is the
    directory that owns that common dir, not the worktree folder basename.
    Falls back to the checkout path basename when git metadata cannot be resolved.
    """
    if checkout is None:
        return None
    path = Path(checkout).resolve()
    try:
        common_dir = Path(_git(
            "-C", str(path),
            "rev-parse", "--path-format=absolute", "--git-common-dir",
        )).resolve()
    except RuntimeError:
        return path.name or None
    if common_dir.name == ".git":
        parent_name = common_dir.parent.name
        if parent_name:
            return parent_name
    if common_dir.name:
        return common_dir.name
    return path.name or None


def repo_root() -> Path:
    """Absolute root of the codebase — where agents are spawned to work.

    The git toplevel when there is one, else the process cwd (ADWs run fine in a
    non-git dir; only a commit phase requires a repo). Always absolute, so it is
    safe to hand to a subprocess regardless of where the ADW was launched from.
    """
    if is_repo():
        return Path(_git("rev-parse", "--show-toplevel")).resolve()
    return Path.cwd().resolve()


def commit_all(message: str, entitled_paths: list[str]) -> str:
    """Commit only paths explicitly attributed to this run."""
    if not is_repo():
        raise RuntimeError(
            "not a git repository — a commit phase needs one. Run `git init` in the "
            "repo root (and make a first commit) before running an ADW that commits.")
    paths = sorted(set(entitled_paths))
    if not paths:
        raise RuntimeError("nothing to commit — the run has not claimed any changed paths")
    # Foreign files may legitimately appear while a run is active. They remain
    # untouched and unstaged; explicit pathspecs are the ownership boundary.
    _git("add", "-A", "--", *paths)
    if not _git("diff", "--cached", "--name-only"):
        raise RuntimeError("nothing to commit — the preceding phases changed no files")
    _git("commit", "-m", message)
    return _git("rev-parse", "--short", "HEAD")


def changed_files() -> list[str]:
    out = _git("status", "--porcelain", "--untracked-files=all")
    return [line[3:] for line in out.splitlines() if line]


def _is_factory_spec(path: str, output_dir: str | None,
                     known_adw_ids: set[str]) -> bool:
    if not output_dir or not known_adw_ids:
        return False
    candidate = Path(path)
    directory = Path(output_dir.rstrip("/"))
    return (candidate.parent == directory
            and candidate.suffix == ".md"
            and any(candidate.name.startswith(f"{adw_id}_")
                    for adw_id in known_adw_ids))


# ── diff plumbing (composed into a ChangeSet by documentation.py) ────────────

def ref_exists(ref: str) -> bool:
    """True when `ref` resolves to a commit. Never raises — this is a question."""
    result = subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
                            capture_output=True, text=True)
    return result.returncode == 0


def rev(ref: str = "HEAD") -> str:
    return _git("rev-parse", ref)


def phase_baseline() -> str:
    """Pin the tracked working tree without changing it."""
    return _git("stash", "create") or rev()


def short_sha(ref: str = "HEAD") -> str:
    return _git("rev-parse", "--short", ref)


def merge_base(ref: str, other: str = "HEAD") -> str:
    """The commit where `ref` and `other` diverged — the honest base of a branch.

    On the base branch itself this returns HEAD, which makes the diff exactly
    "what is not committed yet". Off it, the diff is the whole branch plus the
    working tree. One command covers both cases, so no ADW has to branch on it.
    """
    return _git("merge-base", ref, other)


def is_dirty() -> bool:
    return bool(_git("status", "--porcelain"))


def untracked_files() -> list[str]:
    out = _git("ls-files", "--others", "--exclude-standard")
    return [line for line in out.splitlines() if line]


def diff_files(base: str) -> list[str]:
    """Tracked files that differ between `base` and the working tree."""
    out = _git("diff", "--name-only", base)
    return [line for line in out.splitlines() if line]


def diff_stat(base: str) -> str:
    return _git("diff", "--stat", base)


def diff_counts(base: str) -> tuple[int, int]:
    """(insertions, deletions) across the diff. Binary files count as neither."""
    insertions = deletions = 0
    for line in _git("diff", "--numstat", base).splitlines():
        added, removed, *_ = line.split("\t")
        if added.isdigit():
            insertions += int(added)
        if removed.isdigit():
            deletions += int(removed)
    return insertions, deletions


def diff_text(base: str) -> str:
    return _git("diff", base)


def phase_snapshot() -> tuple[str, dict[str, str]]:
    """Return the HEAD and porcelain state at the start of a phase."""
    head = rev()
    status = _git("status", "--porcelain", "--untracked-files=all")
    paths: dict[str, str] = {}
    for line in status.splitlines():
        if line:
            path = line[3:]
            state = line[:2].strip() or "?"
            if Path(path).is_file():
                state += ":" + _git("hash-object", "--", path)
            paths[path] = state
    return head, paths


def phase_diff(before_head: str, before_paths: dict[str, str]) -> tuple[
        list[dict[str, object]], int, int, str] | None:
    """Capture the git diff introduced since a phase began."""
    head = rev()
    if head != before_head:
        names = _git("diff", "--name-status", before_head)
        numstat = _git("diff", "--numstat", before_head)
        text = _git("diff", "--no-ext-diff", before_head)
        changed_paths = {line.split("\t")[-1] for line in names.splitlines() if line}
    else:
        _, after_paths = phase_snapshot()
        changed_paths = {path for path in set(before_paths) | set(after_paths)
                         if before_paths.get(path) != after_paths.get(path)}
        if not changed_paths:
            return None
        names = _git("diff", "--name-status", "HEAD", "--", *sorted(changed_paths))
        numstat = _git("diff", "--numstat", "HEAD", "--", *sorted(changed_paths))
        text = _git("diff", "--no-ext-diff", "HEAD", "--", *sorted(changed_paths))

    rows: dict[str, dict[str, object]] = {}
    for line in names.splitlines():
        fields = line.split("\t")
        if len(fields) >= 2:
            rows[fields[-1]] = {"path": fields[-1], "status": fields[0][0],
                                "insertions": None, "deletions": None}
    for line in numstat.splitlines():
        fields = line.split("\t")
        if len(fields) >= 3 and fields[-1] in rows:
            rows[fields[-1]]["insertions"] = int(fields[0]) if fields[0].isdigit() else None
            rows[fields[-1]]["deletions"] = int(fields[1]) if fields[1].isdigit() else None

    after_status = _git("status", "--porcelain", "--untracked-files=all")
    for line in after_status.splitlines():
        if line.startswith("?? ") and line[3:] in changed_paths:
            path = line[3:]
            rows[path] = {"path": path, "status": "?", "insertions": None,
                          "deletions": None}
            untracked_text = _git("diff", "--no-index", "--no-ext-diff", "/dev/null", path,
                                  ok_returncodes=(0, 1))
            text = f"{text}\n{untracked_text}" if text else untracked_text

    files = list(rows.values())
    if not files:
        return None
    insertions = sum(row["insertions"] for row in files
                     if isinstance(row["insertions"], int))
    deletions = sum(row["deletions"] for row in files
                    if isinstance(row["deletions"], int))
    return files, insertions, deletions, text
