#!/usr/bin/env python3
"""
RingAI – Call Analytics Dashboard

Scans dataset/calls/ and generates a single, self-contained HTML report.

Usage
-----
    cd ~/my/fyp/ringai
    python dashboard.py              # writes dashboard.html
    python dashboard.py --serve      # also serves on http://localhost:8080
"""

from __future__ import annotations

import argparse
import csv
import http.server
import json
import os
import sys
from collections import defaultdict
from datetime import datetime
from pathlib import Path

# ─── paths ────────────────────────────────────────────────────────
HERE       = Path(__file__).resolve().parent
PROJECT_ROOT = HERE.parent  # Go up from analytics/ to ringai/
CALLS_ROOT = PROJECT_ROOT / "dataset" / "calls"
OUTPUT     = PROJECT_ROOT / "dashboard.html"

# ─── intent normalisation  (raw LLM intent ──► display group) ─────
INTENT_MAP: dict[str, str] = {
    # ERP
    "erp_inquiry": "ERP", "erp_cloud": "ERP", "erp_demo": "ERP",
    "erp_on premise": "ERP", "erp_features": "ERP", "traffic_erp": "ERP",
    # CRM
    "crm_inquiry": "CRM", "crm_features": "CRM",
    # Pricing
    "pricing": "Pricing", "pricing_inquiry": "Pricing",
    # Scheduling
    "schedule_call": "Scheduling", "date_request": "Scheduling",
    # Closing
    "closing": "Closing", "goodbye": "Closing",
    "farewell": "Closing", "thank_you": "Closing",
    # Introduction
    "introduction": "Introduction", "greeting": "Introduction",
    "initial": "Introduction",
    # Clarification
    "clarification": "Clarification", "clarification_needed": "Clarification",
    "needs_clarification": "Clarification",
    # General / info
    "general": "General", "general_request": "General",
    "general_discussion": "General", "general_concern": "General",
    "general_inquiry": "General", "info_request": "General",
    "service_information": "General", "feature_details": "General",
    "data_package_info": "General", "implementation_details": "General",
    "software_name": "General", "solution_power": "General",
    # Escalation
    "contact": "Escalation", "contact_agent": "Escalation",
    "human_agent": "Escalation",
    # Explicit unknowns
    "none": "Unknown", "unknown": "Unknown",
}

GROUP_COLORS: dict[str, str] = {
    "ERP":           "#6366f1",
    "CRM":           "#8b5cf6",
    "Pricing":       "#f59e0b",
    "Scheduling":    "#10b981",
    "Closing":       "#ec4899",
    "Introduction":  "#3b82f6",
    "Clarification": "#f97316",
    "General":       "#64748b",
    "Escalation":    "#ef4444",
    "Other":         "#a3a3a3",
    "Unknown":       "#475569",
}


# ─── helpers ──────────────────────────────────────────────────────
def norm(raw: str) -> str:
    """Raw intent string  →  display-group name."""
    if not raw or not raw.strip():
        return "Unknown"
    return INTENT_MAP.get(raw.strip().lower(), "Other")


def safe_json(obj) -> str:
    """JSON-encode for embedding inside a <script> tag."""
    return json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")


# ─── loading ──────────────────────────────────────────────────────
def load_calls() -> list[dict]:
    if not CALLS_ROOT.is_dir():
        return []
    return [_load_one(d) for d in sorted(CALLS_ROOT.iterdir()) if d.is_dir()]


def _load_one(cd: Path) -> dict:
    rec: dict = {
        "call_id":          cd.name,
        "first_timestamp":  None,
        "duration_sec":     0,
        "utterances":       [],
        "llm_events":       [],
        "final_transcript": "",
        "has_merged":       (cd / "audio" / "merged.wav").is_file(),
    }

    # metadata.csv  →  duration (chunk count) + first timestamp
    mp = cd / "metadata.csv"
    if mp.is_file():
        with open(mp, encoding="utf-8") as f:
            rows = list(csv.DictReader(f))
        rec["duration_sec"]    = len(rows)
        rec["first_timestamp"] = rows[0].get("timestamp") if rows else None

    # transcript/utterances.jsonl
    up = cd / "transcript" / "utterances.jsonl"
    if up.is_file():
        with open(up, encoding="utf-8") as f:
            rec["utterances"] = [json.loads(ln) for ln in f if ln.strip()]

    # llm/events.jsonl
    lp = cd / "llm" / "events.jsonl"
    if lp.is_file():
        with open(lp, encoding="utf-8") as f:
            rec["llm_events"] = [json.loads(ln) for ln in f if ln.strip()]

    # transcript/final.txt
    fp = cd / "transcript" / "final.txt"
    if fp.is_file():
        rec["final_transcript"] = fp.read_text(encoding="utf-8").strip()

    return rec


# ─── aggregation ──────────────────────────────────────────────────
def aggregate(calls: list[dict]) -> dict:
    n = len(calls)

    # latencies  –  skip events that are errors with no llm_ms
    lats = sorted(
        ev["llm_ms"]
        for c in calls
        for ev in c["llm_events"]
        if "llm_ms" in ev and "error" not in ev
    )

    # intent histogram  (normalised groups)
    ic: dict[str, int] = defaultdict(int)
    for c in calls:
        for ev in c["llm_events"]:
            ic[norm(ev.get("llm", {}).get("intent", ""))] += 1

    # latency buckets
    bk = {"0–1s": 0, "1–3s": 0, "3–5s": 0, "5–10s": 0, "10s+": 0}
    for lt in lats:
        if   lt <  1000: bk["0–1s"]  += 1
        elif lt <  3000: bk["1–3s"]  += 1
        elif lt <  5000: bk["3–5s"]  += 1
        elif lt < 10000: bk["5–10s"] += 1
        else:            bk["10s+"]  += 1

    # fallbacks & errors
    fb = sum(1 for c in calls for ev in c["llm_events"] if ev.get("fallback"))
    er = sum(1 for c in calls for ev in c["llm_events"] if "error" in ev)

    # per-call row summaries (used by the table)
    sums: list[dict] = []
    for c in calls:
        norms = [
            norm(ev.get("llm", {}).get("intent", ""))
            for ev in c["llm_events"]
            if "error" not in ev
        ]
        primary = max(set(norms), key=norms.count) if norms else "N/A"
        cl = [
            ev["llm_ms"]
            for ev in c["llm_events"]
            if "llm_ms" in ev and "error" not in ev
        ]
        sums.append({
            "call_id":        c["call_id"],
            "ts":             c["first_timestamp"],
            "duration":       c["duration_sec"],
            "utts":           len(c["utterances"]),
            "llm_count":      len(c["llm_events"]),
            "primary_intent": primary,
            "avg_lat":        round(sum(cl) / len(cl)) if cl else 0,
            "status": (
                "Error"    if any("error" in ev       for ev in c["llm_events"])
                else "Fallback" if any(ev.get("fallback") for ev in c["llm_events"])
                else "Normal"
            ),
        })

    td = sum(c["duration_sec"] for c in calls)
    return {
        "total_calls":      n,
        "calls_with_llm":   sum(1 for c in calls if c["llm_events"]),
        "total_utterances": sum(len(c["utterances"]) for c in calls),
        "total_duration":   td,
        "avg_duration":     round(td / n, 1) if n else 0,
        "avg_latency":      round(sum(lats) / len(lats)) if lats else 0,
        "median_latency":   lats[len(lats) // 2] if lats else 0,
        "fallback_count":   fb,
        "error_count":      er,
        "fallback_rate":    round(fb / len(lats) * 100, 1) if lats else 0.0,
        "intent_counts":    dict(ic),
        "latency_buckets":  bk,
        "summaries":        sums,
    }


# ─── HTML template ────────────────────────────────────────────────
# Placeholders are __UPPER_SNAKE__.  Raw string keeps CSS/JS braces literal.
# Chart.js is loaded from CDN; the page works fully offline except for that.

HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>RingAI – Call Analytics</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4/dist/chart.umd.min.js"></script>
<style>
/* ── reset & tokens ── */
*{margin:0;padding:0;box-sizing:border-box}
:root{
  --bg:#0f172a; --card:#1e293b; --card2:#243447;
  --border:#334155; --txt:#e2e8f0; --txt2:#94a3b8;
  --acc:#6366f1; --ok:#10b981; --warn:#f59e0b;
  --err:#ef4444; --r:10px;
}
body{
  font-family:'Inter',system-ui,-apple-system,sans-serif;
  background:var(--bg); color:var(--txt);
  min-height:100vh; padding:28px 24px;
}
.pg{max-width:1380px;margin:0 auto}

/* ── header ── */
.hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;flex-wrap:wrap;gap:8px}
.hdr h1{font-size:1.5rem;font-weight:700;letter-spacing:-.025em}
.hdr h1 .hi{color:var(--acc)}
.hdr .gen{color:var(--txt2);font-size:.79rem;margin-top:2px}
.btn{background:var(--acc);color:#fff;border:none;border-radius:6px;padding:7px 15px;cursor:pointer;font-size:.82rem;font-weight:600;transition:background .2s}
.btn:hover{background:#4f46e5}

/* ── summary cards ── */
.cards{display:grid;grid-template-columns:repeat(6,1fr);gap:14px;margin-bottom:24px}
@media(max-width:1100px){.cards{grid-template-columns:repeat(3,1fr)}}
@media(max-width:580px){.cards{grid-template-columns:repeat(2,1fr)}}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:18px 16px}
.card .v{font-size:1.7rem;font-weight:700;line-height:1.1;margin-bottom:3px}
.card .l{color:var(--txt2);font-size:.75rem;text-transform:uppercase;letter-spacing:.06em;font-weight:600}
.card .s{color:var(--txt2);font-size:.72rem;margin-top:5px}
.c-acc .v{color:var(--acc)}
.c-pur .v{color:#a78bfa}
.c-ok  .v{color:var(--ok)}
.c-sky .v{color:#38bdf8}
.c-wrn .v{color:var(--warn)}
.c-err .v{color:var(--err)}

/* ── charts row ── */
.charts{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:24px}
@media(max-width:700px){.charts{grid-template-columns:1fr}}
.cbox{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:22px}
.cbox h3{font-size:.82rem;font-weight:600;color:var(--txt2);text-transform:uppercase;letter-spacing:.05em;margin-bottom:16px}

/* donut wrapper */
.dwrap{display:flex;align-items:center;gap:24px}
.dwrap canvas{width:190px!important;height:190px!important;flex-shrink:0}
.legend{display:flex;flex-direction:column;gap:5px}
.li{display:flex;align-items:center;gap:7px;font-size:.79rem}
.ld{width:11px;height:11px;border-radius:3px;flex-shrink:0}
.li .cnt{color:var(--txt2);margin-left:auto;font-size:.75rem}
@media(max-width:480px){.dwrap{flex-direction:column}.dwrap canvas{width:160px!important;height:160px!important}}

/* ── call-log table ── */
.tsec{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:20px;overflow-x:auto;margin-bottom:24px}
.ttop{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;flex-wrap:wrap;gap:10px}
.ttop h3{font-size:.82rem;font-weight:600;color:var(--txt2);text-transform:uppercase;letter-spacing:.05em}
.srch{background:#0f172a;border:1px solid var(--border);border-radius:6px;color:var(--txt);padding:7px 12px;font-size:.83rem;width:230px;outline:none;transition:border-color .2s}
.srch:focus{border-color:var(--acc)}
table{width:100%;border-collapse:collapse;font-size:.82rem}
thead th{text-align:left;padding:9px 10px;color:var(--txt2);font-weight:600;border-bottom:1px solid var(--border);white-space:nowrap;cursor:pointer;user-select:none;-webkit-user-select:none;transition:color .15s}
thead th:hover{color:var(--txt)}
thead th .si{opacity:.35;margin-left:4px;font-size:.7rem}
thead th.active{color:var(--acc)}
thead th.active .si{opacity:1}
tbody tr{border-bottom:1px solid rgba(51,65,85,.4);cursor:pointer;transition:background .15s}
tbody tr:hover{background:var(--card2)}
tbody td{padding:9px 10px}
.mono{font-family:'SF Mono','Fira Code',monospace;font-size:.76rem;color:var(--txt2)}

/* badges */
.badge{display:inline-block;border-radius:4px;padding:2px 8px;font-size:.73rem;font-weight:600}
.b-n{background:rgba(16,185,129,.12);color:var(--ok)}
.b-f{background:rgba(245,158,11,.12);color:var(--warn)}
.b-e{background:rgba(239,68,68,.12);color:var(--err)}
.lat-ok{color:var(--ok)}
.lat-md{color:var(--warn)}
.lat-bd{color:var(--err)}

/* ── modal ── */
.ovl{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;display:none;align-items:center;justify-content:center;backdrop-filter:blur(2px)}
.ovl.open{display:flex}
.mdl{background:var(--card);border:1px solid var(--border);border-radius:12px;width:90%;max-width:740px;max-height:84vh;overflow-y:auto;padding:28px;position:relative}
.mdl-x{position:absolute;top:14px;right:18px;background:none;border:none;color:var(--txt2);font-size:1.45rem;cursor:pointer;line-height:1;transition:color .15s}
.mdl-x:hover{color:var(--txt)}
.mdl-hd{display:flex;align-items:center;gap:10px;margin-bottom:20px;flex-wrap:wrap}
.mdl-hd h2{font-size:1rem;font-weight:700;font-family:monospace;word-break:break-all}
.msec{margin-bottom:18px}
.msec h4{font-size:.77rem;color:var(--txt2);text-transform:uppercase;letter-spacing:.06em;font-weight:600;margin-bottom:8px}

/* transcript box */
.tbox{background:#0f172a;border-radius:6px;padding:12px 14px;font-size:.83rem;line-height:1.6;word-break:break-word;white-space:pre-wrap}

/* conversation thread */
.thread{display:flex;flex-direction:column;gap:9px}
.msg{display:flex;gap:10px;align-items:flex-start}
.av{width:28px;height:28px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:.68rem;font-weight:700;color:#fff}
.av-u{background:#3b82f6}
.av-a{background:var(--acc)}
.bub{background:#0f172a;border-radius:7px;padding:9px 12px;flex:1;font-size:.82rem;line-height:1.5}
.bub .who{font-weight:700;font-size:.71rem;color:var(--txt2);margin-bottom:2px}
.bub .wat{color:var(--txt);word-break:break-word}
.bub .meta{margin-top:5px;font-size:.71rem;color:var(--txt2);display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.bub .lat{font-weight:600}
.tag{padding:1px 5px;border-radius:3px;font-size:.7rem;font-weight:600}
.tag-fb{background:rgba(245,158,11,.12);color:var(--warn)}
.tag-cl{background:rgba(251,191,36,.1);color:#fbbf24}
.err-bub{margin-left:38px;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.2);border-radius:6px;padding:7px 10px;font-size:.78rem;color:var(--err)}

/* empty-state row */
.empty{text-align:center;color:var(--txt2);padding:28px;font-size:.84rem}

/* footer */
.footer{text-align:center;color:var(--txt2);font-size:.75rem;padding-top:16px;border-top:1px solid var(--border)}
</style>
</head>
<body>
<div class="pg">

<!-- ── header ── -->
<div class="hdr">
  <div>
    <h1><span class="hi">Ring</span>AI – Call Analytics</h1>
    <div class="gen">Generated __GENERATED_AT__</div>
  </div>
  <button class="btn" onclick="location.reload()">&#8635; Refresh</button>
</div>

<!-- ── summary cards ── -->
<div class="cards">
  <div class="card c-acc"><div class="v">__TOTAL_CALLS__</div><div class="l">Total Calls</div></div>
  <div class="card c-pur"><div class="v">__CALLS_WITH_LLM__</div><div class="l">LLM Active</div><div class="s">__LLM_PCT__% of total</div></div>
  <div class="card c-ok" ><div class="v">__AVG_DUR__s</div><div class="l">Avg Duration</div><div class="s">__TOT_DUR__s total</div></div>
  <div class="card c-sky"><div class="v">__TOT_UTTS__</div><div class="l">Utterances</div></div>
  <div class="card c-wrn"><div class="v">__AVG_LAT__ms</div><div class="l">Avg LLM Latency</div><div class="s">median __MED_LAT__ms</div></div>
  <div class="card c-err"><div class="v">__FB_RATE__%</div><div class="l">Fallback Rate</div><div class="s">__FB_CNT__ fallbacks &middot; __ERR_CNT__ errors</div></div>
</div>

<!-- ── charts ── -->
<div class="charts">
  <!-- intent donut -->
  <div class="cbox">
    <h3>Intent Distribution</h3>
    <div class="dwrap">
      <canvas id="cIntent"></canvas>
      <div class="legend" id="legend"></div>
    </div>
  </div>

  <!-- latency histogram -->
  <div class="cbox">
    <h3>LLM Response Latency</h3>
    <canvas id="cLat"></canvas>
  </div>
</div>

<!-- ── call log ── -->
<div class="tsec">
  <div class="ttop">
    <h3>Call Log (__TOTAL_CALLS__ calls)</h3>
    <input class="srch" type="text" placeholder="Search call ID, intent…" id="srch" oninput="doFilter()"/>
  </div>
  <table>
    <thead><tr>
      <th data-col="ts"             onclick="doSort(this)">Date            <span class="si">&#9660;</span></th>
      <th data-col="call_id"        onclick="doSort(this)">Call ID         <span class="si">&#9660;</span></th>
      <th data-col="duration"       onclick="doSort(this)">Duration        <span class="si">&#9660;</span></th>
      <th data-col="utts"           onclick="doSort(this)">Utts            <span class="si">&#9660;</span></th>
      <th data-col="llm_count"      onclick="doSort(this)">LLM Events      <span class="si">&#9660;</span></th>
      <th data-col="primary_intent" onclick="doSort(this)">Intent          <span class="si">&#9660;</span></th>
      <th data-col="avg_lat"        onclick="doSort(this)">Avg Lat         <span class="si">&#9660;</span></th>
      <th data-col="status"         onclick="doSort(this)">Status          <span class="si">&#9660;</span></th>
    </tr></thead>
    <tbody id="tbody"></tbody>
  </table>
</div>

<!-- ── call-detail modal ── -->
<div class="ovl" id="ovl">
  <div class="mdl">
    <button class="mdl-x" onclick="closeModal()">&times;</button>
    <div id="mdlBody"></div>
  </div>
</div>

<div class="footer">RingAI Call Analytics &middot; generated from <code>dataset/calls/</code></div>
</div><!-- /pg -->

<!-- ─────────────────── embedded data ─────────────────── -->
<script>
const CALLS   = __CALLS_JSON__;
const DATA    = __DATA_JSON__;
const COLORS  = __COLORS_JSON__;
const IMAP    = __IMAP_JSON__;
const BUCKETS = __BUCKETS_JSON__;
</script>

<!-- ─────────────────── app logic ─────────────────── -->
<script>
// ── utility functions ──────────────────────────────────────────
function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') }

function fts(t){
  if(!t) return 'N/A';
  try{
    const M=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
    return +(t.slice(6,8))+' '+M[+(t.slice(4,6))-1]+' '+t.slice(0,4)+'  '+t.slice(9,11)+':'+t.slice(12,14);
  }catch(e){ return t; }
}

function normI(raw){
  if(!raw||!raw.trim()) return 'Unknown';
  return IMAP[raw.trim().toLowerCase()] || 'Other';
}

function latCls(ms){ return !ms ? '' : ms<3000 ? 'lat-ok' : ms<7000 ? 'lat-md' : 'lat-bd'; }

// ── intent donut chart ─────────────────────────────────────────
(function(){
  const labels = Object.keys(DATA.intent_counts);
  const vals   = labels.map(k => DATA.intent_counts[k]);
  const cols   = labels.map(k => COLORS[k] || '#a3a3a3');
  const total  = vals.reduce((a,b) => a+b, 0);

  new Chart(document.getElementById('cIntent'), {
    type: 'doughnut',
    data: {
      labels,
      datasets: [{ data: vals, backgroundColor: cols, borderColor: '#1e293b', borderWidth: 2 }]
    },
    options: {
      responsive: true,
      maintainAspectRatio: true,
      cutout: '62%',
      plugins: {
        legend: { display: false },
        tooltip: {
          callbacks: {
            label: function(c){ return ' '+c.label+': '+c.parsed+' ('+(c.parsed/total*100).toFixed(0)+'%)'; }
          }
        }
      }
    }
  });

  // manual legend
  const leg = document.getElementById('legend');
  labels.forEach(function(l, i){
    leg.innerHTML += '<div class="li"><span class="ld" style="background:'+cols[i]+'"></span>'+l+'<span class="cnt">'+vals[i]+'</span></div>';
  });
})();

// ── latency bar chart ──────────────────────────────────────────
(function(){
  const labels = Object.keys(BUCKETS);
  const vals   = labels.map(function(k){ return BUCKETS[k]; });

  new Chart(document.getElementById('cLat'), {
    type: 'bar',
    data: {
      labels,
      datasets: [{
        data: vals,
        backgroundColor: ['#10b981','#10b981','#f59e0b','#f59e0b','#ef4444'],
        borderRadius: 4,
        borderSkipped: false
      }]
    },
    options: {
      responsive: true,
      maintainAspectRatio: true,
      plugins: { legend: { display: false } },
      scales: {
        x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(51,65,85,.25)' } },
        y: { beginAtZero: true, ticks: { color: '#94a3b8' }, grid: { color: 'rgba(51,65,85,.25)' },
             title: { display: true, text: 'Events', color: '#94a3b8' } }
      }
    }
  });
})();

// ── table ──────────────────────────────────────────────────────
var _sort = { col: 'ts', asc: false };

function getSorted(){
  var d = DATA.summaries.slice();   // shallow copy
  var col = _sort.col, asc = _sort.asc;
  d.sort(function(a,b){
    var va = a[col], vb = b[col];
    if(typeof va === 'string' && typeof vb === 'string')
      return asc ? va.localeCompare(vb) : vb.localeCompare(va);
    return asc ? (va||0)-(vb||0) : (vb||0)-(va||0);
  });
  return d;
}

function render(arr){
  var tb = document.getElementById('tbody');
  if(!arr.length){
    tb.innerHTML = '<tr><td colspan="8" class="empty">No matching calls</td></tr>';
    return;
  }
  var out = '';
  arr.forEach(function(c){
    var sc = c.status==='Normal' ? 'b-n' : c.status==='Fallback' ? 'b-f' : 'b-e';
    var ic = COLORS[c.primary_intent] || '#a3a3a3';
    out +=
      '<tr onclick="openModal(\''+c.call_id+'\')">'+
      '<td>'+fts(c.ts)+'</td>'+
      '<td class="mono">'+c.call_id.slice(0,28)+'…</td>'+
      '<td>'+c.duration+'s</td>'+
      '<td>'+c.utts+'</td>'+
      '<td>'+c.llm_count+'</td>'+
      '<td><span class="badge" style="background:'+ic+'1a;color:'+ic+'">'+c.primary_intent+'</span></td>'+
      '<td class="'+latCls(c.avg_lat)+'">'+(c.avg_lat ? c.avg_lat+'ms' : '—')+'</td>'+
      '<td><span class="badge '+sc+'">'+c.status+'</span></td>'+
      '</tr>';
  });
  tb.innerHTML = out;
}

function doSort(th){
  var col = th.dataset.col;
  if(_sort.col === col) _sort.asc = !_sort.asc;
  else _sort = { col: col, asc: true };
  document.querySelectorAll('thead th').forEach(function(h){ h.classList.remove('active'); });
  th.classList.add('active');
  render(getSorted());
}

function doFilter(){
  var q = document.getElementById('srch').value.toLowerCase();
  render(getSorted().filter(function(c){
    return c.call_id.toLowerCase().indexOf(q) !== -1 ||
           c.primary_intent.toLowerCase().indexOf(q) !== -1 ||
           c.status.toLowerCase().indexOf(q) !== -1;
  }));
}

// initial paint  +  mark Date column as active
render(getSorted());
document.querySelectorAll('thead th')[0].classList.add('active');

// ── modal ──────────────────────────────────────────────────────
function openModal(cid){
  var call = CALLS.find(function(c){ return c.call_id === cid; });
  if(!call) return;

  var hasErr  = call.llm_events.some(function(e){ return 'error' in e; });
  var hasFb   = call.llm_events.some(function(e){ return !!e.fallback; });
  var sCls    = hasErr ? 'b-e' : hasFb ? 'b-f' : 'b-n';
  var sTxt    = hasErr ? 'Error' : hasFb ? 'Fallback' : 'Normal';

  var h = '<div class="mdl-hd">'+
    '<h2>'+cid+'</h2>'+
    '<span class="badge '+sCls+'">'+sTxt+'</span>'+
    '<span class="badge" style="background:rgba(99,102,241,.12);color:#a78bfa">'+call.duration_sec+'s</span>'+
    '<span class="badge" style="background:rgba(148,163,184,.1);color:var(--txt2)">'+fts(call.first_timestamp)+'</span>'+
    '</div>';

  // ── transcript ──
  if(call.final_transcript){
    h += '<div class="msec"><h4>Transcript</h4>'+
         '<div class="tbox">'+esc(call.final_transcript)+'</div></div>';
  }

  // ── conversation thread  (LLM events)  ──
  if(call.llm_events.length > 0){
    h += '<div class="msec"><h4>Conversation</h4><div class="thread">';

    call.llm_events.forEach(function(ev){
      // caller bubble
      h += '<div class="msg">'+
           '<div class="av av-u">U</div>'+
           '<div class="bub"><div class="who">Caller</div>'+
           '<div class="wat">'+esc(ev.utterance||'')+'</div></div></div>';

      // error bubble (no AI response)
      if('error' in ev){
        h += '<div class="err-bub">&#9888; LLM Error: '+esc(ev.error)+'</div>';
        return;                                              // skip AI bubble
      }

      // AI bubble
      var llm    = ev.llm || {};
      var reply  = llm.assistant_reply || llm.question || '';
      var intent = llm.intent || '';
      var grp    = normI(intent);
      var ic     = COLORS[grp] || '#a3a3a3';

      var meta = '';
      if(ev.llm_ms)                  meta += '<span class="lat '+latCls(ev.llm_ms)+'">'+ev.llm_ms+'ms</span>';
      if(intent)                     meta += '<span class="badge" style="background:'+ic+'1a;color:'+ic+';padding:1px 5px">'+grp+'</span>';
      if(ev.fallback)                meta += '<span class="tag tag-fb">fallback</span>';
      if(llm.needs_clarification)    meta += '<span class="tag tag-cl">clarify</span>';

      h += '<div class="msg">'+
           '<div class="av av-a">AI</div>'+
           '<div class="bub"><div class="who">RingAI</div>'+
           '<div class="wat">'+esc(reply)+'</div>'+
           '<div class="meta">'+meta+'</div></div></div>';
    });

    h += '</div></div>';   // close thread + msec

  // ── utterances only  (pre-LLM calls) ──
  } else if(call.utterances.length > 0){
    h += '<div class="msec"><h4>Utterances</h4><div class="thread">';

    call.utterances.forEach(function(u, i){
      var dur = (u.utter_ms / 1000).toFixed(1);
      var wc  = u.wallclock ? ' (wall-clock flush)' : '';
      h += '<div class="msg">'+
           '<div class="av av-u">U</div>'+
           '<div class="bub"><div class="who">Utterance '+(i+1)+' &#8226; '+dur+'s'+wc+'</div>'+
           '<div class="wat">'+esc(u.text||'')+'</div></div></div>';
    });

    h += '</div></div>';
  }

  document.getElementById('mdlBody').innerHTML = h;
  document.getElementById('ovl').classList.add('open');
}

function closeModal(){ document.getElementById('ovl').classList.remove('open'); }
document.getElementById('ovl').addEventListener('click', function(e){ if(e.target===e.currentTarget) closeModal(); });
document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeModal(); });
</script>
</body>
</html>
"""


# ─── generate ─────────────────────────────────────────────────────
def generate_html(calls: list[dict], data: dict) -> str:
    llm_pct = round(data["calls_with_llm"] / data["total_calls"] * 100, 1) if data["total_calls"] else 0

    return (
        HTML
        .replace("__GENERATED_AT__",  datetime.now().strftime("%d %b %Y  %H:%M"))
        .replace("__TOTAL_CALLS__",   str(data["total_calls"]))          # used twice
        .replace("__CALLS_WITH_LLM__",str(data["calls_with_llm"]))
        .replace("__LLM_PCT__",       str(llm_pct))
        .replace("__AVG_DUR__",       str(data["avg_duration"]))
        .replace("__TOT_DUR__",       str(data["total_duration"]))
        .replace("__TOT_UTTS__",      str(data["total_utterances"]))
        .replace("__AVG_LAT__",       str(data["avg_latency"]))
        .replace("__MED_LAT__",       str(data["median_latency"]))
        .replace("__FB_RATE__",       str(data["fallback_rate"]))
        .replace("__FB_CNT__",        str(data["fallback_count"]))
        .replace("__ERR_CNT__",       str(data["error_count"]))
        # JSON payloads ─ injected into <script> tags
        .replace("__CALLS_JSON__",    safe_json(calls))
        .replace("__DATA_JSON__",     safe_json(data))
        .replace("__COLORS_JSON__",   safe_json(GROUP_COLORS))
        .replace("__IMAP_JSON__",     safe_json(INTENT_MAP))
        .replace("__BUCKETS_JSON__",  safe_json(data["latency_buckets"]))
    )


# ─── entry point ──────────────────────────────────────────────────
def main() -> None:
    parser = argparse.ArgumentParser(description="RingAI Call Analytics Dashboard")
    parser.add_argument("--serve", action="store_true", help="Start HTTP server")
    parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
    parser.add_argument("--port", type=int, default=8081, help="Port to serve on (default: 8081)")
    args = parser.parse_args()

    calls = load_calls()
    if not calls:
        print(f"No call directories found in {CALLS_ROOT}")
        sys.exit(1)

    data = aggregate(calls)
    html = generate_html(calls, data)
    OUTPUT.write_text(html, encoding="utf-8")

    print()
    print(f"  Dashboard  →  {OUTPUT}")
    print(f"  Total calls       {data['total_calls']}")
    print(f"  LLM-active        {data['calls_with_llm']}")
    print(f"  Utterances        {data['total_utterances']}")
    print(f"  Avg LLM latency   {data['avg_latency']} ms")
    print()

    if args.serve:
        os.chdir(str(HERE))
        handler = http.server.SimpleHTTPRequestHandler
        with http.server.HTTPServer((args.host, args.port), handler) as srv:
            print(f"  Serving on http://{args.host}:{args.port}/dashboard.html")
            srv.serve_forever()
    else:
        print("  Open dashboard.html in a browser, or re-run with --serve")


if __name__ == "__main__":
    main()
