#!/usr/bin/env python3
"""Build Network Audit HTML from Google Sheet + DM scan CSV."""

import csv
import re
from datetime import datetime
from pathlib import Path

SHEET_CSV   = Path("/tmp/keith_network.csv")
DM_CSV      = Path(__file__).parent / "follow_up_FULL.csv"
OUTPUT_HTML = Path(__file__).parent / "network_audit.html"

# ── Parse Google Sheet ────────────────────────────────────────────────────────
sheet_rows = []
with open(SHEET_CSV, encoding="utf-8") as f:
    reader = csv.reader(f)
    for row in reader:
        if len(row) < 2:
            continue
        name   = row[0].strip()
        handle = row[1].strip() if len(row) > 1 else ""
        reached_out = (row[2].strip().upper() == "TRUE") if len(row) > 2 else False
        followed_up = (row[3].strip().upper() == "TRUE") if len(row) > 3 else False
        note        = row[4].strip() if len(row) > 4 else ""

        if name.lower() in ("contact info", "marc.paffrath") or handle == "Contact Info":
            continue
        if not handle.startswith("@"):
            continue
        if not name:
            name = handle  # use handle as display name if name is empty

        sheet_rows.append({
            "name": name,
            "handle": handle.lower(),
            "reached_out": reached_out,
            "followed_up": followed_up,
            "note": note,
        })

# ── Parse DM scan CSV ─────────────────────────────────────────────────────────
dm_by_handle = {}
with open(DM_CSV, encoding="utf-8") as f:
    reader = csv.DictReader(f)
    for row in reader:
        h = row["handle"].strip().lower()
        dm_by_handle[h] = {
            "follow_up_type":      row["follow_up_type"],
            "last_msg_date":       row["last_msg_date"],
            "last_sender":         row["last_sender"],
            "thread_id":           row["thread_id"],
            "keith_last_msg":      row["keith_last_msg"][:180] + "…" if len(row["keith_last_msg"]) > 180 else row["keith_last_msg"],
            "other_last_msg":      row["other_last_msg"][:180] + "…" if len(row["other_last_msg"]) > 180 else row["other_last_msg"],
            "conversation_summary":row["conversation_summary"],
            "suggested_reply":     row.get("suggested_reply", ""),
            "lead_score":          row.get("lead_score", "0"),
            "intent":              row.get("intent", ""),
        }

# ── Merge + sort ──────────────────────────────────────────────────────────────
def parse_note_date(note: str) -> datetime:
    note = note.strip()
    if not note:
        return datetime.min
    parts = re.split(r"[+,]", note)
    last = parts[-1].strip()
    for fmt in ("%d%b%y", "%d%b%Y", "%d %b %y", "%d %b %Y"):
        try:
            return datetime.strptime(last, fmt)
        except Exception:
            pass
    return datetime.min

def parse_dm_date(d: str) -> datetime:
    if not d:
        return datetime.min
    try:
        return datetime.strptime(d, "%Y-%m-%d %H:%M")
    except Exception:
        return datetime.min

merged = []
for r in sheet_rows:
    dm        = dm_by_handle.get(r["handle"])
    dm_date   = parse_dm_date(dm["last_msg_date"]) if dm else datetime.min
    note_date = parse_note_date(r["note"])
    best_date = max(dm_date, note_date)
    merged.append({**r, "dm": dm, "best_date": best_date, "dm_date": dm_date})

def lead_score(r):
    dm = r["dm"]
    return int(dm["lead_score"]) if dm and dm.get("lead_score") else 0

merged.sort(key=lambda x: x["best_date"], reverse=True)

# ── Status logic ──────────────────────────────────────────────────────────────
def status(row):
    dm = row["dm"]
    if dm:
        if dm["follow_up_type"] == "NEEDS_REPLY":
            return "needs_reply"
        return "bump_up"
    if row["followed_up"]:
        return "done"
    if row["reached_out"]:
        return "reached_out"
    return "not_contacted"

STATUS_LABEL = {
    "needs_reply":   "⚡ NEEDS REPLY",
    "bump_up":       "↑ BUMP UP",
    "done":          "✓ DONE",
    "reached_out":   "→ REACHED OUT",
    "not_contacted": "· NOT YET",
}
STATUS_COLOR = {
    "needs_reply":   "#ff3b30",
    "bump_up":       "#ff9500",
    "done":          "#30d158",
    "reached_out":   "#0a84ff",
    "not_contacted": "#636366",
}

def days_ago(dt: datetime) -> str:
    if dt == datetime.min:
        return "—"
    delta = datetime.now() - dt
    d = delta.days
    if d == 0:   return "today"
    if d == 1:   return "yesterday"
    if d < 7:    return f"{d}d ago"
    if d < 30:   return f"{d//7}w ago"
    if d < 365:  return f"{d//30}mo ago"
    return f"{d//365}y ago"

def esc(s: str) -> str:
    return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")

# ── Build rows ────────────────────────────────────────────────────────────────
counts = {k: 0 for k in STATUS_LABEL}
rows_html = ""

for r in merged:
    st    = status(r)
    color = STATUS_COLOR[st]
    label = STATUS_LABEL[st]
    dm    = r["dm"]
    counts[st] += 1

    handle_raw = r["handle"].lstrip("@")
    ig_url  = f"https://www.instagram.com/{handle_raw}/"
    dm_url  = f"https://www.instagram.com/direct/t/{dm['thread_id']}" if dm else ig_url

    date_display = days_ago(r["best_date"])
    date_raw     = (dm["last_msg_date"] if dm else r["note"]) or "—"

    # last message preview
    if dm:
        if dm["follow_up_type"] == "NEEDS_REPLY":
            preview_label = "they said:"
            preview_text  = esc(dm["other_last_msg"])
            preview_cls   = "preview-other"
        else:
            preview_label = "keith said:"
            preview_text  = esc(dm["keith_last_msg"])
            preview_cls   = "preview-keith"
    else:
        preview_label = ""
        preview_text  = ""
        preview_cls   = ""

    # suggested reply
    suggestion = ""
    if dm and dm.get("suggested_reply") and not dm["suggested_reply"].startswith("["):
        suggestion = esc(dm["suggested_reply"])

    # intent + score
    score = int(dm["lead_score"]) if dm and dm.get("lead_score") else 0
    intent_raw = dm.get("intent", "") if dm else ""
    intent_label = intent_raw.split("|")[0].strip() if "|" in intent_raw else intent_raw
    intent_signal = intent_raw.split("|")[1].strip() if "|" in intent_raw else ""
    INTENT_COLORS = {"HOT": "#ff3b30", "WARM": "#ff9500", "COLD": "#636366", "SUPPORT": "#0a84ff", "COLLAB": "#bf5af2", "SPAM": "#3a3a3c"}
    intent_color = INTENT_COLORS.get(intent_label, "#555")
    score_bar = f'<div class="score-bar"><div class="score-fill" style="width:{score}%;background:{"#ff3b30" if score>=70 else "#ff9500" if score>=40 else "#555"}"></div></div><span class="score-num">{score}</span>'
    intent_badge = f'<span class="intent-badge" style="background:{intent_color}" title="{esc(intent_signal)}">{intent_label}</span>' if intent_label else ""

    # flags
    flag_reached = '<span class="flag green">reached out</span>' if r["reached_out"] else '<span class="flag gray">not reached</span>'
    flag_followed = '<span class="flag green">followed up</span>' if r["followed_up"] else ""
    flag_note     = f'<span class="flag note">{esc(r["note"])}</span>' if r["note"] else ""

    # ── first-name extraction + prefab templates ────────────────────────
    _src_name = (r["name"] or r["handle"].lstrip("@") or "").strip()
    _tokens = re.split(r"[\s,._\-]+", _src_name)
    _first = ""
    for _tok in _tokens:
        _clean = re.sub(r"[^A-Za-z]", "", _tok)
        if len(_clean) >= 2:
            _first = _clean[0].upper() + _clean[1:].lower()
            break
    _greet = f"Hey {_first}" if _first else "Hey there"
    t_bump   = f"{_greet}, just bumping this up. I know how easy it is for messages to get buried. \U0001f9f6\U0001f64f\U0001fab7"
    t_circle = f"{_greet}, just circling back, how are you doing? Did you have a chance to try out shaking yet? If you got any questions, feel free to message me anytime \U0001f9f6\U0001f64f\U0001fab7"

    _ai_link = (f'<a class="sug-text sug-clickable" href="{dm_url}" target="_blank" '
                f'onclick="return openDM(event,this)" data-sug="{esc(dm["suggested_reply"])}" '
                f'data-url="{dm_url}" title="click to copy + open IG DM">{suggestion}</a>'
                f'<button class="copy-btn" onclick="copyText(this, `{suggestion}`)">copy</button>'
                if suggestion else '')

    _tpl_block = (f'<div class="tpl-row">'
                  f'<a class="tpl-btn" href="{dm_url}" target="_blank" onclick="return openDM(event,this)" '
                  f'data-sug="{esc(t_bump)}" data-url="{dm_url}" title="click to copy + open IG DM">↑ bump</a>'
                  f'<a class="tpl-btn" href="{dm_url}" target="_blank" onclick="return openDM(event,this)" '
                  f'data-sug="{esc(t_circle)}" data-url="{dm_url}" title="click to copy + open IG DM">↻ circle back</a>'
                  f'</div>')

    suggestion_html = (f'<div class="suggestion">'
                       f'<span class="sug-label">suggested reply:</span>'
                       f'{_ai_link}{_tpl_block}'
                       f'</div>') if dm else ""

    preview_html = f"""
      <div class="preview {preview_cls}">
        <span class="prev-label">{preview_label}</span> {preview_text}
      </div>""" if preview_text else ""

    rows_html += f"""
  <tr class="row row-{st}" data-status="{st}" data-intent="{intent_label.lower()}" data-score="{score}" data-name="{esc(r['name'].lower())}" data-handle="{esc(r['handle'])}">
    <td class="col-status">
      <span class="badge" style="background:{color}">{label}</span>
      {intent_badge}
      {score_bar}
    </td>
    <td class="col-person">
      <a href="{ig_url}" target="_blank" class="person-name">{esc(r['name'])}</a>
      <a href="{ig_url}" target="_blank" class="person-handle">{esc(r['handle'])}</a>
      {'<a href="' + dm_url + '" target="_blank" class="dm-link" onclick="return openDM(event,this)" data-sug="' + (esc(dm["suggested_reply"]) if dm and dm.get("suggested_reply") and not dm["suggested_reply"].startswith("[") else "") + '" data-url="' + dm_url + '">open DM →</a>' if dm else ''}
    </td>
    <td class="col-date">
      <span class="date-rel">{date_display}</span>
      <span class="date-abs">{date_raw[:10] if date_raw != '—' else '—'}</span>
    </td>
    <td class="col-msg">
      {preview_html}
      {suggestion_html}
    </td>
    <td class="col-flags">
      {flag_reached}
      {flag_followed}
      {flag_note}
    </td>
  </tr>
"""

# ── Summary bar ───────────────────────────────────────────────────────────────
summary_pills = "".join(
    f'<button class="pill" data-filter="{k}" style="--c:{STATUS_COLOR[k]}" onclick="filterStatus(this)">'
    f'{STATUS_LABEL[k]} <b>{counts[k]}</b></button>'
    for k in STATUS_LABEL if counts[k] > 0
)
summary_pills += '<button class="pill pill-all active" data-filter="" onclick="filterStatus(this)">ALL <b>' + str(len(merged)) + '</b></button>'
summary_pills += '<button class="pill" data-filter="sent" style="--c:#8e8e93" onclick="filterStatus(this)">✉ SENT <b id="sentCount">0</b></button>'

# ── Full HTML ─────────────────────────────────────────────────────────────────
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Keith — Network Audit {datetime.now().strftime('%d %b %Y')}</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0c0c0e;color:#e0e0e0;font-size:13px;min-height:100vh}}

/* header */
.header{{background:#111113;border-bottom:1px solid #222;padding:16px 20px 12px;position:sticky;top:0;z-index:100}}
.header h1{{font-size:16px;font-weight:600;color:#fff;margin-bottom:10px}}
.pills{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}}
.pill{{background:#1c1c1e;border:1px solid #333;border-radius:20px;color:#aaa;cursor:pointer;font-size:11px;padding:4px 12px;transition:all .15s}}
.pill:hover,.pill.active{{background:var(--c,#555);border-color:var(--c,#555);color:#fff}}
.pill-all{{--c:#555}}
.pill-all.active{{background:#333;border-color:#555}}
.search-row{{display:flex;gap:8px}}
.search-row input{{background:#1c1c1e;border:1px solid #2c2c2e;border-radius:8px;color:#e0e0e0;font-size:13px;outline:none;padding:7px 12px;width:280px}}
.search-row input:focus{{border-color:#555}}
.count-label{{color:#555;font-size:12px;align-self:center;margin-left:4px}}

/* table */
table{{width:100%;border-collapse:collapse}}
th{{background:#111113;color:#555;font-size:10px;font-weight:600;letter-spacing:.06em;padding:8px 12px;text-align:left;text-transform:uppercase;border-bottom:1px solid #1e1e20}}
td{{padding:10px 12px;border-bottom:1px solid #161618;vertical-align:top}}
tr.row:hover td{{background:#111115}}
.row-hidden{{display:none}}
.row-sent{{opacity:.4}}
.row-sent td{{background:#0a0a0c}}
.row-sent:hover{{opacity:.7}}

/* cols */
.col-status{{width:120px;white-space:nowrap}}
.col-person{{width:180px}}
.col-date{{width:90px;white-space:nowrap}}
.col-msg{{max-width:380px}}
.col-flags{{width:160px}}

/* badge */
.badge{{border-radius:5px;color:#fff;display:inline-block;font-size:10px;font-weight:700;letter-spacing:.03em;padding:3px 8px;white-space:nowrap}}

/* person */
.person-name{{color:#fff;display:block;font-size:13px;font-weight:600;text-decoration:none}}
.person-name:hover{{color:#5bf}}
.person-handle{{color:#555;display:block;font-size:11px;text-decoration:none;margin-top:1px}}
.person-handle:hover{{color:#9cf}}
.dm-link{{color:#f90;display:inline-block;font-size:10px;margin-top:4px;text-decoration:none;opacity:.75}}
.dm-link:hover{{opacity:1}}
.intent-badge{{border-radius:4px;color:#fff;display:inline-block;font-size:9px;font-weight:700;letter-spacing:.04em;margin-top:4px;padding:2px 6px;cursor:default}}
.score-bar{{background:#1c1c1e;border-radius:3px;height:3px;margin-top:6px;overflow:hidden;width:70px;display:inline-block;vertical-align:middle}}
.score-fill{{height:100%;border-radius:3px}}
.score-num{{color:#444;font-size:10px;margin-left:4px;vertical-align:middle}}
.presets{{display:flex;gap:5px;flex-wrap:wrap;margin-top:8px}}
.preset{{background:#1c1c1e;border:1px solid #2c2c2e;border-radius:6px;color:#777;cursor:pointer;font-size:11px;padding:4px 10px;transition:all .15s}}
.preset:hover{{background:#2c2c2e;color:#ccc}}
.preset.active{{background:#2a2a10;border-color:#f90;color:#f90}}
th.sortable{{cursor:pointer;user-select:none}}
th.sortable:hover{{color:#aaa}}

/* date */
.date-rel{{color:#ccc;display:block;font-size:13px}}
.date-abs{{color:#444;display:block;font-size:10px;margin-top:2px}}

/* message preview */
.preview{{border-left:2px solid #333;color:#999;font-size:11px;line-height:1.5;margin-bottom:6px;padding:4px 8px}}
.preview-other{{border-color:#30d158;color:#9ef5b0}}
.preview-keith{{border-color:#0a84ff;color:#9ecfff}}
.prev-label{{color:#555;font-size:10px;font-style:italic}}

/* suggestion */
.suggestion{{background:#1a1a0e;border:1px solid #3a3a10;border-radius:6px;padding:7px 9px;margin-top:4px}}
.sug-label{{color:#888;display:block;font-size:10px;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em}}
.sug-text{{color:#ffd60a;font-size:12px;line-height:1.5}}
.copy-btn{{background:#2a2a10;border:1px solid #4a4a20;border-radius:4px;color:#aaa;cursor:pointer;float:right;font-size:10px;margin-top:-2px;padding:2px 8px}}
.copy-btn:hover{{background:#3a3a18;color:#fff}}
.copy-btn.copied{{background:#1a3a10;color:#5f5}}

/* flags */
.flag{{border-radius:4px;display:inline-block;font-size:10px;margin:1px 2px 1px 0;padding:2px 7px}}
.green{{background:#0a2a15;color:#30d158}}
.gray{{background:#1c1c1e;color:#555}}
.note{{background:#1c1c22;color:#888}}
</style>
</head>
<body>
<div class="header">
  <h1>Keith — Network Audit &nbsp;<span style="color:#555;font-weight:400;font-size:13px">{datetime.now().strftime('%d %b %Y')}</span></h1>
  <div class="pills">{summary_pills}</div>
  <div class="search-row">
    <input type="text" id="search" placeholder="Search name or @handle…" oninput="applyFilters()">
    <span class="count-label" id="countLabel">{len(merged)} shown</span>
  </div>
  <div class="presets">
    <span style="color:#444;font-size:11px;align-self:center">quick filters:</span>
    <button class="preset" onclick="applyPreset(this,'hot')" data-preset="hot">🔥 HOT leads</button>
    <button class="preset" onclick="applyPreset(this,'warm')" data-preset="warm">🌤 WARM</button>
    <button class="preset" onclick="applyPreset(this,'needs_reply')" data-preset="needs_reply">⚡ needs reply</button>
    <button class="preset" onclick="applyPreset(this,'score70')" data-preset="score70">score 70+</button>
    <button class="preset" onclick="applyPreset(this,'recent3d')" data-preset="recent3d">last 3 days</button>
    <button class="preset" onclick="applyPreset(this,'older1w')" data-preset="older1w">&gt; 1w old</button>
    <button class="preset" onclick="applyPreset(this,'older2w')" data-preset="older2w">&gt; 2w old</button>
    <button class="preset" onclick="applyPreset(this,'older1m')" data-preset="older1m">&gt; 1m old</button>
    <button class="preset" onclick="applyPreset(this,'')" data-preset="">clear</button>
  </div>
</div>

<table>
<thead>
  <tr>
    <th>Status</th>
    <th>Person</th>
    <th class="sortable" onclick="sortByDate()">Last Contact <span id="dateSortIcon">↓</span></th>
    <th>Last Message + Suggested Reply</th>
    <th>Flags</th>
  </tr>
</thead>
<tbody id="tbody">
{rows_html}
</tbody>
</table>

<script>
let activeFilter = "";
let activePreset = "";
let sortDir = "desc";
let originalOrder = null;

function rowDate(tr) {{
  if (tr._cachedDate !== undefined) return tr._cachedDate;
  const txt = (tr.querySelector('.date-abs') || {{}}).textContent || '';
  const d = new Date(txt);
  tr._cachedDate = isNaN(d) ? null : d.getTime();
  return tr._cachedDate;
}}

function sortByDate() {{
  const tbody = document.getElementById('tbody');
  sortDir = sortDir === 'asc' ? 'desc' : 'asc';
  const rows = [...tbody.querySelectorAll('tr.row')].sort((a, b) => {{
    const da = rowDate(a); const db = rowDate(b);
    const av = da === null ? -Infinity : da;
    const bv = db === null ? -Infinity : db;
    return sortDir === 'asc' ? av - bv : bv - av;
  }});
  rows.forEach(r => tbody.appendChild(r));
  const icon = document.getElementById('dateSortIcon');
  if (icon) icon.textContent = sortDir === 'asc' ? ' ↑' : ' ↓';
}}

function filterStatus(btn) {{
  document.querySelectorAll('.pill').forEach(p => p.classList.remove('active'));
  btn.classList.add('active');
  activeFilter = btn.dataset.filter;
  applyFilters();
}}

function applyPreset(btn, preset) {{
  document.querySelectorAll('.preset').forEach(p => p.classList.remove('active'));
  btn.classList.add('active');
  activePreset = preset;
  applyFilters();
}}

function applyFilters() {{
  const q = document.getElementById('search').value.toLowerCase();
  const now = Date.now();
  let shown = 0;
  const wantSent = activeFilter === 'sent';
  document.querySelectorAll('#tbody tr.row').forEach(tr => {{
    const isSent = tr.dataset.sent === '1';
    const matchSent = wantSent ? isSent : !isSent;
    const matchStatus = wantSent || !activeFilter || tr.dataset.status === activeFilter;
    const matchQ = !q || tr.dataset.name.includes(q) || tr.dataset.handle.includes(q);
    let matchPreset = true;
    if (activePreset === 'hot')         matchPreset = tr.dataset.intent === 'hot';
    else if (activePreset === 'warm')   matchPreset = tr.dataset.intent === 'warm';
    else if (activePreset === 'needs_reply') matchPreset = tr.dataset.status === 'needs_reply';
    else if (activePreset === 'score70') matchPreset = parseInt(tr.dataset.score||0) >= 70;
    else if (activePreset === 'recent3d') {{
      const d = rowDate(tr);
      matchPreset = d !== null && (now - d) < 3*86400000;
    }}
    else if (activePreset === 'older1w') {{
      const d = rowDate(tr);
      matchPreset = d !== null && (now - d) > 7*86400000;
    }}
    else if (activePreset === 'older2w') {{
      const d = rowDate(tr);
      matchPreset = d !== null && (now - d) > 14*86400000;
    }}
    else if (activePreset === 'older1m') {{
      const d = rowDate(tr);
      matchPreset = d !== null && (now - d) > 30*86400000;
    }}
    const show = matchSent && matchStatus && matchQ && matchPreset;
    tr.classList.toggle('row-hidden', !show);
    if (show) shown++;
  }});
  document.getElementById('countLabel').textContent = shown + ' shown';
}}

async function loadSentState() {{
  try {{
    const r = await fetch('/log.json', {{cache:'no-store'}});
    const handles = await r.json();
    const sentSet = new Set(handles);
    let count = 0;
    document.querySelectorAll('#tbody tr.row').forEach(tr => {{
      if (sentSet.has(tr.dataset.handle)) {{
        tr.dataset.sent = '1';
        tr.classList.add('row-sent');
        count++;
      }}
    }});
    const sc = document.getElementById('sentCount');
    if (sc) sc.textContent = count;
    applyFilters();
  }} catch(e) {{}}
}}
window.addEventListener('DOMContentLoaded', loadSentState);

function updateQueueBadge() {{
  const q = JSON.parse(localStorage.getItem('trackQueue') || '[]');
  let badge = document.getElementById('queueBadge');
  if (!badge) {{
    badge = document.createElement('span');
    badge.id = 'queueBadge';
    badge.style.cssText = 'margin-left:8px;background:#ff9500;color:#000;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;cursor:pointer';
    badge.title = 'queued tracks waiting to send — click to retry now';
    badge.onclick = retryQueue;
    document.querySelector('.count-label').after(badge);
  }}
  if (q.length === 0) {{ badge.style.display = 'none'; return; }}
  badge.style.display = '';
  badge.textContent = '⏳ ' + q.length + ' queued';
}}

async function retryQueue() {{
  let q = JSON.parse(localStorage.getItem('trackQueue') || '[]');
  if (!q.length) return;
  const remaining = [];
  for (const item of q) {{
    try {{
      const r = await fetch('/track', {{
        method: 'POST',
        headers: {{'Content-Type': 'application/json'}},
        body: JSON.stringify(item)
      }});
      if (!r.ok) remaining.push(item);
    }} catch(e) {{ remaining.push(item); }}
  }}
  localStorage.setItem('trackQueue', JSON.stringify(remaining));
  updateQueueBadge();
  if (remaining.length === 0) loadSentState();
}}
window.addEventListener('DOMContentLoaded', () => {{ updateQueueBadge(); retryQueue(); }});

function copyText(btn, text) {{
  navigator.clipboard.writeText(text).then(() => {{
    btn.textContent = 'copied!';
    btn.classList.add('copied');
    setTimeout(() => {{ btn.textContent = 'copy'; btn.classList.remove('copied'); }}, 2000);
  }});
}}

function openDM(e, link) {{
  e.preventDefault();
  const sug  = link.dataset.sug;
  const url  = link.dataset.url;
  const row  = link.closest('tr');
  const handle = row ? row.dataset.handle : '';
  const name   = row ? row.dataset.name   : '';
  const type   = row ? row.dataset.status : '';

  function doOpen() {{ window.open(url, '_blank'); }}

  function trackAndOpen() {{
    if (row) {{
      row.dataset.sent = '1';
      row.classList.add('row-sent');
      link.textContent = '✓ done';
      link.style.color = '#30d158';
      const sc = document.getElementById('sentCount');
      if (sc) sc.textContent = (parseInt(sc.textContent||0) + 1);
      if (activeFilter !== 'sent') applyFilters();
    }}
    const payload = {{ handle, name, type, suggestion: sug, dm_url: url }};
    fetch('/track', {{
      method: 'POST',
      headers: {{'Content-Type': 'application/json'}},
      body: JSON.stringify(payload)
    }}).then(r => {{ if (!r.ok) throw new Error('rc='+r.status); }}).catch(() => {{
      const q = JSON.parse(localStorage.getItem('trackQueue') || '[]');
      q.push({{...payload, ts_client: new Date().toISOString().slice(0,16).replace('T',' ')}});
      localStorage.setItem('trackQueue', JSON.stringify(q));
      link.textContent = '✓ queued';
      link.style.color = '#ff9500';
      updateQueueBadge();
    }});
    doOpen();
  }}

  if (!sug) {{ trackAndOpen(); return false; }}

  if (navigator.clipboard && navigator.clipboard.writeText) {{
    navigator.clipboard.writeText(sug).then(() => {{
      link.textContent = '✓ copied — opening...';
      setTimeout(trackAndOpen, 400);
    }}).catch(() => {{ fallbackCopy(sug); trackAndOpen(); }});
  }} else {{
    fallbackCopy(sug);
    trackAndOpen();
  }}
  return false;
}}

function fallbackCopy(text) {{
  const ta = document.createElement('textarea');
  ta.value = text;
  ta.style.position = 'fixed';
  ta.style.opacity = '0';
  document.body.appendChild(ta);
  ta.focus();
  ta.select();
  try {{ document.execCommand('copy'); }} catch(e) {{}}
  document.body.removeChild(ta);
}}

function copyText(btn, text) {{
  if (navigator.clipboard && navigator.clipboard.writeText) {{
    navigator.clipboard.writeText(text).then(() => {{
      btn.textContent = 'copied!'; btn.classList.add('copied');
      setTimeout(() => {{ btn.textContent = 'copy'; btn.classList.remove('copied'); }}, 2000);
    }}).catch(() => {{ fallbackCopy(text); btn.textContent = 'copied!'; setTimeout(() => btn.textContent = 'copy', 2000); }});
  }} else {{
    fallbackCopy(text);
    btn.textContent = 'copied!';
    setTimeout(() => btn.textContent = 'copy', 2000);
  }}
}}
</script>
</body>
</html>
"""

OUTPUT_HTML.write_text(html, encoding="utf-8")
print(f"written: {OUTPUT_HTML}")

# sync to iCloud
import shutil
icloud = Path.home() / "Library/Mobile Documents/com~apple~CloudDocs/keith_network_audit.html"
shutil.copy2(OUTPUT_HTML, icloud)
print(f"synced to iCloud: {icloud.name}")

print(f"total: {len(merged)} entries")
for k, v in counts.items():
    if v:
        print(f"  {STATUS_LABEL[k]}: {v}")
