#!/usr/bin/python3 -B
import argparse
import contextlib
import dataclasses
import errno
import itertools
import json
import operator
import os
import re
import shutil
import stat
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,
    Type,
    TypedDict,
    NotRequired,
)

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,
    DeclarativeNonMappingInputParser,
)
from debputy.packager_provided_files import (
    detect_all_packager_provided_files,
    PerPackagePackagerProvidedResult,
    PackagerProvidedFile,
)
from debputy.plugin.api.example_processing import (
    process_discard_rule_example,
    DiscardVerdict,
)
from debputy.plugin.api.spec import (
    ParserDocumentation,
    undocumented_attr,
    reference_documentation,
    VirtualPath,
    packager_provided_file_reference_documentation,
)

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,
    build_virtual_fs,
)
from debputy.plugin.api.impl_types import (
    MetadataOrMaintscriptDetector,
    PackagerProvidedFileClassSpec,
    PluginProvidedFeatureSet,
    DebputyPluginMetadata,
    DeclarativeInputParser,
    PluginProvidedManifestVariable,
    PluginProvidedDiscardRule,
    AutomaticDiscardRuleExample,
    DispatchingParserBase,
    OPARSER_MANIFEST_ROOT,
    DispatchingObjectParser,
    PluginProvidedKnownPackagingFile,
    KNOWN_PACKAGING_FILE_CATEGORY_DESCRIPTIONS,
    KNOWN_PACKAGING_FILE_CONFIG_FEATURE_DESCRIPTION,
    expand_known_packaging_config_features,
    InstallPatternDHCompatRule,
    KnownPackagingFileInfo,
)
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,
    plugin_metadata_for_debputys_own_plugin,
)
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,
    parse_drules_for_addons,
    extract_dh_addons_from_control,
)

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_TABLE_PARSERS


try:
    from colors import color as _colored
except ImportError:

    def _colored(text: str, **kwargs: Any) -> str:
        return text


REFERENCE_DATA_TABLE = {
    "config-features": KNOWN_PACKAGING_FILE_CONFIG_FEATURE_DESCRIPTION,
    "file-categories": KNOWN_PACKAGING_FILE_CATEGORY_DESCRIPTIONS,
}


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: Any, **kw: Any) -> 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: Any, **kw: Any) -> Any:
        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: Any, **kw: Any) -> Any:
        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: Any, **kw: Any) -> Any:
        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: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--debputy-manifest",
        dest="debputy_manifest",
        action="store",
        default=None,
        help="Specify another `debputy` manifest (default: debian/debputy.manifest)",
        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: argparse.ArgumentParser) -> 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._requested_plugins: Optional[Sequence[str]] = 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

    def requested_plugins(self) -> Sequence[str]:
        if self._requested_plugins is None:
            self._requested_plugins = self._resolve_requested_plugins()
        return self._requested_plugins

    @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.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

    @property
    def has_dctrl_file(self) -> bool:
        debian_control = self.debian_dir.get("control")
        return debian_control is not None

    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.debputy_manifest is not None:
            manifest_path = self.parsed_args.debputy_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"] = 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.

    If you are using a screen reader, consider exporting setting the environment variable
    OPTIMIZE_FOR_SCREEN_READER=1. This will remove some of the visual formatting and some
    commands will render the output in a purely textual manner rather than visual layout.
    """
    )

    parser: argparse.ArgumentParser = 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)
    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",
    )

    tool_support = subparsers.add_parser(
        "tool-support",
        help="Tool integration commands. These are intended to have stable output and behaviour",
    )
    tool_support_subparser = tool_support.add_subparsers(
        dest="tool_subcommand",
        required=False,
        metavar="command",
    )

    tool_support_subparser.add_parser(
        "annotate-debian-directory",
        help="Scan debian/* for known package files and annotate them with information."
        " Output is evaluated and may change. Please get in touch if you want to use it"
        " or want additional features.",
    )
    export_reference_data_parser = tool_support_subparser.add_parser(
        "export-reference-data",
        help="Export reference data for other tool-support commands",
    )
    export_reference_data_parser.add_argument(
        "--output-format",
        default="text",
        choices=["text", "json"],
        help="Output format of the reference data",
    )
    export_reference_data_parser.add_argument(
        "dataset",
        metavar="name",
        default=None,
        nargs="?",
        help="The dataset to export (if any)",
        choices=REFERENCE_DATA_TABLE,
    )
    supports_tool_command_parser = tool_support_subparser.add_parser(
        "supports-tool-command",
        help="Test where a given tool-support command exists",
    )
    supports_tool_command_parser.add_argument(
        "test_command",
        metavar="name",
        default=None,
        help="The name of the command",
    )

    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 file definitions (debian/pkg.foo)",
    )
    plugin_list_subparser.add_parser(
        "used-packager-provided-files",
        aliases=["uppf", "u-p-p-f"],
        help="List packager provided files used by this package (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_ppf_parser = plugin_show_subparser.add_parser(
        "packager-provided-files",
        aliases=["ppf", "p-p-f"],
        help="List packager provided files (debian/pkg.foo)",
    )
    plugin_show_ppf_parser.add_argument(
        "ppf_name",
        metavar="name",
        help="Name of the packager provided file (such as `changelog`) to display details about",
    )
    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`)",
    )

    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)

    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

    plugins = context.load_plugins().plugin_data
    for plugin in plugins.values():
        _info(f"Loaded plugin {plugin.plugin_name}")

    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."
            )


PackagingFileInfo = TypedDict(
    "PackagingFileInfo",
    {
        "path": str,
        "binary-package": NotRequired[str],
        "install-path": NotRequired[str],
        "install-pattern": NotRequired[str],
        "file-categories": NotRequired[List[str]],
        "config-features": NotRequired[List[str]],
        "likely-generated-from": NotRequired[List[str]],
        "related-tools": NotRequired[List[str]],
        "documentation-uris": NotRequired[List[str]],
        "debputy-cmd-templates": NotRequired[List[List[str]]],
        "generates": NotRequired[str],
        "generated-from": NotRequired[str],
    },
)


def _scan_debian_dir(debian_dir: VirtualPath) -> Iterator[VirtualPath]:
    for p in debian_dir.iterdir:
        yield p
        if p.is_dir and p.path in ("debian/source", "debian/tests"):
            yield from p.iterdir


def _flatten_ppfs(
    all_ppfs: Dict[str, PerPackagePackagerProvidedResult]
) -> Iterable[PackagerProvidedFile]:
    for matched_ppf in all_ppfs.values():
        yield from matched_ppf.auto_installable
        for reserved_ppfs in matched_ppf.reserved_only.values():
            yield from reserved_ppfs


_POST_FORMATTING_RERWITE = {
    "period-to-underscore": lambda n: n.replace(".", "_"),
}


def _fake_PPFClassSpec(
    debputy_plugin_metadata: DebputyPluginMetadata,
    stem: str,
    doc_uris: Sequence[str],
    install_pattern: Optional[str],
    *,
    default_priority: Optional[int] = None,
    packageless_is_fallback_for_all_packages: bool = False,
    post_formatting_rewrite: Optional[str] = None,
) -> PackagerProvidedFileClassSpec:
    if install_pattern is None:
        install_pattern = "not-a-real-ppf"
    if post_formatting_rewrite is not None:
        formatting_hook = _POST_FORMATTING_RERWITE[post_formatting_rewrite]
    else:
        formatting_hook = None
    return PackagerProvidedFileClassSpec(
        debputy_plugin_metadata,
        stem,
        install_pattern,
        allow_architecture_segment=True,
        allow_name_segment=True,
        default_priority=default_priority,
        default_mode=0o644,
        post_formatting_rewrite=formatting_hook,
        packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages,
        reservation_only=False,
        formatting_callback=None,
        reference_documentation=packager_provided_file_reference_documentation(
            format_documentation_uris=doc_uris,
        ),
    )


def _relevant_dh_compat_rules(
    compat_level: Optional[int],
    info: KnownPackagingFileInfo,
) -> Iterable[InstallPatternDHCompatRule]:
    if compat_level is None:
        return
    dh_compat_rules = info.get("dh_compat_rules")
    if not dh_compat_rules:
        return
    for dh_compat_rule in dh_compat_rules:
        rule_compat_level = dh_compat_rule.get("starting_with_compat_level")
        if rule_compat_level is not None and compat_level < rule_compat_level:
            continue
        yield dh_compat_rule


def _kpf_install_pattern(
    compat_level: Optional[int],
    ppkpf: PluginProvidedKnownPackagingFile,
) -> Optional[str]:
    for compat_rule in _relevant_dh_compat_rules(compat_level, ppkpf.info):
        install_pattern = compat_rule.get("install_pattern")
        if install_pattern is not None:
            return install_pattern
    return ppkpf.info.get("install_pattern")


def _resolve_debhelper_config_files(
    debian_dir: VirtualPath,
    binary_packages: Mapping[str, BinaryPackage],
    debputy_plugin_metadata: DebputyPluginMetadata,
    dh_ppf_docs: Dict[str, PluginProvidedKnownPackagingFile],
    dh_rules_addons: Iterable[str],
    dh_compat_level: int,
) -> Tuple[List[PackagerProvidedFile], Optional[object], int]:
    dh_ppfs = {}
    commands, exit_code = _relevant_dh_commands(dh_rules_addons)
    dh_commands = set(commands)

    cmd = ["dh_assistant", "list-guessed-dh-config-files"]
    if dh_rules_addons:
        addons = ",".join(dh_rules_addons)
        cmd.append(f"--with={addons}")
    try:
        output = subprocess.check_output(
            cmd,
            stderr=subprocess.DEVNULL,
        )
    except (subprocess.CalledProcessError, FileNotFoundError) as e:
        config_files = []
        issues = None
        if isinstance(e, subprocess.CalledProcessError):
            exit_code = e.returncode
        else:
            exit_code = 127
    else:
        result = json.loads(output)
        config_files: List[Union[Mapping[str, Any], object]] = result.get(
            "config-files", []
        )
        issues = result.get("issues")
    for config_file in config_files:
        if not isinstance(config_file, dict):
            continue
        if config_file.get("file-type") != "pkgfile":
            continue
        stem = config_file.get("pkgfile")
        if stem is None:
            continue
        commands = config_file.get("commands")
        documentation_uris = []
        related_tools = []
        seen_commands = set()
        seen_docs = set()
        ppkpf = dh_ppf_docs.get(stem)
        if ppkpf:
            dh_cmds = ppkpf.info.get("debhelper_commands")
            doc_uris = ppkpf.info.get("documentation_uris")
            default_priority = ppkpf.info.get("default_priority")
            if doc_uris is not None:
                seen_docs.update(doc_uris)
                documentation_uris.extend(doc_uris)
            if dh_cmds is not None:
                seen_commands.update(dh_cmds)
                related_tools.extend(dh_cmds)
            install_pattern = _kpf_install_pattern(dh_compat_level, ppkpf)
            post_formatting_rewrite = ppkpf.info.get("post_formatting_rewrite")
            packageless_is_fallback_for_all_packages = ppkpf.info.get(
                "packageless_is_fallback_for_all_packages",
                False,
            )
        else:
            install_pattern = None
            default_priority = None
            post_formatting_rewrite = None
            packageless_is_fallback_for_all_packages = False
        for command in commands:
            if isinstance(command, dict):
                command_name = command.get("command")
                if isinstance(command_name, str) and command_name:
                    if command_name not in seen_commands:
                        related_tools.append(command_name)
                        seen_commands.add(command_name)
                    manpage = f"man:{command_name}(1)"
                    if manpage not in seen_docs:
                        documentation_uris.append(manpage)
                        seen_docs.add(manpage)
        dh_ppfs[stem] = _fake_PPFClassSpec(
            debputy_plugin_metadata,
            stem,
            documentation_uris,
            install_pattern,
            default_priority=default_priority,
            post_formatting_rewrite=post_formatting_rewrite,
            packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages,
        )
    for ppkpf in dh_ppf_docs.values():
        stem = ppkpf.detection_value
        if stem in dh_ppfs:
            continue

        default_priority = ppkpf.info.get("default_priority")
        commands = ppkpf.info.get("debhelper_commands")
        install_pattern = _kpf_install_pattern(dh_compat_level, ppkpf)
        post_formatting_rewrite = ppkpf.info.get("post_formatting_rewrite")
        packageless_is_fallback_for_all_packages = ppkpf.info.get(
            "packageless_is_fallback_for_all_packages",
            False,
        )
        if commands and not any(c in dh_commands for c in commands):
            continue
        dh_ppfs[stem] = _fake_PPFClassSpec(
            debputy_plugin_metadata,
            stem,
            ppkpf.info.get("documentation_uris"),
            install_pattern,
            default_priority=default_priority,
            post_formatting_rewrite=post_formatting_rewrite,
            packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages,
        )
    dh_ppfs = list(
        _flatten_ppfs(
            detect_all_packager_provided_files(
                dh_ppfs,
                debian_dir,
                binary_packages,
                allow_fuzzy_matches=True,
            )
        )
    )
    return dh_ppfs, issues, exit_code


def _merge_list(
    existing_table: Dict[str, Any],
    key: str,
    new_data: Optional[List[str]],
) -> None:
    if not new_data:
        return
    existing_values = existing_table.get(key, [])
    if isinstance(existing_values, tuple):
        existing_values = list(existing_values)
    assert isinstance(existing_values, list)
    seen = set(existing_values)
    existing_values.extend(x for x in new_data if x not in seen)
    existing_table[key] = existing_values


def _merge_ppfs(
    identified: List[PackagingFileInfo],
    seen_paths: Set[str],
    ppfs: List[PackagerProvidedFile],
    context: Mapping[str, PluginProvidedKnownPackagingFile],
    dh_compat_level: Optional[int],
) -> None:
    for ppf in ppfs:
        key = ppf.path.path
        ref_doc = ppf.definition.reference_documentation
        documentation_uris = (
            ref_doc.format_documentation_uris if ref_doc is not None else None
        )

        if not ppf.definition.installed_as_format.startswith("not-a-real-ppf"):
            try:
                parts = ppf.compute_dest()
            except RuntimeError:
                dest = None
            else:
                dest = "/".join(parts).lstrip(".")
        else:
            dest = None
        seen_paths.add(key)
        details: PackagingFileInfo = {
            "path": key,
            "binary-package": ppf.package_name,
        }
        if ppf.fuzzy_match and key.endswith(".in"):
            _merge_list(details, "file-categories", ["generic-template"])
            details["generates"] = key[:-3]
        elif assume_not_none(ppf.path.parent_dir).get(ppf.path.name + ".in"):
            _merge_list(details, "file-categories", ["generated"])
            details["generated-from"] = key + ".in"
        if dest is not None:
            details["install-path"] = dest
        identified.append(details)

        extra_details = context.get(ppf.definition.stem)
        if extra_details is not None:
            _add_known_packaging_data(details, extra_details, dh_compat_level)

        _merge_list(details, "documentation-uris", documentation_uris)


def _is_debputy_package(context: CommandContext, dh_rules_addons: Set[str]) -> bool:
    drules = context.debian_dir.get("rules")
    sequences = set()
    source_package = context.source_package()
    if drules is not None and not drules.is_dir:
        parse_drules_for_addons(drules, dh_rules_addons)
    extract_dh_addons_from_control(source_package.package_fields, sequences)
    sequences.update(dh_rules_addons)
    return (
        "debputy" in sequences or "zz-debputy" in sequences or "zz_debputy" in sequences
    )


def _extract_dh_compat_level() -> Tuple[Optional[int], int]:
    try:
        output = subprocess.check_output(
            ["dh_assistant", "active-compat-level"],
            stderr=subprocess.DEVNULL,
        )
    except (FileNotFoundError, subprocess.CalledProcessError) as e:
        exit_code = 127
        if isinstance(e, subprocess.CalledProcessError):
            exit_code = e.returncode
        return None, exit_code
    else:
        data = json.loads(output)
        active_compat_level = data.get("active-compat-level")
        exit_code = 0
        if (
            active_compat_level is not None
            and not isinstance(active_compat_level, int)
            or active_compat_level < 1
        ):
            active_compat_level = None
            exit_code = 255
        return active_compat_level, exit_code


def _relevant_dh_commands(dh_rules_addons: Iterable[str]) -> Tuple[List[str], int]:
    cmd = ["dh_assistant", "list-commands", "--output-format=json"]
    if dh_rules_addons:
        addons = ",".join(dh_rules_addons)
        cmd.append(f"--with={addons}")
    try:
        output = subprocess.check_output(
            cmd,
            stderr=subprocess.DEVNULL,
        )
    except (FileNotFoundError, subprocess.CalledProcessError) as e:
        exit_code = 127
        if isinstance(e, subprocess.CalledProcessError):
            exit_code = e.returncode
        return [], exit_code
    else:
        data = json.loads(output)
        commands_json = data.get("commands")
        commands = []
        for command in commands_json:
            if isinstance(command, dict):
                command_name = command.get("command")
                if isinstance(command_name, str) and command_name:
                    commands.append(command_name)
        return commands, 0


def _supports_tool_command(context: CommandContext) -> None:
    command_name = context.parsed_args.test_command
    if command_name in TOOL_SUPPORT_COMMANDS:
        sys.exit(0)
    else:
        sys.exit(2)


def _export_reference_data(context: CommandContext) -> None:
    dataset_name = context.parsed_args.dataset
    output_format = context.parsed_args.output_format
    if dataset_name is not None:
        subdata_set = REFERENCE_DATA_TABLE.get(dataset_name)
        if subdata_set is None:
            _error(f"Unknown data set: {dataset_name}")
        reference_data = {
            dataset_name: subdata_set,
        }
    else:
        subdata_set = None
        reference_data = REFERENCE_DATA_TABLE
    if output_format == "text":
        if subdata_set is None:
            _error(
                "When output format is text, then the dataset name is required (it is optional for JSON formats)."
            )
        with _stream_to_pager(context.parsed_args) as (fd, fo):
            header = ["key", "description"]
            rows = [(k, v["description"]) for k, v in subdata_set.items()]
            _print_list_table(fd, fo, header, rows)
            print(file=fd)
            print(
                "If you wanted this as JSON, please use --output-format=json", file=fd
            )
    elif output_format == "json":
        _json_output(
            {
                "reference-data": reference_data,
            }
        )
    else:
        raise AssertionError(f"Unsupported output format {output_format}")


def _add_known_packaging_data(
    details: PackagingFileInfo,
    plugin_data: PluginProvidedKnownPackagingFile,
    dh_compat_level: Optional[int],
):
    install_pattern = _kpf_install_pattern(
        dh_compat_level,
        plugin_data,
    )
    config_features = plugin_data.info.get("config_features")
    if config_features:
        config_features = expand_known_packaging_config_features(
            dh_compat_level or 0,
            config_features,
        )
        _merge_list(details, "config-features", config_features)

    if dh_compat_level is not None:
        extra_config_features = []
        for dh_compat_rule in _relevant_dh_compat_rules(
            dh_compat_level, plugin_data.info
        ):
            cf = dh_compat_rule.get("add_config_features")
            if cf:
                extra_config_features.extend(cf)
        if extra_config_features:
            extra_config_features = expand_known_packaging_config_features(
                dh_compat_level,
                extra_config_features,
            )
            _merge_list(details, "config-features", extra_config_features)
    if "install-pattern" not in details and install_pattern is not None:
        details["install-pattern"] = install_pattern
    for mk, ok in [
        ("file_categories", "file-categories"),
        ("documentation_uris", "documentation-uris"),
        ("debputy_cmd_templates", "debputy-cmd-templates"),
    ]:
        value = plugin_data.info.get(mk)
        if value and ok == "debputy-cmd-templates":
            value = [escape_shell(*c) for c in value]
        _merge_list(details, ok, value)


def _annotate_debian_directory(context: CommandContext) -> None:
    # Validates that we are run from a debian directory as a side effect
    binary_packages = context.binary_packages()
    feature_set = context.load_plugins()
    known_packaging_files = feature_set.known_packaging_files
    debputy_plugin_metadata = plugin_metadata_for_debputys_own_plugin()

    reference_data_set_names = [
        "config-features",
        "file-categories",
    ]
    for n in reference_data_set_names:
        assert n in REFERENCE_DATA_TABLE

    annotated: List[PackagingFileInfo] = []
    seen_paths = set()

    drules_sequences = set()
    is_debputy_package = _is_debputy_package(context, drules_sequences)
    dh_compat_level, dh_assistant_exit_code = _extract_dh_compat_level()
    dh_issues = []

    static_packaging_files = {
        kpf.detection_value: kpf
        for kpf in known_packaging_files.values()
        if kpf.detection_method == "path"
    }
    dh_pkgfile_docs = {
        kpf.detection_value: kpf
        for kpf in known_packaging_files.values()
        if kpf.detection_method == "dh.pkgfile"
    }

    if is_debputy_package:
        all_debputy_ppfs = list(
            _flatten_ppfs(
                detect_all_packager_provided_files(
                    feature_set.packager_provided_files,
                    context.debian_dir,
                    binary_packages,
                    allow_fuzzy_matches=True,
                )
            )
        )
    else:
        all_debputy_ppfs = []

    if dh_compat_level is not None:
        (
            all_dh_ppfs,
            dh_issues,
            dh_assistant_exit_code,
        ) = _resolve_debhelper_config_files(
            context.debian_dir,
            binary_packages,
            debputy_plugin_metadata,
            dh_pkgfile_docs,
            drules_sequences,
            dh_compat_level,
        )

    else:
        all_dh_ppfs = []

    for ppf in all_debputy_ppfs:
        key = ppf.path.path
        ref_doc = ppf.definition.reference_documentation
        documentation_uris = (
            ref_doc.format_documentation_uris if ref_doc is not None else None
        )
        details: PackagingFileInfo = {
            "path": key,
            "debputy-cmd-templates": [
                ["debputy", "plugin", "show", "p-p-f", ppf.definition.stem]
            ],
        }
        if ppf.fuzzy_match and key.endswith(".in"):
            _merge_list(details, "file-categories", ["generic-template"])
            details["generates"] = key[:-3]
        elif assume_not_none(ppf.path.parent_dir).get(ppf.path.name + ".in"):
            _merge_list(details, "file-categories", ["generated"])
            details["generated-from"] = key + ".in"
        seen_paths.add(key)
        annotated.append(details)
        static_details = static_packaging_files.get(key)
        if static_details is not None:
            # debhelper compat rules does not apply to debputy files
            _add_known_packaging_data(details, static_details, None)
        if documentation_uris:
            details["documentation-uris"] = list(documentation_uris)

    _merge_ppfs(annotated, seen_paths, all_dh_ppfs, dh_pkgfile_docs, dh_compat_level)

    for virtual_path in _scan_debian_dir(context.debian_dir):
        key = virtual_path.path
        if key in seen_paths:
            continue
        if virtual_path.is_symlink:
            try:
                st = os.stat(virtual_path.fs_path)
            except FileNotFoundError:
                continue
            else:
                if not stat.S_ISREG(st.st_mode):
                    continue
        elif not virtual_path.is_file:
            continue

        static_match = static_packaging_files.get(virtual_path.path)
        if static_match is not None:
            details: PackagingFileInfo = {
                "path": key,
            }
            annotated.append(details)
            if assume_not_none(virtual_path.parent_dir).get(virtual_path.name + ".in"):
                details["generated-from"] = key + ".in"
                _merge_list(details, "file-categories", ["generated"])
            _add_known_packaging_data(details, static_match, dh_compat_level)

    data = {
        "result": annotated,
        "reference-datasets": reference_data_set_names,
    }
    if dh_issues is not None or dh_assistant_exit_code != 0:
        data["issues"] = [
            {
                "source": "dh_assistant",
                "exit-code": dh_assistant_exit_code,
                "issue-data": dh_issues,
            }
        ]
    _json_output(data)


def _json_output(data: Any) -> None:
    format_options = {}
    if sys.stdout.isatty():
        format_options = {
            "indent": 4,
            # sort_keys might be tempting but generally insert order makes more sense in practice.
        }
    json.dump(data, sys.stdout, **format_options)
    if sys.stdout.isatty():
        # Looks better with a final newline.
        print()


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


URL_START = "\033]8;;"
URL_END = "\033]8;;\a"
MAN_URL_REWRITE = re.compile(r"man:(\S+)[(](\d+)[)]")

_SUPPORTED_COLORS = {
    "black",
    "red",
    "green",
    "yellow",
    "blue",
    "magenta",
    "cyan",
    "white",
}
_SUPPORTED_STYLES = {"none", "bold"}


class OutputStylingBase:
    def __init__(
        self, stream: IO[str], *, optimize_for_screen_reader: bool = False
    ) -> None:
        self._stream = stream
        self.optimize_for_screen_reader = optimize_for_screen_reader

    def colored(
        self,
        text: str,
        *,
        fg: Optional[Union[str]] = None,
        style: Optional[str] = None,
    ) -> str:
        self._check_color(fg)
        self._check_text_style(style)
        return text

    @property
    def supports_colors(self) -> bool:
        return False

    def print_visual_formatting(self, format_sequence: str, **kwargs) -> None:
        if "file" in kwargs:
            raise ValueError("Unsupported kwarg file")
        if self.optimize_for_screen_reader:
            return
        print(format_sequence, file=self._stream, **kwargs)

    def _check_color(self, color: Optional[str]) -> None:
        if color is not None and color not in _SUPPORTED_COLORS:
            raise ValueError(
                f"Unsupported color: {color}. Only the following are supported {','.join(_SUPPORTED_COLORS)}"
            )

    def _check_text_style(self, style: Optional[str]) -> None:
        if style is not None and style not in _SUPPORTED_STYLES:
            raise ValueError(
                f"Unsupported style: {style}. Only the following are supported {','.join(_SUPPORTED_STYLES)}"
            )

    def render_url(self, link_url: str) -> str:
        if self.optimize_for_screen_reader and link_url.startswith("man:"):
            m = MAN_URL_REWRITE.match(link_url)
            if m:
                page, section = m.groups()
                link_url = f"https://manpages.debian.org/{page}.{section}"
        return link_url


class ANSIOutputStylingBase(OutputStylingBase):
    def __init__(
        self,
        stream: IO[str],
        *,
        support_colors: bool = True,
        support_clickable_urls: bool = True,
        **kwargs: Any,
    ) -> None:
        super().__init__(stream, **kwargs)
        self._stream = stream
        self._support_colors = support_colors
        self._support_clickable_urls = support_clickable_urls

    @property
    def supports_colors(self) -> bool:
        return self._support_colors

    def colored(
        self,
        text: str,
        *,
        fg: Optional[Union[str]] = None,
        style: Optional[str] = None,
    ) -> str:
        self._check_color(fg)
        self._check_text_style(style)
        if not self.supports_colors:
            return text
        return _colored(text, fg=fg, style=style)

    def render_url(self, link_url: str) -> str:
        if not self._support_clickable_urls:
            return super().render_url(link_url)
        link_text = link_url
        if link_url.startswith("man:"):
            m = MAN_URL_REWRITE.match(link_url)
            if m:
                page, section = m.groups()
                link_url = f"https://manpages.debian.org/{page}.{section}"
                if self.optimize_for_screen_reader:
                    # Allegedly, manpages are horrible. Since we are rewriting the url
                    link_text = link_url
        return URL_START + f"{link_url}\a{link_text}" + URL_END


def _output_styling(
    _parsed_args: argparse.Namespace,
    stream: IO[str],
) -> OutputStylingBase:
    optimize_for_screen_reader = os.environ.get("OPTIMIZE_FOR_SCREEN_READER", "") != ""
    if not stream.isatty():
        return OutputStylingBase(
            stream, optimize_for_screen_reader=optimize_for_screen_reader
        )
    return ANSIOutputStylingBase(
        stream, optimize_for_screen_reader=optimize_for_screen_reader
    )


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

    pager = _pager()
    if pager is None:
        yield sys.stdout, fancy_output
        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, fancy_output
    except Exception:
        stdin.close()
        cmd.kill()
        cmd.wait()
        raise
    stdin.close()
    cmd.wait()


def _normalize_cell(cell: Union[str, Sequence[str]], times: int) -> Iterable[str]:
    if isinstance(cell, str):
        return itertools.chain([cell], itertools.repeat("", times=times - 1))
    if not cell:
        return itertools.repeat("", times=times)
    return itertools.chain(cell, itertools.repeat("", times=times - len(cell)))


def _expand_rows(
    rows: Sequence[Sequence[Union[str, Sequence[str]]]]
) -> Iterator[Sequence[str]]:
    for row in rows:
        if all(isinstance(c, str) for c in row):
            yield row
        else:
            longest = max(len(c) if isinstance(c, list) else 1 for c in row)
            cells = [_normalize_cell(c, times=longest) for c in row]
            yield from zip(*cells)


def _print_list_table(
    fd: IO[str],
    fo: OutputStylingBase,
    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

    if fo.optimize_for_screen_reader:
        print(
            f"The following is a table with {len(rows)} rows and {len(headers)} columns."
        )

        print()
        print("Presenting the column headers:")
        for no, header in enumerate(header_names, start=1):
            print(f"Column number {no} is titled: {header}")
        if rows:
            for rno, row in enumerate(rows, start=1):
                print()
                print(f"Presenting row number {rno}.")
                for header, cell in zip(header_names, row):
                    print(f'The value for column "{header}" is: {cell}')
            print()
            print("That was the last row of the table.")
        else:
            print()
            print("The table contained no data rows.")

        return

    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, fo):
        _print_list_table(
            fd,
            fo,
            ["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_uppf(context: CommandContext) -> None:
    ppf_table = context.load_plugins().packager_provided_files
    all_ppfs = detect_all_packager_provided_files(
        ppf_table,
        context.debian_dir,
        context.binary_packages(),
    )
    requested_plugins = set(context.requested_plugins())
    requested_plugins.add("debputy")
    all_detected_ppfs = list(_flatten_ppfs(all_ppfs))

    used_ppfs = [
        p
        for p in all_detected_ppfs
        if p.definition.debputy_plugin_metadata.plugin_name in requested_plugins
    ]
    inactive_ppfs = [
        p
        for p in all_detected_ppfs
        if p.definition.debputy_plugin_metadata.plugin_name not in requested_plugins
    ]

    if not used_ppfs and not inactive_ppfs:
        print("No packager provided files detected; not even a changelog... ?")
        return

    with _stream_to_pager(context.parsed_args) as (fd, fo):
        if used_ppfs:
            headers: Sequence[Union[str, Tuple[str, str]]] = [
                "File",
                "Matched Stem",
                "Installed Into",
                "Installed As",
            ]
            _print_list_table(
                fd,
                fo,
                headers,
                [
                    (
                        ppf.path.path,
                        ppf.definition.stem,
                        ppf.package_name,
                        "/".join(ppf.compute_dest()).lstrip("."),
                    )
                    for ppf in sorted(
                        used_ppfs, key=operator.attrgetter("package_name")
                    )
                ],
            )

        if inactive_ppfs:
            headers: Sequence[Union[str, Tuple[str, str]]] = [
                "UNUSED FILE",
                "Matched Stem",
                "Installed Into",
                "Could Be Installed As",
                "If B-D Had",
            ]
            _print_list_table(
                fd,
                fo,
                headers,
                [
                    (
                        f"~{ppf.path.path}~",
                        ppf.definition.stem,
                        f"~{ppf.package_name}~",
                        "/".join(ppf.compute_dest()).lstrip("."),
                        f"debputy-plugin-{ppf.definition.debputy_plugin_metadata.plugin_name}",
                    )
                    for ppf in sorted(
                        inactive_ppfs, key=operator.attrgetter("package_name")
                    )
                ],
            )


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, fo):
        headers: Sequence[Union[str, Tuple[str, str]]] = [
            "Stem",
            "Installed As",
            ("Mode", ">"),
            "Features",
            "Provided by",
        ]
        _print_list_table(
            fd,
            fo,
            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"))
            ],
        )

        if os.path.isdir("debian/"):
            print(file=fd)
            print(
                "Hint: You can use `debputy plugin list used-packager-provided-files` to have `debputy`",
                file=fd,
            )
            print(
                "list all the files in debian/ that matches these definitions.", file=fd
            )


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, fo):
        _print_list_table(
            fd,
            fo,
            ["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, fo):
        _print_list_table(
            fd,
            fo,
            ["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,
            fo,
            ["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 _parser_type_name(v: Union[str, Type[Any]]) -> str:
    if isinstance(v, str):
        return v if v != "<ROOT>" else ""
    return v.__name__


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

    # Type hint to make the chain call easier for the type checker, which does not seem
    # to derive to this common base type on its own.
    base_type = Iterable[Tuple[Union[str, Type[Any]], DispatchingParserBase[Any]]]

    table_parsers: base_type = feature_set.dispatchable_table_parsers.items()
    object_parsers: base_type = feature_set.dispatchable_object_parsers.items()

    parsers = chain(
        table_parsers,
        object_parsers,
    )

    with _stream_to_pager(context.parsed_args) as (fd, fo):
        _print_list_table(
            fd,
            fo,
            ["Rule Name", "Rule Type", "Provided By"],
            [
                (
                    rn,
                    _parser_type_name(rt),
                    pt.parser_for(rn).plugin_metadata.plugin_name,
                )
                for rt, pt in parsers
                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, fo):
        _print_list_table(
            fd,
            fo,
            ["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: str,
    declarative_parser: DeclarativeInputParser[Any],
    plugin_metadata: DebputyPluginMetadata,
    manifest_attribute_path: str,
) -> None:
    is_root_rule = rule_name == "::"

    doc_args, parser_doc = _doc_args_parser_doc(
        "the manifest root" if is_root_rule else 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", None)
    if isinstance(
        declarative_parser, (DeclarativeMappingInputParser, DispatchingObjectParser)
    ):
        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
        else:
            attributes = {}
            required = frozenset()
            conditionally_required = frozenset()
            mutually_exclusive = frozenset()
        print("Attributes:")
        attribute_docs = (
            parser_doc.attribute_doc if parser_doc.attribute_doc is not None else []
        )
        for attr_doc in assume_not_none(attribute_docs):
            attr_description = attr_doc.description
            prefix = " - "

            for parameter in sorted(attr_doc.attributes):
                parameter_details = attributes.get(parameter)
                if parameter_details is not None:
                    source_name = parameter_details.source_attribute_name
                    describe_type = parameter_details.type_validator.describe_type()
                else:
                    assert isinstance(declarative_parser, DispatchingObjectParser)
                    source_name = parameter
                    subparser = declarative_parser.parser_for(source_name).parser
                    if isinstance(subparser, DispatchingObjectParser):
                        rule_prefix = rule_name if rule_name != "::" else ""
                        describe_type = f"Object (see `{rule_prefix}::{subparser.manifest_attribute_path_template}`)"
                    elif isinstance(subparser, DeclarativeMappingInputParser):
                        describe_type = "<Type definition not implemented yet>"  # TODO: Derive from subparser
                    elif isinstance(subparser, DeclarativeNonMappingInputParser):
                        describe_type = (
                            subparser.alt_form_parser.type_validator.describe_type()
                        )
                    else:
                        describe_type = f"<Unknown: Non-introspectable subparser - {subparser.__class__.__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}): {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:
        # FIXME: Mapping[str, Any] ends here, which is ironic given the headline.
        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"
        )

    if not is_root_rule:
        print(
            f"Used in: {manifest_attribute_path if manifest_attribute_path != '<ROOT>' else 'The manifest root'}"
        )
        print(f"Rule reference: {rule_type}::{rule_name}")
        print(f"Plugin: {plugin_metadata.plugin_name}")
    else:
        print(f"Rule reference: {rule_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:
    plugin_feature_set = context.load_plugins()
    variables = plugin_feature_set.manifest_variables
    substitution = context.substitution
    parsed_args = context.parsed_args
    variable_name = parsed_args.manifest_variable
    fo = _output_styling(context.parsed_args, sys.stdout)
    if variable_name.startswith("{{") and variable_name.endswith("}}"):
        variable_name = variable_name[2:-2]
    variable: Optional[PluginProvidedManifestVariable]
    if variable_name.startswith("env:") and len(variable_name) > 4:
        env_var = variable_name[4:]
        variable = PluginProvidedManifestVariable(
            plugin_feature_set.plugin_data["debputy"],
            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}")
    fo.print_visual_formatting(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 _determine_ppf(
    context: CommandContext,
) -> Tuple[PackagerProvidedFileClassSpec, bool]:
    feature_set = context.load_plugins()
    ppf_name = context.parsed_args.ppf_name
    try:
        return feature_set.packager_provided_files[ppf_name], False
    except KeyError:
        pass

    orig_ppf_name = ppf_name
    if (
        ppf_name.startswith("d/")
        and not os.path.lexists(ppf_name)
        and os.path.lexists("debian/" + ppf_name[2:])
    ):
        ppf_name = "debian/" + ppf_name[2:]

    if ppf_name in ("debian/control", "debian/debputy.manifest", "debian/rules"):
        if ppf_name == "debian/debputy.manifest":
            doc = f"{DEBPUTY_DOC_ROOT_DIR}/MANIFEST-FORMAT.md"
        else:
            doc = "Debian Policy Manual or a packaging tutorial"
        _error(
            f"Sorry. While {orig_ppf_name} is a well-defined packaging file, it does not match the definition of"
            f" a packager provided file. Please see {doc} for more information about this file"
        )

    if context.has_dctrl_file and os.path.lexists(ppf_name):
        basename = ppf_name[7:]
        if "/" not in basename:
            debian_dir = build_virtual_fs([basename])
            all_ppfs = detect_all_packager_provided_files(
                feature_set.packager_provided_files,
                debian_dir,
                context.binary_packages(),
            )
            if all_ppfs:
                matched = next(iter(all_ppfs.values()))
                if len(matched.auto_installable) == 1 and not matched.reserved_only:
                    return matched.auto_installable[0].definition, True
                if not matched.auto_installable and len(matched.reserved_only) == 1:
                    reserved = next(iter(matched.reserved_only.values()))
                    if len(reserved) == 1:
                        return reserved[0].definition, True

    _error(
        f'Unknown packager provided file "{orig_ppf_name}". Please use'
        f" `debputy plugin list packager-provided-files` to see them all."
    )


def _plugin_cmd_show_ppf(context: CommandContext) -> None:
    ppf, matched_file = _determine_ppf(context)

    fo = _output_styling(context.parsed_args, sys.stdout)

    print(f"Packager Provided File: {ppf.stem}")
    fo.print_visual_formatting(f"========================{'=' * len(ppf.stem)}")
    print()
    ref_doc = ppf.reference_documentation
    description = ref_doc.description if ref_doc else None
    doc_uris = ref_doc.format_documentation_uris if ref_doc else tuple()
    if description is None:
        print(
            f"Sorry, no description provided by the plugin {ppf.debputy_plugin_metadata.plugin_name}."
        )
    else:
        for line in description.splitlines(keepends=False):
            print(line)

    print()
    print("Features:")
    if ppf.packageless_is_fallback_for_all_packages:
        print(f" * debian/{ppf.stem} is used for *ALL* packages")
    else:
        print(f' * debian/{ppf.stem} is used for only for the "main" package')
    if ppf.allow_name_segment:
        print(" * Supports naming segment (multiple files and custom naming).")
    else:
        print(
            " * No naming support; at most one per package and it is named after the package."
        )
    if ppf.allow_architecture_segment:
        print(" * Supports architecture specific variants.")
    else:
        print(" * No architecture specific variants.")
    if ppf.supports_priority:
        print(f" * Has a priority system (default priority: {ppf.default_priority}).")

    print()
    print("Examples matches:")

    if context.has_dctrl_file:
        first_pkg = next(iter(context.binary_packages()))
    else:
        first_pkg = "example-package"
    example_files = [
        (f"debian/{ppf.stem}", first_pkg),
        (f"debian/{first_pkg}.{ppf.stem}", first_pkg),
    ]
    if ppf.allow_name_segment:
        example_files.append(
            (f"debian/{first_pkg}.my.custom.name.{ppf.stem}", "my.custom.name")
        )
    if ppf.allow_architecture_segment:
        example_files.append((f"debian/{first_pkg}.{ppf.stem}.amd64", first_pkg)),
        if ppf.allow_name_segment:
            example_files.append(
                (
                    f"debian/{first_pkg}.my.custom.name.{ppf.stem}.amd64",
                    "my.custom.name",
                )
            )
    fs_root = build_virtual_fs([x for x, _ in example_files])
    priority = ppf.default_priority if ppf.supports_priority else None
    rendered_examples = []
    for example_file, assigned_name in example_files:
        example_path = fs_root.lookup(example_file)
        assert example_path is not None and example_path.is_file
        dest = ppf.compute_dest(
            assigned_name,
            owning_package=first_pkg,
            assigned_priority=priority,
            path=example_path,
        )
        dest_path = "/".join(dest).lstrip(".")
        rendered_examples.append((example_file, dest_path))

    _print_list_table(
        sys.stdout, fo, ["Source file", "Installed As"], rendered_examples
    )

    if doc_uris:
        print()
        print("Documentation URIs:")
        for uri in doc_uris:
            print(f" * {fo.render_url(uri)}")

    plugin_name = ppf.debputy_plugin_metadata.plugin_name
    print()
    print(f"Install Mode: 0{oct(ppf.default_mode)[2:]}")
    print(f"Provided by plugin: {plugin_name}")
    if (
        matched_file
        and plugin_name != "debputy"
        and plugin_name not in context.requested_plugins()
    ):
        print()
        _warn(
            f"The file might *NOT* be used due to missing Build-Depends on debputy-plugin-{plugin_name}"
        )


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

    matched = []

    base_type = Iterable[Tuple[Union[str, Type[Any]], DispatchingParserBase[Any]]]
    table_parsers: base_type = feature_set.dispatchable_table_parsers.items()
    object_parsers: base_type = feature_set.dispatchable_object_parsers.items()

    parsers = chain(
        table_parsers,
        object_parsers,
    )

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

    if len(matched) != 1 and (matched or rule_name != "::"):
        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."
            )
        match_a = matched[0][0]
        match_b = matched[1][0]
        _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 {_parser_type_name(match_a)}::{rule_name} or {_parser_type_name(match_b)}::{rule_name})."
            f" Please use `debputy plugin list plugable-manifest-rules` to see the list of rules."
        )

    if matched:
        rule_type, matched_dispatching_parser = matched[0]
        plugin_provided_parser = matched_dispatching_parser.parser_for(rule_name)
        if isinstance(rule_type, str):
            manifest_attribute_path = rule_type
        else:
            manifest_attribute_path = SUPPORTED_DISPATCHABLE_TABLE_PARSERS[rule_type]
        parser_type_name = _parser_type_name(rule_type)
        parser = plugin_provided_parser.parser
        plugin_metadata = plugin_provided_parser.plugin_metadata
    else:
        rule_name = "::"
        parser = feature_set.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT]
        parser_type_name = ""
        plugin_metadata = plugin_metadata_for_debputys_own_plugin()
        manifest_attribute_path = ""

    _render_rule(
        rule_name,
        parser_type_name,
        parser,
        plugin_metadata,
        manifest_attribute_path,
    )


def _render_discard_rule_example(
    fo: OutputStylingBase,
    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 is inconsistent with what the code actually does."
            f" Please consider filing a bug against the plugin {plugin_name}"
        )

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

    if fo.optimize_for_screen_reader:
        print("In this example, the following paths will be examined:")
        print()
        for p, _ in processed.rendered_paths:
            path_name = p.absolute
            print(
                f"The path {path_name} is a {'directory' if p.is_dir else 'file or symlink.'}"
            )

        print()
        if any(v.is_consistent and v.is_discarded for _, v in processed.rendered_paths):
            print("The following paths will be discarded by this rule:")
            for p, verdict in processed.rendered_paths:
                path_name = p.absolute
                if verdict.is_consistent and verdict.is_discarded:
                    print()
                    if p.is_dir:
                        print(f"{path_name} along with anything beneath it")
                    else:
                        print(path_name)
        else:
            print("No paths will be discarded in this example.")

        print()
        if any(v.is_consistent and v.is_kept for _, v in processed.rendered_paths):
            print("The following paths will be kept or ignored by this rule:")
            for p, verdict in processed.rendered_paths:
                path_name = p.absolute
                if verdict.is_consistent and verdict.is_kept:
                    print()
                    print(path_name)

        if any(not v.is_consistent for _, v in processed.rendered_paths):
            print()
            print(
                "The example was inconsistent with the code. These are the paths where the code disagrees with"
                " the provided example:"
            )
            for p, verdict in processed.rendered_paths:
                path_name = p.absolute
                if not verdict.is_consistent:
                    print()
                    if verdict == DiscardVerdict.DISCARDED_BY_CODE:
                        print(
                            f"The path {path_name} was discarded by the code, but the example said it should"
                            f" have been kept."
                        )
                    else:
                        print(
                            f"The path {path_name} was ignored by the code, but the example said it should"
                            f" have been discarded."
                        )
        return

    # 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 not 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(
    context: CommandContext,
    discard_rule: PluginProvidedDiscardRule,
) -> None:
    fo = _output_styling(context.parsed_args, sys.stdout)
    print(fo.colored(f"Automatic Discard Rule: {discard_rule.name}", style="bold"))
    fo.print_visual_formatting(
        f"========================{'=' * len(discard_rule.name)}"
    )
    print()
    doc = discard_rule.reference_documentation or "No documentation provided"
    _render_multiline_documentation(doc, first_line_prefix="", following_line_prefix="")

    if len(discard_rule.examples) > 1:
        print()
        fo.print_visual_formatting("Examples")
        fo.print_visual_formatting("--------")
        print()
        for no, example in enumerate(discard_rule.examples, start=1):
            print(
                fo.colored(
                    f"Example {no} of {len(discard_rule.examples)}", style="bold"
                )
            )
            fo.print_visual_formatting(f"........{'.' * len(str(no))}")
            _render_discard_rule_example(fo, discard_rule, example)
    elif discard_rule.examples:
        print()
        print(fo.colored("Example", style="bold"))
        fo.print_visual_formatting("-------")
        print()
        _render_discard_rule_example(fo, 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(context, discard_rule)


PLUGIN_LIST_TOPICS = {
    "plugins": _plugin_cmd_list_plugins,
    "used-packager-provided-files": _plugin_cmd_list_uppf,
    "uppf": _plugin_cmd_list_uppf,
    "u-p-p-f": _plugin_cmd_list_uppf,
    "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 = {
    "packager-provided-files": _plugin_cmd_show_ppf,
    "ppf": _plugin_cmd_show_ppf,
    "p-p-f": _plugin_cmd_show_ppf,
    "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


TOOL_SUPPORT_COMMANDS = {
    "export-reference-data": _export_reference_data,
    "supports-tool-command": _supports_tool_command,
    "annotate-debian-directory": _annotate_debian_directory,
}


def _tool_support(context: CommandContext) -> None:
    subcommand = context.parsed_args.tool_subcommand
    TOOL_SUPPORT_COMMANDS[subcommand](context)


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),
    "tool-support": Command(handler=_tool_support),
}


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()
