"""Pre-dispatch model-identity normalization for CLI workers.

The catalog (models.py) carries okstra's model identity. Some worker CLIs
(antigravity `agy`, codex) accept their own spelling of that identity, and it
drifts across CLI versions / accounts. This module maps a catalog execution
value + role to the CLI's exact accepted identifier, or hard-fails with the
CLI's real list. It never substitutes a different model — spelling/effort only.
"""
from __future__ import annotations

import subprocess
from functools import lru_cache
from pathlib import Path

from .json_boundary import (
    JsonBoundaryError,
    external_codex_catalog_json_source,
    load_external_json,
)

# Per-role reasoning-effort policy for CLIs that bake effort into the model
# name (agy). Deterministic — no per-run improvisation.
# `critic` is intentionally absent: an opt-in critic reuses its worker's
# execution value (render.py maps critic_choice -> *_WORKER_MODEL_EXECUTION_VALUE),
# so it inherits the worker's normalized identity + effort. role_effort("critic")
# returns _DEFAULT_EFFORT, matching the worker it mirrors.
ROLE_EFFORT = {"worker": "High", "executor": "High"}
_DEFAULT_EFFORT = "High"


class ModelUnavailableError(RuntimeError):
    """Requested model identity is not offered by the worker CLI on this host."""


def role_effort(role: str) -> str:
    return ROLE_EFFORT.get(role, _DEFAULT_EFFORT)


def agy_tier_suffixes() -> frozenset[str]:
    """agy 가 모델 식별자에 담는 티어 어휘.

    서빙 식별자에서 티어를 벗겨 카탈로그 id 로 되돌릴 때 이 집합만 벗긴다 —
    마지막 하이픈 뒤를 무엇이든 벗기면 카탈로그에 없는 id 를 만들어 낸다.
    이 집합은 okstra 의 역할별 effort 정책(`ROLE_EFFORT`)이 아니라 agy 의
    어휘다. 둘은 다르다: 정책이 지금 붙이는 것은 `high` 뿐이지만, 관측값에는
    다른 티어가 실려 올 수 있다.
    """
    return frozenset({"high", "medium", "low"})


@lru_cache(maxsize=1)
def agy_models(agy_bin: str = "agy") -> tuple[str, ...]:
    """Live `agy models` ids, or () when agy is unavailable (non-blocking).

    agy prints one model per line as `<id>\\t<display label>`; only the id is
    comparable to a catalog execution value, so the label column is dropped.
    A line with no tab is already bare and passes through unchanged.
    """
    try:
        proc = subprocess.run(
            [agy_bin, "models"],
            capture_output=True, text=True, timeout=20,
        )
    except (FileNotFoundError, OSError, subprocess.SubprocessError):
        return ()
    if proc.returncode != 0:
        return ()
    ids = (line.split("\t", 1)[0].strip() for line in proc.stdout.splitlines())
    return tuple(model_id for model_id in ids if model_id)


def _codex_home() -> Path:
    return Path.home() / ".codex"


def codex_availability(codex_home: Path | None = None) -> tuple[str, ...]:
    """Model slugs the codex CLI last fetched from the provider, or ().

    The earlier source, `[tui.model_availability_nux]` in `config.toml`, is a
    per-model onboarding-notice ledger, not an account's model list: measured
    2026-08-26 that section held `gpt-5.5` and `gpt-5.6-sol` while this machine's
    session history recorded 2072 turns on `gpt-5.6-terra` and 130 on
    `gpt-5.6-luna`. Gating dispatch on it rejected models the CLI runs.

    `visibility` is not filtered. It governs the CLI's own model picker, not
    whether `--model <slug>` is accepted -- `codex-auto-review` is `hide` and is
    dispatched by okstra today.
    """
    home = codex_home or _codex_home()
    try:
        payload = load_external_json(
            external_codex_catalog_json_source(home / "models_cache.json", home),
            artifact="codex model catalog",
        )
    except JsonBoundaryError:
        # No cache yet, or one this process may not read. Returning () turns the
        # gate off rather than blocking dispatch, which is what every other
        # discovery failure in this module does.
        return ()
    models = payload.get("models") if isinstance(payload, dict) else None
    if not isinstance(models, list):
        return ()
    out: list[str] = []
    for entry in models:
        slug = entry.get("slug") if isinstance(entry, dict) else None
        if isinstance(slug, str) and slug.strip():
            out.append(slug.strip())
    return tuple(out)


def normalize_execution_for_dispatch(
    *, provider: str, execution: str, role: str,
) -> str:
    """Return the CLI-accepted spelling of `execution`. When discovery is
    unavailable, fall back to the best-known dispatchable spelling rather than
    blocking. Raise ModelUnavailableError when the identity is definitely not
    offered (never substitute a different model)."""
    if provider == "codex":
        return normalize_codex_execution(
            execution=execution,
            available=codex_availability(),
        )
    if provider != "antigravity":
        return execution  # claude runs via the Agent tool
    return normalize_antigravity_execution(
        execution=execution,
        role=role,
        available=agy_models(),
    )


def normalize_codex_execution(
    *, execution: str, available: tuple[str, ...]
) -> str:
    """Normalize one Codex value against a previously captured config list."""
    if not available or execution in available:
        return execution
    raise ModelUnavailableError(
        f"codex model {execution!r} is not available for this account. "
        f"Available: {', '.join(available)} (no auto-rename — update the catalog)"
    )


def normalize_antigravity_execution(
    *, execution: str, role: str, available: tuple[str, ...]
) -> str:
    """Normalize one agy value against a previously captured model list."""
    # agy bakes the tier into the model identity as a slug suffix
    # (`gemini-3.1-pro-high`) and rejects the bare slug with "requires --effort"
    # — a flag the wrapper never sends. So the suffixed form is the only
    # dispatchable spelling, including on the un-verified fallback below.
    tiered = f"{execution}-{role_effort(role).lower()}"
    if not available:
        return tiered  # discovery unavailable → dispatch anyway, wrapper still guards
    if execution in available:
        return execution  # identity already carries its tier (agy's Claude models)
    if tiered in available:
        return tiered
    raise ModelUnavailableError(
        f"antigravity model {tiered!r} (from catalog {execution!r}, role {role!r}) "
        f"is not offered by this agy install. Available: {', '.join(available)}"
    )
