#!/usr/bin/env python3

kStatusOpen = "open"
kStatusNew = "new"
kStatusDelete = "delete"
kStatusUnchanged = "same/open"
# these are defined by `p4`, you can't change them
kStatusMissing = "missing"
kStatusDiff = "diff"
kStatusSame = "same"

import subprocess, sys, os

def shellEscape(p):
    if set(p).intersection( set(r'''~!@#$%^&*(){}[]"'\|<>? ''') ):
        return "'" + p.replace("'", "\'\"\'\"\'") + "'"
    return p

def p4escape(path):
    return path.replace("%","%25").replace("@","%40").replace("#","%23").replace("*","%2A")

def p4unescape(path):
    return path.replace("%40","@").replace("%23","#").replace("%2A","*").replace("%25","%")

def check_workspace():
    try:
        output = subprocess.check_output("p4 info", shell=True, universal_newlines=True)
    except:
        print("Can not run `p4 info`. Make sure you are logged in (`p4 login`).", file=sys.stderr)
        raise
    if "Client unknown." in output:
        return False
    return True

p4config = os.environ.get('P4CONFIG')
if not p4config:
    p4config = '.perforce'

def inspect(args):
    if not check_workspace():
        print("No workspace found. You must first create it manually or with `p5 up -c`.", file=sys.stderr)
        raise SystemExit(1)

    import re, os.path, fnmatch
    output = subprocess.check_output("p4 client -o", shell=True, universal_newlines=True)
    client = re.search(r'''^Client:\s+(.*)''', output, re.M).group(1)
    root = re.search(r'''^Root:\s+(.*)''', output, re.M).group(1)
    statuses = {}
    
    print("Checking opened files...", file=sys.stderr)
    output = subprocess.check_output(r"""p4 -ztag opened | grep '^\.\.\. depotFile' | sed 's/^\.\.\. depotFile//' | xargs -I {} p4 -ztag where {}""", shell=True).decode(errors='replace')
    tag = '... path '
    for path in output.splitlines():
        if path.startswith(tag):
            statuses[p4unescape(path[len(tag):])] = kStatusOpen
    
    print("Checking unopened tracked files...", file=sys.stderr)
    output = subprocess.check_output("p4 diff -sl", shell=True).decode(errors='replace')
    for match in re.finditer(r'''^(\w+)\s+(.*)''', output, re.M):
        status, path = match.groups()
        # files returned by previous `p4 opened` check are ignored
        if (path in statuses):
            continue
        # files that are already oped for deletion will be 'missing' here
        if (status == kStatusMissing) and (path in statuses):
            status = kStatusDelete
        statuses[path] = status
    
    print("Searching unchanged files...", file=sys.stderr)
    output = subprocess.check_output("p4 diff -sr", shell=True).decode(errors='replace')
    for path in output.splitlines():
        if path not in statuses:
            statuses[path] = kStatusUnchanged
    
    print("Finding untracked (new) files...", file=sys.stderr)
    output = subprocess.check_output(r'''find "%s" -type f''' % root, shell=True, universal_newlines=True)
    for phys in output.splitlines():
        if phys not in statuses:
            statuses[phys] = kStatusNew
    
    p4ignore = os.path.join(root, ".p4ignore")
    ignores = list(map(str.strip, open(p4ignore, "r").readlines())) if os.path.exists(p4ignore) else []
    while "" in ignores: 
        ignores.remove("")
    ignores.append("/" + p4config)
    def is_ignored(path):
        if statuses[path] == kStatusSame:
            return True
        basename = os.path.basename(path)
        dirname = os.path.dirname(path) + os.sep
        relpath = os.path.relpath(path, root) 
        for patt in ignores:
            patHasSep = os.sep in patt
            # shortcut for speed
            if (basename == patt) or (relpath == patt):
                return True
            # with '/', path is matched, otherwise, filename only
            if fnmatch.fnmatch( (relpath if (patHasSep) else basename), patt):
                return True
            # is our pattern an unrooted directory pattern?
            # or unrooted dir with path wildcard: i.e. dir/file*.txt
            if (patHasSep and not patt.startswith(os.sep)):
                if (patt.endswith(os.sep)):
                    if ((relpath.startswith(patt) or patt in dirname)):
                        return True
                else:
                    patDir = os.path.dirname(patt) + os.sep
                    patFile = os.path.basename(patt)
                    if (dirname.endswith(patDir) and fnmatch.fnmatch(basename, patFile)):
                        return True
            # exact full path patterns start with /
            if (patt[0] == os.sep) and fnmatch.fnmatch(relpath, patt[1:]):
                return True
        return False

    command_from_status = { kStatusMissing : "delete", kStatusDiff : "edit", kStatusNew : "add", kStatusOpen : None, kStatusDelete : None, kStatusUnchanged : "revert" }
    edit_part, revert_part, opened_part = [], [], []
    destination_from_status = { kStatusMissing : edit_part, kStatusDiff : edit_part, kStatusNew : edit_part, kStatusOpen : opened_part, kStatusDelete : opened_part, kStatusUnchanged : revert_part }
    for path in sorted(statuses.keys()):
        if is_ignored(path):
            continue
        status = statuses[path]
        
        # ignore resource forks problem with 'apple' file types
        # http://theaccidentalreleaseengineer.blogspot.ru/2007/12/perforce-apple-filetype-and-mysterious.html
        if status == kStatusNew:
            basename = os.path.basename(path)
            dirname = os.path.dirname(path)
            if basename.startswith('%') and (os.path.join(dirname, basename[1:]) in statuses):
                continue

        command = command_from_status[status]
        destination_list = destination_from_status[status]
        destination_list.append((status, command, os.path.relpath(path)))
    return (edit_part, revert_part, opened_part)



def status(args):
    edit_part, revert_part, opened_part = inspect(args)

    for status, _, path in edit_part:
        print("{0:>12} {1}".format(status, path))
    for status, _, path in revert_part:
        print("{0:>12} {1}".format(status, path))
    for status, _, path in opened_part:
        print("{0:>12} {1}".format(status, path))



def reconcile(args):
    editor = os.getenv("EDITOR")
    if not editor:
        warn_about_vi = True
        editor = "vi"
    else:
        warn_about_vi = False

    edit_part, revert_part, opened_part = inspect(args)

    def warn(f):
        nonlocal warn_about_vi
        if warn_about_vi:
            print("""
# You are running `vi` because EDITOR environment variable is not set.
# To save and exit, type `:wq<Enter>`. To delete the line under the cursor, type `dd`.
# Select multiple lines with `V` then delete selection with `d` or cancel it with `Esc`. To undo, type `u`.
# To exit without saving, type `:q!`
""", end='', file=f)
            warn_about_vi = False

    if edit_part or revert_part:
        tempy_path = "/tmp/{0}_pfs_reconcile.txt".format(os.getenv("USER"))
        tempy = open(tempy_path,"w")
        print("""\
#!/bin/sh
#
# Save and exit to perform these commands. Edit to remove unwanted commands.
# Quit without saving to cancel.
""", end='', file=tempy)
        warn(tempy)

        if edit_part: print("\n\n# Unopened changes:\n", file=tempy)
        for _, command, path in edit_part:
            flag = ""
            escapedPath = shellEscape(path)
            if ('@' in path) or ('#' in path) or ('%' in path):
                if command == "add":
                    flag = " -f"
                elif (command == "delete") or (command == "edit"):
                    escapedPath = p4escape(escapedPath)
            print("p4 {0}{1} {2}".format(command, flag, escapedPath), file=tempy)

        if revert_part: print("\n\n# Opened but not changed (revert recommended):\n", file=tempy)
        for _, _, path in revert_part:
            print("p4 revert {0}".format(shellEscape(path)), file=tempy)

        if opened_part: print("\n\n# For information only. Opened files:\n", file=tempy)
        for status, _, path in opened_part:
            print("# {0:>12} {1}".format(status, path), file=tempy)

        tempy.close()
        del tempy
        mtime = os.stat(tempy_path).st_mtime
        if os.system("'{0}' '{1}'".format(editor, tempy_path)):
            print("Editor exited with error status, abort.")
        elif mtime == os.stat(tempy_path).st_mtime:
            print("File not saved => aborted. The file is saved at\n\t{0}".format(tempy_path))
        else:
            if 0 == os.system("/bin/sh '{0}'".format(tempy_path)):
                os.unlink(tempy_path)
            else:
                print("Error performing commands, whatever. The file is saved at\n\t{0}".format(tempy_path))
    else:
        print("\nNothing to reconcile.")
        if opened_part:
            print()
            print("The list of already opened files:")
            for status, _, path in opened_part:
                print("{0:>12} {1}".format(status, path))



def diff(args):
    edit_part, revert_part, opened_part = inspect(args)

    paths = '\n'.join(path for _,_,path in edit_part+opened_part).encode('utf-8')

    pager_cmd = os.getenv('PFS_PAGER') or os.getenv('PAGER') or '/usr/bin/less -R'
    sh = subprocess.Popen("p4 -x - diff -f | %s" % pager_cmd, shell=True, stdin=subprocess.PIPE)
    sh.communicate(paths)



def sync(args):
    workspace_exists = check_workspace()
    if (not args.create) and (not workspace_exists):
        print("No workspace here. Run with `-n` if you want to auto create it.", file=sys.stderr)
        raise SystemExit(1)

    import re, os.path, fnmatch
    viewdef = subprocess.check_output("p4 client -o", shell=True, universal_newlines=True)
    client = re.search(r'''^Client:\s+(.*)''', viewdef, re.M).group(1)
    root = re.search(r'''^Root:\s+(.*)''', viewdef, re.M).group(1)

    postsync_warnings = ""

    # update clientspec
    workspace_config_filename = os.path.join(root, p4config)
    if (workspace_exists or args.create) and os.path.exists(workspace_config_filename):
        m = re.search(r'''P5CLIENTSPEC\s*=\s*(.*)\s*$''', open(workspace_config_filename, "r").read(), re.M)
        if m:
            clientspec_path = m.group(1)
            if clientspec_path.startswith('//'):
                # check if this file is mapped to clientview and locally modified, and use local modifications instead
                must_fetch = True
                if workspace_exists:
                    output = subprocess.check_output("p4 diff -sl {0} 2>/dev/null".format(shellEscape(clientspec_path)), shell=True, universal_newlines=True)
                    m = re.search(r'''^diff (.*)$''', output, re.M)
                    if m:
                        clientspec_path = m.group(1)
                        warning = "*** NOTICE: Clientspec is locally modified! Using local file instead: {0}".format(clientspec_path)
                        print(warning)
                        postsync_warnings += warning + "\n"
                        clientspec_contents = open(m.group(1), "r").read()
                        must_fetch = False
                
                    output = subprocess.check_output("p4 diff -sa {0} 2>/dev/null".format(shellEscape(clientspec_path)), shell=True, universal_newlines=True)
                    output = output.rstrip("\n")
                    if output:
                        clientspec_path = output
                        warning = "*** NOTICE: Clientspec is opened for edit! Using local file instead: {0}".format(clientspec_path)
                        print(warning)
                        postsync_warnings += warning + "\n"
                        clientspec_contents = open(clientspec_path, "r").read()
                        must_fetch = False
                
                if must_fetch:
                    print("Fetching clientspec from depot: {0}".format(clientspec_path))
                    clientspec_contents = subprocess.check_output("p4 print -q " + shellEscape(clientspec_path), shell=True, universal_newlines=True)
            else:
                clientspec_contents = open(clientspec_path, "r").read()

            # replace whatever client name there is with current client name
            clientspec_contents = re.sub(r'''^("?[+\-]?//(?:.*?)\s+"?//)([^/]+)(/.*)$''', r'''\1%s\3''' % client, clientspec_contents, flags=re.M)
            # erase comments and empty lines, and add tabs as needed by `p4 client -i`
            clientspec_contents = re.sub(r'''^\s*;.*$''', "", clientspec_contents, flags=re.M)
            clientspec_contents = os.linesep.join([ ("\t"+s) for s in clientspec_contents.splitlines() if s.strip()])

            # recreate client
            new_clientspec = viewdef
            new_clientspec = re.sub(r'''^(Options:\s+)(.*)$''', r'''\1allwrite noclobber compress unlocked nomodtime rmdir''', new_clientspec, flags=re.M)
            new_clientspec = re.sub(r'''^View:\s*\n.*''', "", new_clientspec, flags=re.DOTALL|re.M)
            new_clientspec += "\nView:\n" + clientspec_contents
            proc = subprocess.Popen("p4 client -i", stdin=subprocess.PIPE, shell=True, universal_newlines=True)
            proc.communicate(input=new_clientspec)
            if proc.returncode != 0:
                open("/tmp/clientspec.txt", "w").write(new_clientspec)
                print("The attempted workspace definition is saved to /tmp/p5clientspec.txt", file=sys.stderr)
                raise SystemExit(1)
            workspace_exists = check_workspace()

    if not workspace_exists:
        print("Creating workspace failed. Make sure you have `$P4CONFIG` file in workspace root with `P4CLIENT` (and optionally `P5CLIENTSPEC`) variables defined.", file=sys.stderr)
        raise SystemExit(1)

    # then simply run `p4 sync`
    exitcode = os.system("p4 sync" + (" -f" if args.forced else ""))
    if (0 == exitcode) and postsync_warnings:
        print(postsync_warnings, end='')



import argparse
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", help="Commands")

st_parser = subparsers.add_parser("status", aliases=["st"], help="Shows status of workspace files (changed, missing, etc)")
st_parser.set_defaults(func=status)

re_parser = subparsers.add_parser("reconcile", aliases=['re'], help="Interactively reconcile changes.")
re_parser.set_defaults(func=reconcile)

di_parser = subparsers.add_parser("diff", aliases=['di'], help="Show diff, including unopened files.")
di_parser.set_defaults(func=diff)

sync_parser = subparsers.add_parser("update", aliases=['up'], help="Fetches the latest clientspec, updates client view, runs `p4 sync`. May create workspace if needed.")
sync_parser.add_argument("--force", "-f", dest="forced", action="store_true", help="Pass `-f` to `p4 sync`")
###sync_parser.add_argument("--changelist", "-c", dest="changelist", help="Changelist number to use")
sync_parser.add_argument("--new", "-n", dest="create", action="store_true", help="Create workspace if it does not exist")
sync_parser.set_defaults(func=sync)

parser.add_argument("--version", action="version", version="%(prog)s 0.5")
args = parser.parse_args()

if hasattr(args, 'func'):
    args.func(args)
else:
    parser.print_help()

