"""The Booth — a standing web server that renders drop-folders as ephemeral media booths.

Model (deliberately dead-simple, no database):
  * The data dir holds one subfolder per "booth". A booth is created by a CC
    session simply making a folder and dropping files in — there is no upload API.
  * GET /            -> index: a card per booth (scan of the data dir).
  * GET /b/<name>/   -> if <name>/index.html exists, serve it verbatim; otherwise
                        auto-render a gallery of the images / webm-videos / audio in it.
  * GET /b/<name>/<file> -> serve a file out of the booth (also feeds a custom index.html's assets).
  * 24h TTL: a background sweeper wipes any booth untouched for TTL hours. A booth's
    age is measured from the *newest* mtime in its tree, so it lives while it's being
    worked on and self-destructs TTL hours after the last activity.
  * KEPT BOOTHS: a booth containing the KEEP_MARKER dotfile (`.forever`) is exempt
    from the sweep and renders in its own lane above the ephemeral grid. That is the
    home for durable operator-facing boards — chiefly the standing link board agent
    sessions post to, whose whole purpose is to survive longer than the scrollback
    it replaces. Opt-in per booth, so the ephemeral default is unchanged and nobody
    inherits a cleanup chore; `rm` the sentinel and the booth rejoins the sweep.

State is the filesystem — `ls ~/booth-data` tells you everything. That is the whole point.
"""

from __future__ import annotations

import asyncio
import fcntl
import hashlib
import io
import os
import re
import secrets
import shutil
import time
import zipfile
from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import quote

from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import (
    FileResponse,
    HTMLResponse,
    JSONResponse,
    RedirectResponse,
    Response,
)
from fastapi.templating import Jinja2Templates

try:
    import markdown as _markdown
except ImportError:  # optional dep — .md then degrades to a plain-text view
    _markdown = None

TEMPLATES_DIR = Path(__file__).parent / "templates"

# Browser-playable media buckets. Anything else renders as a download link.
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".bmp"}
VIDEO_EXTS = {".webm", ".mp4", ".ogv", ".m4v", ".mov"}
AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".oga", ".flac", ".m4a", ".opus", ".aac"}

CAPTION_MAX = 800  # chars of a sidecar .txt caption we render

# Loose text docs that render as a readable in-booth page (not a download).
MARKDOWN_EXTS = {".md", ".markdown", ".mdown"}
TEXT_EXTS = {".txt", ".text", ".log"}
DOC_MAX_BYTES = 2 * 1024 * 1024  # above this, a doc is handed back raw, not rendered

# Sentinel dotfile that exempts a booth from the TTL sweep — see the "kept
# booths" note in the module docstring. A dotfile because the existing listing
# code already skips dotfiles, so it costs nothing in item counts or galleries,
# and because `touch`/`rm` is the entire user interface: no flag to remember, no
# state anywhere but the filesystem.
KEEP_MARKER = ".forever"

# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
# can use it without pulling FastAPI in. Re-exported here because call sites and
# tests already reference these names through app.
from booth.links import (  # noqa: E402
    LINK_LOCK,
    LINKS_FILE,
    link_entry_id,
    parse_link_entries,
    remove_link_entry,
)


def doc_kind(name: str) -> str | None:
    """'markdown' | 'text' | None — a booth file viewable as a readable page."""
    ext = Path(name).suffix.lower()
    if ext in MARKDOWN_EXTS:
        return "markdown"
    if ext in TEXT_EXTS:
        return "text"
    return None


def render_doc(text: str, kind: str) -> tuple[str, bool]:
    """(rendered, is_html). Markdown → HTML (fenced code, tables, sane lists);
    plain text — or markdown when the lib is unavailable — → raw text for <pre>."""
    if kind == "markdown" and _markdown is not None:
        html = _markdown.markdown(text, extensions=["fenced_code", "tables", "sane_lists"])
        return html, True
    return text, False


def booth_image_names(child: Path) -> list[str]:
    """Image files in a booth, in gallery (sorted-rel) order — for viewer prev/next."""
    return sorted(
        p.relative_to(child).as_posix()
        for p in child.rglob("*")
        if p.is_file() and not p.name.startswith(".") and classify(p.name) == "image"
    )


def classify(name: str) -> str:
    """image | video | audio | other, by extension."""
    ext = Path(name).suffix.lower()
    if ext in IMAGE_EXTS:
        return "image"
    if ext in VIDEO_EXTS:
        return "video"
    if ext in AUDIO_EXTS:
        return "audio"
    return "other"


def human_dur(seconds: float) -> str:
    s = int(seconds)
    if s <= 0:
        return "expired"
    h, rem = divmod(s, 3600)
    m, _ = divmod(rem, 60)
    if h and m:
        return f"{h}h {m}m"
    if h:
        return f"{h}h"
    if m:
        return f"{m}m"
    return "<1m"


def _newest_mtime(path: Path) -> float:
    """Newest mtime among a folder and everything under it."""
    try:
        newest = path.stat().st_mtime
    except OSError:
        return 0.0
    for p in path.rglob("*"):
        try:
            m = p.stat().st_mtime
        except OSError:
            continue
        if m > newest:
            newest = m
    return newest


def booth_age_seconds(path: Path, now: float | None = None) -> float:
    now = time.time() if now is None else now
    return now - _newest_mtime(path)


def is_expired(path: Path, ttl_seconds: float, now: float | None = None) -> bool:
    """Pure age question. Deliberately does NOT consider the keep sentinel.

    Expiry arithmetic (what `expires_in` renders) and reaper policy (what
    actually gets deleted) are kept apart so they cannot drift into each other.
    Only `sweep_once` honours the pin.
    """
    return booth_age_seconds(path, now) > ttl_seconds


def is_kept(path: Path) -> bool:
    """True if this booth carries the keep sentinel and must never be swept."""
    return (path / KEEP_MARKER).exists()


def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) -> list[str]:
    """Wipe every direct-child booth older than the TTL. Returns names wiped.

    Only ever removes direct children of data_dir (never data_dir itself), and
    skips dotfolders so a stray control dir can opt out.

    A booth carrying KEEP_MARKER is exempt no matter how stale it is. That is
    the one escape hatch from the 24h contract, and it is opt-in per booth: the
    default stays ephemeral, so nobody inherits a cleanup chore they did not ask
    for. Removing the sentinel hands the booth straight back to the sweeper.
    """
    wiped: list[str] = []
    if not data_dir.is_dir():
        return wiped
    for child in data_dir.iterdir():
        if not child.is_dir() or child.name.startswith("."):
            continue
        try:
            if is_kept(child):
                continue
            if is_expired(child, ttl_seconds, now):
                shutil.rmtree(child)
                wiped.append(child.name)
        except OSError:
            pass
    return wiped


def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> list[dict]:
    now = time.time() if now is None else now
    booths: list[dict] = []
    if not data_dir.is_dir():
        return booths
    for child in data_dir.iterdir():
        if not child.is_dir() or child.name.startswith("."):
            continue
        files = [p for p in child.rglob("*") if p.is_file() and not p.name.startswith(".")]
        kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
        thumb_url = None
        for f in files:
            k = classify(f.name)
            kinds[k] += 1
            if k == "image" and thumb_url is None:
                thumb_url = quote(f.relative_to(child).as_posix(), safe="/")
        mtime = _newest_mtime(child)
        booths.append(
            {
                "name": child.name,
                "name_url": quote(child.name, safe=""),
                "count": len(files),
                "kinds": kinds,
                "thumb_url": thumb_url,
                "has_index": (child / "index.html").is_file(),
                "uploaded": (child / UPLOAD_MARKER).exists(),
                "kept": is_kept(child),
                "expires_in": max(0.0, ttl_seconds - (now - mtime)),
                "mtime": mtime,
            }
        )
    booths.sort(key=lambda b: b["mtime"], reverse=True)
    return booths


def build_gallery(child: Path) -> list[dict]:
    """Files in a booth as render items, with caption sidecars folded in.

    A `<file>.txt` (e.g. `a.png.txt`) or a same-stem `<stem>.txt` (e.g. `a.txt`
    next to `a.png`) is consumed as that item's caption rather than shown itself —
    the natural way to label an A/B pair.
    """
    all_files = [p for p in child.rglob("*") if p.is_file() and not p.name.startswith(".")]
    by_rel = {p.relative_to(child).as_posix(): p for p in all_files}
    caption: dict[str, str] = {}
    sidecars: set[str] = set()

    for rel, p in by_rel.items():
        if not rel.lower().endswith(".txt"):
            continue
        target = None
        base_full = rel[:-4]  # strip ".txt"  -> "a.png.txt" => "a.png"
        if base_full in by_rel:
            target = base_full
        else:  # "a.txt" beside "a.png"
            parent = str(Path(rel).parent)
            stem = Path(rel).stem
            for q_rel, q in by_rel.items():
                if q_rel == rel:
                    continue
                if (
                    str(Path(q_rel).parent) == parent
                    and Path(q_rel).stem == stem
                    and classify(q.name) != "other"
                ):
                    target = q_rel
                    break
        if target is not None:
            try:
                caption[target] = p.read_text(errors="replace").strip()[:CAPTION_MAX]
            except OSError:
                pass
            sidecars.add(rel)

    items = []
    for rel in sorted(by_rel):
        if rel in sidecars:
            continue
        p = by_rel[rel]
        dkind = doc_kind(p.name)
        rendered = None
        rendered_html = False
        # Pre-render docs so the gallery can show them INLINE (collapsible)
        # instead of linking out to a separate page. Bounded by DOC_MAX_BYTES:
        # a giant log stays a download link rather than being inlined into every
        # index render. Markdown → HTML (marked safe in the template); plain text
        # is returned RAW and the template escapes it inside <pre> — pre-escaping
        # here would double-encode under Jinja autoescape.
        if dkind is not None:
            try:
                if p.stat().st_size <= DOC_MAX_BYTES:
                    text = p.read_text(errors="replace")
                    rendered, rendered_html = render_doc(text, dkind)
            except OSError:
                rendered = None
        items.append(
            {
                "name": rel,
                "kind": classify(p.name),
                "doc": dkind,
                "url": quote(rel, safe="/"),
                "caption": caption.get(rel),
                "rendered": rendered,
                "rendered_html": rendered_html,
            }
        )
    return items


def zip_booth(booth: Path) -> bytes:
    """Zip a booth's whole tree (dotfiles excluded) into an in-memory archive.

    Lets a booth be downloaded as one artifact regardless of shape — the case a
    verbatim `index.html` booth (e.g. a rendered brief + its assets) has no
    per-file download affordance for, since the page is served raw.
    """
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        for p in sorted(booth.rglob("*")):
            if p.is_file() and not p.name.startswith("."):
                zf.write(p, p.relative_to(booth).as_posix())
    return buf.getvalue()


def _zip_filename(name: str) -> str:
    """A Content-Disposition-safe `<booth>.zip` (strip quotes/control chars)."""
    safe = "".join(c for c in name if c.isprintable() and c != '"')
    return f"{safe or 'booth'}.zip"


# ---- verbatim-index.html wrapper -------------------------------------------

# Mirror of base.html's favicon (the app templates set it there; this is the copy
# injected into a booth's *verbatim* index.html so a raw page inherits the same
# icon). Keep the two in sync if the Booth's icon ever changes.
FAVICON_HREF = (
    "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'"
    "%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' "
    "cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle "
    "cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E"
)
FAVICON_LINK = f'<link rel="icon" href="{FAVICON_HREF}">'

# A self-contained floating "back to all booths" chip injected into verbatim
# booths. Scoped class + fixed positioning + max z-index so it overlays the raw
# page without touching its layout; hidden in print so downloaded reports stay clean.
_BACK_CHIP = (
    '<a href="/" class="booth-nav-home" aria-label="back to all booths">‹ all booths</a>'
    # top-right: empty on left-aligned report layouts (a top-left chip clips the
    # page title), and consistent with the zoom view's top-right back affordance.
    "<style>.booth-nav-home{position:fixed;top:0;right:0;z-index:2147483647;"
    "display:inline-block;margin:.6rem;padding:.34rem .72rem;"
    "font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
    "color:#dfe7ef;text-decoration:none;letter-spacing:.01em;"
    "background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);border-radius:8px;"
    "-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);"
    "box-shadow:0 2px 10px rgba(0,0,0,.35);transition:background .18s,border-color .18s}"
    ".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}"
    "@media print{.booth-nav-home{display:none}}</style>"
)

WRAP_MAX_BYTES = 8 * 1024 * 1024  # above this, serve the verbatim page raw (unwrapped)

_ICON_RE = re.compile(r"<link\b[^>]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE)
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", re.IGNORECASE)
_HTML_OPEN_RE = re.compile(r"<html\b[^>]*>", re.IGNORECASE)
_DOCTYPE_RE = re.compile(r"<!doctype[^>]*>", re.IGNORECASE)
_BODY_CLOSE_RE = re.compile(r"</body\s*>", re.IGNORECASE)
_HTML_CLOSE_RE = re.compile(r"</html\s*>", re.IGNORECASE)


def _insert_before(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
    m = pattern.search(html)
    if m:
        return html[: m.start()] + snippet + html[m.start() :], True
    return html, False


def _insert_after(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
    m = pattern.search(html)
    if m:
        return html[: m.end()] + snippet + html[m.end() :], True
    return html, False


def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK) -> str:
    """Inject a floating 'all booths' back-chip — and the Booth favicon, if the page
    declares none — into a booth's verbatim index.html, without altering the page's
    rendered content.

    Robust to the compact HTML real booths use (`<!doctype html><meta charset><title>
    <style>…content`, no explicit head/body). The two hard constraints:
      * NEVER put anything ahead of a leading <!doctype> — that forces quirks mode.
      * Keep the charset <meta> within the first 1024 bytes so it's still honoured.
    So the favicon lands at the first head-ish seam (before </head>, else after
    <html>, else right after the doctype — a ~250B link keeps charset in range), and
    the fixed-position chip is appended at the END of the document (before </body> /
    </html> or appended), which renders top-left regardless and disturbs nothing.
    """
    if favicon_link and not _ICON_RE.search(html):
        for inserter, pat in (
            (_insert_before, _HEAD_CLOSE_RE),  # inside an explicit <head>
            (_insert_after, _HTML_OPEN_RE),    # top of an explicit <html>
            (_insert_after, _DOCTYPE_RE),      # right after the doctype (compact HTML)
        ):
            html, done = inserter(html, pat, favicon_link)
            if done:
                break
        else:
            html = favicon_link + html  # bare fragment, no doctype: safe to prepend

    for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
        html, done = _insert_before(html, pat, _BACK_CHIP)
        if done:
            break
    else:
        html = html + _BACK_CHIP  # no </body>/</html>: append to the end
    return html


# ---- uploads (browser drop-off for pickup) ---------------------------------

UPLOAD_MARKER = ".uploaded"  # dotfile stamped into upload booths (excluded from listings)

# Friendly, unambiguous words for human-readable pickup ids (4-wombat / star-84).
PICKUP_WORDS = (
    "wombat otter panda koala tiger walrus gecko heron badger beaver falcon marmot "
    "lemur narwhal ocelot puffin quokka raccoon tapir urchin vulture weasel yak zebra "
    "alpaca bison cobra dingo egret ferret gibbon hare ibis jaguar llama moose newt "
    "osprey possum quail robin seal toad viper wren lynx mole swan crane finch sloth "
    "shrew stoat skunk heronry orca walnut sparrow "
    "star comet moon cloud river maple cedar birch fern moss reef dune mesa cove glade "
    "brook pine cedarwood kelp coral amber opal jade onyx slate flint ember spark frost "
    "storm tide wave ridge peak vale marsh delta atoll canyon fjord geyser lagoon prairie "
    "anchor beacon lantern kettle copper brass velvet cobalt indigo crimson violet olive "
    "hazel cocoa mango guava papaya plum kiwi lime pear quince radish turnip acorn clover "
    "thistle poppy aster dahlia iris lily sage thyme basil clove nutmeg ginger honey"
).split()


def safe_upload_name(name: str, fallback: str) -> str:
    """Reduce a client-supplied filename to a safe basename (no path, no hidden)."""
    base = (name or "").replace("\\", "/").split("/")[-1].strip()
    base = base.lstrip(".")  # a leading dot would hide the file from every listing
    return base[:200] or fallback


def _dedupe_name(name: str, used: set) -> str:
    if name not in used:
        return name
    stem, dot, ext = name.partition(".")
    i = 1
    while f"{stem}-{i}{dot}{ext}" in used:
        i += 1
    return f"{stem}-{i}{dot}{ext}"


def generate_pickup_id(exists) -> str:
    """A human-readable id like '4-wombat' or 'star-84'. `exists(name)->bool` gates collisions."""
    for _ in range(400):
        word = secrets.choice(PICKUP_WORDS)
        num = secrets.randbelow(99) + 1
        name = f"{num}-{word}" if secrets.randbelow(2) else f"{word}-{num}"
        if not exists(name):
            return name
    # astronomically unlikely fallback: two words keep it human-readable
    while True:
        name = f"{secrets.choice(PICKUP_WORDS)}-{secrets.choice(PICKUP_WORDS)}-{secrets.randbelow(999) + 1}"
        if not exists(name):
            return name


def create_app(
    data_dir,
    ttl_hours: float = 24.0,
    host_label: str = "",
    start_sweeper: bool = True,
    sweep_interval_s: int = 900,
    max_upload_mb: float = 1024.0,
    max_files: int = 50,
) -> FastAPI:
    data_dir = Path(data_dir).expanduser().resolve()
    data_dir.mkdir(parents=True, exist_ok=True)
    ttl_seconds = ttl_hours * 3600.0
    max_upload_bytes = int(max_upload_mb * 1024 * 1024)

    templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
    templates.env.filters["dur"] = human_dur

    @asynccontextmanager
    async def lifespan(app: FastAPI):
        task = None
        if start_sweeper:

            async def loop():
                while True:
                    try:
                        wiped = sweep_once(data_dir, ttl_seconds)
                        if wiped:
                            print(f"[booth] swept {len(wiped)} expired: {', '.join(wiped)}", flush=True)
                    except Exception as exc:  # never let the sweeper die
                        print(f"[booth] sweep error: {exc}", flush=True)
                    await asyncio.sleep(sweep_interval_s)

            task = asyncio.create_task(loop())
        try:
            yield
        finally:
            if task is not None:
                task.cancel()

    app = FastAPI(title="The Booth", lifespan=lifespan)

    ttl_display = int(ttl_hours) if float(ttl_hours).is_integer() else ttl_hours
    base_ctx = {
        "ttl_hours": ttl_display,
        "host": host_label,
        "data_dir": str(data_dir),
        "keep_marker": KEEP_MARKER,  # shown in the kept lane so the mechanism is discoverable
    }

    def resolve_booth(name: str) -> Path:
        if not name or name.startswith(".") or "/" in name or "\\" in name or ".." in name:
            raise HTTPException(status_code=404, detail="no such booth")
        candidate = data_dir / name
        try:
            resolved = candidate.resolve()
        except OSError:
            raise HTTPException(status_code=404, detail="no such booth")
        # resolved.parent must be the data dir itself — blocks symlink escape + nesting.
        if resolved.parent != data_dir or not resolved.is_dir():
            raise HTTPException(status_code=404, detail="no such booth")
        return resolved

    @app.get("/", response_class=HTMLResponse)
    def index(request: Request):
        # Two lanes, split here rather than in the template: kept boards are a
        # different KIND of thing from the ephemeral churn — durable, deliberate,
        # operator-facing — and burying them in a feed that turns over daily is
        # exactly how they would get lost, which is the problem they exist to
        # solve. Kept renders first.
        everything = list_booths(data_dir, ttl_seconds)
        return templates.TemplateResponse(
            request,
            "index.html",
            {
                **base_ctx,
                "kept": [b for b in everything if b["kept"]],
                "booths": [b for b in everything if not b["kept"]],
            },
        )

    @app.get("/healthz")
    def healthz():
        return {"ok": True, "ttl_hours": ttl_hours, "booths": len(list_booths(data_dir, ttl_seconds))}

    @app.get("/b/{name}", include_in_schema=False)
    def booth_redirect(name: str):
        resolve_booth(name)
        return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307)

    @app.get("/b/{name}/", response_class=HTMLResponse)
    def booth_view(request: Request, name: str, download: int = 0):
        booth = resolve_booth(name)
        if download:
            # whole-booth zip — the download path for a verbatim index.html booth
            # (which has no gallery/per-file chrome), and a "download all" for any.
            return Response(
                content=zip_booth(booth),
                media_type="application/zip",
                headers={"Content-Disposition": f'attachment; filename="{_zip_filename(name)}"'},
            )
        own_index = booth / "index.html"
        if own_index.is_file():
            # Serve the operator's verbatim report, but inject a floating
            # back-to-booths chip + the Booth favicon (if it declares none) so a
            # raw page still has a way home. Small HTML -> read + wrap in memory;
            # a pathological large file falls back to serving raw, unwrapped.
            try:
                if own_index.stat().st_size <= WRAP_MAX_BYTES:
                    return HTMLResponse(
                        wrap_verbatim_html(own_index.read_text(encoding="utf-8", errors="replace"))
                    )
            except OSError:
                pass
            return FileResponse(str(own_index), media_type="text/html")
        return templates.TemplateResponse(
            request,
            "booth.html",
            {
                **base_ctx,
                "name": name,
                "name_url": quote(name, safe=""),
                # links.md is rendered AS the board below, so it must not also
                # appear as a markdown doc tile — that would show the same
                # content twice, once interactive and once not.
                "items": [
                    it for it in build_gallery(booth)
                    if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE)
                ],
                # A booth carrying links.md is the standing link board: render
                # its rows as real UI (link, provenance, per-row remove) instead
                # of a markdown blob you can only edit by hand. Empty list for
                # every other booth, so the template branch simply does not fire.
                "board": (
                    parse_link_entries((booth / LINKS_FILE).read_text())
                    if (booth / LINKS_FILE).is_file() else []
                ),
                "uploaded": (booth / UPLOAD_MARKER).exists(),
                "expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
            },
        )

    @app.get("/b/{name}/view", response_class=HTMLResponse)
    def booth_view_file(request: Request, name: str, f: str):
        booth = resolve_booth(name)
        try:
            target = (booth / f).resolve()
        except OSError:
            raise HTTPException(status_code=404, detail="no such file")
        if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
            raise HTTPException(status_code=404, detail="no such file")
        file_url = quote(f, safe="/")
        common = {**base_ctx, "name": name, "name_url": quote(name, safe=""), "file": f, "file_url": file_url}
        if classify(target.name) == "image":
            # prev/next image nav (wraps around; only when >1 image in the booth)
            names = booth_image_names(booth)
            prev_url = next_url = None
            if f in names and len(names) > 1:
                i = names.index(f)
                prev_url = quote(names[(i - 1) % len(names)], safe="/")
                next_url = quote(names[(i + 1) % len(names)], safe="/")
            return templates.TemplateResponse(
                request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url}
            )
        # .md renders, .txt/.log show as text — viewable in-booth, no download
        dk = doc_kind(target.name)
        if dk:
            try:
                if target.stat().st_size <= DOC_MAX_BYTES:
                    body, is_html = render_doc(target.read_text(encoding="utf-8", errors="replace"), dk)
                    return templates.TemplateResponse(
                        request, "doc.html", {**common, "kind": dk, "body": body, "is_html": is_html}
                    )
            except OSError:
                raise HTTPException(status_code=404, detail="no such file")
        # nothing to render — hand back the raw file
        return RedirectResponse(url=f"/b/{quote(name, safe='')}/{file_url}", status_code=307)

    @app.get("/b/{name}/{filepath:path}")
    def booth_file(name: str, filepath: str, dl: int = 0):
        booth = resolve_booth(name)
        try:
            target = (booth / filepath).resolve()
        except OSError:
            raise HTTPException(status_code=404, detail="no such file")
        if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
            raise HTTPException(status_code=404, detail="no such file")
        # ?dl=1 forces a download (Content-Disposition: attachment) instead of the
        # browser rendering inline — the fix for html/md/text that otherwise opens
        # in-page with no easy "save".
        if dl:
            return FileResponse(str(target), filename=target.name)
        return FileResponse(str(target))

    @app.post("/upload")
    async def upload(files: list[UploadFile] = File(...)):
        """Browser/curl drop-off: files land in a new booth with a human-readable
        pickup id (e.g. 4-wombat), sweep-expiring in the usual TTL. Redirects (303)
        to the pickup page; curl clients read the Location header for the id."""
        files = [f for f in files if f and f.filename]
        if not files:
            raise HTTPException(status_code=400, detail="no files uploaded")
        if len(files) > max_files:
            raise HTTPException(status_code=413, detail=f"too many files (max {max_files})")

        booth_id = generate_pickup_id(lambda n: (data_dir / n).exists())
        dest = data_dir / booth_id
        dest.mkdir(parents=True)
        (dest / UPLOAD_MARKER).write_text("")  # stamp as an upload (dotfile, not listed)

        total = 0
        used: set = {UPLOAD_MARKER}
        try:
            for i, f in enumerate(files):
                name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used)
                used.add(name)
                with (dest / name).open("wb") as out:
                    while chunk := await f.read(1024 * 1024):
                        total += len(chunk)
                        if total > max_upload_bytes:
                            raise HTTPException(
                                status_code=413,
                                detail=f"upload too large (max {max_upload_mb:g} MB)",
                            )
                        out.write(chunk)
                await f.close()
        except Exception:
            shutil.rmtree(dest, ignore_errors=True)  # never leave a half-written booth
            raise

        return RedirectResponse(url=f"/b/{quote(booth_id, safe='')}/", status_code=303)

    # Releasing a kept board. The kept lane has no wipe control on purpose —
    # destroying a durable board should not be one misclick — but "deliberate"
    # had been built as "impossible from the UI": the only ways out were ssh or
    # a hand-written API call. These two routes make the release step reachable
    # while keeping deletion two deliberate acts (release, then wipe).
    #
    # NOTE ON THE TTL, which is not intuitive: removing the sentinel BUMPS the
    # booth directory's mtime, and booth_age_seconds reads the newest mtime in
    # the tree — so a released board's clock resets to zero and it survives
    # another full TTL. "Unkeep and let the sweeper take it" therefore does NOT
    # delete promptly. Release is the step that makes the × available; the ×
    # is what deletes. Anything relying on release-then-sweep is relying on a
    # 24h delay it probably did not intend.

    @app.post("/b/{name}/unlink")
    def board_unlink(name: str, entry: str = Form(...)):
        """Remove ONE row from a link board, by content id.

        Deliberately not by index: the board is append-only and multi-writer,
        so between rendering the page and clicking × another session may have
        posted. A content id either matches the row the operator saw or matches
        nothing — it can never resolve to a neighbour.
        """
        removed = remove_link_entry(resolve_booth(name), entry)
        if removed is None:
            # Already gone (double-click, stale tab, someone else pruned it).
            # Not an error worth a 404 page — the desired end state holds.
            pass
        return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)

    @app.post("/b/{name}/keep")
    def booth_keep(name: str):
        (resolve_booth(name) / KEEP_MARKER).touch()
        return RedirectResponse(url="/", status_code=303)

    @app.post("/b/{name}/unkeep")
    def booth_unkeep(name: str):
        # missing_ok: releasing an already-released board is a no-op, not a 500.
        (resolve_booth(name) / KEEP_MARKER).unlink(missing_ok=True)
        return RedirectResponse(url="/", status_code=303)

    @app.post("/b/{name}/delete")
    def booth_delete_form(name: str):
        shutil.rmtree(resolve_booth(name))
        return RedirectResponse(url="/", status_code=303)

    @app.delete("/b/{name}")
    def booth_delete_api(name: str):
        shutil.rmtree(resolve_booth(name))
        return JSONResponse({"wiped": name})

    return app


def _from_env() -> FastAPI:
    data = os.environ.get("BOOTH_DATA_DIR", str(Path.home() / "booth-data"))
    ttl = float(os.environ.get("BOOTH_TTL_HOURS", "24"))
    host = os.environ.get("BOOTH_HOST_LABEL", "")
    interval = int(float(os.environ.get("BOOTH_SWEEP_INTERVAL_MIN", "15")) * 60)
    max_mb = float(os.environ.get("BOOTH_MAX_UPLOAD_MB", "1024"))
    max_n = int(os.environ.get("BOOTH_MAX_FILES", "50"))
    return create_app(
        data,
        ttl_hours=ttl,
        host_label=host,
        sweep_interval_s=interval,
        max_upload_mb=max_mb,
        max_files=max_n,
    )


app = _from_env()
