#!/usr/bin/env python3
"""
  muld (Mirror from Upstream to Local to Downstream)

  For a list of repos do:

  * clone/fetch from upstream to local
    - prune tags and branches; remove what does not exist on upstream
  * push branches, not forcing it, from local to "downstream"
  * push tags, not forcing it,  from local to "downstream"

  Basically, this is a soft way of forwarding changes from an "upstream" to a
  "downstream" repository, via a "local" repository.

  The local mirror-repository must not be used for anything else that this
  task, in case of any kind of error, then 'muld' exits with an error-message,
  giving the user user a chance to fix what may be wrong. There are no clever
  tricks applied to resolve anything.
"""
import subprocess
import argparse
import pathlib
import sys
import os
import yaml

VERSION_MAJOR = 0
VERSION_MINOR = 0
VERSION_PATCH = 6


def expand_path(path):
    """Expands variables from the given path and turns it into absolute path"""

    return pathlib.Path(
        os.path.abspath(os.path.expanduser(os.path.expandvars(path)))
    ).resolve()


def args_to_conf():
    """Parse command-line options"""

    prsr = argparse.ArgumentParser(
        description='git; Mirrror Upstream to Local to Downstream',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter
    )
    prsr.add_argument(
        "--yaml",
        help="Path to yaml-file describing what to do...",
        required=True
    )
    prsr.add_argument(
        "--mirrors",
        help="Path to directory containing repository mirrors",
        default=os.getcwd(),
        required=True
    )
    args = prsr.parse_args()
    args.yaml = expand_path(args.yaml)
    args.mirrors = expand_path(args.mirrors)

    conf = {}
    with open(args.yaml, 'r') as yml_file:
        conf.update(yaml.safe_load(yml_file))

    conf["mirrors"] = args.mirrors

    return conf


def execute(cmd, cwd=None, pipe=False):
    """Execute the given cmd"""

    print("# %s%c %s" % (cwd, '%', " ".join(cmd)), flush=True)

    proc = subprocess.Popen(
        cmd,
        cwd=cwd,
        stdout=subprocess.PIPE if pipe else None,
        stderr=subprocess.PIPE if pipe else None,
    )
    out, err = proc.communicate()
    rcode = proc.returncode

    print(f"# rcode: {rcode}\n", flush=True)
    if rcode:
        print("\n## ---=={[ EPIC FAIL -- see above -- EPIC FAIL ]}==--- ##\n")

    return (out, err, rcode)


def repo_name(repo):
    """Returns name of repo"""

    if "name" not in repo or repo["name"] is None:
        repo["name"] = os.path.basename(repo["upstream"])
        if repo["name"].endswith(".git"):
            repo["name"] = repo["name"][:-4]

    return repo["name"]


def repo_downstream(repo):
    """Returns downstream URI or None"""

    if "downstream" in repo:
        return repo.get("downstream", None)

    return None


def repo_path(conf, repo):
    """Returns local path on filesystem to repository inside mirrors"""

    return os.path.join(conf["mirrors"], repo_name(repo))


def repo_remote_names(conf, repo):
    """Return a list of remote-names for the given repo"""

    rnames = set()

    out, _, _ = execute(
        ["git", "remote", "-v"],
        cwd=repo_path(conf, repo), pipe=True
    )
    for line in out.decode("utf-8").splitlines():
        rnames.add(line.split()[0])

    return list(rnames)


def repo_local_branches(conf, repo):
    """Return a list of local branches for the given repo"""

    rnames = set()

    out, _, _ = execute(
        ["git", "branch", "-l"],
        cwd=repo_path(conf, repo), pipe=True
    )
    for line in (ln.strip() for ln in out.decode("utf-8").splitlines()):
        rnames.add(line.strip().replace("* ", ""))

    return sorted(list(rnames))


def repo_upstream_branches(conf, repo):
    """Return a list of "upstream" branches for the given repo"""

    rnames = set()

    out, _, _ = execute(
        ["git", "branch", "-r"],
        cwd=repo_path(conf, repo), pipe=True
    )
    for line in (ln.strip() for ln in out.decode("utf-8").splitlines()):
        if (not line.startswith("upstream")) or ("->" in line):
            continue

        rnames.add(line.strip())

    return sorted(list(rnames))


def repo_track_upstream_branches(conf, repo):
    """Track all upstream branches"""

    branches_upstream = repo_upstream_branches(conf, repo)
    branches_local = repo_local_branches(conf, repo)

    for upstream in branches_upstream:
        local = upstream[len("upstream/"):]

        if local in branches_local:
            _, _, rcode = execute(
                ["git", "branch", "--set-upstream-to", upstream, local],
                cwd=repo_path(conf, repo)
            )
        else:
            _, _, rcode = execute(
                ["git", "branch", "--track", local, upstream],
                cwd=repo_path(conf, repo)
            )

        if rcode:
            print(f"# failed tracking: {upstream}")
            return 1

    return 0


def main(conf):
    """Main entry point"""
    # pylint: disable=too-many-return-statements
    # pylint: disable=too-many-branches
    # This main function is the core logic, it will just be harder to
    # understand what goes on if it is encapsulated / split further up into
    # functions

    if conf is None:
        return 1

    os.makedirs(conf["mirrors"], exist_ok=True)

    for repo in conf["repos"]:
        if "upstream" not in repo:
            print(f"# invalid repo: {repo}; fix it, then run again")
            return 1

        if not os.path.exists(repo_path(conf, repo)):   # Grab the repository
            _, _, rcode = execute(
                ["git", "clone", repo["upstream"], repo_path(conf, repo)],
                cwd=conf["mirrors"]
            )
            if rcode:
                print(f"failed cloning: {repo}; aborting for manual fixup")
                return 1

            _, _, rcode = execute(
                ["git", "remote", "rename", "origin", "upstream"],
                cwd=repo_path(conf, repo)
            )
            if rcode:
                print(f"failed cloning: {repo}; aborting for manual fixup")
                return 1

        _, _, rcode = execute(
            ["git", "fetch", "-pP", "upstream"],
            cwd=repo_path(conf, repo)
        )
        if rcode:
            print("failed fetching; aborting for manual fixup")
            return 1

        if repo_track_upstream_branches(conf, repo):
            print("failed tracking upstream; aborting for manual fixup")
            return 1

        if repo_downstream(repo) is None:
            print(f"no 'downstream'; skipping further handling of {repo}")
            continue

        rnames = repo_remote_names(conf, repo)
        if "downstream" not in rnames:
            _, _, rcode = execute(
                ["git", "remote", "add", "downstream", repo["downstream"]],
                cwd=repo_path(conf, repo)
            )
            if rcode:
                print("# failed adding downstream; aborting for manual fixup")
                return 1

        _, _, rcode = execute(  # push branches
            ["git", "push", repo["downstream"], "--all"],
            cwd=repo_path(conf, repo)
        )
        if rcode:
            print("# failed pushing branches; aborting for manual fixup")
            return 1

        _, _, rcode = execute(  # push tags
            ["git", "push", repo["downstream"], "--tags"],
            cwd=repo_path(conf, repo)
        )
        if rcode:
            print("# failed pushing tags; aborting for manual fixup")
            return 1

    return 0


if __name__ == "__main__":
    try:
        sys.exit(main(args_to_conf()))
    except KeyboardInterrupt:
        print("Somebody hit Ctrl+C, bailing...")
        sys.exit(1)
