#!/p4/common/python/bin/python3
# Version 2.1.3

"""
CaseCheckTrigger.py

Example 1: Typical usage from the Helix Core server triggers table:

    Triggers:
        CheckCase change-submit //... "/p4/common/bin/triggers/CheckCaseTrigger.py %changelist% myuser=%user%"

SAMPLE OUTPUT:
    Submit validation failed -- fix problems then use 'p4 submit -c 1234'.
    'CheckCaseTrigger' validation failed:

    Your submission has been rejected because the following files
    are inconsistent in their use of case with respect to existing
    directories:

    Your file:         '//depot/dir/test'
    existing file/dir: '//depot/DIR'

BYPASS LOGIC:
    By default, this trigger can be bypassed by any user by adding the
    token BYPASS_CASE_CHECK to the changelist description.  Specify
    'allowbypass=no' on the command line to disable the ability to
    bypass this trigger

    As an exception, if the user is 'git-fusion-user', the case check
    is always bypassed if 'myuser' is defined.

DEPENDENCIES:
    This trigger requires P4Triggers.py and the P4Python API.

SEE ALSO:
    See 'p4 help triggers'.

"""

from __future__ import print_function

import P4
import P4Triggers
import os
import re
import subprocess
import sys

class CheckCaseTrigger(P4Triggers.P4Trigger):
    """CaseCheckTrigger is a subclass of P4Trigger. Use this trigger to ensure
       that your depot does not contain two filenames or directories that only
       differ in case.
       Having files with different case spelling will cause problems in mixed
       environments where both case-insensitive clients like Windows and case-
       sensitive clients like UNIX access the same server.
    """

    def __init__(self, *args, maxErrors=10, **kwargs):
        kwargs['charset'] = 'none'
        kwargs['api_level'] = 71

        self.allowBypass=AllowBypass
        
        fileFilter = None
        if 'filefilter' in kwargs:
            fileFilter = kwargs['filefilter']
            del kwargs['filefilter']
        
        P4Triggers.P4Trigger.__init__(self, **kwargs)
        self.parse_args(__doc__, args)

        # need to reset the args in case a p4config file overwrote them
        for (k, v) in kwargs.items():
            if k != "log":
                try:
                    setattr(self.p4, k, v)
                except:
                    self.logger.error("error setting p4 property: '%s' to '%v'" % (k, v))

        self.map = None
        if fileFilter:
            try:
                with open(fileFilter) as f:
                    self.map = P4.Map()
                    for line in f:
                        self.map.insert(line.strip())
            except IOError:
                self.logger.error("Could not open filter file %s" % fileFilter)
        
        self.maxErrors = maxErrors
        self.depotCache = {}
        self.dirCache = {}
        self.fileCache = {}

    def add_parse_args(self, parser):
        """Specific args for this trigger - also calls super class to add common trigger args"""
        parser.add_argument('change', help="Change to validate - %%change%% argument from triggers entry.")
        super(CheckCaseTrigger, self).add_parse_args(parser)

    def setUp(self):
        info = self.p4.run_info()[0]
        if "unicode" in info and info["unicode"] == "enabled":
            self.p4.charset = "utf8"
        self.p4.exception_level = 1 # ignore WARNINGS like "no such file"
        self.p4.prog = "CheckCaseTrigger"

        if self.allowBypass:
            self.USER_MESSAGE="""

    Your changelist submit attempt has been rejected because
    one or more file paths opened for add vary only by case
    from existing files/directory paths.  Creating file/folders
    that vary only in case from existing paths causes inconsistent
    behavior across platforms with different case handling behaviors
    (e.g. Windows, Linux/UNIX, Mac OSX). Thus, adding case-only
    variations of existing paths is strongly discouraged.

    If you are certain the files to be added will only be accessed
    from workpsaces on case-sensitive platforms (like UNIX/Linux),
    then this trigger can be bypassed by adding the token
    BYPASS_CASE_CHECK to the changelist description and attempting
    the submit again.

    Alternately, you can revert any files opened for add in your
    changelists that vary only in case from existing files, or
    move them to new names that don't conflict with existing files.

    Offending files:
    """
        else:
            self.USER_MESSAGE="""

    Your changelist submit attempt has been rejected because
    one or more file paths opened for add vary only by case
    from existing files/directory paths.  Creating file/folders
    that vary only in case from existing paths causes inconsistent
    behavior across platforms with different case handling behaviors
    (e.g. Windows, Linux/UNIX, Mac OSX). Thus, adding case-only
    variations of existing paths is disallowed.

    To move forward, you can revert any files opened for add in your
    changelists that vary only in case from existing files, or
    move them to new names that don't conflict with existing files.

    Offending files:
    """

        self.BADFILE_FORMAT="""
      Your file:         '%s'
      existing file/dir: '%s'
      """

    def validate(self):
        """Here the fun begins. This method overrides P4Trigger.validate()"""
        badlist = {}
        files = self.change.files

        self.filterRenames(files)

        for file in files:
            action = file.revisions[0].action
            if self.map and self.map.includes(file.depotFile):
                continue
            if action not in ("add", "branch", "move/add"):
                continue

            mismatch = self.findMismatch(file.depotFile)
            if mismatch:
                badlist[file.depotFile] = mismatch

         # end for
      
        if len(badlist) > 0:
            self.report(badlist)
    
        return len(badlist) == 0

    # Method canonical 
    # IN:  string, utf8 compatible
    # OUT: unicode string, all lower case
    def canonical(self, aString):
        if self.p4.charset == "utf8":
            return unicode(aString, "utf8").lower()
        else:
            return aString.lower()

    # Method filterRenames:
    # Removes pairs of files that only differ in case
    # where one action is branch, and the other delete
    def filterRenames(self, files):
        branches = [x for x in files if x.revisions[0].action == 'branch']
        deletes = [x.depotFile.lower() for x in files if x.revisions[0].action == 'delete']
        
        for f in branches:
            if f.depotFile.lower() in deletes:
                files.remove(f)

    # This method returns either a depot or directory that differs from 
    # the supplied path only in case, or None if no mismatch is found
    def findMismatch(self, path):
        path = path[2:] # cut the // at the beginning of the depot off
        dirs = path.split('/')
        depot = dirs.pop(0)
        file = dirs.pop()

        depotMismatch = self.findDepotMismatch(depot)
        if (depotMismatch):
            return depotMismatch

        oldDirs = dirs[:]
        dirMismatch = self.findDirMismatch('//' + depot, dirs)
        if (dirMismatch):
            return dirMismatch

        fileMismatch = self.findFileMismatch(depot, oldDirs, file)
        if (fileMismatch):
            return fileMismatch

        return None

    # This method looks for a depot mismatch
    # It caches the depots as it found them
    # IN: depot name (as provided by the change list
    # OUT: None if (no mismatch found) else (stored depot name)

    def findDepotMismatch(self, depot):
        canonicalDepot = self.canonical(depot)

        # if the depot is in the cache, the cached version is authorative
    
        if canonicalDepot in self.depotCache:
            if self.depotCache[canonicalDepot] == depot:
                return None
            else:
                return '//' + self.depotCache[canonicalDepot]

        # depot not found in cache. Populate the cache
    
        mismatch = None
        for d in self.p4.run_depots():
            dname = d["name"]
            cd = self.canonical(dname)
            self.depotCache[cd] = dname
            if canonicalDepot == cd and depot != dname:
                mismatch = '//' + dname

        return mismatch
    # end findDepotMismatch

    # Look for matching or mismatching directories
    # Recursive algorithm, descending down the directory paths
    # Caching directories as we find them
    # 

    def findDirMismatch(self, top, dirs):
        if len(dirs) == 0:
            return None

        aDir = dirs.pop(0)
        path = top + '/' + aDir
        cpath = self.canonical(path)

        if cpath in self.dirCache :
            # negative lookups are cached with the value set to False
            if (self.dirCache[cpath] == path) or (self.dirCache[cpath] == False):
               return self.findDirMismatch(path, dirs)
            else:
                # it is a mismatch, either with a previously stored name, or, worse
                # with a previous file in the same change list
            
                return self.dirCache [cpath]
            # end if
        # end if

        # so, it is not in the cache. Let's populate the cache and check
        # while we are at it
        
        mismatch = None
        for d in self.p4.run_dirs(top + "/*"):
            d = d["dir"]  # result is in tagged mode, single entry "dir"=>directory name
            cd = self.canonical (d)
            self.dirCache[cd] = d
            
            if cd == cpath:
                # we found the dir entry. Is it the right case?
                
                if d == path:
                    # so far so good, lets step one directory level down
                    
                    mismatch = self.findDirMismatch(path, dirs)
                else:
                    # oh-ho, we found a mismatch
                    mismatch = d
                # end if d == path
            # end if cd == cpath
        # end for

        if not mismatch:
            # enter as a negative match
            self.dirCache [cpath] = False

        return mismatch

    def findFileMismatch(self, depot, dirs, file):
        base = '//' + depot + '/'
        if (len(dirs) > 0):
            base += '/'.join(dirs)
            base += '/'

        name = base + file
        cname = self.canonical(name)
        if cname in self.fileCache:
            if (self.fileCache[cname] == name):
                return None # all is well, but cannot happen, add would fail
            else:
                return self.fileCache[cname]

        # so the file is not in the cache. Let's load the cache
        for f in self.p4.run_files(base + "*"):
            if "delete" not in f["action"]:
                f = f["depotFile"]
                cf = self.canonical(f)
                self.fileCache[cf] = f
                if (cf == cname and f != name):
                    return f

        # so it is not in the file cache
        self.fileCache[cname] = name
        return None

    def report(self, badfiles):
        msg = self.USER_MESSAGE
        for (n, (file, mismatch)) in enumerate(badfiles.items()):
            if n >= self.maxErrors:
                break
            msg += self.BADFILE_FORMAT % (file, mismatch)

        self.message(msg)

    def run(self):
        """Runs trigger"""
        try:
            self.logger.debug("CheckCaseTrigger firing")
            self.setupP4()
            return self.parseChange(self.options.change)
        except Exception:
            return self.reportException()
        return 0

# If called from the command line, go in here

if __name__ == "__main__":
    # Generate new args - parsing out port=123 style way of specifying
    # parameters intended for p4 properties
    kwargs = {}
    args = []
    for arg in sys.argv[1:]:
        p = arg.split("=", 1)
        if len(p) == 1:
            args.append(arg)
        else:
            kwargs[p[0]] = p[1]

    # Example of how to exclude the 'git-fusion-user'
    # Note: Need to remove 'mysuser' after test as it's not a valid P4 argument.
    if 'myuser' in kwargs: 
        if kwargs['myuser'] == 'git-fusion-user':
            sys.exit(0)
        else:
            del kwargs['myuser']

    AllowBypass = 1

    if 'allowbypass' in kwargs: 
        if kwargs['allowbypass'] == 'no':
            AllowBypass = 0

        # Remove 'allowbypass' after test as it's not a valid P4 argument.
        del kwargs['allowbypass']

    if AllowBypass:
        # Grab the changelist description, and scan for the bypass token string.
        # If the token is detected, silently and immediately exit with a happy 0
        # exit code.
        changelist = sys.argv[1]
        cmd = "%s -ztag -F %%desc%% describe -f -s %s" % (os.getenv('P4BIN','p4'), changelist)
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=True)
        (changeDesc, err) = p.communicate()
        p_status = p.wait()

        # If the changelist description contains the text BYPASS_CASE_CHECK,
        # bypass the case check logic.
        if (re.search (b'BYPASS_CASE_CHECK', changeDesc, re.MULTILINE)):
            sys.exit(0)

    ct = CheckCaseTrigger(*args, maxErrors=10, **kwargs)
    sys.exit(ct.run())
