import collections
import contextlib
import itertools
import operator
import os
from typing import IO, Iterator
from typing import (
    Iterable,
    Tuple,
    Optional,
    Dict,
    TypeVar,
    Callable,
    List,
    Any,
    Union,
    Mapping,
    NoReturn,
)

from debian.debian_support import DpkgArchTable

from debputy.builtin_manifest_rules import (
    builtin_path_metadata_rules,
    DEFAULT_PATH_INFO,
)
from debputy.highlevel_manifest import (
    HighLevelManifest,
    PackageTransformationDefinition,
    MutableYAMLManifest,
    MANIFEST_YAML,
)
from debputy.maintscript_snippet import (
    DpkgMaintscriptHelperCommand,
    MaintscriptSnippet,
    STD_CONTROL_SCRIPTS,
    MaintscriptSnippetContainer,
)
from debputy.manifest_path_rules import ManifestPathRule
from debputy.packages import BinaryPackage
from debputy.path_matcher import MatchRuleType, ExactFileSystemPath, MatchRule
from debputy.substitution import Substitution
from debputy.transformation_rules import (
    TransformationRule,
    ExclusionTransformationRule,
    CreateDirectoryTransformationRule,
    CreateVirtualPathTransformationRule,
    MoveTransformationRule,
)
from debputy.util import _error as _error_exit
from debputy.util import (
    _normalize_path,
    PKGVERSION_REGEX,
    PKGNAME_REGEX,
    escape_shell,
    active_profiles_match,
)
from ._deb_options_profiles import DebOptionsAndProfiles
from ._manifest_constants import *
from .architecture_support import DpkgArchitectureBuildProcessValuesTable
from .installations import InstallRule
from .manifest_conditions import (
    ManifestCondition,
    ArchMatchManifestCondition,
    BuildProfileMatch,
)
from .packager_provided_files import detect_all_packager_provided_files
from .plugin.api.impl import PluginProvidedFeatureSet


class OwnershipDefinition:
    __slots__ = ("entity_name", "entity_id")

    def __init__(self, entity_name, entity_id):
        self.entity_name = entity_name
        self.entity_id = entity_id


ROOT_DEFINITION = OwnershipDefinition("root", 0)

BAD_OWNER_NAMES = {
    "_apt",  # All things owned by _apt are generated by apt after installation
    "nogroup",  # It is not supposed to own anything as it is an entity used for dropping permissions
    "nobody",  # It is not supposed to own anything as it is an entity used for dropping permissions
}
BAD_OWNER_IDS = {
    65534,  # ID of nobody / nogroup
}

OSK = TypeVar("OSK")
OSV = TypeVar("OSV")


def _full_key_name(key, key_prefix):
    return key if not key_prefix else key_prefix + "." + key


def _parse_keyword(keyword: str) -> Tuple[str, str]:
    v = keyword.split("=", 1)
    return v[0], v[1]


def _read_ownership_def_from_base_password_template(
    template_file: str,
) -> Iterable[OwnershipDefinition]:
    with open(template_file) as fd:
        for line in fd:
            entity_name, _star, entity_id, _remainder = line.split(":", 3)
            yield OwnershipDefinition(entity_name, int(entity_id))


def _parse_ownership(v: str) -> Tuple[Optional[str], Optional[int]]:
    if isinstance(v, str) and ":" in v:
        if v == ":":
            raise ValueError(
                'Ownership is redundant if it is ":" (blank name and blank id)'
            )
        entity_name, entity_id = v.split(":")
        if entity_name == "":
            entity_name = None
        if entity_id != "":
            entity_id = int(entity_id)
        else:
            entity_id = None
        return entity_name, entity_id

    if isinstance(v, int):
        return None, v
    return v, None


def _resolve_entity(
    key: OSK,
    key_type: str,
    cache_lookup: Callable[[OSK], OSV],
    expected_result: Optional[OSV] = None,
):
    try:
        resolved_entity_value = cache_lookup(key)
    except KeyError:
        raise ValueError(
            f"Refusing to use {key} as {key_type}: It is not known to be a static {key_type} from base-passwd"
        )

    if expected_result is not None and expected_result != resolved_entity_value:
        raise ValueError(
            f"Bad {key_type} declaration: Looking up {key} resolves to {resolved_entity_value} according to"
            f" base-passwd, but the packager declared to should have been {expected_result}"
        )
    return resolved_entity_value


def _per_package_subst_variables(
    p: BinaryPackage,
    *,
    name: Optional[str] = None,
) -> Dict[str, str]:
    return {
        "PACKAGE": name if name is not None else p.name,
        "_DEBPUTY_PKG_ARCH": p.package_deb_architecture_variable("ARCH"),
        "_DEBPUTY_PKG_ARCHOS": p.package_deb_architecture_variable("ARCH_OS"),
    }


class ManifestParseException(RuntimeError):
    pass


class HighLevelManifestParser:
    def __init__(
        self,
        manifest_path: str,
        binary_packages: Mapping[str, BinaryPackage],
        substitution: Substitution,
        dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable,
        dpkg_arch_query_table: DpkgArchTable,
        build_env: DebOptionsAndProfiles,
        plugin_provided_feature_set: PluginProvidedFeatureSet,
        *,
        # Available for testing purposes only
        debian_dir: str = "./debian",
    ):
        self.manifest_path = manifest_path
        self.binary_packages = binary_packages
        self._mutable_yaml_manifest: Optional[MutableYAMLManifest] = None
        self._substitution = substitution
        self._dpkg_architecture_variables = dpkg_architecture_variables
        self._dpkg_arch_query_table = dpkg_arch_query_table
        self._build_env = build_env
        self._substitution_stack = [self._substitution]
        self._package_state_stack = []
        self._plugin_provided_feature_set = plugin_provided_feature_set
        self._reasons_for_assembly_with_dpkg_deb_requiring_root: Dict[str, str] = {}
        self._debian_dir = debian_dir  # TODO: Make this an iterator or something else that is easier to mock/stub

        self._package_states = {
            n: PackageTransformationDefinition(
                binary_package=p,
                binary_version=None,
                ensure_directories={},
                create_virtual_paths={},
                path_specs={},
                substitution=substitution.with_extra_substitutions(
                    **_per_package_subst_variables(p)
                ),
                dpkg_maintscript_helper_snippets=[],
                maintscript_snippets=collections.defaultdict(
                    MaintscriptSnippetContainer
                ),
                transformations=[],
                is_auto_generated_package=False,
            )
            for n, p in binary_packages.items()
        }
        for n, p in binary_packages.items():
            dbgsym_name = f"{n}-dbgsym"
            if dbgsym_name in self._package_states:
                continue
            self._package_states[dbgsym_name] = PackageTransformationDefinition(
                binary_package=p,
                binary_version=None,
                ensure_directories={},
                create_virtual_paths={},
                path_specs={},
                substitution=substitution.with_extra_substitutions(
                    **_per_package_subst_variables(p, name=dbgsym_name)
                ),
                dpkg_maintscript_helper_snippets=[],
                maintscript_snippets=collections.defaultdict(
                    MaintscriptSnippetContainer
                ),
                transformations=[],
                is_auto_generated_package=True,
            )

        self._install_rules: Optional[List[InstallRule]] = None

        self._owner_cache = {ROOT_DEFINITION.entity_name: ROOT_DEFINITION}
        self._group_cache = {ROOT_DEFINITION.entity_name: ROOT_DEFINITION}
        self._uid_cache = {v.entity_id: v for v in self._owner_cache.values()}
        self._gid_cache = {v.entity_id: v for v in self._group_cache.values()}
        self._ownership_caches_loaded = False
        self._used = False

    def build_manifest(self) -> HighLevelManifest:
        if self._used:
            raise TypeError("build_manifest can only be called once!")
        self._used = True
        if isinstance(self, YAMLManifestParser) and self._mutable_yaml_manifest is None:
            self._mutable_yaml_manifest = MutableYAMLManifest.empty_manifest()
        with os.scandir(self._debian_dir) as scan_dir_iter:
            all_packager_provided_files = detect_all_packager_provided_files(
                self._plugin_provided_feature_set.packager_provided_files,
                scan_dir_iter,
                self.binary_packages,
                lambda n: self._package_states[n].substitution,
            )
        for package in self._package_states:
            with self.binary_package_context(package) as context:
                if not context.is_auto_generated_package:
                    for manifest_info in all_packager_provided_files[package]:
                        self._add_entry(manifest_info)
                for manifest_info in builtin_path_metadata_rules(
                    self._dpkg_architecture_variables,
                    context.binary_package,
                    context.substitution,
                ):
                    self._add_entry(manifest_info)
                if context.ensure_directories:
                    context.transformations.append(
                        CreateDirectoryTransformationRule(
                            context.ensure_directories.values()
                        )
                    )
                if context.create_virtual_paths:
                    context.transformations.append(
                        CreateVirtualPathTransformationRule(
                            context.create_virtual_paths.values()
                        )
                    )
                self._transform_dpkg_maintscript_helpers_to_snippets()

        return HighLevelManifest(
            self.manifest_path,
            self._mutable_yaml_manifest,
            self._install_rules,
            self.binary_packages,
            self.substitution,
            self._package_states,
            self._dpkg_architecture_variables,
            self._dpkg_arch_query_table,
            self._build_env,
            self._reasons_for_assembly_with_dpkg_deb_requiring_root,
            self._plugin_provided_feature_set,
        )

    def _error(self, msg: str) -> "NoReturn":
        raise ManifestParseException(msg)

    @contextlib.contextmanager
    def binary_package_context(
        self, package_name: str
    ) -> Iterator[PackageTransformationDefinition]:
        if package_name not in self._package_states:
            self._error(
                f'The package "{package_name}" is not present in the debian/control file (could not find'
                f' "Package: {package_name}" in a binary paragraph) nor is it a -dbgsym package for one'
                " for a package in debian/control."
            )
        package_state = self._package_states[package_name]
        self._substitution_stack.append(package_state.substitution)
        self._package_state_stack.append(package_state)
        ss_len = len(self._substitution_stack)
        ps_len = len(self._package_state_stack)
        yield package_state
        if ss_len != len(self._substitution_stack) or ps_len != len(
            self._package_state_stack
        ):
            raise RuntimeError("Internal error: Unbalanced stack manipulation detected")
        self._substitution_stack.pop()
        self._package_state_stack.pop()

    @property
    def substitution(self) -> Substitution:
        return self._substitution_stack[-1]

    @property
    def current_binary_package_state(self) -> PackageTransformationDefinition:
        if not self._package_state_stack:
            raise RuntimeError("Invalid state: Not in a binary package context")
        return self._package_state_stack[-1]

    def _transform_dpkg_maintscript_helpers_to_snippets(self) -> None:
        package_state = self.current_binary_package_state
        for dmh in package_state.dpkg_maintscript_helper_snippets:
            snippet = MaintscriptSnippet(
                definition_source=dmh.definition_source,
                snippet=f'dpkg-maintscript-helper {escape_shell(*dmh.cmdline)} -- "$@"\n',
            )
            for script in STD_CONTROL_SCRIPTS:
                package_state.maintscript_snippets[script].append(snippet)

    def normalize_path(
        self, path: str, definition_source: str, *, allow_root_dir_match: bool = False
    ) -> ExactFileSystemPath:
        try:
            normalized = _normalize_path(path)
        except ValueError:
            self._error(
                f'The path "{path}" provided in {definition_source} should be relative to the root of the'
                ' package and not use any ".." or "." segments.'
            )
        if normalized == "." and not allow_root_dir_match:
            self._error(
                "Manifests must not change the root directory of the deb file.  Please correct"
                f' "{definition_source}" (path: "{path}) in {self.manifest_path}'
            )
        return ExactFileSystemPath(
            self.substitution.substitute(normalized, definition_source)
        )

    def parse_path_or_glob(
        self, path_or_glob: str, definition_source: str
    ) -> MatchRule:
        match_rule = MatchRule.from_path_or_glob(
            path_or_glob, definition_source, substitution=self.substitution
        )
        # NB: "." and "/" will be translated to MATCH_ANYTHING by MatchRule.from_path_or_glob,
        # so there is no need to check for an exact match on "." like in normalize_path.
        if match_rule.rule_type == MatchRuleType.MATCH_ANYTHING:
            self._error(
                f'The chosen match rule "{path_or_glob}" matches everything (including the deb root directory).'
                f' Please correct "{definition_source}" (path: "{path_or_glob}) in {self.manifest_path} to'
                f' something that matches "less" than everything.'
            )
        return match_rule

    def parse_manifest(self) -> HighLevelManifest:
        raise NotImplementedError

    def _load_ownership_cache(self) -> None:
        for owner_def in _read_ownership_def_from_base_password_template(
            "/usr/share/base-passwd/passwd.master"
        ):
            if owner_def.entity_id == 0:
                # Leave root as it is
                continue

            # Could happen if base-passwd template has two users with the same ID.  We assume this will not occur.
            assert owner_def.entity_name not in self._owner_cache
            assert owner_def.entity_id not in self._uid_cache
            self._owner_cache[owner_def.entity_name] = owner_def
            self._uid_cache[owner_def.entity_id] = owner_def
        for group_def in _read_ownership_def_from_base_password_template(
            "/usr/share/base-passwd/group.master"
        ):
            if group_def.entity_id == 0:
                # Leave root as it is
                continue

            # Could happen if base-passwd template has two groups with the same ID. We assume this will not occur.
            assert group_def.entity_name not in self._group_cache
            assert group_def.entity_id not in self._gid_cache
            self._group_cache[group_def.entity_name] = group_def
            self._gid_cache[group_def.entity_id] = group_def
        self._ownership_caches_loaded = True

    def parse_and_resolve_owner(self, declaration: Optional[str]):
        return self.parse_and_resolve_ownership(
            declaration, "owner", self._owner_cache, self._uid_cache
        )

    def parse_and_resolve_group(self, declaration: Optional[str]):
        return self.parse_and_resolve_ownership(
            declaration, "group", self._group_cache, self._gid_cache
        )

    def parse_and_resolve_ownership(
        self,
        declaration: Optional[str],
        ownership_type: str,
        name_cache: Dict[str, OwnershipDefinition],
        id_cache: Dict[int, OwnershipDefinition],
    ):
        if declaration is None:
            return None, None
        entity_name, entity_id = _parse_ownership(declaration)
        return self.resolve_ownership(
            entity_name, entity_id, ownership_type, name_cache, id_cache
        )

    def resolve_ownership(
        self,
        entity_name: Optional[str],
        entity_id: Optional[int],
        ownership_type: str,
        name_cache: Dict[str, OwnershipDefinition],
        id_cache: Dict[int, OwnershipDefinition],
    ) -> Tuple[str, int]:
        if not self._ownership_caches_loaded:
            if (entity_name is not None and entity_name not in name_cache) or (
                entity_id is not None and entity_id not in id_cache
            ):
                self._load_ownership_cache()
                assert self._ownership_caches_loaded
        resolved_name = entity_name
        resolved_id = entity_id
        if entity_id is not None:
            resolved_name = _resolve_entity(
                entity_id,
                ownership_type,
                lambda x: id_cache[x].entity_name,
                expected_result=entity_name,
            )
        if entity_name is not None:
            resolved_id = _resolve_entity(
                entity_name,
                ownership_type,
                lambda x: name_cache[x].entity_id,
                expected_result=entity_id,
            )
        if resolved_name in BAD_OWNER_NAMES or resolved_id in BAD_OWNER_IDS:
            raise ValueError(
                f"Refusing to use {resolved_name}:{resolved_id} as {ownership_type}."
                " No path should have this entity as owner/group as it is unsafe."
            )
        if resolved_name is None or resolved_id is None:
            raise AssertionError(
                f"Internal error: The base-passwd template could not fully resolve {ownership_type}"
                f" with input name {entity_name} and/or id {entity_id}."
                " The full ownership information is necessary to generate a correct data.tar file!"
                " Please file a bug for this tool for now."
            )
        # The asserts here are mostly typing hints
        assert resolved_name is not None
        assert resolved_id is not None
        return resolved_name, resolved_id

    def _check_existing(
        self,
        match_rule: MatchRule,
        path_info: ManifestPathRule,
    ) -> bool:
        package_state = self.current_binary_package_state
        if match_rule in package_state.path_specs:
            other_path_spec = package_state.path_specs[match_rule]
            if (
                path_info.is_builtin_definition
                and not other_path_spec.is_builtin_definition
            ):
                return False
            self._error(
                f'The path "{match_rule.describe_match_short()}" has conflicting definitions (or is defined'
                f" twice) in manifest {self.manifest_path}. The two definitions are:\n"
                f" * {other_path_spec.definition_source}\n"
                f" * {path_info.definition_source}\n"
                "Please consolidate them into one definition."
            )
        if path_info.is_builtin_definition and match_rule.is_path_type_part_of_match:
            # if the user provided a rule that overlaps with a built-in rule, we also discard the built-in rule, if
            # has a path type restriction while the user provided does not.  Notably, users are currently unable to
            # provide a path type restricted rules.
            #
            # This is the logic that ensures that the user can write "remove *.la" and not be steam rolled by the
            # built-in "normalize permissions for *.la files" rule.
            shadowing_match_rule = match_rule.match_rule_without_path_type_match()
            return shadowing_match_rule not in package_state.path_specs
        return True

    def _add_entry(self, path_info: ManifestPathRule) -> None:
        package_state = self.current_binary_package_state
        match_rule = path_info.match_rule
        if not self._check_existing(match_rule, path_info):
            return

        package_name = package_state.binary_package.name

        if (
            package_name not in self._reasons_for_assembly_with_dpkg_deb_requiring_root
            and (path_info.uid != 0 or path_info.gid != 0)
        ):
            self._reasons_for_assembly_with_dpkg_deb_requiring_root[
                package_name
            ] = path_info.definition_source

        package_state.path_specs[match_rule] = path_info
        if path_info.rule_type.creates_path:
            assert isinstance(match_rule, ExactFileSystemPath)
            assert path_info.member_path not in package_state.create_virtual_paths
            package_state.create_virtual_paths[path_info.member_path] = path_info
            current_path_info = path_info
            parent_dir = ExactFileSystemPath(os.path.dirname(match_rule.path))
            assert parent_dir.path.startswith(
                "./"
            ), f'{parent_dir.path} should have started with "./" ({path_info.member_path})'
            while True:
                parent_path_info = package_state.path_specs.get(parent_dir)
                existed = False
                if parent_path_info is None:
                    parent_path_info = ManifestPathRule.directory(
                        path_info.definition_source,
                        parent_dir,
                        triggered_by=path_info,
                    )
                    package_state.path_specs[parent_dir] = parent_path_info
                    package_state.ensure_directories[parent_dir.path] = parent_path_info
                else:
                    existed = True
                parent_path_info.add_virtual_child(current_path_info)
                if existed or parent_dir.path == ".":
                    break
                current_path_info = parent_path_info
                parent_dir = ExactFileSystemPath(os.path.dirname(parent_dir.path))


class YAMLManifestParser(HighLevelManifestParser):
    def _optional_key(self, d, key, key_prefix, expected_type=None, default_value=None):
        v = d.get(key)
        if v is None:
            return default_value
        if expected_type is not None:
            return self._ensure_value_is_type(v, expected_type, key, key_prefix)
        return v

    def _required_key(self, d, key, key_prefix: str, expected_type=None, extra=None):
        v = d.get(key)
        if v is None:
            extra_info = " " + extra() if extra is not None else ""
            self._error(
                f'Missing required key {key} at {key_prefix} in manifest "{self.manifest_path}.{extra_info}"'
            )

        if expected_type is not None:
            return self._ensure_value_is_type(v, expected_type, key, key_prefix)
        return v

    def _ensure_value_is_type(self, v, t, key: str, key_prefix: str):
        if v is None:
            return None
        if not isinstance(v, t):
            if isinstance(t, tuple):
                t_msg = "one of: " + ", ".join(x.__name__ for x in t)
            else:
                t_msg = f"a {t.__name__}"
            self._error(
                f'The key {_full_key_name(key, key_prefix)} must be {t_msg} in manifest "{self.manifest_path}"'
            )
        return v

    def _parse_ownership_and_mode(
        self, d: Dict[str, Any], definition_source
    ) -> Dict[str, Union[int, str]]:
        mode = self._optional_key(d, "mode", definition_source, expected_type=str)
        try:
            mode = int(mode, base=8) if mode is not None else None
        except ValueError:
            key_path = _full_key_name("mode", definition_source)
            self._error(
                f"Unable to parse {key_path} as an octal number (manifest: {self.manifest_path})"
            )
        declared_owner = self._optional_key(
            d,
            "owner",
            definition_source,
            expected_type=(str, int),
            default_value="root:0",
        )
        declared_group = self._optional_key(
            d,
            "group",
            definition_source,
            expected_type=(str, int),
            default_value="root:0",
        )
        try:
            owner, uid = self.parse_and_resolve_owner(declared_owner)
        except ValueError as e:
            self._error(
                f'Invalid ownership information in {_full_key_name("owner", definition_source)}'
                f" (manifest: {self.manifest_path}): {e.args[0]}"
            )
        try:
            group, gid = self.parse_and_resolve_group(declared_group)
        except ValueError as e:
            self._error(
                f'Invalid ownership in {_full_key_name("group", definition_source)}'
                f" (manifest: {self.manifest_path}): {e.args[0]}"
            )
        return {
            "mode": mode,
            "dirmode": mode,
            "special_mode": mode,
            "uid": uid,
            "uname": owner,
            "gid": gid,
            "gname": group,
        }

    def _parse_yaml_path_metadata(self, d, orig_definition_source: str) -> None:
        raw_path_specs = self._optional_key(
            d,
            "path-metadata",
            orig_definition_source,
            expected_type=dict,
            default_value={},
        )
        for path_spec, path_metadata in raw_path_specs.items():
            definition_source = f"{orig_definition_source}.path-metadata"
            # Ensure path_spec is a str for the error m
            self._ensure_value_is_type(
                path_spec, str, f"{path_spec}", definition_source
            )
            if path_metadata is None:
                path_metadata = {}
            else:
                self._ensure_value_is_type(
                    path_metadata, dict, path_spec, definition_source
                )
            definition_source = f'{definition_source}."{path_spec}"'

            ownership_and_mode = self._parse_ownership_and_mode(
                path_metadata, definition_source
            )
            condition = self._parse_yaml_when_if_present(
                path_metadata, definition_source
            )

            path_info = ManifestPathRule.ensure_metadata(
                definition_source,
                self.parse_path_or_glob(path_spec, definition_source),
                **ownership_and_mode,
                condition=condition,
            )
            self._add_entry(path_info)

    def _parse_yaml_create_symlinks(self, d, orig_definition_source: str) -> None:
        raw_path_specs = self._optional_key(
            d,
            MK_CREATE_SYMLINKS,
            orig_definition_source,
            expected_type=list,
            default_value=[],
        )
        package_state = self.current_binary_package_state
        for no, symlink_spec in enumerate(raw_path_specs):
            definition_source = f"{orig_definition_source}.{MK_CREATE_SYMLINKS}[{no}]"
            self._ensure_value_is_type(symlink_spec, dict, definition_source, "")
            if "*" in symlink_spec or "?" in symlink_spec:
                self._error(f"Globs are currently not supported {definition_source}")
            link_path = self._required_key(
                symlink_spec,
                MK_CREATE_SYMLINKS_LINK_PATH,
                definition_source,
                expected_type=str,
            )
            link_target = self._required_key(
                symlink_spec,
                MK_CREATE_SYMLINKS_LINK_TARGET,
                definition_source,
                expected_type=str,
            )
            condition = self._parse_yaml_when_if_present(
                symlink_spec, definition_source
            )
            # Include the name in the source as it is easier for people to find than counting their way through
            # the list.
            definition_source = f"{definition_source} <{link_path}>"
            normalized_link_path = self.normalize_path(link_path, definition_source)
            path_info = ManifestPathRule.symlink(
                definition_source,
                normalized_link_path,
                link_target=self.substitution.substitute(
                    link_target, definition_source
                ),
                condition=condition,
            )
            directory = os.path.dirname(path_info.member_path)
            if directory not in package_state.ensure_directories:
                package_state.ensure_directories[
                    directory
                ] = ManifestPathRule.directory(
                    definition_source,
                    ExactFileSystemPath(directory),
                    triggered_by=path_info,
                )
            self._add_entry(path_info)

    def _unpack_single_key_dict(self, value, definition_source: str) -> str:
        self._ensure_value_is_type(value, dict, definition_source, "")
        if len(value) != 1:
            self._error(
                f'The mapping "{definition_source}" had two keys, but it should only have one top level key.'
                " Maybe you are missing a list marker behind the second key or some indentation.  The keys"
                f' are: {", ".join(value)}'
            )
        return next(iter(value))

    def _parse_yaml_directories(self, d, orig_definition_source: str) -> None:
        raw_path_specs = self._optional_key(
            d,
            "directories",
            orig_definition_source,
            expected_type=list,
            default_value=[],
        )
        directories = []
        package_state = self.current_binary_package_state
        for no, dir_def in enumerate(raw_path_specs):
            definition_source = f"{orig_definition_source}.directories[{no}]"
            self._ensure_value_is_type(dir_def, (str, dict), definition_source, "")
            if isinstance(dir_def, str):
                dir_path = dir_def
                dir_data = {}
            else:
                assert isinstance(dir_def, dict)
                dir_data = dir_def
                dir_path = self._required_key(
                    dir_def, "path", definition_source, expected_type=str
                )

            ownership_and_mode = self._parse_ownership_and_mode(
                dir_data, definition_source
            )
            condition = self._parse_yaml_when_if_present(dir_data, definition_source)

            # Include the name in the source as it is easier for people to find than counting their way through
            # the list.
            definition_source = f"{definition_source} <{dir_path}>"
            normalized_dir_path = self.normalize_path(dir_path, definition_source)
            path_info = ManifestPathRule.directory(
                definition_source,
                normalized_dir_path,
                **ownership_and_mode,
                condition=condition,
            )
            directories.append(path_info)

        # Insert them in sort order as that will ensure the implicit path match the "shortest" rule rather than the
        # "first" rule.  It also saves us form having to do "conflict" resolution between implicit and explicit
        # directory rules.
        for path_info in sorted(directories, key=operator.attrgetter("member_path")):
            self._add_entry(path_info)
            assert path_info.member_path not in package_state.ensure_directories
            package_state.ensure_directories[path_info.member_path] = path_info

    def _parse_transformation_exclusion(
        self, d, key: str, definition_source
    ) -> TransformationRule:
        exclusion = self._required_key(d, key, definition_source, expected_type=str)
        definition_source += f".{key} <{exclusion}>"
        return ExclusionTransformationRule(
            self.parse_path_or_glob(exclusion, definition_source),
            definition_source,
        )

    def _parse_transformation_move(
        self, d, key: str, definition_source: str
    ) -> TransformationRule:
        move_def = self._required_key(d, key, definition_source, expected_type=dict)
        definition_source += f".{key}"
        source = self._required_key(
            move_def, "source", definition_source, expected_type=str
        )
        target = self._required_key(
            move_def, "target", definition_source, expected_type=str
        )
        condition = self._parse_yaml_when_if_present(d, definition_source)
        definition_source += f" <{source}>"
        source_match = self.parse_path_or_glob(source, definition_source)
        target_path = self.normalize_path(target, definition_source).path
        if (
            isinstance(source_match, ExactFileSystemPath)
            and source_match.path == target_path
        ):
            self._error(
                f"The transformation rule {definition_source} requests a move of {source_match} to"
                f" {target_path}, which is the same path"
            )
        return MoveTransformationRule(
            source_match,
            target_path,
            target.endswith("/"),
            definition_source,
            condition,
        )

    def _parse_yaml_condition_not(
        self, d, key: str, orig_definition_source: str
    ) -> ManifestCondition:
        not_def = self._required_key(d, key, orig_definition_source, expected_type=dict)
        condition = self._parse_yaml_condition(
            not_def, f"{orig_definition_source}.{key}"
        )
        return condition.negated()

    def _parse_yaml_condition_group(
        self, d, key: str, orig_definition_source: str
    ) -> ManifestCondition:
        group_def = self._required_key(
            d, key, orig_definition_source, expected_type=list
        )
        definition_source = f"{orig_definition_source}.{key}"
        conditions = [
            self._parse_yaml_condition(cd, f"{definition_source}[{i}]")
            for i, cd in enumerate(group_def)
        ]
        if len(conditions) < 2:
            self._error(
                f"Invalid condition at {definition_source}, the {key} requires at least 2 conditions."
            )

        if key == "anyOf":
            return ManifestCondition.any_of(conditions)
        assert key == "allOf"
        return ManifestCondition.all_of(conditions)

    def _parse_yaml_condition_arch_matches(
        self, d, key: str, orig_definition_source: str
    ) -> ManifestCondition:
        arch_matches_raw = self._required_key(
            d, key, orig_definition_source, expected_type=str
        )
        package_state = self.current_binary_package_state
        arch_matches_as_list = arch_matches_raw.split()
        dpkg_arch_query = self._dpkg_arch_query_table

        if not arch_matches_as_list:
            self._error(f"The condition at {orig_definition_source} must not be empty")
        if package_state.binary_package.is_arch_all:
            if arch_matches_as_list[0].startswith("["):
                arch_matches_as_list[0] = arch_matches_as_list[0][1:]
            if arch_matches_as_list[-1].endswith("]"):
                arch_matches_as_list[-1] = arch_matches_as_list[-1][:-1]
            result = dpkg_arch_query.architecture_is_concerned(
                "all", arch_matches_as_list
            )
            self._error(
                f"The package architecture restriction at {orig_definition_source} is applied to the"
                f' "Architecture: all" package {package_state.binary_package.name}, which does not make sense'
                f" as the condition will always resolves to `{str(result).lower()}`."
            )
        if arch_matches_as_list[0].startswith("[") or arch_matches_as_list[-1].endswith(
            "]"
        ):
            self._error(
                f"The architecture match at {orig_definition_source} must be defined without enclosing it with "
                '"[" or/and "]" brackets'
            )
        # Can we check arch list for typos.  If we do, it must be tight in how close matches it does.
        # Consider "arm" vs. "armel" (edit distance 2, but both are valid).  Likewise, names often
        # include a bit indicator "foo", "foo32", "foo64" - all of these have an edit distance of 2
        # of each other.
        return ArchMatchManifestCondition(arch_matches_as_list)

    def _parse_yaml_condition_build_profiles_matches(
        self,
        d,
        key: str,
        orig_definition_source: str,
    ) -> ManifestCondition:
        build_profile_spec = self._required_key(
            d, key, orig_definition_source, expected_type=str
        )
        build_profile_spec = build_profile_spec.strip()
        if not build_profile_spec:
            self._error(f"The condition at {orig_definition_source} must not be empty")
        try:
            active_profiles_match(build_profile_spec, frozenset())
        except ValueError as e:
            self._error(
                f"Could not parse the build specification at {orig_definition_source}: {e.args[0]}"
            )
        return BuildProfileMatch(build_profile_spec)

    def _parse_yaml_condition(
        self,
        d,
        orig_definition_source: str,
    ) -> ManifestCondition:
        supported_mapping_conditions: Dict[
            str, Callable[[Dict, str, str], ManifestCondition]
        ] = {
            "not": self._parse_yaml_condition_not,
            MK_CONDITION_ARCH_MATCHES: self._parse_yaml_condition_arch_matches,
            MK_CONDITION_BUILD_PROFILES_MATCHES: self._parse_yaml_condition_build_profiles_matches,
            "anyOf": self._parse_yaml_condition_group,
            "allOf": self._parse_yaml_condition_group,
        }
        supported_str_conditions: Dict[str, ManifestCondition] = {
            "cross-compiling": ManifestCondition.is_cross_building(),
            "can-execute-compiled-binaries": ManifestCondition.can_execute_compiled_binaries(),
            "run-build-time-tests": ManifestCondition.run_build_time_tests(),
        }
        condition = None
        if isinstance(d, str):
            condition_key = d
            condition = supported_str_conditions.get(condition_key)
        else:
            condition_key = self._unpack_single_key_dict(d, orig_definition_source)

            handler = supported_mapping_conditions.get(condition_key)
            if handler is not None:
                condition = handler(d, condition_key, orig_definition_source)

        if condition is None:
            if condition_key in supported_mapping_conditions:
                assert not isinstance(d, dict)
                self._error(
                    f"Invalid definition at {orig_definition_source}.  The condition {condition_key} must be a "
                    "mapping."
                )
            elif condition_key in supported_str_conditions:
                self._error(
                    f"Invalid definition at {orig_definition_source}.  The condition {condition_key} must be a "
                    "string."
                )
            dict_options = (f"{k} (mapping)" for k in supported_mapping_conditions)
            string_options = (f"{k} (string)" for k in supported_str_conditions)
            valid_options = ", ".join(
                sorted(itertools.chain(dict_options, string_options))
            )
            self._error(
                f'Unknown or unsupported condition "{condition_key}" at {orig_definition_source}.'
                f" Valid conditions are: {valid_options}"
            )

        return condition

    def _parse_yaml_when_if_present(
        self,
        d,
        orig_definition_source: str,
    ) -> Optional[ManifestCondition]:
        raw_condition = self._optional_key(
            d,
            MK_CONDITION_WHEN,
            orig_definition_source,
            expected_type=dict,
            default_value=None,
        )
        if raw_condition is None:
            return None
        definition_source = f"{orig_definition_source}.{MK_CONDITION_WHEN}"
        return self._parse_yaml_condition(raw_condition, definition_source)

    def _as_list_of_str(
        self, v: Union[str, List, None], definition_source: str
    ) -> List[str]:
        if v is None:
            return []
        if isinstance(v, str):
            return [v]
        for i, sv in enumerate(v):
            self._ensure_value_is_type(v, str, f"{definition_source}[{i}]", "")
        return v

    def _resolve_package(
        self,
        v: str,
        definition_source: str,
        *,
        accept_auto_generated_packages: bool = False,
    ) -> BinaryPackage:
        state = self._package_states.get(v)
        if (
            state is None
            or state.is_auto_generated_package
            and not accept_auto_generated_packages
        ):
            restriction = ""
            if state is not None:
                restriction = " that is listed in debian/control"
            self._error(
                f'The value "{v}" (defined at "{definition_source}") should be a valid package name'
                f" {restriction}, but it is not."
            )

        return state.binary_package

    def _parse_package_or_list_of_packages(
        self,
        d,
        key: str,
        key_prefix: str,
        required: bool,
        *,
        accept_auto_generated_packages: bool = False,
    ) -> List[BinaryPackage]:
        if required:
            v = self._required_key(
                d,
                key,
                key_prefix,
                expected_type=(str, list),
            )
        else:
            v = self._optional_key(
                d,
                key,
                key_prefix,
                expected_type=(str, list),
            )

        if v is None:
            return []

        definition_source = f"{key_prefix}.{key}"
        if isinstance(v, str):
            p = self._resolve_package(
                v,
                definition_source,
                accept_auto_generated_packages=accept_auto_generated_packages,
            )
            return [p]
        pkgs = []
        for i, n in v:
            self._ensure_value_is_type(n, str, f"{definition_source}[{i}]", "")
            p = self._resolve_package(
                v,
                definition_source,
                accept_auto_generated_packages=accept_auto_generated_packages,
            )

            pkgs.append(p)
        return pkgs

    def _parse_install_rule_install_docs_or_examples(
        self,
        d,
        key: str,
        definition_source: str,
    ) -> InstallRule:
        install_def = self._required_key(d, key, definition_source, expected_type=dict)
        definition_source += f".{key}"
        source_raw = self._required_key(
            install_def,
            MK_INSTALLATIONS_INSTALL_SOURCES,
            definition_source,
            expected_type=(str, list),
        )
        condition = self._parse_yaml_when_if_present(d, definition_source)

        into = self._parse_package_or_list_of_packages(
            install_def,
            MK_INSTALLATIONS_INSTALL_INTO,
            definition_source,
            True,
        )

        if isinstance(source_raw, str):
            sources = [source_raw]
        else:
            sources = [
                self._ensure_value_is_type(v, str, f"{definition_source}[{i}]", "")
                for i, v in enumerate(source_raw)
            ]

        definition_source += f" <{sources[0]}>"
        search_dirs = ["debian/tmp", "."]  # TODO: make configurable

        # FIXME: Migrate to standard match parsing with better errors.
        source_match = [
            self.substitution.substitute(
                _normalize_path(source, with_prefix=False),
                definition_source,
            )
            for source in sources
        ]
        if "**" in source_match:
            self._error(
                f'The "**" pattern is not implemented in {definition_source} at the moment.'
            )
        if key in ("install-examples", "install-example"):
            rule = InstallRule.install_examples
        else:
            rule = InstallRule.install_doc

        return rule(
            source_match,
            frozenset(into),
            search_dirs,
            definition_source,
            condition,
        )

    def _parse_install_rule_install(
        self,
        d,
        key: str,
        definition_source: str,
    ) -> InstallRule:
        install_def = self._required_key(d, key, definition_source, expected_type=dict)
        definition_source += f".{key}"
        source_raw = self._required_key(
            install_def,
            MK_INSTALLATIONS_INSTALL_SOURCES,
            definition_source,
            expected_type=(str, list),
        )
        condition = self._parse_yaml_when_if_present(d, definition_source)
        into = self._parse_package_or_list_of_packages(
            install_def,
            MK_INSTALLATIONS_INSTALL_INTO,
            definition_source,
            True,
        )
        dest_dir_raw = self._optional_key(
            d, MK_INSTALLATIONS_INSTALL_DEST_DIR, definition_source, expected_type=str
        )
        install_as_raw = self._optional_key(
            d, MK_INSTALLATIONS_INSTALL_AS, definition_source, expected_type=str
        )

        if isinstance(source_raw, str):
            sources = [source_raw]
        else:
            sources = [
                self._ensure_value_is_type(v, str, f"{definition_source}[{i}]", "")
                for i, v in enumerate(source_raw)
            ]

        definition_source += f" <{sources[0]}>"

        dest_dir = None
        install_as = None
        if dest_dir_raw is not None:
            dest_dir = self.normalize_path(
                dest_dir_raw, definition_source, allow_root_dir_match=True
            ).path
        if install_as_raw is not None:
            install_as = self.normalize_path(install_as_raw, definition_source).path

        if install_as_raw is not None and dest_dir_raw is not None:
            self._error(
                f'Cannot use "as" with "dest-dir" in {definition_source}. Please remove either "as" or "dest-dir"'
            )

        search_dirs = ["debian/tmp", "."]  # TODO: make configurable

        # FIXME: Migrate to standard match parsing with better errors.
        source_match = [
            self.substitution.substitute(
                _normalize_path(source, with_prefix=False),
                definition_source,
            )
            for source in sources
        ]
        if "**" in source_match:
            self._error(
                f'The "**" pattern is not implemented in {definition_source} at the moment.'
            )
        if install_as is not None:
            if len(source_match) != 1:
                self._error(
                    'The "as" keyword cannot be used with multiple sources. '
                    f' Please remove the extra source or remove the "as" keyword in {definition_source}'
                )
            return InstallRule.install_as(
                source_match[0],
                install_as,
                frozenset(into),
                search_dirs,
                definition_source,
                condition,
            )

        return InstallRule.install_dest(
            source_match,
            dest_dir,
            frozenset(into),
            search_dirs,
            definition_source,
            condition,
        )

    def _parse_yaml_installations(
        self,
        d,
        orig_definition_source: str,
    ) -> None:
        raw_installations = self._optional_key(
            d,
            MK_INSTALLATIONS,
            orig_definition_source,
            expected_type=list,
            default_value=None,
        )

        if raw_installations is None and MK_INSTALLATIONS not in d:
            # Explicitly empty vs. implicitly empty matters in corner cases, so preserve this
            # fact.
            return

        self._install_rules: List[InstallRule] = []

        supported_installations: Dict[str, Callable[[Dict, str, str], InstallRule]] = {
            MK_INSTALLATIONS_INSTALL: self._parse_install_rule_install,
            "install-doc": self._parse_install_rule_install_docs_or_examples,
            MK_INSTALLATIONS_INSTALL_DOCS: self._parse_install_rule_install_docs_or_examples,
            "install-example": self._parse_install_rule_install_docs_or_examples,
            MK_INSTALLATIONS_INSTALL_EXAMPLES: self._parse_install_rule_install_docs_or_examples,
        }

        for no, raw_installation in enumerate(raw_installations):
            definition_source = f"{orig_definition_source}.installations[{no}]"
            transformation_key = self._unpack_single_key_dict(
                raw_installation, definition_source
            )
            handler = supported_installations.get(transformation_key)
            if handler is not None:
                installation = handler(
                    raw_installation, transformation_key, definition_source
                )
            else:
                self._error(
                    f'Unknown or unsupported transformation "{transformation_key}" at {definition_source}.'
                    " Transformations must have one of the following keys:"
                    f' {", ".join(sorted(supported_installations))}'
                )
            self._install_rules.append(installation)

    def _parse_yaml_transformations(
        self,
        d,
        transformations: List[TransformationRule],
        orig_definition_source: str,
    ) -> None:
        raw_transformations = self._optional_key(
            d,
            "transformations",
            orig_definition_source,
            expected_type=list,
            default_value=[],
        )

        supported_transformations: Dict[
            str, Callable[[Dict, str, str], TransformationRule]
        ] = {
            "exclude": self._parse_transformation_exclusion,
            "move": self._parse_transformation_move,
        }

        for no, raw_transformation in enumerate(raw_transformations):
            definition_source = f"{orig_definition_source}.transformations[{no}]"
            transformation_key = self._unpack_single_key_dict(
                raw_transformation, definition_source
            )
            handler = supported_transformations.get(transformation_key)
            if handler is not None:
                transformation = handler(
                    raw_transformation, transformation_key, definition_source
                )
            else:
                self._error(
                    f'Unknown or unsupported transformation "{transformation_key}" at {definition_source}.'
                    " Transformations must have one of the following keys:"
                    f' {", ".join(sorted(supported_transformations))}'
                )
            transformations.append(transformation)

    def _parse_conffile_prior_version_and_owning_package(
        self,
        d,
        definition_source,
    ) -> Tuple[Optional[str], Optional[str]]:
        prior_version = self._optional_key(
            d,
            MK_CONFFILE_MANAGEMENT_X_PRIOR_TO_VERSION,
            definition_source,
            expected_type=str,
        )
        owning_package = self._optional_key(
            d,
            MK_CONFFILE_MANAGEMENT_X_OWNING_PACKAGE,
            definition_source,
            expected_type=str,
        )

        if prior_version is not None and not PKGVERSION_REGEX.match(prior_version):
            self._error(
                f"The {MK_CONFFILE_MANAGEMENT_X_PRIOR_TO_VERSION} parameter in {definition_source} must be a"
                f" valid package version (i.e., match {PKGVERSION_REGEX})."
            )
        if owning_package is not None and not PKGNAME_REGEX.match(owning_package):
            self._error(
                f"The {MK_CONFFILE_MANAGEMENT_X_OWNING_PACKAGE} parameter in {definition_source} must be a valid"
                f" package name (i.e., match {PKGNAME_REGEX})."
            )

        return prior_version, owning_package

    def _parse_filename(
        self,
        d,
        key,
        key_prefix,
        key_hint=None,
        required: bool = True,
        with_prefix: bool = True,
    ) -> Tuple[str, str]:
        if required:
            path = self._required_key(d, key, key_prefix, expected_type=str)
        else:
            path = self._optional_key(d, key, key_prefix, expected_type=str)
        normalized_path = _normalize_path(path, with_prefix=False)
        if path.endswith("/") or normalized_path == ".":
            definition_source = f"{key_prefix}.{key}"
            if key_hint is not None:
                definition_source += f" <{key_hint}>"
            if normalized_path == ".":
                self._error(
                    f'The path "{path}" in {definition_source} looks like the root directory,'
                    f" but this feature can only be used for files"
                )
            self._error(
                f'The path "{path}" in {definition_source} ends with "/" implying it is a directory,'
                f" but this feature can only be used for files"
            )
        return path, normalized_path

    def _parse_conffile_removal(
        self, d: Dict, definition_source: str
    ) -> DpkgMaintscriptHelperCommand:
        remove_def = self._required_key(
            d, MK_CONFFILE_MANAGEMENT_REMOVE, definition_source, expected_type=dict
        )
        definition_source += f".{MK_CONFFILE_MANAGEMENT_REMOVE}"
        orig_path, normalized_path = self._parse_filename(
            remove_def,
            MK_CONFFILE_MANAGEMENT_REMOVE_PATH,
            definition_source,
            with_prefix=False,
        )
        normalized_path = "/" + normalized_path
        version, owning_package = self._parse_conffile_prior_version_and_owning_package(
            remove_def, definition_source
        )
        definition_source += f" <{orig_path}>"
        return DpkgMaintscriptHelperCommand.rm_conffile(
            definition_source,
            normalized_path,
            version,
            owning_package,
        )

    def _parse_conffile_rename(
        self, d: Dict, definition_source: str
    ) -> DpkgMaintscriptHelperCommand:
        rename_def = self._required_key(
            d, MK_CONFFILE_MANAGEMENT_RENAME, definition_source, expected_type=dict
        )
        definition_source += f".{MK_CONFFILE_MANAGEMENT_RENAME}"
        orig_source, normalized_source = self._parse_filename(
            rename_def,
            MK_CONFFILE_MANAGEMENT_RENAME_SOURCE,
            definition_source,
            with_prefix=False,
        )

        orig_target, normalized_target = self._parse_filename(
            rename_def,
            MK_CONFFILE_MANAGEMENT_RENAME_TARGET,
            definition_source,
            key_hint=orig_source,
            with_prefix=False,
        )
        normalized_source = "/" + normalized_source
        normalized_target = "/" + normalized_target

        if normalized_source == normalized_target:
            self._error(
                f"Invalid rename defined in {definition_source}: The source and target path are the same!"
            )

        version, owning_package = self._parse_conffile_prior_version_and_owning_package(
            rename_def, definition_source
        )
        definition_source += f" <{orig_source}>"
        return DpkgMaintscriptHelperCommand.mv_conffile(
            definition_source,
            normalized_source,
            normalized_target,
            version,
            owning_package,
        )

    def _parse_yaml_conffile_management(self, d, definition_source: str) -> None:
        entries = self._optional_key(
            d,
            MK_CONFFILE_MANAGEMENT,
            definition_source,
            expected_type=list,
            default_value=[],
        )
        package_state = self.current_binary_package_state

        supported_conffile_actions: Dict[
            str, Callable[[Dict, str], DpkgMaintscriptHelperCommand]
        ] = {
            MK_CONFFILE_MANAGEMENT_REMOVE: self._parse_conffile_removal,
            MK_CONFFILE_MANAGEMENT_RENAME: self._parse_conffile_rename,
        }

        for no, entry in enumerate(entries):
            path_definition_source = (
                f"{definition_source}.{MK_CONFFILE_MANAGEMENT}[{no}]"
            )
            key = self._unpack_single_key_dict(entry, path_definition_source)
            handler = supported_conffile_actions.get(key)
            if handler is not None:
                dpkg_script_snippet = handler(entry, path_definition_source)
            else:
                self._error(
                    f'Unknown or unsupported conffile management "{key}" at {path_definition_source}.'
                    " Conffile management must have one of the following keys:"
                    f' {", ".join(sorted(entry))}'
                )
            package_state.dpkg_maintscript_helper_snippets.append(dpkg_script_snippet)

    def from_yaml_dict(self, yaml_data):
        manifest_version = self._required_key(
            yaml_data, MK_MANIFEST_VERSION, "", expected_type=str
        )
        if manifest_version not in SUPPORTED_MANIFEST_VERSIONS:
            self._error(
                "Unsupported manifest-version.  This implementation supports the following versions:"
                f' {", ".join(repr(v) for v in SUPPORTED_MANIFEST_VERSIONS)}"'
            )

        packages_dict = self._optional_key(
            yaml_data, MK_PACKAGES, "", expected_type=dict
        )
        if packages_dict is None:
            packages_dict = {}
        self._parse_yaml_installations(yaml_data, "")
        for k, v in packages_dict.items():
            self._ensure_value_is_type(v, dict, k, MK_PACKAGES)
            definition_source = f"packages.{k}"
            if not v:
                v = {}
            with self.binary_package_context(k) as package_state:
                if package_state.is_auto_generated_package:
                    # Maybe lift (part) of this restriction.
                    self._error(
                        f'Cannot define rules for package "{k}". It is an auto-generated package.'
                    )

                binary_version = self._optional_key(
                    v,
                    "binary-version",
                    definition_source,
                    expected_type=str,
                )
                if binary_version is not None:
                    package_state.binary_version = (
                        package_state.substitution.substitute(
                            binary_version,
                            f"{definition_source}.binary-version",
                        )
                    )
                # Parse directories first.  It makes it easier for us to get the implicit rules to make sense
                # for people and saves us from doing it conflict resolution between implicit and explicit
                # directory rules.
                self._parse_yaml_directories(v, definition_source)
                self._parse_yaml_path_metadata(v, definition_source)
                self._parse_yaml_create_symlinks(v, definition_source)
                self._parse_yaml_transformations(
                    v, package_state.transformations, definition_source
                )
                self._parse_yaml_conffile_management(v, definition_source)
        return self.build_manifest()

    def parse_manifest(self, *, fd: Union[IO[bytes], str] = None) -> HighLevelManifest:
        if fd is None:
            with open(self.manifest_path) as fd:
                data = MANIFEST_YAML.load(fd)
                self._mutable_yaml_manifest = MutableYAMLManifest(data)
                return self.from_yaml_dict(data)
        else:
            data = MANIFEST_YAML.load(fd)
            self._mutable_yaml_manifest = MutableYAMLManifest(data)
            return self.from_yaml_dict(data)


class InstallLikeManifestParser(HighLevelManifestParser):
    def _parse_owner_keyword(self, value: str, line_no: int):
        owner = "root"
        uid = 0
        group = "root"
        gid = 0
        if ":" not in value and value != ":":
            self._error(
                "The owner keyword must have [user]:[group] as value and at least one of [user]"
                f" or [group] must be non-empty (manifest: {self.manifest_path}:{line_no})"
            )
        if value.count(":") > 1:
            self._error(
                "The owner keyword must have [user]:[group] where none of [user] nor [group] can"
                f" contain a colon (manifest: {self.manifest_path}:{line_no})"
            )
        declared_owner, declared_group = value.split(":")
        if declared_owner:
            try:
                owner, uid = self.parse_and_resolve_owner(declared_owner)
            except ValueError as e:
                self._error(
                    'Invalid ownership (user) information in "owner"'
                    f" (manifest: {self.manifest_path}:{line_no}): {e.args[0]}"
                )
        if declared_group:
            try:
                group, gid = self.parse_and_resolve_group(declared_group)
            except ValueError as e:
                self._error(
                    'Invalid ownership (group) information in "owner"'
                    f" (manifest: {self.manifest_path}:{line_no}): {e.args[0]}"
                )
        return owner, uid, group, gid

    def _parse_install_line(
        self, line_no: int, install_line: List[str]
    ) -> ManifestPathRule:
        mode = None
        owner = "root"
        uid = 0
        group = "root"
        gid = 0
        seen_key_words = set()
        while install_line:
            if "=" not in install_line[-1]:
                break

            latest_entry = install_line.pop()
            k, v = _parse_keyword(latest_entry)
            if k in seen_key_words:
                self._error(
                    f"The {k} keyword can only appear once per line"
                    f" (manifest: {self.manifest_path}:{line_no})"
                )
            seen_key_words.add(k)
            if k == "mode":
                mode = int(v, 8)
            elif k == "owner":
                owner, uid, group, gid = self._parse_owner_keyword(v, line_no)
            else:
                self._error(
                    f"Unsupported key {k} in {self.manifest_path} line {line_no}"
                )

        # TODO: Pop the last path and use it as a target dir for true dh_install compat
        if len(install_line) != 1:
            # Cannot be bothered to do the "install foo and bar into baz" case right now.
            self._error(
                "There must be exactly one path per line in this prototype (restriction will be loosen later)."
                f"  Bad line is {line_no} in {self.manifest_path}"
            )

        definition_source = f"line {line_no}"

        return ManifestPathRule.ensure_metadata(
            definition_source,
            self.parse_path_or_glob(install_line[0], definition_source),
            mode=mode,
            dirmode=mode,
            special_mode=mode,
            uid=uid,
            uname=owner,
            gid=gid,
            gname=group,
        )

    def from_install(self, d: List[Tuple[int, List[str]]]):
        for line_no, install_line in d:
            path_info = self._parse_install_line(line_no, install_line)
            self._add_entry(path_info)

        return self.build_manifest()

    def parse_manifest(self) -> HighLevelManifest:
        with open(self.manifest_path) as fd:
            data = [
                (no, line.split())
                for no, line in enumerate(fd, start=1)
                if line.strip() and not line.strip().startswith("#")
            ]
            return self.from_install(data)


class MTreeManifestParser(HighLevelManifestParser):
    def _process_mtree_keywords(
        self,
        path: str,
        keywords: Dict[str, str],
        line_no: int,
    ) -> ManifestPathRule:
        baseline = DEFAULT_PATH_INFO
        keywords.setdefault(
            "mode", oct(baseline.mode)[2:] if baseline.mode is not None else None
        )
        if "uname" in keywords or "uid" in keywords:
            keywords.setdefault("uname", "")
            keywords.setdefault("uid", "")
        else:
            keywords.setdefault("uname", baseline.uname)
            keywords.setdefault("uid", str(baseline.uid))

        if "gname" in keywords or "gid" in keywords:
            keywords.setdefault("gname", "")
            keywords.setdefault("gid", "")
        else:
            keywords.setdefault("gname", baseline.gname)
            keywords.setdefault("gid", str(baseline.gid))

        mtree_mode = keywords["mode"]
        mtree_uname = keywords.get("uname")
        mtree_uid = keywords["uid"]
        mtree_gname = keywords["gname"]
        mtree_gid = keywords["gid"]

        try:
            mode = int(mtree_mode, 8) if mtree_mode is not None else None
        except ValueError as e:
            self._error(
                f"The tool only supports an octal mode (manifest: {self.manifest_path}:{line_no}): {e.args[0]}"
            )

        try:
            owner, uid = self.parse_and_resolve_owner(f"{mtree_uname}:{mtree_uid}")
        except ValueError as e:
            self._error(
                'Invalid ownership (user) information in "owner"'
                f" (manifest: {self.manifest_path}:{line_no}): {e.args[0]}"
            )
        try:
            group, gid = self.parse_and_resolve_group(f"{mtree_gname}:{mtree_gid}")
        except ValueError as e:
            self._error(
                'Invalid ownership (group) information in "owner"'
                f" (manifest: {self.manifest_path}:{line_no}): {e.args[0]}"
            )
        definition_source = f"line {line_no}"
        return ManifestPathRule.ensure_metadata(
            definition_source,
            self.parse_path_or_glob(path, definition_source),
            mode=mode,
            dirmode=mode,
            special_mode=mode,
            uid=uid,
            uname=owner,
            gid=gid,
            gname=group,
        )

    def _decode_mtree_path(self, mtree_path: str, line_no: int) -> str:
        if "\\" in mtree_path:
            self._error(
                f"Sorry, mtree path decoding not implemented yet (manifest: {self.manifest_path}:{line_no})"
            )
        return mtree_path

    def from_mtree(self, d: List[Tuple[int, List[str]]]):
        supported_keywords = {
            "mode",
            "uname",
            "uid",
            "gname",
            "gid",
        }
        for line_no, install_line in d:
            if install_line[0].startswith("/"):
                self._error(
                    "Special commands such as /set and /unset are unsupported. If you wanted a path,"
                    f" please remove the leading slash. Problematic line is {line_no} in {self.manifest_path}"
                )
            if "/" not in install_line[0]:
                self._error(
                    'Only "Full" paths are supported at the moment meaning they have to contain (but must not'
                    " start with) a slash.  You can prefix the path with ./ or (for directories) end with a"
                    f" slash. Problematic line is {line_no} in {self.manifest_path}"
                )

            path = self._decode_mtree_path(install_line[0], line_no)
            keywords = dict(_parse_keyword(kw) for kw in install_line[1:])
            unknown_keywords = keywords.keys() - supported_keywords
            if unknown_keywords:
                as_str = ", ".join(sorted(unknown_keywords))
                self._error(
                    f"The following keywords in {self.manifest_path} line {line_no} are currently not"
                    f" supported: {as_str}"
                )

            path_info = self._process_mtree_keywords(path, keywords, line_no)
            self._add_entry(path_info)

        return self.build_manifest()

    def parse_manifest(self) -> HighLevelManifest:
        with open(self.manifest_path) as fd:
            data = [
                (no, line.split())
                for no, line in enumerate(fd, start=1)
                if line.strip() and not line.strip().startswith("#")
            ]
            return self.from_mtree(data)


def parse_manifest(
    manifest_path: Optional[str],
    binary_packages: Mapping[str, BinaryPackage],
    substitution: Substitution,
    dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable,
    dpkg_arch_query_table: DpkgArchTable,
    build_env: DebOptionsAndProfiles,
    debputy_plugin_feature_set,
) -> HighLevelManifest:
    if manifest_path is None:
        manifest_path = "debian/debputy.manifest"
        if not os.path.exists(manifest_path):
            return YAMLManifestParser(
                manifest_path,
                binary_packages,
                substitution,
                dpkg_architecture_variables,
                dpkg_arch_query_table,
                build_env,
                debputy_plugin_feature_set,
            ).build_manifest()
    if (
        manifest_path.endswith(("/debputy.manifest", ".yaml"))
        or manifest_path == "debputy.manifest"
    ):
        return YAMLManifestParser(
            manifest_path,
            binary_packages,
            substitution,
            dpkg_architecture_variables,
            dpkg_arch_query_table,
            build_env,
            debputy_plugin_feature_set,
        ).parse_manifest()
    if manifest_path.endswith((".mtree", "/mtree")) or manifest_path == "mtree":
        return MTreeManifestParser(
            manifest_path,
            binary_packages,
            substitution,
            dpkg_architecture_variables,
            dpkg_arch_query_table,
            build_env,
            debputy_plugin_feature_set,
        ).parse_manifest()
    if (
        manifest_path.endswith(("/install-manifest", ".install-manifest"))
        or manifest_path == "install-manifest"
    ):
        return InstallLikeManifestParser(
            manifest_path,
            binary_packages,
            substitution,
            dpkg_architecture_variables,
            dpkg_arch_query_table,
            build_env,
            debputy_plugin_feature_set,
        ).parse_manifest()
    _error_exit(
        "The manifest path must be either a debputy.manifest, .yaml, a .mtree or a .install-manifest file"
    )
