import os
import stat
from pathlib import Path
from typing import Union, List, Generator, Tuple, Optional, Callable

from clarabot_nano.utility.path.common import (
    ensure_path,
    validate_resolve_path_generator,
    get_path_flavour,
    resolve_path,
    ConditionalValidationError,
    normalize_validate_absolute_path,
)
from clarabot_nano.utility.path.sensitive import normalize_validate_sensitive_absolute_path
from clarabot_nano.utility.path.validate import ValidationError, validate_name


class ResolvedRootConsistencyError(ValidationError):
    pass


class CycledPathError(ValidationError):
    pass


class PartialNodeResolutionError(Exception):
    pass  # Used for the node() resolution when there is some error for listing the directory


class ChrootPath(object):
    """
    Strict path functions around a specific path which needs chroot-like containment.
    Each method documents the well expected exceptions to handle by calling code.
    """

    _SYSTEM_FLAVOUR = get_path_flavour()

    def __init__(
        self,
        root_path: Union[str, Path],
        allow_symlinks: bool = True,
        contain_symlinks: bool = True,
        allow_path_cycles: bool = True,
        restrict_sensitive: bool = True,
    ):
        """
        The root path is strictly validated when the object is created.
        It is naively resolved later at execution, but the resolved root must be the same as here.
        :raises:
            ValidationError if root_path is not absolute or is invalid
            any OSError instance if root_path cannot be resolved
            NotADirectoryError if root_path is resolved to something other than a directory
        """
        self.__root_path_validator_fn = (
            normalize_validate_sensitive_absolute_path if restrict_sensitive else normalize_validate_absolute_path
        )
        self.__root_paths = [Path(self.__root_path_validator_fn(root_path))]
        self.__allow_symlinks = allow_symlinks
        self.__contain_symlinks = contain_symlinks
        self.__allow_path_cycles = allow_path_cycles
        self.__resolved_root = resolve_path(self.__root_paths[0], True)  # raises if root_path is not suitable
        self.__root_path_validator_fn(self.__resolved_root)  # raises if resolved_root is not suitable (eg. sensitive)
        if not self.__resolved_root.is_dir():
            raise NotADirectoryError()

    def _check_root_resolution_consistency(self):
        """
        :raises ResolvedRootConsistencyError if any of the __root_paths do not resolve to the __resolved_root
        """
        try:
            roots = [resolve_path(root_path, strict=True) for root_path in self.__root_paths]
        except OSError as e:
            raise ResolvedRootConsistencyError() from e
        if any(root != self.__resolved_root for root in roots):
            raise ResolvedRootConsistencyError("Root path have changed since start")

    def add_alternate_root_path(self, root_path: Union[str, Path]):
        """
        Add an additional path for root resolution checking.
        :raises: ValidationError if root_path is not absolute or is invalid
        """
        root_path = Path(self.__root_path_validator_fn(root_path))
        if root_path not in self.__root_paths:
            self.__root_paths.append(root_path)

    # def is_root_resolution_consistent(self) -> bool:
    #     """
    #     Return True if the root path resolves to the same one as at initialization.
    #     Miscellaneous OS errors are ignored and treated as consistency error.
    #     """
    #     try:
    #         self._check_root_resolution_consistency()
    #     except ResolvedRootConsistencyError:
    #         return False
    #     return True

    @property
    def root(self):
        return self.__resolved_root

    def path(self, rel: Union[str, Path]) -> Path:
        """
        WARNING!
        This is a convenience function that has limited usability.
        In case the returned path IS the root, there is a race condition where after the resolution it is replaced
        with a file. Since the root shall always be a directory this is a root consistency violation that is not
        enforced by stat()-ing the returned path later. To avoid this issue use .path_stat() instead.
        See paths_stat() for exceptions.
        """
        return self.paths_stat(rel)[0][-1][0]

    def path_stat(self, rel: Union[str, Path]) -> Tuple[Path, os.stat_result]:
        """
        Use this for reading from the path, file or directory listing.
        Create and validate a path under the root. Symlinks may be enabled and still force all resolutions to
        be inside the root unless contain_symlinks is switched off.
        See paths_stat() for exceptions.
        """
        paths, s = self.paths_stat(rel)
        return paths[-1][0], s

    def paths_stat(self, rel: Union[str, Path]) -> Tuple[List[Tuple[Path, str]], os.stat_result]:
        """
        Resolve the relative path under the root enforcing symlink and containment. The whole path is validated and
        resolved strictly, meaning it must exist. This will return each level of resolution from the root by rel.
        :raises
            ValidationError
                if the rel is an absolute path or invalid in some way
            ResolvedRootConsistencyError (ValidationError)
                if any resolution of the managed roots have changed
                if the primary root resolution is no longer a directory
            ConditionalValidationError (ValidationError)
                if the rel contains a symlink but they are not allowed
                if the rel is not strictly contained under the root, but it is required
            CycledPathError (ValidationError)
                if the rel forms has a cycle by symlink resolution, but it is forbidden
            any OSError instance
                for failing to stat the root
                failing to resolve the rel under the root
        """
        self._check_root_resolution_consistency()
        if rel:
            rel = ensure_path(rel)
            if rel.is_absolute():
                raise ValidationError("Path cannot be absolute")
        if not rel or not rel.parts:  # empty relative path is the root itself
            s = self.__resolved_root.stat()
            if not stat.S_ISDIR(s.st_mode):
                raise ResolvedRootConsistencyError()
            return [(self.__resolved_root, "")], s
        visited_paths = {self.__resolved_root}
        paths = [(self.__resolved_root, "")]
        i = 0
        for p in validate_resolve_path_generator(self.__resolved_root.parts, rp := rel.parts, self.__allow_symlinks):
            # Parent directive is never allowed therefore the only way to escape containment is through symlinks.
            # They can link to absolute paths outside the root or just contain ".." in their link. We do not worry
            # about hardlinks and mount points, those are more explicitly managed by the user.
            # p is resolved here, so it cannot be a symlink
            if self.__contain_symlinks and not self._is_contained(self.__resolved_root, p):
                raise ConditionalValidationError("Combined path is not contained by root")
            if not self.__allow_path_cycles:
                if p in visited_paths:
                    raise CycledPathError()
                visited_paths.add(p)
            paths.append((p, f"{paths[-1][1]}/{rp[i]}" if paths[-1][1] else rp[i]))
            i += 1
        assert len(paths) == len(rp) + 1
        # generator is guaranteed to yield exactly the time of len(rp) or raise an exception, so len(path) >= 2
        s = paths[-1][0].stat()
        if self.__allow_path_cycles and paths[-1][0] == self.__resolved_root:
            # p can be the root if the path symlinks back to it while allowing cycles
            if not stat.S_ISDIR(s.st_mode):
                raise ResolvedRootConsistencyError()
        return paths, s

    def list(self, rel: Union[str, Path]) -> List[Path]:
        """
        See paths_stat() and _iter() for exceptions.
        """
        return list(it[0] for it in self._iter(self.paths_stat(rel)[0][-1][0]))

    def iter(self, rel: Union[str, Path]) -> Generator[Path, None, None]:
        """
        See paths_stat() and _iter() for exceptions.
        """
        return (it[0] for it in self._iter(self.paths_stat(rel)[0][-1][0]))

    def _iter(self, path: Path) -> Generator[Tuple[Path, Optional[Path]], None, None]:
        """
        The listed items are not validated before yielding. Seems to be no reason to do so.
        They are not really resolved either, but the symlink containment is enforced if needed.
        :raises any OSError for failing to iterate on directory or resolving child symlinks
        """
        # path is a directory or a symlink to a directory (with allow_symlinks True)
        for p in path.iterdir():
            sym = p.is_symlink()
            if not self.__allow_symlinks:
                if sym:
                    continue
                yield p, None
            elif sym:
                try:
                    t = resolve_path(p, strict=True)
                except OSError:
                    # Any error here is expected to be because of broken symlinks normally.
                    continue
                if self.__contain_symlinks and not self._is_contained(self.__resolved_root, t):
                    continue
                yield p, t
            else:
                yield p, None

    def node(
        self, rel: Union[str, Path]
    ) -> Tuple[List[Tuple[Path, str]], os.stat_result, Optional[List[Tuple[Path, Optional[Path]]]]]:
        """
        Resolve the relative path. Adapt the behaviour and return value to the type of the resolved node.
        This enforces type consistency of the path for the lookup.
        :raises
            PartialNodeResolutionError if the rel was resolved to be a directory, but listing it failed
            See paths_stat() for more exceptions.
        """
        paths, s = self.paths_stat(rel)
        if stat.S_ISDIR(s.st_mode):
            try:
                return paths, s, list(self._iter(paths[-1][0]))
            except OSError as e:
                raise PartialNodeResolutionError(paths, s, e) from e
        return paths, s, None

    def new_path(self, rel: Union[str, Path], name_preprocess_fn: Optional[Callable[[str], str]] = None) -> Path:
        """
        Do not use this for reading from the path. This should be used for creating new items under an existing
        parent, or deleting items which may even be symlinks.
        Create and validate a path under the root, but do not be strict with the last part. The very last part
        is allowed to not exist yet or be a symlink. If it already exists the caller is responsible for
        handling it.
        :raises
            ConditionalValidationError (ValidationError)
                if the resolved parent of rel is not a directory
                if the final path is an existing symlink, but they are not allowed
                if the final path is an existing symlink which resolves outside of root, but that is forbidden
            See paths_stat() for more exceptions.
        """
        rel = ensure_path(rel)
        new_name = name_preprocess_fn(rel.name) if name_preprocess_fn else rel.name
        validate_name(new_name, dos=False)
        parent_path, parent_s = self.path_stat(rel.parent)
        if not stat.S_ISDIR(parent_s.st_mode):
            if parent_path == self.__resolved_root:
                raise ResolvedRootConsistencyError()
            raise ConditionalValidationError("Path must be inside an existing directory")
        final_path = parent_path / new_name
        # Relative directives like ".." parent are never allowed, so appending a name to a safely contained path is
        # appropriate as long as it is not a symlink. If it is, then allowance and containment must be checked.
        if final_path.is_symlink():
            if not self.__allow_symlinks:
                raise ConditionalValidationError("Final part may not be a symlink")
            if self.__contain_symlinks and not self._is_contained(
                self.__resolved_root, resolve_path(final_path, strict=True)
            ):
                raise ConditionalValidationError("Final path is not contained by root")
        # FIXME: normalize name of existing entity (nt casefolding) if any
        return final_path

    def _is_contained(self, root: Path, p: Path) -> bool:
        """
        Use with resolved path.
        """
        if len(p.parts) < len(root.parts):
            return False
        for np, rp in zip(
            self._SYSTEM_FLAVOUR.casefold_parts(p.parts), self._SYSTEM_FLAVOUR.casefold_parts(root.parts)
        ):
            if np != rp:
                return False
        return True
