#
# A class which manages both (1) the general purpose stack ("data
# stack") used by the story code to store temporary data, and (2) the
# interpreter-private stack of routines ("call stack") and their local
# variables.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#

from .zlogging import log


class ZStackError(Exception):
    "General exception for stack or routine-related errors"

    pass


class ZStackUnsupportedVersion(ZStackError):
    "Unsupported version of Z-story file."

    pass


class ZStackNoRoutine(ZStackError):
    "No routine is being executed."

    pass


class ZStackNoSuchVariable(ZStackError):
    "Trying to access non-existent local variable."

    pass


class ZStackPopError(ZStackError):
    "Nothing to pop from stack!"

    pass


# Helper class used by ZStackManager; a 'routine' object which
# includes its own private stack of data.
class ZRoutine:
    def __init__(
        self, start_addr, return_addr, zmem, args, local_vars=None, stack=None
    ):
        """Initialize a routine object beginning at START_ADDR in ZMEM,
        with initial argument values in list ARGS.  If LOCAL_VARS is None,
        then parse them from START_ADDR."""

        self.start_addr = start_addr
        self.return_addr = return_addr
        self.program_counter = 0  # used when execution interrupted
        self.arg_count = len(args)  # track number of args passed

        if stack is None:
            self.stack = []
        else:
            self.stack = stack[:]

        if local_vars is not None:
            self.local_vars = local_vars[:]
        else:
            num_local_vars = zmem[self.start_addr]
            if not (0 <= num_local_vars <= 15):
                log(f"num local vars is {num_local_vars}")
                raise ZStackError
            self.start_addr += 1

            # Initialize the local vars in the ZRoutine's dictionary. This is
            # only needed on machines v1 through v4. In v5 and v8 machines, all
            # local variables are preinitialized to zero.
            self.local_vars = [0 for _ in range(15)]
            if 1 <= zmem.version <= 4:
                for i in range(num_local_vars):
                    self.local_vars[i] = zmem.read_word(self.start_addr)
                    self.start_addr += 2
            elif zmem.version not in (5, 8):
                raise ZStackUnsupportedVersion

        # Place call arguments into local vars, if available
        for i in range(0, len(args)):
            self.local_vars[i] = args[i]

    def pretty_print(self):
        "Display a ZRoutine nicely, for debugging purposes."

        log(f"ZRoutine:        start address: {self.start_addr}")
        log(f"ZRoutine: return value address: {self.return_addr}")
        log(f"ZRoutine:      program counter: {self.program_counter}")
        log(f"ZRoutine:      local variables: {self.local_vars}")


class ZStackBottom:
    """Sentinel object at the bottom of the call stack.

    Includes stack and local_vars attributes so code that walks the call
    stack can treat all frames uniformly without special-case checks for
    the bottom sentinel.
    """

    def __init__(self):
        self.program_counter = 0  # used as a cache only
        self.return_addr = None
        self.stack = []
        self.local_vars = [0 for _ in range(15)]


class ZStackManager:
    def __init__(self, zmem):
        self._memory = zmem
        self._stackbottom = ZStackBottom()
        self._call_stack = [self._stackbottom]

    def get_local_variable(self, varnum):
        """Return value of local variable VARNUM from currently-running
        routine.  VARNUM must be a value between 0 and 15, and must
        exist."""

        if self._call_stack[-1] == self._stackbottom:
            raise ZStackNoRoutine

        if not 0 <= varnum <= 15:
            raise ZStackNoSuchVariable

        current_routine = self._call_stack[-1]

        return current_routine.local_vars[varnum]

    def set_local_variable(self, varnum, value):
        """Set value of local variable VARNUM to VALUE in
        currently-running routine.  VARNUM must be a value between 0 and
        15, and must exist."""

        if self._call_stack[1] == self._stackbottom:
            raise ZStackNoRoutine

        if not 0 <= varnum <= 15:
            raise ZStackNoSuchVariable

        current_routine = self._call_stack[-1]

        current_routine.local_vars[varnum] = value

    def push_stack(self, value):
        "Push VALUE onto the top of the current routine's data stack."

        current_routine = self._call_stack[-1]
        current_routine.stack.append(value)

    def pop_stack(self):
        "Remove and return value from the top of the data stack."

        current_routine = self._call_stack[-1]
        if not current_routine.stack:
            frame_idx = len(self._call_stack) - 1
            ra = getattr(current_routine, "return_addr", "N/A")
            pc = getattr(current_routine, "program_counter", 0)
            raise ZStackPopError(f"frame {frame_idx}, return_addr={ra}, pc=0x{pc:06x}")
        return current_routine.stack.pop()

    def get_stack_frame_index(self):
        "Return current stack frame number.  For use by 'catch' opcode."

        return len(self._call_stack) - 1

    # Used by quetzal save-file parser to reconstruct stack-frames.
    def push_routine(self, routine):
        """Blindly push a ZRoutine object to the call stack.
        WARNING: do not use this unless you know what you're doing; you
        probably want the more full-featured start_routine() belowe
        instead."""

        self._call_stack.append(routine)

    # ZPU should call this whenever it decides to call a new routine.
    def start_routine(self, routine_addr, return_addr, program_counter, args):
        """Save the state of the currenly running routine (by examining
        the current value of the PROGRAM_COUNTER), and prepare for
        execution of a new routine at ROUTINE_ADDR with list of initial
        arguments ARGS."""

        new_routine = ZRoutine(routine_addr, return_addr, self._memory, args)
        current_routine = self._call_stack[-1]
        current_routine.program_counter = program_counter
        self._call_stack.append(new_routine)

        return new_routine.start_addr

    # ZPU should call this whenever it decides to return from current
    # routine.
    def finish_routine(self, return_value):
        """Toss the currently running routine from the call stack, and
        toss any leftover values pushed to the data stack by said routine.
        Return the previous routine's program counter address, so that
        execution can resume where from it left off."""

        exiting_routine = self._call_stack.pop()
        current_routine = self._call_stack[-1]

        # Depending on many things, return stuff.
        if exiting_routine.return_addr is not None:
            if exiting_routine.return_addr == 0:
                # Push to stack
                self.push_stack(return_value)
            elif 0 < exiting_routine.return_addr < 0x10:
                # Store in local var
                self.set_local_variable(exiting_routine.return_addr - 1, return_value)
            else:
                # Store in global var
                self._memory.write_global(exiting_routine.return_addr, return_value)
        return current_routine.program_counter
