from __future__ import annotations

import json
import os
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Callable

import remote_dispatch
import containment
from stall_supervisor import supervise

from account_lock import (
    AccountLockError,
    AccountLockStore,
    AccountLockedError,
    AccountUseAuthorization,
    HumanOverrideError,
    authorize_account_use,
    revalidate_account_use,
)
from account_registry import (
    HEALTH_CACHE_FILENAMES,
    ROUTING_RULES_FILENAMES,
    Account,
    AccountRegistry,
    AccountRegistryKind,
    AuthorityMode,
)
from authority_client import (
    AuthorityConfigurationError,
    AuthorityLaunch,
    build_authority_launch,
    build_credentialless_info_environment,
    gateway_health_snapshot,
)
from health_client import AccountHealthClient, AccountSnapshot
from health_store import HealthSnapshotStore
from model_catalog import MODEL_ALIASES
from routing_resolver import (
    AllAccountsCappedError,
    NoHealthyAccountError,
    ResolvedRoute,
    RoutingResolver,
    RoutingRules,
    load_rules,
)
from runtime_paths import runtime_dir
from spend_cap import capped_windows


HEALTH_STALE_SECONDS = 60 * 60


def _format_duration(seconds: float) -> str:
    total = int(seconds)
    hours, remainder = divmod(total, 3600)
    minutes, secs = divmod(remainder, 60)
    if hours:
        return f"{hours}h{minutes:02d}m{secs:02d}s"
    if minutes:
        return f"{minutes}m{secs:02d}s"
    return f"{seconds:.1f}s"


def _format_size(num_bytes: int) -> str:
    size = float(num_bytes)
    for unit in ("B", "KB", "MB", "GB"):
        if size < 1024 or unit == "GB":
            return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
        size /= 1024
    return f"{size:.1f} GB"


def _result_transport_exit_code(returncode: int, sync_error: str | None) -> int:
    if sync_error is not None and returncode == 0:
        return remote_dispatch.EXIT_MIRROR
    return returncode


def _redact_credential_path(
    message: str,
    credential: remote_dispatch.Credential | None,
) -> str:
    if credential is None:
        return message
    return message.replace(str(credential.path), "<credential-path>")


def _append_containment_record(
    tool: str,
    runtime: str,
    mode: str,
    exit_code: int,
    started: float,
    account: remote_dispatch.Credential | None,
    log_path: Path | None,
    reason: str | None = None,
    host: str | None = None,
    workspace: str | None = None,
    sandbox_id: str | None = None,
) -> None:
    containment.append_record(
        tool, runtime, mode, exit_code, started, host=host, workspace=workspace,
        sandbox_id=sandbox_id, account=account.slug if account is not None else None,
        log_path=log_path, reason=reason if mode == "aborted" else None,
    )


@dataclass(frozen=True)
class SelectedDefault:
    slug: str
    base_dir: Path


def detect_project_name(cwd: Path | None = None) -> str:
    current_dir = Path.cwd() if cwd is None else cwd
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
            check=False,
            capture_output=True,
            text=True,
            cwd=current_dir,
        )
    except OSError:
        return current_dir.name

    if result.returncode == 0:
        common_dir = result.stdout.strip()
        if common_dir:
            common_path = Path(common_dir)
            return common_path.parent.name if common_path.name == ".git" else common_path.name
    try:
        result = subprocess.run(["git", "rev-parse", "--show-toplevel"], check=False, capture_output=True, text=True, cwd=current_dir)
    except OSError:
        return current_dir.name
    if result.returncode == 0 and result.stdout.strip():
        return Path(result.stdout.strip()).name
    return current_dir.name


def parse_account_override(argv: list[str]) -> tuple[str | None, list[str]]:
    slug: str | None = None
    remaining: list[str] = []
    i = 0
    while i < len(argv):
        arg = argv[i]
        if arg.startswith("--account=") or arg.startswith("--profile="):
            slug = arg.split("=", 1)[1]
        elif arg in {"--account", "--profile"}:
            i += 1
            if i >= len(argv):
                raise ValueError(f"{arg} requires a value")
            slug = argv[i]
        else:
            remaining.append(arg)
        i += 1
    return slug, remaining


def load_health(
    path: Path | None = None,
    *,
    stale_after_s: int = HEALTH_STALE_SECONDS,
    tool_name: str = "cdx",
    health_snapshot_tool: str = "codex",
) -> dict[str, AccountSnapshot]:
    if path is None:
        path = runtime_dir() / "health_cache.json"
    snapshots = HealthSnapshotStore(path, stale_after_s).read_fresh()
    if snapshots is None:
        print(
            f"{tool_name}: health cache missing or stale; proceeding without health data",
            file=sys.stderr,
        )
        return {}
    return _tool_health_snapshots(health_snapshot_tool, snapshots)


def _tool_health_snapshots(
    tool: str,
    snapshots: dict[str, AccountSnapshot],
) -> dict[str, AccountSnapshot]:
    prefix = f"{tool}:"
    normalized: dict[str, AccountSnapshot] = {}
    for key, snapshot in snapshots.items():
        if key.startswith(prefix):
            normalized[key[len(prefix) :]] = snapshot
        elif ":" not in key:
            normalized[key] = snapshot
    return normalized


_CODEX_GLOBAL_OPTIONS_WITH_VALUE = frozenset({
    "-a", "--ask-for-approval", "-c", "--config", "-m", "--model",
    "-p", "--profile", "-s", "--sandbox",
})


def is_exec_invocation(forwarded_argv: list[str]) -> bool:
    index = 0
    while index < len(forwarded_argv):
        arg = forwarded_argv[index]
        if arg == "--":
            return False
        if arg in _CODEX_GLOBAL_OPTIONS_WITH_VALUE:
            index += 2
            continue
        if arg.startswith("-"):
            index += 1
            continue
        return arg == "exec"
    return False


INFO_ONLY_FLAGS = frozenset({"--version", "-V", "--help", "-h"})


def is_info_only_invocation(forwarded_argv: list[str]) -> bool:
    return bool(forwarded_argv) and all(arg in INFO_ONLY_FLAGS for arg in forwarded_argv)


def _writable_dir(*candidates: Path) -> Path | None:
    for candidate in candidates:
        try:
            candidate.mkdir(parents=True, exist_ok=True)
            with tempfile.NamedTemporaryFile(dir=candidate):
                pass
        except OSError:
            continue
        return candidate
    return None


STANDARD_EFFORTS = {"low": "low", "med": "medium", "medium": "medium", "high": "high"}
EXTENDED_EFFORTS = ("xhigh", "max")
EXTENDED_EFFORT_MODEL = "gpt-5.6-luna"


def allowed_efforts(model: str | None) -> tuple[str, ...]:
    """Efforts `cdx exec` accepts for a model: a model's catalog capability
    narrowed by the wrapper's cost policy, which keeps the top tiers on the
    cheapest model. Callers validating ahead of the wrapper intersect with this.
    """
    base = ("low", "medium", "high")
    return base + EXTENDED_EFFORTS if model == EXTENDED_EFFORT_MODEL else base


def _resolve_model_alias(token: str) -> str:
    resolved = MODEL_ALIASES.get(token)
    if resolved is not None:
        return resolved
    if token and not any(separator in token for separator in ("-", ".", "/")):
        raise ValueError(
            f"unknown model alias '{token}' (valid aliases: {', '.join(MODEL_ALIASES)})"
        )
    return token


def resolve_model_aliases(argv: list[str]) -> list[str]:
    """Resolve model aliases before exec flag indexes are collected."""
    resolved_argv = list(argv)
    index = 0
    while index < len(resolved_argv):
        argument = resolved_argv[index]
        if argument == "--":
            break
        if argument in {"-m", "--model"}:
            if index + 1 < len(resolved_argv):
                resolved_argv[index + 1] = _resolve_model_alias(resolved_argv[index + 1])
            index += 2
            continue
        if argument.startswith("-m=") or argument.startswith("--model="):
            flag, separator, token = argument.partition("=")
            resolved_argv[index] = f"{flag}{separator}{_resolve_model_alias(token)}"
            index += 1
            continue
        if argument.startswith("-m") and len(argument) > 2:
            resolved_argv[index] = f"-m{_resolve_model_alias(argument[2:])}"
        index += 1
    return resolved_argv


def normalize_codex_exec_flags(forwarded_argv: list[str]) -> list[str]:
    """Own the `cdx exec` plumbing flags in the wrapper so callers (skills,
    agents) never pass them and cannot get them wrong:

    - forces `--sandbox danger-full-access`, stripping any restrictive
      `-s`/`--sandbox` the caller supplied. `workspace-write` makes
      `~/.claude/run/local-gate` read-only, which EROFSes the cpu-guard→
      local-gate shim before any gate runs; `read-only` blocks writes. A
      caller-supplied `--dangerously-bypass-approvals-and-sandbox` is a
      strict superset (also bypasses approvals) and is left intact.
    - injects `--skip-git-repo-check` when absent (codex exec refuses to run
      outside a git checkout otherwise).

    - validates and canonicalizes model reasoning effort at the wrapper boundary,
      applying defaults only for the three known models.
    """
    if not is_exec_invocation(forwarded_argv):
        return forwarded_argv
    forwarded_argv = resolve_model_aliases(forwarded_argv)

    model_defaults = {
        "gpt-5.6-sol": "low",
        "gpt-5.6-terra": "medium",
        "gpt-5.6-luna": "high",
    }
    standard_efforts = STANDARD_EFFORTS
    effort_entries: list[tuple[int, int, str, bool, str]] = []
    model: str | None = None
    i = 0
    while i < len(forwarded_argv):
        arg = forwarded_argv[i]
        if arg == "--":
            break
        if arg in {"-m", "--model", "-c", "--config"}:
            if (
                i + 1 >= len(forwarded_argv)
                or not forwarded_argv[i + 1]
                or forwarded_argv[i + 1].startswith("-")
            ):
                raise ValueError(f"{arg} requires a value")
            value = forwarded_argv[i + 1]
            if arg in {"-m", "--model"}:
                model = value
            else:
                key, separator, effort = value.partition("=")
                if key == "model_reasoning_effort" and not separator:
                    raise ValueError("model_reasoning_effort requires a value")
                if separator and key == "model_reasoning_effort":
                    effort_entries.append((i, i + 2, arg, True, effort))
            i += 2
            continue

        inline_model_flag = next(
            (flag for flag in ("-m", "--model") if arg.startswith(f"{flag}=")),
            None,
        )
        if inline_model_flag is not None:
            value = arg[len(inline_model_flag) + 1 :]
            if not value:
                raise ValueError(f"{inline_model_flag} requires a value")
            model = value
            i += 1
            continue

        inline_config_flag = next(
            (flag for flag in ("-c", "--config") if arg.startswith(f"{flag}=")),
            None,
        )
        if inline_config_flag is not None:
            value = arg[len(inline_config_flag) + 1 :]
            if not value:
                raise ValueError(f"{inline_config_flag} requires a value")
            key, separator, effort = value.partition("=")
            if key == "model_reasoning_effort" and not separator:
                raise ValueError("model_reasoning_effort requires a value")
            if separator and key == "model_reasoning_effort":
                effort_entries.append((i, i + 1, inline_config_flag, False, effort))
            i += 1
            continue

        if arg.startswith("-m") and len(arg) > 2:
            model = arg[2:]
            i += 1
            continue

        if arg.startswith("-c") and len(arg) > 2:
            value = arg[2:]
            key, separator, effort = value.partition("=")
            if key == "model_reasoning_effort" and not separator:
                raise ValueError("model_reasoning_effort requires a value")
            if separator and key == "model_reasoning_effort":
                effort_entries.append((i, i + 1, "-c", False, effort))
            i += 1
            continue
        i += 1

    effective_model = model if model is not None else "<unspecified>"
    effort_addition: list[str] = []
    if effort_entries:
        raw_effort = effort_entries[-1][4]
        if raw_effort in standard_efforts:
            canonical_effort = standard_efforts[raw_effort]
        elif raw_effort in EXTENDED_EFFORTS and model == EXTENDED_EFFORT_MODEL:
            canonical_effort = raw_effort
        elif raw_effort in EXTENDED_EFFORTS:
            raise ValueError(
                f"unsupported reasoning effort '{raw_effort}' for model '{effective_model}'"
            )
        else:
            raise ValueError(
                f"invalid reasoning effort '{raw_effort}' for model '{effective_model}'"
            )

        final_start, _, final_flag, final_separate, _ = effort_entries[-1]
        effort_indexes = {
            index
            for start, end, _, _, _ in effort_entries
            for index in range(start, end)
        }
        normalized: list[str] = []
        for index, arg in enumerate(forwarded_argv):
            if index == final_start:
                config_entry = f"model_reasoning_effort={canonical_effort}"
                normalized.extend(
                    [final_flag, config_entry]
                    if final_separate
                    else [f"{final_flag}={config_entry}"]
                )
            if index in effort_indexes:
                continue
            normalized.append(arg)
        forwarded_argv = normalized
    elif model in model_defaults:
        effort_addition = [
            "-c",
            f"model_reasoning_effort={model_defaults[model]}",
        ]

    options_end = forwarded_argv.index("--") if "--" in forwarded_argv else len(forwarded_argv)
    has_bypass = (
        "--dangerously-bypass-approvals-and-sandbox" in forwarded_argv[:options_end]
    )
    kept: list[str] = []
    drop_value = False
    for index, arg in enumerate(forwarded_argv):
        if index >= options_end:
            kept.append(arg)
            continue
        if drop_value:
            drop_value = False
            continue
        if not has_bypass and arg in {"--sandbox", "-s"}:
            drop_value = True
            continue
        if not has_bypass and (
            arg.startswith("--sandbox=") or (arg.startswith("-s") and len(arg) > 2)
        ):
            continue
        kept.append(arg)
    additions: list[str] = []
    if not has_bypass:
        additions += ["--sandbox", "danger-full-access"]
    if "--skip-git-repo-check" not in kept:
        additions.append("--skip-git-repo-check")
    additions += effort_addition
    exec_index = kept.index("exec")
    return [*kept[:exec_index + 1], *additions, *kept[exec_index + 1:]]


def _dedupe_paths(paths: list[Path]) -> list[Path]:
    unique: list[Path] = []
    for path in paths:
        if path not in unique:
            unique.append(path)
    return unique


def load_selected_default_from_dirs(
    base_dirs: list[Path],
    default_filename: str | None,
    accounts_dir_name: str,
    account_home_name: str,
) -> SelectedDefault | None:
    if default_filename is None:
        return None

    selected: tuple[float, int, str, Path] | None = None
    for index, base_dir in enumerate(base_dirs):
        default_path = base_dir / default_filename
        try:
            slug = default_path.read_text(encoding="utf-8").strip()
        except OSError:
            continue
        if not slug:
            continue

        account_home = base_dir / accounts_dir_name / slug / account_home_name
        if not account_home.exists():
            continue

        try:
            mtime = os.path.getmtime(default_path)
        except OSError:
            mtime = 0.0

        candidate = (mtime, -index, slug, base_dir)
        if selected is None or candidate > selected:
            selected = candidate

    if selected is None:
        return None
    return SelectedDefault(slug=selected[2], base_dir=selected[3])


def routing_base_dir(base_dirs: list[Path], selected_default: SelectedDefault | None, routing_rules_filename: str) -> Path:
    if selected_default is not None:
        return selected_default.base_dir
    for base_dir in base_dirs:
        if (base_dir / routing_rules_filename).exists():
            return base_dir
    return base_dirs[0]


def apply_selected_default(rules: RoutingRules, selected_default: str | None) -> RoutingRules:
    if selected_default is None:
        return rules
    return replace(
        rules,
        projects={},
        default=selected_default,
        fallback_trigger="broken_only",
    )


def _override_account_spend_capped(resolver: RoutingResolver, slug: str) -> bool:
    caps = resolver.account_caps(slug)
    if not caps:
        return False
    snapshot = resolver.health.get(slug)
    if snapshot is None:
        return False
    return bool(capped_windows(snapshot, caps))


class ToolAdapter:
    def __init__(
        self,
        *,
        tool_name: str,
        exec_name: str,
        accounts_dir_name: str,
        account_home_name: str,
        routing_rules_filename: str,
        health_cache_filename: str,
        health_cache_stale_seconds: int,
        health_snapshot_tool: str,
        registry_kind: AccountRegistryKind,
        config_env_var: str,
        selected_default_filename: str | None = None,
        legacy_tray_dir_name: str | None = None,
    ) -> None:
        self.tool_name = tool_name
        self.exec_name = exec_name
        self.accounts_dir_name = accounts_dir_name
        self.account_home_name = account_home_name
        self.routing_rules_filename = routing_rules_filename
        self.health_cache_filename = health_cache_filename
        self.health_cache_stale_seconds = health_cache_stale_seconds
        self.health_snapshot_tool = health_snapshot_tool
        self.registry_kind = registry_kind
        self.config_env_var = config_env_var
        self.selected_default_filename = selected_default_filename
        self.legacy_tray_dir_name = legacy_tray_dir_name

    def tray_dir(self) -> Path:
        return runtime_dir()

    def candidate_tray_dirs(self) -> list[Path]:
        dirs = [self.tray_dir()]
        if self.legacy_tray_dir_name is not None:
            dirs.append(Path.home() / self.legacy_tray_dir_name)
        return _dedupe_paths(dirs)

    def account_root(self, base_dir: Path) -> Path:
        return base_dir / self.accounts_dir_name

    def account_home(self, base_dir: Path, slug: str) -> Path:
        return self.account_root(base_dir) / slug / self.account_home_name

    def routing_rules_path(self, base_dir: Path) -> Path:
        return base_dir / self.routing_rules_filename

    def health_cache_path(self, base_dir: Path) -> Path:
        return base_dir / self.health_cache_filename

    def lock_policy_dir(self) -> Path:
        return self.tray_dir()

    def locked_slugs(self) -> frozenset[str]:
        return AccountLockStore(self.lock_policy_dir()).locked_slugs(
            self.registry_kind.value
        )

    def known_slugs(self, base_dir: Path) -> set[str]:
        root = self.account_root(base_dir)
        try:
            entries = list(root.iterdir())
        except OSError:
            return set()

        known: set[str] = set()
        for entry in entries:
            if not entry.is_dir():
                continue
            account_home = entry / self.account_home_name
            if account_home.exists():
                known.add(entry.name)
        return known

    def resolve_profile_token(self, base_dir: Path, token: str) -> str | None:
        token = token.strip()
        if not token:
            return None
        known = self.known_slugs(base_dir)
        if token in known:
            return token
        registry = AccountRegistry(base_dir=base_dir, kind=self.registry_kind)
        resolved = registry.resolve_profile_token(token)
        return resolved if resolved in known else None

    def selected_default(self, base_dirs: list[Path]) -> SelectedDefault | None:
        return load_selected_default_from_dirs(
            base_dirs,
            self.selected_default_filename,
            self.accounts_dir_name,
            self.account_home_name,
        )

    def account(self, base_dir: Path, slug: str) -> Account | None:
        registry = AccountRegistry(base_dir=base_dir, kind=self.registry_kind)
        return next((account for account in registry.list() if account.slug == slug), None)

    def sync_before_exec(self, base_dir: Path) -> list[Path]:
        return []

    def capture_log_path(self, forwarded_argv: list[str]) -> Path | None:
        return None

    def build_env(self, account_home: Path) -> dict[str, str]:
        return {self.config_env_var: str(account_home)}

    def verify_env_support(self, account_home: Path) -> bool:
        return True

    def credential(self, env: dict[str, str]) -> remote_dispatch.Credential | None:
        return None

    def fetch_health(self, base_dir: Path, slug: str) -> AccountSnapshot:
        return AccountHealthClient().fetch(self.account_home(base_dir, slug))

    def notify_fallback(self, fallback_from: str, slug: str) -> None:
        try:
            subprocess.Popen(
                [
                    "notify-send",
                    self.tool_name,
                    f"{fallback_from} unavailable, using {slug} instead",
                ]
            )
        except OSError:
            pass

    def format_unknown_account(self, slug: str) -> str:
        return f"{self.tool_name}: unknown account '{slug}'"


class CodexAdapter(ToolAdapter):
    def __init__(self) -> None:
        super().__init__(
            tool_name="cdx",
            exec_name="codex",
            accounts_dir_name="accounts",
            account_home_name="CODEX_HOME",
            routing_rules_filename=ROUTING_RULES_FILENAMES[AccountRegistryKind.CODEX],
            health_cache_filename=HEALTH_CACHE_FILENAMES[AccountRegistryKind.CODEX],
            health_cache_stale_seconds=HEALTH_STALE_SECONDS,
            health_snapshot_tool="codex",
            registry_kind=AccountRegistryKind.CODEX,
            config_env_var="CODEX_HOME",
            selected_default_filename="default_slug",
            legacy_tray_dir_name=".codex-tray",
        )

    def sync_before_exec(self, base_dir: Path) -> list[Path]:
        return AccountRegistry(
            base_dir=base_dir,
            legacy_codex_home=Path.home() / ".codex",
        ).sync_all_shared_links()

    def capture_log_path(self, forwarded_argv: list[str]) -> Path | None:
        if not is_exec_invocation(forwarded_argv):
            return None
        logs_dir = _writable_dir(
            self.tray_dir() / "logs" / self.tool_name,
            Path(tempfile.gettempdir()) / f"{self.tool_name}-logs",
        )
        if logs_dir is None:
            print(
                f"{self.tool_name}: no writable log directory; running without capture",
                file=sys.stderr,
            )
            return None
        stamp = time.strftime("%Y%m%d-%H%M%S")
        return logs_dir / f"{stamp}-{os.getpid()}.log"

    def build_env(self, account_home: Path) -> dict[str, str]:
        sqlite_home = account_home.parent / "SQLITE_HOME"
        return {
            "CODEX_HOME": str(account_home),
            "CODEX_SQLITE_HOME": str(sqlite_home),
        }

    def credential(self, env: dict[str, str]) -> remote_dispatch.Credential | None:
        home = env.get("CODEX_HOME")
        if not home:
            return None
        path = Path(home) / "auth.json"
        return remote_dispatch.Credential(
            runtime="codex",
            slug=Path(home).parent.name or None,
            path=path,
        )


class ClaudeAdapter(ToolAdapter):
    def __init__(self) -> None:
        super().__init__(
            tool_name="cld",
            exec_name="claude",
            accounts_dir_name="claude-accounts",
            account_home_name="CLAUDE_HOME",
            routing_rules_filename=ROUTING_RULES_FILENAMES[AccountRegistryKind.CLAUDE],
            health_cache_filename=HEALTH_CACHE_FILENAMES[AccountRegistryKind.CLAUDE],
            health_cache_stale_seconds=2 * 60 * 60,
            health_snapshot_tool="claude",
            registry_kind=AccountRegistryKind.CLAUDE,
            config_env_var="CLAUDE_CONFIG_DIR",
            selected_default_filename="claude_default_slug",
        )

    def build_env(self, account_home: Path) -> dict[str, str]:
        return {
            "CLAUDE_CONFIG_DIR": str(account_home),
            "SYSTRAY_CLAUDE_ACCOUNT_HOME": str(account_home),
            "SYSTRAY_CLAUDE_ACCOUNT_SLUG": account_home.parent.name,
        }

    def sync_before_exec(self, base_dir: Path) -> list[Path]:
        return AccountRegistry(
            base_dir=base_dir,
            kind=AccountRegistryKind.CLAUDE,
        ).sync_all_shared_links()

    def credential(self, env: dict[str, str]) -> remote_dispatch.Credential | None:
        home = env.get("CLAUDE_CONFIG_DIR")
        if not home:
            return None
        return remote_dispatch.Credential(
            runtime="claude",
            slug=Path(home).parent.name or None,
            path=Path(home) / ".credentials.json",
        )

    def fetch_health(self, base_dir: Path, slug: str) -> AccountSnapshot:
        from claude_health_client import ClaudeHealthClient

        registry = AccountRegistry(base_dir=base_dir, kind=AccountRegistryKind.CLAUDE)
        account = next((item for item in registry.list() if item.slug == slug), None)
        if account is None:
            raise KeyError(slug)
        return ClaudeHealthClient().fetch(account)


def account_spend_cap_block(adapter: ToolAdapter, slug: str) -> AllAccountsCappedError | None:
    base_dirs = adapter.candidate_tray_dirs()
    selected_default = adapter.selected_default(base_dirs)
    base_dir = routing_base_dir(base_dirs, selected_default, adapter.routing_rules_filename)
    known_slugs = adapter.known_slugs(base_dir)
    if slug not in known_slugs:
        return None
    try:
        rules = load_rules(adapter.routing_rules_path(base_dir))
    except (OSError, json.JSONDecodeError, ValueError):
        return None
    health = load_health(
        adapter.health_cache_path(base_dir),
        stale_after_s=adapter.health_cache_stale_seconds,
        tool_name=adapter.tool_name,
        health_snapshot_tool=adapter.health_snapshot_tool,
    )
    resolver = RoutingResolver(rules, health, known_slugs=known_slugs)
    if not _override_account_spend_capped(resolver, slug):
        return None
    return AllAccountsCappedError(resolver.earliest_cap_resume_at((slug,)))


class CommandRouter:
    def __init__(
        self,
        adapter: ToolAdapter,
        *,
        detect_project_name: Callable[[Path | None], str] = detect_project_name,
        parse_account_override: Callable[[list[str]], tuple[str | None, list[str]]] = parse_account_override,
    ) -> None:
        self.adapter = adapter
        self._detect_project_name = detect_project_name
        self._parse_account_override = parse_account_override
        self._last_gateway_status: str | None = None

    def main(self, argv: list[str]) -> int:
        started = time.monotonic()
        route_query = len(argv) >= 2 and argv[1] == "route"
        if route_query:
            argv = [argv[0], *argv[2:]]
        if any(arg.startswith("--human-override-lock=") for arg in argv):
            print(
                f"{self.adapter.tool_name}: --human-override-lock does not take a value",
                file=sys.stderr,
            )
            return 1
        human_override_lock, argv = self._extract_flag(argv, "--human-override-lock")
        canonical_query = False
        if route_query:
            canonical_query, argv = self._extract_flag(argv, "--canonical")
        show_status, usage_json, argv = self._parse_status_flag(argv)
        try:
            account_override, forwarded_argv = self._parse_account_override(argv[1:])
        except ValueError as exc:
            print(f"{self.adapter.tool_name}: {exc}", file=sys.stderr)
            return 1

        if route_query and human_override_lock:
            print(
                f"{self.adapter.tool_name}: --human-override-lock is not valid for route queries",
                file=sys.stderr,
            )
            return 1
        if human_override_lock and account_override is None:
            return self._emit_lock_refusal(
                HumanOverrideError(
                    "--human-override-lock requires an explicit --account"
                )
            )
        if human_override_lock and len(
            tuple(part.strip() for part in account_override.split(",") if part.strip())
        ) != 1:
            return self._emit_lock_refusal(
                HumanOverrideError(
                    "--human-override-lock requires exactly one explicit --account"
                )
            )
        if canonical_query:
            return self._emit_canonical_profile(account_override)

        try:
            if self.adapter.tool_name == "cdx":
                forwarded_argv = normalize_codex_exec_flags(forwarded_argv)
        except ValueError as exc:
            print(f"{self.adapter.tool_name}: {exc}", file=sys.stderr)
            return 1

        info_only = is_info_only_invocation(forwarded_argv)
        if info_only and not route_query:
            try:
                env = build_credentialless_info_environment(
                    self.adapter.tray_dir(),
                    self.adapter.registry_kind.value,
                    inherited_environment=os.environ,
                )
            except (AuthorityConfigurationError, OSError):
                print(
                    f"{self.adapter.tool_name}: credential-free info environment unavailable",
                    file=sys.stderr,
                )
                return 1
            env.pop("STALL_GUARD_NOTIFY", None)
            _append_containment_record(
                tool=self.adapter.tool_name,
                runtime=self.adapter.exec_name,
                mode="info-only",
                exit_code=0,
                started=started,
                account=None,
                log_path=None,
            )
            os.execvpe(
                self.adapter.exec_name,
                [self.adapter.exec_name, *forwarded_argv],
                env,
            )
            return 0
        try:
            resolved = self._resolve_account(
                account_override,
                notify_fallback=not route_query,
                ignore_locks=human_override_lock or info_only,
            )
        except AccountLockError as exc:
            return self._emit_lock_refusal(exc)
        if resolved is None:
            if route_query:
                print(
                    json.dumps(
                        {"ok": False, "detail": "account route unavailable"},
                        separators=(",", ":"),
                    )
                )
            return 1
        if isinstance(resolved, AllAccountsCappedError):
            payload: dict[str, object] = {
                "ok": False,
                "detail": "rate-limited",
                "reason": "all-accounts-capped",
            }
            if resolved.resume_at is not None:
                payload["resume_at"] = resolved.resume_at
            print(json.dumps(payload, separators=(",", ":")))
            return 75
        base_dir, slug, account_home = resolved
        lock_policy_dir = self.adapter.lock_policy_dir()

        if route_query:
            route = self._last_route
            print(
                json.dumps(
                    {
                        "account": slug,
                        "chain": list(route.chain),
                        "source": route.source,
                        "project": self._last_project,
                    },
                    separators=(",", ":"),
                )
            )
            return 0

        authority_launch: AuthorityLaunch | None = None
        account = None if info_only else self.adapter.account(base_dir, slug)
        if (
            not info_only
            and not show_status
            and account is not None
            and account.authority_binding is not None
        ):
            binding = account.authority_binding
            if binding.quiesced:
                self._last_gateway_status = "Gateway: migration paused"
                print(
                    f"{self.adapter.tool_name}: Gateway: migration paused",
                    file=sys.stderr,
                )
                return 1
            try:
                authority_launch = build_authority_launch(
                    base_dir,
                    account,
                    self.adapter.registry_kind.value,
                    forwarded_argv,
                    inherited_environment=os.environ,
                )
                self._last_gateway_status = authority_launch.sanitized_status.detail
            except AuthorityConfigurationError as exc:
                self._last_gateway_status = str(exc)
                if binding.mode == AuthorityMode.SUBROUTER:
                    print(f"{self.adapter.tool_name}: {exc}", file=sys.stderr)
                    return 1
            except Exception:
                self._last_gateway_status = "Gateway: unavailable"
                if binding.mode == AuthorityMode.SUBROUTER:
                    print(
                        f"{self.adapter.tool_name}: Gateway: unavailable",
                        file=sys.stderr,
                    )
                    return 1

        authorization: AccountUseAuthorization | None = None
        if not info_only:
            try:
                authorization = authorize_account_use(
                    lock_policy_dir,
                    self.adapter.registry_kind.value,
                    slug,
                    allow_locked=human_override_lock,
                    explicit_account=account_override is not None,
                )
            except AccountLockError as exc:
                return self._emit_lock_refusal(exc)

        if show_status:
            try:
                if authorization is not None:
                    revalidate_account_use(lock_policy_dir, authorization)
            except AccountLockError as exc:
                return self._emit_lock_refusal(exc)
            snapshot: AccountSnapshot | None = None
            if account is not None and account.authority_binding is not None:
                gateway = gateway_health_snapshot(base_dir, account)
                if account.authority_binding.mode == AuthorityMode.SUBROUTER:
                    snapshot = gateway
                elif account.authority_binding.mode == AuthorityMode.SUBROUTER_DARK:
                    native = AccountHealthClient().fetch(account_home)
                    detail = native.detail or ""
                    if gateway.detail:
                        detail = f"{detail}; {gateway.detail}" if detail else gateway.detail
                    snapshot = replace(native, detail=detail)
            return self._print_live_status(
                slug,
                account_home,
                as_json=usage_json,
                snapshot=snapshot,
            )

        if not info_only:
            try:
                if authorization is not None:
                    revalidate_account_use(lock_policy_dir, authorization)
            except AccountLockError as exc:
                return self._emit_lock_refusal(exc)

        gateway_enabled = bool(
            not info_only
            and account is not None
            and account.authority_binding is not None
            and account.authority_binding.mode == AuthorityMode.SUBROUTER
        )
        if not gateway_enabled and not info_only:
            try:
                self.adapter.sync_before_exec(base_dir)
            except (OSError, RuntimeError) as exc:
                print(
                    f"{self.adapter.tool_name}: failed to synchronize shared Codex state: {exc}",
                    file=sys.stderr,
                )
                return 1

        if (
            not gateway_enabled
            and not info_only
            and not self.adapter.verify_env_support(account_home)
        ):
            print(
                f"{self.adapter.tool_name}: unsupported routing: {self.adapter.exec_name} did not honor {self.adapter.config_env_var} for {account_home}",
                file=sys.stderr,
            )
            return 1

        if info_only:
            try:
                env = build_credentialless_info_environment(
                    base_dir,
                    self.adapter.registry_kind.value,
                    inherited_environment=os.environ,
                )
            except (AuthorityConfigurationError, OSError):
                print(
                    f"{self.adapter.tool_name}: credential-free info environment unavailable",
                    file=sys.stderr,
                )
                return 1
            credential = None
        elif gateway_enabled:
            if authority_launch is None:
                print(f"{self.adapter.tool_name}: Gateway: migration required", file=sys.stderr)
                return 1
            env = authority_launch.environment
            credential = None
        else:
            env = {**os.environ, **self.adapter.build_env(account_home)}
            credential = self.adapter.credential(env)
        # only this CLI supervises the run; a child that inherited the opt-in would
        # let every gate and suite it spawns raise desktop popups
        env.pop("STALL_GUARD_NOTIFY", None)

        # --version/--help start no agent and do not touch account credentials.
        if info_only:
            _append_containment_record(
                tool=self.adapter.tool_name,
                runtime=self.adapter.exec_name,
                mode="info-only",
                exit_code=0,
                started=started,
                account=credential,
                log_path=None,
            )
            os.execvpe(self.adapter.exec_name, [self.adapter.exec_name, *forwarded_argv], env)
            return 0

        try:
            if authorization is not None:
                revalidate_account_use(lock_policy_dir, authorization)
        except AccountLockError as exc:
            return self._emit_lock_refusal(exc)

        log_path = self.adapter.capture_log_path(forwarded_argv)
        if log_path is not None:
            try:
                return self._run_captured(
                    forwarded_argv,
                    env,
                    log_path,
                    credential,
                    started,
                    base_dir=lock_policy_dir,
                    authorization=authorization,
                    allow_offload=not gateway_enabled,
                )
            except AccountLockError as exc:
                return self._emit_lock_refusal(exc)

        if gateway_enabled:
            try:
                if authorization is not None:
                    revalidate_account_use(lock_policy_dir, authorization)
            except AccountLockError as exc:
                return self._emit_lock_refusal(exc)
            _append_containment_record(
                tool=self.adapter.tool_name,
                runtime=self.adapter.exec_name,
                mode="gateway-local",
                exit_code=0,
                started=started,
                account=None,
                log_path=None,
            )
            os.execvpe(
                self.adapter.exec_name,
                [self.adapter.exec_name, *forwarded_argv],
                env,
            )
            return 0

        if remote_dispatch.in_container():
            try:
                if authorization is not None:
                    revalidate_account_use(lock_policy_dir, authorization)
            except AccountLockError as exc:
                return self._emit_lock_refusal(exc)
            _append_containment_record(
                tool=self.adapter.tool_name,
                runtime=self.adapter.exec_name,
                mode="in-container",
                exit_code=0,
                started=started,
                account=credential,
                log_path=None,
            )
            os.execvpe(self.adapter.exec_name, [self.adapter.exec_name, *forwarded_argv], env)
            return 0

        reason = (
            "no writable log directory"
            if is_exec_invocation(forwarded_argv)
            else "adapter captures no log for these arguments"
        )
        print(
            f"{self.adapter.tool_name}: no containerized path for this invocation ({reason})",
            file=sys.stderr,
        )
        _append_containment_record(
            tool=self.adapter.tool_name,
            runtime=self.adapter.exec_name,
            mode="aborted",
            exit_code=remote_dispatch.EXIT_UNCONTAINABLE,
            started=started,
            account=credential,
            log_path=log_path,
            reason=reason,
        )
        return remote_dispatch.EXIT_UNCONTAINABLE

    def _emit_lock_refusal(self, exc: AccountLockError) -> int:
        print(f"{self.adapter.tool_name}: {exc}", file=sys.stderr)
        print(json.dumps(exc.refusal_payload(), separators=(",", ":")))
        return exc.exit_code

    def _run_captured(
        self,
        forwarded_argv: list[str],
        env: dict[str, str],
        log_path: Path,
        credential: remote_dispatch.Credential | None,
        started: float,
        *,
        base_dir: Path | None = None,
        authorization: AccountUseAuthorization | None = None,
        allow_offload: bool = True,
    ) -> int:
        tool = self.adapter.tool_name
        if base_dir is not None and authorization is not None:
            revalidate_account_use(base_dir, authorization)
        argv = [self.adapter.exec_name, *forwarded_argv]
        session = None
        mode = "in-container"
        reason = None
        if allow_offload and remote_dispatch.should_offload():
            try:
                session = remote_dispatch.open_session(
                    self.adapter.exec_name,
                    forwarded_argv,
                    credential=credential,
                )
            except remote_dispatch.OffloadUnavailable as exc:
                print(f"{tool}: containment unavailable — {exc}", file=sys.stderr)
                _append_containment_record(
                    tool=self.adapter.tool_name,
                    runtime=self.adapter.exec_name,
                    mode="aborted",
                    exit_code=exc.code,
                    started=started,
                    account=credential,
                    log_path=log_path,
                    reason=_redact_credential_path(str(exc), credential),
                )
                return exc.code
            else:
                mode = "container"
                argv = session.argv
                print(f"{tool}: running on {session.host_name} ({session.rel_dir})", file=sys.stderr)
                if session.git_warning is not None:
                    print(f"{tool}: {session.git_warning}", file=sys.stderr)
        try:
            if base_dir is not None and authorization is not None:
                revalidate_account_use(base_dir, authorization)
            returncode = supervise(argv, env, log_path, key=tool)
        except OSError as exc:
            print(f"{tool}: failed to run {self.adapter.exec_name}: {exc}", file=sys.stderr)
            returncode = 1
        finally:
            if session is not None:
                try:
                    sync_error = session.pull_back()
                    if sync_error is not None:
                        print(f"{tool}: {sync_error}", file=sys.stderr)
                    returncode = _result_transport_exit_code(returncode, sync_error)
                finally:
                    session.release()
        if session is not None and returncode in remote_dispatch.LAUNCHER_FAILURE_CODES:
            mode = "aborted"
            reason = f"{session.host_name}: the sandbox launcher started no container (rc {returncode})"
            print(f"{tool}: containment unavailable — {reason}", file=sys.stderr)
        _append_containment_record(
            tool=self.adapter.tool_name,
            runtime=self.adapter.exec_name,
            mode=mode,
            exit_code=returncode,
            started=started,
            account=credential,
            log_path=log_path,
            reason=reason,
            host=session.host_name if session is not None else None,
            workspace=session.workspace if session is not None else None,
            sandbox_id=session.sandbox_id if session is not None else None,
        )
        self._print_completion(log_path, returncode, time.monotonic() - started)
        # stdout stays the bare log path: callers capture it as `LOG=$(cdx exec ...)`.
        print(str(log_path))
        return returncode

    def _print_completion(self, log_path: Path, exit_code: int, elapsed: float) -> None:
        try:
            size = log_path.stat().st_size
        except OSError:
            size = None
        tool = self.adapter.tool_name
        print(
            f"{tool}: {self.adapter.exec_name} exec finished — exit={exit_code} "
            f"in {_format_duration(elapsed)}. The run is COMPLETE; do not wait or poll for it.",
            file=sys.stderr,
        )
        print(
            f"{tool}: log {log_path}" + (f" ({_format_size(size)})" if size is not None else ""),
            file=sys.stderr,
        )

    @staticmethod
    def _parse_status_flag(argv: list[str]) -> tuple[bool, bool, list[str]]:
        show_status = False
        usage_json = False
        filtered = [argv[0]]
        for arg in argv[1:]:
            if arg in {"--status", "--usage"}:
                show_status = True
                continue
            if arg == "--json":
                usage_json = True
                continue
            filtered.append(arg)
        return show_status, usage_json, filtered

    @staticmethod
    def _extract_flag(argv: list[str], flag: str) -> tuple[bool, list[str]]:
        if flag in argv:
            return True, [arg for arg in argv if arg != flag]
        return False, argv

    def _emit_canonical_profile(self, account_override: str | None) -> int:
        raw_chain = [
            part.strip() for part in (account_override or "").split(",") if part.strip()
        ]
        canonical_parts: list[str] = []
        for part in raw_chain:
            if part == "dynamic":
                canonical_parts.append("dynamic")
                continue
            resolved_slug = None
            for candidate_dir in self.adapter.candidate_tray_dirs():
                resolved_slug = self.adapter.resolve_profile_token(candidate_dir, part)
                if resolved_slug is not None:
                    break
            if resolved_slug is None:
                print(f"{self.adapter.tool_name}: unknown account: {part}", file=sys.stderr)
                return 1
            canonical_parts.append(resolved_slug)
        print(json.dumps({"profile": ",".join(canonical_parts)}, separators=(",", ":")))
        return 0

    def _resolve_account(
        self,
        account_override: str | None,
        *,
        notify_fallback: bool = True,
        ignore_locks: bool = False,
    ) -> tuple[Path, str, Path] | AllAccountsCappedError | None:
        if account_override is not None:
            raw_chain = tuple(part.strip() for part in account_override.split(",") if part.strip())
            if not raw_chain:
                print(f"{self.adapter.tool_name}: --profile requires a non-empty account", file=sys.stderr)
                return None
            for candidate_dir in self.adapter.candidate_tray_dirs():
                selected = self.adapter.selected_default([candidate_dir])
                resolved_parts: list[str] = []
                ok = True
                for part in raw_chain:
                    if part == "dynamic":
                        if selected is None:
                            ok = False
                            break
                        resolved_parts.append(selected.slug)
                    else:
                        resolved_slug = self.adapter.resolve_profile_token(candidate_dir, part)
                        if resolved_slug is None or not self.adapter.account_home(
                            candidate_dir, resolved_slug
                        ).exists():
                            ok = False
                            break
                        resolved_parts.append(resolved_slug)
                if not ok:
                    continue
                chain = tuple(resolved_parts)
                locked_slugs = (
                    frozenset() if ignore_locks else self.adapter.locked_slugs()
                )
                selectable_chain = tuple(
                    candidate_slug
                    for candidate_slug in chain
                    if candidate_slug not in locked_slugs
                )
                if not selectable_chain:
                    raise AccountLockedError(
                        self.adapter.registry_kind.value,
                        chain,
                    )
                slug = selectable_chain[0]
                try:
                    rules = load_rules(self.adapter.routing_rules_path(candidate_dir))
                    rules = apply_selected_default(
                        rules,
                        selected.slug if selected is not None else None,
                    )
                    health = load_health(
                        self.adapter.health_cache_path(candidate_dir),
                        stale_after_s=self.adapter.health_cache_stale_seconds,
                        tool_name=self.adapter.tool_name,
                        health_snapshot_tool=self.adapter.health_snapshot_tool,
                    )
                    resolver = RoutingResolver(
                        rules,
                        health,
                        known_slugs=self.adapter.known_slugs(candidate_dir),
                        locked_slugs=locked_slugs,
                    )
                except (FileNotFoundError, json.JSONDecodeError, ValueError):
                    resolver = None
                if resolver is not None:
                    for candidate_slug in selectable_chain:
                        if not _override_account_spend_capped(resolver, candidate_slug):
                            slug = candidate_slug
                            break
                    else:
                        return AllAccountsCappedError(
                            resolver.earliest_cap_resume_at(selectable_chain)
                        )
                self._last_route = ResolvedRoute(slug, chain, "cli", False, None)
                self._last_project = None
                return candidate_dir, slug, self.adapter.account_home(candidate_dir, slug)
            print(self.adapter.format_unknown_account(account_override), file=sys.stderr)
            return None

        selected_default = self.adapter.selected_default(self.adapter.candidate_tray_dirs())
        base_dir = routing_base_dir(
            self.adapter.candidate_tray_dirs(),
            selected_default,
            self.adapter.routing_rules_filename,
        )
        project = self._detect_project_name()
        try:
            rules = load_rules(self.adapter.routing_rules_path(base_dir))
        except (FileNotFoundError, json.JSONDecodeError, ValueError) as exc:
            print(
                f"{self.adapter.tool_name}: failed to load routing rules: {exc}",
                file=sys.stderr,
            )
            return None
        rules = apply_selected_default(
            rules,
            selected_default.slug if selected_default is not None else None,
        )
        health = load_health(
            self.adapter.health_cache_path(base_dir),
            stale_after_s=self.adapter.health_cache_stale_seconds,
            tool_name=self.adapter.tool_name,
            health_snapshot_tool=self.adapter.health_snapshot_tool,
        )
        known_slugs = self.adapter.known_slugs(base_dir)
        locked_slugs = frozenset() if ignore_locks else self.adapter.locked_slugs()

        resolver = RoutingResolver(
            rules,
            health,
            known_slugs=known_slugs,
            locked_slugs=locked_slugs,
        )
        try:
            route = resolver.resolve(
                project,
                selected_default.slug if selected_default is not None else None,
            )
        except NoHealthyAccountError as exc:
            if resolver.all_locked(exc.chain):
                raise AccountLockedError(
                    self.adapter.registry_kind.value,
                    exc.chain,
                ) from exc
            unlocked_chain = resolver.unlocked(exc.chain)
            if resolver.all_cap_excluded(unlocked_chain):
                return AllAccountsCappedError(
                    resolver.earliest_cap_resume_at(unlocked_chain)
                )
            print(f"{self.adapter.tool_name}: {exc}", file=sys.stderr)
            return None

        if notify_fallback and route.fallback_used and route.fallback_from is not None:
            self.adapter.notify_fallback(route.fallback_from, route.slug)

        self._last_route = route
        self._last_project = project
        return base_dir, route.slug, self.adapter.account_home(base_dir, route.slug)

    def _print_live_status(
        self,
        slug: str,
        account_home: Path,
        *,
        as_json: bool = False,
        snapshot: AccountSnapshot | None = None,
    ) -> int:
        gateway_status = snapshot is not None
        if snapshot is None:
            snapshot = AccountHealthClient().fetch(account_home)
        if as_json:
            print(json.dumps(_usage_json_payload(slug, snapshot), separators=(",", ":")))
            summary = _format_cli_usage_summary(snapshot, now=time.time())
            if summary is not None or (gateway_status and snapshot.status.value == "ok"):
                return 0
            return 1
        summary = _format_cli_usage_summary(snapshot, now=time.time())
        if summary is None:
            if gateway_status and snapshot.status.value == "ok" and snapshot.detail:
                print(f"{slug}: {snapshot.detail}")
                return 0
            print(f"{self.adapter.tool_name}: live usage unavailable for {slug}", file=sys.stderr)
            return 1
        print(f"{slug}: {summary}")
        return 0


def _usage_json_payload(slug: str, snapshot: AccountSnapshot) -> dict[str, object]:
    return {
        "schema_version": 1,
        "account": slug,
        "checked_at": snapshot.checked_at if snapshot.checked_at is not None else time.time(),
        "status": snapshot.status.value,
        "detail": snapshot.detail if snapshot.detail is not None else (
            None if snapshot.status.value == "ok" else snapshot.status.value
        ),
        "windows": {
            "five_hour": {
                "used_percentage": snapshot.primary_used_pct,
                "resets_at": snapshot.primary_reset_at,
            },
            "seven_day": {
                "used_percentage": snapshot.secondary_used_pct,
                "resets_at": snapshot.secondary_reset_at,
            },
        },
    }


def _format_cli_usage_summary(
    snapshot: AccountSnapshot,
    *,
    now: float,
) -> str | None:
    if snapshot.status.value != "ok":
        return None
    if snapshot.primary_used_pct is None or snapshot.secondary_used_pct is None:
        return None
    primary = _format_cli_window("5h", snapshot.primary_used_pct, snapshot.primary_reset_at, now)
    secondary = _format_cli_window("7d", snapshot.secondary_used_pct, snapshot.secondary_reset_at, now)
    if primary is None or secondary is None:
        return None
    return f"{primary}, {secondary}"


def _format_cli_window(
    label: str,
    used_pct: int,
    reset_at: float | None,
    now: float,
) -> str | None:
    remaining = _reset_delta(reset_at, now)
    if remaining is None:
        return None
    bounded_used = max(0, min(used_pct, 100))
    return f"{label}: {bounded_used}% ({remaining} left)"


def _reset_delta(reset_at: float | None, now: float) -> str | None:
    if reset_at is None:
        return None
    seconds = max(0, int(reset_at - now))
    if seconds == 0:
        return "0m"
    if seconds < 3600:
        minutes = max(1, (seconds + 59) // 60)
        return f"{minutes}m"
    if seconds < 86400:
        hours = seconds // 3600
        minutes = (seconds % 3600) // 60
        if minutes == 0:
            return f"{hours}h"
        return f"{hours}h{minutes}m"
    days = seconds // 86400
    hours = (seconds % 86400) // 3600
    if hours == 0:
        return f"{days}d"
    return f"{days}d{hours}h"
