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

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.packages import BinaryPackage
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


UNSUPPORTED_DH_CONFIGS_AND_TOOLS = [
    UnsupportedDHConfig("dirs", "dh_installdirs"),
    UnsupportedDHConfig("manpages", "dh_installman"),
    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"),
    UnsupportedDHConfig(
        "upstart", "dh_installinit"
    ),  # Should be long dead, but we might as well detect it.
    # 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("menu", "dh_installmenu"),
    UnsupportedDHConfig("menu-method", "dh_installmenu"),
    UnsupportedDHConfig("alternatives", "dh_installalternatives"),
    UnsupportedDHConfig("ucf", "dh_ucf"),
    UnsupportedDHConfig("wm", "dh_installwm"),
    UnsupportedDHConfig("not-installed", "dh_missing"),
    UnsupportedDHConfig("triggers", "dh_installdeb"),
    UnsupportedDHConfig("postinst", "dh_installdeb"),
    UnsupportedDHConfig("postrm", "dh_installdeb"),
    UnsupportedDHConfig("preinst", "dh_installdeb"),
    UnsupportedDHConfig("prerm", "dh_installdeb"),
]


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}" 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} 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(
    dctrl_bin: BinaryPackage,
    basename,
    helper_name,
    acceptable_migration_issues: AcceptableMigrationIssues,
    warnings: List[str],
    paths_to_remove: Optional[List[str]],
    support_executable_files=False,
    allow_dh_exec_rename=False,
) -> Tuple[Optional[str], Optional[Iterable[DHConfigFileLine]]]:
    dh_config_file = dhe_pkgfile(dctrl_bin, basename)
    if dh_config_file is None:
        return None, None
    if os.access(dh_config_file, os.X_OK) 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)
    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}" 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}" 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}" 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}" 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}" 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}" 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(
    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(
            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(
    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 = []
    for dctrl_bin in manifest.all_packages:
        install_file, content = _dh_config_file(
            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
        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}."
                    " Missing support for conditions."
                )
            priority = False
            if "=>" in dhe_line.tokens:
                if dhe_line.tokens[0] == "=>" and len(dhe_line.tokens) == 2:
                    priority = True
                    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} on'
                                f' line {dhe_line.line_no}. Remove the "debian/tmp" prefix manually and try'
                                " again."
                            )
                    install_rule = AbstractMutableYAMLInstallRule.install_dest(
                        path,
                        dctrl_bin.name,
                        dest_dir=dest_dir,
                    )
                elif len(dhe_line.tokens) != 3:
                    _error(
                        f"Validation error in {install_file} 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,
                    )
            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 len(sources) == 1:
                    sources = _normalize_path(sources[0], with_prefix=False)
                else:
                    sources = [_normalize_path(w, with_prefix=False) for w in sources]

                install_rule = AbstractMutableYAMLInstallRule.install_dest(
                    sources,
                    dctrl_bin.name,
                    dest_dir=dest_dir,
                )
            if priority:
                priority_lines.append(install_rule)
            else:
                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(
    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
    for dctrl_bin in manifest.all_packages:
        install_file, content = _dh_config_file(
            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}."
                    " 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,
        )
        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(
    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
    for dctrl_bin in manifest.all_packages:
        install_file, content = _dh_config_file(
            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}."
                    " 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,
        )
        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 detect_pam_files(
    manifest: HighLevelManifest,
    _acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    assumed_compat = None
    for dctrl_bin in manifest.all_packages:
        dh_config_file = dhe_pkgfile(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(
    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(dctrl_bin, "tmpfile")
        if dh_config_file is not None:
            target = (
                dh_config_file.replace(".tmpfile", ".tmpfiles")
                if "." in dh_config_file
                else "tmpfiles"
            )
            _rename_file_if_exists(
                dh_config_file, 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(
    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(
            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(
    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(
            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(
    manifest: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    paths_to_rename = []
    paths_to_remove = []
    warnings = []
    for dctrl_bin in manifest.all_packages:
        filename, _ = _dh_config_file(
            dctrl_bin,
            "README.debian",
            "dh_installdocs",
            acceptable_migration_issues,
            warnings,
            None,
            support_executable_files=False,
        )
        if filename is None:
            continue
        new_name = filename.replace("README.debian", "README.Debian")
        assert filename != new_name
        _rename_file_if_exists(
            filename,
            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(
    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 os.listdir("./debian")
        if (
            (".doc-base." in f or f.startswith("doc-base."))
            and not f.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.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(
            os.path.join("debian", doc_base_file),
            os.path.join("debian", 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(
    _: HighLevelManifest,
    acceptable_migration_issues: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    warnings = []
    detected_hook_targets = json.loads(
        subprocess.check_output(["dh_assistant", "detect-hook-targets"]).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 migrate_shlibs_file(
    manifest: HighLevelManifest,
    _: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    yield _migrate_packageless_file(manifest, "shlibs")


def migrate_symbols_file(
    manifest: HighLevelManifest,
    _: AcceptableMigrationIssues,
) -> Iterable[FeatureMigration]:
    yield _migrate_packageless_file(manifest, "symbols")


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

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


def _migrate_packageless_file(
    manifest: HighLevelManifest, basename: str
) -> FeatureMigration:
    paths_to_remove = []
    paths_to_rename = []
    warnings = []
    pkgless_variant = f"debian/{basename}"
    main_package = [p for p in manifest.all_packages if p.is_main_package][0]
    main_variant = f"debian/{main_package.name}.{basename}"
    _rename_file_if_exists(
        pkgless_variant, main_variant, warnings, paths_to_rename, paths_to_remove
    )
    return FeatureMigration(
        f"Migrate {pkgless_variant} to {main_variant}",
        successful_manifest_changes=bool(len(paths_to_remove) + len(paths_to_rename)),
        warnings=warnings,
        already_present=bool(len(warnings)),
        rename_paths_on_success=paths_to_rename,
        remove_paths_on_success=paths_to_remove,
    )


def _rename_file_if_exists(
    source: str,
    dest: str,
    warnings: List[str],
    paths_to_rename: List[Tuple[str, str]],
    paths_to_remove: List[str],
) -> None:
    if os.path.isfile(source):
        if os.path.exists(dest):
            if not os.path.isfile(dest):
                warnings.append(
                    f'TODO: MANUAL MIGRATION - there is a "{source}" (file) and "{dest}" (not a file).'
                    f' The migration wanted to replace "{source}" with "{dest}", but since "{dest}" is not'
                    " a file, this step is left as a manual migration."
                )
                return
            if subprocess.call(["cmp", "-s", source, dest]) != 0:
                warnings.append(
                    f'TODO: MANUAL MIGRATION - there is a "{source}" and "{dest}"'
                    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}".'
                )
            else:
                paths_to_remove.append(source)
        else:
            paths_to_rename.append((source, dest))
    elif os.path.exists(source):
        warnings.append(
            f'TODO: MANUAL MIGRATION - The migration would normally have renamed "{source}" to "{dest}".'
            f' However, the migration assumed "{source}" would be a file and it is not. Therefore, this step'
            " as a manual migration."
        )


def _find_dh_config_file_for_any_pkg(
    manifest: HighLevelManifest,
    unsupported_config: UnsupportedDHConfig,
) -> Iterable[str]:
    for dctrl_bin in manifest.all_packages:
        dh_config_file = dhe_pkgfile(
            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(
    manifest: HighLevelManifest,
    unsupported_config: UnsupportedDHConfig,
    acceptable_migration_issues: AcceptableMigrationIssues,
    warnings: List[str],
) -> None:
    dh_config_files = list(
        _find_dh_config_file_for_any_pkg(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
    )
    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
    ):
        dh_config_file = dh_config_files[0]
        msg = (
            f'The "{dh_config_file}" 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}" file (used by {dh_tool})'
        )
