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

from debputy.filesystem_scan import FSPath, fs_scan_dir, FSPathDirEntry
from debputy.manifest_conditions import (
    ConditionContext,
    ManifestCondition,
    _BUILD_DOCS_BDO,
)
from debputy.packages import BinaryPackage
from debputy.util import has_glob_magic, _normalize_path, _warn, _error


class PathAlreadyInstalledError(RuntimeError):
    @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(RuntimeError):
    @property
    def path(self) -> str:
        return self.args[0]

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

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


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

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

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


@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 SourcePathMatcher:
    def __init__(self) -> None:
        self._already_matched: Dict[
            str,
            Tuple[FrozenSet[BinaryPackage], str],
        ] = {}
        self._exact_match_request: Set[Tuple[str, str]] = set()

    def is_reserved(self, path: str) -> bool:
        return path in self._already_matched

    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)

    def find_and_reserve_all_matches(
        self,
        norm_pattern: str,
        search_dirs: Sequence[str],
        glob_expand: bool,
        dir_only_match: bool,
        reserved_by: FrozenSet[BinaryPackage],
        definition_source: str,
    ) -> Tuple[List[PathMatch], int]:
        matched = []
        already_installed_paths = 0
        for match in _resolve_path(
            norm_pattern,
            glob_expand,
            search_dirs,
            dir_only_match,
        ):
            installed_into = self._already_matched.get(match.fs_path)
            if installed_into:
                if glob_expand:
                    already_installed_paths += 1
                    continue
                raise PathAlreadyInstalledError(
                    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(
                            match.path,
                            pkg,
                            definition_source,
                        )
                    self._exact_match_request.add(m_key)

            self._already_matched[match.fs_path] = reserved_by, definition_source
            matched.append(match)
        return matched, already_installed_paths


def _resolve_path(
    norm_pattern: str,
    glob_expand: bool,
    search_dirs: Iterable[str],
    dir_only_match: 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:
                    yield PathMatch(match, sdir, fs_path, st, False)
                    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:
                yield PathMatch(norm_pattern, sdir, fs_path, st, True)
                break


def _resolve_matches(
    matches: List[PathMatch],
    dest_path: Union[str, Callable[[PathMatch], str]],
    dest_path_is_format: bool,
    package_name: str,
) -> 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=package_name,
                )
            else:
                yield match, dest_path


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

    def __init__(
        self,
        into: FrozenSet[BinaryPackage],
        search_dirs: Sequence[str],
        condition: Optional[ManifestCondition],
        definition_source: str,
    ) -> None:
        self._into = into
        self._search_dirs = search_dirs
        self._condition = condition
        self._definition_source = definition_source
        # 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, already_installed_paths = path_matcher.find_and_reserve_all_matches(
            norm_pattern,
            self._search_dirs,
            is_glob,
            pattern.endswith("/"),
            self._into,
            self._definition_source,
        )

        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_installed_paths:
                _warn(
                    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 warning is {self._definition_source}"
                )
            else:
                # TODO: Try harder to find the match and point out possible typos
                _warn(
                    f"There were no matches for {pattern} in {self._search_dirs} (definition:"
                    f" {self._definition_source})."
                )
            raise NoMatchForInstallPatternError(
                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,
        package_name: str,
        fs_root: FSPath,
    ) -> None:
        if not matches:
            raise ValueError("matches must not be empty")
        for match, dest in _resolve_matches(
            matches,
            dest_path,
            dest_path_is_format,
            package_name,
        ):
            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)
            dir_path, missing_parts = fs_root.attempt_lookup(dir_part)
            for missing_part in missing_parts:
                # 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 = dir_path.mkdir(missing_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.path)
        ]

        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.path)
                )

    def perform_install(
        self,
        path_matcher: SourcePathMatcher,
        fs_root_dirs: Mapping[str, FSPath],
        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/{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/{package_name}/examples/{basename}",
            into,
            search_dirs,
            cond,
            definition_source,
            dest_path_is_format=True,
        )


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,
    ) -> None:
        super().__init__(into, search_dirs, condition, definition_source)
        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,
        fs_root_dirs: Mapping[str, FSPath],
        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:
                fs_root = fs_root_dirs[pkg.name]
                self._install_matches(
                    path_matcher,
                    matches,
                    self._dest_path,
                    self._dest_path_is_format,
                    pkg.name,
                    fs_root,
                )
