#!/usr/bin/python
# -*- coding: utf-8 -*-
########################################################################
# Generalized facility to manipulate os8 device images from the POSIX
# (host) side using OS/8 system programs under SIMH.
#
# See USAGE message below for details.
#
# Copyright © 2018 by Bill Cattey and Warren Young
#
# 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 LISTED ABOVE 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.
#
# Except as contained in this notice, the names of the authors above
# shall not be used in advertising or otherwise to promote the sale,
# use or other dealings in this Software without prior written
# authorization from those authors.
########################################################################

# Bring in just the basics so we can bring in our local modules
import os
import glob
import sys
import string
import re
import argparse

sys.path.insert (0, os.path.dirname (__file__) + '/../lib')
sys.path.insert (0, os.getcwd () + '/lib')

# Our local modules
from pidp8i import *
from simh   import *

# Other global Python modules
import glob
import subprocess


#### GLOBALS AND CONSTANTS #############################################

progmsg = True
DEBUG = False

# regex for parsing an argument string into a sys device

_dev_arg_regex_str = "-(rk|td|dt|rx)(\d?)(s)?"
_dev_arg_re = re.compile(_dev_arg_regex_str)

# regex for parsing an action file att string into a sys device
# reuse _dev_arg_regex_str

_dev_actfile_regex_str = _dev_arg_regex_str[1:]   # strip -
_dev_actfile_re = re.compile (_dev_actfile_regex_str)

_os8_from_simh_dev = {"rk" : "RK", "td" : "DTA", "dt" : "DTA", "rx" : "RX"}
_os8_partitions = {"RK": ["A", "B"]}

_os8_file_regex_str = "(\S+):(\S+)?"
_os8_file_re = re.compile(_os8_file_regex_str)

_valid_options = "abiyz"

_arg_to_option = {"-a" : "a", "-b" : "b", "-i" : "i", "-y" : "y", "-z" : "z" }
_option_to_info = {"a" : "ASCII", "b" : "binary", "i" : "image",
                    "y" : "yank system head", "z" : "zero device" }
  



#### UTILITY ROUTINES ##################################################

def fetch_one (s, os8dev, oname):
  if debug: print "\t" + oname
  fname, option = extract_option(oname)
  if debug: print "Option: " + option + ", fname: " + fname
  s.os8_fetch_pip(os8dev, fname, option)

def send_one (s, os8dev, iname):
  if debug: print "\t" + iname
  fname, option = extract_option(iname)
  if debug: print "Option: " + option + ", fname: " + fname
  s.os8_send_pip(os8dev, fname, option)

#### abort_prog ########################################################
#
# Print err_string and exit with -1 return status.
#

def abort_prog (err_str):
  print "Abort: " + err_str
  sys.exit(-1)

#### parse_attach #####################################################
#
# Parser for OS/8 attach spec.
#

def parse_attach (action_plan, match, imagename):
  if match.group(2) == None or match.group(2) == "":
    abort_prog ("Need unit number for: " + match.group(1) + ".")

  image_spec = [match.group(1), match.group(2), imagename]
  if match.group(3) == 's': 
    if action_plan ["sys"] != None:
      print ("Already specified system device. Ignoring sys mount of: " + imagename)
    else:
      action_plan["sys"] = image_spec
  else:
    action_plan["mount"].append(image_spec)

  
#### parse_action_file #################################################
#
# This option allows creation of the action_plan from a file
# instead of by indivitual arguments.  Think of it as a batch mode.
#
# Figuring out how to mix command lines args and the action_file
# would be tricky since the args are globbed, analyzed and then
# turned into an action plan.
#
# For now, the action file REPLACES the action plan of any other
# command line arguments before --action-file, and no other
# command line arguments are bothered with.
#
# The action file is 3 columns separated by one or more whitepace chars.
#
# If the first column is "att" it's an attach command
# and is parsed the same way as the attach args are parsed
# (Except we don't have a dash.)
#
# Otherwise the 3 columns are:
#
# option     source   destination
#
# option is one of the valid OS/8 file options:
# a, b, i, y, z
#
# Example:
#
# att	rk0s	bootdisk.rk05
# att	td0	boing.tu56
# a	pidp8i.in	DTA0:
# b	ac-mq-blinker.pal.pt	DTA0:


def parse_action_file(fname):
  try:
    manifest = open(fname, "r")
  except IOError:
    print fname + " not found. Skipping."
    return None

  ioline_re = re.compile("(\S+)\s+(\S+)\s+(\S+)")

  action_plan = {}
  action_plan["sys"] = None
  action_plan["mount"] = []
  action_plan["copy"] = []

  for line in manifest:
    ioline = line.strip()
    if DEBUG: print "parse_action_file: ioline: " + ioline
    if ioline == "": continue
    if ioline[0] == '#': continue                  # Allow comments

    m=re.match(ioline_re, ioline)
    if m== None: 
      print "Ignoring line: " + ioline
      continue

    option = m.group(1)
    source = m.group(2)
    destination = m.group(3)
    
    if option == "att":
      m = re.match(_dev_actfile_re, source)
      if m== None:
        prog_abort ("Could not parse attach spec: " + source)
      parse_attach (action_plan, m, destination)
    else:
      if len(option) != 1:
        print "Format options are only 1 letter in size. Ignoring line: " + ioline
      elif option[0] not in _valid_options:
        print "Unrecognize option in line: " + ioline
      elif source == None:
        print "Null value of source. Ignoring line: " + ioline
      elif destination == None:
        print "Null value of destination. Ignoring line: " + ioline
      else:
        action_plan["copy"].append([option, source, destination])

  return action_plan

def is_directory(path):
  if DEBUG: "is_directory (" + path + ")"
  m = re.match(_os8_file_re, path)
  if m != None:
    if DEBUG: print "OS/8 Match: DEV: " + m.group(1) + ", File: " + str(m.group(2))
    if m.group(2) == None or m.group(2) == "": 
      return True     # Just a device so yes it's a directory.
    else: return False
  if has_os8_wildcards(path):
    if DEBUG: print "Has wildcards."
    return False
  return os.path.isdir(path)

def has_os8_wildcards (filename):
  os8_wild_cards = "*?"
  for char in os8_wild_cards:
    if char in filename: return True
  return False

def append_copy(action_plan, mode, source, destination):
  if ":" in source:
    if "/" in source:
      print "Illegal OS/8 file spec containing a slash:" + source
      sys.exit(-1)
    source = source.upper()

  if ": in destination":
    if "/" in destination:
      print "Illegal OS/8 file spec containing a slash:" + destination
      sys.exit(-1)
    destination = destination.upper()
    
  copyspec = [mode, source, destination]
  action_plan["copy"].append (copyspec)
  

#### parse_args ##############################################################
#
# Builds the action plan from the command line arguments and executes it
#
# Note that if we specify the --action-file, the
# contents of that file REPLACE any command line arguments.
#
# An element of the action_plan["copy"] array is itself an array
# that names a file format, a source, and a destination:
#
#    [<format>, <source>, <destination>]
#
# The source and destination file specifications are interpreted as in
# the USAGE message below.  (Look for "colon".)
#

USAGE = 'usage: ' + os.path.basename (__file__) + \
""" [-dh] [--rk,--rx[tag] rotating-disk] [--dt,--td[tag] tape]
    [[-abiyz] src-files]... [src-files] <dest>

    This program boots an OS/8 environment underneath the SIMH PDP-8
    simulator then tries to behave like the POSIX program we are named
    after, either copying files from the POSIX (host) side into the
    simulated OS/8 environment, copying files out of OS/8 to the POSIX
    side, or copying filoes within the OS/8 world from one volume to
    another.

    The copying direction is determined by which file name arguments
    have a colon in them:

    *   copy-within: The source and destination file arguments have
        colons, so copy within the OS/8 environment from one volume to
        another.

    *   copy-into: Only the dest argument has a colon, so assume the
        source file names are POSIX-side and copy those files into the
        simulated OS/8 environment.

    *   copy-out: The dest argument has no colon but the source file
        names do, so copy the named OS/8 files out of the simulation.

    If none of the file arguments has a colon in it and you give exactly
    two such arguments, we operate in a special case of copy-within
    mode: the source and destination volumes are assumed to be DSK:, so
    the file is simply copied within the OS/8 DSK: volume from one name
    to the other.  If you give greater than two file name arguments
    without a colon in any of them, it is not possible to make sense of
    the command since we do not intend to try and replace your perfectly
    good POSIX cp implementation, so it errors out.

    If you give only one file name argument, the program always errors
    out: it requires at least one source and one destination.

    The -a, -b, -i, -y, and -z flags correspond to the OS/8 PIP options
    /A, /B, /I, /Y, and /Z, which are passed to PIP as-is.  (This
    priogram currently uses PIP as its primary handler for the OS/8 side
    of the work.)  They must be followed by at least one source file
    name, and they affect all subsequent source file names until another
    such option is found.  For example:

        $ os8-cp -a foo bar -b qux sys:

    Files foo and bar are copied to SYS: in ASCII mode, overriding the
    default binary mode, then binary mode is restored for the copy of
    file qux to the SYS: volume.

    Beware that -i means something very different to this program than
    it means to POSIX cp: destination files will be unceremoniously
    overwritten!

    If --rk or --rx is given, the named rotating disk image is attached
    to the simulator on the first free disk controller if no digit
    follows the letters, or to the specified controller otherwise.

    If an "s" is at the end of any of the above media image file option
    names, that marks the "system disk", which overrides our default
    boot media, $prefix/shared/media/os8/os8v3d-patched.rk05.

    Therefore, the following:

        $ os8-cp --rk my.rk05 foo RKA1:

    ...will boot from the default RK05 disk image attached to RK0 with
    my.rk05 attached to RK1, then copy POSIX-side file foo to RKA1:FOO.

        $ os8-cp --tds my.tu56 --rk my.rk05 foo DSK:

    ...will boot from my.tu56, which is presumed to be a bootable OS/8
    DECtape attached to SIMH device TD0.  The RK05 disk image my.rk05
    will be attached to RK0, since the default boot disk is not attached
    there in this example.  It will copy POSIX-side file foo to DSK:FOO
    which will probably be interpreted as DTA0:FOO by the typical BUILD
    options for a bootable OS/8 TU56 DECtape.  Beware therefore of using
    the generic SYS: and DSK: device names!  You would be better advised
    to use DTA0:, RKA0: or RKB0: as the destination in this example.

        $ os8-cp --tds my.tu56 --rx1 my.rx01 foo RXA1:

    This not only fixes the almost-certainly incorrect use of DSK: in
    the prior example, it shows how a disk may be mounted on a device
    other than the default by giving a digit after the device type in
    the option name.  (That is, --rx1 instead of just --rx, which would
    be the same as --rx0 in this example.)

    The --dt and --td options are handled similarly to the --r* options,
    differing only in whether we use the SIMH DT or TD PDP-8 devices,
    which correspond to the TC08 or TD8E DECtape controllers.  Which one
    you give depends on the device support built into the OS/8 media
    you've booted from.

    Destination OS/8 volumes are modified in place, rather than
    recreated.  The volume must therefore exist before this program
    runs.

    When only a destination device, directory, or volume name is given,
    file names are normalized when coping between POSIX and OS/8
    systems.  File names are uppercased and truncated to 6.2 limits when
    copying from POSIX to OS/8.  File names are lowercased on copying
    from OS/8 to the POSIX side unless you give the *source* file name
    in all-uppercase; then, file name case is preserved.  This behavior
    is overridden if you give a complete file name for the destination:

        $ os8-cp my-long-file-name.txt DSK:MLF.FD

    If you gave "DSK:" as the destination instead, you would have gotten
    "MY-LON.TX" as the desintation file name instead.

    Give -d to run in debug mode.

    Give -h to print this message.
"""

def parse_args ():
  global DEBUG

  action_plan = {}
  action_plan["sys"] = None
  action_plan["mount"] = []
  action_plan["copy"] = []

  idx = 1
  numargs = len(sys.argv)

  filespec_seen = 0
  mode_opt = "b"                              # start of with default of binary.
  first_mode = mode_opt
  source = ""
  destination = ""
  # Keep file_list and mode_list in sync.
  file_and_mode_list = []
  
  while idx < numargs:
    if DEBUG: print "idx: " + str(idx)
    arg = sys.argv[idx]

    # First the simple bit set options
    if arg == "-d":
      DEBUG = True
    elif arg == "-h":
      print USAGE
      sys.exit(0)

    # look for option args.
    elif arg in _arg_to_option:
      new_opt = _arg_to_option[arg]
      if mode_opt == new_opt:
        print "Warning redundant reset of mode option to " + \
          _option_to_info[new_opt]
      mode_opt = new_opt

    # Not a simple bit set option.
    
    elif arg == "--action-file":
      if idx + 1 == numargs:             # Need filename, but no args left.
        abort_prog ("No action file name.")
      argfilename = sys.argv[idx + 1]
      retval = parse_action_file(argfilename)
      if retval == None:
        abort_prog ("No action plan could be made from " + argfilename + ".")
      else: return retval
      
    else:
      # Parser for OS/8 attach spec.
      m = re.match(_dev_arg_re, arg)
      if m != None:
        if idx + 1 == numargs:             # Need filename, but no args left.
          abort_prog ("No image file name.")
        idx +=1
        parse_attach (action_plan, m, sys.argv[idx])

      # Do file parser if we didn't get an OS/8 attach spec.
      else:

        if DEBUG: print "File parsing of: " + arg
        
        # Need to know if arg is Linux. If so, we need to do globbing.
        # If you want OS/8 globbing, specify a device to prevent globbing
        # from being run.
        m = re.match(_os8_file_re, arg)
        if m == None:          # Yup, it's linux.  Glob it.
          more_files = glob.glob(arg)
          if more_files == []:
            more_files.append(arg)  # If file not found may be an OS/8 internal xfer.
          for new_file in more_files:
            if filespec_seen == 0:
              source = new_file
              first_mode = mode_opt
            elif filespec_seen == 1:
              destination = new_file  
            else:
              file_and_mode_list.append([mode_opt,destination])
              destination = new_file
            filespec_seen += 1
        else:
          if filespec_seen == 0:
            source = arg
            first_mode = mode_opt
          elif filespec_seen == 1:
            destination = arg
          else:
            file_and_mode_list.append([mode_opt, destination])
            destination = arg
          filespec_seen += 1

    if DEBUG: print "filespec_seen: " + str(filespec_seen)

    idx +=1        # Bottom of the while loop. Increment.

  if filespec_seen == 0:
    abort_prog ("No file specs seen.  Nothing to do.")
  elif filespec_seen == 1:
    abort_prog ("Only 1 file spec found. Nothing to do.")
  # Now it gets a little complicated...
  # If neither source nor destination is OS/8, pretend they both were OS/8 "DSK:"
  # If source is OS/8, and has OS/8 wild cards, the destination must be a directory.
  else:
    # If more than 2 files, the destination must be either an OS/8 device or a Linux directory.
    if DEBUG: print "Destination: " + destination
    if filespec_seen > 2 and is_directory(destination) == False:
      abort_prog ("Destination must be a Linux directory or OS/8 device for multiple source files.")

    m1 = re.match(_os8_file_re, source)
    m2 = re.match(_os8_file_re, destination)

    # If source is OS/8 and it has wild cards, but destination is a file, not a device,
    # it's a fatal error.
    if m1 != None and has_os8_wildcards(source) and is_directory (destination) == False:
      abort_prog ("Not going to concatinate multiple OS/8 files into: " + destination + ".")

    if m1 == None and m2 == None and filespec_seen == 2:      # No OS/8 dev on two names, local DSK: copy.
      source = "DSK:" + source
      destination = "DSK:" + destination
      
    append_copy(action_plan, first_mode, source, destination)
    
    for mode_and_file in file_and_mode_list:
      filename = mode_and_file[1]
      m3 = re.match(_os8_file_re, filename)
      if m3 != None and has_os8_wildcards(filename) and is_directory (destination) == False:
        abort_prog ("Not going to concatinate multiple OS/8 files into: " + destination + ".")
      append_copy(action_plan, mode_and_file[0], filename, destination)

      
  return action_plan

#### main ##############################################################

def main ():

  action_plan = parse_args()
  if action_plan == None:
    prog_abort ("No action plan was parsed.")
  if DEBUG: print str(action_plan)

  # Create the SIMH child instance and tell it where to send log output
  try:
    s = simh (dirs.build, True)
  except (RuntimeError) as e:
    print "Could not start simulator: " + e.message + '!'
    exit (1)
  s.set_logfile (os.fdopen (sys.stdout.fileno (), 'w', 0))

  # Perform sys attach
  att_spec = action_plan["sys"]
  simh_boot_dev = att_spec[0] + att_spec[1]     # Compose simh dev from name and unit.
  imagename = att_spec[2]
  if not os.path.exists (imagename):
    abort_prog ("Requested boot image file: " + imagename + " not found.")
  
  s.send_cmd ("att " + simh_boot_dev + " " + imagename)

  images_to_zero = []
  
  # Attach other mounts
  for att_spec in action_plan["mount"]:
    simh_dev = att_spec[0] + att_spec[1]     # Compose simh dev from name and unit.
    imagename = att_spec[2]
    if os.path.exists (imagename):
      print "Modifying existing " + simh_dev + " image " + imagename
    else:
      print "Will create a new image file named: " + imagename
      # Save this att_spec so we can zero it later.
      images_to_zero.append (att_spec)
      s.send_cmd ("att " + simh_dev + " " + imagename)

  print "Booting " + simh_boot_dev + "..."
  s.send_cmd ("boot " + simh_boot_dev)

  for att_spec in images_to_zero:
    os8dev = _os8_from_simh_dev[att_spec[0]]
    if os8dev in _os8_partitions:
      for partition in _os8_subunits[os8dev]:
        os8name = os8dev + partition + att_spec[1] + ":"
        print "Initializing directory of " + os8name + " in " + imagename
        s.os8_send_cmd ('\\.', "ZERO " + os8name)
    else:
      os8name = os8dev + att_spec[1] + ":"
      print "Initializing directory of " + os8name + " in " + imagename
      s.os8_send_cmd ('\\.', "ZERO " + os8name)

  # Perform copy operations
  for do_copy in action_plan["copy"]:
    mode_opt = do_copy[0]
    source = do_copy[1]
    destination = do_copy[2]

    if DEBUG: print "Source: " + source + ", Destination: " + destination + ", Mode: " + mode_opt + "."
    # Is this "from" OS/8 to POSIX, "to" OS/8 from POSIX or "on" OS/8?
    # "to" -- Attach source to simh ptr
    # If we are operating "from" and source has wild cards,
    # Use DIRECT to create list of files.
    # "from" -- Attach destination to ptp
    # "on" -- Use FOTP.
 
  # Detach all mounts and then sys.
  s.back_to_cmd ('\\.')
  for att_spec in action_plan["mount"]:
    simh_dev = att_spec[0] + att_spec[1]     # Compose simh dev from name and unit.
    s.send_cmd ("det " + simh_dev)
  s.send_cmd ("det " + simh_boot_dev)

  # And shut down the simulator.
  s.send_cmd ('quit')

if __name__ == "__main__": main()
