import os
import re
import sys
import urllib3
import argparse

from os.path import abspath, expanduser

try:
    import ruamel.yaml as ruamel_yaml
except ImportError:
    import ruamel_yaml

from . import __version__
from .conda_interface import (
    known_subdirs,
    MatchSpec,
    __version__ as conda_version,
    default_channels as conda_default_channels,
    channel_alias as conda_channel_alias,
    context,
)
from .filter import standard_license_families


urllib3.disable_warnings()


all_access_policies = ["private", "authenticated", "public", "default"]
all_format_policies = [
    "prefer-conda",
    "prefer-tarbz2",
    "only-conda",
    "only-tarbz2",
    "transmute-conda",
    "transmute-tarbz2",
    "keep-both",
    "default",
]


class ConfigError(RuntimeError):
    pass


def _load_config(config_path):
    if not config_path:
        return {}
    try:
        return ruamel_yaml.safe_load(open(config_path)) or {}
    except Exception as e:
        msg = 'Unable to load config file "{}": {}'
        raise ConfigError(msg.format(config_path, e))


def _get_str_list(value, unique=True):
    if value is None:
        result = []
    elif isinstance(value, (list, tuple)):
        result = [str(v) for v in value]
    elif isinstance(value, dict):
        assert False
    else:
        result = [str(value)]
    if unique:
        new_result = []
        for x in result:
            if x not in new_result:
                new_result.append(x)
        return new_result
    return result


class Config(object):
    def __init__(self, dest_channel=None, path=None):
        self.path = path
        rc = _load_config(self.path)
        self.strict_platforms = rc.get("strict_platforms", False)
        self.channel_alias = rc.get("channel_alias", None)
        self.dest_site = rc.get("dest_site")
        self.dest_channel = dest_channel or rc.get("dest_channel", None)
        self.dest_channel_alias = rc.get("dest_channel_alias", None)
        self.max_retries = int(rc.get("max_retries", 0))
        self.access_policy = rc.get("access_policy", None)
        self.format_policy = rc.get("format_policy", "default")
        self.max_attempts = rc.get("max_attempts", 5)
        self.max_failures = rc.get("max_failures", 100)
        self.verify_ssl = rc.get("verify_ssl", True)
        self.verify_ssl_src = rc.get("verify_ssl_src", True)
        self.verify_ssl_dst = rc.get("verify_ssl_dst", True)
        self.verbose = rc.get("verbose", False)
        self.clean = rc.get("clean", None)
        self.dry_run = rc.get("dry_run", False)
        self.ignore_missing = rc.get("ignore_missing", False)
        self.username = rc.get("username", os.environ.get("ANACONDA_MIRROR_USERNAME"))
        self.password = rc.get("password", os.environ.get("ANACONDA_MIRROR_PASSWORD"))
        self.token = rc.get("token", os.environ.get("ANACONDA_MIRROR_TOKEN"))
        self.dependencies = rc.get("dependencies", False)
        self.pruning = rc.get("pruning", False)
        self.auto_freeze = rc.get("auto_freeze", True)
        for var_name in (
            "channels",
            "platforms",
            "python_versions",
            "r_versions",
            "pkg_list",
            "license_exclusions",
            "constraints",
            "exclusions",
            "inclusions",
        ):
            setattr(self, var_name, _get_str_list(rc.get(var_name)))
        for var_name, value in rc.items():
            if not hasattr(self, var_name):
                setattr(self, var_name, value)
        self.check()

    def verify_ssl_mode(self, which):
        assert which in ("src", "dst")
        verify_ssl = self.verify_ssl
        verify_ssl_w = getattr(self, "verify_ssl_" + which)
        if (
            not verify_ssl
            or not verify_ssl_w
            or which == "src"
            and not context.ssl_verify
        ):
            return False
        if isinstance(verify_ssl_w, str):
            return verify_ssl_w
        if isinstance(verify_ssl, str):
            return verify_ssl
        if which == "src" and isinstance(context.ssl_verify, str):
            return context.ssl_verify
        return True

    def check(self, finalize=False):
        cvalue = self.channel_alias or conda_channel_alias()
        if cvalue.startswith("file://"):
            cvalue = cvalue[7:]
        if "://" not in cvalue:
            cvalue = "file://" + abspath(expanduser(cvalue))
        cvalue = cvalue.rstrip("/") + "/"
        self.channel_alias = cvalue

        if self.clean is not None and not finalize:
            raise ConfigError("--clean may only be specified on the command line")

        if self.token:
            if self.password:
                raise ConfigError("Must not supply both --token and --password")
            elif finalize:
                self.password = self.token
                self.token = None

        self.channels = _get_str_list(self.channels)
        if finalize:
            if not self.channels:
                raise ConfigError("At least one channel must be supplied")
            new_channels = []
            while self.channels:
                url = self.channels.pop(0)
                if url == "defaults":
                    self.channels[0:0] = conda_default_channels()
                    continue
                if url.startswith((".", "~", "/")):
                    url = "file://" + abspath(expanduser(url))
                elif "://" not in url:
                    url = cvalue + url
                if url.startswith(cvalue):
                    url = url[len(cvalue) :]
                elif url.startswith("file://"):
                    url = url[7:]
                new_channels.append(url)
            self.channels = new_channels

        if finalize and not self.dest_channel:
            raise ConfigError("Destination channel must be supplied")

        if self.platforms:
            all_platforms = known_subdirs()
            self.platforms = sorted(_get_str_list(self.platforms))
            for platform in self.platforms:
                if platform not in all_platforms:
                    raise ConfigError(
                        "Invalid platform: {}\nValid platforms: {}".format(
                            platform, ", ".join(all_platforms)
                        )
                    )
            if finalize:
                has_noarch = "noarch" in self.platforms or not self.strict_platforms
                self.platforms = sorted(set(self.platforms) - {"noarch"})
                if has_noarch:
                    self.platforms.append("noarch")

        ver_matcher = re.compile(r"\d+(.\d+)+$")
        for field in ("python_versions", "r_versions"):
            vlist = getattr(self, field) or []
            for ver in vlist:
                if not ver_matcher.match(ver):
                    raise ConfigError(
                        "Invalid version string in {}: {}".format(field, ver)
                    )
        if "3.1" in self.python_versions:
            vlist = (set(self.python_versions) - {"3.1"}) | {"3.10"}
            self.python_versions = sorted(vlist)

        for license_family in self.license_exclusions:
            if license_family not in standard_license_families:
                raise ConfigError(
                    "Invalid license family: {}\nKnown license families: {}".format(
                        license_family, standard_license_families
                    )
                )

        for field in ("pkg_list", "constraints", "exclusions", "inclusions"):
            mlist = getattr(self, field) or []
            for k, pkg in enumerate(mlist):
                try:
                    mlist[k] = MatchSpec(pkg)
                except Exception:
                    raise ConfigError(
                        "Invalid match string in {}: {}".format(field, pkg)
                    )

        if self.access_policy and self.access_policy not in all_access_policies:
            raise ConfigError(
                "Invalid access policy: {}\nValid access policies: {}".format(
                    self.access_policy, ", ".join(all_access_policies)
                )
            )

        if self.format_policy and self.format_policy not in all_format_policies:
            raise ConfigError(
                "Invalid format policy: {}\nValid format policies: {}".format(
                    self.format_policy, ", ".join(all_format_policies)
                )
            )

    def show(self):
        print("Configuration")
        print("-------------")
        for config_field in (
            "channel_alias",
            "channels",
            "platforms",
            "dest_site",
            "dest_channel",
            "format_policy",
            "ignore_missing",
            "max_attempts",
            "max_failures",
            "python_versions",
            "r_versions",
            "pkg_list",
            "dependencies",
            "pruning",
            "license_exclusions",
            "constraints",
            "exclusions",
            "inclusions",
            "clean",
        ):
            value = getattr(self, config_field)
            if isinstance(value, list):
                print("{}:{}".format(config_field, "" if value else " []"))
                for v in value:
                    print("  - {}".format(v))
            else:
                print("{}: {}".format(config_field, value))


def parse_argv():
    global config

    p = argparse.ArgumentParser(
        description="The official mirroring tool for Anaconda repositories",
        epilog="Versions: anaconda-mirror %s, conda %s" % (__version__, conda_version),
    )
    p.add_argument(
        "-f",
        "--file",
        dest="filename",
        action="store",
        help="Path to the configuration file. Note that the filename can "
        "also be supplied as a positional argument without -f/--file.",
    )
    p.add_argument(
        "--dest-channel",
        dest="dest_channel",
        action="store",
        default=None,
        help="The name of the destination channel.",
    )
    p.add_argument(
        "--channel",
        dest="channels",
        action="append",
        help="A source channel, given as either a short name or a full URL. "
        "Multiple channels may be supplied by repeated use of this option.",
    )
    p.add_argument(
        "--channel-alias",
        default=None,
        help="A URL to prepend to all short source channel names. If not "
        "supplied, the value configured within conda will be used.",
    )
    p.add_argument(
        "--dry-run",
        dest="dry_run",
        action="store_true",
        default=None,
        help="No upload, removal, or patch action should be taken. Instead, "
        "just show what steps would be taken during normal operation.",
    )
    p.add_argument(
        "-v",
        "--verbose",
        default=None,
        action="store_true",
        help="Provide more verbose output.",
    )
    p.add_argument(
        "-c",
        "--clean",
        default=None,
        action="store_true",
        help="Packages that no longer exist at the source will be removed "
        "from the destination. To ensure this is not accidentally invoked, "
        "it can only be included on the command line as, not in a config file. "
        "We recommend using the --dry-run option first to confirm what "
        "deletions would actually take place.",
    )
    p.add_argument(
        "--platform",
        dest="platforms",
        action="append",
        help="The name of a platform to mirror. Multiple platforms "
        "may be supplied by repeated use of this option. If none are "
        "supplied, all platforms known to conda will be included. The "
        "noarch platform will be included in any platform selection, "
        "unless --strict_platforms is also invoked.",
    )
    p.add_argument(
        "--strict-platforms",
        action="store_true",
        default=None,
        help="By default, the noarch platform is included in any mirror, "
        "since its packages are compatible with all platforms. If this flag "
        "is supplied, it will be included. So if you seek to truly mirror "
        "particular platform subdirectories, you may need this option.",
    )
    p.add_argument(
        "--format-policy",
        default=None,
        help="Determines what formats should be delivered to the destination. "
        "Values include: keep-both, prefer-conda, prefer-tarbz2, only-conda, "
        "only-tarbz2, transmute-conda, transmute-tarbz2. Not all destinations "
        "support the .conda package format, so the default is prefer-conda for "
        "destinations that can, and transmute-bz2 otherwise.",
    )
    p.add_argument(
        "--python-version",
        action="append",
        dest="python_versions",
        help="Filter the package set by Python version. Multiple versions "
        "can be supplied by repeated use of this option. By default, all "
        "python versions are included. Packages that do not depend on python "
        "are unaffected by this filter.",
    )
    p.add_argument(
        "--r-version",
        action="append",
        dest="r_versions",
        help="Filter the package set by R version. Multiple versions "
        "can be supplied by repeated use of this option. By default, all "
        "R versions are included. Packages that do not depend on R are "
        "unaffected by this filter.",
    )
    p.add_argument(
        "--include-only",
        "--pkg-list",
        action="append",
        dest="pkg_list",
        help="A package name or conda match pattern. Multiple items can be "
        "supplied by repeated use of this option. If supplied, the mirror "
        "will only include packages that match one or more of these patterns; "
        "and if --dependencies is selected, dependencies as well. This filter "
        "is applied prior to any exclusion/inclusion filtering.",
    )
    p.add_argument(
        "--dependencies",
        action=argparse.BooleanOptionalAction,
        default=None,
        help="The mirror will include not just the items matching "
        "the --include-only/--pkg-list values, but their dependencies as well. "
        "This only applies if --include-only/--pkg-list was used.",
    )
    p.add_argument(
        "--pruning",
        action=argparse.BooleanOptionalAction,
        default=None,
        help="Packages that are uninstallable due to missing dependencies "
        "will be removed from the mirror. If --include-only/--pkg-list is "
        "invoked, and one or more of those matches is pruned away "
        "completely, the mirror will not proceed.",
    )
    lstrs = ", ".join(standard_license_families)
    p.add_argument(
        "--license-exclude",
        action="append",
        dest="license_exclusions",
        help="Excludes packages with a license from the given license family. "
        "Multiple exclusions may be supplied by repeated use of this option. "
        "Possible values include: " + lstrs + ".",
    )
    p.add_argument(
        "--constraint",
        action="append",
        dest="constraints",
        help="Constrains packages according to the conda patch pattern given. "
        "Unlike standard conda match behavior, each constraint is applied only to "
        "packages that match by name. So for instance, python=3.9 will block "
        "python 3.10, but not plotly 3.10. Multiple constraints may be supplied by "
        "repeated use of this option.",
    )
    p.add_argument(
        "--exclude",
        action="append",
        dest="exclusions",
        help="Exclude packages that match the given conda match pattern from "
        "the mirror. Multiple exclusions may be supplied by repeated use "
        "of this option.",
    )
    p.add_argument(
        "--include",
        action="append",
        dest="inclusions",
        help="Include packages that match the supplied conda match expression, "
        "even though they would otherwise be excluded by a --filter, --exclude, "
        "or --license-exclude expression. This is used to make exceptions to a "
        "more blanket exclusion list. Packages filtered out by the --pkg-list "
        "or --include-only options are not affected. Multiple exceptions may be "
        "supplied by repeated use of this option.",
    )
    p.add_argument(
        "--ignore-missing",
        default=False,
        action=argparse.BooleanOptionalAction,
        help="If a package is listed in the destination index but the file "
        "is not present, do not re-retrieve it. This is useful for certain "
        "airgap scenarios to build incremental mirror bundles. "
        "Applies to local, s3, nexus, and jfrog targets only.",
    )
    p.add_argument(
        "--max-attempts",
        action="store",
        type=int,
        default=None,
        help="The maximum number of times to attempt a download or upload in "
        "the face of unexpected connection issues. (default: 5)",
    )
    p.add_argument(
        "--max-failures",
        action="store",
        type=int,
        default=None,
        help="The maximum number of packages that can fail the download/upload "
        "process before the mirror process stops. (default: 100)",
    )
    p.add_argument(
        "--verify-ssl",
        default=True,
        action=argparse.BooleanOptionalAction,
        help="Enable/disable SSL verification on both the source and the destination. "
        "On the command line it is a boolean flag, but in a config file you may set it "
        "to the string providing the path to a root CA trust file.",
    )
    p.add_argument(
        "--verify-ssl-src",
        default=None,
        action=argparse.BooleanOptionalAction,
        help="Enable/disable SSL verification on the source. "
        "On the command line it is a boolean flag, but in a config file you may set it "
        "to the string providing the path to a root CA trust file.",
    )
    p.add_argument(
        "--verify-ssl-dst",
        default=None,
        action=argparse.BooleanOptionalAction,
        help="Enable/disable SSL verification on the destination. "
        "On the command line it is a boolean flag, but in a config file you may set it "
        "to the string providing the path to a root CA trust file.",
    )
    p.add_argument(
        "--dest-site",
        dest="dest_site",
        action="store",
        default=None,
        help="The name of the destination site. If you have multiple "
        "destination repositories configured, this option allows you "
        "to select between them. The format of this site name depends "
        "upon the destination API.",
    )
    p.add_argument(
        "--username",
        default=None,
        help="The username used to authenticate to the destination repository. "
        "The precise interpretation depends upon the destination API. Can also "
        "be supplied in the environment variable ANACONDA_MIRROR_USERNAME, or "
        "using a standard ~/.netrc file.",
    )
    p.add_argument(
        "--password",
        "--token",
        default=None,
        help="The password, API token, or bearer token used to authenticate to "
        "the destination repository. The precise interpretation depends upon the "
        "destination API. Can also be supplied in the environment variable "
        "ANACONDA_MIRROR_PASSWORD or ANACONDA_MIRROR_TOKEN, or using a "
        "standard ~/.netrc file.",
    )
    p.add_argument(
        "--auto-freeze",
        action=argparse.BooleanOptionalAction,
        default=None,
        help="(Anaconda Server 6 only.) By default, anaconda-mirror freezes the "
        "channel index during the mirroring process, and un-freezes it upon "
        "completion, to improve performance. To disable this behavior, use "
        "the --no-auto-freeze option.",
    )
    p.add_argument(
        "--config",
        action="store_true",
        help="show the configuration information and exit.",
    )
    p.add_argument("args", nargs="?")

    opts = p.parse_args()
    args = []
    if opts.args:
        args.append(opts.args)
    if opts.filename:
        args.append(opts.filename)
    if args and any(args[0] != a for a in args[1:]):
        p.error("%d filenames supplied: %s" % (len(args), ",".join(args)))

    if args:
        try:
            config.__init__(path=args[0])
        except ConfigError as exc:
            p.error(str(exc))
    opts = vars(opts)
    for attr, value in opts.items():
        if value is not None and hasattr(config, attr):
            if isinstance(value, list):
                getattr(config, attr).extend(value)
            else:
                setattr(config, attr, value)
    try:
        config.check(finalize=True)
    except ConfigError as exc:
        p.error(str(exc))

    do_exit = False
    if opts["config"]:
        if do_exit:
            print("")
        do_exit = True
        config.show()
    if do_exit:
        print("")
        sys.exit(0)


config = Config()
