#! /usr/bin/env python3
import argparse
from pathlib import Path
import re
import subprocess
import time

# format is [crate : path]
# this is an ordered list that also prescribes the publication order to crates.io
UTRA_CRATES = [
    ["svd2utra", "svd2utra"],
    ["utralib", "utralib"],
]
CRATES = [
    ["xous", "xous-rs"],
    # ["xous-kernel", "kernel"], # this is no longer published, as it is an implementation
    ["xous-ipc", "xous-ipc"],
    ["xous-api-log", "api/xous-api-log"],
    ["xous-api-names", "api/xous-api-names"],
    ["xous-api-susres", "api/xous-api-susres"],
    ["xous-api-ticktimer", "api/xous-api-ticktimer"],
    # ["xous-log", "services/xous-log"],  # implementations, no longer published
    # ["xous-names", "services/xous-names"],
    # ["xous-susres", "services/xous-susres"],
    # ["xous-ticktimer", "services/xous-ticktimer"],
]
# dictionary of crate names -> version strings
VERSIONS = {}

class PatchInfo:
    def __init__(self, filename, cratelist=None, cratename=None):
        self.filepath = Path(filename)
        if not self.filepath.is_file():
            print("Bad crate path: {}".format(filename))
        self.cratename = cratename
        self.cratelist = cratelist
        self.debug = False

    def get_version(self):
        with open(self.filepath, 'r') as file:
            lines = file.readlines()
            in_package = False
            name_check = False
            for line in lines:
                if line.strip().startswith('['):
                    if 'package' in line:
                        in_package = True
                    else:
                        in_package = False
                    continue
                elif in_package:
                    if line.strip().startswith('version'):
                        version = line.split('=')[1].replace('"', '').strip()
                    if line.strip().startswith('name'):
                        name = line.split('=')[1].replace('"', '').strip()
                        for [item_name, path] in self.cratelist:
                            if name == item_name:
                                name_check = True
            if name_check:
                assert version is not None # "Target name found but no version was extracted!"
                VERSIONS[name] = version

            return name_check

    def debug_mode(self, arg):
        self.debug = arg

    def output(self, line):
        if self.debug:
            print("Dry run: {}".format(line.rstrip()))
            # pass
        else:
            self.file.write(line)

    # assumes that VERSIONS has been initialized.
    def increment_versions(self, mode='bump'):
        # check that global variables are in sane states
        assert len(VERSIONS) > 0 # "No VERSIONS found, something is weird."
        with open(self.filepath, 'r') as file:
            lines = file.readlines()
        with open(self.filepath, 'w', newline='\n') as file:
            self.file = file
            in_package = False
            in_dependencies = False
            for line in lines:
                if line.strip().startswith('['):
                    if 'package' in line:
                        in_package = True
                    else:
                        in_package = False
                    if 'dependencies' in line:
                        in_dependencies = True
                    else:
                        in_dependencies = False
                    self.output(line)
                elif line.strip().startswith('#'): # skip comments
                    self.output(line)
                elif in_package:
                    # increment my own version, if I'm in the listed crates and we're in 'bump' mode
                    if (self.cratename is not None) and (mode == 'bump'):
                        if line.strip().startswith('version'):
                            self.output('version = "{}"\n'.format(bump_version(VERSIONS[self.cratename])))
                        else:
                            self.output(line)
                    else:
                        self.output(line)

                    if line.strip().startswith('name'):
                        this_crate = line.split('=')[1].replace('"', '').strip()
                        print("Patching {}...".format(this_crate))
                elif in_dependencies:
                    # now increment dependency versions

                    # first search and see if the dependency name is in the table
                    if 'package' in line: # renamed package
                        semiparse = re.split('=|,', line.strip())
                        if 'package' in semiparse[0]:
                            # catch the case that the crate name has the word package in it. If this error gets printed,
                            # we have to rewrite this semi-parser to be smarter about looking inside the curly braces only,
                            # instead of just stupidly splitting on = and ,
                            print("Warning: crate name has the word 'package' in it, and we don't parse this correctly!")
                            print("Searching in {}, found {}".format(this_crate, semiparse[0]))
                        else:
                            # extract the first index where 'package' is found
                            index = [idx for idx, s in enumerate(semiparse) if 'package' in s][0]
                            depcrate = semiparse[index + 1].replace('"', '').strip()
                            # print("assigned package name: {}".format(depcrate))
                    else: # simple version number
                        depcrate = line.strip().split('=')[0].strip()
                        # print("simple package name: {}".format(depcrate))

                    # print("{}:{}".format(this_crate, depcrate))
                    if depcrate in VERSIONS:
                        if mode == 'bump':
                            oldver = VERSIONS[depcrate]
                            (newline, numsubs) = re.subn(oldver, bump_version(oldver), line)
                            if numsubs != 1 and not "\"*\"" in newline:
                                print("Warning! Version substitution failed for {}:{} in crate {} ({})".format(depcrate, oldver, this_crate, numsubs))

                            # print("orig: {}\nnew: {}".format(line, newline))
                            self.output(newline)
                        elif mode == 'to_local':
                            if 'path' in line:
                                self.output(line) # already local path, do nothing
                            else:
                                for [name, path] in self.cratelist:
                                    if depcrate == name:
                                        # print("self.file: {}".format(self.file.name))
                                        depth = self.file.name.count('/')
                                        base = '../' * (depth)
                                        subpath = 'path = "{}{}"'.format(base, path)
                                if subpath is None:
                                    print("Error: couldn't find substitution path for dependency {}".format(depcrate))

                                if 'version' in line:
                                    oldver = 'version = "{}"'.format(VERSIONS[depcrate])
                                    newpath = subpath
                                else:
                                    oldver = '"{}"'.format(VERSIONS[depcrate])
                                    newpath = '{{ {} }}'.format(subpath)

                                (newline, numsubs) = re.subn(oldver, newpath, line)
                                if numsubs != 1 and not "\"*\"" in newline:
                                    print("Warning! Path substitution failed for {}:{} in crate {} ({})".format(depcrate, oldver, this_crate, numsubs))

                                self.output(newline)
                        elif mode == 'to_remote':
                            if 'path' not in line:
                                self.output(line) # already remote, nothing to do
                            else:
                                if '{' in line and ',' in line:
                                    specs = re.split(',|}|{', line) # line.split(',')
                                    for spec in specs:
                                        if 'path' in spec:
                                            oldpath = spec.rstrip().lstrip()
                                    if oldpath is None:
                                        print("Error! couldn't parse out path to substitute for dependency {}".format(depcrate))
                                    newver = 'version = "{}"'.format(VERSIONS[depcrate])
                                    (newline, numsubs) = re.subn(oldpath, newver, line)
                                    if numsubs != 1 and not "\"*\"" in newline:
                                        print("Warning! Path substitution failed for {}:{} in crate {} ({})".format(depcrate, oldpath, this_crate, numsubs))
                                    else:
                                        # print("Substitute {}:{} in crate {} ({})".format(depcrate, oldpath, this_crate, newver))
                                        # print("  " + oldpath)
                                        # print("  " + newline)
                                        pass
                                    self.output(newline)
                                else:
                                    self.output('{} = "{}"\n'.format(depcrate, VERSIONS[depcrate]))
                    else:
                        self.output(line)
                else:
                    self.output(line)
                # if debug mode, just write the line unharmed
                if self.debug:
                    self.file.write(line)

def bump_version(semver):
    components = semver.split('.')
    components[-1] = str(int(components[-1]) + 1)
    retver = ""
    for (index, component) in enumerate(components):
        retver += str(component)
        if index < len(components) - 1:
            retver += "."
    return retver

def main():
    parser = argparse.ArgumentParser(description="Update and publish crates")
    parser.add_argument(
        "-x", "--xous", help="Process Xous kernel dependent crates", action="store_true",
    )
    parser.add_argument(
        "-u", "--utralib", help="Process UTRA dependent crates", action="store_true",
    )
    parser.add_argument(
        "-b", "--bump", help="Do a version bump", action="store_true",
    )
    parser.add_argument(
        "-p", "--publish", help="Publish crates", action="store_true",
    )
    parser.add_argument(
        "-l", "--local-paths", help="Convert crate references to local paths", action="store_true"
    )
    parser.add_argument(
        "-r", "--remote-paths", help="Convert crate references to remote paths", action="store_true"
    )
    parser.add_argument(
        "-w", "--wet-run", help="Used in conjunction with --publish to do a 'wet run'", action="store_true"
    )
    args = parser.parse_args()

    if not(args.xous or args.utralib):
        print("Warning: no dependencies selected, operation is a no-op. Use -x/-u/... to select dependency trees")
        exit(1)

    cratelist = []
    if args.utralib: # ordering is important, the UTRA crates need to publish before Xous crates
        cratelist += UTRA_CRATES
        if not args.xous: # most Xous crates are also affected by this, so they need a bump as well
            cratelist += CRATES
    if args.xous:
        cratelist += CRATES

    if (args.bump or args.publish) and (args.local_paths or args.remote_paths):
        print("Do not mix path changes with bump and publish operations. Do them serially.")
        exit(1)
    if args.local_paths and args.remote_paths:
        print("Can't simultaneously change to local and remote paths. Pick only one operation.")
        exit(1)

    crate_roots = ['.', '../hashes/sha2', '../curve25519-dalek/curve25519-dalek']

    if args.bump or args.local_paths or args.remote_paths:
        cargo_toml_paths = []
        for roots in crate_roots:
            for path in Path(roots).rglob('Cargo.toml'):
                if 'target' not in str(path):
                    not_core_path = True
                    for cratespec in cratelist:
                        editpath = cratespec[1]
                        normpath = str(Path(path)).replace('\\', '/').rpartition('/')[0]  # fix windows paths
                        # print(editpath)
                        # print(normpath)
                        if editpath == normpath:
                            not_core_path = False
                    if not_core_path:
                        cargo_toml_paths += [path]

        # import pprint
        # pp = pprint.PrettyPrinter(indent=2)
        # pp.pprint(cargo_toml_paths)

        patches = []
        # extract the versions of crates to patch
        for [crate, path] in cratelist:
            #print("extracting {}".format(path))
            patchinfo = PatchInfo(path + '/Cargo.toml', cratelist, crate)
            if not patchinfo.get_version():
                print("Couldn't extract version info from {} crate".format(crate))
                exit(1)
            patches += [patchinfo]

        # now extract all the *other* crates
        for path in cargo_toml_paths:
            #print("{}".format(str(path)))
            patchinfo = PatchInfo(path, cratelist)
            patches += [patchinfo]

        if args.bump:
            for (name, ver) in VERSIONS.items():
                print("{}: {} -> {}".format(name, ver, bump_version(ver)))
        if args.local_paths or args.remote_paths:
            print("Target crate list")
            for (name, ver) in VERSIONS.items():
                print("{}: {}".format(name, ver))

        if args.bump:
            mode = 'bump'
        elif args.local_paths:
            mode = 'to_local'
        elif args.remote_paths:
            mode = 'to_remote'

        for patch in patches:
            patch.debug_mode(not args.wet_run)
            patch.increment_versions(mode)
        print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
        print("Don't forget to check in & update git rev in Cargo.lock for: {}".format(crate_roots[1:]))
        print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")

    if args.publish:
        # small quirk: if you're doing a utralib update, just use -u only.
        # there is some order-sensitivity in how the dictionaries are accessed
        # but of course dictionaries are unordered. I think we need to re-do
        # the specifier from a dictionary to an ordered list, to guarantee that
        # publishing happens in the correct operation order.
        wet_cmd = ["cargo",  "publish"]
        dry_cmd = ["cargo",  "publish", "--dry-run", "--allow-dirty"]
        if args.wet_run:
            cmd = wet_cmd
        else:
            cmd = dry_cmd
        for [crate, path] in cratelist:
            print("Publishing {} in {}".format(crate, path))
            try:
                subprocess.run(cmd, cwd=path, check=True, capture_output=True, encoding='utf-8')
            except subprocess.CalledProcessError as err:
                if 'already uploaded' in err.stderr:
                    print("  Already uploaded, skipping to next module...")
                    continue

                print("Process failed, waiting for crates.io to update and retrying...")
                time.sleep(2) # the latest Cargo seems to fix this problem
                # just try running it again
                try:
                    subprocess.run(cmd, cwd=path, check=True)
                except:
                    print("Retry failed, moving on anyways...")
            time.sleep(1)

        print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
        print("Don't forget to manually push alternate crate roots to github: {}".format(crate_roots[1:]))
        print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")

if __name__ == "__main__":
    main()
    exit(0)