from __future__ import annotations

from time import monotonic, sleep
from typing import Any

from sqlalchemy.orm import Session

from app.core.logging import get_logger
from app.services.model_connectivity import _send_json_request
from app.services.runtime_chat_attempts import (
    RuntimeChatAttemptObserver,
    RuntimeChatAttemptOutcome,
    RuntimeChatAttemptTracker,
    RuntimeChatAuthoritativeUsage,
    RuntimeChatCallTrace,
    RuntimeChatOperationContext,
    RuntimeChatProviderAttemptError,
    RuntimeChatProviderResponse,
    RuntimeChatResult,
    RuntimeChatToolCall,
    RuntimeToolCallResult,
)
from app.services.runtime_chat_provider import (
    request_azure_openai_completion,
    request_azure_openai_tool_call,
    request_ollama_completion,
    request_openai_compatible_completion,
    request_openai_compatible_tool_call,
)
from app.services.settings import SettingsService

logger = get_logger("app.services.runtime_chat")
DEFAULT_RUNTIME_CHAT_TIMEOUT_SECONDS = 45
DEFAULT_RUNTIME_CHAT_RETRY_ATTEMPTS = 2
DEFAULT_RUNTIME_CHAT_RETRY_DELAY_SECONDS = 0.6
DEFAULT_RUNTIME_CHAT_FAILURE_COOLDOWN_SECONDS = 90

_slot_failure_until: dict[str, float] = {}


def clear_runtime_chat_failure_cache() -> int:
    cleared_count = len(_slot_failure_until)
    _slot_failure_until.clear()
    return cleared_count


class RuntimeChatService:
    def __init__(
        self,
        db: Session,
        *,
        attempt_observer: RuntimeChatAttemptObserver | None = None,
    ) -> None:
        self.db = db
        self.settings_service = SettingsService(db)
        self.attempt_observer = attempt_observer

    def complete(
        self,
        messages: list[dict[str, Any]],
        *,
        slot_priority: tuple[str, ...] = ("main", "backup"),
        max_tokens: int = 500,
        temperature: float = 0.2,
        timeout_seconds: int | None = None,
        slot_timeouts: dict[str, int] | None = None,
        max_attempts: int | None = None,
        operation_context: RuntimeChatOperationContext | None = None,
    ) -> str | None:
        return self.complete_with_trace(
            messages,
            slot_priority=slot_priority,
            max_tokens=max_tokens,
            temperature=temperature,
            timeout_seconds=timeout_seconds,
            slot_timeouts=slot_timeouts,
            max_attempts=max_attempts,
            operation_context=operation_context,
        ).text

    def complete_with_trace(
        self,
        messages: list[dict[str, Any]],
        *,
        slot_priority: tuple[str, ...] = ("main", "backup"),
        max_tokens: int = 500,
        temperature: float = 0.2,
        timeout_seconds: int | None = None,
        slot_timeouts: dict[str, int] | None = None,
        max_attempts: int | None = None,
        operation_context: RuntimeChatOperationContext | None = None,
    ) -> RuntimeChatResult:
        configs: list[dict[str, str]] = []
        calls: list[RuntimeChatCallTrace] = []
        for slot in slot_priority:
            config = self._load_chat_slot(slot)
            if config is None:
                calls.append(
                    RuntimeChatCallTrace(
                        slot=slot,
                        provider="",
                        model="",
                        attempt=0,
                        status="skipped",
                        skipped_reason="not_configured",
                    )
                )
                continue
            configs.append(config)
        if not configs:
            return RuntimeChatResult(None, calls)

        resolved_timeout_seconds = timeout_seconds or DEFAULT_RUNTIME_CHAT_TIMEOUT_SECONDS
        resolved_slot_timeouts = dict(slot_timeouts or {})
        resolved_max_attempts = max_attempts or DEFAULT_RUNTIME_CHAT_RETRY_ATTEMPTS

        for attempt in range(1, resolved_max_attempts + 1):
            for config in configs:
                cache_key = self._build_slot_cache_key(config)
                if _slot_failure_until.get(cache_key, 0.0) > monotonic():
                    logger.info(
                        "Skip runtime chat slot=%s provider=%s because it is in cooldown",
                        config["slot"],
                        config["provider"],
                    )
                    calls.append(
                        RuntimeChatCallTrace(
                            slot=config["slot"],
                            provider=config["provider"],
                            model=config["model"],
                            attempt=attempt,
                            status="skipped",
                            skipped_reason="cooldown",
                        )
                    )
                    continue
                started = monotonic()
                tracker = RuntimeChatAttemptTracker(
                    observer=self.attempt_observer,
                    operation_context=operation_context,
                    slot=config["slot"],
                    provider=config["provider"],
                    model=config["model"],
                    attempt=attempt,
                )
                permit = tracker.notify_permit()
                if not permit.allowed:
                    permit_error = RuntimeError(
                        permit.reason or "Runtime chat attempt permit denied."
                    )
                    calls.append(
                        self._complete_attempt_trace(
                            tracker=tracker,
                            config=config,
                            attempt=attempt,
                            status="blocked",
                            outcome="not_sent",
                            duration_ms=int((monotonic() - started) * 1000),
                            error=permit_error,
                        )
                    )
                    continue
                try:
                    provider_response = self._coerce_provider_response(
                        self._request_chat_completion(
                            config,
                            messages,
                            max_tokens=max_tokens,
                            temperature=temperature,
                            timeout_seconds=resolved_slot_timeouts.get(
                                config["slot"],
                                resolved_timeout_seconds,
                            ),
                        )
                    )
                    duration_ms = int((monotonic() - started) * 1000)
                    response_text = str(provider_response.output or "").strip()
                    if response_text:
                        _slot_failure_until.pop(cache_key, None)
                        calls.append(
                            self._complete_attempt_trace(
                                tracker=tracker,
                                config=config,
                                attempt=attempt,
                                status="succeeded",
                                outcome="succeeded",
                                duration_ms=duration_ms,
                                provider_response=provider_response,
                            )
                        )
                        return RuntimeChatResult(response_text, calls)
                    calls.append(
                        self._complete_attempt_trace(
                            tracker=tracker,
                            config=config,
                            attempt=attempt,
                            status="empty",
                            outcome="empty",
                            duration_ms=duration_ms,
                            provider_response=provider_response,
                            error=ValueError("模型返回空内容。"),
                        )
                    )
                except Exception as exc:
                    duration_ms = int((monotonic() - started) * 1000)
                    _slot_failure_until[cache_key] = (
                        monotonic() + DEFAULT_RUNTIME_CHAT_FAILURE_COOLDOWN_SECONDS
                    )
                    (
                        outcome,
                        response_id,
                        response_model,
                        provider_status_code,
                        usage,
                    ) = self._error_attempt_metadata(exc)
                    calls.append(
                        self._complete_attempt_trace(
                            tracker=tracker,
                            config=config,
                            attempt=attempt,
                            status="failed",
                            outcome=outcome,
                            duration_ms=duration_ms,
                            response_id=response_id,
                            response_model=response_model,
                            provider_status_code=provider_status_code,
                            usage=usage,
                            error=exc,
                        )
                    )
                    logger.warning(
                        "Runtime chat request failed slot=%s provider=%s attempt=%s/%s: %s",
                        config["slot"],
                        config["provider"],
                        attempt,
                        resolved_max_attempts,
                        exc,
                    )
            if attempt < resolved_max_attempts:
                sleep(DEFAULT_RUNTIME_CHAT_RETRY_DELAY_SECONDS)

        return RuntimeChatResult(None, calls)

    def complete_with_tool_call(
        self,
        messages: list[dict[str, Any]],
        *,
        tools: list[dict[str, Any]],
        tool_choice: dict[str, Any] | str | None = None,
        slot_priority: tuple[str, ...] = ("main", "backup"),
        max_tokens: int = 1200,
        temperature: float = 0.1,
        timeout_seconds: int | None = None,
        slot_timeouts: dict[str, int] | None = None,
        max_attempts: int | None = None,
        use_failure_cooldown: bool = True,
        operation_context: RuntimeChatOperationContext | None = None,
    ) -> RuntimeToolCallResult:
        configs: list[dict[str, str]] = []
        calls: list[RuntimeChatCallTrace] = []
        for slot in slot_priority:
            config = self._load_chat_slot(slot)
            if config is None:
                calls.append(
                    RuntimeChatCallTrace(
                        slot=slot,
                        provider="",
                        model="",
                        attempt=0,
                        status="skipped",
                        skipped_reason="not_configured",
                    )
                )
                continue
            configs.append(config)
        if not configs:
            return RuntimeToolCallResult(None, calls)

        resolved_timeout_seconds = timeout_seconds or DEFAULT_RUNTIME_CHAT_TIMEOUT_SECONDS
        resolved_slot_timeouts = dict(slot_timeouts or {})
        resolved_max_attempts = max_attempts or DEFAULT_RUNTIME_CHAT_RETRY_ATTEMPTS

        for attempt in range(1, resolved_max_attempts + 1):
            for config in configs:
                cache_key = self._build_slot_cache_key(config)
                if use_failure_cooldown and _slot_failure_until.get(cache_key, 0.0) > monotonic():
                    logger.info(
                        "Skip runtime chat tool slot=%s provider=%s because it is in cooldown",
                        config["slot"],
                        config["provider"],
                    )
                    calls.append(
                        RuntimeChatCallTrace(
                            slot=config["slot"],
                            provider=config["provider"],
                            model=config["model"],
                            attempt=attempt,
                            status="skipped",
                            skipped_reason="cooldown",
                        )
                    )
                    continue
                started = monotonic()
                tracker = RuntimeChatAttemptTracker(
                    observer=self.attempt_observer,
                    operation_context=operation_context,
                    slot=config["slot"],
                    provider=config["provider"],
                    model=config["model"],
                    attempt=attempt,
                )
                permit = tracker.notify_permit()
                if not permit.allowed:
                    permit_error = RuntimeError(
                        permit.reason or "Runtime chat attempt permit denied."
                    )
                    calls.append(
                        self._complete_attempt_trace(
                            tracker=tracker,
                            config=config,
                            attempt=attempt,
                            status="blocked",
                            outcome="not_sent",
                            duration_ms=int((monotonic() - started) * 1000),
                            error=permit_error,
                        )
                    )
                    continue
                try:
                    provider_response = self._coerce_provider_response(
                        self._request_chat_tool_call(
                            config,
                            messages,
                            tools=tools,
                            tool_choice=tool_choice,
                            max_tokens=max_tokens,
                            temperature=temperature,
                            timeout_seconds=resolved_slot_timeouts.get(
                                config["slot"],
                                resolved_timeout_seconds,
                            ),
                        )
                    )
                    duration_ms = int((monotonic() - started) * 1000)
                    tool_call = provider_response.output
                    if tool_call is not None:
                        _slot_failure_until.pop(cache_key, None)
                        calls.append(
                            self._complete_attempt_trace(
                                tracker=tracker,
                                config=config,
                                attempt=attempt,
                                status="succeeded",
                                outcome="succeeded",
                                duration_ms=duration_ms,
                                provider_response=provider_response,
                            )
                        )
                        return RuntimeToolCallResult(tool_call, calls)
                    calls.append(
                        self._complete_attempt_trace(
                            tracker=tracker,
                            config=config,
                            attempt=attempt,
                            status="empty",
                            outcome="empty",
                            duration_ms=duration_ms,
                            provider_response=provider_response,
                            error=ValueError("模型未返回工具调用。"),
                        )
                    )
                except Exception as exc:
                    duration_ms = int((monotonic() - started) * 1000)
                    if use_failure_cooldown:
                        _slot_failure_until[cache_key] = (
                            monotonic() + DEFAULT_RUNTIME_CHAT_FAILURE_COOLDOWN_SECONDS
                        )
                    (
                        outcome,
                        response_id,
                        response_model,
                        provider_status_code,
                        usage,
                    ) = self._error_attempt_metadata(exc)
                    calls.append(
                        self._complete_attempt_trace(
                            tracker=tracker,
                            config=config,
                            attempt=attempt,
                            status="failed",
                            outcome=outcome,
                            duration_ms=duration_ms,
                            response_id=response_id,
                            response_model=response_model,
                            provider_status_code=provider_status_code,
                            usage=usage,
                            error=exc,
                        )
                    )
                    logger.warning(
                        "Runtime chat tool request failed slot=%s provider=%s attempt=%s/%s: %s",
                        config["slot"],
                        config["provider"],
                        attempt,
                        resolved_max_attempts,
                        exc,
                    )
            if attempt < resolved_max_attempts:
                sleep(DEFAULT_RUNTIME_CHAT_RETRY_DELAY_SECONDS)

        return RuntimeToolCallResult(None, calls)

    def _complete_attempt_trace(
        self,
        *,
        tracker: RuntimeChatAttemptTracker,
        config: dict[str, str],
        attempt: int,
        status: str,
        outcome: RuntimeChatAttemptOutcome,
        duration_ms: int,
        provider_response: RuntimeChatProviderResponse[Any] | None = None,
        response_id: str | None = None,
        response_model: str | None = None,
        provider_status_code: int | None = None,
        usage: RuntimeChatAuthoritativeUsage | None = None,
        error: Exception | None = None,
    ) -> RuntimeChatCallTrace:
        resolved_response_id = (
            provider_response.response_id if provider_response is not None else response_id
        )
        resolved_response_model = (
            provider_response.response_model
            if provider_response is not None
            else response_model
        )
        resolved_usage = (
            provider_response.usage
            if provider_response is not None
            else usage or RuntimeChatAuthoritativeUsage()
        )
        notification = tracker.notify_completed(
            outcome=outcome,
            response_id=resolved_response_id,
            response_model=resolved_response_model,
            provider_status_code=provider_status_code,
            usage=resolved_usage,
            error=error,
        )
        return RuntimeChatCallTrace(
            slot=config["slot"],
            provider=config["provider"],
            model=config["model"],
            attempt=attempt,
            status=status,
            duration_ms=duration_ms,
            error_message=str(error) if error is not None else None,
            outcome=outcome,
            started_at=notification.started_at,
            completed_at=notification.completed_at,
            response_id=resolved_response_id,
            response_model=resolved_response_model,
            provider_status_code=provider_status_code,
            usage=resolved_usage,
            observer_status=notification.observer_status,
            observer_failures=list(notification.observer_failures),
        )

    @staticmethod
    def _coerce_provider_response(value: Any) -> RuntimeChatProviderResponse[Any]:
        if isinstance(value, RuntimeChatProviderResponse):
            return value
        return RuntimeChatProviderResponse(output=value)

    @staticmethod
    def _error_attempt_metadata(
        exc: Exception,
    ) -> tuple[
        RuntimeChatAttemptOutcome,
        str | None,
        str | None,
        int | None,
        RuntimeChatAuthoritativeUsage,
    ]:
        if isinstance(exc, RuntimeChatProviderAttemptError):
            return (
                exc.outcome,
                exc.response_id,
                exc.response_model,
                exc.status_code,
                exc.usage,
            )
        # 未经 provider adapter 分类的异常无法证明请求未发送，保守进入补偿。
        return "outcome_unknown", None, None, None, RuntimeChatAuthoritativeUsage()

    @staticmethod
    def _build_slot_cache_key(config: dict[str, str]) -> str:
        return "|".join(
            [
                str(config.get("slot") or ""),
                str(config.get("provider") or ""),
                str(config.get("endpoint") or ""),
                str(config.get("model") or ""),
            ]
        )

    def _load_chat_slot(self, slot: str) -> dict[str, str] | None:
        try:
            config = self.settings_service.get_runtime_model_config(slot)
        except ValueError:
            return None

        if config["capability"] != "chat":
            return None

        provider = str(config["provider"] or "").strip()
        endpoint = str(config["endpoint"] or "").strip()
        model = str(config["model"] or "").strip()
        api_key = str(config["apiKey"] or "").strip()

        if not provider or not endpoint or not model:
            return None

        if provider != "Ollama" and not api_key:
            logger.info("Skip runtime chat slot=%s because api key is empty", slot)
            return None

        return {
            "slot": slot,
            "provider": provider,
            "endpoint": endpoint,
            "model": model,
            "apiKey": api_key,
        }

    def _request_chat_completion(
        self,
        config: dict[str, str],
        messages: list[dict[str, Any]],
        *,
        max_tokens: int,
        temperature: float,
        timeout_seconds: int,
    ) -> RuntimeChatProviderResponse[str]:
        provider = config["provider"]
        endpoint = config["endpoint"]
        model = config["model"]
        api_key = config["apiKey"]

        try:
            if provider == "Azure OpenAI":
                return self._request_azure_openai(
                    endpoint=endpoint,
                    model=model,
                    api_key=api_key,
                    messages=messages,
                    max_tokens=max_tokens,
                    temperature=temperature,
                    timeout_seconds=timeout_seconds,
                )

            if provider == "Ollama":
                return self._request_ollama(
                    endpoint=endpoint,
                    model=model,
                    api_key=api_key,
                    messages=messages,
                    max_tokens=max_tokens,
                    temperature=temperature,
                    timeout_seconds=timeout_seconds,
                )

            return self._request_openai_compatible(
                provider=provider,
                endpoint=endpoint,
                model=model,
                api_key=api_key,
                messages=messages,
                max_tokens=max_tokens,
                temperature=temperature,
                timeout_seconds=timeout_seconds,
            )
        except RuntimeChatProviderAttemptError:
            raise
        except Exception as exc:
            raise RuntimeChatProviderAttemptError(
                str(exc) or "模型请求发送前失败。",
                outcome="not_sent",
            ) from exc

    def _request_chat_tool_call(
        self,
        config: dict[str, str],
        messages: list[dict[str, Any]],
        *,
        tools: list[dict[str, Any]],
        tool_choice: dict[str, Any] | str | None,
        max_tokens: int,
        temperature: float,
        timeout_seconds: int,
    ) -> RuntimeChatProviderResponse[RuntimeChatToolCall | None]:
        provider = config["provider"]
        endpoint = config["endpoint"]
        model = config["model"]
        api_key = config["apiKey"]

        try:
            if provider == "Azure OpenAI":
                return self._request_azure_openai_tool_call(
                    endpoint=endpoint,
                    model=model,
                    api_key=api_key,
                    messages=messages,
                    tools=tools,
                    tool_choice=tool_choice,
                    max_tokens=max_tokens,
                    temperature=temperature,
                    timeout_seconds=timeout_seconds,
                )

            if provider == "Ollama":
                raise RuntimeChatProviderAttemptError(
                    "Ollama 暂不支持小财管家 function calling。",
                    outcome="not_sent",
                )

            return self._request_openai_compatible_tool_call(
                provider=provider,
                endpoint=endpoint,
                model=model,
                api_key=api_key,
                messages=messages,
                tools=tools,
                tool_choice=tool_choice,
                max_tokens=max_tokens,
                temperature=temperature,
                timeout_seconds=timeout_seconds,
            )
        except RuntimeChatProviderAttemptError:
            raise
        except Exception as exc:
            raise RuntimeChatProviderAttemptError(
                str(exc) or "模型工具请求发送前失败。",
                outcome="not_sent",
            ) from exc

    def _request_openai_compatible(
        self,
        *,
        provider: str,
        endpoint: str,
        model: str,
        api_key: str,
        messages: list[dict[str, Any]],
        max_tokens: int,
        temperature: float,
        timeout_seconds: int,
    ) -> RuntimeChatProviderResponse[str]:
        return request_openai_compatible_completion(
            send_json_request=_send_json_request,
            provider=provider,
            endpoint=endpoint,
            model=model,
            api_key=api_key,
            messages=messages,
            max_tokens=max_tokens,
            temperature=temperature,
            timeout_seconds=timeout_seconds,
        )

    def _request_openai_compatible_tool_call(
        self,
        *,
        provider: str,
        endpoint: str,
        model: str,
        api_key: str,
        messages: list[dict[str, Any]],
        tools: list[dict[str, Any]],
        tool_choice: dict[str, Any] | str | None,
        max_tokens: int,
        temperature: float,
        timeout_seconds: int,
    ) -> RuntimeChatProviderResponse[RuntimeChatToolCall | None]:
        return request_openai_compatible_tool_call(
            send_json_request=_send_json_request,
            provider=provider,
            endpoint=endpoint,
            model=model,
            api_key=api_key,
            messages=messages,
            tools=tools,
            tool_choice=tool_choice,
            max_tokens=max_tokens,
            temperature=temperature,
            timeout_seconds=timeout_seconds,
        )

    def _request_ollama(
        self,
        *,
        endpoint: str,
        model: str,
        api_key: str,
        messages: list[dict[str, Any]],
        max_tokens: int,
        temperature: float,
        timeout_seconds: int,
    ) -> RuntimeChatProviderResponse[str]:
        return request_ollama_completion(
            send_json_request=_send_json_request,
            endpoint=endpoint,
            model=model,
            api_key=api_key,
            messages=messages,
            max_tokens=max_tokens,
            temperature=temperature,
            timeout_seconds=timeout_seconds,
        )

    def _request_azure_openai(
        self,
        *,
        endpoint: str,
        model: str,
        api_key: str,
        messages: list[dict[str, Any]],
        max_tokens: int,
        temperature: float,
        timeout_seconds: int,
    ) -> RuntimeChatProviderResponse[str]:
        return request_azure_openai_completion(
            send_json_request=_send_json_request,
            endpoint=endpoint,
            model=model,
            api_key=api_key,
            messages=messages,
            max_tokens=max_tokens,
            temperature=temperature,
            timeout_seconds=timeout_seconds,
        )

    def _request_azure_openai_tool_call(
        self,
        *,
        endpoint: str,
        model: str,
        api_key: str,
        messages: list[dict[str, Any]],
        tools: list[dict[str, Any]],
        tool_choice: dict[str, Any] | str | None,
        max_tokens: int,
        temperature: float,
        timeout_seconds: int,
    ) -> RuntimeChatProviderResponse[RuntimeChatToolCall | None]:
        return request_azure_openai_tool_call(
            send_json_request=_send_json_request,
            endpoint=endpoint,
            model=model,
            api_key=api_key,
            messages=messages,
            tools=tools,
            tool_choice=tool_choice,
            max_tokens=max_tokens,
            temperature=temperature,
            timeout_seconds=timeout_seconds,
        )
