"""Cloud transcriber — short-clip dictation via our /transcribe endpoint.

Drop-in replacement for the MLXTranscriber when the user has Cloud Mode on
(the default for v8+). Same shape as `transcriber._BaseTranscriber.transcribe`:
takes a numpy float32 audio buffer + sample rate, returns
`{"text": str, "language": str, ...}`. The dictation hotkey path in app.py
doesn't need to know which backend is behind it.

Why server-proxied instead of client → Groq directly:
  • API key safety — keys baked into a desktop .app are extractable.
  • Daily-word-cap enforcement — server-side, can't be patched out.
  • Provider abstraction — Groq today, possibly Deepgram / OpenAI tomorrow;
    one config flip on the server, no client redeploy.

Authentication: we read the session token written by `licensing.py` to
`~/Library/Application Support/Lumo/session.json`. If no session exists
(user never activated), we raise so app.py can fall back to local.
"""
from __future__ import annotations

import io
import json
import os
import time
import wave
from pathlib import Path
from typing import Any
from urllib import error as urllib_error
from urllib import request as urllib_request

import numpy as np


# Multipart-form boundary used to encode audio + form fields for upload.
# urllib doesn't have a multipart helper; we build the body by hand. Boundary
# uniqueness only has to be local — process-startup-time plus randomness is
# overkill but it's the convention.
_MULTIPART_BOUNDARY = f"----LirelleBoundary{int(time.time() * 1000)}"


class CloudDictatorError(RuntimeError):
    """Network or auth failure talking to /transcribe. app.py catches and
    falls back to the local transcriber so the user isn't dead in the water
    when Lirelle's server (or their internet) is down."""


class CloudDictator:
    """Cloud dictation backend.

    Public surface mirrors `transcriber._BaseTranscriber.transcribe()` so
    app.py can swap us in with no extra plumbing."""

    friendly_name = "Cloud Whisper (Groq)"

    def __init__(self, language: str = "auto",
                 initial_prompt: str = "",
                 no_speech_threshold: float = 0.6,
                 condition_on_previous_text: bool = False,
                 server_url: str | None = None) -> None:
        # We only use `language` from the kwargs that MLXTranscriber accepts —
        # the rest are kept for signature parity so make_transcriber can pass
        # them without inspecting the backend.
        self.language: str | None = None if language == "auto" else language
        self._server_url = server_url
        # Light-touch deps — accept the signature shape but ignore unused
        # MLX/whisper-cpp params (initial_prompt, thresholds).
        _ = (initial_prompt, no_speech_threshold, condition_on_previous_text)

    # ── transcriber.py interface compatibility ──────────────────────────

    def is_model_ready(self) -> bool:
        """No model to download — cloud is always 'ready' from the bundle's
        perspective. Network availability is checked at call time."""
        return True

    def estimated_size_bytes(self) -> int:
        return 0

    def ensure_model_ready(self, progress=None) -> None:
        if progress is not None:
            progress(0, 0, "Cloud mode — no local model needed")
        return

    # ── The actual transcription ────────────────────────────────────────

    def transcribe(self, audio: np.ndarray,
                   sample_rate: int = 16000) -> dict[str, Any]:
        """Send `audio` to the Lirelle server's /transcribe endpoint and
        return the same dict shape MLXTranscriber returns:
          {"text": str, "language": str, "duration": float, ...}

        Raises CloudDictatorError on any failure — caller can fall back to
        local transcription if it wants."""
        if audio.size == 0:
            return {"text": "", "language": self.language or ""}

        wav_bytes = _numpy_to_wav_bytes(audio, sample_rate)
        token = _read_session_token()
        if not token:
            raise CloudDictatorError(
                "no session token — sign in to use Cloud Mode (or switch "
                "Local Mode on in Settings)",
            )

        server = (self._server_url
                  or os.environ.get("LUMO_LICENSE_SERVER")
                  or "https://lirelle.us")
        url = f"{server.rstrip('/')}/transcribe"

        body, content_type = _build_multipart(
            wav_bytes,
            language=self.language or "",
        )
        req = urllib_request.Request(
            url, data=body, method="POST",
            headers={
                "Authorization": f"Bearer {token}",
                "Content-Type": content_type,
                # Server returns JSON; explicit Accept makes intermediaries
                # not auto-decompress in weird ways.
                "Accept": "application/json",
            },
        )

        started = time.monotonic()
        try:
            # CR (2026-07-04) — was 30s. Groq responds in 300-800 ms
            # for typical short dictation. 30s means if the server
            # burps the user waits half a minute before the local
            # fallback kicks in — which felt like "the app is hung".
            # 8s is 10× the normal p99 margin; anything longer means
            # the network is genuinely broken and local is faster.
            with urllib_request.urlopen(req, timeout=8.0) as resp:
                payload = json.loads(resp.read().decode("utf-8"))
        except urllib_error.HTTPError as e:
            try:
                err_body = e.read().decode("utf-8", errors="replace")
            except Exception:
                err_body = ""
            # 429 = daily cap reached → surface a friendly message so
            # app.py can show "Free limit hit — try tomorrow or upgrade".
            if e.code == 429:
                raise CloudDictatorError(f"daily word cap reached ({err_body[:200]})") from e
            raise CloudDictatorError(f"HTTP {e.code}: {err_body[:300]}") from e
        except urllib_error.URLError as e:
            raise CloudDictatorError(f"network: {e.reason}") from e
        except TimeoutError as e:
            raise CloudDictatorError(f"timeout: {e}") from e

        elapsed = time.monotonic() - started
        text = (payload.get("text") or "").strip()
        return {
            "text": text,
            "language": payload.get("language") or "",
            "duration": float(audio.size) / float(sample_rate),
            "_provider": payload.get("provider"),
            "_server_elapsed_ms": payload.get("elapsed_ms"),
            "_client_elapsed_s": elapsed,
            "_words": payload.get("words"),
            "_words_used_today": payload.get("words_used_today"),
            "_words_cap": payload.get("words_cap"),
        }


# ── helpers ─────────────────────────────────────────────────────────────


def _numpy_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
    """Encode a float32 numpy buffer as 16-bit mono WAV in memory.

    Whisper backends all consume mono 16 kHz PCM; we keep the same rate the
    helper captures at. Floats clipped to [-1, 1] then scaled to int16 — the
    standard PCM-WAV path. Total overhead vs Groq's 1.5 MB/30 s WAV size:
    ~2 ms in Python."""
    clipped = np.clip(audio, -1.0, 1.0)
    pcm16 = (clipped * 32767.0).astype(np.int16)
    buf = io.BytesIO()
    with wave.open(buf, "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)  # 16-bit
        w.setframerate(int(sample_rate))
        w.writeframes(pcm16.tobytes())
    return buf.getvalue()


def _read_session_token() -> str | None:
    """Pull the session token from session.json. The file is written by
    licensing.py at activation time + on every refresh. Path matches
    licensing._session_path() — keep them in sync."""
    p = (Path.home() / "Library" / "Application Support" / "Lumo"
         / "session.json")
    try:
        data = json.loads(p.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return None
    tok = data.get("token") if isinstance(data, dict) else None
    return tok if isinstance(tok, str) and tok else None


def _build_multipart(wav_bytes: bytes, language: str = "") -> tuple[bytes, str]:
    """Hand-rolled multipart/form-data — urllib doesn't bundle a builder.
    Two parts: `file` (the WAV) + `language` (optional ISO-639-1 hint).
    Returns (body_bytes, content_type_header)."""
    parts: list[bytes] = []
    boundary = _MULTIPART_BOUNDARY

    def _field(name: str, value: str) -> None:
        parts.append(f"--{boundary}\r\n".encode())
        parts.append(
            f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
        parts.append(value.encode("utf-8"))
        parts.append(b"\r\n")

    def _file(name: str, filename: str, content_type: str, data: bytes) -> None:
        parts.append(f"--{boundary}\r\n".encode())
        parts.append(
            (f'Content-Disposition: form-data; name="{name}"; '
             f'filename="{filename}"\r\n').encode())
        parts.append(f"Content-Type: {content_type}\r\n\r\n".encode())
        parts.append(data)
        parts.append(b"\r\n")

    _file("file", "dictation.wav", "audio/wav", wav_bytes)
    _field("language", language)
    parts.append(f"--{boundary}--\r\n".encode())

    body = b"".join(parts)
    return body, f"multipart/form-data; boundary={boundary}"
