"""
Tests for QuetzalWriter._generate_stks_chunk() serialization.

The Stks chunk serializes the Z-machine call stack. Each frame has:
- Bytes 0-2: return_pc (24-bit big-endian) — caller's resume PC
- Byte 3: flags (bits 0-3 = num local vars, bit 4 = discard result)
- Byte 4: varnum (which variable gets return value)
- Byte 5: argflag (bitmask of supplied arguments)
- Bytes 6-7: eval_stack_size (16-bit big-endian)
- Next (num_local_vars * 2) bytes: local variables
- Next (eval_stack_size * 2) bytes: evaluation stack values

All multi-byte values are big-endian. Bottom frame has return_pc=0.

Field mapping to runtime:
- return_pc for frame N → stored as program_counter on frame N-1 (the caller)
- varnum → stored as return_addr on the frame (the store variable)
"""

from unittest import TestCase

from mudlib.zmachine.quetzal import QuetzalWriter
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager


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

    def __init__(self):
        self.version = 5


class MockZMachine:
    """Mock z-machine with stack manager."""

    def __init__(self):
        self._mem = MockMemory()
        self._stackmanager = ZStackManager(self._mem)


class QuetzalStksTests(TestCase):
    """Test suite for Stks chunk generation."""

    def setUp(self):
        self.zmachine = MockZMachine()
        self.writer = QuetzalWriter(self.zmachine)

    def test_empty_call_stack_generates_empty_chunk(self):
        """With only the sentinel bottom, should generate empty bytes."""
        # Call stack has only ZStackBottom sentinel
        chunk = self.writer._generate_stks_chunk()
        self.assertEqual(chunk, b"")

    def test_single_frame_serialization(self):
        """Single routine frame should serialize correctly."""
        # Set up caller resume PC on ZStackBottom
        sentinel = self.zmachine._stackmanager._call_stack[0]
        sentinel.program_counter = 0x4200

        # Create a routine with return_addr = store variable (varnum 5)
        routine = ZRoutine(
            start_addr=0x5000,
            return_addr=5,
            zmem=self._mem,
            args=[],
            local_vars=[0x1234, 0x5678, 0xABCD],
            stack=[0x1111, 0x2222],
        )
        self.zmachine._stackmanager.push_routine(routine)

        chunk = self.writer._generate_stks_chunk()

        # return_pc comes from ZStackBottom.program_counter (0x4200)
        # varnum is frame.return_addr (5)
        expected = bytes(
            [
                0x00,
                0x42,
                0x00,  # return_pc (from caller's program_counter)
                0x03,  # flags (3 local vars)
                0x05,  # varnum (store variable)
                0x00,  # argflag
                0x00,
                0x02,  # eval_stack_size = 2
                0x12,
                0x34,  # local_vars[0]
                0x56,
                0x78,  # local_vars[1]
                0xAB,
                0xCD,  # local_vars[2]
                0x11,
                0x11,  # stack[0]
                0x22,
                0x22,  # stack[1]
            ]
        )
        self.assertEqual(chunk, expected)

    def test_multiple_frames_serialization(self):
        """Multiple nested frames should serialize in order."""
        sentinel = self.zmachine._stackmanager._call_stack[0]
        sentinel.program_counter = 0  # main routine has no caller

        # Frame 1: outer routine (varnum=0 means push result to stack)
        routine1 = ZRoutine(
            start_addr=0x1000,
            return_addr=0,
            zmem=self._mem,
            args=[],
            local_vars=[0x0001],
            stack=[],
        )
        self.zmachine._stackmanager.push_routine(routine1)
        # Set routine1's resume PC (where to go after frame2 returns)
        routine1.program_counter = 0x1050

        # Frame 2: inner routine (varnum=3)
        routine2 = ZRoutine(
            start_addr=0x2000,
            return_addr=3,
            zmem=self._mem,
            args=[],
            local_vars=[0x0002, 0x0003],
            stack=[0xAAAA],
        )
        self.zmachine._stackmanager.push_routine(routine2)

        chunk = self.writer._generate_stks_chunk()

        # Frame 1: return_pc from sentinel.pc (0), varnum=0
        frame1 = bytes(
            [
                0x00,
                0x00,
                0x00,  # return_pc = 0 (from sentinel)
                0x01,  # flags (1 local var)
                0x00,  # varnum = 0 (push to stack)
                0x00,  # argflag
                0x00,
                0x00,  # eval_stack_size = 0
                0x00,
                0x01,  # local_vars[0]
            ]
        )
        # Frame 2: return_pc from routine1.pc (0x1050), varnum=3
        frame2 = bytes(
            [
                0x00,
                0x10,
                0x50,  # return_pc (from routine1.program_counter)
                0x02,  # flags (2 local vars)
                0x03,  # varnum = 3
                0x00,  # argflag
                0x00,
                0x01,  # eval_stack_size = 1
                0x00,
                0x02,  # local_vars[0]
                0x00,
                0x03,  # local_vars[1]
                0xAA,
                0xAA,  # stack[0]
            ]
        )
        expected = frame1 + frame2
        self.assertEqual(chunk, expected)

    def test_frame_with_no_locals_or_stack(self):
        """Frame with no local vars or stack values should serialize correctly."""
        sentinel = self.zmachine._stackmanager._call_stack[0]
        sentinel.program_counter = 0x2500

        routine = ZRoutine(
            start_addr=0x3000,
            return_addr=1,
            zmem=self._mem,
            args=[],
            local_vars=[],
            stack=[],
        )
        self.zmachine._stackmanager.push_routine(routine)

        chunk = self.writer._generate_stks_chunk()

        expected = bytes(
            [
                0x00,
                0x25,
                0x00,  # return_pc (from sentinel.program_counter)
                0x00,  # flags (0 local vars)
                0x01,  # varnum = 1
                0x00,  # argflag
                0x00,
                0x00,  # eval_stack_size = 0
            ]
        )
        self.assertEqual(chunk, expected)

    def test_discard_result_sets_flags_bit4(self):
        """Frame with return_addr=None should set bit 4 in flags byte."""
        sentinel = self.zmachine._stackmanager._call_stack[0]
        sentinel.program_counter = 0

        routine = ZRoutine(
            start_addr=0x1000,
            return_addr=None,
            zmem=self._mem,
            args=[],
            local_vars=[0x0001, 0x0002],
            stack=[],
        )
        self.zmachine._stackmanager.push_routine(routine)

        chunk = self.writer._generate_stks_chunk()

        # flags = 0x02 (2 locals) | 0x10 (discard) = 0x12
        expected = bytes(
            [
                0x00,
                0x00,
                0x00,  # return_pc
                0x12,  # flags (2 locals + discard bit)
                0x00,  # varnum (0 when discarding)
                0x00,  # argflag
                0x00,
                0x00,  # eval_stack_size = 0
                0x00,
                0x01,  # local_vars[0]
                0x00,
                0x02,  # local_vars[1]
            ]
        )
        self.assertEqual(chunk, expected)

    def test_round_trip_with_parser(self):
        """Generated stks bytes should parse back identically."""
        from mudlib.zmachine.quetzal import QuetzalParser

        sentinel = self.zmachine._stackmanager._call_stack[0]
        sentinel.program_counter = 0  # main routine caller

        # Create a complex stack state
        routine1 = ZRoutine(
            start_addr=0x1000,
            return_addr=0,
            zmem=self._mem,
            args=[],
            local_vars=[0x1111, 0x2222, 0x3333],
            stack=[0xAAAA, 0xBBBB],
        )
        self.zmachine._stackmanager.push_routine(routine1)
        routine1.program_counter = 0x1234  # resume PC for after routine2

        routine2 = ZRoutine(
            start_addr=0x2000,
            return_addr=5,
            zmem=self._mem,
            args=[],
            local_vars=[0x4444],
            stack=[0xCCCC, 0xDDDD, 0xEEEE],
        )
        self.zmachine._stackmanager.push_routine(routine2)

        # Generate the stks chunk
        stks_bytes = self.writer._generate_stks_chunk()

        # Parse it back
        parser = QuetzalParser(self.zmachine)
        parser._parse_stks(stks_bytes)

        # Verify the stack was reconstructed correctly
        # Parser creates a new stack manager, skip bottom sentinel
        call_stack = self.zmachine._stackmanager._call_stack
        frames = call_stack[1:]
        self.assertEqual(len(frames), 2)

        # Check frame 1: return_addr = varnum, caller PC on sentinel
        assert isinstance(frames[0], ZRoutine)
        self.assertEqual(frames[0].return_addr, 0)
        self.assertEqual(frames[0].local_vars[:3], [0x1111, 0x2222, 0x3333])
        self.assertEqual(frames[0].stack, [0xAAAA, 0xBBBB])
        # Sentinel should have frame1's return_pc (0)
        self.assertEqual(call_stack[0].program_counter, 0)

        # Check frame 2: return_addr = varnum, caller PC on frame1
        assert isinstance(frames[1], ZRoutine)
        self.assertEqual(frames[1].return_addr, 5)
        self.assertEqual(frames[1].local_vars[:1], [0x4444])
        self.assertEqual(frames[1].stack, [0xCCCC, 0xDDDD, 0xEEEE])
        # Frame1 should have frame2's return_pc (0x1234)
        self.assertEqual(frames[0].program_counter, 0x1234)

    def test_parse_return_pc_goes_to_caller(self):
        """Parser should put return_pc on the caller frame's program_counter."""
        from mudlib.zmachine.quetzal import QuetzalParser

        # Construct a minimal stack frame with return_pc=0x123456
        stks_bytes = bytes(
            [
                0x12,
                0x34,
                0x56,  # return_pc = 0x123456
                0x00,  # flags (0 local vars)
                0x07,  # varnum = 7
                0x00,  # argflag
                0x00,
                0x00,  # eval_stack_size = 0
            ]
        )

        parser = QuetzalParser(self.zmachine)
        parser._parse_stks(stks_bytes)

        call_stack = self.zmachine._stackmanager._call_stack
        frames = call_stack[1:]  # skip sentinel
        self.assertEqual(len(frames), 1)

        # return_pc goes to caller (sentinel) program_counter
        self.assertEqual(call_stack[0].program_counter, 0x123456)
        # varnum goes to frame's return_addr
        assert isinstance(frames[0], ZRoutine)
        self.assertEqual(frames[0].return_addr, 7)

    def test_parse_discard_bit_restores_none(self):
        """Parser should set return_addr=None when flags bit 4 is set."""
        from mudlib.zmachine.quetzal import QuetzalParser

        # flags = 0x12 = 2 locals + discard bit
        stks_bytes = bytes(
            [
                0x00,
                0x00,
                0x00,  # return_pc = 0
                0x12,  # flags (2 locals + discard)
                0x00,  # varnum (ignored when discarding)
                0x00,  # argflag
                0x00,
                0x00,  # eval_stack_size = 0
                0x00,
                0x01,  # local_vars[0]
                0x00,
                0x02,  # local_vars[1]
            ]
        )

        parser = QuetzalParser(self.zmachine)
        parser._parse_stks(stks_bytes)

        frames = self.zmachine._stackmanager._call_stack[1:]
        self.assertEqual(len(frames), 1)
        self.assertIsNone(frames[0].return_addr)
        self.assertEqual(frames[0].local_vars[:2], [1, 2])

    @property
    def _mem(self):
        """Helper to get mock memory."""
        return self.zmachine._mem
