#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///

import argparse
import json
import os
import re
import signal
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path

FLASH_SIZE = 16 * 1024 * 1024
APP_OFFSET = 0x10000
PARTITIONS_OFFSET = 0x8000
RELEASED_ADC = 4095
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_PATHS = {
    "XTEINK_QEMU": PROJECT_ROOT / "dist/qemu-native/bin/qemu-system-riscv32",
    "XTEINK_BIOS": PROJECT_ROOT / "dist/qemu-native/share/qemu",
    "XTEINK_BOOTLOADER": PROJECT_ROOT / "scripts/assets/esp32c3-bootloader.bin",
    "XTEINK_PARTITIONS": PROJECT_ROOT / "scripts/assets/xteink-partitions.bin",
}
BUTTONS = {
    "left": ("/machine/adc", "adci[2]", 2242, RELEASED_ADC),
    "right": ("/machine/adc", "adci[2]", 5, RELEASED_ADC),
    "bottom-1": ("/machine/adc", "adci[1]", 3512, RELEASED_ADC),
    "bottom-2": ("/machine/adc", "adci[1]", 2694, RELEASED_ADC),
    "bottom-3": ("/machine/adc", "adci[1]", 1493, RELEASED_ADC),
    "bottom-4": ("/machine/adc", "adci[1]", 5, RELEASED_ADC),
    "power": ("/machine/gpio", "input-level[3]", False, True),
}


def default_state_dir():
    return Path(tempfile.gettempdir()) / f"xteink-emu-{os.getuid()}"


def state_dir(args):
    return Path(args.state_dir).expanduser().resolve()


def require_env(name):
    path = Path(os.environ.get(name, DEFAULT_PATHS[name]))
    if not path.exists():
        raise RuntimeError(f"{path} does not exist; run 'make xteink'")
    return path


def pid_from(state):
    try:
        return int((state / "pid").read_text().strip())
    except (FileNotFoundError, ValueError):
        return None


def process_alive(pid):
    if pid is None:
        return False
    try:
        os.kill(pid, 0)
        return True
    except ProcessLookupError:
        return False
    except PermissionError:
        return True


def qmp_command(state, execute, arguments=None, timeout=5):
    with socket.socket(socket.AF_UNIX) as sock:
        sock.settimeout(timeout)
        sock.connect(str(state / "qmp.sock"))
        stream = sock.makefile("rwb")
        json.loads(stream.readline())

        def send(command):
            stream.write(json.dumps(command).encode() + b"\n")
            stream.flush()
            while True:
                response = json.loads(stream.readline())
                if "event" in response:
                    continue
                if "error" in response:
                    raise RuntimeError(response["error"]["desc"])
                return response.get("return")

        send({"execute": "qmp_capabilities"})
        command = {"execute": execute}
        if arguments is not None:
            command["arguments"] = arguments
        return send(command)


def require_running(state):
    pid = pid_from(state)
    if not process_alive(pid):
        raise RuntimeError(f"xteink-emu is not running in {state}")
    return pid


def build_flash(firmware, output):
    app = firmware.read_bytes()
    if len(app) == FLASH_SIZE:
        output.write_bytes(app)
        return
    if not app or app[0] != 0xE9:
        raise RuntimeError(f"{firmware} is not an ESP32 app image")
    if APP_OFFSET + len(app) > FLASH_SIZE:
        raise RuntimeError(f"{firmware} does not fit in a 16 MiB flash image")

    flash = bytearray(b"\xff" * FLASH_SIZE)
    bootloader = require_env("XTEINK_BOOTLOADER").read_bytes()
    partitions = require_env("XTEINK_PARTITIONS").read_bytes()
    flash[: len(bootloader)] = bootloader
    flash[PARTITIONS_OFFSET : PARTITIONS_OFFSET + len(partitions)] = partitions
    flash[APP_OFFSET : APP_OFFSET + len(app)] = app
    output.write_bytes(flash)


def start(args):
    state = state_dir(args)
    old_pid = pid_from(state)
    if process_alive(old_pid):
        raise RuntimeError(f"xteink-emu is already running as PID {old_pid} in {state}")

    firmware = Path(args.firmware).expanduser().resolve()
    sdcard = Path(args.sdcard).expanduser().resolve()
    if not firmware.is_file():
        raise RuntimeError(f"firmware not found: {firmware}")
    if not sdcard.is_file():
        raise RuntimeError(f"SD card image not found: {sdcard}")

    state.mkdir(parents=True, exist_ok=True, mode=0o700)
    for name in ("pid", "qmp.sock", "serial.log", "qemu.log", "wait.offset"):
        (state / name).unlink(missing_ok=True)
    (state / "serial.log").touch()
    (state / "wait.offset").write_text("0\n")
    flash = state / "flash.bin"
    build_flash(firmware, flash)
    efuse = bytearray(1024)
    efuse[0x18:0x1E] = bytes.fromhex("563412005452")
    efuse[0x26] = 0x0C
    (state / "efuse.bin").write_bytes(efuse)

    command = [
        str(require_env("XTEINK_QEMU")),
        "-machine", "xteink,variant=x3",
        "-accel", "tcg",
        "-L", str(require_env("XTEINK_BIOS")),
        "-display", "none",
        "-serial", f"file:{state / 'serial.log'}",
        "-monitor", "none",
        "-qmp", f"unix:{state / 'qmp.sock'},server=on,wait=off",
        "-nic", "user,model=esp32c3_wifi,mac=52:54:00:12:34:56,net=192.168.4.0/24",
        "-drive", f"file={flash},if=mtd,format=raw",
        "-drive", f"file={state / 'efuse.bin'},if=none,format=raw,id=efuse",
        "-global", "driver=nvram.esp32c3.efuse,property=drive,value=efuse",
        "-drive", f"file={sdcard},if=sd,format=raw",
    ]
    qemu_log = (state / "qemu.log").open("ab")
    process = subprocess.Popen(
        command,
        stdin=subprocess.DEVNULL,
        stdout=qemu_log,
        stderr=subprocess.STDOUT,
        start_new_session=True,
    )
    qemu_log.close()
    (state / "pid").write_text(f"{process.pid}\n")

    deadline = time.monotonic() + 10
    while time.monotonic() < deadline:
        if process.poll() is not None:
            message = (state / "qemu.log").read_text(errors="replace")
            raise RuntimeError(f"QEMU exited during startup\n{message}")
        try:
            qmp_command(state, "query-status")
            if args.power_hold_ms:
                path, prop, pressed, released = BUTTONS["power"]
                qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed})
                try:
                    time.sleep(args.power_hold_ms / 1000)
                finally:
                    qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released})
            print(f"started PID {process.pid}")
            print(f"state: {state}")
            print(f"serial log: {state / 'serial.log'}")
            return
        except (FileNotFoundError, ConnectionRefusedError, socket.timeout):
            time.sleep(0.05)
    process.terminate()
    raise RuntimeError("QEMU did not open its QMP socket")


def stop(args):
    state = state_dir(args)
    pid = require_running(state)
    try:
        qmp_command(state, "quit")
    except (ConnectionError, FileNotFoundError):
        os.kill(pid, signal.SIGTERM)

    deadline = time.monotonic() + 5
    while process_alive(pid) and time.monotonic() < deadline:
        time.sleep(0.05)
    if process_alive(pid):
        os.kill(pid, signal.SIGKILL)
    (state / "pid").unlink(missing_ok=True)
    (state / "qmp.sock").unlink(missing_ok=True)
    print(f"stopped PID {pid}")


def wait_for_log(args):
    state = state_dir(args)
    require_running(state)
    log_path = state / "serial.log"
    offset_path = state / "wait.offset"
    try:
        pattern = re.compile(args.match)
    except re.error as error:
        raise RuntimeError(f"invalid regex: {error}") from error
    try:
        offset = int(offset_path.read_text().strip())
    except (FileNotFoundError, ValueError):
        offset = 0

    deadline = time.monotonic() + args.timeout
    with log_path.open("r", errors="replace") as log:
        log.seek(offset)
        while True:
            line = log.readline()
            if line:
                if pattern.search(line):
                    offset_path.write_text(f"{log.tell()}\n")
                    print(line, end="" if line.endswith("\n") else "\n")
                    return
                continue
            if time.monotonic() >= deadline:
                raise RuntimeError(f"timed out after {args.timeout:g}s waiting for /{args.match}/")
            if not process_alive(pid_from(state)):
                raise RuntimeError("QEMU exited while waiting for serial output")
            time.sleep(0.05)


def button(args):
    state = state_dir(args)
    require_running(state)
    path, prop, pressed, released = BUTTONS[args.name]
    if args.down:
        qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed})
        return
    if args.up:
        qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released})
        return
    qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed})
    try:
        time.sleep(args.hold_ms / 1000)
    finally:
        qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released})


def screenshot(args):
    state = state_dir(args)
    require_running(state)
    output = Path(args.output).expanduser().resolve()
    output.parent.mkdir(parents=True, exist_ok=True)
    qmp_command(state, "screendump", {"filename": str(output), "format": "png"}, timeout=30)
    print(output)


def parser():
    root = argparse.ArgumentParser(prog="xteink-emu")
    commands = root.add_subparsers(dest="command", required=True)

    def add_state(command):
        command.add_argument("--state-dir", default=default_state_dir())

    command = commands.add_parser("start")
    add_state(command)
    command.add_argument("--firmware", required=True)
    command.add_argument("--sdcard", required=True)
    command.add_argument("--power-hold-ms", type=int, default=2000)
    command.set_defaults(handler=start)

    command = commands.add_parser("stop")
    add_state(command)
    command.set_defaults(handler=stop)

    command = commands.add_parser("wait")
    add_state(command)
    command.add_argument("--timeout", type=float, required=True, help="seconds")
    command.add_argument("--match", required=True, help="regular expression")
    command.set_defaults(handler=wait_for_log)

    command = commands.add_parser("button")
    add_state(command)
    command.add_argument("name", choices=BUTTONS)
    command.add_argument("--hold-ms", type=int, default=100)
    button_mode = command.add_mutually_exclusive_group()
    button_mode.add_argument("--down", action="store_true", help="press and hold")
    button_mode.add_argument("--up", action="store_true", help="release")
    command.set_defaults(handler=button)

    command = commands.add_parser("screenshot")
    add_state(command)
    command.add_argument("--output", required=True)
    command.set_defaults(handler=screenshot)
    return root


def main():
    args = parser().parse_args()
    if getattr(args, "timeout", 0) < 0:
        raise RuntimeError("--timeout must not be negative")
    if getattr(args, "hold_ms", 0) < 0:
        raise RuntimeError("--hold-ms must not be negative")
    if getattr(args, "power_hold_ms", 0) < 0:
        raise RuntimeError("--power-hold-ms must not be negative")
    args.handler(args)


if __name__ == "__main__":
    try:
        main()
    except (RuntimeError, OSError) as error:
        print(f"xteink-emu: {error}", file=sys.stderr)
        sys.exit(1)
