#!/usr/bin/env python3
r"""
mame_interactive.py — автотест .exe в MAME без участия человека.

Полный справочник по автотестированию в MAME: docs/mame-autotest.md.

Инструмент запускает Sprinter в MAME, вводит команды в DSS эмуляцией
нажатий клавиш, снимает скриншоты в заданные моменты и завершает сессию
по таймауту.  Ввод идёт в НАСТОЯЩУЮ клавиатуру DSS — AT/PS-2 устройство
`:kbd:ms_naturl` (подключено последовательно к SIO Z84C015): через Lua
`ioport.ports[":kbd:ms_naturl:PX.Y"].fields[...]:set_value(1/0)` жмём/
отпускаем поля, at_keyboard сам генерит scancode'ы → SIO → DSS.
(ZX-матрица `IO_LINE0..7` и natkeyboard/-autoboot_command командную
строку DSS НЕ достают — не использовать.)

Тонкости (детали — в docs/mame-autotest.md):
- attotime.seconds — ЦЕЛОЕ; субсекундный тайминг = seconds +
  attoseconds/1e18.
- клавишу держать коротко (~0.06с), между символами ~0.14с — иначе
  автоповтор (typematic) и слипание scancode'ов.
- загрузка должна встать на приглашении C:\> (system.bat не должен
  автозапускать Flex Navigator/приложение).
- перед запуском проверяем, что нет висящих копий MAME.

Использование:
    python3 toolchain/mame_interactive.py [exe] [--data f ...] \
        [--launch-at T] [--step "T:TEXT" ...] [--snap t1,t2,...] [--timeout N]

  exe             — .exe, который кладётся на дискету A: и АВТОМАТИЧЕСКИ
                    запускается (печатается a:\<EXE> + Enter в момент
                    --launch-at).  Без exe работаем на голой строке.
  --data f ...    — доп. файлы на дискету A:.
  --launch-at T   — секунда, когда печатается запуск exe (по умолч. 8).
  --step "T:TEXT" — в момент T сек напечатать TEXT (можно много раз —
                    диалог с уже запущенной программой).  В TEXT: \n =
                    Enter, \t = Tab; заглавные/символы с Shift — авто.
  --snap          — секунды для скриншотов (по умолчанию — авто).
  --timeout       — секунд эмуляции до выхода.

Примеры:
    # запустить тест и снять экран
    python3 toolchain/mame_interactive.py tests/rt_test/rt_test.exe \
        --snap 12,14 --timeout 16

    # набрать команду на голой командной строке
    python3 toolchain/mame_interactive.py --step "8:dir\n" \
        --snap 10,11 --timeout 12

    # запустить программу и ответить на её ввод
    python3 toolchain/mame_interactive.py tests/menu/menu.exe \
        --step "13:2\n" --snap 15 --timeout 17
"""

import argparse
import os
import shutil
import subprocess
import sys

PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MAME_DIR = os.path.join(PROJECT_ROOT, "mame", "v306")
IMG_DIR = os.path.join(MAME_DIR, "IMG")
MC_IMG = os.path.join(IMG_DIR, "mc.img")
SNAP_DIR = os.path.join(MAME_DIR, "snap_auto")
LUA_SCRIPT = os.path.join(MAME_DIR, "_interactive_gen.lua")

COMMON_ARGS = [
    "-skip_gameinfo", "-video", "opengl", "-window", "-nofilter",
    "-beta:wd179x:0", "35hd", "-beta:wd179x:1", "35hd",
    "-flop1", MC_IMG, "-flop2", os.path.join(IMG_DIR, "dss171u.img"),
    "-isa0", "zxbus_adapter", "-isa0:zxbus_adapter:card", "neogs",
    "-hard1", os.path.join(IMG_DIR, "sp_hdd_sys.chd"),
    "-hard2", os.path.join(IMG_DIR, "sp_hdd_media.chd"),
    "-ata2:0", "cdrom", "-cdrom", os.path.join(IMG_DIR, "SprinterCD.iso"),
    "-bios", "v3.06",
]

# --- Раскладка AT-клавиатуры :kbd:ms_naturl (tag, mask) ---
# Снято дампом ioport-полей в MAME (см. docs/mame-autotest.md).
KB = ":kbd:ms_naturl:"
PHYS = {
    "a": (KB+"P1.6", 0x04), "b": (KB+"P1.5", 0x10), "c": (KB+"P1.4", 0x08),
    "d": (KB+"P1.4", 0x04), "e": (KB+"P1.4", 0x20), "f": (KB+"P1.5", 0x02),
    "g": (KB+"P1.5", 0x04), "h": (KB+"P2.0", 0x02), "i": (KB+"P1.4", 0x01),
    "j": (KB+"P2.0", 0x04), "k": (KB+"P1.4", 0x02), "l": (KB+"P2.2", 0x08),
    "m": (KB+"P2.0", 0x10), "n": (KB+"P2.0", 0x08), "o": (KB+"P2.2", 0x02),
    "p": (KB+"P2.3", 0x20), "q": (KB+"P1.6", 0x02), "r": (KB+"P1.5", 0x01),
    "s": (KB+"P1.2", 0x08), "t": (KB+"P1.5", 0x20), "u": (KB+"P2.0", 0x20),
    "v": (KB+"P1.5", 0x08), "w": (KB+"P1.2", 0x04), "x": (KB+"P1.2", 0x10),
    "y": (KB+"P2.0", 0x01), "z": (KB+"P1.6", 0x10),
    "1": (KB+"P1.6", 0x01), "2": (KB+"P1.2", 0x02), "3": (KB+"P1.4", 0x40),
    "4": (KB+"P1.5", 0x40), "5": (KB+"P1.5", 0x80), "6": (KB+"P2.0", 0x40),
    "7": (KB+"P2.0", 0x80), "8": (KB+"P1.4", 0x80), "9": (KB+"P2.2", 0x01),
    "0": (KB+"P2.3", 0x01),
    "-": (KB+"P2.2", 0x20), "=": (KB+"P2.1", 0x01), "[": (KB+"P2.2", 0x04),
    "]": (KB+"P2.1", 0x02), "\\": (KB+"P2.1", 0x04), ";": (KB+"P2.3", 0x02),
    "'": (KB+"P2.3", 0x04), ",": (KB+"P1.4", 0x10), ".": (KB+"P2.2", 0x10),
    "/": (KB+"P2.3", 0x08), "`": (KB+"P1.6", 0x80),
    " ": (KB+"P2.4", 0x80), "\n": (KB+"P2.1", 0x10), "\t": (KB+"P1.6", 0x20),
}
# Символы, набираемые с Shift (значение → базовая физ. клавиша).
SHIFTED = {
    "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7",
    "*": "8", "(": "9", ")": "0", "_": "-", "+": "=", "{": "[", "}": "]",
    "|": "\\", ":": ";", '"': "'", "<": ",", ">": ".", "?": "/", "~": "`",
}
SHIFT_KEY = (KB+"P1.7", 0x02)   # Left Shift

# Тайминг набора (сек эмуляции).
CADENCE = 0.14   # шаг между символами
HOLD = 0.06      # длительность удержания клавиши


def basename_83(name):
    """Обрезать имя файла до формата 8.3, как в FAT12 корневом каталоге."""
    name = name.upper()
    if '.' in name:
        base, ext = name.rsplit('.', 1)
        base = base[:8].ljust(8, ' ')
        ext = ext[:3].ljust(3, ' ')
        return f"{base}.{ext}"
    return name[:8].ljust(8, ' ') + '.EXE'


def resolve(ch):
    """char → (tag, mask, need_shift). Заглавные буквы = shift+строчная."""
    if ch in PHYS:
        return PHYS[ch][0], PHYS[ch][1], False
    if ch.isalpha() and ch.lower() in PHYS:       # заглавные A-Z
        t, m = PHYS[ch.lower()]
        return t, m, True
    if ch in SHIFTED:
        t, m = PHYS[SHIFTED[ch]]
        return t, m, True
    raise ValueError(f"нет раскладки для символа {ch!r}")


def build_events(steps):
    """steps: список (t0, text) → плоский список событий (t, tag, mask, val)."""
    ev = []
    for t0, text in steps:
        ct = t0
        for ch in text:
            tag, mask, sh = resolve(ch)
            if sh:
                ev.append((ct,          SHIFT_KEY[0], SHIFT_KEY[1], 1))
                ev.append((ct + 0.02,   tag, mask, 1))
                ev.append((ct + HOLD,   tag, mask, 0))
                ev.append((ct + HOLD+0.02, SHIFT_KEY[0], SHIFT_KEY[1], 0))
            else:
                ev.append((ct,          tag, mask, 1))
                ev.append((ct + HOLD,   tag, mask, 0))
            ct += CADENCE
    ev.sort(key=lambda e: e[0])
    return ev


def gen_lua(events, snap_times, timeout):
    ev_lua = ",\n".join(
        f'  {{{t:.4f},"{tag}",{mask},{val}}}' for (t, tag, mask, val) in events
    )
    snaps = ",".join(f"{s:.4f}" for s in snap_times)
    lua = f"""-- СГЕНЕРИРОВАНО mame_interactive.py — не редактировать вручную.
local function now()
  local t = manager.machine.time
  return t.seconds + t.attoseconds / 1e18
end
local start_time = nil
emu.add_machine_reset_notifier(function() start_time = now() end)

local events = {{
{ev_lua}
}}
local snap_times = {{{snaps}}}
local snaps_done = {{}}
local ports, idx = nil, 1
local fcache = {{}}
local function getf(tag, mask)
  local key = tag .. "/" .. mask
  local f = fcache[key]
  if f then return f end
  for _, fld in pairs(ports[tag].fields) do
    if fld.mask == mask then fcache[key] = fld return fld end
  end
  error("нет поля " .. key)
end

emu.register_periodic(function()
  if not start_time then return end
  if not ports then ports = manager.machine.ioport.ports end
  local e = now() - start_time
  while idx <= #events and e >= events[idx][1] do
    local ev = events[idx]
    getf(ev[2], ev[3]):set_value(ev[4])
    idx = idx + 1
  end
  for _, st in ipairs(snap_times) do
    if not snaps_done[st] and e >= st then
      snaps_done[st] = true
      manager.machine.video:snapshot()
      print("[interactive] snapshot t=" .. e)
    end
  end
  if e >= {timeout} then
    print("[interactive] exit t=" .. e)
    manager.machine:exit()
  end
end)
"""
    with open(LUA_SCRIPT, "w") as f:
        f.write(lua)


def check_no_stray_mame():
    # Ловим только запущенный ЭМУЛЯТОР (mame/mame.arm/mame.exe c машиной
    # sprinter), а не всё, где в командной строке встречается «mame»:
    # голый паттерн ложно срабатывал на параллельную сборку MAME из
    # исходников (clang/make с путями .../src/mame/...).
    out = subprocess.run(["pgrep", "-fl", r"mame[^ ]* sprinter( |$)"],
                         capture_output=True, text=True).stdout
    if out.strip():
        print("ОШИБКА: есть запущенные копии MAME — закройте их:")
        print(out)
        sys.exit(1)


def parse_step(s):
    if ":" not in s:
        raise argparse.ArgumentTypeError("шаг должен быть 'T:TEXT'")
    t_str, text = s.split(":", 1)
    text = text.replace("\\n", "\n").replace("\\t", "\t")
    return float(t_str), text


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("exe", nargs="?", help=".exe на дискету A: (авто-запуск)")
    ap.add_argument("--data", nargs="*", default=[], help="доп. файлы на дискету")
    ap.add_argument("--launch-at", type=float, default=8.0,
                    help="сек, когда печатается запуск exe (по умолч. 8)")
    ap.add_argument("--step", action="append", default=[], type=parse_step,
                    help="'T:TEXT' — в момент T сек напечатать TEXT (можно много)")
    ap.add_argument("--snap", default=None, help="секунды для скриншотов, через запятую")
    ap.add_argument("--timeout", type=float, default=None, help="сек эмуляции до выхода")
    args = ap.parse_args()

    check_no_stray_mame()

    # Собираем шаги ввода: авто-запуск exe (печать пути) + пользовательские.
    steps = list(args.step)
    exe_path = None
    if args.exe:
        exe_path = os.path.abspath(args.exe)
        if not os.path.isfile(exe_path):
            print(f"ОШИБКА: не найден {exe_path}")
            sys.exit(1)
        exe_name = basename_83(os.path.basename(exe_path)).upper()
        steps.append((args.launch_at, f"a:\\{exe_name}\n"))
    if not steps:
        print("ОШИБКА: укажите exe для запуска или хотя бы один --step")
        sys.exit(1)

    events = build_events(steps)
    last_ev_t = events[-1][0] if events else max(t for t, _ in steps)

    if args.snap:
        snap_times = [float(x) for x in args.snap.split(",")]
    else:
        # exe: даём программе доработать (снимаем через 4 и 6 с после ввода);
        # чистый ввод: сразу после последнего шага.
        pad = 4.0 if exe_path else 1.5
        snap_times = [last_ev_t + pad, last_ev_t + pad + 2.0]
    timeout = args.timeout if args.timeout is not None else (max(snap_times) + 1.5)

    gen_lua(events, snap_times, timeout)

    # Дискета БЕЗ авто-запускаемого bat → приглашение C:\>.
    disk_files = []
    if exe_path:
        disk_files.append(exe_path)
    disk_files += [os.path.abspath(p) for p in args.data]

    if os.path.exists(MC_IMG):
        shutil.copy(MC_IMG, MC_IMG + ".bak")
    make_disk_cmd = [sys.executable, os.path.join(MAME_DIR, "make_disk.py"), MC_IMG]
    subprocess.run(make_disk_cmd + disk_files, check=True, cwd=MAME_DIR)

    snap_sub = os.path.join(SNAP_DIR, "sprinter")
    if os.path.isdir(snap_sub):
        shutil.rmtree(snap_sub)

    cmd = ["timeout", str(int(timeout) + 8), "./mame.arm", "sprinter"] + COMMON_ARGS + [
        "-snapshot_directory", SNAP_DIR,
        "-autoboot_script", LUA_SCRIPT,
    ]
    print("Ввод по шагам:", [(round(t, 2), repr(txt)) for t, txt in steps])
    print("Скриншоты (сек):", snap_times, " таймаут:", timeout)
    result = subprocess.run(cmd, cwd=MAME_DIR, capture_output=True, text=True)
    for line in result.stdout.splitlines():
        if "[interactive]" in line:
            print(line)
    if result.returncode not in (0, 124):
        print("STDERR:", result.stderr[-2000:])

    if os.path.isdir(snap_sub):
        print("Скриншоты:")
        for s in sorted(os.listdir(snap_sub)):
            print(" ", os.path.join(snap_sub, s))
    else:
        print("Скриншотов не создано.")


if __name__ == "__main__":
    main()
