#!/usr/bin/env python3
# testHotkeys.py - exercise the module's Commodore-key hot keys, which no earlier test could reach.
#
#   python3 testHotkeys.py <disk.d64> [--warp]
#
# The one thing that has to be worked out before any of this is possible is which *host* key VICE
# turns into the C64's Commodore key, because the game does not use the KERNAL: scanKeyboard $0DB7
# reads the CIA1 matrix itself, once per raster IRQ, and reports the Commodore key only when matrix
# row 7 column 5 is down.  So the question is purely "which X keysym does VICE's active keymap put at
# 7/5", and the script answers it by experiment: hold each candidate together with C and watch
# carrierOverrideFlags ($E04A) bit 6, which C= + C toggles and nothing else in the module touches.
#
# Because the matrix is sampled once a frame, every key here is pressed with xdotool keydown, held
# for the better part of a second, and only then released.  A tap ("xdotool key") is what earlier
# attempts used and it is not reliably visible to a once-a-frame scanner.
#
# What is checked, in order:
#   1. which host key is the Commodore key (all candidates recorded, including the ones that fail);
#   2. C= + C toggles $E04A bit 6 on, and off again;
#   3. with the link open, what the override does to $E03B/$E03C/$E03D;
#   3a. that the module really paints the border while C= is held, by breaking on its own store;
#   4. what the override does when the ACIA's DCD says "no carrier" - forced into aciaStatusSave
#      ($E5BC) at a breakpoint, because VICE never raises that bit by itself (see the report);
#   5. a baud hot key (C= + 2, then C= + 3 to put it back) changing the ACIA control register $DE03.
#
# Timing: the loader is warped through with the monitor's own "warp on" (VICE 3.7.1 has no WarpMode
# resource, and -warp on the command line does not survive the autostart), and warp is switched off
# again before the first key, so every hot key here is pressed at true C64 speed.  Pass --warp to
# stay in warp for the test phase as well.
import os
import subprocess
import sys
import time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from viceHarness import ViceSession, aciaArgs, readByte, SCRATCH
from testModemSelect import SerialSink

SHOTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots")

# Everything worth watching while a hot key is pressed.  $DE01/$DE03 are read with the monitor's
# side effects turned off, so looking at the status register does not clear an IRQ the driver has
# not seen yet.
STATE_ADDRS = [
    ("carrierOverrideFlags", 0xE04A),
    ("isLinkActive",         0xE03B),
    ("linkStatus",           0xE03C),
    ("linkStatusSample",     0xE03D),
    ("connectionPhase",      0xE040),
    ("lastHotkeyCode",       0xE046),
    ("aciaStatusSave",       0xE5BC),
    ("aciaCommandShadow",    0xE5BE),
    ("baudIndex",            0xE055),
    ("liveControlByte",      0xE056),
    ("aciaStatusReg",        0xDE01),
    ("aciaControlReg",       0xDE03),
    ("inputLockoutTimer",    0x0B7D),
    ("borderColour",         0xD020),
]

# Left Control and Tab are the two candidates the C64 keymaps actually use for the Commodore key
# (gtk3_pos.vkm puts it on Control_L, gtk3_sym.vkm on Tab); Super_L is here as a control - a key no
# C64 keymap claims - so that the sweep shows what "the letter arrived but no hot key fired" looks
# like.  Alt_L was in this list and has been removed: holding Alt opens the GTK menu bar, which
# takes a keyboard grab, and every key sent after that went to the menu instead of the emulator.
CANDIDATES = ["Control_L", "Tab", "Super_L"]


def hx(value):
    return "??" if value is None else f"${value:02X}"


def pause(session):
    session.enterMonitor()


def resume(session):
    session.sock.sendall(b"x\n")
    session.recv(2)


def keyDown(session, keysym):
    subprocess.run(["xdotool", "keydown", keysym], env=session.env, check=False)


def keyUp(session, keysym):
    subprocess.run(["xdotool", "keyup", keysym], env=session.env, check=False)


def holdCombo(session, modifier, key, ms=900):
    # The modifier goes down first and comes up last, exactly as a player would do it.  ms is real
    # time, so at true C64 speed 900 ms is about 54 frames of the game's own keyboard scan.
    keyDown(session, modifier)
    time.sleep(0.2)
    keyDown(session, key)
    time.sleep(ms / 1000.0)
    keyUp(session, key)
    time.sleep(0.1)
    keyUp(session, modifier)
    time.sleep(0.4)


def setWarpMode(session, on):
    # VICE 3.7.1 has no WarpMode *resource* - testReport.md records all three spellings being
    # rejected - but its monitor does have a "warp" command, which is how this run gets the loader
    # over with quickly and still does the key work at true C64 speed.
    pause(session)
    session.mon("warp " + ("on" if on else "off"))
    out = session.mon("warp")
    resume(session)
    return out


def snapshot(session, label, addrs=STATE_ADDRS):
    pause(session)
    state = {}
    for name, addr in addrs:
        state[name] = readByte(session, addr)
    resume(session)
    text = "  ".join(f"{n}={hx(state[n])}" for n, _ in addrs)
    print(f"[state] {label}: {text}", flush=True)
    return state


def pickModemOpponent(session):
    # Options menu: the highlighted row is $91D5 and row 0 is COMPETE WITH MODEM OPPONENT.  KP_8 is
    # joystick up, KP_0 is fire (VICE's numpad joystick uses KP_0, not KP_5).
    for _ in range(5):
        pause(session)
        row = readByte(session, 0x91D5)
        resume(session)
        print(f"menu row = {row}", flush=True)
        if row == 0:
            break
        session.hold("KP_8", 250)
        time.sleep(0.8)
    session.hold("KP_0", 300)
    time.sleep(2)


def waitForModule(session, timeout=240):
    # $E012 is the module's keyboard hook; initCommModule patches its operand to scanKeyboard $0DB7
    # once the modem module has been loaded from track 18 s7 + track 34.  Until that has happened
    # there is no hot-key layer to talk to.
    deadline = time.time() + timeout
    while time.time() < deadline:
        pause(session)
        opcode = readByte(session, 0xE012)
        lo = readByte(session, 0xE013)
        hi = readByte(session, 0xE014)
        entry = readByte(session, 0xE015)
        resume(session)
        if opcode == 0x4C and lo == 0xB7 and hi == 0x0D:
            print(f"modem module resident: $E012 = JMP $0DB7 (scanKeyboard), $E015 opcode = "
                  f"{hx(entry)}", flush=True)
            return True
        time.sleep(3)
    print("modem module never became resident", flush=True)
    return False


def checkKeyDelivery(session):
    # Prove that key events are still reaching the emulated matrix: tap C on its own and watch
    # lastHotkeyCode ($E046), which the module writes on every new key code it sees.  $C3 means the
    # key got in; $FF means nothing arrived and every result after this point would be meaningless.
    keyDown(session, "c")
    time.sleep(0.6)
    pause(session)
    code = readByte(session, 0xE046)
    resume(session)
    keyUp(session, "c")
    time.sleep(0.4)
    print(f"[keys] plain C: lastHotkeyCode = {hx(code)} "
          f"({'reaching the matrix' if code == 0xC3 else 'NOT reaching the matrix'})", flush=True)
    return code == 0xC3


def findCommodoreKey(session):
    # Hold candidate + C and see whether the module's C= + C hot key fired.  Two things are read
    # while the keys are still down: $E04A (the flag the hot key toggles) and $E046 lastHotkeyCode,
    # which is the code the driver actually saw - $C3 means "C= + C reached the hot-key layer".
    results = []
    winner = None
    for candidate in CANDIDATES:
        pause(session)
        before = readByte(session, 0xE04A)
        resume(session)
        keyDown(session, candidate)
        time.sleep(0.2)
        keyDown(session, "c")
        time.sleep(0.9)
        pause(session)
        held = readByte(session, 0xE04A)
        code = readByte(session, 0xE046)
        border = readByte(session, 0xD020)
        resume(session)
        keyUp(session, "c")
        time.sleep(0.1)
        keyUp(session, candidate)
        # A candidate the emulator does not use may still mean something to the GTK front end (Alt
        # opens the menu bar), and a popped-up menu would swallow every key from here on, so take
        # the input focus back before looking at the result.
        session.focus()
        time.sleep(0.5)
        pause(session)
        after = readByte(session, 0xE04A)
        resume(session)
        ok = (before is not None and after is not None and (before ^ after) == 0x40)
        results.append({"candidate": candidate, "before": before, "held": held, "after": after,
                        "lastHotkeyCode": code, "border": border, "toggled": ok})
        print(f"[candidate] {candidate:10s} $E04A {hx(before)} -> {hx(after)}  "
              f"(held {hx(held)})  lastHotkeyCode={hx(code)}  border={hx(border)}  "
              f"{'TOGGLED' if ok else 'no effect'}", flush=True)
        if ok and winner is None:
            winner = candidate
    return winner, results


def answerModemPrompts(session, answerKey="a"):
    # "GET OPPONENT ON PHONE AND PRESS A OR O", then "PRESS SPACE, WAIT, HANGUP PHONE".  A key that
    # arrives before the prompt is simply lost, so offer both until aciaCommandShadow ($E5BE) stops
    # reading back zero, which only happens once configureUserPortLines has programmed the 6551.
    for attempt in range(20):
        session.hold(answerKey, 300)
        session.hold("space", 300)
        time.sleep(1.5)
        pause(session)
        shadow = readByte(session, 0xE5BE)
        active = readByte(session, 0xE03B)
        resume(session)
        if shadow:
            print(f"link opened after {attempt + 1} attempt(s): aciaCommandShadow = {hx(shadow)}, "
                  f"isLinkActive = {hx(active)}", flush=True)
            return True
    print("link never opened", flush=True)
    return False


def toggleOverride(session, commodoreKey, label):
    holdCombo(session, commodoreKey, "c")
    return snapshot(session, label)


def borderCheck(session):
    # The other half of the Commodore key's job: while it is held the module paints the border from
    # linkStatusBorderTable.  Reading $D020 afterwards is not proof, because the game paints the
    # border too; a breakpoint on the module's own store at $E30C is.
    # Arm the checkpoint *before* the key goes down: entering the monitor appears to clear VICE's
    # emulated key matrix, so a breakpoint set while the key is held is armed on a machine that has
    # already forgotten about it.  This ordering never stops the machine with a key down.
    pause(session)
    session.mon("break e30c")
    resume(session)
    keyDown(session, "Tab")
    hit = session.recv(10)
    print(f"[border] break on the module's STA VIC_BORDER at $E30C while C= is held:\n{hit[-300:]}",
          flush=True)
    border = readByte(session, 0xD020)
    phase = readByte(session, 0xE040)
    status = readByte(session, 0xE03C)
    session.mon("del")
    resume(session)
    keyUp(session, "Tab")
    time.sleep(0.3)
    pause(session)
    after = readByte(session, 0xD020)
    resume(session)
    print(f"[border] $D020 at the module's store = {hx(border)}, after the frame finished = "
          f"{hx(after)}, connectionPhase = {hx(phase)}, linkStatus = {hx(status)}", flush=True)
    return {"hit": hit, "borderAtStore": border, "borderAfter": after, "connectionPhase": phase,
            "linkStatus": status}


def forcedDcdTrace(session, label):
    # VICE's emulated 6551 never sets status bit 6, which is where a SwiftLink presents DCD (CMD
    # swapped DCD and DSR at the chip), so the "no carrier" case cannot be produced by the emulator.
    # It can be produced where the driver actually reads it: aciaStatusSave $E5BC, the shadow the NMI
    # keeps and the only DCD the carrier sampler ever sees.  Break at $E5E9 - the instruction that
    # loads carrierOverrideFlags, one instruction before the DCD test - poke the shadow, and single
    # step through the sampler to watch which way it goes.
    pause(session)
    session.mon("break e5e9")
    resume(session)
    hit = session.recv(30)
    print(f"[trace {label}] breakpoint hit:\n{hit[-400:]}", flush=True)
    session.mon("> e5bc 40")
    check = readByte(session, 0xE5BC)
    print(f"[trace {label}] aciaStatusSave forced to {hx(check)} (bit 6 set = DCD says NO "
          f"carrier)", flush=True)
    # Sixteen single steps: the longest way through the sampler (override off, DCD says no carrier,
    # quiet line) is fourteen instructions from $E5E9 to the STA at $E604 that publishes the sample,
    # so sixteen always lands past the store and a few bytes into startNextTxChar.
    steps = []
    for _ in range(16):
        steps.append(session.mon("z"))
    sample = readByte(session, 0xE03D)
    status = readByte(session, 0xE03C)
    flags = readByte(session, 0xE04A)
    shadow = readByte(session, 0xE5BC)
    print(f"[trace {label}] after the sampler ran: carrierOverrideFlags={hx(flags)} "
          f"aciaStatusSave={hx(shadow)} linkStatusSample={hx(sample)} linkStatus={hx(status)}",
          flush=True)
    session.mon("del")
    resume(session)
    return {"trace": "".join(steps), "linkStatusSample": sample, "linkStatus": status,
            "carrierOverrideFlags": flags, "aciaStatusSave": shadow}


def main():
    disk = os.path.abspath(sys.argv[1])
    warp = "--warp" in sys.argv[2:]
    os.makedirs(SHOTS, exist_ok=True)
    sink = SerialSink()
    print(f"serial sink on 127.0.0.1:{sink.port}; keep warp for the test phase = {warp}",
          flush=True)
    args = aciaArgs(rsDevAddress=f"127.0.0.1:{sink.port}", baud=2400)
    session = ViceSession(disk, f"{SCRATCH}/hotkeys.vice.log", args, label="hk", warp=True)
    findings = {}
    try:
        session.connect()
        session.mon('sidefx off')
        session.mon('resourceget "KeymapIndex"')
        session.mon('resourceget "KeymapSymFile"')
        session.mon('resourceget "KeymapPosFile"')
        session.mon('resourceget "KeymapUserSymFile"')
        # -autostart-warp turns warp off again when the autostart is done, which leaves the game's
        # own fast loader crawling at 1x, so ask for warp explicitly before waiting for $0800.
        setWarpMode(session, True)
        session.bootPastLoader(waitSecs=900)
        # The loader is done; from here on the game runs at true C64 speed, so a key held for 900 ms
        # really is held for about 54 of the game's once-a-frame keyboard scans.
        if not warp:
            setWarpMode(session, False)
        session.findWindow()
        session.focus()
        time.sleep(5)
        session.shot(f"{SHOTS}/hotkeys01menu.png")

        pickModemOpponent(session)
        if not waitForModule(session):
            raise SystemExit("the modem module never loaded - nothing to test")
        session.shot(f"{SHOTS}/hotkeys02modemPrompt.png")

        # 1. which host key is the Commodore key
        findings["keyDeliveryBefore"] = checkKeyDelivery(session)
        commodoreKey, findings["candidates"] = findCommodoreKey(session)
        findings["keyDeliveryAfter"] = checkKeyDelivery(session)
        print(f"=== Commodore key = {commodoreKey} ===", flush=True)
        if commodoreKey is None:
            raise SystemExit("no candidate host key reached the module as the Commodore key")
        findings["commodoreKey"] = commodoreKey

        # The sweep leaves the override wherever its own presses left it; put it back to 0 so the
        # on/off demonstration below starts from a known state.
        pause(session)
        flags = readByte(session, 0xE04A)
        resume(session)
        if flags:
            holdCombo(session, commodoreKey, "c")

        # 2. on and off again, before the link is even open (the hot-key layer runs regardless)
        findings["preLinkOff"] = snapshot(session, "before any C= + C")
        findings["preLinkOn"] = toggleOverride(session, commodoreKey, "after C= + C #1")
        findings["preLinkOff2"] = toggleOverride(session, commodoreKey, "after C= + C #2")

        # The border indicator is the other half of the C= key's job: hold it alone and the module
        # paints the border from linkStatusBorderTable.  Only meaningful once the link is up.
        # 3. open the link and look at what the override does to the published link state
        answerModemPrompts(session)
        time.sleep(3)
        session.shot(f"{SHOTS}/hotkeys03linkOpen.png")
        findings["linkOverrideOff"] = snapshot(session, "link open, override OFF")

        # 3a. the border indicator, while the link is up and the game is still on the link screen
        findings["borderHeld"] = borderCheck(session)
        session.shot(f"{SHOTS}/hotkeys05borderHeld.png")
        findings["overrideOnImmediate"] = toggleOverride(session, commodoreKey,
                                                         "link open, override ON (immediately)")
        time.sleep(6)
        findings["overrideOnDebounced"] = snapshot(session, "link open, override ON (after 6 s)")
        session.shot(f"{SHOTS}/hotkeys04overrideOn.png")

        # 4. the case a real null-modem cable with no DCD wire depends on
        findings["forcedDcdOverrideOn"] = forcedDcdTrace(session, "override ON")
        findings["overrideOffAgain"] = toggleOverride(session, commodoreKey,
                                                      "link open, override OFF again")
        time.sleep(6)
        findings["overrideOffDebounced"] = snapshot(session, "link open, override OFF (after 6 s)")
        findings["forcedDcdOverrideOff"] = forcedDcdTrace(session, "override OFF")

        # 5. a baud hot key
        pause(session)
        session.mon("m e6fd e70f")
        resume(session)
        findings["baudBefore"] = snapshot(session, "before C= + 2")
        holdCombo(session, commodoreKey, "2")
        findings["baud2400"] = snapshot(session, "after C= + 2 (expect $DE03 = $18, 2400 baud)")
        holdCombo(session, commodoreKey, "3")
        findings["baud300"] = snapshot(session, "after C= + 3 (expect $DE03 = $15, 300 baud)")

        findings["final"] = snapshot(session, "final")
    finally:
        session.close()
        time.sleep(1)
        sink.stop()
        print(f"serial sink connected={sink.connected} bytes={len(sink.data)}", flush=True)
        print("first serial bytes:", sink.data[:64].hex(" "), flush=True)
        import json
        path = f"{SCRATCH}/hotkeyFindings.json"
        with open(path, "w") as handle:
            json.dump(findings, handle, indent=1, default=str)
        print(f"findings written to {path}", flush=True)


if __name__ == "__main__":
    main()
