"""kicad_demo — the bridge's first-class, self-contained showcase.

Adom Desktop demos this bridge during Hydrogen Desktop's install, so an AI with
no context must be able to run ONE verb and narrate a convincing tour of what
the bridge can do. Six steps, in the order a hardware person thinks:

    symbol -> footprint -> 3D of that part -> schematic -> 2D board -> 3D board

Design constraints that shaped this file:

* **Ships zero assets.** The runtime zip is auto-bundled into AD, so it carries
  no sample projects (see the kicad-bridge-publish skill). Every symbol,
  footprint, schematic and board below is GENERATED here as text, a few KB of
  code instead of ~500 KB of files.
* **Real 3D for free.** The generated footprints point at KiCad's OWN bundled
  `share/kicad/3dmodels/*.3dshapes` models, resolved from the detected install
  at runtime. Nothing to download, and the 3D viewer shows a real part. If the
  model is missing we still place the pads and say so rather than failing.
* **Narration, not just status.** Every step returns `say` (a line the AI can
  speak), `pointOut` (what is actually on screen worth mentioning) and the
  window title to screenshot. The AI reads verb OUTPUT, not our docs.
* **KiCad may not exist yet.** During HD install that is the likely case, so
  the not-installed path is a first-class, actionable answer — not an error.
"""

import base64
import os
from pathlib import Path

from .install_symbol import handle_install_symbol
from .install_footprint import handle_install_footprint
from .open_symbol_editor import handle_open_symbol_editor
from .open_footprint_editor import handle_open_footprint_editor, handle_open_3d_viewer
from .open_files import handle_open_board, handle_open_schematic
from .upgrade import handle_upgrade
from .kicad_ui import handle_screenshot_all
from kicad_detect import detect_kicad

DEMO_LIB = "Adom"
IC_SYM, IC_FP = "AdomDemo_IC", "AdomDemo_SOIC8"
R_SYM, R_FP = "AdomDemo_R", "AdomDemo_R0805"

STEPS = ["symbol", "footprint", "footprint3d", "schematic", "board", "board3d"]

# Screenshot labels. `schematic` / `board_2d` / `board_3d` deliberately match the
# labels fusion_demo returns, so an AI driving BOTH bridges (HD's installer does)
# sees one vocabulary instead of two.
STEP_LABEL = {"symbol": "symbol", "footprint": "footprint", "footprint3d": "part_3d",
              "schematic": "schematic", "board": "board_2d", "board3d": "board_3d"}


def _capture(step: str, window_title: str) -> dict | None:
    """Capture the window this beat just opened and return {label, path}.

    fusion_demo hands back `screenshots:[{label,path}]` rather than making the
    caller hunt for windows; matching that means the AI shows the tour the same
    way for both bridges.
    """
    try:
        shots = handle_screenshot_all({}, {}) or {}
        for sh in (shots.get("data") or {}).get("screenshots") or shots.get("screenshots") or []:
            if window_title.lower() in (sh.get("title") or "").lower():
                p = sh.get("fullPath") or sh.get("localPath") or sh.get("savedTo")
                if p:
                    return {"label": STEP_LABEL.get(step, step), "path": p,
                            "title": sh.get("title")}
    except Exception:
        pass
    return None


# ---------------------------------------------------------------- 3D models --
def _model_path(kicad_info: dict, lib: str, name: str):
    """Absolute path to one of KiCad's OWN bundled 3D models, or None.

    Layout is stable across KiCad 6-10: <base>/share/kicad/3dmodels/<lib>/<name>.
    We probe .wrl then .step so the demo degrades gracefully instead of writing
    a dangling (model ...) that makes the 3D viewer look broken.
    """
    base = kicad_info.get("base_dir")
    if not base:
        return None
    root = Path(base) / "share" / "kicad" / "3dmodels" / lib
    for ext in (".wrl", ".step"):
        p = root / (name + ext)
        if p.exists():
            return str(p).replace("\\", "/")
    return None


# ------------------------------------------------------------ asset writers --
def _sym_block(name: str) -> str:
    """The bare `(symbol "<name>" ...)` block.

    Returned on its own (not wrapped in a kicad_symbol_lib) because it is needed
    in TWO places: the .kicad_sym we install, and the schematic's `lib_symbols`.
    Slicing it back out of the wrapper is how you silently ship a schematic with
    no symbols in it — so it is generated once, here.
    """
    if name == IC_SYM:
        pins = []
        names = ["VDD", "IN1", "IN2", "GND", "OUT2", "OUT1", "EN", "NC"]
        for i in range(4):  # left 1-4, top-down
            pins.append(f'      (pin bidirectional line (at -12.7 {5.08 - i * 2.54:.2f} 0) (length 2.54) '
                        f'(name "{names[i]}") (number "{i + 1}"))')
        for i in range(4):  # right 5-8, bottom-up, like a real SOIC
            pins.append(f'      (pin bidirectional line (at 12.7 {-3.81 + i * 2.54:.2f} 180) (length 2.54) '
                        f'(name "{names[4 + i]}") (number "{i + 5}"))')
        return f'''  (symbol "{IC_SYM}"
    (pin_names (offset 1.016)) (exclude_from_sim no) (in_bom yes) (on_board yes)
    (property "Reference" "U" (at -10.16 9.53 0) (effects (font (size 1.27 1.27)) (justify left)))
    (property "Value" "{IC_SYM}" (at -10.16 -9.53 0) (effects (font (size 1.27 1.27)) (justify left)))
    (property "Footprint" "{DEMO_LIB}:{IC_FP}" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))
    (property "Datasheet" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))
    (symbol "{IC_SYM}_0_1"
      (rectangle (start -10.16 7.62) (end 10.16 -7.62)
        (stroke (width 0.254) (type default)) (fill (type background)))
    )
    (symbol "{IC_SYM}_1_1"
{chr(10).join(pins)}
    )
  )
'''
    return f'''  (symbol "{R_SYM}"
    (pin_numbers (hide yes)) (pin_names (offset 0) (hide yes))
    (exclude_from_sim no) (in_bom yes) (on_board yes)
    (property "Reference" "R" (at 2.54 1.27 0) (effects (font (size 1.27 1.27)) (justify left)))
    (property "Value" "10k" (at 2.54 -1.27 0) (effects (font (size 1.27 1.27)) (justify left)))
    (property "Footprint" "{DEMO_LIB}:{R_FP}" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))
    (symbol "{R_SYM}_0_1"
      (rectangle (start -1.016 2.54) (end 1.016 -2.54)
        (stroke (width 0.254) (type default)) (fill (type none)))
    )
    (symbol "{R_SYM}_1_1"
      (pin passive line (at 0 3.81 270) (length 1.27) (name "~") (number "1"))
      (pin passive line (at 0 -3.81 90) (length 1.27) (name "~") (number "2"))
    )
  )
'''


def _sym_lib(name: str) -> str:
    """Installable .kicad_sym = the wrapper + the block."""
    return ('(kicad_symbol_lib (version 20231120) (generator "adom-demo")\n'
            + _sym_block(name) + ')\n')


def _fp_soic8(model: str | None, at: str = "") -> str:
    pads = []
    ys = [1.905, 0.635, -0.635, -1.905]
    for i, y in enumerate(ys):  # 1-4 left
        pads.append(f'  (pad "{i + 1}" smd roundrect (at -2.475 {y}) (size 1.95 0.6) '
                    f'(layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25))')
    for i, y in enumerate(reversed(ys)):  # 5-8 right
        pads.append(f'  (pad "{i + 5}" smd roundrect (at 2.475 {y}) (size 1.95 0.6) '
                    f'(layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25))')
    model_block = (f'  (model "{model}" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))\n'
                   if model else "")
    return f'''(footprint "{IC_FP}"
  (version 20240108) (generator "adom-demo") (layer "F.Cu"){at}
  (attr smd)
  (property "Reference" "U**" (at 0 -3.7 0) (layer "F.SilkS") (effects (font (size 0.8 0.8) (thickness 0.12))))
  (property "Value" "{IC_FP}" (at 0 3.7 0) (layer "F.Fab") (effects (font (size 0.8 0.8) (thickness 0.12))))
  (fp_line (start -1.95 -2.45) (end 1.95 -2.45) (stroke (width 0.12) (type solid)) (layer "F.SilkS"))
  (fp_line (start -1.95 2.45) (end 1.95 2.45) (stroke (width 0.12) (type solid)) (layer "F.SilkS"))
  (fp_circle (center -2.6 2.6) (end -2.45 2.6) (stroke (width 0.2) (type solid)) (fill none) (layer "F.SilkS"))
  (fp_rect (start -3.7 -2.7) (end 3.7 2.7) (stroke (width 0.05) (type solid)) (fill none) (layer "F.CrtYd"))
{chr(10).join(pads)}
{model_block})
'''


def _fp_r0805(model: str | None, at: str = "") -> str:
    model_block = (f'  (model "{model}" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))\n'
                   if model else "")
    return f'''(footprint "{R_FP}"
  (version 20240108) (generator "adom-demo") (layer "F.Cu"){at}
  (attr smd)
  (property "Reference" "R**" (at 0 -1.6 0) (layer "F.SilkS") (effects (font (size 0.6 0.6) (thickness 0.1))))
  (property "Value" "10k" (at 0 1.6 0) (layer "F.Fab") (effects (font (size 0.6 0.6) (thickness 0.1))))
  (fp_rect (start -1.7 -0.9) (end 1.7 0.9) (stroke (width 0.05) (type solid)) (fill none) (layer "F.CrtYd"))
  (pad "1" smd roundrect (at -0.9125 0) (size 1.025 1.4) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25))
  (pad "2" smd roundrect (at 0.9125 0) (size 1.025 1.4) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25))
{model_block})
'''


def _uuid(n: int) -> str:
    return f"00000000-0000-4000-8000-{n:012d}"


def _schematic() -> str:
    """Modern (v7+) schematic. The `(instances ...)` block is MANDATORY — without
    it KiCad 8/9/10 opens the sheet but renders NO symbols (silently)."""
    sheet = _uuid(1)
    parts = [(IC_SYM, "U1", 101.6, 76.2), (R_SYM, "R1", 152.4, 63.5), (R_SYM, "R2", 152.4, 88.9)]
    # lib_symbols entries are keyed by the FULL lib id ("Adom:Name") — the instances
    # below reference exactly that, or KiCad renders an empty sheet.
    libs = (_sym_block(IC_SYM).replace(f'(symbol "{IC_SYM}"', f'(symbol "{DEMO_LIB}:{IC_SYM}"', 1)
            + _sym_block(R_SYM).replace(f'(symbol "{R_SYM}"', f'(symbol "{DEMO_LIB}:{R_SYM}"', 1))
    inst = ""
    for i, (sym, ref, x, y) in enumerate(parts):
        u = _uuid(200 + i)
        inst += f'''  (symbol (lib_id "{DEMO_LIB}:{sym}") (at {x} {y} 0) (unit 1)
    (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (uuid "{u}")
    (property "Reference" "{ref}" (at {x + 12} {y - 2.54} 0) (effects (font (size 1.27 1.27)) (justify left)))
    (property "Value" "{sym}" (at {x + 12} {y + 2.54} 0) (effects (font (size 1.27 1.27)) (justify left)))
    (instances (project "adom-demo" (path "/{sheet}" (reference "{ref}") (unit 1))))
  )
'''
    return f'''(kicad_sch (version 20231120) (generator "eeschema") (generator_version "8.0")
  (uuid "{sheet}") (paper "A4")
  (title_block (title "Adom KiCad Bridge — Demo") (company "Adom"))
  (lib_symbols
{libs}  )
{inst}  (sheet_instances (path "/" (page "1")))
)
'''


def _board(kicad_info: dict) -> str:
    soic = _model_path(kicad_info, "Package_SO.3dshapes", "SOIC-8_3.9x4.9mm_P1.27mm")
    r08 = _model_path(kicad_info, "Resistor_SMD.3dshapes", "R_0805_2012Metric")
    fps = (_fp_soic8(soic, "\n  (at 120 80)").replace("\n", "\n  ")
           + "\n  " + _fp_r0805(r08, "\n  (at 145 70)").replace("\n", "\n  ")
           + "\n  " + _fp_r0805(r08, "\n  (at 145 90)").replace("\n", "\n  "))
    edge = ""
    pts = [(100, 60), (170, 60), (170, 100), (100, 100), (100, 60)]
    for i in range(len(pts) - 1):
        (x1, y1), (x2, y2) = pts[i], pts[i + 1]
        edge += (f'  (gr_line (start {x1} {y1}) (end {x2} {y2}) '
                 f'(stroke (width 0.15) (type solid)) (layer "Edge.Cuts") (uuid "{_uuid(300 + i)}"))\n')
    return f'''(kicad_pcb (version 20240108) (generator "pcbnew") (generator_version "8.0")
  (general (thickness 1.6)) (paper "A4")
  (layers
    (0 "F.Cu" signal) (31 "B.Cu" signal)
    (36 "B.SilkS" user "B.Silkscreen") (37 "F.SilkS" user "F.Silkscreen")
    (38 "B.Mask" user) (39 "F.Mask" user) (40 "B.Paste" user) (41 "F.Paste" user)
    (44 "Edge.Cuts" user) (45 "Margin" user) (49 "F.Fab" user) (50 "B.Fab" user)
    (46 "B.CrtYd" user "B.Courtyard") (47 "F.CrtYd" user "F.Courtyard")
  )
  (setup (pad_to_mask_clearance 0))
{edge}  {fps}
)
'''


# ------------------------------------------------------------------ the verb --
def _demo_dir() -> Path:
    d = Path.home() / "Documents" / "adom-kicad-demo"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _b64(s: str) -> str:
    return base64.b64encode(s.encode("utf-8")).decode()


def _prepare(kicad_info: dict) -> dict:
    """Install the two demo parts into the Adom library + write the project."""
    out = {"installed": [], "warnings": []}
    for fname, sym, content in ((f"{IC_SYM}.kicad_sym", IC_SYM, _sym_lib(IC_SYM)),
                                (f"{R_SYM}.kicad_sym", R_SYM, _sym_lib(R_SYM))):
        r = handle_install_symbol(kicad_info, {"fileName": fname, "symbolName": sym,
                                               "fileContent": _b64(content), "quietInstall": True})
        (out["installed"] if r.get("success") else out["warnings"]).append(
            sym if r.get("success") else f"{sym}: {r.get('error')}")

    soic = _model_path(kicad_info, "Package_SO.3dshapes", "SOIC-8_3.9x4.9mm_P1.27mm")
    r08 = _model_path(kicad_info, "Resistor_SMD.3dshapes", "R_0805_2012Metric")
    out["models3d"] = {"soic8": soic, "r0805": r08}
    if not (soic and r08):
        out["warnings"].append(
            "KiCad's bundled 3D models were not found — pads/board still render, "
            "component bodies will be missing in the 3D views.")
    for fname, fp, content in ((f"{IC_FP}.kicad_mod", IC_FP, _fp_soic8(soic)),
                               (f"{R_FP}.kicad_mod", R_FP, _fp_r0805(r08))):
        r = handle_install_footprint(kicad_info, {"fileName": fname, "footprintName": fp,
                                                  "fileContent": _b64(content), "quietInstall": True})
        (out["installed"] if r.get("success") else out["warnings"]).append(
            fp if r.get("success") else f"{fp}: {r.get('error')}")

    d = _demo_dir()
    (d / "adom-demo.kicad_sch").write_text(_schematic(), encoding="utf-8")
    (d / "adom-demo.kicad_pcb").write_text(_board(kicad_info), encoding="utf-8")
    (d / "adom-demo.kicad_pro").write_text('{"board":{},"meta":{"filename":"adom-demo.kicad_pro","version":1},"schematic":{},"sheets":[]}', encoding="utf-8")
    out["projectDir"] = str(d)
    out["schematic"] = str(d / "adom-demo.kicad_sch")
    out["board"] = str(d / "adom-demo.kicad_pcb")
    return out


def _warm_kicad(kicad_info: dict, prep: dict) -> dict:
    """Get KiCad up and settled BEFORE the tour's first beat.

    The demo is by definition the cold-start case: on a fresh machine nothing is
    running, and `open_symbol_editor` has to launch the project manager, post a
    WM_COMMAND and hope the Symbol Editor appears within its 10 s budget. On a
    cold KiCad (splash + loading every symbol library) it frequently does not —
    the tour's very first step then fails, which is the worst possible demo.

    Opening the demo SCHEMATIC first fixes it twice over: it launches eeschema,
    and Symbol Editor is hosted INSIDE eeschema, so the subsequent open takes the
    fast in-process plugin pathway instead of the fragile cold WM_COMMAND one.
    Windows still open in the background; this only costs a few seconds.
    """
    import time
    # Symbol Editor is hosted inside EESCHEMA and Footprint Editor inside PCBNEW.
    # Warming only one leaves the other's editor with no host app, which fails as
    # "Could not open the Footprint Editor via the background (plugin) path".
    # So open BOTH documents up front; every later beat then takes the fast
    # in-process plugin pathway.
    sch = handle_open_schematic(kicad_info, {"filePath": prep["schematic"]})
    time.sleep(6)
    brd = handle_open_board(kicad_info, {"filePath": prep["board"]})
    time.sleep(8)   # pcbnew is the slower of the two to paint
    return {"warmed": bool(sch.get("success") and brd.get("success")),
            "eeschema": bool(sch.get("success")), "pcbnew": bool(brd.get("success")),
            "raw": {"schematic": sch, "board": brd}}


def _run_step(kicad_info: dict, step: str, prep: dict) -> dict:
    """Run one tour step. Returns narration-ready info, never raises."""
    has3d = bool((prep.get("models3d") or {}).get("soic8"))
    if step == "symbol":
        r = handle_open_symbol_editor(kicad_info, {"libraryName": DEMO_LIB, "symbolName": IC_SYM})
        return {"ok": bool(r.get("success")), "window": "Symbol Editor",
                "title": "1/6 · Schematic symbol",
                "say": (f"This is the {IC_SYM} symbol, in the user's own KiCad symbol editor. "
                        "The bridge installed it into their Adom library a second ago — no file "
                        "copying, no dialogs."),
                "pointOut": ["8 named pins (VDD, IN1/IN2, GND, OUT1/OUT2, EN, NC)",
                             "the body rectangle and pin numbering",
                             f"it is registered in the '{DEMO_LIB}' library, not a scratch file"],
                "raw": r}
    if step == "footprint":
        r = handle_open_footprint_editor(kicad_info, {"libraryName": DEMO_LIB, "footprintName": IC_FP})
        return {"ok": bool(r.get("success")), "window": "Footprint Editor",
                "title": "2/6 · The footprint for that symbol",
                "say": (f"Same part, now its land pattern: {IC_FP}. This is what actually gets "
                        "soldered — the symbol is the idea, the footprint is the copper."),
                "pointOut": ["8 SMD pads on 1.27 mm pitch", "silkscreen outline + the pin-1 dot",
                             "the courtyard rectangle (keep-out for the assembler)"],
                "raw": r}
    if step == "footprint3d":
        import time; time.sleep(2)
        r = handle_open_3d_viewer(kicad_info, {"editor": "footprint"})
        return {"ok": bool(r.get("success")), "window": "3D Viewer",
                "title": "3/6 · That part in 3D",
                "say": ("And here is the same footprint in three dimensions" +
                        (", with the real SOIC-8 body from KiCad's own 3D model library."
                         if has3d else " — pads only; KiCad's bundled 3D models weren't found.")),
                "pointOut": (["the SOIC-8 package body sitting on its pads", "gold pad plating"]
                             if has3d else ["the bare pads (no bundled 3D model on this machine)"]),
                "raw": r}
    if step == "schematic":
        r = handle_open_schematic(kicad_info, {"filePath": prep["schematic"]})
        return {"ok": bool(r.get("success")), "window": "Schematic Editor",
                "title": "4/6 · A schematic using it",
                "say": ("Now a real schematic sheet: the demo IC as U1 with two 10k resistors, "
                        "R1 and R2. The bridge generated and opened this project itself."),
                "pointOut": ["U1 plus R1/R2, each properly annotated",
                             "the title block: 'Adom KiCad Bridge — Demo'"],
                "raw": r}
    if step == "board":
        r = handle_open_board(kicad_info, {"filePath": prep["board"]})
        return {"ok": bool(r.get("success")), "window": "PCB Editor",
                "title": "5/6 · The 2D board layout",
                "say": ("The PCB side: the same three parts placed on a 70 by 40 millimetre "
                        "board outline, copper, silkscreen and courtyards all real."),
                "pointOut": ["the SOIC-8 land pattern and two 0805 resistors",
                             "the Edge.Cuts board outline",
                             "'Pads 12' in the status bar — proof the footprints loaded"],
                "raw": r}
    if step == "board3d":
        import time; time.sleep(2)
        r = handle_open_3d_viewer(kicad_info, {"editor": "pcb"})
        return {"ok": bool(r.get("success")), "window": "3D Viewer",
                "title": "6/6 · The whole board in 3D",
                "say": ("And the finished board in 3D — this is the payoff: from a symbol to a "
                        "rendered assembly, every step driven from the cloud on the user's own "
                        "machine."),
                "pointOut": (["all three component bodies on the green board"] if has3d
                             else ["the board and copper (component bodies need KiCad's 3D models)"])
                            + ["you can orbit it with the mouse — it is their real KiCad"],
                "raw": r}
    return {"ok": False, "error": f"unknown step '{step}'", "validSteps": STEPS}


def handle_demo(kicad_info: dict, args: dict) -> dict:
    """Run the bridge's showcase tour (all six steps, or one at a time)."""
    # KiCad absent is the LIKELY case during an HD install demo. Never dead-end the
    # user with "install it yourself" — OFFER, and install it for them on the spot.
    if not kicad_info.get("installed"):
        if not args.get("installKiCad"):
            return {
                "success": False,
                "stage": "not_installed",
                "done": False,
                "narrate": (
                    "You don't have KiCad yet — want me to install it for you? It's the free, "
                    "fully open-source EDA suite: no trial clock, no personal-use tier, no feature "
                    "limits. Everything in this demo works forever, on any machine you own. "
                    "It's a large download, so give it a few minutes."),
                "screenshots": [],
                "error": "KiCad is not installed",
                "data": {
                    "offer": "I can install KiCad for you right now.",
                    "acceptWith": "kicad_demo {\"installKiCad\": true}",
                    "installVerb": "kicad_upgrade",
                    "steps": STEPS,
                },
                "_hint": (
                    "ASK, then do it for them — never hand a first-time user a download page or an "
                    "installer to click through. Speak `narrate`, and on yes call "
                    "kicad_demo {\"installKiCad\": true}: it installs the official build silently "
                    "(per-user, no UAC) and then runs the tour in the SAME call. Watching the AI "
                    "install their EDA tool is itself the best demo beat, so narrate it rather than "
                    "going quiet for several minutes. Unlike Fusion there is no trial or tier "
                    "caveat to disclose — KiCad is free and unrestricted, which is worth saying."
                ),
            }
        # They said yes — install it, then re-detect and carry on into the tour.
        up = handle_upgrade(kicad_info, {})
        fresh = detect_kicad() or {}
        if not fresh.get("installed"):
            return {
                "success": False,
                "error": "KiCad install did not complete",
                "data": {"upgrade": up},
                "_hint": ("The silent install ran but KiCad still isn't detected. Tell the user "
                          "plainly, then check kicad_upgrade {\"diagnoseOnly\":true} — the usual "
                          "causes are SmartScreen/AV blocking the downloaded installer or a "
                          "pending reboot. Don't pretend the demo succeeded."),
            }
        kicad_info = fresh
        installed_now = True
    else:
        installed_now = False

    step = args.get("step")
    if step and step not in STEPS:
        return {"success": False, "error": f"unknown step '{step}'", "data": {"validSteps": STEPS}}

    prep = _prepare(kicad_info)

    # DEFAULT = one beat at a time. The full six-window tour takes ~90 s, which
    # blows AD's per-request budget and returns a timeout that looks like a broken
    # demo. Stepping also simply narrates better: say the line, screenshot, move on.
    # {"all": true} still runs the whole thing for non-interactive use.
    run_all = bool(args.get("all"))
    steps = STEPS if run_all else [step or STEPS[0]]

    # Warm eeschema only when starting the tour (Symbol Editor is hosted inside it,
    # and a cold WM_COMMAND misses its 10 s budget). Later beats reuse the warm app.
    if run_all or steps[0] == STEPS[0]:
        prep["warm"] = _warm_kicad(kicad_info, prep)

    import time
    results, failed = [], []
    for s_ in steps:
        r = _run_step(kicad_info, s_, prep)
        if not r.get("ok"):          # one retry — a cold editor often misses its first budget
            time.sleep(3)
            r = _run_step(kicad_info, s_, prep)
            r["retried"] = True
        r["step"] = s_
        results.append(r)
        if not r.get("ok"):
            failed.append(s_)

    last = steps[-1]
    nxt = STEPS[STEPS.index(last) + 1] if last in STEPS and STEPS.index(last) + 1 < len(STEPS) else None

    # Capture what each beat put on screen, so the caller can SHOW the tour
    # instead of hunting for windows (same contract as fusion_demo).
    shots = []
    for r in results:
        if r.get("ok") and r.get("window"):
            c = _capture(r["step"], r["window"])
            if c:
                shots.append(c)
                r["screenshot"] = c["path"]

    # Narration. A failed beat must STILL say something useful and hand the user
    # forward — "never end a demo on an error screen" (fusion-demo, rule).
    lines = []
    for r in results:
        if r.get("ok"):
            lines.append(r.get("say", ""))
        else:
            lines.append(
                f"{r.get('title', r['step'])} didn't open on this machine, so I'll skip it rather "
                f"than leave you staring at nothing — the rest of the tour still works.")
    if installed_now:
        lines.insert(0, "I installed KiCad for you first — that's the bridge doing real work on "
                        "your machine, not a video.")
    narrate = " ".join(x for x in lines if x)

    return {
        "success": True,   # a skipped beat is not a failed demo; `failedSteps` carries the detail
        "stage": last,
        "done": nxt is None,
        "narrate": narrate,
        "screenshots": shots,
        "steps": [{"step": r["step"], "title": r.get("title"), "ok": r.get("ok"),
                   "pointOut": r.get("pointOut"), "window": r.get("window"),
                   "retried": r.get("retried", False)} for r in results],
        "output": (f"Demo {last}: {len([r for r in results if r.get('ok')])}/{len(results)} opened"
                   + (f"; skipped {', '.join(failed)}" if failed else "")),
        "data": {
            "nextCall": (f'kicad_demo {{"step": "{nxt}"}}' if nxt else None),
            "remainingSteps": STEPS[STEPS.index(last) + 1:] if last in STEPS else [],
            "failedSteps": failed,
            "installedKiCadJustNow": installed_now,
            "warmedUp": {k: (prep.get("warm") or {}).get(k) for k in ("warmed", "eeschema", "pcbnew")},
            "projectDir": prep.get("projectDir"),
            "installedParts": prep.get("installed"),
            "models3d": prep.get("models3d"),
            "warnings": prep.get("warnings"),
            "cleanup": "kicad_close {\"force\":true}",
        },
        "_hint": (
            ("You just installed KiCad for them — say so, it lands well. " if installed_now else "")
            + "STAGED + RESUMABLE, same contract as fusion_demo: speak `narrate` (it's written for a "
            "newcomer, not an engineer), SHOW every entry in `screenshots` ({label,path} — already "
            "captured for you), then call `data.nextCall` and repeat until `done` is true. Mention "
            "`steps[].pointOut` while the image is up. Everything is generated by the bridge (no "
            "shipped sample files) and installs into the user's real KiCad. Windows open in the "
            "BACKGROUND and all input is window-targeted, so the user can keep working throughout — "
            "say that, it's a genuine differentiator. If a beat was skipped, don't dwell: say what "
            "you tried and move on. Finish by offering next steps in THEIR words ('export the "
            "Gerbers', 'run DRC', 'make a part with my number etched on it') — never verb names. "
            "Tear down with kicad_close {\"force\":true}."
        ),
    }