#! /usr/bin/env python
# Script to create and restore a git fingerprint of the ghc repositories.

from __future__ import print_function

from   datetime   import datetime
from   optparse   import OptionParser
import os
import os.path
import re
import subprocess
from   subprocess import PIPE, Popen
import sys

def main():
  opts, args = parseopts(sys.argv[1:])
  opts.action(opts)

def create_action(opts):
  """Action called for the create commmand"""
  if opts.fpfile:
    fp = FingerPrint.read(opts.source)
  else:
    fp = fingerprint(opts.source)
  if len(fp) == 0:
    error("Got empty fingerprint from source: "+str(opts.source))
  if opts.output_file:
    print("Writing fingerprint to: ", opts.output_file)
  fp.write(opts.output)

def restore_action(opts):
  """Action called for the restore commmand"""
  def branch_name(filename):
    return "fingerprint_" + os.path.basename(filename).replace(".", "_")
  if opts.fpfile:
    try:
      fp = FingerPrint.read(opts.source)
      bn = branch_name(opts.fpfile)
    except MalformedFingerPrintError:
      error("Error parsing fingerprint file: "+opts.fpfile)
    if len(fp) == 0:
      error("No fingerprint found in fingerprint file: "+opts.fpfile)
  elif opts.logfile:
    fp = fingerprint(opts.source)
    bn = branch_name(opts.logfile)
    if len(fp) == 0:
      error("No fingerprint found in build log file: "+opts.logfile)
  else:
    error("Must restore from fingerprint or log file")
  restore(fp, branch_name=bn if opts.branch else None)

def fingerprint(source=None):
  """Create a new fingerprint of current repositories.

  The source argument is parsed to look for the expected output
  from a `sync-all` command. If the source is `None` then the
  `sync-all` command will be run to get the current fingerprint.
  """
  if source is None:
    if sys.platform == 'win32':
      # Can't rely on perl being located at `/usr/bin/perl`.
      sync_all = ["perl", "./sync-all", "log", "-1", "--pretty=oneline"]
    else:
      sync_all = ["./sync-all", "log", "-1", "--pretty=oneline"]

    source = Popen(sync_all, stdout=PIPE).stdout

  lib = ""
  commits = {}
  for line in source.readlines():
    if line.startswith("=="):
      lib = line.split()[1].rstrip(":")
      lib = "." if lib == "running" else lib # hack for top ghc repo
    elif re.match("[abcdef0-9]{40}", line):
      commit = line[:40]
      commits[lib] = commit
  return FingerPrint(commits)

def restore(fp, branch_name=None):
  """Restore the ghc repos to the commits in the fingerprint

  This function performs a checkout of each commit specifed in
  the fingerprint. If `branch_name` is not None then a new branch
  will be created for the top ghc repository. We also add an entry
  to the git config that sets the remote for the new branch as `origin`
  so that the `sync-all` command can be used from the branch.
  """
  checkout = ["git", "checkout"]

  # run checkout in all subdirs
  for (subdir, commit) in fp:
    if subdir != ".":
      cmd = checkout + [commit]
      print("==", subdir, " ".join(cmd))
      if os.path.exists(subdir):
        rc = subprocess.call(cmd, cwd=subdir)
        if rc != 0:
          error("Too many errors, aborting")
      else:
        sys.stderr.write("WARNING: "+
          subdir+" is in fingerprint but missing in working directory\n")

  # special handling for top ghc repo
  # if we are creating a new branch then also add an entry to the
  # git config so the sync-all command is happy
  branch_args = ["-b", branch_name] if branch_name else []
  rc = subprocess.call(checkout + branch_args + [fp["."]])
  if (rc == 0) and branch_name:
    branch_config = "branch."+branch_name+".remote"
    subprocess.call(["git", "config", "--add", branch_config, "origin"])

actions = {"create" : create_action, "restore" : restore_action}
def parseopts(argv):
  """Parse and check the validity of the command line arguments"""
  usage = "fingerprint ("+"|".join(sorted(actions.keys()))+") [options]"
  parser = OptionParser(usage=usage)

  parser.add_option("-d", "--dir", dest="dir",
    help="write output to directory DIR", metavar="DIR")

  parser.add_option("-o", "--output", dest="output",
    help="write output to file FILE", metavar="FILE")

  parser.add_option("-l", "--from-log", dest="logfile",
    help="reconstruct fingerprint from build log", metavar="FILE")

  parser.add_option("-f", "--from-fp", dest="fpfile",
    help="reconstruct fingerprint from fingerprint file", metavar="FILE")

  parser.add_option("-n", "--no-branch",
    action="store_false", dest="branch", default=True,
    help="do not create a new branch when restoring fingerprint")

  parser.add_option("-g", "--ghc-dir", dest="ghcdir",
    help="perform actions in GHC dir", metavar="DIR")

  opts,args = parser.parse_args(argv)
  return (validate(opts, args, parser), args)

def validate(opts, args, parser):
  """ Validate and prepare the command line options.

  It performs the following actions:
    * Check that we have a valid action to perform
    * Check that we have a valid output destination
    * Opens the output file if needed
    * Opens the input  file if needed
  """
  # Determine the action
  try:
    opts.action = actions[args[0]]
  except (IndexError, KeyError):
    error("Must specify a valid action", parser)

  # Inputs
  if opts.logfile and opts.fpfile:
    error("Must specify only one of -l and -f")

  opts.source = None
  if opts.logfile:
    opts.source = file(opts.logfile, "r")
  elif opts.fpfile:
    opts.source = file(opts.fpfile, "r")

  # Outputs
  if opts.dir:
    fname = opts.output
    if fname is None:
      fname = datetime.today().strftime("%Y-%m-%d_%H-%M-%S") + ".fp"
    path = os.path.join(opts.dir, fname)
    opts.output_file = path
    opts.output = file(path, "w")
  elif opts.output:
    opts.output_file = opts.output
    opts.output = file(opts.output_file, "w")
  else:
    opts.output_file = None
    opts.output = sys.stdout

  # GHC Directory
  # As a last step change the directory to the GHC directory specified
  if opts.ghcdir:
    os.chdir(opts.ghcdir)

  return opts

def error(msg="fatal error", parser=None, exit=1):
  """Function that prints error message and exits"""
  print("ERROR:", msg)
  if parser:
    parser.print_help()
  sys.exit(exit)

class MalformedFingerPrintError(Exception):
  """Exception raised when parsing a bad fingerprint file"""
  pass

class FingerPrint:
  """Class representing a fingerprint of all ghc git repos.

  A finger print is represented by a dictionary that maps a
  directory to a commit. The directory "." is used for the top
  level ghc repository.
  """
  def __init__(self, subcommits = {}):
    self.commits = subcommits

  def __eq__(self, other):
    if other.__class__ != self.__class__:
      raise TypeError
    return self.commits == other.commits

  def __neq__(self, other):
    not(self == other)

  def __hash__(self):
    return hash(str(self))

  def __len__(self):
    return len(self.commits)

  def __repr__(self):
    return "FingerPrint(" + repr(self.commits) + ")"

  def __str__(self):
    s = ""
    for lib in sorted(self.commits.keys()):
      commit = self.commits[lib]
      s += "{0}|{1}\n".format(lib, commit)
    return s

  def __getitem__(self, item):
    return self.commits[item]

  def __iter__(self):
    return self.commits.iteritems()

  def write(self, outh):
      outh.write(str(self))
      outh.flush()

  @staticmethod
  def read(inh):
    """Read a fingerprint from a fingerprint file"""
    commits = {}
    for line in inh.readlines():
      splits = line.strip().split("|", 1)
      if len(splits) != 2:
        raise MalformedFingerPrintError(line)
      lib, commit = splits
      commits[lib] = commit
    return FingerPrint(commits)

if __name__ == "__main__":
  main()
