"""LLM engine abstraction for the rewrite step.

Engines are user-managed presets pointing at any OpenAI-compatible chat endpoint
(OpenAI, vLLM, llama-swap, Ollama /v1, LM Studio, Groq, OpenRouter, …). Provides
reachability status (online/offline) and a chat-completion call. Uses only the
standard library so the daemon needs no extra deps.
"""

from __future__ import annotations

import json
import os
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Callable

from .stt import reachable


@dataclass
class LLMEngine:
    name: str
    url: str = "https://api.openai.com/v1"   # base URL (incl. /v1)
    model: str = "gpt-4o-mini"
    api_key_env: str = "OPENAI_API_KEY"
    temperature: float = 0.3
    type: str = "cloud"                       # "local" | "cloud" (label/category)

    @property
    def api_key(self) -> str | None:
        return os.environ.get(self.api_key_env) if self.api_key_env else None


class LLMError(RuntimeError):
    pass


def status(engine: LLMEngine, timeout: float = 2.0) -> bool:
    """Reachable if the endpoint host:port accepts a TCP connection."""
    return reachable(engine.url, timeout)


def chat(
    engine: LLMEngine,
    system_prompt: str,
    user_text: str,
    *,
    model: str | None = None,
    temperature: float | None = None,
    timeout: int = 45,
    on_token: Callable[[str], None] | None = None,
) -> str:
    """Run a chat completion and return the full text.

    When ``on_token`` is given, the request is streamed and each content delta is
    handed to the callback as it arrives (so a UI can show the model writing in
    real time). The callback is best-effort — it never affects the return value,
    which is always the complete, stripped response.
    """
    stream = on_token is not None
    api_key = engine.api_key
    body_obj = {
        "model": model or engine.model,
        "temperature": engine.temperature if temperature is None else temperature,
        "messages": [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_text},
        ],
    }
    if stream:
        body_obj["stream"] = True
    payload = json.dumps(body_obj).encode("utf-8")

    headers = {"Content-Type": "application/json"}
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"

    req = urllib.request.Request(
        engine.url.rstrip("/") + "/chat/completions", data=payload, headers=headers, method="POST"
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            if stream:
                content = _read_stream(resp, on_token)
            else:
                body = json.loads(resp.read().decode("utf-8"))
                content = body["choices"][0]["message"]["content"]
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", "replace")[:300]
        raise LLMError(f"HTTP {exc.code}: {detail}") from exc
    except urllib.error.URLError as exc:
        raise LLMError(f"Connection failed: {exc.reason}") from exc
    except (KeyError, IndexError, TypeError) as exc:
        raise LLMError(f"Unexpected response: {exc}") from exc
    except (TimeoutError, OSError) as exc:
        # socket.timeout (subclass of OSError / TimeoutError) fires when the server
        # stops sending data mid-stream. Not wrapped in URLError — must be caught
        # separately or it would propagate uncaught and kill the background thread.
        raise LLMError(f"Request timed out or connection lost: {exc}") from exc
    except Exception as exc:  # noqa: BLE001
        raise LLMError(f"Unexpected error: {exc}") from exc

    content = (content or "").strip()
    if not content:
        raise LLMError("Empty response from model.")
    return content


def _read_stream(resp, on_token: Callable[[str], None]) -> str:
    """Parse an OpenAI-style SSE stream, returning the accumulated content and
    feeding each delta to ``on_token``. Tolerant of keep-alive blanks and the
    trailing ``[DONE]`` sentinel."""
    parts: list[str] = []
    for raw in resp:
        line = raw.decode("utf-8", "replace").strip()
        if not line or not line.startswith("data:"):
            continue
        data = line[len("data:"):].strip()
        if data == "[DONE]":
            break
        try:
            obj = json.loads(data)
            delta = obj["choices"][0]["delta"].get("content")
        except (ValueError, KeyError, IndexError, TypeError):
            continue
        if delta:
            parts.append(delta)
            try:
                on_token(delta)
            except Exception:  # noqa: BLE001 - UI hiccups must not break delivery
                pass
    return "".join(parts)
