import dataclasses
from typing import Mapping, Iterable, Dict, List, Optional, Tuple, Callable

from debputy.manifest_path_rules import ManifestPathRule
from debputy.packages import BinaryPackage
from debputy.path_matcher import ExactFileSystemPath
from debputy.plugin.api.impl import PackagerProvidedFileClassSpec
from debputy.substitution import Substitution
from debputy.typing import DirEntryLike
from debputy.util import _error


@dataclasses.dataclass(frozen=True, slots=True)
class PackagerProvidedFile:
    source_basename: str
    package_name: str
    installed_as_basename: str
    provided_key: str
    definition: PackagerProvidedFileClassSpec
    match_priority: int = 0

    def compute_dest_name(self) -> str:
        return self.definition.compute_dest_basename(
            self.installed_as_basename,
            owning_package=self.package_name,
        )


def _find_package_name_prefix(
    binary_packages: Mapping[str, BinaryPackage],
    main_binary_package: str,
    max_periods_in_package_name: int,
    orig_basename: str,
) -> Iterable[Tuple[str, str, bool]]:
    if max_periods_in_package_name < 1:
        prefix, remaining = orig_basename.split(".", 1)
        if prefix in binary_packages:
            yield prefix, remaining, True
        else:
            yield main_binary_package, orig_basename, False
        return

    parts = orig_basename.split(".", max_periods_in_package_name + 1)
    for p in range(len(parts) - 1, 0, -1):
        name = ".".join(parts[0:p])
        if name in binary_packages:
            remaining = ".".join(parts[p:])
            yield name, remaining, True
    # main package case
    yield main_binary_package, orig_basename, False


def _find_definition(
    packager_provided_files: Mapping[str, PackagerProvidedFileClassSpec],
    basename: str,
) -> Tuple[Optional[str], Optional[PackagerProvidedFileClassSpec]]:
    definition = packager_provided_files.get(basename)
    if definition is not None:
        return None, definition
    install_as_name = basename
    file_class = ""
    while "." in install_as_name:
        install_as_name, file_class_part = install_as_name.rsplit(".", 1)
        file_class = (
            file_class_part + "." + file_class if file_class != "" else file_class_part
        )
        definition = packager_provided_files.get(file_class)
        if definition is not None:
            return install_as_name, definition
    return None, None


def _check_mismatches(
    orig_basename: str,
    definition: PackagerProvidedFileClassSpec,
    owning_package: BinaryPackage,
    install_as_name: Optional[str],
    had_arch: bool,
) -> None:
    if install_as_name is not None and not definition.allow_name_segment:
        _error(
            f'The file "debian/{orig_basename}" looks like a packager provided file for'
            f' {owning_package.name} of type {definition.name_stem} with the custom name "{install_as_name}".'
            " However, this file type does not allow custom naming. The file type was registered"
            f" by {definition.debputy_plugin_metadata.plugin_name} in case you disagree and want"
            " to file a bug/feature request."
        )
    if had_arch:
        if owning_package.is_arch_all:
            _error(
                f'The file "debian/{orig_basename}" looks like an architecture specific packager provided file for'
                f" {owning_package.name} of type {definition.name_stem}."
                " However, the package in question is arch:all. The use of architecture specific files"
                " for arch:all packages does not make sense."
            )
        if not definition.allow_architecture_segment:
            _error(
                f'The file "debian/{orig_basename}" looks like an architecture specific packager provided file for'
                f" {owning_package.name} of type {definition.name_stem}."
                " However, this file type does not allow architecture specific variants. The file type was registered"
                f" by {definition.debputy_plugin_metadata.plugin_name} in case you disagree and want"
                " to file a bug/feature request."
            )


def _split_path(
    packager_provided_files: Mapping[str, PackagerProvidedFileClassSpec],
    binary_packages: Mapping[str, BinaryPackage],
    main_binary_package: str,
    max_periods_in_package_name: int,
    orig_basename: str,
) -> Iterable[PackagerProvidedFile]:
    owning_package_name = main_binary_package
    basename = orig_basename
    match_priority = 0
    had_arch = False
    if "." not in basename:
        definition = packager_provided_files.get(basename)
        if definition is None:
            return
        if definition.packageless_is_fallback_for_all_packages:
            yield from (
                PackagerProvidedFile(
                    source_basename=orig_basename,
                    package_name=n,
                    installed_as_basename=n,
                    provided_key=".UNNAMED.",
                    definition=definition,
                    match_priority=match_priority,
                )
                for n in binary_packages
            )
        else:
            yield PackagerProvidedFile(
                source_basename=orig_basename,
                package_name=owning_package_name,
                installed_as_basename=owning_package_name,
                provided_key=".UNNAMED.",
                definition=definition,
                match_priority=match_priority,
            )
        return

    for owning_package_name, basename, explicit_package in _find_package_name_prefix(
        binary_packages,
        main_binary_package,
        max_periods_in_package_name,
        orig_basename,
    ):
        owning_package = binary_packages[owning_package_name]
        match_priority = 1 if explicit_package else 0
        if "." in basename:
            remaining, last_word = basename.rsplit(".", 1)
            # We cannot use "resolved_architecture" as it would return "all".
            if last_word == owning_package.package_deb_architecture_variable("ARCH"):
                match_priority = 3
                basename = remaining
                had_arch = True
            elif last_word == owning_package.package_deb_architecture_variable(
                "ARCH_OS"
            ):
                match_priority = 2
                basename = remaining
                had_arch = True
            elif last_word == "all" and owning_package.is_arch_all:
                # This case does not make sense, but we detect it so we can report an error
                # via _check_mismatches.
                match_priority = -1
                basename = remaining
                had_arch = True

        install_as_name, definition = _find_definition(
            packager_provided_files, basename
        )
        if definition is None:
            continue

        _check_mismatches(
            orig_basename,
            definition,
            owning_package,
            install_as_name,
            had_arch,
        )
        if (
            definition.packageless_is_fallback_for_all_packages
            and install_as_name is None
            and not had_arch
        ):
            yield from (
                PackagerProvidedFile(
                    source_basename=orig_basename,
                    package_name=n,
                    installed_as_basename=n,
                    provided_key=".UNNAMED.",
                    definition=definition,
                    match_priority=match_priority,
                )
                for n in binary_packages
            )
        else:
            provided_key = (
                install_as_name if install_as_name is not None else ".UNNAMED."
            )
            basename = (
                install_as_name if install_as_name is not None else owning_package_name
            )
            yield PackagerProvidedFile(
                source_basename=orig_basename,
                package_name=owning_package_name,
                installed_as_basename=basename,
                provided_key=provided_key,
                definition=definition,
                match_priority=match_priority,
            )
        return


def detect_all_packager_provided_files(
    packager_provided_files: Mapping[str, PackagerProvidedFileClassSpec],
    debian_dir: Iterable[DirEntryLike],
    binary_packages: Mapping[str, BinaryPackage],
    substitution_for: Callable[[str], Substitution],
) -> Dict[str, List["ManifestPathRule"]]:
    main_binary_package = [
        p.name for p in binary_packages.values() if p.is_main_package
    ][0]
    provided_files: Dict[str, Dict[Tuple[str, str], PackagerProvidedFile]] = {
        n: {} for n in binary_packages
    }
    max_periods_in_package_name = max(name.count(".") for name in binary_packages)

    for entry in debian_dir:
        if not entry.is_file(follow_symlinks=True):
            continue
        matching_ppfs = _split_path(
            packager_provided_files,
            binary_packages,
            main_binary_package,
            max_periods_in_package_name,
            entry.name,
        )
        for packager_provided_file in matching_ppfs:
            provided_files_for_package = provided_files[
                packager_provided_file.package_name
            ]
            match_key = (
                packager_provided_file.definition.name_stem,
                packager_provided_file.provided_key,
            )
            existing = provided_files_for_package.get(match_key)
            if (
                existing is not None
                and existing.match_priority > packager_provided_file.match_priority
            ):
                continue
            provided_files_for_package[match_key] = packager_provided_file

    result = {
        k: [
            ManifestPathRule.packager_provided_file(
                definition_source=f"debian/{x.source_basename} (auto-detected by plugin {x.definition.debputy_plugin_metadata.plugin_name})",
                match_rule=ExactFileSystemPath(
                    f'{substitution_for(k).substitute(x.definition.install_into_directory, f"debian/{x.source_basename}")}/{x.compute_dest_name()}'
                ),
                source_path=f"debian/{x.source_basename}",
                mode=x.definition.default_mode,
            )
            for x in v.values()
        ]
        for k, v in provided_files.items()
    }

    return result
