import time
from machine import PDP1170
from kw11 import KW11
from kl11 import KL11
from dc11 import DC11
from rp import RPRM
import breakpoints


bootmsg = """Starting PDP11; this window is NOT THE EMULATED PDP-11 CONSOLE.
*** In another window, telnet/nc to localhost:1170 to connect.
    Terminal should be in raw mode. On a mac, this is a good way:
         (stty raw; nc localhost 1170; stty sane)
"""


def boot_hp(p, /, *, addr=0o10000, deposit_only=False, switches=0):
    """Deposit, then run, instructions to read first 1KB of drive 0 --> addr.
    RETURN VALUE:  addr if deposit_only else None

    If no 'addr' given, it defaults to something out of the way.

    If not deposit_only (default):
       * The instructions are loaded to 'addr'
       * They are executed.
       * Return value is None.
       * NOTE: The next start address depends on what those instructions do.
       *       TYPICALLY, the next start address will be zero.

    If deposit_only:
       * The instructions are loaded to 'addr'
       * 'addr' is returned.
    """

    # this is the sort of thing that would be keyed in from
    # the console switches (if the machine was not equipped
    # with a boot rom option to hold it instead)
    #
    # It is a minimalist  program, with lots of assumptions, to  read 1K
    # from block zero of drive 0 into location 0. The execution start
    # at zero is done elsewhere.
    #
    # NOTE WELL: THIS ASSUMES THE MACHINE IS IN RESET CONDITION WHICH
    #            MEANS MANY OF THE DEVICE REGISTERS ARE ASSUMED TO BE ZERO
    #
    #      MOV #176704,R0       -- note how used
    #      MOV #177000,-(R0)    -- word count - read 1K though boot really 512
    #      MOV #071,-(R0)       -- go!
    program_insts = (
        0o012700,          # MOV #0176704,R0
        0o176704,
        0o012740,          # MOV #177000,-(R0)
        0o177000,
        0o012740,          # MOV #071, -(R0)
        0o000071,
        0o0,               # HALT
    )

    for o, w in enumerate(program_insts):
        p.physRW(addr + o + o, w)

    p.r[p.PC] = addr
    if not deposit_only:
        p.run()

    return addr if deposit_only else None


def boot_bin(p, fname, /, *, addr=0, deposit_only=False,
             little_endian=True, skipwords=8):
    """Read a binary file 'fname' into location 'addr' and execute it.
    RETURN VALUE:  addr if deposit_only else None

    NOTE: fname is in the host system, not on an emulated drive.
    If no 'addr' given, it defaults to ZERO.
    If deposit_only=True, the instructions are not executed.

    little_endian (default True) dictates the fname byte order.
    skipwords (default 8 -- a.out header) will seek that many 16-bit
    words into the file before beginning to load.
    """
    with open(fname, 'rb') as f:
        bb = f.read()

        # Two data format cases:
        #   1) little_endian (the default)
        #
        #      The file is truly a binary image of pdp11 format data
        #      and the words (pairs of bytes) in bb are in little endian
        #      order. They will be assembled accordingly and the names
        #      "low" and "hi" make sense.
        #
        #   2) not little_endian
        #
        #      Presumably the file has been byte-swapped already and
        #      the words (pairs of bytes) in it are in big endian order.
        #      They will be assembled accordingly, but the names "low"
        #      and "hi" are backwards.
        #

        xi = iter(bb)
        words = []
        for low in xi:
            hi = next(xi)
            if little_endian:
                words.append((hi << 8) | low)
            else:
                words.append((low << 8) | hi)    # see case 2) above

        for a, w in enumerate(words[skipwords:]):
            p.physmem[a] = w

    p.r[p.PC] = addr
    if not deposit_only:
        p.run()
    return addr if deposit_only else None


def _must_read_n(f, n, zeroskip=False):
    """read exactly n bytes from f; raise exception if can't. n==0 allowed.

    If zeroskip is True (default: False), zero bytes will be discarded.
    It is not legal to zeroskip with n == 0.
    """

    if zeroskip and n == 0:
        raise ValueError("zeroskip and n == 0")

    b = bytes()
    if n > 0:
        while zeroskip and (b := f.read(1)) == b'\00':
            pass

        # b has one byte or none in it, depending on zeroskip
        b += f.read(n - len(b))
        if len(b) != n:
            raise ValueError(f"needed {n} bytes; got {len(b)}")
    return b


def _byte_phys_write(p, b, addr):
    """write b (bytes()) to addr. b may be odd length. addr may be odd."""

    # addr can be odd, length can be odd. Pick up the bookend bytes
    # as needed so physRW_N can do it as words.
    if addr & 1:
        addr -= 1
        b = bytes([p.physRW(addr) & 255]) + b
    if len(b) & 1:
        b += bytes([(p.physRW(addr+len(b)-1) >> 8) & 255])

    words = [(b[i+1] << 8) | b[i] for i in range(0, len(b), 2)]
    p.physRW_N(addr, len(words), words)


def load_lda_f(p, f):
    """Read  and load open file f as an 'absolute loader' file
    (.LDA, sometimes .BIC file). This is the same format that simh
    defines for its load (binary) command.

    Returns: the address specified in the END block (per LDA docs)

    Any file format errors or I/O errors will raise an exception.
    """

    # The file is (should be) a sequence of individual blocks (see
    # comments in get_lda_block()). The last block must have no data.
    while True:
        addr, b = get_lda_block(f)
        if len(b) == 0:
            return addr
        _byte_phys_write(p, b, addr)


def get_lda_block(f):
    """Read next block from LDA file. Return tuple: addr, b

    If the block was an END block, len(b) will be zero.
    """

    # Archived DEC documentation says an LDA/absolute loader file may start
    # with an arbitrary number of zero bytes. SIMH says that ANY block
    # (not just the first) may have arbitrary zeros in front of it, and
    # testing reveals that SIMH indeed allows (ignores) such zeros.
    #
    # Such zeros were probably more common in the (real) paper tape days.
    # They are supported here simply because SIMH does too.
    #
    # The file is a sequence of blocks in header/data/checksum format:
    #   [HEADER]          -- 6 bytes
    #   [DATA]            -- size determined by header, can be zero length
    #   [CHECKSUM]        -- 1 byte
    #
    # The header is six individual bytes, as follows:
    #         1             -- literally, a byte with value 1
    #         0             -- "MUST" be zero (this code ignores this byte)
    #       len-lsb         -- lower 8 bits of block length
    #       len-msb         -- upper 8 bits of block length
    #       addr-lsb        -- lower 8 bits of address
    #       addr-msb        -- upper 8 bits of address
    #
    # As mentioned, runs of zeros in prior to such a header (which starts
    # with a 1) are ignored.
    #
    # The 'block length' includes the header bytes but not the checksum.
    # Thus the lengthof [DATA] is six less than the block length given.
    # Note that the [DATA] length is allowed to be odd, or zero.

    header = _must_read_n(f, 6, zeroskip=True)
    if header[0] != 1:
        raise ValueError(f"header starts with {header[0]} not 1")

    count = (header[3] << 8) | header[2]
    addr = (header[5] << 8) | header[4]

    if count < 6:
        raise ValueError(f"header error, {count=}")

    b = _must_read_n(f, count-6)

    chksum = _must_read_n(f, 1)
    if (sum(header) + sum(b) + sum(chksum)) & 0xFF:
        raise ValueError(f"checksum mismatch, {header=}")

    return addr, b


def boot_lda(p, fname, /, *, force_run=True):
    """Load and boot an LDA/BIC/absolute-loader file.

    By default, the loaded code is started even if the start address
    given in the LDA file is odd (which is normally a flag to not start it).
    The start address is rounded down 1 to even in such cases.

    To override that behavior, specify force_run=False. The file will only
    be run if the start address is even.

    In all cases the raw start address is returned; however, if the loaded
    code is successfully started there will never be a return unless that
    code eventually halts.
    """
    with open(fname, 'rb') as f:
        addr = rawaddr = load_lda_f(p, f)

    if rawaddr & 1:
        if not force_run:
            return rawaddr
        addr = rawaddr - 1

    print(bootmsg)
    p.run(pc=addr)
    return rawaddr


def make_unix_machine(*, loglevel='INFO', drivenames=[]):
    p = PDP1170(loglevel=loglevel)

    p.associate_device(KW11(p.ub), 'KW')    # line clock
    p.associate_device(KL11(p.ub), 'KL')    # console
    p.associate_device(RPRM(p.ub, *drivenames), 'RP')    # disk drive
    p.associate_device(DC11(p.ub), 'DC')    # additional serial poirts
    return p


def boot_unix(p, runoptions={}):

    # load, and execute, the key-in bootstrap
    boot_hp(p)

    print(bootmsg)
    print("There will be no prompt; type 'boot' in your OTHER window")
    print("")
    print("Then, at the ':' prompt, typically type: hp(0,0)unix")

    p.run(pc=0, **runoptions)


# USE:
#    python3 boot.py
#
# to start up unix (or whatever system is on the drive)

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('--debug', action='store_true')
    parser.add_argument('--drive', action='append', default=[], dest='drives')
    parser.add_argument('--instlog', action='store_true')
    parser.add_argument('--lda', action='store', default=None)
    args = parser.parse_args()

    pdpoptions = {'drivenames': args.drives}
    runoptions = {}
    bkpts = []
    if args.debug:
        pdpoptions['loglevel'] = 'DEBUG'
        bkpts.append(breakpoints.MemChecker(1000000))
    if args.instlog:
        bkpts.append(breakpoints.Logger())

    if bkpts:
        # if there is just one, use it directly, else combine 'em
        if len(bkpts) == 1:
            runoptions['breakpoint'] = bkpts[0]
        else:
            runoptions['breakpoint'] = breakpoints.MultiBreakpoint(*bkpts)

    p = make_unix_machine(**pdpoptions)

    if args.lda:
        boot_lda(p, args.lda)
    else:
        boot_unix(p, runoptions=runoptions)