"""Assemble the request every provider strategy then takes on trust.

A strategy translates a ``WorkerExecRequest`` into its CLI's syntax and nothing
more: it does not resolve paths, does not decide what may be written, and does
not know which role it is running. Whoever builds the request answers all
three, so the answers live here — importable — rather than inside a dispatch
entrypoint where the next caller would have to copy them.
"""
from __future__ import annotations

import os
import subprocess
from pathlib import Path

from .domain.worker_exec import ExecutionPolicy, WorkerExecRequest
from .domain.worker_role import role_spec
from .write_policy import WriteEnforcement, WritePolicy

# Carried over from the codex wrapper, where it was one provider's rule: cargo's
# package-cache flock and its registry live outside every workspace, so a
# verifier bounded to the tree alone fails each `cargo build/test/clippy` on the
# global lock. The rule follows the role rather than the provider now — every
# provider's verifier receives these — so the name no longer says codex. Set it
# to a colon-separated list of absolute paths to override, or to empty to
# disable.
VERIFIER_EXTRA_DIRS_ENV = "OKSTRA_VERIFIER_EXTRA_DIRS"

_VERIFIER_ROLE = "verifier"


def build_request(
    *,
    prompt_text: str,
    model: str,
    project_root: Path,
    worktree_path: Path | None,
    role: str,
    idle_timeout_seconds: int,
    session_id: str = "",
    write_policy: WritePolicy | None = None,
    write_enforcement: WriteEnforcement | None = None,
) -> WorkerExecRequest:
    """One dispatch, with every value the strategies will not re-derive.

    Paths are resolved here because nothing downstream resolves them: the
    strategies hand what they are given straight to a CLI, so an unresolved
    `..` or symlink would survive into the sandbox boundary itself.
    """
    root = project_root.resolve()
    worktree = worktree_path.resolve() if worktree_path is not None else None
    return WorkerExecRequest(
        prompt_text=prompt_text,
        model=model,
        project_root=root,
        worktree_path=worktree,
        # Nobody is at the keyboard to answer an approval prompt, so the gate is
        # open and the boundary comes from the write scope instead.
        policy=ExecutionPolicy(
            auto_approve=True,
            write_scope=write_scope(root, worktree, role),
            write_policy=write_policy,
            write_enforcement=write_enforcement,
        ),
        idle_timeout_seconds=idle_timeout_seconds,
        session_id=session_id,
    )


def write_scope(
    project_root: Path, worktree: Path | None, role: str
) -> tuple[Path, ...]:
    """What this worker may write, in the order the CLIs are told it.

    The order is contract, not taste: strategies translate this tuple into
    repeated `--add-dir` positionally, so it mirrors what the shell wrappers
    claimed. The project root leads (the antigravity wrapper's first
    `--add-dir`; codex names it with `-C` and its strategy skips the repeat),
    then the stage tree, then the git directory that tree commits through.

    The worktree belongs here as well as in the request: passing it as the
    working directory alone produces an argv with no `--add-dir`, and the worker
    then fails by not writing files rather than by erroring.
    """
    scope = [project_root]
    if worktree is not None:
        scope.append(worktree)
        common_git_dir = git_common_dir(worktree)
        if common_git_dir is not None:
            scope.append(common_git_dir)
    scope.extend(verifier_extra_dirs(role))
    return tuple(scope)


def git_common_dir(worktree: Path) -> Path | None:
    """The main repository's `.git`, which a linked worktree commits through.

    A linked worktree keeps its index and refs under the main repo's `.git` and
    shares its object database, so a worker bounded to the tree alone cannot
    `git commit` from it. A directory that is no worktree at all simply has
    nothing extra to grant.
    """
    try:
        probe = subprocess.run(
            ["git", "-C", str(worktree), "rev-parse", "--git-common-dir"],
            capture_output=True,
            text=True,
            check=False,
        )
    except OSError:
        return None
    if probe.returncode != 0 or not probe.stdout.strip():
        return None
    candidate = Path(probe.stdout.strip())
    if not candidate.is_absolute():
        candidate = worktree / candidate
    return candidate.resolve() if candidate.is_dir() else None


def verifier_extra_dirs(role: str) -> tuple[Path, ...]:
    """Toolchain directories granted to the verifier role only.

    Role decides this, not the provider, so it is assembled here rather than in
    a strategy — and not in `WorkerRoleSpec`, which holds values that hold for
    every machine while these paths exist only where the toolchain is.
    """
    if role != _VERIFIER_ROLE:
        return ()
    home = Path.home()
    raw = os.environ.get(VERIFIER_EXTRA_DIRS_ENV, f"{home}/.cargo:{home}/.rustup")
    candidates = (Path(entry) for entry in raw.split(":") if entry)
    return tuple(path.resolve() for path in candidates if path.is_dir())


def idle_timeout(raw: str, role: str) -> int:
    """The explicit budget, or the one the role's spec owns.

    The default is not written here. The same 1500/600 pair used to live in
    every wrapper and in the python entrypoint, and each copy could drift from
    the policy. Raises ``ValueError`` on a malformed budget; the exit code that
    failure earns belongs to the entrypoint, not to this module.
    """
    if not raw:
        return role_spec(role).idle_timeout_seconds
    if not raw.isdigit():
        raise ValueError(
            f"idle-timeout-seconds must be a non-negative integer: {raw!r}"
        )
    return int(raw)
