#!/usr/bin/python3

# Copyright (C) 2019-2022 Benjamin Drung <bdrung@posteo.de>
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

"""
Call mmdebstrap with parameters specified in a YAML file.
"""

import argparse
import collections
import io
import logging
import os
import pathlib
import re
import shutil
import subprocess
import sys
import time
import typing

import ruamel.yaml

HOOKS_DIR = pathlib.Path(__file__).parent.parent / "share" / "bdebstrap" / "hooks"
MANIFEST_FILENAME = "manifest"
OUTPUT_DIR = "/tmp/bdebstrap-output"
LOG_FORMAT = "%(asctime)s %(name)s %(levelname)s: %(message)s"
__script_name__ = os.path.basename(sys.argv[0]) if __name__ == "__main__" else __name__


MMDEBSTRAP_OPTS = {
    "aptopts": list,
    "architectures": list,
    "cleanup-hooks": list,
    "components": list,
    "customize-hooks": list,
    "dpkgopts": list,
    "essential-hooks": list,
    "extract-hooks": list,
    "format": str,
    "hook-dirs": list,
    "hostname": str,
    "install-recommends": bool,
    "keyrings": list,
    "mirrors": list,
    "mode": str,
    "packages": list,
    "setup-hooks": list,
    "skip": list,
    "suite": str,
    "target": str,
    "variant": str,
}


class Config(dict[str, typing.Any]):
    """YAML configuration for bdebstrap."""

    _ENV_PREFIX = "BDEBSTRAP_"
    _KEYS = {"env", "mmdebstrap", "name"}

    def __init__(self, *args: typing.Any, **kwargs: dict[str, typing.Any]) -> None:
        super().__init__(self, *args, **kwargs)
        self.logger = logging.getLogger(__script_name__)
        self.yaml = ruamel.yaml.YAML()
        self.yaml.default_flow_style = False
        self.yaml.explicit_start = True
        self.yaml.indent(offset=2, sequence=4)

    def _set_mmdebstrap_option(self, option: str, value: str | int | list[str]) -> None:
        """Set the given mmdebstrap option (overwriting existing values)."""
        if "mmdebstrap" not in self:
            self["mmdebstrap"] = {}
        self["mmdebstrap"][option] = value

    def _append_mmdebstrap_option(self, option: str, value: str | int | list[str]) -> None:
        """Append the given mmdebstrap option to the list of values."""
        if "mmdebstrap" not in self:
            self["mmdebstrap"] = {}
        if option in self["mmdebstrap"]:
            self["mmdebstrap"][option] += value
        else:
            self["mmdebstrap"][option] = value

    # pylint: disable-next=too-many-branches
    def add_command_line_arguments(self, args: argparse.Namespace) -> None:
        """Add/Override configs from the given command line arguments."""
        for config_filename in args.config:
            self.load(config_filename)

        if args.env:
            if "env" not in self:
                self["env"] = {}
            for key, value in args.env.items():
                self["env"][key] = value
        if args.name:
            self["name"] = args.name

        if args.variant:
            self._set_mmdebstrap_option("variant", args.variant)
        if args.mode:
            self._set_mmdebstrap_option("mode", args.mode)
        if args.format:
            self._set_mmdebstrap_option("format", args.format)
        if args.aptopt:
            self._append_mmdebstrap_option("aptopts", args.aptopt)
        if args.keyring:
            self._append_mmdebstrap_option("keyrings", args.keyring)
        if args.dpkgopt:
            self._append_mmdebstrap_option("dpkgopts", args.dpkgopt)
        if args.hostname:
            self._set_mmdebstrap_option("hostname", args.hostname)
        if args.install_recommends:
            self._set_mmdebstrap_option("install-recommends", args.install_recommends)
        if args.packages:
            self._append_mmdebstrap_option("packages", args.packages)
        if args.components:
            self._append_mmdebstrap_option("components", args.components)
        if args.architectures:
            self._append_mmdebstrap_option("architectures", args.architectures)
        if args.hook_dir:
            self._append_mmdebstrap_option("hook-dirs", args.hook_dir)
        if args.setup_hook:
            self._append_mmdebstrap_option("setup-hooks", args.setup_hook)
        if args.essential_hook:
            self._append_mmdebstrap_option("essential-hooks", args.essential_hook)
        if args.extract_hook:
            self._append_mmdebstrap_option("extract-hooks", args.extract_hook)
        if args.customize_hook:
            self._append_mmdebstrap_option("customize-hooks", args.customize_hook)
        if args.cleanup_hook:
            self._append_mmdebstrap_option("cleanup-hooks", args.cleanup_hook)
        if args.skip:
            self._append_mmdebstrap_option("skip", args.skip)
        if args.suite:
            self._set_mmdebstrap_option("suite", args.suite)
        if args.target:
            self._set_mmdebstrap_option("target", args.target)
        if args.mirrors:
            self._append_mmdebstrap_option("mirrors", args.mirrors)

    def env_items(self) -> list[tuple[str, str]]:
        """Return key-value pair of environment variables."""
        return sorted(
            list(self.get("env", {}).items())
            + [
                (self._ENV_PREFIX + "HOOKS", HOOKS_DIR),
                (self._ENV_PREFIX + "NAME", self["name"]),
                (self._ENV_PREFIX + "OUTPUT_DIR", OUTPUT_DIR),
            ]
        )

    def check(self) -> None:
        """Check the format of the configuration."""
        unknown_top_level_keys = sorted(k for k in self.keys() if k not in self._KEYS)
        if unknown_top_level_keys:
            self.logger.warning(
                "Ignoring unknown top level keys: %s", ", ".join(unknown_top_level_keys)
            )

        if "mmdebstrap" in self:
            for key, value in self["mmdebstrap"].items():
                if key not in MMDEBSTRAP_OPTS:
                    self.logger.warning("Ignoring unknown mmdebstrap option '%s'.", key)
                    continue
                if not isinstance(value, MMDEBSTRAP_OPTS[key]):
                    raise ValueError(
                        f"Unexpected type '{type(value)}' for mmdebstrap option '{key}'. "
                        f"Excepted: {MMDEBSTRAP_OPTS[key]}."
                    )
                if MMDEBSTRAP_OPTS[key] is list:
                    assert isinstance(value, list)
                    # Check if list elements are strings
                    for element in value:
                        if not isinstance(element, str):
                            raise ValueError(
                                f"Following list element of mmdebstrap option '{key}' has type "
                                f"'{type(element).__name__}' instead of string: {element}"
                            )
        else:
            self.logger.warning("The configuration does not contain a 'mmdebstrap' entry.")

        if "name" not in self:
            raise ValueError("The configuration does not contain a 'name' entry.")

    def load(self, config_filename: str) -> None:
        """Loading configuration from given config file."""
        self.logger.info("Loading configuration from '%s'...", config_filename)
        try:
            with open(config_filename, "rb") as config_file:
                config = self.yaml.load(config_file)
        except OSError as error:
            self.logger.error(
                "Failed to open configuration '%s': %s", config_filename, error.args[1]
            )
            raise

        if "mmdebstrap" in config and "include" in config["mmdebstrap"]:
            mmdebstrap = config["mmdebstrap"]
            mmdebstrap["packages"] = mmdebstrap["include"] + mmdebstrap.get("packages", [])
            del mmdebstrap["include"]

        dict_merge(self, config)

    def sanitize_packages(self) -> None:
        """Sanitize packages list by removing duplicates (keeping the latest one)."""
        if "mmdebstrap" not in self or "packages" not in self["mmdebstrap"]:
            return

        packages = collections.OrderedDict()
        for package in self["mmdebstrap"]["packages"]:
            if not package:
                # Cover commented out and empty entries
                continue
            if package[0] in {"?", "!", "~", "("}:
                # Do not fiddle with APT patterns
                packages[package] = package
            elif package.startswith("/") or package.startswith("./") or package.startswith("../"):
                name = pathlib.Path(package).stem.split("_", 1)[0]
                packages[name] = package
            else:
                name = re.split("[=/]", package, maxsplit=1)[0]
                packages[name] = package

        self["mmdebstrap"]["packages"] = list(packages.values())

    def save(self, config_filename: str, simulate: bool = False) -> None:
        """Save configuration to given config filename."""
        self.logger.info(
            "%s configuration to '%s'.",
            "Simulate saving" if simulate else "Saving",
            config_filename,
        )
        if simulate:
            self.yaml.dump(dict(self), io.StringIO())
        else:
            with open(config_filename, "w", encoding="utf-8") as config_file:
                self.yaml.dump(dict(self), config_file)
            clamp_mtime(config_filename, self.source_date_epoch)

    @property
    def source_date_epoch(self) -> typing.Any:
        """Return SOURCE_DATE_EPOCH (for reproducible builds)."""
        return self.get("env", {}).get("SOURCE_DATE_EPOCH")

    def set_source_date_epoch(self) -> None:
        """Set SOURCE_DATE_EPOCH (for reproducible builds) if not already set."""

        if "env" not in self:
            self["env"] = {}
        if self["env"].get("SOURCE_DATE_EPOCH") is None:
            self["env"]["SOURCE_DATE_EPOCH"] = int(time.time())


class Mmdebstrap:
    """Wrapper around calling mmdebstrap."""

    def __init__(self, config: Config) -> None:
        self.config = config
        self.logger = logging.getLogger(__script_name__)

    def _get_mmdebstrap_log_level_parameters(self) -> list[str]:
        log_level = self.logger.getEffectiveLevel()
        if log_level >= logging.ERROR:
            return ["-q"]
        if log_level <= logging.DEBUG:
            return ["--debug"]
        if log_level <= logging.INFO:
            return ["-v"]
        return []

    def construct_parameters(self, output_dir: str, simulate: bool = False) -> list[str]:
        """Construct the parameter for mmdebstrap from a given dictionary."""
        # pylint: disable=too-many-branches
        cmd = ["mmdebstrap"] + self._get_mmdebstrap_log_level_parameters()
        if simulate:
            cmd += ["--simulate"]
        mmdebstrap = self.config.get("mmdebstrap", {})
        if "variant" in mmdebstrap:
            cmd.append(f"--variant={mmdebstrap['variant']}")
        if "mode" in mmdebstrap:
            cmd.append(f"--mode={mmdebstrap['mode']}")
        if "format" in mmdebstrap:
            cmd.append(f"--format={mmdebstrap['format']}")
        if "aptopts" in mmdebstrap:
            cmd += [f"--aptopt={aptopt}" for aptopt in mmdebstrap["aptopts"]]
        if "keyrings" in mmdebstrap:
            cmd += [f"--keyring={keyring}" for keyring in mmdebstrap["keyrings"]]
        if "dpkgopts" in mmdebstrap:
            cmd += [f"--dpkgopt={dpkgopt}" for dpkgopt in mmdebstrap["dpkgopts"]]
        # For convenience use "packages" key as alias for "include"
        if "packages" in mmdebstrap:
            cmd.append(f"--include={','.join(mmdebstrap['packages'])}")
        if "components" in mmdebstrap:
            cmd.append(f"--components={','.join(mmdebstrap['components'])}")
        if "architectures" in mmdebstrap:
            cmd.append(f"--architectures={','.join(mmdebstrap['architectures'])}")
        if "skip" in mmdebstrap:
            cmd.append(f"--skip={','.join(mmdebstrap['skip'])}")
        if "hook-dirs" in mmdebstrap:
            cmd += [f"--hook-dir={hook}" for hook in mmdebstrap["hook-dirs"]]
        if "setup-hooks" in mmdebstrap:
            cmd += [f"--setup-hook={hook}" for hook in mmdebstrap["setup-hooks"]]
        if "extract-hooks" in mmdebstrap:
            cmd += [f"--extract-hook={hook}" for hook in mmdebstrap["extract-hooks"]]
        cmd.append(f'--essential-hook=mkdir -p "$1{OUTPUT_DIR}"')
        if "essential-hooks" in mmdebstrap:
            cmd += [f"--essential-hook={hook}" for hook in mmdebstrap["essential-hooks"]]
        if "customize-hooks" in mmdebstrap:
            cmd += [f"--customize-hook={hook}" for hook in mmdebstrap["customize-hooks"]]
        # cleanup hooks are just hooks that run after all other customize hooks
        if "cleanup-hooks" in mmdebstrap:
            cmd += [f"--customize-hook={hook}" for hook in mmdebstrap["cleanup-hooks"]]

        # Special parameters not present in mmdebstrap
        if "hostname" in mmdebstrap:
            cmd.append(f'--customize-hook=echo "{mmdebstrap["hostname"]}" > "$1/etc/hostname"')
        if "install-recommends" in mmdebstrap and mmdebstrap["install-recommends"] is True:
            cmd.append('--aptopt=Apt::Install-Recommends "true"')
        cmd.append(
            "--customize-hook=chroot \"$1\" dpkg-query -f='${Package}\\t${Version}\\n' -W "
            f'> "$1{OUTPUT_DIR}/manifest"'
        )
        cmd.append(f'--customize-hook=sync-out "{OUTPUT_DIR}" "{output_dir}"')
        cmd.append(f'--customize-hook=rm -rf "$1{OUTPUT_DIR}"')

        # Positional arguments
        cmd.append(mmdebstrap.get("suite", "-"))
        cmd.append(mmdebstrap.get("target", "-"))
        cmd += mmdebstrap.get("mirrors", [])

        return cmd

    def call(self, output_dir: str, simulate: bool = False) -> None:
        """Call mmdebstrap."""
        cmd = self.construct_parameters(output_dir, simulate)
        self.logger.info("Calling %s", escape_cmd(cmd))
        subprocess.check_call(cmd)
        self.clamp_mtime(output_dir)

    def clamp_mtime(self, output_dir: str) -> None:
        """Clamp the modification time of the manifest, target, and output directory."""
        for path in (
            os.path.join(output_dir, "manifest"),
            self.config.get("mmdebstrap", {}).get("target", ""),
            output_dir,
        ):
            if os.path.exists(path):
                try:
                    clamp_mtime(path, self.config.source_date_epoch)
                except OSError as error:
                    self.logger.error(
                        "Failed to change modification time of '%s': %s", path, error
                    )


def clamp_mtime(path: str, source_date_epoch: int | str | None) -> None:
    """Clamp the modification time for the given path to SOURCE_DATE_EPOCH."""
    if not source_date_epoch:
        return
    stat = os.stat(path)
    if stat.st_mtime > int(source_date_epoch):
        os.utime(path, (int(source_date_epoch), int(source_date_epoch)))


def duration_str(duration: float) -> str:
    """Return duration in the biggest useful time unit (hours, minutes, seconds)."""
    if duration < 60:
        return f"{duration:.3f} seconds"
    minutes = int(duration // 60)
    if duration < 3600:
        return f"{minutes} min {duration % 60:.3f} s (= {duration:.3f} s)"

    return f"{minutes // 60} h {minutes % 60} min {duration % 60:.3f} s (= {duration:.3f} s)"


def escape_cmd(cmd: list[str]) -> str:
    """Escape command line arguments for printing/logging."""
    unsafe_re = re.compile(r"[^\w@%+=:,./-]", re.ASCII)

    def quote(cmd_argv: str) -> str:
        """Return a shell-escaped version of the string *cmd_argv*."""
        if unsafe_re.search(cmd_argv) is None:
            return cmd_argv
        parts = cmd_argv.split("'")
        for i in range(0, len(parts), 2):
            # Only escape parts that are not quoted with single quotes.
            parts[i] = re.sub('(["$])', r"\\\1", parts[i])
        return '"' + "'".join(parts) + '"'

    return " ".join(quote(x) for x in cmd)


def sanitize_list(list_: list[str]) -> list[str]:
    """Sanitize given list by removing all empty entries."""
    if list_ is None:
        return None
    return [x for x in list_ if x]


# pylint: disable-next=too-many-statements
def parse_args(argv: list[str]) -> argparse.Namespace:
    """Parse the given command line arguments."""
    parser = argparse.ArgumentParser(description=__doc__)
    # parser.add_argument("-m", "--manifest", help="Store packages manifest in given file")
    parser.add_argument(
        "-c", "--config", action="append", default=[], help="bdebstrap configuration YAML."
    )
    parser.add_argument("-n", "--name", help="name of the generated golden image")
    parser.add_argument(
        "-e", "--env", action="append", default=[], help="add additional environment variable."
    )
    parser.add_argument(
        "-s",
        "--simulate",
        "--dry-run",
        action="store_true",
        help=(
            "Run apt-get with --simulate. Only the package cache is initialized but no binary "
            "packages are downloaded or installed. Use this option to quickly check whether a "
            "package selection within a certain suite and variant can in principle be installed "
            "as far as their dependencies go. If the output is a tarball, then no output is "
            "produced. If the output is a directory, then the directory will be left populated "
            "with the skeleton files and directories necessary for apt to run in it."
        ),
    )
    parser.add_argument("-b", "--output-base-dir", default=".", help="output base directory")
    parser.add_argument("-o", "--output", help="output directory (default: output-base-dir/name)")
    parser.add_argument(
        "-q",
        "--quiet",
        "--silent",
        dest="log_level",
        help="Do not write anything to standard error except errors.",
        action="store_const",
        const=logging.ERROR,
        default=logging.WARNING,
    )
    parser.add_argument(
        "-v",
        "--verbose",
        dest="log_level",
        help=(
            "Write informational messages to standard error. Instead of progress bars, "
            "mmdebstrap writes the dpkg and apt output directly to standard error."
        ),
        action="store_const",
        const=logging.INFO,
    )
    parser.add_argument(
        "--debug",
        dest="log_level",
        help=(
            "In addition to the output produced by --verbose, write detailed debugging "
            "information to standard error."
        ),
        action="store_const",
        const=logging.DEBUG,
    )
    parser.add_argument(
        "-f",
        "--force",
        action="store_true",
        help="Remove existing output directory before creating a new one",
    )
    parser.add_argument(
        "-t", "--tmpdir", help="Temporary directory for building the image (default: /tmp)"
    )

    # Arguments from mmdebstrap
    parser.add_argument(
        "--variant",
        choices=[
            "extract",
            "custom",
            "essential",
            "apt",
            "required",
            "minbase",
            "buildd",
            "important",
            "debootstrap",
            "-",
            "standard",
        ],
        help="Choose which package set to install.",
    )
    parser.add_argument(
        "--mode",
        choices=["auto", "sudo", "root", "unshare", "fakeroot", "fakechroot", "chrootless"],
        help=(
            "Choose how to perform the chroot operation and create a filesystem with "
            "ownership information different from the current user."
        ),
    )
    parser.add_argument(
        "--format",
        choices=["auto", "directory", "dir", "tar", "squashfs", "sqfs", "ext2", "null"],
        help="Choose the output format.",
    )
    parser.add_argument(
        "--aptopt", action="append", help="Pass arbitrary options or configuration files to apt."
    )
    parser.add_argument(
        "--keyring", action="append", help="Change the default keyring to use by apt."
    )
    parser.add_argument(
        "--dpkgopt", action="append", help="Pass arbitrary options or configuration files to dpkg."
    )
    parser.add_argument(
        "--hostname", help="Write the given HOSTNAME into /etc/hostname in the target chroot."
    )
    parser.add_argument(
        "--install-recommends",
        action="store_true",
        help="Consider recommended packages as a dependency for installing.",
    )
    parser.add_argument(
        "--packages",
        "--include",
        action="append",
        help=(
            "Comma or whitespace separated list of packages which will be installed in "
            "addition to the packages installed by the specified variant."
        ),
    )
    parser.add_argument(
        "--components",
        action="append",
        help=(
            "Comma or whitespace separated list of components like main, contrib and "
            "non-free which will be used for all URI-only MIRROR arguments."
        ),
    )
    parser.add_argument(
        "--architectures",
        action="append",
        help=(
            "Comma or whitespace separated list of architectures. The first architecture "
            "is the native architecture inside the chroot."
        ),
    )

    parser.add_argument(
        "--setup-hook",
        metavar="COMMAND",
        action="append",
        help=(
            "Execute arbitrary COMMAND right after initial setup (directory creation, "
            "configuration of apt and dpkg, ...) but before any packages are downloaded or "
            "installed. At that point, the chroot directory does not contain any executables and "
            "thus cannot be chroot-ed into."
        ),
    )
    parser.add_argument(
        "--extract-hook",
        metavar="COMMAND",
        action="append",
        help=(
            "Execute arbitrary COMMAND after the Essential:yes packages have been extracted "
            "but before installing them."
        ),
    )
    parser.add_argument(
        "--essential-hook",
        metavar="COMMAND",
        action="append",
        help=(
            "Execute arbitrary COMMAND after the Essential:yes packages have been installed, "
            "but before installing the remaining packages."
        ),
    )
    parser.add_argument(
        "--customize-hook",
        metavar="COMMAND",
        action="append",
        help=(
            "Execute arbitrary COMMAND after the chroot is set up and all packages got installed "
            "but before final cleanup actions are carried out."
        ),
    )
    parser.add_argument(
        "--cleanup-hook",
        metavar="COMMAND",
        action="append",
        help="Execute arbitrary COMMAND after all customize hooks have been executed.",
    )
    parser.add_argument(
        "--hook-dir",
        metavar="DIRECTORY",
        action="append",
        help='Execute scripts in DIRECTORY with filenames starting with "setup", "extract",'
        ' "essential" or "customize", at the respective stages during an mmdebstrap run.',
    )
    parser.add_argument(
        "--skip",
        metavar="STAGE",
        action="append",
        help="Comma or whitespace separated list of actions and safety checks to skip.",
    )

    # Positional arguments from mmdebstrap
    parser.add_argument(
        "--suite",
        help=(
            "The suite may be a valid release code name (eg, sid, stretch, jessie) or a symbolic "
            "name (eg, unstable, testing, stable, oldstable)."
        ),
    )
    parser.add_argument(
        "--target",
        help=(
            "The optional target argument can either be the path to a directory, the path to a "
            "tarball filename, the path to a squashfs image or '-'."
        ),
    )
    parser.add_argument(
        "--mirrors",
        action="append",
        default=[],
        help=(
            "Comma separated list of mirrors. If no mirror option is provided, "
            "http://deb.debian.org/debian is used."
        ),
    )

    parser.add_argument(
        metavar="suite",
        dest="suite_positional",
        nargs="?",
        help=(
            "The suite may be a valid release code name (eg, sid, stretch, jessie) or a symbolic "
            "name (eg, unstable, testing, stable, oldstable)."
        ),
    )
    parser.add_argument(
        metavar="target",
        dest="target_positional",
        nargs="?",
        help=(
            "The optional target argument can either be the path to a directory, the path to a "
            "tarball filename, the path to a squashfs image or '-'."
        ),
    )
    parser.add_argument(
        metavar="mirrors",
        dest="mirrors_positional",
        nargs="*",
        help=(
            "APT mirror to use. If no mirror option is provided, "
            "http://deb.debian.org/debian is used."
        ),
    )

    args = parser.parse_args(argv)

    env_dict = {}
    for env in args.env:
        if "=" not in env:
            parser.error(f"Failed to parse --env '{env}'. It needs to be in the format KEY=value.")
        key, value = env.split("=", 1)
        env_dict[key] = value
    args.env = env_dict

    if args.packages:
        args.packages = [
            p for packages_list in args.packages for p in re.split(",| ", packages_list) if p
        ]
    if args.components:
        args.components = [
            c for component_list in args.components for c in re.split(",| ", component_list) if c
        ]
    if args.architectures:
        args.architectures = [
            a for arch_list in args.architectures for a in re.split(",| ", arch_list) if a
        ]
    args.mirrors = [
        m.strip() for mirror_list in args.mirrors for m in mirror_list.split(",") if m.strip()
    ]

    # Positional arguments override optional arguments (or extent them in case of "mirrors")
    if args.suite_positional:
        args.suite = args.suite_positional
    if args.target_positional:
        args.target = args.target_positional
    args.mirrors += [m.strip() for m in args.mirrors_positional if m.strip()]
    del args.suite_positional
    del args.target_positional
    del args.mirrors_positional

    # Sanitize (clear empty entries in lists)
    args.aptopt = sanitize_list(args.aptopt)
    args.config = sanitize_list(args.config)
    args.dpkgopt = sanitize_list(args.dpkgopt)
    args.keyring = sanitize_list(args.keyring)
    args.hook_dir = sanitize_list(args.hook_dir)
    args.setup_hook = sanitize_list(args.setup_hook)
    args.extract_hook = sanitize_list(args.extract_hook)
    args.essential_hook = sanitize_list(args.essential_hook)
    args.customize_hook = sanitize_list(args.customize_hook)
    args.cleanup_hook = sanitize_list(args.cleanup_hook)
    args.skip = sanitize_list(args.skip)

    return args


def dict_merge(this: dict[str, typing.Any], other: dict[str, typing.Any]) -> None:
    """
    Update this dictionary with the key/value pairs from other, merging existing keys.
    Return ``None``.

    Inspired by ``dict.update()``, instead of updating only top-level keys,
    dict_merge recurses down into nested dicts. Dictionaries are updated
    recursively and lists are appended. The ``other`` dict is merged into
    ``this``.

    :param this: dictionary onto which the merge is executed
    :param other: dictionary merged into ``this``
    """
    for key in other.keys():
        if (
            key in this
            and isinstance(this[key], collections.abc.MutableMapping)
            and isinstance(other[key], collections.abc.Mapping)
        ):
            dict_merge(this[key], other[key])
        elif (
            key in this
            and isinstance(this[key], collections.abc.MutableSequence)
            and isinstance(other[key], collections.abc.Sequence)
        ):
            this[key] += other[key]
        else:
            this[key] = other[key]


def prepare_output_dir(output_dir: str, force: bool, simulate: bool = False) -> bool:
    """Ensure that the output directory exists and is empty."""
    logger = logging.getLogger(__script_name__)

    if os.path.exists(output_dir) and os.listdir(output_dir):
        if force:
            logger.info(
                "%s existing output directory '%s'.",
                "Simulate removing" if simulate else "Removing",
                output_dir,
            )
            if not simulate:
                for content in os.listdir(output_dir):
                    path = os.path.join(output_dir, content)
                    try:
                        shutil.rmtree(path)
                    except NotADirectoryError:
                        os.remove(path)
        else:
            logger.error(
                "The output directory '%s' already exists and is not empty. "
                "Use --force to remove it.",
                output_dir,
            )
            return False

    if not os.path.isdir(output_dir):
        logger.info(
            "%s output directory '%s'...",
            "Simulate creating" if simulate else "Creating",
            output_dir,
        )
        if not simulate:
            os.makedirs(output_dir)

    return True


def main(argv: list[str]) -> int:
    """Call mmdebstrap with parameters specified in a YAML file."""
    start_time = time.time()
    args = parse_args(argv)
    logging.basicConfig(level=args.log_level, format=LOG_FORMAT)
    logger = logging.getLogger(__script_name__)

    config = Config()
    try:
        config.add_command_line_arguments(args)
        config.sanitize_packages()
        config.set_source_date_epoch()
        config.check()
    except OSError:
        return 1
    except ValueError as error:
        logger.error("%s", error)
        return 1

    if not args.output:
        args.output = os.path.join(args.output_base_dir, config["name"])

    if not prepare_output_dir(args.output, args.force, args.simulate):
        return 1
    config.save(os.path.join(args.output, "config.yaml"), args.simulate)

    # Set environment variables
    for env, value in config.env_items():
        if os.environ.get(env) != str(value):
            logger.info("Setting environment variable %s=%s", env, value)
            os.environ[env] = str(value)
    if args.tmpdir:
        os.environ["TMPDIR"] = args.tmpdir

    mmdebstrap = config.get("mmdebstrap", {})

    if mmdebstrap.get("target") not in (None, "-") and "/" not in mmdebstrap["target"]:
        mmdebstrap["target"] = os.path.join(args.output, mmdebstrap["target"])

    if "LD_PRELOAD" in os.environ:
        # gtk3-nocsd preloads libgtk3-nocsd.so.0 which fails on cross-builds
        del os.environ["LD_PRELOAD"]

    try:
        Mmdebstrap(config).call(args.output, args.simulate)
    except subprocess.CalledProcessError as error:
        logger.info("Execution time: %s", duration_str(time.time() - start_time))
        logger.error(
            "mmdebstrap failed with exit code %i. See above for details.", error.returncode
        )
        return 1

    if mmdebstrap.get("target") in {None, "-"}:
        logger.info("Build successful and sent uncompressed tarball to standard output.")
    else:
        logger.info("Build successful in '%s'.", mmdebstrap["target"])
    logger.info("Execution time: %s", duration_str(time.time() - start_time))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))  # pragma: no cover
