"""Static dashboard generator for GitHub Pages — Living Standards Observatory."""

from __future__ import annotations

import json
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path

from .config import Config
from .db import Database
from .models import Rating

console = None


def _get_console():
    global console
    if console is None:
        from rich.console import Console

        console = Console()
    return console


class DashboardGenerator:
    """Generate a static GitHub Pages site under docs/."""

    def __init__(self, config: Config | None = None, db: Database | None = None):
        self.config = config or Config.load()
        self.db = db or Database(self.config)
        self.output_dir = Path(self.config.dashboard_dir)

    def generate(self) -> str:
        """Generate full static site. Returns path to docs/."""
        con = _get_console()
        self.output_dir.mkdir(parents=True, exist_ok=True)
        (self.output_dir / "observatory").mkdir(exist_ok=True)
        (self.output_dir / "data").mkdir(exist_ok=True)
        (self.output_dir / "assets").mkdir(exist_ok=True)

        con.print("[bold]Generating dashboard...[/]")

        self._generate_data_files()
        con.print("  [green]OK[/] Data files")

        self._generate_style()
        con.print("  [green]OK[/] Styles")

        self._generate_index()
        con.print("  [green]OK[/] Index page")

        self._generate_explorer()
        con.print("  [green]OK[/] Explorer page")

        self._generate_gaps_page()
        con.print("  [green]OK[/] Gaps page")

        self._generate_timeline_page()
        con.print("  [green]OK[/] Timeline page")

        con.print(f"\n[bold green]Dashboard generated at {self.output_dir}/[/]")
        return str(self.output_dir)

    # ── Data files ──────────────────────────────────────────────────────────

    def _generate_data_files(self) -> None:
        """Write JSON data files to docs/data/."""
        data_dir = self.output_dir / "data"

        # observatory.json — key metrics
        total = self.db.count_drafts()
        sources = self.db.all_sources()
        gaps = self.db.all_gaps()
        snapshots = self.db.get_snapshots(limit=1)
        unrated = len(self.db.unrated_drafts(limit=10000))
        idea_count = self.db.idea_count()
        author_count = self.db.author_count()

        observatory_data = {
            "total_docs": total,
            "sources": {s["name"]: s["doc_count"] for s in sources},
            "gaps_count": len(gaps),
            "unrated": unrated,
            "ideas": idea_count,
            "authors": author_count,
            "last_update": snapshots[0]["snapshot_at"] if snapshots else None,
        }
        (data_dir / "observatory.json").write_text(json.dumps(observatory_data, indent=2))

        # drafts.json — all docs with ratings
        pairs = self.db.drafts_with_ratings(limit=1000)
        drafts_data = []
        for d, r in pairs:
            drafts_data.append({
                "name": d.name,
                "title": d.title,
                "date": d.date,
                "source": d.source or "ietf",
                "url": d.source_url or d.datatracker_url,
                "pages": d.pages or 0,
                "group": d.group or "individual",
                "score": round(r.composite_score, 2),
                "novelty": r.novelty,
                "maturity": r.maturity,
                "overlap": r.overlap,
                "momentum": r.momentum,
                "relevance": r.relevance,
                "categories": r.categories,
                "summary": r.summary,
                "novelty_note": r.novelty_note,
                "maturity_note": r.maturity_note,
                "overlap_note": r.overlap_note,
                "momentum_note": r.momentum_note,
                "relevance_note": r.relevance_note,
                "doc_status": d.doc_status or "",
            })
        (data_dir / "drafts.json").write_text(json.dumps(drafts_data, indent=2))

        # gaps.json — current gaps + history
        gap_history = self.db.gap_history_timeline()
        gaps_data = {
            "current": gaps,
            "history": gap_history,
        }
        (data_dir / "gaps.json").write_text(json.dumps(gaps_data, indent=2))

        # timeline.json — monthly counts by source and category
        all_drafts = self.db.list_drafts(limit=2000, order_by="time ASC")
        rating_map = {d.name: r for d, r in pairs}
        monthly: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
        monthly_source: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))

        for d in all_drafts:
            month = d.time[:7] if d.time else "unknown"
            src = d.source or "ietf"
            monthly_source[month][src] += 1
            r = rating_map.get(d.name)
            if r:
                for c in r.categories:
                    monthly[month][c] += 1

        months = sorted(set(list(monthly.keys()) + list(monthly_source.keys())))
        all_cats: set[str] = set()
        for mc in monthly.values():
            all_cats.update(mc.keys())
        all_sources_set: set[str] = set()
        for ms in monthly_source.values():
            all_sources_set.update(ms.keys())

        timeline_data = {
            "months": months,
            "by_category": {m: dict(monthly.get(m, {})) for m in months},
            "by_source": {m: dict(monthly_source.get(m, {})) for m in months},
            "categories": sorted(all_cats),
            "sources": sorted(all_sources_set),
        }
        (data_dir / "timeline.json").write_text(json.dumps(timeline_data, indent=2))

        # meta.json
        meta = {
            "generated_at": datetime.now(timezone.utc).isoformat(),
            "version": "0.3.0",
            "project": "IETF Living Standards Observatory",
        }
        (data_dir / "meta.json").write_text(json.dumps(meta, indent=2))

    # ── Style ───────────────────────────────────────────────────────────────

    def _generate_style(self) -> None:
        """Shared CSS."""
        css = """\
:root {
  --bg: #f5f7fa;
  --card-bg: #ffffff;
  --text: #1a1a2e;
  --text-dim: #666;
  --accent: #4a6cf7;
  --accent-light: rgba(74,108,247,0.1);
  --green: #10b981;
  --orange: #f59e0b;
  --red: #ef4444;
  --border: #e5e7eb;
  --shadow: 0 1px 4px rgba(0,0,0,0.08);
  --radius: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: var(--bg); color: var(--text);
  line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

/* Layout */
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.header {
  background: var(--card-bg); border-bottom: 1px solid var(--border);
  padding: 16px 0; margin-bottom: 24px;
}
.header .container { display: flex; align-items: center; justify-content: space-between; }
.header h1 { font-size: 1.3rem; }
.header nav { display: flex; gap: 20px; font-size: 0.9rem; }
.header nav a { color: var(--text-dim); font-weight: 500; }
.header nav a:hover, .header nav a.active { color: var(--accent); text-decoration: none; }

/* Cards */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.card {
  background: var(--card-bg); border-radius: var(--radius);
  padding: 20px; box-shadow: var(--shadow);
}
.card .label { font-size: 0.8rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
.card .value { font-size: 2rem; font-weight: 700; margin-top: 4px; }
.card .sub { font-size: 0.8rem; color: var(--text-dim); margin-top: 4px; }

/* Tables */
.panel {
  background: var(--card-bg); border-radius: var(--radius);
  box-shadow: var(--shadow); overflow: hidden; margin-bottom: 24px;
}
.panel-header { padding: 16px 20px; border-bottom: 1px solid var(--border); font-weight: 600; }
table { width: 100%; border-collapse: collapse; }
th {
  background: #f8f9fb; padding: 10px 12px; text-align: left;
  font-size: 0.78rem; color: var(--text-dim); cursor: pointer; user-select: none;
  white-space: nowrap; border-bottom: 2px solid var(--border);
}
th:hover { color: var(--accent); }
td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; font-size: 0.83rem; vertical-align: top; }
tr:hover { background: #fafbff; }

/* Controls */
.controls {
  background: var(--card-bg); border-radius: var(--radius);
  padding: 16px 20px; margin-bottom: 16px; box-shadow: var(--shadow);
}
.controls-row { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
.controls-row:last-child { margin-bottom: 0; }
.search-box {
  flex: 1; min-width: 250px; padding: 8px 14px;
  border: 1px solid var(--border); border-radius: 6px;
  font-size: 0.9rem; outline: none;
}
.search-box:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-light); }
.slider-group { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; color: var(--text-dim); }
.slider-group input[type=range] { width: 100px; cursor: pointer; }
.slider-val { font-weight: 600; min-width: 24px; text-align: center; }

/* Chips */
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
  display: inline-block; padding: 3px 10px; border-radius: 12px;
  font-size: 0.75rem; cursor: pointer; border: 1px solid var(--border);
  background: var(--card-bg); transition: all 0.15s; user-select: none;
}
.chip.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.chip:hover { border-color: var(--accent); }

/* Badges */
.score-badge {
  display: inline-block; padding: 2px 8px; border-radius: 10px;
  font-weight: 600; font-size: 0.8rem;
}
.score-high { background: #d4edda; color: #155724; }
.score-mid { background: #fff3cd; color: #856404; }
.score-low { background: #f8d7da; color: #721c24; }
.cat-badge {
  display: inline-block; padding: 1px 7px; border-radius: 8px;
  font-size: 0.68rem; margin: 1px 2px; background: #e8eaf6; color: #3949ab;
}
.source-badge {
  display: inline-block; padding: 1px 7px; border-radius: 8px;
  font-size: 0.68rem; margin: 1px 2px;
}
.source-ietf { background: #e3f2fd; color: #1565c0; }
.source-w3c { background: #fce4ec; color: #c62828; }

/* Severity */
.sev-critical { color: var(--red); font-weight: 600; }
.sev-high { color: var(--orange); font-weight: 600; }
.sev-medium { color: var(--text); }
.sev-low { color: var(--text-dim); }

/* Bar */
.bar { display: inline-block; height: 10px; border-radius: 3px; background: var(--accent); vertical-align: middle; }

/* Detail */
.detail-row td { padding: 12px 20px; background: #f8faff; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; max-width: 800px; }
.detail-item { font-size: 0.82rem; }
.detail-item strong { color: #333; }
.detail-item .note { color: var(--text-dim); font-size: 0.78rem; }
.summary-text { font-size: 0.82rem; color: #444; margin-top: 6px; line-height: 1.4; }

/* Chart container */
.chart-container {
  background: var(--card-bg); border-radius: var(--radius);
  box-shadow: var(--shadow); padding: 20px; margin-bottom: 24px;
}

/* Gap cards */
.gap-card {
  background: var(--card-bg); border-radius: var(--radius);
  border-left: 4px solid var(--accent); padding: 16px 20px;
  box-shadow: var(--shadow); margin-bottom: 12px;
}
.gap-card h3 { font-size: 0.95rem; margin-bottom: 4px; }
.gap-card p { font-size: 0.83rem; color: var(--text-dim); margin-bottom: 4px; }
.gap-card .meta { font-size: 0.75rem; color: var(--text-dim); }
.gap-card.critical { border-left-color: var(--red); }
.gap-card.high { border-left-color: var(--orange); }

.dim { font-size: 0.75rem; color: var(--text-dim); }
.clickable { cursor: pointer; }
.reset-btn {
  padding: 4px 12px; border: 1px solid var(--border); border-radius: 6px;
  background: var(--card-bg); cursor: pointer; font-size: 0.78rem; color: var(--text-dim);
}
.reset-btn:hover { border-color: var(--accent); color: var(--accent); }
.result-count { font-size: 0.85rem; color: var(--text-dim); margin: 10px 0 8px; }

/* Timeline bars */
.tl-bar {
  display: inline-block; height: 16px; border-radius: 3px;
  vertical-align: middle; min-width: 2px;
}

@media (max-width: 768px) {
  .cards { grid-template-columns: 1fr 1fr; }
  .controls-row { flex-direction: column; align-items: stretch; }
  .detail-grid { grid-template-columns: 1fr; }
}
"""
        (self.output_dir / "assets" / "style.css").write_text(css)

    # ── Shared HTML pieces ──────────────────────────────────────────────────

    def _header_html(self, active: str = "") -> str:
        def active_cls(page: str) -> str:
            return ' class="active"' if page == active else ""

        return f"""\
<div class="header">
  <div class="container">
    <h1>Living Standards Observatory</h1>
    <nav>
      <a href="../index.html"{active_cls("index")}>Dashboard</a>
      <a href="explorer.html"{active_cls("explorer")}>Explorer</a>
      <a href="gaps.html"{active_cls("gaps")}>Gaps</a>
      <a href="timeline.html"{active_cls("timeline")}>Timeline</a>
    </nav>
  </div>
</div>"""

    def _index_header_html(self) -> str:
        return """\
<div class="header">
  <div class="container">
    <h1>Living Standards Observatory</h1>
    <nav>
      <a href="index.html" class="active">Dashboard</a>
      <a href="observatory/explorer.html">Explorer</a>
      <a href="observatory/gaps.html">Gaps</a>
      <a href="observatory/timeline.html">Timeline</a>
    </nav>
  </div>
</div>"""

    # ── Index page ──────────────────────────────────────────────────────────

    def _generate_index(self) -> None:
        """Landing page with key metrics dashboard."""
        html = f"""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Living Standards Observatory</title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
{self._index_header_html()}
<div class="container">

<div class="cards" id="metricsCards">
  <div class="card"><div class="label">Total Documents</div><div class="value" id="totalDocs">--</div><div class="sub" id="sourceSub"></div></div>
  <div class="card"><div class="label">Standards Bodies</div><div class="value" id="sourceCount">--</div><div class="sub">Active sources</div></div>
  <div class="card"><div class="label">Open Gaps</div><div class="value" id="gapCount">--</div><div class="sub">Identified coverage gaps</div></div>
  <div class="card"><div class="label">Ideas Extracted</div><div class="value" id="ideaCount">--</div><div class="sub">Technical contributions</div></div>
  <div class="card"><div class="label">Authors Tracked</div><div class="value" id="authorCount">--</div><div class="sub">Individual contributors</div></div>
  <div class="card"><div class="label">Last Update</div><div class="value" id="lastUpdate" style="font-size:1rem">--</div><div class="sub" id="updateSub"></div></div>
</div>

<div class="panel">
  <div class="panel-header">Top Rated Documents</div>
  <table>
    <thead>
      <tr><th>Score</th><th>Document</th><th>Source</th><th>Date</th><th>Categories</th></tr>
    </thead>
    <tbody id="topDrafts"></tbody>
  </table>
</div>

<div class="panel">
  <div class="panel-header">Critical &amp; High Severity Gaps</div>
  <div id="gapsList" style="padding: 16px;"></div>
</div>

</div>

<script>
function escHtml(s) {{ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }}
function scoreBadge(s) {{
  const cls = s >= 4.0 ? 'score-high' : s >= 3.0 ? 'score-mid' : 'score-low';
  return '<span class="score-badge ' + cls + '">' + s.toFixed(1) + '</span>';
}}

async function init() {{
  const [obs, drafts, gaps] = await Promise.all([
    fetch('data/observatory.json').then(r => r.json()),
    fetch('data/drafts.json').then(r => r.json()),
    fetch('data/gaps.json').then(r => r.json()),
  ]);

  // Metrics
  document.getElementById('totalDocs').textContent = obs.total_docs;
  const srcNames = Object.keys(obs.sources || {{}});
  document.getElementById('sourceCount').textContent = srcNames.length || 1;
  document.getElementById('sourceSub').textContent = srcNames.map(s => s.toUpperCase() + ': ' + (obs.sources[s] || 0)).join(' | ') || '';
  document.getElementById('gapCount').textContent = obs.gaps_count;
  document.getElementById('ideaCount').textContent = obs.ideas;
  document.getElementById('authorCount').textContent = obs.authors;
  if (obs.last_update) {{
    document.getElementById('lastUpdate').textContent = obs.last_update.substring(0, 10);
  }}

  // Top drafts
  const top = drafts.sort((a, b) => b.score - a.score).slice(0, 15);
  const tbody = document.getElementById('topDrafts');
  top.forEach(d => {{
    const tr = document.createElement('tr');
    const srcClass = 'source-' + (d.source || 'ietf');
    tr.innerHTML =
      '<td>' + scoreBadge(d.score) + '</td>' +
      '<td><a href="' + escHtml(d.url) + '" target="_blank">' + escHtml(d.name) + '</a><br><span class="dim">' + escHtml(d.title.substring(0,80)) + '</span></td>' +
      '<td><span class="source-badge ' + srcClass + '">' + (d.source || 'ietf').toUpperCase() + '</span></td>' +
      '<td class="dim">' + d.date + '</td>' +
      '<td>' + d.categories.map(c => '<span class="cat-badge">' + escHtml(c) + '</span>').join('') + '</td>';
    tbody.appendChild(tr);
  }});

  // Gaps
  const gapsList = document.getElementById('gapsList');
  const critical = (gaps.current || []).filter(g => g.severity === 'critical' || g.severity === 'high');
  if (critical.length === 0) {{
    gapsList.innerHTML = '<p class="dim">No critical or high severity gaps found.</p>';
  }} else {{
    critical.forEach(g => {{
      const cls = g.severity === 'critical' ? 'critical' : 'high';
      gapsList.innerHTML +=
        '<div class="gap-card ' + cls + '">' +
        '<h3>' + escHtml(g.topic) + '</h3>' +
        '<p>' + escHtml(g.description) + '</p>' +
        '<div class="meta"><span class="sev-' + g.severity + '">' + g.severity.toUpperCase() + '</span> &middot; ' + escHtml(g.category || '') + '</div>' +
        '</div>';
    }});
  }}
}}
init();
</script>
</body>
</html>"""
        (self.output_dir / "index.html").write_text(html)

    # ── Explorer page ───────────────────────────────────────────────────────

    def _generate_explorer(self) -> None:
        """Multi-source draft browser with search, filters, score sliders."""
        html = f"""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Explorer - Living Standards Observatory</title>
<link rel="stylesheet" href="../assets/style.css">
</head>
<body>
{self._header_html("explorer")}
<div class="container">

<div class="controls">
  <div class="controls-row">
    <input type="text" class="search-box" id="searchBox" placeholder="Search by name, title, summary, or keyword...">
    <select id="sourceFilter" style="padding:8px;border:1px solid var(--border);border-radius:6px;font-size:0.85rem">
      <option value="">All sources</option>
    </select>
    <div class="slider-group">Min score: <input type="range" id="minScore" min="1" max="5" step="0.1" value="1"><span class="slider-val" id="minScoreVal">1.0</span></div>
    <div class="slider-group">Min novelty: <input type="range" id="minNovelty" min="1" max="5" step="1" value="1"><span class="slider-val" id="minNoveltyVal">1</span></div>
    <div class="slider-group">Max overlap: <input type="range" id="maxOverlap" min="1" max="5" step="1" value="5"><span class="slider-val" id="maxOverlapVal">5</span></div>
    <button class="reset-btn" onclick="resetFilters()">Reset</button>
  </div>
  <div class="controls-row">
    <div class="chip-row" id="catChips"></div>
  </div>
</div>

<div class="result-count" id="resultCount"></div>

<table>
  <thead>
    <tr>
      <th onclick="sortBy('score')" width="60">Score <span class="sort-arrow" id="sort-score"></span></th>
      <th onclick="sortBy('name')">Draft <span class="sort-arrow" id="sort-name"></span></th>
      <th onclick="sortBy('source')" width="60">Src <span class="sort-arrow" id="sort-source"></span></th>
      <th onclick="sortBy('date')" width="90">Date <span class="sort-arrow" id="sort-date"></span></th>
      <th onclick="sortBy('novelty')" width="30">N</th>
      <th onclick="sortBy('maturity')" width="30">M</th>
      <th onclick="sortBy('overlap')" width="30">O</th>
      <th onclick="sortBy('momentum')" width="30">Mom</th>
      <th onclick="sortBy('relevance')" width="30">R</th>
      <th>Categories</th>
    </tr>
  </thead>
  <tbody id="tableBody"></tbody>
</table>

</div>

<script>
let DRAFTS = [];
let ALL_CATS = [];
let activeCats = new Set();
let sortField = 'score';
let sortAsc = false;
let expandedRow = null;

function escHtml(s) {{ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }}
function scoreBadge(s) {{
  const cls = s >= 4.0 ? 'score-high' : s >= 3.0 ? 'score-mid' : 'score-low';
  return '<span class="score-badge ' + cls + '">' + s.toFixed(1) + '</span>';
}}
function dimBar(v) {{ return '<span class="bar" style="width:' + (v * 12) + 'px"></span> ' + v; }}

const searchBox = document.getElementById('searchBox');
const sourceFilter = document.getElementById('sourceFilter');
const minScore = document.getElementById('minScore');
const minNovelty = document.getElementById('minNovelty');
const maxOverlap = document.getElementById('maxOverlap');

searchBox.oninput = render;
sourceFilter.onchange = render;
minScore.oninput = () => {{ document.getElementById('minScoreVal').textContent = parseFloat(minScore.value).toFixed(1); render(); }};
minNovelty.oninput = () => {{ document.getElementById('minNoveltyVal').textContent = minNovelty.value; render(); }};
maxOverlap.oninput = () => {{ document.getElementById('maxOverlapVal').textContent = maxOverlap.value; render(); }};

function resetFilters() {{
  searchBox.value = '';
  sourceFilter.value = '';
  minScore.value = 1; document.getElementById('minScoreVal').textContent = '1.0';
  minNovelty.value = 1; document.getElementById('minNoveltyVal').textContent = '1';
  maxOverlap.value = 5; document.getElementById('maxOverlapVal').textContent = '5';
  activeCats.clear();
  document.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
  sortField = 'score'; sortAsc = false;
  render();
}}

function sortBy(field) {{
  if (sortField === field) sortAsc = !sortAsc;
  else {{ sortField = field; sortAsc = field === 'name' || field === 'date'; }}
  render();
}}

function cmp(a, b) {{
  let va = a[sortField], vb = b[sortField];
  if (typeof va === 'string') return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
  return sortAsc ? va - vb : vb - va;
}}

function render() {{
  const q = searchBox.value.toLowerCase().trim();
  const src = sourceFilter.value;
  const ms = parseFloat(minScore.value);
  const mn = parseInt(minNovelty.value);
  const mo = parseInt(maxOverlap.value);

  let filtered = DRAFTS.filter(d => {{
    if (d.score < ms) return false;
    if (d.novelty < mn) return false;
    if (d.overlap > mo) return false;
    if (src && (d.source || 'ietf') !== src) return false;
    if (activeCats.size > 0 && !d.categories.some(c => activeCats.has(c))) return false;
    if (q) {{
      const hay = (d.name + ' ' + d.title + ' ' + d.summary + ' ' + d.categories.join(' ')).toLowerCase();
      const words = q.split(/\\s+/);
      if (!words.every(w => hay.includes(w))) return false;
    }}
    return true;
  }});

  filtered.sort(cmp);

  document.querySelectorAll('.sort-arrow').forEach(el => el.textContent = '');
  const arrow = document.getElementById('sort-' + sortField);
  if (arrow) arrow.textContent = sortAsc ? '\\u25B2' : '\\u25BC';

  const tbody = document.getElementById('tableBody');
  tbody.innerHTML = '';
  expandedRow = null;

  filtered.forEach(d => {{
    const tr = document.createElement('tr');
    tr.className = 'clickable';
    const srcClass = 'source-' + (d.source || 'ietf');
    tr.innerHTML =
      '<td>' + scoreBadge(d.score) + '</td>' +
      '<td style="max-width:300px"><a href="' + escHtml(d.url) + '" target="_blank" onclick="event.stopPropagation()" style="color:var(--accent);font-weight:500">' + escHtml(d.name) + '</a>' +
        '<br><span class="dim">' + escHtml(d.title.substring(0, 80)) + '</span></td>' +
      '<td><span class="source-badge ' + srcClass + '">' + (d.source || 'ietf').toUpperCase() + '</span></td>' +
      '<td class="dim">' + d.date + '</td>' +
      '<td>' + dimBar(d.novelty) + '</td>' +
      '<td>' + dimBar(d.maturity) + '</td>' +
      '<td>' + dimBar(d.overlap) + '</td>' +
      '<td>' + dimBar(d.momentum) + '</td>' +
      '<td>' + dimBar(d.relevance) + '</td>' +
      '<td>' + d.categories.map(c => '<span class="cat-badge">' + escHtml(c) + '</span>').join('') + '</td>';

    tr.onclick = () => toggleDetail(tr, d);
    tbody.appendChild(tr);
  }});

  document.getElementById('resultCount').textContent =
    'Showing ' + filtered.length + ' of ' + DRAFTS.length + ' drafts';
}}

function toggleDetail(tr, d) {{
  if (expandedRow) {{
    expandedRow.previousElementSibling?.classList.remove('expanded');
    expandedRow.remove();
    if (expandedRow._draftName === d.name) {{ expandedRow = null; return; }}
  }}
  tr.classList.add('expanded');
  const detail = document.createElement('tr');
  detail.className = 'detail-row';
  detail._draftName = d.name;
  function detailItem(label, score, note) {{
    return '<div class="detail-item"><strong>' + label + ':</strong> ' + score + '/5 ' +
      '<span class="bar" style="width:' + (score * 16) + 'px"></span>' +
      (note ? '<div class="note">' + escHtml(note) + '</div>' : '') + '</div>';
  }}
  detail.innerHTML = '<td colspan="10">' +
    '<div class="summary-text"><strong>Summary:</strong> ' + escHtml(d.summary) + '</div>' +
    '<div class="detail-grid" style="margin-top:10px">' +
      detailItem('Novelty', d.novelty, d.novelty_note) +
      detailItem('Maturity', d.maturity, d.maturity_note) +
      detailItem('Overlap', d.overlap, d.overlap_note) +
      detailItem('Momentum', d.momentum, d.momentum_note) +
      detailItem('Relevance', d.relevance, d.relevance_note) +
      '<div class="detail-item"><strong>Source:</strong> ' + (d.source || 'ietf').toUpperCase() + ' &middot; <strong>Pages:</strong> ' + d.pages + '</div>' +
    '</div>' +
    '<div style="margin-top:8px"><a href="' + escHtml(d.url) + '" target="_blank" style="color:var(--accent)">Open document \\u2192</a></div>' +
  '</td>';
  tr.after(detail);
  expandedRow = detail;
}}

async function init() {{
  DRAFTS = await fetch('../data/drafts.json').then(r => r.json());

  // Build categories
  const catSet = new Set();
  const sources = new Set();
  DRAFTS.forEach(d => {{
    d.categories.forEach(c => catSet.add(c));
    sources.add(d.source || 'ietf');
  }});
  ALL_CATS = [...catSet].sort();

  // Source filter options
  sources.forEach(s => {{
    const opt = document.createElement('option');
    opt.value = s;
    opt.textContent = s.toUpperCase();
    sourceFilter.appendChild(opt);
  }});

  // Category chips
  const chipBox = document.getElementById('catChips');
  ALL_CATS.forEach(cat => {{
    const el = document.createElement('span');
    el.className = 'chip';
    const count = DRAFTS.filter(d => d.categories.includes(cat)).length;
    el.innerHTML = escHtml(cat) + '<span style="font-size:0.65rem;opacity:0.7;margin-left:2px">(' + count + ')</span>';
    el.onclick = () => {{
      if (activeCats.has(cat)) {{ activeCats.delete(cat); el.classList.remove('active'); }}
      else {{ activeCats.add(cat); el.classList.add('active'); }}
      render();
    }};
    chipBox.appendChild(el);
  }});

  render();
}}
init();
</script>
</body>
</html>"""
        (self.output_dir / "observatory" / "explorer.html").write_text(html)

    # ── Gaps page ───────────────────────────────────────────────────────────

    def _generate_gaps_page(self) -> None:
        """Gap tracker with fill-status over time."""
        html = f"""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gaps - Living Standards Observatory</title>
<link rel="stylesheet" href="../assets/style.css">
</head>
<body>
{self._header_html("gaps")}
<div class="container">

<h2 style="margin-bottom:16px">Coverage Gaps</h2>
<p class="dim" style="margin-bottom:20px">Areas, problems, or technical challenges not adequately addressed by existing standards documents.</p>

<div class="controls">
  <div class="controls-row">
    <select id="sevFilter" style="padding:8px;border:1px solid var(--border);border-radius:6px;font-size:0.85rem">
      <option value="">All severities</option>
      <option value="critical">Critical</option>
      <option value="high">High</option>
      <option value="medium">Medium</option>
      <option value="low">Low</option>
    </select>
    <input type="text" class="search-box" id="gapSearch" placeholder="Filter gaps..." style="max-width:400px">
  </div>
</div>

<div id="gapsList"></div>

<h2 style="margin:32px 0 16px">Gap History</h2>
<p class="dim" style="margin-bottom:20px">How gaps have evolved across observatory snapshots.</p>
<div class="panel">
  <table>
    <thead>
      <tr><th>Snapshot</th><th>Topic</th><th>Severity</th><th>Status</th></tr>
    </thead>
    <tbody id="historyBody"></tbody>
  </table>
</div>

</div>

<script>
function escHtml(s) {{ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }}

let GAPS_DATA = null;

function renderGaps() {{
  const sev = document.getElementById('sevFilter').value;
  const q = document.getElementById('gapSearch').value.toLowerCase().trim();
  const list = document.getElementById('gapsList');
  list.innerHTML = '';

  let current = GAPS_DATA.current || [];
  if (sev) current = current.filter(g => g.severity === sev);
  if (q) current = current.filter(g => (g.topic + ' ' + g.description + ' ' + (g.category || '')).toLowerCase().includes(q));

  if (current.length === 0) {{
    list.innerHTML = '<p class="dim" style="padding:16px">No gaps match the current filters.</p>';
    return;
  }}

  const order = {{'critical': 0, 'high': 1, 'medium': 2, 'low': 3}};
  current.sort((a, b) => (order[a.severity] || 2) - (order[b.severity] || 2));

  current.forEach(g => {{
    const cls = (g.severity === 'critical' || g.severity === 'high') ? g.severity : '';
    list.innerHTML +=
      '<div class="gap-card ' + cls + '">' +
      '<h3>' + escHtml(g.topic) + '</h3>' +
      '<p>' + escHtml(g.description) + '</p>' +
      '<div class="meta">' +
        '<span class="sev-' + g.severity + '">' + (g.severity || 'medium').toUpperCase() + '</span>' +
        (g.category ? ' &middot; ' + escHtml(g.category) : '') +
        (g.evidence ? '<br><em>' + escHtml(g.evidence) + '</em>' : '') +
      '</div></div>';
  }});
}}

async function init() {{
  GAPS_DATA = await fetch('../data/gaps.json').then(r => r.json());

  document.getElementById('sevFilter').onchange = renderGaps;
  document.getElementById('gapSearch').oninput = renderGaps;
  renderGaps();

  // History table
  const history = GAPS_DATA.history || [];
  const tbody = document.getElementById('historyBody');
  if (history.length === 0) {{
    tbody.innerHTML = '<tr><td colspan="4" class="dim">No history recorded yet.</td></tr>';
  }} else {{
    history.slice(-50).reverse().forEach(h => {{
      const tr = document.createElement('tr');
      tr.innerHTML =
        '<td class="dim">' + (h.snapshot_at || h.recorded_at || '').substring(0, 10) + '</td>' +
        '<td>' + escHtml(h.gap_topic) + '</td>' +
        '<td><span class="sev-' + (h.severity || 'medium') + '">' + (h.severity || 'medium').toUpperCase() + '</span></td>' +
        '<td>' + escHtml(h.status || 'open') + '</td>';
      tbody.appendChild(tr);
    }});
  }}
}}
init();
</script>
</body>
</html>"""
        (self.output_dir / "observatory" / "gaps.html").write_text(html)

    # ── Timeline page ───────────────────────────────────────────────────────

    def _generate_timeline_page(self) -> None:
        """Submission timeline across sources."""
        html = f"""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timeline - Living Standards Observatory</title>
<link rel="stylesheet" href="../assets/style.css">
<style>
.tl-row {{ display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f0f0f0; }}
.tl-month {{ min-width: 80px; font-size: 0.82rem; color: var(--text-dim); font-family: monospace; }}
.tl-bars {{ flex: 1; display: flex; gap: 1px; align-items: center; }}
.tl-count {{ min-width: 30px; text-align: right; font-size: 0.78rem; color: var(--text-dim); }}
.legend {{ display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }}
.legend-item {{ display: flex; align-items: center; gap: 4px; font-size: 0.8rem; }}
.legend-swatch {{ width: 14px; height: 14px; border-radius: 3px; }}
.view-toggle {{ display: flex; gap: 8px; margin-bottom: 16px; }}
.view-btn {{ padding: 6px 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--card-bg); cursor: pointer; font-size: 0.82rem; }}
.view-btn.active {{ background: var(--accent); color: #fff; border-color: var(--accent); }}
</style>
</head>
<body>
{self._header_html("timeline")}
<div class="container">

<h2 style="margin-bottom:8px">Submission Timeline</h2>
<p class="dim" style="margin-bottom:20px">Monthly document submissions across standards bodies and categories.</p>

<div class="view-toggle">
  <button class="view-btn active" id="btnSource" onclick="setView('source')">By Source</button>
  <button class="view-btn" id="btnCategory" onclick="setView('category')">By Category</button>
</div>

<div class="legend" id="legend"></div>

<div class="chart-container" id="timeline"></div>

<div class="panel">
  <div class="panel-header">Monthly Totals</div>
  <table>
    <thead><tr><th>Month</th><th>Total</th><th id="breakdownHeader">By Source</th></tr></thead>
    <tbody id="monthTable"></tbody>
  </table>
</div>

</div>

<script>
function escHtml(s) {{ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }}

const COLORS_SOURCE = {{'ietf': '#4a6cf7', 'w3c': '#ef4444', 'ieee': '#10b981', 'other': '#9ca3af'}};
const COLORS_CAT = [
  '#636EFA', '#EF553B', '#00CC96', '#AB63FA', '#FFA15A',
  '#19D3F3', '#FF6692', '#B6E880', '#FF97FF', '#FECB52',
  '#7C8CF5', '#FF8C69', '#66CDAA', '#BA55D3', '#FFD700',
];

let TL_DATA = null;
let currentView = 'source';

function setView(view) {{
  currentView = view;
  document.getElementById('btnSource').className = 'view-btn' + (view === 'source' ? ' active' : '');
  document.getElementById('btnCategory').className = 'view-btn' + (view === 'category' ? ' active' : '');
  document.getElementById('breakdownHeader').textContent = view === 'source' ? 'By Source' : 'By Category';
  renderTimeline();
}}

function renderTimeline() {{
  if (!TL_DATA) return;
  const months = TL_DATA.months;
  const isSource = currentView === 'source';
  const dataMap = isSource ? TL_DATA.by_source : TL_DATA.by_category;
  const keys = isSource ? TL_DATA.sources : TL_DATA.categories;

  // Assign colors
  const colorMap = {{}};
  if (isSource) {{
    keys.forEach(k => {{ colorMap[k] = COLORS_SOURCE[k] || '#9ca3af'; }});
  }} else {{
    keys.forEach((k, i) => {{ colorMap[k] = COLORS_CAT[i % COLORS_CAT.length]; }});
  }}

  // Max for scaling
  let maxTotal = 0;
  months.forEach(m => {{
    const d = dataMap[m] || {{}};
    let t = 0;
    keys.forEach(k => {{ t += d[k] || 0; }});
    if (t > maxTotal) maxTotal = t;
  }});
  const scale = maxTotal > 0 ? 500 / maxTotal : 1;

  // Legend
  const legendEl = document.getElementById('legend');
  legendEl.innerHTML = '';
  keys.forEach(k => {{
    legendEl.innerHTML += '<div class="legend-item"><div class="legend-swatch" style="background:' + colorMap[k] + '"></div>' + escHtml(k) + '</div>';
  }});

  // Chart
  const container = document.getElementById('timeline');
  container.innerHTML = '';
  months.forEach(m => {{
    const d = dataMap[m] || {{}};
    let total = 0;
    keys.forEach(k => {{ total += d[k] || 0; }});

    let barsHtml = '';
    keys.forEach(k => {{
      const v = d[k] || 0;
      if (v > 0) {{
        const w = Math.max(v * scale, 2);
        barsHtml += '<div class="tl-bar" style="width:' + w + 'px;background:' + colorMap[k] + '" title="' + escHtml(k) + ': ' + v + '"></div>';
      }}
    }});

    container.innerHTML += '<div class="tl-row"><span class="tl-month">' + m + '</span><div class="tl-bars">' + barsHtml + '</div><span class="tl-count">' + total + '</span></div>';
  }});

  // Table
  const tbody = document.getElementById('monthTable');
  tbody.innerHTML = '';
  [...months].reverse().forEach(m => {{
    const d = dataMap[m] || {{}};
    let total = 0;
    const parts = [];
    keys.forEach(k => {{
      const v = d[k] || 0;
      total += v;
      if (v > 0) parts.push(k + ': ' + v);
    }});
    if (total > 0) {{
      const tr = document.createElement('tr');
      tr.innerHTML = '<td class="dim">' + m + '</td><td>' + total + '</td><td class="dim">' + parts.join(', ') + '</td>';
      tbody.appendChild(tr);
    }}
  }});
}}

async function init() {{
  TL_DATA = await fetch('../data/timeline.json').then(r => r.json());
  renderTimeline();
}}
init();
</script>
</body>
</html>"""
        (self.output_dir / "observatory" / "timeline.html").write_text(html)
