from __future__ import annotations

import json
import urllib.error
import urllib.request
from collections.abc import Iterator
from typing import Any, Protocol

from backend.llm.streaming import TextAccumulator


class RuntimeProfile(Protocol):
    api_key: str
    base_url: str
    model_identifier: str


class ProviderFailure(RuntimeError):
    def __init__(self, code: str) -> None:
        super().__init__(code)
        self.code = code


class OpenAICompatibleClient:
    def __init__(self, timeout_seconds: int = 90) -> None:
        self._timeout = timeout_seconds

    def stream(
        self, profile: RuntimeProfile, messages: list[dict[str, str]]
    ) -> Iterator[str]:
        payload = json.dumps(
            {"model": profile.model_identifier, "messages": messages, "stream": True},
            ensure_ascii=False,
        ).encode("utf-8")
        request = urllib.request.Request(
            f"{profile.base_url.rstrip('/')}/chat/completions",
            data=payload,
            headers={
                "Accept": "text/event-stream",
                "Authorization": f"Bearer {profile.api_key}",
                "Content-Type": "application/json",
                "User-Agent": "XiaobaiReview/2",
            },
            method="POST",
        )
        try:
            with urllib.request.urlopen(request, timeout=self._timeout) as response:
                accumulator = TextAccumulator()
                for raw_line in response:
                    line = raw_line.decode("utf-8", errors="replace").strip()
                    if not line or line.startswith(":"):
                        continue
                    if line.startswith("data:"):
                        line = line[5:].strip()
                    if line == "[DONE]":
                        break
                    try:
                        payload = json.loads(line)
                    except json.JSONDecodeError:
                        continue
                    choices = payload.get("choices") if isinstance(payload, dict) else None
                    if not isinstance(choices, list) or not choices:
                        continue
                    choice = choices[0]
                    chunk = accumulator.feed(choice if isinstance(choice, dict) else {})
                    if chunk:
                        yield chunk
                if not accumulator.text:
                    raise ProviderFailure("empty_response")
        except ProviderFailure:
            raise
        except urllib.error.HTTPError as exc:
            raise ProviderFailure(_http_error(exc)) from exc
        except TimeoutError as exc:
            raise ProviderFailure("timeout") from exc
        except (urllib.error.URLError, ConnectionError, OSError) as exc:
            raise ProviderFailure("network") from exc


def _http_error(error: urllib.error.HTTPError) -> str:
    if error.code in {401, 403}:
        return "authentication"
    if error.code == 429:
        detail = _error_detail(error)
        return "capacity" if "capacity" in detail.casefold() else "rate_limited"
    if error.code in {408, 504}:
        return "timeout"
    if error.code >= 500:
        detail = _error_detail(error)
        return "capacity" if "capacity" in detail.casefold() else "upstream"
    return "request_rejected"


def _error_detail(error: urllib.error.HTTPError) -> str:
    try:
        payload: Any = json.loads(error.read().decode("utf-8", errors="replace"))
    except (json.JSONDecodeError, OSError):
        return ""
    if not isinstance(payload, dict):
        return ""
    detail = payload.get("error") or payload.get("message") or ""
    if isinstance(detail, dict):
        detail = detail.get("message") or detail.get("code") or ""
    return str(detail)[:500]
