#!/usr/bin/env python3
"""Keep Hermes agent provider catalogs and managed profiles current."""

from __future__ import annotations

import copy
import json
import os
import re
import shutil
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable

import yaml


CODEX_BASELINE = "gpt-5.6-terra"
CLAUDE_BASELINE = "claude-opus-5"
CLAUDE_SUBSCRIPTION_MODELS = (
    "claude-haiku-4-5-20251001",
    "claude-fable-5",
    "claude-sonnet-5",
    "claude-opus-5",
)
EFFORTS = ("low", "medium", "high", "xhigh")
ATLAS_FALLBACK = {
    "provider": "custom",
    "model": "qwen2.5:14b-instruct-q4_0",
    "base_url": "http://hermes-model-gate.hermes.svc.cluster.local:11434/v1",
    "api_key": "ollama",
}
# Backwards-compatible name used by the focused unit tests and status tooling.
LOCAL_FALLBACK = ATLAS_FALLBACK
SWITCHYARD_PROVIDER = "atlas-switchyard"
SWITCHYARD_API = "http://hermes-switchyard.hermes.svc.cluster.local:9005/v1"
SWITCHYARD_AUTO_ROUTE = "atlas/auto/maximum"
ROUTING_CATALOG_PATH = os.environ.get("HERMES_ROUTING_CATALOG_PATH", "").strip()
MANAGED_ENV_KEYS = {
    "CLAUDE_CODE_OAUTH_TOKEN",
    "GITEA_TOKEN",
    "GITEA_USERNAME",
    "GIT_ASKPASS",
    "GIT_TERMINAL_PROMPT",
}


@dataclass(frozen=True)
class Catalog:
    """Non-secret provider discovery result."""

    provider: str
    models: list[str]
    live: bool
    connected: bool
    state: str


def _unique_models(models: Iterable[str]) -> list[str]:
    """Return normalized model IDs without changing provider order."""
    seen: set[str] = set()
    result: list[str] = []
    for model in models:
        value = str(model or "").strip()
        if value and value.lower() not in seen:
            result.append(value)
            seen.add(value.lower())
    return result


def model_version(model: str) -> tuple[int, ...]:
    """Extract a sortable model version while ignoring dated aliases."""
    value = re.sub(r"-\d{8}$", "", model.lower())
    groups = re.findall(r"\d+(?:\.\d+)*", value)
    parts: list[int] = []
    for group in groups:
        parts.extend(int(piece) for piece in group.split("."))
    return tuple(parts) or (0,)


def _codex_quality(model: str) -> int:
    """Rank known Codex capability tiers for delegated implementation work."""
    value = model.lower()
    if "sol" in value:
        return 60
    if "terra" in value:
        return 50
    if "codex" in value and "spark" not in value:
        return 45
    if re.fullmatch(r"gpt-\d+(?:\.\d+)*", value):
        return 40
    if "luna" in value:
        return 20
    if "mini" in value or "nano" in value:
        return 10
    if "spark" in value:
        return 5
    return 0


def choose_codex_model(
    models: Iterable[str], current: str = CODEX_BASELINE, *, balanced: bool = False
) -> str:
    """Choose a current full Codex model, retaining current when unknown."""
    candidates = [m for m in _unique_models(models) if m.lower().startswith("gpt-")]
    full = [m for m in candidates if _codex_quality(m) >= 40]
    pool = full or candidates
    if not pool:
        return current
    newest = max(model_version(model) for model in pool)
    latest = [model for model in pool if model_version(model) == newest]
    if balanced:
        terra = [model for model in latest if "terra" in model.lower()]
        if terra:
            return max(terra, key=lambda model: (_codex_quality(model), model))
    return max(latest, key=lambda model: (_codex_quality(model), model))


def _choose_by_hints(
    models: Iterable[str], hints: tuple[str, ...], current: str, prefix: str
) -> str:
    """Pick the newest model in the first available capability class."""
    candidates = [
        model
        for model in _unique_models(models)
        if model.lower().startswith(prefix)
    ]
    if not candidates:
        return current
    for hint in hints:
        tier = [model for model in candidates if hint in model.lower()]
        if tier:
            return max(tier, key=lambda model: (model_version(model), model))
    return max(candidates, key=lambda model: (model_version(model), model))


def choose_codex_for_effort(
    models: Iterable[str], effort: str, current: str = CODEX_BASELINE
) -> str:
    """Choose the account-visible Codex tier for an allowed effort level."""
    hints = {
        "low": ("luna", "mini", "spark", "terra", "sol", "codex"),
        "medium": ("terra", "codex", "sol", "luna", "mini", "spark"),
        "high": ("sol", "codex", "terra", "luna", "mini", "spark"),
        "xhigh": ("sol", "codex", "terra", "luna", "mini", "spark"),
    }
    if effort not in EFFORTS:
        raise ValueError(f"unsupported effort: {effort}")
    return _choose_by_hints(models, hints[effort], current, "gpt-")


def _claude_quality(model: str) -> int:
    """Rank known Claude capability tiers for architecture and review work."""
    value = model.lower()
    if "opus" in value:
        return 40
    if "sonnet" in value:
        return 30
    if "fable" in value:
        return 20
    if "haiku" in value:
        return 10
    return 0


def choose_claude_model(models: Iterable[str], current: str = CLAUDE_BASELINE) -> str:
    """Choose the newest full Claude reasoning model visible to the account."""
    candidates = [m for m in _unique_models(models) if m.lower().startswith("claude-")]
    full = [m for m in candidates if _claude_quality(m) >= 30]
    pool = full or candidates
    if not pool:
        return current
    return max(
        pool,
        key=lambda model: (model_version(model), _claude_quality(model), model),
    )


def choose_claude_for_effort(
    models: Iterable[str], effort: str, current: str = CLAUDE_BASELINE
) -> str:
    """Choose the account-visible Claude tier for an allowed effort level."""
    hints = {
        "low": ("haiku", "fable", "sonnet", "opus"),
        "medium": ("sonnet", "fable", "opus", "haiku"),
        "high": ("opus", "sonnet", "fable", "haiku"),
        "xhigh": ("opus", "sonnet", "fable", "haiku"),
    }
    if effort not in EFFORTS:
        raise ValueError(f"unsupported effort: {effort}")
    return _choose_by_hints(models, hints[effort], current, "claude-")


def _read_yaml(path: Path) -> dict[str, Any]:
    """Read a mapping from YAML, returning an empty mapping when unavailable."""
    if not path.is_file():
        return {}
    try:
        value = yaml.safe_load(path.read_text(encoding="utf-8"))
    except (OSError, yaml.YAMLError):
        return {}
    return value if isinstance(value, dict) else {}


def _atomic_write(path: Path, content: str, mode: int | None = None) -> bool:
    """Replace a file only when its content changes."""
    path.parent.mkdir(parents=True, exist_ok=True)
    try:
        if path.read_text(encoding="utf-8") == content:
            if mode is not None:
                path.chmod(mode)
            return False
    except OSError:
        pass
    temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
    temporary.write_text(content, encoding="utf-8")
    if mode is not None:
        temporary.chmod(mode)
    os.replace(temporary, path)
    return True


def _write_yaml(path: Path, value: dict[str, Any], mode: int | None = None) -> bool:
    """Serialize a mapping and atomically update the target YAML file."""
    return _atomic_write(path, yaml.safe_dump(value, sort_keys=False), mode)


def _read_json(path: Path) -> dict[str, Any]:
    """Read a JSON mapping without treating a partial write as valid state."""
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError, json.JSONDecodeError):
        return {}
    return value if isinstance(value, dict) else {}


def _previous_provider_models(
    previous: dict[str, Any], provider: str
) -> tuple[dict[str, str], dict[str, str], list[str]]:
    """Return last-known-good effort, tier, and catalog values for a provider."""
    providers = previous.get("providers", {})
    record = providers.get(provider, {}) if isinstance(providers, dict) else {}
    if not isinstance(record, dict):
        return {}, {}, []
    resolved = record.get("resolved", {})
    tiers = record.get("tiers", {})
    models = record.get("models", [])
    return (
        dict(resolved) if isinstance(resolved, dict) else {},
        dict(tiers) if isinstance(tiers, dict) else {},
        _unique_models(models if isinstance(models, list) else []),
    )


def build_routing_catalog(
    codex: Catalog, claude: Catalog, previous: dict[str, Any] | None = None
) -> dict[str, Any]:
    """Build a current catalog while retaining working routes during outages."""
    previous = previous or {}
    providers: dict[str, Any] = {}
    specifications = (
        (
            "codex",
            codex,
            choose_codex_for_effort,
            {"luna": "low", "terra": "medium", "sol": "high"},
            {
                "low": "gpt-5.6-luna",
                "medium": "gpt-5.6-terra",
                "high": "gpt-5.6-sol",
                "xhigh": "gpt-5.6-sol",
            },
        ),
        (
            "claude",
            claude,
            choose_claude_for_effort,
            {
                "haiku": "low",
                "fable": "medium",
                "sonnet": "high",
                "opus": "xhigh",
            },
            {
                "low": "claude-haiku-4-5-20251001",
                "medium": "claude-sonnet-5",
                "high": "claude-opus-5",
                "xhigh": "claude-opus-5",
            },
        ),
    )
    for name, discovered, chooser, tier_efforts, defaults in specifications:
        old_resolved, old_tiers, old_models = _previous_provider_models(previous, name)
        source_models = discovered.models if discovered.live else old_models
        resolved: dict[str, str] = {}
        for effort in EFFORTS:
            current = str(old_resolved.get(effort) or defaults[effort])
            resolved[effort] = (
                chooser(source_models, effort, current)
                if source_models
                else current
            )
        tiers: dict[str, str] = {}
        for tier, effort in tier_efforts.items():
            current = str(old_tiers.get(tier) or defaults[effort])
            tier_matches = [model for model in source_models if tier in model.lower()]
            tiers[tier] = (
                max(tier_matches, key=lambda model: (model_version(model), model))
                if tier_matches
                else (resolved[effort] if discovered.live else current)
            )
        providers[name] = {
            "state": discovered.state,
            "connected": discovered.connected,
            "live": discovered.live,
            "models": _unique_models(
                discovered.models
                if discovered.live
                else (old_models or discovered.models)
            ),
            "resolved": resolved,
            "tiers": tiers,
        }
    return {
        "schema_version": 1,
        "updated_at": int(time.time()),
        "providers": providers,
    }


def write_routing_catalog(
    path: Path, codex: Catalog, claude: Catalog
) -> dict[str, Any]:
    """Atomically publish the catalog consumed by hosted and worker brokers."""
    catalog = build_routing_catalog(codex, claude, _read_json(path))
    _atomic_write(path, json.dumps(catalog, indent=2, sort_keys=True) + "\n", 0o644)
    return catalog


def _read_env(path: Path) -> dict[str, str]:
    """Read the small dotenv subset used by Hermes provider credentials."""
    values: dict[str, str] = {}
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except OSError:
        return values
    for line in lines:
        value = line.strip()
        if not value or value.startswith("#") or "=" not in value:
            continue
        key, raw = value.removeprefix("export ").split("=", 1)
        raw = raw.strip()
        if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
            raw = raw[1:-1]
        values[key.strip()] = raw
    return values


def _update_profile_env(path: Path, source: dict[str, str]) -> None:
    """Refresh managed credentials while preserving user-owned environment keys."""
    try:
        old_lines = path.read_text(encoding="utf-8").splitlines()
    except OSError:
        old_lines = []
    kept = [
        line
        for line in old_lines
        if not any(line.lstrip().startswith(f"{key}=") for key in MANAGED_ENV_KEYS)
    ]
    kept.extend(
        f"{key}={source[key]}" for key in sorted(MANAGED_ENV_KEYS) if source.get(key)
    )
    _atomic_write(path, "\n".join(kept).rstrip() + "\n", 0o600)


def _existing_model(config: dict[str, Any], provider: str, default: str) -> str:
    """Return the last configured model for a provider or a safe baseline."""
    model_cfg = config.get("model", {})
    if isinstance(model_cfg, dict) and model_cfg.get("provider") == provider:
        return str(model_cfg.get("model") or model_cfg.get("default") or default)
    fallbacks = config.get("fallback_providers", [])
    if isinstance(fallbacks, list):
        for fallback in fallbacks:
            if isinstance(fallback, dict) and fallback.get("provider") == provider:
                return str(fallback.get("model") or default)
    return default


def codex_cli_authenticated() -> bool:
    """Return whether the installed Codex CLI has a usable local login."""
    codex = shutil.which("codex")
    if codex:
        try:
            status = subprocess.run(
                [codex, "login", "status"],
                capture_output=True,
                check=False,
                text=True,
                timeout=10,
            )
            detail = f"{status.stdout}\n{status.stderr}".lower()
            if status.returncode == 0 and (
                "logged in" in detail or "authenticated" in detail
            ):
                return True
        except (OSError, subprocess.SubprocessError):
            pass
    return False


def discover_codex_models() -> Catalog:
    """Use the authenticated Codex endpoint; catalogs are status-only fallback."""
    token = ""
    try:
        from hermes_cli.auth import resolve_codex_runtime_credentials

        credentials = resolve_codex_runtime_credentials(refresh_if_expiring=True) or {}
        token = str(credentials.get("api_key") or "").strip()
    except Exception:
        token = ""
    live: list[str] = []
    if token:
        try:
            from hermes_cli.codex_models import _fetch_models_from_api

            live = _unique_models(_fetch_models_from_api(token))
        except Exception:
            live = []
    if live:
        return Catalog("openai-codex", live, True, True, "connected")
    try:
        from hermes_cli.models import provider_model_ids

        known = _unique_models(provider_model_ids("openai-codex", force_refresh=True))
    except Exception:
        known = []
    # The owner agent deliberately uses the local Codex app-server and the
    # Codex CLI's ChatGPT login. That credential is not exported into Hermes'
    # bearer-token store, so API discovery above may be empty even though the
    # runtime is fully authenticated. Treat a successful CLI status check as a
    # connected, degraded catalog: routing may use Codex while retaining the
    # last-known working model names until app-server discovery is available.
    if codex_cli_authenticated():
        return Catalog(
            "openai-codex",
            known,
            False,
            True,
            "connected-cli",
        )
    state = "degraded" if token else "not-configured"
    return Catalog("openai-codex", known, False, bool(token), state)


def discover_claude_models() -> Catalog:
    """Use the native subscription login before any API-key catalog fallback."""
    claude = shutil.which("claude") or os.environ.get("HERMES_CLAUDE_BIN", "")
    if claude:
        try:
            environment = os.environ.copy()
            environment.pop("ANTHROPIC_API_KEY", None)
            environment.pop("CLAUDE_API_KEY", None)
            status = subprocess.run(
                [claude, "auth", "status"],
                capture_output=True,
                check=False,
                text=True,
                timeout=15,
                env=environment,
            )
            detail = json.loads(status.stdout) if status.stdout.strip() else {}
            if (
                status.returncode == 0
                and isinstance(detail, dict)
                and detail.get("loggedIn") is True
                and detail.get("apiProvider") == "firstParty"
            ):
                try:
                    from hermes_cli.models import provider_model_ids

                    known = _unique_models(
                        provider_model_ids("anthropic", force_refresh=True)
                    )
                except Exception:
                    known = []
                return Catalog(
                    "anthropic",
                    _unique_models((*CLAUDE_SUBSCRIPTION_MODELS, *known)),
                    True,
                    True,
                    "connected-subscription",
                )
        except (OSError, subprocess.SubprocessError, ValueError, json.JSONDecodeError):
            pass
    token = str(os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") or "").strip()
    live: list[str] = []
    if token:
        try:
            from hermes_cli.models import _fetch_anthropic_models

            live = _unique_models(_fetch_anthropic_models(timeout=10.0) or [])
        except Exception:
            live = []
    if live:
        return Catalog("anthropic", live, True, True, "connected")
    try:
        from hermes_cli.models import provider_model_ids

        known = _unique_models(provider_model_ids("anthropic", force_refresh=True))
    except Exception:
        known = []
    state = "degraded" if token else "not-configured"
    return Catalog("anthropic", known, False, bool(token), state)


def _profile_config(
    base: dict[str, Any],
    primary_provider: str,
    primary_model: str,
    fallback: dict[str, str],
    effort: str,
) -> dict[str, Any]:
    """Derive a worker configuration with an explicit cross-provider fallback."""
    config = copy.deepcopy(base)
    config["model"] = {
        "provider": primary_provider,
        "default": primary_model,
        "model": primary_model,
        "openai_runtime": "codex_app_server",
    }
    config["fallback_providers"] = [fallback]
    # Local OpenAI-compatible runtimes top out at the equivalent of high.
    # Never silently downgrade an explicitly xhigh task after both hosted
    # provider lanes are exhausted.
    if effort != "xhigh":
        config["fallback_providers"].append(copy.deepcopy(ATLAS_FALLBACK))
    agent = config.setdefault("agent", {})
    if isinstance(agent, dict):
        agent["reasoning_effort"] = effort
    config["toolsets"] = []
    return config


def _switchyard_profile_config(
    base: dict[str, Any], route: str, effort: str
) -> dict[str, Any]:
    """Derive a profile that cannot bypass the Switchyard authority."""
    config = copy.deepcopy(base)
    providers = config.setdefault("providers", {})
    if not isinstance(providers, dict):
        providers = {}
        config["providers"] = providers
    providers[SWITCHYARD_PROVIDER] = {
        "name": "Atlas Switchyard",
        "api": SWITCHYARD_API,
        "api_key": "atlas-switchyard",
        "default_model": route,
        "transport": "chat_completions",
    }
    config["model"] = {
        "provider": SWITCHYARD_PROVIDER,
        "default": route,
        "model": route,
    }
    config["fallback_providers"] = []
    config["model_catalog"] = {"enabled": True, "ttl_hours": 1}
    agent = config.setdefault("agent", {})
    if isinstance(agent, dict):
        agent["reasoning_effort"] = effort
    config["toolsets"] = []
    return config


def _write_profile(
    root: Path,
    name: str,
    description: str,
    soul: str,
    config: dict[str, Any],
    env_values: dict[str, str],
) -> None:
    """Create or refresh a managed Hermes worker profile."""
    profile = root / "profiles" / name
    for directory in ("logs", "sessions", "skills", "workspace", "home"):
        (profile / directory).mkdir(parents=True, exist_ok=True)
    _write_yaml(profile / "config.yaml", config)
    _write_yaml(
        profile / "profile.yaml",
        {"description": description, "description_auto": False},
    )
    _atomic_write(profile / "SOUL.md", soul.rstrip() + "\n")
    _update_profile_env(profile / ".env", env_values)


def configure_routes(
    root: Path,
    codex: Catalog,
    claude: Catalog,
    catalog_path: Path | None = None,
) -> dict[str, Any]:
    """Refresh catalogs while keeping every Hermes profile on Switchyard."""
    config_path = root / "config.yaml"
    base = _read_yaml(config_path)
    resolved_catalog_path = catalog_path or (
        Path(ROUTING_CATALOG_PATH)
        if ROUTING_CATALOG_PATH
        else root / "routing-catalog.json"
    )
    catalog = write_routing_catalog(resolved_catalog_path, codex, claude)
    providers = catalog["providers"]
    codex_models = providers["codex"]["resolved"]
    claude_models = providers["claude"]["resolved"]

    coordinator_toolsets = copy.deepcopy(base.get("toolsets"))
    base = _switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "high")
    # The coordinator uses its configured toolsets. Worker profiles below are
    # deliberately toolset-empty so Hermes resolves their native defaults.
    if coordinator_toolsets is None:
        base.pop("toolsets", None)
    else:
        base["toolsets"] = coordinator_toolsets
    _write_yaml(config_path, base)

    env_values = _read_env(root / ".env")
    routes: dict[str, list[str]] = {}
    for effort in EFFORTS:
        codex_model = codex_models[effort]
        claude_model = claude_models[effort]
        codex_name = f"codex-{effort}"
        claude_name = f"claude-{effort}"
        codex_route = {
            "low": "atlas/manual/codex/luna",
            "medium": "atlas/manual/codex/terra",
            "high": "atlas/manual/codex/sol",
            "xhigh": "atlas/manual/codex/sol",
        }[effort]
        claude_route = {
            "low": "atlas/manual/claude/haiku",
            "medium": "atlas/manual/claude/sonnet",
            "high": "atlas/manual/claude/sonnet",
            "xhigh": "atlas/manual/claude/opus",
        }[effort]
        _write_profile(
            root,
            codex_name,
            f"Codex implementation preference at {effort} effort, enforced by Switchyard.",
            "You are an implementation worker. Make focused, tested changes for the assigned task, preserve unrelated work, and report evidence and blockers to the coordinator.",
            _switchyard_profile_config(base, codex_route, effort),
            env_values,
        )
        _write_profile(
            root,
            claude_name,
            f"Claude analysis preference at {effort} effort, enforced by Switchyard.",
            "You are an architecture and review worker. Analyze the assigned task deeply, change files only when asked, and return concise conclusions, evidence, and risks to the coordinator.",
            _switchyard_profile_config(base, claude_route, effort),
            env_values,
        )
        routes[codex_name] = [codex_route]
        routes[claude_name] = [claude_route]

    _write_profile(
        root,
        "synthesis-xhigh",
        "Cross-provider synthesis and critical review, capped at xhigh effort.",
        "Synthesize the worker evidence into one answer. Resolve disagreements explicitly, verify high-risk claims, and never claim completion without cited validation.",
        _switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "xhigh"),
        env_values,
    )
    routes["synthesis-xhigh"] = [SWITCHYARD_AUTO_ROUTE]
    _write_yaml(
        root / "profile.yaml",
        {
            "description": "Owner-only project coordinator using Switchyard to route Hermes, Codex, Claude, and local model boundaries.",
            "description_auto": False,
        },
    )
    routes["coordinator"] = [SWITCHYARD_AUTO_ROUTE]
    routes["catalog"] = [
        *(f"openai-codex/{model}" for model in codex_models.values()),
        *(f"anthropic/{model}" for model in claude_models.values()),
    ]
    return routes
