"""EngineHost — owns the shared VoxCast engine inside the server process.

Reuses the client's battle-tested modules unchanged (imported from src/):
TranscriptionService (model lock, chunking, multilingual, warmup, CPU
fallback) and ModelManager (getvoxcast mirror download, SHA-256 verify).
Qt is not present here; the engine's signal emissions are satisfied by a
duck-typed logging shim.
"""
from __future__ import annotations

import asyncio
import time
from typing import Protocol

from loguru import logger

from services.model_manager import ModelManager, best_default_model
from services.transcription_service import (
    TranscriptionService,
    gpu_vram_mb,
    is_cuda_available,
)

from .auth import Principal
from .config import ServerConfig
from version import SERVER_VERSION


class _LogSignal:
    """Duck-typed stand-in for a Qt signal: `.emit(...)` logs instead."""

    def __init__(self, label: str, level: str = "INFO") -> None:
        self._label = label
        self._level = level

    def emit(self, *args) -> None:
        logger.log(self._level, "[engine:{}] {}", self._label,
                   " ".join(str(a) for a in args))


class _ProgressSignal:
    """Download progress: log at most every ~5s instead of per tick."""

    def __init__(self) -> None:
        self._last = 0.0

    def emit(self, fraction: float) -> None:
        now = time.monotonic()
        if now - self._last >= 5.0 or fraction >= 1.0:
            self._last = now
            logger.info("[engine:download] {:.0%}", float(fraction))


class NullSignals:
    """Satisfies every signal attribute the shared engine modules touch."""

    def __init__(self) -> None:
        self.error_occurred = _LogSignal("error", "WARNING")
        self.download_status = _LogSignal("status")
        self.download_progress = _ProgressSignal()


class UsageSink(Protocol):
    """Metering seam for the hosted offering. V1 logs only."""

    def record(self, principal: Principal, audio_seconds: float,
               model: str, request_id: str) -> None: ...


class LogUsageSink:
    def record(self, principal: Principal, audio_seconds: float,
               model: str, request_id: str) -> None:
        logger.info("usage principal={} seconds={:.1f} model={} request={}",
                    principal.id, audio_seconds, model, request_id)


class EngineHost:
    """Model lifecycle + admission control for the FastAPI routes."""

    ALL_TIERS = ("base", "small", "medium", "large-v3-turbo")

    def __init__(self, config: ServerConfig) -> None:
        self._config = config
        self._signals = NullSignals()
        self._model_manager = ModelManager(self._signals)
        self._resolved_model = ""
        self.service: TranscriptionService | None = None
        # Admission: the engine's _model_lock serializes actual inference;
        # this bounds how many requests may WAIT for it (503 beyond that).
        self._waiting = 0
        self._sem = asyncio.Semaphore(1)
        self.usage: UsageSink = LogUsageSink()
        self._started_at = time.time()
        self.update_available: str | None = None  # set by the update check
        # Model management: only one model is loaded at a time (switching is a
        # deliberate admin action, never per-request — two clients toggling
        # tiers would thrash 1.6 GB loads). Downloads run server-side; a lock
        # serializes switch/download so they can't race the loaded model.
        self._model_lock = asyncio.Lock()
        self._downloading: set[str] = set()

    # ── lifecycle ─────────────────────────────────────────────────────────

    def _resolve_model_name(self) -> str:
        if self._config.model != "auto":
            return self._config.model
        vram = gpu_vram_mb()
        choice = best_default_model(vram)
        logger.info("Model 'auto' resolved to '{}' (VRAM {} MB)", choice, vram)
        return choice

    async def start(self) -> None:
        self._resolved_model = self._resolve_model_name()

        if not self._model_manager.whisper_model_exists(self._resolved_model):
            logger.info("Model '{}' not on disk, downloading...", self._resolved_model)
            await self._model_manager.download_whisper(self._resolved_model)
            logger.info("Model '{}' download complete", self._resolved_model)

        self.service = TranscriptionService(self._signals, self._resolved_model)
        self.service.set_compute_device(self._config.device)
        await self.service.start()
        if not self.service.is_ready:
            raise RuntimeError(
                f"Engine failed to load model '{self._resolved_model}'"
            )
        logger.info("Engine ready: model={} device={}",
                    self._resolved_model, self.service.device)

    async def stop(self) -> None:
        if self.service is not None:
            await self.service.stop()

    # ── model management (selectable + downloadable ON the server) ─────────

    def model_exists(self, name: str) -> bool:
        return self._model_manager.whisper_model_exists(name)

    def models_info(self) -> list[dict]:
        """Every tier with its server-side state (downloaded / loaded /
        downloading). This is what the client's Settings picker shows."""
        from services.model_manager import WHISPER_MODELS
        out = []
        for name in self.ALL_TIERS:
            info = WHISPER_MODELS.get(name, {})
            out.append({
                "id": name,
                "downloaded": self.model_exists(name),
                "loaded": name == self._resolved_model and self.is_ready,
                "downloading": name in self._downloading,
                "size_bytes": info.get("size_bytes"),
                "vram_mb": info.get("vram_mb"),
            })
        return out

    async def download_model(self, name: str) -> None:
        """Download a tier onto the server (getvoxcast mirror, SHA-256).
        Idempotent; concurrent calls for the same tier coalesce."""
        if name not in self.ALL_TIERS:
            raise ValueError(f"Unknown model tier: {name}")
        if self.model_exists(name) or name in self._downloading:
            return
        self._downloading.add(name)
        try:
            logger.info("Downloading model '{}' on request...", name)
            await self._model_manager.download_whisper(name)
            logger.info("Model '{}' downloaded", name)
        finally:
            self._downloading.discard(name)

    async def switch_model(self, name: str) -> None:
        """Make `name` the active model: download if missing, then hot-swap
        the loaded model under the lock. Serialized against downloads."""
        if name not in self.ALL_TIERS:
            raise ValueError(f"Unknown model tier: {name}")
        async with self._model_lock:
            if name == self._resolved_model and self.is_ready:
                return
            if not self.model_exists(name):
                await self.download_model(name)
            if self.service is not None:
                await self.service.stop()
                self.service.set_model(name)
                await self.service.start()
            else:
                self.service = TranscriptionService(self._signals, name)
                self.service.set_compute_device(self._config.device)
                await self.service.start()
            if not self.service.is_ready:
                raise RuntimeError(f"Failed to load model '{name}'")
            self._resolved_model = name
            logger.info("Active model switched to '{}' ({})",
                        name, self.service.device)

    # ── admission control ─────────────────────────────────────────────────

    @property
    def queue_depth(self) -> int:
        return self._waiting

    def admit(self) -> "_Admission":
        """Context manager: reject when too many requests already wait."""
        return _Admission(self)

    # ── status ────────────────────────────────────────────────────────────

    @property
    def is_ready(self) -> bool:
        return self.service is not None and self.service.is_ready

    def status(self) -> dict:
        return {
            "server_version": SERVER_VERSION,
            "ready": self.is_ready,
            "model": self._resolved_model,
            "device": self.service.device if self.service else "unknown",
            "fell_back_to_cpu": bool(self.service and self.service.fell_back_to_cpu),
            "gpu": {"vram_mb": gpu_vram_mb(), "cuda": is_cuda_available()},
            "queue_depth": self.queue_depth,
            "models": self.models_info(),
            "uptime_s": int(time.time() - self._started_at),
            "update_available": self.update_available,
        }


class _Admission:
    def __init__(self, host: EngineHost) -> None:
        self._host = host
        self._acquired = False

    async def __aenter__(self):
        if self._host._waiting >= self._host._config.queue_size:
            from fastapi import HTTPException
            raise HTTPException(
                status_code=503,
                detail="Server busy",
                headers={"Retry-After": "5"},
            )
        self._host._waiting += 1
        try:
            await self._host._sem.acquire()
        except BaseException:
            # Cancelled/timed out while queued: __aexit__ is NOT called when
            # __aenter__ raises, so undo the counter here or it leaks and the
            # server wedges at 503 forever.
            self._host._waiting -= 1
            raise
        self._acquired = True
        return self

    async def __aexit__(self, *exc):
        if self._acquired:
            self._host._sem.release()
            self._host._waiting -= 1
            self._acquired = False
        return False
