"""Per-engine headless command construction and binary detection."""

from __future__ import annotations

import os
import shutil
from typing import Optional

_BINARIES = {"claude_code": "claude", "antigravity": "agy", "codex": "codex"}

# ── R1: permission tiers over the vendors' own controls ─────────────────────
#
# Sanjay, 2026-08-14: *"I'm okay with the vendor... Why build second one?
# Probably have control over which one — bypass all permission on, or sandbox
# on, things like that."* We do not build a sandbox; we expose the ones the
# engines already ship, which they test far more heavily than we could.
#
# Ordered SAFEST FIRST. The order is load-bearing: unknown or malformed input
# resolves to PERMISSION_TIERS[0], so every failure path lands on the most
# restrictive rung rather than the most permissive one.
PERMISSION_TIERS = ("careful", "standard", "full")

# The vendor's OWN full mode list, read from `claude --help` on 2026-08-15 —
# not from our usage of it. Recorded here so the next person sizing this work
# starts from what the tool offers rather than from what we happen to call.
# We currently map three of these six; see the note on _TIER_FLAGS for why the
# other three are named but not yet wired.
CLAUDE_PERMISSION_MODES_AVAILABLE = (
    "plan", "acceptEdits", "auto", "manual", "dontAsk", "bypassPermissions",
)

# ⛔ WHERE A TIER IS NOT REAL, IT RESOLVES DOWNWARD — NEVER UPWARD.
#
# Two engines have fewer than three genuine rungs, and the honest handling of
# that is the whole point of this table:
#
#   antigravity — has only a sandboxed mode and a full permission skip, with
#   nothing between. "Standard" therefore resolves to the SAFER neighbour
#   (careful), not to full bypass. A picker that offered "Standard" and quietly
#   handed over the machine would be a control that lies, and a control that
#   lies is worse than none because the user stops checking it.
#
#   codex — ships read-only, workspace-write and danger-full-access. We map
#   "full" to workspace-write and deliberately DO NOT expose danger-full-access
#   anywhere. "Full" means the most permissive tier Atashi offers, never the
#   most permissive the vendor has. workspace-write is still genuinely
#   sandboxed (seccomp/Landlock), so this costs the user nothing real.
#
# claude_code is the only engine with three true rungs today.
#
# ⛔ CORRECTED 2026-08-15 02:2x — READ THIS BEFORE TRUSTING THE CLAUDE ROW.
#
# I built the table below by reading which modes THIS REPO already used
# (`plan` and `bypassPermissions`) and inferring that was the available set.
# It is not. Asking the binary itself — `claude --help` — reports SIX:
#
#     acceptEdits · auto · bypassPermissions · manual · dontAsk · plan
#
# So `auto` and `manual` exist, and Sanjay's ask on 2026-08-15 02:09 was
# almost literally their names ("Auto mode or permission mode just like Claude
# and codex ... let's make use of whatever they have"). `auto` is very likely
# the correct "standard" rung rather than `acceptEdits`, and `manual` may be a
# genuine fourth tier worth exposing.
#
# ⛔ THE MAPPING IS DELIBERATELY LEFT ALONE ANYWAY, and that is the point.
# `--help` lists the NAMES and does not describe what each one DOES. Swapping
# `standard` to `auto` because the word sounds right would repeat the exact
# mistake this comment exists to record — inferring behaviour instead of
# observing it. Worse, headless print mode DENIES unpermitted tools rather than
# prompting (BUG-24 below), so a mode whose whole purpose is to ask could be
# functionally dead in the only way we run these engines.
#
# OWED, and it is a live drive not a code change: run each of the six modes
# headlessly against a real workspace and record what actually happens, then
# set the rungs from the measurement. Until then this table stays as shipped,
# because it is at least KNOWN — every rung here is a mode the codebase has
# genuinely run.
#
# Also unexposed and worth the same treatment: `--allowedTools` /
# `--disallowedTools` / `--tools` give declarative per-tool control decided up
# front, which is the closest thing available to per-call permission without a
# mid-run approval hook (this version exposes no `--permission-prompt-tool`).
_TIER_FLAGS: dict[str, dict[str, list[str]]] = {
    "claude_code": {
        "careful": ["--permission-mode", "plan"],
        # acceptEdits auto-approves file edits but still withholds other tools.
        "standard": ["--permission-mode", "acceptEdits"],
        # Keeps the CLI's own rm -rf circuit breakers, unlike
        # --dangerously-skip-permissions (BUG-24 reasoning, preserved).
        "full": ["--permission-mode", "bypassPermissions"],
    },
    "codex": {
        "careful": ["--sandbox", "read-only"],
        "standard": ["--sandbox", "workspace-write"],
        "full": ["--sandbox", "workspace-write"],
    },
    "antigravity": {
        "careful": ["--sandbox"],
        "standard": ["--sandbox"],
        "full": ["--dangerously-skip-permissions"],
    },
}


def default_tier() -> str:
    """The tier used when a run does not name one.

    ⛔ SHIPS AS "full", WHICH IS TODAY'S EXACT BEHAVIOUR, AND THAT IS DELIBERATE.
    The spec recommends Standard as the shipped default and that flip is right,
    but it is not safe to make unattended: on claude_code, Standard is
    acceptEdits, and headless print mode DENIES unpermitted tools instead of
    prompting (see the BUG-24 note below). A run that needs Bash could start
    failing for every user with no live drive behind the change. So the
    mechanism lands first and the default moves once a live Work drive has
    measured it — one env var, no code change, no redeploy of this logic.
    """
    raw = (os.environ.get("ATASHI_WORK_MODE_DEFAULT_TIER") or "").strip().lower()
    if raw in PERMISSION_TIERS:
        return raw
    if raw:
        # Named something we do not recognise: fail to the safest rung rather
        # than silently to the most permissive one.
        return PERMISSION_TIERS[0]
    return "full"


def resolve_tier(engine: str, tier: Optional[str]) -> str:
    """Normalise a requested tier to one this engine actually supports."""
    if tier is None:
        return default_tier()
    candidate = str(tier).strip().lower()
    if candidate not in PERMISSION_TIERS:
        return PERMISSION_TIERS[0]
    return candidate


def describe_tiers(engine: str) -> list[dict]:
    """Per-tier truth for this engine, for the settings UI to render honestly.

    ``distinct`` is False when the tier produces the same invocation as another
    one, with ``same_as`` naming it. This exists so a picker can grey the rung
    out or label it, instead of presenting three choices where the engine only
    has two.
    """
    table = _TIER_FLAGS.get(engine, {})
    out: list[dict] = []
    for tier in PERMISSION_TIERS:
        flags = table.get(tier)
        same_as = None
        for other in PERMISSION_TIERS:
            if other == tier:
                continue
            if table.get(other) == flags:
                # Name the SAFER twin when tiers collide, so the label reads
                # "same as careful" rather than "same as full" — the user is
                # told the floor they are actually getting.
                if same_as is None or PERMISSION_TIERS.index(other) < PERMISSION_TIERS.index(same_as):
                    same_as = other
        # Only report a collision against a tier that sits BELOW this one, so a
        # single pair is not reported twice from both sides.
        collided = same_as is not None and PERMISSION_TIERS.index(same_as) < PERMISSION_TIERS.index(tier)
        out.append({
            "tier": tier,
            "flags": list(flags or []),
            "distinct": not collided,
            "same_as": same_as if collided else None,
        })
    return out


def _tier_flags(engine: str, tier: str) -> list[str]:
    table = _TIER_FLAGS.get(engine, {})
    return list(table.get(tier) or table.get(PERMISSION_TIERS[0]) or [])


def known_engines() -> list[str]:
    return list(_BINARIES)


def detect_engines() -> list[str]:
    """Return the engines whose CLI binary is on PATH."""
    return [engine for engine, binary in _BINARIES.items() if shutil.which(binary)]


def build_command(
    engine: str,
    prompt: str,
    model: Optional[str],
    kind: str = "run",
    resume_session_id: Optional[str] = None,
    mcp_config_path: Optional[str] = None,
    tier: Optional[str] = None,
) -> tuple[list[str], Optional[str]]:
    """Returns (argv, stdin_payload).

    The prompt is delivered over STDIN for every engine (Phase 0 matrix,
    2026-06-11: claude reads stdin with bare -p; codex with the `-` arg; agy
    with `-p -`) — argv delivery died with the 128 KB kernel cap per argument
    and leaked the prompt into `ps` output.

    kind="plan_pass" builds a READ-ONLY invocation for drafting a work plan
    against the real workspace (claude: --permission-mode plan; codex:
    --sandbox read-only; agy: --sandbox without the permission skip).

    resume_session_id resumes the engine's prior session (claude --resume,
    codex `exec resume <id>`; agy print mode persists nothing and never gets
    one). mcp_config_path is claude-only — codex MCP rides config.toml in the
    relocated CODEX_HOME, agy has no per-run mechanism.
    """
    # R1: a plan pass is READ-ONLY by construction and always uses the safest
    # rung, whatever tier the run is configured for — otherwise merely drafting
    # a plan against the real workspace could start mutating it.
    effective_tier = (
        PERMISSION_TIERS[0] if kind == "plan_pass" else resolve_tier(engine, tier)
    )

    if engine == "claude_code":
        cmd = ["claude", "-p", "--output-format", "stream-json", "--verbose"]
        if resume_session_id:
            cmd += ["--resume", resume_session_id]
        if mcp_config_path:
            # --strict-mcp-config: ONLY the provisioned servers — never the
            # host user's personally configured ones (isolation parity with
            # OBS-10).
            cmd += ["--mcp-config", mcp_config_path, "--strict-mcp-config"]
        # BUG-24 (still the reason "full" is what it is): the OBS-10
        # per-workspace config home carries no permission rules, and headless
        # print mode DENIES unpermitted tools instead of prompting — every
        # write failed. Runs need an explicit posture (runs only ever start
        # from a W4-scanned, user-approved plan, and the workspace jail bounds
        # the cwd); bypassPermissions keeps the CLI's own rm -rf circuit
        # breakers, unlike --dangerously-skip-permissions.
        #
        # R1 turns that fixed choice into the "full" rung of a real setting.
        # The same BUG-24 mechanism is exactly why "standard" (acceptEdits) is
        # not yet the shipped default — see default_tier().
        cmd += _tier_flags(engine, effective_tier)
        if model:
            cmd += ["--model", model]
        return cmd, prompt
    if engine == "antigravity":
        # agy has no explicit read-only flag, but current builds enforce
        # --sandbox in headless print mode and only auto-approve tool
        # permission requests when --dangerously-skip-permissions is passed.
        # A plan pass therefore runs sandboxed WITHOUT the skip flag — write
        # tools cannot self-approve, which is the read-only posture that
        # unlocks engine-drafted plans for Gemini (parity with claude/codex,
        # 2026-06-11). Runs keep the skip flag as before. --model exists
        # since agy 1.0.5 (the old "no CLI flag" note was stale).
        #
        # NO -p / --print FLAG (bug fix, Pink, 2026-08-10 — measured on agy
        # 1.1.11 with a control, four variants): -p takes the prompt AS ITS
        # OWN VALUE, so the "-p -" this used to send made agy receive the
        # literal string "-" as its prompt while the REAL prompt (piped to
        # stdin below) was silently discarded. Bare `-p` with stdin prints
        # agy's own --help instead. Omitting -p entirely and just piping
        # stdin is the one variant that actually delivers the prompt.
        #
        # R1: agy has only these two rungs, so "standard" resolves DOWN to the
        # sandboxed one rather than up to the permission skip — see the table
        # in _TIER_FLAGS for why that direction is not negotiable.
        cmd = ["agy"]
        cmd += _tier_flags(engine, effective_tier)
        if model:
            cmd += ["--model", model]
        return cmd, prompt
    if engine == "codex":
        # codex-cli v0.137 (Rust rewrite): --quiet and --approval-mode are
        # gone; sandboxing is --sandbox {read-only, workspace-write,
        # danger-full-access}. Runs execute on the user's real machine with
        # cwd jailed to the workspace, so workspace-write is the right policy
        # for mutating runs. --skip-git-repo-check because workspace folders
        # are usually not git repos. --json so the run emits structured JSONL
        # (thread.started carries the session id).
        #
        # R1: danger-full-access is deliberately NOT reachable from any tier.
        cmd = ["codex", "exec"]
        if resume_session_id:
            cmd += ["resume", resume_session_id]
        cmd += ["-"] + _tier_flags(engine, effective_tier) + ["--skip-git-repo-check", "--json"]
        if model:
            cmd += ["--model", model]
        return cmd, prompt
    raise ValueError(f"Unknown engine: {engine}")
