#!/usr/bin/env python3
# test1200AutoBaud.py - the two things about 1200 baud that two linked machines cannot show.
#
#   python3 test1200AutoBaud.py <disk.d64> [tag]
#
# 1. The auto-baud path.  One machine, its serial line wired to a fake Hayes modem written in this
#    script rather than to a peer.  The fake modem answers the module's AT string with a verbose
#    "CONNECT 1200" result code, which is exactly what serviceModemInput $E799 sniffs for: the CR
#    that ends the line arrives with lastModemChar $EB02 = '0', so $E7B0 loads X = 3 and $E7B2 calls
#    loadBaudParameters.  What the ACIA control register holds afterwards is the whole question.
#
# 2. The same entry point driven directly.  With the emulator stopped, the ACIA is forced back to
#    300 baud, PC is put on $E7B2 and X is loaded by hand, and the monitor steps over the JSR.  Doing
#    it for X = 0, 3 and 6 shows the three-byte stride of baudEntryTable $E6FD behaving, not just the
#    one index the sniffer uses.
#
# Nothing here trusts a comment: every number below is read back out of the running machine.
import os
import re
import socket
import subprocess
import sys
import threading
import time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from viceHarness import ViceSession, aciaArgs, pickFreePort, SCRATCH
from testTwoMachines import pickModemOpponent
from testRealtime import (command, flush, pauseAll, resumeAll, readRange, snapshot, showState,
                          decodeControl, SHOTS, LOGS)


class FakeModem:
    # A TCP listener that behaves like a Hayes modem far enough to exercise the result-code sniffer:
    # it watches for the CR that ends the module's AT string and answers with a verbose CONNECT 1200.
    # VICE's -rsdev1 makes the emulator the client, so this end listens.
    def __init__(self, reply=b"\r\nCONNECT 1200\r"):
        self.port = pickFreePort()
        self.reply = reply
        self.listener = socket.socket()
        self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.listener.bind(("127.0.0.1", self.port))
        self.listener.listen(2)
        self.rx = bytearray()
        self.events = []
        self.sent = 0
        self.armed = True
        self.running = True
        self.t0 = time.time()
        threading.Thread(target=self.serve, daemon=True).start()

    def note(self, text):
        self.events.append((round(time.time() - self.t0, 3), text))
        print(f"[modem +{time.time() - self.t0:7.3f}s] {text}", flush=True)

    def serve(self):
        self.listener.settimeout(0.5)
        conn = None
        while self.running:
            if conn is None:
                try:
                    conn, _ = self.listener.accept()
                    conn.settimeout(0.2)
                    self.note("the emulator opened the serial line")
                except socket.timeout:
                    continue
                except OSError:
                    return
            try:
                chunk = conn.recv(4096)
            except socket.timeout:
                continue
            except OSError:
                chunk = b""
            if not chunk:
                continue
            self.rx += chunk
            self.note(f"from the C64: {bytes(chunk)!r}")
            # The AT string is CR "ATQ0V1X1A" CR (answer) or ... "D" CR (originate).  Answer the CR
            # that ends it, once, as fast as a real modem never could - the module leaves terminal
            # mode 121 frames after that CR, so the reply has about two emulated seconds to land.
            if self.armed and chunk.rstrip(b"\x00").endswith(b"\r") and b"AT" in bytes(self.rx):
                self.armed = False
                try:
                    conn.sendall(self.reply)
                    self.sent += 1
                    self.note(f"answered {self.reply!r}")
                except OSError:
                    pass

    def stop(self):
        self.running = False
        try:
            self.listener.close()
        except OSError:
            pass


def waitForModule(session, tag, tries=60):
    # The opponent module is only fetched off track 34 once COMPETE WITH MODEM OPPONENT is picked,
    # and at true C64 speed that 1541 load takes the best part of a minute.  Until it lands,
    # $E000-$EFFF still holds whatever the previous overlay left there, so every module address reads
    # garbage - including $E5BE, which is what testTwoMachines.answerModemPrompts uses as its "the
    # link is open" test, and which read $AC out of the wreckage in the first attempt at this test.
    # The module's own jump table is the honest signal: sixteen bytes that cannot be anything else.
    want = [0x4C, 0x11, 0xE1, 0x4C, 0x9A, 0xE2, 0x4C, 0xA9, 0xE0, 0x4C, 0xF6, 0xE0, 0x4C, 0xCB,
            0xE3, 0x60]
    for attempt in range(tries):
        pauseAll([(session, tag)])
        got = readRange(session, 0xE000, 16)
        resumeAll([(session, tag)])
        if got == want:
            print(f"[{tag}] the module is resident: $E000 jump table matches", flush=True)
            return True
        print(f"[{tag}] waiting for the module to load: $E000 = "
              f"{' '.join('??' if v is None else f'{v:02X}' for v in got)}", flush=True)
        if attempt % 6 == 5:
            # The fire press that starts the load does not always land - it did not in one run of
            # this test - and there is no harm in offering it again while the options menu is still
            # up, because the row is already on COMPETE WITH MODEM OPPONENT.
            print(f"[{tag}] no sign of the load - pressing fire again", flush=True)
            session.focus()
            session.hold("KP_0", 300)
        time.sleep(3)
    return False


def openLink(session, tag, answerKey, tries=6):
    # Answer both modem prompts and then leave the emulator completely alone for eight seconds: the
    # AT string, the fake modem's answer, the result-code sniff and the 121-frame terminal-mode
    # timeout all have to happen in emulated real time, and every monitor command stops the clock.
    # $DE03 is the test - the ACIA control register is $00 until configureUserPortLines writes it.
    for attempt in range(tries):
        session.focus()
        session.hold(answerKey, 250)
        time.sleep(0.8)
        session.hold("space", 250)
        time.sleep(8)
        state = snapshot(session, tag)
        showState(state)
        if state["aciaControl"]:
            print(f"[{tag}] the link opened on attempt {attempt + 1}", flush=True)
            return state
    return state


def forceEntry(session, tag, x, expect):
    # Drive $E7B2 with a chosen X and read back what the 6551 was given.  The ACIA is put on 300 baud
    # first so that a 1200-baud answer cannot be the value that was already there, and txCharActive
    # is set so that aciaSetControlIdle's clear is visible too.
    pauseAll([(session, tag)])
    command(session, "sidefx on")
    command(session, "> de03 15")
    command(session, "sidefx off")
    command(session, "> e056 00")
    command(session, "> e5bb 01")
    before = {"$DE03": readRange(session, 0xDE03, 1)[0], "$E055": readRange(session, 0xE055, 1)[0],
              "$E056": readRange(session, 0xE056, 1)[0], "$E5BB": readRange(session, 0xE5BB, 1)[0]}
    saved = command(session, "r")
    command(session, f"r pc=e7b2")
    command(session, f"r x={x:02x}")
    regs = command(session, "r")
    stepped = command(session, "n")             # step over JSR loadBaudParameters $E353
    after = {"$DE03": readRange(session, 0xDE03, 1)[0], "$E055": readRange(session, 0xE055, 1)[0],
             "$E056": readRange(session, 0xE056, 1)[0], "$E5BB": readRange(session, 0xE5BB, 1)[0]}
    result = {"x": x, "before": before, "after": after, "expectControl": expect,
              "regsBefore": saved.strip().splitlines()[-2:], "regsSet": regs.strip().splitlines()[-2:],
              "stopped": stepped.strip().splitlines()[-2:]}
    ok = after["$DE03"] == expect and after["$E056"] == expect
    result["ok"] = ok
    print(f"\n*** forced entry at $E7B2 with X={x}: $DE03 ${before['$DE03']:02X} -> "
          f"${after['$DE03']:02X}, $E056 ${before['$E056']:02X} -> ${after['$E056']:02X}, "
          f"$E5BB ${before['$E5BB']:02X} -> ${after['$E5BB']:02X}, baudIndex $E055 "
          f"${before['$E055']:02X} -> ${after['$E055']:02X}; expected control ${expect:02X}; "
          f"{'PASS' if ok else 'FAIL'}", flush=True)
    print(f"    {decodeControl(after['$DE03'])}", flush=True)
    return result


def main():
    disk = os.path.abspath(sys.argv[1])
    tag = sys.argv[2] if len(sys.argv) > 2 else "ab"
    os.makedirs(SHOTS, exist_ok=True)
    os.makedirs(LOGS, exist_ok=True)
    modem = FakeModem()
    print(f"fake modem listening on 127.0.0.1:{modem.port}", flush=True)
    args = aciaArgs(rsDevAddress=f"127.0.0.1:{modem.port}", baud=2400)
    a = ViceSession(disk, f"{SCRATCH}/ab.a.vice.log", args, label="A", warp=False)
    sessions = [(a, "A")]
    try:
        a.connect()
        command(a, "warp on")
        a.bootPastLoader(waitSecs=900)
        pauseAll(sessions)
        command(a, "warp off")
        print(f"[A] {command(a, 'warp').strip()}", flush=True)
        resumeAll(sessions)
        a.findWindow()
        a.focus()
        time.sleep(25)
        a.shot(f"{SHOTS}/ab{tag}01Menu.png")
        pickModemOpponent(a, "A", "a")
        waitForModule(a, "A")

        state = snapshot(a, "A")
        showState(state)
        print(f"before the link opens: {decodeControl(state['aciaControl'])}", flush=True)

        after = openLink(a, "A", "a")
        a.shot(f"{SHOTS}/ab{tag}02AfterConnect.png")
        print(f"\n*** after the fake modem said CONNECT 1200: "
              f"{decodeControl(after['aciaControl'])}", flush=True)
        print(f"    bitPeriodLo $E056 = ${(after['bitPeriodLo'] or 0):02X}, "
              f"baudIndex $E055 = {after['baudIndex']}, connectionPhase $E040 = "
              f"{after['connectionPhase']}, lastModemChar $EB02 = "
              f"{readRange(a, 0xEB02, 1)[0]}", flush=True)
        autoOk = after["aciaControl"] == 0x17 and after["bitPeriodLo"] == 0x17
        print(f"    auto-baud to 1200: {'PASS' if autoOk else 'FAIL'}", flush=True)
        print(f"    the fake modem sent its result code {modem.sent} time(s); it saw "
              f"{bytes(modem.rx[:64])!r}", flush=True)

        forced = [forceEntry(a, "A", 0, 0x15), forceEntry(a, "A", 3, 0x17),
                  forceEntry(a, "A", 6, 0x18)]

        print("\n================ SUMMARY ================", flush=True)
        print(f"auto-baud CONNECT 1200 -> control ${(after['aciaControl'] or 0):02X}: "
              f"{'PASS' if autoOk else 'FAIL'}", flush=True)
        for item in forced:
            print(f"forced $E7B2 with X={item['x']}: $DE03 = ${item['after']['$DE03']:02X}, "
                  f"expected ${item['expectControl']:02X}: "
                  f"{'PASS' if item['ok'] else 'FAIL'}", flush=True)
        open(f"{LOGS}/autoBaud1200.{tag}.modem.txt", "w").write(
            "\n".join(f"{t}\t{text}" for t, text in modem.events) + "\n")
    finally:
        a.close()
        time.sleep(1)
        modem.stop()
        print(f"[modem] total {len(modem.rx)} bytes from the C64: {bytes(modem.rx)!r}", flush=True)


if __name__ == "__main__":
    main()
