import atexit
import datetime
import json
import os
import re
import requests
import shutil
import tempfile
import time
import traceback

from requests.exceptions import ConnectionError
from requests.auth import _basic_auth_str


from .conda_interface import (
    download,
    fetch_index,
    known_subdirs,
    VersionOrder,
    empty_repodata,
)
from .config import config, parse_argv
from .filter import filter_all
from .indices import (
    index_keys,
    channel_keys,
    build_subdir_indices,
    build_channel_indices,
)
from .utils import compute_checksums, MirrorException, count_pkgs
from .logging import logger as _l, set_log_level, log_prefix, is_verbose


# Remove these keys at the destination
keys_to_remove = ("fn", "channel", "binstar", "target-triplet")
# If any of these keys change, we must upload a new package
keys_to_replace = {"size": -1, "md5": "", "sha256": "", "sha1": ""}
# If any of these keys change, and none of the above, we can just patch
keys_to_patch = {
    "name": "",
    "version": "",
    "build": "",
    "build_number": -1,
    "timestamp": -1,
    "depends": [],
    "constrains": [],
    "features": "",
    "track_features": "",
    "license": "",
    "license_family": "",
}


def _retry(action):
    sleep_time = 1.0
    for attempt in range(config.max_attempts):
        try:
            return action()
        except Exception as exc:
            _l.warning("! Connection error: %s", exc)
            if attempt == config.max_attempts - 1:
                _l.error("! %d attempts exhausted", config.max_attempts)
                raise MirrorException("Download failed")
        _l.warning(
            "! Retrying in %g second%s", sleep_time, "" if sleep_time == 1 else "s"
        )
        time.sleep(sleep_time)
        sleep_time *= 2.0


_session = None


def get_session():
    global _session
    if _session is None:
        _session = requests.Session()
        _session.headers.update({"Connection": "Keep-Alive"})
        if config.password:
            if config.username:
                auth_str = _basic_auth_str(config.username, config.password)
            else:
                auth_str = "Bearer " + config.password
            _session.headers["Authorization"] = auth_str
        if getattr(config, "api_headers", None):
            _session.headers.update(config.api_headers)
        if config.api_url.startswith("https"):
            _session.verify = config.verify_ssl_mode("dst")
    return _session


def _decode(b):
    if not b:
        return ""
    elif not isinstance(b, bytes):
        return b
    try:
        return b.decode("utf-8")
    except UnicodeDecodeError:
        return b.decode("iso-8859-1")


def _safeurl(b):
    return re.sub(r"(^|.*/)t/[^/]*(.*)", r"\1t/<TOKEN>\2", b)


def _canonicalize(url, channel_alias):
    if not (url.startswith((".", "/")) or "://" in url):
        url = channel_alias + url
    if url.startswith(channel_alias):
        return url, url[len(channel_alias) :]
    elif url.startswith("file://"):
        return url, url[7:]
    return url, url


CONTENT_FLAGS = (
    (".json", {"Content-Type": "application/json; charset=utf-8"}),
    (
        ".json.gz",
        {"Content-Type": "application/json; charset=utf-8", "Content-Encoding": "gzip"},
    ),
    ("", {"Content-Type": "application/octet-stream"}),
)


def _content_data(fn):
    for sfx, cdata in CONTENT_FLAGS:
        if not sfx or fn.endswith(sfx):
            return cdata.copy()


def _api(method, *urlparts, **kwargs):
    method = method.upper()
    short_url = "/".join(p for p in urlparts if p)
    if short_url.startswith("/"):
        url = "/".join(config.api_url.split("/")[:3]) + short_url
    else:
        url = config.api_url.rstrip("/") + "/" + short_url
    throw = kwargs.pop("throw", True)
    accept_404 = kwargs.pop("accept_404", False)
    headers = kwargs.pop("headers", None) or {}
    if "json" in kwargs:
        headers.setdefault("Content-Type", "application/json")
        kwargs["data"] = json.dumps(kwargs.pop("json"))
    if headers:
        kwargs["headers"] = headers
    sleep_time = 1.0
    session = get_session()
    for attempt in range(config.max_attempts):
        try:
            response = session.request(method, url, **kwargs)
            reason = _decode(getattr(response, "reason", ""))
            if not response.content:
                data = None
            elif "json" in response.headers.get("Content-Type", ""):
                data = response.json()
                if "jsonapi" in data and "data" in data:
                    data = data["data"]
            else:
                data = _decode(response.content)
            reason = [reason]
            if isinstance(data, dict):
                reason.append(data.get("message"))
                if isinstance(data.get("errors"), list):
                    reason.extend(e.get("message") for e in data["errors"])
            reason = [r for r in reason if r]
            message = "%s %s %d" % (method, _safeurl(short_url), response.status_code)
            if reason:
                message += " " + "\n> ".join(reason)
            if response.status_code < 300 or accept_404 and response.status_code == 404:
                for mstr in message.splitlines():
                    _l.debug("%s", message)
                return data
            break
        except ConnectionError as exc:
            _l.warning("! %s", exc)
            if attempt >= config.max_attempts - 1:
                _l.error("! %d retry attempts exhausted", config.max_attempts)
                message = "Persisent connection error"
                break
            _l.warning(
                "! retrying in %g second%s",
                sleep_time,
                "" if sleep_time == 1 else "s",
            )
            time.sleep(sleep_time)
            sleep_time *= 2.0
    exc = MirrorException(message)
    if throw:
        raise exc
    return exc


_tmpdir = None


def get_tmpdir():
    global _tmpdir
    if _tmpdir is None:
        _tmpdir = tempfile.mkdtemp()
        atexit.register(shutil.rmtree, _tmpdir, ignore_errors=False)
        _l.debug("Temporary directory created.")
    return _tmpdir


def verify_checksums(info_src, path):
    info_comp = compute_checksums(path)
    for name in ("Size", "MD5", "SHA1", "SHA256"):
        key = name.lower()
        sval = info_comp.get(key)
        dval = info_src.get(key)
        if dval in (0, None):
            info_src[key] = sval
        elif sval != dval:
            _l.error("! %s mismatch: download %s, metadata %s", name, sval, dval)
            raise MirrorException("Checksum mismatch")


def transmute(info_src, path):
    from conda_package_handling.api import transmute as cph_transmute

    fname = os.path.basename(path)
    if fname.endswith("conda"):
        sfmt = "conda"
        dfmt = "tar.bz2"
    else:
        sfmt = "tar.bz2"
        dfmt = "conda"
    fname2 = fname[: -len(sfmt)] + dfmt
    path2 = os.path.join(get_tmpdir(), fname2)
    failed_files = cph_transmute(path, "." + dfmt, get_tmpdir())
    if failed_files:
        msg = list(failed_files.values())[0]
        _l.error("Conversion failed: %s", msg)
        raise MirrorException("Conversion failed")
    info_src.pop("fn", None)
    orec = info_src["_orig"] = {"_format": sfmt}
    for k in keys_to_replace:
        if k in info_src:
            orec[k] = info_src[k]
            del info_src[k]
    return path2


def _format_keys(fn):
    if fn in index_keys or fn in channel_keys:
        fmt = "index"
        pkey = "indices"
    elif fn.endswith(".conda"):
        fmt = "conda"
        pkey = "packages.conda"
    elif fn.endswith(".tar.bz2"):
        fmt = "tar.bz2"
        pkey = "packages"
    else:
        fmt = "extra"
        pkey = "extras"
    return fmt, pkey


def reconcile_packages(repodata, packages, ignore_missing=False):
    if not repodata or packages is None:
        return False
    counts = {"unregistered": 0, "missing": 0, "corrupt": 0}
    repodata["indices"] = {}
    repodata["extras"] = {}
    all_names = set(packages)
    all_names.update(repodata["packages"])
    all_names.update(repodata["packages.conda"])
    for fn in all_names:
        srec = packages.get(fn)
        _, pkey = _format_keys(fn)
        prec = repodata[pkey].get(fn)
        if srec is None:
            counts["missing"] += 1
            if not ignore_missing:
                del repodata[pkey][fn]
        elif prec is None:
            repodata[pkey][fn] = srec
            if pkey != "indices":
                counts["unregistered"] += 1
        elif any(v != prec[k] for k, v in srec.items() if k in prec):
            counts["corrupt"] += 1
            prec.update(srec)
    if any(counts.values()):
        if ignore_missing:
            counts["missing (ignored)"] = counts["missing"]
            counts["missing"] = 0
        msg = "found " + ", ".join("%d %s" % (v, k) for k, v in counts.items() if v)
        return msg


def _call(method, module, *args, **kwargs):
    func = getattr(module, method, None)
    if func is None:
        return
    with log_prefix("| "):
        try:
            return func(*args, **kwargs)
        except MirrorException as exc:
            message = getattr(exc, "message", None) or str(exc)
            message = message.splitlines()
            _l.error("ERROR in %s: %s", method, message[0])
            for estr in message[1:]:
                _l.error(estr)
            raise


def sync_files(
    module,
    api,
    cname,
    platform,
    sorted_keys,
    src_pkgs,
    dst_pkgs,
    ignore_missing=False,
    can_patch=False,
    printed=False,
):
    dpath = (module, api, cname)
    if platform:
        dpath = dpath + (platform,)
    pkg_counts = {"before": len(dst_pkgs)}
    while sorted_keys:
        # We cannot just iterate through the list because the transmute
        # logic must in some cases add an additional fn to the search.
        fn = src_fn = sorted_keys.pop(0)
        fmt, fmt_key = _format_keys(fn)
        is_package = fmt_key.startswith("package")

        info_src = src_pkgs.get(fn)
        info_dst = dst_pkgs.get(fn)
        skip = info_src.get("_skip") if info_src else None
        # This is so that we can detect transmutation cases
        src_fn = info_src["fn"] if info_src and "fn" in info_src else fn
        repl_dst = info_dst.get("_orig", {}) if info_dst and src_fn != fn else info_dst

        if not info_src and not info_dst:
            continue
        elif skip and info_src and not info_dst:
            if skip == "transmuting":
                action, action_p = "Transmuting", "silent"
            elif skip == "redundant":
                action, action_p = "Redundant", "silent"
            elif skip == "format":
                action, action_p = "Blocking (format)", "blocked"
            else:
                action, action_p = "Skipping (%s)" % skip, "filtered"
        elif not info_src or skip:
            # Package is on the destination but not the source
            sfx = " (%s)" % skip if skip else ""
            if config.clean:
                action, action_p = "Removing%s" % sfx, "removed"
            else:
                action, action_p = "Keeping%s" % sfx, "kept"
        elif not info_dst:
            # Package is on the source but not the destination
            if src_fn == fn:
                action, action_p = "Adding", "added"
            else:
                action, action_p = "Converting", "added"
        elif any(
            info_src[k] != repl_dst[k]
            for k in keys_to_replace
            if k in info_src and k in repl_dst
        ):
            # Package is present in both, but the file itself is different
            action, action_p = "Replacing", "replaced"
        elif fmt not in ("index", "extra") and any(
            info_src.get(k, v) != info_dst.get(k, v) for k, v in keys_to_patch.items()
        ):
            if can_patch:
                action, action_p = "Patching", "patched"
            else:
                action, action_p = "Replacing", "replaced"
        else:
            action, action_p = "Up to date", "untouched"
            if src_fn != fn:
                action += " (converted)"

        skipv = action_p in ("filtered", "untouched", "transmuting", "kept")
        sfx = ""
        if action in ("Patching", "Replacing"):
            pkeys = []
            if info_src.get("size") != info_dst.get("size"):
                pkeys.append("content")
            if not pkeys:
                pkeys.extend(
                    k
                    for k in keys_to_replace
                    if k in info_src and k in info_dst and info_src[k] != info_dst[k]
                )
            if not pkeys:
                pkeys.extend(
                    k
                    for k in keys_to_patch
                    if k in info_src and info_src[k] != info_dst.get(k)
                )
                if len(pkeys) * 2 >= len(keys_to_patch):
                    pkeys = ["metadata"]
            sfx += " (%s)" % (",".join(pkeys))
        if not skipv and config.dry_run:
            sfx += " (dry run)"
        if action_p != "silent" and (not skipv or is_verbose()):
            level = "debug" if skipv else "info"
            getattr(_l, level)("%s: %s%s", fn, action, sfx)
            printed = True

        paths_to_remove = []
        fpath = dpath + (fn,)
        try:
            if config.dry_run:
                pass

            elif action_p == "removed":
                _call("del_object", *fpath, metadata=info_dst)
                if is_package:
                    del dst_pkgs[fn]

            elif action_p == "patched":
                info_dst.update(info_src)
                info_dst = {
                    k: v for k, v in info_dst.items() if k not in keys_to_remove
                }
                _call("patch_package", *fpath, metadata=info_dst)
                if is_package:
                    dst_pkgs[fn] = info_dst

            elif action_p not in ("added", "converted", "replaced"):
                pass

            elif not fmt_key.startswith("packages"):
                path = info_src.pop("_data")
                info_dst = {
                    k: v for k, v in info_src.items() if k not in keys_to_remove
                }
                _call("put_object", *fpath, path_or_data=path, metadata=info_dst)

            else:
                url = info_src["channel"].rstrip("/") + "/" + src_fn
                url, short_url = _canonicalize(url, config.channel_alias)
                if url.startswith("file://"):
                    _l.debug("| Local: %s", _safeurl(short_url))
                    path = url[7:]
                else:
                    _l.debug("| Fetch: %s", _safeurl(short_url))
                    path = os.path.join(get_tmpdir(), src_fn)
                    paths_to_remove.append(path)
                    _retry(lambda: download(url, path))
                info_src = {
                    k: v for k, v in info_src.items() if k not in keys_to_remove
                }
                verify_checksums(info_src, path)
                if src_fn != fn:
                    _l.debug("%s -> %s", src_fn, fn)
                    path = transmute(info_src, path)
                    paths_to_remove.append(path)
                    verify_checksums(info_src, path)
                if action_p == "replaced":
                    _call("del_object", *fpath, metadata=info_dst)
                _call("put_object", *fpath, path_or_data=path, metadata=info_src)
                if is_package:
                    dst_pkgs[fn] = info_src

        except MirrorException:
            config.error_count += 1
            action_p = "errored"
            fn = (fn, action)

        finally:
            while paths_to_remove:
                path = paths_to_remove.pop()
                if os.path.exists(path):
                    os.remove(path)

        if action_p != "silent" and fmt_key.startswith("packages"):
            pkg_counts[action_p] = pkg_counts.get(action_p, 0) + 1
        if config.error_count >= config.max_failures:
            _l.error("The maximum number of errors have been reached.")
            break

    pkg_counts["after"] = len(dst_pkgs)
    if not printed:
        _l.info("| up to date")
    return pkg_counts


def retrieve_platform_source(module, api, cname, platform):
    src_repodata = fetch_index(
        config.channels, platform, config.channel_alias, config.verify_ssl_mode("src")
    )
    src_pkgs = {**src_repodata["packages"], **src_repodata["packages.conda"]}
    n_blobs, n_pkgs = count_pkgs(src_pkgs)
    if n_blobs or n_pkgs or is_verbose():
        _l.info("%s: %d blobs, %d packages", platform, n_blobs, n_pkgs)
    return src_pkgs


def retrieve_sources(module, api, cname):
    all_sources = {}
    _l.info("Retrieving source metadata")
    with log_prefix("  "):
        for platform in config.platforms or known_subdirs():
            src_pkgs = retrieve_platform_source(module, api, cname, platform)
            if src_pkgs:
                all_sources[platform] = src_pkgs
    if not all_sources:
        _l.info("| no source packages found")
    return all_sources


def retrieve_platform_destination(module, api, cname, platform):
    dpath = (module, api, cname, platform)
    skip_platform = config.platforms and platform not in config.platforms
    need_indices = hasattr(module, "get_object")
    if need_indices:
        dst_repodata = _call("get_object", *dpath, "repodata.json")
        if dst_repodata and isinstance(dst_repodata, (str, bytes)):
            dst_repodata = json.loads(dst_repodata)
    else:
        dst_repodata = fetch_index(
            config.dest_channel,
            platform,
            config.dest_channel_alias,
            config.verify_ssl_mode("dst"),
        )
    if not dst_repodata:
        dst_repodata = empty_repodata(platform)
    dst_objects = _call("list_objects", *dpath, repodata=dst_repodata)
    if dst_objects is None:
        if skip_platform:
            return {}, {}
        found_orphans = None
    else:
        found_orphans = reconcile_packages(
            dst_repodata, dst_objects, config.ignore_missing
        )
    dst_pkgs = {**dst_repodata["packages"], **dst_repodata["packages.conda"]}
    n_blobs, n_pkgs = count_pkgs(dst_pkgs)
    if n_blobs or n_pkgs or found_orphans or is_verbose():
        _l.info("%s: %d blobs, %d packages", platform, n_blobs, n_pkgs)
        if found_orphans:
            _l.info("| %s", found_orphans)
    return dst_pkgs, dst_repodata


def retrieve_destinations(module, api, cname, all_sources=None):
    all_destinations = {}
    _l.info("Retrieving destination metadata")
    with log_prefix("  "):
        found = False
        all_platforms = known_subdirs()
        if config.clean:
            dst_platforms = all_platforms
        else:
            dst_platforms = config.platforms or all_platforms
        for platform in dst_platforms:
            if not config.clean and all_sources is not None:
                if platform not in all_sources:
                    _l.info("| %s: skipping (no packages)", platform)
                continue
            dst_pkgs, dst_repodata = retrieve_platform_destination(
                module, api, cname, platform
            )
            all_destinations[platform] = (dst_pkgs, dst_repodata)
            if dst_pkgs or dst_repodata:
                found = True
        if not found:
            _l.info("| no packages found")
    return all_destinations


def sync_platform(module, api, cname, platform, src_pkgs, dst_pkgs, dst_repodata):
    dpath = (module, api, cname, platform)
    need_indices = hasattr(module, "get_object")
    skip_platform = config.platforms and platform not in config.platforms

    printed = False
    if src_pkgs and all(k.get("_skip") for k in src_pkgs.values()):
        _l.info("| all packages filtered")
        printed = True
    if not src_pkgs and not dst_pkgs:
        if skip_platform:
            _l.info("| skipping platform")
            printed = True
        else:
            _l.info("| no packages")
            printed = True

    # Sort by ascending package name, then descending version, then filename.
    # This helps to ensure that the newest version of a package furnishes
    # its data to the repository. To accomplish this we rely on the stability
    # of the sorted() function and work backwards.
    # First, sort by package name
    sorted_keys = sorted(set(src_pkgs) | set(dst_pkgs))
    # Second, sort by reverse version order
    sorted_keys = sorted(
        sorted_keys,
        key=lambda x: VersionOrder(
            (src_pkgs.get(x) or dst_pkgs.get(x)).get("version", "0")
        ),
        reverse=True,
    )
    # Finally, sort by package name
    sorted_keys = sorted(
        sorted_keys,
        key=lambda x: (src_pkgs.get(x) or dst_pkgs.get(x)).get("name", x),
    )

    pkg_sets = sync_files(
        module,
        api,
        cname,
        platform,
        sorted_keys,
        src_pkgs,
        dst_pkgs,
        ignore_missing=config.ignore_missing,
        can_patch=need_indices or hasattr(module, "patch_package"),
        printed=printed,
    )

    if dst_repodata.get("extras"):
        dst_pkgs = dst_repodata.pop("extras")
        sorted_keys = sorted(dst_pkgs)
        sync_files(*dpath, sorted_keys, {}, dst_pkgs, printed=True)

    if need_indices:
        dst_repodata["packages"] = {
            k: v for k, v in dst_pkgs.items() if k.endswith(".tar.bz2")
        }
        dst_repodata["packages.conda"] = {
            k: v for k, v in dst_pkgs.items() if k.endswith(".conda")
        }
        pkg_sets["_repodata"] = dst_repodata
        dst_pkgs = dst_repodata.pop("indices", {})
        src_pkgs = build_subdir_indices(cname, platform, dst_repodata)
        sorted_keys = sorted(set(src_pkgs) | set(dst_pkgs))
        sync_files(*dpath, sorted_keys, src_pkgs, dst_pkgs, printed=True)

    return pkg_sets


def sync_channel(module, api, cname):
    summary_data = {}
    try:
        _call("begin_channel", module, api, cname, dry_run=config.dry_run)
    except MirrorException:
        config.error_count += 1
        return
    try:
        all_sources = retrieve_sources(module, api, cname)
    except MirrorException:
        config.error_count += 1
        return
    try:
        filter_all(config, all_sources)
    except MirrorException:
        config.error_count += 1
        return
    try:
        all_destinations = retrieve_destinations(module, api, cname)
    except MirrorException:
        config.error_count += 1
        return
    _l.info("Synchronizing")
    need_indices = False
    with log_prefix("  "):
        repodatas = {}
        sync_platforms = known_subdirs() if config.clean else all_sources
        for platform in sync_platforms:
            _l.info("Platform: %s", platform)
            with log_prefix("  "):
                src_pkgs = all_sources.get(platform) or {}
                dst_pkgs, dst_repodata = all_destinations[platform]
                try:
                    result = sync_platform(
                        module, api, cname, platform, src_pkgs, dst_pkgs, dst_repodata
                    )
                except MirrorException:
                    config.error_count += 1
                    result = {}
            if "_repodata" in result:
                repodatas[platform] = result.pop("_repodata")
                need_indices = True
            if any(result.values()):
                summary_data[platform] = result
            if config.error_count >= config.max_failures:
                break
        if need_indices:
            _l.info("Indices:")
            with log_prefix("  "):
                try:
                    dst_pkgs = _call("list_objects", module, api, cname) or {}
                    src_pkgs = build_channel_indices(cname, repodatas)
                    skeys = sorted(set(src_pkgs) | set(dst_pkgs))
                    sync_files(module, api, cname, None, skeys, src_pkgs, dst_pkgs)
                except MirrorException:
                    config.error_count += 1
    _call("finish_channel", module, api, cname, dry_run=config.dry_run)
    return summary_data


def get_api(module):
    api = _call("get_repo_api", module, config)
    valid_formats = getattr(config, "valid_formats", ("conda", "tarbz2"))
    if "conda" not in valid_formats:
        if config.format_policy in ("only-conda", "transmute-conda"):
            return "Format policy '%s' not supported" % config.format_policy
        elif config.format_policy != "only-tarbz2":
            config.format_policy = "transmute-tarbz2"
    elif "tarbz2" not in valid_formats:
        if config.format_policy in ("only-tarbz2", "transmute-bz2"):
            return "Format policy '%s' not supported" % config.format_policy
        elif config.format_policy != "only-conda":
            config.format_policy = "transmute-conda"
    elif config.format_policy == "default":
        config.format_policy = "prefer-conda"
    return api


def print_config(config):
    _l.info("")
    _l.info("Recipe")
    _l.info("-------")
    if getattr(config, "path", None):
        _l.info("Config file: %s", config.path)
    cname = cfull = config.dest_channel
    if config.dest_channel_alias:
        cfull = config.dest_channel_alias.rstrip("/") + "/" + cname
        if cfull.startswith("file://"):
            cfull = cfull[7:]
    elif config.dest_site:
        cfull = config.dest_site.rstrip("/") + "/" + cname
    _l.info("Destination: %s", cfull)
    if getattr(config, "access_policy", None):
        _l.info("Access policy: %s", config.access_policy)
    _l.info("Format policy: %s", config.format_policy)
    _l.info("Source%s:" % ("" if len(config.channels) == 1 else "s"))
    for c in config.channels:
        _l.info("  - %s", c)
    if config.platforms:
        _l.info("Subdirs:")
        for p in config.platforms:
            _l.info("  - %s", p)
    else:
        _l.info("Subdirs: <all>")
    if (
        config.python_versions
        or config.r_versions
        or config.pkg_list
        or config.license_exclusions
        or config.constraints
        or config.exclusions
        or config.inclusions
    ):
        _l.info("Filtering:")
        if config.python_versions:
            _l.info("  Python versions: %s", "|".join(config.python_versions))
        if config.r_versions:
            _l.info("  R versions: %s", "|".join(config.r_versions))
        if config.pkg_list:
            _l.info("  Include only:")
            for p in config.pkg_list:
                _l.info("  - %s" % p)
            _l.info("  Dependencies?: %s", "yes" if config.dependencies else "no")
        _l.info("  Prune uninstallable?: %s", "yes" if config.pruning else "no")
        if config.license_exclusions:
            _l.info("  License exclusions:")
            for p in config.license_exclusions:
                _l.info("  - %s" % p)
        if config.constraints:
            _l.info("  Constraints:")
            for p in config.constraints:
                _l.info("  - %s" % p)
        if config.exclusions:
            _l.info("  Exclusions:")
            for p in config.exclusions:
                _l.info("  - %s" % p)
        if config.inclusions:
            _l.info("  Inclusions:")
            for p in config.inclusions:
                _l.info("  - %s" % p)
    else:
        _l.info("Filters: <none>")
    if config.ignore_missing:
        _l.info("Ignore missing: yes")
    ssls = config.verify_ssl_mode("src")
    ssld = config.verify_ssl_mode("dst")
    if not (ssls is True and ssld is True):
        _l.info("SSL verification:")
        _l.info(
            "  Source: %s",
            "disabled"
            if not ssls
            else "CA file"
            if isinstance(ssls, str)
            else "enabled",
        )
        _l.info(
            "  Destination: %s",
            "disabled"
            if not ssld
            else "custom CA file"
            if isinstance(ssld, str)
            else "enabled",
        )
    _l.info("Clean: %s", "yes" if config.clean else "no")
    _l.info("Verbose: %s", "yes" if config.verbose else "no")
    _l.info("Dry run: %s", "yes (no changes will be made)" if config.dry_run else "no")
    _l.info("Connection attempts: %d", config.max_attempts)
    _l.info("Tolerated failures: %d", config.max_failures)


VALUE_STR = {
    "before": "before mirror",
    "kept": "preserved due to clean=false",
    "replaced": "replaced for content changes",
    "patched": "patched for metadata changes",
    "filtered": "blocked due to filtering",
    "after": "after mirror",
    "skipped": "skipped due to format policy",
    "blocked": "blocked due to format policy",
    "errored": "FAILURES encountered",
}


ALL_VALS = (
    "before",
    "added",
    "removed",
    "kept",
    "replaced",
    "patched",
    "filtered",
    "skipped",
    "blocked",
    "errored",
    "after",
)


def print_summary(config, summary_data):
    if summary_data is None:
        _l.warning("| could not be completed")
        return
    elif not summary_data:
        _l.info("| no actions taken")
        return
    strlen = max(
        len(str(v)) for pkg_counts in summary_data.values() for v in pkg_counts.values()
    )
    for platform, pkg_counts in summary_data.items():
        _l.info("Platform: %s", platform)
        with log_prefix("  "):
            for key in ALL_VALS:
                if key in ("before", "after") or pkg_counts.get(key, 0):
                    _l.info("%*d %s", strlen, pkg_counts[key], VALUE_STR.get(key, key))


def main(module):
    parse_argv()
    set_log_level(config.verbose)
    start_time = datetime.datetime.now()
    _l.info("START: %s", start_time.isoformat())
    config.error_count = 0
    try:
        api = get_api(module)
        print_config(config)
        _l.info("")
        _l.info("Actions")
        _l.info("-------")
        cname = config.dest_channel
        summary_data = {}
        try:
            summary_data[cname] = sync_channel(module, api, cname)
        except MirrorException as exc:
            _l.error(exc)
            config.error_count += 1
        _l.info("")
        _l.info("Summary")
        _l.info("-------")
        for cname, sdict in summary_data.items():
            _l.info("Channel: %s", cname)
            with log_prefix("  "):
                print_summary(config, sdict)
    except MirrorException:
        config.error_count += 1
    except Exception:
        _l.info("---------")
        _l.error(traceback.format_exc())
        _l.info("---------")
        config.error_count = -1
    _l.info("")
    if config.error_count:
        _l.info("Sync operation DID NOT complete successfully.")
        _l.info("Please examine the logs for more details.")
    elif config.dry_run:
        _l.info("REMINDER: this was a dry run. The 'final' numbers")
        _l.info("above represent what *would* have occurred after")
        _l.info("a successful, actual run.")
    else:
        _l.info("Sync operation completed successfully.")
    end_time = datetime.datetime.now()
    _l.info("")
    _l.info("END: %s (%s)", end_time.isoformat(), end_time - start_time)
    return config.error_count
