import dataclasses
import os
import re
from typing import NoReturn, Iterable, Optional, Callable

from debputy.filesystem_scan import FSPath, VirtualDirectoryFSPath, FSPathVirtualPath
from debputy.manifest_conditions import ConditionContext, ManifestCondition
from debputy.manifest_path_rules import ManifestPathRule, EnsureDirectoryPathRule
from debputy.path_matcher import MatchRule


class TransformationRuntimeError(RuntimeError):
    pass


def _prune_pyc(fs_root: FSPath) -> None:
    for p in fs_root.all_paths():
        if (p.is_dir and p.name == "__pycache__") or (
            p.is_file and p.name.endswith((".pyc", ".pyo"))
        ):
            p.unlink(recursive=True)


def _prune_la_files(fs_root: FSPath) -> None:
    for p in fs_root.all_paths():
        if (p.is_file or p.is_symlink) and p.name.endswith(".la"):
            p.unlink()


_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 _prune_backup_files(fs_root: FSPath) -> None:
    for p in fs_root.all_paths():
        if p.is_file and _BACKUP_FILES_RE.match(p.name):
            p.unlink(recursive=True)


_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 _prune_vcs_files(fs_root: FSPath) -> None:
    for p in fs_root.all_paths():
        if p.name in _VCS_PATHS:
            p.unlink(recursive=True)


@dataclasses.dataclass(frozen=True, slots=True)
class PreProvidedExclusion:
    tag: str
    description: str
    pruner: Callable[[FSPath], None]


PRE_PROVIDED_EXCLUDE_RULES = {
    p.tag: p
    for p in [
        PreProvidedExclusion(
            "pyc",
            "python pyc/pycache files/directories",
            _prune_pyc,
        ),
        PreProvidedExclusion(
            "la",
            "*.la files",
            _prune_la_files,
        ),
        PreProvidedExclusion(
            "vcs",
            "common version control files/directories",
            _prune_vcs_files,
        ),
        PreProvidedExclusion(
            "backup-files",
            "common backup/editor files",
            _prune_backup_files,
        ),
        PreProvidedExclusion(
            "info-dir-file",
            "the usr/share/info/dir file",
            _prune_backup_files,
        ),
    ]
}


class TransformationRule:
    @property
    def is_builtin_rule(self) -> bool:
        return False

    def describe_transformation(self) -> str:
        raise NotImplementedError

    def transform_file_system(
        self, fs_root: FSPath, condition_context: ConditionContext
    ) -> None:
        raise NotImplementedError

    def _evaluate_condition(
        self,
        condition: Optional[ManifestCondition],
        condition_context: ConditionContext,
        result_if_condition_is_missing: bool = True,
    ) -> bool:
        if condition is None:
            return result_if_condition_is_missing
        return condition.evaluate(condition_context)

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

    def _match_rule_had_no_matches(
        self, match_rule: MatchRule, definition_source: str
    ) -> NoReturn:
        self._error(
            f'The match rule "{match_rule.describe_match_short()}" in transformation "{definition_source}" did'
            " not match any paths. Either the definition is redundant (and can be omitted) or the match rule is"
            " incorrect."
        )

    def _fs_path_as_dir(
        self, path: FSPath, force_convert: bool, definition_source: str
    ) -> FSPath:
        if path.is_dir:
            return path
        if not force_convert:
            path_type = (
                "file" if path.is_file else 'symlink/"special file system object"'
            )
            self._error(
                f"The path {path.path} was expected to be a directory (or non-existing) due to"
                f" {definition_source}. However that path existed and is a {path_type}."
            )
        return VirtualDirectoryFSPath(path.name, path.parent_dir)

    def _ensure_is_directory(
        self,
        fs_root: FSPath,
        path_to_directory: str,
        definition_source: str,
        force_convert=False,
    ) -> FSPath:
        current, missing_parts = fs_root.attempt_lookup(path_to_directory)
        current = self._fs_path_as_dir(current, force_convert, definition_source)
        for missing_part in missing_parts:
            assert missing_part not in (".", "..")
            current = current.mkdir(missing_part)
        return current


class PreProvidedExclusionTransformationRule(TransformationRule):
    def __init__(self, exclusion: PreProvidedExclusion) -> None:
        self._exclusion = exclusion

    def describe_transformation(self) -> str:
        return f"Exclude {self._exclusion.description} (standard-exclusion: {self._exclusion.tag})"

    def transform_file_system(self, fs_root: FSPath, _: ConditionContext) -> None:
        pruner = self._exclusion.pruner
        pruner(fs_root)

    @property
    def is_builtin_rule(self) -> bool:
        return True


class ExclusionTransformationRule(TransformationRule):
    def __init__(self, match_rule: MatchRule, definition_source: str) -> None:
        self._match_rule = match_rule
        self._definition_source = definition_source

    def describe_transformation(self) -> str:
        return f"Exclude {self._match_rule.describe_match_short()} (from: {self._definition_source})"

    def transform_file_system(
        self, fs_root: FSPath, condition_context: ConditionContext
    ) -> None:
        matched_any = False
        for m in self._match_rule.finditer(fs_root):
            matched_any = True
            parent = m.parent_dir
            if parent is None:
                self._error(
                    f"Cannot exclude the root directory (triggered by {self._definition_source}"
                )
            m.unlink(recursive=True)
        if not matched_any:
            self._match_rule_had_no_matches(self._match_rule, self._definition_source)


class MoveTransformationRule(TransformationRule):
    def __init__(
        self,
        match_rule: MatchRule,
        dest_path: str,
        dest_is_dir: bool,
        definition_source: str,
        condition: Optional[ManifestCondition],
    ) -> None:
        self._match_rule = match_rule
        self._dest_path = dest_path
        self._dest_is_dir = dest_is_dir
        self._definition_source = definition_source
        self._condition = condition

    def describe_transformation(self) -> str:
        dest_path = self._dest_path
        if self._dest_is_dir:
            dest_path += "/"
        return (
            f'Rename {self._match_rule.describe_match_short()} -> "{dest_path}"'
            f" (from: {self._definition_source})"
        )

    def transform_file_system(
        self, fs_root: FSPath, condition_context: ConditionContext
    ) -> None:
        if not self._evaluate_condition(self._condition, condition_context):
            return
        # Eager resolve is necessary to avoid "self-recursive" matching in special cases (e.g., **/*.la)
        matches = list(self._match_rule.finditer(fs_root))
        if not matches:
            self._match_rule_had_no_matches(self._match_rule, self._definition_source)

        if self._dest_is_dir:
            target_dir = self._ensure_is_directory(
                fs_root, self._dest_path, self._definition_source
            )
        else:
            dir_part, basename = os.path.split(self._dest_path)
            target_parent_dir = self._ensure_is_directory(
                fs_root, dir_part, self._definition_source
            )
            target_dir = target_parent_dir.get(basename)

            if target_dir is None or not target_dir.is_dir:
                if len(matches) > 1:
                    self._error(
                        f"Could not rename {self._match_rule.describe_match_short()} to {self._dest_path}"
                        f" (from: {self._definition_source}).  Multiple paths matched the pattern and the"
                        " destination was not a directory. Either correct the pattern to only match ony source"
                        " OR define the destination to be a directory (E.g., add a trailing slash - example:"
                        f' "{self._dest_path}/")'
                    )
                p = matches[0]
                if p.path == self._dest_path:
                    self._error(
                        f"Error in {self._definition_source}, the source"
                        f" {self._match_rule.describe_match_short()} matched {self._dest_path} making the"
                        " rename redundant!?"
                    )
                p.parent_dir = target_parent_dir
                p.name = basename
                return

        assert target_dir is not None and target_dir.is_dir
        basenames = dict()
        target_dir_path = target_dir.path

        for m in matches:
            if m.path == target_dir_path:
                self._error(
                    f"Error in {self._definition_source}, the source {self._match_rule.describe_match_short()}"
                    f"matched {self._dest_path} (among other), but it is not possible to copy a directory into"
                    " itself"
                )
            if m.name in basenames:
                alt_path = basenames[m.name]
                # We document "two *distinct*" paths.  However, as the glob matches are written, it should not be
                # possible for a *single* glob to match the same path twice.
                assert alt_path is not m
                self._error(
                    f"Could not rename {self._match_rule.describe_match_short()} to {self._dest_path}"
                    f" (from: {self._definition_source}).  Multiple paths matched the pattern had the"
                    f' same basename "{m.name}" ("{m.path}" vs. "{alt_path.path}").  Please correct the'
                    f" pattern, so it only matches one path with that basename to avoid this conflict."
                )
            existing = m.get(m.name)
            if existing and existing.is_dir:
                self._error(
                    f"Could not rename {self._match_rule.describe_match_short()} to {self._dest_path}"
                    f" (from: {self._definition_source}).  The pattern matched {m.path} which would replace"
                    f" the existing directory {existing.path}.  If this replacement is intentional, then please"
                    f' exclude {existing.path} first (e.g., via `- exclude: "{existing.path}"`)'
                )
            basenames[m.name] = m
            m.parent_dir = target_dir


class CreateDirectoryTransformationRule(TransformationRule):
    def __init__(self, directories: Iterable[EnsureDirectoryPathRule]):
        self._directories = directories

    @property
    def is_builtin_rule(self) -> bool:
        return True

    def describe_transformation(self) -> str:
        return "<built-in: Insert implicitly and explicitly declared directories>"

    def transform_file_system(
        self, fs_root: FSPath, condition_context: ConditionContext
    ) -> None:
        for path_definition in self._directories:
            if (
                path_definition.condition is not None
                and not path_definition.condition.evaluate(condition_context)
            ):
                continue
            self._ensure_is_directory(
                fs_root,
                path_definition.member_path,
                '<Built-in; implementation of "directories">',
                force_convert=True,
            )


class CreateVirtualPathTransformationRule(TransformationRule):
    def __init__(self, path_definitions: Iterable[ManifestPathRule]):
        self._path_definitions = path_definitions

    @property
    def is_builtin_rule(self) -> bool:
        return True

    def describe_transformation(self) -> str:
        return "<built-in: Insert explicitly declared virtual paths>"

    def transform_file_system(
        self, fs_root: FSPath, condition_context: ConditionContext
    ) -> None:
        for path_definition in self._path_definitions:
            if not self._evaluate_condition(
                path_definition.condition, condition_context
            ):
                continue
            parent_path = os.path.dirname(path_definition.member_path)
            parent_dir = fs_root.lookup(parent_path)
            assert parent_dir is not None and parent_dir.is_dir
            FSPathVirtualPath(path_definition, parent_dir)
