import dataclasses
import os
from contextlib import suppress
from typing import (
    List,
    Dict,
    Iterable,
    Tuple,
    Mapping,
    Any,
    Union,
    Optional,
    TypeVar,
    Generic,
    Iterator,
    cast,
)

from debian.debian_support import DpkgArchTable
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap, CommentedSeq

from ._deb_options_profiles import DebOptionsAndProfiles
from ._manifest_constants import *
from .architecture_support import DpkgArchitectureBuildProcessValuesTable
from .builtin_manifest_rules import DEFAULT_PATH_INFO
from .filesystem_scan import FSPath, build_fs_from_root_dir, FSRootDir
from .installations import InstallRule, SourcePathMatcher
from .intermediate_manifest import TarMember, PathType, IntermediateManifest
from .maintscript_snippet import (
    DpkgMaintscriptHelperCommand,
    MaintscriptSnippetContainer,
)
from .manifest_conditions import ConditionContext, ManifestCondition
from .manifest_path_rules import (
    ManifestPathRule,
    ManifestPathInfoRuleType,
    EnsureDirectoryPathRule,
)
from .packages import BinaryPackage
from .packaging.makeshlibs import SONAMEInfo, extract_soname_info
from .path_matcher import MatchRule, PathMatcher
from .plugin.api.impl import PluginProvidedFeatureSet
from .plugin.api.spec import FlushableSubstvars
from .substitution import Substitution
from .transformation_rules import (
    TransformationRule,
    PreProvidedExclusionTransformationRule,
    PRE_PROVIDED_EXCLUDE_RULES,
)
from .util import _error

MANIFEST_YAML = YAML()


@dataclasses.dataclass(slots=True, frozen=True)
class BinaryPackageData:
    binary_package: BinaryPackage
    binary_staging_root_dir: str
    fs_root: FSPath
    substvars: FlushableSubstvars
    shared_libraries: Optional[List[SONAMEInfo]]


@dataclasses.dataclass
class ChangeDescription:
    match_rule: MatchRule
    path_info: ManifestPathRule
    matched_fs_path: Optional[FSPath] = None

    def _pretty_rule_def(self) -> str:
        if self.path_info.is_builtin_definition:
            source = f"(builtin rule: {self.path_info.definition_source})"
        elif self.path_info.is_implicit_definition:
            source = f"(internal - triggered by {self.path_info.definition_source})"
        else:
            source = f"(from {self.path_info.definition_source})"
        if self.matched_fs_path:
            matched = f'"{self.matched_fs_path.path}" was matched by: '
        else:
            matched = ""
        return f'{matched}"{self.match_rule.describe_match_short()}" {source}'

    def _pretty_format_ensure_metadata(self) -> str:
        unchanged = "(unchanged)"
        attributes = [
            f"   - {self._pretty_rule_def()}",
            f"     path metadata will be set to:",
            f"         owner: {self.path_info.uname} (uid: {self.path_info.uid})",
            f"         group: {self.path_info.gname} (gid: {self.path_info.gid})",
        ]
        if self.path_info.symbolic_mode is not None:
            attributes.append(f"         symbolic mode: {self.path_info.symbolic_mode}")
        elif (
            self.match_rule.path_type is not None
            or self.path_info.mode
            == self.path_info.dirmode
            == self.path_info.special_mode
        ):
            filemode = (
                oct(self.path_info.mode)[2:]
                if self.path_info.mode is not None
                else unchanged
            )
            attributes.append(f"         mode: {filemode}")
        else:
            filemode = (
                oct(self.path_info.mode)[2:]
                if self.path_info.mode is not None
                else unchanged
            )
            dirmode = (
                oct(self.path_info.dirmode)[2:]
                if self.path_info.dirmode is not None
                else unchanged
            )
            special_mode = (
                oct(self.path_info.special_mode)[2:]
                if self.path_info.special_mode is not None
                else unchanged
            )
            attributes.append(
                f"         filemode: {filemode}     [when matching a file]"
            )
            attributes.append(
                f"         dirmode: {dirmode}      [when matching a directory]"
            )
            attributes.append(
                f"         other mode: {special_mode}   [when matching non-file/non-directory]"
            )

        return "\n".join(attributes)

    def _pretty_format_remove_path(self) -> str:
        return f"   - {self._pretty_rule_def()}"

    def _pretty_format_create_symlink(self) -> str:
        attributes = [
            f"   - {self._pretty_rule_def()}",
            f"     link target: {self.path_info.link_target}",
        ]
        return "\n".join(attributes)

    def _pretty_format_create_directory(self) -> str:
        attributes = [
            f"   - {self._pretty_rule_def()}",
        ]
        if (
            self.path_info.uid != 0
            or self.path_info.gid != 0
            or self.path_info.mode != 0o755
        ):
            attributes.append(
                f"         owner: {self.path_info.uname} (uid: {self.path_info.uid})"
            )
            attributes.append(
                f"         group: {self.path_info.gname} (gid: {self.path_info.gid})"
            )
            attributes.append(
                f'         mode: {oct(self.path_info.mode)[2:] if self.path_info.mode else "(unchanged)"}'
            )
        else:
            attributes.append(
                f"         default owner and mode (root:root and mode 0755)"
            )
        return "\n".join(attributes)

    def pretty_format(self) -> str:
        rule_type = self.path_info.rule_type
        if rule_type == ManifestPathInfoRuleType.ENSURE_METADATA:
            return self._pretty_format_ensure_metadata()
        elif rule_type == ManifestPathInfoRuleType.EXCLUDE_PATH:
            return self._pretty_format_remove_path()
        elif rule_type == ManifestPathInfoRuleType.CREATE_SYMLINK:
            return self._pretty_format_create_symlink()
        elif rule_type == ManifestPathInfoRuleType.CREATE_DIRECTORY:
            return self._pretty_format_create_directory()
        raise NotImplementedError(f"Missing case for {rule_type}")


@dataclasses.dataclass(slots=True)
class PackageTransformationDefinition:
    binary_package: BinaryPackage
    binary_version: Optional[str]
    ensure_directories: Dict[str, EnsureDirectoryPathRule]
    create_virtual_paths: Dict[str, ManifestPathRule]
    path_specs: Dict[MatchRule, ManifestPathRule]
    substitution: Substitution
    dpkg_maintscript_helper_snippets: List[DpkgMaintscriptHelperCommand]
    maintscript_snippets: Dict[str, MaintscriptSnippetContainer]
    transformations: List[TransformationRule]
    is_auto_generated_package: bool


@dataclasses.dataclass(slots=True)
class TraceEvent:
    fs_path: FSPath

    @property
    def is_active(self) -> bool:
        return True

    @property
    def condition(self) -> Optional[ManifestCondition]:
        return None

    def describe_event(self) -> str:
        raise NotImplementedError


@dataclasses.dataclass(slots=True)
class TracePathMatcherEvent(TraceEvent):
    manifest_path_rule: ManifestPathRule
    condition_context: ConditionContext

    @property
    def is_active(self) -> bool:
        condition = self.condition
        return condition is None or condition.evaluate(self.condition_context)

    @property
    def condition(self) -> Optional[ManifestCondition]:
        return self.manifest_path_rule.condition

    def describe_event(self) -> str:
        manifest_path_rule = self.manifest_path_rule
        change_description = ChangeDescription(
            manifest_path_rule.match_rule, manifest_path_rule, self.fs_path
        )
        return change_description.pretty_format()


def _trace_path_matcher(
    path_matcher: PathMatcher[ManifestPathRule],
    fs_root: FSPath,
    condition_context: ConditionContext,
) -> Iterable[TracePathMatcherEvent]:
    yield from (
        TracePathMatcherEvent(fs_path, manifest_path_rule, condition_context)
        for fs_path, manifest_path_rule in path_matcher.resolve_all(fs_root)
    )


def _generate_intermediate_manifest(
    path_matcher: PathMatcher[ManifestPathRule],
    fs_root: FSPath,
    clamp_mtime_to: int,
    condition_context: ConditionContext,
) -> Iterable[TarMember]:
    symlinks = []
    for trace_event in _trace_path_matcher(path_matcher, fs_root, condition_context):
        manifest_path_rule = trace_event.manifest_path_rule
        if not trace_event.is_active:
            manifest_path_rule.mark_used()
            continue
        tar_member = manifest_path_rule.generate_tar_member(
            trace_event.fs_path, clamp_mtime_to=clamp_mtime_to
        )
        if tar_member.path_type == PathType.SYMLINK:
            symlinks.append(tar_member)
            continue
        yield tar_member
    yield from symlinks


ST = TypeVar("ST")
T = TypeVar("T")


class AbstractYAMLSubStore(Generic[ST]):
    def __init__(
        self,
        parent_store: Any,
        parent_key: Optional[Union[int, str]],
        store: Optional[ST] = None,
    ) -> None:
        if parent_store is not None and parent_key is not None:
            try:
                from_parent_store = parent_store[parent_key]
            except (KeyError, IndexError):
                from_parent_store = None
            if (
                store is not None
                and from_parent_store is not None
                and store is not parent_store
            ):
                raise ValueError(
                    "Store is provided but is not the one already in the parent store"
                )
            if store is None:
                store = from_parent_store
        self._parent_store = parent_store
        self._parent_key = parent_key
        self._is_detached = (
            parent_key is None or parent_store is None or parent_key not in parent_store
        )
        assert self._is_detached or store is not None
        if store is None:
            store = self._create_new_instance()
        self._store: ST = store

    def _create_new_instance(self) -> ST:
        raise NotImplementedError

    def create_definition_if_missing(self) -> None:
        if self._is_detached:
            self.create_definition()

    def create_definition(self) -> None:
        if not self._is_detached:
            raise RuntimeError("Definition is already present")
        parent_store = self._parent_store
        if parent_store is None:
            raise RuntimeError(
                f"Definition is not attached to any parent!? ({self.__class__.__name__})"
            )
        if isinstance(parent_store, list):
            assert self._parent_key is None
            self._parent_key = len(parent_store)
            self._parent_store.append(self._store)
        else:
            parent_store[self._parent_key] = self._store
        self._is_detached = False

    def remove_definition(self) -> None:
        self._ensure_attached()
        del self._parent_store[self._parent_key]
        if isinstance(self._parent_store, list):
            self._parent_key = None
        self._is_detached = True

    def _ensure_attached(self) -> None:
        if self._is_detached:
            raise RuntimeError("The definition has been removed!")


class AbstractYAMLListSubStore(Generic[T], AbstractYAMLSubStore[List[T]]):
    def _create_new_instance(self) -> List[T]:
        return CommentedSeq()


class AbstractYAMLDictSubStore(Generic[T], AbstractYAMLSubStore[Dict[str, T]]):
    def _create_new_instance(self) -> Dict[str, T]:
        return CommentedMap()


class MutableCondition:
    @classmethod
    def arch_matches(cls, arch_filter: str):
        return CommentedMap({MK_CONDITION_ARCH_MATCHES: arch_filter})

    @classmethod
    def build_profiles_matches(cls, build_profiles_matches: str):
        return CommentedMap(
            {MK_CONDITION_BUILD_PROFILES_MATCHES: build_profiles_matches}
        )


class MutableYAMLSymlink(AbstractYAMLDictSubStore[Any]):
    @classmethod
    def new_symlink(
        cls, link_path: str, link_target: str, condition: Optional[Any]
    ) -> "MutableYAMLSymlink":
        content = {
            MK_CREATE_SYMLINKS_LINK_PATH: link_path,
            MK_CREATE_SYMLINKS_LINK_TARGET: link_target,
        }
        if condition is not None:
            content["when"] = condition
        return cls(None, None, store=CommentedMap(content))

    @property
    def symlink_path(self) -> str:
        return self._store[MK_CREATE_SYMLINKS_LINK_PATH]

    @symlink_path.setter
    def symlink_path(self, path: str) -> None:
        self._store[MK_CREATE_SYMLINKS_LINK_PATH] = path

    @property
    def symlink_target(self) -> Optional[str]:
        return self._store[MK_CREATE_SYMLINKS_LINK_TARGET]

    @symlink_target.setter
    def symlink_target(self, target: str) -> None:
        self._store[MK_CREATE_SYMLINKS_LINK_TARGET] = target


class MutableYAMLConffileManagementItem(AbstractYAMLDictSubStore[Any]):
    @classmethod
    def rm_conffile(
        cls,
        conffile: str,
        prior_to_version: Optional[str],
        owning_package: Optional[str],
    ) -> "MutableYAMLConffileManagementItem":
        r = cls(
            None,
            None,
            store=CommentedMap(
                {
                    MK_CONFFILE_MANAGEMENT_REMOVE: CommentedMap(
                        {MK_CONFFILE_MANAGEMENT_REMOVE_PATH: conffile}
                    )
                }
            ),
        )
        r.prior_to_version = prior_to_version
        r.owning_package = owning_package
        return r

    @classmethod
    def mv_conffile(
        cls,
        old_conffile: str,
        new_conffile: str,
        prior_to_version: Optional[str],
        owning_package: Optional[str],
    ) -> "MutableYAMLConffileManagementItem":
        r = cls(
            None,
            None,
            store=CommentedMap(
                {
                    MK_CONFFILE_MANAGEMENT_RENAME: CommentedMap(
                        {
                            MK_CONFFILE_MANAGEMENT_RENAME_SOURCE: old_conffile,
                            MK_CONFFILE_MANAGEMENT_RENAME_TARGET: new_conffile,
                        }
                    )
                }
            ),
        )
        r.prior_to_version = prior_to_version
        r.owning_package = owning_package
        return r

    @property
    def _container(self) -> Dict[str, Any]:
        assert len(self._store) == 1
        return next(iter(self._store.values()))

    @property
    def command(self) -> str:
        assert len(self._store) == 1
        return next(iter(self._store))

    @property
    def obsolete_conffile(self) -> str:
        if self.command == MK_CONFFILE_MANAGEMENT_REMOVE:
            return self._container[MK_CONFFILE_MANAGEMENT_REMOVE_PATH]
        assert self.command == MK_CONFFILE_MANAGEMENT_RENAME
        return self._container[MK_CONFFILE_MANAGEMENT_RENAME_SOURCE]

    @obsolete_conffile.setter
    def obsolete_conffile(self, value: str) -> None:
        if self.command == MK_CONFFILE_MANAGEMENT_REMOVE:
            self._container[MK_CONFFILE_MANAGEMENT_REMOVE_PATH] = value
        else:
            assert self.command == MK_CONFFILE_MANAGEMENT_RENAME
            self._container[MK_CONFFILE_MANAGEMENT_RENAME_SOURCE] = value

    @property
    def new_conffile(self) -> str:
        if self.command != MK_CONFFILE_MANAGEMENT_RENAME:
            raise TypeError(
                f"The new_conffile attribute is only applicable to command {MK_CONFFILE_MANAGEMENT_RENAME}."
                f" This is a {self.command}"
            )
        return self._container[MK_CONFFILE_MANAGEMENT_RENAME_TARGET]

    @new_conffile.setter
    def new_conffile(self, value: str) -> None:
        if self.command != MK_CONFFILE_MANAGEMENT_RENAME:
            raise TypeError(
                f"The new_conffile attribute is only applicable to command {MK_CONFFILE_MANAGEMENT_RENAME}."
                f" This is a {self.command}"
            )
        self._container[MK_CONFFILE_MANAGEMENT_RENAME_TARGET] = value

    @property
    def prior_to_version(self) -> Optional[str]:
        return self._container.get(MK_CONFFILE_MANAGEMENT_X_PRIOR_TO_VERSION)

    @prior_to_version.setter
    def prior_to_version(self, value: Optional[str]) -> None:
        if value is None:
            try:
                del self._container[MK_CONFFILE_MANAGEMENT_X_PRIOR_TO_VERSION]
            except KeyError:
                pass
        else:
            self._container[MK_CONFFILE_MANAGEMENT_X_PRIOR_TO_VERSION] = value

    @property
    def owning_package(self) -> Optional[str]:
        return self._container[MK_CONFFILE_MANAGEMENT_X_PRIOR_TO_VERSION]

    @owning_package.setter
    def owning_package(self, value: Optional[str]) -> None:
        if value is None:
            try:
                del self._container[MK_CONFFILE_MANAGEMENT_X_OWNING_PACKAGE]
            except KeyError:
                pass
        else:
            self._container[MK_CONFFILE_MANAGEMENT_X_OWNING_PACKAGE] = value


class MutableYAMLPackageDefinition(AbstractYAMLDictSubStore):
    def _list_store(
        self, key, *, create_if_absent: bool = False
    ) -> Optional[List[Dict[str, Any]]]:
        if self._is_detached or key not in self._store:
            if create_if_absent:
                return None
            self.create_definition_if_missing()
            self._store[key] = []
        return self._store[key]

    def _insert_item(self, key: str, item: AbstractYAMLDictSubStore) -> None:
        parent_store = self._list_store(key, create_if_absent=True)
        assert parent_store is not None
        if not item._is_detached or (
            item._parent_store is not None and item._parent_store is not parent_store
        ):
            raise RuntimeError(
                "Item is already attached or associated with a different container"
            )
        item._parent_store = parent_store
        item.create_definition()

    def add_symlink(self, symlink: MutableYAMLSymlink) -> None:
        self._insert_item(MK_CREATE_SYMLINKS, symlink)

    def symlinks(self) -> Iterable[MutableYAMLSymlink]:
        store = self._list_store(MK_CREATE_SYMLINKS)
        if store is None:
            return
        yield from (MutableYAMLSymlink(store, i) for i in range(len(store)))

    def conffile_management_items(self) -> Iterable[MutableYAMLConffileManagementItem]:
        store = self._list_store(MK_CONFFILE_MANAGEMENT)
        if store is None:
            return
        yield from (
            MutableYAMLConffileManagementItem(store, i) for i in range(len(store))
        )

    def add_conffile_management(
        self, conffile_management_item: MutableYAMLConffileManagementItem
    ) -> None:
        self._insert_item(MK_CONFFILE_MANAGEMENT, conffile_management_item)


class AbstractMutableYAMLInstallRule(AbstractYAMLDictSubStore):
    @property
    def _container(self) -> Dict[str, Any]:
        assert len(self._store) == 1
        return next(iter(self._store.values()))

    @classmethod
    def install_dest(
        cls,
        sources: Union[str, List[str]],
        into: Union[str, List[str]],
        *,
        dest_dir: Optional[str] = None,
    ) -> "MutableYAMLInstallRuleInstall":
        r = MutableYAMLInstallRuleInstall(
            None,
            None,
            store=CommentedMap(
                {
                    MK_INSTALLATIONS_INSTALL: CommentedMap(
                        {
                            MK_INSTALLATIONS_INSTALL_SOURCES: sources,
                        }
                    )
                }
            ),
        )
        r.dest_dir = dest_dir
        # We could it via the literal definition, but this puts "into" after "dest-dir", which looks better
        r.into = into
        return r

    @classmethod
    def install_as(
        cls,
        source: str,
        install_as: str,
        into: Union[str, List[str]],
    ) -> "MutableYAMLInstallRuleInstall":
        return MutableYAMLInstallRuleInstall(
            None,
            None,
            store=CommentedMap(
                {
                    MK_INSTALLATIONS_INSTALL: CommentedMap(
                        {
                            MK_INSTALLATIONS_INSTALL_SOURCES: source,
                            MK_INSTALLATIONS_INSTALL_AS: install_as,
                            MK_INSTALLATIONS_INSTALL_INTO: into,
                        }
                    )
                }
            ),
        )

    @classmethod
    def install_docs(
        cls,
        source: Union[str, List[str]],
        into: Union[str, List[str]],
    ) -> "MutableYAMLInstallRuleInstallDocLike":
        return MutableYAMLInstallRuleInstallDocLike(
            None,
            None,
            store=CommentedMap(
                {
                    MK_INSTALLATIONS_INSTALL_DOCS: CommentedMap(
                        {
                            MK_INSTALLATIONS_INSTALL_SOURCES: source,
                            MK_INSTALLATIONS_INSTALL_INTO: into,
                        }
                    )
                }
            ),
        )

    @classmethod
    def install_examples(
        cls,
        source: Union[str, List[str]],
        into: Union[str, List[str]],
    ) -> "MutableYAMLInstallRuleInstallDocLike":
        return MutableYAMLInstallRuleInstallDocLike(
            None,
            None,
            store=CommentedMap(
                {
                    MK_INSTALLATIONS_INSTALL_EXAMPLES: CommentedMap(
                        {
                            MK_INSTALLATIONS_INSTALL_SOURCES: source,
                            MK_INSTALLATIONS_INSTALL_INTO: into,
                        }
                    )
                }
            ),
        )


class MutableYAMLInstallRuleInstallDocLike(AbstractMutableYAMLInstallRule):
    pass


class MutableYAMLInstallRuleInstall(AbstractMutableYAMLInstallRule):
    @property
    def sources(self) -> List[str]:
        v = self._container[MK_INSTALLATIONS_INSTALL_SOURCES]
        if isinstance(v, str):
            return [v]
        return v

    @sources.setter
    def sources(self, new_value: Union[str, List[str]]) -> None:
        if isinstance(new_value, str):
            self._container[MK_INSTALLATIONS_INSTALL_SOURCES] = new_value
            return
        new_list = CommentedSeq(new_value)
        self._container[MK_INSTALLATIONS_INSTALL_SOURCES] = new_list

    @property
    def dest_dir(self) -> Optional[str]:
        return self._container.get(MK_INSTALLATIONS_INSTALL_DEST_DIR)

    @dest_dir.setter
    def dest_dir(self, new_value: Optional[str]) -> None:
        if new_value is not None and self.dest_as is not None:
            raise ValueError(
                f'Cannot both have a "{MK_INSTALLATIONS_INSTALL_DEST_DIR}" and'
                f' "{MK_INSTALLATIONS_INSTALL_AS}"'
            )
        if new_value is not None:
            self._container[MK_INSTALLATIONS_INSTALL_DEST_DIR] = new_value
        else:
            with suppress(KeyError):
                del self._container[MK_INSTALLATIONS_INSTALL_DEST_DIR]

    @property
    def dest_as(self) -> Optional[str]:
        return self._container.get(MK_INSTALLATIONS_INSTALL_AS)

    @dest_as.setter
    def dest_as(self, new_value: Optional[str]) -> None:
        if new_value is not None:
            if self.dest_dir is not None:
                raise ValueError(
                    f'Cannot both have a "{MK_INSTALLATIONS_INSTALL_DEST_DIR}" and'
                    f' "{MK_INSTALLATIONS_INSTALL_AS}"'
                )

            sources = self._container[MK_INSTALLATIONS_INSTALL_SOURCES]
            if isinstance(sources, list):
                if len(sources) != 1:
                    raise ValueError(
                        f'Cannot have "{MK_INSTALLATIONS_INSTALL_AS}" when'
                        f' "{MK_INSTALLATIONS_INSTALL_SOURCES}" is not exactly one item'
                    )
                self.sources = sources[0]
            self._container[MK_INSTALLATIONS_INSTALL_AS] = new_value
        else:
            with suppress(KeyError):
                del self._container[MK_INSTALLATIONS_INSTALL_AS]

    @property
    def into(self) -> List[str]:
        v = self._container[MK_INSTALLATIONS_INSTALL_INTO]
        if isinstance(v, str):
            return [v]
        return v

    @into.setter
    def into(self, new_value: Union[str, List[str]]) -> None:
        if isinstance(new_value, str):
            self._container[MK_INSTALLATIONS_INSTALL_INTO] = new_value
            return
        new_list = CommentedSeq(new_value)
        self._container[MK_INSTALLATIONS_INSTALL_INTO] = new_list


class MutableYAMLInstallationsDefinition(AbstractYAMLListSubStore[Any]):
    def append(self, install_rule: AbstractMutableYAMLInstallRule) -> None:
        parent_store = self._store
        if not install_rule._is_detached or (
            install_rule._parent_store is not None
            and install_rule._parent_store is not parent_store
        ):
            raise RuntimeError(
                "Item is already attached or associated with a different container"
            )
        self.create_definition_if_missing()
        install_rule._parent_store = parent_store
        install_rule.create_definition()


class MutableYAMLManifest:
    def __init__(self, store: Any) -> None:
        self._store = store

    @classmethod
    def empty_manifest(cls) -> "MutableYAMLManifest":
        return cls(CommentedMap({MK_MANIFEST_VERSION: DEFAULT_MANIFEST_VERSION}))

    @property
    def manifest_version(self) -> str:
        return self._store[MK_MANIFEST_VERSION]

    @manifest_version.setter
    def manifest_version(self, version: str) -> None:
        if version not in SUPPORTED_MANIFEST_VERSIONS:
            raise ValueError("Unsupported version")
        self._store[MK_MANIFEST_VERSION] = version

    def installations(
        self, *, create_if_absent=True
    ) -> MutableYAMLInstallationsDefinition:
        d = MutableYAMLInstallationsDefinition(self._store, MK_INSTALLATIONS)
        if create_if_absent:
            d.create_definition_if_missing()
        return d

    def package(
        self, name: str, *, create_if_absent=True
    ) -> MutableYAMLPackageDefinition:
        if MK_PACKAGES not in self._store:
            self._store[MK_PACKAGES] = CommentedMap()
        packages_store = self._store[MK_PACKAGES]
        package = packages_store.get(name)
        if package is None:
            if not create_if_absent:
                raise KeyError(name)
            assert packages_store is not None
            d = MutableYAMLPackageDefinition(packages_store, name)
            d.create_definition()
        else:
            d = MutableYAMLPackageDefinition(packages_store, name)
        return d

    def write_to(self, fd) -> None:
        MANIFEST_YAML.dump(self._store, fd)


class HighLevelManifest:
    def __init__(
        self,
        manifest_path: str,
        mutable_manifest: Optional[MutableYAMLManifest],
        install_rules: Optional[List[InstallRule]],
        binary_packages: Mapping[str, BinaryPackage],
        substitution: Substitution,
        package_transformations: Dict[str, PackageTransformationDefinition],
        dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable,
        dpkg_arch_query_table: DpkgArchTable,
        build_env: DebOptionsAndProfiles,
        reasons_for_assembly_with_dpkg_deb_requiring_root: Dict[str, str],
        plugin_provided_feature_set: PluginProvidedFeatureSet,
    ) -> None:
        self.manifest_path = manifest_path
        self.mutable_manifest = mutable_manifest
        self._install_rules = install_rules
        self._binary_packages = binary_packages
        self.substitution = substitution
        self.package_transformations = package_transformations
        self._dpkg_architecture_variables = dpkg_architecture_variables
        self._dpkg_arch_query_table = dpkg_arch_query_table
        self._build_env = build_env
        self._used_for = set()
        self._reasons_for_assembly_with_dpkg_deb_requiring_root = (
            reasons_for_assembly_with_dpkg_deb_requiring_root
        )
        self._plugin_provided_feature_set = plugin_provided_feature_set

    @property
    def dpkg_architecture_variables(self) -> DpkgArchitectureBuildProcessValuesTable:
        return self._dpkg_architecture_variables

    @property
    def build_env(self) -> DebOptionsAndProfiles:
        return self._build_env

    @property
    def plugin_provided_feature_set(self) -> PluginProvidedFeatureSet:
        return self._plugin_provided_feature_set

    @property
    def active_packages(self) -> Iterable[BinaryPackage]:
        yield from (p for p in self._binary_packages.values() if p.should_be_acted_on)

    @property
    def all_packages(self) -> Iterable[BinaryPackage]:
        yield from self._binary_packages.values()

    def change_descriptions(self, package: str) -> Iterable[ChangeDescription]:
        seen = set()
        if package not in self.package_transformations:
            _error(f"Invalid package: {package}")
        for k, v in self.package_transformations[package].path_specs.items():
            if k in seen:
                continue
            seen.add(k)
            yield ChangeDescription(
                match_rule=k,
                path_info=v,
            )

    @property
    def will_assembly_with_dpkg_deb_require_root(self) -> bool:
        return bool(self._reasons_for_assembly_with_dpkg_deb_requiring_root)

    @property
    def package_definitions_that_require_root(self) -> Iterator[Tuple[str, str]]:
        return iter(self._reasons_for_assembly_with_dpkg_deb_requiring_root.items())

    def package_state_for(self, package: str) -> PackageTransformationDefinition:
        return self.package_transformations[package]

    def perform_installations(self) -> Dict[str, BinaryPackageData]:
        package_data_table = {}
        package_name2fs_root = {}

        if self._install_rules is None and os.path.isdir("debian/tmp"):
            msg = (
                "The build system appears to have provided the output of upstream build system's"
                " install in debian/tmp.  However, these are no provisions for debputy to install"
                " any of that into any of the debian packages listed in debian/control."
                " To avoid accidentally creating empty packages, debputy will insist that you "
                " explicitly define an empty installation definition if you did not want to "
                " install any of those files even though they have been provided."
                ' Example: "installations: []"'
            )
            _error(msg)

        for dctrl_bin in self.all_packages:
            package = dctrl_bin.name
            root_dir = os.path.join("debian", package)
            if dctrl_bin.should_be_acted_on:
                if os.path.isdir(root_dir):
                    fs_root = build_fs_from_root_dir(root_dir)
                else:
                    fs_root = FSRootDir()
            else:
                # Provide an empty FSRootDir for simplicity of the install rules
                fs_root = FSRootDir()

            package_name2fs_root[package] = fs_root

        if self._install_rules:
            path_matcher = SourcePathMatcher()
            condition_context = ConditionContext(
                binary_package=None,
                substitution=self.substitution,
                build_env=self._build_env,
                dpkg_architecture_variables=self._dpkg_architecture_variables,
                dpkg_arch_query_table=self._dpkg_arch_query_table,
            )
            # FIXME: Check that every install rule remains used after transformations have run.
            for install_rule in self._install_rules:
                install_rule.perform_install(
                    path_matcher,
                    package_name2fs_root,
                    condition_context,
                )

            # FIXME: Implement dh_missing like check

        for dctrl_bin in self.all_packages:
            package = dctrl_bin.name
            fs_root = package_name2fs_root[package]
            sonames = None
            if dctrl_bin.should_be_acted_on:
                self.apply_fs_transformations(package, fs_root)
                sonames = extract_soname_info(dctrl_bin, fs_root)
                substvars_file = f"debian/{package}.substvars"
                substvars = FlushableSubstvars.load_from_path(
                    substvars_file, missing_ok=True
                )
                # We do not want to touch the substvars file (non-clean rebuild contamination)
                substvars.substvars_path = None
            else:
                substvars = FlushableSubstvars()

            package_data_table[package] = BinaryPackageData(
                dctrl_bin,
                root_dir,
                fs_root,
                substvars,
                sonames,
            )

        return package_data_table

    def trace_apply_to_binary_staging_directory(
        self,
        package: str,
        fs_root: FSPath,
    ) -> Iterable[TraceEvent]:
        if package not in self.package_transformations:
            raise ValueError(
                f'The package "{package}" was not relevant for the manifest!?'
            )
        package_transformation = self.package_transformations[package]
        condition_context = ConditionContext(
            binary_package=package_transformation.binary_package,
            substitution=package_transformation.substitution,
            build_env=self._build_env,
            dpkg_architecture_variables=self._dpkg_architecture_variables,
            dpkg_arch_query_table=self._dpkg_arch_query_table,
        )
        # TODO: Add tracing of transformation rules here
        path_matcher = PathMatcher.from_dict(
            package_transformation.path_specs, DEFAULT_PATH_INFO
        )
        yield from _trace_path_matcher(
            path_matcher,
            fs_root,
            condition_context,
        )

    def apply_fs_transformations(
        self,
        package: str,
        fs_root: FSPath,
    ) -> None:
        if package in self._used_for:
            raise ValueError(
                f"data.tar contents for {package} has already been finalized!?"
            )
        if package not in self.package_transformations:
            raise ValueError(
                f'The package "{package}" was not relevant for the manifest!?'
            )
        package_transformation = self.package_transformations[package]
        condition_context = ConditionContext(
            binary_package=package_transformation.binary_package,
            substitution=package_transformation.substitution,
            build_env=self._build_env,
            dpkg_architecture_variables=self._dpkg_architecture_variables,
            dpkg_arch_query_table=self._dpkg_arch_query_table,
        )

        for pre_provided_exclusion_rule in PRE_PROVIDED_EXCLUDE_RULES.values():
            transformation = PreProvidedExclusionTransformationRule(
                pre_provided_exclusion_rule
            )
            transformation.transform_file_system(fs_root, condition_context)

        for transformation in package_transformation.transformations:
            transformation.transform_file_system(fs_root, condition_context)

    def finalize_data_tar_contents(
        self,
        package: str,
        fs_root: FSPath,
        clamp_mtime_to: int,
    ) -> IntermediateManifest:
        if package in self._used_for:
            raise ValueError(
                f"data.tar contents for {package} has already been finalized!?"
            )
        if package not in self.package_transformations:
            raise ValueError(
                f'The package "{package}" was not relevant for the manifest!?'
            )
        self._used_for.add(package)
        package_transformation = self.package_transformations[package]
        condition_context = ConditionContext(
            binary_package=package_transformation.binary_package,
            substitution=package_transformation.substitution,
            build_env=self._build_env,
            dpkg_architecture_variables=self._dpkg_architecture_variables,
            dpkg_arch_query_table=self._dpkg_arch_query_table,
        )

        # At this point, there so be no further mutations to the file system (because the will not
        # be present in the intermediate manifest)
        cast("FSRootDir", fs_root).is_read_write = False

        path_matcher = PathMatcher.from_dict(
            package_transformation.path_specs, DEFAULT_PATH_INFO
        )
        intermediate_manifest = list(
            _generate_intermediate_manifest(
                path_matcher,
                fs_root,
                clamp_mtime_to,
                condition_context,
            )
        )
        self._validate_usage(
            package, package_transformation.path_specs, condition_context
        )
        return intermediate_manifest

    def apply_to_binary_staging_directory(
        self,
        package: str,
        fs_root: FSPath,
        clamp_mtime_to: int,
    ) -> IntermediateManifest:
        self.apply_fs_transformations(package, fs_root)
        return self.finalize_data_tar_contents(package, fs_root, clamp_mtime_to)

    def _validate_usage(
        self,
        package: str,
        path_specs: Dict[MatchRule, ManifestPathRule],
        condition_context: ConditionContext,
    ) -> None:
        for path, path_info in path_specs.items():
            if not path_info.has_been_used and (
                path_info.condition is not None
                and not path_info.condition.evaluate(condition_context)
            ):
                continue
            if not path_info.has_been_used and not path_info.is_builtin_definition:
                # Only built-in rules use non-str paths at the moment.
                # FIXME: PPFs end up as a path_info. For now, they will not trigger this due to
                #  them being defined as "is_builtin_definition".  In theory, they should always
                #  be used.  Except when they happen to be compressed later then the PPF manifest
                #  rule is not used and could trigger this error. Maybe other things end up here
                #  and we would need a different way of determine the source (so we do not
                #  unconditionally claim the bug is in the manifest, when it is not).
                _error(
                    f'Declaration "{path.describe_match_exact()}" in manifest {self.manifest_path} was present but'
                    f" not used (nothing matched it) when trying to create {package}. If the declaration is not"
                    " needed, then please remove it. Otherwise, the path is from the deb is missing. The"
                    f" problematic definition is {path_info.definition_source}"
                )
