import dataclasses
import glob
import os.path
import re
import stat
from enum import IntEnum
from os import DirEntry
from typing import (
    List,
    Dict,
    FrozenSet,
    Callable,
    Union,
    Iterator,
    Tuple,
    Set,
    Sequence,
    Optional,
    Mapping,
    Iterable,
    TYPE_CHECKING,
)

from debputy.exceptions import DebputyRuntimeError
from debputy.filesystem_scan import FSPath, fs_scan_dir, FSPathDirEntry
from debputy.manifest_conditions import (
    ConditionContext,
    ManifestCondition,
    _BUILD_DOCS_BDO,
)
from debputy.packager_provided_files import PackagerProvidedFile
from debputy.packages import BinaryPackage
from debputy.substitution import Substitution
from debputy.util import has_glob_magic, _normalize_path, _error, _warn

if TYPE_CHECKING:
    from debputy.highlevel_manifest import BinaryPackageInstallRuleContext


_MAN_TH_LINE = re.compile(r'^[.]TH\s+\S+\s+"?(\d+[^"\s]*)"?')
_MAN_DT_LINE = re.compile(r"^[.]Dt\s+\S+\s+(\d+\S*)")
_MAN_SECTION_BASENAME = re.compile(r"[.]([1-9]\w*)(?:[.]gz)?$")
_MAN_REAL_SECTION = re.compile(r"^(\d+)")
_MAN_INST_BASENAME = re.compile(r"[.][^.]+$")
MAN_GUESS_LANG_FROM_PATH = re.compile(
    r"(?:^|/)man/(?:([a-z][a-z](?:_[A-Z][A-Z])?)(?:\.[^/]+)?)?/man[1-9]/"
)
MAN_GUESS_FROM_BASENAME = re.compile(r"[.]([a-z][a-z](?:_[A-Z][A-Z])?)[.](?:[1-9]|man)")


class InstallRuleError(DebputyRuntimeError):
    pass


class PathAlreadyInstalledOrDiscardedError(InstallRuleError):
    @property
    def path(self) -> str:
        return self.args[0]

    @property
    def into(self) -> FrozenSet[BinaryPackage]:
        return self.args[1]

    @property
    def definition_source(self) -> str:
        return self.args[2]


class ExactPathMatchTwiceError(InstallRuleError):
    @property
    def path(self) -> str:
        return self.args[1]

    @property
    def into(self) -> BinaryPackage:
        return self.args[2]

    @property
    def definition_source(self) -> str:
        return self.args[3]


class NoMatchForInstallPatternError(InstallRuleError):
    @property
    def pattern(self) -> str:
        return self.args[1]

    @property
    def search_dirs(self) -> Sequence[str]:
        return self.args[2]

    @property
    def definition_source(self) -> str:
        return self.args[3]


@dataclasses.dataclass(slots=True, frozen=True)
class PathMatch:
    path: str
    search_dir: str
    fs_path: str
    stat_cache: os.stat_result
    is_exact_match: bool


class DiscardState(IntEnum):
    UNCHECKED = 0
    NOT_DISCARDED = 1
    DISCARDED_BY_PLUGIN_PROVIDED_RULE = 2
    DISCARDED_BY_MANIFEST_RULE = 3


def _debputy_discard_pyc_files(_path: str, basename: str) -> bool:
    return basename == "__pycache__" or basename.endswith((".pyc", ".pyo"))


def _debputy_prune_la_files(_path: str, basename: str) -> bool:
    return basename.endswith(".la")


_BACKUP_FILES_RE = re.compile(
    "|".join(
        [
            # Common backup files
            r".*~",
            r".*[.](?:bak|orig)",
            # Editor backup/swap files
            r"[.]#.*",
            r"[.].*[.]sw.",
            # Other known stuff
            r"[.]shelf",
            r",,.*",  # "baz-style junk" (according to dpkg (Dpkg::Source::Package)
            r"DEADJOE",  # Joe's one line of immortality that just gets cargo cult'ed around ... just in case.
        ]
    )
)


def _debputy_prune_backup_files(_path: str, basename: str) -> bool:
    return bool(_BACKUP_FILES_RE.match(basename))


_VCS_PATHS = {
    ".arch-inventory",
    ".arch-ids",
    ".be",
    ".bzrbackup",
    ".bzrignore",
    ".bzrtags",
    ".cvsignore",
    ".hg",
    ".hgignore",
    ".hgtags",
    ".hgsigs",
    ".git",
    ".gitignore",
    ".gitattributes",
    ".gitmodules",
    ".gitreview",
    ".mailmap",
    ".mtn-ignore",
    ".svn",
    "{arch}",
    "CVS",
    "RCS",
    "_MTN",
    "_darcs",
}


def _debputy_prune_vcs_files(_path: str, basename: str) -> bool:
    return basename in _VCS_PATHS


def _debputy_prune_info_dir_file(path: str, _basename) -> bool:
    return path.endswith("usr/share/info/dir")


def _debputy_prune_binary_debian_dir(_path: str, basename: str) -> bool:
    return basename == "DEBIAN"


_PLUGIN_PROVIDED_DISCARD_RULES = [
    _debputy_discard_pyc_files,
    _debputy_prune_la_files,
    _debputy_prune_backup_files,
    _debputy_prune_vcs_files,
    _debputy_prune_info_dir_file,
    _debputy_prune_binary_debian_dir,
]


def _run_plugin_provided_discard_rules_on(path: str) -> bool:
    basename = os.path.basename(path)
    return any(c(path, basename) for c in _PLUGIN_PROVIDED_DISCARD_RULES)


def _determine_manpage_section(
    match_rule: PathMatch,
    provided_section: Optional[int],
    definition_source: str,
) -> Optional[str]:
    section = str(provided_section) if provided_section is not None else None
    if section is None:
        detected_section = None
        with open(match_rule.fs_path, "r") as fd:
            for line in fd:
                if not line.startswith((".TH", ".Dt")):
                    continue

                m = _MAN_DT_LINE.match(line)
                if not m:
                    m = _MAN_TH_LINE.match(line)
                    if not m:
                        continue
                detected_section = m.group(1)
                if "." in detected_section:
                    _warn(
                        f"Ignoring detected section {detected_section} in {match_rule.fs_path}"
                        f" (detected via {definition_source}): It looks too much like a version"
                    )
                    detected_section = None
                break
        if detected_section is None:
            m = _MAN_SECTION_BASENAME.match(os.path.basename(match_rule.path))
            if m:
                detected_section = m.group(1)
        section = detected_section

    return section


def _determine_manpage_real_section(
    match_rule: PathMatch,
    section: Optional[str],
    definition_source: str,
) -> int:
    real_section = None
    if section is not None:
        m = _MAN_REAL_SECTION.match(section)
        if m:
            real_section = int(m.group(1))
    if real_section is None or real_section < 0 or real_section > 9:
        if real_section is not None:
            _warn(
                f"Computed section for {match_rule.fs_path} was {section}"
                f" (detected via {definition_source}): It looks too much like a version"
            )
        _error(
            f"Could not determine the section for {match_rule.fs_path} automatically.  The manpage"
            f" was detected via {definition_source}. Consider using `section: <number>` to"
            " explicitly declare the section. Keep in mind that it applies to all manpages for that"
            " rule and you may have to split the rule into two for this reason."
        )
    return real_section


def _determine_manpage_language(
    match_rule: PathMatch,
    provided_language: Optional[str],
) -> Optional[str]:
    if provided_language is not None:
        if provided_language not in ("derive-from-basename", "derive-from-path"):
            return provided_language if provided_language != "C" else None
        if provided_language == "derive-from-basename":
            m = MAN_GUESS_FROM_BASENAME.search(os.path.basename(match_rule.path))
            if m is None:
                return None
            return m.group(1)
        # Fall-through for derive-from-path case
    m = MAN_GUESS_LANG_FROM_PATH.search(match_rule.path)
    if m is None:
        return None
    return m.group(1)


def _dest_path_for_manpage(
    provided_section: Optional[int],
    provided_language: Optional[str],
    definition_source: str,
) -> Callable[["PathMatch"], str]:
    def _manpage_dest_path(match_rule: PathMatch) -> str:
        inst_basename = _MAN_INST_BASENAME.sub("", os.path.basename(match_rule.path))
        section = _determine_manpage_section(
            match_rule, provided_section, definition_source
        )
        real_section = _determine_manpage_real_section(
            match_rule, section, definition_source
        )
        assert section is not None
        language = _determine_manpage_language(match_rule, provided_language)
        if language is None:
            maybe_language = ""
        else:
            maybe_language = f"{language}/"
            lang_suffix = f".{language}"
            if inst_basename.endswith(lang_suffix):
                inst_basename = inst_basename[: -len(lang_suffix)]

        return (
            f"usr/share/man/{maybe_language}man{real_section}/{inst_basename}.{section}"
        )

    return _manpage_dest_path


class SourcePathMatcher:
    def __init__(self) -> None:
        self._already_matched: Dict[
            str,
            Tuple[FrozenSet[BinaryPackage], str],
        ] = {}
        self._exact_match_request: Set[Tuple[str, str]] = set()
        self._discarded: Dict[str, DiscardState] = {}

    def is_reserved(self, entry: DirEntry[str]) -> bool:
        path = entry.path
        if path in self._already_matched:
            return True
        result = self._discarded.get(path, DiscardState.UNCHECKED)
        if result == DiscardState.UNCHECKED:
            result = self._check_plugin_provided_exclude_state_for(path, ".")
        if result == DiscardState.NOT_DISCARDED:
            return False

        return True

    def exclude(self, path: str) -> None:
        self._discarded[path] = DiscardState.DISCARDED_BY_MANIFEST_RULE

    def _check_plugin_provided_exclude_state_for(
        self, path: str, search_dir: str
    ) -> DiscardState:
        cache_misses = []
        current_path = path
        while True:
            exclude_state = self._discarded.get(current_path, DiscardState.UNCHECKED)
            if exclude_state != DiscardState.UNCHECKED:
                verdict = exclude_state
                break
            cache_misses.append(current_path)
            if _run_plugin_provided_discard_rules_on(current_path):
                verdict = DiscardState.DISCARDED_BY_PLUGIN_PROVIDED_RULE
                break
            # We cannot trust a "NOT_DISCARDED" until we check its parent (the directory could
            # be excluded without the files in it triggering the rule).
            current_path = os.path.dirname(current_path)
            if not current_path or current_path == search_dir:
                verdict = DiscardState.NOT_DISCARDED
                break
        if cache_misses:
            for p in cache_misses:
                self._discarded[p] = verdict
        return verdict

    def may_match(
        self,
        match: PathMatch,
        *,
        is_exact_match: bool = False,
    ) -> Tuple[FrozenSet[BinaryPackage], bool]:
        m = self._already_matched.get(match.fs_path)
        if m:
            return m[0], False
        current_path = match.fs_path
        discard_state = self._discarded.get(current_path, DiscardState.UNCHECKED)

        if discard_state == DiscardState.UNCHECKED:
            discard_state = self._check_plugin_provided_exclude_state_for(
                match.path, match.search_dir
            )

        assert discard_state is not None and discard_state != DiscardState.UNCHECKED

        is_discarded = discard_state != DiscardState.NOT_DISCARDED
        if (
            is_exact_match
            and discard_state == DiscardState.DISCARDED_BY_PLUGIN_PROVIDED_RULE
        ):
            is_discarded = False
        return frozenset(), is_discarded

    def reserve(
        self,
        path: str,
        reserved_by: FrozenSet[BinaryPackage],
        definition_source: str,
        *,
        is_exact_match: bool = False,
    ) -> None:
        assert _normalize_path(path, with_prefix=False) == path

        self._already_matched[path] = reserved_by, definition_source
        if not is_exact_match:
            return
        for pkg in reserved_by:
            m_key = (pkg.name, path)
            self._exact_match_request.add(m_key)
        try:
            del self._discarded[path]
        except KeyError:
            pass

    def detect_missing(self, search_dir: str) -> Iterator[os.DirEntry[str]]:
        stack = [e for e in fs_scan_dir(search_dir)]
        while stack:
            m = stack.pop()
            if m.is_dir(follow_symlinks=False):
                s_len = len(stack)
                stack.extend(de for de in fs_scan_dir(m.path))

                if s_len == len(stack) and not self.is_reserved(m):
                    # "Explicitly" empty dir
                    yield m
            elif not self.is_reserved(m):
                yield m

    def find_and_reserve_all_matches(
        self,
        norm_pattern: str,
        search_dirs: Sequence[str],
        glob_expand: bool,
        dir_only_match: bool,
        match_filter: Optional[Callable[[str, os.stat_result], bool]],
        reserved_by: FrozenSet[BinaryPackage],
        definition_source: str,
    ) -> Tuple[List[PathMatch], Tuple[int, ...]]:
        matched = []
        already_installed_paths = 0
        already_excluded_paths = 0

        for match in _resolve_path(
            norm_pattern,
            glob_expand,
            search_dirs,
            dir_only_match,
            match_filter,
        ):
            installed_into, excluded = self.may_match(
                match, is_exact_match=not glob_expand
            )
            if installed_into:
                if glob_expand:
                    already_installed_paths += 1
                    continue
                packages = ", ".join(p.name for p in installed_into)
                raise PathAlreadyInstalledOrDiscardedError(
                    f'The "{match.path}" has been reserved by and installed into {packages}.'
                    f" The definition that triggered this issue is {definition_source}.",
                    match,
                    installed_into,
                    definition_source,
                )
            if excluded:
                if glob_expand:
                    already_excluded_paths += 1
                    continue
                raise PathAlreadyInstalledOrDiscardedError(
                    f'The "{match.path}" has been excluded. If you want this path installed, move it'
                    f" above the exclusion rule that excluded it. The definition that triggered this"
                    f" issue is {definition_source}.",
                    match,
                    installed_into,
                    definition_source,
                )
            if not glob_expand:
                for pkg in reserved_by:
                    m_key = (pkg.name, match.path)
                    if m_key in self._exact_match_request:
                        raise ExactPathMatchTwiceError(
                            f'The path "{match.path}" (via exact match) has already been installed'
                            f" into {pkg.name}. The second installation triggered by {definition_source}",
                            match.path,
                            pkg,
                            definition_source,
                        )
                    self._exact_match_request.add(m_key)

            if reserved_by:
                self._already_matched[match.fs_path] = reserved_by, definition_source
            else:
                self.exclude(match.fs_path)
            matched.append(match)
        exclude_counts = already_installed_paths, already_excluded_paths
        return matched, exclude_counts


def _resolve_path(
    norm_pattern: str,
    glob_expand: bool,
    search_dirs: Iterable[str],
    dir_only_match: bool,
    match_filter: Optional[Callable[[PathMatch], bool]],
) -> Iterator[PathMatch]:
    if glob_expand:
        for sdir in search_dirs:
            matched = False
            for match in glob.glob(norm_pattern, root_dir=sdir):
                fs_path = os.path.join(sdir, match)
                if dir_only_match:
                    fs_path += "/"
                try:
                    st = os.lstat(fs_path)
                except FileNotFoundError:
                    continue
                else:
                    m = PathMatch(match, sdir, fs_path, st, False)
                    if match_filter is not None and not match_filter(m):
                        continue
                    yield m
                    matched = True
            if matched:
                break
    else:
        for sdir in search_dirs:
            fs_path = os.path.join(sdir, norm_pattern)
            if dir_only_match:
                fs_path += "/"
            try:
                st = os.lstat(fs_path)
            except FileNotFoundError:
                continue
            else:
                m = PathMatch(norm_pattern, sdir, fs_path, st, True)
                if match_filter is not None and not match_filter(m):
                    continue
                yield m
                break


def _resolve_matches(
    matches: List[PathMatch],
    dest_path: Union[str, Callable[[PathMatch], str]],
    dest_path_is_format: bool,
    install_context: "BinaryPackageInstallRuleContext",
) -> Iterator[Tuple[PathMatch, str]]:
    if callable(dest_path):
        if dest_path_is_format:
            raise ValueError(
                "dest_path_is_format cannot be True when dest_path is a Callable"
            )
        for match in matches:
            yield match, dest_path(match)
    else:
        for match in matches:
            if dest_path_is_format:
                yield match, dest_path.format(
                    basename=os.path.basename(match.path),
                    dirname=os.path.dirname(match.path),
                    package_name=install_context.binary_package.name,
                    doc_main_package_name=install_context.doc_main_package.name,
                )
            else:
                yield match, dest_path


class InstallRule:
    __slots__ = (
        "_already_matched",
        "_exact_match_request",
        "_into",
        "_search_dirs",
        "_condition",
        "_match_filter",
        "_definition_source",
    )

    def __init__(
        self,
        into: FrozenSet[BinaryPackage],
        search_dirs: Sequence[str],
        condition: Optional[ManifestCondition],
        definition_source: str,
        *,
        match_filter: Optional[Callable[[str, os.stat_result], bool]] = None,
    ) -> None:
        self._into = into
        self._search_dirs = search_dirs
        self._condition = condition
        self._definition_source = definition_source
        self._match_filter = match_filter
        # We heavily rely on normalization for "_already_matched"
        assert all(
            _normalize_path(s, with_prefix=False) == s for s in self._search_dirs
        )

    def _match_pattern(
        self,
        path_matcher: SourcePathMatcher,
        pattern: str,
        condition_context: ConditionContext,
    ) -> List[PathMatch]:
        norm_pattern = _normalize_path(pattern, with_prefix=False)
        is_glob = has_glob_magic(norm_pattern)
        (matched, exclude_counts) = path_matcher.find_and_reserve_all_matches(
            norm_pattern,
            self._search_dirs,
            is_glob,
            pattern.endswith("/"),
            self._match_filter,
            self._into,
            self._definition_source,
        )

        already_installed_paths, already_excluded_paths = exclude_counts

        allow_empty_match = all(not p.should_be_acted_on for p in self._into)
        if self._condition is not None and not self._condition.evaluate(
            condition_context
        ):
            allow_empty_match = True

        if not matched and not allow_empty_match:
            if already_excluded_paths and already_installed_paths:
                total_paths = already_excluded_paths + already_installed_paths
                msg = (
                    f"There were no matches for {pattern} in {self._search_dirs} after ignoring"
                    f" {total_paths} path(s) already been matched previously either by install or"
                    f" exclude rules. If you wanted to install some of these paths into multiple"
                    f" packages, please tweak the definition that installed them to install them"
                    f' into multiple packages (usually change "into: foo" to "into: [foo, bar]".'
                    f" If you wanted to install these paths and exclude rules are getting in your"
                    f" way, then please move this install rule before the exclusion rule that causes"
                    f" issue or, in case of built-in excludes, list the paths explicitly (without"
                    f" using patterns). Source for this issue is {self._definition_source}"
                )
            elif already_excluded_paths:
                msg = (
                    f"There were no matches for {pattern} in {self._search_dirs} after ignoring"
                    f" {already_excluded_paths} path(s) that have been excluded."
                    " If you wanted to install some of these paths, please move the install rule"
                    " before the exclusion rule or, in case of built-in excludes, list the paths explicitly"
                    f" (without using patterns). Source for this issue is {self._definition_source}."
                )
            elif already_installed_paths:
                msg = (
                    f"There were no matches for {pattern} in {self._search_dirs} after ignoring"
                    f" {already_installed_paths} path(s) already been matched previously."
                    " If you wanted to install some of these paths into multiple packages,"
                    f" please tweak the definition that installed them to install them into"
                    f' multiple packages (usually change "into: foo" to "into: [foo, bar]".'
                    f" Source for this issue is {self._definition_source}"
                )
            else:
                # TODO: Try harder to find the match and point out possible typos
                msg = (
                    f"There were no matches for {pattern} in {self._search_dirs} (definition:"
                    f" {self._definition_source})."
                )
            raise NoMatchForInstallPatternError(
                msg,
                pattern,
                self._search_dirs,
                self._definition_source,
            )
        return matched

    def _install_matches(
        self,
        path_matcher: SourcePathMatcher,
        matches: List[PathMatch],
        dest_path: Union[str, Callable[[PathMatch], str]],
        dest_path_is_format: bool,
        install_context: "BinaryPackageInstallRuleContext",
        condition_context: ConditionContext,
    ) -> None:
        if (
            self._condition is not None
            and not self._condition.evaluate(condition_context)
        ) or not install_context.binary_package.should_be_acted_on:
            # Rule is disabled; skip all its actions - also allow empty matches
            # for this particular case.
            return

        if not matches:
            raise ValueError("matches must not be empty")

        fs_root = install_context.fs_root
        for match, dest in _resolve_matches(
            matches,
            dest_path,
            dest_path_is_format,
            install_context,
        ):
            if dest.endswith("/"):
                raise ValueError(
                    f'Provided destination for "{match.path}" ended with "/" ("{dest}"), which it must not!'
                )
            dir_part, basename = os.path.split(dest)
            # We do not associate these with the FS path.  First off,
            # it is complicated to do in most cases (indeed, debhelper
            # does not preserve these directories either) and secondly,
            # it is "only" mtime and mode - mostly irrelevant as the
            # directory is 99.9% likely to be 0755 (we are talking
            # directories like "/usr", "/usr/share").
            dir_path = fs_root.mkdirs(dir_part)
            existing_path = dir_path.get(basename)

            if stat.S_ISDIR(match.stat_cache.st_mode):
                if existing_path is not None and not existing_path.is_dir:
                    existing_path.unlink()
                    existing_path = None
                current_dir = existing_path

                if current_dir is None:
                    current_dir = dir_path.mkdir(basename, fs_path=match.fs_path)
                self._install_dir_recursively(path_matcher, current_dir, match)
            else:
                if existing_path is not None and existing_path.is_dir:
                    _error(
                        f"Cannot install {match.path} ({match.fs_path}) as {dest}. That path already exist and"
                        f" is a directory.  This error was triggered via {self._definition_source}."
                    )
                dir_path.insert_file_from_fs_path(
                    basename,
                    match.fs_path,
                    follow_symlinks=False,
                    require_copy=False,
                    use_fs_path_mode=True,
                )

    def _install_dir_recursively(
        self,
        path_matcher: SourcePathMatcher,
        parent_dir: FSPath,
        match: PathMatch,
    ) -> None:
        stack = [
            (parent_dir, e)
            for e in fs_scan_dir(match.fs_path)
            if not path_matcher.is_reserved(e)
        ]

        while stack:
            current_dir, dir_entry = stack.pop()
            path_matcher.reserve(
                dir_entry.path,
                self._into,
                self._definition_source,
                is_exact_match=False,
            )
            new_dir = FSPathDirEntry(dir_entry, current_dir)
            if dir_entry.is_dir(follow_symlinks=False):
                stack.extend(
                    (new_dir, de)
                    for de in fs_scan_dir(dir_entry.path)
                    if not path_matcher.is_reserved(de)
                )

    def perform_install(
        self,
        path_matcher: SourcePathMatcher,
        install_contexts: Mapping[str, "BinaryPackageInstallRuleContext"],
        condition_context: ConditionContext,
    ) -> None:
        raise NotImplementedError

    @classmethod
    def install_as(
        cls,
        source: str,
        dest_path: str,
        into: FrozenSet[BinaryPackage],
        search_dirs: List[str],
        definition_source: str,
        condition: Optional[ManifestCondition],
    ) -> "InstallRule":
        return GenericInstallationRule(
            [source],
            dest_path,
            into,
            search_dirs,
            condition,
            definition_source,
            dest_path_is_format=False,
            require_single_match=True,
        )

    @classmethod
    def install_dest(
        cls,
        sources: List[str],
        dest_dir: Optional[str],
        into: FrozenSet[BinaryPackage],
        search_dirs: List[str],
        definition_source: str,
        condition: Optional[ManifestCondition],
    ) -> "InstallRule":
        if dest_dir is None:
            dest_dir = "{dirname}/{basename}"
        else:
            dest_dir = os.path.join(dest_dir, "{basename}")
        return GenericInstallationRule(
            sources,
            dest_dir,
            into,
            search_dirs,
            condition,
            definition_source,
            dest_path_is_format=True,
        )

    @classmethod
    def install_doc(
        cls,
        sources: List[str],
        into: FrozenSet[BinaryPackage],
        search_dirs: List[str],
        definition_source: str,
        condition: Optional[ManifestCondition],
    ) -> "InstallRule":
        cond = _BUILD_DOCS_BDO
        if condition is not None:
            cond = ManifestCondition.all_of([cond, condition])
        return GenericInstallationRule(
            sources,
            "usr/share/doc/{doc_main_package_name}/{basename}",
            into,
            search_dirs,
            cond,
            definition_source,
            dest_path_is_format=True,
        )

    @classmethod
    def install_examples(
        cls,
        sources: List[str],
        into: FrozenSet[BinaryPackage],
        search_dirs: List[str],
        definition_source: str,
        condition: Optional[ManifestCondition],
    ) -> "InstallRule":
        cond = _BUILD_DOCS_BDO
        if condition is not None:
            cond = ManifestCondition.all_of([cond, condition])
        return GenericInstallationRule(
            sources,
            "usr/share/doc/{doc_main_package_name}/examples/{basename}",
            into,
            search_dirs,
            cond,
            definition_source,
            dest_path_is_format=True,
        )

    @classmethod
    def install_man(
        cls,
        sources: List[str],
        into: FrozenSet[BinaryPackage],
        section: Optional[int],
        language: Optional[str],
        search_dirs: List[str],
        definition_source: str,
        condition: Optional[ManifestCondition],
    ) -> "InstallRule":
        cond = _BUILD_DOCS_BDO
        if condition is not None:
            cond = ManifestCondition.all_of([cond, condition])

        dest_path_computer = _dest_path_for_manpage(
            section, language, definition_source
        )

        return GenericInstallationRule(
            sources,
            dest_path_computer,
            into,
            search_dirs,
            cond,
            definition_source,
            match_filter=lambda m: stat.S_ISREG(m.stat_cache.st_mode),
        )

    @classmethod
    def discard_paths(
        cls,
        paths: List[str],
        search_dirs: List[str],
        definition_source: str,
        condition: Optional[ManifestCondition],
    ) -> "InstallRule":
        return DiscardRule(
            paths,
            search_dirs,
            condition,
            definition_source,
        )


class PPFInstallRule(InstallRule):
    __slots__ = (
        "_ppfs",
        "_substitution",
    )

    def __init__(
        self,
        into: BinaryPackage,
        substitution: Substitution,
        ppfs: Sequence[PackagerProvidedFile],
    ) -> None:
        super().__init__(
            frozenset([into]),
            [],
            None,
            "<built-in; PPF install rule>",
        )
        self._substitution = substitution
        self._ppfs = ppfs

    def perform_install(
        self,
        path_matcher: SourcePathMatcher,
        install_contexts: Mapping[str, "BinaryPackageInstallRuleContext"],
        condition_context: ConditionContext,
    ) -> None:
        subst = self._substitution
        for pkg in self._into:
            install_context = install_contexts[pkg.name]
            fs_root = install_context.fs_root
            for ppf in self._ppfs:
                source_path = f"debian/{ppf.source_basename}"
                dest_dir, name = ppf.compute_dest()
                dir_path = fs_root.mkdirs(dest_dir)

                dir_path.insert_file_from_fs_path(
                    name,
                    source_path,
                    follow_symlinks=True,
                    require_copy=False,
                    use_fs_path_mode=False,
                    mode=ppf.definition.default_mode,
                )


class GenericInstallationRule(InstallRule):
    __slots__ = (
        "_sources",
        "_dest_path",
        "_dest_path_is_format",
        "_require_single_match",
    )

    def __init__(
        self,
        sources: List[str],
        dest_path: Union[str, Callable[[PathMatch], str]],
        into: FrozenSet[BinaryPackage],
        search_dirs: List[str],
        condition: Optional[ManifestCondition],
        definition_source: str,
        *,
        dest_path_is_format: Optional[bool] = None,
        require_single_match: bool = False,
        match_filter: Optional[Callable[[PathMatch], bool]] = None,
    ) -> None:
        super().__init__(
            into,
            search_dirs,
            condition,
            definition_source,
            match_filter=match_filter,
        )
        self._sources = sources
        self._dest_path = dest_path
        self._dest_path_is_format = (
            dest_path_is_format if dest_path_is_format is not None else False
        )
        self._require_single_match = require_single_match
        if self._require_single_match and len(sources) != 1:
            raise ValueError("require_single_match implies sources must have len 1")

    def perform_install(
        self,
        path_matcher: SourcePathMatcher,
        install_contexts: Mapping[str, "BinaryPackageInstallRuleContext"],
        condition_context: ConditionContext,
    ) -> None:
        for source in self._sources:
            matches = self._match_pattern(path_matcher, source, condition_context)
            if self._require_single_match and len(matches) != 1:
                _error(
                    f'The pattern "{source}" matched {len(matches)} entries, but it should matched'
                    f" exactly one item. Please tighten the pattern defined in {self._definition_source}"
                )
            for pkg in self._into:
                install_context = install_contexts[pkg.name]
                self._install_matches(
                    path_matcher,
                    matches,
                    self._dest_path,
                    self._dest_path_is_format,
                    install_context,
                    condition_context,
                )


class DiscardRule(InstallRule):
    __slots__ = ("_paths",)

    def __init__(
        self,
        paths: List[str],
        search_dirs: List[str],
        condition: Optional[ManifestCondition],
        definition_source: str,
    ) -> None:
        super().__init__(frozenset(), search_dirs, condition, definition_source)
        self._paths = paths

    def perform_install(
        self,
        path_matcher: SourcePathMatcher,
        install_contexts: Mapping[str, "BinaryPackageInstallRuleContext"],
        condition_context: ConditionContext,
    ) -> None:
        for path in self._paths:
            self._match_pattern(path_matcher, path, condition_context)
