# Copyright 2013-2021 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2

import argparse
import logging
import os
import sys

import portage
from portage.package.ebuild.fetch import ContentHashLayout
from portage.util import _recursive_file_list, normalize_path
from portage.util._async.run_main_scheduler import run_main_scheduler
from portage.util._async.SchedulerInterface import SchedulerInterface
from portage.util._eventloop.global_event_loop import global_event_loop

from .Config import Config
from .MirrorDistTask import MirrorDistTask

seconds_per_day = 24 * 60 * 60

common_options = (
    {
        "longopt": "--dry-run",
        "help": "perform a trial run with no changes made (usually combined "
        "with --verbose)",
        "action": "store_true",
    },
    {
        "longopt": "--verbose",
        "shortopt": "-v",
        "help": "display extra information on stderr "
        "(multiple occurrences increase verbosity)",
        "action": "count",
        "default": 0,
    },
    {
        "longopt": "--ignore-default-opts",
        "help": "do not use the EMIRRORDIST_DEFAULT_OPTS environment variable",
        "action": "store_true",
    },
    {
        "longopt": "--distfiles",
        "help": "distfiles directory to use (required)",
        "metavar": "DIR",
    },
    {
        "longopt": "--jobs",
        "shortopt": "-j",
        "help": "number of concurrent jobs to run",
        "type": int,
    },
    {
        "longopt": "--load-average",
        "shortopt": "-l",
        "help": "load average limit for spawning of new concurrent jobs",
        "metavar": "LOAD",
        "type": float,
    },
    {
        "longopt": "--tries",
        "help": "maximum number of tries per file, 0 means unlimited (default is 10)",
        "default": 10,
        "type": int,
    },
    {"longopt": "--repo", "help": "name of repo to operate on"},
    {
        "longopt": "--config-root",
        "help": "location of portage config files",
        "metavar": "DIR",
    },
    {
        "longopt": "--repositories-configuration",
        "help": "override configuration of repositories (in format of repos.conf)",
    },
    {
        "longopt": "--strict-manifests",
        "help": 'manually override "strict" FEATURES setting',
        "choices": ("y", "n"),
        "metavar": "<y|n>",
    },
    {
        "longopt": "--failure-log",
        "help": "log file for fetch failures, with tab-delimited "
        "output, for reporting purposes",
        "metavar": "FILE",
    },
    {
        "longopt": "--success-log",
        "help": "log file for fetch successes, with tab-delimited "
        "output, for reporting purposes",
        "metavar": "FILE",
    },
    {
        "longopt": "--scheduled-deletion-log",
        "help": "log file for scheduled deletions, with tab-delimited "
        "output, for reporting purposes",
        "metavar": "FILE",
    },
    {
        "longopt": "--delete",
        "help": "enable deletion of unused distfiles",
        "action": "store_true",
    },
    {
        "longopt": "--deletion-db",
        "help": "database file used to track lifetime of files "
        "scheduled for delayed deletion",
        "metavar": "FILE",
    },
    {
        "longopt": "--deletion-delay",
        "help": "delay time for deletion, measured in seconds",
        "metavar": "SECONDS",
    },
    {
        "longopt": "--temp-dir",
        "help": "temporary directory for downloads",
        "metavar": "DIR",
    },
    {
        "longopt": "--mirror-overrides",
        "help": "file holding a list of mirror overrides",
        "metavar": "FILE",
    },
    {
        "longopt": "--mirror-skip",
        "help": "comma delimited list of mirror targets to skip " "when fetching",
    },
    {
        "longopt": "--restrict-mirror-exemptions",
        "help": "comma delimited list of mirror targets for which to "
        'ignore RESTRICT="mirror"',
    },
    {
        "longopt": "--verify-existing-digest",
        "help": "use digest as a verification of whether existing "
        "distfiles are valid",
        "action": "store_true",
    },
    {
        "longopt": "--distfiles-local",
        "help": "distfiles-local directory to use",
        "metavar": "DIR",
    },
    {
        "longopt": "--distfiles-db",
        "help": "database file used to track which ebuilds a " "distfile belongs to",
        "metavar": "FILE",
    },
    {
        "longopt": "--content-db",
        "help": "database file used to map content digests to"
        "distfiles names (required for content-hash layout)",
        "metavar": "FILE",
    },
    {
        "longopt": "--recycle-dir",
        "help": "directory for extended retention of files that "
        "are removed from distdir with the --delete option",
        "metavar": "DIR",
    },
    {
        "longopt": "--recycle-db",
        "help": "database file used to track lifetime of files " "in recycle dir",
        "metavar": "FILE",
    },
    {
        "longopt": "--recycle-deletion-delay",
        "help": "delay time for deletion of unused files from "
        "recycle dir, measured in seconds (defaults to "
        "the equivalent of 60 days)",
        "default": 60 * seconds_per_day,
        "metavar": "SECONDS",
        "type": int,
    },
    {
        "longopt": "--fetch-log-dir",
        "help": "directory for individual fetch logs",
        "metavar": "DIR",
    },
    {
        "longopt": "--whitelist-from",
        "help": "specifies a file containing a list of files to "
        "whitelist, one per line, # prefixed lines ignored",
        "action": "append",
        "metavar": "FILE",
    },
    {
        "longopt": "--symlinks",
        "help": "use symlinks rather than hardlinks for linking "
        "distfiles between layouts",
        "action": "store_true",
    },
    {
        "longopt": "--layout-conf",
        "help": "specifies layout.conf file to use instead of "
        "the one present in the distfiles directory",
        "metavar": "FILE",
    },
)


def parse_args(args):
    description = "emirrordist - a fetch tool for mirroring " "of package distfiles"
    usage = "emirrordist [options] <action>"
    parser = argparse.ArgumentParser(description=description, usage=usage)

    actions = parser.add_argument_group("Actions")
    actions.add_argument(
        "--version", action="store_true", help="display portage version and exit"
    )
    actions.add_argument(
        "--mirror",
        action="store_true",
        help="mirror distfiles for the selected repository",
    )

    common = parser.add_argument_group("Common options")
    for opt_info in common_options:
        opt_pargs = [opt_info["longopt"]]
        if opt_info.get("shortopt"):
            opt_pargs.append(opt_info["shortopt"])
        opt_kwargs = {"help": opt_info["help"]}
        for k in ("action", "choices", "default", "metavar", "type"):
            if k in opt_info:
                opt_kwargs[k] = opt_info[k]
        common.add_argument(*opt_pargs, **opt_kwargs)

    options, args = parser.parse_known_args(args)

    return (parser, options, args)


def emirrordist_main(args):
    # The calling environment is ignored, so the program is
    # completely controlled by commandline arguments.
    env = {}

    if not sys.stdout.isatty():
        portage.output.nocolor()
        env["NO_COLOR"] = "true"

    parser, options, args = parse_args(args)

    if options.version:
        sys.stdout.write(f"Portage {portage.VERSION}\n")
        return os.EX_OK

    config_root = options.config_root

    if options.repositories_configuration is not None:
        env["PORTAGE_REPOSITORIES"] = options.repositories_configuration

    settings = portage.config(config_root=config_root, local_config=False, env=env)

    default_opts = None
    if not options.ignore_default_opts:
        default_opts = settings.get("EMIRRORDIST_DEFAULT_OPTS", "").split()

    if default_opts:
        parser, options, args = parse_args(default_opts + args)

        settings = portage.config(config_root=config_root, local_config=False, env=env)

    if options.repo is None:
        if len(settings.repositories.prepos) == 2:
            for repo in settings.repositories:
                if repo.name != "DEFAULT":
                    options.repo = repo.name
                    break

        if options.repo is None:
            parser.error("--repo option is required")

    repo_path = settings.repositories.treemap.get(options.repo)
    if repo_path is None:
        parser.error(f"Unable to locate repository named '{options.repo}'")

    if options.jobs is not None:
        options.jobs = int(options.jobs)

    if options.load_average is not None:
        options.load_average = float(options.load_average)

    if options.failure_log is not None:
        options.failure_log = normalize_path(os.path.abspath(options.failure_log))

        parent_dir = os.path.dirname(options.failure_log)
        if not (os.path.isdir(parent_dir) and os.access(parent_dir, os.W_OK | os.X_OK)):
            parser.error(
                ("--failure-log '%s' parent is not a " "writable directory")
                % options.failure_log
            )

    if options.success_log is not None:
        options.success_log = normalize_path(os.path.abspath(options.success_log))

        parent_dir = os.path.dirname(options.success_log)
        if not (os.path.isdir(parent_dir) and os.access(parent_dir, os.W_OK | os.X_OK)):
            parser.error(
                ("--success-log '%s' parent is not a " "writable directory")
                % options.success_log
            )

    if options.scheduled_deletion_log is not None:
        options.scheduled_deletion_log = normalize_path(
            os.path.abspath(options.scheduled_deletion_log)
        )

        parent_dir = os.path.dirname(options.scheduled_deletion_log)
        if not (os.path.isdir(parent_dir) and os.access(parent_dir, os.W_OK | os.X_OK)):
            parser.error(
                ("--scheduled-deletion-log '%s' parent is not a " "writable directory")
                % options.scheduled_deletion_log
            )

        if options.deletion_db is None:
            parser.error("--scheduled-deletion-log requires --deletion-db")

    if options.deletion_delay is not None:
        options.deletion_delay = int(options.deletion_delay)
        if options.deletion_db is None:
            parser.error("--deletion-delay requires --deletion-db")

    if options.deletion_db is not None:
        if options.deletion_delay is None:
            parser.error("--deletion-db requires --deletion-delay")
        options.deletion_db = normalize_path(os.path.abspath(options.deletion_db))

    if options.temp_dir is not None:
        options.temp_dir = normalize_path(os.path.abspath(options.temp_dir))

        if not (
            os.path.isdir(options.temp_dir)
            and os.access(options.temp_dir, os.W_OK | os.X_OK)
        ):
            parser.error(f"--temp-dir '{options.temp_dir}' is not a writable directory")

    if options.distfiles is not None:
        options.distfiles = normalize_path(os.path.abspath(options.distfiles))

        if not (
            os.path.isdir(options.distfiles)
            and os.access(options.distfiles, os.W_OK | os.X_OK)
        ):
            parser.error(
                f"--distfiles '{options.distfiles}' is not a writable directory"
            )
    else:
        parser.error("missing required --distfiles parameter")

    if options.mirror_overrides is not None:
        options.mirror_overrides = normalize_path(
            os.path.abspath(options.mirror_overrides)
        )

        if not (
            os.access(options.mirror_overrides, os.R_OK)
            and os.path.isfile(options.mirror_overrides)
        ):
            parser.error(
                "--mirror-overrides-file '%s' is not a readable file"
                % options.mirror_overrides
            )

    if options.distfiles_local is not None:
        options.distfiles_local = normalize_path(
            os.path.abspath(options.distfiles_local)
        )

        if not (
            os.path.isdir(options.distfiles_local)
            and os.access(options.distfiles_local, os.W_OK | os.X_OK)
        ):
            parser.error(
                ("--distfiles-local '%s' is not a " "writable directory")
                % options.distfiles_local
            )

    if options.distfiles_db is not None:
        options.distfiles_db = normalize_path(os.path.abspath(options.distfiles_db))

    if options.tries is not None:
        options.tries = int(options.tries)

    if options.recycle_dir is not None:
        options.recycle_dir = normalize_path(os.path.abspath(options.recycle_dir))
        if not (
            os.path.isdir(options.recycle_dir)
            and os.access(options.recycle_dir, os.W_OK | os.X_OK)
        ):
            parser.error(
                f"--recycle-dir '{options.recycle_dir}' is not a writable directory"
            )

    if options.recycle_db is not None:
        if options.recycle_dir is None:
            parser.error("--recycle-db requires " "--recycle-dir to be specified")
        options.recycle_db = normalize_path(os.path.abspath(options.recycle_db))

    if options.recycle_deletion_delay is not None:
        options.recycle_deletion_delay = int(options.recycle_deletion_delay)

    if options.fetch_log_dir is not None:
        options.fetch_log_dir = normalize_path(os.path.abspath(options.fetch_log_dir))

        if not (
            os.path.isdir(options.fetch_log_dir)
            and os.access(options.fetch_log_dir, os.W_OK | os.X_OK)
        ):
            parser.error(
                f"--fetch-log-dir '{options.fetch_log_dir}' is not a writable directory"
            )

    if options.whitelist_from:
        normalized_paths = []
        for x in options.whitelist_from:
            path = normalize_path(os.path.abspath(x))
            if not os.access(path, os.R_OK):
                parser.error(f"--whitelist-from '{x}' is not readable")
            if os.path.isfile(path):
                normalized_paths.append(path)
            elif os.path.isdir(path):
                for file in _recursive_file_list(path):
                    if not os.access(file, os.R_OK):
                        parser.error(
                            "--whitelist-from '%s' directory contains not readable file '%s'"
                            % (x, file)
                        )
                    normalized_paths.append(file)
            else:
                parser.error(
                    f"--whitelist-from '{x}' is not a regular file or a directory"
                )
        options.whitelist_from = normalized_paths

    if options.strict_manifests is not None:
        if options.strict_manifests == "y":
            settings.features.add("strict")
        else:
            settings.features.discard("strict")

    settings.lock()

    portdb = portage.portdbapi(mysettings=settings)

    # Limit ebuilds to the specified repo.
    portdb.porttrees = [repo_path]

    portage.util.initialize_logger()

    if options.verbose > 0:
        l = logging.getLogger()
        l.setLevel(l.getEffectiveLevel() - 10 * options.verbose)

    with Config(options, portdb, SchedulerInterface(global_event_loop())) as config:
        if not options.mirror:
            parser.error("No action specified")

        if options.delete and config.content_db is None:
            for layout in config.layouts:
                if isinstance(layout, ContentHashLayout):
                    parser.error(
                        "content-hash layout requires " "--content-db to be specified"
                    )

        returncode = os.EX_OK

        if options.mirror:
            signum = run_main_scheduler(MirrorDistTask(config))
            if signum is not None:
                sys.exit(128 + signum)

    return returncode
