"""Bytecode code generator.

Emits the Roboteq MicroBasic bytecode reverse-engineered in
docs/reverse_engineering.md. Every opcode used here is backed by evidence
in that document (5 batches of real RoborunPlus-compiled output, decoded
byte-for-byte with zero leftovers across ~90 test programs plus a 70-byte
composite script). Where this module makes an assumption beyond what was
directly confirmed, it's called out in a comment -- search this file for
"ASSUMPTION" to find every one of them.

Two-pass design:
  1. `collect_declarations` walks the whole AST for `Dim` statements (in
     source order, including inside nested blocks) and allocates variable
     slots. Integer and Boolean variables have separate slot-numbering
     spaces, confirmed in batch 4.
  2. `emit_block` walks the AST again and emits bytecode. Forward
     references (a `GoTo`/`GoSub` to a label that appears later in the
     source) are handled with a simple patch list, resolved once the whole
     program has been emitted.
"""

from __future__ import annotations

import struct
from dataclasses import dataclass, field

from .ast_nodes import (
    AssignStmt,
    BinaryOp,
    BoolLiteral,
    Call,
    ContinueStmt,
    DimStmt,
    DoLoopUntilStmt,
    DoLoopWhileStmt,
    DoUntilStmt,
    ExitStmt,
    Expr,
    ExprStmt,
    ForCStyleStmt,
    ForTraditionalStmt,
    GosubStmt,
    GotoStmt,
    IfStmt,
    IncDecExpr,
    IncDecStmt,
    IntLiteral,
    Label,
    OptionExplicitStmt,
    PrintStmt,
    Program,
    ReturnStmt,
    Stmt,
    StringLiteral,
    TerminateStmt,
    UnaryOp,
    VarRef,
    WaitStmt,
    WhileStmt,
)
from .commands import resolve_command_code

# -- opcode table (see docs/reverse_engineering.md for the evidence) -------

OP_TERMINATE = 0x00

OP_LOAD_INT = 0x05
OP_STORE_INT = 0x06
OP_LOAD_BOOL = 0x07  # ASSUMPTION: predicted by symmetry with 0x08 (STORE_BOOL,
OP_STORE_BOOL = 0x08  # confirmed); never directly exercised in the corpus.

OP_ADD, OP_SUB, OP_MUL, OP_DIV, OP_MOD = 0x09, 0x0A, 0x0B, 0x0C, 0x0D
OP_ABS = 0x0E
OP_NEG = 0x0F
OP_AND, OP_OR, OP_XOR, OP_NOT = 0x10, 0x11, 0x12, 0x13
OP_INC, OP_DEC = 0x14, 0x15
OP_SHL, OP_SHR = 0x16, 0x17
OP_EQ, OP_NE, OP_GT, OP_GE, OP_LT, OP_LE = 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D

V_BRANCH_IF_TRUE = 0x1E
V_BRANCH_IF_FALSE = 0x1F
V_GOTO = 0x20
V_GOSUB = 0x21

OP_RETURN = 0x22
OP_WAIT = 0x23
OP_STRING = 0x24
OP_PRINT_INT = 0x25
OP_PRINT_BOOL = 0x26

OP_SETCONFIG = 0x27
OP_GETCONFIG = 0x28
OP_SETCOMMAND = 0x29
OP_GETVALUE = 0x2A
OP_GETTIMERCOUNT = 0x2B
OP_SETTIMERCOUNT = 0x2C
OP_SETTIMERSTATE = 0x2D
OP_GETTIMERSTATE = 0x2E
OP_TOBOOL = 0x2F

OP_SQRT, OP_SIN, OP_COS, OP_ATAN = 0x3C, 0x3D, 0x3E, 0x3F

BINOP_OPCODES = {
    "+": OP_ADD, "-": OP_SUB, "*": OP_MUL, "/": OP_DIV, "mod": OP_MOD,
    "and": OP_AND, "or": OP_OR, "xor": OP_XOR,
    "<<": OP_SHL, ">>": OP_SHR,
    "=": OP_EQ, "<>": OP_NE, ">": OP_GT, ">=": OP_GE, "<": OP_LT, "<=": OP_LE,
}
UNARY_OPCODES = {"-": OP_NEG, "not": OP_NOT}

# Device-I/O functions. Each entry is (verb_opcode, full_arity, optional_index_position).
#
# CONFIRMED (batch 6, reverse_engineering.md): when SetCommand is called with
# only 2 args -- `SetCommand(Item, Value)`, the manual's optional *middle*
# Index parameter omitted -- the compiler does NOT just push the 2 given
# args. It pushes all 3 slots with a literal 0 substituted for the missing
# Index: PUSH(Item), PUSH(0), PUSH(Value), then SETCOMMAND. Confirmed by two
# independent files (`92_cmd_ms`: setcommand(_MS,1) -> pushed [16, 0, 1];
# `94_cmd_estop`: setcommand(_ESTOP,1) -> pushed [14, 0, 1]), and the 3-arg
# form (`93_cmd_go`: setcommand(_GO,1,50) -> pushed [0, 1, 50], no defaulting
# needed) confirms the args are positionally (Item, Index, Value) with Index
# in the middle. This replaced an earlier, now-known-wrong assumption that
# args were just pushed in whatever order/count given.
#
# ASSUMPTION (not directly tested): GetValue/GetConfig's optional Index is
# the *trailing* parameter per the manual's `GetValue(Item, [Index])` syntax
# box, so by the same defaulting pattern a 1-arg call should push PUSH(Item),
# PUSH(0), then the verb. Every corpus/real-script file that uses GetValue
# supplies both args explicitly, so this trailing-default case has never
# been exercised against real compiled output. SetConfig is assumed
# symmetric with SetCommand (Index in the middle, defaulting to 0).
DEVICE_IO_SPECS: dict[str, tuple[int, int, int | None]] = {
    # name: (verb_opcode, full_arity, optional_index_position)
    "getvalue": (OP_GETVALUE, 2, 1),          # Item, [Index]      -- index trailing (ASSUMPTION re: defaulting)
    "setcommand": (OP_SETCOMMAND, 3, 1),      # Item, [Index], Value -- index middle (CONFIRMED)
    "getconfig": (OP_GETCONFIG, 2, 1),        # ASSUMPTION: same shape as GetValue
    "setconfig": (OP_SETCONFIG, 3, 1),        # ASSUMPTION: same shape as SetCommand
    "gettimercount": (OP_GETTIMERCOUNT, 1, None),
    "settimercount": (OP_SETTIMERCOUNT, 2, None),
    "settimerstate": (OP_SETTIMERSTATE, 2, None),
    "gettimerstate": (OP_GETTIMERSTATE, 1, None),
}
DEVICE_IO_VERBS = {name: spec[0] for name, spec in DEVICE_IO_SPECS.items()}
BUILTIN_UNARY_VERBS = {
    "abs": OP_ABS, "atan": OP_ATAN, "cos": OP_COS, "sin": OP_SIN,
    "sqrt": OP_SQRT, "tobool": OP_TOBOOL,
}


class CodegenError(Exception):
    pass


# -- symbol table ------------------------------------------------------

@dataclass
class ArrayInfo:
    base_slot: int
    length: int
    is_bool: bool


class SymbolTable:
    def __init__(self) -> None:
        self.int_scalars: dict[str, int] = {}
        self.bool_scalars: dict[str, int] = {}
        self.arrays: dict[str, ArrayInfo] = {}
        self.next_int = 0
        self.next_bool = 0

    def declare(self, name: str, var_type: str, array_len: int | None) -> None:
        key = name.lower()
        if key in self.int_scalars or key in self.bool_scalars or key in self.arrays:
            raise CodegenError(f"variable '{name}' declared more than once")
        is_bool = var_type == "Boolean"
        if array_len is not None:
            # ASSUMPTION: array base slot = cumulative storage-cell offset
            # (this array's Dim consumes `array_len` slots starting at the
            # next free one), not a simple "declaration index" counter.
            # Only ever tested with a single array declared after some
            # scalars, where both models coincide -- see
            # reverse_engineering.md's array section.
            if is_bool:
                base = self.next_bool
                self.next_bool += array_len
            else:
                base = self.next_int
                self.next_int += array_len
            self.arrays[key] = ArrayInfo(base_slot=base, length=array_len, is_bool=is_bool)
        elif is_bool:
            self.bool_scalars[key] = self.next_bool
            self.next_bool += 1
        else:
            self.int_scalars[key] = self.next_int
            self.next_int += 1

    def resolve_scalar(self, name: str) -> tuple[int, bool]:
        key = name.lower()
        if key in self.int_scalars:
            return self.int_scalars[key], False
        if key in self.bool_scalars:
            return self.bool_scalars[key], True
        if key in self.arrays:
            raise CodegenError(f"'{name}' is an array -- use an index, e.g. {name}[0]")
        # Manual: "Micro Basic by default treats undeclared identifiers as
        # integer variables" (unless Option Explicit is set, which this
        # compiler doesn't yet enforce -- see OptionExplicitStmt handling).
        self.int_scalars[key] = self.next_int
        self.next_int += 1
        return self.int_scalars[key], False

    def resolve_array(self, name: str) -> tuple[int, bool, int]:
        key = name.lower()
        if key not in self.arrays:
            raise CodegenError(f"array '{name}' was not declared with Dim")
        info = self.arrays[key]
        return info.base_slot, info.is_bool, info.length

    def alloc_hidden_int_slot(self) -> int:
        """For the traditional For loop's compiler-synthesized limit/step
        variables (confirmed in reverse_engineering.md, batch 3)."""
        slot = self.next_int
        self.next_int += 1
        return slot


def _iter_all_statements(stmts: list[Stmt]):
    for s in stmts:
        yield s
        if isinstance(s, IfStmt):
            yield from _iter_all_statements(s.then_block)
            for _, body in s.elseif_clauses:
                yield from _iter_all_statements(body)
            if s.else_block:
                yield from _iter_all_statements(s.else_block)
        elif isinstance(s, (WhileStmt, DoUntilStmt, DoLoopWhileStmt, DoLoopUntilStmt,
                             ForTraditionalStmt, ForCStyleStmt)):
            yield from _iter_all_statements(s.body)


@dataclass
class LoopContext:
    kind: str  # "For" | "While" | "Do"
    continue_patches: list[int] = field(default_factory=list)
    exit_patches: list[int] = field(default_factory=list)


class CodeGenerator:
    def __init__(self) -> None:
        self.code = bytearray()
        self.symtab = SymbolTable()
        self.label_addrs: dict[str, int] = {}
        self.label_patches: list[tuple[int, str]] = []
        self.loop_stack: list[LoopContext] = []

    # -- top level -----------------------------------------------------

    def generate(self, program: Program) -> bytes:
        self.collect_declarations(program)
        self.emit_block(program.statements)
        self.code.append(0x00)  # fixed program-end sentinel, always present
        self._resolve_label_patches()
        return bytes(self.code)

    def collect_declarations(self, program: Program) -> None:
        for s in _iter_all_statements(program.statements):
            if isinstance(s, DimStmt):
                self.symtab.declare(s.name, s.var_type, s.array_len)

    def _resolve_label_patches(self) -> None:
        for patch_at, label in self.label_patches:
            key = label.lower()
            if key not in self.label_addrs:
                raise CodegenError(f"undefined label '{label}'")
            self.patch_branch(patch_at, self.label_addrs[key])

    # -- low-level emission helpers --------------------------------------

    def emit_operand(self, value: int) -> None:
        """Variable-length operand encoding, confirmed in batch 4: used for
        both literal values and variable-slot references."""
        if value < 0 or value > 0xFFFFFFFF:
            raise CodegenError(f"operand value {value} out of representable range")
        if value == 0:
            self.code.append(0x01)
        elif value <= 0xFF:
            self.code.append(0x02)
            self.code.append(value)
        elif value <= 0xFFFF:
            self.code.append(0x03)
            self.code += struct.pack("<H", value)
        else:
            self.code.append(0x04)
            self.code += struct.pack("<I", value)

    def emit_branch(self, verb: int) -> int:
        """Emit [0x03][2-byte placeholder address][verb]; return the offset
        of the 2-byte field so the caller can patch it once the target
        address is known."""
        self.code.append(0x03)
        patch_at = len(self.code)
        self.code += b"\x00\x00"
        self.code.append(verb)
        return patch_at

    def patch_branch(self, patch_at: int, target: int) -> None:
        if target < 0 or target > 0xFFFF:
            raise CodegenError(
                "branch target exceeds the 2-byte address field -- program "
                "too large (this limit was never actually confirmed against "
                "real hardware/RoborunPlus, only assumed from every test "
                "program being small)"
            )
        self.code[patch_at : patch_at + 2] = struct.pack("<H", target)

    def emit_goto_addr(self, verb: int, target: int) -> None:
        self.patch_branch(self.emit_branch(verb), target)

    # -- expressions -----------------------------------------------------

    def emit_expr(self, expr: Expr) -> None:
        if isinstance(expr, IntLiteral):
            self.emit_operand(expr.value)
        elif isinstance(expr, BoolLiteral):
            # confirmed: True = push 0xFFFFFFFF, False = push 0
            self.emit_operand(0xFFFFFFFF if expr.value else 0)
        elif isinstance(expr, VarRef):
            self._emit_load_ref(expr)
        elif isinstance(expr, UnaryOp):
            self.emit_expr(expr.operand)
            self.code.append(UNARY_OPCODES[expr.op])
        elif isinstance(expr, BinaryOp):
            self.emit_expr(expr.left)
            self.emit_expr(expr.right)
            self.code.append(BINOP_OPCODES[expr.op])
        elif isinstance(expr, Call):
            self.emit_call(expr)
        elif isinstance(expr, IncDecExpr):
            self._emit_incdec(expr.var, expr.op, expr.prefix)
        elif isinstance(expr, StringLiteral):
            raise CodegenError("string literals can only be used as Print arguments")
        else:
            raise CodegenError(f"unsupported expression node: {expr!r}")

    def _emit_load_ref(self, ref: VarRef, verb: str | None = None) -> None:
        if ref.name.startswith("_"):
            if ref.index is not None:
                raise CodegenError(f"'{ref.name}' is a command constant, not an array")
            self.emit_operand(resolve_command_code(ref.name, verb=verb))
            return
        if ref.index is not None:
            base, is_bool, _length = self.symtab.resolve_array(ref.name)
            self._emit_array_address(base, ref.index)
            self.code.append(OP_LOAD_BOOL if is_bool else OP_LOAD_INT)
            return
        slot, is_bool = self.symtab.resolve_scalar(ref.name)
        self.emit_operand(slot)
        self.code.append(OP_LOAD_BOOL if is_bool else OP_LOAD_INT)

    def _emit_store_ref(self, ref: VarRef) -> None:
        if ref.index is not None:
            base, is_bool, _length = self.symtab.resolve_array(ref.name)
            self._emit_array_address(base, ref.index)
            self.code.append(OP_STORE_BOOL if is_bool else OP_STORE_INT)
            return
        slot, is_bool = self.symtab.resolve_scalar(ref.name)
        self.emit_operand(slot)
        self.code.append(OP_STORE_BOOL if is_bool else OP_STORE_INT)

    def _emit_array_address(self, base_slot: int, index_expr: Expr) -> None:
        self.emit_operand(base_slot)
        self.emit_expr(index_expr)
        self.code.append(OP_ADD)

    def emit_call(self, call: Call) -> None:
        name = call.name.lower()
        if name in DEVICE_IO_SPECS:
            verb, full_arity, optional_pos = DEVICE_IO_SPECS[name]
            args = list(call.args)
            if len(args) == full_arity:
                pass
            elif optional_pos is not None and len(args) == full_arity - 1:
                # Missing optional Index arg -- confirmed (SetCommand) /
                # assumed-by-symmetry (GetValue etc.) that the compiler
                # substitutes a literal 0 at the optional parameter's slot
                # rather than just omitting a push. See DEVICE_IO_SPECS.
                args = args[:optional_pos] + [IntLiteral(0)] + args[optional_pos:]
            else:
                expected = f"{full_arity} arguments"
                if optional_pos is not None:
                    expected += f" (or {full_arity - 1}, omitting the optional Index)"
                raise CodegenError(f"{call.name} expects {expected}, got {len(args)}")
            # args[0] is always the Item/_XXX command constant (manual's
            # syntax boxes all put it first: GetValue(Item,...),
            # SetCommand(Item,...), etc.) -- resolved with the calling verb
            # so VERB_SPECIFIC_CODES aliases (e.g. _BRUN, _CSS) pick the
            # right numeric table.
            item_arg = args[0]
            if isinstance(item_arg, VarRef) and item_arg.name.startswith("_"):
                self._emit_load_ref(item_arg, verb=name)
            else:
                self.emit_expr(item_arg)
            for arg in args[1:]:
                self.emit_expr(arg)
            self.code.append(verb)
            return
        if name in BUILTIN_UNARY_VERBS:
            if len(call.args) != 1:
                raise CodegenError(f"{call.name} expects exactly 1 argument")
            self.emit_expr(call.args[0])
            self.code.append(BUILTIN_UNARY_VERBS[name])
            return
        raise CodegenError(f"unknown function '{call.name}'")

    def _emit_incdec(self, var: str, op: str, prefix: bool) -> None:
        slot, is_bool = self.symtab.resolve_scalar(var)
        if is_bool:
            raise CodegenError("'++'/'--' are not supported on Boolean variables")
        verb = OP_INC if op == "++" else OP_DEC
        if prefix:
            # confirmed: LOAD, verb, STORE, LOAD (re-load the new value)
            self.emit_operand(slot); self.code.append(OP_LOAD_INT)
            self.code.append(verb)
            self.emit_operand(slot); self.code.append(OP_STORE_INT)
            self.emit_operand(slot); self.code.append(OP_LOAD_INT)
        else:
            # confirmed: LOAD (old value kept on the stack), then
            # [LOAD, verb, STORE] as a side effect underneath it
            self.emit_operand(slot); self.code.append(OP_LOAD_INT)
            self.emit_operand(slot); self.code.append(OP_LOAD_INT)
            self.code.append(verb)
            self.emit_operand(slot); self.code.append(OP_STORE_INT)

    # -- statements --------------------------------------------------------

    def emit_block(self, stmts: list[Stmt]) -> None:
        for s in stmts:
            self.emit_stmt(s)

    def emit_stmt(self, stmt: Stmt) -> None:
        if isinstance(stmt, DimStmt):
            return  # slots already allocated in collect_declarations
        if isinstance(stmt, OptionExplicitStmt):
            return  # TODO: enforce declare-before-use
        if isinstance(stmt, Label):
            self._emit_label(stmt); return
        if isinstance(stmt, AssignStmt):
            self.emit_expr(stmt.value)
            self._emit_store_ref(stmt.target)
            return
        if isinstance(stmt, IfStmt):
            self._emit_if(stmt); return
        if isinstance(stmt, WhileStmt):
            self._emit_while(stmt); return
        if isinstance(stmt, DoUntilStmt):
            self._emit_do_until(stmt); return
        if isinstance(stmt, DoLoopWhileStmt):
            self._emit_do_loop_while(stmt); return
        if isinstance(stmt, DoLoopUntilStmt):
            self._emit_do_loop_until(stmt); return
        if isinstance(stmt, ForTraditionalStmt):
            self._emit_for_traditional(stmt); return
        if isinstance(stmt, ForCStyleStmt):
            self._emit_for_cstyle(stmt); return
        if isinstance(stmt, GotoStmt):
            p = self.emit_branch(V_GOTO)
            self.label_patches.append((p, stmt.label))
            return
        if isinstance(stmt, GosubStmt):
            p = self.emit_branch(V_GOSUB)
            self.label_patches.append((p, stmt.label))
            return
        if isinstance(stmt, ReturnStmt):
            self.code.append(OP_RETURN); return
        if isinstance(stmt, TerminateStmt):
            self.code.append(OP_TERMINATE); return
        if isinstance(stmt, WaitStmt):
            self.emit_expr(stmt.milliseconds)
            self.code.append(OP_WAIT)
            return
        if isinstance(stmt, PrintStmt):
            self._emit_print(stmt); return
        if isinstance(stmt, ExitStmt):
            ctx = self._find_loop_ctx(stmt.kind)
            ctx.exit_patches.append(self.emit_branch(V_GOTO))
            return
        if isinstance(stmt, ContinueStmt):
            ctx = self._find_loop_ctx(stmt.kind)
            ctx.continue_patches.append(self.emit_branch(V_GOTO))
            return
        if isinstance(stmt, IncDecStmt):
            self._emit_incdec_stmt(stmt); return
        if isinstance(stmt, ExprStmt):
            self.emit_call(stmt.call); return
        raise CodegenError(f"unsupported statement node: {stmt!r}")

    def _emit_label(self, stmt: Label) -> None:
        key = stmt.name.lower()
        if key in self.label_addrs:
            raise CodegenError(f"label '{stmt.name}' defined more than once")
        self.label_addrs[key] = len(self.code)

    def _emit_incdec_stmt(self, stmt: IncDecStmt) -> None:
        slot, is_bool = self.symtab.resolve_scalar(stmt.var)
        if is_bool:
            raise CodegenError("'++'/'--' are not supported on Boolean variables")
        verb = OP_INC if stmt.op == "++" else OP_DEC
        self.emit_operand(slot); self.code.append(OP_LOAD_INT)
        self.code.append(verb)
        self.emit_operand(slot); self.code.append(OP_STORE_INT)

    def _emit_print(self, stmt: PrintStmt) -> None:
        for arg in stmt.args:
            if isinstance(arg, StringLiteral):
                data = arg.value.encode("latin-1", errors="replace")
                # confirmed: literals over 32 bytes are split into multiple
                # chunks, each with its own 0x24 header. CONFIRMED (real
                # script PumpcontrollerR0-3-LowCurrentPressure.mbs, whose
                # 36-char `print("\n\rStarting (R0-3-LowCurrentPressure)")`
                # was previously thought to compile to just two adjacent
                # STRING chunks -- it actually has a synthesized `Wait(1)`
                # (`02 01 23`) between them, matching the manual's "32
                # characters per 1ms time slot" wording literally: each
                # chunk gets its own 1ms slot, so the compiler waits 1ms
                # between chunks (but not after the last one). Only tested
                # for a single split literal; whether this generalizes to
                # cumulative multi-argument totals >32 chars in one Print
                # is unconfirmed.
                chunks = [data[i : i + 32] for i in range(0, len(data), 32)]
                for idx, chunk in enumerate(chunks):
                    if idx > 0:
                        self.emit_expr(IntLiteral(1))
                        self.code.append(OP_WAIT)
                    self.code.append(OP_STRING)
                    self.code.append(len(chunk))
                    self.code += chunk
            elif isinstance(arg, Call) and arg.name.lower() == "tobool":
                if len(arg.args) != 1:
                    raise CodegenError("ToBool expects exactly 1 argument")
                self.emit_expr(arg.args[0])
                self.code.append(OP_PRINT_BOOL)
            else:
                self.emit_expr(arg)
                self.code.append(OP_PRINT_INT)

    def _find_loop_ctx(self, kind: str) -> LoopContext:
        for ctx in reversed(self.loop_stack):
            if ctx.kind == kind:
                return ctx
        raise CodegenError(f"'{kind}' Exit/Continue used outside of a matching loop")

    # -- control flow ------------------------------------------------------

    def _emit_if(self, stmt: IfStmt) -> None:
        clauses = [(stmt.condition, stmt.then_block)] + stmt.elseif_clauses
        has_else = stmt.else_block is not None
        n = len(clauses)
        end_patches: list[int] = []
        for i, (cond, body) in enumerate(clauses):
            self.emit_expr(cond)
            false_patch = self.emit_branch(V_BRANCH_IF_FALSE)
            self.emit_block(body)
            is_last_clause = (i == n - 1) and not has_else
            if not is_last_clause:
                end_patches.append(self.emit_branch(V_GOTO))
            self.patch_branch(false_patch, len(self.code))
        if has_else:
            self.emit_block(stmt.else_block)
        end_addr = len(self.code)
        for p in end_patches:
            self.patch_branch(p, end_addr)

    def _emit_while(self, stmt: WhileStmt) -> None:
        top = len(self.code)
        self.emit_expr(stmt.condition)
        exit_patch = self.emit_branch(V_BRANCH_IF_FALSE)
        ctx = LoopContext(kind="While")
        self.loop_stack.append(ctx)
        self.emit_block(stmt.body)
        self.loop_stack.pop()
        for p in ctx.continue_patches:
            self.patch_branch(p, top)
        self.emit_goto_addr(V_GOTO, top)
        end_addr = len(self.code)
        self.patch_branch(exit_patch, end_addr)
        for p in ctx.exit_patches:
            self.patch_branch(p, end_addr)

    def _emit_do_until(self, stmt: DoUntilStmt) -> None:
        top = len(self.code)
        self.emit_expr(stmt.condition)
        exit_patch = self.emit_branch(V_BRANCH_IF_TRUE)
        ctx = LoopContext(kind="Do")
        self.loop_stack.append(ctx)
        self.emit_block(stmt.body)
        self.loop_stack.pop()
        for p in ctx.continue_patches:
            self.patch_branch(p, top)
        self.emit_goto_addr(V_GOTO, top)
        end_addr = len(self.code)
        self.patch_branch(exit_patch, end_addr)
        for p in ctx.exit_patches:
            self.patch_branch(p, end_addr)

    def _emit_do_loop_while(self, stmt: DoLoopWhileStmt) -> None:
        top = len(self.code)
        ctx = LoopContext(kind="Do")
        self.loop_stack.append(ctx)
        self.emit_block(stmt.body)
        self.loop_stack.pop()
        cond_addr = len(self.code)
        for p in ctx.continue_patches:
            self.patch_branch(p, cond_addr)
        self.emit_expr(stmt.condition)
        self.emit_goto_addr(V_BRANCH_IF_TRUE, top)
        end_addr = len(self.code)
        for p in ctx.exit_patches:
            self.patch_branch(p, end_addr)

    def _emit_do_loop_until(self, stmt: DoLoopUntilStmt) -> None:
        top = len(self.code)
        ctx = LoopContext(kind="Do")
        self.loop_stack.append(ctx)
        self.emit_block(stmt.body)
        self.loop_stack.pop()
        cond_addr = len(self.code)
        for p in ctx.continue_patches:
            self.patch_branch(p, cond_addr)
        self.emit_expr(stmt.condition)
        self.emit_goto_addr(V_BRANCH_IF_FALSE, top)
        end_addr = len(self.code)
        for p in ctx.exit_patches:
            self.patch_branch(p, end_addr)

    def _emit_for_traditional(self, stmt: ForTraditionalStmt) -> None:
        var_slot, is_bool = self.symtab.resolve_scalar(stmt.var)
        if is_bool:
            raise CodegenError("For loop variable must be Integer")
        limit_slot = self.symtab.alloc_hidden_int_slot()
        step_slot = self.symtab.alloc_hidden_int_slot()

        self.emit_expr(stmt.init)
        self.emit_operand(var_slot); self.code.append(OP_STORE_INT)

        self.emit_expr(stmt.limit)
        self.emit_operand(limit_slot); self.code.append(OP_STORE_INT)

        self.emit_expr(stmt.step) if stmt.step is not None else self.emit_operand(1)
        self.emit_operand(step_slot); self.code.append(OP_STORE_INT)

        cond_addr = len(self.code)
        # confirmed step-sign-generic exit test:
        #   (step >= 0 AND var > limit) OR (step < 0 AND var < limit)
        self.emit_operand(step_slot); self.code.append(OP_LOAD_INT)
        self.emit_operand(0)
        self.code.append(OP_GE)
        self.emit_operand(var_slot); self.code.append(OP_LOAD_INT)
        self.emit_operand(limit_slot); self.code.append(OP_LOAD_INT)
        self.code.append(OP_GT)
        self.code.append(OP_AND)
        self.emit_operand(step_slot); self.code.append(OP_LOAD_INT)
        self.emit_operand(0)
        self.code.append(OP_LT)
        self.emit_operand(var_slot); self.code.append(OP_LOAD_INT)
        self.emit_operand(limit_slot); self.code.append(OP_LOAD_INT)
        self.code.append(OP_LT)
        self.code.append(OP_AND)
        self.code.append(OP_OR)
        exit_patch = self.emit_branch(V_BRANCH_IF_TRUE)

        ctx = LoopContext(kind="For")
        self.loop_stack.append(ctx)
        self.emit_block(stmt.body)
        self.loop_stack.pop()

        # ASSUMPTION: Continue For jumps to the increment step (runs the
        # increment, then falls through to the condition recheck), not
        # straight back to the condition check. Never independently
        # confirmed -- no test exercised Continue inside a traditional For.
        incr_addr = len(self.code)
        for p in ctx.continue_patches:
            self.patch_branch(p, incr_addr)
        self.emit_operand(var_slot); self.code.append(OP_LOAD_INT)
        self.emit_operand(step_slot); self.code.append(OP_LOAD_INT)
        self.code.append(OP_ADD)
        self.emit_operand(var_slot); self.code.append(OP_STORE_INT)
        self.emit_goto_addr(V_GOTO, cond_addr)
        end_addr = len(self.code)
        self.patch_branch(exit_patch, end_addr)
        for p in ctx.exit_patches:
            self.patch_branch(p, end_addr)

    def _emit_for_cstyle(self, stmt: ForCStyleStmt) -> None:
        var_slot, is_bool = self.symtab.resolve_scalar(stmt.var)
        if is_bool:
            raise CodegenError("For loop variable must be Integer")

        self.emit_expr(stmt.init)
        self.emit_operand(var_slot); self.code.append(OP_STORE_INT)

        top = len(self.code)
        self.emit_expr(stmt.condition)
        exit_patch = self.emit_branch(V_BRANCH_IF_FALSE)

        ctx = LoopContext(kind="For")
        self.loop_stack.append(ctx)
        self.emit_block(stmt.body)
        self.loop_stack.pop()

        # ASSUMPTION: Continue For jumps to the Evaluate/auto-increment step
        # (see traditional-For note above -- same reasoning, same lack of a
        # direct test).
        evaluate_addr = len(self.code)
        for p in ctx.continue_patches:
            self.patch_branch(p, evaluate_addr)
        if stmt.evaluate is not None:
            self.emit_stmt(stmt.evaluate)
        else:
            # confirmed: default auto-increment when no Evaluate clause given
            self.emit_operand(var_slot); self.code.append(OP_LOAD_INT)
            self.code.append(OP_INC)
            self.emit_operand(var_slot); self.code.append(OP_STORE_INT)
        self.emit_goto_addr(V_GOTO, top)
        end_addr = len(self.code)
        self.patch_branch(exit_patch, end_addr)
        for p in ctx.exit_patches:
            self.patch_branch(p, end_addr)


def generate_bytecode(program: Program) -> bytes:
    return CodeGenerator().generate(program)
