#!/usr/bin/env python3
"""Drive Hwanse2.exe with key-buffer input while capturing screenshots and selected-pointer state."""
from __future__ import annotations

import argparse
import html
import json
import os
import shutil
import struct
import subprocess
import threading
import time
from pathlib import Path
from typing import Any

from probe_runtime_input_path import (
    DEFAULT_PREFIX,
    OUT,
    ROOT,
    env_for,
    find_free_display,
    find_hwanse_pid,
    hex32,
    load_selector_contexts,
    loaded_base,
    parse_windows,
    run,
    sample_process,
    selected_pointer_context,
    truncate,
    write_key_buffer,
)
from probe_runtime_key_sequences import KEY_OFFSETS
from probe_runtime_opening_noinput_poll import sample_runtime

IMAGE_BASE = 0x00400000
CURRENT_INPUT_MASK = 0x0059E310
PRESSED_EDGE_MASK = 0x0059E312
DEFAULT_ACTION_MASKS = {
    "Return": 0x0150,
    "z": 0x0140,
    "space": 0x0150,
    "Left": 0x0001,
    "Right": 0x0002,
    "Up": 0x0004,
    "Down": 0x0008,
}


def compact_context(context: dict[str, Any] | None) -> dict[str, Any] | None:
    if not context:
        return None
    return {
        "selector": context.get("selector"),
        "rootHex": context.get("rootHex"),
        "rootEndHex": context.get("rootEndHex"),
        "offsetHex": context.get("offsetHex"),
        "fieldMaps": context.get("fieldMaps") or [],
        "equalsCurrentRouteRoot": context.get("equalsCurrentRouteRoot"),
    }


def capture_screenshot(display: str, path: Path, label: str, t0: float) -> dict[str, Any]:
    import_path = shutil.which("import")
    if not import_path:
        return {
            "label": label,
            "timeMs": int((time.time() - t0) * 1000),
            "path": str(path),
            "status": 127,
            "error": "ImageMagick import not found",
            "exists": False,
        }
    env = os.environ.copy()
    env["DISPLAY"] = display
    try:
        result = subprocess.run(
            [import_path, "-window", "root", str(path)],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            errors="replace",
            timeout=5,
            env=env,
        )
        return {
            "label": label,
            "timeMs": int((time.time() - t0) * 1000),
            "path": str(path),
            "status": result.returncode,
            "output": truncate(result.stdout),
            "exists": path.exists(),
        }
    except subprocess.TimeoutExpired as exc:
        output = (exc.stdout or "") + (exc.stderr or "")
        return {
            "label": label,
            "timeMs": int((time.time() - t0) * 1000),
            "path": str(path),
            "status": 124,
            "output": truncate(output),
            "exists": path.exists(),
        }


def focus_windows(env: dict[str, str]) -> dict[str, Any]:
    if not shutil.which("xdotool"):
        return {
            "xdotoolPath": None,
            "classSearch": {"output": ""},
            "nameSearch": {"output": ""},
            "focusedWindows": [],
        }
    class_search = run(["xdotool", "search", "--class", "hwanse2"], env)
    name_search = run(["xdotool", "search", "--name", "hwanse"], env)
    windows = parse_windows([class_search.get("output", ""), name_search.get("output", "")])
    focused = []
    for window in windows[:8]:
        run(["xdotool", "windowactivate", "--sync", window], env)
        run(["xdotool", "windowfocus", window], env)
        run(["xdotool", "mousemove", "--window", window, "320", "240", "click", "1"], env)
        focused.append(window)
        time.sleep(0.05)
    return {
        "xdotoolPath": shutil.which("xdotool"),
        "classSearch": class_search,
        "nameSearch": name_search,
        "focusedWindows": focused,
    }


def watch_values(sample: dict[str, Any]) -> dict[str, str | None]:
    return {
        name: row.get("valueHex")
        for name, row in (sample.get("watchValues") or {}).items()
        if isinstance(row, dict)
    }


def make_sample(
    *,
    phase: str,
    pid: int | None,
    base: int | None,
    t0: float,
    roots: list[int],
    contexts: dict[int, dict],
) -> dict[str, Any]:
    if not pid or not base:
        return {
            "phase": phase,
            "timeMs": int((time.time() - t0) * 1000),
            "readOk": False,
            "error": "Hwanse2.exe process/base not available",
        }
    selected_sample = sample_process(pid, base)
    runtime_sample = sample_runtime(pid, base, t0)
    selected = selected_sample.get("selectedPointerStaticHex") or runtime_sample.get("selectedPointerStaticHex")
    opening_actors = runtime_sample.get("openingActorScriptRows") or []
    return {
        "phase": phase,
        "timeMs": int((time.time() - t0) * 1000),
        "readOk": bool(selected_sample.get("readOk") or runtime_sample.get("readOk")),
        "selectedPointerStaticHex": selected,
        "selectedPointerRuntimeHex": selected_sample.get("selectedPointerRuntimeHex")
        or runtime_sample.get("selectedPointerRuntimeHex"),
        "selectorContext": compact_context(selected_pointer_context(selected, roots, contexts)),
        "watchValues": selected_sample.get("watchValues") or {},
        "watchValueSummary": watch_values(selected_sample),
        "pressedKeyOffsets": selected_sample.get("pressedKeyOffsets") or [],
        "selectedPointerInOpeningRange": runtime_sample.get("selectedPointerInOpeningRange"),
        "openingActorScriptCount": runtime_sample.get("openingActorScriptCount"),
        "openingActorScriptRows": opening_actors[:8],
        "genericStopFlag": runtime_sample.get("genericStopFlag"),
        "activeActorCount": runtime_sample.get("activeActorCount"),
        "currentInputMaskHex": runtime_sample.get("currentInputMaskHex"),
        "pressedEdgeMaskHex": runtime_sample.get("pressedEdgeMaskHex"),
    }


def selector_label(row: dict[str, Any]) -> str:
    context = row.get("selectorContext") or {}
    return context.get("selector") or "-"


def actor_script_label(row: dict[str, Any]) -> str:
    scripts = [
        script
        for script in (
            actor.get("scriptStaticHex")
            for actor in row.get("openingActorScriptRows") or []
        )
        if script
    ]
    if not scripts:
        return "-"
    return ",".join(dict.fromkeys(scripts))


def parse_sequences(raw_rows: list[str]) -> list[tuple[str, list[str]]]:
    if not raw_rows:
        return [("skip-opening-space", ["space"])]
    sequences = []
    for index, raw in enumerate(raw_rows, start=1):
        if "=" in raw:
            name, key_text = raw.split("=", 1)
        else:
            name, key_text = f"custom-{index}", raw
        keys = [item.strip() for item in key_text.split(",") if item.strip()]
        if not keys:
            raise SystemExit(f"empty sequence: {raw!r}")
        missing = [key for key in keys if split_key_token(key)[0] not in KEY_OFFSETS]
        if missing:
            raise SystemExit(f"unknown key(s) in sequence {name!r}: {', '.join(missing)}")
        sequences.append((name.strip() or f"custom-{index}", keys))
    return sequences


def split_key_token(token: str) -> tuple[str, str | None]:
    if "@" not in token:
        return token, None
    key, mode = token.split("@", 1)
    key = key.strip()
    mode = mode.strip()
    if mode not in {"key-buffer", "action-mask", "combined"}:
        raise SystemExit(f"invalid per-key input mode {mode!r} in {token!r}")
    return key, mode


def parse_action_masks(raw_rows: list[str]) -> dict[str, int]:
    masks = dict(DEFAULT_ACTION_MASKS)
    for raw in raw_rows or []:
        if "=" not in raw:
            raise SystemExit(f"invalid action mask {raw!r}; expected KEY=0xNNNN")
        key, value_text = raw.split("=", 1)
        key = key.strip()
        if key not in KEY_OFFSETS:
            raise SystemExit(f"unknown key for action mask: {key!r}")
        try:
            masks[key] = int(value_text.strip(), 0) & 0xFFFF
        except ValueError as exc:
            raise SystemExit(f"invalid action mask value for {key!r}: {value_text!r}") from exc
    return masks


def write_action_mask(
    pid: int,
    base: int,
    mask: int,
    *,
    duration: float,
    edge_duration: float,
) -> dict[str, Any]:
    current_address = base + (CURRENT_INPUT_MASK - IMAGE_BASE)
    edge_address = base + (PRESSED_EDGE_MASK - IMAGE_BASE)
    mask_bytes = struct.pack("<H", mask & 0xFFFF)
    zero = b"\x00\x00"
    try:
        with (Path("/proc") / str(pid) / "mem").open("r+b", buffering=0) as mem:
            deadline = time.time() + duration
            edge_deadline = time.time() + min(edge_duration, duration)
            while time.time() < deadline:
                now = time.time()
                mem.seek(current_address)
                mem.write(mask_bytes)
                mem.seek(edge_address)
                mem.write(mask_bytes if now < edge_deadline else zero)
                time.sleep(0.01)
            mem.seek(current_address)
            mem.write(zero)
            mem.seek(edge_address)
            mem.write(zero)
    except OSError as exc:
        return {
            "writeOk": False,
            "error": str(exc),
        }
    return {
        "writeOk": True,
        "mode": "action-mask",
        "maskHex": f"0x{mask & 0xFFFF:04x}",
        "currentRuntimeAddressHex": hex32(current_address),
        "edgeRuntimeAddressHex": hex32(edge_address),
    }


def write_combined_input(
    pid: int,
    base: int,
    key_offset: int,
    mask: int,
    *,
    duration: float,
    edge_duration: float,
) -> dict[str, Any]:
    key_address = base + (0x0055B868 - IMAGE_BASE) + key_offset
    current_address = base + (CURRENT_INPUT_MASK - IMAGE_BASE)
    edge_address = base + (PRESSED_EDGE_MASK - IMAGE_BASE)
    mask_bytes = struct.pack("<H", mask & 0xFFFF)
    zero2 = b"\x00\x00"
    try:
        with (Path("/proc") / str(pid) / "mem").open("r+b", buffering=0) as mem:
            deadline = time.time() + duration
            edge_deadline = time.time() + min(edge_duration, duration)
            while time.time() < deadline:
                now = time.time()
                mem.seek(key_address)
                mem.write(b"\x80")
                mem.seek(current_address)
                mem.write(mask_bytes)
                mem.seek(edge_address)
                mem.write(mask_bytes if now < edge_deadline else zero2)
                time.sleep(0.01)
            mem.seek(key_address)
            mem.write(b"\x00")
            mem.seek(current_address)
            mem.write(zero2)
            mem.seek(edge_address)
            mem.write(zero2)
    except OSError as exc:
        return {
            "writeOk": False,
            "error": str(exc),
        }
    return {
        "writeOk": True,
        "mode": "combined",
        "keyRuntimeAddressHex": hex32(key_address),
        "maskHex": f"0x{mask & 0xFFFF:04x}",
        "currentRuntimeAddressHex": hex32(current_address),
        "edgeRuntimeAddressHex": hex32(edge_address),
    }


def run_sequence(
    *,
    name: str,
    keys: list[str],
    startup_wait: float,
    hold: float,
    gap: float,
    interval: float,
    screenshot_interval: float,
    prefix: Path,
    out_dir: Path,
    output_prefix: str,
    roots: list[int],
    contexts: dict[int, dict],
    input_mode: str,
    action_masks: dict[str, int],
    action_edge_duration: float,
) -> dict[str, Any]:
    display = find_free_display()
    env = env_for(prefix, display)
    prefix.mkdir(parents=True, exist_ok=True)
    screen_dir = out_dir / f"{output_prefix}_screens" / name
    screen_dir.mkdir(parents=True, exist_ok=True)

    subprocess.run(["wineserver", "-k"], cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=8)
    xvfb = subprocess.Popen(
        ["Xvfb", display, "-screen", "0", "1280x1024x24"],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        errors="replace",
    )
    time.sleep(1)
    wine = subprocess.Popen(
        ["wine", "explorer", "/desktop=hwanse,640x480", "Hwanse2.exe"],
        cwd=ROOT,
        env=env,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        errors="replace",
    )

    t0 = time.time()
    screenshots: list[dict[str, Any]] = []
    samples: list[dict[str, Any]] = []
    writes: list[dict[str, Any]] = []
    pid: int | None = None
    base: int | None = None
    focus: dict[str, Any] = {}
    shot_index = 0

    def shot(label: str) -> None:
        nonlocal shot_index
        safe_label = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in label)[:60]
        path = screen_dir / f"{shot_index:03d}_{safe_label}_{int((time.time() - t0) * 1000):05d}.png"
        shot_index += 1
        screenshots.append(capture_screenshot(display, path, label, t0))

    def sample(phase: str) -> None:
        samples.append(make_sample(phase=phase, pid=pid, base=base, t0=t0, roots=roots, contexts=contexts))

    def poll_for(duration: float, phase: str, *, capture: bool = True) -> None:
        deadline = time.time() + duration
        next_shot = time.time()
        while time.time() < deadline:
            sample(phase)
            if capture and time.time() >= next_shot:
                shot(phase)
                next_shot += screenshot_interval
            time.sleep(interval)

    try:
        time.sleep(startup_wait)
        pid = find_hwanse_pid()
        base = loaded_base(pid) if pid else None
        focus = focus_windows(env)
        sample("initial")
        shot("initial")
        poll_for(0.6, "initial-hold")

        if pid and base:
            for key in keys:
                key_name, key_mode = split_key_token(key)
                effective_input_mode = key_mode or input_mode
                offset = KEY_OFFSETS[key_name]
                write_box: dict[str, Any] = {}

                def writer() -> None:
                    writes_for_key = []
                    if effective_input_mode == "combined":
                        mask = action_masks.get(key_name)
                        if mask is None:
                            writes_for_key.append(
                                {"mode": "combined", "writeOk": False, "error": f"no action mask for {key_name}"}
                            )
                        else:
                            writes_for_key.append(
                                write_combined_input(
                                    pid,
                                    base,
                                    offset,
                                    mask,
                                    duration=hold,
                                    edge_duration=action_edge_duration,
                                )
                            )
                    elif effective_input_mode == "key-buffer":
                        writes_for_key.append(
                            {"mode": "key-buffer", **write_key_buffer(pid, base, offset, duration=hold)}
                        )
                    elif effective_input_mode == "action-mask":
                        mask = action_masks.get(key_name)
                        if mask is None:
                            writes_for_key.append(
                                {"mode": "action-mask", "writeOk": False, "error": f"no action mask for {key_name}"}
                            )
                        else:
                            writes_for_key.append(
                                write_action_mask(
                                    pid,
                                    base,
                                    mask,
                                    duration=hold,
                                    edge_duration=action_edge_duration,
                                )
                            )
                    write_box.update({
                        "writeOk": all(row.get("writeOk") for row in writes_for_key),
                        "writes": writes_for_key,
                    })

                shot(f"before-{key}")
                thread = threading.Thread(target=writer, daemon=True)
                thread.start()
                poll_for(hold, f"key-down:{key}")
                thread.join(timeout=1)
                writes.append({
                    "key": key,
                    "keyName": key_name,
                    "inputMode": effective_input_mode,
                    "keyOffsetHex": hex32(offset),
                    "write": write_box,
                })
                shot(f"after-{key}")
                poll_for(gap, f"after:{key}")
        sample("final")
        shot("final")
    finally:
        subprocess.run(["wineserver", "-k"], cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=8)
        try:
            wine_output, _ = wine.communicate(timeout=5)
        except subprocess.TimeoutExpired:
            wine.kill()
            wine_output, _ = wine.communicate(timeout=5)
        xvfb.terminate()
        try:
            xvfb_output, _ = xvfb.communicate(timeout=5)
        except subprocess.TimeoutExpired:
            xvfb.kill()
            xvfb_output, _ = xvfb.communicate(timeout=5)

    selectors = []
    seen_selectors = set()
    for row in samples:
        selector = selector_label(row)
        if selector not in seen_selectors:
            selectors.append(selector)
            seen_selectors.add(selector)
    selected_pointers = []
    seen_pointers = set()
    for row in samples:
        pointer = row.get("selectedPointerStaticHex")
        if pointer and pointer not in seen_pointers:
            selected_pointers.append(pointer)
            seen_pointers.add(pointer)

    return {
        "name": name,
        "keys": keys,
        "display": display,
        "linuxPid": pid,
        "loadedBaseHex": hex32(base),
        "focus": focus,
        "sampleCount": len(samples),
        "screenshotCount": len([row for row in screenshots if row.get("exists")]),
        "uniqueSelectors": selectors,
        "uniqueSelectedPointers": selected_pointers,
        "initialSelectedPointerHex": samples[0].get("selectedPointerStaticHex") if samples else None,
        "finalSelectedPointerHex": samples[-1].get("selectedPointerStaticHex") if samples else None,
        "finalSelectorContext": samples[-1].get("selectorContext") if samples else None,
        "writes": writes,
        "samples": samples,
        "screenshots": screenshots,
        "startupOutput": truncate(wine_output or ""),
        "xvfbOutput": truncate(xvfb_output or ""),
    }


def build_summary(args: argparse.Namespace) -> dict[str, Any]:
    roots, contexts = load_selector_contexts()
    out_dir = args.out_dir
    out_dir.mkdir(parents=True, exist_ok=True)
    sequences = parse_sequences(args.sequence)
    action_masks = parse_action_masks(args.action_mask)
    rows = [
        run_sequence(
            name=name,
            keys=keys,
            startup_wait=args.startup_wait,
            hold=args.hold,
            gap=args.gap,
            interval=args.interval,
            screenshot_interval=args.screenshot_interval,
            prefix=args.prefix,
            out_dir=out_dir,
            output_prefix=args.output_prefix,
            roots=roots,
            contexts=contexts,
            input_mode=args.input_mode,
            action_masks=action_masks,
            action_edge_duration=args.action_edge_duration,
        )
        for name, keys in sequences
    ]
    observed_selectors = []
    seen_selectors = set()
    for row in rows:
        for selector in row.get("uniqueSelectors") or []:
            if selector not in seen_selectors:
                observed_selectors.append(selector)
                seen_selectors.add(selector)
    return {
        "objective": "visual Wine runtime probe with direct key-buffer input and selected-pointer polling",
        "startupWaitSeconds": args.startup_wait,
        "holdSeconds": args.hold,
        "gapSeconds": args.gap,
        "pollIntervalSeconds": args.interval,
        "screenshotIntervalSeconds": args.screenshot_interval,
        "inputMode": args.input_mode,
        "actionMasks": {key: f"0x{value:04x}" for key, value in action_masks.items()},
        "actionEdgeDurationSeconds": args.action_edge_duration,
        "winePrefix": str(args.prefix),
        "sequenceCount": len(rows),
        "observedSelectors": observed_selectors,
        "rows": rows,
        "conclusion": (
            "This probe records the visible screen and selected-pointer context around each direct key-buffer input. "
            "Use it when X events are ignored by the EXE but key-buffer pokes advance the original runtime."
        ),
    }


def screenshot_src(row: dict[str, Any], out_dir: Path) -> str:
    path = Path(row["path"])
    try:
        return path.relative_to(out_dir).as_posix()
    except ValueError:
        return path.name


def markdown(summary: dict[str, Any]) -> str:
    lines = [
        "# Runtime Visual Key Sequence",
        "",
        f"- objective: {summary.get('objective')}",
        f"- sequence count: {summary.get('sequenceCount')}",
        f"- startup wait: `{summary.get('startupWaitSeconds')}s`",
        f"- hold/gap: `{summary.get('holdSeconds')}s / {summary.get('gapSeconds')}s`",
        f"- observed selectors: `{', '.join(summary.get('observedSelectors') or []) or '-'}`",
        "",
        summary.get("conclusion") or "",
        "",
        "## Sequences",
        "",
        "| name | keys | samples | screenshots | selectors | initial | final |",
        "| --- | --- | ---: | ---: | --- | --- | --- |",
    ]
    for row in summary.get("rows") or []:
        lines.append(
            f"| `{row.get('name')}` | `{','.join(row.get('keys') or [])}` | {row.get('sampleCount')} | "
            f"{row.get('screenshotCount')} | `{', '.join(row.get('uniqueSelectors') or []) or '-'}` | "
            f"`{row.get('initialSelectedPointerHex') or '-'}` | `{row.get('finalSelectedPointerHex') or '-'}` |"
        )
    lines.extend(["", "## Timeline", ""])
    for row in summary.get("rows") or []:
        lines.append(f"### {row.get('name')}")
        for sample in (row.get("samples") or [])[:: max(1, len(row.get("samples") or []) // 40 or 1)]:
            lines.append(
                f"- {sample.get('timeMs')}ms {sample.get('phase')}: "
                f"`{sample.get('selectedPointerStaticHex') or '-'}` selector `{selector_label(sample)}` "
                f"actorScripts=`{actor_script_label(sample)}` "
                f"pressed={sample.get('pressedKeyOffsets') or []} "
                f"input={sample.get('currentInputMaskHex') or '-'} / {sample.get('pressedEdgeMaskHex') or '-'}"
            )
        lines.append("")
    return "\n".join(lines)


def html_page(summary: dict[str, Any], out_dir: Path) -> str:
    sequence_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('name')))}</code></td>"
        f"<td><code>{html.escape(','.join(row.get('keys') or []))}</code></td>"
        f"<td>{html.escape(str(row.get('sampleCount')))}</td>"
        f"<td>{html.escape(str(row.get('screenshotCount')))}</td>"
        f"<td><code>{html.escape(', '.join(row.get('uniqueSelectors') or []) or '-')}</code></td>"
        f"<td><code>{html.escape(str(row.get('initialSelectedPointerHex') or '-'))}</code></td>"
        f"<td><code>{html.escape(str(row.get('finalSelectedPointerHex') or '-'))}</code></td>"
        "</tr>"
        for row in summary.get("rows") or []
    )
    sections = []
    for row in summary.get("rows") or []:
        samples = row.get("samples") or []
        step = max(1, len(samples) // 80 or 1)
        sample_rows = "\n".join(
            "<tr>"
            f"<td>{html.escape(str(sample.get('timeMs')))}ms</td>"
            f"<td><code>{html.escape(str(sample.get('phase')))}</code></td>"
            f"<td><code>{html.escape(str(sample.get('selectedPointerStaticHex') or '-'))}</code></td>"
            f"<td><code>{html.escape(selector_label(sample))}</code></td>"
            f"<td><code>{html.escape(str(sample.get('currentInputMaskHex') or '-'))} / {html.escape(str(sample.get('pressedEdgeMaskHex') or '-'))}</code></td>"
            f"<td>{html.escape(str(sample.get('pressedKeyOffsets') or []))}</td>"
            f"<td><code>{html.escape(actor_script_label(sample))}</code></td>"
            "</tr>"
            for sample in samples[::step]
        )
        shots = "\n".join(
            "<figure>"
            f"<img src=\"{html.escape(screenshot_src(shot, out_dir))}\" alt=\"{html.escape(str(shot.get('label')))}\" loading=\"lazy\">"
            f"<figcaption>{html.escape(str(shot.get('label')))} · {html.escape(str(shot.get('timeMs')))}ms</figcaption>"
            "</figure>"
            for shot in row.get("screenshots") or []
            if shot.get("exists")
        )
        sections.append(
            f"<section class=\"panel\"><h2>{html.escape(str(row.get('name')))}</h2>"
            f"<p>keys: <code>{html.escape(','.join(row.get('keys') or []))}</code></p>"
            f"<div class=\"shots\">{shots or '<p>No screenshots.</p>'}</div>"
            "<h3>Sample Timeline</h3>"
            "<div class=\"table-wrap\"><table><thead><tr><th>t</th><th>phase</th><th>selected pointer</th><th>selector</th><th>input</th><th>pressed</th><th>opening actor scripts</th></tr></thead><tbody>"
            f"{sample_rows}</tbody></table></div></section>"
        )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Runtime Visual Key Sequence</title>
  <style>
    body {{ margin: 24px; background: #f6f7f9; color: #17202a; font: 14px system-ui, sans-serif; }}
    a {{ color: #185abc; text-decoration: none; }}
    code {{ color: #7a4b00; }}
    .panel {{ background: white; border: 1px solid #d8dee6; border-radius: 8px; padding: 14px; margin: 14px 0; }}
    table {{ width: 100%; border-collapse: collapse; background: white; }}
    th, td {{ border-bottom: 1px solid #d8dee6; padding: 7px 9px; text-align: left; vertical-align: top; }}
    th {{ background: #eef2f6; position: sticky; top: 0; }}
    .table-wrap {{ overflow: auto; border: 1px solid #d8dee6; border-radius: 6px; max-height: 520px; }}
    .shots {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin: 12px 0; }}
    figure {{ margin: 0; padding: 10px; background: white; border: 1px solid #d8dee6; border-radius: 8px; }}
    img {{ width: 100%; height: auto; image-rendering: pixelated; background: #111; }}
  </style>
</head>
<body>
  <h1>Runtime Visual Key Sequence</h1>
  <p><a href="../web/index.html">Home</a> · <a href="runtime_opening_noinput_poll.html">opening no-input poll</a></p>
  <div class="panel">
    <ul>
      <li>startup wait: <code>{html.escape(str(summary.get('startupWaitSeconds')))}s</code></li>
      <li>hold/gap: <code>{html.escape(str(summary.get('holdSeconds')))}s / {html.escape(str(summary.get('gapSeconds')))}s</code></li>
      <li>observed selectors: <code>{html.escape(', '.join(summary.get('observedSelectors') or []) or '-')}</code></li>
    </ul>
    <p>{html.escape(summary.get('conclusion') or '')}</p>
  </div>
  <h2>Sequences</h2>
  <table><thead><tr><th>name</th><th>keys</th><th>samples</th><th>screenshots</th><th>selectors</th><th>initial</th><th>final</th></tr></thead><tbody>{sequence_rows}</tbody></table>
  {''.join(sections)}
  <script>
    window.HWANSE_RUNTIME_VISUAL_KEY_SEQUENCE_READY = true;
    window.HWANSE_LAST_RUNTIME_VISUAL_KEY_SEQUENCE = {{
      sequenceCount: {int(summary.get('sequenceCount') or 0)},
      observedSelectors: {json.dumps(summary.get('observedSelectors') or [], ensure_ascii=False)}
    }};
  </script>
</body>
</html>
"""


def write_outputs(summary: dict[str, Any], out_dir: Path, output_prefix: str) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / f"{output_prefix}.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / f"{output_prefix}.md").write_text(markdown(summary), encoding="utf-8")
    (out_dir / f"{output_prefix}.html").write_text(html_page(summary, out_dir), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--startup-wait", type=float, default=14.0)
    parser.add_argument("--hold", type=float, default=0.7)
    parser.add_argument("--gap", type=float, default=1.0)
    parser.add_argument("--interval", type=float, default=0.08)
    parser.add_argument("--screenshot-interval", type=float, default=0.45)
    parser.add_argument("--input-mode", choices=["key-buffer", "action-mask", "combined"], default="key-buffer")
    parser.add_argument(
        "--action-mask",
        action="append",
        default=[],
        help="internal game input mask mapping, e.g. Return=0x0150; used by --input-mode action-mask/combined",
    )
    parser.add_argument("--action-edge-duration", type=float, default=0.2)
    parser.add_argument("--prefix", type=Path, default=DEFAULT_PREFIX)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--output-prefix", default="runtime_visual_key_sequence")
    parser.add_argument(
        "--sequence",
        action="append",
        default=[],
        help="custom sequence, e.g. skip=space or start=space,Return,Down,space",
    )
    args = parser.parse_args()
    summary = build_summary(args)
    write_outputs(summary, args.out_dir, args.output_prefix)
    print(f"runtime visual key sequence -> {args.out_dir / (args.output_prefix + '.html')}")
    print(summary["conclusion"])


if __name__ == "__main__":
    main()
