"""Feature-complete tests for CircuitPython code generation."""

from __future__ import annotations

import math
import sys
import types
from typing import cast

import pytest

from pyrung.circuitpy import P1AM, ModbusServerConfig, RunStopConfig, board, generate_circuitpy
from pyrung.circuitpy.codegen import (
    CodegenContext,
    compile_condition,
    compile_expression,
    compile_rung,
)
from pyrung.click import TagMap, c
from pyrung.core import (
    Block,
    Bool,
    Char,
    Dint,
    InputBlock,
    Int,
    OutputBlock,
    Program,
    Real,
    Rung,
    TagType,
    Tms,
    all_of,
    blockcopy,
    branch,
    calc,
    call,
    copy,
    count_down,
    count_up,
    event_drum,
    fill,
    forloop,
    lro,
    off_delay,
    on_delay,
    out,
    pack_bits,
    pack_text,
    pack_words,
    return_early,
    run_enabled_function,
    run_function,
    search,
    shift,
    sqrt,
    subroutine,
    time_drum,
    to_ascii,
    to_binary,
    to_text,
    to_value,
    unpack_to_bits,
    unpack_to_words,
)
from pyrung.core.condition import (
    AllCondition,
    AnyCondition,
    BitCondition,
    CompareEq,
    CompareGe,
    CompareGt,
    CompareLe,
    CompareLt,
    CompareNe,
    FallingEdgeCondition,
    IntTruthyCondition,
    NormallyClosedCondition,
    RisingEdgeCondition,
)
from pyrung.core.expression import Expression
from pyrung.core.instruction import Instruction
from pyrung.core.system_points import system


def _context_for_program(program: Program, hw: P1AM) -> CodegenContext:
    ctx = CodegenContext(program=program, hw=hw, target_scan_ms=10.0, watchdog_ms=None)
    ctx.collect_hw_bindings()
    ctx.collect_program_references()
    ctx.collect_retentive_tags()
    ctx.assign_symbols()
    return ctx


def _manual_context(*tags) -> CodegenContext:
    hw = P1AM()
    hw.slot(1, "P1-08SIM")
    prog = Program(strict=False)
    ctx = CodegenContext(program=prog, hw=hw, target_scan_ms=10.0, watchdog_ms=None)
    ctx.collect_hw_bindings()
    ctx.referenced_tags = {tag.name: tag for tag in tags}
    ctx.collect_retentive_tags()
    ctx.assign_symbols()
    return ctx


def _basic_hw() -> tuple[P1AM, InputBlock, OutputBlock]:
    hw = P1AM()
    di = hw.slot(1, "P1-08SIM")
    do = hw.slot(2, "P1-08TRS")
    return hw, di, do


def _stub_module(name: str, **attrs: object) -> types.ModuleType:
    module = types.ModuleType(name)
    for key, value in attrs.items():
        setattr(module, key, value)
    return module


def _namespace_list(namespace: dict[str, object], symbol: str) -> list[object]:
    value = namespace[symbol]
    assert isinstance(value, list)
    return cast(list[object], value)


def _run_single_scan_source(
    source: str,
    monkeypatch,
    stub_base: object,
    *,
    socket_factory=None,
    runtime_source: str = "",
) -> dict[str, object]:
    single_scan_source = source.replace("while True:", "for __scan_once in range(1):", 1)

    class _StubDigitalInOut:
        def __init__(self, pin: object):
            self.pin = pin
            self.direction = None
            self.pull = None
            self.value = False

    class _StubNeoPixel:
        def __init__(self, pin: object, count: int, auto_write: bool = True):
            self.pin = pin
            self.count = count
            self.auto_write = auto_write
            self._pixels = [(0, 0, 0)] * count

        def __setitem__(self, index: int, value: tuple[int, int, int]) -> None:
            self._pixels[index] = value

        def __getitem__(self, index: int) -> tuple[int, int, int]:
            return self._pixels[index]

    class _StubWiznet5k:
        def __init__(self, spi: object, cs: object, *args, **kwargs):
            self.spi = spi
            self.cs = cs
            self.args = args
            self.kwargs = kwargs
            self.ifconfig = None

    class _StubSocket:
        def __init__(self, *args, **kwargs):
            self.args = args
            self.kwargs = kwargs
            self.timeout = None
            self.bound = None
            self.listen_backlog = None
            self.sent_packets: list[bytes] = []
            self.connected_to = None

        def bind(self, address):
            self.bound = address

        def listen(self, backlog: int):
            self.listen_backlog = backlog

        def settimeout(self, timeout):
            self.timeout = timeout

        def connect(self, address):
            self.connected_to = address
            return None

        def accept(self):
            raise OSError("would block")

        def recv_into(self, buf):
            return 0

        def send(self, payload):
            self.sent_packets.append(bytes(payload))
            return len(payload)

        def close(self):
            return None

    board_mod = _stub_module(
        "board",
        SD_SCK=object(),
        SD_MOSI=object(),
        SD_MISO=object(),
        SD_CS=object(),
        SCK=object(),
        MOSI=object(),
        MISO=object(),
        SWITCH=object(),
        LED=object(),
        NEOPIXEL=object(),
        D5=object(),
    )
    busio_mod = _stub_module("busio", SPI=lambda *args, **kwargs: object())
    sdcardio_mod = _stub_module("sdcardio", SDCard=lambda *args, **kwargs: object())
    digitalio_mod = _stub_module(
        "digitalio",
        DigitalInOut=_StubDigitalInOut,
        Direction=types.SimpleNamespace(INPUT=object(), OUTPUT=object()),
        Pull=types.SimpleNamespace(UP=object(), DOWN=object()),
    )
    neopixel_mod = _stub_module("neopixel", NeoPixel=_StubNeoPixel)
    adafruit_wiznet5k_mod = _stub_module("adafruit_wiznet5k")
    adafruit_wiznet5k_core_mod = _stub_module(
        "adafruit_wiznet5k.adafruit_wiznet5k", WIZNET5K=_StubWiznet5k
    )
    _socket_fn = (
        socket_factory
        if socket_factory is not None
        else (lambda *args, **kwargs: _StubSocket(*args, **kwargs))
    )

    class _StubSocketPool:
        def __init__(self, *_args, **_kwargs):
            pass

        def socket(self, *_args, **_kwargs):
            return _socket_fn(*_args, **_kwargs)

    adafruit_wiznet5k_socketpool_mod = _stub_module(
        "adafruit_wiznet5k.adafruit_wiznet5k_socketpool",
        SocketPool=_StubSocketPool,
    )
    storage_mod = _stub_module(
        "storage",
        VfsFat=lambda *_args, **_kwargs: object(),
        mount=lambda *_args, **_kwargs: None,
    )
    p1am_mod = _stub_module("P1AM", Base=lambda: stub_base)
    microcontroller_mod = _stub_module("microcontroller", nvm=bytearray(1))

    monkeypatch.setitem(sys.modules, "board", board_mod)
    monkeypatch.setitem(sys.modules, "busio", busio_mod)
    monkeypatch.setitem(sys.modules, "sdcardio", sdcardio_mod)
    monkeypatch.setitem(sys.modules, "digitalio", digitalio_mod)
    monkeypatch.setitem(sys.modules, "neopixel", neopixel_mod)
    monkeypatch.setitem(sys.modules, "adafruit_wiznet5k", adafruit_wiznet5k_mod)
    monkeypatch.setitem(
        sys.modules, "adafruit_wiznet5k.adafruit_wiznet5k", adafruit_wiznet5k_core_mod
    )
    monkeypatch.setitem(
        sys.modules,
        "adafruit_wiznet5k.adafruit_wiznet5k_socketpool",
        adafruit_wiznet5k_socketpool_mod,
    )
    monkeypatch.setitem(sys.modules, "storage", storage_mod)
    monkeypatch.setitem(sys.modules, "P1AM", p1am_mod)
    monkeypatch.setitem(sys.modules, "microcontroller", microcontroller_mod)

    rt_module = None
    if runtime_source:
        rt_module = types.ModuleType("pyrung_rt")
        exec(compile(runtime_source, "pyrung_rt.py", "exec"), rt_module.__dict__)
        monkeypatch.setitem(sys.modules, "pyrung_rt", rt_module)

    namespace: dict[str, object] = {}
    exec(compile(single_scan_source, "code.py", "exec"), namespace, namespace)

    # Expose runtime-resident names so tests can call them directly.
    if rt_module is not None:
        for key, value in rt_module.__dict__.items():
            if not key.startswith("__"):
                namespace.setdefault(key, value)

    return namespace


class TestGenerateCircuitPyAPI:
    def test_rejects_bad_argument_types_and_values(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        prog = Program(strict=False)

        with pytest.raises(TypeError, match="program"):
            generate_circuitpy("nope", hw, target_scan_ms=10.0)  # ty: ignore[invalid-argument-type]
        with pytest.raises(TypeError, match="hw"):
            generate_circuitpy(prog, object(), target_scan_ms=10.0)  # ty: ignore[invalid-argument-type]
        with pytest.raises(ValueError, match="target_scan_ms"):
            generate_circuitpy(prog, hw, target_scan_ms=0.0)
        with pytest.raises(ValueError, match="target_scan_ms"):
            generate_circuitpy(prog, hw, target_scan_ms=math.inf)
        with pytest.raises(TypeError, match="watchdog_ms"):
            generate_circuitpy(prog, hw, target_scan_ms=10.0, watchdog_ms=1.5)  # ty: ignore[invalid-argument-type]
        with pytest.raises(TypeError, match="runstop"):
            generate_circuitpy(prog, hw, target_scan_ms=10.0, runstop="nope")  # ty: ignore[invalid-argument-type]
        with pytest.raises(TypeError, match="modbus_server"):
            generate_circuitpy(prog, hw, target_scan_ms=10.0, modbus_server="nope")  # ty: ignore[invalid-argument-type]
        with pytest.raises(TypeError, match="tag_map"):
            generate_circuitpy(prog, hw, target_scan_ms=10.0, tag_map="nope")  # ty: ignore[invalid-argument-type]
        with pytest.raises(ValueError, match="mapped_tag_scope"):
            generate_circuitpy(prog, hw, target_scan_ms=10.0, mapped_tag_scope="nope")  # ty: ignore[invalid-argument-type]

    def test_modbus_requires_tag_map(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        prog = Program(strict=False)

        with pytest.raises(ValueError, match="tag_map is required"):
            generate_circuitpy(
                prog,
                hw,
                target_scan_ms=10.0,
                modbus_server=ModbusServerConfig(ip="192.168.1.200"),
            )

    def test_accepts_tag_map_without_modbus(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        prog = Program(strict=False)
        mapping = TagMap({Bool("Mapped"): c[1]})

        source = generate_circuitpy(prog, hw, target_scan_ms=10.0, tag_map=mapping).code
        assert "def _run_main_rungs():" in source

    def test_mapped_tag_scope_defaults_to_referenced_only(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        prog = Program(strict=False)
        mapping = TagMap({Bool("Mapped"): c[1]}, include_system=False)

        source = generate_circuitpy(
            prog,
            hw,
            target_scan_ms=10.0,
            modbus_server=ModbusServerConfig(ip="192.168.1.200"),
            tag_map=mapping,
        ).code
        assert 'if bank == "C" and index == 1:' not in source

    def test_mapped_tag_scope_all_mapped_emits_all_mapped_slots(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        prog = Program(strict=False)
        mapping = TagMap({Bool("Mapped"): c[1]}, include_system=False)

        source = generate_circuitpy(
            prog,
            hw,
            target_scan_ms=10.0,
            modbus_server=ModbusServerConfig(ip="192.168.1.200"),
            tag_map=mapping,
            mapped_tag_scope="all_mapped",
        ).code
        assert 'if bank == "C" and index == 1:' in source

    def test_rejects_empty_and_non_contiguous_slots(self):
        empty_hw = P1AM()
        prog = Program(strict=False)
        with pytest.raises(ValueError, match="at least one configured slot"):
            generate_circuitpy(prog, empty_hw, target_scan_ms=10.0, runstop=None)

        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        hw.slot(3, "P1-08TRS")
        with pytest.raises(ValueError, match="contiguous"):
            generate_circuitpy(prog, hw, target_scan_ms=10.0, runstop=None)

    def test_allows_zero_slot_codegen_when_board_tags_are_used(self):
        hw = P1AM()
        with Program(strict=False) as prog:
            with Rung(board.switch):
                out(board.led)
        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "base.rollCall" not in source_code
        assert "P1AM.Base()" not in source_code
        assert "_SLOT_MODULES = []" in source_code
        assert "import digitalio" in source_code

    def test_function_call_requires_inspectable_source(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")

        with Program(strict=False) as prog:
            with Rung(Bool("Enable")):
                run_function(abs)

        with pytest.raises(ValueError, match="inspect"):
            generate_circuitpy(prog, hw, target_scan_ms=10.0)

    def test_strict_validation_blocks_io_untracked_findings(self):
        hw = P1AM()
        outputs = hw.slot(1, "P1-08TRS")
        light = outputs[1]
        external = InputBlock("External", TagType.BOOL, 1, 8)[1]

        with Program(strict=False) as prog:
            with Rung(external):
                out(light)

        with pytest.raises(ValueError, match="CPY_IO_BLOCK_UNTRACKED"):
            generate_circuitpy(prog, hw, target_scan_ms=10.0)

    def test_strict_validation_keeps_advisories_non_blocking(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        done = Bool("Done")
        acc = Int("Acc")
        dest = Int("Dest")

        def fn():
            return {"result": 1}

        with Program(strict=False) as prog:
            with Rung(Bool("Enable")):
                on_delay(done, acc, preset=5, unit=Tms)
                run_function(fn, outs={"result": dest})

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "def _run_main_rungs():" in source_code


class TestDeterministicOutput:
    def test_same_inputs_generate_identical_output(self):
        hw, di, do = _basic_hw()
        with Program(strict=False) as prog:
            with Rung(di[1]):
                out(do[1])

        s1 = generate_circuitpy(prog, hw, target_scan_ms=10.0, watchdog_ms=1000).code
        s2 = generate_circuitpy(prog, hw, target_scan_ms=10.0, watchdog_ms=1000).code
        assert s1 == s2


class TestPrevSnapshotScope:
    def test_no_edge_conditions_emit_no_prev_snapshots(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        with Program(strict=False) as prog:
            with Rung(Bool("Enable")):
                out(Bool("Light"))

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert '_prev["' not in source_code

    def test_prev_snapshots_only_include_edge_tags(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        sensor = Bool("Car_Sensor")
        log_enable = Bool("Car_LogEnable")
        aux = Bool("Aux")
        light = Bool("Light")

        with Program(strict=False) as prog:
            with Rung(RisingEdgeCondition(sensor)):
                out(light)
            with Rung(BitCondition(log_enable)):
                out(aux)
            with Rung(FallingEdgeCondition(log_enable)):
                out(light)

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert '_prev["Car_Sensor"]' in source_code
        assert '_prev["Car_LogEnable"]' in source_code
        assert source_code.count('_prev["') == 2
        assert '_prev["Aux"]' not in source_code
        assert '_prev["Light"]' not in source_code


class TestConditionAndExpressionCompiler:
    @pytest.mark.parametrize(
        ("condition", "needle"),
        [
            (BitCondition(Bool("A")), "bool("),
            (NormallyClosedCondition(Bool("A")), "not bool"),
            (IntTruthyCondition(Int("N")), "!= 0"),
            (CompareEq(Int("N"), 1), "=="),
            (CompareNe(Int("N"), 1), "!="),
            (CompareLt(Int("N"), 1), "<"),
            (CompareLe(Int("N"), 1), "<="),
            (CompareGt(Int("N"), 1), ">"),
            (CompareGe(Int("N"), 1), ">="),
            (AllCondition(Bool("A"), Bool("B")), " and "),
            (AnyCondition(Bool("A"), Bool("B")), " or "),
            (RisingEdgeCondition(Bool("A")), "_rise("),
            (FallingEdgeCondition(Bool("A")), "_fall("),
        ],
    )
    def test_condition_mappings(self, condition, needle):
        light = Bool("Light")
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        with Program(strict=False) as prog:
            with Rung(condition):
                out(light)
        ctx = _context_for_program(prog, hw)
        compiled = compile_condition(condition, ctx)
        assert needle in compiled

    def test_expression_nodes_compile(self):
        a = Int("A")
        b = Int("B")
        ctx = _manual_context(a, b)

        assert " + " in compile_expression(a + 1, ctx)
        assert " - " in compile_expression(a - b, ctx)
        assert " * " in compile_expression(a * 2, ctx)
        assert " / " in compile_expression(a / 2, ctx)
        assert " // " in compile_expression(a // 2, ctx)
        assert " % " in compile_expression(a % 2, ctx)
        assert " ** " in compile_expression(a**2, ctx)
        assert "abs(" in compile_expression(abs(a), ctx)
        assert "&" in compile_expression(a & b, ctx)
        assert "|" in compile_expression(a | b, ctx)
        assert "^" in compile_expression(a ^ b, ctx)
        assert "<<" in compile_expression(a << 1, ctx)
        assert ">>" in compile_expression(a >> 1, ctx)
        assert "~int" in compile_expression(~a, ctx)
        assert "math.sqrt(" in compile_expression(sqrt(a), ctx)
        assert "0xFFFF" in compile_expression(lro(a, 1), ctx)

    def test_unknown_expression_type_raises(self):
        a = Int("A")
        ctx = _manual_context(a)

        class UnknownExpr(Expression):
            def evaluate(self, ctx):  # pragma: no cover - not executed
                return 0

        with pytest.raises(TypeError, match="Unsupported expression type"):
            compile_expression(UnknownExpr(), ctx)


class TestInstructionCoverage:
    def test_timers_counters_copy_calc_and_block_ops_emit(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        done_ton = Bool("DoneTON")
        acc_ton = Int("AccTON")
        done_tof = Bool("DoneTOF")
        acc_tof = Int("AccTOF")
        done_up = Bool("DoneUp")
        acc_up = Dint("AccUp")
        done_dn = Bool("DoneDn")
        acc_dn = Dint("AccDn")
        source = Int("Source")
        calc_out = Int("CalcOut")
        reset_tag = Bool("Reset")
        ds = Block("DS", TagType.INT, 1, 10)
        dd = Block("DD", TagType.INT, 1, 10)
        idx = Int("Idx")

        with Program(strict=False) as prog:
            with Rung(Bool("Enable")):
                on_delay(done_ton, acc_ton, preset=5).reset(reset_tag)
            with Rung(Bool("Enable")):
                off_delay(done_tof, acc_tof, preset=5)
            with Rung(Bool("Enable")):
                count_up(done_up, acc_up, preset=2).reset(reset_tag)
            with Rung(Bool("Enable")):
                count_down(done_dn, acc_dn, preset=2).reset(reset_tag)
            with Rung(Bool("Enable")):
                copy(40000, source)
                calc(source + 1, calc_out)
                blockcopy(ds.select(1, 3), dd.select(1, 3))
                fill(7, dd.select(idx, idx + 2))

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "_frac:" in source_code
        assert "_dt_units" in source_code
        assert "_delta" in source_code
        assert "_store_copy_value_to_type(" in source_code
        assert "_wrap_int(" in source_code
        assert "BlockCopy length mismatch" in source_code
        assert "for _src_idx, _dst_idx in zip(" in source_code
        assert "Indirect range start must be <= end" in source_code

    def test_search_shift_pack_unpack_and_forloop_emit(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        ds = Block("DS", TagType.INT, 1, 10)
        txt = Block("TXT", TagType.CHAR, 1, 8)
        bits = Block("Bits", TagType.BOOL, 1, 32)
        words = Block("Words", TagType.INT, 1, 2)
        found = Bool("Found")
        result = Int("Result")
        word = Int("Word")
        dword = Real("DWord")
        loop_count = Int("LoopCount")
        target = Int("Target")
        clock = Bool("Clock")
        reset_tag = Bool("Reset")

        with Program(strict=False) as prog:
            with Rung(Bool("Enable")):
                search(ds.select(1, 10) >= 5, result=result, found=found, continuous=True)
                search(txt.select(1, 8) == "AB", result=result, found=found)
            with Rung(Bool("Enable")):
                shift(bits.select(1, 8)).clock(clock).reset(reset_tag)
            with Rung(Bool("Enable")):
                pack_bits(bits.select(1, 16), word)
                pack_words(words.select(1, 2), dword)
                pack_text(txt.select(1, 8), dword, allow_whitespace=True)
                unpack_to_bits(dword, bits.select(1, 32))
                unpack_to_words(dword, words.select(1, 2))
                with forloop(loop_count, oneshot=True) as lp:
                    copy(lp.idx + 1, target)

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "_cursor_index" in source_code
        assert "_shift_prev_clock:" in source_code
        assert "_int_to_float_bits(" in source_code
        assert "_float_to_int_bits(" in source_code
        assert "_parse_pack_text_value(" in source_code
        assert "_for_i" in source_code
        assert "_search_1_rng_1_indices = range(0, 10)" in source_code
        assert "_search_1_rng_1_addrs = range(1, 11)" in source_code
        assert "_shift_1_rng_1_indices = range(0, 8)" in source_code
        assert "\"Text search only supports '==' and '!=' conditions\"" not in source_code
        assert '"shift bit_range resolved to an empty range"' not in source_code
        assert '"pack_bits destination width is 16 bits but block has' not in source_code
        assert '"pack_words requires exactly 2 source tags; got' not in source_code
        assert '"unpack_to_bits source width is 32 bits but block has' not in source_code
        assert '"unpack_to_words requires exactly 2 destination tags; got' not in source_code
        assert "import hashlib" not in source_code
        assert "if True:" not in source_code
        assert "if False:" not in source_code
        key_lines = [
            line
            for line in source_code.splitlines()
            if ("_oneshot:" in line) or ("_shift_prev_clock:" in line)
        ]
        assert key_lines
        for line in key_lines:
            assert ":i" in line
            assert ".py:" not in line

    def test_event_and_time_drum_codegen_keys_and_precedence_smoke(self, monkeypatch):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        enable = Bool("Enable", default=True)
        reset_cmd = Bool("ResetCmd", default=True)
        jump_cmd = Bool("JumpCmd", default=True)
        jog_cmd = Bool("JogCmd", default=True)
        step = Int("Step")
        acc = Int("Acc")
        done = Bool("Done")
        event1 = Bool("Event1")
        event2 = Bool("Event2")
        out1 = Bool("Out1")
        out2 = Bool("Out2")
        out3 = Bool("Out3")
        out4 = Bool("Out4")

        with Program(strict=False) as prog:
            with Rung(enable):
                event_drum(
                    outputs=[out1, out2],
                    events=[event1, event2],
                    pattern=[[1, 0], [0, 1]],
                    current_step=step,
                    completion_flag=done,
                ).reset(reset_cmd).jump(jump_cmd, step=step).jog(jog_cmd)
            with Rung(enable):
                time_drum(
                    outputs=[out1, out2, out3, out4],
                    presets=[0, 0, 0, 0],
                    pattern=[
                        [1, 0, 0, 0],
                        [0, 1, 0, 0],
                        [0, 0, 1, 0],
                        [0, 0, 0, 1],
                    ],
                    current_step=step,
                    accumulator=acc,
                    completion_flag=done,
                ).reset(reset_cmd).jump(jump_cmd, step=3).jog(jog_cmd)

        ctx = _context_for_program(prog, hw)
        step_symbol = ctx.symbol_for_tag(step)
        acc_symbol = ctx.symbol_for_tag(acc)
        out4_symbol = ctx.symbol_for_tag(out4)
        result = generate_circuitpy(prog, hw, target_scan_ms=10.0, watchdog_ms=None, runstop=None)
        source_code = result.code

        assert "_drum_event_prev:i" in source_code
        assert "_drum_time_frac:i" in source_code
        assert "_drum_jump_prev:i" in source_code
        assert "_drum_jog_prev:i" in source_code

        class StubBase:
            def rollCall(self, modules):
                return None

            def readDiscrete(self, slot):
                return 0

            def writeDiscrete(self, value, slot):
                return None

            def readAnalog(self, slot, ch_num):
                return 0

            def writeAnalog(self, value, slot, ch_num):
                return None

            def readTemperature(self, slot, ch_num):
                return 0.0

        namespace = _run_single_scan_source(
            source_code, monkeypatch, StubBase(), runtime_source=result.runtime
        )
        assert namespace[step_symbol] == 4
        assert namespace[acc_symbol] == 0
        assert namespace[out4_symbol] is True

    def test_function_call_subroutine_and_return_emit(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        source = Int("Source")
        dest = Int("Dest")

        def plus_one(value):
            return {"result": value + 1}

        def gated(enabled, value):
            return {"result": value + (1 if enabled else 0)}

        with Program(strict=False) as prog:
            with Rung(Bool("Enable")):
                run_function(plus_one, ins={"value": source}, outs={"result": dest})
                run_enabled_function(gated, ins={"value": source}, outs={"result": dest})
                call("worker")

            with subroutine("worker"):
                with Rung(Bool("Stop")):
                    return_early()
                with Rung(Bool("Enable")):
                    out(Bool("Light"))

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "def _sub_worker():" in source_code
        assert "return" in source_code
        assert "_fn_plus_one" in source_code
        assert "_fn_gated" in source_code
        assert "run_enabled_function" in source_code

    def test_unknown_instruction_type_raises(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")

        class UnknownInstruction(Instruction):
            def execute(self, ctx, enabled):  # pragma: no cover - generation-only
                return

        with Program(strict=False) as prog:
            with Rung(Bool("Enable")):
                out(Bool("Light"))

        prog.rungs[0].add_instruction(UnknownInstruction())
        with pytest.raises(NotImplementedError, match="UnknownInstruction"):
            generate_circuitpy(prog, hw, target_scan_ms=10.0)


class TestPersistenceWatchdogAndDiagnostics:
    def test_sd_persistence_and_status_system_points_emit(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        step = Int("Step")

        with Program(strict=False) as prog:
            with Rung(all_of(Bool("Enable"), system.storage.sd.ready)):
                copy(5, step)
                out(board.save_memory_cmd)
                out(system.storage.sd.eject_cmd)
                out(system.storage.sd.delete_all_cmd)

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "busio.SPI(board.SD_SCK, board.SD_MOSI, board.SD_MISO)" in source_code
        assert "_MEMORY_TMP_PATH" in source_code
        assert "_MEMORY_BAK_PATH" in source_code
        assert "os.rename(_MEMORY_TMP_PATH, _MEMORY_PATH)" in source_code
        assert "os.rename(_MEMORY_PATH, _MEMORY_BAK_PATH)" in source_code
        assert "for _path in (_MEMORY_PATH, _MEMORY_TMP_PATH, _MEMORY_BAK_PATH):" in source_code
        assert "os.remove(_path)" in source_code
        assert 'payload = {"schema": _RET_SCHEMA, "values": values}' in source_code
        assert "_sd_error_code = 1" in source_code
        assert "_sd_error_code = 2" in source_code
        assert "_sd_error_code = 3" in source_code
        assert "_service_sd_commands()" in source_code
        assert "save_memory()" in source_code
        assert "storage.sd.save_cmd" not in source_code
        assert "_t_board_save_memory_cmd" in source_code
        assert "_t_storage_sd_eject_cmd" in source_code
        assert 'storage.umount("/sd")' in source_code
        assert "_sd_available = False" in source_code
        assert (
            "while True:\n"
            "    scan_start = time.monotonic()\n"
            "    _sd_write_status = False\n" in source_code
        )
        helper_section = source_code.split("def _service_sd_commands():", 1)[1].split("\ndef _", 1)[
            0
        ]
        assert helper_section.count("_sd_write_status = True") == 2
        assert (
            "# SC69 pulses for this serviced-command scan; reset occurs at next scan start."
            in helper_section
        )
        assert "_t_storage_sd_copy_system_cmd" not in source_code

    def test_bak_recovery_in_load_memory(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        step = Int("Step")

        with Program(strict=False) as prog:
            with Rung():
                copy(1, step)

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        # NVM dirty flag path tries .bak
        assert '_MEMORY_BAK_PATH = "/sd/memory.json.bak"' in source_code
        assert "open(_MEMORY_BAK_PATH" in source_code
        assert 'print("Retentive memory recovered from backup")' in source_code
        assert (
            'print("Retentive load skipped: interrupted save, no backup available")' in source_code
        )
        # Normal path falls back to .bak when primary fails
        assert "Retentive memory loaded from backup" in source_code

    def test_auto_save_globals_emitted(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        count = Int("Count")

        with Program(strict=False) as prog:
            with Rung():
                copy(1, count)

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "_ret_snapshot = {}" in source_code
        assert "_ret_last_save_ts = 0.0" in source_code
        assert "_RET_AUTO_SAVE_S = 30.0" in source_code

    def test_auto_save_dirty_check_in_scan_loop(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        count = Int("Count")

        with Program(strict=False) as prog:
            with Rung():
                copy(1, count)

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "(scan_start - _ret_last_save_ts) >= _RET_AUTO_SAVE_S" in source_code
        assert '_ret_snapshot.get("Count")' in source_code
        assert "save_memory()" in source_code

    def test_save_memory_updates_snapshot(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        count = Int("Count")

        with Program(strict=False) as prog:
            with Rung():
                copy(1, count)

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        save_section = source_code.split("def save_memory():", 1)[1].split("\ndef ", 1)[0]
        assert "_ret_snapshot" in save_section
        assert "_ret_last_save_ts = time.monotonic()" in save_section

    def test_runstop_save_on_stop_transition(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")

        with Program(strict=False) as prog:
            pass

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        # save_memory() should appear before print("Mode: STOP")
        stop_idx = source_code.index('print("Mode: STOP")')
        save_before_stop = source_code.rfind("save_memory()", 0, stop_idx)
        assert save_before_stop != -1

    def test_runstop_default(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")

        with Program(strict=False) as prog:
            pass

        # Default should include RunStopConfig behavior
        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "_mode_run" in source_code
        assert "_runstop_debounced" in source_code
        assert 'print("Mode: RUN")' in source_code
        assert 'print("Mode: STOP")' in source_code

        # Explicit runstop=None should NOT include it
        source_no_rs = generate_circuitpy(prog, hw, target_scan_ms=10.0, runstop=None).code
        assert "_runstop_debounced" not in source_no_rs

    def test_watchdog_and_scan_diagnostics_emit(self):
        hw, di, do = _basic_hw()
        with Program(strict=False) as prog:
            with Rung(di[1]):
                out(do[1])

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0, watchdog_ms=1000).code
        assert 'getattr(base, "config_watchdog", None)' in source_code
        assert (
            'raise RuntimeError("P1AM snake_case watchdog API not found on Base() instance")'
            in source_code
        )
        assert "\n_wd_config(WATCHDOG_MS)\n" in source_code
        assert "\n_wd_start()\n" in source_code
        assert "if WATCHDOG_MS is not None:" not in source_code
        assert "    _wd_pet()" in source_code
        assert "_scan_overrun_count += 1" in source_code
        assert "PRINT_SCAN_OVERRUNS" in source_code


class TestIOMappingAndBranching:
    def test_discrete_analog_temperature_and_combo_mapping(self):
        hw = P1AM()
        di = hw.slot(1, "P1-08SIM")
        do = hw.slot(2, "P1-08TRS")
        hw.slot(3, "P1-04RTD")
        hw.slot(4, "P1-04DAL-1")
        combo_in, combo_out = hw.slot(5, "P1-16CDR")

        with Program(strict=False) as prog:
            with Rung(di[1]):
                out(do[1])
            with Rung(combo_in[1]):
                out(combo_out[1])

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "base.readDiscrete(1)" in source_code
        assert "base.writeDiscrete(" in source_code
        assert "base.readTemperature(3, 1)" in source_code
        assert "base.writeAnalog(" in source_code
        assert "base.readDiscrete(5)" in source_code

    def test_branch_precompute_and_source_order(self):
        enable = Bool("Enable")
        branch_enable = Bool("BranchEnable")
        a = Bool("A")
        b = Bool("B")
        c = Bool("C")
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        with Program(strict=False) as prog:
            with Rung(enable):
                out(a)
                with branch(branch_enable):
                    out(b)
                out(c)

        ctx = _context_for_program(prog, hw)
        lines = compile_rung(prog.rungs[0], "_run_main_rungs", ctx, indent=0)
        a_sym = ctx.symbol_for_tag(a)
        b_sym = ctx.symbol_for_tag(b)
        c_sym = ctx.symbol_for_tag(c)
        branch_idx = next(i for i, line in enumerate(lines) if "_branch_" in line)
        a_idx = next(i for i, line in enumerate(lines) if f"{a_sym} = True" in line)
        b_idx = next(i for i, line in enumerate(lines) if f"{b_sym} = True" in line)
        c_idx = next(i for i, line in enumerate(lines) if f"{c_sym} = True" in line)
        assert branch_idx < a_idx < b_idx < c_idx


class TestBoardPeripheralsAndRunStop:
    def test_board_peripheral_codegen_emits_wiring_and_neopixel_clamp(self):
        hw = P1AM()
        with Program(strict=False) as prog:
            with Rung(board.switch):
                out(board.led)
            with Rung(Bool("Enable", default=True)):
                copy(999, board.neopixel.r)
                copy(-3, board.neopixel.g)
                copy(128, board.neopixel.b)

        source_code = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "import digitalio" in source_code
        assert "_board_switch_io = digitalio.DigitalInOut(board.SWITCH)" in source_code
        assert "_board_led_io = digitalio.DigitalInOut(board.LED)" in source_code
        assert "import neopixel" in source_code
        assert "_board_pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, auto_write=True)" in source_code
        assert "_board_pixel[0] = (_pixel_r, _pixel_g, _pixel_b)" in source_code
        assert "max(0, min(255, int(" in source_code

    def test_runstop_emits_mapping_and_mode_tag_support(self):
        hw, di, do = _basic_hw()
        with Program(strict=False) as prog:
            with Rung(di[1]):
                out(do[1])

        source_code = generate_circuitpy(
            prog,
            hw,
            target_scan_ms=10.0,
            runstop=RunStopConfig(debounce_ms=15),
        ).code
        assert "_runstop_initialized = False" in source_code
        assert "_runstop_debounced" in source_code
        assert "_reset_for_run_transition()" in source_code
        assert "_force_outputs_off()" in source_code
        assert "_t_sys_mode_run" in source_code
        assert "_t_sys_cmd_mode_stop" in source_code
        assert "if bool(_t_sys_cmd_mode_stop):" in source_code

    def test_runstop_stop_mode_forces_outputs_off(self, monkeypatch):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        outputs = hw.slot(2, "P1-08TRS")
        enable = Bool("Enable", default=True)

        with Program(strict=False) as prog:
            with Rung(enable):
                out(outputs[1])

        result = generate_circuitpy(
            prog,
            hw,
            target_scan_ms=10.0,
            runstop=RunStopConfig(debounce_ms=0, run_when_high=True),
        )

        class StubBase:
            def __init__(self):
                self.discrete_writes: list[tuple[int, int]] = []

            def rollCall(self, modules):
                return None

            def readDiscrete(self, slot):
                return 0

            def writeDiscrete(self, value, slot):
                self.discrete_writes.append((slot, value))

            def readAnalog(self, slot, ch):
                return 0

            def writeAnalog(self, value, slot, ch):
                return None

            def readTemperature(self, slot, ch):
                return 0.0

        stub_base = StubBase()
        namespace = _run_single_scan_source(
            result.code, monkeypatch, stub_base, runtime_source=result.runtime
        )
        assert stub_base.discrete_writes[-1] == (2, 0)
        out_block_symbol = _context_for_program(prog, hw).symbol_for_block(outputs)
        out_values = _namespace_list(namespace, out_block_symbol)
        assert out_values[0] is False

    def test_runstop_run_when_high_false_keeps_runtime_in_run(self, monkeypatch):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        outputs = hw.slot(2, "P1-08TRS")
        enable = Bool("Enable", default=True)

        with Program(strict=False) as prog:
            with Rung(enable):
                out(outputs[1])

        result = generate_circuitpy(
            prog,
            hw,
            target_scan_ms=10.0,
            runstop=RunStopConfig(debounce_ms=0, run_when_high=False),
        )

        class StubBase:
            def __init__(self):
                self.discrete_writes: list[tuple[int, int]] = []

            def rollCall(self, modules):
                return None

            def readDiscrete(self, slot):
                return 0

            def writeDiscrete(self, value, slot):
                self.discrete_writes.append((slot, value))

            def readAnalog(self, slot, ch):
                return 0

            def writeAnalog(self, value, slot, ch):
                return None

            def readTemperature(self, slot, ch):
                return 0.0

        stub_base = StubBase()
        _run_single_scan_source(result.code, monkeypatch, stub_base, runtime_source=result.runtime)
        assert stub_base.discrete_writes[-1] == (2, 1)


class TestCopyConverterCodegen:
    def test_generation_unblocks_copy_blockcopy_converters(self):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        ch = Block("CH", TagType.CHAR, 1, 12)
        ds = Block("DS", TagType.INT, 1, 12)

        with Program(strict=False) as prog:
            with Rung(Bool("Enable")):
                copy(ch[1], ds[1], convert=to_value)
                blockcopy(ch.select(1, 3), ds.select(1, 3), convert=to_ascii)

        source = generate_circuitpy(prog, hw, target_scan_ms=10.0).code
        assert "_store_numeric_text_digit(" in source

    def test_copy_converter_runtime_modes_smoke(self, monkeypatch):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        ch = Block("CH", TagType.CHAR, 1, 12)
        ds = Block("DS", TagType.INT, 1, 12)
        enable = Bool("Enable", default=True)

        with Program(strict=False) as prog:
            with Rung(enable):
                fill("", ch.select(1, 12))
                fill(0, ds.select(1, 12))
                copy("5", ch[1])
                copy("A", ch[2])
                copy(7, ds[3])
                copy(123, ds[4])
                copy(ch[1], ds[1], convert=to_value)
                copy(ch[2], ds[2], convert=to_ascii)
                copy(ds[3], ch[4], convert=to_text(suppress_zero=False, termination_code=13))
                copy(ds[4], ch[10], convert=to_binary)

        ctx = _context_for_program(prog, hw)
        ch_symbol = ctx.symbol_for_block(ch)
        ds_symbol = ctx.symbol_for_block(ds)

        result = generate_circuitpy(prog, hw, target_scan_ms=10.0, watchdog_ms=None, runstop=None)

        class StubBase:
            def rollCall(self, modules):
                return None

            def readDiscrete(self, slot):
                return 0

            def writeDiscrete(self, value, slot):
                return None

            def readAnalog(self, slot, ch_num):
                return 0

            def writeAnalog(self, value, slot, ch_num):
                return None

            def readTemperature(self, slot, ch_num):
                return 0.0

        namespace = _run_single_scan_source(
            result.code, monkeypatch, StubBase(), runtime_source=result.runtime
        )
        ch_values = _namespace_list(namespace, ch_symbol)
        ds_values = _namespace_list(namespace, ds_symbol)

        assert ds_values[0] == 5
        assert ds_values[1] == 65
        assert ch_values[3] == "0"
        assert ch_values[4] == "0"
        assert ch_values[5] == "0"
        assert ch_values[6] == "0"
        assert ch_values[7] == "7"
        ch_terminator = ch_values[8]
        assert isinstance(ch_terminator, str)
        assert ord(ch_terminator) == 13
        assert ch_values[9] == "{"

    def test_blockcopy_converter_failure_sets_fault_and_avoids_partial_write(self, monkeypatch):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        ch = Block("CH", TagType.CHAR, 1, 10)
        ds = Block("DS", TagType.INT, 1, 10)
        fault_seen = Bool("FaultSeen")
        enable = Bool("Enable", default=True)

        with Program(strict=False) as prog:
            with Rung(enable):
                copy("1", ch[1])
                copy("A", ch[2])
                copy("3", ch[3])
                fill(9, ds.select(1, 3))
                blockcopy(ch.select(1, 3), ds.select(1, 3), convert=to_value)
            with Rung(system.fault.out_of_range):
                out(fault_seen)

        ctx = _context_for_program(prog, hw)
        ds_symbol = ctx.symbol_for_block(ds)
        fault_seen_symbol = ctx.symbol_for_tag(fault_seen)

        result = generate_circuitpy(prog, hw, target_scan_ms=10.0, watchdog_ms=None, runstop=None)

        class StubBase:
            def rollCall(self, modules):
                return None

            def readDiscrete(self, slot):
                return 0

            def writeDiscrete(self, value, slot):
                return None

            def readAnalog(self, slot, ch_num):
                return 0

            def writeAnalog(self, value, slot, ch_num):
                return None

            def readTemperature(self, slot, ch_num):
                return 0.0

        namespace = _run_single_scan_source(
            result.code, monkeypatch, StubBase(), runtime_source=result.runtime
        )
        ds_values = _namespace_list(namespace, ds_symbol)
        assert ds_values[0] == 9
        assert ds_values[1] == 9
        assert ds_values[2] == 9
        assert namespace[fault_seen_symbol] is True

    def test_scalar_multi_char_copy_converter_faults_and_skips_write(self, monkeypatch):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        source_value = Int("SourceValue", default=123)
        target = Int("Target", default=9)
        fault_seen = Bool("FaultSeen")
        enable = Bool("Enable", default=True)

        with Program(strict=False) as prog:
            with Rung(enable):
                copy(source_value, target, convert=to_text(suppress_zero=False))
            with Rung(system.fault.out_of_range):
                out(fault_seen)

        ctx = _context_for_program(prog, hw)
        target_symbol = ctx.symbol_for_tag(target)
        fault_seen_symbol = ctx.symbol_for_tag(fault_seen)
        result = generate_circuitpy(prog, hw, target_scan_ms=10.0, watchdog_ms=None, runstop=None)

        class StubBase:
            def rollCall(self, modules):
                return None

            def readDiscrete(self, slot):
                return 0

            def writeDiscrete(self, value, slot):
                return None

            def readAnalog(self, slot, ch_num):
                return 0

            def writeAnalog(self, value, slot, ch_num):
                return None

            def readTemperature(self, slot, ch_num):
                return 0.0

        namespace = _run_single_scan_source(
            result.code, monkeypatch, StubBase(), runtime_source=result.runtime
        )
        assert namespace[target_symbol] == 9
        assert namespace[fault_seen_symbol] is True


class TestGeneratedSourceSmoke:
    def test_generated_source_compile_and_execute_single_scan(self, monkeypatch):
        hw, di, do = _basic_hw()
        with Program(strict=False) as prog:
            with Rung(di[1]):
                out(do[1])

        source = generate_circuitpy(
            prog, hw, target_scan_ms=10.0, watchdog_ms=None, runstop=None
        ).code
        assert 'getattr(base, "config_watchdog", None)' not in source
        assert "_wd_pet()" not in source
        compile(source, "code.py", "exec")
        single_scan_source = source.replace("while True:", "for __scan_once in range(1):", 1)

        class StubBase:
            def __init__(self):
                self.roll_called = None
                self.discrete_reads = {1: 0b00000001}
                self.discrete_writes = []

            def rollCall(self, modules):
                self.roll_called = list(modules)

            def readDiscrete(self, slot):
                return self.discrete_reads.get(slot, 0)

            def writeDiscrete(self, value, slot):
                self.discrete_writes.append((slot, value))

            def readAnalog(self, slot, ch):
                return 0

            def writeAnalog(self, value, slot, ch):
                return None

            def readTemperature(self, slot, ch):
                return 0.0

        stub_base = StubBase()

        board_mod = _stub_module(
            "board",
            SD_SCK=object(),
            SD_MOSI=object(),
            SD_MISO=object(),
            SD_CS=object(),
        )
        busio_mod = _stub_module("busio", SPI=lambda *args, **kwargs: object())
        sdcardio_mod = _stub_module("sdcardio", SDCard=lambda *args, **kwargs: object())
        storage_mod = _stub_module(
            "storage",
            VfsFat=lambda *_args, **_kwargs: object(),
            mount=lambda *_args, **_kwargs: None,
        )
        p1am_mod = _stub_module("P1AM", Base=lambda: stub_base)
        microcontroller_mod = _stub_module("microcontroller", nvm=bytearray(1))

        monkeypatch.setitem(sys.modules, "board", board_mod)
        monkeypatch.setitem(sys.modules, "busio", busio_mod)
        monkeypatch.setitem(sys.modules, "sdcardio", sdcardio_mod)
        monkeypatch.setitem(sys.modules, "storage", storage_mod)
        monkeypatch.setitem(sys.modules, "P1AM", p1am_mod)
        monkeypatch.setitem(sys.modules, "microcontroller", microcontroller_mod)

        namespace: dict[str, object] = {}
        exec(compile(single_scan_source, "code.py", "exec"), namespace, namespace)

        assert stub_base.roll_called == ["P1-08SIM", "P1-08TRS"]
        assert stub_base.discrete_writes[-1] == (2, 1)

    def test_pack_text_parse_error_sets_fault_and_scan_continues(self, monkeypatch):
        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        txt = Block("TXT", TagType.CHAR, 1, 1)
        parsed = Int("Parsed")
        fault_seen = Bool("FaultSeen")
        enable = Bool("Enable", default=True)

        with Program(strict=False) as prog:
            with Rung(enable):
                # Empty CHAR slot parses as empty text and should fault (not crash scan).
                pack_text(txt.select(1, 1), parsed, allow_whitespace=True)
            with Rung(system.fault.out_of_range):
                out(fault_seen)

        ctx = _context_for_program(prog, hw)
        fault_symbol = ctx.symbol_for_tag(system.fault.out_of_range)
        parsed_symbol = ctx.symbol_for_tag(parsed)
        fault_seen_symbol = ctx.symbol_for_tag(fault_seen)

        source = generate_circuitpy(
            prog, hw, target_scan_ms=10.0, watchdog_ms=None, runstop=None
        ).code
        single_scan_source = source.replace("while True:", "for __scan_once in range(1):", 1)

        class StubBase:
            def __init__(self):
                self.discrete_reads = {1: 0}

            def rollCall(self, modules):
                return None

            def readDiscrete(self, slot):
                return self.discrete_reads.get(slot, 0)

            def writeDiscrete(self, value, slot):
                return None

            def readAnalog(self, slot, ch):
                return 0

            def writeAnalog(self, value, slot, ch):
                return None

            def readTemperature(self, slot, ch):
                return 0.0

        stub_base = StubBase()

        board_mod = _stub_module(
            "board",
            SD_SCK=object(),
            SD_MOSI=object(),
            SD_MISO=object(),
            SD_CS=object(),
        )
        busio_mod = _stub_module("busio", SPI=lambda *args, **kwargs: object())
        sdcardio_mod = _stub_module("sdcardio", SDCard=lambda *args, **kwargs: object())
        storage_mod = _stub_module(
            "storage",
            VfsFat=lambda *_args, **_kwargs: object(),
            mount=lambda *_args, **_kwargs: None,
        )
        p1am_mod = _stub_module("P1AM", Base=lambda: stub_base)
        microcontroller_mod = _stub_module("microcontroller", nvm=bytearray(1))

        monkeypatch.setitem(sys.modules, "board", board_mod)
        monkeypatch.setitem(sys.modules, "busio", busio_mod)
        monkeypatch.setitem(sys.modules, "sdcardio", sdcardio_mod)
        monkeypatch.setitem(sys.modules, "storage", storage_mod)
        monkeypatch.setitem(sys.modules, "P1AM", p1am_mod)
        monkeypatch.setitem(sys.modules, "microcontroller", microcontroller_mod)

        namespace: dict[str, object] = {}
        exec(compile(single_scan_source, "code.py", "exec"), namespace, namespace)

        assert namespace[fault_symbol] is True
        assert namespace[fault_seen_symbol] is True
        assert namespace[parsed_symbol] == 0


# ---------------------------------------------------------------------------
# Runtime split validation
# ---------------------------------------------------------------------------


class TestRuntimeSplit:
    """Verify the pyrung_rt / code.py split produces correct output shapes."""

    def test_modbus_program_produces_nonempty_runtime(self):
        """Traffic-light-style Modbus program: runtime is non-empty and
        code.py is significantly smaller than the single-file baseline."""
        from pyrung.circuitpy import ModbusClientConfig, ModbusServerConfig
        from pyrung.click import ModbusTcpTarget, TagMap, c, ds, receive, t, td, txt
        from pyrung.core.instruction.send_receive import ModbusAddress, RegisterType

        State = Char("State", default="r")
        RedDone, GreenDone, YellowDone = Bool("RedDone"), Bool("GreenDone"), Bool("YellowDone")
        RedAcc, GreenAcc, YellowAcc = Int("RedAcc"), Int("GreenAcc"), Int("YellowAcc")
        WalkRequest = Bool("WalkRequest")
        RxBusy, RxOk, RxErr = Bool("RxBusy"), Bool("RxOk"), Bool("RxErr")
        RxExCode = Int("RxExCode")

        with Program(strict=False) as prog:
            with Rung(State == "r"):
                on_delay(RedDone, RedAcc, preset=5000, unit=Tms)
            with Rung(RedDone):
                copy("g", State)
            with Rung(State == "g"):
                on_delay(GreenDone, GreenAcc, preset=4000, unit=Tms)
            with Rung(GreenDone):
                copy("y", State)
            with Rung(State == "y"):
                on_delay(YellowDone, YellowAcc, preset=1500, unit=Tms)
            with Rung(YellowDone):
                copy("r", State)
            with Rung():
                receive(
                    target="panel",
                    remote_start=ModbusAddress(0, RegisterType.COIL),
                    dest=WalkRequest,
                    receiving=RxBusy,
                    success=RxOk,
                    error=RxErr,
                    exception_response=RxExCode,
                )

        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        hw.slot(2, "P1-15TD2")
        mapping = TagMap(
            {
                State: txt[1],
                RedDone: t[1],
                RedAcc: td[1],
                GreenDone: t[2],
                GreenAcc: td[2],
                YellowDone: t[3],
                YellowAcc: td[3],
                WalkRequest: c[1],
                RxBusy: c[2],
                RxOk: c[3],
                RxErr: c[4],
                RxExCode: ds[1],
            }
        )

        result = generate_circuitpy(
            prog,
            hw,
            target_scan_ms=10.0,
            modbus_server=ModbusServerConfig(ip="192.168.1.200"),
            modbus_client=ModbusClientConfig(
                targets=(ModbusTcpTarget(name="panel", ip="192.168.1.50"),)
            ),
            tag_map=mapping,
        )

        assert result.runtime, "Modbus program must produce non-empty runtime"
        assert "service_modbus_server" in result.runtime
        assert "service_modbus_client" in result.runtime

        code_lines = result.code.strip().splitlines()
        runtime_lines = result.runtime.strip().splitlines()
        # code.py should be smaller than a single-file version: the runtime
        # absorbs helpers + Modbus infrastructure, so code.py < 65% of total.
        total = len(code_lines) + len(runtime_lines)
        assert len(code_lines) < total * 0.65, (
            f"code.py should be <65% of total ({len(code_lines)}/{total})"
        )

    def test_non_modbus_program_produces_empty_runtime(self):
        """Neopixel-style program (no Modbus, no slots): runtime is empty."""
        State = Char("State", default="r")
        RedDone = Bool("RedDone")
        RedAcc = Int("RedAcc")

        with Program(strict=False) as prog:
            with Rung(State == "r"):
                on_delay(RedDone, RedAcc, preset=3000, unit=Tms)
            with Rung(RedDone):
                copy("g", State)
            with Rung(State == "g"):
                copy(0, board.neopixel.r)
                copy(255, board.neopixel.g)

        result = generate_circuitpy(prog, P1AM(), target_scan_ms=10.0)
        assert result.runtime == "", "Non-Modbus program must produce empty runtime"
        assert "import pyrung_rt" not in result.code

    def test_both_generated_files_compile(self):
        """Both code.py and pyrung_rt.py must be valid Python."""
        from pyrung.circuitpy import ModbusServerConfig
        from pyrung.click import TagMap, c

        flag = Bool("Flag", default=True)
        with Program(strict=False) as prog:
            with Rung(flag):
                out(flag)

        hw = P1AM()
        hw.slot(1, "P1-08SIM")
        result = generate_circuitpy(
            prog,
            hw,
            target_scan_ms=10.0,
            modbus_server=ModbusServerConfig(ip="192.168.1.200"),
            tag_map=TagMap({flag: c[1]}),
        )

        # generate_circuitpy already compiles internally, but verify here too
        compile(result.code, "code.py", "exec")
        compile(result.runtime, "pyrung_rt.py", "exec")
