"""
cohort_eda.py: Registry Forge cohort summary report
======================================================================
Reads dashboard_data.json and writes one self-contained HTML report describing the
cohort, the volume and coverage of the data, the vocabularies in use, and ALS markers
(motor neuron disease diagnoses, coded ALSFRS-R and FVC observations, ALS medicines,
frontotemporal dementia codes, reports, recorded deaths).

Privacy controls
----------------
  - Participant IDs are replaced with PT-NNNN pseudonyms in sorted order.
  - Dates of birth become 10-year age bands at the reference date; ages over 89 are "90+".
  - Observation periods are shown as duration bands, and activity over time by year.
  - Every participant count below k (default 5) is held back for cell size. Where
    categories add up to a published total, a second category is held back when needed
    so the first cannot be worked out by subtraction.
  - Per-participant values (records per participant, observation years) are binned in
    Python; the HTML holds no per-participant list of values or demographics.
  - Free text from notes is never included.
  - The report loads nothing from the internet: no fonts, scripts or charting libraries.

Usage
-----
    python cohort_eda.py --bundle dashboard_data.json --out cohort_eda_report.html --k 5

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

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


# ============================================================================
# DEFAULTS & CONSTANTS
# ============================================================================
DEFAULT_K_ANONYMITY = 5
HELD = 'held back for cell size'

AGE_BANDS = ['<20', '20-29', '30-39', '40-49', '50-59', '60-69', '70-79', '80-89', '90+']

CATEGORIES = ['documents', 'encounters', 'problems', 'medications', 'labs_vitals', 'observations',
              'procedures', 'immunizations', 'allergies', 'care_plans', 'goals', 'diagnostic_reports', 'notes']

# ALS markers shown in their own panel
ALS_DISEASE_CODES = {
    ('ICD-10-CM', 'G12.21'): 'Amyotrophic lateral sclerosis',
    ('ICD-9-CM', '335.20'):  'Amyotrophic lateral sclerosis',
    ('SNOMED-CT', '86044005'): 'Amyotrophic lateral sclerosis',
    ('ICD-10-CM', 'G12.22'): 'Progressive bulbar palsy',
    ('ICD-9-CM', '335.22'):  'Progressive bulbar palsy',
    ('ICD-10-CM', 'G12.23'): 'Primary lateral sclerosis',
    ('ICD-9-CM', '335.24'):  'Primary lateral sclerosis',
    ('ICD-10-CM', 'G12.20'): 'Other or unspecified motor neuron disease',
    ('ICD-10-CM', 'G12.29'): 'Other or unspecified motor neuron disease',
    ('SNOMED-CT', '230258005'): 'Other or unspecified motor neuron disease',
}
FTD_CODES = {('ICD-10-CM', 'G31.09'), ('ICD-10-CM', 'G31.01')}
ALS_MEDICATION_PATTERNS = [
    (re.compile(r'\briluzole\b|\brilutek\b|\btiglutik\b|\bexservan\b', re.I), 'Riluzole'),
    (re.compile(r'\bedaravone\b|\bradicava\b', re.I), 'Edaravone'),
    (re.compile(r'\btofersen\b|\bqalsody\b', re.I), 'Tofersen (SOD1)'),
    (re.compile(r'\bsodium phenylbutyrate\b|\btaurursodiol\b|\brelyvrio\b|\balbrioza\b', re.I), 'Phenylbutyrate-taurursodiol'),
]
# LOINC codes checked against loinc.org (November 2026). LOINC has an ALSFRS-R total
# score (82953-1) but no codes for the four domain subscores.
ALS_OBSERVATION_LOINCS = {
    '82953-1': 'ALSFRS-R total recorded as a coded observation',
    '19868-9': 'FVC recorded as a coded observation',
    '19870-5': 'FVC recorded as a coded observation',
    '19872-1': 'FVC percent predicted recorded as a coded observation',
}

# Date fields we'll look at on each record, in priority order
DATE_FIELDS = ['effective_date', 'start_date', 'authored_on',
               'onset_date_time', 'period_start', 'recorded_date',
               'date', 'occurrence_date_time']

# Friendly-name mapping for vocabulary identifiers. Both OIDs and URLs.
# When a record's vocabulary isn't already in human form (e.g. it's a raw OID),
# we map to a friendly label here. Anything starting with the Epic prefix
# 1.2.840.114350 collapses to 'Epic local' to keep the bar chart legible.
OID_FRIENDLY_NAMES = {
    '2.16.840.1.113883.6.96':   'SNOMED-CT',
    '2.16.840.1.113883.6.88':   'RxNorm',
    '2.16.840.1.113883.6.1':    'LOINC',
    '2.16.840.1.113883.6.90':   'ICD-10-CM',
    '2.16.840.1.113883.6.103':  'ICD-9-CM',
    '2.16.840.1.113883.6.12':   'CPT-4',
    '2.16.840.1.113883.6.13':   'CDT',
    '2.16.840.1.113883.6.4':    'ICD-10-PCS',
    '2.16.840.1.113883.6.14':   'HCPCS',
    '2.16.840.1.113883.6.285':  'HCPCS Level II',
    '2.16.840.1.113883.12.292': 'CVX',
    '2.16.840.1.113883.6.69':   'NDC',
    '2.16.840.1.113883.6.238':  'CDC Race & Ethnicity',
    '2.16.840.1.113883.5.83':   'HL7 ObservationInterpretation',
    '2.16.840.1.113883.6.59':   'CVX',
    '2.16.840.1.113883.5.4':    'HL7 ActCode',
    '2.16.840.1.113883.5.25':   'HL7 Confidentiality',
    '2.16.840.1.113883.5.111':  'HL7 RoleCode',
    '2.16.840.1.113883.4.642.4.224': 'HL7 AdministrativeGender',
    '2.16.840.1.113883.6.68':   'NDDF (First DataBank)',
    '2.16.840.1.113883.6.314':  'Medispan',
}
URL_FRIENDLY_NAMES = {
    'http://snomed.info/sct':                                   'SNOMED-CT',
    'http://www.nlm.nih.gov/research/umls/rxnorm':              'RxNorm',
    'http://loinc.org':                                         'LOINC',
    'http://hl7.org/fhir/sid/icd-10-cm':                        'ICD-10-CM',
    'http://hl7.org/fhir/sid/icd-10':                           'ICD-10-CM',
    'http://hl7.org/fhir/sid/icd-9-cm':                         'ICD-9-CM',
    'http://www.ama-assn.org/go/cpt':                           'CPT-4',
    'http://hl7.org/fhir/sid/cvx':                              'CVX',
    'http://hl7.org/fhir/sid/ndc':                              'NDC',
    'urn:oid:2.16.840.1.113883.6.238':                          'CDC Race & Ethnicity',
    'http://terminology.hl7.org/CodeSystem/v3-Race':            'CDC Race & Ethnicity',
    'http://terminology.hl7.org/CodeSystem/v3-Ethnicity':       'CDC Race & Ethnicity',
}
EPIC_OID_PREFIX = '1.2.840.114350'
CERNER_OID_PREFIX = '2.16.840.1.113883.3.247'

# Case-insensitive string aliases for vocabularies. Matches the lower-cased
# raw vocab to a canonical name. Catches the variants real EHR exports use.
STRING_ALIASES = {
    # SNOMED variants
    'snomed': 'SNOMED-CT', 'snomedct': 'SNOMED-CT', 'snomed ct': 'SNOMED-CT',
    'snomed-ct': 'SNOMED-CT', 'snomed_ct': 'SNOMED-CT', 'sct': 'SNOMED-CT',
    # ICD-10
    'icd10': 'ICD-10-CM', 'icd-10': 'ICD-10-CM', 'icd 10': 'ICD-10-CM',
    'icd10cm': 'ICD-10-CM', 'icd-10-cm': 'ICD-10-CM', 'icd-10cm': 'ICD-10-CM',
    'icd-10cm (diagnosis codes)': 'ICD-10-CM', 'icd-10-cm (diagnosis codes)': 'ICD-10-CM',
    # ICD-9
    'icd9': 'ICD-9-CM', 'icd-9': 'ICD-9-CM', 'icd 9': 'ICD-9-CM',
    'icd9cm': 'ICD-9-CM', 'icd-9-cm': 'ICD-9-CM', 'icd-9cm': 'ICD-9-CM',
    'icd-9cm (diagnosis codes)': 'ICD-9-CM', 'icd-9-cm (diagnosis codes)': 'ICD-9-CM',
    # RxNorm
    'rxnorm': 'RxNorm',
    # LOINC
    'loinc': 'LOINC',
    # CPT
    'cpt': 'CPT-4', 'cpt4': 'CPT-4', 'cpt-4': 'CPT-4', 'cpt (4)': 'CPT-4',
    # HCPCS
    'hcpcs': 'HCPCS', 'hcpcs level ii': 'HCPCS Level II',
    # CVX
    'cvx': 'CVX',
    # NDC
    'ndc': 'NDC',
    # IMO ProblemIT variants
    'problem-it': 'IMO ProblemIT', 'problemit': 'IMO ProblemIT',
    'imo': 'IMO ProblemIT', 'imo problemit': 'IMO ProblemIT',
    'intelligent medical objects problemit': 'IMO ProblemIT',
    'imo problem-it': 'IMO ProblemIT',
    # Medispan / Medi-Span: multiple naming conventions in Epic exports
    'medispan': 'Medispan',
    'medispan drug descriptor id': 'Medispan',
    'medi-span generic product identifier': 'Medispan',
    'medi-span': 'Medispan', 'mediscan': 'Medispan',
    # NDDF (First DataBank)
    'nddf': 'NDDF', 'nddf (first databank)': 'NDDF',
    'first databank': 'NDDF',
    # FHIR metadata code systems (not clinical content but show up as records)
    'data-absent-reason': 'FHIR DataAbsentReason',
    'observation-category': 'FHIR ObservationCategory',
    'condition-category': 'FHIR ConditionCategory',
    # Epic encounter and other FHIR-flavored encounter classes
    'fhir encounter class': 'FHIR encounter class',
}

# Substring patterns: anything containing these prefixes collapses to the
# canonical label. Catches Epic.* string-style namespaces and similar.
SUBSTRING_TO_LABEL = [
    ('epic.',                  'Epic local'),
    ('observation-flowsheet',  'Epic local'),  # Epic flowsheet IDs
    ('flowsheet-id',           'Epic local'),
    ('cerner.',                'Cerner local'),
]


def normalize_vocab(raw):
    """Map a raw vocabulary identifier (OID, URL, or string) to a friendly
    label. Annotated with what real Epic exports contain.
    Returns the original string if no rule matches."""
    if not raw:
        return 'Unknown'
    s = str(raw).strip()
    lower = s.lower()

    # Strip urn:oid: prefix
    if lower.startswith('urn:oid:'):
        s = s[len('urn:oid:'):]
        lower = s.lower()

    # Direct OID match
    if s in OID_FRIENDLY_NAMES:
        return OID_FRIENDLY_NAMES[s]

    # URL match (full or via urn:oid:-stripped form)
    if s in URL_FRIENDLY_NAMES:
        return URL_FRIENDLY_NAMES[s]

    # OID-prefix matches: Epic and Cerner local code tables
    if s.startswith(EPIC_OID_PREFIX):    return 'Epic local'
    if s.startswith(CERNER_OID_PREFIX):  return 'Cerner local'

    # Case-insensitive direct string alias
    if lower in STRING_ALIASES:
        return STRING_ALIASES[lower]

    # Substring matches (Epic.*, observation-flowsheet-*, etc.)
    for needle, label in SUBSTRING_TO_LABEL:
        if needle in lower:
            return label

    # Bare integer with dots: looks like an unknown OID
    if re.match(r'^\d+(\.\d+)+$', s):
        return f'Other local (OID {s[:20]}…)'

    # Bare integer: likely an HL7 v2 table or local code system number
    if re.match(r'^\d+$', s):
        return f'Local table {s}'

    # URL we don't recognize: show last path segment
    if s.startswith('http://') or s.startswith('https://'):
        tail = s.rstrip('/').split('/')[-1]
        return tail or 'Unknown URL'

    # Otherwise return as-is (already a friendly label, probably)
    return s


# ============================================================================
# PSEUDONYMIZATION
# ============================================================================
def build_id_map(patient_ids):
    """Stable sorted-order pseudonymization. Same patient_id always gets
    the same PT-NNNN suffix as long as the same cohort is present."""
    sorted_ids = sorted(set(p for p in patient_ids if p))
    return {pid: f'PT-{i+1:04d}' for i, pid in enumerate(sorted_ids)}


# ============================================================================
# DEMOGRAPHIC NORMALIZATION
# ============================================================================
SEX_NORM = {
    'm': 'Male', 'male': 'Male', 'M': 'Male',
    'f': 'Female', 'female': 'Female', 'F': 'Female',
    'o': 'Other', 'other': 'Other',
    'u': 'Unknown', 'unknown': 'Unknown',
}

def normalize_sex(s):
    if not s: return 'Unknown'
    return SEX_NORM.get(str(s).strip().lower(), 'Other')


def parse_date(s):
    if not s: return None
    s = str(s)[:10]
    try:
        return _dt.date.fromisoformat(s)
    except ValueError:
        pass
    # Be lenient with /-separated or YYYYMMDD
    m = re.match(r'^(\d{4})[-/]?(\d{1,2})[-/]?(\d{1,2})$', s)
    if m:
        try:
            return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
        except ValueError:
            return None
    m = re.match(r'^(\d{4})$', s)
    if m:
        try:
            return _dt.date(int(m.group(1)), 7, 1)  # mid-year approximation
        except ValueError:
            return None
    return None


def compute_age(dob_str, ref_date):
    dob = parse_date(dob_str)
    if not dob: return None
    age = ref_date.year - dob.year - ((ref_date.month, ref_date.day) < (dob.month, dob.day))
    return max(0, age)


def age_band(age):
    if age is None: return 'Unknown'
    if age < 20:    return '<20'
    if age >= 90:   return '90+'
    decade = (age // 10) * 10
    return f'{decade}-{decade+9}'


def obs_period_band(years):
    if years is None: return 'Unknown'
    if years < 1:   return '<1 year'
    if years < 2:   return '1-2 years'
    if years < 5:   return '2-5 years'
    if years < 10:  return '5-10 years'
    if years < 20:  return '10-20 years'
    return '20+ years'


def record_count_band(n):
    if n == 0:      return '0'
    if n < 10:      return '1-9'
    if n < 50:      return '10-49'
    if n < 100:     return '50-99'
    if n < 500:     return '100-499'
    if n < 1000:    return '500-999'
    return '1000+'


# ============================================================================
# K-ANONYMITY
# ============================================================================
def k_safe(n, k):
    """Return n if n >= k else None (None renders as '<k' in HTML)."""
    return n if (n is None or n >= k) else None


def filter_counter_k(counter, k):
    """Apply k-anonymity to a Counter; small categories collapse to 'Other (<k)'."""
    safe = {}
    suppressed = 0
    for key, val in counter.items():
        if val >= k:
            safe[key] = val
        else:
            suppressed += val
    if suppressed:
        safe[f'Other (<{k} each)'] = suppressed
    return safe


def suppress_with_total(counter, k):
    """{label: n} -> {label: n or HELD} for categories that add up to a published total.
    Counts from 1 to k-1 are held back; when exactly one cell is held back, the next
    smallest is held back too, so no held-back count can be recovered by subtraction."""
    out = {key: (HELD if 0 < n < k else n) for key, n in counter.items()}
    if sum(1 for v in out.values() if v == HELD) == 1:
        rest = sorted((n, key) for key, n in counter.items() if out[key] != HELD and n > 0)
        if rest:
            out[rest[0][1]] = HELD
    return out



# ============================================================================
# CORE AGGREGATION
# ============================================================================
def _mnd_group(vocab, code):
    return ALS_DISEASE_CODES.get((vocab, (code or '').upper()))


def _record_date(row):
    for f in DATE_FIELDS:
        d = parse_date(row.get(f))
        if d:
            return d
    return None


def compute_eda(bundle, k=DEFAULT_K_ANONYMITY, reference_date=None):
    patients = bundle.get('patients') or []
    if not patients:
        raise ValueError('Bundle has no patients[] array')
    id_map = build_id_map([p.get('patient_id') for p in patients])

    # Reference date for ages: the data cut-off if given, otherwise the latest record date
    all_dates = [d for cat in CATEGORIES for r in (bundle.get(cat) or []) for d in [_record_date(r)] if d]
    ref = parse_date(reference_date) if isinstance(reference_date, str) else reference_date
    ref = ref or (max(all_dates) if all_dates else _dt.date.today())

    prof = {}
    for p in patients:
        pid = p.get('patient_id')
        if not pid:
            continue
        death = parse_date(p.get('deceased_date_time') or p.get('death_date'))
        age = compute_age(p.get('dob') or p.get('birth_date') or p.get('birthDate') or '', min(ref, death) if death else ref)
        prof[id_map[pid]] = {'sex': normalize_sex(p.get('gender') or p.get('sex')), 'age_band': age_band(age),
                             'race': (p.get('race') or 'Unknown').strip()[:50] or 'Unknown',
                             'ethnicity': (p.get('ethnicity') or 'Unknown').strip()[:50] or 'Unknown',
                             'records': Counter(), 'dates': [], 'formats': Counter(), 'markers': set(),
                             'groups': {}, 'death': bool(death)}
        if death:
            prof[id_map[pid]]['markers'].add('Death date recorded in the Patient resource')

    by_year = defaultdict(Counter)
    code_freq = {}
    cat_totals, cat_codes, fmt_records = Counter(), defaultdict(set), Counter()
    for cat in CATEGORIES:
        for row in (bundle.get(cat) or []):
            pid = row.get('patient_id')
            if pid not in id_map:
                continue
            synth = id_map[pid]
            P = prof[synth]
            cat_totals[cat] += 1
            P['records'][cat] += 1
            src = (row.get('source_kind') or row.get('source_format') or row.get('source') or row.get('format') or '').lower()
            fmt = ('FHIR' if 'fhir' in src else 'C-CDA' if 'cda' in src else 'PDF' if 'pdf' in src else
                   'RTF' if 'rtf' in src else 'HTML' if 'html' in src else 'Other or unknown')
            fmt_records[fmt] += 1
            P['formats'][fmt] += 1
            d = _record_date(row)
            if d:
                P['dates'].append(d)
                by_year[d.year][cat] += 1
            if cat == 'diagnostic_reports':
                P['markers'].add('Diagnostic report (pathology, radiology or laboratory)')
            codings = row.get('all_codings') or ([{'code': row.get('code'), 'system_name': row.get('code_system') or '',
                                                   'display': row.get('display_name') or ''}] if row.get('code') else [])
            for c in codings:
                raw = (c.get('system_name') or c.get('system_oid') or c.get('system') or '').strip()
                vocab = normalize_vocab(raw)
                code = (c.get('code') or '').strip()
                if not code:
                    continue
                key = (raw, code)
                info = code_freq.setdefault(key, {'vocab': vocab, 'code': code, 'display': (c.get('display') or '').strip()[:80],
                                                  'n_records': 0, 'patients': set(), 'category': cat})
                info['n_records'] += 1
                info['patients'].add(synth)
                cat_codes[cat].add(key)
                if cat == 'problems':
                    g = _mnd_group(vocab, code)
                    if g:
                        P['groups'].setdefault(d or _dt.date.max, g)
                    if (vocab, code.upper()) in FTD_CODES:
                        P['markers'].add('Frontotemporal dementia code')
                if vocab == 'LOINC' and code in ALS_OBSERVATION_LOINCS:
                    P['markers'].add(ALS_OBSERVATION_LOINCS[code])
                if cat == 'medications':
                    text = c.get('display') or row.get('display_name') or ''
                    for rx, label in ALS_MEDICATION_PATTERNS:
                        if rx.search(text):
                            P['markers'].add(f'ALS medicine: {label}')

    n = len(prof)
    for P in prof.values():
        P['obs_years'] = (max(P['dates']) - min(P['dates'])).days / 365.25 if len(P['dates']) >= 2 else None
        P['group'] = P['groups'][min(P['groups'])] if P['groups'] else 'No motor neuron disease code'

    def dist(values, order=None):
        c = Counter(values)
        keys = order or [x for x, _ in c.most_common()]
        return {key: c.get(key, 0) for key in keys if c.get(key, 0) or order is None}

    obs_order = ['<1 year', '1-2 years', '2-5 years', '5-10 years', '10-20 years', '20+ years', 'Unknown']
    rec_order = ['0', '1-9', '10-49', '50-99', '100-499', '500-999', '1000+']
    marker_counts = Counter(m for P in prof.values() for m in P['markers'])
    top_codes = defaultdict(list)
    for info in code_freq.values():
        top_codes[info['vocab'] or 'Unknown'].append(info)
    top = {}
    for vocab, items in top_codes.items():
        items.sort(key=lambda r: (-r['n_records'], -len(r['patients'])))
        top[vocab] = [{'code': r['code'], 'display': r['display'], 'category': r['category'], 'n_records': r['n_records'],
                       'participants': len(r['patients']) if len(r['patients']) >= k else HELD} for r in items[:25]]
    years = sorted(by_year)
    return {
        'meta': {'generated': _dt.datetime.now(_dt.timezone.utc).strftime('%Y-%m-%d %H:%M UTC'), 'n_patients': n,
                 'k_anonymity': k, 'reference_date': ref.isoformat(),
                 'categories_present': [c for c in CATEGORIES if cat_totals[c]]},
        'cohort': {'Sex': suppress_with_total(dist(P['sex'] for P in prof.values()), k),
                   'Age band at the reference date': suppress_with_total(dist((P['age_band'] for P in prof.values()), AGE_BANDS + ['Unknown']), k),
                   'Race': suppress_with_total(dist(P['race'] for P in prof.values()), k),
                   'Ethnicity': suppress_with_total(dist(P['ethnicity'] for P in prof.values()), k)},
        'volume': {'Records by category': {c: cat_totals[c] for c in CATEGORIES if cat_totals[c]},
                   'Distinct codes by category': {c: len(cat_codes[c]) for c in CATEGORIES if cat_codes[c]},
                   'Participants by number of records': suppress_with_total(dist((record_count_band(sum(P['records'].values())) for P in prof.values()), rec_order), k),
                   'Records by source format': dict(fmt_records.most_common())},
        'coverage': {'Participants by observation period (first to last dated record)':
                     suppress_with_total(dist((obs_period_band(P['obs_years']) for P in prof.values()), obs_order), k)},
        'trends': {'years': years, 'by_category': {c: [by_year[y].get(c, 0) for y in years] for c in CATEGORIES
                                                   if any(by_year[y].get(c, 0) for y in years)}},
        'vocabularies': dict(sum((Counter({i['vocab'] or 'Unknown': i['n_records']}) for i in code_freq.values()), Counter())),
        'als': {'groups': suppress_with_total(dist(P['group'] for P in prof.values()), k),
                     'markers': {m: (c if c >= k else HELD) for m, c in sorted(marker_counts.items())},
                     'deaths': sum(1 for P in prof.values() if P['death'])},
        'top_codes': top,
        'participants': [{'id': sid, 'records': record_count_band(sum(P['records'].values())),
                          'observation': obs_period_band(P['obs_years']),
                          'categories': len(P['records']), 'formats': ', '.join(sorted(P['formats'])),
                          'markers': len(P['markers'])} for sid, P in sorted(prof.items())],
    }


# ============================================================================
# HTML RENDERING (no external resources)
# ============================================================================
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}
nav{display:flex;flex-wrap:wrap;gap:4px;padding:10px 28px 0;border-bottom:1px solid #e1e3e6;background:#fff;margin-top:14px;position:sticky;top:0}
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}h3{font-size:14px;color:#51284F;margin:14px 0 6px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:16px}
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:5px 8px;border-bottom:1px solid #eef0f2;vertical-align:top}td.n,th.n{text-align:right}
.bar{height:10px;background:#93358C;border-radius:2px;min-width:1px}
.held{color:#8a5a00;font-style:italic}
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';}}
"""


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


def _c(v):
    if v == HELD:
        return '<span class="held">held back for cell size</span>'
    return f'{v:,}' if isinstance(v, (int, float)) else _e(v)


def _bars(title, d, unit='Participants'):
    if not d:
        return f'<div><h3>{_e(title)}</h3><p class="note">No data.</p></div>'
    mx = max([v for v in d.values() if isinstance(v, (int, float))] or [1]) or 1
    rows = ''.join(f'<tr><td>{_e(k)}</td><td class="n">{_c(v)}</td><td style="width:45%">'
                   f'{"" if not isinstance(v, (int, float)) else f"<div class=bar style=width:{100 * v / mx:.0f}%></div>"}</td></tr>'
                   for k, v in d.items())
    return f'<div><h3>{_e(title)}</h3><table><tr><th></th><th class="n">{_e(unit)}</th><th></th></tr>{rows}</table></div>'


def render_report(eda, cohort_name='Registry Forge ALS cohort'):
    k = eda['meta']['k_anonymity']
    t = eda['trends']
    trend_rows = ''.join(f'<tr><td>{y}</td>' + ''.join(f'<td class="n">{vals[i]:,}</td>' for vals in t['by_category'].values()) + '</tr>'
                         for i, y in enumerate(t['years']))
    trend_html = (f'<table><tr><th>Year</th>{"".join(f"<th class=n>{_e(c)}</th>" for c in t["by_category"])}</tr>{trend_rows}</table>'
                  if t['years'] else '<p class="note">No dated records.</p>')
    codes = []
    for vocab in sorted(eda['top_codes'], key=lambda v: -sum(r['n_records'] for r in eda['top_codes'][v])):
        rows = ''.join(f'<tr><td><code>{_e(r["code"])}</code></td><td>{_e(r["display"])}</td><td>{_e(r["category"])}</td>'
                       f'<td class="n">{r["n_records"]:,}</td><td class="n">{_c(r["participants"])}</td></tr>' for r in eda['top_codes'][vocab])
        codes.append(f'<details><summary><b>{_e(vocab)}</b>: most frequent codes</summary><table><tr><th>Code</th><th>Display</th>'
                     f'<th>Category</th><th class="n">Records</th><th class="n">Participants</th></tr>{rows}</table></details>')
    prow = ''.join(f'<tr><td>{_e(p["id"])}</td><td>{_e(p["records"])}</td><td>{_e(p["observation"])}</td><td class="n">{p["categories"]}</td>'
                   f'<td>{_e(p["formats"])}</td><td class="n">{p["markers"]}</td></tr>' for p in eda['participants'])
    onc = eda['als']
    panels = [
        ('cohort', 'Cohort', '<div class="grid">' + ''.join(_bars(t, d) for t, d in eda['cohort'].items()) + '</div>',
         f'Ages are at the reference date ({eda["meta"]["reference_date"]}), or at death when that is earlier.'),
        ('volume', 'Data volume', '<div class="grid">' + _bars('Records by category', eda['volume']['Records by category'], 'Records')
         + _bars('Distinct codes by category', eda['volume']['Distinct codes by category'], 'Codes')
         + _bars('Participants by number of records', eda['volume']['Participants by number of records'])
         + _bars('Records by source format', eda['volume']['Records by source format'], 'Records') + '</div>', ''),
        ('coverage', 'Observation period', ''.join(_bars(t, d) for t, d in eda['coverage'].items()),
         'Time from the first to the last dated record for each participant, in bands.'),
        ('trends', 'Activity over time', trend_html, 'Records per calendar year and category.'),
        ('vocab', 'Vocabularies', _bars('Coded records by vocabulary', dict(sorted(eda['vocabularies'].items(), key=lambda kv: -kv[1])), 'Codings'), ''),
        ('als', 'ALS markers', '<div class="grid">' + _bars('Diagnosis from the earliest motor neuron disease code', onc['groups'])
         + _bars('Participants with each marker', onc['markers']) + '</div>',
         'Markers show what the records hold, not whether a value is correct. ALSFRS-R and FVC here are coded '
         'observations only; scores written in notes are counted by note_extraction.py.'),
        ('codes', 'Top codes', ''.join(codes), 'Records are counts of rows; participant counts below k are held back.'),
        ('participants', 'Participants', '<input class="filter" placeholder="Filter" oninput="filterTable(this,\'pt\')">'
         f'<table id="pt"><thead><tr><th>Participant</th><th>Records</th><th>Observation period</th><th class="n">Categories with data</th>'
         f'<th>Source formats</th><th class="n">ALS markers</th></tr></thead><tbody>{prow}</tbody></table>',
         'One row per participant, with pseudonyms assigned in sorted order. Demographics are not shown per participant.'),
    ]
    nav = ''.join(f'<button data-tab="{i}" class="{"active" if j == 0 else ""}" onclick="showTab(\'{i}\')">{_e(l)}</button>'
                  for j, (i, l, _, _) in enumerate(panels))
    body = ''.join(f'<div id="panel-{i}" class="panel{" active" if j == 0 else ""}"><section><h2>{_e(l)}</h2>'
                   f'{f"<p class=note>{_e(n)}</p>" if n else ""}{h}</section></div>' for j, (i, l, h, n) in enumerate(panels))
    return f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>Cohort summary: {_e(cohort_name)}</title><style>{CSS}</style></head><body>
<header><h1>Registry Forge: cohort summary</h1>
<div class="meta">{_e(cohort_name)} &middot; {eda['meta']['n_patients']:,} participants &middot; reference date {eda['meta']['reference_date']} &middot; generated {_e(eda['meta']['generated'])}</div></header>
<div class="banner"><b>Privacy:</b> participant counts below {k} are held back for cell size, along with any count that would let one be worked out. Participant IDs are pseudonymized and no free text is included. Review the report in full before sharing it.</div>
<nav>{nav}</nav><main>{body}</main><script>{JS}</script></body></html>"""


def generate_report(bundle_path='./dashboard_data.json', out_path='./cohort_eda_report.html',
                    k=DEFAULT_K_ANONYMITY, cohort_name='Registry Forge ALS cohort', reference_date=None):
    with open(bundle_path, encoding='utf-8') as f:
        bundle = json.load(f)
    eda = compute_eda(bundle, k=k, reference_date=reference_date)
    with open(out_path, 'w', encoding='utf-8') as f:
        f.write(render_report(eda, cohort_name=cohort_name))
    print(f'Wrote {out_path} ({os.path.getsize(out_path):,} bytes; {eda["meta"]["n_patients"]:,} participants; k={k})')
    return out_path


if __name__ == '__main__':
    ap = argparse.ArgumentParser(description='Registry Forge: cohort summary report')
    ap.add_argument('--bundle', default='./dashboard_data.json')
    ap.add_argument('--out', default='./cohort_eda_report.html')
    ap.add_argument('--k', type=int, default=DEFAULT_K_ANONYMITY)
    ap.add_argument('--cohort-name', default='Registry Forge ALS cohort')
    ap.add_argument('--reference-date', help='date for ages, YYYY-MM-DD (for example the data cut-off)')
    a = ap.parse_args()
    generate_report(a.bundle, a.out, a.k, a.cohort_name, a.reference_date)
