#!/usr/bin/python3 -B
import argparse
import contextlib
import dataclasses
import errno
import json
import operator
import os
import shutil
import subprocess
import sys
import textwrap
import traceback
from itertools import chain
from tempfile import TemporaryDirectory
from typing import (
    List,
    Dict,
    Iterable,
    Callable,
    Any,
    Tuple,
    Sequence,
    Optional,
    NoReturn,
    Mapping,
    IO,
    Union,
    NamedTuple,
    Literal,
    FrozenSet,
    Set,
    Iterator,
)

from debian.debian_support import DpkgArchTable

from debputy import DEBPUTY_ROOT_DIR, DEBPUTY_PLUGIN_ROOT_DIR, DEBPUTY_DOC_ROOT_DIR
from debputy.exceptions import (
    DebputyRuntimeError,
    DebputySubstitutionError,
    PluginNotFoundError,
    PluginAPIViolationError,
    PluginInitializationError,
    UnhandledOrUnexpectedErrorFromPluginError,
)
from debputy.manifest_parser.declarative_parser import (
    DeclarativeMappingInputParser,
)
from debputy.plugin.api.example_processing import process_discard_rule_example
from debputy.plugin.api.spec import (
    ParserDocumentation,
    undocumented_attr,
    reference_documentation,
    VirtualPath,
)

try:
    from argcomplete import autocomplete
except ImportError:

    def autocomplete(_parser: argparse.ArgumentParser) -> None:
        pass


from debputy.version import __full_version__
from debputy.filesystem_scan import (
    FSRootDir,
    FSROOverlay,
)
from debputy.plugin.api.impl_types import (
    MetadataOrMaintscriptDetector,
    PackagerProvidedFileClassSpec,
    PluginProvidedFeatureSet,
    DebputyPluginMetadata,
    DeclarativeInputParser,
    PluginProvidedManifestVariable,
    PluginProvidedDiscardRule,
    AutomaticDiscardRuleExample,
)
from debputy.plugin.api.impl import (
    load_plugin_features,
    find_json_plugin,
    find_tests_for_plugin,
    find_related_implementation_files_for_plugin,
    parse_json_plugin_desc,
)
from debputy._deb_options_profiles import DebBuildOptionsAndProfiles
from debputy.architecture_support import (
    dpkg_architecture_table,
    DpkgArchitectureBuildProcessValuesTable,
)
from debputy.dh_migration.migration import migrate_from_dh
from debputy.dh_migration.models import AcceptableMigrationIssues
from debputy.packages import parse_source_debian_control, BinaryPackage, SourcePackage
from debputy.debhelper_emulation import dhe_dbgsym_root_dir, dhe_pkgdir

from debputy.deb_packaging_support import (
    setup_control_files,
    usr_local_transformation,
    handle_perl_code,
    detect_systemd_user_service_files,
    fixup_debian_changelog_and_news_file,
    install_upstream_changelog,
    relocate_dwarves_into_dbgsym_packages,
    run_package_processors,
    cross_package_control_files,
)
from debputy.highlevel_manifest import (
    HighLevelManifest,
)
from debputy.highlevel_manifest_parser import YAMLManifestParser
from debputy.intermediate_manifest import (
    TarMember,
    IntermediateManifest,
)
from debputy.substitution import (
    Substitution,
    SubstitutionImpl,
    NULL_SUBSTITUTION,
    VariableContext,
)
from debputy.util import (
    _error,
    compute_output_filename,
    ensure_dir,
    scratch_dir,
    resolve_source_date_epoch,
    _warn,
    ColorizedArgumentParser,
    setup_logging,
    _info,
    escape_shell,
    PKGNAME_REGEX,
    program_name,
    integrated_with_debhelper,
    assume_not_none,
)
from debputy.plugin.api.impl_types import SUPPORTED_DISPATCHABLE_PARSERS


class SharedArgument(NamedTuple):
    """
    Information about an argument shared between a parser and its subparsers
    """

    action: argparse.Action
    args: Tuple[Any, ...]
    kwargs: Dict[str, Any]


class Namespace(argparse.Namespace):
    """
    Hacks around a namespace to allow merging of values set multiple times

    Based on: https://www.enricozini.org/blog/2022/python/sharing-argparse-arguments-with-subcommands/
    """

    def __setattr__(self, name: str, value: Any) -> None:
        arg = self._shared_args.get(name)
        if arg is not None:
            action_type = arg.kwargs.get("action")
            if action_type == "store_true":
                # OR values
                old = getattr(self, name, False)
                super().__setattr__(name, old or value)
            elif action_type == "store_false":
                # AND values
                old = getattr(self, name, True)
                super().__setattr__(name, old and value)
            elif action_type == "append":
                old = getattr(self, name, None)
                if old is None:
                    old = []
                    super().__setattr__(name, old)
                if isinstance(value, list):
                    old.extend(value)
                elif value is not None:
                    old.append(value)
            elif action_type == "store":
                old = getattr(self, name, None)
                if old is None:
                    super().__setattr__(name, value)
                elif old != value and value is not None:
                    raise argparse.ArgumentError(
                        None,
                        f"conflicting values provided for {arg.action.dest!r} ({old!r} and {value!r})",
                    )
            else:
                raise NotImplementedError(
                    f"Action {action_type!r} for {arg.action.dest!r} is not supported"
                )
        else:
            return super().__setattr__(name, value)


class DebputyArgumentParser(ColorizedArgumentParser):
    """
    Hacks around a standard ArgumentParser to allow to have a limited set of
    options both outside and inside subcommands

    Based on: https://www.enricozini.org/blog/2022/python/sharing-argparse-arguments-with-subcommands/
    """

    def __init__(self, *args, **kw) -> None:
        super().__init__(*args, **kw)

        if not hasattr(self, "shared_args"):
            self.shared_args: dict[str, SharedArgument] = {}

        # Add arguments from the shared ones
        for a in self.shared_args.values():
            super().add_argument(*a.args, **a.kwargs)

    def add_argument(self, *args, **kw):
        shared = kw.pop("shared", False)
        res = super().add_argument(*args, **kw)
        if shared:
            action = kw.get("action")
            if action not in ("store", "store_true", "store_false", "append"):
                raise NotImplementedError(
                    f"Action {action!r} for {args!r} is not supported"
                )
            # Take note of the argument if it was marked as shared
            self.shared_args[res.dest] = SharedArgument(res, args, kw)
        return res

    def add_subparsers(self, *args, **kw):
        if "parser_class" not in kw:
            kw["parser_class"] = type(
                "ArgumentParser",
                (self.__class__,),
                {"shared_args": dict(self.shared_args)},
            )
        return super().add_subparsers(*args, **kw)

    def parse_args(self, *args, **kw):
        if "namespace" not in kw:
            # Use a subclass to pass the special action list without making it
            # appear as an argument
            kw["namespace"] = type(
                "Namespace", (Namespace,), {"_shared_args": self.shared_args}
            )()
        return super().parse_args(*args, **kw)


def _add_common_args(parser) -> None:
    parser.add_argument(
        "--package-manifest",
        dest="package_manifest",
        action="store",
        default=None,
        help="Manifest of file metadata that is not visible in the file-system",
        shared=True,
    )

    parser.add_argument(
        "-d",
        "--debug",
        dest="debug_mode",
        action="store_true",
        default=False,
        help="Enable debug logging and raw stack traces on errors",
        shared=True,
    )

    parser.add_argument(
        "--no-pager",
        dest="pager",
        action="store_false",
        default=True,
        help="For subcommands that can use a pager, disable the use of pager",
        shared=True,
    )

    parser.add_argument(
        "--plugin",
        dest="required_plugins",
        action="append",
        type=str,
        default=[],
        help="Request the plugin to be loaded. Can be used multiple time."
        " Ignored for some commands (such as autopkgtest-test-runner)",
        shared=True,
    )


def _add_packages_args(parser) -> None:
    parser.add_argument(
        "-p",
        "--package",
        dest="packages",
        action="append",
        type=str,
        default=[],
        help="The package(s) to act on.  Affects default permission normalization rules",
    )


class CommandContext:
    def __init__(
        self,
        parsed_args: argparse.Namespace,
        command: "Command",
        plugin_search_dirs: Sequence[str],
    ) -> None:
        self.parsed_args = parsed_args
        self.command = command
        self.plugin_search_dirs = plugin_search_dirs
        self._debputy_plugin_feature_set: PluginProvidedFeatureSet = (
            PluginProvidedFeatureSet()
        )
        self._debian_dir = FSROOverlay.create_root_dir("debian", "debian")
        self._mtime: Optional[int] = None
        self._source_variables: Optional[Mapping[str, str]] = None
        self._substitution: Optional[Substitution] = None
        self._plugins_loaded = False
        self._dctrl_data: Optional[
            Tuple[
                DpkgArchitectureBuildProcessValuesTable,
                DpkgArchTable,
                DebBuildOptionsAndProfiles,
                "SourcePackage",
                Mapping[str, "BinaryPackage"],
            ]
        ] = None

    @property
    def debian_dir(self) -> VirtualPath:
        return self._debian_dir

    @property
    def mtime(self) -> int:
        if self._mtime is None:
            self._mtime = resolve_source_date_epoch(None)
        return self._mtime

    def source_package(self) -> SourcePackage:
        _a, _b, _c, source, _d = self._parse_dctrl()
        return source

    def binary_packages(self) -> Mapping[str, "BinaryPackage"]:
        _a, _b, _c, _source, binary_package_table = self._parse_dctrl()
        return binary_package_table

    @property
    def deb_build_options_and_profiles(self) -> "DebBuildOptionsAndProfiles":
        _a, _b, deb_build_options_and_profiles, _c, _d = self._parse_dctrl()
        return deb_build_options_and_profiles

    @property
    def deb_build_options(self) -> Mapping[str, Optional[str]]:
        return self.deb_build_options_and_profiles.deb_build_options

    def load_plugins(self) -> PluginProvidedFeatureSet:
        if not self._plugins_loaded:
            requested_plugins = None
            required_plugins = set(getattr(self.parsed_args, "required_plugins") or [])
            if self.command.requested_plugins_only:
                requested_plugins = self._resolve_requested_plugins()
            load_plugin_features(
                self.plugin_search_dirs,
                self.substitution,
                requested_plugins_only=requested_plugins,
                required_plugins=required_plugins,
                plugin_feature_set=self._debputy_plugin_feature_set,
            )
            self._plugins_loaded = True
        return self._debputy_plugin_feature_set

    @staticmethod
    def _plugin_from_dependency_field(dep_field: str) -> Iterable[str]:
        package_prefix = "debputy-plugin-"
        for dep_clause in (d.strip() for d in dep_field.split(",")):
            dep = dep_clause.split("|")[0].strip()
            if not dep.startswith(package_prefix):
                continue
            m = PKGNAME_REGEX.search(dep)
            assert m
            package_name = m.group(0)
            plugin_name = package_name[len(package_prefix) :]
            yield plugin_name

    def _resolve_requested_plugins(self) -> Sequence[str]:
        _a, _b, _c, source_package, _d = self._parse_dctrl()
        bd = source_package.package_fields.get("Build-Depends", "")
        plugins = list(self._plugin_from_dependency_field(bd))
        for field_name in ("Build-Depends-Arch", "Build-Depends-Indep"):
            f = source_package.package_fields.get(field_name)
            if not f:
                continue
            for plugin in self._plugin_from_dependency_field(f):
                raise DebputyRuntimeError(
                    f"Cannot load plugins via {field_name}:"
                    f" Please move debputy-plugin-{plugin} dependency to Build-Depends."
                )

        return plugins

    @property
    def substitution(self) -> Substitution:
        if self._substitution is None:
            self._substitution = self.command.create_substitution(
                self.parsed_args,
                self._debputy_plugin_feature_set,
                self.debian_dir,
            )
        return self._substitution

    def _parse_dctrl(
        self,
    ) -> Tuple[
        DpkgArchitectureBuildProcessValuesTable,
        DpkgArchTable,
        DebBuildOptionsAndProfiles,
        "SourcePackage",
        Mapping[str, "BinaryPackage"],
    ]:
        if self._dctrl_data is None:
            build_env = DebBuildOptionsAndProfiles.instance()
            dpkg_architecture_variables = dpkg_architecture_table()
            dpkg_arch_query_table = DpkgArchTable.load_arch_table()

            packages: Union[Set[str], FrozenSet[str]] = frozenset()
            if hasattr(self.parsed_args, "packages"):
                packages = self.parsed_args.packages

            try:
                debian_control = self.debian_dir.get("control")
                if debian_control is None:
                    raise FileNotFoundError(
                        errno.ENOENT,
                        os.strerror(errno.ENOENT),
                        os.path.join(self.debian_dir.fs_path, "control"),
                    )
                source_package, binary_packages = parse_source_debian_control(
                    debian_control,
                    packages,  # -p/--package
                    set(),  # -N/--no-package
                    False,  # -i
                    False,  # -a
                    dpkg_architecture_variables=dpkg_architecture_variables,
                    dpkg_arch_query_table=dpkg_arch_query_table,
                    build_env=build_env,
                )
                assert packages <= binary_packages.keys()
            except FileNotFoundError:
                _error(
                    "This subcommand must be run from a source package root; expecting debian/control to exist."
                )

            self._dctrl_data = (
                dpkg_architecture_variables,
                dpkg_arch_query_table,
                build_env,
                source_package,
                binary_packages,
            )

        return self._dctrl_data

    def parse_manifest(
        self, *, manifest_path: Optional[str] = None
    ) -> HighLevelManifest:
        substitution = self.substitution
        manifest_required = False

        (
            dpkg_architecture_variables,
            dpkg_arch_query_table,
            build_env,
            _,
            binary_packages,
        ) = self._parse_dctrl()

        if self.parsed_args.package_manifest is not None:
            manifest_path = self.parsed_args.package_manifest
            manifest_required = True
        if manifest_path is None:
            manifest_path = os.path.join(self.debian_dir.fs_path, "debputy.manifest")
        parser = YAMLManifestParser(
            manifest_path,
            binary_packages,
            substitution,
            dpkg_architecture_variables,
            dpkg_arch_query_table,
            build_env,
            self.load_plugins(),
            debian_dir=self.debian_dir,
        )

        os.environ["SOURCE_DATE_EPOCH"] = self._substitution.substitute(
            "{{SOURCE_DATE_EPOCH}}",
            "Internal resolution",
        )
        if os.path.isfile(manifest_path):
            return parser.parse_manifest()
        if manifest_required:
            _error(f'The path "{manifest_path}" is not a file!')
        return parser.build_manifest()


def parse_args() -> argparse.Namespace:
    description = textwrap.dedent(
        """\
    The `debputy` program is a manifest-based Debian packaging tool.

    It is used as a part of compiling a source package and transforming it into one or
    more binary (.deb) packages.
    """
    )

    parser = DebputyArgumentParser(
        description=description,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        allow_abbrev=False,
        prog=program_name(),
    )

    parser.add_argument("--version", action="version", version=__full_version__)

    _add_common_args(parser)
    subparsers = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)

    internal_commands_subparser = subparsers.add_parser(
        "internal-command",
        help="Commands used for internal purposes. These are implementation details and subject to change",
    ).add_subparsers(dest="internal_command", metavar="COMMAND", required=True)

    generate_deb_package = internal_commands_subparser.add_parser(
        "dh-integration-generate-debs",
        help="[Internal command] Generate .deb/.udebs packages from debian/<pkg> (Not stable API)",
    )
    _add_packages_args(generate_deb_package)
    generate_deb_package.add_argument(
        "output",
        metavar="output",
        help="Where to place the resulting packages.  Should be a directory",
    )
    # Added for "help only" - you cannot trigger this option in practice
    generate_deb_package.add_argument(
        "--", metavar="UPSTREAM_ARGS", action="extend", nargs="+", dest="unused"
    )
    run_tests_for_plugin = internal_commands_subparser.add_parser(
        "run-tests-for-plugin",
        help="[Internal command] Run tests for a plugin",
    )
    run_tests_for_plugin.add_argument("target_plugin", metavar="PLUGIN", action="store")
    run_tests_for_plugin.add_argument(
        "--require-tests",
        dest="require_tests",
        default=True,
        action=argparse.BooleanOptionalAction,
    )
    install_plugin = internal_commands_subparser.add_parser(
        "install-plugin",
        help="[Internal command] Install a plugin and related files",
    )
    install_plugin.add_argument("target_plugin", metavar="PLUGIN", action="store")
    install_plugin.add_argument(
        "--dest-dir",
        dest="dest_dir",
        default="",
        action="store",
    )
    dh_integration_install_plugin_parser = internal_commands_subparser.add_parser(
        "dh-integration-install-plugin",
        help="[Internal command] Install a plugin and related files via debhelper integration",
    )
    _add_packages_args(dh_integration_install_plugin_parser)
    subparsers.add_parser(
        "autopkgtest-test-runner",
        help="Detect tests in the debian dir and run them against installed plugins",
    )

    subparsers.add_parser(
        "check-manifest",
        help="Check the manifest for obvious errors, but do not run anything",
    )

    migrate_from_debhelper = subparsers.add_parser(
        "migrate-from-dh", help='Generate/update manifest from a "dh $@" using package'
    )
    migrate_from_debhelper.add_argument(
        "--acceptable-migration-issues",
        dest="acceptable_migration_issues",
        action="append",
        type=str,
        default=[],
        help="Continue the migration even if this/these issues are detected."
        " Can be set to ALL (in all upper-case) to accept all issues",
    )

    migrate_from_debhelper.add_argument(
        "--no-act",
        "--no-apply-changes",
        dest="destructive",
        action="store_false",
        default=None,
        help="Do not perform changes.  Existing manifest will not be overridden",
    )
    migrate_from_debhelper.add_argument(
        "--apply-changes",
        dest="destructive",
        action="store_true",
        default=None,
        help="Perform changes.  The debian/debputy.manifest will updated in place if exists",
    )

    plugin_parser = subparsers.add_parser(
        "plugin", help="Interact with debputy plugins"
    )
    plugin_subparser = plugin_parser.add_subparsers(
        dest="plugin_subcommand",
        required=False,
        metavar="command",
    )
    plugin_list_parser = plugin_subparser.add_parser(
        "list",
        help="List plugins or things provided by plugins (unstable format)."
        " Pass `--help` *after* `list` get a topic listing",
    )
    plugin_list_subparser = plugin_list_parser.add_subparsers(
        dest="plugin_list_topic",
        required=False,
        metavar="topic",
    )
    plugin_list_subparser.add_parser(
        "plugins", help="List known plugins with their versions"
    )
    plugin_list_subparser.add_parser(
        "packager-provided-files",
        aliases=["ppf", "p-p-f"],
        help="List packager provided files (debian/pkg.foo)",
    )
    plugin_list_subparser.add_parser(
        "plugable-manifest-rules",
        aliases=["pmr", "p-m-r"],
        help="Plugable manifest rules (such as install rules)",
    )
    plugin_list_subparser.add_parser(
        "metadata-detectors",
        help="List metadata detectors",
    )
    plugin_list_manifest_variables_parser = plugin_list_subparser.add_parser(
        "manifest-variables",
        help="List plugin provided manifest variables (such as `{{path:FOO}}`)",
    )
    plugin_list_manifest_variables_parser.add_argument(
        "--show-special-case-variables",
        dest="show_special_use_variables",
        default=False,
        action="store_true",
        help="Show variables that are only used in special / niche cases",
    )
    plugin_list_manifest_variables_parser.add_argument(
        "--show-token-variables",
        dest="show_token_variables",
        default=False,
        action="store_true",
        help="Show token (syntactical) variables like {{token:TAB}}",
    )
    plugin_list_manifest_variables_parser.add_argument(
        "--show-all-variables",
        dest="show_all_variables",
        default=False,
        action="store_true",
        help="Show all variables regardless of type/kind (overrules other filter settings)",
    )
    plugin_list_subparser.add_parser(
        "automatic-discard-rules",
        aliases=["a-d-r"],
        help="List automatic discard rules",
    )

    plugin_show_parser = plugin_subparser.add_parser(
        "show",
        help="Show details about a plugin or things provided by plugins (unstable format)."
        " Pass `--help` *after* `show` get a topic listing",
    )
    plugin_show_subparser = plugin_show_parser.add_subparsers(
        dest="plugin_show_topic",
        required=False,
        metavar="topic",
    )
    plugin_show_pmr_parser = plugin_show_subparser.add_parser(
        "plugable-manifest-rules",
        aliases=["pmr", "p-m-r"],
        help="Plugable manifest rules (such as install rules)",
    )
    plugin_show_pmr_parser.add_argument(
        "pmr_rule_name",
        metavar="rule-name",
        help="Name of the rule (such as `install`) to display details about",
    )
    plugin_show_manifest_variable_parser = plugin_show_subparser.add_parser(
        "manifest-variables",
        help="Plugin provided manifest variables (such as `{{path:FOO}}`)",
    )
    plugin_show_manifest_variable_parser.add_argument(
        "manifest_variable",
        metavar="manifest-variable",
        help="Name of the variable (such as `path:FOO` or `{{path:FOO}}`) to display details about",
    )
    plugin_show_adr_parser = plugin_show_subparser.add_parser(
        "automatic-discard-rules",
        aliases=["a-d-r"],
        help="Automatic discard rules",
    )
    plugin_show_adr_parser.add_argument(
        "discard_rule",
        metavar="automatic-discard-rule",
        help="Name of the automatic discard rule (such as `backup-files`)",
    )

    autocomplete(parser)

    argv = sys.argv
    try:
        i = argv.index("--")
        upstream_args = argv[i + 1 :]
        argv = argv[:i]
    except (IndexError, ValueError):
        upstream_args = []
    parsed_args: argparse.Namespace = parser.parse_args(argv[1:])

    if parsed_args.command == "internal-command":
        setattr(parsed_args, "command", parsed_args.internal_command)

    if (
        parsed_args.command == "plugin"
        and getattr(parsed_args, "plugin_subcommand") is None
    ):
        plugin_parser.parse_args(["--help"])
        _error("Missing plugin command", prog=parser.prog)

    setattr(parsed_args, "upstream_args", upstream_args)
    if hasattr(parsed_args, "packages"):
        setattr(parsed_args, "packages", frozenset(parsed_args.packages))

    return parsed_args


def _serialize_intermediate_manifest(members: Iterable[TarMember]) -> str:
    serial_format = [m.to_manifest() for m in members]
    return json.dumps(serial_format)


_RRR_DEB_ASSEMBLY_KEYWORD = "debputy/deb-assembly"
_WARNED_ABOUT_FALLBACK_ASSEMBLY = False


def _determine_assembly_method(
    package: str,
    intermediate_manifest: IntermediateManifest,
) -> Tuple[bool, bool, List[str]]:
    paths_needing_root = (
        tm for tm in intermediate_manifest if tm.owner != "root" or tm.group != "root"
    )
    matched_path = next(paths_needing_root, None)
    if matched_path is None:
        return False, False, []
    rrr = os.environ.get("DEB_RULES_REQUIRES_ROOT")
    if rrr and _RRR_DEB_ASSEMBLY_KEYWORD in rrr:
        gain_root_cmd = os.environ.get("DEB_GAIN_ROOT_CMD")
        if not gain_root_cmd:
            _error(
                "DEB_RULES_REQUIRES_ROOT contains a debputy keyword but DEB_GAIN_ROOT_CMD does not contain a "
                '"gain root" command'
            )
        return True, False, gain_root_cmd.split()
    if rrr == "no":
        global _WARNED_ABOUT_FALLBACK_ASSEMBLY
        if not _WARNED_ABOUT_FALLBACK_ASSEMBLY:
            _warn(
                'Using internal assembly method due to "Rules-Requires-Root" being "no" and dpkg-deb assembly would'
                " require (fake)root for binary packages that needs it."
            )
            _WARNED_ABOUT_FALLBACK_ASSEMBLY = True
        return True, True, []

    _error(
        f'Due to the path "{matched_path.member_path}" in {package}, the package assembly will require (fake)root.'
        " However, this command is not run as root nor was debputy requested to use a root command via"
        f' "Rules-Requires-Root".  Please consider adding "{_RRR_DEB_ASSEMBLY_KEYWORD}" to "Rules-Requires-Root"'
        " in debian/control. Though, due to #1036865, you may have to revert to"
        ' "Rules-Requires-Root: binary-targets" depending on which version of dpkg you need to support.'
        ' Alternatively, you can set "Rules-Requires-Root: no" in debian/control and debputy will assemble'
        " the package anyway. In this case, dpkg-deb will not be used, but the output should be bit-for-bit"
        " compatible with what debputy would have produced with dpkg-deb (and root/fakeroot)."
    )


def _check_manifest(context: CommandContext) -> None:
    context.parse_manifest()
    _info("No errors detected.")


def _install_plugin_from_plugin_metadata(
    plugin_metadata: DebputyPluginMetadata,
    dest_dir: str,
) -> None:
    related_files = find_related_implementation_files_for_plugin(plugin_metadata)
    install_dir = os.path.join(
        f"{dest_dir}/{DEBPUTY_PLUGIN_ROOT_DIR}".replace("//", "/"),
        "debputy",
        "plugins",
    )

    os.umask(0o022)
    os.makedirs(install_dir, exist_ok=True)
    cmd = ["cp", "--reflink=auto", "-t", install_dir]
    cmd.extend(related_files)
    cmd.append(plugin_metadata.plugin_path)
    _info(f"   {escape_shell(*cmd)}")
    subprocess.check_call(
        cmd,
        stdin=subprocess.DEVNULL,
    )


def _install_plugin(context: CommandContext) -> None:
    target_plugin = context.parsed_args.target_plugin
    if not os.path.isfile(target_plugin):
        _error(
            f'The value "{target_plugin}" must be a file. It should be the JSON descriptor of'
            f" the plugin."
        )
    plugin_metadata = parse_json_plugin_desc(target_plugin)
    _install_plugin_from_plugin_metadata(
        plugin_metadata,
        context.parsed_args.dest_dir,
    )


_DH_PLUGIN_PKG_DIR = "debputy-plugins"


def _find_plugins_and_tests_in_source_package(
    context: CommandContext,
) -> Tuple[bool, List[Tuple[DebputyPluginMetadata, str]], List[str]]:
    debian_dir = context.debian_dir
    binary_packages = context.binary_packages()
    installs = []
    all_tests = []
    had_plugin_dir = False
    for binary_package in binary_packages.values():
        if not binary_package.should_be_acted_on:
            continue
        debputy_plugins_dir = dhe_pkgdir(debian_dir, binary_package, _DH_PLUGIN_PKG_DIR)
        if debputy_plugins_dir is None:
            continue
        if not debputy_plugins_dir.is_dir:
            continue
        had_plugin_dir = True
        dest_dir = os.path.join("debian", binary_package.name)
        for path in debputy_plugins_dir.iterdir:
            if not path.is_file or not path.name.endswith((".json", ".json.in")):
                continue
            plugin_metadata = parse_json_plugin_desc(path.path)
            if (
                plugin_metadata.plugin_name.startswith("debputy-")
                or plugin_metadata.plugin_name == "debputy"
            ):
                _error(
                    f"The plugin name {plugin_metadata.plugin_name} is reserved by debputy. Please rename"
                    " the plugin to something else."
                )
            installs.append((plugin_metadata, dest_dir))
            all_tests.extend(find_tests_for_plugin(plugin_metadata))
    return had_plugin_dir, installs, all_tests


def _autodep8_test_runner(context: CommandContext) -> None:
    ad_hoc_run = "AUTOPKGTEST_TMP" not in os.environ
    _a, _b, all_tests = _find_plugins_and_tests_in_source_package(context)

    source_package = context.source_package()
    explicit_test = (
        "autopkgtest-pkg-debputy"
        in source_package.package_fields.get("Testsuite", "").split()
    )

    if not shutil.which("py.test"):
        if ad_hoc_run:
            extra_context = ""
            if not explicit_test:
                extra_context = (
                    " Remember to add python3-pytest to the Depends field of your autopkgtests field if"
                    " you are writing your own test case for autopkgtest. Note you can also add"
                    ' "autopkgtest-pkg-debputy" to the "Testsuite" field in debian/control if you'
                    " want the test case autogenerated."
                )
            _error(
                f"Please install the py.test command (apt-get install python3-pytest).{extra_context}"
            )
        _error("Please add python3-pytest to the Depends field of your autopkgtests.")

    if not all_tests:
        extra_context = ""
        if explicit_test:
            extra_context = (
                " If the package no longer provides any plugin or tests, please remove the "
                ' "autopkgtest-pkg-debputy" test from the "Testsuite" in debian/control'
            )
        _error(
            "There are no tests to be run. The autodep8 feature should not have generated a test for"
            f" this case.{extra_context}"
        )

    if _run_tests(
        context,
        all_tests,
        test_plugin_location="installed",
        on_error_return=False,
    ):
        return
    extra_context = ""
    if not ad_hoc_run:
        extra_context = (
            ' These tests can be run manually via the "debputy autopkgtest-test-runner" command without any'
            ' autopkgtest layering. To do so, install "dh-debpputy python3-pytest" plus the packages'
            " being tested and relevant extra dependencies required for the tests. Then open a shell in"
            f' the unpacked source directory of {source_package.name} and run "debputy autopkgtest-test-runner"'
        )
    _error(f"The tests were not successful.{extra_context}")


def _dh_integration_install_plugin(context: CommandContext) -> None:
    had_plugin_dir, installs, all_tests = _find_plugins_and_tests_in_source_package(
        context
    )

    if not installs:
        if had_plugin_dir:
            _warn(
                "There were plugin dirs, but no plugins were detected inside them. Please ensure that "
                f" the plugin dirs (debian/<pkg>.{_DH_PLUGIN_PKG_DIR} or debian/{_DH_PLUGIN_PKG_DIR})"
                f" contains a .json or .json.in file, or remove them (plus drop the"
                f" dh-sequence-installdebputy build dependency) if they are no longer useful."
            )
        else:
            _info(
                f"No plugin directories detected (debian/<pkg>.{_DH_PLUGIN_PKG_DIR} or debian/{_DH_PLUGIN_PKG_DIR})"
            )
        return

    if all_tests:
        if "nocheck" in context.deb_build_options_and_profiles.deb_build_options:
            _info("Skipping tests due to DEB_BUILD_OPTIONS=nocheck")
        elif not shutil.which("py.test"):
            _warn("Skipping tests because py.test is not available")
        else:
            _run_tests(context, all_tests)
    else:
        _info("No tests detected for any of the plugins. Skipping running tests.")

    for plugin_metadata, dest_dir in installs:
        _info(f"Installing plugin {plugin_metadata.plugin_name} into {dest_dir}")
        _install_plugin_from_plugin_metadata(plugin_metadata, dest_dir)


def _run_tests(
    context: CommandContext,
    test_paths: List[str],
    *,
    cwd: Optional[str] = None,
    tmpdir_root: Optional[str] = None,
    test_plugin_location: Literal["installed", "uninstalled"] = "uninstalled",
    on_error_return: Optional[Any] = None,
    on_success_return: Optional[Any] = True,
) -> Any:
    env = dict(os.environ)
    env["DEBPUTY_TEST_PLUGIN_LOCATION"] = test_plugin_location
    if "PYTHONPATH" in env:
        env["PYTHONPATH"] = f"{DEBPUTY_ROOT_DIR}:{env['PYTHONPATH']}"
    else:
        env["PYTHONPATH"] = str(DEBPUTY_ROOT_DIR)

    env["PYTHONDONTWRITEBYTECODE"] = "1"
    _info("Running debputy plugin tests.")
    _info("")
    _info("Environment settings:")
    for envname in [
        "PYTHONPATH",
        "PYTHONDONTWRITEBYTECODE",
        "DEBPUTY_TEST_PLUGIN_LOCATION",
    ]:
        _info(f"    {envname}={env[envname]}")

    with TemporaryDirectory(dir=tmpdir_root) as tmpdir:
        cmd = [
            "py.test",
            "-vvvvv" if context.parsed_args.debug_mode else "-v",
            "--config-file=/dev/null",
            f"--rootdir={cwd if cwd is not None else '.'}",
            "-o",
            f"cache_dir={tmpdir}",
        ]
        cmd.extend(test_paths)

        _info(f"Test Command: {escape_shell(*cmd)}")
        try:
            subprocess.check_call(
                cmd,
                stdin=subprocess.DEVNULL,
                env=env,
                cwd=cwd,
            )
        except subprocess.CalledProcessError:
            if on_error_return is None:
                _error("The tests were not successful.")
            return on_error_return
    return True


def _run_tests_for_plugin(context: CommandContext) -> None:
    target_plugin = context.parsed_args.target_plugin
    if not os.path.isfile(target_plugin):
        _error(
            f'The value "{target_plugin}" must be a file. It should be the JSON descriptor of'
            f" the plugin."
        )
    try:
        plugin_metadata = find_json_plugin(
            context.plugin_search_dirs,
            target_plugin,
        )
    except PluginNotFoundError as e:
        _error(e.message)

    tests = find_tests_for_plugin(plugin_metadata)

    if not tests:
        if context.parsed_args.require_tests:
            plugin_name = plugin_metadata.plugin_name
            plugin_dir = os.path.dirname(plugin_metadata.plugin_path)

            _error(
                f"Cannot find any tests for {plugin_name}: Expected them to be in "
                f' "{plugin_dir}". Use --no-require-tests to consider missing tests'
                " a non-error."
            )
        _info(
            f"No tests found for {plugin_metadata.plugin_name}. Use --require-tests to turn"
            " this into an error."
        )
        return

    if not shutil.which("py.test"):
        _error(
            f"Cannot run the tests for {plugin_metadata.plugin_name}: This feature requires py.test"
            f" (apt-get install python3-pytest)"
        )
    _run_tests(context, tests, cwd="/")


def _dh_integration_generate_debs(context: CommandContext) -> None:
    integrated_with_debhelper()
    manifest = context.parse_manifest()
    parsed_args = context.parsed_args
    mtime = context.mtime

    output_path = parsed_args.output
    upstream_args = parsed_args.upstream_args

    package_data_table = manifest.perform_installations()
    source_fs = FSROOverlay.create_root_dir(".", ".")
    deb_materialize = str(DEBPUTY_ROOT_DIR / "deb_materialization.py")
    source_version = manifest.source_version()
    is_native = "-" not in source_version

    for dctrl_bin in manifest.active_packages:
        package = dctrl_bin.name
        dbgsym_package_name = f"{package}-dbgsym"
        dctrl_data = package_data_table[package]
        fs_root = dctrl_data.fs_root
        package_metadata_context = dctrl_data.package_metadata_context

        assert dctrl_bin.should_be_acted_on

        detect_systemd_user_service_files(dctrl_bin, fs_root)
        usr_local_transformation(dctrl_bin, fs_root)
        handle_perl_code(
            dctrl_bin,
            manifest.dpkg_architecture_variables,
            fs_root,
            dctrl_data.substvars,
        )
        if "nostrip" not in manifest.build_env.deb_build_options:
            dbgsym_ids = relocate_dwarves_into_dbgsym_packages(
                dctrl_bin,
                fs_root,
                dctrl_data.dbgsym_info.dbgsym_fs_root,
            )
            dctrl_data.dbgsym_info.dbgsym_ids = dbgsym_ids

        if (
            dbgsym_package_name in package_data_table
            or "noautodbgsym" in manifest.build_env.deb_build_options
            or "noddebs" in manifest.build_env.deb_build_options
        ):
            # Discard the dbgsym part if it conflicts with a real package, or
            # we were asked not to build it.
            dctrl_data.dbgsym_info.dbgsym_fs_root = FSRootDir()
            dctrl_data.dbgsym_info.dbgsym_ids.clear()

        fixup_debian_changelog_and_news_file(
            dctrl_bin,
            fs_root,
            is_native,
            manifest.build_env,
        )
        if not is_native:
            install_upstream_changelog(
                dctrl_bin,
                fs_root,
                source_fs,
            )
        run_package_processors(manifest, package_metadata_context, fs_root)

    cross_package_control_files(package_data_table, manifest)

    for dctrl_bin in manifest.active_packages:
        package = dctrl_bin.name
        dbgsym_package_name = f"{package}-dbgsym"
        dctrl_data = package_data_table[package]
        fs_root = dctrl_data.fs_root
        control_output_dir = assume_not_none(dctrl_data.control_output_dir)
        package_metadata_context = dctrl_data.package_metadata_context
        dbgsym_fs_root = dctrl_data.dbgsym_info.dbgsym_fs_root
        dbgsym_ids = dctrl_data.dbgsym_info.dbgsym_ids
        intermediate_manifest = manifest.finalize_data_tar_contents(
            package, fs_root, mtime
        )

        setup_control_files(
            dctrl_data, manifest, dbgsym_fs_root, dbgsym_ids, package_metadata_context
        )

        needs_root, use_fallback_assembly, gain_root_cmd = _determine_assembly_method(
            package, intermediate_manifest
        )

        if not dctrl_bin.is_udeb and any(
            f for f in dbgsym_fs_root.all_paths() if f.is_file
        ):
            # We never built udebs due to #797391. We currently do not generate a control
            # file for it either for the same reason.
            dbgsym_root = dhe_dbgsym_root_dir(dctrl_bin)
            if not os.path.isdir(output_path):
                _error(
                    "Cannot produce a dbgsym package when output path is not a directory."
                )
            dbgsym_intermediate_manifest = manifest.finalize_data_tar_contents(
                dbgsym_package_name,
                dbgsym_fs_root,
                mtime,
            )
            assemble_deb(
                dbgsym_package_name,
                deb_materialize,
                dbgsym_intermediate_manifest,
                mtime,
                os.path.join(dbgsym_root, "DEBIAN"),
                output_path,
                upstream_args,
                is_udeb=dctrl_bin.is_udeb,  # Review this if we ever do dbgsyms for udebs
                use_fallback_assembly=False,
                needs_root=False,
            )

        assemble_deb(
            package,
            deb_materialize,
            intermediate_manifest,
            mtime,
            control_output_dir,
            output_path,
            upstream_args,
            is_udeb=dctrl_bin.is_udeb,
            use_fallback_assembly=use_fallback_assembly,
            needs_root=needs_root,
            gain_root_cmd=gain_root_cmd,
        )


def assemble_deb(
    package: str,
    deb_materialize_cmd: str,
    intermediate_manifest: IntermediateManifest,
    mtime: int,
    control_output_dir: str,
    output_path: str,
    upstream_args: Optional[List[str]],
    is_udeb: bool = False,
    use_fallback_assembly: bool = False,
    needs_root: bool = False,
    gain_root_cmd: Optional[Sequence[str]] = None,
) -> None:
    scratch_root_dir = scratch_dir()
    materialization_dir = os.path.join(
        scratch_root_dir, "materialization-dirs", package
    )
    ensure_dir(os.path.dirname(materialization_dir))
    materialize_cmd: List[str] = []
    assert not use_fallback_assembly or not gain_root_cmd
    if needs_root and gain_root_cmd:
        # Only use the gain_root_cmd if we absolutely need it.
        # Note that gain_root_cmd will be empty unless R³ is set to the relevant keyword
        # that would make us use targeted promotion. Therefore, we do not need to check other
        # conditions than the package needing root. (R³: binary-targets implies `needs_root=True`
        # without a gain_root_cmd)
        materialize_cmd.extend(gain_root_cmd)
    materialize_cmd.extend(
        [
            deb_materialize_cmd,
            "materialize-deb",
            "--intermediate-package-manifest",
            "-",
            "--may-move-control-files",
            "--may-move-data-files",
            "--source-date-epoch",
            str(mtime),
            "--discard-existing-output",
            control_output_dir,
            materialization_dir,
        ]
    )
    output = output_path
    if is_udeb:
        materialize_cmd.append("--udeb")
        output = os.path.join(
            output_path, compute_output_filename(control_output_dir, True)
        )

    assembly_method = "debputy" if needs_root and use_fallback_assembly else "dpkg-deb"
    combined_materialization_and_assembly = not needs_root
    if combined_materialization_and_assembly:
        materialize_cmd.extend(
            ["--build-method", assembly_method, "--assembled-deb-output", output]
        )

    if upstream_args:
        materialize_cmd.append("--")
        materialize_cmd.extend(upstream_args)

    if combined_materialization_and_assembly:
        print(
            f"Materializing and assembling {package} via: {escape_shell(*materialize_cmd)}"
        )
    else:
        print(f"Materializing {package} via: {escape_shell(*materialize_cmd)}")
    proc = subprocess.Popen(materialize_cmd, stdin=subprocess.PIPE)
    proc.communicate(
        _serialize_intermediate_manifest(intermediate_manifest).encode("utf-8")
    )
    if proc.returncode != 0:
        _error(f"{escape_shell(deb_materialize_cmd)} exited with a non-zero exit code!")

    if not combined_materialization_and_assembly:
        build_materialization = [
            deb_materialize_cmd,
            "build-materialized-deb",
            materialization_dir,
            assembly_method,
            "--output",
            output,
        ]
        print(f"Assembling {package} via: {escape_shell(*build_materialization)}")
        try:
            subprocess.check_call(build_materialization)
        except subprocess.CalledProcessError as e:
            exit_code = f" with exit code {e.returncode}" if e.returncode else ""
            _error(
                f"Assembly command for {package} failed{exit_code}. Please review the output of the command"
                f" for more details on the problem."
            )


def _migrate_from_dh(context: CommandContext) -> None:
    parsed_args = context.parsed_args
    manifest = context.parse_manifest()
    acceptable_migration_issues = AcceptableMigrationIssues(
        frozenset(
            i for x in parsed_args.acceptable_migration_issues for i in x.split(",")
        )
    )
    migrate_from_dh(
        manifest,
        acceptable_migration_issues,
        parsed_args.destructive,
        lambda p: context.parse_manifest(manifest_path=p),
    )


def _pager() -> Optional[str]:
    pager = os.environ.get("DEBPUTY_PAGER")
    if pager is None:
        pager = os.environ.get("PAGER")
    if pager is None and shutil.which("less") is not None:
        pager = "less"
    return pager


@contextlib.contextmanager
def _stream_to_pager(parsed_args: argparse.Namespace) -> Iterator[IO[str]]:
    if not parsed_args.pager or not sys.stdout.isatty():
        yield sys.stdout
        return

    pager = _pager()
    if pager is None:
        yield sys.stdout
        return

    env: Mapping[str, str] = os.environ
    if "LESS" not in env:
        env_copy = dict(os.environ)
        env_copy["LESS"] = "-FRSXMQ"
        env = env_copy

    cmd = subprocess.Popen(
        pager,
        stdin=subprocess.PIPE,
        encoding="utf-8",
        env=env,
    )
    stdin = assume_not_none(cmd.stdin)
    try:
        yield stdin
    except Exception:
        stdin.close()
        cmd.kill()
        cmd.wait()
        raise
    stdin.close()
    cmd.wait()


def _print_list_table(
    fd: IO[str],
    headers: Sequence[Union[str, Tuple[str, str]]],
    rows: Sequence[Sequence[str]],
) -> None:
    if rows:
        if any(len(r) != len(rows[0]) for r in rows):
            raise ValueError(
                "Unbalanced table: All rows must have the same column count"
            )
        if len(rows[0]) != len(headers):
            raise ValueError(
                "Unbalanced table: header list does not agree with row list on number of columns"
            )

    if not headers:
        raise ValueError("No headers provided!?")

    cadjust = {}
    header_names = []
    for c in headers:
        if isinstance(c, str):
            header_names.append(c)
        else:
            cname, adjust = c
            header_names.append(cname)
            cadjust[cname] = adjust

    column_lengths = [
        max((len(h), max(len(r[i]) for r in rows))) for i, h in enumerate(header_names)
    ]
    # divider => "+---+---+-...-+"
    divider = "+-" + "-+-".join("-" * x for x in column_lengths) + "-+"
    # row_format => '| {:<10} | {:<8} | ... |' where the numbers are the column lengths
    row_format_inner = " | ".join(
        f"{{:{cadjust.get(cn, '<')}{x}}}" for cn, x in zip(header_names, column_lengths)
    )
    row_format = f"| {row_format_inner} |"

    print(divider, file=fd)
    print(row_format.format(*header_names), file=fd)
    print(divider, file=fd)
    for row in rows:
        print(row_format.format(*row), file=fd)
    print(divider, file=fd)


def _plugin_cmd_list_plugins(context: CommandContext) -> None:
    plugin_metadata_entries = context.load_plugins().plugin_data.values()
    with _stream_to_pager(context.parsed_args) as fd:
        _print_list_table(
            fd,
            ["Plugin Name", "Plugin Path"],
            [(p.plugin_name, p.plugin_path) for p in plugin_metadata_entries],
        )


def _path(path: str) -> str:
    if path.startswith("./"):
        return path[1:]
    return path


def _ppf_flags(ppf: PackagerProvidedFileClassSpec) -> str:
    flags = []
    if ppf.allow_name_segment:
        flags.append("named")
    if ppf.allow_architecture_segment:
        flags.append("arch")
    if ppf.supports_priority:
        flags.append(f"priority={ppf.default_priority}")
    if ppf.packageless_is_fallback_for_all_packages:
        flags.append("main-all-fallback")
    if ppf.post_formatting_rewrite:
        flags.append("post-format-hook")
    return ",".join(flags)


def _plugin_cmd_list_ppf(context: CommandContext) -> None:
    ppfs: Iterable[PackagerProvidedFileClassSpec]
    ppfs = context.load_plugins().packager_provided_files.values()
    with _stream_to_pager(context.parsed_args) as fd:
        headers: Sequence[Union[str, Tuple[str, str]]] = [
            "Stem",
            "Installed As",
            ("Mode", ">"),
            "Features",
            "Provided by",
        ]
        _print_list_table(
            fd,
            headers,
            [
                (
                    ppf.stem,
                    _path(ppf.installed_as_format),
                    "0" + oct(ppf.default_mode)[2:],
                    _ppf_flags(ppf),
                    ppf.debputy_plugin_metadata.plugin_name,
                )
                for ppf in sorted(ppfs, key=operator.attrgetter("stem"))
            ],
        )


def _plugin_cmd_list_metadata_detectors(context: CommandContext) -> None:
    mds = list(
        chain.from_iterable(
            context.load_plugins().metadata_maintscript_detectors.values()
        )
    )

    def _sort_key(md: "MetadataOrMaintscriptDetector") -> Any:
        return md.plugin_metadata.plugin_name, md.detector_id

    with _stream_to_pager(context.parsed_args) as fd:
        _print_list_table(
            fd,
            ["Provided by", "Detector Id"],
            [
                (md.plugin_metadata.plugin_name, md.detector_id)
                for md in sorted(mds, key=_sort_key)
            ],
        )


def _resolve_variable_for_list(
    substitution: Substitution,
    variable: PluginProvidedManifestVariable,
) -> str:
    var = "{{" + variable.variable_name + "}}"
    try:
        value = substitution.substitute(var, "CLI request")
    except DebputySubstitutionError:
        value = None
    return _render_manifest_variable_value(value)


def _render_manifest_variable_flag(variable: PluginProvidedManifestVariable) -> str:
    flags = []
    if variable.is_for_special_case:
        flags.append("special-use-case")
    if variable.is_internal:
        flags.append("internal")
    return ",".join(flags)


def _render_list_filter(v: Optional[bool]) -> str:
    if v is None:
        return "N/A"
    return "shown" if v else "hidden"


def _plugin_cmd_list_manifest_variables(context: CommandContext) -> None:
    variables = context.load_plugins().manifest_variables
    substitution = context.substitution.with_extra_substitutions(
        PACKAGE="<package-name>"
    )
    parsed_args = context.parsed_args
    show_special_case_vars = parsed_args.show_special_use_variables
    show_token_vars = parsed_args.show_token_variables
    show_all_vars = parsed_args.show_all_variables

    def _include_var(var: PluginProvidedManifestVariable) -> bool:
        if show_all_vars:
            return True
        if var.is_internal:
            return False
        if var.is_for_special_case and not show_special_case_vars:
            return False
        if var.is_token and not show_token_vars:
            return False
        return True

    with _stream_to_pager(context.parsed_args) as fd:
        _print_list_table(
            fd,
            ["Variable (use via: `{{ NAME }}`)", "Value", "Flag", "Provided by"],
            [
                (
                    k,
                    _resolve_variable_for_list(substitution, var),
                    _render_manifest_variable_flag(var),
                    var.plugin_metadata.plugin_name,
                )
                for k, var in sorted(variables.items())
                if _include_var(var)
            ],
        )

        print("", file=fd)

        filters = [
            (
                "Token variables",
                show_token_vars if not show_all_vars else None,
                "--show-token-variables",
            ),
            (
                "Special use variables",
                show_special_case_vars if not show_all_vars else None,
                "--show-special-case-variables",
            ),
        ]

        _print_list_table(
            fd,
            ["Variable type", "Value", "Option"],
            [
                (
                    fname,
                    _render_list_filter(value or show_all_vars),
                    f"{option} OR --show-all-variables",
                )
                for fname, value, option in filters
            ],
        )


def _plugin_cmd_list_manifest_rules(context: CommandContext) -> None:
    parsers = context.load_plugins().dispatchable_parsers

    with _stream_to_pager(context.parsed_args) as fd:
        _print_list_table(
            fd,
            ["Rule Name", "Rule Type", "Provided By"],
            [
                (
                    rn,
                    rt.__name__,
                    pt.parser_for(rn).plugin_metadata.plugin_name,
                )
                for rt, pt in parsers.items()
                for rn in pt.registered_keywords()
            ],
        )


def _plugin_cmd_list_automatic_discard_rules(context: CommandContext) -> None:
    auto_discard_rules = context.load_plugins().auto_discard_rules

    with _stream_to_pager(context.parsed_args) as fd:
        _print_list_table(
            fd,
            ["Name", "Provided By"],
            [
                (
                    name,
                    ppdr.plugin_metadata.plugin_name,
                )
                for name, ppdr in auto_discard_rules.items()
            ],
        )


def _provide_placeholder_parser_doc(
    parser_doc: Optional[ParserDocumentation],
    attributes: Iterable[str],
) -> ParserDocumentation:
    if parser_doc is None:
        parser_doc = reference_documentation()
    changes = {}
    if parser_doc.attribute_doc is None:
        changes["attribute_doc"] = [undocumented_attr(attr) for attr in attributes]

    if changes:
        return parser_doc.replace(**changes)
    return parser_doc


def _doc_args_parser_doc(
    rule_name: str,
    declarative_parser: DeclarativeInputParser[Any],
    plugin_metadata: DebputyPluginMetadata,
) -> Tuple[Mapping[str, str], ParserDocumentation]:
    attributes: Iterable[str]
    if isinstance(declarative_parser, DeclarativeMappingInputParser):
        attributes = declarative_parser.source_attributes.keys()
    else:
        attributes = []
    doc_args = {
        "RULE_NAME": rule_name,
        "MANIFEST_FORMAT_DOC": f"{DEBPUTY_DOC_ROOT_DIR}/MANIFEST-FORMAT.md",
        "PLUGIN_NAME": plugin_metadata.plugin_name,
    }
    parser_doc = _provide_placeholder_parser_doc(
        declarative_parser.inline_reference_documentation,
        attributes,
    )
    return doc_args, parser_doc


def _render_rule(
    rule_name: str,
    rule_type: type,
    declarative_parser: DeclarativeInputParser[Any],
    plugin_metadata: DebputyPluginMetadata,
    manifest_attribute_path: str,
) -> None:
    doc_args, parser_doc = _doc_args_parser_doc(
        rule_name,
        declarative_parser,
        plugin_metadata,
    )
    t = assume_not_none(parser_doc.title).format(**doc_args)
    print(t)
    print("=" * len(t))
    print()

    print(assume_not_none(parser_doc.description).format(**doc_args).rstrip())

    print()
    alt_form_parser = getattr(declarative_parser, "alt_form_parser")
    if isinstance(declarative_parser, DeclarativeMappingInputParser):
        attributes = declarative_parser.source_attributes
        required = declarative_parser.input_time_required_parameters
        conditionally_required = declarative_parser.at_least_one_of
        mutually_exclusive = declarative_parser.mutually_exclusive_attributes
        print("Attributes: ")
        for attr_doc in assume_not_none(parser_doc.attribute_doc):
            attr_description = attr_doc.description
            prefix = " - "

            for parameter in sorted(attr_doc.attributes):
                parameter_details = attributes[parameter]
                source_name = parameter_details.source_attribute_name
                if source_name in required:
                    req_str = "required"
                elif any(source_name in s for s in conditionally_required):
                    req_str = "conditional"
                else:
                    req_str = "optional"
                print(
                    f"{prefix}`{source_name}` ({req_str}): {parameter_details.type_validator.describe_type()}"
                )
                prefix = "   "

            if attr_description:
                print()
                for line in attr_description.format(**doc_args).splitlines(
                    keepends=False
                ):
                    print(f"   {line}")
                print()

        if (
            bool(conditionally_required)
            or bool(mutually_exclusive)
            or any(pd.conflicting_attributes for pd in attributes.values())
        ):
            print()
            print("This rule enforces the following restrictions:")

            if conditionally_required:
                for cr in conditionally_required:
                    anames = "`, `".join(
                        attributes[a].source_attribute_name for a in cr
                    )
                    if cr in mutually_exclusive:
                        print(f" - The rule must use exactly one of: `{anames}`")
                    else:
                        print(f" - The rule must use at least one of: `{anames}`")

            if mutually_exclusive or any(
                pd.conflicting_attributes for pd in attributes.values()
            ):
                for parameter, parameter_details in sorted(attributes.items()):
                    source_name = parameter_details.source_attribute_name
                    conflicts = set(parameter_details.conflicting_attributes)
                    for mx in mutually_exclusive:
                        if parameter in mx and mx not in conditionally_required:
                            conflicts |= mx
                    if conflicts:
                        conflicts.discard(parameter)
                        cnames = "`, `".join(
                            attributes[a].source_attribute_name for a in conflicts
                        )
                        print(
                            f" - The attribute `{source_name}` cannot be used with any of: `{cnames}`"
                        )
        print()
    if alt_form_parser is not None:
        print(f"Non-mapping format: {alt_form_parser.type_validator.describe_type()}")
        alt_parser_desc = parser_doc.alt_parser_description
        if alt_parser_desc:
            for line in alt_parser_desc.format(**doc_args).splitlines(keepends=False):
                print(f"   {line}")
        print()

    if declarative_parser.reference_documentation_url is not None:
        print(
            f"Reference documentation: {declarative_parser.reference_documentation_url}"
        )
    else:
        print(
            "Reference documentation: No reference documentation link provided by the plugin"
        )

    print(f"Used in: {manifest_attribute_path}")
    print(f"Rule reference: {rule_type.__name__}::{rule_name}")
    print(f"Plugin: {plugin_metadata.plugin_name}")


def _render_manifest_variable_value(v: Optional[str]) -> str:
    if v is None:
        return "(N/A: Cannot resolve the variable)"
    v = v.replace("\n", "\\n").replace("\t", "\\t")
    return v


def _render_multiline_documentation(
    documentation: str,
    *,
    first_line_prefix: str = "Documentation: ",
    following_line_prefix: str = " ",
) -> None:
    current_prefix = first_line_prefix
    for line in documentation.splitlines(keepends=False):
        if line.isspace():
            if not current_prefix.isspace():
                print(current_prefix.rstrip())
                current_prefix = following_line_prefix
            else:
                print()
            continue
        print(f"{current_prefix}{line}")
        current_prefix = following_line_prefix


def _plugin_cmd_show_manifest_variables(context: CommandContext) -> None:
    variables = context.load_plugins().manifest_variables
    substitution = context.substitution
    parsed_args = context.parsed_args
    variable_name = parsed_args.manifest_variable
    if variable_name.startswith("{{") and variable_name.endswith("}}"):
        variable_name = variable_name[2:-2]
    if variable_name.startswith("env:") and len(variable_name) > 4:
        env_var = variable_name[4:]
        variable = PluginProvidedManifestVariable(
            variable_name,
            variable_value=None,
            is_context_specific_variable=False,
            is_documentation_placeholder=True,
            variable_reference_documentation=f'Environment variable "{env_var}"',
        )
    else:
        variable = variables.get(variable_name)
    if variable is None:
        _error(
            f'Cannot resolve "{variable_name}" as a known variable from any of the available'
            f" plugins. Please use `debputy plugin list manifest-variables` to list all known"
            f" provided variables."
        )

    var_with_braces = "{{" + variable_name + "}}"
    try:
        source_value = substitution.substitute(var_with_braces, "CLI request")
    except DebputySubstitutionError:
        source_value = None
    binary_value = source_value
    print(f"Variable: {variable_name}")
    print(f"=========={'=' * len(variable_name)}")
    print()

    if variable.is_context_specific_variable:
        try:
            binary_value = substitution.with_extra_substitutions(
                PACKAGE="<package-name>",
            ).substitute(var_with_braces, "CLI request")
        except DebputySubstitutionError:
            binary_value = None

    doc = variable.variable_reference_documentation or "No documentation provided"
    _render_multiline_documentation(doc)

    if source_value == binary_value:
        print(f"Resolved: {_render_manifest_variable_value(source_value)}")
    else:
        print("Resolved:")
        print(f"    [source context]: {_render_manifest_variable_value(source_value)}")
        print(f"    [binary context]: {_render_manifest_variable_value(binary_value)}")

    if variable.is_for_special_case:
        print(
            'Special-case: The variable has been marked as a "special-case"-only variable.'
        )

    if not variable.is_documentation_placeholder:
        print(f"Plugin: {variable.plugin_metadata.plugin_name}")

    if variable.is_internal:
        print()
        # I knew everything I felt was showing on my face, and I hate that. I grated out,
        print("That was private.")


def _plugin_cmd_show_manifest_rule(context: CommandContext) -> None:
    parsers = context.load_plugins().dispatchable_parsers
    parsed_args = context.parsed_args
    req_rule_type = None
    rule_name = parsed_args.pmr_rule_name
    if "::" in rule_name:
        req_rule_type, rule_name = rule_name.split("::", 1)

    matched = []

    for rule_type, dispatching_parser in parsers.items():
        if req_rule_type is not None and rule_type.__name__ != req_rule_type:
            continue
        if dispatching_parser.is_known_keyword(rule_name):
            matched.append(rule_type)

    if len(matched) != 1:
        if not matched:
            _error(
                f"Could not find any plugable manifest rule related to {parsed_args.pmr_rule_name}."
                f" Please use `debputy plugin list plugable-manifest-rules` to see the list of rules."
            )
        _error(
            f"The name {rule_name} was ambiguous and matched multiple rule types.  Please use"
            f" <rule-type>::{rule_name} to clarify which rule to use"
            f" (such as {matched[0].__name__}::{rule_name} or {matched[1].__name__}::{rule_name}). "
            f" Please use `debputy plugin list plugable-manifest-rules` to see the list of rules."
        )

    rule_type = matched[0]
    dispatching_parser = parsers[rule_type].parser_for(rule_name)
    manifest_attribute_path = SUPPORTED_DISPATCHABLE_PARSERS[rule_type]

    _render_rule(
        rule_name,
        rule_type,
        dispatching_parser.parser,
        dispatching_parser.plugin_metadata,
        manifest_attribute_path,
    )


def _render_discard_rule_example(
    discard_rule: PluginProvidedDiscardRule,
    example: AutomaticDiscardRuleExample,
) -> None:
    processed = process_discard_rule_example(discard_rule, example)

    if processed.inconsistent_paths:
        plugin_name = discard_rule.plugin_metadata.plugin_name
        _warn(
            f"This example contains bugs. Please file a bug against the plugin {plugin_name}"
        )

    doc = example.description
    if doc:
        print(doc)

    # Add +1 for dirs because we want trailing slashes in the output
    max_len = max(
        (len(p.absolute) + (1 if p.is_dir else 0)) for p, _ in processed.rendered_paths
    )
    for p, verdict in processed.rendered_paths:
        path_name = p.absolute
        if p.is_dir:
            path_name += "/"

        if verdict.is_consistent:
            print(f"    {path_name:<{max_len}}  !! {verdict.message}")
        elif verdict.is_discarded:
            print(f"    {path_name:<{max_len}}  << {verdict.message}")
        else:
            print(f"    {path_name:<{max_len}}")


def _render_discard_rule(discard_rule: PluginProvidedDiscardRule) -> None:
    print(f"Automatic Discard Rule: {discard_rule.name}")
    print(f"========================{'=' * len(discard_rule.name)}")
    print()
    doc = discard_rule.reference_documentation or "No documentation provided"
    _render_multiline_documentation(doc)

    if len(discard_rule.examples) > 1:
        print()
        print("Examples")
        print("--------")
        print()
        for no, example in enumerate(discard_rule.examples, start=1):
            print(f"Example {no}")
            print(f"........{'.' * len(str(no))}")
            _render_discard_rule_example(discard_rule, example)
    elif discard_rule.examples:
        print()
        print("Example")
        print("-------")
        print()
        _render_discard_rule_example(discard_rule, discard_rule.examples[0])


def _plugin_cmd_show_automatic_discard_rules(context: CommandContext) -> None:
    auto_discard_rules = context.load_plugins().auto_discard_rules
    name = context.parsed_args.discard_rule
    discard_rule = auto_discard_rules.get(name)
    if discard_rule is None:
        _error(
            f'No automatic discard rule with the name "{name}". Please use'
            f" `debputy plugin list automatic-discard-rules` to see the list of automatic discard rules"
        )

    _render_discard_rule(discard_rule)


PLUGIN_LIST_TOPICS = {
    "plugins": _plugin_cmd_list_plugins,
    "packager-provided-files": _plugin_cmd_list_ppf,
    "ppf": _plugin_cmd_list_ppf,
    "p-p-f": _plugin_cmd_list_ppf,
    "metadata-detectors": _plugin_cmd_list_metadata_detectors,
    "plugable-manifest-rules": _plugin_cmd_list_manifest_rules,
    "p-m-r": _plugin_cmd_list_manifest_rules,
    "pmr": _plugin_cmd_list_manifest_rules,
    "manifest-variables": _plugin_cmd_list_manifest_variables,
    "automatic-discard-rules": _plugin_cmd_list_automatic_discard_rules,
    "a-d-r": _plugin_cmd_list_automatic_discard_rules,
}


PLUGIN_SHOW_TOPICS = {
    "plugable-manifest-rules": _plugin_cmd_show_manifest_rule,
    "p-m-r": _plugin_cmd_show_manifest_rule,
    "pmr": _plugin_cmd_show_manifest_rule,
    "manifest-variables": _plugin_cmd_show_manifest_variables,
    "automatic-discard-rules": _plugin_cmd_show_automatic_discard_rules,
    "a-d-r": _plugin_cmd_show_automatic_discard_rules,
}


def _plugin_cmd_list(context: CommandContext) -> None:
    parsed_args = context.parsed_args
    if (
        not hasattr(parsed_args, "plugin_list_topic")
        or parsed_args.plugin_list_topic is None
    ):
        setattr(parsed_args, "plugin_list_topic", "plugins")
    PLUGIN_LIST_TOPICS[parsed_args.plugin_list_topic](context)


def _plugin_cmd_show(context: CommandContext) -> None:
    parsed_args = context.parsed_args
    if getattr(parsed_args, "plugin_show_topic") is None:
        _error(
            "Missing mandatory topic argument.  Please add --help to see a list of topics"
        )
    PLUGIN_SHOW_TOPICS[parsed_args.plugin_show_topic](context)


PLUGIN_COMMANDS = {
    "list": _plugin_cmd_list,
    "show": _plugin_cmd_show,
}


def _plugin_cmd(context: CommandContext) -> None:
    assert getattr(context.parsed_args, "plugin_subcommand") is not None
    PLUGIN_COMMANDS[context.parsed_args.plugin_subcommand](context)


@dataclasses.dataclass
class Command:
    handler: Callable[["CommandContext"], None]
    require_substitution: bool = True
    requested_plugins_only: bool = False

    def create_substitution(
        self,
        parsed_args: argparse.Namespace,
        plugin_feature_set: PluginProvidedFeatureSet,
        debian_dir: VirtualPath,
    ) -> Substitution:
        requested_subst = self.require_substitution
        if hasattr(parsed_args, "substitution"):
            requested_subst = parsed_args.substitution
        if requested_subst is False and self.require_substitution:
            _error(f"--no-substitution cannot be used with {parsed_args.command}")
        if self.require_substitution or requested_subst is not False:
            variable_context = VariableContext(debian_dir)
            return SubstitutionImpl(
                plugin_feature_set=plugin_feature_set,
                unresolvable_substitutions=frozenset(["PACKAGE"]),
                variable_context=variable_context,
            )
        return NULL_SUBSTITUTION


COMMANDS = {
    "dh-integration-generate-debs": Command(
        handler=_dh_integration_generate_debs,
        requested_plugins_only=True,
    ),
    "dh-integration-install-plugin": Command(
        handler=_dh_integration_install_plugin,
        requested_plugins_only=True,
    ),
    "run-tests-for-plugin": Command(
        handler=_run_tests_for_plugin,
        requested_plugins_only=True,
    ),
    "autopkgtest-test-runner": Command(
        handler=_autodep8_test_runner,
        requested_plugins_only=True,
    ),
    "install-plugin": Command(
        handler=_install_plugin,
        requested_plugins_only=True,
    ),
    "migrate-from-dh": Command(handler=_migrate_from_dh),
    "plugin": Command(handler=_plugin_cmd),
    "check-manifest": Command(handler=_check_manifest, requested_plugins_only=True),
}


def _setup_and_parse_args() -> argparse.Namespace:
    is_arg_completing = "_ARGCOMPLETE" in os.environ
    if not is_arg_completing:
        setup_logging()
    parsed_args = parse_args()
    if is_arg_completing:
        # We could be asserting at this point; but lets just recover gracefully.
        setup_logging()
    return parsed_args


def main() -> None:
    parsed_args = _setup_and_parse_args()

    command = COMMANDS[parsed_args.command]
    plugin_search_dirs = [str(DEBPUTY_PLUGIN_ROOT_DIR)]

    try:
        cmd_context = CommandContext(
            parsed_args,
            command,
            plugin_search_dirs,
        )
        command.handler(cmd_context)
    except PluginInitializationError as e:
        _error_w_stack_trace(
            "Failed to load a plugin - full stack strace:",
            e.message,
            e,
            parsed_args.debug_mode,
            follow_warning=[
                "Please consider filing a bug against the plugin in question"
            ],
        )
    except UnhandledOrUnexpectedErrorFromPluginError as e:
        trace = e.__cause__ if e.__cause__ is not None else e
        # TODO: Reframe this as an internal error if `debputy` is the misbehaving plugin
        _error_w_stack_trace(
            "A plugin misbehaved:",
            e.message,
            trace,
            parsed_args.debug_mode,
            orig_exception=e,
            follow_warning=[
                "Please consider filing a bug against the plugin in question"
            ],
        )
    except PluginAPIViolationError as e:
        trace = e.__cause__ if e.__cause__ is not None else e
        # TODO: Reframe this as an internal error if `debputy` is the misbehaving plugin
        _error_w_stack_trace(
            "A plugin misbehaved:",
            e.message,
            trace,
            parsed_args.debug_mode,
            orig_exception=e,
            follow_warning=[
                "Please consider filing a bug against the plugin in question"
            ],
        )
    except DebputyRuntimeError as e:
        if parsed_args.debug_mode:
            _warn(
                "Re-raising original exception to show the full stack trace due to debug mode being active"
            )
            raise e
        _error(e.message)
    except AssertionError as e:
        _error_w_stack_trace(
            "Internal error in debputy",
            str(e),
            e,
            parsed_args.debug_mode,
            orig_exception=e,
            follow_warning=["Please file a bug against debputy with the full output."],
        )
    except subprocess.CalledProcessError as e:
        cmd = escape_shell(*e.cmd) if isinstance(e.cmd, list) else str(e.cmd)
        _error_w_stack_trace(
            f"The command << {cmd} >> failed and the code did not explicitly handle that exception.",
            str(e),
            e,
            parsed_args.debug_mode,
            orig_exception=e,
            follow_warning=[
                "The output above this error and the stacktrace may provide context to why the command failed.",
                "Please file a bug against debputy with the full output.",
            ],
        )
    except Exception as e:
        _error_w_stack_trace(
            "Unhandled exception (Re-run with --debug to see the raw stack trace)",
            str(e),
            e,
            parsed_args.debug_mode,
            orig_exception=e,
            follow_warning=["Please file a bug against debputy with the full output."],
        )


def _error_w_stack_trace(
    warning: str,
    error_msg: str,
    stacktrace: BaseException,
    debug_mode: bool,
    orig_exception: Optional[BaseException] = None,
    follow_warning: Optional[List[str]] = None,
) -> "NoReturn":
    if debug_mode:
        _warn(
            "Re-raising original exception to show the full stack trace due to debug mode being active"
        )
        raise orig_exception if orig_exception is not None else stacktrace
    _warn(warning)
    _warn("  ----- 8< ---- BEGIN STACK TRACE ---- 8< -----")
    traceback.print_exception(stacktrace)
    _warn("  ----- 8< ---- END STACK TRACE ---- 8< -----")
    if follow_warning:
        for line in follow_warning:
            _warn(line)
    _error(error_msg)


if __name__ == "__main__":
    main()
