"""Async job API for long-running transcriptions (file conversions, meeting
recordings) that must not hold an HTTP request open for minutes to hours.

V1 store is an in-memory dict behind a small interface so the hosted offering
can swap in Redis + multiple workers without route changes. One worker thread
drains a FIFO queue; the engine's own model lock serializes actual inference.
"""
from __future__ import annotations

import asyncio
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol

from loguru import logger


@dataclass
class Job:
    id: str
    input_path: Path
    language: str | None
    with_timestamps: bool
    multilingual: bool
    response_format: str
    created_at: float
    principal_id: str = "local"
    status: str = "queued"          # queued | processing | done | error
    progress: float = 0.0
    result: dict | None = None       # engine (text, meta) collapsed into a dict
    error: str | None = None
    finished_at: float | None = None

    def public(self) -> dict:
        body = {
            "id": self.id,
            "status": self.status,
            "progress": round(self.progress, 3),
        }
        if self.status == "done":
            body["result"] = self.result
        if self.status == "error":
            body["error"] = self.error
        return body


class JobStore(Protocol):
    def create(self, **kwargs) -> Job: ...
    def get(self, job_id: str) -> Job | None: ...
    def delete(self, job_id: str) -> bool: ...
    def purge_expired(self, ttl_s: float) -> int: ...
    def pending_count(self) -> int: ...


class MemoryJobStore:
    def __init__(self) -> None:
        self._jobs: dict[str, Job] = {}

    def create(self, **kwargs) -> Job:
        job_id = uuid.uuid4().hex
        job = Job(id=job_id, created_at=time.time(), **kwargs)
        self._jobs[job_id] = job
        return job

    def pending_count(self) -> int:
        return sum(1 for j in self._jobs.values()
                   if j.status in ("queued", "processing"))

    def get(self, job_id: str) -> Job | None:
        return self._jobs.get(job_id)

    def delete(self, job_id: str) -> bool:
        job = self._jobs.pop(job_id, None)
        if job is not None:
            _safe_unlink(job.input_path)
            return True
        return False

    def purge_expired(self, ttl_s: float) -> int:
        now = time.time()
        stale = [
            jid for jid, j in self._jobs.items()
            if j.finished_at is not None and now - j.finished_at > ttl_s
        ]
        for jid in stale:
            self.delete(jid)
        return len(stale)


def _safe_unlink(path: Path) -> None:
    try:
        if path and path.exists():
            path.unlink()
    except OSError as e:
        logger.warning("Could not remove job upload {}: {}", path, e)


class JobRunner:
    """One background worker draining the queue. Runs transcriptions on the
    engine and feeds progress back into the Job. Started on app startup."""

    def __init__(self, engine, store: JobStore, ttl_s: float) -> None:
        self._engine = engine
        self._store = store
        self._ttl_s = ttl_s
        self._queue: asyncio.Queue[str] = asyncio.Queue()
        self._task: asyncio.Task | None = None
        self._purge_task: asyncio.Task | None = None
        self._stopped = False
        self._restart_handle: asyncio.TimerHandle | None = None

    def start(self) -> None:
        self._stopped = False
        loop = asyncio.get_running_loop()
        # Only (re)create tasks that are not already alive, so a worker restart
        # never spawns a duplicate purge loop.
        if self._task is None or self._task.done():
            self._task = loop.create_task(self._run(), name="job-runner")
            self._task.add_done_callback(self._on_run_done)
        if self._purge_task is None or self._purge_task.done():
            self._purge_task = loop.create_task(self._purge_loop(), name="job-purge")

    def _on_run_done(self, t: asyncio.Task) -> None:
        if t.cancelled() or self._stopped:
            return
        exc = t.exception()
        if exc is not None:
            # The per-iteration guard should prevent this, but never let the
            # worker die silently. Restart after a short backoff so a
            # deterministic pre-await failure can't spin/flood logs. The handle
            # is stored so stop() can cancel a pending restart during shutdown.
            logger.error("Job runner exited unexpectedly, restarting: {}", exc)
            self._restart_handle = asyncio.get_running_loop().call_later(
                2.0, self._restart)

    def _restart(self) -> None:
        self._restart_handle = None
        if not self._stopped:
            self.start()

    async def stop(self) -> None:
        self._stopped = True
        if self._restart_handle is not None:
            self._restart_handle.cancel()
            self._restart_handle = None
        for t in (self._task, self._purge_task):
            if t is not None:
                t.cancel()
                try:
                    await t
                except asyncio.CancelledError:
                    pass
        self._task = None
        self._purge_task = None

    def submit(self, job: Job) -> None:
        self._queue.put_nowait(job.id)

    async def _run(self) -> None:
        while True:
            try:
                job_id = await self._queue.get()
                job = self._store.get(job_id)
                if job is None or job.status != "queued":
                    continue
                await self._process(job)
            except asyncio.CancelledError:
                raise
            except Exception as e:  # noqa: BLE001 — one bad job can't kill the loop
                logger.error("Job runner iteration failed: {}", e)

    async def _process(self, job: Job) -> None:
        job.status = "processing"
        loop = asyncio.get_running_loop()

        def _on_progress(fraction: float) -> None:
            # Called from the engine worker thread — assign only (GIL-safe).
            job.progress = float(fraction)

        try:
            async with self._engine.admit():
                text, meta = await self._engine.service.transcribe_file(
                    job.input_path,
                    language=job.language,
                    with_timestamps=job.with_timestamps,
                    on_progress=_on_progress,
                    multilingual=job.multilingual,
                )
            if not text.strip() and not meta:
                job.status = "error"
                job.error = "Transcription failed on the server"
            else:
                import json

                from .auth import Principal
                from .formats import render
                body, _ = render(text, meta, "verbose_json")
                job.result = json.loads(body)
                job.progress = 1.0
                job.status = "done"
                self._engine.usage.record(
                    Principal(id=job.principal_id),
                    meta.get("duration") or 0.0,
                    self._engine.status().get("model", ""), job.id,
                )
        except Exception as e:  # noqa: BLE001 — job must capture any failure
            logger.error("Job {} failed: {}", job.id, e)
            job.status = "error"
            job.error = str(e)
        finally:
            job.finished_at = time.time()

    async def _purge_loop(self) -> None:
        while True:
            await asyncio.sleep(60.0)
            try:
                n = self._store.purge_expired(self._ttl_s)
                if n:
                    logger.debug("Purged {} expired job(s)", n)
            except Exception as e:  # noqa: BLE001
                logger.warning("Job purge failed: {}", e)
