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

import csv
import json
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"
ACTIVITY_LOG = Path(__file__).parent / "activity_log.json"

# load sent handles to embed in HTML (so GitHub Pages shows sent state)
_sent_handles = []
if ACTIVITY_LOG.exists():
    try:
        _log = json.loads(ACTIVITY_LOG.read_text())
        if _log and isinstance(_log[0], dict):
            _sent_handles = list({e["handle"] for e in _log if "handle" in e})
        else:
            _sent_handles = _log
    except Exception:
        pass
SENT_HANDLES_JS = json.dumps(_sent_handles)

# ── 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,
        })

# ── Skip list ─────────────────────────────────────────────────────────────────
SKIP_FILE = Path(__file__).parent / "skip_list.txt"
skip_handles = set()
if SKIP_FILE.exists():
    for line in SKIP_FILE.read_text().splitlines():
        h = line.strip().lower()
        if h:
            skip_handles.add(h)

# ── 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()
        if h in skip_handles:
            continue
        dm_by_handle[h] = {
            "display_name":        row.get("display_name", ""),
            "focus_hint":          row.get("focus_hint", ""),
            "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

# ── Family / friends exclude list (not leads, won't appear in audit) ──────────
EXCLUDE_HANDLES = {
    "@the.ecosystem.movement",
    "@finnwheatley",
    "@aliseabass",
    "@aprilvaccaroart",
    "@alexislumpkin",
    "@nature_in_lex",
    "@drpeterrohde",
    "@allymacpole",
    "@cristallelove",
}

# dedupe sheet_rows by handle: keep the entry with the longest non-handle-like name
_seen_handles = {}
for _r in sheet_rows:
    _h = _r["handle"].lower().strip()
    _name = (_r.get("name", "") or "").strip()
    # score the name: real names (with space) > short names > handle-as-name
    _score = (10 if " " in _name else (5 if _name and not _name.startswith("@") else 0)) + len(_name)
    if _h not in _seen_handles or _score > _seen_handles[_h][1]:
        _seen_handles[_h] = (_r, _score)
sheet_rows = [v[0] for v in _seen_handles.values()]

merged = []
for r in sheet_rows:
    if r["handle"].lower().strip() in EXCLUDE_HANDLES:
        continue
    dm        = dm_by_handle.get(r["handle"])
    if not dm:
        # no IG thread = nothing actionable; skip stub rows that only have a sheet checkbox
        continue
    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"


# ── User-set buckets (EXCLUDED / PARTNER) ────────────────────────────────────
import json as _json
_buckets_path = Path(__file__).parent / "buckets.json"
USER_BUCKETS = {}
if _buckets_path.exists():
    try:
        USER_BUCKETS = _json.loads(_buckets_path.read_text())
    except Exception:
        USER_BUCKETS = {}

# ── Auto-pause computation from activity_log ─────────────────────────────────
from collections import Counter
from datetime import timedelta as _timedelta
_log_path = Path(__file__).parent / "activity_log.json"
_bump_count = Counter()
_last_bump_date = {}
if _log_path.exists():
    try:
        _entries = _json.loads(_log_path.read_text())
        # sort by timestamp ascending so we can dedupe consecutive ones
        def _parse_ts(_e):
            try: return datetime.strptime(_e["ts"], "%Y-%m-%d %H:%M")
            except Exception: return datetime.min
        _entries_sorted = sorted(_entries, key=_parse_ts)
        _prev_bump = {}  # handle -> last bump ts seen
        for _e in _entries_sorted:
            _h = _e.get("handle", "").strip().lower()
            if not _h: continue
            if _e.get("type") != "bump_up": continue
            _ts = _parse_ts(_e)
            if _ts == datetime.min: continue
            # dedupe: skip entries within 5 min of the previous one for same handle
            if _h in _prev_bump and (_ts - _prev_bump[_h]).total_seconds() < 300:
                continue
            _prev_bump[_h] = _ts
            _bump_count[_h] += 1
            if _h not in _last_bump_date or _ts > _last_bump_date[_h]:
                _last_bump_date[_h] = _ts
    except Exception:
        pass

# ── Load engagers.json (new prospects from likes/comments on last 3 posts) ──
ENGAGERS_DATA = {"posts": [], "engagers": []}
_engagers_path = Path(__file__).parent / "engagers.json"
if _engagers_path.exists():
    try:
        ENGAGERS_DATA = _json.loads(_engagers_path.read_text())
    except Exception:
        pass
ENGAGERS_POSTS_BY_ID = {p["id"]: p for p in ENGAGERS_DATA.get("posts", [])}
# ── Load new_followers.json (welcome targets from overnight scrape) ──────────
NEW_FOLLOWERS_DATA = {"followers": []}
_nf_path = Path(__file__).parent / "new_followers.json"
if _nf_path.exists():
    try:
        NEW_FOLLOWERS_DATA = _json.loads(_nf_path.read_text())
    except Exception:
        pass



def _keith_message_dates(summary: str):
    """Set of dates (date objects) where keith sent a message in the convo summary."""
    out = set()
    if not summary: return out
    for entry in summary.split(" | "):
        m = re.match(r"\[([^\]]+)\]\s*([^:]+):", entry)
        if not m: continue
        sender = m.group(2).strip().lower()
        if sender != "keith": continue
        date_str = m.group(1).strip()
        for fmt in ("%d.%m.%Y", "%Y-%m-%d %H:%M", "%d.%m.%y"):
            try:
                out.add(datetime.strptime(date_str, fmt).date())
                break
            except Exception:
                pass
    return out

def _latest_other_date(summary: str):
    """Parse conversation_summary, return most recent OTHER (non-keith) message date."""
    if not summary: return None
    latest = None
    for entry in summary.split(" | "):
        m = re.match(r"\[([^\]]+)\]\s*([^:]+):", entry)
        if not m: continue
        date_str = m.group(1).strip()
        sender = m.group(2).strip().lower()
        if sender == "keith": continue
        for fmt in ("%d.%m.%Y", "%Y-%m-%d %H:%M", "%d.%m.%y"):
            try:
                ts = datetime.strptime(date_str, fmt)
                if latest is None or ts > latest:
                    latest = ts
                break
            except Exception:
                pass
    return latest

PAUSE_DAYS = 180  # 6 months

def compute_bucket(handle: str, last_msg_date_str: str, last_sender: str, summary: str = "") -> str:
    """Determine bucket: LEAD (default), EXCLUDED, PARTNER, LONG_PAUSE, EXHAUSTED."""
    h = handle.lower().strip()
    user = USER_BUCKETS.get(h)
    if user and user.get("bucket") in ("EXCLUDED", "PARTNER"):
        return user["bucket"]
    # validate bump count: only count activity_log entries that have a matching
    # keith message on the same date in the conversation_summary
    keith_dates = _keith_message_dates(summary)
    raw_bumps = _bump_count.get(h, 0)
    last_bump_raw = _last_bump_date.get(h)
    if raw_bumps == 0 or not last_bump_raw or last_bump_raw.date() not in keith_dates:
        # the most recent activity_log bump is phantom (test click, etc)
        # we conservatively require the LAST bump to be confirmed before pausing
        return "LEAD"
    bumps = raw_bumps  # could be more granular but keeping simple
    if bumps < 2:
        return "LEAD"
    last_bump = last_bump_raw
    # Did the prospect engage since last bump? Check ANY OTHER message in convo,
    # not just last_sender (since keith may have replied AFTER the prospect).
    latest_other = _latest_other_date(summary)
    if latest_other and latest_other.date() >= last_bump.date():
        return "LEAD"
    if bumps >= 3:
        return "EXHAUSTED"
    if datetime.now() < last_bump + _timedelta(days=PAUSE_DAYS):
        return "LONG_PAUSE"
    return "LEAD"  # pause expired, eligible again

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

def render_convo_tail(summary: str, n: int = 5) -> str:
    """Parse "[DATE] sender: text | ..." and render last n messages as HTML."""
    if not summary:
        return ""
    parts = [p.strip() for p in summary.split(" | ") if p.strip()]
    parts = parts[-n:]
    out = []
    for p in parts:
        m = re.match(r"\[([^\]]*)\]\s*([^:]+):\s*(.*)", p, re.DOTALL)
        if not m:
            out.append(f'<div class="convo-msg">{esc(p)}</div>')
            continue
        date, sender, text = m.group(1), m.group(2).strip(), m.group(3).strip()
        cls = "convo-keith" if sender.lower() == "keith" else "convo-other"
        sender_label = "keith" if sender.lower() == "keith" else esc(sender)
        out.append(
            f'<div class="convo-msg {cls}">'
            f'<span class="convo-meta">{esc(date)} · {sender_label}</span>'
            f'<span class="convo-text">{esc(text)}</span>'
            f'</div>'
        )
    return "".join(out)


# ── Build rows ────────────────────────────────────────────────────────────────
counts = {k: 0 for k in STATUS_LABEL}
bucket_counts = {"LEAD_REPLY": 0, "LEAD_BUMP": 0, "PARTNER": 0, "LONG_PAUSE": 0, "EXCLUDED": 0, "EXHAUSTED": 0, "ENGAGERS": 0, "NEW_FOLLOWERS": 0}
rows_html = ""

for r in merged:
    st    = status(r)
    color = STATUS_COLOR[st]
    label = STATUS_LABEL[st]
    dm    = r["dm"]
    _row_bucket = compute_bucket(r["handle"], dm.get("last_msg_date", "") if dm else "", dm.get("last_sender", "") if dm else "", dm.get("conversation_summary", "") if dm else "")
    _next_bump_iso = ""
    if _row_bucket == "LONG_PAUSE":
        _lb = _last_bump_date.get(r["handle"].lower().strip())
        if _lb:
            _next_bump_iso = (_lb + _timedelta(days=PAUSE_DAYS)).strftime("%Y-%m-%d")
    counts[st] += 1
    if _row_bucket == "PARTNER":
        bucket_counts["PARTNER"] += 1
    elif _row_bucket == "LONG_PAUSE":
        bucket_counts["LONG_PAUSE"] += 1
    elif _row_bucket == "EXCLUDED":
        bucket_counts["EXCLUDED"] += 1
    elif _row_bucket == "EXHAUSTED":
        bucket_counts["EXHAUSTED"] += 1
    elif st == "needs_reply":
        bucket_counts["LEAD_REPLY"] += 1
    elif st == "bump_up":
        bucket_counts["LEAD_BUMP"] += 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 ────────────────────────
    _handle_clean = r["handle"].lstrip("@").lower()
    _handle_alphas = re.sub(r"[^a-z]", "", _handle_clean)
    # candidates in priority order: IG display_name first (has real names),
    # then the sheet name. Reject any token that equals the handle.
    _candidates = []
    if dm and dm.get("display_name"):
        _candidates.append(dm["display_name"])
    if r.get("name"):
        _candidates.append(r["name"])
    _first = ""
    for _src in _candidates:
        for _tok in re.split(r"[\s,._\-]+", _src.strip()):
            _clean = re.sub(r"[^A-Za-z]", "", _tok)
            if len(_clean) >= 2 and _clean.lower() != _handle_alphas:
                _first = _clean[0].upper() + _clean[1:].lower()
                break
        if _first:
            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. \U0001fAF6\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 \U0001fAF6\U0001f64f\U0001fab7"
    t_sm      = f"{_greet}, just circling back, how are you doing? Curious what drew you to follow Shaking Medicine \U0001fAF6\U0001f64f\U0001fab7"
    t_content = f"{_greet}, just circling back, how are you doing? Curious if anything from my content that I shared recently stood out to you \U0001fAF6\U0001f64f\U0001fab7"

    _focus = dm.get("focus_hint", "") if dm else ""
    _focus_block = (f'<div class="focus-hint">👁 focus on: {esc(_focus)}</div>'
                    if _focus and dm and dm.get("follow_up_type") == "NEEDS_REPLY" else "")
    _convo_block = (f'<details class="convo-expand"><summary>📜 show last 5 messages</summary><div class="convo-body">{render_convo_tail(dm["conversation_summary"], 5)}</div></details>'
                    if dm and dm.get("follow_up_type") == "NEEDS_REPLY" and dm.get("conversation_summary") else "")
    if _row_bucket == "LONG_PAUSE":
        _bucket_info = f'<div class="pause-info">⏸ paused after 2 unanswered bumps. next bump available: <b>{_next_bump_iso}</b></div>'
    elif _row_bucket == "PARTNER":
        _bucket_info = '<div class="partner-info">🤝 partner / practitioner. write a custom collab message in IG, no auto-suggestions here.</div>'
    elif _row_bucket == "EXCLUDED":
        _bucket_info = '<div class="archive-info">🚫 excluded from regular outreach.</div>'
    elif _row_bucket == "EXHAUSTED":
        _bucket_info = '<div class="archive-info">🗂 exhausted (3+ unanswered bumps).</div>'
    else:
        _bucket_info = ""
    _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'<a class="tpl-btn" href="{dm_url}" target="_blank" onclick="return openDM(event,this)" '
                  f'data-sug="{esc(t_sm)}" data-url="{dm_url}" title="click to copy + open IG DM">🌿 SM</a>'
                  f'<a class="tpl-btn" href="{dm_url}" target="_blank" onclick="return openDM(event,this)" '
                  f'data-sug="{esc(t_content)}" data-url="{dm_url}" title="click to copy + open IG DM">📲 CONTENT</a>'
                  f'</div>')

    suggestion_html = (f'<div class="suggestion">'
                       f'<span class="sug-label">suggested reply:</span>'
                       f'{_bucket_info}{_focus_block}{_convo_block}{_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-tab-group="{("reply" if st == "needs_reply" else "hidden" if st == "done" else "bump")}" data-bucket="{_row_bucket}" data-next-bump="{_next_bump_iso}" data-intent="{intent_label.lower()}" data-score="{score}" data-name="{esc(r['name'].lower())}" data-handle="{esc(r['handle'])}">
    <td class="col-status">
      <button class="row-undo" onclick="return unsendDM(event,this)" title="undo sent">✕ undo</button>
      <div class="bucket-actions">
        <button class="bucket-btn b-exclude" onclick="return setBucket(event,this,'EXCLUDED')" title="exclude (ex/not interested)">🚫</button>
        <button class="bucket-btn b-partner" onclick="return setBucket(event,this,'PARTNER')" title="mark as partner/practitioner">🤝</button>
        <button class="bucket-btn b-restore" onclick="return setBucket(event,this,'LEAD')" title="restore to leads">↩</button>
      </div>
      <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><button class="undo-btn" onclick="return unsendDM(event,this)" title="mark as not sent">↶ undo</button>' 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>
"""


# ── Render engagers as additional rows ────────────────────────────────────────
# deconflict: if person also showed up as new-follower, only show them as follower
_NEW_FOLLOWER_HANDLES = {f.get("handle", "").lower().strip() for f in NEW_FOLLOWERS_DATA.get("followers", [])}
for _eng in ENGAGERS_DATA.get("engagers", []):
    handle = _eng.get("handle", "")
    name = _eng.get("name", handle.lstrip("@"))
    if not handle: continue
    h_lc = handle.lower().strip()
    # deconflict: skip if also a new-follower (follower bucket wins)
    if h_lc in _NEW_FOLLOWER_HANDLES: continue
    # final safety: skip if in EXCLUDE_HANDLES or USER_BUCKETS EXCLUDED/PARTNER
    if h_lc in EXCLUDE_HANDLES: continue
    if USER_BUCKETS.get(h_lc, {}).get("bucket") in ("EXCLUDED", "PARTNER"):
        continue
    bucket_counts["ENGAGERS"] += 1
    post = ENGAGERS_POSTS_BY_ID.get(_eng.get("post_id", ""), {})
    descriptor = post.get("descriptor", "your recent post")
    post_url = post.get("url", "")
    action = _eng.get("action", "engaged")
    when = _eng.get("ts", "")
    handle_raw = handle.lstrip("@")
    ig_url = f"https://www.instagram.com/{handle_raw}/"
    dm_url = f"https://ig.me/m/{handle_raw}"
    # extract first name for template (same logic as main loop)
    _src_name = name.strip()
    _tokens = re.split(r"[\s,._\-]+", _src_name)
    _first_e = ""
    for _tok in _tokens:
        _clean = re.sub(r"[^A-Za-z]", "", _tok)
        if len(_clean) >= 2 and _clean.lower() != handle_raw.lower():
            _first_e = _clean[0].upper() + _clean[1:].lower()
            break
    _greet_e = f"Hey {_first_e}" if _first_e else "Hey there"
    template = f"{_greet_e}! 🤗 Thanks so much for the love on our {descriptor}. 💙🙏 I'm wondering, what resonated for you the most?"
    comment_blurb = ""
    if action == "commented" and _eng.get("comment"):
        comment_blurb = f' "{esc(_eng["comment"][:100])}"'
    rows_html += f"""
  <tr class="row row-engager" data-status="engager" data-tab-group="engagers" data-bucket="LEAD" data-next-bump="" data-intent="" data-score="0" data-name="{esc(name.lower())}" data-handle="{esc(handle)}">
    <td class="col-status">
      <button class="row-undo" onclick="return unsendDM(event,this)" title="undo sent">✕ undo</button>
      <div class="bucket-actions">
        <button class="bucket-btn b-exclude" onclick="return setBucket(event,this,'EXCLUDED')" title="exclude">🚫</button>
        <button class="bucket-btn b-partner" onclick="return setBucket(event,this,'PARTNER')" title="mark as partner">🤝</button>
        <button class="bucket-btn b-restore" onclick="return setBucket(event,this,'LEAD')" title="restore">↩</button>
      </div>
      <span class="badge engager-badge">🎯 NEW LEAD</span>
    </td>
    <td class="col-person">
      <a href="{ig_url}" target="_blank" class="person-name">{esc(name)}</a>
      <a href="{ig_url}" target="_blank" class="person-handle">{esc(handle)}</a>
      <a href="{dm_url}" target="_blank" class="dm-link" onclick="return openDM(event,this)" data-sug="{esc(template)}" data-url="{dm_url}">open DM →</a>
    </td>
    <td class="col-date">
      <span class="date-rel">{action}</span>
      <span class="date-abs">{esc(when[:10])}</span>
    </td>
    <td class="col-msg">
      <div class="engager-info">🎯 {esc(action)} {f'<a href="{post_url}" target="_blank" style="color:#8ad">your post</a>' if post_url else 'your post'}{comment_blurb}</div>
      <div class="suggestion">
        <span class="sug-label">prefab message:</span>
        <a class="sug-text sug-clickable" href="{dm_url}" target="_blank" onclick="return openDM(event,this)" data-sug="{esc(template)}" data-url="{dm_url}" title="click to copy + open IG profile">{esc(template)}</a>
        <button class="copy-btn" onclick="copyText(this, `{template}`)">copy</button>
      </div>
    </td>
    <td class="col-flags"></td>
  </tr>
"""


# ── Render new followers as additional rows ──────────────────────────────────
for _nf in NEW_FOLLOWERS_DATA.get("followers", []):
    handle = _nf.get("handle", "")
    name = _nf.get("name", handle.lstrip("@"))
    if not handle: continue
    h_lc = handle.lower().strip()
    if h_lc in EXCLUDE_HANDLES: continue
    if USER_BUCKETS.get(h_lc, {}).get("bucket") in ("EXCLUDED", "PARTNER"):
        continue
    bucket_counts["NEW_FOLLOWERS"] += 1
    handle_raw = handle.lstrip("@")
    ig_url = f"https://www.instagram.com/{handle_raw}/"
    dm_url = f"https://ig.me/m/{handle_raw}"
    when = _nf.get("followed_at", "")
    # first name extraction
    _src_name = name.strip()
    _tokens = re.split(r"[\s,._\-]+", _src_name)
    _first_n = ""
    for _tok in _tokens:
        _clean = re.sub(r"[^A-Za-z]", "", _tok)
        if len(_clean) >= 2 and _clean.lower() != handle_raw.lower():
            _first_n = _clean[0].upper() + _clean[1:].lower()
            break
    _greet_n = f"Hey {_first_n}" if _first_n else "Hey there"
    welcome = f"{_greet_n}! 🤗 Thanks so much for following along. Curious, what brought you to Shaking Medicine?"
    welcome_floki = (
        f"{_greet_n}! 🤗 Thanks so much for following! I suppose you found me through that Shaking Medicine reel we made with Floki. "
        "I" + chr(39) + "m curious, what resonated the most for you?"
    )
    welcome_casual = (
        f"{_greet_n}! 🌿 So glad you" + chr(39) + "re here. "
        "Curious what drew you to explore shaking medicine?"
    )
    rows_html += f"""
  <tr class="row row-new-follower" data-status="new_follower" data-tab-group="newfollowers" data-bucket="LEAD" data-next-bump="" data-intent="" data-score="0" data-name="{esc(name.lower())}" data-handle="{esc(handle)}">
    <td class="col-status">
      <button class="row-undo" onclick="return unsendDM(event,this)" title="undo sent">✕ undo</button>
      <div class="bucket-actions">
        <button class="bucket-btn b-exclude" onclick="return setBucket(event,this,'EXCLUDED')" title="exclude">🚫</button>
        <button class="bucket-btn b-partner" onclick="return setBucket(event,this,'PARTNER')" title="mark as partner">🤝</button>
        <button class="bucket-btn b-restore" onclick="return setBucket(event,this,'LEAD')" title="restore">↩</button>
      </div>
      <span class="badge new-follower-badge">👋 NEW FOLLOWER</span>
    </td>
    <td class="col-person">
      <a href="{ig_url}" target="_blank" class="person-name">{esc(name)}</a>
      <a href="{ig_url}" target="_blank" class="person-handle">{esc(handle)}</a>
      <a href="{ig_url}" target="_blank" class="dm-link" onclick="return openDM(event,this)" data-sug="{esc(welcome)}" data-url="{ig_url}">open IG →</a>
    </td>
    <td class="col-date">
      <span class="date-rel">followed</span>
      <span class="date-abs">{esc(when[:10])}</span>
    </td>
    <td class="col-msg">
      <div class="new-follower-info">👋 just followed @shakingmedicine</div>
      <div class="suggestion">
        <span class="sug-label">welcome (default):</span>
        <a class="sug-text sug-clickable" href="{ig_url}" target="_blank" onclick="return openDM(event,this)" data-sug="{esc(welcome)}" data-url="{ig_url}" title="click to copy + open IG profile">{esc(welcome)}</a>
        <button class="copy-btn" onclick="copyText(this, `{welcome}`)">copy</button>
        <div class="tpl-row">
          <a class="tpl-btn" href="{ig_url}" target="_blank" onclick="return openDM(event,this)" data-sug="{esc(welcome_floki)}" data-url="{ig_url}" title="floki collab welcome">🎬 Floki</a>
          <a class="tpl-btn" href="{ig_url}" target="_blank" onclick="return openDM(event,this)" data-sug="{esc(welcome_casual)}" data-url="{ig_url}" title="casual welcome">🌿 casual</a>
        </div>
      </div>
    </td>
    <td class="col-flags"></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 ─────────────────────────────────────────────────────────────────
sent_handles_js = SENT_HANDLES_JS
user_buckets_js = _json.dumps(USER_BUCKETS)
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}}
.row-undo{{background:#3a1010;border:1px solid #6a1818;border-radius:4px;color:#ff8080;cursor:pointer;display:none;font-size:11px;font-weight:600;margin-bottom:6px;padding:4px 10px;transition:all .15s;width:fit-content}}
.row-undo:hover{{background:#5a1818;border-color:#8a2828;color:#ffb0b0}}
tr.row[data-sent="1"] .row-undo{{display:inline-block}}
/* bucket action buttons */
.bucket-actions{{display:flex;gap:3px;margin-top:4px;flex-wrap:wrap}}
.bucket-btn{{background:#1c1c1e;border:1px solid #2c2c2e;border-radius:4px;color:#777;cursor:pointer;font-size:12px;padding:2px 6px;transition:all .15s;line-height:1}}
.bucket-btn:hover{{background:#2c2c2e;color:#fff;border-color:#444}}
.b-exclude:hover{{border-color:#ff453a;color:#ff453a}}
.b-partner:hover{{border-color:#0a84ff;color:#0a84ff}}
.b-restore:hover{{border-color:#30d158;color:#30d158}}
/* restore button only visible when bucket != LEAD */
.b-restore{{display:none}}
tr.row[data-bucket="EXCLUDED"] .b-restore,
tr.row[data-bucket="PARTNER"] .b-restore{{display:inline-block}}
/* exclude/partner hidden when already in that bucket */
tr.row[data-bucket="EXCLUDED"] .b-exclude{{display:none}}
.engager-badge{{background:#1a3a2a;color:#5fffa0}}
.tab-btn.tab-engagers.active{{background:#5fffa0;border-color:#5fffa0;color:#000}}
.engager-info{{color:#9ef5b0;font-size:11px;margin-bottom:6px;padding:4px 8px;background:#0a1a10;border-left:3px solid #30d158;border-radius:3px}}
.new-follower-badge{{background:#3a2a4a;color:#d8b0ff}}
.tab-btn.tab-newfollowers.active{{background:#bf5af2;border-color:#bf5af2;color:#fff}}
.new-follower-info{{color:#d8b0ff;font-size:11px;margin-bottom:6px;padding:4px 8px;background:#1a0e2a;border-left:3px solid #bf5af2;border-radius:3px}}
.row-new-follower .col-status .badge{{background:#3a2a4a;color:#d8b0ff}}
.row-engager .col-status .badge{{background:#1a3a2a;color:#5fffa0}}
/* hide focus_hint, convo, suggestion's normal AI in engagers (it's the prefab template) */
body[data-tab="engagers"] tr.row[data-tab-group="engagers"]{{display:table-row}}

tr.row[data-bucket="PARTNER"] .b-partner{{display:none}}
/* archive tab: red tint */
.tab-btn.tab-archive.active{{background:#3a1010;border-color:#3a1010;color:#ff8080}}
/* in PARTNERS tab, hide all the AI tooling — partners need custom outreach */
body[data-tab="partners"] .focus-hint,
body[data-tab="partners"] .convo-expand,
body[data-tab="partners"] .sug-clickable,
body[data-tab="partners"] .copy-btn,
body[data-tab="partners"] .tpl-row,
body[data-tab="paused"] .focus-hint,
body[data-tab="paused"] .convo-expand,
body[data-tab="paused"] .sug-clickable,
body[data-tab="paused"] .copy-btn,
body[data-tab="paused"] .tpl-row{{display:none}}
/* paused-info badge in PAUSED tab */
.pause-info{{background:#1a1a0a;border-left:3px solid #f90;border-radius:3px;color:#ffa040;font-size:11px;padding:5px 8px;margin-top:4px}}
.partner-info{{background:#0a1a2a;border-left:3px solid #0a84ff;border-radius:3px;color:#7ab8ff;font-size:11px;padding:5px 8px;margin-top:4px}}
.archive-info{{background:#1a0a0a;border-left:3px solid #ff453a;border-radius:3px;color:#ff8080;font-size:11px;padding:5px 8px;margin-top:4px}}
body[data-tab="paused"] .pause-info,
body[data-tab="partners"] .partner-info,
body[data-tab="archive"] .archive-info{{display:block}}
.pause-info,.partner-info,.archive-info{{display:none}}
/* clickable AI suggestion + template chips */
.sug-clickable{{color:#ffd60a;cursor:pointer;display:inline;text-decoration:none}}
.sug-clickable:hover{{color:#fff5a0;text-decoration:underline}}
.tpl-row{{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}}
.tpl-btn{{background:#2a2018;border:1px solid #4a3520;border-radius:4px;color:#ffa040;cursor:pointer;font-size:11px;padding:3px 8px;text-decoration:none;transition:all .15s}}
.tpl-btn:hover{{background:#3a2a1c;color:#fff;border-color:#6a4a30}}
.focus-hint{{background:#0e1a2a;border-left:3px solid #0a84ff;border-radius:3px;color:#7ab8ff;font-size:11px;line-height:1.45;margin-bottom:6px;padding:5px 8px}}
.convo-expand{{background:#111;border:1px solid #222;border-radius:4px;font-size:11px;margin-bottom:6px}}
.convo-expand summary{{color:#777;cursor:pointer;font-size:10px;padding:5px 8px;user-select:none}}
.convo-expand summary:hover{{color:#aaa}}
.convo-expand[open] summary{{border-bottom:1px solid #1a1a1a;color:#aaa}}
.convo-body{{padding:6px 8px;display:flex;flex-direction:column;gap:5px;max-height:280px;overflow-y:auto}}
.convo-msg{{display:flex;flex-direction:column;gap:1px;line-height:1.4}}
.convo-meta{{color:#444;font-size:9px;text-transform:lowercase}}
.convo-text{{color:#ddd}}
.convo-keith .convo-text{{color:#9ecfff}}
.convo-keith .convo-meta{{color:#0a84ff}}
.convo-other .convo-text{{color:#9ef5b0}}
.convo-other .convo-meta{{color:#30d158}}
/* tab nav */
.tab-nav{{display:flex;gap:8px;margin:14px 0 6px}}
.tab-btn{{background:#1a1a1c;border:1px solid #2a2a2c;border-radius:8px;color:#888;cursor:pointer;font-size:13px;font-weight:600;padding:8px 16px;transition:all .15s}}
.tab-btn:hover{{border-color:#444;color:#ccc}}
.tab-btn.active{{background:#ff9500;border-color:#ff9500;color:#000}}
.tab-btn b{{font-weight:700;margin-left:4px;opacity:.85}}
/* hide rows not in active tab */
/* tab visibility now handled in applyFilters JS */

/* 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="tab-nav">
    <button class="tab-btn" data-tab="reply" onclick="switchTab('reply')">💬 REPLY <b id="replyCount">{bucket_counts["LEAD_REPLY"]}</b></button>
    <button class="tab-btn" data-tab="bump" onclick="switchTab('bump')">↑ BUMP UPS <b id="bumpCount">{bucket_counts["LEAD_BUMP"]}</b></button>
    <button class="tab-btn" data-tab="partners" onclick="switchTab('partners')">🤝 PARTNERS <b id="partnerCount">{bucket_counts["PARTNER"]}</b></button>
    <button class="tab-btn" data-tab="paused" onclick="switchTab('paused')">⏸ PAUSED <b id="pausedCount">{bucket_counts["LONG_PAUSE"]}</b></button>
    <button class="tab-btn tab-newfollowers" data-tab="newfollowers" onclick="switchTab('newfollowers')">👋 NEW FOLLOWERS <b id="newfollowersCount">{bucket_counts["NEW_FOLLOWERS"]}</b></button>
    <button class="tab-btn tab-engagers" data-tab="engagers" onclick="switchTab('engagers')">🎯 ENGAGERS <b id="engagersCount">{bucket_counts["ENGAGERS"]}</b></button>
    <button class="tab-btn tab-archive" data-tab="archive" onclick="switchTab('archive')">🗂 ARCHIVE <b id="archiveCount">{bucket_counts["EXCLUDED"] + bucket_counts["EXHAUSTED"]}</b></button>
  </div>
  <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>
const SENT_HANDLES = new Set({sent_handles_js});
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 setBucket(e, btn, bucket) {{
  e.preventDefault();
  const row = btn.closest("tr");
  if (!row) return false;
  const handle = row.dataset.handle;
  const prev = row.dataset.bucket || "LEAD";
  row.dataset.bucket = bucket;
  applyFilters();
  updateTabCounts();
  fetch("/bucket", {{
    method: "POST",
    headers: {{"Content-Type": "application/json"}},
    body: JSON.stringify({{ handle, bucket }})
  }}).catch(() => {{ /* best-effort */ }});
  return false;
}}

function updateTabCounts() {{
  const c = {{reply: 0, bump: 0, partners: 0, paused: 0, archive: 0, engagers: 0, newfollowers: 0}};
  document.querySelectorAll('#tbody tr.row').forEach(tr => {{
    const bucket = tr.dataset.bucket || 'LEAD';
    const tg = tr.dataset.tabGroup;
    const isSent = tr.dataset.sent === '1';
    if (tg === 'reply' && bucket === 'LEAD' && !isSent) c.reply++;
    else if (tg === 'bump' && bucket === 'LEAD' && !isSent) c.bump++;
    else if (bucket === 'PARTNER') c.partners++;
    else if (bucket === 'LONG_PAUSE') c.paused++;
    else if (bucket === 'EXCLUDED' || bucket === 'EXHAUSTED') c.archive++;
    if (tg === 'engagers' && bucket === 'LEAD') c.engagers++;
    if (tg === 'newfollowers' && bucket === 'LEAD') c.newfollowers++;
  }});
  ['reply','bump','partners','paused','archive','engagers','newfollowers'].forEach(t => {{
    const el = document.getElementById(t + 'Count');
    if (el) el.textContent = c[t];
  }});
}}

function unsendDM(e, btn) {{
  e.preventDefault();
  const row = btn.closest("tr");
  if (!row) return false;
  const link = row.querySelector(".dm-link");
  const handle = row.dataset.handle;
  // revert UI state
  delete row.dataset.sent;
  row.classList.remove("row-sent");
  if (link) {{ link.textContent = "open DM →"; link.style.color = ""; }}
  const sc = document.getElementById("sentCount");
  if (sc) sc.textContent = Math.max(0, parseInt(sc.textContent || 0) - 1);
  applyFilters();
  updateTabCounts();
  // tell server (best-effort; if it fails, UI revert still stands)
  fetch("/untrack", {{
    method: "POST",
    headers: {{"Content-Type": "application/json"}},
    body: JSON.stringify({{ handle }})
  }}).catch(() => {{}});
  return false;
}}

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';
  const currentTab = document.body.dataset.tab || 'reply';
  document.querySelectorAll('#tbody tr.row').forEach(tr => {{
    const bucket = tr.dataset.bucket || 'LEAD';
    const tg = tr.dataset.tabGroup;
    let matchTab = false;
    if (currentTab === 'reply'   && tg === 'reply' && bucket === 'LEAD') matchTab = true;
    else if (currentTab === 'bump'    && tg === 'bump'  && bucket === 'LEAD') matchTab = true;
    else if (currentTab === 'partners' && bucket === 'PARTNER') matchTab = true;
    else if (currentTab === 'paused'   && bucket === 'LONG_PAUSE') matchTab = true;
    else if (currentTab === 'engagers' && tg === 'engagers' && bucket === 'LEAD') matchTab = true;
    else if (currentTab === 'newfollowers' && tg === 'newfollowers' && bucket === 'LEAD') matchTab = true;
    else if (currentTab === 'archive'  && (bucket === 'EXCLUDED' || bucket === 'EXHAUSTED')) matchTab = true;
    if (!matchTab) {{ tr.classList.add('row-hidden'); return; }}
    const isSent = tr.dataset.sent === '1';
    const ignoreSent = (currentTab === 'paused' || currentTab === 'archive' || currentTab === 'partners' || currentTab === 'engagers' || currentTab === 'newfollowers' || currentTab === 'reply');
    const matchSent = ignoreSent || (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() {{
  let sentSet = SENT_HANDLES;
  try {{
    const r = await fetch('/log.json', {{cache:'no-store'}});
    if (r.ok) sentSet = new Set(await r.json());
  }} catch(e) {{}}
  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();
  updateTabCounts();
}}
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 switchTab(tab) {{
  document.body.dataset.tab = tab;
  document.querySelectorAll(".tab-btn").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
  const tbody = document.getElementById("tbody");
  const rows = [...tbody.querySelectorAll("tr.row")];
  if (tab === "bump" || tab === "paused" || tab === "archive") {{
    rows.sort((a, b) => (parseInt(b.dataset.score)||0) - (parseInt(a.dataset.score)||0));
  }} else {{
    rows.sort((a, b) => {{
      const da = rowDate(a); const db = rowDate(b);
      return (db === null ? -Infinity : db) - (da === null ? -Infinity : da);
    }});
  }}
  rows.forEach(r => tbody.appendChild(r));
  // auto-default preset per tab: bump → older2w, others → none
  activePreset = (tab === 'bump') ? 'older2w' : '';
  document.querySelectorAll('.preset').forEach(p => p.classList.toggle('active', p.dataset.preset === activePreset));
  applyFilters();
  updateTabCounts();
  history.replaceState(null, "", "#" + tab);
}}
// init: hash overrides default; default = reply
document.addEventListener("DOMContentLoaded", () => {{
  const valid = ["reply", "bump", "partners", "paused", "archive", "engagers", "newfollowers"];
  const initial = (window.location.hash || "#reply").slice(1);
  switchTab(valid.includes(initial) ? initial : "reply");
}});

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}")
