"""Clipshelf — VOD organizer + CUT editor.

One FastAPI app on localhost serving:
  - GSI ingest (games POST here while you play; events timestamp the VOD)
  - the vod library sync + game detector (background threads)
  - the VOD browser (vods.html) and the CUT editor (editor.html)
"""
import asyncio
import hashlib
import os
import subprocess
import threading
import time
import urllib.parse
from pathlib import Path
from contextlib import asynccontextmanager

import hashlib
import hmac

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from clipshelf import config
from clipshelf import clipmark
from clipshelf import clips_api
from clipshelf import db
from clipshelf import editor_api
from clipshelf import game_detect
from clipshelf import gsi_poll
from clipshelf import lanauth
from clipshelf import obs_client
from clipshelf import plugin_host
from clipshelf import presets
from clipshelf import scanning
from clipshelf import setup_api
from clipshelf import trmnl
from clipshelf import (backup, crashreport, enroll, ffmpeg_setup, gameart, ingest,
                       onboarding, storage, updater)
from clipshelf import selfhost
from clipshelf import share_client
from clipshelf.workers import deadlock_worker, proxy_worker, transcribe_worker
from clipshelf.plugin_host import api as plugin_api
from clipshelf.config import APP_NAME, ASSETS, EXPORTS, FRONTEND, HIGHLIGHTS_DIR, SOUNDS, THUMBS
from clipshelf.gsi_api import router as gsi_router


@asynccontextmanager
async def lifespan(_app):
    """All startup side effects live here — importing the package must stay
    side-effect free (bundlers, tests, tooling)."""
    db.init()
    plugin_host.load_all()
    threading.Thread(target=_obs_loop, daemon=True).start()
    threading.Thread(target=_sync_loop, daemon=True).start()
    editor_api.start_workers()
    game_detect.start()
    gsi_poll.start()      # games that must be asked, not games that push
    scanning.start()
    proxy_worker.start()
    transcribe_worker.start()
    deadlock_worker.start()
    clipmark.start()
    trmnl.start()
    ffmpeg_setup.ensure_on_path()   # a bundled ffmpeg beats a system one
    crashreport.start()
    updater.start()
    yield


app = FastAPI(title=APP_NAME, lifespan=lifespan)


@app.exception_handler(config.ConfigNotWritable)
async def _settings_not_writable(request: Request, exc: config.ConfigNotWritable):
    """Every screen that changes a setting calls config.save(), so this is
    handled once here rather than at eleven call sites. Without it the
    failure arrived as a bare 500 whose body was plain text — which the
    wizard could not even parse to tell you what went wrong."""
    from fastapi.responses import JSONResponse
    return JSONResponse({"detail": str(exc)}, status_code=500)


# Network mode only: requests from anywhere but this machine need the LAN
# code. Localhost is untouched — the person at the keyboard never sees it.
if os.getenv("CLIPSHELF_LAN") == "1":
    app.add_middleware(lanauth.LanAuthMiddleware, secret=config.CFG["secret"])


@app.post("/api/lan/login")
async def lan_login(request: Request):
    from fastapi.responses import JSONResponse
    body = {}
    try:
        body = await request.json()
    except Exception:
        pass
    client = request.client.host if request.client else "?"
    delay = lanauth.login_delay(client)
    if delay:
        # sleeping here rather than refusing outright keeps the honest
        # mistyper's experience unchanged while making a scripted sweep of
        # a million codes take longer than anyone will wait
        await asyncio.sleep(delay)

    code = str(body.get("code") or "")
    if not hmac.compare_digest(code, lanauth.lan_code(config.CFG["secret"])):
        n = lanauth.note_failure(client)
        if n == lanauth.FREE_TRIES + 1:
            from clipshelf import problems
            problems.record("network access",
                            f"repeated wrong LAN codes from {client}",
                            "someone on your network is guessing — turn off "
                            "--lan if you did not expect this")
        raise HTTPException(401, "wrong code")
    lanauth.note_success(client)
    token = hashlib.sha256(f"lancookie:{config.CFG['secret']}".encode()).hexdigest()[:32]
    resp = JSONResponse({"status": "success"})
    resp.set_cookie(lanauth.COOKIE, token, max_age=30 * 86400,
                    httponly=True, samesite="lax")
    return resp


@app.get("/api/lan/code")
def lan_code_for_owner(request: Request):
    """Only the machine itself may read the code (that is the point)."""
    host = request.client.host if request.client else ""
    if host not in ("127.0.0.1", "::1"):
        raise HTTPException(403, "ask the machine running the app")
    return {"code": lanauth.lan_code(config.CFG["secret"]),
            "network_mode": os.getenv("CLIPSHELF_LAN") == "1"}
app.include_router(editor_api.router)
app.include_router(presets.router)
app.include_router(plugin_api.router)
app.include_router(share_client.router)
app.include_router(clipmark.router)
app.include_router(clips_api.router)
app.include_router(setup_api.router)
app.include_router(trmnl.router)
app.include_router(backup.router)
app.include_router(crashreport.router)
app.include_router(enroll.router)
app.include_router(ffmpeg_setup.router)
app.include_router(gameart.router)
app.include_router(onboarding.router)
app.include_router(storage.router)
app.include_router(ingest.router)
app.include_router(updater.router)
app.include_router(selfhost.router)


# ------------------------------------------------------- local clip library
# The editor's Saved_Clips/My_Edits tabs speak the original clip-library
# shape — serve the same shape from the local VOD folder + exports dir.
@app.get("/api/health/ping")
def health_ping():
    """Cheapest possible liveness check — the launcher waits on this."""
    from clipshelf import desktop
    # the frontend needs to know: inside the app window a page-initiated
    # download is silently dropped, so those buttons take another route
    return {"ok": True, "configured": config.configured(),
            "desktop": desktop.is_desktop()}


@app.get("/api/activity")
def activity():
    """What the background workers are doing right now — the answer to
    "is it broken or is it just working?"."""
    import shutil as _sh
    from clipshelf import vod_assets
    from clipshelf.workers import transcribe_worker
    out = {"whisper": transcribe_worker.available()}
    conn = db.connect()
    try:
        out["vods"] = conn.execute("SELECT COUNT(*) n FROM vods").fetchone()["n"]
        tr = {r["status"]: r["n"] for r in conn.execute(
            "SELECT status, COUNT(*) n FROM transcripts GROUP BY status")}
        done = tr.get("done", 0)
        out["transcripts"] = {"done": done, "running": tr.get("running", 0),
                              "error": tr.get("error", 0),
                              "pending": max(0, out["vods"] - done - tr.get("error", 0))}
        out["transcribe_state"] = dict(transcribe_worker.state)
        try:
            out["transcribe_why"] = transcribe_worker.why_pending(conn)
        except Exception:
            out["transcribe_why"] = ""
        out["marks_pending"] = conn.execute(
            "SELECT COUNT(*) n FROM pending_cuts WHERE status='pending'").fetchone()["n"]
        out["ingest_waiting"] = conn.execute(
            "SELECT COUNT(*) n FROM ingest_clips WHERE status='new'").fetchone()["n"]
        rows = conn.execute("SELECT filename FROM vods ORDER BY start_epoch DESC LIMIT 60").fetchall()
    finally:
        conn.close()
    missing = 0
    for r in rows:
        if not os.path.exists(vod_assets.proxy_path(r["filename"])):
            missing += 1
    out["proxies_missing"] = missing
    try:
        du = _sh.disk_usage(config.VOD_DIR or str(config.DATA))
        out["disk"] = {"free_gb": round(du.free / 1e9, 1), "total_gb": round(du.total / 1e9, 1)}
    except Exception:
        out["disk"] = None
    out["renders_queued"] = editor_api.editor_render.job_q.qsize()
    return out


@app.get("/api/problems")
def problems_list():
    from clipshelf import problems
    return {"problems": problems.items()}


@app.delete("/api/problems")
def problems_clear():
    from clipshelf import problems
    problems.clear()
    return {"ok": True}


@app.get("/api/diagnostics")
def diagnostics():
    """One blob to paste when asking for help — versions, what's wired up,
    what's pending, and the recent problems. No paths beyond the ones the
    user already sees, no keys."""
    import platform
    import sys as _sys
    from clipshelf import __version__, problems
    from clipshelf.plugin_host import loader
    st = status_snapshot = None
    try:
        from clipshelf.gsi_api import status as _status
        status_snapshot = _status()
    except Exception:
        pass
    st = dict(status_snapshot or {})
    st.pop("vod_dir", None)
    return {
        "version": __version__,
        "python": _sys.version.split()[0],
        "platform": f"{platform.system()} {platform.release()}",
        "status": st,
        "activity": activity(),
        "plugins": [{k: p.get(k) for k in ("id", "version", "status", "provides", "error")}
                    for p in loader.PLUGINS.values()],
        "problems": problems.items(),
    }


@app.get("/api/games/tags")
def games_tags():
    return {"tags": ["VODs"]}


def _thumb_url(src: str) -> str:
    return "/api/thumb?src=" + urllib.parse.quote(src)


@app.get("/api/thumb")
def thumb(src: str):
    """Lazy ffmpeg thumbnail for a local media url, cached on disk."""
    from clipshelf.editor_api import resolve_media
    try:
        path = resolve_media(src)
    except ValueError as e:
        raise HTTPException(404, str(e))
    key = hashlib.sha1(path.encode()).hexdigest()[:20]
    out = THUMBS / f"{key}.jpg"
    if not out.exists():
        r = subprocess.run(
            ["ffmpeg", "-y", "-ss", "3", "-i", path, "-frames:v", "1",
             "-vf", "scale=480:-1", str(out)], capture_output=True, timeout=60)
        if r.returncode != 0 or not out.exists():
            # very short clips: retry from the first frame
            subprocess.run(["ffmpeg", "-y", "-i", path, "-frames:v", "1",
                            "-vf", "scale=480:-1", str(out)], capture_output=True, timeout=60)
    if not out.exists():
        raise HTTPException(500, "thumbnail failed")
    return FileResponse(out, media_type="image/jpeg")


@app.get("/api/clips")
def clips(limit: int = 60, type: str = "all", game: str = "", search: str = ""):
    q = search.lower().strip()
    out = []

    def vods():
        if not config.VOD_DIR or not os.path.isdir(config.VOD_DIR):
            return
        files = [f for f in os.listdir(config.VOD_DIR)
                 if f.lower().endswith(".mp4") and not f.endswith("_proxy.mp4")]
        files.sort(key=lambda f: os.path.getmtime(os.path.join(config.VOD_DIR, f)), reverse=True)
        for f in files:
            if q and q not in f.lower():
                continue
            src = "/vod_media/" + f
            out.append({"video_url": src, "thumbnail": _thumb_url(src),
                        "title": f.rsplit(".", 1)[0], "game": "VOD"})

    def edits():
        files = [f for f in os.listdir(EXPORTS)
                 if f.lower().endswith(".mp4")]
        files.sort(key=lambda f: os.path.getmtime(os.path.join(EXPORTS, f)), reverse=True)
        for f in files:
            if q and q not in f.lower():
                continue
            stem = f[:-4]
            th = EXPORTS / f"{stem}_thumb.jpg"
            src = "/clips/" + f
            out.append({"video_url": src,
                        "thumbnail": ("/clips/" + th.name) if th.exists() else _thumb_url(src),
                        "title": stem.split("_CUT ")[0].replace("_", " "), "game": "edit"})

    if type == "edit":
        edits()
    elif type == "clip":
        vods()
    else:
        edits()
        vods()
    return {"clips": out[:max(1, min(200, limit))]}


# ------------------------------------------------------- background workers
def _obs_loop():
    while True:
        try:
            obs_client.check_and_reconnect()
        except Exception:
            pass
        time.sleep(15)


def _sync_loop():
    while True:
        try:
            db.sync_vod_library()
        except Exception as e:
            print(f"[sync] {e}", flush=True)
        time.sleep(60)


# gsi_router last among API routes: it ends in the POST /api/gsi/{game}
# catch-all, which must not shadow anything registered after it
app.include_router(gsi_router)

# ------------------------------------------------------- static mounts
app.mount("/api/editor/assets", StaticFiles(directory=str(ASSETS)), name="assets")
app.mount("/api/editor/sounds", StaticFiles(directory=str(SOUNDS)), name="sounds")
app.mount("/api/editor/highlights/file", StaticFiles(directory=str(HIGHLIGHTS_DIR)), name="highlights")
app.mount("/clips", StaticFiles(directory=str(EXPORTS)), name="clips")
# /vod_media is a ROUTE, not a mount: the recordings folder can be unset at
# first boot and changed later in settings — a StaticFiles mount would bind
# the path at import time. FileResponse handles Range, which <video> needs.
@app.get("/vod_media/{path:path}")
def vod_media(path: str):
    from urllib.parse import unquote
    root = config.VOD_DIR
    if not root:
        raise HTTPException(404, "no recordings folder configured")
    p = (Path(root) / unquote(path)).resolve()
    if not str(p).startswith(str(Path(root).resolve())) or not p.is_file():
        raise HTTPException(404)
    return FileResponse(p)
# /editor/editor.html is the URL the render worker loads; / serves the UI
app.mount("/editor", StaticFiles(directory=str(FRONTEND), html=True), name="editor")
app.mount("/", StaticFiles(directory=str(FRONTEND), html=True), name="frontend")
