#!/usr/bin/env python3
"""Git workspace isolation, scope fingerprinting, and patch transport."""

from __future__ import annotations

import contextlib
import hashlib
import mimetypes
import os
import subprocess
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any

from mmo_util import atomic_write_bytes


class WorkspaceTargetNotGit(RuntimeError):
    """A writable worker target has no Git repository to isolate."""


def _git(
    cwd: Path, *args: str, env: Mapping[str, str] | None = None
) -> subprocess.CompletedProcess[bytes]:
    process_env = os.environ.copy()
    if env:
        process_env.update(env)
    return subprocess.run(
        ["git", "-C", str(cwd), *args],
        stdin=subprocess.DEVNULL,
        capture_output=True,
        check=False,
        env=process_env,
    )


def _git_root(cwd: Path) -> Path | None:
    result = _git(cwd, "rev-parse", "--show-toplevel")
    if result.returncode != 0:
        return None
    root = Path(os.fsdecode(result.stdout.removesuffix(b"\n"))).resolve()
    return root if root.is_dir() else None


def _content_fingerprint(path: Path) -> str:
    digest = hashlib.sha256()
    try:
        status = path.lstat()
    except FileNotFoundError:
        return "missing"
    digest.update(f"{status.st_mode:o}\0{status.st_size}\0".encode())
    if path.is_symlink():
        digest.update(os.fsencode(os.readlink(path)))
    elif path.is_file():
        with path.open("rb") as handle:
            while chunk := handle.read(1024 * 1024):
                digest.update(chunk)
    return digest.hexdigest()


def _scope_fingerprints(repo_root: Path, cwd: Path, scopes: Sequence[str]) -> dict[str, Any]:
    repo_scopes: list[str] = []
    for scope in scopes:
        absolute = (cwd / scope).resolve(strict=False)
        try:
            repo_scopes.append(absolute.relative_to(repo_root).as_posix())
        except ValueError as exc:
            raise ValueError(f"write scope escapes Git repository: {scope}") from exc
    listed = _git(
        repo_root,
        "ls-files",
        "-co",
        "--exclude-standard",
        "-z",
        "--",
        *repo_scopes,
    )
    if listed.returncode != 0:
        raise RuntimeError(
            "unable to fingerprint write scope: "
            + listed.stderr.decode("utf-8", errors="replace")[-2000:]
        )
    paths = sorted(
        {os.fsdecode(value) for value in listed.stdout.split(b"\0") if value} | set(repo_scopes)
    )
    return {
        "repo_root": str(repo_root),
        "scope_roots": repo_scopes,
        "paths": {relative: _content_fingerprint(repo_root / relative) for relative in paths},
    }


def create_isolated_worktree(
    canonical_cwd: Path,
    directory: Path,
    scopes: Sequence[str],
    attachments: Sequence[str],
) -> dict[str, Any]:
    repo_root = _git_root(canonical_cwd)
    if repo_root is None:
        raise WorkspaceTargetNotGit("writable MCP workers require a Git worktree target")
    try:
        relative_cwd = canonical_cwd.relative_to(repo_root)
    except ValueError as exc:
        raise RuntimeError("job cwd is outside its reported Git root") from exc
    index_path = directory / "synthetic.index"
    index_env = {"GIT_INDEX_FILE": str(index_path)}
    head = _git(repo_root, "rev-parse", "--verify", "HEAD")
    if head.returncode == 0:
        read_tree = _git(repo_root, "read-tree", "HEAD", env=index_env)
    else:
        read_tree = _git(repo_root, "read-tree", "--empty", env=index_env)
    if read_tree.returncode != 0:
        raise RuntimeError("unable to initialize synthetic Git index")
    add = _git(repo_root, "add", "-A", "--", ".", env=index_env)
    if add.returncode != 0:
        raise RuntimeError(
            "unable to snapshot Git workspace: "
            + add.stderr.decode("utf-8", errors="replace")[-2000:]
        )
    tree = _git(repo_root, "write-tree", env=index_env)
    if tree.returncode != 0:
        raise RuntimeError("unable to write synthetic Git tree")
    # ``git commit-tree`` reads its log message from stdin unless one is
    # supplied. MMO normally runs beneath an interactive Codex TUI, so
    # inheriting stdin here can block a writable worker forever waiting for
    # operator input. Keep the synthetic snapshot non-interactive and
    # deterministic.
    commit_args = [
        "commit-tree",
        tree.stdout.decode("ascii").strip(),
        "-m",
        "Codex MMO isolated workspace snapshot",
    ]
    if head.returncode == 0:
        commit_args.extend(["-p", head.stdout.decode("ascii").strip()])
    commit = _git(
        repo_root,
        *commit_args,
        env={
            "GIT_AUTHOR_NAME": "Codex MMO",
            "GIT_AUTHOR_EMAIL": "codex-mmo@localhost",
            "GIT_COMMITTER_NAME": "Codex MMO",
            "GIT_COMMITTER_EMAIL": "codex-mmo@localhost",
        },
    )
    with contextlib.suppress(OSError):
        index_path.unlink()
    if commit.returncode != 0:
        raise RuntimeError(
            "unable to create synthetic Git commit: "
            + commit.stderr.decode("utf-8", errors="replace")[-2000:]
        )
    base_commit = commit.stdout.decode("ascii").strip()
    worktree_root = directory / "worktree"
    added = _git(repo_root, "worktree", "add", "--detach", str(worktree_root), base_commit)
    if added.returncode != 0:
        raise RuntimeError(
            "unable to create isolated Git worktree: "
            + added.stderr.decode("utf-8", errors="replace")[-2000:]
        )
    execution_cwd = (worktree_root / relative_cwd).resolve()
    mapped_attachments: list[str] = []
    for raw in attachments:
        canonical = Path(raw).resolve()
        mapped_attachments.append(str(worktree_root / canonical.relative_to(repo_root)))
    return {
        "canonical_cwd": str(canonical_cwd),
        "canonical_repo_root": str(repo_root),
        "worktree_root": str(worktree_root),
        "cwd": execution_cwd,
        "attachments": mapped_attachments,
        "base_commit": base_commit,
        "base_fingerprints": _scope_fingerprints(repo_root, canonical_cwd, scopes),
    }


def remove_isolated_worktree(metadata: Mapping[str, Any]) -> None:
    root_value = metadata.get("canonical_repo_root")
    worktree_value = metadata.get("worktree_root")
    if not isinstance(root_value, str) or not isinstance(worktree_value, str):
        return
    _git(Path(root_value), "worktree", "remove", "--force", worktree_value)


def _nul_paths(result: subprocess.CompletedProcess[bytes]) -> set[str]:
    if result.returncode != 0:
        return set()
    return {os.fsdecode(value) for value in result.stdout.split(b"\0") if value}


def _relative_to_job_cwd(repo_root: Path, job_cwd: Path, repo_relative: str) -> str | None:
    absolute = (repo_root / repo_relative).resolve(strict=False)
    try:
        return absolute.relative_to(job_cwd).as_posix()
    except ValueError:
        return None


def path_within_scope(relative: str, scopes: list[str]) -> bool:
    path = Path(relative)
    for raw in scopes:
        scope = Path(raw)
        if raw == "." or path == scope:
            return True
        with contextlib.suppress(ValueError):
            path.relative_to(scope)
            return True
    return False


def capture_isolated_patch(
    metadata: dict[str, Any], directory: Path, cwd: Path
) -> tuple[list[str], list[dict[str, Any]], dict[str, Any] | None]:
    """Capture one scope-checked binary patch from a disposable worker worktree."""

    worktree_value = metadata.get("worktree_root")
    base_commit = metadata.get("base_commit")
    if not isinstance(worktree_value, str) or not isinstance(base_commit, str):
        return ["writable worker lacks isolated worktree metadata"], [], None
    worktree_root = Path(worktree_value)
    staged = _git(worktree_root, "add", "-A", "--", ".")
    if staged.returncode != 0:
        return ["unable to stage isolated worker changes"], [], None
    names = _git(
        worktree_root,
        "diff",
        "--cached",
        "--no-renames",
        "--name-only",
        "-z",
        base_commit,
        "--",
    )
    if names.returncode != 0:
        return ["unable to enumerate isolated worker changes"], [], None
    changed = sorted(_nul_paths(names))
    violations: list[str] = []
    relative_changes: list[tuple[str, str]] = []
    scopes = list(metadata.get("write_scope", []))
    for repo_relative in changed:
        relative = _relative_to_job_cwd(worktree_root, cwd, repo_relative)
        if relative is None or not path_within_scope(relative, scopes):
            violations.append(repo_relative)
        else:
            relative_changes.append((repo_relative, relative))
    if violations:
        return ["out-of-scope mutation: " + ", ".join(violations[:40])], [], None
    patch_result = _git(
        worktree_root,
        "diff",
        "--cached",
        "--binary",
        "--full-index",
        "--no-renames",
        base_commit,
        "--",
    )
    if patch_result.returncode != 0:
        return ["unable to produce isolated binary patch"], [], None
    patch_path = directory / "changes.patch"
    atomic_write_bytes(patch_path, patch_result.stdout, 0o600)
    artifacts: list[dict[str, Any]] = []
    for repo_relative, relative in relative_changes:
        path = worktree_root / repo_relative
        if not path.is_file():
            artifacts.append(
                {
                    "relative_path": relative,
                    "sha256": hashlib.sha256(b"").hexdigest(),
                    "size": 0,
                    "media_type": "application/x-deleted",
                    "state": "deleted",
                }
            )
            continue
        content = path.read_bytes()
        media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
        artifacts.append(
            {
                "relative_path": relative,
                "sha256": hashlib.sha256(content).hexdigest(),
                "size": len(content),
                "media_type": media_type,
                "state": "present",
            }
        )
    patch = {
        "path": str(patch_path),
        "sha256": hashlib.sha256(patch_result.stdout).hexdigest(),
        "size": len(patch_result.stdout),
        "base_commit": base_commit,
        "base_fingerprints": metadata.get("base_fingerprints"),
        "changed_paths": [relative for _repo, relative in relative_changes],
    }
    return [], artifacts, patch


def apply_validated_patch(
    *,
    canonical_cwd: Path,
    repo_root: Path,
    scopes: Sequence[str],
    base_fingerprints: Any,
    patch_path: Path,
) -> None:
    """Verify a worker snapshot boundary and apply its already-authenticated patch."""

    if _git_root(canonical_cwd) != repo_root:
        raise RuntimeError("canonical Git repository identity changed before integration")
    current = _scope_fingerprints(repo_root, canonical_cwd, scopes)
    if current != base_fingerprints:
        raise RuntimeError(
            "canonical write scope changed after worker snapshot; integration refused"
        )
    check = _git(repo_root, "apply", "--check", "--binary", str(patch_path))
    if check.returncode != 0:
        raise RuntimeError(
            "git apply --check rejected worker patch: "
            + check.stderr.decode("utf-8", errors="replace")[-2000:]
        )
    applied = _git(repo_root, "apply", "--binary", str(patch_path))
    if applied.returncode != 0:
        raise RuntimeError(
            "worker patch integration failed: "
            + applied.stderr.decode("utf-8", errors="replace")[-2000:]
        )


def reverse_applied_patch(repo_root: Path, patch_path: Path) -> None:
    """Reverse one patch after its lifecycle publication fails."""

    rollback = _git(repo_root, "apply", "--reverse", "--binary", str(patch_path))
    if rollback.returncode != 0:
        raise RuntimeError(rollback.stderr.decode("utf-8", errors="replace")[-2000:])
