#!/usr/bin/env python3
"""Holodeck Asset Library — a self-hostable asset library server.

Serves a folder of 3D assets over the same /api/v1 shape Holodeck uses, so a
Holodeck Playground (PLAYGROUND_ASSET_API_BASE), the Unity client or any MCP
tool can search and place them.

    python3 holodeck_library.py serve --root ./my-assets --port 8787 [--token SECRET]
    python3 holodeck_library.py index --root ./my-assets

Python 3.10+ standard library only.

Folder conventions:
    <root>/<Pack>/...           top-level folders are packs
    <root>/<Pack>/pack.json     optional: {"atlas": "Textures/Atlas.png", "unit_scale": 0.01, "tags": [...]}
    model.glb + model.png       sidecar thumbnail (or <Pack>/Thumbnails/model.png)
    model.fbx + model.fbx.meta  Unity .meta sidecar → unity_guid
"""
from __future__ import annotations

import argparse
import hashlib
import hmac
import json
import math
import mimetypes
import os
import re
import struct
import sys
import threading
import time
from datetime import datetime, timezone
from email.utils import formatdate, parsedate_to_datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, quote, unquote, urlsplit

VERSION = "1.0.0"
INDEX_FILE = ".holodeck-index.json"
INDEX_VERSION = 2
MODEL_EXT = {".glb", ".gltf", ".fbx", ".obj"}
TEXTURE_EXT = {".png", ".jpg", ".jpeg", ".tga"}
CONTENT_TYPES = {
    ".glb": "model/gltf-binary", ".gltf": "model/gltf+json", ".fbx": "application/octet-stream",
    ".obj": "text/plain", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
    ".tga": "image/x-tga", ".bin": "application/octet-stream", ".json": "application/json",
}
STOP_TOKENS = {"sm", "sk", "fx", "the", "and", "of", "glb", "gltf", "fbx", "obj", "png", "jpg", "jpeg", "tga", "models", "model", "prefabs"}


def slug(s: str) -> str:
    return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "library"


def asset_id(rel: str) -> str:
    return hashlib.sha1(rel.encode()).hexdigest()[:10]


def tokens_of(text: str) -> list[str]:
    """SM_Env_TreePine_01 → env, tree, pine."""
    text = re.sub(r"([a-z])([A-Z])", r"\1 \2", text)
    out = []
    for t in re.split(r"[^A-Za-z0-9]+", text):
        t = t.lower()
        if len(t) < 3 or t.isdigit() or t in STOP_TOKENS:
            continue
        out.append(t)
    return out


def sha256_file(path: Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


# ── geometry facts ──────────────────────────────────────────────

def _mat_mul(a, b):
    return [[sum(a[i][k] * b[k][j] for k in range(4)) for j in range(4)] for i in range(4)]


def _node_matrix(node):
    if "matrix" in node:
        m = node["matrix"]  # column-major
        return [[m[c * 4 + r] for c in range(4)] for r in range(4)]
    t = node.get("translation", [0, 0, 0])
    x, y, z, w = node.get("rotation", [0, 0, 0, 1])
    s = node.get("scale", [1, 1, 1])
    r = [
        [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
        [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
        [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
    ]
    return [[r[i][0] * s[0], r[i][1] * s[1], r[i][2] * s[2], t[i]] for i in range(3)] + [[0, 0, 0, 1]]


def gltf_facts(doc: dict) -> dict:
    """Bounding box (file units, node transforms applied), triangles, textures."""
    nodes = doc.get("nodes", [])
    meshes = doc.get("meshes", [])
    accessors = doc.get("accessors", [])
    lo = [math.inf] * 3
    hi = [-math.inf] * 3
    tris = 0
    identity = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]

    def visit(idx, parent, depth=0):
        nonlocal tris
        if depth > 64 or idx >= len(nodes):
            return
        node = nodes[idx]
        m = _mat_mul(parent, _node_matrix(node))
        if "mesh" in node and node["mesh"] < len(meshes):
            for prim in meshes[node["mesh"]].get("primitives", []):
                pos = prim.get("attributes", {}).get("POSITION")
                if pos is None or pos >= len(accessors):
                    continue
                acc = accessors[pos]
                if "min" in acc and "max" in acc:
                    mn, mx = acc["min"], acc["max"]
                    for cx in (mn[0], mx[0]):
                        for cy in (mn[1], mx[1]):
                            for cz in (mn[2], mx[2]):
                                p = [m[i][0] * cx + m[i][1] * cy + m[i][2] * cz + m[i][3] for i in range(3)]
                                for i in range(3):
                                    lo[i] = min(lo[i], p[i])
                                    hi[i] = max(hi[i], p[i])
                if prim.get("mode", 4) == 4:
                    ind = prim.get("indices")
                    count = accessors[ind]["count"] if ind is not None and ind < len(accessors) else acc.get("count", 0)
                    tris += count // 3
        for c in node.get("children", []):
            visit(c, m, depth + 1)

    scenes = doc.get("scenes") or []
    scene = scenes[doc.get("scene", 0)] if scenes else None
    roots = scene.get("nodes", []) if scene else [i for i in range(len(nodes))]
    for r in roots:
        visit(r, identity)
    size = None
    if lo[0] != math.inf:
        size = {"x": round(hi[0] - lo[0], 4), "y": round(hi[1] - lo[1], 4), "z": round(hi[2] - lo[2], 4)}
    return {"size": size, "triangles": tris or None, "has_textures": bool(doc.get("images"))}


def glb_facts(path: Path) -> dict:
    with open(path, "rb") as f:
        head = f.read(20)
        if len(head) < 20 or head[:4] != b"glTF":
            return {}
        length, ctype = struct.unpack("<II", head[12:20])
        if ctype != 0x4E4F534A:
            return {}
        doc = json.loads(f.read(length))
    return gltf_facts(doc)


def gltf_json_facts(path: Path) -> dict:
    with open(path, "rb") as f:
        return gltf_facts(json.load(f))


def obj_facts(path: Path) -> dict:
    lo = [math.inf] * 3
    hi = [-math.inf] * 3
    faces = 0
    with open(path, "r", errors="ignore") as f:
        for line in f:
            if line.startswith("v "):
                parts = line.split()
                try:
                    p = [float(parts[1]), float(parts[2]), float(parts[3])]
                except (IndexError, ValueError):
                    continue
                for i in range(3):
                    lo[i] = min(lo[i], p[i])
                    hi[i] = max(hi[i], p[i])
            elif line.startswith("f "):
                faces += max(1, len(line.split()) - 3)
    size = None
    if lo[0] != math.inf:
        size = {"x": round(hi[0] - lo[0], 4), "y": round(hi[1] - lo[1], 4), "z": round(hi[2] - lo[2], 4)}
    return {"size": size, "triangles": faces or None, "has_textures": False}


def unity_guid(path: Path) -> str | None:
    meta = path.with_name(path.name + ".meta")
    if not meta.is_file():
        return None
    try:
        m = re.search(r"^guid:\s*([0-9a-f]{32})", meta.read_text(errors="ignore"), re.M)
        return m.group(1) if m else None
    except OSError:
        return None


# ── index ───────────────────────────────────────────────────────

class Library:
    """Scans the root folder into an in-memory index, cached on disk."""

    def __init__(self, root: Path, name: str | None = None):
        self.root = root.resolve()
        self.name = name or self.root.name
        self.scope = "@" + slug(self.name)
        self.assets: dict[str, dict] = {}
        self.by_sha: dict[str, str] = {}
        self.lock = threading.Lock()
        self.signature = None

    def _walk(self):
        for dirpath, dirnames, filenames in os.walk(self.root):
            dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
            for fn in sorted(filenames):
                if fn.startswith("."):
                    continue
                p = Path(dirpath) / fn
                ext = p.suffix.lower()
                if ext in MODEL_EXT or ext in TEXTURE_EXT or fn == "pack.json":
                    yield p

    def _signature(self):
        h = hashlib.sha1()
        for p in self._walk():
            try:
                st = p.stat()
            except OSError:
                continue
            h.update(f"{p}|{st.st_size}|{int(st.st_mtime)}\n".encode())
        return h.hexdigest()

    def refresh(self, force: bool = False) -> bool:
        """Re-index when files changed. Returns True when it rebuilt."""
        sig = self._signature()
        if not force and sig == self.signature:
            return False
        cache_path = self.root / INDEX_FILE
        cached = {}
        if cache_path.is_file():
            try:
                data = json.loads(cache_path.read_text())
                if data.get("version") == INDEX_VERSION:
                    cached = {a["_rel"]: a for a in data.get("assets", [])}
            except (OSError, ValueError):
                cached = {}
        assets = self._build(cached)
        with self.lock:
            self.assets = {a["id"]: a for a in assets}
            self.by_sha = {a["content_sha256"]: a["id"] for a in assets}
            self.signature = sig
        try:
            cache_path.write_text(json.dumps({"version": INDEX_VERSION, "name": self.name, "assets": assets}))
        except OSError:
            pass
        return True

    def _pack_config(self, pack_dir: Path) -> dict:
        cfg = pack_dir / "pack.json"
        if cfg.is_file():
            try:
                return json.loads(cfg.read_text())
            except ValueError:
                print(f"warning: bad JSON in {cfg}", file=sys.stderr)
        return {}

    def _build(self, cached: dict) -> list[dict]:
        packs: dict[str, dict] = {}
        out = []
        files = [p for p in self._walk() if p.name != "pack.json"]
        stems = {}
        for p in files:
            if p.suffix.lower() in TEXTURE_EXT:
                stems.setdefault((p.parent, p.stem.lower()), p)
        for p in files:
            rel = p.relative_to(self.root).as_posix()
            parts = rel.split("/")
            pack = parts[0] if len(parts) > 1 else "_root"
            if pack not in packs:
                packs[pack] = self._pack_config(self.root / pack) if pack != "_root" else {}
            cfg = packs[pack]
            st = p.stat()
            prev = cached.get(rel)
            if prev and prev.get("_size") == st.st_size and prev.get("_mtime") == int(st.st_mtime) and prev.get("_cfg") == cfg:
                out.append(prev)
                continue
            ext = p.suffix.lower()
            kind = "model" if ext in MODEL_EXT else "texture"
            facts = {}
            try:
                if ext == ".glb":
                    facts = glb_facts(p)
                elif ext == ".gltf":
                    facts = gltf_json_facts(p)
                elif ext == ".obj":
                    facts = obj_facts(p)
            except Exception as e:  # a broken file shouldn't stop the index
                print(f"warning: {rel}: {e}", file=sys.stderr)
            unit = float(cfg.get("unit_scale", 1.0) or 1.0)
            size = facts.get("size")
            size_m = {k: round(v * unit, 3) for k, v in size.items()} if size else None
            thumb = None
            if kind == "model":
                t = stems.get((p.parent, p.stem.lower())) or stems.get((self.root / pack / "Thumbnails", p.stem.lower()))
                if t:
                    thumb = t.relative_to(self.root).as_posix()
            name = p.stem
            tag_src = [pack] + parts[1:-1] + [name]
            tags = sorted({t for s in tag_src for t in tokens_of(s)} | {slug(pack), self.scope[1:]} | set(cfg.get("tags", [])))
            out.append({
                "_rel": rel, "_size": st.st_size, "_mtime": int(st.st_mtime), "_cfg": cfg,
                "id": asset_id(rel), "name": name, "kind": kind, "pack": pack, "ext": ext,
                "path": f"{self.scope}/{rel}", "tags": tags, "unity_guid": unity_guid(p),
                "content_sha256": sha256_file(p), "byte_size": st.st_size,
                "size": size, "size_m": size_m, "triangles": facts.get("triangles"),
                "has_textures": facts.get("has_textures", False), "unit_scale": unit,
                "thumbnail": thumb, "atlas": cfg.get("atlas"),
                "created_at": datetime.fromtimestamp(st.st_ctime, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
                "updated_at": datetime.fromtimestamp(st.st_mtime, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
            })
        # Sidecar thumbnails are thumbnails, not textures to browse.
        thumbs = {a["thumbnail"] for a in out if a.get("thumbnail")}
        return [a for a in out if a["_rel"] not in thumbs]

    # ── queries ─────────────────────────────────────────────────
    def search(self, q="", kind=None, under=None, packs=None, sha256=None):
        with self.lock:
            items = list(self.assets.values())
        if sha256:
            aid = self.by_sha.get(sha256.lower())
            return [self.assets[aid]] if aid in self.assets else []
        words, tags = [], []
        prefixes = [under] if under else []
        for tok in re.findall(r'"[^"]+"|\S+', q or ""):
            tok = tok.strip('"')
            low = tok.lower()
            if tok.startswith("@"):
                prefixes.append(tok.rstrip("/"))
            elif low.startswith("kind:"):
                kind = kind or low[5:]
            elif low.startswith("#") or low.startswith("tag:"):
                tags.append(low.split(":", 1)[-1].lstrip("#"))
            elif low:
                words.append(low)
        if kind:
            items = [a for a in items if a["kind"] == kind]
        for pre in prefixes:
            pl = pre.lower()
            items = [a for a in items if a["path"].lower() == pl or a["path"].lower().startswith(pl + "/")]
        if packs:
            pk = [p.lower().rstrip("/") for p in packs]
            items = [a for a in items if any(a["path"].lower().startswith(p + "/") for p in pk)]
        for t in tags:
            items = [a for a in items if t in a["tags"]]
        ranked = []
        for a in items:
            name = a["name"].lower()
            hay = f"{name} {a['path'].lower()} {' '.join(a['tags'])}"
            if not all(w in hay for w in words):
                continue
            joined = " ".join(words)
            rank = 0 if words and name == joined else 1 if words and name.startswith(words[0]) else 2 if words and words[0] in name else 3
            ranked.append((rank, a["kind"] != "model", name, a["path"], a))
        ranked.sort(key=lambda r: r[:4])
        return [r[4] for r in ranked]

    def packs(self):
        with self.lock:
            items = list(self.assets.values())
        out: dict[str, dict] = {}
        for a in items:
            key = f"{self.scope}/{a['pack']}"
            p = out.setdefault(key, {"key": key, "name": a["pack"], "scope": self.scope, "counts": {}, "total": 0, "byte_size": 0})
            p["counts"][a["kind"]] = p["counts"].get(a["kind"], 0) + 1
            p["total"] += 1
            p["byte_size"] += a["byte_size"]
        return sorted(out.values(), key=lambda p: -p["total"])

    def atlas_texture(self, a) -> str | None:
        """Pack atlas (relative path) for a model that brings no textures."""
        if a["kind"] != "model" or a.get("has_textures") or not a.get("atlas"):
            return None
        rel = f"{a['pack']}/{a['atlas']}".replace("\\", "/")
        return rel if (self.root / rel).is_file() else None


# ── JSON shapes (mirror Holodeck's Asset#as_api_json / viewer_payload) ──

def file_url(base: str, rel: str) -> str:
    return f"{base}/files/{quote(rel)}"


def api_json(lib: Library, a: dict, base: str) -> dict:
    url = file_url(base, a["_rel"])
    is_gltf = a["ext"] in (".glb", ".gltf")
    thumb = file_url(base, a["thumbnail"]) if a.get("thumbnail") else (url if a["kind"] == "texture" else None)
    meta = {
        "has_geometry": a["kind"] == "model", "has_animation": False, "has_textures": a.get("has_textures", False),
        "glb_url": url if is_gltf else None, "unit_scale": a["unit_scale"],
        "stats": {"size": a["size"], "size_m": a["size_m"], "triangles": a["triangles"], "unit_scale": a["unit_scale"]},
    }
    return {
        "id": a["id"], "name": a["name"], "display_name": f"{a['name']} — {a['pack']}", "description": a["_rel"],
        "kind": a["kind"], "tags": a["tags"], "path": a["path"], "unity_guid": a.get("unity_guid"), "meta": meta,
        "content_sha256": a["content_sha256"], "filename": Path(a["_rel"]).name,
        "content_type": CONTENT_TYPES.get(a["ext"], "application/octet-stream"), "byte_size": a["byte_size"],
        "storage_state": "uploaded", "url": url, "preview_url": thumb, "thumbnail_url": thumb, "loop_url": None,
        "variant_thumbnails": [], "hidden": False, "twin_id": None,
        "created_at": a["created_at"], "updated_at": a["updated_at"],
    }


def detail_json(lib: Library, a: dict, base: str, viewer: bool) -> dict:
    body = api_json(lib, a, base)
    atlas = lib.atlas_texture(a)
    material = None
    if atlas:
        material = {"id": asset_id(f"{a['pack']}/{a['atlas']}"), "name": Path(atlas).stem, "texture_url": file_url(base, atlas)}
    materials = [material] if material else []
    body.update(materials=materials, material=material, variants=[], has_geometry=a["kind"] == "model",
                has_animation=False, used_by=[])
    if viewer:
        body["viewer"] = {
            "id": a["id"], "name": a["name"], "path": a["path"], "kind": a["kind"],
            "model_url": body["url"] if a["kind"] == "model" else None,
            "format": a["ext"].lstrip("."), "has_geometry": a["kind"] == "model", "has_animation": False,
            "material": dict(material, guessed=False) if material else None, "materials": materials, "variants": [],
            "triangles": a["triangles"], "thumbnail_url": body["thumbnail_url"], "unit_scale": a["unit_scale"],
            "unit_assumed": False, "glb_url": body["meta"]["glb_url"],
            "texture_url": body["url"] if a["kind"] == "texture" else None, "mannequin": None,
        }
    return body


INDEX_HTML = """<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{name} · Holodeck Asset Library</title>
<style>body{{font:15px system-ui,sans-serif;margin:0;background:#0b0d12;color:#e9e6df}}header{{padding:16px 24px;border-bottom:1px solid #1c2030}}
h1{{margin:0;font-size:20px;color:#ffc785}}main{{padding:16px 24px}}input{{width:min(480px,100%);padding:8px 12px;border-radius:6px;border:1px solid #2a2e40;background:#0f1119;color:inherit}}
.packs a{{display:inline-block;margin:4px 6px 4px 0;padding:3px 10px;border-radius:12px;background:#1a1d29;color:#8fc4ff;text-decoration:none;font-size:13px}}
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:14px}}.card{{background:#0f1119;border:1px solid #1c2030;border-radius:8px;padding:6px;font-size:12px;overflow:hidden}}
.card img{{width:100%;aspect-ratio:1;object-fit:contain;background:#151823;border-radius:5px}}.card .k{{color:#8b8f9c}}code{{color:#ffc785}}</style></head>
<body><header><h1>{name}</h1><div>Holodeck asset library · scope <code>{scope}</code> · {count} assets · API at <code>/api/v1/assets</code></div></header>
<main><div class="packs">{packs}</div><input id="q" placeholder="Search assets…" autofocus><div class="grid" id="grid"></div></main>
<script>
const token = new URLSearchParams(location.search).get("token") || localStorage.getItem("hl-token") || "";
if (token) localStorage.setItem("hl-token", token);
const grid = document.getElementById("grid"), q = document.getElementById("q");
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}})[c]);
async function run() {{
  const r = await fetch("/api/v1/assets?per=60&q=" + encodeURIComponent(q.value), {{ headers: token ? {{ Authorization: "Bearer " + token }} : {{}} }});
  if (!r.ok) {{ grid.textContent = r.status === 401 ? "This library needs a token: add ?token=… to the URL." : "Error " + r.status; return }}
  const d = await r.json();
  grid.innerHTML = d.assets.map(a => `<a class="card" href="${{esc(a.url)}}"><img loading="lazy" src="${{esc(a.thumbnail_url || "")}}" alt="">
    <div>${{esc(a.name)}}</div><div class="k">${{a.kind}}${{a.meta.stats.size_m ? " · " + a.meta.stats.size_m.x + "×" + a.meta.stats.size_m.y + "×" + a.meta.stats.size_m.z + " m" : ""}}</div></a>`).join("") || "Nothing found.";
}}
let t; q.addEventListener("input", () => {{ clearTimeout(t); t = setTimeout(run, 200) }}); run();
</script></body></html>"""


# ── HTTP ────────────────────────────────────────────────────────

class Handler(BaseHTTPRequestHandler):
    server_version = f"HolodeckLibrary/{VERSION}"
    lib: Library
    token: str | None = None
    private_files = False
    base_url: str | None = None
    reindex_interval = 30.0
    _last_check = 0.0

    def log_message(self, fmt, *args):
        if not getattr(self.server, "quiet", False):
            sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))

    # helpers
    def _base(self) -> str:
        if self.base_url:
            return self.base_url.rstrip("/")
        proto = self.headers.get("X-Forwarded-Proto", "http")
        host = self.headers.get("X-Forwarded-Host") or self.headers.get("Host") or f"localhost:{self.server.server_port}"
        return f"{proto}://{host}"

    def _cors(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, Range")
        self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
        self.send_header("Access-Control-Expose-Headers", "Content-Length, Content-Range, ETag")

    def _json(self, status, body):
        data = json.dumps(body).encode()
        self.send_response(status)
        self._cors()
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(data)

    def _authorized(self, query) -> bool:
        if not self.token:
            return True
        header = self.headers.get("Authorization", "")
        given = header[7:].strip() if header.lower().startswith("bearer ") else ""
        if not given:
            given = (query.get("token") or [""])[0]
        return bool(given) and hmac.compare_digest(given.encode(), self.token.encode())

    def _maybe_reindex(self):
        now = time.monotonic()
        if now - Handler._last_check >= self.reindex_interval:
            Handler._last_check = now
            try:
                if self.lib.refresh():
                    sys.stderr.write(f"reindexed: {len(self.lib.assets)} assets\n")
            except Exception as e:
                sys.stderr.write(f"reindex failed: {e}\n")

    # verbs
    def do_OPTIONS(self):
        self.send_response(204)
        self._cors()
        self.send_header("Access-Control-Max-Age", "86400")
        self.end_headers()

    def do_HEAD(self):
        self.do_GET()

    def do_GET(self):
        url = urlsplit(self.path)
        path = unquote(url.path)
        query = parse_qs(url.query)
        try:
            if path == "/healthz":
                return self._json(200, {"ok": True, "assets": len(self.lib.assets), "version": VERSION})
            if path.startswith("/files/"):
                if self.private_files and not self._authorized(query):
                    return self._json(401, {"error": "token required"})
                return self._file(path[len("/files/"):])
            if path.startswith("/api/v1/"):
                if not self._authorized(query):
                    return self._json(401, {"error": "Missing or bad Authorization: Bearer token"})
                self._maybe_reindex()
                return self._api(path, query)
            if path in ("/", "/index.html"):
                return self._index()
            return self._json(404, {"error": "not found"})
        except BrokenPipeError:
            pass
        except Exception as e:  # never kill the server thread
            sys.stderr.write(f"error on {self.path}: {e!r}\n")
            try:
                self._json(500, {"error": "internal error"})
            except Exception:
                pass

    def _api(self, path, query):
        one = lambda k, d=None: (query.get(k) or [d])[0]
        base = self._base()
        if path == "/api/v1/me":
            return self._json(200, {"name": self.lib.name, "scope": self.lib.scope, "kind": "holodeck-asset-library", "version": VERSION})
        if path == "/api/v1/packs":
            return self._json(200, {"packs": self.lib.packs()})
        if path == "/api/v1/assets":
            kind = one("kind")
            if kind == "prefab":  # no prefabs in a plain folder library
                results = []
            else:
                packs = query.get("pack[]") or query.get("pack") or []
                results = self.lib.search(one("q", ""), kind=kind, under=one("under"), packs=packs, sha256=one("sha256"))
            try:
                per = max(1, min(int(one("per", 50)), 200))
                page = max(1, int(one("page", 1)))
            except ValueError:
                return self._json(400, {"error": "per and page must be integers"})
            total = len(results)
            chunk = results[(page - 1) * per: page * per]
            return self._json(200, {
                "assets": [api_json(self.lib, a, base) for a in chunk],
                "meta": {"current_page": page, "total_pages": max(1, math.ceil(total / per)), "total_count": total, "per_page": per},
            })
        m = re.fullmatch(r"/api/v1/assets/([a-zA-Z0-9]{4,20})", path)
        if m:
            a = self.lib.assets.get(m.group(1))
            if not a:
                return self._json(404, {"error": "not found"})
            return self._json(200, detail_json(self.lib, a, base, viewer=bool(one("viewer"))))
        return self._json(404, {"error": "not found"})

    def _file(self, rel):
        target = (self.lib.root / rel).resolve()
        if self.lib.root not in target.parents or not target.is_file() or target.name.startswith("."):
            return self._json(404, {"error": "not found"})
        st = target.stat()
        etag = f'"{st.st_size:x}-{int(st.st_mtime):x}"'
        last_mod = formatdate(st.st_mtime, usegmt=True)
        if self.headers.get("If-None-Match") == etag:
            self.send_response(304)
            self._cors()
            self.send_header("ETag", etag)
            self.end_headers()
            return
        ims = self.headers.get("If-Modified-Since")
        if ims:
            try:
                if int(st.st_mtime) <= parsedate_to_datetime(ims).timestamp():
                    self.send_response(304)
                    self._cors()
                    self.end_headers()
                    return
            except (TypeError, ValueError):
                pass
        ctype = CONTENT_TYPES.get(target.suffix.lower()) or mimetypes.guess_type(target.name)[0] or "application/octet-stream"
        start, end, status = 0, st.st_size - 1, 200
        rng = self.headers.get("Range")
        if rng:
            mm = re.fullmatch(r"bytes=(\d*)-(\d*)", rng.strip())
            if mm and (mm.group(1) or mm.group(2)):
                if mm.group(1):
                    start = int(mm.group(1))
                    end = min(int(mm.group(2)), st.st_size - 1) if mm.group(2) else st.st_size - 1
                else:
                    start = max(0, st.st_size - int(mm.group(2)))
                if start > end:
                    self.send_response(416)
                    self._cors()
                    self.send_header("Content-Range", f"bytes */{st.st_size}")
                    self.end_headers()
                    return
                status = 206
        self.send_response(status)
        self._cors()
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(end - start + 1))
        self.send_header("Accept-Ranges", "bytes")
        self.send_header("ETag", etag)
        self.send_header("Last-Modified", last_mod)
        self.send_header("Cache-Control", "public, max-age=3600")
        if status == 206:
            self.send_header("Content-Range", f"bytes {start}-{end}/{st.st_size}")
        self.end_headers()
        if self.command == "HEAD":
            return
        with open(target, "rb") as f:
            f.seek(start)
            left = end - start + 1
            while left > 0:
                chunk = f.read(min(1 << 16, left))
                if not chunk:
                    break
                self.wfile.write(chunk)
                left -= len(chunk)

    def _index(self):
        from html import escape
        packs = "".join(f'<a href="#" onclick="q.value=\'{escape(p["key"])}\';run();return false">{escape(p["name"])} ({p["total"]})</a>'
                        for p in self.lib.packs())
        data = INDEX_HTML.format(name=escape(self.lib.name), scope=escape(self.lib.scope), count=len(self.lib.assets), packs=packs).encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(data)


def make_server(lib: Library, host="0.0.0.0", port=8787, token=None, base_url=None, private_files=False,
                reindex_interval=30.0, quiet=False) -> ThreadingHTTPServer:
    handler = type("BoundHandler", (Handler,), {
        "lib": lib, "token": token or None, "base_url": base_url, "private_files": private_files,
        "reindex_interval": reindex_interval,
    })
    httpd = ThreadingHTTPServer((host, port), handler)
    httpd.daemon_threads = True
    httpd.quiet = quiet
    return httpd


def main(argv=None):
    ap = argparse.ArgumentParser(description="Holodeck Asset Library — self-hosted asset library server")
    sub = ap.add_subparsers(dest="cmd", required=True)
    for name in ("serve", "index"):
        p = sub.add_parser(name)
        p.add_argument("--root", required=True, type=Path, help="folder of packs")
        p.add_argument("--name", help="library name; scope becomes @<name> (default: root folder name)")
    s = sub.choices["serve"]
    s.add_argument("--host", default=os.environ.get("HOLODECK_LIBRARY_HOST", "0.0.0.0"))
    s.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8787")))
    s.add_argument("--token", default=os.environ.get("HOLODECK_LIBRARY_TOKEN"), help="require Authorization: Bearer <token> on /api/v1")
    s.add_argument("--base-url", default=os.environ.get("HOLODECK_LIBRARY_BASE_URL"), help="public URL used in file links (default: request Host)")
    s.add_argument("--private-files", action="store_true", help="require the token for /files too (?token= accepted)")
    s.add_argument("--reindex-interval", type=float, default=30.0, help="seconds between change checks (0 = every request)")
    s.add_argument("--reindex", action="store_true", help="rebuild the index from scratch on start")
    args = ap.parse_args(argv)
    if not args.root.is_dir():
        ap.error(f"--root {args.root} is not a folder")
    lib = Library(args.root, args.name or os.environ.get("HOLODECK_LIBRARY_NAME"))
    t0 = time.time()
    lib.refresh(force=True) if getattr(args, "reindex", False) else lib.refresh()
    print(f"indexed {len(lib.assets)} assets from {lib.root} as {lib.scope} in {time.time() - t0:.1f}s", file=sys.stderr)
    if args.cmd == "index":
        return 0
    httpd = make_server(lib, args.host, args.port, args.token, args.base_url, args.private_files, args.reindex_interval)
    print(f"serving on http://{args.host}:{args.port}  (auth: {'token' if args.token else 'open'})", file=sys.stderr)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    return 0


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