#
# A class which represents the z-machine's main memory bank.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#

from .zlogging import log

# This class that represents the "main memory" of the z-machine.  It's
# readable and writable through normal indexing and slice notation,
# just like a typical python 'sequence' object (e.g. mem[342] and
# mem[22:90]).  The class validates memory layout, enforces read-only
# areas of memory, and also the ability to return both word-addresses
# and 'packed' addresses.


class ZMemoryError(Exception):
    "General exception for ZMemory class"

    pass


class ZMemoryIllegalWrite(ZMemoryError):
    "Tried to write to a read-only part of memory"

    def __init__(self, address):
        super().__init__(f"Illegal write to address {address}")


class ZMemoryBadInitialization(ZMemoryError):
    "Failure to initialize ZMemory class"

    pass


class ZMemoryOutOfBounds(ZMemoryError):
    "Accessed an address beyond the bounds of memory."

    pass


class ZMemoryBadMemoryLayout(ZMemoryError):
    "Static plus dynamic memory exceeds 64k"

    pass


class ZMemoryBadStoryfileSize(ZMemoryError):
    "Story is too large for Z-machine version."

    pass


class ZMemoryUnsupportedVersion(ZMemoryError):
    "Unsupported version of Z-story file."

    pass


class ZMemory:
    # A list of 64 tuples describing who's allowed to tweak header-bytes.
    # Index into the list is the header-byte being tweaked.
    # List value is a tuple of the form
    #
    #         [minimum_z_version, game_allowed, interpreter_allowed]
    #
    # Note: in section 11.1 of the spec, we should technically be
    # enforcing authorization by *bit*, not by byte.  Maybe do this
    # someday.

    HEADER_PERMS = (
        [1, 0, 0],
        [3, 0, 1],
        None,
        None,
        [1, 0, 0],
        None,
        [1, 0, 0],
        None,
        [1, 0, 0],
        None,
        [1, 0, 0],
        None,
        [1, 0, 0],
        None,
        [1, 0, 0],
        None,
        [1, 1, 1],
        [1, 1, 1],
        None,
        None,
        None,
        None,
        None,
        None,
        [2, 0, 0],
        None,
        [3, 0, 0],
        None,
        [3, 0, 0],
        None,
        [4, 1, 1],
        [4, 1, 1],
        [4, 0, 1],
        [4, 0, 1],
        [5, 0, 1],
        None,
        [5, 0, 1],
        None,
        [5, 0, 1],
        [5, 0, 1],
        [6, 0, 0],
        None,
        [6, 0, 0],
        None,
        [5, 0, 1],
        [5, 0, 1],
        [5, 0, 0],
        None,
        [6, 0, 1],
        None,
        [1, 0, 1],
        None,
        [5, 0, 0],
        None,
        [5, 0, 0],
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
    )

    def __init__(self, initial_string):
        """Construct class based on a string that represents an initial
        'snapshot' of main memory."""
        if initial_string is None:
            raise ZMemoryBadInitialization

        # Copy string into a _memory sequence that represents main memory.
        self._total_size = len(initial_string)
        self._memory = bytearray(initial_string)

        # Figure out the different sections of memory
        self._static_start = self.read_word(0x0E)
        self._static_end = min(0x0FFFF, self._total_size)
        self._dynamic_start = 0
        self._dynamic_end = self._static_start - 1
        self._high_start = self.read_word(0x04)
        self._high_end = self._total_size
        self._global_variable_start = self.read_word(0x0C)

        # Dynamic + static must not exceed 64k
        dynamic_plus_static = (self._dynamic_end - self._dynamic_start) + (
            self._static_end - self._static_start
        )
        if dynamic_plus_static > 65534:
            raise ZMemoryBadMemoryLayout

        # What z-machine version is this story file?
        self.version = self._memory[0]

        # Validate game size
        if 1 <= self.version <= 3:
            if self._total_size > 131072:
                raise ZMemoryBadStoryfileSize
        elif 4 <= self.version <= 5:
            if self._total_size > 262144:
                raise ZMemoryBadStoryfileSize
        elif self.version == 8:
            if self._total_size > 524288:
                raise ZMemoryBadStoryfileSize
        else:
            raise ZMemoryUnsupportedVersion

        log("Memory system initialized, map follows")
        log(f"  Dynamic memory: {self._dynamic_start:x} - {self._dynamic_end:x}")
        log(f"  Static memory: {self._static_start:x} - {self._static_end:x}")
        log(f"  High memory: {self._high_start:x} - {self._high_end:x}")
        log(f"  Global variable start: {self._global_variable_start:x}")

    def _check_bounds(self, index):
        if isinstance(index, slice):
            start, stop = index.start, index.stop
            # For slices, stop can be _total_size since slicing is exclusive
            if not (
                (0 <= start < self._total_size) and (0 <= stop <= self._total_size)
            ):
                raise ZMemoryOutOfBounds
        else:
            start, stop = index, index
            if not ((0 <= start < self._total_size) and (0 <= stop < self._total_size)):
                raise ZMemoryOutOfBounds

    def _check_static(self, index):
        """Throw error if INDEX is within the static-memory area."""
        if isinstance(index, slice):
            start, stop = index.start, index.stop
        else:
            start, stop = index, index
        if (
            self._static_start <= start <= self._static_end
            and self._static_start <= stop <= self._static_end
        ):
            raise ZMemoryIllegalWrite(index)

    def print_map(self):
        """Pretty-print a description of the memory map."""
        print("Dynamic memory: ", self._dynamic_start, "-", self._dynamic_end)
        print(" Static memory: ", self._static_start, "-", self._static_end)
        print("   High memory: ", self._high_start, "-", self._high_end)

    def __getitem__(self, index):
        """Return the byte value stored at address INDEX.."""
        self._check_bounds(index)
        return self._memory[index]

    def __setitem__(self, index, value):
        """Set VALUE in memory address INDEX."""
        self._check_bounds(index)
        self._check_static(index)
        self._memory[index] = value

    def __getslice__(self, start, end):
        """Return a sequence of bytes from memory."""
        self._check_bounds(start)
        self._check_bounds(end)
        return self._memory[start:end]

    def __setslice__(self, start, end, sequence):
        """Set a range of memory addresses to SEQUENCE."""
        self._check_bounds(start)
        self._check_bounds(end - 1)
        self._check_static(start)
        self._check_static(end - 1)
        self._memory[start:end] = sequence

    def word_address(self, address):
        """Return the 'actual' address of word address ADDRESS."""
        if address < 0 or address > (self._total_size // 2):
            raise ZMemoryOutOfBounds
        return address * 2

    def packed_address(self, address):
        """Return the 'actual' address of packed address ADDRESS."""
        if 1 <= self.version <= 3:
            if address < 0 or address > (self._total_size // 2):
                raise ZMemoryOutOfBounds
            return address * 2
        elif 4 <= self.version <= 5:
            if address < 0 or address > (self._total_size // 4):
                raise ZMemoryOutOfBounds
            return address * 4
        elif self.version == 8:
            if address < 0 or address > (self._total_size // 8):
                raise ZMemoryOutOfBounds
            return address * 8
        else:
            raise ZMemoryUnsupportedVersion

    def read_word(self, address):
        """Return the 16-bit value stored at ADDRESS, ADDRESS+1."""
        if address < 0 or address >= (self._total_size - 1):
            raise ZMemoryOutOfBounds
        return (self._memory[address] << 8) + self._memory[(address + 1)]

    def write_word(self, address, value):
        """Write the given 16-bit value at ADDRESS, ADDRESS+1.

        _check_static() is sufficient for permission checking because the
        dynamic memory region (which includes the header) is always writable
        by the game per Z-machine spec.
        """
        if address < 0 or address >= (self._total_size - 1):
            raise ZMemoryOutOfBounds
        value_msb = (value >> 8) & 0xFF
        value_lsb = value & 0xFF
        self._check_static(address)
        self._check_static(address + 1)
        self._memory[address] = value_msb
        self._memory[address + 1] = value_lsb

    # Normal sequence syntax cannot be used to set bytes in the 64-byte
    # header.  Instead, the interpreter or game must call one of the
    # following APIs.

    def interpreter_set_header(self, address, value):
        """Possibly allow the interpreter to set header ADDRESS to VALUE."""
        if address < 0 or address > 63:
            raise ZMemoryOutOfBounds
        perm_tuple = self.HEADER_PERMS[address]
        if perm_tuple is None:
            raise ZMemoryIllegalWrite(address)
        if self.version >= perm_tuple[0] and perm_tuple[2]:
            self._memory[address] = value
        else:
            raise ZMemoryIllegalWrite(address)

    def game_set_header(self, address, value):
        """Possibly allow the game code to set header ADDRESS to VALUE."""
        if address < 0 or address > 63:
            raise ZMemoryOutOfBounds
        perm_tuple = self.HEADER_PERMS[address]
        if perm_tuple is None:
            raise ZMemoryIllegalWrite(address)
        if self.version >= perm_tuple[0] and perm_tuple[1]:
            self._memory[address] = value
        else:
            raise ZMemoryIllegalWrite(address)

    # The ZPU will need to read and write global variables.  The 240
    # global variables are located at a place determined by the header.

    def read_global(self, varnum):
        """Return 16-bit value of global variable VARNUM.  Incoming VARNUM
        must be between 0x10 and 0xFF."""
        if not (0x10 <= varnum <= 0xFF):
            raise ZMemoryOutOfBounds
        actual_address = self._global_variable_start + ((varnum - 0x10) * 2)
        return self.read_word(actual_address)

    def write_global(self, varnum, value):
        """Write 16-bit VALUE to global variable VARNUM.  Incoming VARNUM
        must be between 0x10 and 0xFF."""
        if not (0x10 <= varnum <= 0xFF):
            raise ZMemoryOutOfBounds
        if not (0x00 <= value <= 0xFFFF):
            raise ZMemoryIllegalWrite(value)
        log(f"Write {value} to global variable {varnum}")
        actual_address = self._global_variable_start + ((varnum - 0x10) * 2)
        self._memory[actual_address] = (value >> 8) & 0xFF
        self._memory[actual_address + 1] = value & 0xFF

    # The 'verify' opcode and the QueztalWriter class both need to have
    # a checksum of memory generated.

    def generate_checksum(self):
        """Return a checksum value which represents all the bytes of
        memory added from $0040 upwards, modulo $10000."""
        count = 0x40
        total = 0
        while count < self._total_size:
            total += self._memory[count]
            count += 1
        return total % 0x10000
