#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Jiri Vyskocil
# SPDX-License-Identifier: Apache-2.0
# terok:container — this file is deployed into task containers, not used on the host.

"""Unified OpenCode provider launcher for Blablador, KISSKI, and future providers.

This script is self-contained (stdlib-only) and runs inside containers where
terok is NOT installed. Provider-specific configuration is passed via environment
variables injected by the host.

Usage:
  opencode-provider [--list-models] [--base-url BASE_URL] [--]

The script gets the provider name from argv[0]. For example, the name can be
"blablador" or "kisski". The script reads legacy TEROK_OC_{NAME}_* variables.
It also reads the generic TEROK_PROVIDER_{NAME}_* variables from the host.
"""

import argparse
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
from urllib import request

_OPENCODE_SCHEMA = "https://opencode.ai/config.json"
_OPENAI_COMPATIBLE_NPM = "@ai-sdk/openai-compatible"

# Hardcoded fallbacks for manual invocation (when env vars not set)
_FALLBACK_PROVIDERS = {
    "blablador": {
        "display_name": "Helmholtz Blablador",
        "base_url": "https://api.helmholtz-blablador.fz-juelich.de/v1",
        "preferred_model": "alias-huge",
        "fallback_model": "alias-code",
        "env_var_prefix": "BLABLADOR",
        "config_dir": ".blablador",
    },
    "kisski": {
        "display_name": "KISSKI",
        "base_url": "https://chat-ai.academiccloud.de/v1",
        "preferred_model": "devstral-2-123b-instruct-2512",
        "fallback_model": "mistral-large-3-675b-instruct-2512",
        "env_var_prefix": "KISSKI",
        "config_dir": ".kisski",
    },
}


def _provider_prefix(name: str) -> str:
    """Return the generic environment-variable prefix for *name*."""
    return f"TEROK_PROVIDER_{name.upper()}_"


def _positive_integer(value: object) -> int | None:
    """Return a positive non-boolean integer, or return ``None``."""
    return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None


def _resolve_provider_config(name: str | None = None) -> dict[str, str]:
    """Return provider configuration from environment variables or defaults.

    By default, the file name in ``argv[0]`` selects the provider. An explicit
    ``--provider`` option overrides this name. A curated provider uses the full
    ``TEROK_OC_{NAME}_*`` configuration. Other authenticated providers use the
    generic ``TEROK_PROVIDER_{NAME}_*`` variables. These variables contain the
    base URL, label, model data, and default model.
    """
    if name is None:
        name = os.path.basename(sys.argv[0])
    prefix = f"TEROK_OC_{name.upper()}_"
    provider_prefix = _provider_prefix(name)

    # Curated provider — full TEROK_OC_* config injected by the host.
    base_url = os.environ.get(f"{prefix}BASE_URL")
    if base_url:
        return {
            "name": name,
            "display_name": os.environ.get(f"{prefix}DISPLAY_NAME", name),
            "base_url": base_url,
            "preferred_model": os.environ.get(
                f"{provider_prefix}DEFAULT_MODEL",
                os.environ.get(f"{prefix}PREFERRED_MODEL", ""),
            ),
            "fallback_model": os.environ.get(f"{prefix}FALLBACK_MODEL", ""),
            "env_var_prefix": os.environ.get(f"{prefix}ENV_VAR_PREFIX", name.upper()),
            "config_dir": os.environ.get(f"{prefix}CONFIG_DIR", f".{name}"),
        }

    # Generic provider — any authenticated, openai-chat-compatible provider
    # the host materialized via TEROK_PROVIDER_{NAME}_*.
    generic_base = os.environ.get(f"TEROK_PROVIDER_{name.upper()}_BASE_OPENAI_CHAT")
    if generic_base:
        return {
            "name": name,
            "display_name": os.environ.get(f"{provider_prefix}LABEL", name),
            "base_url": generic_base,
            "preferred_model": os.environ.get(f"{provider_prefix}DEFAULT_MODEL", ""),
            "fallback_model": "",
            "env_var_prefix": name.upper(),
            "config_dir": f".{name}",
        }

    # Fallback to hardcoded defaults for manual invocation
    if name in _FALLBACK_PROVIDERS:
        return {**_FALLBACK_PROVIDERS[name], "name": name}

    raise SystemExit(f"Unknown provider: {name}")


def _declared_models(name: str) -> dict[str, dict[str, Any]]:
    """Return provider-neutral model data from the host.

    Ignore invalid values to prevent an OpenCode start failure. The host schema
    validates the normal input. This parser also accepts manual environments
    and containers from different releases.
    """
    raw = os.environ.get(f"{_provider_prefix(name)}MODELS")
    if not raw:
        return {}
    try:
        payload = json.loads(raw)
    except json.JSONDecodeError:
        return {}
    if not isinstance(payload, dict):
        return {}

    models: dict[str, dict[str, Any]] = {}
    for model_id, metadata in payload.items():
        if not isinstance(model_id, str) or not model_id or not isinstance(metadata, dict):
            continue
        model: dict[str, Any] = {}
        name_value = metadata.get("name")
        if isinstance(name_value, str) and name_value:
            model["name"] = name_value
        for key in ("context_limit", "output_limit"):
            if (value := _positive_integer(metadata.get(key))) is not None:
                model[key] = value
        models[model_id] = model
    return models


def _config_dir(config: dict[str, str]) -> Path:
    """Return the provider-specific configuration directory."""
    return Path.home() / config["config_dir"]


def _config_path(config: dict[str, str]) -> Path:
    """Return the path to the provider's config.json file."""
    return _config_dir(config) / "config.json"


def _load_api_key(config: dict[str, str]) -> str | None:
    """Load API key from environment or config file.

    Generic providers carry their phantom key in ``TEROK_PROVIDER_{NAME}_TOKEN``;
    curated ones also expose it under ``{PREFIX}_API_KEY``.  Both hold the same
    vault token, so either is fine — the generic var is checked first so an
    arbitrary ``--provider`` selection works without a curated prefix.
    """
    token = os.environ.get(f"TEROK_PROVIDER_{config['name'].upper()}_TOKEN")
    if token:
        return token
    env_var = config["env_var_prefix"] + "_API_KEY"
    api_key = os.environ.get(env_var)
    if api_key:
        return api_key

    cfg_path = _config_path(config)
    if not cfg_path.is_file():
        return None

    try:
        data = json.loads(cfg_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    if not isinstance(data, dict):
        return None

    val = data.get("api_key")
    return val if isinstance(val, str) and val.strip() else None


def _fetch_models(base_url: str, api_key: str) -> dict[str, dict[str, Any]] | None:
    """Fetch available models from the API. Returns None on failure.

    ``OSError`` is the whole network-failure family here — ``HTTPError``,
    ``URLError`` and the socket-level ``TimeoutError`` a half-dead proxy
    produces are all subclasses.  A failure is announced with its reason
    rather than swallowed: the fetch can stall for the full timeout, and
    a silent ``None`` would leave the user staring at a frozen launcher.
    """
    url = base_url.rstrip("/") + "/models"
    req = request.Request(
        url,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Accept": "application/json",
        },
    )

    try:
        with request.urlopen(req, timeout=30) as resp:  # nosec B310
            payload = json.loads(resp.read().decode("utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        print(f"Warning: model-list refresh from {url} failed: {exc}", file=sys.stderr)
        return None

    items = []
    if isinstance(payload, dict):
        if isinstance(payload.get("data"), list):
            items = payload["data"]
        elif isinstance(payload.get("models"), list):
            items = payload["models"]

    models: dict[str, dict[str, Any]] = {}
    for item in items:
        if isinstance(item, dict):
            model_id = item.get("id")
            if isinstance(model_id, str) and model_id:
                metadata: dict[str, Any] = {}
                name = item.get("name")
                if isinstance(name, str) and name:
                    metadata["name"] = name
                context_limit = _positive_integer(item.get("context_window"))
                if context_limit is not None:
                    metadata["context_limit"] = context_limit
                output_limit = _positive_integer(item.get("max_tokens"))
                if output_limit is not None:
                    metadata["output_limit"] = output_limit
                models[model_id] = metadata

    return dict(sorted(models.items())) if models else None


def _opencode_models(models: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
    """Convert generic model data to the OpenCode provider schema.

    OpenCode requires both ``limit.context`` and ``limit.output``. Some
    providers specify only one limit. Omit an incomplete pair. Do not create an
    unknown value.
    """
    projected: dict[str, dict[str, Any]] = {}
    for model_id, metadata in models.items():
        model: dict[str, Any] = {"name": metadata.get("name") or model_id}
        context = metadata.get("context_limit")
        output = metadata.get("output_limit")
        if context is not None and output is not None:
            model["limit"] = {"context": context, "output": output}
        projected[model_id] = model
    return projected


def _build_provider_update(
    config: dict[str, str],
    base_url: str,
    api_key: str,
    model: str,
    models: dict[str, dict[str, Any]],
) -> dict[str, Any]:
    """Build the provider-specific config fragment for opencode.json."""
    model_map = _opencode_models(models)
    model_map.setdefault(model, {"name": model})

    provider_name = config["name"]
    return {
        "$schema": _OPENCODE_SCHEMA,
        "model": f"{provider_name}/{model}",
        "provider": {
            provider_name: {
                "npm": _OPENAI_COMPATIBLE_NPM,
                "name": config["display_name"],
                "options": {
                    "baseURL": base_url,
                    "apiKey": api_key,
                },
                "models": model_map,
            }
        },
        "permission": {
            "*": "allow",
        },
    }


def _merge_provider_config(existing: dict, update: dict, config: dict) -> dict:
    """Merge provider update into existing opencode.json config."""
    merged = dict(existing)

    # Schema handling
    existing_schema = merged.get("$schema")
    expected_schema = update["$schema"]
    if existing_schema and existing_schema != expected_schema:
        print(
            f"Warning: opencode.json has unexpected $schema value "
            f"{existing_schema!r}, expected {expected_schema!r}. Overwriting.",
            file=sys.stderr,
        )
    merged["$schema"] = expected_schema

    # Provider deep-merge
    existing_providers = merged.get("provider")
    if not isinstance(existing_providers, dict):
        existing_providers = {}
    update_providers = update.get("provider", {})
    merged_providers = dict(existing_providers)
    merged_providers.update(update_providers)
    merged["provider"] = merged_providers

    # Model handling - only overwrite if unset or already provider-prefixed
    current_model = merged.get("model")
    if not current_model or (
        isinstance(current_model, str) and current_model.startswith(f"{config['name']}/")
    ):
        merged["model"] = update["model"]

    # Permission - only set if not already configured
    if "permission" not in merged:
        merged["permission"] = update["permission"]

    return merged


def _opencode_config_path(config: dict[str, str]) -> Path:
    """Return the provider-specific OpenCode config path."""
    return _config_dir(config) / "opencode" / "opencode.json"


def _load_opencode_config(config: dict[str, str]) -> dict | None:
    """Load existing OpenCode config if present."""
    config_path = _opencode_config_path(config)
    if not config_path.is_file():
        return None

    try:
        loaded = json.loads(config_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    return loaded if isinstance(loaded, dict) else None


def _get_configured_models(
    config: dict[str, str], existing_config: dict | None
) -> dict[str, dict[str, Any]]:
    """Return generic model data from an existing OpenCode configuration."""
    if not existing_config:
        return {}

    try:
        models = existing_config.get("provider", {}).get(config["name"], {}).get("models", {})
        if not isinstance(models, dict):
            return {}
        generic: dict[str, dict[str, Any]] = {}
        for model_id, raw in models.items():
            if not isinstance(model_id, str) or not isinstance(raw, dict):
                continue
            metadata: dict[str, Any] = {}
            if isinstance(raw.get("name"), str):
                metadata["name"] = raw["name"]
            limit = raw.get("limit")
            if isinstance(limit, dict):
                if (context := _positive_integer(limit.get("context"))) is not None:
                    metadata["context_limit"] = context
                if (output := _positive_integer(limit.get("output"))) is not None:
                    metadata["output_limit"] = output
            generic[model_id] = metadata
        return generic
    except (AttributeError, TypeError):
        return {}


def _get_configured_options(config: dict[str, str], existing_config: dict | None) -> dict:
    """Extract provider options from existing config."""
    if not existing_config:
        return {}

    try:
        options = existing_config.get("provider", {}).get(config["name"], {}).get("options", {})
        return options if isinstance(options, dict) else {}
    except (AttributeError, TypeError):
        return {}


def _get_configured_display_name(
    config: dict[str, str], existing_config: dict | None
) -> str | None:
    """Return the stored display name for the selected provider."""
    if not existing_config:
        return None

    try:
        name = existing_config.get("provider", {}).get(config["name"], {}).get("name")
        return name if isinstance(name, str) else None
    except (AttributeError, TypeError):
        return None


def _managed_config_needs_update(config: dict[str, str], existing_config: dict | None) -> bool:
    """Return whether OpenCode-managed fields need repair."""
    if not existing_config:
        return True

    providers = existing_config.get("provider")
    provider = providers.get(config["name"]) if isinstance(providers, dict) else None
    return (
        existing_config.get("$schema") != _OPENCODE_SCHEMA
        or "permission" not in existing_config
        or not isinstance(provider, dict)
        or provider.get("npm") != _OPENAI_COMPATIBLE_NPM
    )


def _write_opencode_config(config: dict[str, str], content: dict) -> Path:
    """Write config to OpenCode's location via atomic replace."""
    config_path = _opencode_config_path(config)
    config_path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = None

    try:
        with tempfile.NamedTemporaryFile(
            "w", encoding="utf-8", dir=config_path.parent, suffix=".tmp", delete=False
        ) as f:
            tmp_path = f.name
            f.write(json.dumps(content, indent=2) + "\n")
        os.replace(tmp_path, config_path)
        # Security: Restrict config file permissions to owner only
        try:
            os.chmod(config_path, 0o600)
        except (OSError, PermissionError):
            # Permission change may fail in some environments (e.g., read-only filesystems)
            # This is not a security critical failure
            pass
    except BaseException:
        if tmp_path and os.path.exists(tmp_path):
            os.unlink(tmp_path)
        raise

    return config_path


def main() -> int:
    """Main entry point for the unified provider launcher."""
    # The provider comes from either the invoked command name (a symlink like
    # ``blablador``) or an explicit ``--provider`` (what the ``opencode`` wrapper
    # passes for a runtime selection).  Resolve it *before* building the full
    # parser, whose help text embeds the resolved provider's config — otherwise
    # ``opencode-provider --provider X`` would resolve the literal command name
    # ``opencode-provider`` and fail with "Unknown provider".
    _pre = argparse.ArgumentParser(add_help=False)
    _pre.add_argument("--provider", default=None)
    _selected, _ = _pre.parse_known_args()
    config = _resolve_provider_config(_selected.provider)

    parser = argparse.ArgumentParser(
        prog=config["name"],
        description=f"Run OpenCode against {config['display_name']} with full permissions.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Examples:\n"
            f"  {config['name']}                # launch with preferred model\n"
            f"  {config['name']} --list-models  # list available models\n"
            f"  {config['name']} -- --help      # pass flags to opencode\n"
        ),
    )
    parser.add_argument("--list-models", action="store_true", help="List available models and exit")
    parser.add_argument(
        "--base-url",
        default=None,
        help=f"Override API base URL (default: {config['base_url']})",
    )
    parser.add_argument(
        "--provider",
        default=None,
        help="Run against a specific authenticated provider (overrides the command name)",
    )

    args, opencode_args = parser.parse_known_args()
    # ``config`` is already resolved from ``--provider`` (or argv[0]) above; the
    # full parse here just keeps ``--provider`` out of the args forwarded to
    # opencode.

    # Load and validate API key
    api_key = _load_api_key(config)
    if not api_key:
        raise SystemExit(
            f"Missing {config['env_var_prefix']}_API_KEY. Set it in the environment or write "
            f'{{"api_key": "..."}} to {_config_path(config)}.'
        )

    # Determine base URL
    base_url = (
        args.base_url
        or os.environ.get(f"{config['env_var_prefix']}_BASE_URL")
        or config["base_url"]
    )
    base_url = base_url.rstrip("/")

    # Declared provider model data is the source of truth. It preserves limits.
    # It also supports an API whose chat endpoint and model list have different
    # locations. A provider without declared data keeps live /models discovery.
    declared_models = _declared_models(config["name"])
    fetched_models = None
    if declared_models:
        available_models = declared_models
    else:
        # Announce first: the refresh can legitimately take up to the 30s
        # timeout, and a silent launcher reads as frozen.
        print(f"Updating the model list from {config['display_name']}.", file=sys.stderr)
        fetched_models = _fetch_models(base_url, api_key)
        available_models = fetched_models or {}

    # Load existing config
    existing_config = _load_opencode_config(config)
    configured_models = _get_configured_models(config, existing_config)

    # A failed live refresh preserves the last known config.  Declared models
    # never merge with that cache: their non-empty set is the provider's source
    # of truth and intentionally suppresses /models discovery.
    models = dict(available_models or configured_models)

    if args.list_models:
        if models:
            for model in models:
                print(model)
        else:
            raise SystemExit(f"Failed to fetch models from {config['display_name']} API")
        return 0

    # Determine which model to use.
    model = config["preferred_model"]
    if not model and models:
        model = next(iter(models))
    elif models and model not in models:
        fallback = config["fallback_model"]
        if fallback in models:
            model = fallback
        else:
            # Both defaults gone — pick the first available model
            model = next(iter(models))
        print(
            f"Warning: Preferred model '{config['preferred_model']}' is no longer available.\n"
            f"Using '{model}'.\n"
            "Update default_model in the provider YAML file if this change is permanent.",
            file=sys.stderr,
        )
    if not model:
        raise SystemExit(
            f"No models are available from {config['display_name']}. "
            "Add a models map to the provider YAML file. Alternatively, make the "
            "provider's /models endpoint available."
        )
    models.setdefault(model, {})

    # Update config if needed
    stored_options = _get_configured_options(config, existing_config)
    options_changed = (
        stored_options.get("baseURL") != base_url or stored_options.get("apiKey") != api_key
    )

    projected_models = _opencode_models(models)
    configured_projection = _opencode_models(configured_models)
    current_model = (existing_config or {}).get("model")
    managed_model = not current_model or (
        isinstance(current_model, str) and current_model.startswith(f"{config['name']}/")
    )
    model_changed = managed_model and current_model != f"{config['name']}/{model}"
    name_changed = _get_configured_display_name(config, existing_config) != config["display_name"]
    needs_update = (
        projected_models != configured_projection
        or options_changed
        or model_changed
        or name_changed
        or _managed_config_needs_update(config, existing_config)
    )
    if needs_update:
        new_models = set(models) - set(configured_models)
        if new_models:
            print(f"New models available: {', '.join(sorted(new_models))}", file=sys.stderr)
        update = _build_provider_update(config, base_url, api_key, model, models)
        merged = _merge_provider_config(existing_config or {}, update, config)
        _write_opencode_config(config, merged)

    # Launch OpenCode.  Git identity is the caller's job — the generated
    # ``blablador()`` / ``kisski()`` shell wrappers (and the ``*-acp`` env
    # scripts on the ACP path) apply the task's authorship before this launcher
    # runs, so it stays a pure config-translate-and-exec step, matching
    # ``pi-provider`` and ``terok-native-provider``.
    cmd = ["opencode"] + opencode_args
    env = {**os.environ, "OPENCODE_CONFIG": str(_opencode_config_path(config))}
    try:
        return subprocess.call(cmd, env=env)
    except FileNotFoundError:
        raise SystemExit("opencode not found. Rebuild the L1 CLI image to install it.")


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