#!/usr/bin/env python3
# viceHarness.py - shared plumbing for driving x64sc with a SwiftLink cartridge on a private Xvfb
# display.  Nothing here is Modem Wars specific except the "break at $0800 while warping through the
# loader" trick, which every earlier session in this project has used.
#
# The machine is shared, so every resource this module grabs is picked at run time: a random free X
# display, a kernel assigned TCP port for the remote monitor, another for the serial line, and a
# private VICE config file so the user's ~/.config/vice/vicerc is neither read nor written.
import os
import random
import re
import socket
import subprocess
import sys
import time

SCRATCH = os.environ.get("SWIFTLINK_SCRATCH", "/tmp/claude-1000/-home-scott-claude-modemwars/"
                         "d69befd0-05e0-41b3-8bba-17d73f8cf6d9/scratchpad")

# Which page the SwiftLink is strapped to for this run.  A real cartridge has a jumper for $DE00 or
# $DF00 and the driver probes for it, so every test in this directory has to be runnable against
# either.  $DE00 stays the default, so nothing that does not set the variable changes behaviour.
#
#   SWIFTLINK_ACIA_BASE=0xDF00 python3 testBoot.py ...
ACIA_BASE = int(os.environ.get("SWIFTLINK_ACIA_BASE", "0xDE00"), 16)


def pickFreePort():
    s = socket.socket()
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]
    s.close()
    return port


def pickFreeDisplay():
    # Other people are on this box; do not assume a range is ours.
    for _ in range(200):
        n = random.randint(200, 900)
        if not os.path.exists(f"/tmp/.X11-unix/X{n}"):
            return n
    raise SystemExit("no free X display")


class ViceSession:
    def __init__(self, disk, logPath, aciaArgs, extraArgs=(), label="vice", warp=True):
        # warp=True races through the loader and the game; warp=False still warps the autostart
        # phase but then runs at true C64 speed, which is what a real-time measurement needs.
        self.warp = warp
        self.label = label
        self.display = f":{pickFreeDisplay()}"
        self.port = pickFreePort()
        self.logPath = logPath
        self.configPath = os.path.join(SCRATCH, f"vicerc.{label}.{os.getpid()}")
        self.env = dict(os.environ, DISPLAY=self.display)
        self.sock = None
        self.winId = None
        self.xvfb = subprocess.Popen(["Xvfb", self.display, "-screen", "0", "800x600x24",
                                      "-nolisten", "tcp"],
                                     stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        time.sleep(2)
        cmd = ["x64sc",
               "-config", self.configPath, "+saveres",
               "-remotemonitor", "-remotemonitoraddress", f"127.0.0.1:{self.port}",
               "-drive8truedrive", "-drive8type", "1541", "-sounddev", "dummy", "+sound",
               ("-warp" if self.warp else "+warp"), "-jamaction", "2", "-autostart-warp", "-model", "ntsc",
               "-joydev2", "1", "-chdir", SCRATCH] + list(aciaArgs) + list(extraArgs) + \
              ["-autostart", disk]
        self.cmd = cmd
        self.vice = subprocess.Popen(cmd, env=self.env,
                                     stdout=open(logPath, "w"), stderr=subprocess.STDOUT)

    def connect(self, timeout=60):
        deadline = time.time() + timeout
        while time.time() < deadline:
            try:
                self.sock = socket.create_connection(("127.0.0.1", self.port), timeout=1)
                break
            except OSError:
                time.sleep(0.3)
        if self.sock is None:
            raise SystemExit(f"{self.label}: monitor did not open")
        self.recv(10)
        return self

    def recv(self, timeout):
        self.sock.settimeout(0.5)
        buf = b""
        deadline = time.time() + timeout
        while time.time() < deadline:
            try:
                chunk = self.sock.recv(65536)
                if not chunk:
                    break
                buf += chunk
                tail = buf[-40:]
                if b") " in tail and (b"(C:$" in tail or b"(8:$" in tail):
                    break
            except socket.timeout:
                pass
        return buf.decode("latin-1")

    def mon(self, cmd, timeout=15):
        self.sock.sendall((cmd + "\n").encode())
        out = self.recv(timeout)
        print(f"[{self.label}] >>> {cmd}\n{out}", flush=True)
        return out

    def enterMonitor(self, timeout=15):
        # Any line of input while the emulator is running drops back into the monitor.
        self.sock.sendall(b"\n")
        return self.recv(timeout)

    def bootPastLoader(self, waitSecs=400):
        # The loader runs for a couple of minutes of warp and ends with JMP $0800, so a breakpoint
        # there is the reliable "the game is up" signal.  When two emulators are started at once the
        # second one can be past $0800 before its breakpoint is set, and then the break would never
        # fire; the monitor's own prompt gives away where the CPU is, so check that first.
        out = self.mon("break 0800")
        match = re.search(r"\(C:\$([0-9a-f]{4})\)", out)
        pc = int(match.group(1), 16) if match else 0
        if 0x0800 <= pc < 0xC000:
            print(f"[{self.label}] already past the loader (PC ${pc:04X})", flush=True)
        else:
            self.sock.sendall(b"x\n")
            self.recv(5)
            print(f"[{self.label}] loading under warp ...", flush=True)
            out = self.recv(waitSecs)
            print(f"[{self.label}] {out[-200:]}", flush=True)
        self.mon("del")
        self.setWarp(0)
        self.sock.sendall(b"x\n")
        self.recv(3)
        return out

    def setWarp(self, value):
        # The monitor's resource syntax has changed between VICE releases; try the spellings until
        # one is not rejected, so the game runs at real speed once the loader is done.
        for name in ("WarpMode", "Warp", "WarpModeEnabled"):
            out = self.mon(f'resourceset "{name}" "{value}"')
            if "ERROR" not in out and "Unknown resource" not in out:
                return name
        return None

    def findWindow(self, timeout=20):
        deadline = time.time() + timeout
        while time.time() < deadline:
            out = subprocess.run(["xdotool", "search", "--name", "VICE"], env=self.env,
                                 capture_output=True, text=True).stdout.split()
            if out:
                self.winId = out[-1]
                return self.winId
            time.sleep(0.5)
        raise SystemExit(f"{self.label}: no VICE window on {self.display}")

    def focus(self):
        # There is no window manager on the private display, so windowactivate cannot work; plain
        # XSetInputFocus does, and the keys then have to go through XTEST (xdotool without --window).
        # Synthetic XSendEvent keys - what "xdotool key --window" sends - are ignored by VICE's GTK
        # front end, which is why earlier attempts at driving the menu did nothing.
        subprocess.run(["xdotool", "windowfocus", "--sync", self.winId], env=self.env, check=False)
        time.sleep(0.5)

    def key(self, keysym, times=1, gap=0.25):
        for _ in range(times):
            subprocess.run(["xdotool", "key", "--clearmodifiers", keysym], env=self.env, check=False)
            time.sleep(gap)

    def hold(self, keysym, ms=300):
        subprocess.run(["xdotool", "keydown", keysym], env=self.env, check=False)
        time.sleep(ms / 1000.0)
        subprocess.run(["xdotool", "keyup", keysym], env=self.env, check=False)
        time.sleep(0.3)

    def shot(self, path):
        self.enterMonitor()
        self.mon(f'screenshot "{path}" 2')
        self.sock.sendall(b"x\n")
        self.recv(5)
        print(f"[{self.label}] shot {path}", flush=True)

    def close(self):
        try:
            if self.sock:
                self.sock.sendall(b"quit\n")
                time.sleep(1)
        except OSError:
            pass
        for proc in (self.vice, self.xvfb):
            try:
                proc.kill()
                proc.wait(timeout=10)
            except Exception:
                pass
        try:
            os.remove(self.configPath)
        except OSError:
            pass


def readByte(session, addr, tries=4):
    # One byte out of the monitor's "m" output.  The remote monitor interleaves the reply to one
    # command with the prompt of the next, so the answer can arrive split across two reads; match on
    # the address label rather than on line position, and ask again when it does not turn up.
    for _ in range(tries):
        out = session.mon(f"m {addr:04x} {addr:04x}")
        m = re.search(r"C:%04x\s+([0-9a-f]{2})" % addr, out)
        if m:
            return int(m.group(1), 16)
    return None


def aciaArgs(base=None, rsDevAddress=None, baud=2400, mode=1, irq=1, dev=0):
    base = base if base is not None else f"0x{ACIA_BASE:04X}"
    args = ["-acia1", "-acia1mode", str(mode), "-acia1base", base, "-acia1irq", str(irq),
            "-myaciadev", str(dev)]
    if rsDevAddress is not None:
        args += [f"-rsdev{dev+1}", rsDevAddress, f"-rsdev{dev+1}baud", str(baud)]
    return args
