#!/usr/bin/env python3
"""The job worker daemon (#25) — a single serialized loop.

    ~/.virtualenvs/douanier/bin/python -m worker          # or: python worker.py

Boots, recovers crashed in-flight jobs, then loops: claim one pending job → run
its registered handler → write result / error → optional webhook. One job at a
time on purpose (erable is 2 GB / no GPU). Runs as a systemd --user unit in prod
(see VERDACCIO/SRE ops); SIGTERM/SIGINT finish the current job's cleanup then exit.

Engine handlers register themselves by importing their modules here — the worker
is the ONLY process that loads the heavy deps (demucs, yt-dlp, …). Until #29/#30/
#31 land there are none, and the worker just idles politely.
"""
from __future__ import annotations

import json
import signal
import sys
import time
import urllib.request

import jobs

POLL_IDLE_SEC = 1.0      # sleep between empty polls
_STOP = False

# Engine handlers register on import (each module calls @jobs.handler(...)).
# Wrapped so a not-yet-built engine doesn't stop the worker booting.
for _mod in ("engines.sources", "engines.separate", "engines.loops"):
    try:
        __import__(_mod)
    except Exception:
        pass


def _on_signal(signum, _frame):
    global _STOP
    _STOP = True
    print(f"[worker] signal {signum} — finishing up, will exit", flush=True)


def _webhook(url: str, payload: dict) -> None:
    try:
        req = urllib.request.Request(
            url, data=json.dumps(payload).encode(),
            headers={"Content-Type": "application/json"}, method="POST")
        urllib.request.urlopen(req, timeout=10).close()
    except Exception as e:                      # webhooks are best-effort, never fatal
        print(f"[worker] webhook to {url} failed: {e}", flush=True)


def run_one(job: dict) -> None:
    """Dispatch a claimed job to its handler and record the outcome."""
    jid, jtype = job["id"], job["type"]
    fn = jobs.HANDLERS.get(jtype)
    if fn is None:
        jobs.fail(jid, f"no handler for job type '{jtype}'", requeue=False)
        return

    def progress(p: float) -> None:
        if jobs.is_cancelled(jid):
            raise _Cancelled()
        jobs.set_progress(jid, p)

    try:
        params = json.loads(job["params_json"] or "{}")
        out = fn({**job, "params": params}, progress)
        # handler may return result dict, or (result, result_ref)
        result, ref = out if isinstance(out, tuple) else (out, None)
        if jobs.is_cancelled(jid):
            return                              # cancelled mid-run — leave it cancelled
        jobs.finish(jid, result=result, result_ref=ref)
        status, payload = "done", {"job_id": jid, "status": "done"}
    except _Cancelled:
        print(f"[worker] {jid} cancelled mid-flight", flush=True)
        return
    except Exception as e:                      # transient — requeue under the cap
        status = jobs.fail(jid, f"{type(e).__name__}: {e}", requeue=True)
        payload = {"job_id": jid, "status": status, "error": str(e)}
        print(f"[worker] {jid} failed ({status}): {e}", flush=True)

    if status in jobs.TERMINAL and job.get("webhook_url"):
        _webhook(job["webhook_url"], payload)


class _Cancelled(Exception):
    pass


def main() -> int:
    signal.signal(signal.SIGTERM, _on_signal)
    signal.signal(signal.SIGINT, _on_signal)
    n = jobs.recover()
    print(f"[worker] up. recovered {n} in-flight job(s). handlers: "
          f"{sorted(jobs.HANDLERS) or '(none yet)'}", flush=True)
    while not _STOP:
        job = jobs.claim()
        if job is None:
            time.sleep(POLL_IDLE_SEC)
            continue
        print(f"[worker] running {job['id']} ({job['type']})", flush=True)
        run_one(job)
    print("[worker] stopped.", flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())
