"""Local media: asset/sound uploads, the library browser, framegrabs, trims,
waveform peaks, the highlights-tree browser, orphan GC."""
import hashlib
import json
import os
import re
import subprocess
import threading
import time
from pathlib import Path

from fastapi import APIRouter, HTTPException, UploadFile

from clipshelf.config import ASSETS, DATA, JOBS, PROJECTS, SOUNDS
from clipshelf.routers.editor_common import (ASSET_KINDS, HIGHLIGHTS, SOUND_EXTS,
                                           clean_folder, lib_load, lib_lock,
                                           lib_note, lib_save, resolve_media,
                                           slugify)

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


@router.post("/api/editor/assets")
async def upload_asset(file: UploadFile):
    raw = await file.read()
    if len(raw) > 2_000_000_000:
        raise HTTPException(413, "too large")
    ext = Path(file.filename or "bin").suffix[:8] or ".bin"
    name = hashlib.sha1(raw).hexdigest()[:20] + ext
    p = ASSETS / name
    if not p.exists():
        p.write_bytes(raw)
    # remember what the user called it — internal captures stay nameless and
    # land in the browser's "unsorted" folder instead
    stem = Path(file.filename or "").stem
    if stem and (file.filename or "") not in ("freeze.jpg", "pad.wav", "voiceover.wav"):
        lib_note("assets", name, name=stem[:80])
    return {"url": f"/api/editor/assets/{name}", "size": len(raw)}


@router.post("/api/editor/framegrab")
def framegrab(body: dict):
    """Exact full-quality frame from the original media, extracted
    server-side at the requested source time."""
    src = body.get("src") or ""
    # input-side -ss picks the frame DISPLAYED at t (same floor semantics as
    # the browser and the render engine) — pass t through untouched
    t = max(0.0, float(body.get("t") or 0))
    p = resolve_media(src)
    tmp = ASSETS / f"grab-{os.getpid()}-{time.time_ns()}.jpg"
    r = subprocess.run(
        ["ffmpeg", "-y", "-ss", f"{t:.3f}", "-i", p, "-frames:v", "1", "-q:v", "2", str(tmp)],
        capture_output=True, timeout=60)
    if r.returncode != 0 or not tmp.exists() or tmp.stat().st_size == 0:
        tmp.unlink(missing_ok=True)
        raise HTTPException(500, "frame extraction failed")
    raw = tmp.read_bytes()
    name = hashlib.sha1(raw).hexdigest()[:20] + ".jpg"
    dest = ASSETS / name
    if dest.exists():
        tmp.unlink(missing_ok=True)
    else:
        tmp.rename(dest)
    return {"url": f"/api/editor/assets/{name}"}


@router.post("/api/editor/trimextract")
def trimextract(body: dict):
    """Cut a source range out into a standalone asset ("trim → new project").
    Re-encoded rather than stream-copied so the cut is frame-exact."""
    src = body.get("src") or ""
    start = max(0.0, float(body.get("start") or 0))
    end = float(body.get("end") or 0)
    if end - start < 0.05:
        raise HTTPException(400, "empty range")
    if end - start > 600:
        raise HTTPException(400, "range too long (10 min max)")
    try:
        p = resolve_media(src)
    except ValueError as e:
        raise HTTPException(400, str(e))
    tmp = ASSETS / f"trim-{os.getpid()}-{time.time_ns()}.mp4"
    # -map a:0 pins the mix track — OBS vods can carry multiple audio tracks
    # and default stream selection would pick by channel count
    r = subprocess.run(
        ["ffmpeg", "-y", "-ss", f"{start:.3f}", "-i", p, "-t", f"{end - start:.3f}",
         "-map", "0:v:0", "-map", "0:a:0?",
         "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-pix_fmt", "yuv420p",
         "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", str(tmp)],
        capture_output=True, timeout=600)
    if r.returncode != 0 or not tmp.exists() or tmp.stat().st_size == 0:
        tmp.unlink(missing_ok=True)
        raise HTTPException(500, "trim extraction failed")
    raw = tmp.read_bytes()
    name = hashlib.sha1(raw).hexdigest()[:20] + ".mp4"
    dest = ASSETS / name
    if dest.exists():
        tmp.unlink(missing_ok=True)
    else:
        tmp.rename(dest)
    lib_note("assets", name, name=(body.get("name") or "trim"), folder="Saved Trims")
    return {"url": f"/api/editor/assets/{name}", "file": name}


# ---------------------------------------------------------------- highlights
# Read-only browser over the saved-clips tree:
#   <game>/<map>_<date>/<n>k_<HH-MM-SS>.mp4          (legacy kill chains)
#   <game>/<map>_<date>/c_<HH-MM-SS>[_name].mp4      (manual timeline cuts)
# plus <game>/reels/.
_HL_CLIP = re.compile(r"^(?:(\d+)k|c)_(\d{2}-\d{2}-\d{2})(?:_([\w-]{1,40}))?\.mp4$")
_HL_NIGHT = re.compile(r"^(.+)_(\d{4}-\d{2}-\d{2})$")
_HL_REEL = re.compile(r"^(\d{4}-\d{2}-\d{2})\.mp4$")


@router.get("/api/editor/highlights")
def highlights_tree():
    games = []
    if HIGHLIGHTS.is_dir():
        for gdir in sorted(p for p in HIGHLIGHTS.iterdir() if p.is_dir()):
            nights, reels = [], []
            for sub in sorted(gdir.iterdir()):
                if not sub.is_dir():
                    continue
                if sub.name == "reels":
                    for f in sorted(sub.iterdir(), reverse=True):
                        m = _HL_REEL.match(f.name)
                        if m and f.is_file():
                            reels.append({
                                "date": m.group(1), "file": f.name,
                                "url": f"/api/editor/highlights/file/{gdir.name}/reels/{f.name}",
                                "size": f.stat().st_size})
                    continue
                nm = _HL_NIGHT.match(sub.name)
                clips = []
                for f in sub.iterdir():
                    if f.name.endswith("_proxy.mp4"):
                        continue  # preview siblings, never content
                    cm = _HL_CLIP.match(f.name)
                    if cm and f.is_file():
                        clips.append({
                            "file": f.name,
                            "url": f"/api/editor/highlights/file/{gdir.name}/{sub.name}/{f.name}",
                            "kills": int(cm.group(1) or 0),
                            "name": (cm.group(3) or "").replace("-", " "),
                            "time": cm.group(2).replace("-", ":"),
                            "size": f.stat().st_size})
                if not clips:
                    continue
                clips.sort(key=lambda c: (-c["kills"], c["time"]))
                nights.append({
                    "dir": sub.name,
                    "map": nm.group(1) if nm else sub.name,
                    "date": nm.group(2) if nm else "",
                    "clips": clips})
            nights.sort(key=lambda n: (n["date"], n["map"]), reverse=True)
            if nights or reels:
                games.append({"game": gdir.name, "nights": nights, "reels": reels})
    return {"games": games}


# ---------------------------------------------------------------- library
@router.get("/api/editor/library")
def library():
    with lib_lock:
        lib = lib_load()
    out = {"assets": [], "sounds": [], "folders": {}}
    for f in ASSETS.iterdir():
        if not f.is_file() or f.suffix.lower() not in ASSET_KINDS:
            continue
        m = lib["assets"].get(f.name, {})
        st = f.stat()
        out["assets"].append({
            "file": f.name, "url": f"/api/editor/assets/{f.name}",
            "kind": ASSET_KINDS[f.suffix.lower()],
            "name": m.get("name") or "", "named": bool(m.get("name")),
            "folder": m.get("folder") or "", "auto_folder": m.get("auto_folder") or "",
            "tags": m.get("tags") or [],
            "size": st.st_size, "ts": int(st.st_mtime)})
    for f in SOUNDS.iterdir():
        if not f.is_file() or f.suffix.lower() not in SOUND_EXTS:
            continue
        m = lib["sounds"].get(f.name, {})
        st = f.stat()
        out["sounds"].append({
            "file": f.name, "url": f"/api/editor/sounds/{f.name}", "kind": "audio",
            "name": m.get("name") or f.stem.replace("_", " "), "named": True,
            "folder": m.get("folder") or "", "tags": m.get("tags") or [],
            "size": st.st_size, "ts": int(st.st_mtime)})
    for k in ("assets", "sounds"):
        implied = {e["folder"] or e.get("auto_folder", "") for e in out[k] if e["folder"] or e.get("auto_folder")}
        out["folders"][k] = sorted(set(lib["folders"][k]) | implied)
        out[k].sort(key=lambda e: -e["ts"])
    return out


@router.post("/api/editor/library/set")
def library_set(body: dict):
    kind = body.get("kind")
    if kind not in ("assets", "sounds"):
        raise HTTPException(400, "kind must be assets|sounds")
    fname = os.path.basename(body.get("file") or "")
    if not ((ASSETS if kind == "assets" else SOUNDS) / fname).exists():
        raise HTTPException(404, "no such file")
    patch = {}
    if "folder" in body:
        patch["folder"] = clean_folder(body["folder"])
    if "name" in body:
        patch["name"] = str(body["name"]).strip()[:80]
    if "tags" in body:
        patch["tags"] = [str(t).strip().lower()[:24]
                         for t in (body["tags"] or []) if str(t).strip()][:12]
    if not patch:
        raise HTTPException(400, "nothing to set")
    with lib_lock:
        lib = lib_load()
        lib[kind].setdefault(fname, {}).update(patch)
        lib_save(lib)
    return {"ok": True}


@router.post("/api/editor/library/folder")
def library_folder(body: dict):
    kind = body.get("kind")
    if kind not in ("assets", "sounds"):
        raise HTTPException(400, "kind must be assets|sounds")
    name = clean_folder(body.get("name"))
    if not name:
        raise HTTPException(400, "bad folder name")
    with lib_lock:
        lib = lib_load()
        if body.get("delete"):
            # deleting a folder just moves its members back to the root
            lib["folders"][kind] = [f for f in lib["folders"][kind] if f != name]
            for ent in lib[kind].values():
                if ent.get("folder") == name:
                    ent["folder"] = ""
        elif name not in lib["folders"][kind]:
            lib["folders"][kind].append(name)
        lib_save(lib)
    return {"ok": True}


@router.delete("/api/editor/assets/{name}")
def delete_asset(name: str):
    p = (ASSETS / name).resolve()
    if str(p).startswith(str(ASSETS.resolve())) and p.exists():
        p.unlink()
        with lib_lock:
            lib = lib_load()
            lib["assets"].pop(p.name, None)
            lib_save(lib)
    return {"ok": True}


# ---------------------------------------------------------------- orphan GC
def _referenced_assets() -> set:
    """Every asset filename mentioned in any saved (or draft) project, OR in a
    past render job's project snapshot — the set garbage collection must
    never touch."""
    refs = set()
    for f in list(PROJECTS.glob("*.json")) + list(JOBS.glob("*.json")):
        try:
            txt = f.read_text(encoding="utf-8")
        except OSError:
            continue
        refs.update(m.group(1) for m in re.finditer(r"/api/editor/assets/([\w.\- %]+)", txt))
    return refs


def _orphaned_assets(min_age_s: float = 0) -> list:
    """Assets nobody's project points at AND the user never explicitly
    organized via the library UI. min_age_s guards against racing an edit
    that hasn't autosaved yet."""
    with lib_lock:
        lib = lib_load()
    refs = _referenced_assets()
    now = time.time()
    out = []
    for f in ASSETS.iterdir():
        if not f.is_file() or f.suffix.lower() not in ASSET_KINDS or f.name in refs:
            continue
        m = lib["assets"].get(f.name, {})
        if m.get("name") or m.get("folder") or m.get("tags"):
            continue
        if now - f.stat().st_mtime < min_age_s:
            continue
        out.append(f)
    return out


@router.get("/api/editor/assets/orphans")
def asset_orphans():
    files = _orphaned_assets()
    return {"files": [{"file": f.name, "size": f.stat().st_size, "ts": int(f.stat().st_mtime)} for f in files],
            "total_size": sum(f.stat().st_size for f in files)}


@router.post("/api/editor/assets/gc")
def asset_gc(body: dict = None):
    min_age_s = float((body or {}).get("min_age_h", 0)) * 3600
    files = _orphaned_assets(min_age_s)
    freed = 0
    with lib_lock:
        lib = lib_load()
        for f in files:
            freed += f.stat().st_size
            f.unlink(missing_ok=True)
            lib["assets"].pop(f.name, None)
        lib_save(lib)
    return {"deleted": len(files), "freed": freed}


def _asset_gc_loop():
    """Daily hygiene sweep — 48h margin so nothing mid-edit gets caught."""
    time.sleep(600)
    while True:
        try:
            files = _orphaned_assets(min_age_s=48 * 3600)
            with lib_lock:
                lib = lib_load()
                for f in files:
                    f.unlink(missing_ok=True)
                    lib["assets"].pop(f.name, None)
                lib_save(lib)
            if files:
                print(f"[{time.strftime('%X')}] [ASSET-GC] removed {len(files)} orphaned assets", flush=True)
        except Exception as e:
            print(f"[{time.strftime('%X')}] [ASSET-GC] error: {e}", flush=True)
        time.sleep(6 * 3600)


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


# ---------------------------------------------------------------- gifs
def gif_bytes_to_webm(raw: bytes, nice_name: str = "") -> str:
    """Convert gif bytes into an alpha-preserving looping VP9 webm asset so
    it composites as a transparent layer. Returns the asset url. Shared by
    the upload path and gif media providers."""
    name = hashlib.sha1(raw).hexdigest()[:20]
    out = ASSETS / f"{name}.webm"
    if not out.exists():
        src = ASSETS / f"{name}.gifsrc"
        src.write_bytes(raw)
        try:
            subprocess.run(["ffmpeg", "-y", "-i", str(src),
                            "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p",
                            "-b:v", "0", "-crf", "32", "-an", str(out)],
                           check=True, capture_output=True, timeout=300)
        except subprocess.CalledProcessError as e:
            raise ValueError("couldn't convert: "
                             + (e.stderr or b"")[-300:].decode(errors="replace"))
        finally:
            src.unlink(missing_ok=True)
    lib_note("assets", f"{name}.webm", name=(nice_name.strip()[:80] or None))
    return f"/api/editor/assets/{name}.webm"


@router.post("/api/editor/gif")
def import_gif(body: dict):
    """Convert an already-uploaded gif asset into the transparent webm form.
    Remote-URL fetching lives in gif media providers, not core."""
    url = (body.get("url") or "").strip()
    if not url.startswith("/api/editor/assets/"):
        raise HTTPException(400, "bad url — upload the gif first, or use a gif provider")
    p = (ASSETS / url.rsplit("/", 1)[1]).resolve()
    if not str(p).startswith(str(ASSETS.resolve())) or not p.exists():
        raise HTTPException(404, "asset not found")
    nice = (body.get("name") or "").strip() or Path(url.split("?")[0]).stem.replace("-", " ")
    try:
        return {"url": gif_bytes_to_webm(p.read_bytes(), nice)}
    except ValueError as e:
        raise HTTPException(422, str(e))


# ---------------------------------------------------------------- waveform peaks
@router.get("/api/editor/peaks")
def peaks(src: str):
    import array
    path = resolve_media(src)
    key = hashlib.sha1(path.encode()).hexdigest()[:20]
    cache = DATA / "peaks" / f"{key}.json"
    if cache.exists():
        return json.loads(cache.read_text(encoding="utf-8"))
    proc = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-ac", "1", "-ar", "8000",
                           "-f", "s16le", "-"], capture_output=True, timeout=600)
    a = array.array("h")
    a.frombytes(proc.stdout[: len(proc.stdout) // 2 * 2])
    n = len(a)
    if n < 100:
        return {"dur": 0, "peaks": []}
    step = max(1, n // 2000)
    pk = []
    for i in range(0, n - step, step):
        m = 0
        for j in range(i, i + step, 16):
            v = abs(a[j])
            if v > m:
                m = v
        pk.append(m)
    mx = max(pk) or 1
    out = {"dur": n / 8000, "peaks": [round(p / mx, 3) for p in pk]}
    cache.parent.mkdir(exist_ok=True)
    cache.write_text(json.dumps(out), encoding="utf-8")
    return out


# ---------------------------------------------------------------- soundboard
@router.get("/api/editor/sounds")
def list_sounds():
    out = []
    for f in sorted(SOUNDS.iterdir(), key=lambda p: -p.stat().st_mtime):
        if f.suffix.lower() in SOUND_EXTS:
            out.append({"name": f.stem.replace("_", " "), "url": f"/api/editor/sounds/{f.name}",
                        "file": f.name})
    return {"sounds": out}


@router.post("/api/editor/sounds")
async def upload_sound(file: UploadFile, name: str = ""):
    raw = await file.read()
    if len(raw) > 50_000_000:
        raise HTTPException(413, "too large")
    ext = Path(file.filename or "s.wav").suffix[:8] or ".wav"
    base = slugify(name or Path(file.filename or "sound").stem) or "sound"
    p = SOUNDS / f"{base}{ext}"
    n = 1
    while p.exists():
        n += 1
        p = SOUNDS / f"{base}-{n}{ext}"
    p.write_bytes(raw)
    return {"name": p.stem.replace("_", " "), "url": f"/api/editor/sounds/{p.name}"}


@router.delete("/api/editor/sounds/{name}")
def delete_sound(name: str):
    p = (SOUNDS / name).resolve()
    if str(p).startswith(str(SOUNDS.resolve())) and p.exists():
        p.unlink()
    return {"ok": True}


# ---------------------------------------------------------------- vod -> soundboard
@router.post("/api/editor/vodsound")
def vod_sound(body: dict):
    """Slice audio out of a VOD (or clip) file straight onto the soundboard."""
    src = (body.get("src") or "").strip()
    start = max(0.0, float(body.get("start", 0)))
    end = float(body.get("end", 0))
    name = (body.get("name") or "vod sound").strip()[:60]
    if not 0.05 <= end - start <= 60:
        raise HTTPException(400, "range must be 0.05–60s")
    path = resolve_media(src)
    base = slugify(name) or "sound"
    p = SOUNDS / f"{base}.mp3"
    n = 1
    while p.exists():
        n += 1
        p = SOUNDS / f"{base}-{n}.mp3"
    subprocess.run(["ffmpeg", "-y", "-ss", f"{start:.3f}", "-t", f"{end - start:.3f}",
                    "-i", path, "-vn", "-ac", "2", "-c:a", "libmp3lame", "-q:a", "3", str(p)],
                   check=True, capture_output=True, timeout=300)
    return {"name": p.stem.replace("_", " "), "url": f"/api/editor/sounds/{p.name}"}
