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

from debian.debian_support import DpkgArchTable

from debputy import DEBPUTY_ROOT_DIR, DEBPUTY_PLUGIN_ROOT_DIR
from debputy.exceptions import DebputyRuntimeError

try:
    from argcomplete import autocomplete
except ImportError:

    def autocomplete(_parser):
        pass


from debputy.version import __full_version__
from debputy.filesystem_scan import build_fs_from_root_dir, FSRootDir
from debputy.plugin.api.impl_types import (
    PluginInitializationError,
    MetadataOrMaintscriptDetector,
    PluginAPIViolationError,
    PackagerProvidedFileClassSpec,
    PluginProvidedFeatureSet,
)
from debputy.plugin.api.impl import load_plugin_features
from debputy._deb_options_profiles import DebOptionsAndProfiles
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.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,
)
from debputy.highlevel_manifest_parser import YAMLManifestParser
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,
    ColorizedArgumentParser,
    setup_logging,
    _info,
    escape_shell,
    PKGNAME_REGEX,
    program_name,
)


class _DelegateSubparsers:
    __slots__ = ("_delegate",)

    def __init__(self, delegate):
        self._delegate = delegate

    def add_parser(self, *args, **kwargs):
        parser = self._delegate.add_parser(*args, **kwargs)
        _add_common_args(parser)
        return parser

    def __missing__(self, key):
        return getattr(self._delegate, key)


class DebputyArgumentParser(ColorizedArgumentParser):
    def add_subparsers(self, *args, **kw):
        return _DelegateSubparsers(super().add_subparsers(*args, **kw))


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

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

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


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


def _add_substitute_args(parser) -> None:
    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.",
    )


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: Optional[PluginProvidedFeatureSet] = None
        self._mtime: Optional[int] = None
        self._source_variables: Optional[Mapping[str, str]] = None
        self._substitution: Optional[Substitution] = None
        self._dctrl_data: Optional[
            Tuple[
                DpkgArchitectureBuildProcessValuesTable,
                DpkgArchTable,
                DebOptionsAndProfiles,
                "SourcePackage",
                Mapping[str, "BinaryPackage"],
            ]
        ] = None

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

    def load_plugins(self) -> PluginProvidedFeatureSet:
        if self._debputy_plugin_feature_set is None:
            requested_plugins = None
            if self.command.requested_plugins_only:
                requested_plugins = self._resolve_requested_plugins()
            self._debputy_plugin_feature_set = load_plugin_features(
                self._plugin_search_dirs,
                requested_plugins_only=requested_plugins,
            )
        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 = []
        plugins.extend(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

    def load_source_variables(self) -> Mapping[str, str]:
        if self._source_variables is None:
            try:
                self._source_variables = load_source_variables("debian/changelog")
            except FileNotFoundError:
                _error(
                    "This subcommand must be run from a source package root; expecting debian/changelog to exist."
                )
            os.environ["SOURCE_DATE_EPOCH"] = self._source_variables[
                "SOURCE_DATE_EPOCH"
            ]
        return self._source_variables

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

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

            packages = frozenset()
            if hasattr(self.parsed_args, "packages"):
                packages = self.parsed_args.packages

            try:
                source_package, 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()
            except FileNotFoundError:
                _error(
                    "This subcommand must be run from a source package root; expecting debian/control to exist."
                )

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

        return self._dctrl_data

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

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

        if manifest_path is None:
            manifest_path = self.parsed_args.package_manifest
        if manifest_path is None:
            manifest_path = "debian/debputy.manifest"

        parser = YAMLManifestParser(
            manifest_path,
            binary_packages,
            substitution,
            dpkg_architecture_variables,
            dpkg_arch_query_table,
            build_env,
            self.load_plugins(),
        )
        if os.path.exists(manifest_path):
            return parser.parse_manifest()
        return parser.build_manifest()


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 = DebputyArgumentParser(
        description=description,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        allow_abbrev=False,
        prog=program_name(),
    )

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

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

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

    generate_deb_package = internal_commands_subparser.add_parser(
        "dh-integration-generate-debs",
        help="[Internal command] Generate .deb/.udebs packages from debian/<pkg> (Not stable API)",
    )
    _add_packages_args(generate_deb_package)
    generate_deb_package.add_argument(
        "output",
        metavar="output",
        help="Where to place the resulting packages.  Should be a directory",
    )
    # Added for "help only" - you cannot trigger this option in practice
    generate_deb_package.add_argument(
        "--", metavar="UPSTREAM_ARGS", action="extend", nargs="+", dest="unused"
    )

    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",
    )
    generator_parser = internal_commands_subparser.add_parser(
        "generate-intermediate-manifest",
        help="[Internal command] Generate intermediate manifest for deb_packer (Not stable API)",
    )
    _add_packages_args(generator_parser)

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

    plugin_parser = subparsers.add_parser(
        "plugin", help="Interact with debputy plugins"
    )
    plugin_subparser = plugin_parser.add_subparsers(
        dest="plugin_subcommand",
        required=False,
        metavar="command",
    )
    plugin_list_parser = plugin_subparser.add_parser(
        "list",
        help="List plugins or things provided by plugins (unstable format)."
        " Pass `--help` *after* `list` get a topic listing",
    )
    plugin_list_subparser = plugin_list_parser.add_subparsers(
        dest="plugin_list_topic",
        required=False,
        metavar="topic",
    )
    plugin_list_subparser.add_parser(
        "plugins", help="List known plugins with their versions"
    )
    plugin_list_subparser.add_parser(
        "packager-provided-files",
        aliases=["ppf", "p-p-f"],
        help="List packager provided files (debian/pkg.foo)",
    )
    plugin_list_subparser.add_parser(
        "metadata-detectors",
        help="List metadata detectors",
    )

    autocomplete(parser)

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


def _generate_intermediate_manifest(context: CommandContext) -> None:
    output_path = context.parsed_args.intermediate_manifest_output
    manifest = context.parse_manifest()
    active_packages = list(manifest.active_packages)
    if len(active_packages) != 1:
        _error("This command requires exactly one package (-p/--package")
    root_dir = context.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, context.mtime
    )
    output_intermediate_manifest(output_path, intermediate_manifest)


_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 _dh_integration_generate_debs(context: CommandContext) -> None:
    manifest = context.parse_manifest()
    parsed_args = context.parsed_args
    mtime = context.mtime

    output_path = parsed_args.output
    upstream_args = parsed_args.upstream_args

    package_data_table = manifest.perform_installations()
    deb_materialize = str(DEBPUTY_ROOT_DIR / "deb_materialization.py")
    # 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]
        control_output_dir = dctrl_data.control_output_dir
        fs_root = dctrl_data.fs_root
        doc_main_package = dctrl_data.doc_main_package

        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, doc_main_package, fs_root)
        intermediate_manifest = manifest.finalize_data_tar_contents(
            package, fs_root, mtime
        )
        needs_root, use_fallback_assembly, gain_root_cmd = _determine_assembly_method(
            package, intermediate_manifest
        )

        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,
                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 = []
    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",
            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: {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}")
        try:
            subprocess.check_call(build_materialization)
        except subprocess.CalledProcessError as e:
            exit_code = f" with exit code {e.returncode}" if e.returncode else ""
            _error(
                f"Assembly command for {package} failed{exit_code}. Please review the output of the command"
                f" for more details on the problem."
            )


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


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


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

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

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

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


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

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

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

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

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


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


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


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


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


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

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

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


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


PLUGIN_COMMANDS = {
    "list": _plugin_cmd_list,
}


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, extra_substitutions: Dict[str, str]
    ) -> 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:
            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,
        requested_plugins_only=True,
    ),
    "migrate-from-dh": Command(handler=_migrate_from_dh),
    "plugin": Command(handler=_plugin_cmd),
    "check-manifest": Command(handler=_check_manifest, requested_plugins_only=True),
}


def _setup_and_parse_args():
    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():
    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 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 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()
