"""LlmConfigService — runtime CRUD for LLM providers/fallbacks (U5).

This module wraps the YAML read/write logic from
:mod:`agentkit.server.routes.settings` as a reusable service. Web UI
routes (``/api/v1/admin/llm/*``) and the CLI ``agentkit admin llm``
sub-app both call into :class:`LlmConfigService` rather than touching
the YAML directly, keeping the validation rules (duplicate-provider,
fallback-in-use guard, API-key masking) in one place.

The service writes back to ``agentkit.yaml`` using the existing
:func:`_write_yaml_config` helper (which preserves ``${VAR}`` env-var
references and comments via ruamel.yaml). Concurrent writes are
serialized with :func:`fcntl.flock` to prevent corruption.

The service is a module-level singleton (see
:func:`get_llm_config_service`) so tests can inject a custom instance
via :func:`set_llm_config_service`.
"""

from __future__ import annotations

import fcntl
import logging
import os
import re
from pathlib import Path
from typing import Any

import yaml

from agentkit.server.routes.settings import (
    _mask_api_key,
    _read_yaml_config,
    _write_env_var,
    _write_yaml_config,
)

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

#: Regex matching ``${ENV_VAR}`` references in YAML api_key fields.
_ENV_REF_RE = re.compile(r"^\$\{([^}]+)\}$")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _provider_to_dict(name: str, pconf: dict[str, Any]) -> dict[str, Any]:
    """Convert a raw YAML provider dict to a masked response dict.

    The ``api_key`` field is masked via :func:`_mask_api_key`. The
    ``models`` field is preserved as-is (it's already a dict in YAML).
    """
    raw_key = pconf.get("api_key", "")
    # Resolve ${VAR} references for masking — we want to show the
    # masked *resolved* value, not the literal "${VAR}" string.
    resolved_key = _resolve_env_var(raw_key)
    return {
        "name": name,
        "type": pconf.get("type", "openai"),
        "api_key": _mask_api_key(resolved_key),
        "base_url": pconf.get("base_url", ""),
        "models": pconf.get("models", {}) or {},
        "max_tokens": pconf.get("max_tokens", 4096),
        "timeout": pconf.get("timeout", 60.0),
    }


def _resolve_env_var(value: str) -> str:
    """Resolve a ``${VAR}`` reference to its env value (if set).

    If ``value`` is ``${OPENAI_API_KEY}`` and ``OPENAI_API_KEY`` is set
    in the environment, returns the env value. Otherwise returns the
    input unchanged.
    """
    if not isinstance(value, str):
        return ""
    match = _ENV_REF_RE.match(value)
    if match:
        var_name = match.group(1).split(":-")[0]
        return os.environ.get(var_name, value)
    return value


def _env_var_name_for_provider(name: str) -> str:
    """Derive a sensible env var name from a provider name."""
    return f"{name.upper().replace('-', '_')}_API_KEY"


# ---------------------------------------------------------------------------
# File-locking context manager
# ---------------------------------------------------------------------------


class _YamlLock:
    """Context manager that acquires an exclusive ``fcntl.flock`` on the
    YAML file's sibling lockfile (``<config_path>.lock``).

    The lockfile is created on demand and is not removed after release
    (it's reused on subsequent writes). Holding the lock prevents
    concurrent writers from corrupting the YAML file.
    """

    def __init__(self, config_path: Path) -> None:
        self._lock_path = config_path.with_suffix(config_path.suffix + ".lock")
        self._fh = None

    def __enter__(self) -> "_YamlLock":
        self._fh = open(self._lock_path, "w")
        fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX)
        return self

    def __exit__(self, exc_type, exc, tb) -> None:
        if self._fh is not None:
            fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
            self._fh.close()
            self._fh = None


# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------


class LlmConfigService:
    """CRUD for LLM providers, API keys, and fallback chains.

    All methods read/write the YAML file at ``self._config_path``.
    Writes are serialized via :class:`_YamlLock` (``fcntl.flock``) to
    prevent concurrent corruption. The existing ``watch_config()``
    mechanism detects the file change and rebuilds the
    :class:`LLMGateway` — no explicit notification is needed.

    API keys are NEVER returned in full — every response masks them via
    :func:`_mask_api_key`. New plaintext keys are written to ``.env``
    (next to the YAML) and the YAML stores a ``${ENV_VAR}`` reference.
    """

    def __init__(self, config_path: Path) -> None:
        self._config_path = Path(config_path)

    # ------------------------------------------------------------------
    # Provider CRUD
    # ------------------------------------------------------------------

    def list_providers(self) -> list[dict[str, Any]]:
        """Return all providers with masked API keys."""
        data = self._read()
        providers = data.get("llm", {}).get("providers", {}) or {}
        return [_provider_to_dict(name, pconf) for name, pconf in providers.items()]

    def get_provider(self, name: str) -> dict[str, Any] | None:
        """Return a single provider by name (masked key), or ``None``."""
        data = self._read()
        pconf = data.get("llm", {}).get("providers", {}).get(name)
        if pconf is None:
            return None
        return _provider_to_dict(name, pconf)

    def create_provider(
        self,
        name: str,
        provider_type: str,
        api_key: str,
        base_url: str = "",
        models: dict[str, Any] | None = None,
        max_tokens: int = 4096,
        timeout: float = 60.0,
    ) -> dict[str, Any]:
        """Add a new provider to the YAML file.

        Args:
            name: Provider name (must be unique within the YAML).
            provider_type: Provider type (``openai`` / ``anthropic`` /
                ``gemini`` / ``doubao`` / ``wenxin`` / ``yuanbao``).
            api_key: Plaintext API key. Stored in ``.env`` as
                ``{NAME}_API_KEY``; the YAML stores ``${...}`` ref.
            base_url: Optional base URL override.
            models: Optional models dict (e.g. ``{"gpt-4o": {}}``).
            max_tokens: Default max tokens for completions.
            timeout: Request timeout in seconds.

        Returns:
            The newly-created provider dict (masked key).

        Raises:
            ValueError: If a provider with the same name already exists.
        """
        with _YamlLock(self._config_path):
            data = self._read()
            data.setdefault("llm", {}).setdefault("providers", {})
            if name in data["llm"]["providers"]:
                raise ValueError(f"Provider {name!r} already exists")

            env_key = _env_var_name_for_provider(name)
            _write_env_var(str(self._config_path), env_key, api_key)

            pconf: dict[str, Any] = {
                "type": provider_type,
                "api_key": f"${{{env_key}}}",
                "base_url": base_url,
                "max_tokens": max_tokens,
                "timeout": timeout,
            }
            if models is not None:
                pconf["models"] = models
            data["llm"]["providers"][name] = pconf
            self._write(data)

        created = self.get_provider(name)
        assert created is not None  # we just inserted it
        return created

    def update_provider(
        self,
        name: str,
        provider_type: str | None = None,
        api_key: str | None = None,
        base_url: str | None = None,
        models: dict[str, Any] | None = None,
        max_tokens: int | None = None,
        timeout: float | None = None,
    ) -> dict[str, Any]:
        """Partially update a provider.

        Only the provided fields are updated. If ``api_key`` is a masked
        value (starts with ``****``), it is ignored — the existing key
        is preserved.

        Raises:
            ValueError: If the provider does not exist.
        """
        with _YamlLock(self._config_path):
            data = self._read()
            providers = data.get("llm", {}).get("providers", {}) or {}
            if name not in providers:
                raise ValueError(f"Provider {name!r} not found")

            pconf = providers[name]
            if provider_type is not None:
                pconf["type"] = provider_type
            if base_url is not None:
                pconf["base_url"] = base_url
            if models is not None:
                pconf["models"] = models
            if max_tokens is not None:
                pconf["max_tokens"] = max_tokens
            if timeout is not None:
                pconf["timeout"] = timeout
            if api_key is not None and not api_key.startswith("****"):
                # New plaintext key — write to .env, keep ${VAR} ref in YAML.
                existing_key = str(pconf.get("api_key", ""))
                env_match = _ENV_REF_RE.match(existing_key)
                if env_match:
                    env_key = env_match.group(1).split(":-")[0]
                else:
                    env_key = _env_var_name_for_provider(name)
                _write_env_var(str(self._config_path), env_key, api_key)
                pconf["api_key"] = f"${{{env_key}}}"

            providers[name] = pconf
            data.setdefault("llm", {})["providers"] = providers
            self._write(data)

        updated = self.get_provider(name)
        assert updated is not None
        return updated

    def delete_provider(self, name: str) -> bool:
        """Remove a provider from the YAML file.

        Returns:
            ``True`` if the provider was deleted.

        Raises:
            ValueError: If the provider does not exist, or if it is
                referenced by any fallback chain (removing it would
                break the chain).
        """
        with _YamlLock(self._config_path):
            data = self._read()
            providers = data.get("llm", {}).get("providers", {}) or {}
            if name not in providers:
                raise ValueError(f"Provider {name!r} not found")

            # Guard: refuse to delete if referenced in any fallback chain.
            fallbacks = data.get("llm", {}).get("fallbacks", {}) or {}
            for model, chain in fallbacks.items():
                if isinstance(chain, list) and name in chain:
                    raise ValueError(
                        f"Provider {name!r} is used in fallback chain for model "
                        f"{model!r}; remove the fallback first"
                    )

            del providers[name]
            data.setdefault("llm", {})["providers"] = providers
            self._write(data)
        return True

    # ------------------------------------------------------------------
    # API key management
    # ------------------------------------------------------------------

    def set_api_key(self, provider_name: str, api_key: str) -> dict[str, Any]:
        """Set the API key for a provider.

        The plaintext key is written to ``.env`` (as
        ``{NAME}_API_KEY``) and the YAML stores a ``${...}`` reference.
        If the provider does not yet exist in the YAML, a stub entry is
        created with ``type=openai`` and default settings.

        Returns:
            The updated provider dict (masked key).
        """
        with _YamlLock(self._config_path):
            data = self._read()
            data.setdefault("llm", {}).setdefault("providers", {})
            providers = data["llm"]["providers"]
            pconf = providers.get(provider_name, {})
            existing_key = str(pconf.get("api_key", ""))
            env_match = _ENV_REF_RE.match(existing_key)
            if env_match:
                env_key = env_match.group(1).split(":-")[0]
            else:
                env_key = _env_var_name_for_provider(provider_name)
            _write_env_var(str(self._config_path), env_key, api_key)
            pconf["api_key"] = f"${{{env_key}}}"
            if "type" not in pconf:
                pconf["type"] = "openai"
            providers[provider_name] = pconf
            self._write(data)

        updated = self.get_provider(provider_name)
        assert updated is not None
        return updated

    # ------------------------------------------------------------------
    # Fallback chain management
    # ------------------------------------------------------------------

    def get_fallbacks(self) -> dict[str, list[str]]:
        """Return the fallback chains (model → list of provider/model refs)."""
        data = self._read()
        fallbacks = data.get("llm", {}).get("fallbacks", {}) or {}
        # Normalize: ensure all values are lists.
        return {
            str(model): list(chain) if isinstance(chain, list) else [str(chain)]
            for model, chain in fallbacks.items()
        }

    def set_fallback(self, model: str, chain: list[str]) -> dict[str, Any]:
        """Set the fallback chain for a model.

        Returns:
            ``{"model": model, "chain": chain}`` dict.
        """
        with _YamlLock(self._config_path):
            data = self._read()
            data.setdefault("llm", {}).setdefault("fallbacks", {})
            data["llm"]["fallbacks"][model] = list(chain)
            self._write(data)
        return {"model": model, "chain": list(chain)}

    def delete_fallback(self, model: str) -> bool:
        """Delete the fallback chain for a model.

        Returns:
            ``True`` if a chain was deleted, ``False`` if no chain
            existed for ``model``.
        """
        with _YamlLock(self._config_path):
            data = self._read()
            fallbacks = data.get("llm", {}).get("fallbacks", {}) or {}
            if model not in fallbacks:
                return False
            del fallbacks[model]
            data.setdefault("llm", {})["fallbacks"] = fallbacks
            self._write(data)
        return True

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _read(self) -> dict[str, Any]:
        """Read the YAML config (returns ``{}`` if file is missing/empty)."""
        if not self._config_path.exists():
            return {}
        return _read_yaml_config(str(self._config_path))

    def _write(self, data: dict[str, Any]) -> None:
        """Write the full config dict back to the YAML file."""
        _write_yaml_config(str(self._config_path), data)


# ---------------------------------------------------------------------------
# Module-level singleton (overridable in tests via set_llm_config_service)
# ---------------------------------------------------------------------------


_llm_config_service: LlmConfigService | None = None


def get_llm_config_service(config_path: Path | str | None = None) -> LlmConfigService:
    """Return the process-wide :class:`LlmConfigService`.

    On first call, ``config_path`` must be provided (or the service
    will raise ``ValueError``). Subsequent calls ignore ``config_path``
    and return the cached singleton.
    """
    global _llm_config_service
    if _llm_config_service is None:
        if config_path is None:
            raise ValueError("config_path is required to initialize LlmConfigService on first call")
        _llm_config_service = LlmConfigService(Path(config_path))
    return _llm_config_service


def set_llm_config_service(service: LlmConfigService | None) -> None:
    """Inject a custom :class:`LlmConfigService` (used by tests)."""
    global _llm_config_service
    _llm_config_service = service


# Re-export yaml for tests that need to construct sample config files.
__all__ = [
    "LlmConfigService",
    "get_llm_config_service",
    "set_llm_config_service",
    "yaml",
]
