#!/usr/bin/python3 -B
import argparse
import collections
import dataclasses
import json
import operator
import os
import pathlib
import re
import subprocess
import sys
import textwrap
import traceback
from datetime import datetime
from itertools import chain
from typing import (
    List,
    Dict,
    Iterable,
    Callable,
    Any,
    Tuple,
    cast,
    Sequence,
    Optional,
    NoReturn,
)

from debian.debian_support import DpkgArchTable

DEBPUTY_ROOT_DIR = pathlib.Path(__file__).parent  # TODO: Subst during install

# setup PYTHONPATH: add our installation directory.
sys.path.insert(0, str(DEBPUTY_ROOT_DIR))

from debputy.version import __full_version__
from debputy.filesystem_scan import build_fs_from_root_dir, build_virtual_fs, FSRootDir
from debputy.plugin.api.impl import (
    load_plugin_features,
    PluginError,
    PluginInitializationError,
    MetadataOrMaintscriptDetector,
    PluginAPIViolationError,
)
from debputy.transformation_rules import TransformationRuntimeError

from debputy._deb_options_profiles import DebOptionsAndProfiles
from debputy.architecture_support import dpkg_architecture_table
from debputy.dh_migration.migration import migrate_from_dh
from debputy.dh_migration.models import AcceptableMigrationIssues
from debputy.packages import parse_source_debian_control
from debputy.version_substitutions import load_source_variables
from debputy.debhelper_emulation import dhe_dbgsym_root_dir

from debputy.deb_packaging_support import (
    setup_control_files,
    apply_compression,
    usr_local_transformation,
    handle_perl_code,
    detect_systemd_user_service_files,
    detect_systemd_system_service_files,
    process_manpages,
    fixup_debian_changelog_and_news_file,
    install_upstream_changelog,
    relocate_dwarves_into_dbgsym_packages,
)
from debputy.highlevel_manifest import (
    HighLevelManifest,
    ManifestPathInfoRuleType,
    ChangeDescription,
)
from debputy.highlevel_manifest_parser import parse_manifest, ManifestParseException
from debputy.intermediate_manifest import (
    TarMember,
    output_intermediate_manifest,
    IntermediateManifest,
)
from debputy.substitution import Substitution, SubstitutionImpl, NULL_SUBSTITUTION
from debputy.util import (
    _error,
    compute_output_filename,
    ensure_dir,
    scratch_dir,
    resolve_source_date_epoch,
    _warn,
    _normalize_path,
    ColorizedArgumentParser,
    setup_logging,
    _info,
)


def parse_args():
    description = textwrap.dedent(
        """\
    The `debputy` program is a manifest-based Debian packaging tool. 
    
    It is used as a part of compiling a source package and transforming it into one or
    more binary (.deb) packages.
    """
    )

    parser = ColorizedArgumentParser(
        description=description,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        allow_abbrev=False,
    )

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

    parser.add_argument(
        "--package-manifest",
        dest="package_manifest",
        action="store",
        default=None,
        help="Manifest of file metadata that is not visible in the file-system",
    )
    parser.add_argument(
        "--substitution",
        dest="substitution",
        action="store_true",
        default=None,
        help="*Some* commands can work without doing path substitution.  With this option, substitution"
        " is always done.  It is a no-op for commands that requires substitution",
    )
    parser.add_argument(
        "--no-substitution",
        dest="substitution",
        action="store_false",
        default=None,
        help="*Some* commands can work without doing path substitution.  With this option, substitution"
        " is not done.  However, this option *cannot* be used with all commands.",
    )

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

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

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

    internal_commands_subparser = subparsers.add_parser(
        "internal-command",
        help="Commands used for internal purposes. These are implementation details and subject to change",
    ).add_subparsers(dest="internal_command", 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)",
    )
    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"
    )

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

    show_changes_parser = internal_commands_subparser.add_parser(
        "show-manifest-rules",
        help="Show what the manifest will do (format is not stable nor intended for machine parsing)",
        description=textwrap.dedent(
            """\
        Translate manifest into a textual description of what it would do. The output is intended to be human
        readable and may change from version to version.  If you need a stable machine readable format, please
        file a feature request expanding on what data you need.
        """
        ),
    )

    show_changes_parser.add_argument(
        "--show-implicit-rules",
        action="store_true",
        default=False,
        dest="show_implicit_rules",
        help="Show implicit rules (usually directories implied by other actions)",
    )
    show_changes_parser.add_argument(
        "--show-builtin-rules",
        action="store_true",
        default=False,
        dest="show_builtin_rules",
        help="Show builtin rules (e.g., default permission normalization)",
    )

    generator_parser = internal_commands_subparser.add_parser(
        "generate-intermediate-manifest",
        help="[Internal command] Generate intermediate manifest for deb_packer (Not stable API)",
    )

    generator_parser.add_argument(
        "package_root_dir",
        metavar="PACKAGE_ROOT_DIR",
        help="Root directory of the package. Must contain a DEBIAN directory",
    )
    generator_parser.add_argument(
        "intermediate_manifest_output",
        nargs="?",
        metavar="PATH",
        help="Path where the intermediate manifest should be placed.",
    )

    trace_parser = internal_commands_subparser.add_parser(
        "trace-rules",
        help="Trace what happens with a given file (format is not stable nor intended for machine parsing)",
    )
    trace_parser.add_argument(
        "path",
        metavar="PATH",
        help="The path to trace. If it ends with '/' is will match as a directory, otherwise as a file.",
    )

    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)"
    )
    plugin_list_subparser = plugin_list_parser.add_subparsers(
        dest="plugin_list_topic",
        required=False,
        metavar="topic",
    )
    plugin_list_subparser.add_parser(
        "plugins", help="List known plugins with their versions"
    )
    plugin_list_subparser.add_parser(
        "packager-provided-files",
        aliases=["ppf", "p-p-f"],
        help="List packager provided files (debian/pkg.foo)",
    )
    plugin_list_subparser.add_parser(
        "metadata-detectors",
        help="List metadata detectors",
    )

    argv = sys.argv
    try:
        i = argv.index("--")
        upstream_args = argv[i + 1 :]
        argv = argv[:i]
    except (IndexError, ValueError):
        upstream_args = []
    parsed_args = 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)
    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)


def _generate_intermediate_manifest(
    parsed_args, manifest: HighLevelManifest, mtime: int
) -> None:
    output_path = parsed_args.intermediate_manifest_output
    active_packages = list(manifest.active_packages)
    if len(active_packages) != 1:
        _error("This command requires exactly one package (-p/--package")
    root_dir = parsed_args.package_root_dir
    package = active_packages[0].name
    fs_root = build_fs_from_root_dir(root_dir)
    intermediate_manifest = manifest.apply_to_binary_staging_directory(
        package, fs_root, mtime
    )
    output_intermediate_manifest(output_path, intermediate_manifest)


def _print_manifest_change_group(
    grouped: Dict[ManifestPathInfoRuleType, List[ChangeDescription]],
    rule_type: ManifestPathInfoRuleType,
    header: str,
    footer: str = "",
):
    changes = grouped.get(rule_type)
    if changes:
        print(header)
        for change_description in changes:
            print(change_description.pretty_format())
        print()
        if footer:
            print(footer)
            print()


def _show_manifest_rules(parsed_args, manifest: HighLevelManifest, mtime: int) -> None:
    for dctrl_bin in manifest.active_packages:
        package = dctrl_bin.name
        package_state = manifest.package_state_for(package)
        declared_transformations = [
            t for t in package_state.transformations if not t.is_builtin_rule
        ]

        print(f"=== {package} ===")
        print()

        if declared_transformations:
            print(f"  Transformations [{package}]:")
            for transformation in declared_transformations:
                print(f" * {transformation.describe_transformation()}")
            print()
        grouped = collections.defaultdict(list)
        for change_description in sorted(
            manifest.change_descriptions(package),
            key=lambda x: x.match_rule.sort_key(),
            reverse=True,
        ):
            path_info = change_description.path_info
            if path_info.is_builtin_definition and not parsed_args.show_builtin_rules:
                continue
            if path_info.is_implicit_definition and not parsed_args.show_implicit_rules:
                continue
            grouped[change_description.path_info.rule_type].append(change_description)

        _print_manifest_change_group(
            grouped,
            ManifestPathInfoRuleType.CREATE_DIRECTORY,
            f"  Directory creation [{package}]:",
        )
        _print_manifest_change_group(
            grouped,
            ManifestPathInfoRuleType.CREATE_SYMLINK,
            f"  Symlink creation [{package}]:",
        )

        print(f"  Metadata changes (default settings) [{package}]:")
        print("   * Owner: root (uid: 0)")
        print("   * Group: root (gid: 0)")
        print(
            f"   * mtime (clamp/upper bound): {mtime} ({datetime.utcfromtimestamp(mtime)}Z)"
        )
        print()
        _print_manifest_change_group(
            grouped,
            ManifestPathInfoRuleType.ENSURE_METADATA,
            f"  Metadata changes (path rules) [{package}]:",
        )

        if package_state.maintscript_snippets:
            print()
            print(f"  Maintscript snippets triggered by the manifest [{package}]:")
            for script, snippets in package_state.maintscript_snippets.items():
                print(f"   * {script}:")
                for snippet in snippets.all_snippets():
                    print(f"       - triggered by {snippet.definition_source}")


_RRR_DEB_ASSEMBLY_KEYWORD = "debputy/deb-assembly"


def _determine_assembly_method(manifest: HighLevelManifest) -> Tuple[bool, List[str]]:
    if not manifest.will_assembly_with_dpkg_deb_require_root or os.getuid() == 0:
        return 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 False, gain_root_cmd.split()
    if rrr == "no":
        _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."
        )
        return True, []

    for package, def_source in manifest.package_definitions_that_require_root:
        break
    else:
        assert False
    _error(
        f'Due to the definition "{def_source}" in the manifest, 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(
    _parsed_args,
    _manifest: HighLevelManifest,
    _mtime: int,
) -> None:
    # By the time we get here currently, everything we can do is already checked.
    _info("No issues detected")


def _dh_integration_generate_debs(
    parsed_args,
    manifest: HighLevelManifest,
    mtime: int,
) -> None:
    use_fallback_assembly, gain_root_cmd = _determine_assembly_method(manifest)

    output_path = parsed_args.output
    upstream_args = parsed_args.upstream_args

    package_data_table = manifest.perform_installations()
    deb_materialize = str(DEBPUTY_ROOT_DIR / "deb_materialization.py")
    packages_needing_root = frozenset(
        t[0] for t in manifest.package_definitions_that_require_root
    )
    # TODO: There should an easier way to determine the source version; really.
    source_version = manifest.substitution.substitute(
        "{{DEB_VERSION}}", "internal (resolve 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]
        root_dir = dctrl_data.binary_staging_root_dir
        fs_root = dctrl_data.fs_root

        assert dctrl_bin.should_be_acted_on

        detect_systemd_system_service_files(dctrl_bin, fs_root)
        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,
        )
        process_manpages(fs_root)
        dbgsym_fs_root = FSRootDir()
        if "nostrip" not in manifest.build_env.deb_build_options:
            dbgsym_ids = relocate_dwarves_into_dbgsym_packages(
                dctrl_bin, fs_root, dbgsym_fs_root
            )
        else:
            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.
            dbgsym_fs_root = FSRootDir()
            dbgsym_ids.clear()

        fixup_debian_changelog_and_news_file(
            dctrl_bin,
            fs_root,
            is_native,
            manifest.build_env,
        )
        if not is_native:
            # FIXME: Virtual source fs_root needed here.
            install_upstream_changelog(
                dctrl_bin,
                fs_root,
                ".",
            )
        apply_compression(dctrl_bin, dctrl_bin, fs_root)
        intermediate_manifest = manifest.finalize_data_tar_contents(
            package, fs_root, mtime
        )

        udeb_package_data = package_data_table.get(f"{dctrl_bin.name}-udeb")
        if udeb_package_data and not udeb_package_data.binary_package.is_udeb:
            udeb_package_data = None

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

        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."
                )
                return
            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,
                dbgsym_root,
                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,
            root_dir,
            output_path,
            upstream_args,
            is_udeb=dctrl_bin.is_udeb,
            use_fallback_assembly=use_fallback_assembly,
            needs_root=package in packages_needing_root,
            gain_root_cmd=gain_root_cmd,
        )


def assemble_deb(
    package: str,
    deb_materialize_cmd: str,
    intermediate_manifest: IntermediateManifest,
    mtime: int,
    root_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 = []
    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",
            "-",
            "--source-date-epoch",
            str(mtime),
            "--discard-existing-output",
            root_dir,
            materialization_dir,
        ]
    )
    output = output_path
    if is_udeb:
        materialize_cmd.append("--udeb")
        output = os.path.join(output_path, compute_output_filename(root_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: {materialize_cmd}")
    else:
        print(f"Materializing {package} via: {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"{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: {build_materialization}")
        subprocess.check_call(build_materialization)


def _migrate_from_dh(parsed_args, manifest: HighLevelManifest, _mtime: int) -> None:
    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)


def _print_list_table(headers: Sequence[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!?")

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

    print(divider)
    print(row_format.format(*headers))
    print(divider)
    for row in rows:
        print(row_format.format(*row))
    print(divider)


def _plugin_cmd_list_plugins(
    _parsed_args, manifest: HighLevelManifest, _mtime: int
) -> None:
    plugin_metadata_entries = manifest.plugin_provided_feature_set.plugin_data.values()
    _print_list_table(
        ["Plugin Name", "Plugin Version"],
        [(p.plugin_name, p.plugin_version) for p in plugin_metadata_entries],
    )


def _plugin_cmd_list_ppf(
    _parsed_args, manifest: HighLevelManifest, _mtime: int
) -> None:
    ppfs = manifest.plugin_provided_feature_set.packager_provided_files.values()
    _print_list_table(
        ["Name stem", "Installed into", " Provided by"],
        [
            (
                ppf.name_stem,
                ppf.install_into_directory,
                ppf.debputy_plugin_metadata.plugin_name,
            )
            for ppf in sorted(ppfs, key=operator.attrgetter("name_stem"))
        ],
    )


def _plugin_cmd_list_metadata_detectors(
    _parsed_args, manifest: HighLevelManifest, _mtime: int
) -> None:
    mds = list(
        chain.from_iterable(
            manifest.plugin_provided_feature_set.metadata_maintscript_detectors.values()
        )
    )

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

    _print_list_table(
        ["Provided by", "Detector Id"],
        [
            (md.plugin_metadata.plugin_name, md.detector_id)
            for md in sorted(mds, key=_sort_key)
        ],
    )


PLUGIN_LIST_TOPICS = {
    "plugins": _plugin_cmd_list_plugins,
    "packager-provided-files": _plugin_cmd_list_ppf,
    "ppf": _plugin_cmd_list_ppf,
    "p-p-f": _plugin_cmd_list_ppf,
    "metadata-detectors": _plugin_cmd_list_metadata_detectors,
}


def _plugin_cmd_list(parsed_args, manifest: HighLevelManifest, mtime: int) -> None:
    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](parsed_args, manifest, mtime)


PLUGIN_COMMANDS = {
    "list": _plugin_cmd_list,
}


def _plugin_cmd(parsed_args, manifest: HighLevelManifest, mtime: int) -> None:
    assert getattr(parsed_args, "plugin_subcommand") is not None
    PLUGIN_COMMANDS[parsed_args.plugin_subcommand](parsed_args, manifest, mtime)


def _trace_rules(parsed_args, manifest: HighLevelManifest, _mtime: int) -> None:
    path: str = parsed_args.path
    package: str = ""
    if path.startswith(("debian/", "./debian/")):
        m = re.search(r"(?:[.]/)?debian/([^/]+)/(.+)", path)
        if m:
            pkg, subpath = m.groups()
            if pkg in (p.name for p in manifest.all_packages):
                _info(f"Assuming you meant {subpath} in package {pkg}")
                print()
                path = subpath
                package = pkg
        if not package:
            _warn(
                "In the current version, the path should be relative to the binary package root."
            )
            _warn('That is, use "usr/bin" rather than "debian/foo/usr/bin".')
            _warn("")
    if not package:
        active_packages = list(manifest.active_packages)
        if len(active_packages) != 1:
            _error(
                "This action need exactly one package. Please use -p<package> to pick one"
            )
        package = active_packages[0].name
    normalized_path = _normalize_path(path)
    # Restore trailing slash if _normalize_path removed it.
    normalized_path_as_path_def = normalized_path + ("/" if path.endswith("/") else "")
    fs_root = build_virtual_fs([normalized_path_as_path_def])
    fs_path = fs_root.lookup(normalized_path)
    trace_paths = {normalized_path}
    print(
        f"Tracing rules for: {fs_path.path} ({'directory' if fs_path.is_dir else 'file'})"
    )
    print()

    for event in manifest.trace_apply_to_binary_staging_directory(
        package, cast("FSPath", fs_root)
    ):
        if event.fs_path.path not in trace_paths:
            continue
        if not event.is_active:
            conditional_description = (
                event.condition.describe()
                if event.condition
                else "<Unknown conditional>"
            )
            print(
                f"   - One match ignored due to a unsatisfied condition: {conditional_description}"
            )
        else:
            print(f"{event.describe_event()}")

    print()
    _warn(
        f"Transformation rules (packages.{package}.transformations in the manifest) are currently not traced."
    )
    _warn("Any entry that list (if it exists) is currently silently ignored.")


@dataclasses.dataclass
class Command:
    handler: Callable[[Any, HighLevelManifest, int], None]
    require_substitution: bool = True

    def create_substitution(
        self, parsed_args, extra_substitutions: Dict[str, str]
    ) -> 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:
            if not os.path.isfile("debian/changelog"):
                _error(
                    "Expected to be run from a directory containing debian/changelog"
                )
            return SubstitutionImpl(extra_substitutions=extra_substitutions)
        return NULL_SUBSTITUTION


COMMANDS = {
    "generate-intermediate-manifest": Command(handler=_generate_intermediate_manifest),
    "dh-integration-generate-debs": Command(handler=_dh_integration_generate_debs),
    "show-manifest-rules": Command(
        handler=_show_manifest_rules, require_substitution=False
    ),
    "migrate-from-dh": Command(handler=_migrate_from_dh),
    "trace-rules": Command(handler=_trace_rules),
    "plugin": Command(handler=_plugin_cmd),
    "check-manifest": Command(handler=_check_manifest),
}


def main():
    setup_logging()
    parsed_args = parse_args()

    command = COMMANDS[parsed_args.command]

    packages = parsed_args.packages
    v = load_source_variables("debian/changelog")
    substitution = command.create_substitution(parsed_args, v)
    mtime = resolve_source_date_epoch(None)
    os.environ["SOURCE_DATE_EPOCH"] = v["SOURCE_DATE_EPOCH"]
    build_env = DebOptionsAndProfiles.instance()
    dpkg_architecture_variables = dpkg_architecture_table()
    dpkg_arch_query_table = DpkgArchTable.load_arch_table()
    json_plugin_search_dirs = [os.path.join(DEBPUTY_ROOT_DIR, "plugins")]

    _, binary_packages = parse_source_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()

    try:
        debputy_plugin_feature_set = load_plugin_features(json_plugin_search_dirs)
        manifest = parse_manifest(
            parsed_args.package_manifest,
            binary_packages,
            substitution,
            dpkg_architecture_variables,
            dpkg_arch_query_table,
            build_env,
            debputy_plugin_feature_set,
        )
        command.handler(parsed_args, manifest, mtime)
    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 PluginAPIViolationError as e:
        trace = e.__cause__ if e.__cause__ is not None else e
        _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 (ManifestParseException, TransformationRuntimeError, PluginError) 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.args[0])
    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 RuntimeError 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,
        )


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