#!python

from __future__ import print_function

import argparse
import collections
import errno
import getpass
import logging
import os
import select
import subprocess
import sys

import gitlab
import urllib3

try:
    # python 3
    from urllib.parse import urlparse
except ImportError:
    # python 2
    from urlparse import urlparse


urllib3.disable_warnings()
FNULL = open(os.devnull, "w", encoding="utf-8")


def log_error(message):
    if type(message) == str:
        message = [message]

    for msg in message:
        sys.stderr.write(" ! {0}\n".format(msg))


def log_debug(message):
    if type(message) == str:
        message = [message]

    for msg in message:
        sys.stderr.write(" # {0}\n".format(msg))


def log_fail(message):
    if type(message) == str:
        message = [message]

    for msg in message:
        sys.stderr.write(" ! {0}\n".format(msg))

    sys.exit(1)


def log_info(message):
    if type(message) == str:
        message = [message]

    for msg in message:
        sys.stdout.write("{0}\n".format(msg))


def logging_subprocess(
    popenargs,
    logger,
    stdout_log_level=logging.DEBUG,
    stderr_log_level=logging.ERROR,
    **kwargs
):
    """
    Variant of subprocess.call that accepts a logger instead of stdout/stderr,
    and logs stdout messages via logger.debug and stderr messages via
    logger.error.
    """
    child = subprocess.Popen(
        popenargs, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs
    )
    if sys.platform == "win32":
        log_info(
            "Windows operating system detected - no subprocess logging will be returned"
        )

    log_level = {child.stdout: stdout_log_level, child.stderr: stderr_log_level}

    def check_io():
        if sys.platform == "win32":
            return
        ready_to_read = select.select([child.stdout, child.stderr], [], [], 1000)[0]
        for io in ready_to_read:
            line = io.readline()
            if not logger:
                continue
            if not (io == child.stderr and not line):
                logger.log(log_level[io], line[:-1])

    # keep checking stdout/stderr until the child exits
    while child.poll() is None:
        check_io()

    check_io()  # check again to catch anything after the process exits

    rc = child.wait()

    if rc != 0:
        print("{} returned {}:".format(popenargs[0], rc), file=sys.stderr)
        print("\t", " ".join(popenargs), file=sys.stderr)

    return rc


def mkdir_p(*args):
    for path in args:
        try:
            os.makedirs(path)
        except OSError as exc:  # Python >2.5
            if exc.errno == errno.EEXIST and os.path.isdir(path):
                pass
            else:
                raise


def mask_password(url, secret="*****"):
    parsed = urlparse(url)

    if not parsed.password:
        return url
    elif parsed.password == "x-oauth-basic":
        return url.replace(parsed.username, secret)

    return url.replace(parsed.password, secret)


def should_include_repository(args, attributes):
    full_path = attributes["namespace"]["full_path"]
    if args.namespace and args.namespace != full_path:
        log_debug(
            "Skipping {0} as namespace does not match {1}".format(
                attributes["path_with_namespace"], args.namespace
            )
        )
        return False
    return True


def fetch_repository(
    name, remote_url, local_dir, skip_existing=False, bare_clone=False, lfs_clone=False
):
    if bare_clone:
        if os.path.exists(local_dir):
            clone_exists = (
                subprocess.check_output(
                    ["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
                )
                == b"true\n"
            )
        else:
            clone_exists = False
    else:
        clone_exists = os.path.exists(os.path.join(local_dir, ".git"))

    if clone_exists and skip_existing:
        return

    masked_remote_url = mask_password(remote_url)

    initialized = subprocess.call(
        "git ls-remote " + remote_url, stdout=FNULL, stderr=FNULL, shell=True
    )
    if initialized == 128:
        log_info(
            "Skipping {0} ({1}) since it's not initialized".format(
                name, masked_remote_url
            )
        )
        return

    if clone_exists:
        log_info("Updating {0} in {1}".format(name, local_dir))

        remotes = subprocess.check_output(["git", "remote", "show"], cwd=local_dir)
        remotes = [i.strip() for i in remotes.decode("utf-8").splitlines()]

        if "origin" not in remotes:
            git_command = ["git", "remote", "rm", "origin"]
            logging_subprocess(git_command, None, cwd=local_dir)
            git_command = ["git", "remote", "add", "origin", remote_url]
            logging_subprocess(git_command, None, cwd=local_dir)
        else:
            git_command = ["git", "remote", "set-url", "origin", remote_url]
            logging_subprocess(git_command, None, cwd=local_dir)

        if lfs_clone:
            git_command = [
                "git",
                "lfs",
                "fetch",
                "--all",
                "--force",
                "--tags",
                "--prune",
            ]
        else:
            git_command = ["git", "fetch", "--all", "--force", "--tags", "--prune"]
        logging_subprocess(git_command, None, cwd=local_dir)
    else:
        log_info(
            "Cloning {0} repository from {1} to {2}".format(
                name, masked_remote_url, local_dir
            )
        )
        if bare_clone:
            if lfs_clone:
                git_command = ["git", "lfs", "clone", "--mirror", remote_url, local_dir]
            else:
                git_command = ["git", "clone", "--mirror", remote_url, local_dir]
        else:
            if lfs_clone:
                git_command = ["git", "lfs", "clone", remote_url, local_dir]
            else:
                git_command = ["git", "clone", remote_url, local_dir]
        logging_subprocess(git_command, None)


def backup_repository(args, item):
    if not should_include_repository(args, item.attributes):
        return

    repo_name = item.path_with_namespace
    repo_cwd = os.path.join(args.output_directory, "repositories", repo_name)

    repo_dir = os.path.join(repo_cwd, "repository")
    repo_url = get_repo_url(args, item.attributes)
    fetch_repository(
        repo_name,
        repo_url,
        repo_dir,
        skip_existing=args.skip_existing,
        bare_clone=args.clone_bare,
        lfs_clone=args.clone_lfs,
    )


def get_repo_url(args, attributes):
    if args.prefer_ssh:
        return attributes.get("ssh_url_to_repo", None)

    return attributes.get("http_url_to_repo", None)


def get_client(args):
    if not args.host:
        log_error("Missing --host flag")
        return None

    _path_specifier = "file://"

    client = None
    if args.private_token:
        if args.private_token.startswith(_path_specifier):
            filename = args.private_token[len(_path_specifier) :]
            args.private_token = open(filename, "rt").readline().strip()

        client = gitlab.Gitlab(
            args.host,
            private_token=args.private_token,
            ssl_verify=args.disable_ssl_verification,
        )
    elif args.oauth_token:
        if args.oauth_token.startswith(_path_specifier):
            filename = args.oauth_token[len(_path_specifier) :]
            args.oauth_token = open(filename, "rt").readline().strip()

        client = gitlab.Gitlab(
            args.host,
            oauth_token=args.oauth_token,
            ssl_verify=args.disable_ssl_verification,
        )
    elif args.username:
        if not args.password:
            args.password = getpass.getpass()

        if not args.password:
            log_error("You must specify a password for basic auth")

        client = gitlab.Gitlab(
            args.host,
            email=args.username,
            password=args.password,
            ssl_verify=args.disable_ssl_verification,
        )
        client.auth()
    else:
        client = gitlab.Gitlab(args.host, ssl_verify=args.disable_ssl_verification)

    return client


def parse_args():
    parser = argparse.ArgumentParser(description="Backup a gitlab account")
    parser.add_argument("--host", dest="host", help="gitlab host")
    parser.add_argument("--username", dest="username", help="username for basic auth")
    parser.add_argument(
        "--password",
        dest="password",
        help="password for basic auth. "
        "If a username is given but not a password, the "
        "password will be prompted for.",
    )
    parser.add_argument(
        "--oauth-token",
        dest="oauth_token",
        help="oauth token, or path to token (file://...)",
    )
    parser.add_argument(
        "--private-token",
        dest="private_token",
        help="private token, or path to token (file://...)",
    )
    parser.add_argument(
        "--clone-bare",
        action="store_true",
        dest="clone_bare",
        help="clone bare repositories",
    )
    parser.add_argument(
        "--clone-lfs",
        action="store_true",
        dest="clone_lfs",
        help="clone LFS repositories (requires Git LFS to be installed, https://git-lfs.github.com)",
    )
    parser.add_argument(
        "--disable-ssl-verification",
        action="store_true",
        dest="disable_ssl_verification",
        help="disable ssl verification",
    )
    parser.add_argument(
        "--namespace",
        default=None,
        dest="namespace",
        help="specify a gitlab namespace to backup",
    )
    parser.add_argument(
        "--output-directory",
        default=".",
        dest="output_directory",
        help="directory at which to backup the repositories",
    )
    parser.add_argument(
        "--prefer-ssh",
        action="store_true",
        dest="prefer_ssh",
        help="Clone repositories using SSH instead of HTTPS",
    )
    parser.add_argument(
        "--skip-existing",
        action="store_true",
        dest="skip_existing",
        help="skip project if a backup directory exists",
    )
    parser.add_argument(
        "--owned-only",
        action="store_true",
        dest="owned_only",
        help="Only backup projects owned by the provided user or key",
    )
    parser.add_argument(
        "--with-membership",
        action="store_true",
        dest="with_membership",
        help="Backup projects provided user or key is member of",
    )
    parser.add_argument(
        "--private_key", default="", dest="private_key", help="Path to the private key"
    )
    return parser.parse_args()


def main():
    args = parse_args()

    if args.private_key != "":
        log_info("Use the private key: {0}".format(args.private_key))
        os.environ["GIT_SSH_COMMAND"] = 'ssh -i "{0}" -o IdentitiesOnly=yes'.format(
            args.private_key
        )
        print(os.environ["GIT_SSH_COMMAND"])

    client = get_client(args)
    if not client:
        log_fail("Unable to create gitlab client")

    output_directory = os.path.realpath(args.output_directory)
    if not os.path.isdir(output_directory):
        log_info("Create output directory {0}".format(output_directory))
        mkdir_p(output_directory)

    items = client.projects.list(
        as_list=False, owned=args.owned_only, membership=args.with_membership
    )
    repositories = {}
    for item in items:
        repositories[item.path_with_namespace] = item

    repositories = collections.OrderedDict(sorted(repositories.items()))
    for path, item in repositories.items():
        backup_repository(args, item)


if __name__ == "__main__":
    main()
