import collections
import dataclasses
import json
import os
import re
import subprocess
from typing import Iterable, Optional, Tuple, List

from debian.deb822 import Deb822

from debputy.architecture_support import dpkg_architecture_table
from debputy.debhelper_emulation import (
    dhe_filedoublearray,
    DHConfigFileLine,
    dhe_pkgfile,
)
from debputy.dh_migration.models import (
    ConflictingChange,
    FeatureMigration,
    UnsupportedFeature,
    AcceptableMigrationIssues,
    DHMigrationSubstitution,
)
from debputy.highlevel_manifest import (
    MutableYAMLSymlink,
    HighLevelManifest,
    MutableYAMLConffileManagementItem,
    MutableCondition,
    AbstractMutableYAMLInstallRule,
)
from debputy.installations import MAN_GUESS_FROM_BASENAME, MAN_GUESS_LANG_FROM_PATH
from debputy.packages import BinaryPackage
from debputy.plugin.api import VirtualPath
from debputy.util import _error, PKGVERSION_REGEX, PKGNAME_REGEX, _normalize_path

# Align with debputy.py
DH_COMMANDS_REPLACED = frozenset(
    {
        "dh_installdocs",
        "dh_installchangelogs",
        "dh_installexamples",
        "dh_installman",
        "dh_installcatalogs",
        "dh_installcron",
        "dh_installdebconf",
        "dh_installemacsen",
        "dh_installifupdown",
        "dh_installinfo",
        "dh_installinit",
        "dh_installsysusers",
        "dh_installtmpfiles",
        "dh_installsystemd",
        "dh_installsystemduser",
        "dh_installmenu",
        "dh_installmime",
        "dh_installmodules",
        "dh_installlogcheck",
        "dh_installlogrotate",
        "dh_installpam",
        "dh_installppp",
        "dh_installudev",
        "dh_installgsettings",
        "dh_installinitramfs",
        "dh_installalternatives",
        "dh_bugfiles",
        "dh_ucf",
        "dh_lintian",
        "dh_icons",
        "dh_usrlocal",
        "dh_perl",
        "dh_link",
        "dh_installwm",
        "dh_installxfonts",
        "dh_strip_nondeterminism",
        "dh_compress",
        "dh_fixperms",
        "dh_dwz",
        "dh_strip",
        "dh_makeshlibs",
        "dh_shlibdeps",
        "dh_missing",
        "dh_installdeb",
        "dh_gencontrol",
        "dh_md5sums",
        "dh_builddeb",
    }
)


@dataclasses.dataclass(frozen=True, slots=True)
class UnsupportedDHConfig:
    dh_config_basename: str
    dh_tool: str
    bug_950723_prefix_matching: bool = False
    is_missing_migration: bool = False


UNSUPPORTED_DH_CONFIGS_AND_TOOLS = [
    UnsupportedDHConfig("dirs", "dh_installdirs", is_missing_migration=True),
    UnsupportedDHConfig("config", "dh_installdebconf"),
    UnsupportedDHConfig("templates", "dh_installdebconf"),
    UnsupportedDHConfig("emacsen-compat", "dh_installemacsen"),
    UnsupportedDHConfig("emacsen-install", "dh_installemacsen"),
    UnsupportedDHConfig("emacsen-remove", "dh_installemacsen"),
    UnsupportedDHConfig("emacsen-startup", "dh_installemacsen"),
    UnsupportedDHConfig("info", "dh_installinfo"),
    UnsupportedDHConfig("init", "dh_installinit"),
    UnsupportedDHConfig("default", "dh_installinit"),
    # The `upstart` file should be long dead, but we might as well detect it.
    UnsupportedDHConfig("upstart", "dh_installinit"),
    # dh_installsystemd
    UnsupportedDHConfig("mount", "dh_installsystemd"),
    UnsupportedDHConfig("path", "dh_installsystemd", bug_950723_prefix_matching=False),
    UnsupportedDHConfig("path", "dh_installsystemd", bug_950723_prefix_matching=True),
    UnsupportedDHConfig(
        "service", "dh_installsystemd", bug_950723_prefix_matching=False
    ),
    UnsupportedDHConfig(
        "service", "dh_installsystemd", bug_950723_prefix_matching=True
    ),
    UnsupportedDHConfig(
        "socket", "dh_installsystemd", bug_950723_prefix_matching=False
    ),
    UnsupportedDHConfig("socket", "dh_installsystemd", bug_950723_prefix_matching=True),
    UnsupportedDHConfig(
        "target", "dh_installsystemd", bug_950723_prefix_matching=False
    ),
    UnsupportedDHConfig("target", "dh_installsystemd", bug_950723_prefix_matching=True),
    UnsupportedDHConfig("timer", "dh_installsystemd", bug_950723_prefix_matching=False),
    UnsupportedDHConfig("timer", "dh_installsystemd", bug_950723_prefix_matching=True),
    # dh_installsystemduser
    UnsupportedDHConfig(
        "user.path", "dh_installsystemduser", bug_950723_prefix_matching=False
    ),
    UnsupportedDHConfig(
        "user.path", "dh_installsystemduser", bug_950723_prefix_matching=True
    ),
    UnsupportedDHConfig(
        "user.service", "dh_installsystemduser", bug_950723_prefix_matching=False
    ),
    UnsupportedDHConfig(
        "user.service", "dh_installsystemduser", bug_950723_prefix_matching=True
    ),
    UnsupportedDHConfig(
        "user.socket", "dh_installsystemduser", bug_950723_prefix_matching=False
    ),
    UnsupportedDHConfig(
        "user.socket", "dh_installsystemduser", bug_950723_prefix_matching=True
    ),
    UnsupportedDHConfig(
        "user.target", "dh_installsystemduser", bug_950723_prefix_matching=False
    ),
    UnsupportedDHConfig(
        "user.target", "dh_installsystemduser", bug_950723_prefix_matching=True
    ),
    UnsupportedDHConfig(
        "user.timer", "dh_installsystemduser", bug_950723_prefix_matching=False
    ),
    UnsupportedDHConfig(
        "user.timer", "dh_installsystemduser", bug_950723_prefix_matching=True
    ),
    UnsupportedDHConfig("udev", "dh_installudev"),
    UnsupportedDHConfig("menu", "dh_installmenu"),
    UnsupportedDHConfig("menu-method", "dh_installmenu"),
    UnsupportedDHConfig("alternatives", "dh_installalternatives"),
    UnsupportedDHConfig("ucf", "dh_ucf"),
    UnsupportedDHConfig("wm", "dh_installwm"),
    UnsupportedDHConfig("triggers", "dh_installdeb"),
    UnsupportedDHConfig("postinst", "dh_installdeb"),
    UnsupportedDHConfig("postrm", "dh_installdeb"),
    UnsupportedDHConfig("preinst", "dh_installdeb"),
    UnsupportedDHConfig("prerm", "dh_installdeb"),
    UnsupportedDHConfig("menutest", "dh_installdeb"),
    UnsupportedDHConfig("isinstallable", "dh_installdeb"),
]
SUPPORTED_DH_ADDONS = frozenset(
    {
        # debputy's own
        "dh-sequence-debputy",
        "dh-sequence-zz-debputy",
        # debhelper provided sequences that should work.
        "dh-sequence-single-binary",
        "dh-sequence-dwz",
        "dh-sequence-elf-tools",
        "dh-sequence-installinitramfs",
        "dh-sequence-installsysusers",
        "dh-sequence-single-binary",
    }
)


def _extract_condition_from_dh_config_line(
    dh_config_line: DHConfigFileLine,
    acceptable_migration_issues: AcceptableMigrationIssues,
    warnings: List[str],
):
    if dh_config_line.build_profile_filter and dh_config_line.arch_filter:
        if "dh-exec-multiple-filters" in acceptable_migration_issues:
            warnings.append(
                "TODO: MANUAL MIGRATION of conditions from executable dh config"
                f' "{dh_config_line.config_file.path}" line {dh_config_line.line_no} is required'
            )
            return None
        raise UnsupportedFeature(
            "The dh-exec emulation cannot support multiple filters at the moment"
            f" (found: {dh_config_line.config_file.path} at line {dh_config_line.line_no}",
            ["dh-exec-multiple-filters"],
        )
    if dh_config_line.build_profile_filter:
        return MutableCondition.build_profiles_matches(
            dh_config_line.build_profile_filter
        )
    if dh_config_line.arch_filter:
        return MutableCondition.arch_matches(dh_config_line.arch_filter)
    return None


def _dh_config_file(
    debian_dir: VirtualPath,
    dctrl_bin: BinaryPackage,
    basename,
    helper_name,
    acceptable_migration_issues: AcceptableMigrationIssues,
    warnings: List[str],
    paths_to_remove: Optional[List[str]],
    support_executable_files: bool = False,
    allow_dh_exec_rename: bool = False,
    pkgfile_lookup: bool = True,
) -> Tuple[Optional[VirtualPath], Optional[Iterable[DHConfigFileLine]]]:
    dh_config_file = (
        dhe_pkgfile(debian_dir, dctrl_bin, basename)
        if pkgfile_lookup
        else debian_dir.get(basename)
    )
    if dh_config_file is None or dh_config_file.is_dir:
        return None, None
    if (dh_config_file.mode & 0o111) != 0 and not support_executable_files:
        primary_key = f"executable-{helper_name}-config"
        if (
            primary_key in acceptable_migration_issues
            or "any-executable-dh-configs" in acceptable_migration_issues
        ):
            warnings.append(
                f'TODO: MANUAL MIGRATION of executable dh config "{dh_config_file}" is required.'
            )
            return None, None
        raise UnsupportedFeature(
            f"Executable configuration files not supported (found: {dh_config_file}).",
            [primary_key, "any-executable-dh-configs"],
        )

    if paths_to_remove is not None:
        paths_to_remove.append(dh_config_file.fs_path)
    substitution = DHMigrationSubstitution(
        dpkg_architecture_table(),
        acceptable_migration_issues,
        warnings,
    )
    content = dhe_filedoublearray(
        dh_config_file,
        substitution,
        allow_dh_exec_rename=allow_dh_exec_rename,
    )
    return dh_config_file, content


def _validate_rm_mv_conffile(
    package: str,
    config_line: DHConfigFileLine,
) -> Tuple[str, str, Optional[str], Optional[str], Optional[str]]:
    cmd, *args = config_line.tokens
    if "--" in config_line.tokens:
        raise ValueError(
            f'The maintscripts file "{config_line.config_file.path}" for {package} includes a "--" in line'
            f" {config_line.line_no}. The offending line is: {config_line.original_line}"
        )
    if cmd == "rm_conffile":
        min_args = 1
        max_args = 3
    else:
        min_args = 2
        max_args = 4
    if len(args) > max_args or len(args) < min_args:
        raise ValueError(
            f'The "{cmd}" command takes at least {min_args} and at most {max_args} arguments.  However,'
            f' in "{config_line.config_file.path}" line {config_line.line_no} (for {package}), there'
            f" are {len(args)} arguments. The offending line is: {config_line.original_line}"
        )

    obsolete_conffile = args[0]
    new_conffile = args[1] if cmd == "mv_conffile" else None
    prior_version = args[min_args] if len(args) > min_args else None
    owning_package = args[min_args + 1] if len(args) > min_args + 1 else None
    if not obsolete_conffile.startswith("/"):
        raise ValueError(
            f'The (old-)conffile parameter for {cmd} must be absolute (i.e., start with "/").  However,'
            f' in "{config_line.config_file.path}" line {config_line.line_no} (for {package}), it was specified'
            f' as "{obsolete_conffile}". The offending line is: {config_line.original_line}'
        )
    if new_conffile is not None and not new_conffile.startswith("/"):
        raise ValueError(
            f'The new-conffile parameter for {cmd} must be absolute (i.e., start with "/").  However,'
            f' in "{config_line.config_file.path}" line {config_line.line_no} (for {package}), it was specified'
            f' as "{new_conffile}". The offending line is: {config_line.original_line}'
        )
    if prior_version is not None and not PKGVERSION_REGEX.fullmatch(prior_version):
        raise ValueError(
            f"The prior-version parameter for {cmd} must be a valid package version (i.e., match"
            f' {PKGVERSION_REGEX}).  However, in "{config_line.config_file.path}" line {config_line.line_no}'
            f' (for {package}), it was specified as "{prior_version}". The offending line is:'
            f" {config_line.original_line}"
        )
    if owning_package is not None and not PKGNAME_REGEX.fullmatch(owning_package):
        raise ValueError(
            f"The package parameter for {cmd} must be a valid package name (i.e., match {PKGNAME_REGEX})."
            f'  However, in "{config_line.config_file.path}" line {config_line.line_no} (for {package}), it'
            f' was specified as "{owning_package}". The offending line is: {config_line.original_line}'
        )
    return cmd, obsolete_conffile, new_conffile, prior_version, owning_package


def migrate_maintscript(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    mutable_manifest = manifest.mutable_manifest
    paths_to_remove = []
    warnings = []
    manifest_changes = 0
    already_present = 0
    for dctrl_bin in manifest.all_packages:
        mainscript_file, content = _dh_config_file(
            debian_dir,
            dctrl_bin,
            "maintscript",
            "dh_installdeb",
            acceptable_migration_issues,
            warnings,
            paths_to_remove,
        )

        if mainscript_file is None:
            continue
        assert content is not None

        package_definition = mutable_manifest.package(dctrl_bin.name)
        conffiles = {
            it.obsolete_conffile: it
            for it in package_definition.conffile_management_items()
        }
        seen_conffiles = set()

        for dhe_line in content:
            cmd = dhe_line.tokens[0]
            if cmd not in {"rm_conffile", "mv_conffile"}:
                raise UnsupportedFeature(
                    f"The dh_installdeb file {mainscript_file} contains the (currently)"
                    f' unsupported command "{cmd}" on line {dhe_line.line_no}'
                    f' (line: "{dhe_line.original_line}"'
                )

            try:
                (
                    _,
                    obsolete_conffile,
                    new_conffile,
                    prior_to_version,
                    owning_package,
                ) = _validate_rm_mv_conffile(dctrl_bin.name, dhe_line)
            except ValueError as e:
                _error(
                    f"Validation error in {mainscript_file} on line {dhe_line.line_no}. The error was: {e.args[0]}."
                )

            if obsolete_conffile in seen_conffiles:
                raise ConflictingChange(
                    f'The {mainscript_file} file defines actions for "{obsolete_conffile}" twice!'
                    f" Please ensure that it is defined at most once in that file."
                )
            seen_conffiles.add(obsolete_conffile)

            if cmd == "rm_conffile":
                item = MutableYAMLConffileManagementItem.rm_conffile(
                    obsolete_conffile,
                    prior_to_version,
                    owning_package,
                )
            else:
                assert cmd == "mv_conffile"
                item = MutableYAMLConffileManagementItem.mv_conffile(
                    obsolete_conffile,
                    new_conffile,
                    prior_to_version,
                    owning_package,
                )

            existing_def = conffiles.get(item.obsolete_conffile)
            if existing_def is not None:
                if not (
                    item.command == existing_def.command
                    and item.new_conffile == existing_def.new_conffile
                    and item.prior_to_version == existing_def.prior_to_version
                    and item.owning_package == existing_def.owning_package
                ):
                    raise ConflictingChange(
                        f"The maintscript defines the action {item.command} for"
                        f' "{obsolete_conffile}" in {mainscript_file}, but there is another'
                        f" conffile management definition for same path defined already (in the"
                        f" existing manifest or an migration e.g., inside {mainscript_file})"
                    )
                already_present += 1
                continue

            package_definition.add_conffile_management(item)
            manifest_changes += 1

    yield FeatureMigration(
        "dh_installdeb files",
        warnings=warnings,
        remove_paths_on_success=paths_to_remove,
        successful_manifest_changes=manifest_changes,
        already_present=already_present,
    )


def migrate_install_file(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    warnings = []
    paths_to_remove = []
    mutable_manifest = manifest.mutable_manifest
    installations = mutable_manifest.installations(create_if_absent=False)
    priority_lines = []
    remaining_install_lines = []

    is_single_binary = sum(1 for _ in manifest.all_packages) == 1

    for dctrl_bin in manifest.all_packages:
        install_file, content = _dh_config_file(
            debian_dir,
            dctrl_bin,
            "install",
            "dh_install",
            acceptable_migration_issues,
            warnings,
            paths_to_remove,
            support_executable_files=True,
            allow_dh_exec_rename=True,
        )
        if not install_file:
            continue
        current_sources = []
        sources_by_destdir = collections.defaultdict(list)
        install_as_rules = []
        for dhe_line in content:
            if dhe_line.arch_filter or dhe_line.build_profile_filter:
                _error(
                    f"Unable to migrate line {dhe_line.line_no} of {install_file.path}."
                    " Missing support for conditions."
                )
            special_rule = None
            if "=>" in dhe_line.tokens:
                if dhe_line.tokens[0] == "=>" and len(dhe_line.tokens) == 2:
                    # This rule must be as early as possible to retain the semantics
                    path = _normalize_path(dhe_line.tokens[1], with_prefix=False)
                    dest_dir = None
                    if path.startswith("debian/tmp/"):
                        dest_dir = os.path.dirname(path[9:])
                        if not dest_dir:
                            _error(
                                f'Unable to derive the proper replacement for "{path}" in {install_file.path} on'
                                f' line {dhe_line.line_no}. Remove the "debian/tmp" prefix manually and try'
                                " again."
                            )
                    special_rule = AbstractMutableYAMLInstallRule.install_dest(
                        path,
                        dctrl_bin.name if not is_single_binary else None,
                        dest_dir=dest_dir,
                    )
                elif len(dhe_line.tokens) != 3:
                    _error(
                        f"Validation error in {install_file.path} on line {dhe_line.line_no}. Cannot migrate dh-exec"
                        ' renames that is not exactly "SOURCE => TARGET" or "=> TARGET".'
                    )
                    assert False  # Reachability analysis hint;
                else:
                    install_rule = AbstractMutableYAMLInstallRule.install_as(
                        _normalize_path(dhe_line.tokens[0], with_prefix=False),
                        _normalize_path(dhe_line.tokens[2], with_prefix=False),
                        dctrl_bin.name if not is_single_binary else None,
                    )
                    install_as_rules.append(install_rule)
            else:
                if len(dhe_line.tokens) > 1:
                    sources = list(dhe_line.tokens[:-1])
                    dest_dir = _normalize_path(dhe_line.tokens[-1], with_prefix=False)
                else:
                    sources = list(dhe_line.tokens)
                    dest_dir = None

                if dest_dir is None:
                    current_sources.extend(
                        _normalize_path(w, with_prefix=False) for w in sources
                    )
                    continue
                sources_by_destdir[dest_dir].extend(
                    _normalize_path(w, with_prefix=False) for w in sources
                )

            if special_rule:
                priority_lines.append(special_rule)

        remaining_install_lines.extend(install_as_rules)

        for dest_dir, sources in sources_by_destdir.items():
            install_rule = AbstractMutableYAMLInstallRule.install_dest(
                sources[0] if len(sources) == 1 else sources,
                dctrl_bin.name if not is_single_binary else None,
                dest_dir=dest_dir,
            )
            remaining_install_lines.append(install_rule)

        if current_sources:
            install_rule = AbstractMutableYAMLInstallRule.install_dest(
                current_sources[0] if len(current_sources) == 1 else current_sources,
                dctrl_bin.name if not is_single_binary else None,
                dest_dir=None,
            )
            remaining_install_lines.append(install_rule)

    if priority_lines:
        installations.create_definition_if_missing()
        for install_rule in priority_lines:
            installations.append(install_rule)

    if remaining_install_lines:
        installations.create_definition_if_missing()
        for install_rule in remaining_install_lines:
            installations.append(install_rule)

    yield FeatureMigration(
        "dh_install config files",
        warnings=warnings,
        remove_paths_on_success=paths_to_remove,
        successful_manifest_changes=len(priority_lines) + len(remaining_install_lines),
        # TODO: Deduplicate
        already_present=0,
    )


def migrate_installdocs_file(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    warnings = []
    paths_to_remove = []
    mutable_manifest = manifest.mutable_manifest
    installations = mutable_manifest.installations(create_if_absent=False)
    migrations = 0

    is_single_binary = sum(1 for _ in manifest.all_packages) == 1

    for dctrl_bin in manifest.all_packages:
        install_file, content = _dh_config_file(
            debian_dir,
            dctrl_bin,
            "docs",
            "dh_installdocs",
            acceptable_migration_issues,
            warnings,
            paths_to_remove,
            support_executable_files=True,
        )
        if not install_file:
            continue
        docs = []
        for dhe_line in content:
            if dhe_line.arch_filter or dhe_line.build_profile_filter:
                _error(
                    f"Unable to migrate line {dhe_line.line_no} of {install_file.path}."
                    " Missing support for conditions."
                )
            docs.extend(_normalize_path(w, with_prefix=False) for w in dhe_line.tokens)

        if not docs:
            continue
        migrations += 1
        install_rule = AbstractMutableYAMLInstallRule.install_docs(
            docs if len(docs) > 1 else docs[0],
            dctrl_bin.name if not is_single_binary else None,
        )
        installations.create_definition_if_missing()
        installations.append(install_rule)

    yield FeatureMigration(
        "dh_installdocs config files",
        warnings=warnings,
        remove_paths_on_success=paths_to_remove,
        successful_manifest_changes=migrations,
        # TODO: Deduplicate
        already_present=0,
    )


def migrate_installexamples_file(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    warnings = []
    paths_to_remove = []
    mutable_manifest = manifest.mutable_manifest
    installations = mutable_manifest.installations(create_if_absent=False)
    migrations = 0
    is_single_binary = sum(1 for _ in manifest.all_packages) == 1

    for dctrl_bin in manifest.all_packages:
        install_file, content = _dh_config_file(
            debian_dir,
            dctrl_bin,
            "examples",
            "dh_installexamples",
            acceptable_migration_issues,
            warnings,
            paths_to_remove,
            support_executable_files=True,
        )
        if not install_file:
            continue
        examples = []
        for dhe_line in content:
            if dhe_line.arch_filter or dhe_line.build_profile_filter:
                _error(
                    f"Unable to migrate line {dhe_line.line_no} of {install_file.path}."
                    " Missing support for conditions."
                )
            examples.extend(
                _normalize_path(w, with_prefix=False) for w in dhe_line.tokens
            )

        if not examples:
            continue
        migrations += 1
        install_rule = AbstractMutableYAMLInstallRule.install_examples(
            examples if len(examples) > 1 else examples[0],
            dctrl_bin.name if not is_single_binary else None,
        )
        installations.create_definition_if_missing()
        installations.append(install_rule)

    yield FeatureMigration(
        "dh_installexamples config files",
        warnings=warnings,
        remove_paths_on_success=paths_to_remove,
        successful_manifest_changes=migrations,
        # TODO: Deduplicate
        already_present=0,
    )


def migrate_installman_file(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    warnings = []
    paths_to_remove = []
    mutable_manifest = manifest.mutable_manifest
    installations = mutable_manifest.installations(create_if_absent=False)
    migrations = 0
    is_single_binary = sum(1 for _ in manifest.all_packages) == 1
    warn_about_basename = False

    for dctrl_bin in manifest.all_packages:
        install_file, content = _dh_config_file(
            debian_dir,
            dctrl_bin,
            "manpages",
            "dh_installman",
            acceptable_migration_issues,
            warnings,
            paths_to_remove,
            support_executable_files=True,
        )
        if not install_file:
            continue

        default_language = []
        by_basename_language = []
        for dhe_line in content:
            if dhe_line.arch_filter or dhe_line.build_profile_filter:
                _error(
                    f"Unable to migrate line {dhe_line.line_no} of {install_file.path}."
                    " Missing support for conditions."
                )
            sources = [_normalize_path(w, with_prefix=False) for w in dhe_line.tokens]
            if any(
                MAN_GUESS_FROM_BASENAME.search(x)
                and not MAN_GUESS_LANG_FROM_PATH.search(x)
                for x in sources
            ):
                warn_about_basename = True
                by_basename_language.extend(sources)
            else:
                default_language.extend(sources)

        if not default_language and not by_basename_language:
            continue
        migrations += 1
        installations.create_definition_if_missing()
        if default_language:
            install_rule = AbstractMutableYAMLInstallRule.install_man(
                default_language if len(default_language) > 1 else default_language[0],
                dctrl_bin.name if not is_single_binary else None,
                None,
            )
            installations.append(install_rule)
        if by_basename_language:
            install_rule = AbstractMutableYAMLInstallRule.install_man(
                by_basename_language
                if len(by_basename_language) > 1
                else by_basename_language[0],
                dctrl_bin.name if not is_single_binary else None,
                "derive-from-basename",
            )
            installations.append(install_rule)

    if warn_about_basename:
        warnings.append(
            'Detected manpages that might rely on "derive-from-basename" logic.  Please double check'
            " that the generated `install-man` rules are correct"
        )

    yield FeatureMigration(
        "dh_installman config files",
        warnings=warnings,
        remove_paths_on_success=paths_to_remove,
        successful_manifest_changes=migrations,
        # TODO: Deduplicate
        already_present=0,
    )


def migrate_not_installed_file(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    warnings = []
    paths_to_remove = []
    mutable_manifest = manifest.mutable_manifest
    installations = mutable_manifest.installations(create_if_absent=False)
    migrations = 0
    main_binary = [p for p in manifest.all_packages if p.is_main_package][0]

    missing_file, content = _dh_config_file(
        debian_dir,
        main_binary,
        "not-installed",
        "dh_missing",
        acceptable_migration_issues,
        warnings,
        paths_to_remove,
        support_executable_files=False,
        pkgfile_lookup=False,
    )
    discard_rules = []
    if missing_file:
        for dhe_line in content:
            discard_rules.extend(
                _normalize_path(w, with_prefix=False) for w in dhe_line.tokens
            )

    if discard_rules:
        migrations += 1
        install_rule = AbstractMutableYAMLInstallRule.discard(
            discard_rules if len(discard_rules) > 1 else discard_rules[0],
        )
        installations.create_definition_if_missing()
        installations.append(install_rule)

    yield FeatureMigration(
        "dh_missing's not-installed config file",
        warnings=warnings,
        remove_paths_on_success=paths_to_remove,
        successful_manifest_changes=migrations,
        # TODO: Deduplicate
        already_present=0,
    )


def detect_pam_files(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    _acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    assumed_compat = None
    for dctrl_bin in manifest.all_packages:
        dh_config_file = dhe_pkgfile(debian_dir, dctrl_bin, "pam")
        if dh_config_file is not None:
            assumed_compat = 14
            break
    yield FeatureMigration(
        "detect dh_installpam files (min dh compat)",
        warnings=[],
        successful_manifest_changes=0,
        already_present=0,
        assumed_compat=assumed_compat,
    )


def migrate_tmpfile(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    _acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    warnings = []
    paths_to_remove = []
    paths_to_rename = []
    for dctrl_bin in manifest.all_packages:
        dh_config_file = dhe_pkgfile(debian_dir, dctrl_bin, "tmpfile")
        if dh_config_file is not None:
            target = (
                dh_config_file.name.replace(".tmpfile", ".tmpfiles")
                if "." in dh_config_file.name
                else "tmpfiles"
            )
            _rename_file_if_exists(
                debian_dir,
                dh_config_file.name,
                target,
                warnings,
                paths_to_rename,
                paths_to_remove,
            )
    yield FeatureMigration(
        "dh_installtmpfiles config files",
        warnings=warnings,
        remove_paths_on_success=paths_to_remove,
        rename_paths_on_success=paths_to_rename,
        successful_manifest_changes=0,
        already_present=0,
    )


def migrate_lintian_overrides_files(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    warnings = []
    for dctrl_bin in manifest.all_packages:
        # We do not support executable lintian-overrides and `_dh_config_file` handles all of that.
        # Therefore, the return value is irrelevant to us.
        _dh_config_file(
            debian_dir,
            dctrl_bin,
            "lintian-overrides",
            "dh_lintian",
            acceptable_migration_issues,
            warnings,
            None,
            support_executable_files=False,
        )
    yield FeatureMigration(
        "dh_lintian config files",
        warnings=warnings,
        remove_paths_on_success=tuple(),
        successful_manifest_changes=0,
        already_present=0,
    )


def migrate_links_files(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    mutable_manifest = manifest.mutable_manifest
    paths_to_remove = []
    warnings = []
    manifest_changes = 0
    already_present = 0
    for dctrl_bin in manifest.all_packages:
        links_file, content = _dh_config_file(
            debian_dir,
            dctrl_bin,
            "links",
            "dh_link",
            acceptable_migration_issues,
            warnings,
            paths_to_remove,
            support_executable_files=True,
        )

        if links_file is None:
            continue
        assert content is not None

        package_definition = mutable_manifest.package(dctrl_bin.name)
        defined_symlink = {
            symlink.symlink_path: symlink.symlink_target
            for symlink in package_definition.symlinks()
        }

        seen_symlinks = set()

        for dhe_line in content:
            if len(dhe_line.tokens) != 2:
                raise UnsupportedFeature(
                    f"The dh_link file {links_file} did not have exactly two paths on line"
                    f' {dhe_line.line_no} (line: "{dhe_line.original_line}"'
                )
            target, source = dhe_line.tokens
            if source in seen_symlinks:
                # According to #934499, this has happened in the wild already
                raise ConflictingChange(
                    f"The {links_file} file defines the link path {source} twice! Please ensure"
                    " that it is defined at most once in that file"
                )
            seen_symlinks.add(source)
            # Symlinks in .links are always considered absolute, but you were not required to have a leading slash.
            # However, in the debputy manifest, you can have relative links, so we should ensure it is explicitly
            # absolute.
            if not target.startswith("/"):
                target = "/" + target
            existing_target = defined_symlink.get(source)
            if existing_target is not None:
                if existing_target != target:
                    raise ConflictingChange(
                        f'The symlink "{source}" points to "{target}" in {links_file}, but there is'
                        f' another symlink with same path pointing to "{existing_target}" defined'
                        " already (in the existing manifest or an migration e.g., inside"
                        f" {links_file})"
                    )
                already_present += 1
                continue
            condition = _extract_condition_from_dh_config_line(
                dhe_line,
                acceptable_migration_issues,
                warnings,
            )
            package_definition.add_symlink(
                MutableYAMLSymlink.new_symlink(
                    source,
                    target,
                    condition,
                )
            )
            manifest_changes += 1
    yield FeatureMigration(
        "dh_link files",
        warnings=warnings,
        remove_paths_on_success=paths_to_remove,
        successful_manifest_changes=manifest_changes,
        already_present=already_present,
    )


def migrate_misspelled_readme_debian_files(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    paths_to_rename = []
    paths_to_remove = []
    warnings = []
    for dctrl_bin in manifest.all_packages:
        readme, _ = _dh_config_file(
            debian_dir,
            dctrl_bin,
            "README.debian",
            "dh_installdocs",
            acceptable_migration_issues,
            warnings,
            None,
            support_executable_files=False,
        )
        if readme is None:
            continue
        new_name = readme.name.replace("README.debian", "README.Debian")
        assert readme != new_name
        _rename_file_if_exists(
            debian_dir,
            readme.name,
            new_name,
            warnings,
            paths_to_rename,
            paths_to_remove,
        )

    yield FeatureMigration(
        "misspelled README.Debian files",
        warnings=warnings,
        remove_paths_on_success=paths_to_remove,
        rename_paths_on_success=paths_to_rename,
        successful_manifest_changes=0,
        already_present=0,
    )


def migrate_doc_base_files(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    _: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    paths_to_rename = []
    paths_to_remove = []
    warnings = []
    manifest_changes = 0
    already_present = 0
    # ignore the dh_make ".EX" file if one should still be present. The dh_installdocs tool ignores it too.
    possible_effected_doc_base_files = [
        f
        for f in debian_dir.iterdir
        if (
            (".doc-base." in f.name or f.name.startswith("doc-base."))
            and not f.name.endswith("doc-base.EX")
        )
    ]
    known_packages = {d.name: d for d in manifest.all_packages}
    main_package = [d for d in manifest.all_packages if d.is_main_package][0]
    for doc_base_file in possible_effected_doc_base_files:
        parts = doc_base_file.name.split(".")
        owning_package = known_packages.get(parts[0])
        if owning_package is None:
            owning_package = main_package
            package_part = None
        else:
            package_part = parts[0]
            parts = parts[1:]

        if not parts or parts[0] != "doc-base":
            # Not a doc-base file after all
            continue

        if len(parts) > 1:
            name_part = ".".join(parts[1:])
            if package_part is None:
                # Named files must have a package prefix
                package_part = owning_package.name
        else:
            # No rename needed
            continue

        new_basename = ".".join(filter(None, (package_part, name_part, "doc-base")))
        _rename_file_if_exists(
            debian_dir,
            doc_base_file.name,
            new_basename,
            warnings,
            paths_to_rename,
            paths_to_remove,
        )

    yield FeatureMigration(
        "doc-base files",
        warnings=warnings,
        remove_paths_on_success=paths_to_remove,
        rename_paths_on_success=paths_to_rename,
        successful_manifest_changes=manifest_changes,
        already_present=already_present,
    )


def migrate_dh_hook_targets(
    debian_dir: VirtualPath,
    _: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    warnings = []
    source_root = os.path.dirname(debian_dir.fs_path)
    detected_hook_targets = json.loads(
        subprocess.check_output(
            ["dh_assistant", "detect-hook-targets"],
            cwd=source_root,
        ).decode("utf-8")
    )
    sample_hook_target = None

    for hook_target_def in detected_hook_targets["hook-targets"]:
        if hook_target_def["is-empty"]:
            continue
        command = hook_target_def["command"]
        if command not in DH_COMMANDS_REPLACED:
            continue
        hook_target = hook_target_def["target-name"]
        if sample_hook_target is None:
            sample_hook_target = hook_target
        warnings.append(
            f"TODO: MANUAL MIGRATION required for hook target {hook_target}"
        )
    if warnings and "dh-hook-targets" not in acceptable_migration_issues:
        assert sample_hook_target
        raise UnsupportedFeature(
            f"The debian/rules file contains one or more non empty dh hook targets that will not"
            f" be run with the debputy (or zz-debputy) dh sequence. One of these would be"
            f" {sample_hook_target}.",
            ["dh-hook-targets"],
        )
    yield FeatureMigration(
        "dh hook targets",
        warnings=warnings,
        successful_manifest_changes=0,
        already_present=0,
    )


def detect_unsupported_features(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    warnings = []

    for unsupported_config in UNSUPPORTED_DH_CONFIGS_AND_TOOLS:
        _unsupported_debhelper_config_file(
            debian_dir,
            manifest,
            unsupported_config,
            acceptable_migration_issues,
            warnings,
        )
    yield FeatureMigration(
        "known unsupported features",
        warnings=warnings,
        successful_manifest_changes=0,
        already_present=0,
    )


def detect_dh_addons(
    debian_dir: VirtualPath,
    _manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    warnings = []

    ctrl_file = debian_dir.get("control")
    if not ctrl_file:
        warnings.append(
            "Cannot find debian/control. Detection of unsupported/missing dh-sequence addon"
            " could not be performed. Please ensure the package will Build-Depend on dh-sequence-debputy "
            " and not rely on any other debhelper sequence addons except those debputy explicitly supports."
        )

    else:
        dep_regex = re.compile("^([a-z0-9][-+.a-z0-9]+)", re.ASCII)
        sequences = set()

        with ctrl_file.open() as fd:
            ctrl = list(Deb822.iter_paragraphs(fd))
        source_paragraph = ctrl[0] if ctrl else {}

        for f in ("Build-Depends", "Build-Depends-Indep", "Build-Depends-Arch"):
            field = source_paragraph.get(f)
            if not field:
                continue

            for dep_clause in (d.strip() for d in field.split(",")):
                match = dep_regex.match(dep_clause.strip())
                if not match:
                    continue
                dep = match.group(1)
                if not dep.startswith("dh-sequence-"):
                    continue
                sequences.add(dep)
        saw_dh_debputy = (
            "dh-sequence-debputy" in sequences or "dh-sequence-zz-debputy" in sequences
        )
        sequences -= SUPPORTED_DH_ADDONS
        alt_key = "unsupported-dh-sequences"
        for sequence in sequences:
            # sequence includes "dh-sequence", so it becomes "unsupported-dh-sequence-foo"
            key = f"unsupported-{sequence}"
            msg = f'The dh addon "{sequence}" is not known to work with dh-debputy and might malfunction'
            if (
                key not in acceptable_migration_issues
                and alt_key not in acceptable_migration_issues
            ):
                raise UnsupportedFeature(msg, [key, alt_key])
            warnings.append(msg)

        if not saw_dh_debputy:
            warnings.append(
                "Missing Build-Depends on dh-sequence-debputy or dh-sequence-zz-debputy"
            )

    yield FeatureMigration(
        "Check for dh-sequence-addons",
        warnings=warnings,
        successful_manifest_changes=0,
        already_present=0,
    )


def _rename_file_if_exists(
    debian_dir: VirtualPath,
    source: str,
    dest: str,
    warnings: List[str],
    paths_to_rename: List[Tuple[str, str]],
    paths_to_remove: List[str],
) -> None:
    source_path = debian_dir.get(source)
    dest_path = debian_dir.get(dest)
    spath = (
        source_path.path
        if source_path is not None
        else os.path.join(debian_dir.path, source)
    )
    dpath = (
        dest_path.path if dest_path is not None else os.path.join(debian_dir.path, dest)
    )
    if source_path is not None and source_path.is_file:
        if dest_path is not None:
            if not dest_path.is_file:
                warnings.append(
                    f'TODO: MANUAL MIGRATION - there is a "{spath}" (file) and "{dpath}" (not a file).'
                    f' The migration wanted to replace "{spath}" with "{dpath}", but since "{dpath}" is not'
                    " a file, this step is left as a manual migration."
                )
                return
            if (
                subprocess.call(["cmp", "-s", source_path.fs_path, dest_path.fs_path])
                != 0
            ):
                warnings.append(
                    f'TODO: MANUAL MIGRATION - there is a "{source_path.path}" and "{dest_path.path}"'
                    f" file. Normally these files are for the same package and there would only be one of"
                    f" them. In this case, they both exist but their content differs. Be advised that"
                    f' debputy tool will use the "{dest_path.path}".'
                )
            else:
                paths_to_remove.append(dest_path.fs_path)
        else:
            paths_to_rename.append(
                (source_path.fs_path, os.path.join(debian_dir.fs_path, dest))
            )
    elif source_path is not None:
        warnings.append(
            f'TODO: MANUAL MIGRATION - The migration would normally have renamed "{spath}" to "{dpath}".'
            f' However, the migration assumed "{spath}" would be a file and it is not. Therefore, this step'
            " as a manual migration."
        )


def _find_dh_config_file_for_any_pkg(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    unsupported_config: UnsupportedDHConfig,
) -> Iterable[VirtualPath]:
    for dctrl_bin in manifest.all_packages:
        dh_config_file = dhe_pkgfile(
            debian_dir,
            dctrl_bin,
            unsupported_config.dh_config_basename,
            bug_950723_prefix_matching=unsupported_config.bug_950723_prefix_matching,
        )
        if dh_config_file is not None:
            yield dh_config_file


def _unsupported_debhelper_config_file(
    debian_dir: VirtualPath,
    manifest: HighLevelManifest,
    unsupported_config: UnsupportedDHConfig,
    acceptable_migration_issues: AcceptableMigrationIssues,
    warnings: List[str],
) -> None:
    dh_config_files = list(
        _find_dh_config_file_for_any_pkg(debian_dir, manifest, unsupported_config)
    )
    if not dh_config_files:
        return
    dh_tool = unsupported_config.dh_tool
    basename = unsupported_config.dh_config_basename
    file_stem = (
        f"@{basename}" if unsupported_config.bug_950723_prefix_matching else basename
    )
    dh_config_file = dh_config_files[0]
    if unsupported_config.is_missing_migration:
        warnings.append(
            f'Missing migration support for the "{dh_config_file.path}" debhelper config file'
            f" (used by {dh_tool}). Manual migration may be feasible depending on the exact features"
            " required."
        )
        return
    primary_key = f"unsupported-dh-config-file-{file_stem}"
    secondary_key = "any-unsupported-dh-config-file"
    if (
        primary_key not in acceptable_migration_issues
        and secondary_key not in acceptable_migration_issues
    ):
        msg = (
            f'The "{dh_config_file.path}" debhelper config file (used by {dh_tool} is currently not'
            " supported by debputy."
        )
        raise UnsupportedFeature(
            msg,
            [primary_key, secondary_key],
        )
    for dh_config_file in dh_config_files:
        warnings.append(
            f'TODO: MANUAL MIGRATION - Use of unsupported "{dh_config_file.path}" file (used by {dh_tool})'
        )
