"""
Unit tests for the Z-machine opcodes and object parser.

These tests verify the basic behavior of each opcode by mocking the
required dependencies (memory, stack, decoder, etc).
"""

from unittest import TestCase
from unittest.mock import Mock

from mudlib.zmachine.zcpu import ZCpu, ZCpuDivideByZero, ZCpuQuit, ZCpuRestart


class MockMemory:
    """Mock memory for testing."""

    def __init__(self):
        self.data = bytearray(65536)
        self.version = 3
        self.globals = {}

    def __getitem__(self, addr):
        return self.data[addr]

    def __setitem__(self, addr, value):
        self.data[addr] = value & 0xFF

    def read_word(self, addr):
        return (self.data[addr] << 8) | self.data[addr + 1]

    def write_word(self, addr, value):
        self.data[addr] = (value >> 8) & 0xFF
        self.data[addr + 1] = value & 0xFF

    def read_global(self, varnum):
        return self.globals.get(varnum, 0)

    def write_global(self, varnum, value):
        self.globals[varnum] = value

    def generate_checksum(self):
        """Generate checksum from 0x40 onwards, modulo 0x10000."""
        total = sum(self.data[0x40:])
        return total % 0x10000


class MockStackManager:
    """Mock stack manager for testing."""

    def __init__(self):
        self.stack = []
        self.locals = [0] * 15
        # For QuetzalWriter support - empty call stack
        from mudlib.zmachine.zstackmanager import ZStackBottom

        self._call_stack = [ZStackBottom()]

    def push_stack(self, value):
        self.stack.append(value)

    def pop_stack(self):
        return self.stack.pop()

    def get_local_variable(self, index):
        return self.locals[index]

    def set_local_variable(self, index, value):
        self.locals[index] = value

    def finish_routine(self, return_value):
        # Mock implementation - just return a PC value
        return 0x1000


class MockOpDecoder:
    """Mock opcode decoder for testing."""

    def __init__(self):
        self.program_counter = 0x800
        self.store_address = None
        self.branch_condition = True
        self.branch_offset = 2

    def get_store_address(self):
        return self.store_address

    def get_branch_offset(self):
        return (self.branch_condition, self.branch_offset)


class MockUI:
    """Mock UI for testing."""

    def __init__(self):
        self.screen = Mock()
        self.screen.write = Mock()
        self.keyboard_input = Mock()
        self.keyboard_input.read_line = Mock()
        self.filesystem = Mock()


class ZMachineOpcodeTests(TestCase):
    """Test suite for Z-machine opcodes."""

    def setUp(self):
        """Create a minimal CPU for testing."""
        self.memory = MockMemory()
        self.stack = MockStackManager()
        self.decoder = MockOpDecoder()
        self.ui = MockUI()

        # Create CPU with mocked dependencies
        self.cpu = ZCpu(
            self.memory,
            self.decoder,
            self.stack,
            Mock(),  # objects
            Mock(),  # string
            Mock(),  # stream manager
            self.ui,
            Mock(),  # lexer
            zmachine=None,
        )

    def test_op_nop(self):
        """Test NOP does nothing."""
        # Should just return without error
        self.cpu.op_nop()

    def test_op_new_line(self):
        """Test new_line prints a newline."""
        self.cpu.op_new_line()
        self.ui.screen.write.assert_called_once_with("\n")

    def test_op_ret_popped(self):
        """Test ret_popped pops stack and returns."""
        self.stack.push_stack(42)
        self.cpu.op_ret_popped()
        # Should have popped the value and set PC
        self.assertEqual(len(self.stack.stack), 0)
        self.assertEqual(self.cpu._opdecoder.program_counter, 0x1000)

    def test_op_pop(self):
        """Test pop discards top of stack."""
        self.stack.push_stack(100)
        self.stack.push_stack(200)
        self.cpu.op_pop()
        self.assertEqual(len(self.stack.stack), 1)
        self.assertEqual(self.stack.stack[0], 100)

    def test_op_quit(self):
        """Test quit raises exception."""
        with self.assertRaises(ZCpuQuit):
            self.cpu.op_quit()

    def test_op_dec(self):
        """Test decrement variable."""
        # Set local variable 1 to 10
        self.stack.set_local_variable(0, 10)
        # Decrement it (variable 1 = local 0)
        self.cpu.op_dec(1)
        # Should be 9 now
        self.assertEqual(self.stack.get_local_variable(0), 9)

    def test_op_dec_wrapping(self):
        """Test decrement wraps at zero."""
        # Set local variable 1 to 0
        self.stack.set_local_variable(0, 0)
        # Decrement it
        self.cpu.op_dec(1)
        # Should wrap to 65535
        self.assertEqual(self.stack.get_local_variable(0), 65535)

    def test_op_not(self):
        """Test bitwise NOT."""
        self.decoder.store_address = 0  # Store to stack
        self.cpu.op_not(0x00FF)
        result = self.stack.pop_stack()
        self.assertEqual(result, 0xFF00)

    def test_op_not_all_ones(self):
        """Test NOT of all ones gives zero."""
        self.decoder.store_address = 0
        self.cpu.op_not(0xFFFF)
        result = self.stack.pop_stack()
        self.assertEqual(result, 0)

    def test_op_load(self):
        """Test load reads variable."""
        # Set local variable 2 to 42
        self.stack.set_local_variable(1, 42)
        self.decoder.store_address = 0  # Store to stack
        # Load variable 2
        self.cpu.op_load(2)
        result = self.stack.pop_stack()
        self.assertEqual(result, 42)

    def test_op_mod_positive(self):
        """Test modulo with positive numbers."""
        self.decoder.store_address = 0
        self.cpu.op_mod(17, 5)
        result = self.stack.pop_stack()
        self.assertEqual(result, 2)

    def test_op_mod_negative_dividend(self):
        """Test modulo with negative dividend."""
        self.decoder.store_address = 0
        # -17 mod 5 = -2 (z-machine uses C-style truncation toward zero)
        self.cpu.op_mod(self.cpu._unmake_signed(-17), 5)
        result = self.cpu._make_signed(self.stack.pop_stack())
        self.assertEqual(result, -2)

    def test_op_mod_divide_by_zero(self):
        """Test modulo by zero raises exception."""
        self.decoder.store_address = 0
        with self.assertRaises(ZCpuDivideByZero):
            self.cpu.op_mod(10, 0)

    def test_op_storeb(self):
        """Test store byte to memory."""
        self.cpu.op_storeb(0x1000, 5, 0x42)
        self.assertEqual(self.memory[0x1005], 0x42)

    def test_op_storeb_truncates(self):
        """Test store byte truncates to 8 bits."""
        self.cpu.op_storeb(0x2000, 10, 0x1FF)
        self.assertEqual(self.memory[0x200A], 0xFF)

    def test_op_jg_true(self):
        """Test jump if greater (signed) - true case."""
        self.decoder.branch_condition = True
        self.decoder.branch_offset = 100
        old_pc = self.cpu._opdecoder.program_counter
        self.cpu.op_jg(10, 5)
        # Should have branched (offset - 2)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 98)

    def test_op_jg_false(self):
        """Test jump if greater (signed) - false case."""
        self.decoder.branch_condition = True  # Branch if true (but test is false)
        self.decoder.branch_offset = 100
        old_pc = self.cpu._opdecoder.program_counter
        self.cpu.op_jg(5, 10)
        # Should not have branched (test is false, condition is true)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)

    def test_op_jg_signed(self):
        """Test jump if greater handles signed comparison."""
        # -1 (as unsigned 65535) should NOT be greater than 1
        self.decoder.branch_condition = False
        old_pc = self.cpu._opdecoder.program_counter
        self.cpu.op_jg(65535, 1)
        # Should not branch (false condition matches)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)

    def test_op_pull(self):
        """Test pull from stack to variable."""
        # Push value onto stack
        self.stack.push_stack(123)
        # Pull into local variable 1
        self.cpu.op_pull(1)
        # Should have stored in local variable 0 (variable 1)
        self.assertEqual(self.stack.get_local_variable(0), 123)
        # Stack should be empty
        self.assertEqual(len(self.stack.stack), 0)

    def test_op_print_addr(self):
        """Test print_addr decodes and prints text at byte address."""
        # Configure mock string decoder to return a known string
        self.cpu._string.get = Mock(return_value="Hello, world!")
        # Print text at address 0x5000
        self.cpu.op_print_addr(0x5000)
        # Should have called string decoder with the address
        self.cpu._string.get.assert_called_once_with(0x5000)
        # Should have written the decoded text
        self.ui.screen.write.assert_called_once_with("Hello, world!")

    def test_op_print_num_positive(self):
        """Test print_num prints positive number."""
        self.cpu.op_print_num(42)
        self.ui.screen.write.assert_called_once_with("42")

    def test_op_print_num_negative(self):
        """Test print_num prints negative number."""
        # -1 as unsigned 16-bit is 65535
        self.cpu.op_print_num(65535)
        self.ui.screen.write.assert_called_once_with("-1")

    def test_op_print_num_zero(self):
        """Test print_num prints zero."""
        self.cpu.op_print_num(0)
        self.ui.screen.write.assert_called_once_with("0")

    def test_op_ret(self):
        """Test ret returns from routine with value."""
        self.cpu.op_ret(42)
        # Should have set PC to caller's address (0x1000 from mock)
        self.assertEqual(self.cpu._opdecoder.program_counter, 0x1000)

    def test_op_show_status(self):
        """Test show_status is a no-op (V3 only, not needed in MUD)."""
        # Should just not raise an exception
        self.cpu.op_show_status()

    def test_op_test_all_flags_set(self):
        """Test op_test branches when all flags are set in bitmap."""
        self.decoder.branch_condition = True
        self.decoder.branch_offset = 10
        old_pc = self.cpu._opdecoder.program_counter

        # bitmap 0b11010110, flags 0b10010100 - all flags present
        self.cpu.op_test(0b11010110, 0b10010100)

        # Should branch (offset - 2)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 8)

    def test_op_test_some_flags_missing(self):
        """Test op_test doesn't branch when some flags are missing."""
        self.decoder.branch_condition = True
        old_pc = self.cpu._opdecoder.program_counter

        # bitmap 0b11010110, flags 0b10011100 - bit 3 missing
        self.cpu.op_test(0b11010110, 0b10011100)

        # Should not branch
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)

    def test_op_test_zero_flags(self):
        """Test op_test with zero flags always branches (all 0 flags set)."""
        self.decoder.branch_condition = True
        self.decoder.branch_offset = 15
        old_pc = self.cpu._opdecoder.program_counter

        # Any bitmap with flags=0 should pass (0 & 0 == 0)
        self.cpu.op_test(0b11111111, 0)

        # Should branch
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 13)

    def test_op_test_identical(self):
        """Test op_test branches when bitmap and flags are identical."""
        self.decoder.branch_condition = True
        self.decoder.branch_offset = 20
        old_pc = self.cpu._opdecoder.program_counter

        # Identical bitmap and flags
        self.cpu.op_test(0b10101010, 0b10101010)

        # Should branch
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 18)

    def test_op_verify_matching_checksum(self):
        """Test op_verify branches when checksum matches."""
        # Set expected checksum at 0x1C
        self.memory.write_word(0x1C, 0x1234)
        # Set data that produces matching checksum
        # checksum = sum(data[0x40:]) % 0x10000
        # For simplicity, set one byte to produce desired checksum
        self.memory[0x40] = 0x34
        self.memory[0x41] = 0x12
        # Sum = 0x34 + 0x12 = 0x46, need 0x1234
        # Set more bytes: 0x1234 - 0x46 = 0x11EE
        for i in range(0x42, 0x42 + 0x11EE):
            self.memory[i] = 1 if i < 0x42 + 0x11EE else 0

        self.decoder.branch_condition = True
        self.decoder.branch_offset = 25
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_verify()

        # Should branch
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 23)

    def test_op_verify_mismatched_checksum(self):
        """Test op_verify doesn't branch when checksum doesn't match."""
        # Set expected checksum at 0x1C
        self.memory.write_word(0x1C, 0x5678)
        # Memory data will produce different checksum (mostly zeros)

        self.decoder.branch_condition = True
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_verify()

        # Should not branch (checksums don't match)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)


class MockObjectParser:
    """Mock object parser for testing CPU opcodes."""

    def __init__(self):
        self.attributes = {}
        self.parents = {}
        self.siblings = {}
        self.children = {}
        self.shortnames = {}
        self.property_data_addresses = {}
        self.next_properties = {}

    def get_attribute(self, objnum, attrnum):
        return self.attributes.get((objnum, attrnum), 0)

    def set_attribute(self, objnum, attrnum):
        self.attributes[(objnum, attrnum)] = 1

    def clear_attribute(self, objnum, attrnum):
        self.attributes[(objnum, attrnum)] = 0

    def get_parent(self, objnum):
        return self.parents.get(objnum, 0)

    def get_sibling(self, objnum):
        return self.siblings.get(objnum, 0)

    def get_child(self, objnum):
        return self.children.get(objnum, 0)

    def remove_object(self, objnum):
        # Simple implementation - just clear parent
        self.parents[objnum] = 0

    def get_shortname(self, objnum):
        return self.shortnames.get(objnum, "object")

    def get_property_data_address(self, objnum, propnum):
        return self.property_data_addresses.get((objnum, propnum), 0)

    def get_next_property(self, objnum, propnum):
        return self.next_properties.get((objnum, propnum), 0)

    def get_property_length(self, data_address):
        # Simple mock - return 2 for non-zero addresses
        return 2 if data_address != 0 else 0


class ZMachineObjectOpcodeTests(TestCase):
    """Test suite for Z-machine object tree opcodes."""

    def setUp(self):
        """Create a CPU with mocked object parser."""
        self.memory = MockMemory()
        self.stack = MockStackManager()
        self.decoder = MockOpDecoder()
        self.ui = MockUI()
        self.objects = MockObjectParser()
        self.string = Mock()
        self.string.get = Mock(return_value="test object")

        self.cpu = ZCpu(
            self.memory,
            self.decoder,
            self.stack,
            self.objects,
            self.string,
            Mock(),  # stream manager
            self.ui,
            Mock(),  # lexer
            zmachine=None,
        )

    def test_op_get_sibling_with_sibling(self):
        """Test get_sibling stores sibling and branches if nonzero."""
        self.objects.siblings[5] = 7
        self.decoder.store_address = 0
        self.decoder.branch_condition = True
        self.decoder.branch_offset = 10
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_get_sibling(5)

        # Should store 7
        self.assertEqual(self.stack.pop_stack(), 7)
        # Should branch (offset - 2)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 8)

    def test_op_get_sibling_no_sibling(self):
        """Test get_sibling with no sibling doesn't branch."""
        self.objects.siblings[5] = 0
        self.decoder.store_address = 0
        self.decoder.branch_condition = True
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_get_sibling(5)

        # Should store 0
        self.assertEqual(self.stack.pop_stack(), 0)
        # Should not branch
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)

    def test_op_test_attr_true(self):
        """Test test_attr branches when attribute is set."""
        self.objects.attributes[(10, 5)] = 1
        self.decoder.branch_condition = True
        self.decoder.branch_offset = 20
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_test_attr(10, 5)

        # Should branch
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 18)

    def test_op_test_attr_false(self):
        """Test test_attr doesn't branch when attribute is clear."""
        self.objects.attributes[(10, 5)] = 0
        self.decoder.branch_condition = True
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_test_attr(10, 5)

        # Should not branch
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)

    def test_op_set_attr(self):
        """Test set_attr sets attribute on object."""
        self.cpu.op_set_attr(15, 3)
        # Should have called set_attribute
        self.assertEqual(self.objects.attributes.get((15, 3)), 1)

    def test_op_clear_attr(self):
        """Test clear_attr clears attribute on object."""
        self.objects.attributes[(15, 3)] = 1
        self.cpu.op_clear_attr(15, 3)
        # Should have called clear_attribute
        self.assertEqual(self.objects.attributes.get((15, 3)), 0)

    def test_op_jin_true(self):
        """Test jin branches when obj1 parent equals obj2."""
        self.objects.parents[5] = 10
        self.decoder.branch_condition = True
        self.decoder.branch_offset = 15
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_jin(5, 10)

        # Should branch
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 13)

    def test_op_jin_false(self):
        """Test jin doesn't branch when obj1 parent not equal to obj2."""
        self.objects.parents[5] = 8
        self.decoder.branch_condition = True
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_jin(5, 10)

        # Should not branch
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)

    def test_op_remove_obj(self):
        """Test remove_obj removes object from parent."""
        self.objects.parents[7] = 3
        self.cpu.op_remove_obj(7)
        # Parent should be cleared
        self.assertEqual(self.objects.parents.get(7), 0)

    def test_op_print_obj(self):
        """Test print_obj prints object's short name."""
        self.objects.shortnames[12] = "brass lantern"
        self.cpu.op_print_obj(12)
        self.ui.screen.write.assert_called_once_with("brass lantern")

    def test_op_get_prop_addr_found(self):
        """Test get_prop_addr stores data address when property exists."""
        self.objects.property_data_addresses[(20, 5)] = 0x5000
        self.decoder.store_address = 0

        self.cpu.op_get_prop_addr(20, 5)

        # Should store the data address
        self.assertEqual(self.stack.pop_stack(), 0x5000)

    def test_op_get_prop_addr_not_found(self):
        """Test get_prop_addr stores 0 when property doesn't exist."""
        self.decoder.store_address = 0

        self.cpu.op_get_prop_addr(20, 99)

        # Should store 0
        self.assertEqual(self.stack.pop_stack(), 0)

    def test_op_get_next_prop_first(self):
        """Test get_next_prop with propnum=0 returns first property."""
        self.objects.next_properties[(25, 0)] = 15
        self.decoder.store_address = 0

        self.cpu.op_get_next_prop(25, 0)

        # Should store first property number
        self.assertEqual(self.stack.pop_stack(), 15)

    def test_op_get_next_prop_next(self):
        """Test get_next_prop with propnum>0 returns next property."""
        self.objects.next_properties[(25, 10)] = 8
        self.decoder.store_address = 0

        self.cpu.op_get_next_prop(25, 10)

        # Should store next property number
        self.assertEqual(self.stack.pop_stack(), 8)

    def test_op_get_prop_len(self):
        """Test get_prop_len returns property data length."""
        self.decoder.store_address = 0

        self.cpu.op_get_prop_len(0x6000)

        # Should store 2 (from mock)
        self.assertEqual(self.stack.pop_stack(), 2)

    def test_op_get_prop_len_zero_addr(self):
        """Test get_prop_len with address 0 returns 0."""
        self.decoder.store_address = 0

        self.cpu.op_get_prop_len(0)

        # Should store 0
        self.assertEqual(self.stack.pop_stack(), 0)

    def test_op_get_child_with_child(self):
        """Test get_child stores child and branches if nonzero."""
        self.objects.children[10] = 5
        self.decoder.store_address = 0
        self.decoder.branch_condition = True
        self.decoder.branch_offset = 12
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_get_child(10)

        # Should store 5
        self.assertEqual(self.stack.pop_stack(), 5)
        # Should branch (offset - 2)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 10)

    def test_op_get_child_no_child(self):
        """Test get_child with no child doesn't branch."""
        self.objects.children[10] = 0
        self.decoder.store_address = 0
        self.decoder.branch_condition = True
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_get_child(10)

        # Should store 0
        self.assertEqual(self.stack.pop_stack(), 0)
        # Should not branch
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)


class ZMachineComplexOpcodeTests(TestCase):
    """Test suite for complex Z-machine opcodes (input, save/restore, restart)."""

    def setUp(self):
        """Create a CPU with all necessary mocks."""
        self.memory = MockMemory()
        self.stack = MockStackManager()
        self.decoder = MockOpDecoder()
        self.ui = MockUI()

        # Create CPU with mocked dependencies
        self.cpu = ZCpu(
            self.memory,
            self.decoder,
            self.stack,
            Mock(),  # objects
            Mock(),  # string
            Mock(),  # stream manager
            self.ui,
            Mock(),  # lexer
            zmachine=None,
        )

    def test_op_sread_v3_basic_input(self):
        """Test sread (V3) reads text into buffer and null-terminates."""
        # Setup: text buffer at 0x1000, max length 20
        text_buffer_addr = 0x1000
        self.memory[text_buffer_addr] = 20  # max length

        # Mock keyboard input
        self.ui.keyboard_input.read_line = Mock(return_value="hello world\n")

        # Call sread with text buffer only (no parse buffer)
        self.cpu.op_sread(text_buffer_addr, 0)

        # Verify text is stored lowercased starting at offset 1
        expected = "hello world"
        for i, ch in enumerate(expected):
            self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))

        # Verify null termination
        self.assertEqual(self.memory[text_buffer_addr + 1 + len(expected)], 0)

    def test_op_sread_v3_truncates_to_max_length(self):
        """Test sread respects max length in buffer."""
        text_buffer_addr = 0x1000
        self.memory[text_buffer_addr] = 5  # max length of 5

        self.ui.keyboard_input.read_line = Mock(return_value="hello world\n")

        self.cpu.op_sread(text_buffer_addr, 0)

        # Should only store first 5 characters
        expected = "hello"
        for i, ch in enumerate(expected):
            self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))
        self.assertEqual(self.memory[text_buffer_addr + 1 + 5], 0)

    def test_op_sread_v3_lowercases_input(self):
        """Test sread converts input to lowercase."""
        text_buffer_addr = 0x1000
        self.memory[text_buffer_addr] = 20

        self.ui.keyboard_input.read_line = Mock(return_value="HELLO World\n")

        self.cpu.op_sread(text_buffer_addr, 0)

        expected = "hello world"
        for i, ch in enumerate(expected):
            self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))

    def test_op_sread_v3_strips_newlines(self):
        """Test sread strips newlines and carriage returns."""
        text_buffer_addr = 0x1000
        self.memory[text_buffer_addr] = 20

        self.ui.keyboard_input.read_line = Mock(return_value="test\r\n")

        self.cpu.op_sread(text_buffer_addr, 0)

        expected = "test"
        for i, ch in enumerate(expected):
            self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))
        self.assertEqual(self.memory[text_buffer_addr + 1 + 4], 0)

    def test_op_sread_v3_calls_show_status(self):
        """Test sread calls op_show_status for V3."""
        text_buffer_addr = 0x1000
        self.memory[text_buffer_addr] = 20
        self.ui.keyboard_input.read_line = Mock(return_value="test\n")

        # Track if show_status was called
        call_count = [0]
        original = self.cpu.op_show_status

        def counted_show_status(*args):
            call_count[0] += 1
            return original(*args)

        self.cpu.op_show_status = counted_show_status  # type: ignore

        self.cpu.op_sread(text_buffer_addr, 0)

        # Should have called show_status once
        self.assertEqual(call_count[0], 1)

    def test_op_save_v3_branches_false_when_filesystem_fails(self):
        """Test save (V3) branches false when filesystem returns False."""
        # Need a valid zmachine for the test to proceed
        from mudlib.zmachine.zmemory import ZMemory

        story = bytearray(1024)
        story[0] = 3
        story[0x0E] = 0x04
        story[0x0F] = 0x00

        zmachine_mock = Mock()
        zmachine_mock._mem = ZMemory(bytes(story))
        zmachine_mock._pristine_mem = ZMemory(bytes(story))
        zmachine_mock._cpu = self.cpu
        zmachine_mock._stackmanager = self.stack

        self.cpu._zmachine = zmachine_mock

        # Mock filesystem to fail
        self.ui.filesystem.save_game = Mock(return_value=False)

        self.decoder.branch_condition = True
        self.decoder.branch_offset = 100
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_save()

        # Should not have branched (test is false)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)

    def test_op_restore_v3_branches_false_when_filesystem_returns_none(self):
        """Test restore (V3) branches false when filesystem returns None."""
        from mudlib.zmachine.zmemory import ZMemory

        story = bytearray(1024)
        story[0] = 3
        story[0x0E] = 0x04
        story[0x0F] = 0x00

        zmachine_mock = Mock()
        zmachine_mock._mem = ZMemory(bytes(story))
        zmachine_mock._pristine_mem = ZMemory(bytes(story))
        zmachine_mock._cpu = self.cpu
        zmachine_mock._stackmanager = self.stack

        self.cpu._zmachine = zmachine_mock

        # Mock filesystem to return None (user cancelled or no file)
        self.ui.filesystem.restore_game = Mock(return_value=None)

        self.decoder.branch_condition = True
        self.decoder.branch_offset = 100
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_restore()

        # Should not have branched (test is false)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)

    def test_op_restore_v3_branches_false_when_no_zmachine(self):
        """Test restore (V3) branches false when zmachine is not set."""
        self.cpu._zmachine = None

        self.decoder.branch_condition = True
        self.decoder.branch_offset = 100
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_restore()

        # Should not have branched (test is false)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)

    def test_op_restore_v3_branches_true_on_success(self):
        """Test restore (V3) branches true when restore succeeds."""
        from mudlib.zmachine.quetzal import QuetzalWriter
        from mudlib.zmachine.zmemory import ZMemory

        # Create a story with some dynamic memory
        story = bytearray(1024)
        story[0] = 3  # version 3
        story[0x0E] = 0x04  # static memory starts at 0x0400
        story[0x0F] = 0x00
        # Set header values
        story[0x02] = 0x12  # release high byte
        story[0x03] = 0x34  # release low byte
        for i, byte in enumerate(b"860509"):
            story[0x12 + i] = byte
        story[0x1C] = 0xAB  # checksum high
        story[0x1D] = 0xCD  # checksum low

        # Create zmachine with modified memory state
        zmachine_mock = Mock()
        zmachine_mock._mem = ZMemory(bytes(story))
        zmachine_mock._pristine_mem = ZMemory(bytes(story))
        zmachine_mock._cpu = self.cpu
        zmachine_mock._stackmanager = self.stack

        # Modify some dynamic memory to create a save state
        zmachine_mock._mem[0x100] = 0x42
        zmachine_mock._mem[0x200] = 0x99

        self.cpu._zmachine = zmachine_mock

        # Generate save data using QuetzalWriter
        writer = QuetzalWriter(zmachine_mock)
        save_data = writer.generate_save_data()

        # Mock filesystem to return the save data
        self.ui.filesystem.restore_game = Mock(return_value=save_data)

        self.decoder.branch_condition = True
        self.decoder.branch_offset = 10
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_restore()

        # Should have branched (test is true)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 8)

    def test_op_restore_v3_restores_memory_state(self):
        """Test restore (V3) correctly restores dynamic memory."""
        from mudlib.zmachine.quetzal import QuetzalWriter
        from mudlib.zmachine.zmemory import ZMemory

        # Create a story
        story = bytearray(1024)
        story[0] = 3
        story[0x0E] = 0x04
        story[0x0F] = 0x00
        story[0x02] = 0x12
        story[0x03] = 0x34
        for i, byte in enumerate(b"860509"):
            story[0x12 + i] = byte
        story[0x1C] = 0xAB
        story[0x1D] = 0xCD

        # Create zmachine with saved state
        saved_zmachine = Mock()
        saved_zmachine._mem = ZMemory(bytes(story))
        saved_zmachine._pristine_mem = ZMemory(bytes(story))
        saved_zmachine._cpu = self.cpu
        saved_zmachine._stackmanager = self.stack

        # Modify memory to create unique save state
        saved_zmachine._mem[0x50] = 0xAA
        saved_zmachine._mem[0x150] = 0xBB
        saved_zmachine._mem[0x250] = 0xCC

        # Generate save data
        writer = QuetzalWriter(saved_zmachine)
        save_data = writer.generate_save_data()

        # Create fresh zmachine with different state
        current_zmachine = Mock()
        current_zmachine._mem = ZMemory(bytes(story))
        current_zmachine._pristine_mem = ZMemory(bytes(story))
        current_zmachine._cpu = self.cpu
        current_zmachine._stackmanager = self.stack

        # Different values in memory
        current_zmachine._mem[0x50] = 0x11
        current_zmachine._mem[0x150] = 0x22
        current_zmachine._mem[0x250] = 0x33

        self.cpu._zmachine = current_zmachine

        # Mock filesystem to return save data
        self.ui.filesystem.restore_game = Mock(return_value=save_data)

        self.decoder.branch_condition = True
        self.decoder.branch_offset = 10

        self.cpu.op_restore()

        # Memory should now match saved state
        self.assertEqual(current_zmachine._mem[0x50], 0xAA)
        self.assertEqual(current_zmachine._mem[0x150], 0xBB)
        self.assertEqual(current_zmachine._mem[0x250], 0xCC)

    def test_op_restore_v3_branches_false_on_malformed_data(self):
        """Test restore (V3) branches false when save data is malformed."""
        from mudlib.zmachine.zmemory import ZMemory

        story = bytearray(1024)
        story[0] = 3
        story[0x0E] = 0x04
        story[0x0F] = 0x00

        zmachine_mock = Mock()
        zmachine_mock._mem = ZMemory(bytes(story))
        zmachine_mock._pristine_mem = ZMemory(bytes(story))
        zmachine_mock._cpu = self.cpu
        zmachine_mock._stackmanager = self.stack

        self.cpu._zmachine = zmachine_mock

        # Mock filesystem to return invalid data
        self.ui.filesystem.restore_game = Mock(return_value=b"invalid data")

        self.decoder.branch_condition = True
        self.decoder.branch_offset = 10
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_restore()

        # Should not have branched (test is false)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)

    def test_op_save_v3_branches_true_on_success(self):
        """Test save (V3) branches true when filesystem succeeds."""
        # Create minimal zmachine mock
        from mudlib.zmachine.zmemory import ZMemory

        story = bytearray(1024)
        story[0] = 3  # version 3
        story[0x0E] = 0x04  # static memory starts at 0x0400
        story[0x0F] = 0x00

        zmachine_mock = Mock()
        zmachine_mock._mem = ZMemory(bytes(story))
        zmachine_mock._pristine_mem = ZMemory(bytes(story))
        zmachine_mock._cpu = self.cpu
        zmachine_mock._stackmanager = self.stack

        # Attach zmachine to cpu
        self.cpu._zmachine = zmachine_mock

        # Mock filesystem to succeed
        self.ui.filesystem.save_game = Mock(return_value=True)

        self.decoder.branch_condition = True
        self.decoder.branch_offset = 10
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_save()

        # Should have branched (test is true)
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 8)
        # Filesystem should have been called with bytes
        self.assertTrue(self.ui.filesystem.save_game.called)
        call_args = self.ui.filesystem.save_game.call_args[0]
        self.assertIsInstance(call_args[0], bytes)

    def test_op_save_v3_generates_valid_iff_data(self):
        """Test save generates valid IFF/FORM/IFZS container."""
        from mudlib.zmachine.zmemory import ZMemory

        story = bytearray(1024)
        story[0] = 3
        story[0x0E] = 0x04
        story[0x0F] = 0x00
        # Set header values
        story[0x02] = 0x12  # release high byte
        story[0x03] = 0x34  # release low byte
        for i, byte in enumerate(b"860509"):
            story[0x12 + i] = byte
        story[0x1C] = 0xAB  # checksum high
        story[0x1D] = 0xCD  # checksum low

        zmachine_mock = Mock()
        zmachine_mock._mem = ZMemory(bytes(story))
        zmachine_mock._pristine_mem = ZMemory(bytes(story))
        zmachine_mock._cpu = self.cpu
        zmachine_mock._stackmanager = self.stack

        self.cpu._zmachine = zmachine_mock

        # Mock filesystem to capture data
        captured_data = []

        def capture_save(data):
            captured_data.append(data)
            return True

        self.ui.filesystem.save_game = Mock(side_effect=capture_save)

        self.decoder.branch_condition = True
        self.decoder.branch_offset = 10

        self.cpu.op_save()

        # Verify we got data
        self.assertEqual(len(captured_data), 1)
        data = captured_data[0]

        # Verify IFF structure
        self.assertEqual(data[0:4], b"FORM")  # FORM header
        # Bytes 4-7 are the size (big-endian 32-bit)
        self.assertEqual(data[8:12], b"IFZS")  # IFZS type

        # Verify chunks are present
        self.assertIn(b"IFhd", data)
        self.assertIn(b"CMem", data)
        self.assertIn(b"Stks", data)
        self.assertIn(b"ANNO", data)

    def test_op_save_v3_without_zmachine_branches_false(self):
        """Test save fails gracefully when zmachine is not set."""
        # Don't set zmachine
        self.cpu._zmachine = None

        self.decoder.branch_condition = True
        old_pc = self.cpu._opdecoder.program_counter

        self.cpu.op_save()

        # Should not have branched
        self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)

    def test_op_input_stream_is_noop(self):
        """Test input_stream is a no-op stub."""
        # Should not raise
        self.cpu.op_input_stream(1)

    def test_op_sound_effect_is_noop(self):
        """Test sound_effect is a no-op stub."""
        # Should not raise
        self.cpu.op_sound_effect(1, 2, 3)

    def test_op_restart_raises_exception(self):
        """Test restart raises ZCpuRestart exception for run loop to handle."""
        with self.assertRaises(ZCpuRestart):
            self.cpu.op_restart()


class QuetzalWriterTests(TestCase):
    """Test suite for QuetzalWriter save functionality."""

    def test_generate_ifhd_chunk(self):
        """Test _generate_ifhd_chunk() produces correct 13-byte IFhd chunk."""
        from mudlib.zmachine.quetzal import QuetzalWriter

        # Create a mock zmachine with known header values
        mock_zmachine = Mock()
        mock_zmachine._mem = MockMemory()

        # Set header values in memory:
        # Bytes 2-3: Release number (0x1234)
        mock_zmachine._mem.write_word(0x02, 0x1234)
        # Bytes 0x12-0x17: Serial number (6 bytes: "860509")
        serial = b"860509"
        for i, byte in enumerate(serial):
            mock_zmachine._mem[0x12 + i] = byte
        # Bytes 0x1C-0x1D: Checksum (0xABCD)
        mock_zmachine._mem.write_word(0x1C, 0xABCD)

        # Set program counter
        mock_cpu = Mock()
        mock_cpu._program_counter = 0x123456  # 24-bit PC
        mock_zmachine._cpu = mock_cpu

        # Create writer and generate chunk
        writer = QuetzalWriter(mock_zmachine)
        chunk_data = writer._generate_ifhd_chunk()

        # Verify chunk is exactly 13 bytes
        self.assertEqual(len(chunk_data), 13)

        # Verify release number (bytes 0-1)
        self.assertEqual(chunk_data[0], 0x12)
        self.assertEqual(chunk_data[1], 0x34)

        # Verify serial number (bytes 2-7)
        for i, expected in enumerate(serial):
            self.assertEqual(chunk_data[2 + i], expected)

        # Verify checksum (bytes 8-9)
        self.assertEqual(chunk_data[8], 0xAB)
        self.assertEqual(chunk_data[9], 0xCD)

        # Verify program counter (bytes 10-12)
        self.assertEqual(chunk_data[10], 0x12)
        self.assertEqual(chunk_data[11], 0x34)
        self.assertEqual(chunk_data[12], 0x56)

    def test_cmem_all_unchanged(self):
        """Test CMem chunk with no changes (all zeros after XOR)."""
        from mudlib.zmachine.quetzal import QuetzalWriter
        from mudlib.zmachine.zmemory import ZMemory

        # Create a minimal z3 story file
        story = bytearray(1024)
        story[0] = 3  # version 3
        story[0x0E] = 0x04  # static memory starts at 0x0400
        story[0x0F] = 0x00

        pristine = ZMemory(bytes(story))
        current = ZMemory(bytes(story))

        zmachine = Mock()
        zmachine._pristine_mem = pristine
        zmachine._mem = current

        writer = QuetzalWriter(zmachine)
        result = writer._generate_cmem_chunk()

        # All identical means no output (trailing zeros omitted)
        self.assertIsInstance(result, bytes)
        self.assertEqual(len(result), 0)

    def test_cmem_single_byte_change(self):
        """Test CMem chunk with one byte changed."""
        from mudlib.zmachine.quetzal import QuetzalWriter
        from mudlib.zmachine.zmemory import ZMemory

        story = bytearray(1024)
        story[0] = 3
        story[0x0E] = 0x04
        story[0x0F] = 0x00

        pristine = ZMemory(bytes(story))
        current = ZMemory(bytes(story))
        current[0x0100] = 0x42

        zmachine = Mock()
        zmachine._pristine_mem = pristine
        zmachine._mem = current

        writer = QuetzalWriter(zmachine)
        result = writer._generate_cmem_chunk()

        self.assertIsInstance(result, bytes)
        self.assertIn(0x42, result)

    def test_cmem_multiple_scattered_changes(self):
        """Test CMem chunk with multiple changes across memory."""
        from mudlib.zmachine.quetzal import QuetzalWriter
        from mudlib.zmachine.zmemory import ZMemory

        story = bytearray(1024)
        story[0] = 3
        story[0x0E] = 0x04
        story[0x0F] = 0x00

        pristine = ZMemory(bytes(story))
        current = ZMemory(bytes(story))
        current[0x0010] = 0xAA
        current[0x0100] = 0xBB
        current[0x0200] = 0xCC

        zmachine = Mock()
        zmachine._pristine_mem = pristine
        zmachine._mem = current

        writer = QuetzalWriter(zmachine)
        result = writer._generate_cmem_chunk()

        self.assertIsInstance(result, bytes)
        self.assertIn(0xAA, result)
        self.assertIn(0xBB, result)
        self.assertIn(0xCC, result)
        self.assertLess(len(result), 1024)

    def test_cmem_roundtrip_with_parser(self):
        """Test that CMem output can be decoded by QuetzalParser._parse_cmem()."""
        from mudlib.zmachine.quetzal import QuetzalParser, QuetzalWriter
        from mudlib.zmachine.zmemory import ZMemory

        story = bytearray(1024)
        story[0] = 3
        story[0x0E] = 0x04
        story[0x0F] = 0x00

        pristine = ZMemory(bytes(story))
        current = ZMemory(bytes(story))
        current[0x0050] = 0x12
        current[0x0051] = 0x34
        current[0x0150] = 0xAB
        current[0x0300] = 0xFF

        zmachine = Mock()
        zmachine._pristine_mem = pristine
        zmachine._mem = current

        writer = QuetzalWriter(zmachine)
        compressed_bytes = writer._generate_cmem_chunk()

        # Create fresh memory for parsing into
        restored = ZMemory(bytes(story))
        restored_zmachine = Mock()
        restored_zmachine._pristine_mem = pristine
        restored_zmachine._mem = restored

        parser = QuetzalParser(restored_zmachine)
        parser._parse_cmem(compressed_bytes)

        # Verify restored memory matches current memory
        for addr in [0x0050, 0x0051, 0x0150, 0x0300]:
            self.assertEqual(
                restored[addr],
                current[addr],
                f"Mismatch at address 0x{addr:04X}",
            )

    def test_cmem_consecutive_zeros(self):
        """Test CMem encoding handles consecutive zero XOR results correctly."""
        from mudlib.zmachine.quetzal import QuetzalWriter
        from mudlib.zmachine.zmemory import ZMemory

        story = bytearray(1024)
        story[0] = 3
        story[0x0E] = 0x04
        story[0x0F] = 0x00

        pristine = ZMemory(bytes(story))
        current = ZMemory(bytes(story))
        current[0x0040] = 0x11
        current[0x0045] = 0x22

        zmachine = Mock()
        zmachine._pristine_mem = pristine
        zmachine._mem = current

        writer = QuetzalWriter(zmachine)
        result = writer._generate_cmem_chunk()

        self.assertIsInstance(result, bytes)
        idx_11 = result.index(0x11)
        self.assertEqual(result[idx_11 + 1], 0x00)
        self.assertEqual(result[idx_11 + 2], 0x03)
        self.assertEqual(result[idx_11 + 3], 0x22)


# Note: ZObjectParser methods are tested through integration tests
# with real story files, not unit tests with mock memory, as the
# interaction with ZStringFactory makes mocking complex.
