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

from dataclasses import dataclass, field
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 builtin_mode_normalization_rules
from .filesystem_scan import FSPath, FSRootDir
from .installations import (
    InstallRule,
    SourcePathMatcher,
    PathAlreadyInstalledOrDiscardedError,
    NoMatchForInstallPatternError,
)
from .intermediate_manifest import TarMember, PathType, IntermediateManifest
from .maintscript_snippet import (
    DpkgMaintscriptHelperCommand,
    MaintscriptSnippetContainer,
)
from .manifest_conditions import ConditionContext
from .packager_provided_files import PackagerProvidedFile
from .packages import BinaryPackage
from .packaging.makeshlibs import SONAMEInfo, extract_soname_info
from .plugin.api.impl_types import PluginProvidedFeatureSet
from .plugin.api.spec import FlushableSubstvars
from .substitution import Substitution
from .transformation_rules import (
    TransformationRule,
    ModeNormalizationTransformationRule,
)
from .util import (
    _error,
    _warn,
    debian_policy_normalize_symlink_target,
    generated_content_dir,
)

MANIFEST_YAML = YAML()


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


@dataclass(slots=True, frozen=True)
class BinaryPackageInstallRuleContext:
    binary_package: BinaryPackage
    fs_root: FSPath
    doc_main_package: BinaryPackage


@dataclass(slots=True)
class PackageTransformationDefinition:
    binary_package: BinaryPackage
    substitution: Substitution
    is_auto_generated_package: bool
    binary_version: Optional[str] = None
    dpkg_maintscript_helper_snippets: List[DpkgMaintscriptHelperCommand] = field(
        default_factory=list
    )
    maintscript_snippets: Dict[str, MaintscriptSnippetContainer] = field(
        default_factory=dict
    )
    transformations: List[TransformationRule] = field(default_factory=list)
    reserved_packager_provided_files: Dict[str, List[PackagerProvidedFile]] = field(
        default_factory=dict
    )
    install_rules: List[InstallRule] = field(default_factory=list)


def _path_to_tar_member(
    path: FSPath,
    clamp_mtime_to: int,
) -> TarMember:
    mtime = float(clamp_mtime_to)
    owner, uid, group, gid = path.tar_owner_info
    mode = path.mode

    if path.has_fs_path:
        mtime = min(mtime, path.mtime)

    if path.is_dir:
        path_type = PathType.DIRECTORY
    elif path.is_file:
        # TODO: someday we will need to deal with hardlinks and it might appear here.
        path_type = PathType.FILE
    elif path.is_symlink:
        # Special-case that we resolve immediately (since we need to normalize the target anyway)
        link_target = debian_policy_normalize_symlink_target(
            path.path,
            path.readlink(),
        )
        return TarMember.virtual_path(
            path.tar_path,
            PathType.SYMLINK,
            mtime,
            link_target=link_target,
            # Force mode to be 0777 as that is the mode we see in the data.tar.  In theory, tar lets you set
            # it to whatever. However, for reproducibility, we have to be well-behaved - and that is 0777.
            mode=0o0777,
            owner=owner,
            uid=uid,
            group=group,
            gid=gid,
        )
    else:
        assert not path.is_symlink
        raise AssertionError(
            f"Unsupported file type: {path.path}  - not a file, dir nor a symlink!"
        )

    if not path.has_fs_path:
        assert not path.is_file
        return TarMember.virtual_path(
            path.tar_path,
            path_type,
            mtime,
            mode=mode,
            owner=owner,
            uid=uid,
            group=group,
            gid=gid,
        )
    return TarMember.from_file(
        path.tar_path,
        path.fs_path,
        mode=mode,
        uid=uid,
        owner=owner,
        gid=gid,
        group=group,
        path_type=path_type,
        path_mtime=mtime,
        clamp_mtime_to=clamp_mtime_to,
    )


def _generate_intermediate_manifest(
    fs_root: FSPath,
    clamp_mtime_to: int,
) -> Iterable[TarMember]:
    symlinks = []
    for path in fs_root.all_paths():
        tar_member = _path_to_tar_member(path, 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":
        inner = {
            MK_TRANSFORMATIONS_CREATE_SYMLINK_LINK_PATH: link_path,
            MK_TRANSFORMATIONS_CREATE_SYMLINK_LINK_TARGET: link_target,
        }
        content = {MK_TRANSFORMATIONS_CREATE_SYMLINK: inner}
        if condition is not None:
            inner["when"] = condition
        return cls(None, None, store=CommentedMap(content))

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

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

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

    @symlink_target.setter
    def symlink_target(self, target: str) -> None:
        self._store[MK_TRANSFORMATIONS_CREATE_SYMLINK][
            MK_TRANSFORMATIONS_CREATE_SYMLINK_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_TRANSFORMATIONS, symlink)

    def symlinks(self) -> Iterable[MutableYAMLSymlink]:
        store = self._list_store(MK_TRANSFORMATIONS)
        if store is None:
            return
        for i in range(len(store)):
            d = store[i]
            if d and isinstance(d, dict) and len(d) == 1 and "symlink" in d:
                yield MutableYAMLSymlink(store, i)

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

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

    @into.setter
    def into(self, new_value: Optional[Union[str, List[str]]]) -> None:
        if new_value is None:
            with suppress(KeyError):
                del self._container[MK_INSTALLATIONS_INSTALL_INTO]
            return
        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

    @classmethod
    def install_dest(
        cls,
        sources: Union[str, List[str]],
        into: Optional[Union[str, List[str]]],
        *,
        dest_dir: Optional[str] = None,
    ) -> "MutableYAMLInstallRuleInstall":
        k = MK_INSTALLATIONS_INSTALL_SOURCES
        if isinstance(sources, str):
            k = MK_INSTALLATIONS_INSTALL_SOURCE
        r = MutableYAMLInstallRuleInstall(
            None,
            None,
            store=CommentedMap(
                {
                    MK_INSTALLATIONS_INSTALL: CommentedMap(
                        {
                            k: sources,
                        }
                    )
                }
            ),
        )
        r.dest_dir = dest_dir
        r.into = into
        return r

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

    @classmethod
    def install_docs(
        cls,
        sources: Union[str, List[str]],
        into: Optional[Union[str, List[str]]],
    ) -> "MutableYAMLInstallRuleInstallDocLike":
        k = MK_INSTALLATIONS_INSTALL_SOURCES
        if isinstance(sources, str):
            k = MK_INSTALLATIONS_INSTALL_SOURCE
        r = MutableYAMLInstallRuleInstallDocLike(
            None,
            None,
            store=CommentedMap(
                {
                    MK_INSTALLATIONS_INSTALL_DOCS: CommentedMap(
                        {
                            k: sources,
                        }
                    )
                }
            ),
        )
        r.into = into
        return r

    @classmethod
    def install_examples(
        cls,
        sources: Union[str, List[str]],
        into: Optional[Union[str, List[str]]],
    ) -> "MutableYAMLInstallRuleInstallDocLike":
        k = MK_INSTALLATIONS_INSTALL_SOURCES
        if isinstance(sources, str):
            k = MK_INSTALLATIONS_INSTALL_SOURCE
        r = MutableYAMLInstallRuleInstallDocLike(
            None,
            None,
            store=CommentedMap(
                {
                    MK_INSTALLATIONS_INSTALL_EXAMPLES: CommentedMap(
                        {
                            k: sources,
                        }
                    )
                }
            ),
        )
        r.into = into
        return r

    @classmethod
    def install_man(
        cls,
        sources: Union[str, List[str]],
        into: Optional[Union[str, List[str]]],
        language: Optional[str],
    ) -> "MutableYAMLInstallRuleMan":
        k = MK_INSTALLATIONS_INSTALL_SOURCES
        if isinstance(sources, str):
            k = MK_INSTALLATIONS_INSTALL_SOURCE
        r = MutableYAMLInstallRuleMan(
            None,
            None,
            store=CommentedMap(
                {
                    MK_INSTALLATIONS_INSTALL_MAN: CommentedMap(
                        {
                            k: sources,
                        }
                    )
                }
            ),
        )
        r.language = language
        r.into = into
        return r

    @classmethod
    def discard(
        cls,
        sources: Union[str, List[str]],
    ) -> "MutableYAMLInstallRuleDiscard":
        return MutableYAMLInstallRuleDiscard(
            None,
            None,
            store=CommentedMap({MK_INSTALLATIONS_DISCARD: sources}),
        )


class MutableYAMLInstallRuleInstallDocLike(AbstractMutableYAMLInstallRule):
    pass


class MutableYAMLInstallRuleMan(AbstractMutableYAMLInstallRule):
    @property
    def language(self) -> Optional[str]:
        v = self._container[MK_INSTALLATIONS_INSTALL_MAN_LANGUAGE]
        if isinstance(v, str):
            return [v]
        return v

    @language.setter
    def language(self, new_value: Optional[str]) -> None:
        if new_value is not None:
            self._container[MK_INSTALLATIONS_INSTALL_MAN_LANGUAGE] = new_value
            return
        with suppress(KeyError):
            del self._container[MK_INSTALLATIONS_INSTALL_MAN_LANGUAGE]


class MutableYAMLInstallRuleDiscard(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]


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)


def _describe_missing_path(entry: os.DirEntry[str]) -> str:
    if entry.is_dir(follow_symlinks=False):
        return f"{entry.path}/ (empty directory; possible integration point)"
    if entry.is_symlink():
        target = os.readlink(entry.path)
        return f"{entry.path} (symlink; links to {target})"
    if entry.is_file(follow_symlinks=False):
        return f"{entry.path} (file)"
    return f"{entry.path} (other!? Probably not supported by debputy and may need a `remove`)"


def _detect_missing_installations(path_matcher: SourcePathMatcher) -> None:
    search_dir = "debian/tmp"
    if not os.path.isdir(search_dir):
        return
    missing = list(path_matcher.detect_missing(search_dir))
    if not missing:
        return

    _warn(
        f"The following paths were present in {search_dir}, but not installed (nor explicitly discarded)."
    )
    _warn("")
    for entry in missing:
        desc = _describe_missing_path(entry)
        _warn(f" * {desc}")
    _warn("")

    excl = textwrap.dedent(
        """\
        - discard:
            path: "*"
    """
    )

    _error(
        "Please review the list and add either install rules or exclusions to `installations` in"
        " debian/debputy.manifest.  If you do not need any of these paths, add the following to the"
        f" end of your 'installations`:\n\n{excl}\n"
    )


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: Mapping[str, PackageTransformationDefinition],
        dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable,
        dpkg_arch_query_table: DpkgArchTable,
        build_env: DebOptionsAndProfiles,
        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._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 package_state_for(self, package: str) -> PackageTransformationDefinition:
        return self.package_transformations[package]

    def _detect_doc_main_package_for(self, package: BinaryPackage) -> BinaryPackage:
        name = package.name
        # If it is not a -doc package, then docs should be installed
        # under its own package name.
        if not name.endswith("-doc"):
            return package
        name = name[:-4]
        main_package = self._binary_packages.get(name)
        if main_package:
            return main_package
        if name.startswith("lib"):
            dev_pkg = self._binary_packages.get(f"{name}-dev")
            if dev_pkg:
                return dev_pkg

        # If we found no better match; default to the doc package itself.
        return package

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

        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
            doc_main_package = self._detect_doc_main_package_for(dctrl_bin)

            install_rule_contexts[package] = BinaryPackageInstallRuleContext(
                dctrl_bin,
                FSRootDir(),
                doc_main_package,
            )
        path_matcher = SourcePathMatcher()

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

        for dctrl_bin in self.active_packages:
            package = dctrl_bin.name
            build_system_staging_dir = os.path.join("debian", package)

            if os.path.isdir(build_system_staging_dir):
                install_rule = InstallRule.install_dest(
                    ["*"],
                    None,
                    frozenset([dctrl_bin]),
                    [build_system_staging_dir],
                    f"Built-in; install everything from {build_system_staging_dir} into {package}",
                    None,
                )
                try:
                    install_rule.perform_install(
                        path_matcher,
                        install_rule_contexts,
                        source_condition_context,
                    )
                except (
                    NoMatchForInstallPatternError,
                    PathAlreadyInstalledOrDiscardedError,
                ):
                    # Empty directory or everything excluded by default; ignore the error
                    pass

        if self._install_rules:
            # FIXME: Check that every install rule remains used after transformations have run.
            # What we want to check is transformations do not exclude everything from an install
            # rule. The hard part here is that renaming (etc.) is fine, so we cannot 1:1 string
            # match.
            for install_rule in self._install_rules:
                install_rule.perform_install(
                    path_matcher,
                    install_rule_contexts,
                    source_condition_context,
                )

        _detect_missing_installations(path_matcher)

        for dctrl_bin in self.all_packages:
            package = dctrl_bin.name
            install_rule_context = install_rule_contexts[package]
            build_system_staging_dir = os.path.join("debian", package)
            fs_root = install_rule_context.fs_root
            sonames = None

            context = self.package_transformations[package]
            for special_install_rule in context.install_rules:
                special_install_rule.perform_install(
                    path_matcher,
                    install_rule_contexts,
                    source_condition_context,
                )

            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
                control_output_dir = generated_content_dir(
                    package=dctrl_bin, subdir_key="DEBIAN"
                )
            else:
                substvars = FlushableSubstvars()
                control_output_dir = None

            package_data_table[package] = BinaryPackageData(
                dctrl_bin,
                build_system_staging_dir,
                control_output_dir,
                fs_root,
                substvars,
                install_rule_context.doc_main_package,
                sonames,
            )

        return package_data_table

    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,
        )
        norm_rules = list(
            builtin_mode_normalization_rules(
                self._dpkg_architecture_variables,
                package_transformation.binary_package,
                package_transformation.substitution,
            )
        )
        norm_mode_transformation_rule = ModeNormalizationTransformationRule(norm_rules)
        norm_mode_transformation_rule.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)

        # 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

        intermediate_manifest = list(
            _generate_intermediate_manifest(
                fs_root,
                clamp_mtime_to,
            )
        )
        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)
