"""Git worktree lifecycle: one clean checkout per trial.

docs/03-architecture.md — worktrees give perfect filesystem isolation for the
thing agents actually mutate. They do not isolate the network, installed
system packages, or a hostile agent's ability to touch $HOME; that is a
documented v1 limitation, not something this module tries to paper over.

Deferred: the env-cache (hardlinking node_modules/.venv across trials that
share a base_commit + lockfile hash) described in docs/03-architecture.md.
`setup` re-runs on every trial. It is a wall-clock optimization, not a
correctness requirement.

It stays deferred for a reason worth writing down, because "we just haven't
got to it" would be the wrong summary. Hardlinks share an inode: any tool that
rewrites a file *in place* inside one worktree changes it inside every other
worktree sharing the cache. Most package managers replace files rather than
mutating them, so this would work almost all of the time — and the failure
mode when it did not would be one trial silently altering another's
environment.

That is precisely the isolation this module exists to guarantee (ADR-0002),
and the resulting contamination would look like agent non-determinism rather
than like a bug: noise in the very measurement the tool is built to make. A
wall-clock win is not worth buying with a correctness risk that the statistics
layer would faithfully report as signal.

If it is built, it needs copy-on-write cloning (`cp --reflink`, APFS
`clonefile`) rather than hardlinks, with a hardlink fallback only on
filesystems that support neither — and a test that mutates a cached file in
one worktree and asserts the others are untouched.
"""

from __future__ import annotations

import shutil
import subprocess
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path

from ablate.errors import AblateError
from ablate.gitutil import run_git, worktree_add, worktree_remove

# `git worktree add/remove` mutates shared metadata under .git/worktrees and
# races under concurrency (docs/03-architecture.md runs trials in a thread
# pool). Serialize just the checkout/teardown; the expensive part of a trial
# — the agent invocation — still runs fully in parallel outside this lock.
_WORKTREE_LOCK = threading.Lock()


@dataclass
class Worktree:
    trial_id: str
    path: Path
    base_commit: str


def _run_setup(setup_cmd: str, cwd: Path, timeout_s: int = 1800) -> None:
    proc = subprocess.run(
        setup_cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=timeout_s
    )
    if proc.returncode != 0:
        raise AblateError(
            f"setup command failed ({proc.returncode}): {setup_cmd}\n{proc.stderr[-2000:]}"
        )


@contextmanager
def make_worktree(
    repo: Path,
    trial_id: str,
    base_commit: str,
    worktrees_root: Path,
    setup: str | None = None,
    keep: bool = False,
) -> Iterator[Worktree]:
    """Checkout a detached worktree at base_commit, run setup once, yield it,
    then remove it (unless `keep`)."""
    worktrees_root.mkdir(parents=True, exist_ok=True)
    path = worktrees_root / trial_id
    if path.exists():
        shutil.rmtree(path, ignore_errors=True)

    with _WORKTREE_LOCK:
        worktree_add(repo, path, base_commit)
    try:
        if setup:
            _run_setup(setup, path)
        yield Worktree(trial_id=trial_id, path=path, base_commit=base_commit)
    finally:
        with _WORKTREE_LOCK:
            if not keep:
                worktree_remove(repo, path)
            # Stale worktree metadata (a removed dir that git still thinks is
            # registered) breaks the *next* `worktree add`. Cheap to always run.
            run_git(["worktree", "prune"], cwd=repo, check=False)
