"""
LLMService - Optional post-processing of transcribed text via Anthropic or
OpenAI.  Thin httpx client (no vendor SDKs) so nothing new has to be bundled.

Used by the "AI enhancement" modes: the raw Whisper transcript is sent with a
mode-specific prompt and the polished result replaces it before output.
"""
from __future__ import annotations

import re
from typing import TYPE_CHECKING

import httpx
from loguru import logger

from .base_service import BaseService
from .http_exec import arequest
from .i18n import t

if TYPE_CHECKING:
    from bridge.signals import AppSignals

ANTHROPIC_BASE = "https://api.anthropic.com/v1"
OPENAI_BASE = "https://api.openai.com/v1"
ANTHROPIC_VERSION = "2023-06-01"
VOXCAST_CONFIG_URL = "https://getvoxcast.com/api/v1/client-config"

# Cheap, fast defaults for short dictation post-processing. Baked-in fallback —
# the live recommendation is maintained server-side (client-config) so users
# never have to pick a model themselves when providers rename/retire models.
# Ordered by preference; every entry is tried before pattern discovery kicks in.
DEFAULT_MODEL_CANDIDATES: dict[str, list[str]] = {
    "anthropic": ["claude-haiku-4-5", "claude-3-5-haiku-latest"],
    "openai": ["gpt-4o-mini", "gpt-4.1-mini"],
}
# Kept for backward compatibility (tests, single-model call sites).
DEFAULT_MODELS = {p: c[0] for p, c in DEFAULT_MODEL_CANDIDATES.items()}
# Cap for how many server-supplied candidates we accept per provider.
_MAX_CANDIDATES = 5
# Minimum Whisper language-detection confidence before we name the language
# explicitly in the enhancement prompt (below that we fall back to a generic
# "same language as the input" instruction).
LANG_CONFIDENCE_MIN = 0.7

# English names for the language codes VoxCast exposes (used in LLM prompts —
# the models follow "must be in German" far more reliably than a bare code).
LANGUAGE_NAMES = {
    "de": "German", "en": "English", "fr": "French", "es": "Spanish",
    "it": "Italian", "pl": "Polish", "pt": "Portuguese", "nl": "Dutch",
    "ru": "Russian", "ja": "Japanese", "zh": "Chinese", "ko": "Korean",
    "ar": "Arabic", "tr": "Turkish", "sv": "Swedish", "da": "Danish",
    "no": "Norwegian", "fi": "Finnish", "cs": "Czech", "hu": "Hungarian",
    "ro": "Romanian", "uk": "Ukrainian", "hi": "Hindi",
}


def language_directive(code: str | None) -> str:
    """Instruction appended to EVERY enhancement prompt so the output stays in
    the dictation's language (also protects user-written custom modes, which
    rarely think of adding a language rule themselves).

    The "unless ... explicitly require" escape keeps deliberate translation
    modes working."""
    name = LANGUAGE_NAMES.get(code or "")
    if name:
        return (
            f"The input text is in {name} ({code}). Unless the instructions above "
            f"explicitly require a different output language, your ENTIRE output "
            f"must be in {name}. Never translate the input into another language."
        )
    return (
        "Reply in the exact same language as the input text. Unless the "
        "instructions above explicitly require a different output language, "
        "never translate the input into another language."
    )


def _version_key(model_id: str) -> tuple:
    """Sortable version tuple from the numeric tokens of a model id.

    Date/snapshot stamps and context markers must not count as version parts,
    in all provider spellings: 'claude-3-5-haiku-20241022' -> (3, 5),
    'gpt-4o-2024-08-06' -> (4,), 'gpt-4-0613' -> (4,), 'gpt-4-32k' -> (4,).
    Real semantic version parts are always 1-2 digits."""
    stripped = re.sub(r"\d{4}-\d{2}-\d{2}", "", model_id)
    stripped = re.sub(r"\d+k\b", "", stripped)
    nums = [int(n) for n in re.findall(r"\d+", stripped) if len(n) <= 2]
    return tuple(nums) or (0,)


def rank_anthropic_models(ids: list[str]) -> list[str]:
    """Preference order for dictation enhancement: fastest family first
    (haiku > sonnet > opus), newest version within a family."""
    family_rank = {"haiku": 2, "sonnet": 1, "opus": 0}
    ranked = []
    for mid in ids:
        low = mid.lower()
        fam = next((f for f in family_rank if f in low), None)
        if not low.startswith("claude") or fam is None:
            continue
        ranked.append((family_rank[fam], _version_key(low), mid))
    ranked.sort(key=lambda t: (t[0], t[1]), reverse=True)
    return [mid for _, _, mid in ranked]


_OPENAI_NON_CHAT = (
    "embed", "whisper", "tts", "audio", "realtime", "dall-e", "davinci",
    "babbage", "moderation", "search", "transcribe", "image", "computer-use",
    "instruct", "codex",
)


def rank_openai_models(ids: list[str]) -> list[str]:
    """Preference order for dictation enhancement: mini variants of the newest
    GPT generation first (fast + capable), then full chat models. Reasoning
    (o1/o3/...) and non-chat ids are excluded (too slow / not chat)."""
    ranked = []
    for mid in ids:
        low = mid.lower()
        if not low.startswith("gpt-") or any(x in low for x in _OPENAI_NON_CHAT):
            continue
        size = 2 if "mini" in low else (1 if "nano" in low else 0)
        ranked.append((size, _version_key(low), mid))
    # Size class first (mini > nano > full), newest generation within a class:
    # dictation cleanup needs speed and low cost, so an older mini beats a
    # newer full model (a full flagship would be slow and 15-30x the price).
    ranked.sort(key=lambda t: (t[0], t[1]), reverse=True)
    return [mid for _, _, mid in ranked]
_CONFIG_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
_TIMEOUT = httpx.Timeout(60.0, connect=10.0)
# Long inputs (meeting summaries) need more time to generate.
_TIMEOUT_LONG = httpx.Timeout(180.0, connect=10.0)
# Output budget for dictation enhancement. Dictations can run 4-5 minutes
# (700+ words); 1024 used to truncate those mid-sentence.
_MAX_TOKENS = 4096
# Threshold above which the long timeout is used (chars of input text).
_LONG_INPUT_CHARS = 8000


class LLMError(Exception):
    """User-facing LLM failure (bad key, network, rate limit, ...).

    model_unavailable=True marks "this model id does not exist / key has no
    access" — the auto-selection retries with the next candidate on those."""

    def __init__(self, message: str, *, model_unavailable: bool = False) -> None:
        super().__init__(message)
        self.model_unavailable = model_unavailable


class LLMService(BaseService):
    def __init__(self, signals: AppSignals) -> None:
        self._signals = signals
        self._ready = False
        self._remote_defaults: dict[str, list[str]] = {}
        # Session memory for the auto-selection: models that returned
        # "not found" for this key, and the last model that worked.
        self._bad_models: set[tuple[str, str]] = set()
        self._working_model: dict[str, str] = {}

    async def start(self) -> None:
        self._ready = True

    async def refresh_remote_defaults(self) -> None:
        """Fetch the server-maintained recommended models (fail-silent).

        Lets us steer which model each provider key uses without shipping an
        app update; the baked-in DEFAULT_MODELS stay as offline fallback."""
        try:
            r = await arequest("GET", VOXCAST_CONFIG_URL, timeout=_CONFIG_TIMEOUT)
            r.raise_for_status()
            defaults = (r.json() or {}).get("llm_defaults") or {}
            cleaned: dict[str, list[str]] = {}
            for p, m in defaults.items():
                if p not in ("anthropic", "openai"):
                    continue
                # Accept a single model id or an ordered candidate list.
                raw = m if isinstance(m, list) else [m]
                models = [str(x).strip()[:100] for x in raw
                          if isinstance(x, str) and x.strip()]
                if models:
                    cleaned[p] = models[:_MAX_CANDIDATES]
            if cleaned:
                self._remote_defaults = cleaned
                logger.info("LLM defaults from server: {}", cleaned)
        except Exception as e:
            logger.info("client-config fetch failed (using baked-in defaults): {}", e)

    def model_candidates(self, provider: str) -> list[str]:
        """Ordered candidate models: last known-working, server-recommended,
        baked-in — de-duplicated, preference order preserved."""
        chain: list[str] = []
        if provider in self._working_model:
            chain.append(self._working_model[provider])
        chain += self._remote_defaults.get(provider, [])
        chain += DEFAULT_MODEL_CANDIDATES.get(provider, [])
        seen: set[str] = set()
        return [m for m in chain if not (m in seen or seen.add(m))]

    def default_model(self, provider: str) -> str:
        candidates = self.model_candidates(provider)
        return candidates[0] if candidates else ""

    async def stop(self) -> None:
        self._ready = False

    @property
    def is_ready(self) -> bool:
        return self._ready

    async def list_models(self, provider: str, api_key: str) -> list[str]:
        """Fetch available model ids for a provider (for the Settings dropdown)."""
        if not api_key:
            raise LLMError(t("llm_no_key"))
        try:
            if provider == "anthropic":
                r = await arequest(
                    "GET",
                    f"{ANTHROPIC_BASE}/models",
                    timeout=_TIMEOUT,
                    headers={"x-api-key": api_key, "anthropic-version": ANTHROPIC_VERSION},
                )
                r.raise_for_status()
                return [m["id"] for m in r.json().get("data", [])
                        if isinstance(m, dict) and m.get("id")]
            r = await arequest(
                "GET",
                f"{OPENAI_BASE}/models",
                timeout=_TIMEOUT,
                headers={"Authorization": f"Bearer {api_key}"},
            )
            r.raise_for_status()
            ids = [m["id"] for m in r.json().get("data", [])
                   if isinstance(m, dict) and m.get("id")]
            # Keep chat-capable models, newest-looking first.
            chat = [m for m in ids if m.startswith(("gpt-", "o1", "o3", "o4", "chatgpt"))]
            return sorted(chat, reverse=True) or sorted(ids, reverse=True)
        except httpx.HTTPStatusError as e:
            raise LLMError(self._status_message(e)) from e
        except httpx.HTTPError as e:
            raise LLMError(t("llm_network", error=e)) from e

    async def enhance(
        self, *, text: str, system_prompt: str, provider: str, model: str,
        api_key: str, max_tokens: int = _MAX_TOKENS,
    ) -> str:
        """Run the transcript through the model with a mode prompt. Returns the
        polished text (falls back to the original on empty result).

        Raises LLMError on truncation (stop_reason max_tokens/length) — a
        cut-off enhancement is worse than the complete raw transcript, so the
        caller's raw-text fallback must kick in.
        """
        text = (text or "").strip()
        if not text:
            return ""
        if not api_key:
            raise LLMError(t("llm_no_key_configured"))
        model = model or self.default_model(provider)
        timeout = _TIMEOUT_LONG if len(text) > _LONG_INPUT_CHARS else _TIMEOUT
        try:
            if provider == "anthropic":
                out = await self._anthropic(text, system_prompt, model, api_key,
                                            max_tokens, timeout)
            else:
                out = await self._openai(text, system_prompt, model, api_key, timeout)
            return out.strip() or text
        except httpx.HTTPStatusError as e:
            # 404 = unknown model id or key without access (both providers) —
            # flag it so enhance_auto can fall through to the next candidate.
            unavailable = e.response.status_code == 404
            raise LLMError(self._status_message(e),
                           model_unavailable=unavailable) from e
        except httpx.HTTPError as e:
            raise LLMError(t("llm_network", error=e)) from e

    async def discover_models(self, provider: str, api_key: str) -> list[str]:
        """Best models for dictation from the provider's own /models list,
        ranked by our heuristics. Works without any VoxCast server contact.
        Returns [] on any failure (discovery is a last resort, never fatal)."""
        try:
            ids = await self.list_models(provider, api_key)
        except Exception as e:
            # last resort only — a proxy serving HTML instead of JSON etc.
            # must degrade silently, never crash the enhancement pipeline
            logger.warning("Model discovery failed for {}: {}", provider, e)
            return []
        rank = rank_anthropic_models if provider == "anthropic" else rank_openai_models
        ranked = rank(ids)[:3]
        if ranked:
            logger.info("Discovered {} models for {}: {}", len(ranked), provider, ranked)
        return ranked

    async def enhance_auto(
        self, *, text: str, system_prompt: str, provider: str, api_key: str,
        preferred_model: str = "", max_tokens: int = _MAX_TOKENS,
    ) -> str:
        """enhance() with robust model selection: user-picked model first, then
        the candidate chain (known-working, server-recommended, baked-in), and
        as a last resort models discovered from the provider's own list. Only
        "model not found" errors advance the chain — real failures (bad key,
        rate limit, network) surface immediately."""
        if not (text or "").strip():
            return ""
        # The user's explicit Settings choice is ALWAYS tried first, even if it
        # 404ed before (access can be provisioned later; one fast fail is the
        # honest price for respecting the choice). Everything else is ordered
        # fresh-candidates-first; models that 404ed earlier this session are
        # only DEMOTED to the end, never banned — a transient 404 (proxy,
        # provider hiccup) must not kill AI enhancement until app restart.
        rest = [m for m in self.model_candidates(provider) if m != preferred_model]
        chain = ([preferred_model] if preferred_model else []) \
            + sorted(rest, key=lambda m: (provider, m) in self._bad_models)
        tried: set[str] = set()
        last_unavailable: LLMError | None = None

        async def _try(models: list[str]) -> str | None:
            nonlocal last_unavailable
            for model in models:
                if not model or model in tried:
                    continue
                tried.add(model)
                try:
                    out = await self.enhance(
                        text=text, system_prompt=system_prompt, provider=provider,
                        model=model, api_key=api_key, max_tokens=max_tokens,
                    )
                    self._working_model[provider] = model
                    self._bad_models.discard((provider, model))
                    return out
                except LLMError as e:
                    if not e.model_unavailable:
                        raise
                    logger.warning("Model {} unavailable for this {} key, "
                                   "trying next candidate", model, provider)
                    self._bad_models.add((provider, model))
                    last_unavailable = e
            return None

        result = await _try(chain)
        if result is None:
            result = await _try(await self.discover_models(provider, api_key))
        if result is None:
            raise last_unavailable or LLMError(t("llm_no_model"))
        return result

    async def _anthropic(
        self, text: str, system: str, model: str, key: str,
        max_tokens: int, timeout: httpx.Timeout,
    ) -> str:
        r = await arequest(
            "POST",
            f"{ANTHROPIC_BASE}/messages",
            timeout=timeout,
            headers={"x-api-key": key, "anthropic-version": ANTHROPIC_VERSION},
            json={
                "model": model,
                "max_tokens": max_tokens,
                "system": system,
                "messages": [{"role": "user", "content": text}],
            },
        )
        r.raise_for_status()
        body = r.json()
        if body.get("stop_reason") == "max_tokens":
            logger.warning("LLM output truncated at {} tokens", max_tokens)
            raise LLMError(t("llm_truncated"))
        parts = body.get("content", [])
        return "".join(p.get("text", "") for p in parts if p.get("type") == "text")

    async def _openai(
        self, text: str, system: str, model: str, key: str, timeout: httpx.Timeout,
    ) -> str:
        r = await arequest(
            "POST",
            f"{OPENAI_BASE}/chat/completions",
            timeout=timeout,
            headers={"Authorization": f"Bearer {key}"},
            json={
                "model": model,
                "messages": [
                    {"role": "system", "content": system},
                    {"role": "user", "content": text},
                ],
            },
        )
        r.raise_for_status()
        choices = r.json().get("choices", [])
        if not choices:
            return ""
        if choices[0].get("finish_reason") == "length":
            logger.warning("LLM output truncated (finish_reason=length)")
            raise LLMError(t("llm_truncated"))
        # content can be null (tool calls, content filter) — never propagate None
        return choices[0].get("message", {}).get("content") or ""

    @staticmethod
    def _status_message(e: httpx.HTTPStatusError) -> str:
        code = e.response.status_code
        if code in (401, 403):
            return t("llm_bad_key")
        if code == 429:
            return t("llm_rate_limit")
        if code >= 500:
            return t("llm_provider_down")
        return t("llm_request_failed", code=code)
