"""Web server for browsing catalog and tagging faces."""

import contextlib
import json
import pathlib
import sqlite3
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer

from .config import DB_PATH, FACES_PER_PAGE, PROJECT_ROOT, SERVER_PORT, VIDEO_DIR
from .db import get_db

CSS = """
:root {
    --bg: #1a1a2e;
    --card: #16213e;
    --accent: #e2b33e;
    --accent2: #c47a3a;
    --text: #eee;
    --muted: #8899aa;
    --border: #2a3a5e;
    --success: #4a8a4f;
    --danger: #8a4a4a;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
    font-family: 'Georgia', 'Times New Roman', serif;
    background: var(--bg);
    color: var(--text);
    line-height: 1.6;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

nav {
    background: var(--card);
    border-bottom: 1px solid var(--border);
    padding: 12px 20px;
    position: sticky;
    top: 0;
    z-index: 100;
    display: flex;
    justify-content: space-between;
    align-items: center;
}
nav .links a {
    color: var(--muted);
    margin: 0 15px;
    font-size: 0.9em;
    text-transform: uppercase;
    letter-spacing: 1px;
}
nav .links a:hover, nav .links a.active { color: var(--accent); text-decoration: none; }
nav .title {
    color: var(--accent);
    font-size: 1.2em;
    letter-spacing: 2px;
}

.container { max-width: 1400px; margin: 0 auto; padding: 20px; }

h1 { color: var(--accent); font-weight: normal; margin-bottom: 20px; font-size: 1.8em; }
h2 { color: var(--accent); font-weight: normal; margin: 20px 0 15px; font-size: 1.4em;
     border-bottom: 1px solid var(--border); padding-bottom: 8px; }

/* Video grid */
.video-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
    gap: 20px;
}
.video-card {
    background: var(--card);
    border: 1px solid var(--border);
    border-radius: 8px;
    overflow: hidden;
    transition: border-color 0.2s;
}
.video-card:hover { border-color: var(--accent); }
.video-card .thumbs {
    display: flex;
    height: 120px;
    overflow: hidden;
}
.video-card .thumbs img {
    flex: 1;
    object-fit: cover;
    min-width: 0;
    border-right: 1px solid var(--bg);
}
.video-card .meta { padding: 12px 15px; }
.video-card .meta .num { color: var(--accent); font-size: 0.8em; letter-spacing: 1px; }
.video-card .meta h4 { color: var(--text); font-weight: normal; font-size: 1em; margin: 4px 0; }
.video-card .meta .details { color: var(--muted); font-size: 0.85em; }
.tag {
    display: inline-block;
    padding: 1px 8px;
    border-radius: 10px;
    font-size: 0.75em;
    margin-right: 4px;
    margin-top: 6px;
}
.tag-family { background: #3a6b4f; }
.tag-travel { background: #4a5a8a; }
.tag-event { background: #7a4a5a; }
.tag-novelty { background: #5a5a3a; }

/* People layout */
.people-layout { display: flex; gap: 30px; }
.people-nav {
    width: 200px;
    flex-shrink: 0;
    position: sticky;
    top: 70px;
    max-height: calc(100vh - 90px);
    overflow-y: auto;
    background: var(--card);
    border-radius: 8px;
    padding: 16px;
}
.people-nav h3 { color: var(--accent); font-weight: normal; margin-bottom: 10px; font-size: 1.1em; }
.people-nav a { display: block; color: var(--muted); font-size: 0.9em; padding: 3px 0; }
.people-nav a:hover { color: var(--accent); text-decoration: none; }
.people-main { flex: 1; min-width: 0; }
.person-section { margin-bottom: 30px; }
.person-section h2 { scroll-margin-top: 70px; }
.person-section .detail { color: var(--muted); font-size: 0.9em; margin-bottom: 4px; }
.person-section .face-thumbs { display: flex; gap: 6px; margin-top: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.person-section .face-thumbs img {
    width: 60px; height: 60px; object-fit: cover; border-radius: 4px;
    border: 1px solid var(--border);
}
.person-section .events { color: var(--text); font-size: 0.95em; margin-top: 4px; }
.person-section .events li { margin-bottom: 3px; list-style: none; }
.person-section .events li::before { content: '\\2014\\00a0'; color: var(--muted); }

/* Tagging UI */
.tag-ui { display: flex; gap: 20px; min-height: calc(100vh - 80px); }
.tag-sidebar {
    width: 260px;
    flex-shrink: 0;
    background: var(--card);
    border-radius: 8px;
    padding: 16px;
    position: sticky;
    top: 70px;
    max-height: calc(100vh - 90px);
    overflow-y: auto;
}
.tag-sidebar h3 { color: var(--accent); font-weight: normal; margin-bottom: 12px; font-size: 1.1em; }
.person-btn {
    display: block;
    width: 100%;
    padding: 8px 12px;
    margin-bottom: 6px;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 6px;
    color: var(--text);
    cursor: pointer;
    text-align: left;
    font-family: inherit;
    font-size: 0.95em;
    transition: all 0.15s;
}
.person-btn:hover { border-color: var(--accent); color: var(--accent); }
.person-btn .key {
    display: inline-block;
    width: 22px;
    height: 22px;
    line-height: 22px;
    text-align: center;
    background: var(--border);
    border-radius: 4px;
    font-size: 0.8em;
    margin-right: 8px;
    color: var(--accent);
}
.tag-main { flex: 1; }
.progress-bar {
    background: var(--card);
    border-radius: 8px;
    padding: 12px 16px;
    margin-bottom: 16px;
    display: flex;
    align-items: center;
    gap: 12px;
}
.progress-bar .bar {
    flex: 1;
    height: 8px;
    background: var(--border);
    border-radius: 4px;
    overflow: hidden;
}
.progress-bar .bar .fill { height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.3s; }
.progress-bar .text { color: var(--muted); font-size: 0.9em; white-space: nowrap; }

.face-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
    gap: 10px;
}
.face-item {
    position: relative;
    cursor: pointer;
    border-radius: 6px;
    overflow: hidden;
    border: 2px solid transparent;
    transition: all 0.15s;
}
.face-item:hover { border-color: var(--accent); }
.face-item.selected { border-color: var(--accent); box-shadow: 0 0 12px rgba(226, 179, 62, 0.4); }
.face-item img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.face-item .face-label {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    background: rgba(0,0,0,0.7);
    color: var(--text);
    font-size: 0.7em;
    padding: 2px 6px;
    text-align: center;
}

.btn {
    padding: 6px 14px;
    border: 1px solid var(--border);
    border-radius: 6px;
    background: var(--card);
    color: var(--text);
    cursor: pointer;
    font-family: inherit;
    font-size: 0.9em;
    transition: all 0.15s;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn-accent { background: var(--accent); color: #000; border-color: var(--accent); }
.btn-accent:hover { background: #c9a035; }

.new-person-form {
    margin-top: 12px;
    padding-top: 12px;
    border-top: 1px solid var(--border);
}
.new-person-form input {
    width: 100%;
    padding: 6px 10px;
    margin-bottom: 6px;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 4px;
    color: var(--text);
    font-family: inherit;
}

/* Timeline */
.timeline-layout { display: flex; gap: 30px; }
.timeline-nav {
    width: 200px;
    flex-shrink: 0;
    position: sticky;
    top: 70px;
    max-height: calc(100vh - 90px);
    overflow-y: auto;
    background: var(--card);
    border-radius: 8px;
    padding: 16px;
}
.timeline-nav h3 { color: var(--accent); font-weight: normal; margin-bottom: 10px; font-size: 1.1em; }
.timeline-nav a { display: block; color: var(--muted); font-size: 0.9em; padding: 3px 0; }
.timeline-nav a:hover { color: var(--accent); text-decoration: none; }
.timeline { position: relative; padding-left: 40px; flex: 1; min-width: 0; }
.timeline::before {
    content: ''; position: absolute; left: 15px; top: 0; bottom: 0;
    width: 2px; background: var(--border);
}
.timeline-item { position: relative; margin-bottom: 30px; }
.timeline-item::before {
    content: ''; position: absolute; left: -30px; top: 6px;
    width: 12px; height: 12px; border-radius: 50%;
    background: var(--accent); border: 2px solid var(--bg);
}
.timeline-item .year { font-size: 1.4em; color: var(--accent); scroll-margin-top: 70px; }
.timeline-item .events { color: var(--text); font-size: 0.95em; margin-top: 4px; }
.timeline-item .events li { margin-bottom: 3px; list-style: none; }
.timeline-item .events li::before { content: '\\2014\\00a0'; color: var(--muted); }

/* Places */
.places-grid {
    display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px;
    margin-bottom: 30px;
}
.place-card {
    background: var(--card); border: 1px solid var(--border);
    border-radius: 8px; overflow: hidden;
}
.place-card img { width: 100%; height: 180px; object-fit: cover; display: block; }
.place-card .info { padding: 15px; }
.place-card .info h4 { color: var(--accent); font-weight: normal; font-size: 1.1em; margin-bottom: 4px; }
.place-card .info .loc-detail { color: var(--muted); font-size: 0.9em; }

/* Video detail */
.video-detail { }
.video-detail .video-header {
    display: flex;
    gap: 20px;
    align-items: flex-start;
    margin-bottom: 24px;
}
.video-detail .video-header video { max-width: 640px; border-radius: 8px; }
.video-detail .video-info h1 { margin-bottom: 8px; }
.video-detail .video-info .meta-line { color: var(--muted); margin-bottom: 4px; }

.frame-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
    gap: 12px;
}
.frame-grid img {
    width: 100%;
    border-radius: 4px;
    cursor: pointer;
    border: 1px solid var(--border);
    transition: border-color 0.2s;
}
.frame-grid img:hover { border-color: var(--accent); }

/* Lightbox */
.lightbox {
    display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
    background: rgba(0,0,0,0.92); z-index: 1000; justify-content: center; align-items: center;
    cursor: pointer;
}
.lightbox.active { display: flex; }
.lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 4px; }

/* Toast notification */
.toast {
    position: fixed; bottom: 20px; right: 20px; background: var(--success);
    color: #fff; padding: 10px 20px; border-radius: 8px; font-size: 0.9em;
    opacity: 0; transition: opacity 0.3s; z-index: 2000;
}
.toast.show { opacity: 1; }

@media (max-width: 800px) {
    .tag-ui { flex-direction: column; }
    .tag-sidebar { width: 100%; position: static; max-height: none; }
    .video-detail .video-header { flex-direction: column; }
    .video-detail .video-header video { max-width: 100%; }
}
"""


def nav_html(active=""):
    """Generate the navigation bar HTML."""
    return f"""<nav>
    <span class="title">Dad's Home Movies</span>
    <span class="links">
        <a href="/" class="{"active" if active == "catalog" else ""}">Catalog</a>
        <a href="/timeline" class="{"active" if active == "timeline" else ""}">Timeline</a>
        <a href="/places" class="{"active" if active == "places" else ""}">Places</a>
        <a href="/people" class="{"active" if active == "people" else ""}">People</a>
        <a href="/tag" class="{"active" if active == "tag" else ""}">Tag Faces</a>
    </span>
</nav>"""


def page_html(title, body, active=""):
    """Wrap body content in a full HTML page."""
    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} - Dad's Home Movies</title>
<style>{CSS}</style>
</head>
<body>
{nav_html(active)}
{body}
</body>
</html>"""


def format_duration(seconds):
    """Format seconds as 'Xm XXs'."""
    if seconds is None:
        return "?"
    m = int(seconds // 60)
    s = int(seconds % 60)
    return f"{m}m {s:02d}s"


def catalog_page(conn):
    """Generate the main catalog page."""
    videos = conn.execute("""
        SELECT v.*, COUNT(DISTINCT f.id) as frame_count, COUNT(DISTINCT fc.id) as face_count
        FROM videos v
        LEFT JOIN frames f ON v.id = f.video_id
        LEFT JOIN faces fc ON v.id = fc.video_id
        GROUP BY v.id
        ORDER BY v.number
    """).fetchall()

    stats = conn.execute("""
        SELECT COUNT(DISTINCT v.id) as video_count,
               SUM(v.duration_seconds) as total_duration,
               COUNT(DISTINCT f.id) as frame_count,
               COUNT(DISTINCT fc.id) as face_count
        FROM videos v
        LEFT JOIN frames f ON v.id = f.video_id
        LEFT JOIN faces fc ON v.id = fc.video_id
    """).fetchone()

    total_dur = stats["total_duration"] or 0
    hours = int(total_dur // 3600)

    body = f"""
<div style="text-align:center; padding: 40px 20px; background: linear-gradient(180deg, #0f0f23, var(--bg)); border-bottom: 2px solid var(--accent);">
    <h1 style="font-size: 2.4em; letter-spacing: 2px;">Dad's Home Movies</h1>
    <div style="color: var(--muted); font-style: italic; margin-bottom: 16px;">A Family Film Archive - 1940s through 1978</div>
    <div style="display: flex; justify-content: center; gap: 40px; flex-wrap: wrap;">
        <div><span style="font-size: 2em; color: var(--accent); display: block;">{stats["video_count"]}</span><span style="font-size: 0.85em; color: var(--muted); text-transform: uppercase;">Videos</span></div>
        <div><span style="font-size: 2em; color: var(--accent); display: block;">{hours}+</span><span style="font-size: 0.85em; color: var(--muted); text-transform: uppercase;">Hours</span></div>
        <div><span style="font-size: 2em; color: var(--accent); display: block;">{stats["frame_count"]}</span><span style="font-size: 0.85em; color: var(--muted); text-transform: uppercase;">Frames</span></div>
        <div><span style="font-size: 2em; color: var(--accent); display: block;">{stats["face_count"]}</span><span style="font-size: 0.85em; color: var(--muted); text-transform: uppercase;">Faces</span></div>
    </div>
</div>
<div class="container">
    <h2>All {stats["video_count"]} Videos</h2>
    <div class="video-grid">"""

    for v in videos:
        # Get first 3 frames for thumbnails
        frames = conn.execute(
            "SELECT path FROM frames WHERE video_id = ? ORDER BY timestamp_seconds LIMIT 3",
            (v["id"],),
        ).fetchall()

        # Get tags
        tags = conn.execute(
            """
            SELECT t.name, t.category FROM tags t
            JOIN video_tags vt ON t.id = vt.tag_id
            WHERE vt.video_id = ?
        """,
            (v["id"],),
        ).fetchall()

        thumbs_html = ""
        for f in frames:
            thumbs_html += f'<img src="/static/{f["path"]}" alt="">'
        if not frames:
            thumbs_html = '<div style="flex:1; background: var(--border); display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 0.8em;">No frames yet</div>'

        tags_html = ""
        for t in tags:
            cat = t["category"] or "event"
            tags_html += f'<span class="tag tag-{cat}">{t["name"]}</span>'

        body += f"""
        <div class="video-card">
            <a href="/video/{v["id"]}" style="text-decoration:none;">
            <div class="thumbs">{thumbs_html}</div>
            </a>
            <div class="meta">
                <div class="num"><a href="/video/{v["id"]}">#{v["number"]:03d}</a></div>
                <h4>{v["title"] or v["filename"]}</h4>
                <div class="details">{format_duration(v["duration_seconds"])}{f" &bull; {v['frame_count']} frames" if v["frame_count"] else ""}{f" &bull; {v['face_count']} faces" if v["face_count"] else ""}</div>
                {tags_html}
            </div>
        </div>"""

    body += """
    </div>
</div>"""

    return page_html("Catalog", body, active="catalog")


def tag_page(conn, page=1, video_filter=None):
    """Generate the face tagging UI page."""
    # Get counts
    total_faces = conn.execute("SELECT COUNT(*) FROM faces").fetchone()[0]
    tagged_faces = conn.execute(
        "SELECT COUNT(*) FROM faces WHERE person_id IS NOT NULL"
    ).fetchone()[0]
    untagged_faces = total_faces - tagged_faces
    pct = (tagged_faces / total_faces * 100) if total_faces > 0 else 0

    # Get people for sidebar
    people = conn.execute("SELECT * FROM people ORDER BY name").fetchall()

    # Get videos that have untagged faces (for filter dropdown)
    videos_with_faces = conn.execute("""
        SELECT DISTINCT v.number, v.title, COUNT(f.id) as face_count
        FROM faces f
        JOIN videos v ON f.video_id = v.id
        WHERE f.person_id IS NULL
        GROUP BY v.id
        ORDER BY v.number
    """).fetchall()

    # Build query based on filters
    offset = (page - 1) * FACES_PER_PAGE
    if video_filter:
        faces = conn.execute(
            """
            SELECT f.id, f.path, f.video_id, v.number, v.title,
                   fr.timestamp_seconds
            FROM faces f
            JOIN videos v ON f.video_id = v.id
            JOIN frames fr ON f.frame_id = fr.id
            WHERE f.person_id IS NULL AND v.number = ?
            ORDER BY fr.timestamp_seconds, f.id
            LIMIT ? OFFSET ?
        """,
            (video_filter, FACES_PER_PAGE, offset),
        ).fetchall()
        total_in_view = conn.execute(
            "SELECT COUNT(*) FROM faces f JOIN videos v ON f.video_id = v.id WHERE f.person_id IS NULL AND v.number = ?",
            (video_filter,),
        ).fetchone()[0]
    else:
        faces = conn.execute(
            """
            SELECT f.id, f.path, f.video_id, v.number, v.title,
                   fr.timestamp_seconds
            FROM faces f
            JOIN videos v ON f.video_id = v.id
            JOIN frames fr ON f.frame_id = fr.id
            WHERE f.person_id IS NULL
            ORDER BY v.number, fr.timestamp_seconds, f.id
            LIMIT ? OFFSET ?
        """,
            (FACES_PER_PAGE, offset),
        ).fetchall()
        total_in_view = untagged_faces

    total_pages = max(1, (total_in_view + FACES_PER_PAGE - 1) // FACES_PER_PAGE)

    sidebar_html = "<h3>Assign to Person</h3>\n"
    for i, p in enumerate(people):
        key = str(i + 1) if i < 9 else ""
        key_html = f'<span class="key">{key}</span>' if key else ""
        sidebar_html += f'<button class="person-btn" data-person-id="{p["id"]}" data-key="{key}">{key_html}{p["name"]}</button>\n'

    sidebar_html += """
<div class="new-person-form">
    <input type="text" id="new-person-name" placeholder="New person name...">
    <button class="btn btn-accent" onclick="createPerson()" style="width:100%;">+ New Person</button>
</div>
<div style="margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
    <button class="btn" onclick="skipSelected()" style="width:100%; margin-bottom: 6px;">Skip (S)</button>
    <button class="btn" onclick="undoLast()" style="width:100%; margin-bottom: 6px;">Undo (Z)</button>
    <button class="btn" onclick="deleteSelected()" style="width:100%; background: var(--danger);">Delete (X)</button>
</div>"""

    # Video filter dropdown
    filter_html = '<select onchange="filterByVideo(this.value)" style="background:var(--card);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 8px;font-family:inherit;">'
    filter_html += f'<option value="">All videos ({untagged_faces} faces)</option>'
    for vf in videos_with_faces:
        selected = "selected" if video_filter == vf["number"] else ""
        filter_html += f'<option value="{vf["number"]}" {selected}>#{vf["number"]:03d} {vf["title"] or ""} ({vf["face_count"]})</option>'
    filter_html += "</select>"

    # If filtered to a single video, show the video player above the grid
    video_player_html = ""
    if video_filter:
        vf_row = conn.execute(
            "SELECT filename, title FROM videos WHERE number = ?", (video_filter,)
        ).fetchone()
        if vf_row:
            video_player_html = f"""
            <div style="margin-bottom: 16px; background: var(--card); border-radius: 8px; padding: 12px;">
                <video controls preload="metadata" style="width: 100%; max-height: 400px; border-radius: 6px;">
                    <source src="/video-file/{vf_row["filename"]}" type="video/mp4">
                </video>
            </div>"""

    # Group faces by video for display
    face_html = ""
    current_video = None
    for f in faces:
        if f["number"] != current_video:
            current_video = f["number"]
            vid_filename = conn.execute(
                "SELECT filename FROM videos WHERE number = ?", (current_video,)
            ).fetchone()
            vid_fn = vid_filename["filename"] if vid_filename else ""
            face_html += f"""<div style="grid-column: 1 / -1; margin-top: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); color: var(--accent); font-size: 0.95em; display: flex; align-items: center; gap: 8px;">
                <span>#{f["number"]:03d} &mdash; {f["title"] or "Untitled"}</span>
                <button class="btn" style="padding: 2px 8px; font-size: 0.8em;" onclick="toggleInlineVideo(this, \'{vid_fn}\')">&#9654; Play</button>
            </div>
            <div class="inline-video-container" data-video="{vid_fn}" style="grid-column: 1 / -1; display: none;"></div>"""

        ts = int(f["timestamp_seconds"]) if f["timestamp_seconds"] else 0
        ts_label = f"{ts // 60}:{ts % 60:02d}"
        face_html += f"""
        <div class="face-item" data-face-id="{f["id"]}" data-ts="{ts}" onclick="toggleFace(this)">
            <img src="/static/{f["path"]}" alt="face">
            <div class="face-label" onclick="event.stopPropagation(); seekVideo({ts})" style="cursor:pointer;" title="Seek to {ts_label}">{ts_label}</div>
        </div>"""

    if not faces and total_faces == 0:
        face_html = '<div style="text-align:center; padding: 60px; color: var(--muted);">No faces detected yet. Run <code>python3 -m videocatalog.extract</code> first.</div>'
    elif not faces:
        face_html = '<div style="text-align:center; padding: 60px; color: var(--success); font-size: 1.2em;">All faces tagged!</div>'

    # Pagination controls
    page_base = f"/tag?video={video_filter}" if video_filter else "/tag?"
    sep = "&" if video_filter else ""
    prev_link = f'<a href="{page_base}{sep}page={page - 1}" class="btn" {"style=visibility:hidden" if page <= 1 else ""}>&laquo; Prev</a>'
    next_link = f'<a href="{page_base}{sep}page={page + 1}" class="btn" {"style=visibility:hidden" if page >= total_pages else ""}>Next &raquo;</a>'
    page_info = f"Page {page} of {total_pages}"

    body = f"""
<div class="container">
    <div class="tag-ui">
        <div class="tag-sidebar">{sidebar_html}</div>
        <div class="tag-main">
            <div class="progress-bar">
                <div class="text">{tagged_faces}/{total_faces} tagged</div>
                <div class="bar"><div class="fill" style="width: {pct:.0f}%"></div></div>
                <div class="text">{untagged_faces} remaining</div>
            </div>
            <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px;">
                <div>{filter_html}</div>
                <div style="display: flex; align-items: center; gap: 12px;">
                    {prev_link}
                    <span style="color: var(--muted); font-size: 0.9em;">{page_info}</span>
                    {next_link}
                </div>
            </div>
            {video_player_html}
            <div class="face-grid" id="face-grid">{face_html}</div>
            <div style="display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
                {prev_link}
                <span style="color: var(--muted); font-size: 0.9em;">{page_info}</span>
                {next_link}
            </div>
        </div>
    </div>
</div>
<div class="toast" id="toast"></div>
<script>
let selectedFaces = new Set();
let undoStack = [];

function toggleFace(el) {{
    if (event.shiftKey && selectedFaces.size > 0) {{
        // Shift-click: select range
        const items = [...document.querySelectorAll('.face-item')];
        const lastIdx = items.findIndex(e => e.dataset.faceId === [...selectedFaces][selectedFaces.size - 1]);
        const thisIdx = items.indexOf(el);
        const [start, end] = [Math.min(lastIdx, thisIdx), Math.max(lastIdx, thisIdx)];
        for (let i = start; i <= end; i++) {{
            items[i].classList.add('selected');
            selectedFaces.add(items[i].dataset.faceId);
        }}
    }} else {{
        const id = el.dataset.faceId;
        if (selectedFaces.has(id)) {{
            selectedFaces.delete(id);
            el.classList.remove('selected');
        }} else {{
            selectedFaces.add(id);
            el.classList.add('selected');
        }}
    }}
}}

function toast(msg) {{
    const t = document.getElementById('toast');
    t.textContent = msg;
    t.classList.add('show');
    setTimeout(() => t.classList.remove('show'), 2000);
}}

async function assignPerson(personId) {{
    if (selectedFaces.size === 0) {{
        toast('Select faces first');
        return;
    }}
    const faceIds = [...selectedFaces].map(Number);
    const resp = await fetch('/api/tag-bulk', {{
        method: 'POST',
        headers: {{'Content-Type': 'application/json'}},
        body: JSON.stringify({{face_ids: faceIds, person_id: personId}})
    }});
    const data = await resp.json();
    if (data.ok) {{
        undoStack.push({{face_ids: faceIds, person_id: personId}});
        faceIds.forEach(id => {{
            const el = document.querySelector(`.face-item[data-face-id="${{id}}"]`);
            if (el) el.remove();
        }});
        selectedFaces.clear();
        toast(`Tagged ${{faceIds.length}} face(s)`);
        updateProgress(data.tagged, data.total);
        if (document.querySelectorAll('.face-item').length < 10) location.reload();
    }}
}}

function updateProgress(tagged, total) {{
    const pct = total > 0 ? (tagged / total * 100) : 0;
    document.querySelector('.progress-bar .fill').style.width = pct + '%';
    document.querySelector('.progress-bar .text:first-child').textContent = tagged + '/' + total + ' tagged';
    document.querySelector('.progress-bar .text:last-child').textContent = (total - tagged) + ' remaining';
}}

function skipSelected() {{
    selectedFaces.forEach(id => {{
        document.querySelector(`.face-item[data-face-id="${{id}}"]`)?.classList.remove('selected');
    }});
    selectedFaces.clear();
    toast('Selection cleared');
}}

async function undoLast() {{
    if (undoStack.length === 0) {{ toast('Nothing to undo'); return; }}
    const last = undoStack.pop();
    const resp = await fetch('/api/tag-bulk', {{
        method: 'POST',
        headers: {{'Content-Type': 'application/json'}},
        body: JSON.stringify({{face_ids: last.face_ids, person_id: null}})
    }});
    if ((await resp.json()).ok) {{
        toast('Undone');
        location.reload();
    }}
}}

async function deleteSelected() {{
    if (selectedFaces.size === 0) {{
        toast('Select faces first');
        return;
    }}
    const faceIds = [...selectedFaces].map(Number);
    const resp = await fetch('/api/delete-faces', {{
        method: 'POST',
        headers: {{'Content-Type': 'application/json'}},
        body: JSON.stringify({{face_ids: faceIds}})
    }});
    const data = await resp.json();
    if (data.ok) {{
        faceIds.forEach(id => {{
            const el = document.querySelector(`.face-item[data-face-id="${{id}}"]`);
            if (el) el.remove();
        }});
        selectedFaces.clear();
        toast(`Deleted ${{faceIds.length}} face(s)`);
        updateProgress(data.tagged, data.total);
        if (document.querySelectorAll('.face-item').length < 10) location.reload();
    }}
}}

async function createPerson() {{
    const name = document.getElementById('new-person-name').value.trim();
    if (!name) return;
    const resp = await fetch('/api/person', {{
        method: 'POST',
        headers: {{'Content-Type': 'application/json'}},
        body: JSON.stringify({{name: name}})
    }});
    const data = await resp.json();
    if (data.ok) {{
        toast(`Created "${{name}}"`);
        location.reload();
    }} else {{
        toast(data.error || 'Error');
    }}
}}

// Keyboard shortcuts
document.addEventListener('keydown', e => {{
    if (e.target.tagName === 'INPUT') return;
    const key = e.key.toLowerCase();
    if (key === 's') skipSelected();
    else if (key === 'x') deleteSelected();
    else if (key === 'z') undoLast();
    else if (key === 'a') {{
        // Select all visible
        document.querySelectorAll('.face-item').forEach(el => {{
            el.classList.add('selected');
            selectedFaces.add(el.dataset.faceId);
        }});
    }}
    else {{
        const btn = document.querySelector(`.person-btn[data-key="${{key}}"]`);
        if (btn) assignPerson(parseInt(btn.dataset.personId));
    }}
}});

// Click person buttons
document.querySelectorAll('.person-btn').forEach(btn => {{
    btn.addEventListener('click', () => assignPerson(parseInt(btn.dataset.personId)));
}});

function toggleInlineVideo(btn, filename) {{
    const container = btn.closest('div').nextElementSibling;
    if (container.style.display === 'none') {{
        container.innerHTML = `<video controls preload="metadata" style="width:100%;max-height:360px;border-radius:6px;margin-bottom:8px;">
            <source src="/video-file/${{filename}}" type="video/mp4"></video>`;
        container.style.display = 'block';
        btn.innerHTML = '&#9724; Hide';
    }} else {{
        container.innerHTML = '';
        container.style.display = 'none';
        btn.innerHTML = '&#9654; Play';
    }}
}}

function seekVideo(ts) {{
    // Seek the nearest visible video to the given timestamp
    const videos = document.querySelectorAll('video');
    if (videos.length > 0) {{
        const video = videos[videos.length - 1];
        video.currentTime = ts;
        video.play();
    }}
}}

function filterByVideo(val) {{
    if (val) window.location.href = '/tag?video=' + val;
    else window.location.href = '/tag';
}}

// Left/right arrow keys for pagination
document.addEventListener('keydown', e => {{
    if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
    if (e.key === 'ArrowRight') {{
        const next = document.querySelector('a.btn[href*="page="]:last-of-type');
        if (next && next.style.visibility !== 'hidden') next.click();
    }} else if (e.key === 'ArrowLeft') {{
        const prev = document.querySelector('a.btn[href*="page="]:first-of-type');
        if (prev && prev.style.visibility !== 'hidden') prev.click();
    }}
}});
</script>"""

    return page_html("Tag Faces", body, active="tag")


def people_page(conn):
    """Generate the people gallery page."""
    people = conn.execute("""
        SELECT p.*,
               COUNT(DISTINCT vp.video_id) as video_count,
               COUNT(DISTINCT f.id) as face_count
        FROM people p
        LEFT JOIN video_people vp ON p.id = vp.person_id
        LEFT JOIN faces f ON p.id = f.person_id
        GROUP BY p.id
        ORDER BY p.name
    """).fetchall()

    nav_html = ""
    sections_html = ""
    for p in people:
        nav_html += f'<a href="#person-{p["id"]}">{p["name"]}</a>\n'

        # Get face crops for this person
        face_crops = conn.execute(
            "SELECT path FROM faces WHERE person_id = ? LIMIT 8", (p["id"],)
        ).fetchall()

        faces_html = ""
        for fc in face_crops:
            faces_html += f'<img src="/static/{fc["path"]}" alt="">'

        # Get associated videos with titles and durations
        vids = conn.execute(
            """
            SELECT v.id, v.number, v.title, v.duration_seconds FROM videos v
            JOIN video_people vp ON v.id = vp.video_id
            WHERE vp.person_id = ?
            ORDER BY v.number
        """,
            (p["id"],),
        ).fetchall()

        vid_items = ""
        for v in vids:
            dur = format_duration(v["duration_seconds"])
            vid_items += f'<li><a href="/video/{v["number"]}">#{v["number"]:03d}</a> {v["title"] or "Untitled"} ({dur})</li>\n'
        vid_list = (
            f'<ul class="events">{vid_items}</ul>'
            if vids
            else '<div class="detail">No videos linked</div>'
        )

        desc = f'<div class="detail">{p["description"]}</div>' if p["description"] else ""
        sections_html += f"""
    <div class="person-section">
      <h2 id="person-{p["id"]}">{p["name"]}</h2>
      {desc}
      <div class="detail">{p["video_count"]} videos, {p["face_count"]} face crops</div>
      <div class="face-thumbs">{faces_html}</div>
      {vid_list}
    </div>"""

    body = f"""
<div class="container">
    <h1>People</h1>
    <div class="people-layout">
      <div class="people-nav"><h3>People</h3>{nav_html}</div>
      <div class="people-main">{sections_html}</div>
    </div>
</div>"""

    return page_html("People", body, active="people")


def timeline_page(conn):
    """Generate the timeline page."""
    years = conn.execute("""
        SELECT year FROM videos
        WHERE year IS NOT NULL
        GROUP BY year
        ORDER BY year
    """).fetchall()

    nav_html = ""
    for y in years:
        nav_html += f'<a href="#year-{y["year"]}">{y["year"]}</a>\n'

    timeline_html = ""
    for y in years:
        videos = conn.execute(
            """
            SELECT v.id, v.number, v.title, v.duration_seconds,
                   COUNT(DISTINCT f.id) as face_count
            FROM videos v
            LEFT JOIN faces f ON v.id = f.video_id
            WHERE v.year = ?
            GROUP BY v.id
            ORDER BY v.number
        """,
            (y["year"],),
        ).fetchall()

        items_html = ""
        for v in videos:
            dur = format_duration(v["duration_seconds"])
            face_info = f" &bull; {v['face_count']} faces" if v["face_count"] else ""
            items_html += f'<li><a href="/video/{v["id"]}">#{v["number"]:03d}</a> {v["title"] or "Untitled"} ({dur}{face_info})</li>\n'

        timeline_html += f"""
    <div class="timeline-item">
      <div class="year" id="year-{y["year"]}">{y["year"]}</div>
      <ul class="events">{items_html}</ul>
    </div>"""

    # Undated videos
    undated = conn.execute("""
        SELECT v.id, v.number, v.title, v.duration_seconds,
               COUNT(DISTINCT f.id) as face_count
        FROM videos v
        LEFT JOIN faces f ON v.id = f.video_id
        WHERE v.year IS NULL
        GROUP BY v.id
        ORDER BY v.number
    """).fetchall()

    if undated:
        nav_html += '<a href="#year-undated">Undated</a>\n'
        items_html = ""
        for v in undated:
            dur = format_duration(v["duration_seconds"])
            face_info = f" &bull; {v['face_count']} faces" if v["face_count"] else ""
            items_html += f'<li><a href="/video/{v["id"]}">#{v["number"]:03d}</a> {v["title"] or "Untitled"} ({dur}{face_info})</li>\n'
        timeline_html += f"""
    <div class="timeline-item">
      <div class="year" id="year-undated">Undated</div>
      <ul class="events">{items_html}</ul>
    </div>"""

    body = f"""
<div class="container">
    <h1>Timeline</h1>
    <div class="timeline-layout">
      <div class="timeline-nav"><h3>Years</h3>{nav_html}</div>
      <div class="timeline">{timeline_html}</div>
    </div>
</div>"""

    return page_html("Timeline", body, active="timeline")


def places_page(conn):
    """Generate the places page grouped by region."""
    regions = conn.execute("""
        SELECT DISTINCT COALESCE(region, 'Other') as region
        FROM places
        ORDER BY region
    """).fetchall()

    places_html = ""
    for r in regions:
        region = r["region"]
        pls = conn.execute(
            """
            SELECT pl.*, COUNT(DISTINCT vpl.video_id) as video_count
            FROM places pl
            LEFT JOIN video_places vpl ON pl.id = vpl.place_id
            WHERE COALESCE(pl.region, 'Other') = ?
            GROUP BY pl.id
            ORDER BY pl.name
        """,
            (region,),
        ).fetchall()

        places_html += f'<h2>{region}</h2>\n<div class="places-grid">\n'
        for pl in pls:
            # Get a representative frame
            place_frame = conn.execute(
                """
                SELECT f.path FROM frames f
                JOIN video_places vp ON f.video_id = vp.video_id
                WHERE vp.place_id = ?
                ORDER BY f.timestamp_seconds
                LIMIT 1
            """,
                (pl["id"],),
            ).fetchone()
            img = (
                f'<img src="/static/{place_frame["path"]}" alt="{pl["name"]}">'
                if place_frame
                else '<div style="height:180px;background:var(--border);display:flex;align-items:center;justify-content:center;color:var(--muted);">No image</div>'
            )

            # Get linked videos
            pl_vids = conn.execute(
                """
                SELECT v.id, v.number, v.title FROM videos v
                JOIN video_places vpl ON v.id = vpl.video_id
                WHERE vpl.place_id = ?
                ORDER BY v.number
            """,
                (pl["id"],),
            ).fetchall()
            vid_links = ", ".join(
                f'<a href="/video/{v["id"]}">#{v["number"]:03d}</a>' for v in pl_vids
            )

            places_html += f"""
    <div class="place-card">
      {img}
      <div class="info">
        <h4>{pl["name"]}</h4>
        <div class="loc-detail">{pl["description"] or ""}</div>
        <div class="loc-detail" style="margin-top: 6px;">Videos: {vid_links}</div>
      </div>
    </div>"""
        places_html += "</div>\n"

    body = f"""
<div class="container">
    <h1>Places</h1>
    {places_html}
</div>"""

    return page_html("Places", body, active="places")


def video_detail_page(conn, video_id):
    """Generate a single video detail page."""
    video = conn.execute("SELECT * FROM videos WHERE id = ?", (video_id,)).fetchone()
    if not video:
        # Try by number
        video = conn.execute("SELECT * FROM videos WHERE number = ?", (video_id,)).fetchone()
    if not video:
        return page_html("Not Found", '<div class="container"><h1>Video not found</h1></div>')

    frames = conn.execute(
        "SELECT * FROM frames WHERE video_id = ? ORDER BY timestamp_seconds",
        (video["id"],),
    ).fetchall()

    faces = conn.execute(
        """
        SELECT f.*, p.name as person_name
        FROM faces f
        LEFT JOIN people p ON f.person_id = p.id
        WHERE f.video_id = ?
        ORDER BY f.id
    """,
        (video["id"],),
    ).fetchall()

    tags = conn.execute(
        """
        SELECT t.name, t.category FROM tags t
        JOIN video_tags vt ON t.id = vt.tag_id
        WHERE vt.video_id = ?
    """,
        (video["id"],),
    ).fetchall()

    places = conn.execute(
        """
        SELECT pl.name, pl.region FROM places pl
        JOIN video_places vpl ON pl.id = vpl.place_id
        WHERE vpl.video_id = ?
    """,
        (video["id"],),
    ).fetchall()

    people_in_video = conn.execute(
        """
        SELECT p.name FROM people p
        JOIN video_people vp ON p.id = vp.person_id
        WHERE vp.video_id = ?
    """,
        (video["id"],),
    ).fetchall()

    tags_html = " ".join(
        f'<span class="tag tag-{t["category"] or "event"}">{t["name"]}</span>' for t in tags
    )
    places_html = ", ".join(f"{p['name']}" for p in places) if places else "Unknown"
    people_html = (
        ", ".join(p["name"] for p in people_in_video) if people_in_video else "None identified"
    )

    frames_html = ""
    for f in frames:
        ts = f["timestamp_seconds"]
        frames_html += f'<div><img src="/static/{f["path"]}" alt="frame at {ts:.0f}s" onclick="showLightbox(this.src)"><div style="text-align:center;color:var(--muted);font-size:0.8em;margin-top:4px;">{ts:.0f}s</div></div>'

    faces_html = ""
    for f in faces:
        label = f["person_name"] or "Untagged"
        faces_html += f'<div class="face-item"><img src="/static/{f["path"]}" alt="{label}"><div class="face-label">{label}</div></div>'

    body = f"""
<div class="container video-detail">
    <div class="video-header">
        <video controls preload="metadata" style="max-width: 640px; border-radius: 8px;">
            <source src="/video-file/{video["filename"]}" type="video/mp4">
        </video>
        <div class="video-info">
            <h1>#{video["number"]:03d} {video["title"] or video["filename"]}</h1>
            <div class="meta-line">Duration: {format_duration(video["duration_seconds"])}</div>
            <div class="meta-line">Year: {video["year"] or "Unknown"}</div>
            <div class="meta-line">Places: {places_html}</div>
            <div class="meta-line">People: {people_html}</div>
            <div style="margin-top: 8px;">{tags_html}</div>
            <div class="meta-line" style="margin-top: 12px;">{len(frames)} frames extracted, {len(faces)} faces detected</div>
        </div>
    </div>

    {"<h2>Faces</h2><div class='face-grid' style='grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));'>" + faces_html + "</div>" if faces else ""}

    <h2>All Frames</h2>
    <div class="frame-grid">{frames_html}</div>
</div>
<div class="lightbox" id="lightbox" onclick="this.classList.remove('active')">
    <img id="lightbox-img" src="" alt="">
</div>
<script>
function showLightbox(src) {{
    document.getElementById('lightbox-img').src = src;
    document.getElementById('lightbox').classList.add('active');
}}
document.addEventListener('keydown', e => {{
    if (e.key === 'Escape') document.getElementById('lightbox').classList.remove('active');
}});
</script>"""

    return page_html(f"#{video['number']:03d} {video['title']}", body, active="catalog")


class CatalogHandler(BaseHTTPRequestHandler):
    """HTTP request handler for the catalog server."""

    def log_message(self, format, *args):  # noqa: D102
        pass

    def handle_one_request(self):  # noqa: D102
        with contextlib.suppress(BrokenPipeError, ConnectionResetError):
            super().handle_one_request()

    def send_html(self, html, status=200):
        """Send an HTML response."""
        self.send_response(status)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        self.wfile.write(html.encode())

    def send_json(self, data, status=200):
        """Send a JSON response."""
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(data).encode())

    def serve_static(self, path):
        """Serve a static file (frame or face image)."""
        file_path = PROJECT_ROOT / path
        if not file_path.exists() or not file_path.is_file():
            self.send_error(404)
            return

        # Basic extension -> content type mapping
        ext = file_path.suffix.lower()
        ct = {
            ".jpg": "image/jpeg",
            ".jpeg": "image/jpeg",
            ".png": "image/png",
            ".gif": "image/gif",
        }.get(ext, "application/octet-stream")

        self.send_response(200)
        self.send_header("Content-Type", ct)
        self.send_header("Cache-Control", "public, max-age=86400")
        size = file_path.stat().st_size
        self.send_header("Content-Length", str(size))
        self.end_headers()
        with open(file_path, "rb") as f:
            self.wfile.write(f.read())

    def serve_video(self, filename):
        """Serve a video file with range request support."""
        video_path = VIDEO_DIR / filename
        if not video_path.exists():
            self.send_error(404)
            return

        file_size = video_path.stat().st_size
        range_header = self.headers.get("Range")

        if range_header:
            # Parse range header
            range_str = range_header.replace("bytes=", "")
            start_str, end_str = range_str.split("-")
            start = int(start_str)
            end = int(end_str) if end_str else file_size - 1
            length = end - start + 1

            self.send_response(206)
            self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
            self.send_header("Content-Length", str(length))
        else:
            start = 0
            length = file_size
            self.send_response(200)
            self.send_header("Content-Length", str(file_size))

        self.send_header("Content-Type", "video/mp4")
        self.send_header("Accept-Ranges", "bytes")
        self.end_headers()

        with open(video_path, "rb") as f:
            f.seek(start)
            remaining = length
            chunk_size = 65536
            while remaining > 0:
                chunk = f.read(min(chunk_size, remaining))
                if not chunk:
                    break
                self.wfile.write(chunk)
                remaining -= len(chunk)

    def do_GET(self):
        """Handle GET requests."""
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path

        conn = get_db()
        try:
            if path in ("/", ""):
                self.send_html(catalog_page(conn))

            elif path == "/tag":
                params = urllib.parse.parse_qs(parsed.query)
                page = int(params.get("page", [1])[0])
                video_filter = params.get("video", [None])[0]
                if video_filter:
                    video_filter = int(video_filter)
                self.send_html(tag_page(conn, page=page, video_filter=video_filter))

            elif path == "/people":
                self.send_html(people_page(conn))

            elif path == "/timeline":
                self.send_html(timeline_page(conn))

            elif path == "/places":
                self.send_html(places_page(conn))

            elif path.startswith("/video/"):
                try:
                    vid_id = int(path.split("/")[2])
                except (IndexError, ValueError):
                    self.send_error(404)
                    return
                self.send_html(video_detail_page(conn, vid_id))

            elif path.startswith("/static/"):
                static_path = path[8:]  # Remove /static/
                self.serve_static(static_path)

            elif path.startswith("/video-file/"):
                filename = urllib.parse.unquote(path[12:])
                self.serve_video(filename)

            else:
                self.send_error(404)
        finally:
            conn.close()

    def do_POST(self):
        """Handle POST requests for the API."""
        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)
        try:
            data = json.loads(body) if body else {}
        except json.JSONDecodeError:
            self.send_json({"ok": False, "error": "Invalid JSON"}, 400)
            return

        conn = get_db()
        try:
            if self.path == "/api/tag":
                face_id = data.get("face_id")
                person_id = data.get("person_id")
                conn.execute(
                    "UPDATE faces SET person_id = ? WHERE id = ?",
                    (person_id, face_id),
                )
                conn.execute(
                    """INSERT OR IGNORE INTO video_people (video_id, person_id)
                    SELECT DISTINCT video_id, ? FROM faces WHERE id = ?""",
                    (person_id, face_id),
                )
                conn.commit()
                self.send_json({"ok": True})

            elif self.path == "/api/tag-bulk":
                face_ids = data.get("face_ids", [])
                person_id = data.get("person_id")
                for fid in face_ids:
                    conn.execute(
                        "UPDATE faces SET person_id = ? WHERE id = ?",
                        (person_id, fid),
                    )
                conn.execute(
                    """INSERT OR IGNORE INTO video_people (video_id, person_id)
                    SELECT DISTINCT video_id, ? FROM faces
                    WHERE person_id = ?""",
                    (person_id, person_id),
                )
                conn.commit()
                total = conn.execute("SELECT COUNT(*) FROM faces").fetchone()[0]
                tagged = conn.execute(
                    "SELECT COUNT(*) FROM faces WHERE person_id IS NOT NULL"
                ).fetchone()[0]
                self.send_json({"ok": True, "tagged": tagged, "total": total})

            elif self.path == "/api/delete-faces":
                face_ids = data.get("face_ids", [])
                for fid in face_ids:
                    row = conn.execute("SELECT path FROM faces WHERE id = ?", (fid,)).fetchone()
                    if row:
                        img_path = pathlib.Path(__file__).resolve().parent.parent / row["path"]
                        if img_path.exists():
                            img_path.unlink()
                    conn.execute("DELETE FROM faces WHERE id = ?", (fid,))
                conn.commit()
                total = conn.execute("SELECT COUNT(*) FROM faces").fetchone()[0]
                tagged = conn.execute(
                    "SELECT COUNT(*) FROM faces WHERE person_id IS NOT NULL"
                ).fetchone()[0]
                self.send_json({"ok": True, "tagged": tagged, "total": total})

            elif self.path == "/api/person":
                name = data.get("name", "").strip()
                desc = data.get("description", "").strip()
                if not name:
                    self.send_json({"ok": False, "error": "Name required"}, 400)
                    return
                try:
                    cursor = conn.execute(
                        "INSERT INTO people (name, description) VALUES (?, ?)",
                        (name, desc),
                    )
                    conn.commit()
                    self.send_json({"ok": True, "id": cursor.lastrowid})
                except sqlite3.IntegrityError:
                    self.send_json({"ok": False, "error": "Person already exists"}, 409)

            else:
                self.send_json({"ok": False, "error": "Not found"}, 404)
        finally:
            conn.close()


def main():
    """Start the catalog web server."""
    port = SERVER_PORT
    if len(sys.argv) > 1:
        with contextlib.suppress(ValueError):
            port = int(sys.argv[1])

    server = HTTPServer(("", port), CatalogHandler)
    print(f"Dad's Video Catalog server running at http://localhost:{port}")
    print(f"  Catalog:  http://localhost:{port}/")
    print(f"  Tag faces: http://localhost:{port}/tag")
    print(f"  People:   http://localhost:{port}/people")
    print(f"  DB: {DB_PATH}")
    print()
    print("Press Ctrl+C to stop.")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nShutting down.")
        server.shutdown()


if __name__ == "__main__":
    main()
