"""Kilo configuration builder for CyberBrain26 with a strict free-only whitelist.

Only models whose registry record reports ``is_free`` may appear in the
generated configuration.  When no free-eligible model exists the builder
raises instead of emitting an empty configuration, so a caller can never
install a provider block that would silently route to a paid model.
"""

from __future__ import annotations

import json
import logging
import os
import shutil
from typing import Any

logger = logging.getLogger(__name__)


class NoFreeModelAvailable(RuntimeError):
    """Raised when the registry holds no free-eligible active model."""


class KiloConfigBuilder:
    """Build Kilo provider configuration restricted to free-eligible models."""

    def __init__(self, model_registry: Any, provider_id: str = "cb26") -> None:
        self.model_registry = model_registry
        self.provider_id = provider_id

    def _is_free_eligible(self, model: Any) -> bool:
        """Return ``True`` only for registry records explicitly marked free.

        ``ModelRecord.is_free`` is a property that maps ``free_status`` onto
        the FREE / FREE_FOR_TEST / FREE_FOR_STUDY set.  Anything unknown or paid
        is therefore rejected, which is the fail-closed direction.
        """
        if model is None:
            return False
        return bool(model.is_free)

    def _free_models(self) -> list[Any]:
        """Return the sorted free-eligible active models of the registry."""
        free = [m for m in self.model_registry.list_active() if self._is_free_eligible(m)]
        return sorted(free, key=lambda m: m.model_id)

    def build_kilo_provider_config(self, *, provider_id: str | None = None) -> dict[str, Any]:
        """Build the provider configuration block for ``provider_id``.

        Raises:
            NoFreeModelAvailable: if the registry has no free-eligible active
                model.  Emitting an empty whitelist would be indistinguishable
                from a working configuration at the call site, so the builder
                fails instead.
        """
        target = provider_id or self.provider_id
        models = self._free_models()
        if not models:
            raise NoFreeModelAvailable(
                "refusing to build a Kilo provider configuration without a "
                "free-eligible model: a paid fallback must never be configured"
            )

        entries: dict[str, Any] = {}
        for model in models:
            entries[model.model_id] = {
                "name": model.provider,
                "id": model.model_id,
                "provider": model.provider,
                "tool_call": False,
                "reasoning": True,
                "limit": {
                    "context": model.context_window or 32768,
                    "output": 4096,
                },
                "cost": {"input": 0, "output": 0},
            }

        return {
            "provider": {
                target: {
                    "models": entries,
                    "whitelist": [m.model_id for m in models],
                },
            },
            "model": f"{target}/{models[0].model_id}",
        }

    def validate_kilo_provider_config(
        self,
        config: dict[str, Any],
        model_registry: Any | None = None,
        *,
        provider_id: str | None = None,
    ) -> list[str]:
        """Report every model in ``config`` that is unknown or not free-eligible."""
        registry = model_registry or self.model_registry
        target = provider_id or self.provider_id
        problems: list[str] = []

        providers = config.get("provider", {})
        for provider, block in providers.items():
            for model_id in block.get("models", {}):
                # The provider key here is the Kilo-side provider id, which is
                # not necessarily the registry provider, so look up by model id.
                record = registry.get(model_id)
                if record is None:
                    problems.append(
                        f"model {model_id!r} from provider {provider!r} is not in the registry"
                    )
                elif not self._is_free_eligible(record):
                    problems.append(
                        f"model {model_id!r} from provider {provider!r} is not free-eligible "
                        f"(free_status={record.free_status!r})"
                    )
            for model_id in block.get("whitelist", []):
                record = registry.get(model_id)
                if record is None:
                    problems.append(
                        f"whitelisted model {model_id!r} is not in the registry"
                    )
                elif not self._is_free_eligible(record):
                    problems.append(
                        f"whitelisted model {model_id!r} is not free-eligible "
                        f"(free_status={record.free_status!r})"
                    )

        # Fail-closed when the provider block is absent entirely: an absent
        # provider cannot be distinguished from an unvalidated one.
        if target not in providers:
            problems.append(f"configuration declares no provider block for {target!r}")

        return problems

    def diff_kilo_config(self, current: dict[str, Any], new: dict[str, Any]) -> str:
        """Render a human-readable line diff of two configuration mappings."""
        lines: list[str] = []
        for key in sorted(set(current) | set(new)):
            if current.get(key) != new.get(key):
                lines.append(f"- {key}: {current.get(key)!r}")
                lines.append(f"+ {key}: {new.get(key)!r}")
        return "\n".join(lines)

    def backup_kilo_config(self, path: str) -> str | None:
        """Copy ``path`` to ``<path>.bak`` and return the backup path.

        Returns ``None`` when the source file does not exist: a missing file
        must not be reported as a successful backup.
        """
        if not os.path.isfile(path):
            logger.warning("cannot back up missing Kilo config at %s", path)
            return None
        backup_path = f"{path}.bak"
        shutil.copy2(path, backup_path)
        logger.info("Kilo config backed up to %s", backup_path)
        return backup_path

    def apply_kilo_config(
        self,
        path: str,
        new_config: dict[str, Any],
        model_registry: Any | None = None,
        *,
        provider_id: str | None = None,
    ) -> dict[str, Any]:
        """Validate and atomically write ``new_config`` to ``path``.

        The write is atomic (temp file in the same directory followed by
        ``os.replace``) so a failed write can never leave a half-written
        provider configuration behind.
        """
        problems = self.validate_kilo_provider_config(
            new_config, model_registry, provider_id=provider_id
        )
        if problems:
            logger.error("refusing to apply Kilo config: %s", "; ".join(problems))
            return {"applied": False, "problems": problems}

        backup_path = self.backup_kilo_config(path)

        directory = os.path.dirname(os.path.abspath(path)) or "."
        os.makedirs(directory, exist_ok=True)
        tmp_path = f"{path}.tmp"
        with open(tmp_path, "w", encoding="utf-8") as handle:
            json.dump(new_config, handle, indent=2, ensure_ascii=False, sort_keys=True)
            handle.write("\n")
        os.replace(tmp_path, path)

        logger.info("applied Kilo config to %s", path)
        return {"applied": True, "backup": backup_path, "path": path}

    def restore_kilo_config(self, path: str, backup_path: str) -> bool:
        """Restore ``path`` from ``backup_path``.

        Returns ``False`` when the backup is absent instead of raising, so the
        caller keeps the current configuration untouched.
        """
        if not os.path.isfile(backup_path):
            logger.error("cannot restore: backup %s does not exist", backup_path)
            return False
        shutil.copy2(backup_path, path)
        logger.info("restored Kilo config from %s", backup_path)
        return True