#!/usr/bin/env python3
"""
Generate a static HTML website from the USR Total Control mailing list archive.

Reads from ~/.local/share/usr-tc-archive/usr-tc-archive.db and produces a
complete static site in the site/ subdirectory.

No external dependencies required.
"""

import html
import json
import os
import re
import sqlite3
import calendar
import time
from collections import Counter, defaultdict
from pathlib import Path

DB_PATH = os.path.expanduser("~/.local/share/usr-tc-archive/usr-tc-archive.db")
SITE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "site")

MONTH_NAMES = [
    "", "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December"
]
MONTH_ABBR = [
    "", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
    "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
]


def e(text):
    """HTML-escape text, handling None."""
    if text is None:
        return ""
    return html.escape(str(text), quote=True)


def normalize_subject(subj):
    """Strip Re: and (usr-tc) prefixes for grouping."""
    s = subj or ""
    s = re.sub(r'^(Re:\s*)+', '', s, flags=re.IGNORECASE)
    s = re.sub(r'^\(usr-tc\)\s*', '', s, flags=re.IGNORECASE)
    s = re.sub(r'^(Re:\s*)+', '', s, flags=re.IGNORECASE)
    return s.strip()


def parse_date_for_sort(date_parsed):
    """Extract a sortable date string from the date_parsed field."""
    if not date_parsed:
        return "0000-00-00 00:00:00"
    # Most are in YYYY-MM-DD HH:MM:SS format
    m = re.match(r'(\d{4}-\d{2}-\d{2})', date_parsed)
    if m:
        return date_parsed
    return date_parsed


def extract_date_display(date_parsed):
    """Get a short display date."""
    if not date_parsed:
        return "Unknown"
    m = re.match(r'(\d{4}-\d{2}-\d{2})', date_parsed)
    if m:
        return m.group(1)
    return date_parsed[:24]


def format_size(size_bytes):
    """Format bytes as human-readable."""
    if size_bytes < 1024:
        return f"{size_bytes} B"
    elif size_bytes < 1024 * 1024:
        return f"{size_bytes / 1024:.1f} KB"
    else:
        return f"{size_bytes / (1024 * 1024):.1f} MB"


# ---------------------------------------------------------------------------
# CSS
# ---------------------------------------------------------------------------

def generate_css():
    return """\
:root {
    --bg: #0d1117;
    --bg-card: #161b22;
    --bg-input: #1c2129;
    --border: #30363d;
    --text: #c9d1d9;
    --text-dim: #8b949e;
    --amber: #d4a017;
    --amber-dim: #b8860b;
    --red: #c44;
    --link: #d4a017;
    --link-hover: #f0c040;
    --bar: #d4a017;
    --bar-alt: #b8860b;
    --highlight: #2d1f00;
}

*, *::before, *::after { box-sizing: border-box; }

html {
    background: var(--bg);
    color: var(--text);
    font-family: "Cascadia Mono", "Fira Mono", "Consolas", "Liberation Mono", monospace;
    font-size: 14px;
    line-height: 1.55;
}

body {
    max-width: 960px;
    margin: 0 auto;
    padding: 0 16px 60px 16px;
}

a { color: var(--link); text-decoration: none; }
a:hover { color: var(--link-hover); text-decoration: underline; }

/* --- NAV --- */
.nav {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 12px;
    padding: 12px 0;
    border-bottom: 1px solid var(--border);
    margin-bottom: 24px;
}
.nav-title {
    font-weight: bold;
    font-size: 1.1rem;
    color: var(--red);
    white-space: nowrap;
}
.nav-links { display: flex; gap: 14px; align-items: center; }
.nav-search {
    margin-left: auto;
    display: flex;
    gap: 6px;
}
.nav-search input[type="text"] {
    background: var(--bg-input);
    border: 1px solid var(--border);
    color: var(--text);
    padding: 5px 10px;
    font-family: inherit;
    font-size: 0.9rem;
    border-radius: 3px;
    width: 200px;
}
.nav-search button {
    background: var(--amber-dim);
    border: none;
    color: #000;
    padding: 5px 12px;
    font-family: inherit;
    font-size: 0.9rem;
    cursor: pointer;
    border-radius: 3px;
}
.nav-search button:hover { background: var(--amber); }

/* --- HEADINGS --- */
h1 { color: var(--red); font-size: 1.5rem; margin: 0 0 8px; }
h2 { color: var(--amber); font-size: 1.2rem; margin: 28px 0 12px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
h3 { color: var(--amber); font-size: 1rem; margin: 20px 0 8px; }

.subtitle { color: var(--text-dim); margin: 0 0 24px; font-size: 0.95rem; }

/* --- YEAR/MONTH GRID --- */
.grid-table { border-collapse: collapse; width: 100%; margin: 16px 0; }
.grid-table th {
    text-align: center;
    color: var(--amber);
    padding: 4px 2px;
    font-size: 0.8rem;
    font-weight: normal;
}
.grid-table td {
    text-align: center;
    padding: 0;
}
.grid-year {
    color: var(--amber);
    font-weight: bold;
    padding-right: 8px !important;
    text-align: right !important;
    white-space: nowrap;
    font-size: 0.95rem;
}
.grid-cell {
    display: block;
    padding: 6px 2px;
    font-size: 0.78rem;
    border: 1px solid var(--border);
    margin: 1px;
    border-radius: 2px;
    min-height: 34px;
    line-height: 1.3;
}
.grid-cell.has-msgs { cursor: pointer; }
.grid-cell.has-msgs:hover { border-color: var(--amber); }
.grid-cell.empty { color: var(--text-dim); opacity: 0.35; }

/* --- MESSAGE LIST / TOC --- */
.toc { list-style: none; padding: 0; margin: 0; }
.toc li {
    padding: 4px 0;
    border-bottom: 1px solid var(--border);
    font-size: 0.9rem;
}
.toc-subj { color: var(--link); }
.toc-meta { color: var(--text-dim); font-size: 0.82rem; }

/* --- MESSAGE --- */
.msg {
    border: 1px solid var(--border);
    border-radius: 4px;
    margin: 20px 0;
    background: var(--bg-card);
}
.msg-header {
    padding: 10px 14px;
    border-bottom: 1px solid var(--border);
    font-size: 0.88rem;
    line-height: 1.6;
}
.msg-label { color: var(--amber-dim); }
.msg-subject { color: var(--amber); font-weight: bold; font-size: 1rem; }
.msg-body {
    padding: 12px 14px;
    white-space: pre-wrap;
    word-wrap: break-word;
    overflow-wrap: break-word;
    font-size: 0.88rem;
    line-height: 1.5;
    color: var(--text);
    max-height: none;
}

/* --- STATS BARS --- */
.bar-row {
    display: flex;
    align-items: center;
    margin: 3px 0;
    font-size: 0.85rem;
}
.bar-label {
    min-width: 100px;
    text-align: right;
    padding-right: 10px;
    color: var(--text-dim);
    flex-shrink: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.bar-label-wide { min-width: 260px; }
.bar-fill {
    height: 18px;
    background: var(--bar);
    border-radius: 2px;
    min-width: 2px;
    transition: width 0.3s;
}
.bar-count {
    margin-left: 8px;
    color: var(--text-dim);
    font-size: 0.8rem;
    white-space: nowrap;
}

/* --- SEARCH RESULTS --- */
#search-area { margin: 20px 0; }
#search-box {
    width: 100%;
    background: var(--bg-input);
    border: 1px solid var(--border);
    color: var(--text);
    padding: 10px 14px;
    font-family: inherit;
    font-size: 1rem;
    border-radius: 4px;
    margin-bottom: 12px;
}
#search-box:focus { outline: none; border-color: var(--amber); }
#search-results { list-style: none; padding: 0; }
#search-results li {
    padding: 6px 0;
    border-bottom: 1px solid var(--border);
}
#search-results .sr-subj { color: var(--link); font-size: 0.95rem; }
#search-results .sr-meta { color: var(--text-dim); font-size: 0.82rem; }
#search-status { color: var(--text-dim); font-size: 0.9rem; margin: 8px 0; }
#show-more-btn {
    background: var(--amber-dim);
    color: #000;
    border: none;
    padding: 6px 18px;
    font-family: inherit;
    cursor: pointer;
    border-radius: 3px;
    margin: 12px 0;
}
#show-more-btn:hover { background: var(--amber); }

/* --- PREV/NEXT --- */
.pn { display: flex; justify-content: space-between; margin: 16px 0; font-size: 0.9rem; }
.pn a { color: var(--link); }

/* --- RESPONSIVE --- */
@media (max-width: 700px) {
    .nav { font-size: 0.85rem; }
    .nav-search input[type="text"] { width: 130px; }
    .grid-cell { font-size: 0.7rem; padding: 4px 1px; min-height: 28px; }
    .bar-label { min-width: 70px; font-size: 0.78rem; }
    .bar-label-wide { min-width: 150px; }
    .msg-body { font-size: 0.82rem; }
}
"""


# ---------------------------------------------------------------------------
# HTML helpers
# ---------------------------------------------------------------------------

def nav_html(current_page="index"):
    """Generate the top navigation bar."""
    search_action = "index.html" if current_page == "index" else "index.html"
    return f"""\
<nav class="nav">
  <a class="nav-title" href="index.html">USR-TC Archive</a>
  <div class="nav-links">
    <a href="index.html">Home</a>
    <a href="stats.html">Stats</a>
  </div>
  <form class="nav-search" action="{search_action}" method="get" onsubmit="return navSearch(this);">
    <input type="text" name="q" placeholder="Search archive..." id="nav-q">
    <button type="submit">Go</button>
  </form>
</nav>
<script>
function navSearch(form) {{
  var q = form.querySelector('input[name=q]').value.trim();
  if (!q) return false;
  window.location.href = 'index.html?q=' + encodeURIComponent(q);
  return false;
}}
</script>
"""


def page_head(title, extra_head=""):
    return f"""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{e(title)}</title>
<link rel="stylesheet" href="style.css">
{extra_head}
</head>
<body>
"""


def page_foot():
    return """\
<div style="margin-top:40px; padding-top:12px; border-top:1px solid var(--border); color:var(--text-dim); font-size:0.78rem;">
  USR Total Control Mailing List Archive &middot; Messages from 1995&ndash;2001 &middot; Generated from archived data
</div>
</body>
</html>
"""


def month_key(year, month):
    return f"{year:04d}-{month:02d}"


def month_filename(year, month):
    return f"{year:04d}-{month:02d}.html"


# ---------------------------------------------------------------------------
# Density color for grid cells
# ---------------------------------------------------------------------------

def density_color(count, max_count):
    """Return a CSS background-color based on message density."""
    if count == 0:
        return "transparent"
    # Ratio from 0.1 to 1.0
    ratio = max(0.1, count / max_count) if max_count > 0 else 0.1
    # Dark amber gradient: from very dark to medium amber
    r = int(30 + ratio * 100)
    g = int(20 + ratio * 60)
    b = int(5 + ratio * 10)
    return f"rgb({r},{g},{b})"


# ---------------------------------------------------------------------------
# Main generation
# ---------------------------------------------------------------------------

def main():
    start_time = time.time()

    print(f"Reading database: {DB_PATH}")
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    cur = conn.cursor()

    # -- Fetch all messages --
    print("Loading messages...")
    rows = cur.execute("""
        SELECT id, archive_year, archive_month, from_raw, from_email, from_name,
               subject, date_raw, date_parsed, body
        FROM messages
        ORDER BY archive_year, archive_month, date_parsed, id
    """).fetchall()
    total_messages = len(rows)
    print(f"  Loaded {total_messages} messages")

    # -- Organize by month --
    months_data = defaultdict(list)  # key -> list of row dicts
    all_months = set()
    for row in rows:
        y, m = row["archive_year"], row["archive_month"]
        mk = month_key(y, m)
        all_months.add((y, m))
        months_data[mk].append(dict(row))

    # Sorted list of (year, month) tuples
    sorted_months = sorted(all_months)
    month_counts = {month_key(y, m): len(months_data[month_key(y, m)]) for y, m in sorted_months}
    max_month_count = max(month_counts.values()) if month_counts else 1

    # Year range
    all_years = sorted(set(y for y, m in sorted_months))
    min_year, max_year = all_years[0], all_years[-1]

    # Distinct senders
    senders = set()
    for row in rows:
        em = row["from_email"]
        if em:
            senders.add(em.lower())
    sender_count = len(senders)

    # Date range display
    date_min = rows[0]["date_parsed"] or "1995"
    date_max = rows[-1]["date_parsed"] or "2001"
    date_min_disp = extract_date_display(date_min)
    date_max_disp = extract_date_display(date_max)

    # -- Stats --
    print("Computing statistics...")

    # Messages per year
    year_counts = Counter()
    for y, m in sorted_months:
        year_counts[y] += month_counts[month_key(y, m)]

    # Top posters
    poster_counts = Counter()
    for row in rows:
        name = row["from_name"] or row["from_email"] or "Unknown"
        poster_counts[name] += 1
    top_posters = poster_counts.most_common(30)

    # Top subjects (normalized)
    subject_counts = Counter()
    for row in rows:
        ns = normalize_subject(row["subject"])
        if ns:
            subject_counts[ns] += 1
    top_subjects = subject_counts.most_common(30)

    # Top 5 subjects for index overview
    top5_subjects = subject_counts.most_common(5)

    # -- Create output directory --
    os.makedirs(SITE_DIR, exist_ok=True)
    files_written = 0
    total_size = 0

    def write_file(filename, content):
        nonlocal files_written, total_size
        path = os.path.join(SITE_DIR, filename)
        with open(path, "w", encoding="utf-8") as f:
            f.write(content)
        sz = os.path.getsize(path)
        files_written += 1
        total_size += sz
        return sz

    # -- style.css --
    print("Writing style.css...")
    write_file("style.css", generate_css())

    # -- search-data.json --
    print("Writing search-data.json...")
    search_data = []
    for row in rows:
        body_snippet = (row["body"] or "")[:300].replace("\n", " ").replace("\r", "")
        search_data.append({
            "id": row["id"],
            "m": month_key(row["archive_year"], row["archive_month"]),
            "s": row["subject"] or "",
            "f": row["from_name"] or row["from_email"] or "",
            "d": extract_date_display(row["date_parsed"]),
            "b": body_snippet,
        })
    sz = write_file("search-data.json", json.dumps(search_data, ensure_ascii=False, separators=(",", ":")))
    print(f"  search-data.json: {format_size(sz)}")

    # -- index.html --
    print("Writing index.html...")
    idx = []
    idx.append(page_head("USR Total Control Mailing List Archive"))
    idx.append(nav_html("index"))

    idx.append(f'<h1>USR Total Control Mailing List Archive</h1>\n')
    idx.append(f'<p class="subtitle">{total_messages:,} messages from {sender_count:,} senders &middot; {date_min_disp} to {date_max_disp}</p>\n')

    # Search area
    idx.append("""\
<div id="search-area">
  <input type="text" id="search-box" placeholder="Search messages by subject, sender, or content..." autocomplete="off">
  <div id="search-status"></div>
  <ul id="search-results"></ul>
  <button id="show-more-btn" style="display:none;">Show more results</button>
</div>
""")

    # Year/month grid
    idx.append('<h2>Browse by Month</h2>\n')
    idx.append('<table class="grid-table">\n<thead><tr><th></th>')
    for mi in range(1, 13):
        idx.append(f'<th>{MONTH_ABBR[mi]}</th>')
    idx.append('</tr></thead>\n<tbody>\n')

    for year in range(min_year, max_year + 1):
        idx.append(f'<tr><td class="grid-year">{year}</td>')
        for mi in range(1, 13):
            mk = month_key(year, mi)
            cnt = month_counts.get(mk, 0)
            if cnt > 0:
                bg = density_color(cnt, max_month_count)
                idx.append(
                    f'<td><a class="grid-cell has-msgs" href="{month_filename(year, mi)}" '
                    f'style="background:{bg}; color:var(--amber);" title="{MONTH_NAMES[mi]} {year}: {cnt} msgs">'
                    f'{cnt}</a></td>'
                )
            else:
                idx.append(f'<td><span class="grid-cell empty">&mdash;</span></td>')
        idx.append('</tr>\n')
    idx.append('</tbody></table>\n')

    # Overview stats
    idx.append('<h2>Overview</h2>\n')
    idx.append(f'<p><strong>Total messages:</strong> {total_messages:,}<br>\n')
    idx.append(f'<strong>Unique senders:</strong> {sender_count:,}<br>\n')
    idx.append(f'<strong>Date range:</strong> {e(date_min_disp)} to {e(date_max_disp)}</p>\n')
    idx.append('<h3>Top Subjects</h3>\n<ol>\n')
    for subj, cnt in top5_subjects:
        idx.append(f'<li><a href="index.html?q={html.escape(subj, quote=True)}">{e(subj)}</a> ({cnt:,} messages)</li>\n')
    idx.append('</ol>\n')

    # Search JavaScript
    idx.append("""\
<script>
(function() {
  var data = null;
  var results = [];
  var shown = 0;
  var BATCH = 50;
  var searchBox = document.getElementById('search-box');
  var statusEl = document.getElementById('search-status');
  var listEl = document.getElementById('search-results');
  var moreBtn = document.getElementById('show-more-btn');
  var debounceTimer = null;

  function loadData() {
    if (data !== null) return Promise.resolve();
    statusEl.textContent = 'Loading search index...';
    return fetch('search-data.json')
      .then(function(r) { return r.json(); })
      .then(function(d) { data = d; statusEl.textContent = ''; })
      .catch(function() { statusEl.textContent = 'Failed to load search index.'; });
  }

  function doSearch(q) {
    if (!data || !q) { results = []; render(); return; }
    var terms = q.toLowerCase().split(/\\s+/).filter(function(t){return t.length>0;});
    results = [];
    for (var i = 0; i < data.length && results.length < 500; i++) {
      var msg = data[i];
      var hay = (msg.s + ' ' + msg.f + ' ' + msg.b).toLowerCase();
      var ok = true;
      for (var t = 0; t < terms.length; t++) {
        if (hay.indexOf(terms[t]) === -1) { ok = false; break; }
      }
      if (ok) results.push(msg);
    }
    shown = 0;
    render();
  }

  function render() {
    listEl.innerHTML = '';
    if (results.length === 0 && searchBox.value.trim()) {
      statusEl.textContent = 'No results found.';
      moreBtn.style.display = 'none';
      return;
    }
    if (results.length === 0) { statusEl.textContent = ''; moreBtn.style.display = 'none'; return; }
    var end = Math.min(shown + BATCH, results.length);
    statusEl.textContent = results.length + ' result' + (results.length !== 1 ? 's' : '') + ' found' + (results.length >= 500 ? ' (limited to 500)' : '');
    for (var i = shown; i < end; i++) {
      var msg = results[i];
      var li = document.createElement('li');
      li.innerHTML = '<a class="sr-subj" href="' + msg.m + '.html#msg-' + msg.id + '">' + esc(msg.s) + '</a><br><span class="sr-meta">' + esc(msg.f) + ' &middot; ' + esc(msg.d) + '</span>';
      listEl.appendChild(li);
    }
    shown = end;
    moreBtn.style.display = (shown < results.length) ? '' : 'none';
  }

  function esc(s) {
    var d = document.createElement('div');
    d.appendChild(document.createTextNode(s));
    return d.innerHTML;
  }

  moreBtn.addEventListener('click', function() { render(); });

  searchBox.addEventListener('input', function() {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(function() {
      var q = searchBox.value.trim();
      if (!q) { results = []; render(); return; }
      loadData().then(function() { doSearch(q); });
    }, 250);
  });

  // Handle ?q= param
  var params = new URLSearchParams(window.location.search);
  var qp = params.get('q');
  if (qp) {
    searchBox.value = qp;
    loadData().then(function() { doSearch(qp); });
  }
})();
</script>
""")

    idx.append(page_foot())
    write_file("index.html", "".join(idx))

    # -- stats.html --
    print("Writing stats.html...")
    st = []
    st.append(page_head("Stats - USR Total Control Mailing List Archive"))
    st.append(nav_html("stats"))
    st.append('<h1>Archive Statistics</h1>\n')
    st.append(f'<p class="subtitle">{total_messages:,} messages &middot; {sender_count:,} senders &middot; {date_min_disp} to {date_max_disp}</p>\n')

    # Messages per year (horizontal bars)
    st.append('<h2>Messages per Year</h2>\n')
    max_year_count = max(year_counts.values()) if year_counts else 1
    for year in sorted(year_counts.keys()):
        cnt = year_counts[year]
        pct = (cnt / max_year_count) * 100
        st.append(f'<div class="bar-row"><span class="bar-label">{year}</span>'
                   f'<div class="bar-fill" style="width:{pct:.1f}%"></div>'
                   f'<span class="bar-count">{cnt:,}</span></div>\n')

    # Messages per month
    st.append('<h2>Messages per Month</h2>\n')
    for y, m in sorted_months:
        mk = month_key(y, m)
        cnt = month_counts[mk]
        pct = (cnt / max_month_count) * 100
        label = f"{MONTH_ABBR[m]} {y}"
        st.append(f'<div class="bar-row"><span class="bar-label">{label}</span>'
                   f'<div class="bar-fill" style="width:{pct:.1f}%"></div>'
                   f'<span class="bar-count"><a href="{month_filename(y, m)}">{cnt:,}</a></span></div>\n')

    # Top 30 posters
    st.append('<h2>Top 30 Posters</h2>\n')
    if top_posters:
        max_poster = top_posters[0][1]
        for name, cnt in top_posters:
            pct = (cnt / max_poster) * 100
            st.append(f'<div class="bar-row"><span class="bar-label bar-label-wide" title="{e(name)}">{e(name)}</span>'
                       f'<div class="bar-fill" style="width:{pct:.1f}%"></div>'
                       f'<span class="bar-count">{cnt:,}</span></div>\n')

    # Top 30 subjects
    st.append('<h2>Top 30 Subjects</h2>\n')
    if top_subjects:
        max_subj = top_subjects[0][1]
        for subj, cnt in top_subjects:
            pct = (cnt / max_subj) * 100
            search_url = f'index.html?q={html.escape(subj, quote=True)}'
            st.append(f'<div class="bar-row"><span class="bar-label bar-label-wide" title="{e(subj)}">'
                       f'<a href="{search_url}">{e(subj)}</a></span>'
                       f'<div class="bar-fill" style="width:{pct:.1f}%"></div>'
                       f'<span class="bar-count">{cnt:,}</span></div>\n')

    st.append(page_foot())
    write_file("stats.html", "".join(st))

    # -- Monthly pages --
    print("Writing monthly pages...")
    for idx_m, (y, m) in enumerate(sorted_months):
        mk = month_key(y, m)
        msgs = months_data[mk]
        fname = month_filename(y, m)

        pg = []
        pg.append(page_head(f"{MONTH_NAMES[m]} {y} - USR-TC Archive"))
        pg.append(nav_html("month"))

        pg.append(f'<h1>{MONTH_NAMES[m]} {y}</h1>\n')
        pg.append(f'<p class="subtitle">{len(msgs):,} messages</p>\n')

        # Prev/Next
        prev_link = ""
        next_link = ""
        if idx_m > 0:
            py, pm = sorted_months[idx_m - 1]
            prev_link = f'<a href="{month_filename(py, pm)}">&laquo; {MONTH_NAMES[pm]} {py}</a>'
        if idx_m < len(sorted_months) - 1:
            ny, nm = sorted_months[idx_m + 1]
            next_link = f'<a href="{month_filename(ny, nm)}">{MONTH_NAMES[nm]} {ny} &raquo;</a>'
        pg.append(f'<div class="pn">{prev_link}<span></span>{next_link}</div>\n')

        # Table of contents
        pg.append('<h2>Messages</h2>\n')
        pg.append('<ul class="toc">\n')
        for msg in msgs:
            mid = msg["id"]
            subj = e(msg["subject"] or "(no subject)")
            sender = e(msg["from_name"] or msg["from_email"] or "Unknown")
            date_d = e(extract_date_display(msg["date_parsed"]))
            pg.append(f'<li><a class="toc-subj" href="#msg-{mid}">{subj}</a> '
                       f'<span class="toc-meta">&mdash; {sender}, {date_d}</span></li>\n')
        pg.append('</ul>\n')

        # Messages
        for msg in msgs:
            mid = msg["id"]
            subj = e(msg["subject"] or "(no subject)")
            sender_name = e(msg["from_name"] or "")
            sender_email = e(msg["from_email"] or "")
            if sender_name and sender_email:
                sender_disp = f"{sender_name} &lt;{sender_email}&gt;"
            elif sender_name:
                sender_disp = sender_name
            elif sender_email:
                sender_disp = sender_email
            else:
                sender_disp = "Unknown"
            date_d = e(msg["date_parsed"] or msg["date_raw"] or "Unknown")
            body = e(msg["body"] or "")

            pg.append(f'<div class="msg" id="msg-{mid}">\n')
            pg.append(f'<div class="msg-header">')
            pg.append(f'<span class="msg-label">Subject:</span> <span class="msg-subject">{subj}</span><br>')
            pg.append(f'<span class="msg-label">From:</span> {sender_disp}<br>')
            pg.append(f'<span class="msg-label">Date:</span> {date_d}')
            pg.append(f'</div>\n')
            pg.append(f'<div class="msg-body">{body}</div>\n')
            pg.append(f'</div>\n')

        # Bottom prev/next
        pg.append(f'<div class="pn">{prev_link}<span></span>{next_link}</div>\n')
        pg.append(page_foot())

        sz = write_file(fname, "".join(pg))
        print(f"  {fname}: {len(msgs)} messages, {format_size(sz)}")

    conn.close()

    elapsed = time.time() - start_time
    print()
    print(f"Done in {elapsed:.1f}s")
    print(f"Files written: {files_written}")
    print(f"Total size: {format_size(total_size)}")
    print(f"Output directory: {SITE_DIR}")


if __name__ == "__main__":
    main()
