"""
dashboard_common.py: shared pieces for the extraction dashboards
==================================================================================
Used by device_dashboard.py and exposure_dashboard.py. Every dashboard built with it:

  - shows participant counts only, with counts below k held back for cell size;
  - writes a counts-only file that can be reviewed for sharing, and, only on request, a
    second "review" file with short excerpts in which dates, phone numbers, e-mail
    addresses and long ID-like numbers are masked (names can remain, so that file is
    restricted to the review team);
  - loads nothing from the internet and prints every tab when printed.

Registry Forge (Boyce D, Premasiri A, Sullivan S, Levine B, Vieira FG. JAMIA Open
2026;9(5):ooag200). MIT License.
"""

import csv
import datetime as _dt
import html
import os
import re
from collections import Counter, defaultdict

HELD = 'held back for cell size'
DEFAULT_K_ANONYMITY = 5

CONTEXT_LABEL = {'negated_possible': 'possible negation', 'family_history_possible': 'possible family history',
                 'uncertain_possible': 'possible uncertainty', 'historical_possible': 'possibly historical',
                 'unspecified': 'no cue found'}

CSS = """
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;background:#f7f7f5;color:#1d2433;font-size:14px;line-height:1.5}
header{background:#51284F;color:#fff;padding:20px 28px}header h1{margin:0;font-size:21px;font-weight:600}
header .meta{color:#EDD6E4;font-size:13px;margin-top:4px}
.banner{margin:14px 28px 0;padding:10px 14px;border-radius:6px;font-size:13px;background:#fff8e6;border:1px solid #e3c46b;color:#5a4300}
.banner.restricted{background:#fdecea;border-color:#d9827a;color:#7a1f16}
.stats{display:flex;flex-wrap:wrap;gap:12px;padding:16px 28px 0}
.stat{background:#fff;border:1px solid #e1e3e6;border-radius:8px;padding:12px 16px;min-width:180px}
.stat .v{font-size:24px;font-weight:600;color:#51284F}.stat .l{font-size:12px;color:#5b6573}
nav{display:flex;flex-wrap:wrap;gap:4px;padding:12px 28px 0;border-bottom:1px solid #e1e3e6;background:#fff;margin-top:16px}
nav button{background:none;border:0;padding:10px 14px;font-size:14px;color:#5b6573;cursor:pointer;border-bottom:3px solid transparent}
nav button.active{color:#51284F;border-bottom-color:#93358C}
main{padding:18px 28px 48px;max-width:1400px}.panel{display:none}.panel.active{display:block}
section{background:#fff;border:1px solid #e1e3e6;border-radius:8px;padding:16px 20px;margin-bottom:16px}
section h2{margin:0 0 8px;font-size:16px;color:#51284F}section h3{margin:16px 0 6px;font-size:14px;color:#51284F}
p.note{color:#5b6573;font-size:13px;margin:4px 0 10px}
table{width:100%;border-collapse:collapse;font-size:12.5px;font-variant-numeric:tabular-nums}
th{background:#eef1f4;text-align:left;padding:6px 8px;color:#51284F;font-weight:600;border-bottom:2px solid #d6dbe0}
td{padding:6px 8px;border-bottom:1px solid #eef0f2;vertical-align:top}td.n,th.n{text-align:right}
.held{color:#8a5a00;font-style:italic}
.tag{display:inline-block;background:#eef1f4;color:#51284F;padding:1px 7px;border-radius:3px;font-size:11px}
.bar{height:10px;background:#93358C;border-radius:2px}
.excerpt{background:#f4f7f9;border-left:3px solid #93358C;padding:6px 10px;margin:6px 0;font-size:13px}
.excerpt .m{font-size:11px;color:#5b6573}
input.filter{padding:6px 9px;border:1px solid #cfd5db;border-radius:4px;width:260px;font-size:13px;margin-bottom:8px}
@media print{nav{display:none}.panel{display:block!important}}
"""

JS = """
function showTab(n){document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.querySelectorAll('nav button').forEach(b=>b.classList.remove('active'));
document.getElementById('panel-'+n).classList.add('active');document.querySelector('nav button[data-tab='+n+']').classList.add('active');}
function filterTable(i,id){const q=i.value.toLowerCase();for(const r of document.querySelector('#'+id+' tbody').rows){r.style.display=r.textContent.toLowerCase().includes(q)?'':'none';}}
"""

DATE_RX = re.compile(r'\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{4}-\d{2}-\d{2}(?:T[\d:.+-Z]+)?|'
                     r'(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}|'
                     r'\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{4})\b', re.I)
PHONE_RX = re.compile(r'\(?\b\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b')
EMAIL_RX = re.compile(r'\b[\w.+-]+@[\w-]+\.[\w.-]+\b')
LONGNUM_RX = re.compile(r'\b(?:[A-Z]{2,4}-?)?\d{6,}\b')


def esc(s):
    return html.escape('' if s is None else str(s))


def shown(n, k):
    return n if n == 0 or n >= k else HELD


def cell(v):
    if v == HELD:
        return '<span class="held">held back for cell size</span>'
    if isinstance(v, (int, float)):
        return f'{v:,}'
    return esc(v) if v not in (None, '') else '&ndash;'


def mask_excerpt(text, n=220):
    t = ' '.join(str(text or '').split())
    for rx, label in ((DATE_RX, '[date]'), (EMAIL_RX, '[email]'), (PHONE_RX, '[phone]'), (LONGNUM_RX, '[number]')):
        t = rx.sub(label, t)
    return t if len(t) <= n else t[:n - 3] + '...'


def load_rows(path):
    if not path or not os.path.exists(path):
        return []
    with open(path, encoding='utf-8-sig', newline='') as f:
        return list(csv.DictReader(f))


def category_table(code_rows, text_rows, k, labels=None, extra_col=None):
    """Participants with a coded record, a text candidate, or either, per category."""
    labels = labels or {}
    coded, text = defaultdict(set), defaultdict(set)
    for r in code_rows:
        coded[r.get('category')].add(r.get('patient_id'))
    for r in text_rows:
        text[r.get('category')].add(r.get('patient_id'))
    cats = sorted(set(coded) | set(text), key=lambda c: -len(coded[c] | text[c]))
    if not cats:
        return '<p class="note">No matches.</p>'
    mx = max(len(coded[c] | text[c]) for c in cats) or 1
    rows = []
    for c in cats:
        n_any = len(coded[c] | text[c])
        any_shown = shown(n_any, k)
        bar = f'<div class="bar" style="width:{100 * n_any / mx:.0f}%"></div>' if any_shown != HELD else ''
        extra = f'<td>{extra_col(c)}</td>' if extra_col else ''
        rows.append(f'<tr><td>{esc(labels.get(c, c))}</td><td class="n">{cell(shown(len(coded[c]), k))}</td>'
                    f'<td class="n">{cell(shown(len(text[c]), k))}</td><td class="n">{cell(any_shown)}</td>'
                    f'<td style="width:30%">{bar}</td>{extra}</tr>')
    extra_head = '<th>ECTO term</th>' if extra_col else ''
    return ('<table><tr><th>Category</th><th class="n">Participants, coded record</th><th class="n">Participants, text candidate</th>'
            f'<th class="n">Participants, either</th><th></th>{extra_head}</tr>' + ''.join(rows) + '</table>')


def pattern_table(text_rows, k, table_id='pat'):
    pts, recs, ctx, meta = defaultdict(set), Counter(), defaultdict(Counter), {}
    for r in text_rows:
        p = r.get('pattern')
        pts[p].add(r.get('patient_id'))
        recs[p] += 1
        ctx[p][r.get('context_hint') or 'unspecified'] += 1
        meta.setdefault(p, (r.get('category'), r.get('description')))
    rows = []
    for p in sorted(pts, key=lambda x: -len(pts[x])):
        n = len(pts[p])
        if n < k:
            rc, cc = HELD, HELD
        else:
            rc = recs[p]
            cues = [f'{CONTEXT_LABEL.get(c, c)} {v / recs[p]:.0%}' for c, v in ctx[p].most_common() if c != 'unspecified']
            cc = ', '.join(cues) or 'none'
        rows.append(f'<tr><td><code>{esc(p)}</code><br><span class="note">{esc(meta[p][1])}</span></td>'
                    f'<td><span class="tag">{esc(meta[p][0])}</span></td><td class="n">{cell(shown(n, k))}</td>'
                    f'<td class="n">{cell(rc)}</td><td>{cell(cc)}</td></tr>')
    if not rows:
        return '<p class="note">No text matches.</p>'
    return (f'<input class="filter" placeholder="Filter" oninput="filterTable(this,\'{table_id}\')">'
            f'<table id="{table_id}"><thead><tr><th>Pattern</th><th>Category</th><th class="n">Participants</th>'
            '<th class="n">Candidates</th><th>Share of candidates with a context cue</th></tr></thead><tbody>'
            + ''.join(rows) + '</tbody></table>')


def code_table(code_rows, k):
    pts, meta = defaultdict(set), {}
    for r in code_rows:
        key = (r.get('code_system'), r.get('code'))
        pts[key].add(r.get('patient_id'))
        meta.setdefault(key, (r.get('label') or r.get('forge_label') or r.get('recorded_display_name'), r.get('category')))
    if not pts:
        return '<p class="note">No coded records matched.</p>'
    rows = ''.join(f'<tr><td><code>{esc(v)} {esc(c)}</code></td><td>{esc(meta[(v, c)][0])}</td><td><span class="tag">{esc(meta[(v, c)][1])}</span></td>'
                   f'<td class="n">{cell(shown(len(p), k))}</td></tr>'
                   for (v, c), p in sorted(pts.items(), key=lambda kv: -len(kv[1])))
    return f'<table><tr><th>Code</th><th>Label</th><th>Category</th><th class="n">Participants</th></tr>{rows}</table>'


def excerpts(text_rows, k, per_pattern=3):
    pts = defaultdict(set)
    for r in text_rows:
        pts[r.get('pattern')].add(r.get('patient_id'))
    pseudo, seen, out = {}, defaultdict(set), defaultdict(list)
    for r in text_rows:
        p, pid = r.get('pattern'), r.get('patient_id')
        if len(pts[p]) < k or pid in seen[p] or len(seen[p]) >= per_pattern:
            continue
        seen[p].add(pid)
        pseudo.setdefault(pid, f'EX-{len(pseudo) + 1:04d}')
        out[p].append((pseudo[pid], r.get('source_kind', ''), r.get('context_hint', ''), mask_excerpt(r.get('snippet'))))
    parts = []
    for p in sorted(out):
        parts.append(f'<h3><code>{esc(p)}</code></h3>')
        for pid, kind, ctx, text in out[p]:
            parts.append(f'<div class="excerpt"><div class="m">{esc(pid)} &middot; {esc(kind)} &middot; '
                         f'{esc(CONTEXT_LABEL.get(ctx, ctx))}</div>{esc(text)}</div>')
    return ''.join(parts) or '<p class="note">No pattern has enough participants to show excerpts.</p>'


def page(title, cohort_name, k, stats, panels, restricted):
    """panels: list of (tab_id, tab_label, heading, note, html)."""
    now = _dt.datetime.now(_dt.timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
    banner = ('<div class="banner restricted"><b>Restricted:</b> this file contains excerpts from clinical text. Dates, long '
              'numbers, phone numbers and e-mail addresses are masked, but names and other free text can remain. Keep it '
              'with the review team and share the counts-only file instead.</div>' if restricted else
              f'<div class="banner"><b>Privacy:</b> this file holds counts only. Participant counts below {k} are held back for cell size, '
              'and no text from the records is included. Review the file in full before sharing it.</div>')
    nav = ''.join(f'<button data-tab="{t}" class="{"active" if i == 0 else ""}" onclick="showTab(\'{t}\')">{esc(l)}</button>'
                  for i, (t, l, _, _, _) in enumerate(panels))
    body = ''.join(f'<div id="panel-{t}" class="panel{" active" if i == 0 else ""}"><section><h2>{esc(h)}</h2>'
                   f'{f"<p class=note>{esc(n)}</p>" if n else ""}{x}</section></div>' for i, (t, _, h, n, x) in enumerate(panels))
    stat_html = ''.join(f'<div class="stat"><div class="v">{cell(v)}</div><div class="l">{esc(l)}</div></div>' for l, v in stats)
    return f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>{esc(title)}: {esc(cohort_name)}</title>
<style>{CSS}</style></head><body>
<header><h1>Registry Forge: {esc(title.lower())}</h1>
<div class="meta">{esc(cohort_name)} &middot; k = {k} &middot; generated {esc(now)}</div></header>
{banner}<div class="stats">{stat_html}</div><nav>{nav}</nav><main>{body}</main><script>{JS}</script></body></html>"""
