# MIT License
#
# Copyright (c) 2023 Neil Webber
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from enum import Enum
from interrupts import InterruptManager
from pdptraps import PDPTraps, PDPTrap


BusCycle = Enum('BusCycle', ('READ16', 'WRITE16', 'WRITE8', 'RESET'))
BusWrites = {BusCycle.WRITE16, BusCycle.WRITE8}      # convenience


class UNIBUS:

    def __init__(self, cpu):
        self.cpu = cpu
        self.intmgr = InterruptManager(cpu)
        self.logger = cpu.logger
        self.mmiomap = [self.__nodev] * (self.cpu.IOPAGE_SIZE >> 1)

    def resetbus(self):
        # this isn't especially efficient but it really doesn't matter.
        # Construct a sequence of tuples: (a, f)
        #    where a is the address (really offset) in the I/O page
        #    and f is the callback
        # but only for callbacks that are not __nodev
        # and then invoke those callbacks w/BusCycle.RESET
        resets = ((x << 1, f)
                  for x, f in enumerate(self.mmiomap) if f != self.__nodev)
        for ioaddr, f in resets:
            f(ioaddr, BusCycle.RESET)

    # register() -- connect a callback function to an I/O page address
    #
    # A convenient reference for addresses:
    #    https://gunkies.org/wiki/UNIBUS_Device_Addresses
    #
    # memory-mapped I/O is just a 4K array of function callbacks, one
    # entry per each I/O WORD (even) I/O offset. Most entries, of course,
    # remain set to an initial default "non-existent address" callback.
    #
    # Callbacks must be declared like this:
    #       def f(ioaddr, cycle, /, *, value=None):
    #
    # and should contain code dispatching on cycle and (potentially) ioaddr.
    # If cycle is WRITE8 (byte) or WRITE16 (word), the 'value' argument will
    # be supplied. Otherwise 'value' is not supplied.
    #
    # If cycle is READ16 (word), the read value should be returned. Note that
    # no READ8 exists as the UNIBUS is not capable of expressing a byte read.
    #
    # The processor RESET instruction generates calls with a RESET cycle.
    #
    # The ioaddr will always be relative to the base of the I/O page:
    #            (ioaddr & 8191) == ioaddr   will always be True
    #
    # If a device's semantics are simple enough to allow synthesizing a
    # WRITE8 as a READ16 / byte-update / WRITE16 sequence it can use
    # the autobyte wrapper and omit a WRITE8 cycle handler. If the underlying
    # address is write-only the autobyte wrapper substitutes a zero value
    # for the READ16 part of the synthesis. If this is not correct, the
    # callback must handle WRITE8 itself directly.
    #
    # Callback functions that need context or other data can, of course,
    # be bound methods (automatically receiving self) or also can use
    # functools.partial() to get additional arguments passed in.
    #
    #
    # ODD I/O ADDRESSES, BYTE OPERATIONS:
    #
    # The physical UNIBUS always *reads* full 16-bit words; there is no
    # capability for a byte read at the electrical bus level.
    #
    # Oddly (haha) the bus *can* specify byte access for writes.
    # Even odder (hahaha), devices that provide full-word access at
    # odd addresses exist, the best example being the CPU itself -- though
    # as it turns out only very few models allow **CPU** access to cpu
    # registers via the Unibus (vs allowing other devices, e.g., the console
    # switches/display, to access them that way).
    #
    # There are a lot of subtleties to take into account accessing I/O
    # addresses via bytes (and possibly-odd addresses) but so be it.
    # Such byte operations in I/O space abound in real world code.
    #
    # Devices that respond to a block of related addresses can register
    # one callback to cover more than one word of Unibus address space.
    # The callback gets the ioaddr which it can further decode. Again,
    # the CPU register block is a good example of this.
    #

    def register(self, iofunc, offsetaddr, nwords=1, /):
        """register a callback for an I/O address.

        Arguments:
           iofunc     -- callback function. See documentation for signature.
           offsetaddr -- Offset within the 8K I/O page.
           nwords     -- Optional. Default=1. Number of words to span.

        iofunc may be None in which case a dummy is supplied that ignores
        all write operations and always reads as zero.
        """

        if offsetaddr >= self.cpu.IOPAGE_SIZE:
            raise ValueError(f"UNIBUS: offset too large {oct(offsetaddr)}")

        # None is a shorthand for "this is a dummy always-zero addr"
        if iofunc is None:
            iofunc = self.autobyte(self.__ignoredev)

        idx, odd = divmod(offsetaddr, 2)
        if odd != 0:
            # See discussion of odd I/O addrs in block comment elsewhere
            raise ValueError("cannot register odd (byte) address in IO space")

        for i in range(nwords):
            self.mmiomap[idx+i] = iofunc

    # this is a convenience routine for devices that want to signal things
    # such as a write to a read-only register (if they don't simply just
    # ignore them). Devices can, of course, just raise the traps themselves.
    def illegal_cycle(self, addr, /, *, cycle=BusCycle.WRITE16, msg=None):
        if msg is None:
            msg = f"Illegal cycle ({cycle}) at {oct(addr)}"
        self.cpu.logger.info(msg)
        raise PDPTraps.AddressError(cpuerr=self.cpu.CPUERR_BITS.UNIBUS_TIMEOUT)

    # the default entry for unoccupied I/O: cause an AddressError trap
    def __nodev(self, addr, cycle, /, *, value=None):
        self.illegal_cycle(addr, cycle=cycle,
                           msg=f"Non-existent I/O @ offset {oct(addr)}")

    # Devices may have simple "dummy" I/O addresses that always read zero
    # and ignore writes; See "if iofunc is None" in register() method.
    # NOTE: register() byteme-wraps this so opsize not needed.
    def __ignoredev(self, addr, cycle, /, *, value=None):
        self.cpu.logger.debug(f"dummy zero device @ {oct(addr)}, {value=}")
        return 0

    # wrap an I/O function with code that automatically handles byte writes.
    # CAUTION: Some devices may have semantics that make this ill-advised.
    #          Such devices should handle WRITE8 explicitly themselves.
    # This is essentially a decorator but typically is invoked explicitly
    # rather than by '@' syntax.
    def autobyte(self, iofunc):
        def byteme(ioaddr, cycle, /, **kwargs):
            if cycle != BusCycle.WRITE8:
                return iofunc(ioaddr, cycle, **kwargs)

            # A byte write to I/O space. Synthesize it.
            # 'value' is present (by definition) in kwargs
            value = kwargs['value']
            self.cpu.logger.debug(f"Byte write to {oct(ioaddr)} {value=}")
            try:
                wv = iofunc(ioaddr & 0o177776, BusCycle.READ16)
            except PDPTrap:     # this can happen on write-only registers
                wv = 0
            if ioaddr & 1:
                wv = (wv & 0o377) | (value << 8)
            else:
                wv = (wv & 0o177400) | value
            iofunc(ioaddr & 0o177776, BusCycle.WRITE16, value=wv)
        return byteme

    # Convenience method -- registers simple attributes (or properties) into
    # I/O space in the obvious way: Make this attr (of obj) show at this addr
    #
    # BusCycle.RESET handling: If a device wants RESET to zero the attribute,
    # it should specify reset=True. Otherwise RESET will be ignored.
    #
    # BusCycle.WRITE8 handling: autobyte() is used. If those semantics are not
    # suitable, the device must create its own i/o func instead of this.
    #
    # readonly: If True (default is False) then writes to this address will
    #           cause an AddressError trap. The device implementation can
    #           safely (if it wants to) implement the attribute directly
    #           (i.e., without using @property) and no write to the attribute
    #           will ever originate from here.
    #
    def register_simpleattr(self, obj, attrname, addr, /, *,
                            reset=False, readonly=False):
        """Create and register a handler to read/write the named attr.

        obj - the object (often "self" for the caller of this method)
        attrname - the attribute name to read/write
        addr - the I/O address to register it to

        If attrname is None, the address is registered as a dummy location
        that ignores writes and will always read as zero. This is sometimes
        useful for features that have to exist but are emulated as no-op.

        reset - if True, attrname will be set to zero on BusCycle.RESET
        readonly - if True, attrname is readonly and will never be written.
                   Write cycles to the addr will cause AddressError.
        """

        # could do this with partial, but can also do it with this nested
        # func def. One way or another need this func logic anyway.

        if attrname is None:
            def _rwattr(_, cycle, /, *, value=None):
                if cycle == BusCycle.READ16:
                    return 0
                if cycle in BusWrites and readonly:
                    self.illegal_cycle(addr, cycle)
                # all other operations just silently ignored
        else:
            def _rwattr(_, cycle, /, *, value=None):
                if cycle == BusCycle.READ16:
                    return getattr(obj, attrname)
                elif cycle in BusWrites:
                    if readonly:
                        self.illegal_cycle(addr, cycle)
                    else:         # autobyte assumed ... see register() below
                        setattr(obj, attrname, value)
                elif cycle == BusCycle.RESET:
                    if reset:
                        setattr(obj, attrname, 0)
                else:
                    assert False, f"Unknown {cycle=} in simpleattr"

        # NOTES:
        #   * it's a new defn/closure of _rwattr each time through, so the
        #     individual (per registration) addr/etc values are closure'd
        #   * Do not autobyte if readonly, simply so that the correct
        #     (unmolested) BusCycle.WRITE8 will be seen in the trap/errors
        #   * _rwattr ASSUMES autobyte() wrapper if not readonly
        if readonly:
            self.register(_rwattr, addr)
        else:
            self.register(self.autobyte(_rwattr), addr)

    def wordRW(self, ioaddr, value=None, /):
        """Read (value is None) or write the given I/O address."""
        if value is None:
            value = self.mmiomap[ioaddr >> 1](ioaddr, BusCycle.READ16)
        else:
            self.mmiomap[ioaddr >> 1](ioaddr, BusCycle.WRITE16, value=value)
        return value

    def byteRW(self, ioaddr, value=None, /):
        """UNIBUS byte R/W - only write is legal."""
        if value is None:
            raise ValueError("Unibus cannot read bytes")
        else:
            self.mmiomap[ioaddr >> 1](ioaddr, BusCycle.WRITE8, value=value)
            return None


class UNIBUS_1170(UNIBUS):
    UBMAP_OFFS = 0o10200
    UBMAP_N = 62

    def __init__(self, cpu):
        super().__init__(cpu)

        # UBAs being 32-bit (well, really 22 bit) values, they
        # are just stored natively that way and broken down
        # into 16-bit components by the mmio function as needed.
        self.ubas = [0] * (self.UBMAP_N // 2)
        self.register(
            self.autobyte(self.uba_mmio), self.UBMAP_OFFS, self.UBMAP_N)

    def uba_mmio(self, addr, cycle, /, *, value=None):
        ubanum, hi22 = divmod(addr - self.UBMAP_OFFS, 4)
        uba22 = self.ubas[ubanum]

        self.logger.debug(f"UBA addr={oct(addr)}, {value=}")
        self.logger.debug(f"{ubanum=}, {hi22=}")

        if cycle == BusCycle.READ16:
            if hi22 == 0:
                return (uba22 >> 16) & 0o077
            else:
                return uba22 & 0o177777
        elif cycle == BusCycle.WRITE16:
            # the low bit is enforced to be zero
            if hi22:
                uba22 = ((value & 0o077) << 16) | (uba22 & 0o177776)
            else:
                uba22 = (uba22 & 0o17600000) | (value & 0o177776)
            self.ubas[ubanum] = uba22
        elif cycle == BusCycle.RESET:
            pass

    def busRW(self, ubaddr, value=None):
        pass