#!/usr/bin/python

#
# Copyright (c) 2007, Hewlett-Packard Development Company, L.P. <dannf@hp.com>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
#     * Redistributions of source code must retain the above
#       copyright notice, this list of conditions and the following
#       disclaimer.
#
#     * Redistributions in binary form must reproduce the above
#       copyright notice, this list of conditions and the following
#       disclaimer in the documentation and/or other materials
#       provided with the distribution.
#
#     * Neither the name of the Hewlett-Packard Co. nor the names
#       of its contributors may be used to endorse or promote
#       products derived from this software without specific prior
#       written permission.
#
# THIS SOFTWARE IS PROVIDED BY HEWLETT-PACKARD DEVELOPMENT COMPANY
# ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT
# NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
# HEWLETT-PACKARD DEVELOPMENT COMPANY BE LIABLE FOR ANY DIRECT,
# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
# IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#

from optparse import OptionParser
import sys
import os.path, shutil, stat
import tempfile
import pysvn
import re
import types

class TagClient(object):
    def __init__(self, url, svndir, tagdir):
        self.url = url
        self.svndir = svndir
        self.tagdir = tagdir
        self.message = "Tag %s as %s" % (svndir, tagdir)

        self.svnclient = NotifiedClient()
        self.svnclient.callback_get_log_message = self.get_tag_message

    def get_tag_message(self):
        return True, self.message

    def doIt(self):
        self.svnclient.copy(os.path.join(self.url, self.svndir),
                            os.path.join(self.url, self.tagdir))

def NotifiedClient():
    def notify(event_dict):
        if event_dict['action'] == pysvn.wc_notify_action.delete:
            sys.stdout.write("Removing %s\n" % (event_dict['path']))
        elif event_dict['action'] == pysvn.wc_notify_action.add:
            sys.stdout.write("Adding %s\n" % (event_dict['path']))
        elif event_dict['action'] == pysvn.wc_notify_action.copy:
            sys.stdout.write("Copying %s\n" % (event_dict['path']))
    client = pysvn.Client()
    client.callback_notify = notify
    return client

class MoveMenu(object):
    """A menu allowing the user to indicate whether deleted/added files were
    moved, to conserve space in the repository
    """

    def __init__(self, workingdir, newdir, moved=None, interactive=True):
        """Create a MoveMenu instance

        Params:
            workingdir: temporary working directory
            newdir: directory to load
            moved: If not None, should be a regex-indexed dictionary of
              functions of m (an re.match object), which map deleted files to
              added files, e.g.
                    moved = {re.compile('^src/(?P<name>\S+)\.gif') :
                             lambda m: 'images/%s.gif' % m.group('name')}
              which maps any file ending with .gif under 'src' to a file of the
              same name under 'images'.  It's easy enough to do one-to-one
              mappings, as well:
                    moved = {re.compile('^foo/bar$') : lambda m: 'bar/baz'}
            interactive: Menus are only actually displayed if this is True
        """

        self.workingdir = workingdir
        self.newdir = newdir
        self.moved = moved
        self.interactive = interactive

        # make these variables in case of future localization
        self.delcolhead = 'Deleted'
        self.addcolhead = 'Added'

        self.deleted = unique_nodes(workingdir, newdir)
        self.added = unique_nodes(newdir, workingdir)
        
        self.menu_pair = re.compile("^(?P<src>\d+)\s+(?P<dest>\d+)$")

    def go(self):
        """Go through all the differences and perform requested operations"""

        while self.deleted and self.added:
            # The deleted column should be as wide as the longest path
            # in that column or the length of the 'Deleted' string, whichever
            # is greater
            self.delcollen = max([len(i) for i in self.deleted]
                                 + [len(self.delcolhead)]) + 1

            keep_going, answer = self.prompt()

            if not keep_going:
                break
            
            if not answer:
                continue
            else:
                srcindex, destindex = answer

            if srcindex >= len(self.deleted) or destindex >= len(self.added):
                sys.stderr.write("Error: Invalid index.\n")
                continue

            src = self.deleted[srcindex]
            dest = self.added[destindex]
            move_node(self.workingdir, src, dest)

            del self.deleted[srcindex]

            # If we moved a node into a subtree that didn't yet exist,
            # then move_node politely created it for us. That was nice of her.
            # Let's remove those directories from our 'added' list - we can't
            # move a directory to a name that already exists.
            head = dest
            while head:
                if head in self.added:
                    self.added.remove(head)
                (head, tail) = os.path.split(head)
                
            # If we just moved a directory, its subtree went with it and
            # can't move again. Remove subtree nodes from the deleted list so
            # the user can't try it. If this proves to be a desired feature,
            # we'll need to do multiple commits. Otherwise, users should
            # move subtree components first, and then move the whole directory
            if os.path.isdir(os.path.join(workingdir, dest)):
                i = 0
                while i < len(self.deleted):
                    if self.deleted[i][:len(src)+1] == src + '/':
                        self.deleted.pop(i)
                    else:
                        i = i + 1

    def generate_header(self):
        """Generate the header line, returning it as a string"""
        delcollen = max(self.delcollen, len(self.delcolhead))
        header = " " * 5
        header = header + self.delcolhead
        header = header + (delcollen - len(self.delcolhead) + 1) * " "
        header = header + self.addcolhead + "\n"
        return header

    def generate_row(self, num, delfile, addfile):
        """Return a string for a row"""
        deleted = delfile + '_' * (self.delcollen - len(delfile) - 1)
        return("%4d %s  %s\n" % (num, deleted, addfile))

    def display(self):
        """Display the menu, row-by-row"""

        for i in range(max([len(self.deleted), len(self.added)])):
            delcell = ""
            if len(self.deleted) > i:
                delcell = self.deleted[i]
            addcell = ""
            if len(self.added) > i:
                addcell = self.added[i]
            row = self.generate_row(i, delcell, addcell)
            sys.stdout.write(row)

    def _prompt():
        """Return a prompt as a string"""
        return("Enter two indexes for each column to rename, (R)elist, or (F)inish: ")
    _prompt = staticmethod(_prompt)

    def prompt(self):
        """Prompt the user for an answer, and return an answer tuple

        If self.moved is set, mappings from there are exhausted before
        resorting to asking the user.
        
        Return value is of the form (continue_listing, answer_tuple).  Cases:

            If user answers "F", then continue_listing is False, and
            answer_tuple is None.

            If user answers "R" or the input is invalid, then continue_listing
            is True, and answer_tuple is None.

            If user answers with a valid tuple (or one is found using
            self.moved), then continue_listing is True, and answer_tuple is the
            tuple of indices

        If self.interactive is False, no prompts are printed.
        """
        
        ask = self.interactive

        if self.moved is not None:
            # Check in the dictionary mapping deleted files to added files
            found = False
            for node in self.deleted:
                for pattern, func in self.moved.items():
                    m = pattern.match(node)
                    if m:
                        dest = func(m)
                        try:
                            destindex = self.added.index(dest)
                        except ValueError:
                            # This file was probably deleted.  Let's let other
                            # patterns try to match it before giving up.
                            continue
                        else:
                            srcindex = self.deleted.index(node)
                            ret = (True, (srcindex, destindex))
                            found = True
                            break

            if found:
                ask = False
            else:
                # We've exhausted our mapping
                if self.interactive:
                    ask = True
                else:
                    ask = False
                    ret = (False, None)
                
        if ask:
            # Ask the user
            header = self.generate_header()
            sys.stdout.write(header)
            self.display()
            prompt = self._prompt()
            sys.stdout.write(prompt)

            input = sys.stdin.readline()[:-1]
            if input in ['r', 'R']:
                ret = (True, None)
            elif input in ['f', 'F']:
                ret = (False, None)
            else:
                m = self.menu_pair.match(input)
                if m:
                    srcindex = int(m.group('src'))
                    destindex = int(m.group('dest'))
                    ret = (True, (srcindex, destindex))
                else:
                    sys.stderr.write("Error: Invalid input.\n")
                    ret = (True, None)

        return ret

##
## Check to see if a node (path) exists. If so, returns an entry oject for it.
##
def svn_path_exists(svn_url, svn_dir):
    client = NotifiedClient()
    
    try:
        entry = client.info2(os.path.join(svn_url, svn_dir),
                             recurse = False)[0]
        return entry
    except pysvn._pysvn.ClientError:
        return None

##
## Create a directory in svn (and any parents, if necesary)
##
def make_svn_dirs(svn_url, svn_import_dir):
    client = NotifiedClient()

    entry = svn_path_exists(svn_url, svn_import_dir)
    if entry:
        if entry[1]['kind'] == pysvn.node_kind.dir:
            return True
        else:
            sys.stderr.write("\nError: %s exists but is not a directory.\n\n" % (svn_import_dir))
            raise pysvn.ClientError
    else:
        make_svn_dirs(svn_url, os.path.dirname(svn_import_dir))
        client.mkdir(os.path.join(svn_url, svn_import_dir),
                     "Creating directory for import")

def contains_svn_metadata(dir):
    for root, dirs, files in os.walk(dir):
        if '.svn' in dirs or '.svn' in files:
            return True
    return False

##
## Checkout an svn dir to a temporary directory, and return that directory
##
def checkout_to_temp(svn_url, svn_dir):
    workingdir = tempfile.mkdtemp(prefix="svn-load")
    
    client = NotifiedClient()
    client.checkout(os.path.join(svn_url, svn_dir),
                    os.path.join(workingdir, 'working'))
    
    return workingdir

##
## return a list of files that exist only in dir1
##
def unique_nodes(dir1, dir2):
    unique = []
    for root, dirs, files in os.walk(dir1):
        if '.svn' in dirs:
            dirs.remove('.svn')
        for path in files + dirs:
            relpath = os.path.join(root, path)[len(dir1)+1:]
            counterpath = os.path.join(dir2, relpath)
            if not os.path.exists(counterpath):
               unique.append(relpath)

    return unique


def move_node(workingdir, src, dest):
    client = NotifiedClient()
    make_svn_dirs("", os.path.dirname(os.path.join(workingdir, dest)))
    client.move(os.path.join(workingdir, src),
                os.path.join(workingdir, dest))
#    ## Clear out the removed files
#    shutil.rmtree(os.path.join(workingdir, src))

def remove_nodes(workingdir, newdir):
    dellist = unique_nodes(workingdir, newdir)
    fqdellist = [ os.path.join(workingdir, p) for p in dellist ]
    client = NotifiedClient()
    client.remove(fqdellist)

##
## Overlay the new tree on top of our working directory, adding any
## new nodes along the way
##
def overlay_files(workingdir, newdir):
    client = NotifiedClient()

    for root, dirs, files in os.walk(newdir):
        for f in files:
            fullpath = os.path.join(root, f)
            relpath = fullpath[len(newdir)+1:]
            counterpath = os.path.join(workingdir, relpath)
            if os.path.isdir(counterpath):
                sys.stderr.write("Can't replace directory %s with file %s.\n"
                                 % (counterpath, fullpath))
                return False
            needs_add = False
            if not os.path.lexists(counterpath):
                needs_add = True

            # shutil.copy follows symlinks, so we need to handle them
            # separately
            if os.path.lexists(counterpath) and \
                   (os.path.islink(counterpath) or os.path.islink(fullpath)):
                os.unlink(counterpath)

            if os.path.islink(fullpath):
                os.symlink(os.readlink(fullpath), counterpath)
            else:
                shutil.copy(fullpath, counterpath)
            if needs_add:
                client.add(counterpath)
            if not os.path.islink(counterpath):
                # Make sure svn doesn't erroneously treat this as a symlink
                client.propdel("svn:special", counterpath)

        # We have to use a counter instead of something like 'for d in dirs'
        # because we might be removing elements - removing elements in an
        # iterator causes us to skip over some
        i = 0
        while i < len(dirs):
            fullpath = os.path.join(root, dirs[i])
            relpath = fullpath[len(newdir)+1:]
            counterpath = os.path.join(workingdir, relpath)

            if not os.path.exists(counterpath):
                shutil.copytree(fullpath, counterpath, symlinks=True)
                client.add(counterpath)
                dirs.pop(i)
                continue
            if not os.path.isdir(counterpath):
                sys.stderr.write("Can't replace file %s with dir %s.\n"
                                 % (counterpath, fullpath))
                return False
            i = i + 1

## treats u+x as the canonical decider for svn:executable
## should probably see if svn import does it differently..
def is_executable(f):
    if os.path.islink(f):
        return False
    s = os.lstat(f)
    return s[stat.ST_MODE] & 0500 == 0500

def svn_is_executable(file):
    client = NotifiedClient()

    for path, prop_list in client.proplist(file):
        if prop_list.has_key('svn:executable'):
            return True
    return False

def svn_set_exec(file):
    client = NotifiedClient()
    client.propset('svn:executable', '*', file)

def svn_clear_exec(file):
    client = NotifiedClient()
    client.propdel('svn:executable', file)

def sync_exec_flags(workingdir):
    client = NotifiedClient()
    
    for root, dirs, files in os.walk(workingdir):
        if '.svn' in dirs:
            dirs.remove('.svn')
        for f in files:
            path = os.path.join(root, f)
            if is_executable(path) and not svn_is_executable(path):
                svn_set_exec(path)
            if not is_executable(path) and svn_is_executable(path):
                svn_clear_exec(path)

def strip_slashes(path):
    path = os.path.normpath(path)
    while os.path.isabs(path):
        path = path[1:]
    return path

def parse_move_map(filename):
    """Read in mappings from filename, return a dictionary

    Example file entries:
        ^src/(?P<name>\S+)\.gif$    lambda m: "images/%s.gif" % m.group("name")
        ^foo/bar$                   "bar/baz"
    Essentially, the first field must be a pattern that explicitly matches ^
    and $.  The second field (separated by whitespace) is a lambda function of
    one variable, of the type returned by re.match().  Alternately, the second
    field can be an explicit string--in the second example, the following
    function would be constructed automatically:
        lambda m: "bar/baz"
    If you specify an explicit string, it must be enclosed in quotes.

    After parsing, return a dictionary which maps the objects returned by
    re.compile() to the lambda functions of their match objects.
    """
    
    f = open(filename, 'r')
    map = {}

    for line in f:
        if not line.strip():
            # Ignore blanks
            continue
        if not line.startswith('^'):
            sys.stderr.write("Error: Regular expression in map must explicitly "
                             "match ^ and $\n")
            sys.exit(1)
        
        keep_searching = True
        pos = 0
        while keep_searching:
            pos = line.find(r'$', 0)
            if pos == -1:
                sys.stderr.write("Error: Regular expression in map must "
                                 "explicitly match ^ and $\n")
                sys.exit(1)
            elif line[pos-1] == '\\':
                # Escaped, so keep looking!
                continue
            else:
                # Found the end
                keep_searching = False
        
        pattern = re.compile(line[:pos+1])
        rest = line[pos+1:].strip()
        # Evaluate with nothing in the scope
        value = eval(rest, {}, {})
        
        # If it's actually a string, let's turn it into a trivial lambda
        if type(value) == types.StringType:
            func = lambda m, v=value: v
        elif type(value) != types.FunctionType:
            sys.stderr.write("Error: right field in map must be a lambda or "
                             "a string\n")
            sys.exit(1)
        else:
            func = value

        map[pattern] = func

    f.close()
    return map

    
if __name__ == '__main__':
    usage = "usage: %prog [options] svn_url svn_import_dir [dir_v1 [dir_v2 [..]]]"
    parser = OptionParser(usage=usage)
    parser.add_option("-t", dest="tagdir",
                      help="create a tag copy in tag_dir, relative to svn_url",
                      metavar="tag_dir")
    parser.add_option("--no-prompt", action="store_true", dest="noprompt",
                      default=False,
                      help="non-interactive mode - don't ask any questions")
    parser.add_option("--wc", dest="working_copy",
                      help="use the already checked-out working copy at path "
                           "instead of checking out a fresh working copy",
                      metavar="working_copy")
    parser.add_option("-m", "--move-map", metavar="FILE",
                      help="Load a mapping of regular expression patterns to "
                           "lambda functions of match objects from FILE")

    (options, args) = parser.parse_args()

    if len(args) < 3:
        sys.stderr.write("Invalid syntax.\n")
        parser.print_help()
        sys.exit(1)

    url = args[0]
    client = NotifiedClient()
    if not client.is_url(url):
        sys.stderr.write("Error: %s is not a valid svn url.\n" % url)
        sys.exit(1)
    if not svn_path_exists(url, ''):
        sys.stderr.write("Error connecting or no such repository: %s\n" % url)
        sys.exit(1)

    import_dir = strip_slashes(args[1])
    make_svn_dirs(url, import_dir)

    dirs = args[2:]

    # Check to make sure the user isn't trying to import an svn working dir
    for d in dirs:
        if contains_svn_metadata(d):
            sys.stderr.write("Error: %s contains .svn dirs or files\n" % (d))
            sys.exit(1)

    if options.move_map:
        moved = parse_move_map(options.move_map)
    else:
        moved = None

    if options.tagdir:
        make_svn_dirs(url, os.path.dirname(options.tagdir))

    if options.working_copy:
        workingdir = os.path.abspath(options.working_copy)
        if not contains_svn_metadata(workingdir):
            sys.stderr.write("Error: %s isn't an svn working directory\n" %
                             (workingdir))
            sys.exit(2)
    else:
        workingparent = checkout_to_temp(url, import_dir)
        workingdir = os.path.join(workingparent, 'working')

    for d in dirs:
        d = os.path.abspath(d)
        menu = MoveMenu(workingdir, d, interactive=not options.noprompt,
                        moved=moved)
        menu.go()
        remove_nodes(workingdir, d)
        overlay_files(workingdir, d)
        sync_exec_flags(workingdir)
        client.checkin(workingdir,
                       "Load %s into %s." % (os.path.basename(d), import_dir))
    if options.tagdir:
        t = TagClient(url, import_dir, options.tagdir)
        t.doIt()

    if not options.working_copy:
        shutil.rmtree(workingparent)
