import errno
import os
import pathlib
from typing import cast, Any, Callable, Generator, Iterable, List, Optional, Tuple

from cerulean.file_system import FileSystem
from cerulean.file_system_impl import FileSystemImpl
from cerulean.path import AbstractPath, EntryType, Path, Permission


class LocalFileSystem(FileSystemImpl):
    """Represents the local file system.

    To create an instance, just call `LocalFileSystem()`.

    LocalFileSystem support a single operation:

    .. code-block:: python

      fs / 'path'

    which produces a :class:`Path`, through which you can do things with local files.

    LocalFileSystem is a context manager, so you can use it in a ``with`` statement, and
    it has a :meth:`close` method, but since it doesn't hold any resources, you do not
    need to use them. It may be good to do so anyway, to avoid leaks if you ever replace
    it with a different :class:`FileSystem` that does.
    """
    def __eq__(self, other: Any) -> bool:
        """Return True iff this file system equals other."""
        if not isinstance(other, FileSystem):
            return NotImplemented
        return isinstance(other, LocalFileSystem)

    def root(self) -> Path:
        return Path(self, pathlib.Path('/'))

    def __truediv__(self, segment: str) -> Path:
        # TODO: segment: Union[str, pathlib.Path]?
        absseg = '/' + segment.strip('/')
        path = pathlib.Path(absseg)
        return Path(self, path)

    def _supports(self, feature: str) -> bool:
        if feature not in self._features:
            raise ValueError('Invalid argument for "feature"')
        return True

    def _exists(self, path: AbstractPath) -> bool:
        lpath = cast(pathlib.Path, path)
        try:
            return lpath.exists()
        except OSError as e:
            if e.errno == errno.ELOOP:
                return False
            raise

    def _mkdir(
            self, path: AbstractPath, mode: Optional[int] = None,
            parents: bool = False, exists_ok: bool = False) -> None:
        lpath = cast(pathlib.Path, path)
        if mode is not None:
            lpath.mkdir(mode, parents, exists_ok)
        else:
            lpath.mkdir(parents=parents, exist_ok=exists_ok)

    def _iterdir(self, path: AbstractPath) -> Generator[AbstractPath, None, None]:
        lpath = cast(pathlib.Path, path)
        for entry in lpath.iterdir():
            yield entry

    def _rmdir(self, path: AbstractPath, recursive: bool = False) -> None:
        lpath = cast(pathlib.Path, path)
        if not lpath.is_dir():
            raise RuntimeError('Path must refer to a directory')

        if recursive:
            for entry in lpath.iterdir():
                if entry.is_symlink():
                    entry.unlink()
                elif entry.is_dir():
                    self._rmdir(entry, True)
                else:
                    entry.unlink()

        lpath.rmdir()

    def _touch(self, path: AbstractPath) -> None:
        lpath = cast(pathlib.Path, path)
        lpath.touch()

    def _streaming_read(self, path: AbstractPath) -> Generator[bytes, None, None]:
        # Buffer size vs. speed (MB/s) against localhost
        #       up      down        local
        # 8k    33      56          159
        # 16k   52      56          145
        # 24k   66      57          150
        # 32k   24      57          149
        # 2M    24      48
        # scp   120     110
        # cp                        172
        lpath = cast(pathlib.Path, path)
        with lpath.open('rb') as f:
            data = f.read(24576)
            while len(data) > 0:
                yield data
                data = f.read(24576)

    def _streaming_write(self, path: AbstractPath, data: Iterable[bytes]) -> None:
        lpath = cast(pathlib.Path, path)
        with lpath.open('wb') as f:
            for chunk in data:
                f.write(chunk)

    def _rename(self, path: AbstractPath, target: AbstractPath) -> None:
        lpath = cast(pathlib.Path, path)
        ltarget = cast(pathlib.Path, target)
        lpath.replace(pathlib.Path(ltarget))

    def _unlink(self, path: AbstractPath) -> None:
        lpath = cast(pathlib.Path, path)
        lpath.unlink()

    def _is_dir(self, path: AbstractPath) -> bool:
        lpath = cast(pathlib.Path, path)
        return lpath.is_dir()

    def _is_file(self, path: AbstractPath) -> bool:
        lpath = cast(pathlib.Path, path)
        return lpath.is_file()

    def _is_symlink(self, path: AbstractPath) -> bool:
        lpath = cast(pathlib.Path, path)
        return lpath.is_symlink()

    def _entry_type(self, path: AbstractPath) -> EntryType:
        lpath = cast(pathlib.Path, path)
        # Note: symlink goes first, because is_dir() and is_file() will
        # dereference and return true, while we want to say it's a
        # symlink and leave it at that.
        Pred = Callable[[pathlib.Path], bool]
        pred_to_type: List[Tuple[Pred, EntryType]] = [
                (pathlib.Path.is_symlink, EntryType.SYMBOLIC_LINK),
                (pathlib.Path.is_dir, EntryType.DIRECTORY),
                (pathlib.Path.is_file, EntryType.FILE),
                (pathlib.Path.is_char_device,
                    EntryType.CHARACTER_DEVICE),
                (pathlib.Path.is_block_device, EntryType.BLOCK_DEVICE),
                (pathlib.Path.is_fifo, EntryType.FIFO),
                (pathlib.Path.is_socket, EntryType.SOCKET)]

        if not self._exists(lpath):
            raise OSError(errno.ENOENT, 'No such file or directory',
                          str(lpath))
        for pred, entry_type in pred_to_type:
            if pred(lpath):
                return entry_type
        raise RuntimeError('Object is of unknown type, please report a'
                           'Cerulean bug')

    def _size(self, path: AbstractPath) -> int:
        lpath = cast(pathlib.Path, path)
        return lpath.stat().st_size

    def _uid(self, path: AbstractPath) -> int:
        lpath = cast(pathlib.Path, path)
        return lpath.stat().st_uid

    def _gid(self, path: AbstractPath) -> int:
        lpath = cast(pathlib.Path, path)
        return lpath.stat().st_gid

    def _has_permission(self, path: AbstractPath, permission: Permission) -> bool:
        lpath = cast(pathlib.Path, path)
        return bool(lpath.stat().st_mode & permission.value)

    def _set_permission(
            self, path: AbstractPath, permission: Permission, value: bool = True
            ) -> None:
        lpath = cast(pathlib.Path, path)
        mode = lpath.stat().st_mode
        if value:
            mode = mode | permission.value
        else:
            mode = mode & ~permission.value

        self._chmod(lpath, mode)

    def _chmod(self, path: AbstractPath, mode: int) -> None:
        lpath = cast(pathlib.Path, path)
        lpath.chmod(mode)

    def _symlink_to(self, path: AbstractPath, target: AbstractPath) -> None:
        lpath = cast(pathlib.Path, path)
        ltarget = cast(pathlib.Path, target)
        lpath.symlink_to(ltarget)

    def _readlink(self, path: AbstractPath, recursive: bool) -> Path:
        lpath = cast(pathlib.Path, path)
        if recursive:
            # pathlib.Path.resolve() raises if the link is broken
            # we don't want that, so use our own implementation
            max_iter = 32
            cur_path = lpath
            iter_count = 0
            while cur_path.is_symlink() and iter_count < max_iter:
                target = pathlib.Path(os.readlink(str(cur_path)))
                if not target.is_absolute():
                    target = cur_path.parent / target
                cur_path = target
                iter_count += 1

            if iter_count == max_iter:
                raise RuntimeError('Too many symbolic links detected')

            return Path(self, cur_path)
        else:
            return Path(self, pathlib.Path(
                os.readlink(str(lpath))))
