from __future__ import annotations

import time
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Generic, TypeVar


T = TypeVar("T")


class LLMGatewayError(ValueError):
    """Stable application error that does not expose provider details."""

    def __init__(self, message: str, code: str = "unavailable") -> None:
        super().__init__(message)
        self.code = code


@dataclass(frozen=True)
class ModelProfile:
    role: str
    api_key: str
    base_url: str
    model: str

    @property
    def configured(self) -> bool:
        return bool(self.api_key and self.base_url and self.model)


@dataclass(frozen=True)
class LLMResult(Generic[T]):
    value: T
    source: str
    role: str
    model: str
    latency_ms: int


@dataclass(frozen=True)
class LLMStreamEvent(Generic[T]):
    kind: str
    value: T | None = None
    source: str = ""
    role: str = ""
    model: str = ""
    latency_ms: int = 0


class LLMGateway:
    """Single policy boundary for access, model fallback, and call auditing."""

    def __init__(
        self,
        *,
        database: Any,
        user_id_supplier: Callable[[], int],
        membership_supplier: Callable[[], dict[str, Any]],
        settings_supplier: Callable[[], dict[str, Any]],
        profile_supplier: Callable[[], dict[str, Any]],
    ) -> None:
        self.database = database
        self.user_id_supplier = user_id_supplier
        self.membership_supplier = membership_supplier
        self.settings_supplier = settings_supplier
        self.profile_supplier = profile_supplier

    def ensure_access(self, feature: str) -> tuple[str, tuple[ModelProfile, ...]]:
        del feature  # Reserved for future feature-specific policy.
        membership = self.membership_supplier()
        profile = self.profile_supplier()
        source = str(profile.get("source") or "none")
        profiles = self._model_profiles(profile)
        if source == "none" or not profiles:
            raise LLMGatewayError("智能功能尚未配置，请联系管理员。", "not_configured")
        if source == "platform":
            settings = self.settings_supplier()
            limit = max(1, int(settings.get("member_daily_limit") or 50))
            if not membership.get("active"):
                raise LLMGatewayError("开通会员后可使用智能功能。", "membership_required")
            if self._usage_today(source) >= limit:
                raise LLMGatewayError(
                    f"今日会员模型额度已用完（{limit} 次）。", "quota_exhausted"
                )
        return source, profiles

    def call(
        self,
        feature: str,
        prompt_version: str,
        invoke: Callable[[ModelProfile], T],
        error_types: tuple[type[BaseException], ...],
    ) -> LLMResult[T]:
        source, profiles = self.ensure_access(feature)
        started = time.perf_counter()
        last_error: BaseException | None = None
        for profile in profiles:
            try:
                value = invoke(profile)
            except error_types as exc:
                last_error = exc
                continue
            latency_ms = round((time.perf_counter() - started) * 1000)
            self.audit(
                feature, source, profile, "success", latency_ms, prompt_version
            )
            return LLMResult(
                value=value,
                source=source,
                role=profile.role,
                model=profile.model,
                latency_ms=latency_ms,
            )
        failed = profiles[-1]
        latency_ms = round((time.perf_counter() - started) * 1000)
        self.audit(
            feature,
            source,
            failed,
            "failed",
            latency_ms,
            prompt_version,
            self._error_code(last_error),
        )
        raise LLMGatewayError("智能解读服务暂不可用，请稍后重试。") from last_error

    @staticmethod
    def probe(profile: dict[str, Any], invoke: Callable[[ModelProfile], T]) -> T:
        """Route an explicit administrator connection test through the gateway boundary."""
        model = ModelProfile(
            role="probe",
            api_key=str(profile.get("api_key") or ""),
            base_url=str(profile.get("base_url") or ""),
            model=str(profile.get("model") or ""),
        )
        return invoke(model)

    def stream(
        self,
        feature: str,
        prompt_version: str,
        invoke: Callable[[ModelProfile], Iterator[T]],
        error_types: tuple[type[BaseException], ...],
    ) -> Iterator[LLMStreamEvent[T]]:
        source, profiles = self.ensure_access(feature)
        started = time.perf_counter()
        last_error: BaseException | None = None
        for profile in profiles:
            try:
                upstream = iter(invoke(profile))
                first = next(upstream)
            except (*error_types, StopIteration) as exc:
                last_error = exc
                continue
            yield LLMStreamEvent(kind="delta", value=first)
            try:
                for chunk in upstream:
                    yield LLMStreamEvent(kind="delta", value=chunk)
            except error_types as exc:
                latency_ms = round((time.perf_counter() - started) * 1000)
                self.audit(
                    feature,
                    source,
                    profile,
                    "failed",
                    latency_ms,
                    prompt_version,
                    self._error_code(exc),
                )
                raise LLMGatewayError(
                    "智能解读连接中断，请稍后重试。"
                ) from exc
            latency_ms = round((time.perf_counter() - started) * 1000)
            self.audit(
                feature, source, profile, "success", latency_ms, prompt_version
            )
            yield LLMStreamEvent(
                kind="complete",
                source=source,
                role=profile.role,
                model=profile.model,
                latency_ms=latency_ms,
            )
            return
        failed = profiles[-1]
        latency_ms = round((time.perf_counter() - started) * 1000)
        self.audit(
            feature,
            source,
            failed,
            "failed",
            latency_ms,
            prompt_version,
            self._error_code(last_error),
        )
        raise LLMGatewayError("智能解读服务暂不可用，请稍后重试。") from last_error

    def audit(
        self,
        feature: str,
        source: str,
        profile: ModelProfile,
        status: str,
        latency_ms: int,
        prompt_version: str,
        error_code: str = "",
        input_tokens: int = 0,
        output_tokens: int = 0,
    ) -> None:
        self.database.record_llm_usage(
            self.user_id_supplier(),
            feature,
            source,
            profile.model,
            status,
            latency_ms,
            role=profile.role,
            prompt_version=prompt_version,
            error_code=error_code,
            input_tokens=input_tokens,
            output_tokens=output_tokens,
        )

    def _usage_today(self, source: str) -> int:
        now = datetime.now().astimezone()
        start = now.replace(
            hour=0, minute=0, second=0, microsecond=0
        ).astimezone(timezone.utc)
        return self.database.count_llm_usage_since(
            self.user_id_supplier(), source, start.isoformat(timespec="seconds")
        )

    @staticmethod
    def _model_profiles(profile: dict[str, Any]) -> tuple[ModelProfile, ...]:
        result = []
        for role in ("primary", "fallback"):
            item = profile.get(role) or {}
            candidate = ModelProfile(
                role=role,
                api_key=str(item.get("api_key") or ""),
                base_url=str(item.get("base_url") or ""),
                model=str(item.get("model") or ""),
            )
            if candidate.configured:
                result.append(candidate)
        return tuple(result)

    @staticmethod
    def _error_code(error: BaseException | None) -> str:
        if error is None:
            return "empty_response"
        return type(error).__name__[:80]
