"""Release primitives — git/gh subprocess wrappers (#106).

Thin shell-outs that release_project orchestrates. Kept separate from
the GraphQL helpers in this package because they depend on `git` and
`gh` being on PATH rather than on httpx — different runtime contract.

Every function takes the working directory explicitly so the caller
controls which repo is being operated on (dev repo vs cloned mirror).
"""
from __future__ import annotations

import shutil
import subprocess
from pathlib import Path


_GIT_TIMEOUT = 60
_GH_TIMEOUT = 120
_CLONE_TIMEOUT = 300


class ReleaseCommandError(RuntimeError):
    """Raised when a git/gh subprocess fails during a release operation.

    Carries the stderr tail and the command that produced it so the
    handler can surface useful diagnostic detail to the operator.
    """

    def __init__(self, command: list[str], returncode: int, stderr: str):
        self.command = command
        self.returncode = returncode
        self.stderr = stderr
        cmd_str = " ".join(command)
        super().__init__(
            f"Command failed (rc={returncode}): {cmd_str}\nstderr:\n{stderr}"
        )


def _run(
    cmd: list[str],
    cwd: Path | None = None,
    timeout: float = _GIT_TIMEOUT,
    check: bool = True,
) -> subprocess.CompletedProcess:
    """Run a subprocess, capturing stdout/stderr; raise on failure when `check`."""
    try:
        result = subprocess.run(
            cmd,
            cwd=cwd,
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
        )
    except FileNotFoundError as exc:
        raise ReleaseCommandError(cmd, 127, f"command not found: {exc}") from exc
    except subprocess.TimeoutExpired as exc:
        raise ReleaseCommandError(
            cmd, 124, f"command exceeded {timeout}s timeout."
        ) from exc
    if check and result.returncode != 0:
        raise ReleaseCommandError(cmd, result.returncode, result.stderr.strip())
    return result


def head_sha(cwd: Path) -> str:
    """Return the commit SHA at HEAD."""
    return _run(["git", "rev-parse", "HEAD"], cwd=cwd).stdout.strip()


def create_annotated_tag(cwd: Path, name: str, message: str) -> None:
    """Create an annotated tag at HEAD."""
    _run(["git", "tag", "-a", name, "-m", message], cwd=cwd)


def push_tag(cwd: Path, remote: str, name: str) -> None:
    _run(["git", "push", remote, name], cwd=cwd, timeout=_GH_TIMEOUT)


def push_main(cwd: Path, remote: str, *, force: bool = False) -> None:
    args = ["git", "push"]
    if force:
        args.append("--force-with-lease")
    args += [remote, "HEAD:main"]
    _run(args, cwd=cwd, timeout=_GH_TIMEOUT)


def clone_repo(url: str, dest: Path) -> None:
    """Clone `url` into `dest`. `dest` must not yet exist."""
    if dest.exists():
        raise ValueError(f"destination already exists: {dest}")
    _run(
        ["git", "clone", url, str(dest)],
        timeout=_CLONE_TIMEOUT,
    )


def list_tracked_files(cwd: Path) -> list[str]:
    """Return the relative paths of every tracked file at HEAD."""
    out = _run(["git", "ls-files"], cwd=cwd).stdout
    return [line for line in out.splitlines() if line.strip()]


def configure_user(cwd: Path, name: str, email: str) -> None:
    _run(["git", "config", "user.name", name], cwd=cwd)
    _run(["git", "config", "user.email", email], cwd=cwd)


def commit_all(cwd: Path, message: str, allow_empty: bool = False) -> str:
    """Add every working-tree change and commit with `message`. Return the commit SHA."""
    _run(["git", "add", "-A"], cwd=cwd)
    args = ["git", "commit", "-m", message]
    if allow_empty:
        args.append("--allow-empty")
    _run(args, cwd=cwd)
    return head_sha(cwd)


def create_github_release(
    cwd: Path,
    tag: str,
    title: str,
    notes_path: Path,
) -> str:
    """Run `gh release create` from `cwd`. Return the release URL."""
    out = _run(
        [
            "gh", "release", "create", tag,
            "--title", title,
            "--notes-file", str(notes_path),
        ],
        cwd=cwd,
        timeout=_GH_TIMEOUT,
    ).stdout
    return out.strip().splitlines()[-1] if out.strip() else ""


def upload_release_asset(cwd: Path, tag: str, asset_path: Path) -> None:
    """Run `gh release upload <tag> <asset>` from `cwd`.

    Idempotent failure mode: if the asset already exists on the release,
    `gh` errors and the caller decides whether to surface it.
    """
    _run(
        ["gh", "release", "upload", tag, str(asset_path)],
        cwd=cwd,
        timeout=_GH_TIMEOUT,
    )


def git_archive_tarball(
    cwd: Path, tag: str, prefix: str, dest: Path
) -> None:
    """Produce a source tarball at `dest` from `tag` with `prefix/` top dir.

    Uses `git archive --format=tar.gz` so the output is reproducible from
    the git tree (no .git, no untracked, no tooling artefacts).
    """
    _run(
        [
            "git", "archive",
            "--format=tar.gz",
            f"--prefix={prefix}/",
            "-o", str(dest),
            tag,
        ],
        cwd=cwd,
    )


def remote_main_sha(cwd: Path, remote: str) -> str | None:
    """Return remote/main's SHA, or None if the remote has no main branch."""
    result = _run(
        ["git", "ls-remote", remote, "refs/heads/main"],
        cwd=cwd,
        check=False,
        timeout=_GH_TIMEOUT,
    )
    if result.returncode != 0:
        return None
    line = result.stdout.strip().splitlines()
    if not line:
        return None
    return line[0].split()[0]


def replace_tree_contents(target: Path, source: Path) -> None:
    """Wipe `target` (preserving .git) and copy `source`'s contents into it."""
    if not target.exists():
        raise ValueError(f"target does not exist: {target}")
    for entry in target.iterdir():
        if entry.name == ".git":
            continue
        if entry.is_dir() and not entry.is_symlink():
            shutil.rmtree(entry)
        else:
            entry.unlink()
    for entry in source.iterdir():
        dest = target / entry.name
        if entry.is_dir() and not entry.is_symlink():
            shutil.copytree(entry, dest, symlinks=True)
        else:
            shutil.copy2(entry, dest, follow_symlinks=False)
