"""
Helper methods that are used in the repair challenge
"""

import json
import pandas as pd
import numpy as np

import s3fs
import os


def load_data_diagnostics():
    fs = s3fs.S3FileSystem(
        client_kwargs={"endpoint_url": "https://" + "minio-simple.lab.groupe-genes.fr"},
        key=os.environ["AWS_ACCESS_KEY_ID"],
        secret=os.environ["AWS_SECRET_ACCESS_KEY"],
        token=os.environ["AWS_SESSION_TOKEN"],
    )

    with fs.open("projet-bdc-data//carmignac/Flows ENSAE V2 -20251105.csv", "rb") as f:
        flows = pd.read_csv(f, sep=";")

    with fs.open("projet-bdc-data//carmignac/AUM ENSAE V2 -20251105.csv", "rb") as f:
        aum = pd.read_csv(f, sep=";")

    aum["Centralisation Date"] = pd.to_datetime(aum["Centralisation Date"])
    flows["Centralisation Date"] = pd.to_datetime(flows["Centralisation Date"])

    return aum, flows


def load_data_repair():
    fs = s3fs.S3FileSystem(
        client_kwargs={"endpoint_url": "https://" + "minio-simple.lab.groupe-genes.fr"},
        key=os.environ["AWS_ACCESS_KEY_ID"],
        secret=os.environ["AWS_SECRET_ACCESS_KEY"],
        token=os.environ["AWS_SESSION_TOKEN"],
    )

    with fs.open("projet-bdc-data//carmignac/Flows ENSAE V2 -20251105.csv", "rb") as f:
        flows = pd.read_csv(f, sep=";")

    with fs.open("projet-bdc-data//carmignac/AUM ENSAE V2 -20251105.csv", "rb") as f:
        aum = pd.read_csv(f, sep=";")

    aum["Centralisation Date"] = pd.to_datetime(aum["Centralisation Date"])
    flows["Centralisation Date"] = pd.to_datetime(flows["Centralisation Date"])

    # Noms courts
    aum = aum.rename(
        columns={
            "Registrar Account - ID": "reg_id",
            "Product - Isin": "isin",
            "Centralisation Date": "date",
            "Quantity - AUM": "qty_aum",
            "Value - AUM €": "val_eur",
            "Registrar Account - Region": "region",
        }
    )
    flows = flows.rename(
        columns={
            "Registrar Account - ID": "reg_id",
            "Product - Isin": "isin",
            "Centralisation Date": "date",
            "Quantity - NetFlows": "qty_net",
            "Value € - NetFlows": "val_net_eur",
        }
    )

    aum["reg_id"] = aum["reg_id"].astype(str)
    flows["reg_id"] = flows["reg_id"].astype(str)

    return aum, flows


def load_inputs_branch(mapping_path, surgery_path):
    fs = s3fs.S3FileSystem(
        client_kwargs={"endpoint_url": "https://" + "minio-simple.lab.groupe-genes.fr"},
        key=os.environ["AWS_ACCESS_KEY_ID"],
        secret=os.environ["AWS_SECRET_ACCESS_KEY"],
        token=os.environ["AWS_SESSION_TOKEN"],
    )

    with fs.open(
        "s3://projet-bdc-data/carmignac/AUM ENSAE V2 -20251105.csv", "rb"
    ) as f:
        aum = pd.read_csv(f, sep=";")

    mapping = pd.read_csv(mapping_path, parse_dates=["date"])
    surgery = (
        pd.read_csv(surgery_path, parse_dates=["date"])
        if surgery_path
        else pd.DataFrame()
    )

    # Normalise ID columns to string
    aum["Registrar Account - ID"] = aum["Registrar Account - ID"].astype(str)
    mapping["reg_orig"] = mapping["reg_orig"].astype(str)
    mapping["reg_used"] = mapping["reg_used"].astype(str)
    if not surgery.empty:
        surgery["reg_orig"] = surgery["reg_orig"].astype(str)
        surgery["reg_from"] = surgery["reg_from"].astype(str)
        surgery["reg_to"] = surgery["reg_to"].astype(str)

    return aum, mapping, surgery


# ─────────────────────────────────────────────────────────────
# BUILD HTML REPORT
# ─────────────────────────────────────────────────────────────


def build_html_diagnostics(df_broken, df_all, df_agg, df_err_isin, df_err_agg, alpha):
    # ── JS-ready data ────────────────────────────────────────────
    # Timeline: n_broken and total_missing per month
    tl = (
        df_all[df_all["broken"]]
        .groupby("date")
        .agg(
            n_broken=("isin", "count"),
            total_missing=("missing_flow", lambda x: x.abs().sum()),
            n_lag=("is_lag", "sum"),
        )
        .reindex(df_all["date"].sort_values().unique())
        .fillna(0)
    )
    tl.index = pd.to_datetime(tl.index)
    dates_str = json.dumps([d.strftime("%Y-%m-%d") for d in tl.index])

    def jf(arr, dec=4):
        return json.dumps(
            [round(float(v), dec) if not np.isnan(v) else None for v in arr]
        )

    ISIN_COLORS = [
        "#2563eb",
        "#16a34a",
        "#dc2626",
        "#d97706",
        "#7c3aed",
        "#0891b2",
        "#db2777",
        "#65a30d",
        "#ea580c",
        "#6366f1",
    ]

    n_broken_js = jf(tl["n_broken"].values, 0)
    total_miss_js = jf(tl["total_missing"].values)
    n_lag_js = jf(tl["n_lag"].values, 0)

    # Aggregate (cross-ISIN) JS data
    agg_dates_str = json.dumps(
        [d.strftime("%Y-%m-%d") for d in pd.to_datetime(df_agg["date"])]
    )
    agg_delta_js = jf(df_agg["delta_aum"].values)
    agg_flow_js = jf(df_agg["flow_total"].values)
    agg_missing_js = jf(df_agg["missing_flow"].values)
    agg_pct_js = jf((df_agg["missing_pct"] * 100).values)

    # Aggregate KPIs
    n_agg_broken = int(df_agg["broken"].sum())
    n_agg_lag = int(df_agg["is_lag"].sum())
    n_agg_genuine = n_agg_broken - n_agg_lag
    max_agg_pct = float(df_agg["missing_pct"].max() * 100) if len(df_agg) else 0

    # Aggregate detail table rows
    agg_rows = []
    for _, r in df_agg[df_agg["broken"]].iterrows():
        lb = '<span class="lag-badge">lag</span>' if r["is_lag"] else ""
        pc = "pct-high" if r["missing_pct"] > 0.1 else "pct-med"
        ds = (
            r["date"].strftime("%Y-%m-%d")
            if hasattr(r["date"], "strftime")
            else str(r["date"])[:10]
        )
        mc = "miss-neg" if r["missing_flow"] < 0 else "miss-pos"
        agg_rows.append(
            f"<tr><td>{ds}</td>"
            f'<td class="mono right">{r["q_total_prev"]:,.1f}</td>'
            f'<td class="mono right">{r["q_total_curr"]:,.1f}</td>'
            f'<td class="mono right">{r["flow_total"]:,.1f}</td>'
            f'<td class="mono right {mc}">{r["missing_flow"]:+,.1f}</td>'
            f'<td class="mono right {pc}">{r["missing_pct"] * 100:.2f}%</td>'
            f"<td>{lb}</td></tr>"
        )
    agg_detail_rows = (
        "".join(agg_rows)
        if agg_rows
        else (
            '<tr><td colspan="7" style="padding:24px;text-align:center;'
            'color:var(--success);font-family:var(--mono)">✓ No broken months at aggregate level</td></tr>'
        )
    )

    # ── Error account JS data ────────────────────────────────────
    err_dates_str = json.dumps(
        [d.strftime("%Y-%m-%d") for d in pd.to_datetime(df_err_agg["date"])]
    )
    err_agg_stock_js = jf(df_err_agg["stock_error_agg"].values)
    err_agg_res_js = jf(df_err_agg["residual_agg"].values)
    err_agg_pct_js = jf(df_err_agg["stock_error_agg_pct"].values)

    # Top 5 ISINs by max absolute stock error
    top_err_isins = (
        df_err_isin.groupby("isin")["stock_error"]
        .apply(lambda x: x.abs().max())
        .nlargest(5)
        .index.tolist()
    )
    all_err_dates = sorted(df_err_isin["date"].unique())
    err_isin_datasets = []
    for idx, isin in enumerate(top_err_isins):
        sub = (
            df_err_isin[df_err_isin["isin"] == isin]
            .set_index("date")["stock_error"]
            .reindex(all_err_dates)
        )
        err_isin_datasets.append(
            {
                "label": isin,
                "data": [
                    round(float(v), 3) if not pd.isna(v) else None for v in sub.values
                ],
                "borderColor": ISIN_COLORS[idx % len(ISIN_COLORS)],
                "backgroundColor": ISIN_COLORS[idx % len(ISIN_COLORS)] + "22",
                "borderWidth": 1.5,
                "pointRadius": 0,
                "tension": 0.3,
                "fill": False,
            }
        )
    err_isin_ts_json = json.dumps(err_isin_datasets)
    err_isin_dates_str = json.dumps(
        [
            d.strftime("%Y-%m-%d") if hasattr(d, "strftime") else str(d)[:10]
            for d in all_err_dates
        ]
    )

    # Error account KPIs
    max_agg_stock_err = float(df_err_agg["stock_error_agg"].abs().max())
    max_agg_stock_pct = float(df_err_agg["stock_error_agg_pct"].max())
    # Stationarity proxy: std / mean_abs (lower = more stationary)
    agg_std = float(df_err_agg["stock_error_agg"].std())
    agg_mean = float(df_err_agg["stock_error_agg"].abs().mean())
    stationarity = round(agg_std / max(agg_mean, 1e-9), 3)

    # Error account ISIN detail table (worst months per ISIN)
    err_worst = (
        df_err_isin.assign(abs_stock=df_err_isin["stock_error"].abs())
        .sort_values("abs_stock", ascending=False)
        .head(200)
    )
    err_isin_rows = []
    for _, r in err_worst.iterrows():
        ds = (
            r["date"].strftime("%Y-%m-%d")
            if hasattr(r["date"], "strftime")
            else str(r["date"])[:10]
        )
        sc = "miss-neg" if r["stock_error"] < 0 else "miss-pos"
        rc = "miss-neg" if r["residual"] < 0 else "miss-pos"
        pch = (
            "pct-high"
            if r["stock_error_pct"] > 5
            else ("pct-med" if r["stock_error_pct"] > 1 else "")
        )
        err_isin_rows.append(
            f"<tr><td>{ds}</td>"
            f'<td class="mono">{r["isin"]}</td>'
            f'<td class="mono right {rc}">{r["residual"]:+,.2f}</td>'
            f'<td class="mono right {sc}">{r["stock_error"]:+,.2f}</td>'
            f'<td class="mono right {pch}">{r["stock_error_pct"]:.3f}%</td></tr>'
        )
    err_isin_detail = (
        "".join(err_isin_rows)
        if err_isin_rows
        else (
            '<tr><td colspan="5" style="padding:24px;text-align:center;'
            'color:var(--success);font-family:var(--mono)">✓ Error account is flat (no residuals)</td></tr>'
        )
    )

    # Per-ISIN summary
    isin_sum = (
        df_broken.groupby("isin")
        .agg(
            n_months=("date", "count"),
            avg_pct=("missing_pct", "mean"),
            total_abs=("missing_flow", lambda x: x.abs().sum()),
        )
        .sort_values("total_abs", ascending=False)
    )

    # Per-ISIN missing_pct timeseries for the top 5 ISINs
    top_isins = isin_sum.head(5).index.tolist()
    all_dates = sorted(df_all["date"].unique())
    isin_ts_datasets = []
    for idx, isin in enumerate(top_isins):
        sub = (
            df_all[df_all["isin"] == isin]
            .set_index("date")["missing_pct"]
            .reindex(all_dates)
            .fillna(0)
        )
        isin_ts_datasets.append(
            {
                "label": isin,
                "data": [round(float(v) * 100, 3) for v in sub.values],
                "borderColor": ISIN_COLORS[idx % len(ISIN_COLORS)],
                "backgroundColor": ISIN_COLORS[idx % len(ISIN_COLORS)] + "22",
                "borderWidth": 2,
                "pointRadius": 0,
                "tension": 0.3,
                "fill": False,
            }
        )
    isin_ts_json = json.dumps(isin_ts_datasets)
    all_dates_str = json.dumps(
        [
            d.strftime("%Y-%m-%d") if hasattr(d, "strftime") else str(d)[:10]
            for d in all_dates
        ]
    )

    # Detail table rows
    detail_rows = ""
    for _, r in df_broken.head(200).iterrows():
        lag_badge = '<span class="lag-badge">lag</span>' if r["is_lag"] else ""
        pct_class = "pct-high" if r["missing_pct"] > 0.1 else "pct-med"
        detail_rows += f"""
        <tr>
          <td>{r["date"].strftime("%Y-%m-%d") if hasattr(r["date"], "strftime") else str(r["date"])[:10]}</td>
          <td class="mono">{r["isin"]}</td>
          <td class="mono right">{r["q_agg_prev"]:,.1f}</td>
          <td class="mono right">{r["q_agg_curr"]:,.1f}</td>
          <td class="mono right">{r["flow_agg"]:,.1f}</td>
          <td class="mono right {"miss-neg" if r["missing_flow"] < 0 else "miss-pos"}">{r["missing_flow"]:+,.1f}</td>
          <td class="mono right {pct_class}">{r["missing_pct"] * 100:.2f}%</td>
          <td>{lag_badge}</td>
        </tr>"""

    # ISIN summary table
    isin_rows = ""
    for isin, row in isin_sum.iterrows():
        isin_rows += f"""
        <tr>
          <td class="mono">{isin}</td>
          <td class="mono right">{int(row["n_months"])}</td>
          <td class="mono right">{row["avg_pct"] * 100:.2f}%</td>
          <td class="mono right">{row["total_abs"]:,.1f}</td>
        </tr>"""

    # KPIs
    total = len(df_all)
    n_broken_kpi = len(df_broken)
    n_lag_kpi = int(df_broken["is_lag"].sum())
    n_genuine = n_broken_kpi - n_lag_kpi
    max_pct = df_broken["missing_pct"].max() * 100 if len(df_broken) else 0
    n_isins = df_broken["isin"].nunique()

    no_broken_msg = ""
    if n_broken_kpi == 0:
        no_broken_msg = '<div class="no-broken">✓ No broken months detected at this threshold.</div>'

    html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Carmignac — Broken Months Diagnostics</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
  @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;600;700&display=swap');

  :root {{
    --bg:      #0d0f12; --surface: #151820; --border: #252a35;
    --accent:  #3b82f6; --warn:    #f59e0b; --danger: #ef4444;
    --success: #10b981; --text:    #e2e8f0; --muted:  #64748b;
    --mono: 'IBM Plex Mono', monospace;
    --sans: 'IBM Plex Sans', sans-serif;
  }}
  *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
  body {{ font-family: var(--sans); background: var(--bg); color: var(--text);
          padding: 0 0 60px; }}

  .header {{ background: linear-gradient(135deg,#0d1117,#111827,#1a0a0a);
             border-bottom: 1px solid var(--border); padding: 40px 48px 36px; }}
  .header-eyebrow {{ font-family: var(--mono); font-size: 11px; letter-spacing:.15em;
                     color: var(--danger); text-transform: uppercase; margin-bottom:10px; }}
  .header h1 {{ font-size: 2rem; font-weight: 700; letter-spacing:-.02em; margin-bottom:8px; }}
  .header-sub {{ font-size:.85rem; color: var(--muted); font-family: var(--mono); }}

  .kpi-strip {{ display: grid; grid-template-columns: repeat(auto-fit,minmax(160px,1fr));
               gap: 1px; background: var(--border); border-bottom: 1px solid var(--border); }}
  .kpi {{ background: var(--surface); padding: 22px 28px;
          display: flex; flex-direction: column; gap: 4px; }}
  .kpi-label {{ font-size:.7rem; letter-spacing:.1em; text-transform:uppercase;
                color: var(--muted); font-family: var(--mono); }}
  .kpi-value {{ font-size:1.6rem; font-weight:700; font-family: var(--mono); line-height:1; }}
  .kpi-value.danger  {{ color: var(--danger); }}
  .kpi-value.warn    {{ color: var(--warn); }}
  .kpi-value.success {{ color: var(--success); }}
  .kpi-sub {{ font-size:.7rem; color: var(--muted); font-family: var(--mono); }}

  .main {{ max-width:1400px; margin:0 auto; padding:36px 48px;
           display:flex; flex-direction:column; gap:32px; }}

  .card {{ background: var(--surface); border: 1px solid var(--border);
           border-radius:8px; overflow:hidden; }}
  .card-header {{ padding:18px 24px 14px; border-bottom:1px solid var(--border);
                  display:flex; align-items:baseline; gap:12px; }}
  .card-title {{ font-size:.8rem; font-weight:600; letter-spacing:.1em;
                 text-transform:uppercase; color: var(--muted); font-family: var(--mono); }}
  .card-desc {{ font-size:.78rem; color: #475569; }}
  .card-body {{ padding:24px; }}
  .chart-wrap {{ position:relative; height:260px; }}
  .chart-wrap-tall {{ position:relative; height:320px; }}

  .grid-2 {{ display:grid; grid-template-columns:1fr 1fr; gap:24px; }}
  @media(max-width:900px) {{ .grid-2 {{ grid-template-columns:1fr; }}
                              .main  {{ padding:24px 20px; }} }}

  .section-label {{ font-family: var(--mono); font-size:.68rem; letter-spacing:.15em;
                    text-transform:uppercase; color: var(--muted);
                    padding-left:10px; border-left:3px solid var(--danger);
                    margin-bottom:-8px; }}

  table {{ width:100%; border-collapse:collapse; font-size:.82rem; }}
  th {{ font-family: var(--mono); font-size:.68rem; letter-spacing:.08em;
        text-transform:uppercase; color: var(--muted); padding:10px 14px;
        text-align:left; border-bottom:1px solid var(--border); background:#0f1218; }}
  td {{ padding:10px 14px; border-bottom:1px solid #1a1f2a; vertical-align:middle; }}
  tr:last-child td {{ border-bottom:none; }}
  tr:hover td {{ background:#181e2b; }}
  .mono {{ font-family: var(--mono); font-size:.78rem; }}
  .right {{ text-align:right; }}
  .miss-pos {{ color: var(--warn); }}
  .miss-neg {{ color: var(--danger); }}
  .pct-high {{ color: var(--danger); font-weight:600; }}
  .pct-med  {{ color: var(--warn); }}
  .lag-badge {{ font-family: var(--mono); font-size:.65rem; padding:2px 6px;
                background:#f59e0b22; border:1px solid #f59e0b66; border-radius:3px;
                color: var(--warn); }}
  .no-broken {{ padding:40px; text-align:center; color: var(--success);
               font-family: var(--mono); font-size:.9rem; }}

  .footer {{ text-align:center; font-family: var(--mono); font-size:.68rem;
             color:#334155; margin-top:16px; letter-spacing:.05em; }}
  .alpha-note {{ font-family: var(--mono); font-size:.75rem; color: var(--muted);
                 padding:10px 24px 0; }}
</style>
</head>
<body>

<div class="header">
  <div class="header-eyebrow">Carmignac × ENSAE · Data Challenge 2025</div>
  <h1>Broken Months Diagnostics</h1>
  <div class="header-sub">
    Aggregate stock-flow equation check · ISIN level · threshold α = {alpha:.1%}<br>
    <span style='font-size:.78rem'>Missing % = |missing flow| / max(|ΔAUM|, |recorded flow|, 1 share) — capped at movement size, not stock level</span>
  </div>
</div>

<div class="kpi-strip">
  <div class="kpi">
    <span class="kpi-label">(ISIN, month) pairs</span>
    <span class="kpi-value">{total:,}</span>
    <span class="kpi-sub">examined</span>
  </div>
  <div class="kpi">
    <span class="kpi-label">Broken months</span>
    <span class="kpi-value {"danger" if n_broken_kpi > 0 else "success"}">{
        n_broken_kpi:,}</span>
    <span class="kpi-sub">{n_broken_kpi / total * 100:.1f}% of pairs</span>
  </div>
  <div class="kpi">
    <span class="kpi-label">Likely lags</span>
    <span class="kpi-value warn">{n_lag_kpi}</span>
    <span class="kpi-sub">resolved by ±{3}d window</span>
  </div>
  <div class="kpi">
    <span class="kpi-label">Genuine gaps</span>
    <span class="kpi-value {"danger" if n_genuine > 0 else "success"}">{
        n_genuine
    }</span>
    <span class="kpi-sub">unresolved by lag fix</span>
  </div>
  <div class="kpi">
    <span class="kpi-label">ISINs affected</span>
    <span class="kpi-value">{n_isins}</span>
    <span class="kpi-sub">distinct ISINs</span>
  </div>
  <div class="kpi">
    <span class="kpi-label">Max missing %</span>
    <span class="kpi-value {"danger" if max_pct > 10 else "warn"}">{max_pct:.1f}%</span>
    <span class="kpi-sub">worst single (isin, month)</span>
  </div>
</div>

<div class="main">

  <div class="section-label">00 · Error account — cumulative residuals</div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">Aggregate error account stock over time</span>
      <span class="card-desc">
        Stock_error(t_ref) = 0 by definition. At each prior month, the stock absorbs the residual
        [ΔQ_total − F_total]. A stationary signal near zero = clean data.
        A drifting signal = structural data quality problem.
      </span>
    </div>
    <div class="card-body" style="padding-bottom:8px">
      <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);margin-bottom:20px">
        <div style="background:var(--surface);padding:16px 20px">
          <div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Max |stock error|</div>
          <div style="font-family:var(--mono);font-size:1.4rem;font-weight:700;color:var(--danger)">{
        max_agg_stock_err:,.1f} shares</div>
        </div>
        <div style="background:var(--surface);padding:16px 20px">
          <div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Max % of total AUM</div>
          <div style="font-family:var(--mono);font-size:1.4rem;font-weight:700;color:{
        "var(--danger)" if max_agg_stock_pct > 5 else "var(--warn)"
    }">{max_agg_stock_pct:.3f}%</div>
        </div>
        <div style="background:var(--surface);padding:16px 20px">
          <div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Stationarity (σ/μ)</div>
          <div style="font-family:var(--mono);font-size:1.4rem;font-weight:700;color:{
        "var(--success)" if stationarity < 1 else "var(--warn)"
    }">{stationarity:.3f}</div>
          <div style="font-size:.7rem;color:var(--muted);font-family:var(--mono)">lower = more stationary</div>
        </div>
      </div>
      <div class="chart-wrap-tall"><canvas id="chartErrAggStock"></canvas></div>
    </div>
  </div>

  <div class="grid-2">
    <div class="card">
      <div class="card-header">
        <span class="card-title">Monthly aggregate residual</span>
        <span class="card-desc">ΔQ_total − F_total per month (should be near zero)</span>
      </div>
      <div class="card-body">
        <div class="chart-wrap"><canvas id="chartErrAggRes"></canvas></div>
      </div>
    </div>
    <div class="card">
      <div class="card-header">
        <span class="card-title">Error stock — top 5 ISINs</span>
        <span class="card-desc">Cumulative error stock per ISIN (most affected)</span>
      </div>
      <div class="card-body">
        <div class="chart-wrap"><canvas id="chartErrIsinTs"></canvas></div>
      </div>
    </div>
  </div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">Error account detail — worst (ISIN, month) pairs</span>
      <span class="card-desc">Sorted by absolute cumulative error stock. stock_error_pct = |stock| / max(ISIN AUM)</span>
    </div>
    <div class="card-body" style="padding:0">
      <table>
        <thead><tr>
          <th>Date</th><th>ISIN</th>
          <th class="right">Monthly residual</th>
          <th class="right">Cumulative stock</th>
          <th class="right">% of max AUM</th>
        </tr></thead>
        <tbody>{err_isin_detail}</tbody>
      </table>
    </div>
  </div>

  <div class="section-label">01 · Aggregate view — all ISINs combined</div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">Stock-flow equation — total portfolio</span>
      <span class="card-desc">
        Σ Q(t) − Σ Q(t−1) vs Σ F(t) across all ISINs and accounts.
        Detects months where the global portfolio is incoherent, independent of ISIN-level breakdown.
      </span>
    </div>
    <div class="card-body">
      <div class="chart-wrap-tall"><canvas id="chartAggOverlay"></canvas></div>
    </div>
  </div>

  <div class="grid-2">
    <div class="card">
      <div class="card-header">
        <span class="card-title">Aggregate missing flow over time</span>
        <span class="card-desc">Σ Q(t) − Σ Q(t−1) − Σ F(t) — should be near zero every month</span>
      </div>
      <div class="card-body">
        <div class="chart-wrap"><canvas id="chartAggMissing"></canvas></div>
      </div>
    </div>
    <div class="card">
      <div class="card-header">
        <span class="card-title">Aggregate missing % of movement</span>
        <span class="card-desc">|missing| / max(|ΔAUM|, |flow|) — months above α flagged in red</span>
      </div>
      <div class="card-body">
        <div class="chart-wrap"><canvas id="chartAggPct"></canvas></div>
      </div>
    </div>
  </div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">Aggregate broken months — detail</span>
    </div>
    <div class="card-body" style="padding:0">
      <table>
        <thead><tr>
          <th>Date</th>
          <th class="right">Σ Q(t−1)</th><th class="right">Σ Q(t)</th>
          <th class="right">Σ Flow</th><th class="right">Missing</th>
          <th class="right">Missing %</th><th></th>
        </tr></thead>
        <tbody>{agg_detail_rows}</tbody>
      </table>
    </div>
  </div>

  <div class="section-label">01 · Timeline — per ISIN</div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">Broken (isin, month) pairs per month</span>
      <span class="card-desc">Stacked: genuine gaps (red) vs likely accounting lags (amber)</span>
    </div>
    <div class="card-body">
      <div class="chart-wrap-tall"><canvas id="chartTimeline"></canvas></div>
    </div>
  </div>

  <div class="grid-2">
    <div class="card">
      <div class="card-header">
        <span class="card-title">Total absolute missing flow per month</span>
        <span class="card-desc">Sum of |missing flow| across all broken ISINs</span>
      </div>
      <div class="card-body">
        <div class="chart-wrap"><canvas id="chartMissing"></canvas></div>
      </div>
    </div>

    <div class="card">
      <div class="card-header">
        <span class="card-title">Missing % — top 5 ISINs over time</span>
        <span class="card-desc">|missing flow| / max(|ΔAUM|, |recorded flow|) per ISIN — capped at movement size</span>
      </div>
      <div class="card-body">
        <div class="chart-wrap"><canvas id="chartIsinTs"></canvas></div>
      </div>
    </div>
  </div>

  <div class="section-label">02 · By ISIN</div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">ISIN summary — most affected</span>
    </div>
    <div class="card-body" style="padding:0">
      {
        '<div class="no-broken">No broken months detected.</div>'
        if n_broken_kpi == 0
        else f'''
      <table>
        <thead><tr>
          <th>ISIN</th><th>Broken months</th>
          <th>Avg missing %</th><th>Total |missing| (shares)</th>
        </tr></thead>
        <tbody>{isin_rows}</tbody>
      </table>'''
    }
    </div>
  </div>

  <div class="section-label">03 · Detail log</div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">All broken (isin, month) pairs</span>
      <span class="card-desc">
        <span class="lag-badge">lag</span> = likely resolved by extending flow window ±3 days
      </span>
    </div>
    <div class="alpha-note">Threshold α = {alpha:.1%} · showing up to 200 rows</div>
    <div class="card-body" style="padding:0">
      {
        '<div class="no-broken">✓ No broken months detected at this threshold.</div>'
        if n_broken_kpi == 0
        else f'''
      <table>
        <thead><tr>
          <th>Date</th><th>ISIN</th>
          <th class="right">Q(t-1)</th><th class="right">Q(t)</th>
          <th class="right">Net flow</th><th class="right">Missing</th>
          <th class="right">Missing % of movement</th><th></th>
        </tr></thead>
        <tbody>{detail_rows}</tbody>
      </table>'''
    }
    </div>
  </div>

</div>
<div class="footer">Generated by carmignac_diagnostics.py · Carmignac × ENSAE Data Challenge 2025</div>

<script>
Chart.defaults.color = '#64748b';
Chart.defaults.borderColor = '#1e2535';
Chart.defaults.font.family = "'IBM Plex Mono', monospace";
Chart.defaults.font.size = 11;

const DATES     = {dates_str};
const N_BROKEN  = {n_broken_js};
const N_LAG     = {n_lag_js};
const TOT_MISS  = {total_miss_js};
const ISIN_TS   = {isin_ts_json};
const ALL_DATES = {all_dates_str};

function tip() {{
  return {{
    backgroundColor:'#0d1117', borderColor:'#252a35', borderWidth:1,
    titleFont:{{family:"'IBM Plex Mono'"}}, bodyFont:{{family:"'IBM Plex Mono'"}}, padding:10
  }};
}}
function xAxis() {{
  return {{ type:'category', ticks:{{maxTicksLimit:10,maxRotation:0}},
            grid:{{color:'#1a2030'}} }};
}}
function yAxis(label) {{
  return {{ grid:{{color:'#1a2030'}},
            title:{{display:!!label,text:label,color:'#475569'}} }};
}}

// n_genuine per month = N_BROKEN - N_LAG
const N_GENUINE = N_BROKEN.map((b,i) => b - (N_LAG[i]||0));

new Chart(document.getElementById('chartTimeline'), {{
  type:'bar',
  data:{{
    labels: DATES,
    datasets:[
      {{ label:'Genuine gaps', data:N_GENUINE,
         backgroundColor:'#ef444488', borderColor:'#ef4444', borderWidth:1, borderRadius:2 }},
      {{ label:'Likely lags',  data:N_LAG,
         backgroundColor:'#f59e0b88', borderColor:'#f59e0b', borderWidth:1, borderRadius:2 }},
    ]
  }},
  options:{{
    responsive:true, maintainAspectRatio:false,
    interaction:{{mode:'index',intersect:false}},
    plugins:{{
      legend:{{position:'top',labels:{{boxWidth:12,padding:16}}}},
      tooltip:tip()
    }},
    scales:{{ x:xAxis(), y:{{...yAxis('# (isin, month) pairs'), stacked:true}} }},
  }}
}});

new Chart(document.getElementById('chartMissing'), {{
  type:'bar',
  data:{{
    labels: DATES,
    datasets:[{{ label:'|Missing flow| (shares)', data:TOT_MISS,
                 backgroundColor:'#dc262688', borderColor:'#dc2626',
                 borderWidth:1, borderRadius:2 }}]
  }},
  options:{{
    responsive:true, maintainAspectRatio:false,
    plugins:{{legend:{{display:false}}, tooltip:tip()}},
    scales:{{ x:xAxis(), y:yAxis('Shares') }}
  }}
}});

new Chart(document.getElementById('chartIsinTs'), {{
  type:'line',
  data:{{ labels:ALL_DATES, datasets:ISIN_TS }},
  options:{{
    responsive:true, maintainAspectRatio:false,
    interaction:{{mode:'index',intersect:false}},
    plugins:{{
      legend:{{position:'right',labels:{{boxWidth:10,padding:8,font:{{size:10}}}}}},
      tooltip:tip()
    }},
    scales:{{ x:xAxis(), y:yAxis('Missing (%)') }}
  }}
}});

// ── Error account charts ─────────────────────────────────────
const ERR_DATES     = {err_dates_str};
const ERR_AGG_STOCK = {err_agg_stock_js};
const ERR_AGG_RES   = {err_agg_res_js};
const ERR_AGG_PCT   = {err_agg_pct_js};
const ERR_ISIN_TS   = {err_isin_ts_json};
const ERR_ISIN_DATES= {err_isin_dates_str};

// Aggregate error stock — line with zero reference
new Chart(document.getElementById('chartErrAggStock'), {{
  type: 'line',
  data: {{
    labels: ERR_DATES,
    datasets: [
      {{ label: 'Aggregate error stock (shares)',
         data: ERR_AGG_STOCK,
         borderColor: '#ef4444', backgroundColor: '#ef444418',
         borderWidth: 2, pointRadius: 0, tension: 0.3, fill: true }},
    ]
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    interaction: {{mode:'index', intersect:false}},
    plugins: {{ legend:{{display:false}}, tooltip: tip() }},
    scales: {{
      x: xAxis(),
      y: {{
        ...yAxis('Shares'),
        grid: {{
          color: ctx => ctx.tick.value === 0 ? '#ffffff55' : '#1a2030',
          lineWidth: ctx => ctx.tick.value === 0 ? 1.5 : 1,
        }}
      }}
    }}
  }}
}});

// Monthly residual bar
new Chart(document.getElementById('chartErrAggRes'), {{
  type: 'bar',
  data: {{
    labels: ERR_DATES,
    datasets: [{{ label: 'Monthly residual (shares)', data: ERR_AGG_RES,
                  backgroundColor: ERR_AGG_RES.map(v => v < 0 ? '#ef444488' : '#f59e0b88'),
                  borderColor:     ERR_AGG_RES.map(v => v < 0 ? '#ef4444'   : '#f59e0b'),
                  borderWidth: 1, borderRadius: 2 }}]
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    plugins: {{legend:{{display:false}}, tooltip: tip()}},
    scales: {{ x: xAxis(), y: yAxis('Shares') }}
  }}
}});

// Per-ISIN error stock timeseries
new Chart(document.getElementById('chartErrIsinTs'), {{
  type: 'line',
  data: {{ labels: ERR_ISIN_DATES, datasets: ERR_ISIN_TS }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    interaction: {{mode:'index', intersect:false}},
    plugins: {{
      legend:{{position:'right',labels:{{boxWidth:10,padding:8,font:{{size:10}}}}}},
      tooltip: tip()
    }},
    scales: {{ x: xAxis(), y: yAxis('Error stock (shares)') }}
  }}
}});

// ── Aggregate charts ─────────────────────────────────────────
const AGG_DATES   = {agg_dates_str};
const AGG_DELTA   = {agg_delta_js};
const AGG_FLOW    = {agg_flow_js};
const AGG_MISSING = {agg_missing_js};
const AGG_PCT     = {agg_pct_js};
const ALPHA       = {alpha};

// Color each bar: red if broken, amber if lag, else subtle blue
const aggPctColors = AGG_PCT.map(v =>
  Math.abs(v) > ALPHA * 100 ? '#ef444488' : '#3b82f622'
);
const aggPctBorders = AGG_PCT.map(v =>
  Math.abs(v) > ALPHA * 100 ? '#ef4444' : '#3b82f655'
);

// Overlay: ΔAUM vs total flow
new Chart(document.getElementById('chartAggOverlay'), {{
  type: 'line',
  data: {{
    labels: AGG_DATES,
    datasets: [
      {{ label: 'ΔAUM (Σ Q(t) − Σ Q(t−1))',
         data: AGG_DELTA, borderColor: '#3b82f6', backgroundColor: '#3b82f622',
         borderWidth: 2, pointRadius: 0, tension: 0.3, fill: false }},
      {{ label: 'Σ Net flows recorded',
         data: AGG_FLOW,  borderColor: '#10b981', backgroundColor: '#10b98122',
         borderWidth: 2, pointRadius: 0, tension: 0.3, fill: false }},
    ]
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    interaction: {{mode:'index', intersect:false}},
    plugins: {{
      legend: {{position:'top', labels:{{boxWidth:12, padding:16}}}},
      tooltip: tip()
    }},
    scales: {{ x: xAxis(), y: yAxis('Shares') }}
  }}
}});

// Missing flow bar
new Chart(document.getElementById('chartAggMissing'), {{
  type: 'bar',
  data: {{
    labels: AGG_DATES,
    datasets: [{{ label: 'Missing flow (shares)', data: AGG_MISSING,
                  backgroundColor: AGG_MISSING.map(v => v < 0 ? '#ef444488' : '#f59e0b88'),
                  borderColor:     AGG_MISSING.map(v => v < 0 ? '#ef4444'   : '#f59e0b'),
                  borderWidth: 1, borderRadius: 2 }}]
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    plugins: {{legend:{{display:false}}, tooltip: tip()}},
    scales: {{ x: xAxis(), y: yAxis('Shares') }}
  }}
}});

// Missing % bar, coloured by threshold
new Chart(document.getElementById('chartAggPct'), {{
  type: 'bar',
  data: {{
    labels: AGG_DATES,
    datasets: [{{ label: 'Missing % of movement', data: AGG_PCT,
                  backgroundColor: aggPctColors, borderColor: aggPctBorders,
                  borderWidth: 1, borderRadius: 2 }}]
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    plugins: {{
      legend: {{display:false}},
      tooltip: tip(),
      annotation: {{}} // threshold line handled via color
    }},
    scales: {{ x: xAxis(), y: {{...yAxis('Missing (%)'), min: 0}} }}
  }}
}});
</script>
</body>
</html>"""
    return html


def build_html_repair(analytics, surgery, scores, mapping, df_err_isin=None, df_err_agg=None):
    tl = analytics["timeline"]
    ss = analytics["surgery_stats"]
    piv = analytics["pivot"]
    ch = analytics["churn"]
    dates_str = analytics["dates"]

    # ── helpers to serialise for JS ─────────────────────────────
    def jf(arr, decimals=6):
        return json.dumps(
            [round(float(v), decimals) if not np.isnan(v) else None for v in arr]
        )

    def js(arr):
        return json.dumps(list(arr))

    # ── colour palette ───────────────────────────────────────────
    REG_COLORS = [
        "#2563eb",
        "#16a34a",
        "#dc2626",
        "#d97706",
        "#7c3aed",
        "#0891b2",
        "#db2777",
        "#65a30d",
        "#ea580c",
        "#6366f1",
        "#059669",
        "#b45309",
        "#9333ea",
        "#0284c7",
        "#e11d48",
    ]

    # ── 4.1  Surgery sparkline data ──────────────────────────────
    surg_dates = [d.strftime("%Y-%m-%d") for d in ss.index]
    n_surg = jf(ss["n_surgeries"].values, 0)
    total_gain = jf(ss["total_gain"].values)
    avg_gain = jf(ss["avg_gain"].values)
    avg_jaccard = jf(ss["avg_jaccard"].values)

    # ── 4.2  Individual trajectories ────────────────────────────
    reg_ids = list(piv.columns)
    traj_datasets = []
    # Surgery lookup: reg_orig -> list of {date, from, to, composite}
    surg_by_reg = {}
    for _, row in surgery.iterrows():
        surg_by_reg.setdefault(row["reg_orig"], []).append(
            {
                "date": row["date"].strftime("%Y-%m-%d"),
                "reg_from": str(row["reg_from"]),
                "reg_to": str(row["reg_to"]),
                "composite": round(float(row["jaccard_composite"]), 4),
                "gain": round(float(row["gain_vs_no_surgery"]), 6),
            }
        )

    for idx, rid in enumerate(reg_ids):
        remapped = rid in analytics["ever_remapped"]
        traj_datasets.append(
            {
                "label": rid,
                "data": [
                    round(float(v), 6) if not np.isnan(v) else None
                    for v in piv[rid].values
                ],
                "borderColor": REG_COLORS[idx % len(REG_COLORS)],
                "backgroundColor": REG_COLORS[idx % len(REG_COLORS)] + "22",
                "borderWidth": 2,
                "borderDash": [6, 3] if remapped else [],
                "pointRadius": 0,
                "tension": 0.3,
                "fill": False,
                "remapped": remapped,
                "surgeries": surg_by_reg.get(rid, []),
            }
        )

    traj_json = json.dumps(traj_datasets)

    # ── 4.2b  Error account data (optional) ────────────────────
    has_error = df_err_isin is not None and df_err_agg is not None

    if has_error:
        err_dates = [d.strftime("%Y-%m-%d") for d in pd.to_datetime(df_err_agg["date"])]
        err_agg_stock = [
            round(float(v), 3) if not pd.isna(v) else None
            for v in df_err_agg["stock_error_agg"].values
        ]
        err_agg_res = [
            round(float(v), 3) if not pd.isna(v) else None
            for v in df_err_agg["residual_agg"].values
        ]
        err_agg_pct = [
            round(float(v), 4) if not pd.isna(v) else None
            for v in df_err_agg["stock_error_agg_pct"].values
        ]

        # Top 5 ISINs by max |stock error|
        top_err = (
            df_err_isin.groupby("isin")["stock_error"]
            .apply(lambda x: x.abs().max())
            .nlargest(5)
            .index.tolist()
        )
        all_err_dates = sorted(df_err_isin["date"].unique())
        ERR_COLORS = ["#ef4444", "#f59e0b", "#8b5cf6", "#06b6d4", "#10b981"]
        err_isin_ds = []
        for idx, isin in enumerate(top_err):
            sub = (
                df_err_isin[df_err_isin["isin"] == isin]
                .set_index("date")["stock_error"]
                .reindex(all_err_dates)
            )
            err_isin_ds.append(
                {
                    "label": isin,
                    "data": [
                        round(float(v), 3) if not pd.isna(v) else None
                        for v in sub.values
                    ],
                    "borderColor": ERR_COLORS[idx % len(ERR_COLORS)],
                    "backgroundColor": ERR_COLORS[idx % len(ERR_COLORS)] + "22",
                    "borderWidth": 1.5,
                    "pointRadius": 0,
                    "tension": 0.3,
                    "fill": False,
                }
            )

        max_err_stock = float(df_err_agg["stock_error_agg"].abs().max())
        max_err_pct = float(df_err_agg["stock_error_agg_pct"].max())
        agg_std = float(df_err_agg["stock_error_agg"].std())
        agg_mean = float(df_err_agg["stock_error_agg"].abs().mean())
        stationarity = round(agg_std / max(agg_mean, 1e-9), 3)

        err_dates_js = json.dumps(err_dates)
        err_agg_stock_js = json.dumps(err_agg_stock)
        err_agg_res_js = json.dumps(err_agg_res)
        err_agg_pct_js = json.dumps(err_agg_pct)
        err_isin_ds_js = json.dumps(err_isin_ds)
        err_isin_dates_js = json.dumps(
            [
                d.strftime("%Y-%m-%d") if hasattr(d, "strftime") else str(d)[:10]
                for d in all_err_dates
            ]
        )

        # ISIN detail table (top 100 worst)
        err_rows = []
        for _, r in (
            df_err_isin.assign(abs_s=df_err_isin["stock_error"].abs())
            .sort_values("abs_s", ascending=False)
            .head(100)
            .iterrows()
        ):
            ds = (
                r["date"].strftime("%Y-%m-%d")
                if hasattr(r["date"], "strftime")
                else str(r["date"])[:10]
            )
            sc = "color:var(--danger)" if r["stock_error"] < 0 else "color:var(--warn)"
            rc = "color:var(--danger)" if r["residual"] < 0 else "color:var(--warn)"
            pch = (
                "color:var(--danger);font-weight:600"
                if r["stock_error_pct"] > 5
                else ("color:var(--warn)" if r["stock_error_pct"] > 1 else "")
            )
            err_rows.append(
                f"<tr><td>{ds}</td>"
                f'<td style="font-family:var(--mono)">{r["isin"]}</td>'
                f'<td style="text-align:right;font-family:var(--mono);{rc}">{r["residual"]:+,.2f}</td>'
                f'<td style="text-align:right;font-family:var(--mono);{sc}">{r["stock_error"]:+,.2f}</td>'
                f'<td style="text-align:right;font-family:var(--mono);{pch}">{r["stock_error_pct"]:.3f}%</td>'
                f"</tr>"
            )
        err_isin_detail = (
            "".join(err_rows)
            if err_rows
            else (
                '<tr><td colspan="5" style="padding:24px;text-align:center;color:var(--accent2)'
                ';font-family:var(--mono)">✓ Error account is flat</td></tr>'
            )
        )

        # HTML block for error account section
        err_section_html = f"""
  <div class="section-label">06 · Error Account</div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">Aggregate error account stock</span>
      <span class="card-desc">
        Stock_error(t_ref) = 0. The stock absorbs unreconciled residuals going backwards.
        A flat signal near zero = clean data. A drift = structural gap.
      </span>
    </div>
    <div class="card-body" style="padding-bottom:8px">
      <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);margin-bottom:20px">
        <div style="background:var(--surface);padding:14px 20px">
          <div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Max |error stock|</div>
          <div style="font-family:var(--mono);font-size:1.35rem;font-weight:700;color:var(--danger)">{max_err_stock:,.1f} shares</div>
        </div>
        <div style="background:var(--surface);padding:14px 20px">
          <div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Max % of total AUM</div>
          <div style="font-family:var(--mono);font-size:1.35rem;font-weight:700;color:{"var(--danger)" if max_err_pct > 5 else "var(--warn)"}">{max_err_pct:.3f}%</div>
        </div>
        <div style="background:var(--surface);padding:14px 20px">
          <div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Stationarity σ/μ</div>
          <div style="font-family:var(--mono);font-size:1.35rem;font-weight:700;color:{"var(--accent2)" if stationarity < 1 else "var(--warn)"}">{stationarity:.3f}</div>
          <div style="font-size:.7rem;color:var(--muted);font-family:var(--mono)">lower = more stationary</div>
        </div>
      </div>
      <div class="chart-wrap-tall"><canvas id="chartErrStock"></canvas></div>
    </div>
  </div>

  <div class="grid-2">
    <div class="card">
      <div class="card-header">
        <span class="card-title">Monthly aggregate residual</span>
        <span class="card-desc">ΔQ_total − F_total per month</span>
      </div>
      <div class="card-body"><div class="chart-wrap"><canvas id="chartErrRes"></canvas></div></div>
    </div>
    <div class="card">
      <div class="card-header">
        <span class="card-title">Error stock — top 5 ISINs</span>
        <span class="card-desc">Cumulative error stock per ISIN</span>
      </div>
      <div class="card-body"><div class="chart-wrap"><canvas id="chartErrIsin"></canvas></div></div>
    </div>
  </div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">Error account detail — worst (ISIN, month) pairs</span>
    </div>
    <div class="card-body" style="padding:0">
      <table>
        <thead><tr>
          <th>Date</th><th>ISIN</th>
          <th style="text-align:right">Monthly residual</th>
          <th style="text-align:right">Cumul. stock</th>
          <th style="text-align:right">% of max AUM</th>
        </tr></thead>
        <tbody>{err_isin_detail}</tbody>
      </table>
    </div>
  </div>"""

        # JS block for error account charts
        err_js_block = f"""
// ── 8. Error account charts ──────────────────────────────────
const ERR_DATES      = {err_dates_js};
const ERR_AGG_STOCK  = {err_agg_stock_js};
const ERR_AGG_RES    = {err_agg_res_js};
const ERR_ISIN_TS    = {err_isin_ds_js};
const ERR_ISIN_DATES = {err_isin_dates_js};

new Chart(document.getElementById('chartErrStock'), {{
  type: 'line',
  data: {{ labels: ERR_DATES, datasets: [{{
    label: 'Aggregate error stock', data: ERR_AGG_STOCK,
    borderColor: '#ef4444', backgroundColor: '#ef444415',
    borderWidth: 2, pointRadius: 0, tension: 0.3, fill: true
  }}] }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    interaction: {{mode:'index', intersect:false}},
    plugins: {{ legend: {{display:false}}, tooltip: tooltip() }},
    scales: {{ x: timeAxis(), y: {{
      ...yAxis('Shares'),
      grid: {{ color: ctx => ctx.tick.value === 0 ? '#ffffff55' : '#1a2030',
               lineWidth: ctx => ctx.tick.value === 0 ? 1.5 : 1 }}
    }} }}
  }}
}});

new Chart(document.getElementById('chartErrRes'), {{
  type: 'bar',
  data: {{ labels: ERR_DATES, datasets: [{{
    label: 'Monthly residual', data: ERR_AGG_RES,
    backgroundColor: ERR_AGG_RES.map(v => v != null && v < 0 ? '#ef444488' : '#f59e0b88'),
    borderColor:     ERR_AGG_RES.map(v => v != null && v < 0 ? '#ef4444'   : '#f59e0b'),
    borderWidth: 1, borderRadius: 2
  }}] }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    plugins: {{ legend: {{display:false}}, tooltip: tooltip() }},
    scales: {{ x: timeAxis(), y: yAxis('Shares') }}
  }}
}});

new Chart(document.getElementById('chartErrIsin'), {{
  type: 'line',
  data: {{ labels: ERR_ISIN_DATES, datasets: ERR_ISIN_TS }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    interaction: {{mode:'index', intersect:false}},
    plugins: {{
      legend: {{position:'right', labels:{{boxWidth:10, padding:8, font:{{size:10}}}}}},
      tooltip: tooltip()
    }},
    scales: {{ x: timeAxis(), y: yAxis('Error stock (shares)') }}
  }}
}});"""

    else:
        err_section_html = ""
        err_js_block = ""

    # ── 4.3  Surgery detail table rows ──────────────────────────
    sd = analytics["surgery_detail"].sort_values("date")
    surg_rows_html = ""
    if len(sd) == 0:
        surg_rows_html = "<tr><td colspan='9' style='text-align:center;color:#888'>No surgeries performed</td></tr>"
    else:
        for _, r in sd.iterrows():
            gain_class = "gain-high" if r["gain_vs_no_surgery"] > 0.05 else "gain-low"
            lb = int(r.get("lookback_months", 1))
            lb_cell = (
                f'<span style="font-family:var(--mono);font-size:.65rem;padding:1px 5px;'
                f"border-radius:3px;background:#7c3aed22;border:1px solid #7c3aed55;"
                f'color:#a78bfa">{lb}m</span>'
                if lb > 1
                else "—"
            )
            surg_rows_html += f"""
            <tr>
                <td>{r["date"].date()}</td>
                <td><span class="reg-badge">{r["reg_orig"]}</span></td>
                <td class="code-cell">{r["reg_from"]}</td>
                <td>→</td>
                <td class="code-cell">{r["reg_to"]}</td>
                <td>{r["jaccard_composite"]:.4f}</td>
                <td class="{gain_class}">+{r["gain_vs_no_surgery"]:.6f}</td>
                <td>{r["gain_pct_of_score"]:.1f}%</td>
                <td>{lb_cell}</td>
            </tr>"""

    # ── 4.4  Top accounts table ──────────────────────────────────
    last_date = piv.index.max()
    top_accounts = piv.loc[last_date].dropna().sort_values(ascending=False)
    top_rows_html = ""
    for rank, (rid, sc) in enumerate(top_accounts.items(), 1):
        remapped = "✓" if rid in analytics["ever_remapped"] else ""
        bar_w = int(sc / top_accounts.max() * 100)
        color = REG_COLORS[(rank - 1) % len(REG_COLORS)]
        top_rows_html += f"""
        <tr>
            <td class="rank">#{rank}</td>
            <td><span class="reg-badge" style="background:{color}22;border-color:{color}">{rid}</span></td>
            <td class="score-val">{sc:.6f}</td>
            <td class="bar-cell">
                <div class="score-bar" style="width:{bar_w}%;background:{color}"></div>
            </td>
            <td class="center">{remapped}</td>
        </tr>"""

    # ─────────────────────────────────────────────────────────────
    # HTML TEMPLATE
    # ─────────────────────────────────────────────────────────────
    html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Carmignac Pipeline — Analysis Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
  @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;600;700&display=swap');

  :root {{
    --bg:       #0d0f12;
    --surface:  #151820;
    --border:   #252a35;
    --accent:   #3b82f6;
    --accent2:  #10b981;
    --warn:     #f59e0b;
    --danger:   #ef4444;
    --text:     #e2e8f0;
    --muted:    #64748b;
    --mono:     'IBM Plex Mono', monospace;
    --sans:     'IBM Plex Sans', sans-serif;
  }}

  *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}

  body {{
    font-family: var(--sans);
    background: var(--bg);
    color: var(--text);
    min-height: 100vh;
    padding: 0 0 60px;
  }}

  /* ── Header ── */
  .header {{
    background: linear-gradient(135deg, #0d1117 0%, #111827 50%, #0d1f3c 100%);
    border-bottom: 1px solid var(--border);
    padding: 40px 48px 36px;
    position: relative;
    overflow: hidden;
  }}
  .header::before {{
    content: '';
    position: absolute;
    inset: 0;
    background: radial-gradient(ellipse 70% 80% at 80% 50%, #1e40af18, transparent);
    pointer-events: none;
  }}
  .header-eyebrow {{
    font-family: var(--mono);
    font-size: 11px;
    letter-spacing: 0.15em;
    color: var(--accent);
    text-transform: uppercase;
    margin-bottom: 10px;
  }}
  .header h1 {{
    font-size: 2rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    line-height: 1.1;
    margin-bottom: 8px;
  }}
  .header-sub {{
    font-size: 0.85rem;
    color: var(--muted);
    font-family: var(--mono);
  }}

  /* ── KPI strip ── */
  .kpi-strip {{
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
    gap: 1px;
    background: var(--border);
    border-bottom: 1px solid var(--border);
  }}
  .kpi {{
    background: var(--surface);
    padding: 22px 28px;
    display: flex;
    flex-direction: column;
    gap: 4px;
  }}
  .kpi-label {{
    font-size: 0.7rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted);
    font-family: var(--mono);
  }}
  .kpi-value {{
    font-size: 1.6rem;
    font-weight: 700;
    font-family: var(--mono);
    color: var(--text);
    line-height: 1;
  }}
  .kpi-value.accent  {{ color: var(--accent); }}
  .kpi-value.success {{ color: var(--accent2); }}
  .kpi-value.warn    {{ color: var(--warn); }}
  .kpi-sub {{
    font-size: 0.7rem;
    color: var(--muted);
    font-family: var(--mono);
  }}

  /* ── Main layout ── */
  .main {{
    max-width: 1400px;
    margin: 0 auto;
    padding: 36px 48px;
    display: flex;
    flex-direction: column;
    gap: 32px;
  }}

  /* ── Cards ── */
  .card {{
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 8px;
    overflow: hidden;
  }}
  .card-header {{
    padding: 18px 24px 14px;
    border-bottom: 1px solid var(--border);
    display: flex;
    align-items: baseline;
    gap: 12px;
  }}
  .card-title {{
    font-size: 0.8rem;
    font-weight: 600;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted);
    font-family: var(--mono);
  }}
  .card-desc {{
    font-size: 0.78rem;
    color: #475569;
  }}
  .card-body {{
    padding: 24px;
  }}
  .chart-wrap {{
    position: relative;
    height: 280px;
  }}
  .chart-wrap-tall {{
    position: relative;
    height: 340px;
  }}

  /* ── Two-column grid ── */
  .grid-2 {{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 24px;
  }}
  @media (max-width: 900px) {{
    .grid-2 {{ grid-template-columns: 1fr; }}
    .main {{ padding: 24px 20px; }}
  }}

  /* ── Section label ── */
  .section-label {{
    font-family: var(--mono);
    font-size: 0.68rem;
    letter-spacing: 0.15em;
    text-transform: uppercase;
    color: var(--muted);
    padding: 0 4px;
    border-left: 3px solid var(--accent);
    padding-left: 10px;
    margin-bottom: -8px;
  }}

  /* ── Tables ── */
  table {{
    width: 100%;
    border-collapse: collapse;
    font-size: 0.82rem;
  }}
  th {{
    font-family: var(--mono);
    font-size: 0.68rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--muted);
    padding: 10px 14px;
    text-align: left;
    border-bottom: 1px solid var(--border);
    background: #0f1218;
  }}
  td {{
    padding: 10px 14px;
    border-bottom: 1px solid #1a1f2a;
    vertical-align: middle;
  }}
  tr:last-child td {{ border-bottom: none; }}
  tr:hover td {{ background: #181e2b; }}

  .rank {{ color: var(--muted); font-family: var(--mono); font-size: 0.75rem; }}
  .score-val {{ font-family: var(--mono); color: var(--accent2); }}
  .code-cell {{ font-family: var(--mono); font-size: 0.78rem; color: #94a3b8; }}
  .center {{ text-align: center; color: var(--accent2); }}
  .gain-high {{ font-family: var(--mono); color: var(--accent2); font-weight: 600; }}
  .gain-low  {{ font-family: var(--mono); color: var(--warn); }}

  .bar-cell {{ width: 120px; }}
  .score-bar {{
    height: 6px;
    border-radius: 3px;
    min-width: 2px;
    transition: width 0.3s;
  }}

  .reg-badge {{
    display: inline-block;
    padding: 2px 8px;
    border-radius: 4px;
    background: #1e2a3a;
    border: 1px solid #2d3f54;
    font-family: var(--mono);
    font-size: 0.75rem;
    color: var(--accent);
    white-space: nowrap;
  }}

  /* ── Legend patch ── */
  .legend-patch {{
    display: inline-block;
    width: 12px; height: 12px;
    border-radius: 2px;
    margin-right: 4px;
    vertical-align: middle;
  }}

  /* ── No-surgery notice ── */
  .no-surg {{
    padding: 32px;
    text-align: center;
    color: var(--muted);
    font-family: var(--mono);
    font-size: 0.82rem;
  }}

  /* ── Trajectory explorer ── */
  .badge-surgery {{
    font-family: var(--mono);
    font-size: 0.68rem;
    color: var(--warn);
    background: #f59e0b18;
    border: 1px solid #f59e0b44;
    border-radius: 3px;
    padding: 1px 6px;
    margin-left: 4px;
  }}
  .traj-selector {{
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    padding: 16px 20px;
    border-bottom: 1px solid var(--border);
    background: #0f1218;
  }}
  .traj-btn {{
    font-family: var(--mono);
    font-size: 0.72rem;
    padding: 5px 12px;
    border-radius: 4px;
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--muted);
    cursor: pointer;
    transition: all 0.15s;
  }}
  .traj-btn:hover {{ border-color: var(--acc, var(--accent)); color: var(--text); }}
  .traj-btn.active {{
    background: color-mix(in srgb, var(--acc, var(--accent)) 15%, transparent);
    border-color: var(--acc, var(--accent));
    color: var(--acc, var(--accent));
  }}
  .traj-btn-badge {{
    font-size: 0.6rem;
    background: #f59e0b33;
    color: var(--warn);
    border-radius: 3px;
    padding: 0 4px;
    margin-left: 4px;
  }}
  .traj-focus-wrap {{ padding: 16px 24px 8px; }}
  .traj-account-meta {{
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    margin-bottom: 12px;
    min-height: 28px;
  }}
  .meta-id {{
    font-family: var(--mono);
    font-size: 0.85rem;
    font-weight: 600;
    padding: 3px 10px;
    border-radius: 4px;
    border: 1.5px solid;
  }}
  .meta-surgs {{ display: flex; flex-wrap: wrap; gap: 6px; }}
  .surg-chip {{
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--warn);
    background: #f59e0b18;
    border: 1px solid #f59e0b44;
    border-radius: 4px;
    padding: 2px 8px;
  }}
  .spark-section-label {{
    font-family: var(--mono);
    font-size: 0.65rem;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--muted);
    padding: 14px 24px 6px;
    border-top: 1px solid var(--border);
    margin-top: 8px;
  }}
  .spark-grid {{
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
    gap: 1px;
    background: var(--border);
    border-top: 1px solid var(--border);
  }}
  .spark-cell {{
    background: var(--surface);
    padding: 10px 12px 8px;
    cursor: pointer;
    transition: background 0.12s;
  }}
  .spark-cell:hover {{ background: #1a2030; }}
  .spark-cell.active {{ background: #131b2a; outline: 1px solid #3b82f644; }}
  .spark-label {{
    font-family: var(--mono);
    font-size: 0.68rem;
    color: var(--muted);
    margin-bottom: 4px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }}
  .spark-badge {{
    font-size: 0.58rem;
    color: var(--warn);
    background: #f59e0b22;
    border-radius: 2px;
    padding: 0 3px;
  }}

  /* ── Footer ── */
  .footer {{
    text-align: center;
    font-family: var(--mono);
    font-size: 0.68rem;
    color: #334155;
    margin-top: 16px;
    letter-spacing: 0.05em;
  }}
</style>
</head>
<body>

<!-- ═══════════════════════════════════════════ HEADER -->
<div class="header">
  <div class="header-eyebrow">Carmignac × ENSAE · Data Challenge 2025</div>
  <h1>Pipeline Results — Analysis Report</h1>
  <div class="header-sub">Registrar ID repair · Score propagation · Surgery audit</div>
</div>

<!-- ═══════════════════════════════════════════ KPI STRIP -->
<div class="kpi-strip">
  <div class="kpi">
    <span class="kpi-label">Σ score at t_ref</span>
    <span class="kpi-value success">{tl["sum_post"].iloc[-1]:.4f}</span>
    <span class="kpi-sub">post-surgery</span>
  </div>
  <div class="kpi">
    <span class="kpi-label">Σ score at t_min</span>
    <span class="kpi-value accent">{tl["sum_post"].iloc[0]:.4f}</span>
    <span class="kpi-sub">post-surgery</span>
  </div>
  <div class="kpi">
    <span class="kpi-label">Max recovery</span>
    <span class="kpi-value warn">{tl["recovery_pct"].max():.1f}%</span>
    <span class="kpi-sub">score rescued by surgery</span>
  </div>
  <div class="kpi">
    <span class="kpi-label">Total surgeries</span>
    <span class="kpi-value">{len(surgery)}</span>
    <span class="kpi-sub">operations performed</span>
  </div>
  <div class="kpi">
    <span class="kpi-label">Reg IDs universe</span>
    <span class="kpi-value">{piv.shape[1]}</span>
    <span class="kpi-sub">at reference date</span>
  </div>
  <div class="kpi">
    <span class="kpi-label">Ever remapped</span>
    <span class="kpi-value warn">{len(analytics["ever_remapped"])}</span>
    <span class="kpi-sub">reg IDs w/ code change</span>
  </div>
</div>

<!-- ═══════════════════════════════════════════ MAIN -->
<div class="main">

  <div class="section-label">01 · Score Integrity Over Time</div>

  <!-- Chart 1: Σ score with vs without surgery -->
  <div class="card">
    <div class="card-header">
      <span class="card-title">Sum of scores — pre vs post surgery</span>
      <span class="card-desc">
        Post-surgery (solid) shows the corrected score after code repairs.
        Pre-surgery (dashed) is the counterfactual without any remapping.
        Gap = score rescued.
      </span>
    </div>
    <div class="card-body">
      <div class="chart-wrap-tall">
        <canvas id="chartSigma"></canvas>
      </div>
    </div>
  </div>

  <!-- Chart 2: Score drop (pre) -->
  <div class="grid-2">
    <div class="card">
      <div class="card-header">
        <span class="card-title">Score recovered by surgery</span>
        <span class="card-desc">Difference post − pre at each date</span>
      </div>
      <div class="card-body">
        <div class="chart-wrap">
          <canvas id="chartRecovery"></canvas>
        </div>
      </div>
    </div>

    <div class="card">
      <div class="card-header">
        <span class="card-title">Portfolio concentration (entropy)</span>
        <span class="card-desc">Shannon entropy of score distribution — higher = more spread</span>
      </div>
      <div class="card-body">
        <div class="chart-wrap">
          <canvas id="chartEntropy"></canvas>
        </div>
      </div>
    </div>
  </div>

  <div class="section-label">02 · Individual Score Trajectories</div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">Score explorer — per Registrar Account</span>
      <span class="card-desc">
        Click an account to inspect its full history.
        <span class="badge-surgery">◆ remapped</span> = surgery was applied.
      </span>
    </div>
    <div class="card-body" style="padding:0">

      <!-- Selector pill bar -->
      <div class="traj-selector" id="trajSelector"></div>

      <!-- Focused chart + metadata -->
      <div class="traj-focus-wrap">
        <div class="traj-account-meta" id="trajMeta"></div>
        <div style="position:relative;height:300px">
          <canvas id="chartTrajFocus"></canvas>
        </div>
      </div>

      <!-- Sparkline overview grid -->
      <div class="spark-section-label">All accounts — overview</div>
      <div class="spark-grid" id="sparkGrid"></div>

    </div>
  </div>

  <div class="section-label">03 · Surgery Operations</div>

  <div class="grid-2">
    <div class="card">
      <div class="card-header">
        <span class="card-title">Surgeries per time step</span>
        <span class="card-desc">Number of code remappings performed at each month</span>
      </div>
      <div class="card-body">
        <div class="chart-wrap">
          <canvas id="chartNSurg"></canvas>
        </div>
      </div>
    </div>

    <div class="card">
      <div class="card-header">
        <span class="card-title">Score gain per surgery</span>
        <span class="card-desc">Average gain in Σ score from surgery at each month</span>
      </div>
      <div class="card-body">
        <div class="chart-wrap">
          <canvas id="chartGain"></canvas>
        </div>
      </div>
    </div>
  </div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">Jaccard similarity of surgery matches</span>
      <span class="card-desc">
        Composite Jaccard score of the matched code pair — closer to 1.0 = stronger portfolio overlap.
        Low values may indicate uncertain matches.
      </span>
    </div>
    <div class="card-body">
      <div class="chart-wrap">
        <canvas id="chartJaccard"></canvas>
      </div>
    </div>
  </div>

  <div class="section-label">04 · Surgery Detail Log</div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">All surgery operations</span>
    </div>
    <div class="card-body" style="padding:0">
      {
        '<div class="no-surg">No surgeries were performed on this dataset.</div>'
        if len(surgery) == 0
        else f'''
      <table>
        <thead>
          <tr>
            <th>Date</th>
            <th>Reg orig</th>
            <th>Code from</th>
            <th></th>
            <th>Code to</th>
            <th>Jaccard</th>
            <th>Score gain</th>
            <th>% of score</th>
            <th>Lookback</th>
          </tr>
        </thead>
        <tbody>{surg_rows_html}</tbody>
      </table>'''
    }
    </div>
  </div>

  <div class="section-label">05 · Score Ranking at t_ref</div>

  <div class="card">
    <div class="card-header">
      <span class="card-title">Accounts ranked by weight at reference date</span>
      <span class="card-desc">✓ in last column = account was remapped at some point in history</span>
    </div>
    <div class="card-body" style="padding:0">
      <table>
        <thead>
          <tr>
            <th>Rank</th>
            <th>Registrar ID</th>
            <th>Score (weight)</th>
            <th style="width:140px">Relative size</th>
            <th>Remapped</th>
          </tr>
        </thead>
        <tbody>{top_rows_html}</tbody>
      </table>
    </div>
  </div>

  {err_section_html}


</div><!-- /main -->

<div class="footer">Generated by carmignac_analysis.py · Carmignac × ENSAE Data Challenge 2025</div>

<!-- ═══════════════════════════════════════════ CHARTS JS -->
<script>
Chart.defaults.color = '#64748b';
Chart.defaults.borderColor = '#1e2535';
Chart.defaults.font.family = "'IBM Plex Mono', monospace";
Chart.defaults.font.size = 11;

const DATES   = {js(dates_str)};
const SUM_POST = {jf(tl["sum_post"].values)};
const SUM_PRE  = {jf(tl["sum_pre"].values)};
const RECOVERY = {jf(tl["recovery_pct"].values, 4)};
const ENTROPY  = {jf(tl["entropy"].values, 4)};
const SURG_DATES   = {js(surg_dates)};
const N_SURG       = {n_surg};
const TOTAL_GAIN   = {total_gain};
const AVG_GAIN     = {avg_gain};
const AVG_JACCARD  = {avg_jaccard};
const TRAJ         = {traj_json};

// ── Shared options helpers ────────────────────────────────────
function timeAxis(label) {{
  return {{
    type: 'category',
    ticks: {{ maxTicksLimit: 10, maxRotation: 0 }},
    grid: {{ color: '#1a2030' }},
    title: {{ display: !!label, text: label, color: '#475569' }},
  }};
}}
function yAxis(label, opts={{}}) {{
  return {{
    grid: {{ color: '#1a2030' }},
    title: {{ display: !!label, text: label, color: '#475569' }},
    ...opts,
  }};
}}
function tooltip() {{
  return {{
    backgroundColor: '#0d1117',
    borderColor: '#252a35',
    borderWidth: 1,
    titleFont: {{ family: "'IBM Plex Mono'" }},
    bodyFont:  {{ family: "'IBM Plex Mono'" }},
    padding: 10,
  }};
}}

// ── 1. Sigma pre/post ─────────────────────────────────────────
new Chart(document.getElementById('chartSigma'), {{
  type: 'line',
  data: {{
    labels: DATES,
    datasets: [
      {{
        label: 'Σ score (post-surgery)',
        data: SUM_POST,
        borderColor: '#10b981',
        backgroundColor: '#10b98115',
        borderWidth: 2.5,
        pointRadius: 0,
        fill: false,
        tension: 0.2,
      }},
      {{
        label: 'Σ score (pre-surgery / counterfactual)',
        data: SUM_PRE,
        borderColor: '#ef4444',
        borderDash: [6, 4],
        borderWidth: 1.5,
        pointRadius: 0,
        fill: false,
        tension: 0.2,
        backgroundColor: 'transparent',
      }},
    ],
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    interaction: {{ mode: 'index', intersect: false }},
    plugins: {{
      legend: {{ position: 'top', labels: {{ boxWidth: 12, padding: 16 }} }},
      tooltip: tooltip(),
    }},
    scales: {{
      x: timeAxis(),
      y: yAxis('Σ scores', {{ min: 0, max: 1.05, ticks: {{ stepSize: 0.1 }} }}),
    }},
  }},
}});

// ── 2. Recovery ───────────────────────────────────────────────
new Chart(document.getElementById('chartRecovery'), {{
  type: 'bar',
  data: {{
    labels: DATES,
    datasets: [{{
      label: 'Score recovered (%)',
      data: RECOVERY,
      backgroundColor: '#3b82f6aa',
      borderColor: '#3b82f6',
      borderWidth: 1,
      borderRadius: 2,
    }}],
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    plugins: {{ legend: {{ display: false }}, tooltip: tooltip() }},
    scales: {{
      x: timeAxis(),
      y: yAxis('Recovery (% of Σ)', {{ min: 0 }}),
    }},
  }},
}});

// ── 3. Entropy ────────────────────────────────────────────────
new Chart(document.getElementById('chartEntropy'), {{
  type: 'line',
  data: {{
    labels: DATES,
    datasets: [{{
      label: 'Shannon entropy',
      data: ENTROPY,
      borderColor: '#d97706',
      backgroundColor: '#d9770622',
      borderWidth: 2,
      pointRadius: 0,
      fill: true,
      tension: 0.3,
    }}],
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    plugins: {{ legend: {{ display: false }}, tooltip: tooltip() }},
    scales: {{ x: timeAxis(), y: yAxis('Entropy (nats)') }},
  }},
}});

// ── 4. Trajectory explorer ───────────────────────────────────
(function() {{
  let focusChart = null;
  let sparkCharts = [];
  let activeIdx = 0;

  // Surgery annotation plugin (vertical line + label)
  const surgeryPlugin = {{
    id: 'surgeryLines',
    afterDraw(chart) {{
      const surgeries = chart._surgeries || [];
      if (!surgeries.length) return;
      const {{ctx, chartArea, scales}} = chart;
      surgeries.forEach(op => {{
        const xIdx = DATES.indexOf(op.date);
        if (xIdx < 0) return;
        const x = scales.x.getPixelForValue(xIdx);
        ctx.save();
        ctx.strokeStyle = '#f59e0bcc';
        ctx.lineWidth = 1.5;
        ctx.setLineDash([4, 3]);
        ctx.beginPath();
        ctx.moveTo(x, chartArea.top);
        ctx.lineTo(x, chartArea.bottom);
        ctx.stroke();
        // Label
        ctx.font = "10px 'IBM Plex Mono'";
        ctx.fillStyle = '#f59e0b';
        ctx.textAlign = 'center';
        ctx.fillText('✦', x, chartArea.top + 10);
        ctx.restore();
      }});
    }}
  }};

  function buildFocusChart(idx) {{
    const d = TRAJ[idx];
    if (focusChart) focusChart.destroy();

    const ctx = document.getElementById('chartTrajFocus').getContext('2d');
    focusChart = new Chart(ctx, {{
      type: 'line',
      data: {{
        labels: DATES,
        datasets: [{{
          label: d.label,
          data: d.data,
          borderColor: d.borderColor,
          backgroundColor: d.borderColor + '18',
          borderWidth: 2.5,
          borderDash: d.borderDash,
          pointRadius: d.data.map((_, i) => {{
            return d.surgeries.some(s => DATES[i] === s.date) ? 6 : 0;
          }}),
          pointBackgroundColor: '#f59e0b',
          pointBorderColor: '#f59e0b',
          tension: 0.3,
          fill: true,
        }}],
      }},
      options: {{
        responsive: true, maintainAspectRatio: false,
        interaction: {{ mode: 'index', intersect: false }},
        plugins: {{
          legend: {{ display: false }},
          tooltip: {{
            ...tooltip(),
            callbacks: {{
              afterBody(items) {{
                const i = items[0].dataIndex;
                const s = d.surgeries.find(x => DATES[i] === x.date);
                if (!s) return [];
                return [
                  '',
                  '✦ Surgery applied',
                  'From : ' + s.reg_from,
                  'To   : ' + s.reg_to,
                  'Jaccard: ' + s.composite,
                  'Gain: +' + s.gain,
                ];
              }}
            }}
          }},
        }},
        scales: {{
          x: timeAxis(),
          y: yAxis('Score (weight)', {{ min: 0 }}),
        }},
      }},
      plugins: [surgeryPlugin],
    }});
    focusChart._surgeries = d.surgeries;

    // Update meta panel
    const meta = document.getElementById('trajMeta');
    const surgHTML = d.surgeries.length
      ? d.surgeries.map(s =>
          `<span class="surg-chip">✦ ${{s.date}} &nbsp;${{s.reg_from}} → ${{s.reg_to}}</span>`
        ).join('')
      : '<span style="color:#475569;font-size:0.75rem">No surgery applied</span>';
    meta.innerHTML = `
      <span class="meta-id" style="border-color:${{d.borderColor}};color:${{d.borderColor}}">${{d.label}}</span>
      <div class="meta-surgs">${{surgHTML}}</div>
    `;
  }}

  function buildSparkGrid() {{
    const grid = document.getElementById('sparkGrid');
    grid.innerHTML = '';
    TRAJ.forEach((d, idx) => {{
      const wrap = document.createElement('div');
      wrap.className = 'spark-cell' + (idx === activeIdx ? ' active' : '');
      wrap.dataset.idx = idx;

      const label = document.createElement('div');
      label.className = 'spark-label';
      label.innerHTML = d.label + (d.remapped
        ? ' <span class="spark-badge">R</span>' : '');

      const canvasWrap = document.createElement('div');
      canvasWrap.style.cssText = 'position:relative;height:52px';
      const cv = document.createElement('canvas');
      canvasWrap.appendChild(cv);

      wrap.appendChild(label);
      wrap.appendChild(canvasWrap);
      grid.appendChild(wrap);

      const sc = new Chart(cv, {{
        type: 'line',
        data: {{
          labels: DATES,
          datasets: [{{
            data: d.data,
            borderColor: d.borderColor,
            borderWidth: 1.5,
            pointRadius: 0,
            tension: 0.3,
            fill: false,
          }}],
        }},
        options: {{
          responsive: true, maintainAspectRatio: false,
          animation: false,
          plugins: {{ legend: {{ display: false }}, tooltip: {{ enabled: false }} }},
          scales: {{
            x: {{ display: false }},
            y: {{ display: false, min: 0 }},
          }},
        }},
      }});
      sparkCharts.push(sc);

      wrap.addEventListener('click', () => {{
        document.querySelectorAll('.spark-cell').forEach(c => c.classList.remove('active'));
        document.querySelectorAll('.traj-btn').forEach(b => b.classList.remove('active'));
        wrap.classList.add('active');
        document.querySelector(`.traj-btn[data-idx="${{idx}}"]`).classList.add('active');
        activeIdx = idx;
        buildFocusChart(idx);
      }});
    }});
  }}

  function buildSelector() {{
    const sel = document.getElementById('trajSelector');
    sel.innerHTML = '';
    TRAJ.forEach((d, idx) => {{
      const btn = document.createElement('button');
      btn.className = 'traj-btn' + (idx === 0 ? ' active' : '');
      btn.dataset.idx = idx;
      btn.style.setProperty('--acc', d.borderColor);
      btn.innerHTML = d.label + (d.remapped ? ' <span class="traj-btn-badge">R</span>' : '');
      btn.addEventListener('click', () => {{
        document.querySelectorAll('.traj-btn').forEach(b => b.classList.remove('active'));
        document.querySelectorAll('.spark-cell').forEach(c => c.classList.remove('active'));
        btn.classList.add('active');
        const cell = document.querySelector(`.spark-cell[data-idx="${{idx}}"]`);
        if (cell) cell.classList.add('active');
        activeIdx = idx;
        buildFocusChart(idx);
      }});
      sel.appendChild(btn);
    }});
  }}

  buildSelector();
  buildFocusChart(0);
  buildSparkGrid();
}})();

// ── 5. N surgeries ────────────────────────────────────────────
new Chart(document.getElementById('chartNSurg'), {{
  type: 'bar',
  data: {{
    labels: SURG_DATES,
    datasets: [{{
      label: 'Surgeries',
      data: N_SURG,
      backgroundColor: '#7c3aed99',
      borderColor: '#7c3aed',
      borderWidth: 1,
      borderRadius: 3,
    }}],
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    plugins: {{ legend: {{ display: false }}, tooltip: tooltip() }},
    scales: {{
      x: timeAxis('Month'),
      y: yAxis('# operations', {{ min: 0, ticks: {{ stepSize: 1 }} }}),
    }},
  }},
}});

// ── 6. Avg gain ───────────────────────────────────────────────
new Chart(document.getElementById('chartGain'), {{
  type: 'bar',
  data: {{
    labels: SURG_DATES,
    datasets: [
      {{
        label: 'Total gain',
        data: TOTAL_GAIN,
        backgroundColor: '#10b98199',
        borderColor: '#10b981',
        borderWidth: 1,
        borderRadius: 3,
      }},
      {{
        label: 'Avg gain / surgery',
        data: AVG_GAIN,
        backgroundColor: '#06b6d455',
        borderColor: '#06b6d4',
        borderWidth: 1,
        borderRadius: 3,
      }},
    ],
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    interaction: {{ mode: 'index', intersect: false }},
    plugins: {{ legend: {{ position: 'top', labels: {{ boxWidth: 10 }} }}, tooltip: tooltip() }},
    scales: {{ x: timeAxis('Month'), y: yAxis('Score gain') }},
  }},
}});

// ── 7. Jaccard ────────────────────────────────────────────────
new Chart(document.getElementById('chartJaccard'), {{
  type: 'bar',
  data: {{
    labels: SURG_DATES,
    datasets: [{{
      label: 'Avg Jaccard composite',
      data: AVG_JACCARD,
      backgroundColor: '#f59e0b88',
      borderColor: '#f59e0b',
      borderWidth: 1,
      borderRadius: 3,
    }}],
  }},
  options: {{
    responsive: true, maintainAspectRatio: false,
    plugins: {{ legend: {{ display: false }}, tooltip: tooltip() }},
    scales: {{
      x: timeAxis('Month'),
      y: yAxis('Jaccard composite', {{ min: 0, max: 1.05 }}),
    }},
  }},
}});
{err_js_block}
</script>
</body>
</html>"""

    return html