"""Generate notebooks/diagnostic.ipynb from source cell text.

The diagnostic has ~50 Mercury widgets (engagement form, six baseline
inputs with confidence flags, 12 score sliders + 12 evidence fields, an
export button). Hand-maintaining that JSON is error-prone, so the
notebook is *generated* — cell sources live here as readable Python
strings and nbformat writes valid JSON.

Re-run after editing any cell:  python scripts/build_notebook.py
Then execute + export as usual (nbconvert / scripts/export_report.py).

This is a build tool, not the engine — all study logic stays in diaglib.
"""

from __future__ import annotations

import pathlib

import nbformat as nbf

ROOT = pathlib.Path(__file__).resolve().parent.parent
OUT = ROOT / "notebooks" / "diagnostic.ipynb"


# ── Cell sources ─────────────────────────────────────────────────────

MD_TITLE = """\
# CX AI Advisory Diagnostic

The facilitator's cockpit for the CX AI Advisory diagnostic workshop:
capture capability scores across **12 competencies** in four dimensions,
ingest the client's operational baseline, and watch the **value-at-stake
analysis** — bounded by the capability gaps — recompute live in the room.

**This notebook is the deliverable.** Serve it with
`mercury --working-dir .` from the study root and share the screen. Work
the sidebar top-to-bottom: engagement, baseline, then one competency at a
time. The stage shows the current competency's card, the heatmap, the
value analysis, and the unlock sequence. Click **Export JSON + CSV** at
the bottom to write the structured engagement record to `exports/`.

All content and math live in `diaglib/` and `configs/*.yaml` — the
notebook only arranges and renders them. Outputs are **ranges, never
point estimates**, and money displays at two significant figures.

Confidence legend: 🟢 known · 🟡 estimated · 🔴 unknown — flag each
baseline input; unknowns surface as explicit warnings in the analysis."""


SETUP = '''\
# ── Setup ──────────────────────────────────────────────────────────
import sys, pathlib
_ROOT = pathlib.Path.cwd()
if not (_ROOT / "diaglib").exists():             # notebook lives in notebooks/
    _ROOT = _ROOT.parent
sys.path.insert(0, str(_ROOT))

import datetime as dt
import html as _html

import mercury as mr
import pandas as pd
from IPython.display import display

# Single source of truth — all math and content live in the library;
# only presentation (and Mercury widgets) lives here.
from diaglib import (
    BASELINE_FIELDS, CONFIDENCE_ICON, CSV_COLUMNS,
    OperationalBaseline, backstage, build_engagement, build_scores,
    configs_dir, dimension_rollup, engagement_json, evidence_coverage,
    heatmap_fig, heatmap_grid, list_industries, load_config, money,
    parse_participants, scores_dataframe, split_fig, unlock_fig,
    value_at_stake, value_bands_fig, write_exports,
)

pd.options.display.float_format = "{:,.0f}".format

CONFIGS_DIR = configs_dir(_ROOT)
EXPORTS_DIR = _ROOT / "exports"
INDUSTRIES = list_industries(CONFIGS_DIR)
# The competency model is industry-independent (overlays may not redefine
# it — the loader enforces that), so widgets can build from any config.
_BASE = load_config("contact_center", CONFIGS_DIR)
COMPETENCIES = _BASE.competencies
DIMENSION_NAME = {d.id: d.name for d in _BASE.dimensions}

# ── Brand palette (docs/brand.md, light theme) ─────────────────────
NAVY, INK, MUTED = "#151d2c", "#2e404d", "#586671"
BLUE, GREEN, LINE = "#0072bc", "#00a34c", "#e2e6e9"
CARD_BG, HAIRLINE, HILITE = "#f8f8f8", "#d5d9db", "#dcecfa"
FONT = "Georgia, 'Times New Roman', serif"
BODY_FONT = "Arial, 'Helvetica Neue', Helvetica, sans-serif"


def esc(s):
    return _html.escape(str(s))


# ── Workshop seeds — the gate's mock scenario; overwrite live ──────
# Headless nbconvert renders every widget at its seed, so the seeds form
# a coherent, gate-passing scenario (Pattern §3).
SEED_CLIENT = "Acme Demo Co"
SEED_DATE = "2026-07-19"
SEED_FACILITATOR = "Robert Helewka"
SEED_PARTICIPANTS = ("Jane Example | VP Customer Experience | cx; "
                     "Sam Sample | Contact Center Ops Director | ops")
SEED_BASELINE = {
    "annual_contact_volume": 1_200_000,
    "blended_cost_per_contact": 6.50,
    "agent_headcount": 450,
    "annual_attrition_rate": 0.30,
    "current_containment_rate": 0.20,
    "average_handle_time_seconds": 420,
}
CONFIDENCE_CHOICES = ["🟢 known", "🟡 estimated", "🔴 unknown"]
SEED_CONFIDENCE = {
    "annual_contact_volume": "🟢 known",
    "blended_cost_per_contact": "🟡 estimated",
    "agent_headcount": "🟢 known",
    "annual_attrition_rate": "🟡 estimated",
    "current_containment_rate": "🟡 estimated",
    "average_handle_time_seconds": "🟢 known",
}
# Seed capability profile: data_readiness is the unique weakest
# foundation, so the demo shows a single binding constraint.
SEED_SCORES = {
    "automation_ai_strategy": (2, "AI driven by board pressure; no written thesis"),
    "value_realization": (2, "Business cases pre-investment only"),
    "executive_alignment": (3, "COO owns CX AI; steering meets quarterly"),
    "process_discovery": (3, "Top 10 call reasons mapped with volumes"),
    "data_readiness": (2, "KB stale; interaction data siloed in recordings"),
    "technical_architecture": (3, "CCaaS APIs available; shared integration layer WIP"),
    "use_case_prioritization": (3, "Scored backlog reviewed monthly"),
    "delivery_capability": (3, "Two bots in production via SI partner"),
    "talent_and_skills": (2, "One conversation designer, contractor"),
    "ai_operations": (2, "Containment eyeballed weekly, no drift alerts"),
    "change_adoption": (3, "Agent champions for copilot rollout"),
    "governance_and_risk": (3, "AI policy signed; review board for voice bots"),
}
# label, min, max, step per baseline field (explicit min/max — Pattern §3).
BASELINE_META = {
    "annual_contact_volume": ("Annual contact volume", 0, 100_000_000, 10_000),
    "blended_cost_per_contact": ("Blended cost per contact ($)", 0, 100, 0.25),
    "agent_headcount": ("Agent headcount", 0, 100_000, 10),
    "annual_attrition_rate": ("Annual attrition rate (0-1)", 0, 1, 0.01),
    "current_containment_rate": ("Current containment rate (0-1)", 0, 1, 0.01),
    "average_handle_time_seconds": ("Average handle time (seconds)", 0, 3600, 10),
}

backstage(f"diaglib loaded — {len(COMPETENCIES)} competencies · "
          f"industries: {', '.join(INDUSTRIES)}")'''


MD_HOWTO = """\
## How to run this session

- **Sidebar §1 — Engagement.** Client, industry config, date, participants
  (`Name | Role | Function` separated by `;` — functions: cx, it, ops,
  finance, other), and a running notes box.
- **Sidebar §2 — Operational baseline.** Six numbers. If unknown, best
  estimate is fine — set the confidence flag and the analysis will carry
  the uncertainty explicitly.
- **Sidebar §3 — Capability scoring.** Pick **Now scoring**, read the
  competency card on stage with the room, set the 1–5 slider, capture one
  line of evidence, move to the next. The heatmap and value analysis
  update live as you go.
- **Export.** The button at the bottom of the page writes
  `exports/{engagement_id}.json` (source of truth) and `.csv` (flat
  scores) — a deliberate snapshot at click time.
- **Backstage** (JupyterLab / nbconvert) — the verification gate and the
  machine-readable appendix; neither shows on the Mercury stage."""


W_ENGAGEMENT = '''\
# ── 1 · Engagement setup (sidebar — widgets ONLY, no other output) ──
# Mercury re-runs only cells BELOW a changed widget: every .value is
# read in the state cell further down, never here (Pattern §3).
mr.Markdown("#### 1 · Engagement", position="sidebar")
_client_w = mr.TextInput(label="Client name", value=SEED_CLIENT)
_industry_w = mr.Select(label="Industry config", value="contact_center",
                        choices=INDUSTRIES)
_date_w = mr.DateInput(label="Workshop date", value=SEED_DATE)
_facilitator_w = mr.TextInput(label="Facilitator", value=SEED_FACILITATOR)
_participants_w = mr.TextInput(
    label="Participants — Name | Role | Function; ...",
    value=SEED_PARTICIPANTS)
_notes_w = mr.TextInput(label="Session notes", value="")'''


W_BASELINE = '''\
# ── 2 · Operational baseline (sidebar — widgets ONLY) ───────────────
# Six numbers + a confidence flag each. Explicit min/max on every
# NumberInput — Mercury clamps out-of-range seeds to a default range.
mr.Markdown("#### 2 · Operational baseline", position="sidebar")
_baseline_w, _conf_w = {}, {}
for _f in BASELINE_FIELDS:
    _label, _min, _max, _step = BASELINE_META[_f]
    _baseline_w[_f] = mr.NumberInput(label=_label, value=SEED_BASELINE[_f],
                                     min=_min, max=_max, step=_step)
    _conf_w[_f] = mr.Select(label=f"{_label} — confidence",
                            value=SEED_CONFIDENCE[_f],
                            choices=CONFIDENCE_CHOICES)'''


W_SCORING = '''\
# ── 3 · Capability scoring (sidebar — widgets ONLY) ─────────────────
# One screen per competency on stage: pick "Now scoring", read the card
# with the room, set the slider, capture one line of evidence. Labels
# are distinct per widget so Mercury's cache never collides.
mr.Markdown("#### 3 · Capability scoring", position="sidebar")
_now_scoring_w = mr.Select(
    label="Now scoring",
    value=f"1 · {COMPETENCIES[0].name}",
    choices=[f"{_i + 1} · {_c.name}" for _i, _c in enumerate(COMPETENCIES)])
_score_w, _evidence_w = {}, {}
_prev_dim = None
for _c in COMPETENCIES:
    if _c.dimension != _prev_dim:
        mr.Markdown(f"**{DIMENSION_NAME[_c.dimension]}**", position="sidebar")
        _prev_dim = _c.dimension
    _score_w[_c.id] = mr.Slider(label=f"Score — {_c.name}",
                                min=1, max=5, value=SEED_SCORES[_c.id][0])
    _evidence_w[_c.id] = mr.TextInput(label=f"Evidence — {_c.name}",
                                      value=SEED_SCORES[_c.id][1])'''


STATE = '''\
# ── Session state (re-runs on any sidebar change) ───────────────────
# Read every widget .value; all computation happens in diaglib.
CLIENT_NAME = str(_client_w.value).strip() or SEED_CLIENT
INDUSTRY = str(_industry_w.value)
CONFIG = load_config(INDUSTRY, CONFIGS_DIR)
WORKSHOP_DATE = dt.date.fromisoformat(str(_date_w.value) or SEED_DATE)
PARTICIPANTS = parse_participants(str(_participants_w.value))
NOTES = str(_notes_w.value)

_vals = {f: float(_baseline_w[f].value) for f in BASELINE_FIELDS}
BASELINE = OperationalBaseline(
    annual_contact_volume=int(_vals["annual_contact_volume"]),
    blended_cost_per_contact=_vals["blended_cost_per_contact"],
    agent_headcount=int(_vals["agent_headcount"]),
    annual_attrition_rate=_vals["annual_attrition_rate"],
    current_containment_rate=_vals["current_containment_rate"],
    average_handle_time_seconds=int(_vals["average_handle_time_seconds"]),
    field_confidence={f: str(_conf_w[f].value).split()[-1]
                      for f in BASELINE_FIELDS},
)

RAW_SCORES = {c.id: (int(_score_w[c.id].value), str(_evidence_w[c.id].value))
              for c in COMPETENCIES}
SCORES = build_scores(CONFIG, RAW_SCORES, scored_at=dt.datetime.now())
VAS = value_at_stake(CONFIG, BASELINE, SCORES)
ENGAGEMENT = build_engagement(
    config=CONFIG, client_name=CLIENT_NAME,
    facilitator=str(_facilitator_w.value), workshop_date=WORKSHOP_DATE,
    participants=PARTICIPANTS, baseline=BASELINE, scores=SCORES,
    computed_value=VAS, notes=NOTES)

NOW_SCORING = COMPETENCIES[int(str(_now_scoring_w.value).split(" · ")[0]) - 1]
_done, _total = evidence_coverage(SCORES)

# One curated line on stage; warnings surface for the room; echo backstage.
if CONFIG.value_drivers:
    _bind = ", ".join(CONFIG.competency(c).name for c in VAS.binding_constraints)
    print(f"Value at stake: {money(VAS.theoretical_annual_value_low)}-"
          f"{money(VAS.theoretical_annual_value_high)} theoretical per year · "
          f"{money(VAS.realizable_18mo_low)}-{money(VAS.realizable_18mo_high)} "
          f"realizable over 18 months — capped at level "
          f"{VAS.weakest_foundational_score} by {_bind}")
for _warning in VAS.warnings:
    print(f"⚠ {_warning}")
backstage(f"engagement {ENGAGEMENT.engagement_id} · "
          f"{len(PARTICIPANTS)} participants · evidence {_done}/{_total}")'''


STAGE_HEADER = '''\
# ── Stage: engagement banner + baseline echo ────────────────────────
_chips = "".join(
    f'<span style="display:inline-block;border:1px solid {HAIRLINE};'
    f'border-radius:14px;padding:2px 10px;margin:2px 6px 2px 0;'
    f'font:12px {BODY_FONT};color:{MUTED}">{esc(p.name)}'
    + (f" · {esc(p.role)}" if p.role else "")
    + f' <b style="color:{BLUE}">{esc(p.function)}</b></span>'
    for p in PARTICIPANTS) or (
    f'<span style="font:13px {BODY_FONT};color:{MUTED}">'
    f'no participants captured yet</span>')

_rows = ""
for _f in BASELINE_FIELDS:
    _v = getattr(BASELINE, _f)
    _shown = f"{_v:,.2f}" if isinstance(_v, float) and _v < 10 else f"{_v:,.0f}"
    _rows += (
        f'<tr><td style="padding:3px 14px 3px 0;font:13px {BODY_FONT};'
        f'color:{MUTED}">{BASELINE_META[_f][0]}</td>'
        f'<td style="padding:3px 10px;font:600 13px {BODY_FONT};color:{INK};'
        f'text-align:right">{_shown}</td>'
        f'<td style="padding:3px 0">{CONFIDENCE_ICON[BASELINE.confidence_for(_f)]}'
        f'</td></tr>')

_ = mr.Markdown(text=(
    f'<div style="max-width:860px">'
    f'<div style="font:700 24px {FONT};color:{NAVY};margin:4px 0 2px">'
    f'{esc(CLIENT_NAME)} — CX AI Diagnostic</div>'
    f'<div style="font:14px {BODY_FONT};color:{MUTED};margin-bottom:8px">'
    f'{esc(CONFIG.display_name)} · {WORKSHOP_DATE.isoformat()} · '
    f'facilitated by {esc(ENGAGEMENT.facilitator)} · '
    f'evidence captured {_done}/{_total}</div>'
    f'<div style="margin:6px 0 10px">{_chips}</div>'
    f'<div style="border:1px solid {HAIRLINE};border-radius:10px;'
    f'background:{CARD_BG};padding:10px 16px;display:inline-block">'
    f'<div style="font:700 13px {FONT};color:{NAVY};margin-bottom:4px">'
    f'Operational baseline</div>'
    f'<table style="border-collapse:collapse">{_rows}</table></div></div>'))'''


STAGE_CARD = '''\
# ── Stage: the scoring screen (one competency at a time) ────────────
_c = NOW_SCORING
_score, _evidence = RAW_SCORES[_c.id]

# Progress strip: one box per competency — its current score, solid
# border once evidence is captured, highlighted while on screen.
_boxes = ""
for _i, _cc in enumerate(COMPETENCIES):
    _s, _e = RAW_SCORES[_cc.id]
    _bg = HILITE if _cc.id == _c.id else CARD_BG
    _border = f"1px solid {HAIRLINE}" if not _e else f"1px solid {MUTED}"
    if _cc.id == _c.id:
        _border = f"2px solid {BLUE}"
    _boxes += (
        f'<span title="{esc(_cc.name)}" style="display:inline-block;'
        f'width:30px;height:30px;line-height:28px;text-align:center;'
        f'border:{_border};border-radius:6px;background:{_bg};'
        f'font:600 14px {BODY_FONT};color:{INK};margin-right:5px">{_s}</span>')

_levels = ""
for _lvl in range(1, 6):
    _sel = _lvl == _score
    _levels += (
        f'<tr><td style="padding:5px 12px;font:700 14px {BODY_FONT};'
        f'color:{BLUE if _sel else MUTED};border-left:4px solid '
        f'{BLUE if _sel else "transparent"};background:'
        f'{HILITE if _sel else "transparent"}">{_lvl}</td>'
        f'<td style="padding:5px 8px;font:{"600 " if _sel else ""}14px '
        f'{BODY_FONT};color:{INK if _sel else MUTED};background:'
        f'{HILITE if _sel else "transparent"}">'
        f'{esc(_c.level_descriptors[_lvl])}</td></tr>')

_evidence_html = (
    f'<span style="color:{GREEN}">✓</span> {esc(_evidence)}' if _evidence
    else f'<span style="color:{MUTED}">no evidence captured yet — '
         f'one line: what makes this a level {_score}?</span>')

_ = mr.Markdown(text=(
    f'<div style="max-width:860px">'
    f'<div style="margin:14px 0 8px">{_boxes}</div>'
    f'<div style="border:1px solid {HAIRLINE};border-radius:10px;'
    f'background:#ffffff;padding:16px 20px">'
    f'<div style="font:600 12px {BODY_FONT};color:{BLUE};'
    f'text-transform:uppercase;letter-spacing:.06em">'
    f'{DIMENSION_NAME[_c.dimension]}</div>'
    f'<div style="font:700 21px {FONT};color:{NAVY};margin:2px 0 6px">'
    f'{esc(_c.name)}</div>'
    f'<div style="font:15px {BODY_FONT};color:{INK};margin-bottom:6px">'
    f'{esc(_c.description)}</div>'
    f'<div style="font:italic 14px {FONT};color:{MUTED};margin-bottom:10px">'
    f'"{esc(_c.failure_vignette)}"</div>'
    f'<table style="border-collapse:collapse;width:100%">{_levels}</table>'
    f'<div style="font:13px {BODY_FONT};color:{INK};margin-top:10px;'
    f'border-top:1px solid {LINE};padding-top:8px">'
    f'<b>Evidence:</b> {_evidence_html}</div>'
    f'</div></div>'))'''


STAGE_ANALYSIS = '''\
# ── Stage: live analysis & visuals ──────────────────────────────────
heatmap_fig(heatmap_grid(CONFIG, SCORES)).show()

if CONFIG.value_drivers:
    value_bands_fig(VAS).show()
    split_fig(VAS).show()
    unlock_fig(VAS, CONFIG).show()
    # The unlock moves as a table — the room verifies numbers by
    # reading them, not by trusting the bars.
    UNLOCK_DF = pd.DataFrame([{
        "Move": _i + 1,
        "Competencies": " + ".join(CONFIG.competency(c).name
                                   for c in _m.competency_ids),
        "Lift": f"{_m.current_level} → {_m.target_level}",
        "Est. cost": (f"{money(_m.est_cost_low)} – {money(_m.est_cost_high)}"
                      if _m.est_cost_low is not None else "not configured"),
        "Weeks": _m.est_weeks if _m.est_weeks is not None else "—",
        "Annual value unlocked": (f"{money(_m.value_unlocked_low)} – "
                                  f"{money(_m.value_unlocked_high)}"),
        "Note": _m.note,
    } for _i, _m in enumerate(VAS.unlock_sequence)])
    display(UNLOCK_DF.style.hide(axis="index"))
else:
    _ = mr.Markdown(text=(
        f'<div style="border:1px solid {HAIRLINE};border-radius:10px;'
        f'background:{CARD_BG};padding:12px 16px;max-width:860px;'
        f'font:14px {BODY_FONT};color:{MUTED}">'
        f'<b style="color:{INK}">{esc(CONFIG.display_name)}</b> has no value '
        f'drivers configured yet — capability scoring works normally, but '
        f'value-at-stake needs drivers in '
        f'<code>configs/{esc(INDUSTRY)}.yaml</code>.</div>'))'''


MD_EXPORT = """\
## Export

The button writes `exports/{engagement_id}.json` — the full engagement
record, the source-of-truth artifact — and `exports/{engagement_id}.csv`,
one row per competency for cross-engagement analysis. A **deliberate
snapshot**: it captures the state at click time; click again after
changes to refresh."""


W_EXPORT = '''\
# ── Export trigger (widgets ONLY — the click is handled below) ──────
_export_w = mr.Button(label="Export JSON + CSV", position="inline")'''


EXPORT_STATE = '''\
# ── Export on click — snapshot semantics ────────────────────────────
# Kernel globals persist across Mercury re-runs; the guard writes
# exactly once per click, at whatever state the cockpit showed then.
try:
    _LAST_EXPORT_CLICKS
except NameError:
    _LAST_EXPORT_CLICKS = 0

if int(_export_w.n_clicks) > _LAST_EXPORT_CLICKS:
    _json_path, _csv_path = write_exports(ENGAGEMENT, CONFIG, EXPORTS_DIR)
    _LAST_EXPORT_CLICKS = int(_export_w.n_clicks)
    print(f"Exported {_json_path.name} + {_csv_path.name} → exports/")
else:
    backstage("No export click this run — engagement exports come from the "
              "stage button; report sources from scripts/export_report.py.")'''


MD_GATE = """\
## Verification & assertions

Engine pins use the explicit seed scenario, independent of the sidebar,
so the gate tests `diaglib` + `configs/`, not the current session;
live-state pins are guarded so a facilitator moving a slider never
crashes the room; structural ties hold at **any** widget state. This
cell must pass under headless `nbconvert --execute` — it is the study's
smoke test. Output renders backstage only."""


GATE = '''\
# ── Verification gate — must pass under headless nbconvert ──────────
def _approx(got, want, tol=0.5):
    assert abs(got - want) <= tol, f"got {got:,.2f}, want {want:,.2f}"


# Engine pins — EXPLICIT seed scenario, independent of widget state.
# Hand arithmetic in tests/test_value_math.py (same scenario).
_gate_cfg = load_config("contact_center", CONFIGS_DIR)
_gate_baseline = OperationalBaseline(
    annual_contact_volume=1_200_000, blended_cost_per_contact=6.50,
    agent_headcount=450, annual_attrition_rate=0.30,
    current_containment_rate=0.20, average_handle_time_seconds=420)
_gate_scores = build_scores(_gate_cfg, SEED_SCORES,
                            scored_at=dt.datetime(2026, 7, 19, 9, 0))
_gate_vas = value_at_stake(_gate_cfg, _gate_baseline, _gate_scores)
_approx(_gate_vas.theoretical_annual_value_low, 2_542_500)
_approx(_gate_vas.theoretical_annual_value_high, 5_085_000)
_approx(_gate_vas.realizable_18mo_low, 953_437.50)
_approx(_gate_vas.realizable_18mo_high, 3_051_000)
_approx(_gate_vas.trapped_value_low, 1_525_500)
_approx(_gate_vas.trapped_value_high, 3_813_750)
assert _gate_vas.binding_constraints == ["data_readiness"]
assert len(_gate_vas.unlock_sequence) == 3
_m1 = _gate_vas.unlock_sequence[0]
assert _m1.competency_ids == ["data_readiness"]
assert (_m1.est_cost_low, _m1.est_cost_high, _m1.est_weeks) == (300_000, 600_000, 12)
_approx(_m1.value_unlocked_low, 635_625)
_approx(_m1.value_unlocked_high, 1_271_250)

# Live-state pins — guarded, so a moved slider can't crash the room.
_at_default = (
    INDUSTRY == "contact_center"
    and all(getattr(BASELINE, f) == getattr(_gate_baseline, f)
            for f in BASELINE_FIELDS)
    and all(RAW_SCORES[k][0] == SEED_SCORES[k][0] for k in SEED_SCORES))
if _at_default:
    _approx(VAS.theoretical_annual_value_low, 2_542_500)
    _approx(VAS.realizable_18mo_high, 3_051_000)
    _approx(VAS.trapped_value_high, 3_813_750)

# Structural ties — hold at ANY widget state.
assert len(SCORES) == 12 and len({s.competency_id for s in SCORES}) == 12
assert VAS.theoretical_annual_value_low <= VAS.theoretical_annual_value_high
assert VAS.realizable_18mo_low <= VAS.realizable_18mo_high
assert VAS.trapped_value_low <= VAS.trapped_value_high
_approx(sum(d.theoretical_low for d in VAS.driver_values),
        VAS.theoretical_annual_value_low)
_approx(sum(d.theoretical_high for d in VAS.driver_values),
        VAS.theoretical_annual_value_high)
_approx(VAS.realizable_18mo_low,
        VAS.theoretical_annual_value_low * VAS.realization_factor_low * 1.5)
_approx(VAS.realizable_18mo_high,
        VAS.theoretical_annual_value_high * VAS.realization_factor_high * 1.5)
assert set(VAS.binding_constraints) <= set(CONFIG.foundational_competencies)
assert len(VAS.unlock_sequence) <= 3

# Export payload is serializable and matches the live session.
import json as _json
_payload = _json.loads(engagement_json(ENGAGEMENT))
assert _payload["engagement_id"] == ENGAGEMENT.engagement_id
assert len(_payload["scores"]) == 12
_df = scores_dataframe(ENGAGEMENT, CONFIG)
assert list(_df.columns) == CSV_COLUMNS and len(_df) == 12

backstage("All assertions passed.")'''


MD_APPENDIX = """\
## Data appendix — for the machines

The full engagement as markdown tables plus one JSON block of state, so
the exported report is complete LLM input — and the future Athena
study-export payload. Renders **backstage** — hidden on the Mercury
stage."""


APPENDIX = '''\
# ── Data appendix — LLM-readable dump of the engagement ─────────────
# Renders backstage only (JupyterLab / nbconvert exports).
backstage("#### Capability scores\\n")
_rows = ["| Competency | Dimension | Score | Evidence |", "|---|---|---:|---|"]
for _s in SCORES:
    _cc = CONFIG.competency(_s.competency_id)
    _ev = _s.evidence.replace("|", "\\\\|") or "—"
    _rows.append(f"| {_cc.name} | {DIMENSION_NAME[_s.dimension]} | "
                 f"{_s.score} | {_ev} |")
backstage("\\n".join(_rows))

backstage("\\n#### Dimension rollup\\n")
_rows = ["| Dimension | Mean score |", "|---|---:|"]
for _did, _dname, _mean in dimension_rollup(CONFIG, SCORES):
    _rows.append(f"| {_dname} | {_mean:.2f} |")
backstage("\\n".join(_rows))

if CONFIG.value_drivers:
    backstage("\\n#### Value drivers (theoretical annual)\\n")
    _rows = ["| Driver | Low | High |", "|---|---:|---:|"]
    for _d in VAS.driver_values:
        _rows.append(f"| {_d.name} | {money(_d.theoretical_low)} | "
                     f"{money(_d.theoretical_high)} |")
    _rows.append(f"| **Total** | **{money(VAS.theoretical_annual_value_low)}** "
                 f"| **{money(VAS.theoretical_annual_value_high)}** |")
    backstage("\\n".join(_rows))
    backstage(f"\\nRealization band at weakest foundation level "
              f"{VAS.weakest_foundational_score}: "
              f"{VAS.realization_factor_low:.0%}-{VAS.realization_factor_high:.0%} · "
              f"realizable 18-mo {money(VAS.realizable_18mo_low)}-"
              f"{money(VAS.realizable_18mo_high)} · trapped "
              f"{money(VAS.trapped_value_low)}-{money(VAS.trapped_value_high)} "
              f"per year · binding: {', '.join(VAS.binding_constraints)}")

backstage("\\n#### Engagement state (JSON)\\n")
backstage("```json")
backstage(engagement_json(ENGAGEMENT))
backstage("```")'''


REVIEW = '''\
# ── Review a saved engagement (backstage utility, optional) ─────────
# Point _REVIEW_JSON at an exports/*.json and run in JupyterLab to
# reload a past engagement for review; the live session is untouched.
_REVIEW_JSON = ""
if _REVIEW_JSON:
    from diaglib import load_engagement
    _prev = load_engagement(pathlib.Path(_REVIEW_JSON))
    backstage(f"loaded {_prev.engagement_id}: {_prev.client_name} · "
              f"{len(_prev.scores)} scores · " + (
                  "binding: " + ", ".join(_prev.computed_value.binding_constraints)
                  if _prev.computed_value else "no computed value"))'''


def md(source: str) -> nbf.NotebookNode:
    return nbf.v4.new_markdown_cell(source)


def code(source: str) -> nbf.NotebookNode:
    return nbf.v4.new_code_cell(source)


def build() -> nbf.NotebookNode:
    nb = nbf.v4.new_notebook()
    nb.cells = [
        md(MD_TITLE),
        code(SETUP),
        md(MD_HOWTO),
        code(W_ENGAGEMENT),
        code(W_BASELINE),
        code(W_SCORING),
        code(STATE),
        code(STAGE_HEADER),
        code(STAGE_CARD),
        code(STAGE_ANALYSIS),
        md(MD_EXPORT),
        code(W_EXPORT),
        code(EXPORT_STATE),
        md(MD_GATE),
        code(GATE),
        md(MD_APPENDIX),
        code(APPENDIX),
        code(REVIEW),
    ]
    nb.metadata = {
        "kernelspec": {"display_name": "Python 3", "language": "python",
                       "name": "python3"},
        "language_info": {"name": "python"},
    }
    return nb


def main() -> None:
    OUT.parent.mkdir(parents=True, exist_ok=True)
    nbf.write(build(), OUT)
    print(f"wrote {OUT.relative_to(ROOT)} ({len(build().cells)} cells)")


if __name__ == "__main__":
    main()
