"""Remote transcription backend — offloads work to a VoxCast Server.

Mirrors the pieces of the TranscriptionService contract the router needs:
  transcribe_with_language(np.float32@16k) -> (text, (lang, conf)|None)
  transcribe_file(path, ...)               -> (text, {duration, language, segments})
plus status polling for the "Test connection" button and readiness, and a
streaming live session (WebSocket) for remote live dictation.

All HTTP goes through http_exec (QtAsyncio has no async sockets). Failures map
to i18n error toasts and the engine's empty-result contract — never a silent
local fallback (a misconfigured remote must be visible; a remote-only install
has no local model to fall back to).
"""
from __future__ import annotations

import io
import time
import wave

import httpx
import numpy as np
from loguru import logger

from .http_exec import arequest, run_blocking
from .i18n import t

SAMPLE_RATE = 16000
_STATUS_TTL_S = 10.0
_SYNC_TIMEOUT = httpx.Timeout(300.0, connect=10.0)
_STATUS_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
_JOB_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
_UPLOAD_TIMEOUT = httpx.Timeout(600.0, connect=10.0)
_POLL_INTERVAL_S = 1.0


class RemoteError(Exception):
    """Carries a user-facing i18n key so callers can toast the right message."""

    def __init__(self, i18n_key: str) -> None:
        super().__init__(i18n_key)
        self.i18n_key = i18n_key


def float32_to_wav_bytes(audio: np.ndarray, rate: int = SAMPLE_RATE) -> bytes:
    """Encode float32 [-1, 1] mono to an in-memory 16-bit PCM WAV."""
    clipped = np.clip(audio, -1.0, 1.0)
    pcm = (clipped * 32767.0).astype("<i2")
    buf = io.BytesIO()
    with wave.open(buf, "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(rate)
        w.writeframes(pcm.tobytes())
    return buf.getvalue()


class RemoteTranscriptionBackend:
    def __init__(self, signals) -> None:
        self._signals = signals
        self._base_url = ""
        self._api_key = ""
        self._tls_mode = "verify"
        self._cert_pem = ""
        self._verify: object = True
        self._status_cache: dict | None = None
        self._status_ts = 0.0

    # ── configuration ─────────────────────────────────────────────────────

    def configure(self, base_url: str, api_key: str,
                  tls_mode: str = "verify", cert_pem: str = "") -> None:
        base_url = (base_url or "").rstrip("/")
        changed = (base_url != self._base_url or api_key != self._api_key
                   or tls_mode != self._tls_mode or cert_pem != self._cert_pem)
        if changed:
            self._base_url = base_url
            self._api_key = api_key or ""
            self._tls_mode = tls_mode or "verify"
            self._cert_pem = cert_pem or ""
            from .tls_trust import build_verify
            # http:// ignores verify; only https:// uses the pinned context.
            self._verify = build_verify(self._tls_mode, self._cert_pem)
            self._status_cache = None
            self._status_ts = 0.0

    @property
    def verify(self) -> object:
        """SSL verify value (True | SSLContext | False) for httpx/WS callers."""
        return self._verify

    @property
    def configured(self) -> bool:
        return bool(self._base_url)

    @property
    def base_url(self) -> str:
        return self._base_url

    def _headers(self) -> dict:
        h = {"Accept": "application/json"}
        if self._api_key:
            h["Authorization"] = f"Bearer {self._api_key}"
        return h

    def _map_status_error(self, code: int) -> str:
        if code in (401, 403):
            return "err_remote_auth"
        if code == 503:
            return "err_remote_busy"
        return "err_remote_unreachable"

    # ── status / readiness ────────────────────────────────────────────────

    async def get_status(self, *, force: bool = False) -> dict | None:
        """Cached server status (≤10 s). Returns None when unreachable; never
        raises (used by the readiness gate)."""
        now = time.monotonic()
        if not force and self._status_cache is not None \
                and now - self._status_ts < _STATUS_TTL_S:
            return self._status_cache
        if not self.configured:
            return None
        try:
            resp = await arequest("GET", f"{self._base_url}/v1/status",
                                  timeout=_STATUS_TIMEOUT, headers=self._headers(),
                                  verify=self._verify)
        except httpx.HTTPError as e:
            logger.info("Remote status unreachable: {}", e)
            self._status_cache = None
            self._status_ts = now
            return None
        if resp.status_code != 200:
            self._status_cache = None
            self._status_ts = now
            return None
        try:
            data = resp.json()
        except ValueError:
            return None
        self._status_cache = data
        self._status_ts = now
        return data

    async def test_connection(self) -> dict:
        """For the Settings 'Test connection' button. Raises RemoteError with an
        i18n key on failure; returns the status dict on success."""
        if not self.configured:
            raise RemoteError("err_remote_unreachable")
        try:
            resp = await arequest("GET", f"{self._base_url}/v1/status",
                                  timeout=_STATUS_TIMEOUT, headers=self._headers(),
                                  verify=self._verify)
        except httpx.HTTPError:
            raise RemoteError("err_remote_unreachable")
        if resp.status_code != 200:
            raise RemoteError(self._map_status_error(resp.status_code))
        try:
            data = resp.json()
        except ValueError:
            raise RemoteError("err_remote_unreachable")
        self._status_cache = data
        self._status_ts = time.monotonic()
        return data

    @property
    def cached_ready(self) -> bool:
        return bool(self._status_cache and self._status_cache.get("ready"))

    @property
    def maybe_ready(self) -> bool:
        """Optimistic readiness: configured, and not known to be unready. A
        stale/absent cache assumes reachable — the actual call reports the
        truth via an error toast, never a silent success."""
        if not self.configured:
            return False
        if self._status_cache is None:
            return True
        return bool(self._status_cache.get("ready"))

    # ── central enhancement + managed prompts ─────────────────────────────

    def enhance_available(self) -> bool:
        """Accurate view for the UI: True only if the cached status confirms
        the server offers enhancement."""
        enh = (self._status_cache or {}).get("enhance") or {}
        return bool(enh.get("available"))

    def enhance_maybe(self) -> bool:
        """Optimistic view for routing: True unless the cached status is known
        and says enhancement is unavailable. Mirrors maybe_ready, so a stale/
        absent status cache does not silently reroute to the local key path
        (the enhance call itself surfaces an honest error if unavailable)."""
        if self._status_cache is None:
            return True
        enh = self._status_cache.get("enhance") or {}
        return bool(enh.get("available"))

    async def enhance(self, text: str, system_prompt: str) -> str:
        """Run enhancement on the server (its central LLM key). Raises
        RemoteError on failure so the caller can fall back to the raw text."""
        try:
            resp = await arequest(
                "POST", f"{self._base_url}/v1/enhance",
                timeout=_SYNC_TIMEOUT, headers=self._headers(),
                json={"text": text, "system_prompt": system_prompt},
                verify=self._verify,
            )
        except httpx.HTTPError as e:
            logger.warning("Remote enhance failed: {}", e)
            raise RemoteError("err_remote_unreachable") from e
        if resp.status_code != 200:
            raise RemoteError(self._map_status_error(resp.status_code))
        try:
            return resp.json().get("text") or ""
        except ValueError as e:
            raise RemoteError("err_remote_unreachable") from e

    async def get_prompts(self) -> list[dict]:
        """Server-managed enhancement prompts (company modes). Empty on error."""
        if not self.configured:
            return []
        try:
            resp = await arequest(
                "GET", f"{self._base_url}/v1/prompts",
                timeout=_STATUS_TIMEOUT, headers=self._headers(),
                verify=self._verify,
            )
        except httpx.HTTPError:
            return []
        if resp.status_code != 200:
            return []
        try:
            rows = resp.json().get("prompts") or []
        except ValueError:
            return []
        return [r for r in rows if isinstance(r, dict)
                and isinstance(r.get("id"), str)
                and isinstance(r.get("name"), str)
                and isinstance(r.get("prompt"), str)]

    # ── batch dictation ───────────────────────────────────────────────────

    async def transcribe_with_language(
        self, audio: np.ndarray,
    ) -> tuple[str, tuple[str, float] | None]:
        wav = float32_to_wav_bytes(audio)
        try:
            resp = await arequest(
                "POST", f"{self._base_url}/v1/audio/transcriptions",
                timeout=_SYNC_TIMEOUT, headers=self._headers(),
                files={"file": ("audio.wav", wav, "audio/wav")},
                data={"model": "whisper-1", "response_format": "verbose_json"},
                verify=self._verify,
            )
        except httpx.HTTPError as e:
            logger.warning("Remote dictation failed: {}", e)
            self._toast("err_remote_unreachable")
            return "", None
        if resp.status_code != 200:
            self._toast(self._map_status_error(resp.status_code))
            return "", None
        try:
            data = resp.json()
        except ValueError:
            self._toast("err_remote_unreachable")
            return "", None
        text = (data.get("text") or "").strip()
        lang = data.get("language")
        # The server's detection is authoritative; confidence 1.0 so the AI
        # enhancement pins this language (local path returns a real confidence).
        detected = (lang, 1.0) if lang else None
        return text, detected

    # ── batch file (job API, streamed upload) ─────────────────────────────

    async def transcribe_file(
        self, path, *, language=None, with_timestamps=False,
        on_progress=None, multilingual=False,
    ) -> tuple[str, dict]:
        try:
            job_id = await self._submit_job(path, language, with_timestamps, multilingual)
        except RemoteError as e:
            self._toast(e.i18n_key)
            return "", {}
        except httpx.HTTPError as e:
            logger.warning("Remote job submit failed: {}", e)
            self._toast("err_remote_unreachable")
            return "", {}
        return await self._poll_job(job_id, on_progress)

    async def _submit_job(self, path, language, with_timestamps, multilingual) -> str:
        headers = self._headers()
        data = {
            "model": "whisper-1",
            "response_format": "verbose_json",
            "multilingual": "true" if multilingual else "false",
        }
        if language:
            data["language"] = language

        from pathlib import Path
        filename = Path(path).name or "audio"

        def _post() -> httpx.Response:
            # Stream the file from disk (no full-RAM buffering for large meeting
            # WAVs). Automatic retries are DELIBERATELY not used here: httpx's
            # retry would replay a consumed file handle. A transient blip fails
            # the upload; the caller surfaces an honest error.
            with httpx.Client(timeout=_UPLOAD_TIMEOUT, verify=self._verify) as client:
                with open(path, "rb") as fh:
                    return client.post(
                        f"{self._base_url}/v1/jobs",
                        headers=headers,
                        data=data,
                        files={"file": (filename, fh, "application/octet-stream")},
                    )

        resp = await run_blocking(_post)
        if resp.status_code != 200:
            raise RemoteError(self._map_status_error(resp.status_code))
        return resp.json()["id"]

    async def _poll_job(self, job_id: str, on_progress) -> tuple[str, dict]:
        import asyncio
        url = f"{self._base_url}/v1/jobs/{job_id}"
        while True:
            try:
                resp = await arequest("GET", url, timeout=_JOB_TIMEOUT,
                                      verify=self._verify,
                                      headers=self._headers())
            except httpx.HTTPError as e:
                logger.warning("Remote job poll failed: {}", e)
                self._toast("err_remote_unreachable")
                return "", {}
            if resp.status_code != 200:
                self._toast(self._map_status_error(resp.status_code))
                return "", {}
            body = resp.json()
            status = body.get("status")
            if on_progress and body.get("progress") is not None:
                try:
                    on_progress(float(body["progress"]))
                except Exception:
                    logger.exception("remote progress callback failed")
            if status == "done":
                result = body.get("result") or {}
                return self._result_to_engine_shape(result)
            if status == "error":
                # The server IS reachable and reported a real processing failure
                # (bad audio, OOM) — not a transport problem.
                logger.warning("Remote job error: {}", body.get("error"))
                self._toast("err_remote_failed")
                return "", {}
            await asyncio.sleep(_POLL_INTERVAL_S)

    @staticmethod
    def _result_to_engine_shape(result: dict) -> tuple[str, dict]:
        text = (result.get("text") or "").strip()
        meta = {
            "duration": result.get("duration"),
            "language": result.get("language"),
            "segments": [
                {"start": s.get("start", 0.0), "end": s.get("end", 0.0),
                 "text": s.get("text", "")}
                for s in (result.get("segments") or [])
            ],
        }
        return text, meta

    # ── live streaming session factory ────────────────────────────────────

    def ws_url(self, language: str | None) -> str:
        """WS URL WITHOUT the token — the token goes in the Authorization
        header (see RemoteLiveSession) so it never lands in server/proxy access
        logs. Language is percent-encoded."""
        from urllib.parse import quote
        scheme = "wss" if self._base_url.startswith("https://") else "ws"
        host = self._base_url.split("://", 1)[-1]
        q = f"?language={quote(language, safe='')}" if language else ""
        return f"{scheme}://{host}/v1/realtime{q}"

    @property
    def api_key(self) -> str:
        return self._api_key

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

    def _toast(self, key: str) -> None:
        try:
            self._signals.error_occurred.emit(t(key))
        except Exception:
            logger.warning("could not emit remote error toast: {}", key)


class RemoteLiveSession:
    """Streams mic PCM to the server's /v1/realtime WebSocket and delivers
    committed deltas. Runs in a dedicated thread (QtAsyncio has no async
    sockets); deltas are handed back on the event loop via call_soon_threadsafe.
    """

    _SEND_INTERVAL_S = 0.2
    # Final-pass drain budget after commit, and the join budget stop() waits.
    # JOIN must exceed FINALIZE (+ margin for the send/notice latency) or stop()
    # returns while the thread is still finalizing and a late delta could land
    # in the next dictation.
    _FINALIZE_DRAIN_S = 10.0
    _JOIN_TIMEOUT_S = 13.0

    def __init__(self, backend: RemoteTranscriptionBackend, audio_service,
                 loop, on_delta, on_error) -> None:
        self._backend = backend
        self._audio = audio_service
        self._loop = loop
        self._on_delta = on_delta   # callable(str), scheduled on the loop
        self._on_error = on_error   # callable(i18n_key), scheduled on the loop
        self._language: str | None = None
        self._thread = None
        self._stop = False
        self._sent_abs = 0

    def start(self, language: str | None) -> None:
        import threading
        self._language = language
        self._stop = False
        self._sent_abs = 0
        self._thread = threading.Thread(target=self._run, name="remote-live",
                                        daemon=True)
        self._thread.start()

    def stop(self) -> None:
        self._stop = True
        if self._thread is not None:
            self._thread.join(timeout=self._JOIN_TIMEOUT_S)
            self._thread = None

    def _emit_delta(self, delta: str) -> None:
        # The loop may be closed if the app is shutting down mid-session;
        # scheduling onto a closed loop raises — swallow it in the daemon thread.
        try:
            self._loop.call_soon_threadsafe(self._on_delta, delta)
        except RuntimeError:
            pass

    def _emit_error(self, key: str) -> None:
        try:
            self._loop.call_soon_threadsafe(self._on_error, key)
        except RuntimeError:
            pass

    def _new_pcm(self) -> bytes:
        full, base = self._audio.snapshot_buffer()
        total = base + len(full)
        start = max(self._sent_abs - base, 0)
        if start >= len(full):
            return b""
        chunk = full[start:]
        self._sent_abs = total
        pcm = (np.clip(chunk, -1.0, 1.0) * 32767.0).astype("<i2")
        return pcm.tobytes()

    def _run(self) -> None:
        import json
        try:
            from websockets.sync.client import connect
        except Exception as e:  # noqa: BLE001
            logger.error("websockets unavailable for remote live: {}", e)
            self._emit_error("err_remote_unreachable")
            return
        if self._stop:
            return  # stopped before we even connected — nothing to finalize
        url = self._backend.ws_url(self._language)
        headers = {"Authorization": f"Bearer {self._backend.api_key}"}
        # wss:// needs the same TLS trust as the HTTP calls. websockets wants
        # an SSLContext (or None); map our verify value: True -> default
        # context, an SSLContext -> use it, False -> unverified context.
        ssl_ctx = None
        if url.startswith("wss://"):
            import ssl as _ssl
            v = self._backend.verify
            if v is True:
                ssl_ctx = _ssl.create_default_context()
            elif v is False:
                ssl_ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_CLIENT)
                ssl_ctx.check_hostname = False
                ssl_ctx.verify_mode = _ssl.CERT_NONE
            else:
                ssl_ctx = v  # already an SSLContext (pinned)
        try:
            with connect(url, open_timeout=10, max_size=None,
                         additional_headers=headers, ssl=ssl_ctx) as ws:
                self._loop_session(ws, json)
        except Exception as e:  # noqa: BLE001
            logger.warning("Remote live session failed: {}", e)
            self._emit_error("err_remote_unreachable")

    def _loop_session(self, ws, json) -> None:
        import time as _time

        # First check the server did not immediately reject with an error event.
        try:
            early = ws.recv(timeout=0.1)
            evt = json.loads(early)
            if evt.get("type") == "error":
                self._emit_error("err_remote_busy")
                return
        except TimeoutError:
            pass
        except Exception:  # noqa: BLE001
            pass

        while not self._stop:
            pcm = self._new_pcm()
            if pcm:
                ws.send(pcm)
            self._drain(ws, json, final=False)
            _time.sleep(self._SEND_INTERVAL_S)

        # Flush remaining audio, then commit and read to completion.
        pcm = self._new_pcm()
        if pcm:
            ws.send(pcm)
        ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
        deadline = _time.monotonic() + self._FINALIZE_DRAIN_S
        while _time.monotonic() < deadline:
            if not self._drain(ws, json, final=True):
                break

    def _drain(self, ws, json, *, final: bool) -> bool:
        """Read available events without blocking long. Returns False when the
        completed event arrived (final flush can stop). Bounded per call so a
        flooding/misbehaving server can't keep one drain running indefinitely."""
        try:
            for _ in range(500):
                msg = ws.recv(timeout=0.05)
                evt = json.loads(msg)
                etype = evt.get("type", "")
                if etype.endswith("transcription.delta"):
                    delta = evt.get("delta", "")
                    if delta:
                        self._emit_delta(delta)
                elif etype.endswith("transcription.completed"):
                    return False
                elif etype == "error":
                    self._emit_error("err_remote_failed")
                    return False
        except TimeoutError:
            return True
        except Exception:  # noqa: BLE001 — connection closed
            return False
