import fnmatch
import glob
import itertools
import os
import re
from abc import abstractmethod
from enum import Enum
from typing import (
    Callable,
    Optional,
    Generic,
    TypeVar,
    Dict,
    List,
    Tuple,
    Iterable,
    Union,
    Sequence,
)

from debputy.intermediate_manifest import PathType
from debputy.plugin.api import VirtualPath
from debputy.substitution import Substitution, NULL_SUBSTITUTION
from debputy.typing import VP
from debputy.util import _normalize_path, _error


MR = TypeVar("MR")
_GLOB_PARTS = re.compile(r"[*?]|\[]?[^]]+]")


def _glob_match_weight(glob_pattern: str) -> Tuple[int, int]:
    total_len = len(glob_pattern)
    non_glob_prefix_length = total_len
    glob_part_len = 0
    for m in _GLOB_PARTS.finditer(glob_pattern):
        non_glob_prefix_length = min(non_glob_prefix_length, m.start())
        glob_part_len += m.end() - m.start()
    return total_len - glob_part_len, non_glob_prefix_length


def _lookup_path(fs_root: VP, path: str) -> Optional[VP]:
    if not path.startswith("./"):
        raise ValueError("Directory must be normalized (and not the root directory)")
    if fs_root.name != "." or fs_root.parent_dir is not None:
        raise ValueError("Provided fs_root must be the root directory")
    # TODO: Strictly speaking, this is unsound. (E.g., FSRootDir does not return FSRootDir on a lookup)
    return fs_root.lookup(path[2:])


def _compile_basename_glob(basename_glob: str) -> Callable[[str], bool]:
    remainder = None
    if basename_glob.startswith("*"):
        if basename_glob.endswith("*"):
            remainder = basename_glob[1:-1]
            possible_quick_match = lambda x: remainder in x
        else:
            remainder = basename_glob[1:]
            possible_quick_match = lambda x: x.endswith(remainder)
    else:
        remainder = basename_glob[:-1]
        possible_quick_match = lambda x: x.startswith(remainder)

    if not glob.has_magic(remainder):
        return possible_quick_match
    slow_pattern = re.compile(fnmatch.translate(basename_glob))
    return lambda x: bool(slow_pattern.match(x))


def _apply_match(
    fs_path: VP,
    match_part: Union[Callable[[str], bool], str],
) -> Iterable[VP]:
    if isinstance(match_part, str):
        m = fs_path.lookup(match_part)
        if m:
            yield m
    else:
        yield from (p for p in fs_path.iterdir if match_part(p.name))


class MatchRuleType(Enum):
    EXACT_MATCH = ("exact", 999, 0)
    BASENAME_GLOB = ("basename-glob", 1, 0)
    DIRECT_CHILDREN_OF_DIR = ("direct-children-of-dir", 1, 1)
    ANYTHING_BENEATH_DIR = ("anything-beneath-dir", 1, 0)
    GENERIC_GLOB = ("generic-glob", 1, 0)
    MATCH_ANYTHING = ("match-anything", 0, 0)

    @property
    def key(self) -> str:
        return self.value[0]

    @property
    def sort_key(self) -> int:
        return self.value[1]

    @property
    def rule_tiebreaker(self) -> int:
        return self.value[2]


class MatchRule:
    __slots__ = ("_rule_type",)

    def __init__(self, rule_type: MatchRuleType) -> None:
        self._rule_type = rule_type

    @property
    def rule_type(self) -> MatchRuleType:
        return self._rule_type

    def finditer(self, fs_root: VP) -> Iterable[VP]:
        # TODO: Strictly speaking, this is unsound. (E.g., FSRootDir does not return FSRootDir on a lookup)
        raise NotImplementedError

    def matches(self, path: VirtualPath) -> bool:
        return any(p is path for p in self.finditer(path.lookup("/")))

    def _full_pattern(self) -> str:
        raise NotImplementedError

    def _match_weight(self) -> Tuple[int, int]:
        return _glob_match_weight(self._full_pattern())

    @property
    def path_type(self) -> Optional[PathType]:
        return None

    def describe_match_short(self) -> str:
        return self._full_pattern()

    def describe_match_exact(self) -> str:
        raise NotImplementedError

    def lookup_key(self) -> Optional[str]:
        return None

    def __hash__(self) -> int:
        raise NotImplementedError

    @abstractmethod
    def __eq__(self, other: object) -> bool:
        return False

    def sort_key(self):
        rule_type = self._rule_type
        ml, pl = self._match_weight()
        path_type_specific_weight = 1 if self.path_type is not None else 0
        # Used by directory matches to prefer "foo/*" over "foo/**/*".
        tie_breaker = rule_type.rule_tiebreaker
        # The _full_pattern is just there as a tie-breaker
        return (
            rule_type.sort_key,
            ml,
            pl,
            path_type_specific_weight,
            tie_breaker,
            self._full_pattern(),
        )

    @property
    def is_path_type_part_of_match(self) -> bool:
        return self.path_type is not None

    def match_rule_without_path_type_match(self) -> "MatchRule":
        assert not self.is_path_type_part_of_match
        return self

    @classmethod
    def recursive_beneath_directory(
        cls,
        directory: str,
        definition_source: str,
        path_type: Optional[PathType] = None,
        substitution: Substitution = NULL_SUBSTITUTION,
    ) -> "MatchRule":
        if directory in (".", "/"):
            return MATCH_ANYTHING
        assert not glob.has_magic(directory)
        return DirectoryBasedMatch(
            MatchRuleType.ANYTHING_BENEATH_DIR,
            substitution.substitute(_normalize_path(directory), definition_source),
            path_type=path_type,
        )

    @classmethod
    def from_path_or_glob(
        cls,
        path_or_glob: str,
        definition_source: str,
        path_type: Optional[PathType] = None,
        substitution: Substitution = NULL_SUBSTITUTION,
    ) -> "MatchRule":
        # TODO: Handle '{a,b,c}' patterns too
        # FIXME: Better error handling!
        normalized_no_prefix = _normalize_path(path_or_glob, with_prefix=False)
        if path_or_glob in ("*", "**/*", ".", "/"):
            assert path_type is None
            return MATCH_ANYTHING

        # We do not support {a,b} at the moment. This check is not perfect, but it should catch the most obvious
        # unsupported usage.
        if (
            "{" in path_or_glob
            and ("," in path_or_glob or ".." in path_or_glob)
            and re.search(r"[{][^},.]*(?:,|[.][.])[^},.]*[}]", path_or_glob)
        ):
            m = re.search(r"(.*)[{]([^},.]*(?:,|[.][.])[^},.]*[}])", path_or_glob)
            replacement = m.group(1) + "{{OPEN_CURLY_BRACE}}" + m.group(2)
            _error(
                f'The pattern "{path_or_glob}" (defined in {definition_source}) looks like it contains a'
                f' brace expansion (such as "{{a,b}}" or "{{a..b}}").  Brace expansions are not supported.'
                " If you wanted to match the literal path a brace in it, please use a substitution to insert"
                f' the opening brace.  As an example: "{replacement}"'
            )

        normalized_with_prefix = "./" + normalized_no_prefix
        # TODO: Check for escapes here  "foo[?]/bar" can be written as an exact match for foo?/bar
        # - similar holds for "foo[?]/*" being a directory match (etc.).
        if not glob.has_magic(normalized_with_prefix):
            assert path_type is None
            return ExactFileSystemPath(
                substitution.substitute(normalized_with_prefix, definition_source)
            )

        directory = os.path.dirname(normalized_with_prefix)
        basename = os.path.basename(normalized_with_prefix)

        if ("**" in directory and directory != "./**") or "**" in basename:
            raise ValueError(
                f'Cannot process pattern "{path_or_glob}" from {definition_source}: The double-star'
                ' glob ("**") is not supported in general.  Only "**/<basename-glob>" supported.'
            )

        if basename == "*" and not glob.has_magic(directory):
            return DirectoryBasedMatch(
                MatchRuleType.DIRECT_CHILDREN_OF_DIR,
                substitution.substitute(directory, definition_source),
                path_type=path_type,
            )
        elif (directory == "./**" or not glob.has_magic(directory)) and glob.has_magic(
            basename
        ):
            basename_glob = substitution.substitute(
                basename, definition_source, escape_glob_characters=True
            )
            if directory in (".", "./**"):
                return BasenameGlobMatch(
                    basename_glob,
                    path_type=path_type,
                    recursive_match=True,
                )
            return BasenameGlobMatch(
                basename_glob,
                only_when_in_directory=substitution.substitute(
                    directory, definition_source
                ),
                path_type=path_type,
                recursive_match=False,
            )

        return GenericGlobImplementation(normalized_with_prefix, path_type=path_type)


def _match_file_type(path_type, path: VirtualPath) -> bool:
    if path_type == PathType.FILE and path.is_file:
        return True
    if path_type == PathType.DIRECTORY and path.is_dir:
        return True
    if path_type == PathType.SYMLINK and path.is_symlink:
        return True
    assert path_type in (PathType.FILE, PathType.DIRECTORY, PathType.SYMLINK)
    return False


class MatchAnything(MatchRule):
    def __init__(self):
        super().__init__(MatchRuleType.MATCH_ANYTHING)

    def _full_pattern(self) -> str:
        return "**/*"

    def finditer(self, fs_root: VP) -> Iterable[VP]:
        yield from fs_root.all_paths()

    def matches(self, path: VirtualPath) -> bool:
        return True

    def _match_weight(self) -> Tuple[int, int]:
        return 0, 0

    def describe_match_exact(self) -> str:
        return "**/* (Match anything)"

    def __hash__(self) -> int:
        return hash((self.rule_type, "**/*"))

    def __eq__(self, other: object) -> bool:
        return self is other


MATCH_ANYTHING: MatchRule = MatchAnything()

del MatchAnything


class ExactFileSystemPath(MatchRule):
    __slots__ = "_path"

    def __init__(self, path: str) -> None:
        super().__init__(MatchRuleType.EXACT_MATCH)
        self._path = path

    def _full_pattern(self) -> str:
        return self._path

    def _match_weight(self) -> Tuple[int, int]:
        w = len(self._path)
        return w, w

    def finditer(self, fs_root: VP) -> Iterable[VP]:
        p = _lookup_path(fs_root, self._path)
        if p is not None:
            yield p

    def matches(self, path: VirtualPath) -> bool:
        return path.path == self._path

    def describe_match_exact(self) -> str:
        return f"{self._path} (the exact path / no globbing)"

    @property
    def path(self) -> str:
        return self._path

    def lookup_key(self) -> str:
        return self._path

    def __hash__(self) -> int:
        return hash((self.rule_type, self._path))

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, ExactFileSystemPath):
            return NotImplemented
        return self._path == other._path


class DirectoryBasedMatch(MatchRule):
    __slots__ = "_directory", "_path_type"

    def __init__(
        self,
        rule_type: MatchRuleType,
        directory: str,
        path_type: Optional[PathType] = None,
    ) -> None:
        super().__init__(rule_type)
        self._directory = directory
        self._path_type = path_type
        assert rule_type in (
            MatchRuleType.DIRECT_CHILDREN_OF_DIR,
            MatchRuleType.ANYTHING_BENEATH_DIR,
        )
        assert not self._directory.endswith("/")

    def _full_pattern(self) -> str:
        return self._directory

    def _match_weight(self) -> Tuple[int, int]:
        # directory + "/"
        w = len(self._directory) + 1
        return w, w

    def finditer(self, fs_root: VP) -> Iterable[VP]:
        p = _lookup_path(fs_root, self._directory)
        if p is None or not p.is_dir:
            return
        if self._rule_type == MatchRuleType.ANYTHING_BENEATH_DIR:
            path_iter = p.all_paths()
        else:
            path_iter = p.iterdir
        if self._path_type is None:
            yield from path_iter
        else:
            yield from (m for m in path_iter if _match_file_type(self._path_type, m))

    def describe_match_short(self) -> str:
        path_type_match = (
            ""
            if self._path_type is None
            else f" <only for path type {self._path_type.manifest_key}>"
        )
        if self._rule_type == MatchRuleType.ANYTHING_BENEATH_DIR:
            return f"{self._directory}/**/*{path_type_match}"
        return f"{self._directory}/*{path_type_match}"

    def describe_match_exact(self) -> str:
        if self._rule_type == MatchRuleType.ANYTHING_BENEATH_DIR:
            return f"{self._directory}/**/* (anything below the directory)"
        return f"{self.describe_match_short()} (anything directly in the directory)"

    def lookup_key(self) -> str:
        return self._directory

    def __hash__(self) -> int:
        return hash((self.rule_type, self._directory, self._path_type))

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, DirectoryBasedMatch):
            return NotImplemented
        return (
            self._rule_type == other._rule_type
            and self._directory == other._directory
            and self._path_type == other._path_type
        )

    @property
    def path_type(self) -> Optional[PathType]:
        return self._path_type

    def match_rule_without_path_type_match(self) -> "MatchRule":
        if self.is_path_type_part_of_match:
            return DirectoryBasedMatch(self._rule_type, self._directory, path_type=None)
        return self


class BasenameGlobMatch(MatchRule):
    __slots__ = (
        "_basename_glob",
        "_directory",
        "_matcher",
        "_path_type",
        "_recursive_match",
    )

    def __init__(
        self,
        basename_glob: str,
        only_when_in_directory: Optional[str] = None,
        path_type: Optional[PathType] = None,
        recursive_match: bool = None,
    ) -> None:
        super().__init__(MatchRuleType.BASENAME_GLOB)
        self._basename_glob = basename_glob
        self._directory = only_when_in_directory
        self._path_type = path_type
        self._recursive_match = recursive_match
        if self._directory is None and not recursive_match:
            self._recursive_match = True
        assert self._directory is None or not self._directory.endswith("/")
        assert "/" not in basename_glob  # Not a basename if it contains /
        assert "**" not in basename_glob  # Also not a (true) basename if it has **
        assert glob.has_magic(basename_glob)
        self._matcher = _compile_basename_glob(basename_glob)

    def _full_pattern(self) -> str:
        if self._directory is not None:
            maybe_recursive = "**/" if self._recursive_match else ""
            return f"{self._directory}/{maybe_recursive}{self._basename_glob}"
        return self._basename_glob

    def _match_weight(self) -> Tuple[int, int]:
        ml, pl = _glob_match_weight(self._basename_glob)

        dl = len(self._directory) if self._directory is not None else 0
        # +1 for the trailing slash
        # (note this holds for the "**/<foo>" case as well)
        dl += 1
        return ml + dl, pl + dl

    def finditer(self, fs_root: VP) -> Iterable[VP]:
        search_root = fs_root
        if self._directory is not None:
            p = _lookup_path(fs_root, self._directory)
            if p is None or not p.is_dir:
                return
            search_root = p
        path_iter = (
            search_root.all_paths() if self._recursive_match else search_root.iterdir
        )
        if self._path_type is None:
            yield from (m for m in path_iter if self._matcher(m.name))
        else:
            yield from (
                m
                for m in path_iter
                if self._matcher(m.name) and _match_file_type(self._path_type, m)
            )

    def matches(self, path: VirtualPath) -> bool:
        if self._path_type is not None and not _match_file_type(self._path_type, path):
            return False
        if self._directory is None:
            return self._matcher(path.name)
        return super().matches(path)

    def describe_match_short(self) -> str:
        path_type_match = (
            ""
            if self._path_type is None
            else f" <only for path type {self._path_type.manifest_key}>"
        )
        return (
            self._full_pattern()
            if path_type_match == ""
            else f"{self._full_pattern()}{path_type_match}"
        )

    def describe_match_exact(self) -> str:
        if self._directory is not None:
            return f"{self.describe_match_short()} (glob / directly in the directory)"
        return f"{self.describe_match_short()} (basename match)"

    def lookup_key(self) -> Optional[str]:
        return self._directory

    def __hash__(self) -> int:
        return hash(
            (self.rule_type, self._basename_glob, self._directory, self._path_type)
        )

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, BasenameGlobMatch):
            return NotImplemented
        return (
            self._basename_glob == other._basename_glob
            and self._directory == other._directory
            and self._path_type == other._path_type
            and self._recursive_match == other._recursive_match
        )

    @property
    def path_type(self) -> Optional[PathType]:
        return self._path_type

    def match_rule_without_path_type_match(self) -> "MatchRule":
        if self.is_path_type_part_of_match:
            return BasenameGlobMatch(
                self._basename_glob,
                self._directory,
                recursive_match=self._recursive_match,
                path_type=None,
            )
        return self


class GenericGlobImplementation(MatchRule):
    __slots__ = "_glob_pattern", "_path_type", "_match_parts"

    def __init__(
        self,
        glob_pattern: str,
        path_type: Optional[PathType] = None,
    ) -> None:
        super().__init__(MatchRuleType.GENERIC_GLOB)
        if glob_pattern.startswith("./"):
            glob_pattern = glob_pattern[2:]
        self._glob_pattern = glob_pattern
        self._path_type = path_type
        assert "**" not in glob_pattern  # No recursive globs
        assert glob.has_magic(
            glob_pattern
        )  # If it has no glob, then it could have been an exact match
        assert (
            "/" in glob_pattern
        )  # If it does not have a / then a BasenameGlob could have been used instead
        self._match_parts = self._compile_glob()

    def _full_pattern(self) -> str:
        return self._glob_pattern

    def finditer(self, fs_root: VP) -> Iterable[VP]:
        search_history = [fs_root]
        for part in self._match_parts:
            next_layer = itertools.chain.from_iterable(
                _apply_match(m, part) for m in search_history
            )
            # TODO: Figure out why we need to materialize next_layer into a list for this to work.
            search_history = list(next_layer)
            if not search_history:
                # While we have it as a list, we might as well have an "early exit".
                return

        if self._path_type is None:
            yield from search_history
        else:
            yield from (
                m for m in search_history if _match_file_type(self._path_type, m)
            )

    def describe_match_short(self) -> str:
        path_type_match = (
            ""
            if self._path_type is None
            else f" <only for path type {self._path_type.manifest_key}>"
        )
        return (
            self._full_pattern()
            if path_type_match == ""
            else f"{self._full_pattern()}{path_type_match}"
        )

    def describe_match_exact(self) -> str:
        return f"{self.describe_match_short()} (glob)"

    def _compile_glob(self) -> Sequence[Union[Callable[[str], bool], str]]:
        assert self._glob_pattern.strip("/") == self._glob_pattern
        return [
            _compile_basename_glob(part) if glob.has_magic(part) else part
            for part in self._glob_pattern.split("/")
        ]

    def __hash__(self) -> int:
        return hash((self.rule_type, self._glob_pattern, self._path_type))

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, GenericGlobImplementation):
            return NotImplemented
        return (
            self._glob_pattern == other._glob_pattern
            and self._path_type == other._path_type
        )

    @property
    def path_type(self) -> Optional[PathType]:
        return self._path_type

    def match_rule_without_path_type_match(self) -> "MatchRule":
        if self.is_path_type_part_of_match:
            return GenericGlobImplementation(
                self._glob_pattern,
                path_type=None,
            )
        return self


class PathMatcher(Generic[MR]):
    def __init__(
        self,
        exact_match_rules: Dict[str, Tuple[MatchRule, MR]],
        global_globs: List[Tuple[MatchRule, MR]],
        global_default_match: MR,
    ) -> None:
        self._exact_match_rules = exact_match_rules
        self._globs = global_globs
        self._global_default_match = global_default_match

    def resolve_all(self, fs_root: VP) -> Iterable[Tuple[VP, MR]]:
        default_match_def = MATCH_ANYTHING, self._global_default_match
        match_state: Dict[str, Tuple[MatchRule, MR]] = {
            p.path: default_match_def for p in fs_root.all_paths()
        }
        for match_rule_def in self._globs:
            match_rule, _result = match_rule_def
            for match in match_rule.finditer(fs_root):
                match_state[match.path] = match_rule_def

        match_state.update(self._exact_match_rules)

        yield from ((p, match_state[p.path][1]) for p in fs_root.all_paths())

    @classmethod
    def from_dict(cls, path_specs: Dict[MatchRule, MR], builtin_default_match: MR):
        exact_match_rules = {}
        global_globs = []
        global_default_match = None
        match_rule: MatchRule
        manifest_path_info: MR
        for match_rule, manifest_path_info in sorted(
            path_specs.items(), key=lambda x: x[0].sort_key()
        ):
            lookup_key = match_rule.lookup_key()
            if match_rule.rule_type == MatchRuleType.EXACT_MATCH:
                assert lookup_key is not None
                exact_match_rules[lookup_key] = match_rule, manifest_path_info
                continue
            if (
                match_rule.rule_type == MatchRuleType.ANYTHING_BENEATH_DIR
                and lookup_key == "."
            ):
                raise ValueError(
                    "Please use MATCH_ANYTHING instead of a ANYTHING_BENEATH_DIR rule for '.'"
                )
            if match_rule is MATCH_ANYTHING:
                assert global_default_match is None
                global_default_match = manifest_path_info
                continue

            global_globs.append((match_rule, manifest_path_info))
        if global_default_match is None:
            global_default_match = builtin_default_match

        return PathMatcher(
            exact_match_rules,
            global_globs,
            global_default_match,
        )
