"""List models and atomically write role defaults. No inference calls."""
from __future__ import annotations

import argparse
import json
import os
import tempfile
from pathlib import Path
from typing import Any

from okstra_project import project_json_path, resolve_project_root
from okstra_project.dirs import okstra_home

from .domain.provider import UnknownModelError
from .domain.role import RoleCatalogError, normalize_role
from .model_pool import ModelPool, ModelPoolValidationError
from .ports.host_model import HostModelBindingError, HostModelBindingRequest
from .registry.host_registry import default_host_registry
from .registry.provider_registry import default_provider_registry
from .json_boundary import load_owned_object, write_owned_object_atomic


class ModelCliError(ValueError):
    """Raised when a model list or default write cannot be completed."""


def list_models(
    *,
    role: str | None,
    host_runtime: str,
    as_json: bool,
) -> int:
    wanted = normalize_role(role) if role else None
    pool, adapter = _pool_and_host(host_runtime)
    rows = [
        _catalog_row(model, adapter, wanted)
        for model in pool.list_catalog(role=wanted)
    ]
    if as_json:
        print(json.dumps({"models": rows}, ensure_ascii=False))
        return 0
    for row in rows:
        mark = "yes" if row["selectable"] else "no"
        print(f"{row['modelRef']}\t{mark}\t{row['reason']}")
    return 0


def set_model_default(
    *,
    role: str,
    refs: tuple[str, ...],
    scope: str,
    cwd: Path,
) -> int:
    canonical = normalize_role(role)
    if not refs or len(refs) != len(set(refs)):
        raise ModelCliError("model defaults must be a unique non-empty list")
    pool, _adapter = _pool_and_host("claude-code")
    for ref in refs:
        try:
            availability = pool.availability(ref, canonical)
        except UnknownModelError as exc:
            raise ModelCliError(str(exc)) from exc
        if not availability.available:
            raise ModelCliError(
                f"model {ref!r} cannot default role {canonical}: "
                f"{availability.reason}"
            )
    path = _config_path(scope, cwd)
    payload = _read_json(path)
    defaults = dict(payload.get("modelDefaults") or {})
    defaults[canonical] = list(refs)
    payload["modelDefaults"] = defaults
    _write_json_atomic(path, payload)
    return 0


def unset_model_default(*, role: str, scope: str, cwd: Path) -> int:
    canonical = normalize_role(role)
    path = _config_path(scope, cwd)
    payload = _read_json(path)
    defaults = dict(payload.get("modelDefaults") or {})
    defaults.pop(canonical, None)
    payload["modelDefaults"] = defaults
    _write_json_atomic(path, payload)
    return 0


def model_diagnostics(
    *,
    host_runtime: str = "claude-code",
    cwd: Path | None = None,
) -> dict[str, Any]:
    """Report pool and default issues without making an inference call."""
    root = Path(cwd) if cwd is not None else Path.cwd()
    try:
        pool, adapter = _pool_and_host(host_runtime)
        pool_errors: list[str] = []
    except ModelPoolValidationError as exc:
        return {
            "poolErrors": [str(exc)],
            "defaultErrors": [],
            "bindingPrecision": "unknown",
            "observedModel": None,
            "maxWriteBoundary": "none",
            "invocationDowngradeReason": None,
            "inferenceCall": False,
        }
    default_errors = [
        *_default_errors(pool, _try_project_json(root), "project"),
        *_default_errors(pool, okstra_home() / "config.json", "global"),
    ]
    capability = adapter.worker_dispatch().worker_write_capability()
    max_boundary = (
        capability.max_boundary_precision if capability is not None else "none"
    )
    return {
        "poolErrors": pool_errors,
        "defaultErrors": default_errors,
        "bindingPrecision": "channel" if host_runtime == "claude-code" else "exact",
        "observedModel": None,
        "maxWriteBoundary": max_boundary,
        "invocationDowngradeReason": None,
        "inferenceCall": False,
    }


def _pool_and_host(host_runtime: str):
    registry = default_provider_registry()
    return ModelPool.from_registry(registry), default_host_registry(registry).resolve(
        host_runtime
    )


def _catalog_row(model, adapter, role: str | None) -> dict[str, Any]:
    row = {
        "modelRef": str(model.model_ref),
        "provider": model.provider_id,
        "displayName": model.display_name,
        "selectable": bool(model.selectable),
        "reason": "",
    }
    if not model.selectable:
        row["reason"] = "not selectable"
        return row
    if role != "leader":
        return row
    if model.provider_id != adapter.descriptor.native_provider_id:
        return row
    try:
        adapter.host_model().resolve(
            HostModelBindingRequest(
                host_runtime=adapter.descriptor.id,
                provider=model.provider_id,
                model_ref=str(model.model_ref),
                version_kind=model.version_kind,
                model_execution_value=model.execution_value,
                runner="native-session",
            )
        )
    except HostModelBindingError as exc:
        # Not "unusable as a lead": the host cannot bind this model to a native
        # session, so the lead runs through the provider CLI instead. Reporting
        # it as unselectable made the listing disagree with what assignment
        # resolution actually does — it accepts the model and falls back to
        # `cli-wrapper` — leaving no way to find out why a lead was not native.
        row["leaderRunner"] = "cli-wrapper"
        row["reason"] = (
            f"native-session unavailable ({exc}); the lead runs via cli-wrapper"
        )
    else:
        row["leaderRunner"] = "native-session"
    return row


def _default_errors(pool: ModelPool, path: Path, scope: str) -> list[str]:
    if not path.is_file():
        return []
    payload = _read_json(path)
    defaults = payload.get("modelDefaults")
    if defaults is None:
        return []
    if not isinstance(defaults, dict):
        return [f"{scope} modelDefaults must be an object"]
    errors: list[str] = []
    for role, refs in defaults.items():
        try:
            canonical = normalize_role(str(role))
        except RoleCatalogError as exc:
            errors.append(str(exc))
            continue
        if not isinstance(refs, list) or not refs:
            errors.append(f"{scope} model defaults for {canonical!r} are empty")
            continue
        seen: set[str] = set()
        for ref in refs:
            token = str(ref)
            if token in seen:
                errors.append(f"{scope} duplicate model default {token}")
            seen.add(token)
            try:
                availability = pool.availability(token, canonical)
            except UnknownModelError as exc:
                errors.append(str(exc))
                continue
            if not availability.available:
                errors.append(
                    f"{scope} model {token!r} cannot default role {canonical}: "
                    f"{availability.reason}"
                )
    return errors


def _try_project_json(cwd: Path) -> Path:
    try:
        return project_json_path(resolve_project_root(cwd=str(cwd)))
    except Exception:
        return cwd / ".okstra" / "project.json"


def _config_path(scope: str, cwd: Path) -> Path:
    if scope == "global":
        return okstra_home() / "config.json"
    return project_json_path(resolve_project_root(cwd=str(cwd)))


def _read_json(path: Path) -> dict[str, Any]:
    if not path.is_file():
        return {}
    return load_owned_object(path, artifact="model configuration")


def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
    write_owned_object_atomic(path, payload, artifact="model configuration")


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="okstra model")
    sub = parser.add_subparsers(dest="command", required=True)
    listed = sub.add_parser("list")
    listed.add_argument("--role")
    listed.add_argument("--host", default="claude-code")
    listed.add_argument("--json", action="store_true")
    default = sub.add_parser("default")
    default_sub = default.add_subparsers(dest="default_command", required=True)
    setter = default_sub.add_parser("set")
    setter.add_argument("role")
    setter.add_argument("refs")
    setter.add_argument("--scope", choices=("project", "global"), required=True)
    setter.add_argument("--cwd", default=".")
    unsetter = default_sub.add_parser("unset")
    unsetter.add_argument("role")
    unsetter.add_argument("--scope", choices=("project", "global"), required=True)
    unsetter.add_argument("--cwd", default=".")
    args = parser.parse_args(argv)
    try:
        if args.command == "list":
            return list_models(
                role=args.role, host_runtime=args.host, as_json=args.json
            )
        if args.default_command == "set":
            refs = tuple(part for part in args.refs.split(",") if part)
            return set_model_default(
                role=args.role, refs=refs, scope=args.scope, cwd=Path(args.cwd)
            )
        return unset_model_default(
            role=args.role, scope=args.scope, cwd=Path(args.cwd)
        )
    except (ModelCliError, RoleCatalogError, UnknownModelError, OSError, ValueError) as exc:
        print(f"error: {exc}", flush=True)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
