"""Render queue: lossless stream-copy path + headless-Chrome engine path,
plus publish-to-My-Edits and post-render conversions."""
import hashlib
import json
import os
import queue
import re
import shutil
import signal
import subprocess
import threading
import time
import uuid
from pathlib import Path

from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse

from clipshelf.config import (ASSETS, BASE_URL, DATA, EXPORTS, JOBS, NODE,
                            RENDERS, RENDER_WORKER, ROOT)
from clipshelf.routers.editor_common import (SLUG, clean_folder, lib_note,
                                           resolve_media, slugify)

router = APIRouter(tags=["editor"])

job_q: "queue.Queue[str]" = queue.Queue()
def _spawn_child(cmd, **kw):
    """Start a render child in its own process group, so cancelling can take
    the whole tree down. The node worker launches Chrome; signalling only
    node leaves Chrome alive and holding the pipe."""
    if os.name == "nt":
        kw["creationflags"] = (kw.get("creationflags", 0)
                               | subprocess.CREATE_NEW_PROCESS_GROUP
                               | 0x08000000)          # NO_WINDOW
    else:
        kw["start_new_session"] = True
    return subprocess.Popen(cmd, **kw)


def _signal_tree(pid: int, sig):
    """Signal a child and everything it spawned."""
    if os.name == "nt":
        subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],
                       capture_output=True, timeout=30)
    else:
        os.killpg(os.getpgid(pid), sig)


def _stop_child(proc, grace: float = 6.0):
    """Stop a render child and everything it spawned.

    proc.terminate() signals node only. The Chrome it launched is node's own
    child, survives the signal, and keeps the output pipe open — so
    communicate() went on waiting (its timeout is four hours) and the job sat
    at 'cancelling' forever with the queue wedged behind it. On a fast
    machine node usually died anyway, which is why this only showed up on a
    slow CI runner.

    This SIGNALS ONLY. It never calls wait() or poll(), because the render
    thread owns this Popen and is inside communicate(); two threads reaping
    one child is its own hang. `returncode` is a plain attribute, set by the
    owning thread once the child is gone, so reading it here is safe.
    """
    if proc is None or proc.returncode is not None:
        return
    pid = proc.pid
    try:
        _signal_tree(pid, signal.SIGTERM if os.name != "nt" else None)
    except Exception:
        try:
            proc.terminate()
        except Exception:
            pass
    deadline = time.time() + grace
    while time.time() < deadline:
        if proc.returncode is not None:
            return
        time.sleep(0.2)
    try:                                   # it ignored the polite request
        _signal_tree(pid, signal.SIGKILL if os.name != "nt" else None)
    except Exception:
        pass


# cancellation: a queued job is dropped when dequeued; a running one has its
# ffmpeg/Chrome killed. Long exports are the one place the app can look
# hung, so "stop" has to actually stop.
_cancelled: set = set()
_running = {"jid": None, "proc": None}


@router.get("/api/editor/health")
def health():
    return {"ok": True, "queue": job_q.qsize()}


def job_path(jid: str) -> Path:
    return JOBS / f"{slugify(jid)}.json"


def read_job(jid: str) -> dict:
    p = job_path(jid)
    if not p.exists():
        raise HTTPException(404, "no such job")
    return json.loads(p.read_text(encoding="utf-8"))


TERMINAL = ("done", "error", "cancelled")
_job_write_lock = threading.Lock()


def write_job(jid: str, **patch):
    """Update a job file. Returns the job as it now stands.

    Locked, because this is a read-modify-write and several threads do it:
    the render loop writes progress and the final status, the cancel
    endpoint writes 'cancelling'. Unlocked, cancelling a render that was
    about to finish would land AFTER the loop wrote 'done' and clobber it —
    leaving the job stuck on 'cancelling' forever with the finished mp4
    sitting right there on disk, and the download button never appearing.

    A finished job also never moves backwards: once done/error/cancelled,
    a later 'cancelling' or 'rendering' is dropped.
    """
    p = job_path(jid)
    with _job_write_lock:
        j = json.loads(p.read_text(encoding="utf-8")) if p.exists() else {}
        want = patch.get("status")
        if want and want not in TERMINAL and j.get("status") in TERMINAL:
            patch = {k: v for k, v in patch.items() if k != "status"}
        j.update(patch)
        tmp = p.with_suffix(".json.tmp")
        tmp.write_text(json.dumps(j), encoding="utf-8")
        tmp.replace(p)
        return j


def is_lossless(project: dict) -> bool:
    """Pure cuts of one local source, nothing fancy -> stream copy."""
    clips = project.get("clips", [])
    canvas = project.get("canvas") or [1280, 720]
    if canvas[1] > canvas[0]:
        return False  # vertical canvas re-frames the source — engine path
    vids = [c for c in clips if c.get("type") == "video"]
    others = [c for c in clips if c.get("type") != "video"]
    if others or not vids:
        return False
    srcs = {c.get("src") for c in vids}
    src0 = next(iter(srcs), "")
    if len(srcs) != 1 or not (src0.startswith("/clips/") or src0.startswith("/vod_media/")
                              or src0.startswith("/api/editor/highlights/file/")):
        return False
    for c in vids:
        if c.get("track") != "V1" or c.get("pitch") or c.get("auto") or c.get("kf"):
            return False
        if c.get("punch") or c.get("blend", "source-over") != "source-over":
            return False
        f = c.get("filters", {})
        if any(f.get(k, d) != d for k, d in
               (("bright", 100), ("contrast", 100), ("sat", 100), ("hue", 0), ("blur", 0))):
            return False
        if c.get("opacity", 1) != 1 or c.get("scale", 1) != 1 or c.get("x", 0) or c.get("y", 0):
            return False
        if c.get("volume", 1) != 1:
            return False
    return True


def render_lossless(jid: str, project: dict, out: Path):
    vids = sorted([c for c in project["clips"] if c["type"] == "video"],
                  key=lambda c: c["start"])
    src = resolve_media(vids[0]["src"])
    tmp = RENDERS / f"{jid}-segs"
    tmp.mkdir(exist_ok=True)
    concat_lines = []
    for i, c in enumerate(vids):
        _check_cancel(jid)
        seg = tmp / f"seg{i}.mp4"
        cmd = ["ffmpeg", "-y", "-ss", f"{c['in']:.3f}", "-t", f"{c['dur']:.3f}",
               "-i", src, "-c", "copy", "-avoid_negative_ts", "make_zero", str(seg)]
        subprocess.run(cmd, check=True, capture_output=True, timeout=600)
        concat_lines.append("file '" + str(seg).replace("'", r"'\''") + "'")
    lst = tmp / "list.txt"
    lst.write_text("\n".join(concat_lines), encoding="utf-8")
    subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(lst),
                    "-c", "copy", str(out)], check=True, capture_output=True, timeout=600)
    shutil.rmtree(tmp, ignore_errors=True)


# ---------------------------------------------------------------- browser
# The engine renders in headless Chrome. A stock Windows has Edge, but a
# debloated one may have no Chromium at all — so the app can fetch its own
# into DATA/browsers rather than making exports a dead end.
BROWSERS = DATA / "browsers"
_install_state = {"running": False, "log": "", "ok": None}


def _browser_env() -> dict:
    env = dict(os.environ)
    env["CLIPSHELF_BROWSERS"] = str(BROWSERS)
    from clipshelf import config as _c
    if _c.CFG.get("chrome_path"):
        env["CHROME"] = str(_c.CFG["chrome_path"])
    return env


def find_browser() -> str:
    """Ask the shared JS resolver what it would use, or "" if nothing."""
    r = subprocess.run([NODE, str(ROOT / "render" / "chrome_find.js")],
                       capture_output=True, text=True, timeout=60,
                       env=_browser_env(), encoding="utf-8", errors="replace")
    return r.stdout.strip() if r.returncode == 0 else ""


@router.get("/api/render/browser")
def browser_status():
    try:
        path = find_browser()
    except Exception as e:
        return {"found": False, "error": str(e)[:200], "installing": _install_state["running"]}
    return {"found": bool(path), "path": path,
            "installing": _install_state["running"],
            "last_install": _install_state["log"][-400:] if _install_state["log"] else ""}


def _do_install():
    _install_state.update(running=True, log="", ok=None)
    try:
        BROWSERS.mkdir(parents=True, exist_ok=True)
        r = subprocess.run(
            [NODE, str(ROOT / "node_modules" / "@puppeteer" / "browsers" / "lib" / "cjs" / "main-cli.js"),
             "install", "chrome@stable", "--path", str(BROWSERS)],
            capture_output=True, text=True, timeout=1800, cwd=str(ROOT),
            env=_browser_env(), encoding="utf-8", errors="replace")
        _install_state["log"] = (r.stdout or "") + (r.stderr or "")
        path = find_browser()
        if path:
            from clipshelf import config as _c
            _c.apply({"chrome_path": path})
            _install_state["ok"] = True
        else:
            _install_state["ok"] = False
            from clipshelf import problems
            problems.record("render browser", "download finished but no browser was found",
                            _install_state["log"][-200:])
    except Exception as e:
        _install_state.update(ok=False, log=str(e)[:500])
        from clipshelf import problems
        problems.record("render browser", e, "installing a Chromium for exports failed")
    finally:
        _install_state["running"] = False


@router.post("/api/render/browser/install")
def browser_install():
    """Download a Chromium for rendering (~180 MB) into the app's data dir."""
    if _install_state["running"]:
        return {"status": "already running"}
    if not shutil.which(NODE):
        raise HTTPException(400, "Node.js is required to download a render browser")
    threading.Thread(target=_do_install, daemon=True).start()
    return {"status": "started"}


def _check_cancel(jid: str):
    if jid in _cancelled:
        raise RuntimeError("cancelled")


def render_engine(jid: str, project: dict, out: Path):
    """Headless-Chrome render via render_worker.js (exact preview parity)."""
    jobfile = JOBS / f"{jid}-work.json"
    cw, ch = (project.get("canvas") or [1280, 720])[:2]
    portrait = ch > cw
    media = {}
    for c in project.get("clips", []):
        if c.get("src"):
            media[c["src"]] = resolve_media(c["src"])
    base_job = {
        "id": jid,
        "project": project,
        "media": media,
        "out": str(out),
        "fps": int(project.get("fps", 30)),
        "exact": bool(project.get("exact")),
        "speed": float(project.get("renderSpeed", 0)),
        "width": 1080 if portrait else 1920, "height": 1920 if portrait else 1080,
        "base": BASE_URL,
        "progress": str(job_path(jid)),
    }
    jobfile.write_text(json.dumps(base_job), encoding="utf-8")
    _check_cancel(jid)
    p = _spawn_child([NODE, str(RENDER_WORKER), str(jobfile)],
                     stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
                     env=_browser_env())
    _running["proc"] = p
    # A cancel that arrived between _check_cancel above and this assignment
    # found _running["proc"] still None, signalled nothing, and the render
    # ran to completion before being marked cancelled — a "cancel" that took
    # a full minute to do anything. Close the window from this side.
    if jid in _cancelled:
        _stop_child(p, grace=1.0)
    try:
        so, se = p.communicate(timeout=14400)
    finally:
        _running["proc"] = None
    if jid in _cancelled:
        raise RuntimeError("cancelled")
    if p.returncode != 0 or not out.exists():
        raise RuntimeError((se or so or "render failed")[-2000:])


def render_loop():
    while True:
        jid = job_q.get()
        try:
            if jid in _cancelled:
                _cancelled.discard(jid)
                write_job(jid, status="cancelled", progress=0)
                continue
            job = read_job(jid)
            if job.get("status") == "error":
                continue
            _running["jid"] = jid
            project = job["project"]
            out = RENDERS / f"{jid}.mp4"
            write_job(jid, status="rendering", progress=0.01)
            if is_lossless(project):
                write_job(jid, mode="lossless")
                render_lossless(jid, project, out)
            else:
                write_job(jid, mode="engine")
                render_engine(jid, project, out)
            write_job(jid, status="done", progress=1.0,
                      url=f"/api/editor/renders/{jid}.mp4",
                      size=out.stat().st_size)
        except Exception as e:  # noqa: BLE001
            if jid in _cancelled or "cancelled" in str(e):
                write_job(jid, status="cancelled", progress=0, error=None)
            else:
                from clipshelf import problems
                problems.record("render", e, "export failed — see the job's error")
                write_job(jid, status="error", error=str(e)[-2000:])
        finally:
            _cancelled.discard(jid)
            _running.update(jid=None, proc=None)
            job_q.task_done()


def start():
    threading.Thread(target=render_loop, daemon=True).start()


@router.post("/api/editor/render")
def start_render(project: dict):
    if not project.get("clips"):
        raise HTTPException(400, "empty timeline")
    jid = time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6]
    write_job(jid, id=jid, status="queued", progress=0,
              name=project.get("name", "untitled"), project=project,
              created=time.time())
    job_q.put(jid)
    return {"job": jid}


@router.get("/api/editor/render/{jid}")
def render_status(jid: str):
    try:
        j = read_job(jid)
    except Exception:
        raise HTTPException(404, "no such render job")
    return {k: j.get(k) for k in
            ("id", "status", "progress", "url", "error", "mode", "size", "name")}


@router.post("/api/editor/render/{jid}/cancel")
def cancel_render(jid: str):
    """Stop a queued or running export."""
    jid = slugify(jid)
    if not job_path(jid).exists():
        raise HTTPException(404, "no such render job")
    _cancelled.add(jid)
    # one atomic read-modify-write decides it: reading the status first and
    # writing after is the race this used to lose
    j = write_job(jid, status="cancelling")
    if j.get("status") in TERMINAL:
        _cancelled.discard(jid)
        return {"status": j["status"], "already_finished": True}
    proc = _running.get("proc")
    # `returncode`, not poll(): poll() REAPS, and the render thread is inside
    # communicate() on this same Popen. Reaping it from here is how the job
    # ended up stuck at 'cancelling' with the worker blocked forever.
    if _running.get("jid") == jid and proc and proc.returncode is None:
        # off the request thread: killing a process tree can take a moment
        # and the caller only needs to know we accepted the cancellation
        threading.Thread(target=_stop_child, args=(proc,), daemon=True).start()
    write_job(jid, status="cancelling")
    return {"status": "cancelling"}


@router.get("/api/editor/renders/{name}")
def get_render(name: str):
    p = (RENDERS / name).resolve()
    if not str(p).startswith(str(RENDERS.resolve())) or not p.exists():
        raise HTTPException(404)
    mt = "image/gif" if p.suffix == ".gif" else "video/mp4"
    return FileResponse(p, media_type=mt, filename=name)


@router.post("/api/editor/renders/saveasset")
def save_render_asset(body: dict):
    """File a finished render into the asset library — a reusable piece that
    comes back as a draggable clip in future edits."""
    jid = slugify(body.get("job", ""))
    src = RENDERS / f"{jid}.mp4"
    if not src.exists():
        raise HTTPException(404, "render not found")
    raw = src.read_bytes()
    name = hashlib.sha1(raw).hexdigest()[:20] + ".mp4"
    dest = ASSETS / name
    if not dest.exists():
        shutil.copy2(src, dest)
    lib_note("assets", name, name=(body.get("name") or "").strip()[:80] or None,
             folder=clean_folder(body.get("folder")) or "Renders")
    return {"url": f"/api/editor/assets/{name}", "file": name}


@router.post("/api/editor/togif")
def togif(body: dict):
    """Convert a finished render to a looping gif (15fps, 480px, palette pass)."""
    jid = re.sub(r"[^\w-]", "", str(body.get("job") or ""))
    src = RENDERS / f"{jid}.mp4"
    if not jid or not src.exists():
        raise HTTPException(404, "render not found")
    out = RENDERS / f"{jid}.gif"
    if not out.exists():
        pal = RENDERS / f"{jid}-pal.png"
        vf = "fps=15,scale=480:-1:flags=lanczos"
        r1 = subprocess.run(["ffmpeg", "-y", "-i", str(src), "-vf", f"{vf},palettegen",
                             str(pal)], capture_output=True, timeout=300)
        r2 = subprocess.run(["ffmpeg", "-y", "-i", str(src), "-i", str(pal), "-lavfi",
                             f"{vf}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=4",
                             "-loop", "0", str(out)], capture_output=True, timeout=600)
        pal.unlink(missing_ok=True)
        if r2.returncode != 0 or not out.exists():
            raise HTTPException(500, (r2.stderr or r1.stderr or b"gif failed")[-500:].decode(errors="replace"))
    return {"url": f"/api/editor/renders/{jid}.gif", "size": out.stat().st_size}


@router.post("/api/editor/publish")
def publish(body: dict):
    """Local "publish": file the render into the exports folder, which is
    the editor's My Edits shelf. The returned links are local. This becomes
    a publisher-plugin surface (local export / share service) later."""
    jid = slugify(body.get("job", ""))
    title = (body.get("title") or "untitled cut").strip()[:120]
    src = RENDERS / f"{jid}.mp4"
    if not src.exists():
        raise HTTPException(404, "render not found")
    safe = SLUG.sub("_", title)[:60] or "cut"
    stamp = time.strftime("%Y-%m-%d %H-%M-%S")
    final_name = f"{safe}_CUT {stamp}.mp4"
    dest = EXPORTS / final_name
    shutil.copy2(src, dest)
    thumb = EXPORTS / (dest.stem + "_thumb.jpg")
    subprocess.run(["ffmpeg", "-y", "-ss", "0.5", "-i", str(dest), "-frames:v", "1",
                    "-vf", "scale=480:-1", str(thumb)], capture_output=True, timeout=120)
    url = "/clips/" + final_name
    return {"ok": True, "clip_id": 0, "slug": jid,
            "watch": url, "share": BASE_URL + url.replace(" ", "%20")}
