"""
Radio archive query server. Read-only FastAPI over the SQLite archive.db.

Endpoints:
  GET /                          Landing page with search UI
  GET /api/episodes              List all episodes (year, title, duration)
  GET /api/episodes/{id}         Episode detail: intros + qa_pairs
  GET /api/episodes/{id}/transcript  Chronologically merged segments + turns
  GET /api/search?q=...&kind=... FTS over segments and/or qa_pairs
  GET /api/qa                    List Q&A pairs (no search query, filterable)
  GET /api/audio/{id}            Stream the episode MP3 (HTTP Range supported)
  GET /api/callers               Top recurring caller_names
  GET /episode/{id}              HTML transcript view with audio player

Config via env:
  ARCHIVE_DB    path to archive.db   (default /data/archive.db)
  EPISODES_DIR  path to mp3 tree     (default /data/episodes)
  PORT          listen port          (default 8765)
"""
import html as _html
import json
import os
import re
import sqlite3
import subprocess
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Iterator

from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse

DB_PATH = os.environ.get("ARCHIVE_DB", "/data/archive.db")
EPISODES_DIR = os.environ.get("EPISODES_DIR", "/data/episodes")
PORT = int(os.environ.get("PORT", "8765"))


def _connect() -> sqlite3.Connection:
    if not Path(DB_PATH).exists():
        raise RuntimeError(f"Archive DB not found at {DB_PATH}")
    conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, check_same_thread=False)
    conn.row_factory = sqlite3.Row
    return conn


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.db = _connect()
    yield
    app.state.db.close()


app = FastAPI(title="Computer Guru Radio Archive", lifespan=lifespan)


def fts_escape(q: str) -> str:
    """Wrap each term in double quotes so FTS5 treats reserved chars literally."""
    return " ".join(f'"{tok}"' for tok in q.split() if tok)


# Excerpt extraction for Q&A texts.
#
# Whisper transcripts often start with disfluent run-on chatter that's a
# leftover from the previous turn. We trim that prefix, take the first 300
# chars, and try to end on a sentence boundary so the excerpt reads cleanly.
_EXCERPT_BODY = 300       # primary character budget
_EXCERPT_LOOKAHEAD = 80   # extra chars allowed to find a sentence end
_EXCERPT_LEAD_SCAN = 30   # window to look for a leading capital letter


def _excerpt(text: str | None) -> str:
    """Return a short, readable excerpt suitable for browsing.

    Rules (intentionally simple — see spec):
      1. Walk the leading prefix and skip to the first capital letter, but
         only within the first ~30 chars; otherwise keep the original start.
      2. Take the first 300 chars.
      3. If that cut lands mid-sentence, look up to 80 more chars ahead for
         the next .!? and end there.
      4. Otherwise back up to the last word boundary and append "..." so we
         never display half a word.
    """
    if not text:
        return ""
    s = text.strip()
    if not s:
        return ""

    # 1. trim disfluent leading run-on to the first capital letter
    lead_window = s[:_EXCERPT_LEAD_SCAN]
    cap_match = re.search(r"[A-Z]", lead_window)
    if cap_match and cap_match.start() > 0:
        s = s[cap_match.start():]

    if len(s) <= _EXCERPT_BODY:
        return s

    body = s[:_EXCERPT_BODY]
    # 3. if the body ends mid-sentence, look ahead for a terminator
    if body and body[-1] not in ".!?":
        ahead = s[_EXCERPT_BODY:_EXCERPT_BODY + _EXCERPT_LOOKAHEAD]
        m = re.search(r"[.!?]", ahead)
        if m:
            return body + ahead[: m.end()]
        # 4. back up to last whitespace and ellipsize
        cut = body.rfind(" ")
        if cut > 0:
            return body[:cut].rstrip(",;:- ") + "..."
        return body + "..."

    return body


def _qa_search_excerpts(row: dict) -> dict:
    """Augment a search/qa row with question/answer excerpts.

    Excerpts are computed from the (un-highlighted) full text that lives
    next to the snippet in the row. This keeps the existing q_snippet/
    a_snippet (with <mark> highlighting) working for back-compat and adds
    plain-text excerpts the UI can prefer.
    """
    row["question_excerpt"] = _excerpt(row.pop("_question_text", None))
    row["answer_excerpt"] = _excerpt(row.pop("_answer_text", None))
    return row


@app.get("/api/episodes")
def list_episodes(year: int | None = None, limit: int = 1000):
    db: sqlite3.Connection = app.state.db
    sql = """
        SELECT id, year, title, air_date, ROUND(duration_sec/60.0,1) AS minutes,
               (SELECT COUNT(*) FROM qa_pairs q WHERE q.episode_id = e.id) AS qa_count,
               (SELECT COUNT(*) FROM intros i WHERE i.episode_id = e.id) AS intro_count
        FROM episodes e
    """
    params: list = []
    if year is not None:
        sql += " WHERE year = ?"
        params.append(year)
    sql += " ORDER BY COALESCE(air_date, '9999') ASC, title ASC LIMIT ?"
    params.append(limit)
    rows = db.execute(sql, params).fetchall()
    return [dict(r) for r in rows]


@app.get("/api/episodes/{episode_id}")
def episode_detail(episode_id: int):
    db: sqlite3.Connection = app.state.db
    ep = db.execute("SELECT * FROM episodes WHERE id = ?", (episode_id,)).fetchone()
    if not ep:
        raise HTTPException(404, "episode not found")
    intros = db.execute(
        "SELECT name, role_hint, intro_time_sec, affiliation, fillin_for, source_text "
        "FROM intros WHERE episode_id = ? ORDER BY intro_time_sec",
        (episode_id,),
    ).fetchall()
    qa = db.execute(
        "SELECT id, question_start_sec, question_end_sec, "
        "answer_start_sec, answer_end_sec, "
        "question_text, answer_text, caller_name, caller_role, topic, topic_tags, "
        "usefulness_score, topic_class, is_banter "
        "FROM qa_pairs WHERE episode_id = ? ORDER BY question_start_sec",
        (episode_id,),
    ).fetchall()
    return {
        "episode": dict(ep),
        "intros": [dict(r) for r in intros],
        "qa_pairs": [
            {**dict(r), "topic_tags": json.loads(r["topic_tags"] or "[]")} for r in qa
        ],
    }


@app.get("/api/episodes/{episode_id}/transcript")
def episode_transcript(episode_id: int):
    db: sqlite3.Connection = app.state.db
    ep = db.execute("SELECT id, title, year FROM episodes WHERE id = ?", (episode_id,)).fetchone()
    if not ep:
        raise HTTPException(404, "episode not found")
    segments = db.execute(
        "SELECT seg_idx, start_sec, end_sec, text FROM segments "
        "WHERE episode_id = ? ORDER BY seg_idx",
        (episode_id,),
    ).fetchall()
    turns = db.execute(
        "SELECT speaker, start_sec, end_sec, confidence FROM turns "
        "WHERE episode_id = ? ORDER BY start_sec",
        (episode_id,),
    ).fetchall()
    return {
        "episode": dict(ep),
        "segments": [dict(r) for r in segments],
        "turns": [dict(r) for r in turns],
    }


@app.get("/api/search")
def search(
    q: str = Query(..., min_length=2),
    kind: str = Query("both", pattern="^(both|segments|qa)$"),
    limit: int = Query(50, ge=1, le=500),
    min_score: int = Query(0, ge=0, le=5,
                           description="Minimum usefulness_score for Q&A hits (0=no filter)"),
    exclude_banter: bool = Query(False,
                                 description="Drop Q&A rows where is_banter=1"),
):
    db: sqlite3.Connection = app.state.db
    fts_q = fts_escape(q)
    if not fts_q:
        return {"q": q, "segments": [], "qa": []}

    seg_results = []
    qa_results = []

    if kind in ("both", "segments"):
        seg_results = [
            dict(r) for r in db.execute(
                """
                SELECT e.id AS episode_id, e.year, e.title, e.air_date,
                       s.start_sec, s.end_sec,
                       snippet(segments_fts, 0, '<mark>', '</mark>', '...', 16) AS snippet,
                       bm25(segments_fts) AS rank
                FROM segments_fts
                JOIN segments s ON s.id = segments_fts.rowid
                JOIN episodes e ON e.id = s.episode_id
                WHERE segments_fts MATCH ?
                ORDER BY rank LIMIT ?
                """,
                (fts_q, limit),
            ).fetchall()
        ]

    if kind in ("both", "qa"):
        # NULL is treated as "unscored, include" so unprocessed rows still
        # appear and old saved URLs keep working as the classifier rolls out.
        # Filters are applied as additional WHERE clauses on top of the FTS
        # MATCH; SQLite's planner can use idx_qa_usefulness once it's helpful.
        qa_clauses = ["qa_fts MATCH :q"]
        qa_params: dict[str, object] = {"q": fts_q, "limit": limit}
        if min_score > 0:
            qa_clauses.append(
                "(p.usefulness_score IS NULL OR p.usefulness_score >= :min_score)"
            )
            qa_params["min_score"] = min_score
        if exclude_banter:
            qa_clauses.append("(p.is_banter IS NULL OR p.is_banter = 0)")

        qa_sql = f"""
            SELECT e.id AS episode_id, e.year, e.title, e.air_date,
                   p.id AS qa_id, p.caller_name,
                   p.question_start_sec, p.answer_start_sec,
                   p.usefulness_score, p.topic_class, p.is_banter,
                   p.question_text AS _question_text,
                   p.answer_text   AS _answer_text,
                   snippet(qa_fts, 0, '<mark>', '</mark>', '...', 16) AS q_snippet,
                   snippet(qa_fts, 1, '<mark>', '</mark>', '...', 16) AS a_snippet,
                   bm25(qa_fts) AS rank
            FROM qa_fts
            JOIN qa_pairs p ON p.id = qa_fts.rowid
            JOIN episodes e ON e.id = p.episode_id
            WHERE {' AND '.join(qa_clauses)}
            ORDER BY rank LIMIT :limit
        """
        qa_results = [
            _qa_search_excerpts(dict(r))
            for r in db.execute(qa_sql, qa_params).fetchall()
        ]

    return {"q": q, "segments": seg_results, "qa": qa_results}


# Sort key whitelist so we can pass user input straight into ORDER BY.
_QA_SORT_ORDERS: dict[str, str] = {
    "air_date_desc": "COALESCE(e.air_date, '0000') DESC, p.question_start_sec ASC",
    "air_date_asc":  "COALESCE(e.air_date, '9999') ASC,  p.question_start_sec ASC",
    "score_desc":    "COALESCE(p.usefulness_score, 0) DESC, "
                     "COALESCE(e.air_date, '0000') DESC, p.question_start_sec ASC",
}


@app.get("/api/qa")
def list_qa(
    year: int | None = None,
    min_score: int = Query(0, ge=0, le=5),
    exclude_banter: bool = Query(False),
    topic_class: str | None = None,
    limit: int = Query(50, ge=1, le=200),
    offset: int = Query(0, ge=0),
    order: str = Query("air_date_desc"),
):
    """Browseable Q&A list — same column shape as /api/search Q&A hits."""
    db: sqlite3.Connection = app.state.db
    if order not in _QA_SORT_ORDERS:
        raise HTTPException(400, f"unknown order: {order}")
    order_sql = _QA_SORT_ORDERS[order]

    where = ["1=1"]
    params: dict[str, object] = {}
    if year is not None:
        where.append("e.year = :year")
        params["year"] = year
    if min_score > 0:
        where.append("(p.usefulness_score IS NULL OR p.usefulness_score >= :min_score)")
        params["min_score"] = min_score
    if exclude_banter:
        where.append("(p.is_banter IS NULL OR p.is_banter = 0)")
    if topic_class:
        where.append("p.topic_class = :topic_class")
        params["topic_class"] = topic_class

    where_sql = " AND ".join(where)

    total = db.execute(
        f"""SELECT COUNT(*) FROM qa_pairs p
            JOIN episodes e ON e.id = p.episode_id
            WHERE {where_sql}""",
        params,
    ).fetchone()[0]

    params_pl = dict(params, limit=limit, offset=offset)
    rows = db.execute(
        f"""SELECT e.id AS episode_id, e.year, e.title, e.air_date,
                   p.id AS qa_id, p.caller_name,
                   p.question_start_sec, p.answer_start_sec,
                   p.usefulness_score, p.topic_class, p.is_banter,
                   p.question_text AS _question_text,
                   p.answer_text   AS _answer_text
            FROM qa_pairs p
            JOIN episodes e ON e.id = p.episode_id
            WHERE {where_sql}
            ORDER BY {order_sql}
            LIMIT :limit OFFSET :offset""",
        params_pl,
    ).fetchall()

    items = [_qa_search_excerpts(dict(r)) for r in rows]
    return {"total": total, "items": items}


# --- Audio streaming with HTTP Range support ----------------------------

_AUDIO_CHUNK = 64 * 1024


def _resolve_audio_path(rel_path: str) -> Path | None:
    """Return the absolute Path to the MP3 if it exists, else None.

    rel_path is the value stored in episodes.rel_path (e.g.
    "2010/10 - October/10-02-10 HR 1.mp3"). We refuse anything that escapes
    the episodes root via .. so a malicious DB row cannot read arbitrary
    files.
    """
    if not rel_path:
        return None
    base = Path(EPISODES_DIR).resolve()
    candidate = (base / rel_path).resolve()
    try:
        candidate.relative_to(base)
    except ValueError:
        return None
    if not candidate.is_file():
        return None
    return candidate


def _parse_range(header: str, file_size: int) -> tuple[int, int] | None:
    """Parse a single-range "bytes=START-END" header. Returns None if invalid."""
    if not header or not header.startswith("bytes="):
        return None
    spec = header[len("bytes="):].strip()
    if "," in spec:
        # Multi-range — fall back to no-range (full file) for simplicity
        return None
    if "-" not in spec:
        return None
    start_s, end_s = spec.split("-", 1)
    try:
        if start_s == "":
            # suffix range: "-N" -> last N bytes
            length = int(end_s)
            if length <= 0:
                return None
            start = max(0, file_size - length)
            end = file_size - 1
        else:
            start = int(start_s)
            end = int(end_s) if end_s else file_size - 1
    except ValueError:
        return None
    if start < 0 or end < start or start >= file_size:
        return None
    end = min(end, file_size - 1)
    return start, end


def _file_iter(path: Path, start: int, length: int,
               chunk: int = _AUDIO_CHUNK) -> Iterator[bytes]:
    remaining = length
    with open(path, "rb") as f:
        f.seek(start)
        while remaining > 0:
            data = f.read(min(chunk, remaining))
            if not data:
                break
            remaining -= len(data)
            yield data


@app.get("/api/audio/{episode_id}")
def stream_audio(episode_id: int, request: Request):
    """Stream the episode's MP3 with HTTP Range support.

    Returns 404 if the episode doesn't exist or the file isn't on disk
    (Jupiter currently has no episodes/ tree — that's a clean 404). The
    audio element on the transcript page checks the response and hides
    itself on 404.
    """
    db: sqlite3.Connection = app.state.db
    ep = db.execute("SELECT rel_path FROM episodes WHERE id = ?", (episode_id,)).fetchone()
    if not ep:
        raise HTTPException(404, "episode not found")
    path = _resolve_audio_path(ep["rel_path"])
    if path is None:
        raise HTTPException(404, "audio file missing")

    file_size = path.stat().st_size
    range_header = request.headers.get("range") or request.headers.get("Range")
    rng = _parse_range(range_header, file_size) if range_header else None

    headers = {
        "Accept-Ranges": "bytes",
        "Cache-Control": "public, max-age=86400",
        "Content-Type": "audio/mpeg",
    }

    if rng is None:
        # Full content
        headers["Content-Length"] = str(file_size)
        return StreamingResponse(
            _file_iter(path, 0, file_size),
            status_code=200,
            headers=headers,
            media_type="audio/mpeg",
        )

    start, end = rng
    length = end - start + 1
    headers["Content-Length"] = str(length)
    headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
    return StreamingResponse(
        _file_iter(path, start, length),
        status_code=206,
        headers=headers,
        media_type="audio/mpeg",
    )


@app.get("/api/clip/{qa_id}")
def download_clip(qa_id: int):
    """Extract and stream a Q&A pair as a stand-alone MP3.

    Runs ffmpeg with 1-second padding and 200ms fade in/out on each side.
    Streams directly from ffmpeg stdout — no temp file on disk.
    """
    db: sqlite3.Connection = app.state.db
    row = db.execute(
        """SELECT p.question_start_sec, p.answer_end_sec,
                  p.topic_class, p.caller_name,
                  e.rel_path, e.air_date
           FROM qa_pairs p JOIN episodes e ON e.id = p.episode_id
           WHERE p.id = ?""",
        (qa_id,),
    ).fetchone()
    if not row:
        raise HTTPException(404, "Q&A pair not found")

    path = _resolve_audio_path(row["rel_path"])
    if path is None:
        raise HTTPException(404, "audio file not on this server")

    a_end = row["answer_end_sec"]
    if a_end is None:
        raise HTTPException(422, "Q&A pair has no end timestamp")

    q_start = row["question_start_sec"] or 0.0
    start = max(0.0, q_start - 1.0)
    end = a_end + 1.0
    duration = end - start
    fade_s = 0.2

    date_part = (row["air_date"] or "unknown").replace("/", "-")
    name_parts = [date_part]
    if row["caller_name"]:
        name_parts.append(re.sub(r"[^\w\s-]", "", row["caller_name"]).strip()[:30])
    if row["topic_class"]:
        name_parts.append(row["topic_class"])
    filename = "-".join(name_parts).replace(" ", "-") + ".mp3"

    def _stream():
        cmd = [
            "ffmpeg", "-y",
            "-ss", f"{start:.3f}",
            "-i", str(path),
            "-t", f"{duration:.3f}",
            "-af", (
                f"afade=t=in:st=0:d={fade_s},"
                f"afade=t=out:st={max(0.0, duration - fade_s):.3f}:d={fade_s}"
            ),
            "-q:a", "2",
            "-f", "mp3",
            "pipe:1",
        ]
        proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
        try:
            while True:
                chunk = proc.stdout.read(65536)
                if not chunk:
                    break
                yield chunk
        finally:
            proc.stdout.close()
            proc.wait()

    return StreamingResponse(
        _stream(),
        media_type="audio/mpeg",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


@app.get("/api/callers")
def top_callers(limit: int = 50):
    db: sqlite3.Connection = app.state.db
    rows = db.execute(
        "SELECT caller_name, COUNT(*) AS pairs FROM qa_pairs "
        "WHERE caller_name IS NOT NULL "
        "GROUP BY caller_name ORDER BY pairs DESC LIMIT ?",
        (limit,),
    ).fetchall()
    return [dict(r) for r in rows]


@app.get("/api/stats")
def stats():
    db: sqlite3.Connection = app.state.db
    counts = {
        t: db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
        for t in ("episodes", "segments", "turns", "intros", "qa_pairs")
    }
    by_year = [
        dict(r) for r in db.execute(
            "SELECT year, COUNT(*) AS episodes, "
            "ROUND(SUM(duration_sec)/3600.0, 1) AS hours "
            "FROM episodes GROUP BY year ORDER BY year"
        ).fetchall()
    ]
    return {"counts": counts, "by_year": by_year}


@app.get("/api/db.sqlite")
def download_db():
    """Stream the read-only archive.db for offline laptop sync.

    Anyone who can reach /api/search can already read every transcript,
    so exposing the underlying SQLite file adds no meaningful disclosure.
    Sync side: curl -o archive.db <host>:<port>/api/db.sqlite
    """
    if not Path(DB_PATH).exists():
        raise HTTPException(404, "archive db not present")
    return FileResponse(
        DB_PATH,
        media_type="application/vnd.sqlite3",
        filename="archive.db",
    )


@app.get("/", response_class=HTMLResponse)
def index():
    return INDEX_HTML


# --- Single-episode HTML transcript view --------------------------------


def _fmt_time(sec: float | None) -> str:
    if sec is None:
        return ""
    s = int(sec)
    return f"{s // 60}:{s % 60:02d}"


def _episode_html(episode_id: int) -> str:
    db: sqlite3.Connection = app.state.db
    ep = db.execute("SELECT * FROM episodes WHERE id = ?", (episode_id,)).fetchone()
    if not ep:
        raise HTTPException(404, "episode not found")
    intros = db.execute(
        "SELECT id, name, role_hint, intro_time_sec FROM intros "
        "WHERE episode_id = ? ORDER BY intro_time_sec",
        (episode_id,),
    ).fetchall()
    qa = db.execute(
        "SELECT id, question_start_sec, question_end_sec, answer_start_sec, "
        "       answer_end_sec, question_text, answer_text, caller_name, "
        "       caller_role, usefulness_score, topic_class, is_banter "
        "FROM qa_pairs WHERE episode_id = ? ORDER BY question_start_sec",
        (episode_id,),
    ).fetchall()
    segments = db.execute(
        "SELECT seg_idx, start_sec, end_sec, text FROM segments "
        "WHERE episode_id = ? ORDER BY seg_idx",
        (episode_id,),
    ).fetchall()

    esc = _html.escape
    title = esc(ep["title"] or f"Episode {episode_id}")
    air = esc(ep["air_date"] or "")
    year = ep["year"]
    duration_min = round((ep["duration_sec"] or 0) / 60.0, 1)
    rel_path = esc(ep["rel_path"] or "")

    # Build qa lookup keyed by question_start so we can splice them into
    # the segment stream chronologically.
    qa_rows = [dict(r) for r in qa]
    qa_starts = sorted(
        (((r["question_start_sec"] or 0.0), r) for r in qa_rows),
        key=lambda x: x[0],
    )

    # Right rail summary lists
    intro_items = []
    for r in intros:
        t = _fmt_time(r["intro_time_sec"])
        name = esc(r["name"] or "?")
        role = esc(r["role_hint"] or "")
        role_html = f' <span class="muted">({role})</span>' if role else ""
        intro_items.append(
            f'<li><a href="#intro-{r["id"]}" data-seek="{r["intro_time_sec"] or 0}">'
            f'{t}</a> &middot; {name}{role_html}</li>'
        )
    intros_html = "\n".join(intro_items) or '<li class="muted">none</li>'

    qa_items = []
    for r in qa_rows:
        t = _fmt_time(r["question_start_sec"])
        score = r["usefulness_score"]
        badge = (
            f'<span class="badge s{score}" title="usefulness {score}/5">{score}</span>'
            if score is not None else ""
        )
        topic = esc(r["topic_class"] or "")
        topic_html = f'<span class="topic">{topic}</span> ' if topic else ""
        caller = esc(r["caller_name"] or "")
        caller_html = f' &middot; {caller}' if caller else ""
        first_q = _excerpt(r["question_text"] or "")[:80]
        teaser = esc(first_q)
        qa_items.append(
            f'<li><a href="#qa-{r["id"]}" data-seek="{r["question_start_sec"] or 0}">'
            f'{t}</a> {badge}{topic_html}<span class="muted">{teaser}</span>{caller_html}</li>'
        )
    qa_summary_html = "\n".join(qa_items) or '<li class="muted">none</li>'

    # Build the chronological transcript body. We walk segments and, before
    # any segment whose start_sec >= a Q&A's question_start, we emit the
    # Q&A block. (Q&A blocks contain the full question/answer text already,
    # so segment text becomes context around them.)
    body_parts: list[str] = []
    qa_iter = iter(qa_starts)
    next_qa: tuple[float, dict] | None = next(qa_iter, None)

    # Intros also get inline anchors so the right-rail jump links work
    intro_by_time = sorted(
        (((r["intro_time_sec"] or 0.0), r) for r in intros),
        key=lambda x: x[0],
    )
    intro_iter = iter(intro_by_time)
    next_intro = next(intro_iter, None)

    def _flush_inline_at(t_seg: float) -> None:
        nonlocal next_intro, next_qa
        while next_intro and next_intro[0] <= t_seg:
            ir = next_intro[1]
            tlbl = _fmt_time(ir["intro_time_sec"])
            name = esc(ir["name"] or "?")
            role = esc(ir["role_hint"] or "")
            role_html = f' <span class="muted">({role})</span>' if role else ""
            body_parts.append(
                f'<div class="intro-marker" id="intro-{ir["id"]}">'
                f'<a class="ts" href="#" data-seek="{ir["intro_time_sec"] or 0}">'
                f'{tlbl}</a> intro: <b>{name}</b>{role_html}'
                f'</div>'
            )
            next_intro = next(intro_iter, None)
        while next_qa and next_qa[0] <= t_seg:
            qr = next_qa[1]
            qstart = qr["question_start_sec"] or 0.0
            astart = qr["answer_start_sec"] or qstart
            score = qr["usefulness_score"]
            badge = (
                f'<span class="badge s{score}" title="usefulness {score}/5">{score}</span>'
                if score is not None else ""
            )
            topic = esc(qr["topic_class"] or "")
            topic_html = f'<span class="topic">{topic}</span> ' if topic else ""
            caller = esc(qr["caller_name"] or "")
            caller_html = f' &middot; <i>{caller}</i>' if caller else ""
            qbody = esc(qr["question_text"] or "")
            abody = esc(qr["answer_text"] or "")
            dim = " dim" if (score is not None and score <= 2) or qr["is_banter"] == 1 else ""
            body_parts.append(
                f'<div class="qa{dim}" id="qa-{qr["id"]}">'
                f'<div class="qa-head">{badge}{topic_html}'
                f'<a class="ts" href="#" data-seek="{qstart}">{_fmt_time(qstart)}</a>'
                f' Q&amp;A{caller_html}'
                f'<button class="play" data-seek="{qstart}">play from here</button>'
                f'<a class="btn-dl" href="/api/clip/{qr["id"]}" download>&#x2913; mp3</a>'
                f'</div>'
                f'<div class="qa-q"><b>Q:</b> {qbody}</div>'
                f'<div class="qa-a">'
                f'<a class="ts inline" href="#" data-seek="{astart}">{_fmt_time(astart)}</a>'
                f' <b>A:</b> {abody}</div>'
                f'</div>'
            )
            next_qa = next(qa_iter, None)

    for s in segments:
        t_seg = s["start_sec"] or 0.0
        _flush_inline_at(t_seg)
        seg_text = esc(s["text"] or "").strip()
        if not seg_text:
            continue
        body_parts.append(
            f'<p class="seg">'
            f'<a class="ts" href="#" data-seek="{t_seg}">{_fmt_time(t_seg)}</a> '
            f'{seg_text}</p>'
        )
    # Flush any tail intros / Q&As after final segment
    _flush_inline_at(float("inf"))

    body_html = "\n".join(body_parts) or '<p class="muted">no transcript</p>'

    qa_count = len(qa_rows)
    intro_count = len(intros)

    return EPISODE_HTML.format(
        title=title,
        episode_id=episode_id,
        year=year,
        air=air,
        duration_min=duration_min,
        rel_path=rel_path,
        qa_count=qa_count,
        intro_count=intro_count,
        intros_summary=intros_html,
        qa_summary=qa_summary_html,
        body=body_html,
    )


@app.get("/episode/{episode_id}", response_class=HTMLResponse)
def episode_page(episode_id: int):
    return _episode_html(episode_id)


INDEX_HTML = """<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Computer Guru Radio Archive</title>
<style>
  :root {
    --fg: #1a1a1a;
    --fg-muted: #666;
    --fg-subtle: #999;
    --bg: #fefefe;
    --bg-soft: #f6f5f1;
    --bg-card-hover: #fbf8ec;
    --border: #e7e5dd;
    --border-soft: #efede5;
    --link: #1853a0;
    --link-hover: #0e3f7f;
    --accent: #c39733;
    --accent-soft: #faf1d4;
    --accent-line: #e3c378;
    --s5: #2a8f43; --s4: #5aa54b; --s3: #999; --s2: #c08a3a; --s1: #b85a4a;
  }
  * { box-sizing: border-box; }
  html { scroll-behavior: smooth; }
  body { font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
         max-width: 940px; margin: 1.5em auto; padding: 0 1em;
         color: var(--fg); background: var(--bg); }
  h1 { font-size: 22px; font-weight: 600; letter-spacing: -.01em; margin: 0 0 .15em; }
  h3 { font-size: 11px; font-weight: 600; text-transform: uppercase;
       letter-spacing: .07em; color: var(--fg-muted); margin: 0 0 .5em; }
  a { color: var(--link); text-decoration: none; }
  a:hover { color: var(--link-hover); text-decoration: underline; }

  .topline { display: flex; align-items: baseline; gap: .85em;
             margin: 0 0 .9em; flex-wrap: wrap; }
  .sub { font-size: 12.5px; color: var(--fg-muted); }

  .search-wrap { position: relative; margin-bottom: .35em; }
  input[type=search] { width: 100%; padding: .65em 1em .65em 2.4em;
                       font-size: 16px; font-family: inherit; line-height: 1.4;
                       border: 1px solid var(--border); border-radius: 7px;
                       background: var(--bg) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round'><circle cx='11' cy='11' r='7'/><path d='m20 20-3.5-3.5'/></svg>") .8em center/16px no-repeat;
                       color: var(--fg);
                       transition: border-color .15s, box-shadow .15s; }
  input[type=search]:focus { outline: none; border-color: var(--link);
                              box-shadow: 0 0 0 3px rgba(24,83,160,.13); }
  input[type=search]:disabled { background-color: var(--bg-soft);
                                 color: var(--fg-subtle); cursor: not-allowed; }

  .controls { display: flex; gap: .25em .55em; align-items: center;
              margin: .5em 0 1em; flex-wrap: wrap; font-size: 13px;
              color: var(--fg-muted); }
  .controls .group { display: flex; gap: .55em; align-items: center;
                     padding: 0 .85em; border-left: 1px solid var(--border); }
  .controls .group:first-child { padding-left: 0; border-left: none; }
  .controls label { display: inline-flex; gap: .3em; align-items: center;
                    cursor: pointer; user-select: none; }
  .controls select { font: inherit; padding: 1px 4px; border: 1px solid var(--border);
                     border-radius: 3px; background: var(--bg); color: var(--fg);
                     cursor: pointer; }
  .controls input[type=checkbox], .controls input[type=radio] { accent-color: var(--link); }

  .browse-toggle { padding: 4px 10px; border: 1px solid var(--border);
                   border-radius: 4px; background: var(--bg); cursor: pointer;
                   transition: background .12s, border-color .12s, color .12s; }
  .browse-toggle:has(input:checked) { background: var(--accent-soft);
                                      border-color: var(--accent-line);
                                      color: #6f5320; font-weight: 500; }
  .browse-toggle input { display: none; }

  .group-hits { padding: .5em 0 .85em; border-top: 1px solid var(--border-soft); }
  .group-hits:first-of-type { border-top: none; padding-top: .25em; }

  a.hit-link { display: block; padding: .65em .8em; margin: .3em -.8em;
               border: 1px solid transparent; border-radius: 7px;
               color: inherit; text-decoration: none;
               transition: background .12s, border-color .12s; }
  a.hit-link:hover, a.hit-link:focus-visible {
    background: var(--bg-card-hover); border-color: var(--accent-line);
    text-decoration: none;
  }
  a.hit-link:focus-visible { outline: 2px solid var(--link); outline-offset: 1px; }
  a.hit-link:hover .arrow, a.hit-link:focus-visible .arrow {
    opacity: 1; transform: translateX(2px);
  }
  a.hit-link.dim { opacity: .55; }
  a.hit-link.dim:hover { opacity: .85; }

  .hit-head { display: flex; gap: .55em; align-items: baseline;
              flex-wrap: wrap; margin-bottom: .35em;
              font-size: 12px; color: var(--fg-muted); }
  .hit-id { display: inline-flex; gap: .35em; align-items: center;
            flex-wrap: wrap; }
  .caller { color: var(--fg); font-weight: 600; }
  .caller-none { font-style: italic; color: var(--fg-subtle); }
  .hit-where { flex: 1 1 auto; min-width: 0; }
  .ep-title { color: var(--fg); }
  .ep-time { font-variant-numeric: tabular-nums; }
  .arrow { color: var(--link); opacity: .25; padding-left: .25em;
           font-size: 14px; transition: opacity .12s, transform .12s; }

  .hit-q, .hit-a { margin: .15em 0; line-height: 1.5; color: var(--fg); }
  .hit-q .label, .hit-a .label { display: inline-block; min-width: 1.4em;
                                  padding: 0 .3em; margin-right: .25em;
                                  font-size: 10px; font-weight: 700;
                                  text-transform: uppercase; letter-spacing: .05em;
                                  vertical-align: 1px; border-radius: 2px;
                                  text-align: center; }
  .hit-q .label { background: var(--accent); color: #fff; }
  .hit-a .label { background: #ddd; color: #555; }

  .seg-hit { padding: .35em 0; }
  .seg-hit .meta { font-size: 12px; color: var(--fg-muted); }

  .badge { display: inline-block; min-width: 1.4em; padding: 0 .35em;
           font-size: 10px; font-weight: 700; text-align: center;
           border-radius: 3px; color: #fff; background: var(--s3);
           vertical-align: 1px; line-height: 1.5; }
  .badge.s5 { background: var(--s5); }
  .badge.s4 { background: var(--s4); }
  .badge.s3 { background: var(--s3); }
  .badge.s2 { background: var(--s2); }
  .badge.s1 { background: var(--s1); }

  .topic { font-size: 10px; color: var(--fg-muted); padding: 1px .4em;
           border-radius: 3px; background: var(--bg-soft);
           border: 1px solid var(--border-soft);
           text-transform: lowercase; letter-spacing: .02em; }

  mark { background: #fff2c0; padding: 0 .15em; border-radius: 2px; }

  .browse-bar { display: flex; align-items: center; justify-content: space-between;
                padding: .5em .85em; background: var(--accent-soft);
                border: 1px solid var(--accent-line); border-radius: 6px;
                margin: .5em 0 1em; font-size: 13px; color: #6f5320; }

  button.more, button.clear {
    padding: .45em 1em; font: inherit; font-size: 13px; cursor: pointer;
    background: var(--bg-soft); border: 1px solid var(--border);
    border-radius: 4px; color: var(--fg);
    transition: background .12s, border-color .12s;
  }
  button.more:hover, button.clear:hover {
    background: var(--bg-card-hover); border-color: var(--accent-line);
  }
  button.more { display: block; margin: 1em auto; min-width: 140px; }
  button.more:disabled { color: var(--fg-subtle); cursor: default; }

  button.btn-dl {
    font: inherit; font-size: 11px; padding: 1px 7px; cursor: pointer;
    border: 1px solid var(--border); border-radius: 3px;
    background: var(--bg); color: var(--fg-muted);
    transition: background .12s, color .12s;
    flex-shrink: 0;
  }
  button.btn-dl:hover { background: var(--bg-soft); color: var(--fg); }

  .loading { padding: 1.25em 0; text-align: center; color: var(--fg-subtle);
             font-style: italic; font-size: 13px; }
  .loading::after { content: '...'; animation: dots 1.4s steps(4, end) infinite;
                    display: inline-block; width: 1em; text-align: left; }
  @keyframes dots { 0%,20% { content: '.'; } 40% { content: '..'; }
                    60%,100% { content: '...'; } }

  .empty { padding: 2.5em 0; text-align: center; color: var(--fg-muted); }
  .empty p { margin: .35em 0; }
  .empty .suggest { font-size: 13px; color: var(--fg-subtle); }
  .empty button.clear { margin-top: .65em; }

  .stats { font-size: 11px; color: var(--fg-subtle); margin-top: 3em;
           padding-top: 1em; border-top: 1px solid var(--border-soft); }
</style>
<header class="topline">
  <h1>Computer Guru Radio Archive</h1>
  <span class="sub" id="sub">loading...</span>
</header>
<div class="search-wrap">
  <input type="search" id="q" autofocus
         placeholder="search transcripts and Q&amp;A — wireless, virus, BIOS...">
</div>
<div class="controls">
  <span class="group">
    <label><input type="radio" name="kind" value="both" checked> both</label>
    <label><input type="radio" name="kind" value="qa"> Q&amp;A only</label>
    <label><input type="radio" name="kind" value="segments"> transcript only</label>
  </span>
  <span class="group">
    <label>min score
      <select id="min_score">
        <option value="0">any</option>
        <option value="2">2+</option>
        <option value="3">3+</option>
        <option value="4">4+</option>
        <option value="5">5</option>
      </select>
    </label>
    <label>topic
      <select id="topic_class">
        <option value="">any</option>
        <option value="computer-help">computer-help</option>
        <option value="banter">banter</option>
        <option value="off-topic">off-topic</option>
        <option value="promo">promo</option>
        <option value="unclear">unclear</option>
      </select>
    </label>
    <label><input type="checkbox" id="exclude_banter"> hide banter</label>
  </span>
  <span class="group">
    <label class="browse-toggle">
      <input type="checkbox" id="browse_all"> browse all Q&amp;A
    </label>
  </span>
</div>
<div class="controls" id="browse_controls" style="display:none">
  <span class="group">
    <label>year
      <select id="browse_year"><option value="">any</option></select>
    </label>
    <label>sort
      <select id="browse_order">
        <option value="air_date_desc">newest first</option>
        <option value="air_date_asc">oldest first</option>
        <option value="score_desc">usefulness score</option>
      </select>
    </label>
  </span>
</div>
<div id="results"></div>
<div class="stats" id="stats"></div>
<script>
const q = document.getElementById('q');
const results = document.getElementById('results');
const sub = document.getElementById('sub');
const browseToggle = document.getElementById('browse_all');
const browseControls = document.getElementById('browse_controls');
const browseYear = document.getElementById('browse_year');
const browseOrder = document.getElementById('browse_order');
const minScoreEl = document.getElementById('min_score');
const excludeBanterEl = document.getElementById('exclude_banter');
const topicEl = document.getElementById('topic_class');

let browseOffset = 0;
const BROWSE_LIMIT = 50;
let browseTotal = 0;
let restoring = false;

// === Stats + year list ===
fetch('/api/stats').then(r => r.json()).then(s => {
  const c = s.counts;
  sub.textContent = `${c.episodes} episodes · ${c.qa_pairs.toLocaleString()} Q&A pairs · ${c.segments.toLocaleString()} segments`;
  const yrs = (s.by_year || []).map(x => x.year).sort((a,b) => b - a);
  for (const y of yrs) {
    const o = document.createElement('option');
    o.value = y; o.textContent = y;
    browseYear.appendChild(o);
  }
  restoreFromUrl();
});

// === Format helpers ===
function fmtTime(s) {
  if (s == null) return '';
  const m = Math.floor(s/60), sec = Math.floor(s%60);
  return `${m}:${sec.toString().padStart(2,'0')}`;
}
function escapeHtml(s) {
  return (s ?? '').replace(/[&<>"']/g, c => ({
    '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
  }[c]));
}

// === Hit rendering ===
function qaHitHtml(h) {
  const score = h.usefulness_score;
  const topic = h.topic_class;
  const banter = h.is_banter === 1;
  const dim = (score != null && score <= 2) || banter ? ' dim' : '';

  const badge = score != null
    ? `<span class="badge s${score}" title="usefulness ${score}/5">${score}</span>`
    : '';
  const topicTag = topic
    ? `<span class="topic">${escapeHtml(topic)}</span>`
    : '';
  const callerHtml = h.caller_name
    ? `<span class="caller">${escapeHtml(h.caller_name)}</span>`
    : `<span class="caller-none">unknown caller</span>`;

  const epTitle = escapeHtml(h.title || '');
  const epDate = h.air_date ? ` · ${escapeHtml(h.air_date)}` : '';
  const time = fmtTime(h.question_start_sec);

  const qBody = h.question_excerpt
    ? escapeHtml(h.question_excerpt)
    : (h.q_snippet || '');
  const aBody = h.answer_excerpt
    ? escapeHtml(h.answer_excerpt)
    : (h.a_snippet || '');

  const href = `/episode/${h.episode_id}#qa-${h.qa_id}`;
  const dlBtn = `<button class="btn-dl" title="Download clip as MP3"
    onclick="event.preventDefault();event.stopPropagation();location.href='/api/clip/${h.qa_id}'">&#x2913; mp3</button>`;

  return `<a class="hit-link${dim}" href="${href}">
    <div class="hit-head">
      <span class="hit-id">${badge}${topicTag}${callerHtml}</span>
      <span class="hit-where"><span class="ep-title">${epTitle}</span>${epDate} · <span class="ep-time">@ ${time}</span></span>
      ${dlBtn}
      <span class="arrow">&rarr;</span>
    </div>
    <div class="hit-q"><span class="label">Q</span>${qBody}</div>
    <div class="hit-a"><span class="label">A</span>${aBody}</div>
  </a>`;
}

function segmentHitHtml(h) {
  const ad = h.air_date ? ` · ${escapeHtml(h.air_date)}` : '';
  return `<div class="seg-hit">
    <div class="meta">${h.year} · ${escapeHtml(h.title)}${ad} · @ ${fmtTime(h.start_sec)}</div>
    <div>${h.snippet}</div>
  </div>`;
}

// === Filter helpers ===
function filtersActive() {
  return minScoreEl.value !== '0'
      || excludeBanterEl.checked
      || topicEl.value !== ''
      || (browseToggle.checked && browseYear.value !== '');
}
function clearFilters() {
  minScoreEl.value = browseToggle.checked ? '3' : '0';
  excludeBanterEl.checked = false;
  topicEl.value = '';
  if (browseToggle.checked) browseYear.value = '';
  refresh();
}
function emptyHtml(headline) {
  const fActive = filtersActive();
  const tip = fActive
    ? `<p class="suggest">Filters are active.</p>
       <button class="clear" id="clear_filters">clear filters</button>`
    : (browseToggle.checked
        ? `<p class="suggest">No Q&amp;A pairs match. Try a different year or sort.</p>`
        : `<p class="suggest">Try a different term, or browse all.</p>
           <button class="clear" id="open_browse">browse all Q&amp;A</button>`);
  return `<div class="empty"><p>${headline}</p>${tip}</div>`;
}
function wireEmptyButtons() {
  const cf = document.getElementById('clear_filters');
  if (cf) cf.onclick = clearFilters;
  const ob = document.getElementById('open_browse');
  if (ob) ob.onclick = () => browseToggle.click();
}

// === Search runner ===
async function runSearch() {
  const term = q.value.trim();
  if (term.length < 2) { results.innerHTML = ''; syncUrl(); return; }
  syncUrl();
  results.innerHTML = '<div class="loading">searching</div>';

  const kind = document.querySelector('input[name=kind]:checked').value;
  const params = new URLSearchParams({ q: term, kind, limit: '40' });
  if (minScoreEl.value !== '0') params.set('min_score', minScoreEl.value);
  if (excludeBanterEl.checked) params.set('exclude_banter', 'true');

  let j;
  try {
    const r = await fetch(`/api/search?${params}`);
    j = await r.json();
  } catch (e) {
    results.innerHTML = `<div class="empty"><p>Search failed.</p><p class="suggest">${escapeHtml(e.message || String(e))}</p></div>`;
    return;
  }

  // Client-side topic filter (the search API doesn't filter by topic_class
  // currently; cheap to apply here since results are capped at 40).
  let qa = j.qa || [];
  if (topicEl.value) qa = qa.filter(h => h.topic_class === topicEl.value);

  let html = '';
  if (qa.length) {
    html += '<section class="group-hits"><h3>Q&amp;A pairs</h3>';
    for (const h of qa) html += qaHitHtml(h);
    html += '</section>';
  }
  if ((j.segments || []).length) {
    html += '<section class="group-hits"><h3>Transcript segments</h3>';
    for (const h of j.segments) html += segmentHitHtml(h);
    html += '</section>';
  }
  if (!html) html = emptyHtml(`No hits for &ldquo;${escapeHtml(term)}&rdquo;`);
  results.innerHTML = html;
  wireEmptyButtons();
}

// === Browse runner ===
async function runBrowse(reset) {
  if (reset) browseOffset = 0;
  syncUrl();

  const params = new URLSearchParams({
    limit: String(BROWSE_LIMIT),
    offset: String(browseOffset),
    order: browseOrder.value,
  });
  if (browseYear.value) params.set('year', browseYear.value);
  if (minScoreEl.value !== '0') params.set('min_score', minScoreEl.value);
  if (excludeBanterEl.checked) params.set('exclude_banter', 'true');
  if (topicEl.value) params.set('topic_class', topicEl.value);

  if (reset) results.innerHTML = '<div class="loading">loading Q&amp;A pairs</div>';

  let j;
  try {
    const r = await fetch(`/api/qa?${params}`);
    j = await r.json();
  } catch (e) {
    results.innerHTML = `<div class="empty"><p>Load failed.</p><p class="suggest">${escapeHtml(e.message || String(e))}</p></div>`;
    return;
  }
  browseTotal = j.total;
  const items = j.items || [];

  const newRows = items.map(qaHitHtml).join('');
  const shown = Math.min(browseOffset + items.length, browseTotal);
  const header = `<div class="browse-bar"><span>Showing <b>${shown.toLocaleString()}</b> of <b>${browseTotal.toLocaleString()}</b> Q&amp;A pairs</span><span>${browseOrder.options[browseOrder.selectedIndex].text}</span></div>`;
  const moreEnabled = browseOffset + items.length < browseTotal;

  if (reset) {
    if (items.length === 0) {
      results.innerHTML = emptyHtml('No Q&amp;A pairs match these filters.');
      wireEmptyButtons();
      return;
    }
    results.innerHTML =
      `${header}<section class="group-hits" id="qa_browse"><h3>Q&amp;A pairs</h3>${newRows}</section>` +
      (moreEnabled ? '<button class="more" id="load_more">load more</button>' : '');
  } else {
    const list = document.getElementById('qa_browse');
    list.insertAdjacentHTML('beforeend', newRows);
    const bar = results.querySelector('.browse-bar');
    if (bar) bar.outerHTML = header;
    const btn = document.getElementById('load_more');
    if (btn && !moreEnabled) btn.remove();
  }
  browseOffset += items.length;
  const btn = document.getElementById('load_more');
  if (btn) btn.onclick = () => runBrowse(false);
}

// === URL state ===
function syncUrl() {
  if (restoring) return;
  const url = new URL(window.location);
  url.search = '';
  const sp = url.searchParams;
  if (browseToggle.checked) sp.set('browse', '1');
  else if (q.value.trim().length >= 2) sp.set('q', q.value.trim());
  const kind = document.querySelector('input[name=kind]:checked').value;
  if (kind !== 'both') sp.set('kind', kind);
  if (minScoreEl.value !== '0') sp.set('min_score', minScoreEl.value);
  if (excludeBanterEl.checked) sp.set('exclude_banter', '1');
  if (topicEl.value) sp.set('topic', topicEl.value);
  if (browseToggle.checked) {
    if (browseYear.value) sp.set('year', browseYear.value);
    if (browseOrder.value !== 'air_date_desc') sp.set('order', browseOrder.value);
  }
  history.replaceState(null, '', url);
}
function restoreFromUrl() {
  const sp = new URLSearchParams(window.location.search);
  if (!sp.toString()) return;
  restoring = true;
  if (sp.has('min_score')) minScoreEl.value = sp.get('min_score');
  if (sp.has('topic')) topicEl.value = sp.get('topic');
  if (sp.has('exclude_banter')) excludeBanterEl.checked = sp.get('exclude_banter') === '1';
  if (sp.has('kind')) {
    const r = document.querySelector(`input[name=kind][value=${sp.get('kind')}]`);
    if (r) r.checked = true;
  }
  if (sp.has('browse') || sp.has('year') || sp.has('order')) {
    browseToggle.checked = true;
    browseControls.style.display = 'flex';
    q.disabled = true;
    if (sp.has('year')) browseYear.value = sp.get('year');
    if (sp.has('order')) browseOrder.value = sp.get('order');
    if (minScoreEl.value === '0') minScoreEl.value = '3';
    restoring = false;
    runBrowse(true);
    return;
  }
  if (sp.has('q')) {
    q.value = sp.get('q');
    restoring = false;
    runSearch();
    return;
  }
  restoring = false;
}

// === Wiring ===
let timer;
q.addEventListener('input', () => {
  if (browseToggle.checked) return;
  clearTimeout(timer);
  timer = setTimeout(runSearch, 250);
});
document.querySelectorAll('input[name=kind]').forEach(el =>
  el.addEventListener('change', () => { if (!browseToggle.checked) runSearch(); })
);
minScoreEl.addEventListener('change', refresh);
excludeBanterEl.addEventListener('change', refresh);
topicEl.addEventListener('change', refresh);
browseToggle.addEventListener('change', () => {
  const on = browseToggle.checked;
  browseControls.style.display = on ? 'flex' : 'none';
  q.disabled = on;
  if (on) {
    if (minScoreEl.value === '0') minScoreEl.value = '3';
    runBrowse(true);
  } else {
    results.innerHTML = '';
    syncUrl();
    if (q.value.trim().length >= 2) runSearch();
  }
});
browseYear.addEventListener('change', () => runBrowse(true));
browseOrder.addEventListener('change', () => runBrowse(true));

function refresh() {
  if (browseToggle.checked) runBrowse(true);
  else if (q.value.trim().length >= 2) runSearch();
  else syncUrl();
}
</script>
</html>
"""


# Single-episode transcript view.
EPISODE_HTML = """<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} &middot; Computer Guru Radio Archive</title>
<style>
  :root {{
    --fg: #1a1a1a;
    --fg-muted: #666;
    --fg-subtle: #999;
    --bg: #fefefe;
    --bg-soft: #f6f5f1;
    --bg-stuck: rgba(254,254,254,.96);
    --border: #e7e5dd;
    --border-soft: #efede5;
    --link: #1853a0;
    --link-hover: #0e3f7f;
    --accent: #c39733;
    --accent-soft: #fbf3da;
    --accent-line: #d8c97a;
    --accent-active: #f7e2a3;
    --intro-soft: #f6f9fc;
    --intro-line: #b9d2ec;
    --s5: #2a8f43; --s4: #5aa54b; --s3: #999; --s2: #c08a3a; --s1: #b85a4a;
  }}
  * {{ box-sizing: border-box; }}
  html {{ scroll-behavior: smooth; scroll-padding-top: 110px; }}
  body {{ font: 14.5px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
         max-width: 1100px; margin: 1.25em auto; padding: 0 1em;
         color: var(--fg); background: var(--bg); }}
  a {{ color: var(--link); text-decoration: none; }}
  a:hover {{ color: var(--link-hover); text-decoration: underline; }}

  .topbar {{ position: sticky; top: 0; z-index: 20; padding: .75em 0 .65em;
            background: var(--bg-stuck); backdrop-filter: blur(6px);
            border-bottom: 1px solid var(--border-soft); margin-bottom: 1em; }}
  .topbar h1 {{ margin: 0; font-size: 19px; font-weight: 600; letter-spacing: -.005em; }}
  .topbar .meta {{ color: var(--fg-muted); font-size: 12px; margin-top: 2px; }}
  .topbar code {{ font-size: 11px; color: var(--fg-subtle);
                 background: var(--bg-soft); padding: 1px 4px; border-radius: 2px; }}
  audio {{ width: 100%; margin-top: .55em; }}
  .audio-missing {{ background: var(--accent-soft); border: 1px solid var(--accent-line);
                   padding: .5em .75em; border-radius: 4px; font-size: 13px;
                   color: #6f5320; margin-top: .5em; }}

  .layout {{ display: grid; grid-template-columns: 1fr 280px; gap: 2em;
            align-items: start; }}
  @media (max-width: 820px) {{
    .layout {{ grid-template-columns: 1fr; }}
    aside {{ position: static !important; }}
  }}

  .body p.seg {{ margin: .2em 0; line-height: 1.55; }}

  .ts {{ display: inline-block; min-width: 3.4em; color: var(--link);
        font-variant-numeric: tabular-nums; font-size: 12px;
        text-decoration: none; cursor: pointer; }}
  .ts:hover {{ text-decoration: underline; }}
  .ts.inline {{ min-width: 0; margin-right: .25em; }}

  .qa {{ background: var(--accent-soft); border-left: 3px solid var(--accent-line);
        padding: .65em .85em; margin: 1.1em 0; border-radius: 4px;
        transition: background .25s, border-color .25s, box-shadow .25s; }}
  .qa.dim {{ opacity: .55; }}
  .qa.dim:hover {{ opacity: .9; }}
  .qa.active {{ background: var(--accent-active);
                border-left-color: var(--accent);
                box-shadow: 0 0 0 1px var(--accent-line); }}
  .qa.active .now-playing {{ display: inline-block; }}
  .now-playing {{ display: none; font-size: 10px; font-weight: 700;
                 text-transform: uppercase; letter-spacing: .07em;
                 color: var(--accent); margin-left: .5em;
                 vertical-align: 1px; }}

  .qa-head {{ font-size: 12px; color: var(--fg-muted); margin-bottom: .4em;
             display: flex; gap: .35em; align-items: center; flex-wrap: wrap; }}
  .qa-q, .qa-a {{ margin: .35em 0; line-height: 1.55; color: var(--fg); }}
  .qa-q b, .qa-a b {{ display: inline-block; min-width: 1.4em; padding: 0 .3em;
                       margin-right: .25em; font-size: 10px; font-weight: 700;
                       text-transform: uppercase; letter-spacing: .05em;
                       vertical-align: 1px; border-radius: 2px;
                       text-align: center; }}
  .qa-q b {{ background: var(--accent); color: #fff; }}
  .qa-a b {{ background: #ddd; color: #555; }}

  .intro-marker {{ border-left: 3px solid var(--intro-line);
                  background: var(--intro-soft);
                  padding: .35em .65em; margin: .85em 0;
                  font-size: 13px; border-radius: 4px;
                  transition: background .25s; }}
  .intro-marker.active {{ background: #e7eff8;
                           border-left-color: #6ba0d6; }}

  .badge {{ display: inline-block; min-width: 1.4em; padding: 0 .35em;
           font-size: 10px; font-weight: 700; text-align: center;
           border-radius: 3px; color: #fff; background: var(--s3);
           vertical-align: 1px; line-height: 1.5; }}
  .badge.s5 {{ background: var(--s5); }}
  .badge.s4 {{ background: var(--s4); }}
  .badge.s3 {{ background: var(--s3); }}
  .badge.s2 {{ background: var(--s2); }}
  .badge.s1 {{ background: var(--s1); }}

  .topic {{ font-size: 10px; color: var(--fg-muted); padding: 1px .4em;
           border-radius: 3px; background: var(--bg-soft);
           border: 1px solid var(--border-soft); }}
  .muted {{ color: var(--fg-subtle); }}

  button.play {{ font-size: 11px; margin-left: auto; padding: 2px 8px;
                border: 1px solid var(--border); border-radius: 3px;
                background: var(--bg); color: var(--link); cursor: pointer;
                transition: background .12s; font-family: inherit; }}
  button.play:hover {{ background: var(--bg-soft); }}

  a.btn-dl {{ font-size: 11px; padding: 2px 8px;
              border: 1px solid var(--border); border-radius: 3px;
              background: var(--bg); color: var(--fg-muted);
              text-decoration: none;
              transition: background .12s, color .12s; }}
  a.btn-dl:hover {{ background: var(--bg-soft); color: var(--fg);
                    text-decoration: none; }}

  aside {{ position: sticky; top: 130px; max-height: calc(100vh - 150px);
          overflow-y: auto; padding-right: 4px; }}
  aside h3 {{ font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
             color: var(--fg-muted); margin: 1em 0 .4em; font-weight: 600; }}
  aside h3:first-child {{ margin-top: 0; }}
  aside ul {{ list-style: none; margin: 0; padding: 0; font-size: 12px; }}
  aside li {{ padding: .25em .35em; line-height: 1.35; border-radius: 3px;
             transition: background .15s; }}
  aside li:hover {{ background: var(--bg-soft); }}
  aside li.active {{ background: var(--accent-soft); }}
  aside a {{ color: var(--link); text-decoration: none;
            display: block; }}
  aside a:hover {{ text-decoration: none; }}

  .back-link {{ font-size: 12px; color: var(--fg-muted); }}
</style>
<div class="topbar">
  <h1>{title}</h1>
  <div class="meta">
    {year} &middot; {air} &middot; {duration_min} min &middot;
    {qa_count} Q&amp;A &middot; {intro_count} intros &middot;
    <a class="back-link" href="/">&laquo; back to search</a>
  </div>
  <div class="meta"><code>{rel_path}</code></div>
  <audio id="player" controls preload="metadata" src="/api/audio/{episode_id}"></audio>
  <div id="audio_missing" class="audio-missing" style="display:none">
    Audio file isn't on this server. Browse-only.
  </div>
</div>
<div class="layout">
  <div class="body">
    {body}
  </div>
  <aside>
    <h3>Q&amp;A pairs ({qa_count})</h3>
    <ul id="qa_index">{qa_summary}</ul>
    <h3>Intros ({intro_count})</h3>
    <ul>{intros_summary}</ul>
  </aside>
</div>
<script>
(function() {{
  const player = document.getElementById('player');
  const missing = document.getElementById('audio_missing');

  // Hide audio + show notice if the file is unavailable on this server
  player.addEventListener('error', () => {{
    player.style.display = 'none';
    missing.style.display = '';
  }});

  function seek(t) {{
    const sec = parseFloat(t);
    if (isNaN(sec)) return;
    if (player.style.display === 'none') return;
    try {{
      player.currentTime = sec;
      player.play().catch(() => {{}});
    }} catch (e) {{}}
  }}

  // Click handler for any element with data-seek
  document.body.addEventListener('click', (ev) => {{
    const el = ev.target.closest('[data-seek]');
    if (!el) return;
    const tag = el.tagName.toLowerCase();
    if (tag === 'a' && el.getAttribute('href') && !el.getAttribute('href').startsWith('#')) return;
    ev.preventDefault();
    seek(el.getAttribute('data-seek'));
    const href = el.getAttribute('href') || '';
    if (tag === 'a' && (href.startsWith('#qa-') || href.startsWith('#intro-'))) {{
      const target = document.getElementById(href.slice(1));
      if (target) target.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
    }}
  }});

  // On hash load, jump + seek
  function handleHash() {{
    const h = window.location.hash;
    if (!h) return;
    const target = document.getElementById(h.slice(1));
    if (!target) return;
    target.scrollIntoView({{ block: 'start' }});
    const seekEl = target.querySelector('[data-seek]');
    if (seekEl) {{
      const t = seekEl.getAttribute('data-seek');
      if (player.readyState >= 1) seek(t);
      else player.addEventListener('loadedmetadata', () => seek(t), {{ once: true }});
    }}
  }}
  handleHash();
  window.addEventListener('hashchange', handleHash);

  // Active-Q&A highlighting following audio playhead.
  // Build a sorted index of {{ start, end, el, indexLi }} once.
  const qaBlocks = Array.from(document.querySelectorAll('.qa[id^="qa-"]')).map(el => {{
    const startEl = el.querySelector('[data-seek]');
    const start = startEl ? parseFloat(startEl.getAttribute('data-seek')) : 0;
    // The corresponding aside list item
    const id = el.id;
    const indexLi = document.querySelector(`#qa_index a[href="#${{id}}"]`);
    return {{ el, start, indexLi: indexLi ? indexLi.parentElement : null }};
  }}).sort((a, b) => a.start - b.start);

  // Compute end as next start (capped at +180s for safety).
  for (let i = 0; i < qaBlocks.length; i++) {{
    const next = qaBlocks[i+1];
    qaBlocks[i].end = next ? Math.min(next.start, qaBlocks[i].start + 180) : qaBlocks[i].start + 180;
  }}

  let currentActive = null;
  function updateActive() {{
    const t = player.currentTime;
    if (player.paused || isNaN(t) || t === 0) {{
      if (currentActive) {{
        currentActive.el.classList.remove('active');
        if (currentActive.indexLi) currentActive.indexLi.classList.remove('active');
        currentActive = null;
      }}
      return;
    }}
    const found = qaBlocks.find(b => t >= b.start && t < b.end);
    if (found === currentActive) return;
    if (currentActive) {{
      currentActive.el.classList.remove('active');
      if (currentActive.indexLi) currentActive.indexLi.classList.remove('active');
    }}
    if (found) {{
      found.el.classList.add('active');
      if (found.indexLi) found.indexLi.classList.add('active');
    }}
    currentActive = found || null;
  }}
  player.addEventListener('timeupdate', updateActive);
  player.addEventListener('pause', updateActive);
}})();
</script>
</html>
"""


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=PORT)
