import getpass
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path


class IsolationError(RuntimeError):
    pass


#: The orchestrator commits as itself, so a host with no global Git identity still works.
IDENTITY = ("-c", "user.email=orchestrator@local", "-c", "user.name=dual-agent")

#: Kept in .git/info/exclude so bootstrapping never edits the user's project files.
SAFE_EXCLUDES = (
    ".dual-agent/", ".env", ".env.*", "!.env.example", "!.env.sample",
    "*.pem", "*.key", "id_rsa", "id_ed25519", "*.log", "logs/",
    "uploads/", "media/", ".venv/", "venv/", "__pycache__/",
    "node_modules/", "dist/", "build/", ".cache/",
)


def running_as() -> str:
    """Who this process is, phrased the way an operator would grant access to it."""
    try:
        name = getpass.getuser()
    except Exception:  # noqa: BLE001 - no passwd entry, a container without a home, etc.
        name = "?"
    if hasattr(os, "geteuid"):
        return f"{name} (uid {os.geteuid()}, gid {os.getegid()})"
    return name


def nearest_existing(path: Path) -> Path:
    """The deepest ancestor that exists, which is where permission actually has to be."""
    current = Path(path)
    while not current.exists():
        parent = current.parent
        if parent == current:
            return current
        current = parent
    return current


def check_writable(target: Path) -> None:
    """Refuse early, and say exactly what to change.

    A permission failure surfaces from deep inside git or mkdir as `Errno 13`, which
    names neither the directory the operator has to fix nor the account that needs
    access. Diagnosing it up front turns a log dive into one copyable command.

    ponytail: `os.access`, which root bypasses entirely, so a service running as root
    sees every path as writable and falls through to the original error. That is the
    correct trade: the check exists for the service-account deployments where it works,
    and running this as root is its own problem.
    """
    target = Path(target)
    anchor = nearest_existing(target)
    needed = os.W_OK | os.X_OK if anchor.is_dir() else os.W_OK
    if os.access(anchor, needed):
        return
    who = running_as()
    hint = f"chown -R {getpass.getuser() if os.name != 'nt' else '<service-user>'} {anchor}"
    detail = "" if anchor == target else f" (the deepest existing directory on that path)"
    raise IsolationError(
        f"cannot use {target}: {anchor}{detail} is not writable by the service account "
        f"{who}. Grant it access, for example `{hint}`, or point the task at a directory "
        f"the service already owns."
    )


@dataclass
class GitWorkspace:
    runtime_name: str = ".dual-agent"
    allowed_repo_roots: tuple[Path, ...] = ()
    owner: str = ""

    def create(self, task_id: str, repo_path: Path) -> tuple[Path, str]:
        repo = Path(repo_path).resolve()
        self.ensure_repository(repo)
        baseline = self._git(repo, "rev-parse", "HEAD").strip()
        destination = repo / self.runtime_name / "worktrees" / task_id
        if destination.exists():
            raise IsolationError(f"worktree exists: {destination}")
        self._exclude_runtime(repo)
        destination.parent.mkdir(parents=True, exist_ok=True)
        result = subprocess.run(
            ["git", "-C", str(repo), "worktree", "add", "--detach", str(destination), baseline],
            capture_output=True, text=True, errors="replace",
        )
        if result.returncode:
            raise IsolationError(result.stderr.strip() or "worktree creation failed")
        return destination, baseline

    def ensure_repository(self, repo: Path) -> None:
        """Make `repo` a Git repository with at least one commit.

        A worktree can only branch from a commit, so an empty or brand new directory needs
        a baseline before any task can run. An existing repository is left completely
        alone: no re-init, no commits, no touching whatever the operator has staged.
        """
        repo = Path(repo).resolve()
        if repo.exists() and not repo.is_dir():
            raise IsolationError(f"{repo} exists but is not a directory")
        if not repo.exists():
            try:
                repo.mkdir(parents=True, exist_ok=True)
            except OSError as error:
                raise IsolationError(f"cannot create {repo}: {error}") from error
        self._ensure_writable(repo)

        if not self._is_repository(repo):
            self._run(repo, "init", "-q", failure=f"cannot initialise a repository at {repo}")

        if self._has_commit(repo):
            return  # existing history: nothing of ours belongs in it

        if self._has_content(repo):
            self._install_bootstrap_excludes(repo)
            self._run(repo, "add", "-A", failure=f"cannot stage the existing files in {repo}")
            self._run(
                repo, *IDENTITY, "commit", "-qm", "baseline: existing files",
                failure=f"cannot commit the existing files in {repo}",
            )
        else:
            self._run(
                repo, *IDENTITY, "commit", "-q", "--allow-empty", "-m", "baseline: empty repository",
                failure=f"cannot create the initial commit in {repo}",
            )

    def _ensure_writable(self, repo: Path) -> None:
        if self._is_writable(repo):
            return
        if not self.owner or not self.allowed_repo_roots:
            raise IsolationError(f"cannot use {repo}: automatic ownership repair is not configured")
        roots = tuple(Path(root).resolve() for root in self.allowed_repo_roots)
        if not any(repo == root or root in repo.parents for root in roots):
            raise IsolationError(f"cannot chown {repo}: it is outside the configured repository roots")
        self._chown(repo)
        if not self._is_writable(repo):
            raise IsolationError(f"cannot use {repo}: chown to {self.owner} completed but the path is still not writable")

    @staticmethod
    def _is_writable(repo: Path) -> bool:
        return os.access(repo, os.W_OK | os.X_OK)

    def _chown(self, repo: Path) -> None:
        if os.name == "nt":
            raise IsolationError("automatic chown is only available on POSIX hosts")
        command = ["chown", "-R", "--", self.owner, str(repo)]
        if hasattr(os, "geteuid") and os.geteuid() != 0:
            command = ["sudo", "-n", *command]
        result = subprocess.run(command, capture_output=True, text=True, errors="replace")
        if result.returncode:
            detail = (result.stderr or result.stdout).strip()
            raise IsolationError(f"cannot chown {repo} to {self.owner}: {detail or 'chown failed'}")

    @staticmethod
    def _install_bootstrap_excludes(repo: Path) -> None:
        path = repo / ".git" / "info" / "exclude"
        path.parent.mkdir(parents=True, exist_ok=True)
        current = path.read_text(encoding="utf-8") if path.exists() else ""
        lines = current.splitlines()
        additions = [pattern for pattern in SAFE_EXCLUDES if pattern not in lines]
        if not additions:
            return
        with path.open("a", encoding="utf-8", newline="\n") as stream:
            if current and not current.endswith("\n"):
                stream.write("\n")
            stream.write("\n".join(additions) + "\n")
    def remove(self, repo_path: Path, worktree: Path) -> bool:
        """Drop a finished worktree and its registration. False when git refused."""
        repo = Path(repo_path).resolve()
        result = subprocess.run(
            ["git", "-C", str(repo), "worktree", "remove", "--force", str(worktree)],
            capture_output=True, text=True,
        )
        if result.returncode:
            subprocess.run(["git", "-C", str(repo), "worktree", "prune"], capture_output=True)
        return result.returncode == 0

    # ------------------------------------------------------------------ internals

    @staticmethod
    def _is_repository(repo: Path) -> bool:
        """True only for a repository rooted here, not for one we happen to sit inside."""
        result = subprocess.run(
            ["git", "-C", str(repo), "rev-parse", "--show-toplevel"],
            capture_output=True, text=True, errors="replace",
        )
        if result.returncode:
            return False
        try:
            return Path(result.stdout.strip()).resolve() == repo.resolve()
        except OSError:
            return False

    @staticmethod
    def _has_commit(repo: Path) -> bool:
        return subprocess.run(
            ["git", "-C", str(repo), "rev-parse", "--verify", "-q", "HEAD"],
            capture_output=True,
        ).returncode == 0

    def _has_content(self, repo: Path) -> bool:
        return any(entry.name not in {".git", self.runtime_name} for entry in repo.iterdir())

    @staticmethod
    def _run(repo: Path, *args: str, failure: str) -> str:
        """Run git, surfacing its own stderr rather than a generic message."""
        result = subprocess.run(
            ["git", "-C", str(repo), *args], capture_output=True, text=True, errors="replace"
        )
        if result.returncode:
            detail = (result.stderr or result.stdout).strip()
            raise IsolationError(f"{failure}: {detail}" if detail else failure)
        return result.stdout

    def _exclude_runtime(self, repo: Path) -> None:
        raw = self._git(repo, "rev-parse", "--git-path", "info/exclude").strip()
        path = (repo / raw).resolve()
        current = path.read_text(encoding="utf-8") if path.exists() else ""
        entry = f"{self.runtime_name}/"
        if entry not in current.splitlines():
            path.parent.mkdir(parents=True, exist_ok=True)
            with path.open("a", encoding="utf-8", newline="\n") as stream:
                if current and not current.endswith("\n"):
                    stream.write("\n")
                stream.write(entry + "\n")

    @staticmethod
    def _git(repo: Path, *args: str) -> str:
        result = subprocess.run(
            ["git", "-C", str(repo), *args], capture_output=True, text=True, errors="replace"
        )
        if result.returncode:
            raise IsolationError(result.stderr.strip() or "invalid Git repository")
        return result.stdout



