"""Manage the filesystem provided by your OS.

In essence, an `OSFS` is a thin layer over the `io` and `os` modules
of the Python standard library.
"""

from __future__ import absolute_import
from __future__ import print_function
from __future__ import unicode_literals

import errno
import io
import itertools
import logging
import os
import platform
import stat
import sys
import typing

import six

try:
    from os import scandir
except ImportError:
    try:
        from scandir import scandir  # type: ignore
    except ImportError:  # pragma: no cover
        scandir = None

from . import errors
from .errors import FileExists
from .base import FS
from .enums import ResourceType
from ._fscompat import fsencode, fsdecode, fspath
from .info import Info
from .path import basename
from .permissions import Permissions
from .error_tools import convert_os_errors
from .mode import Mode, validate_open_mode
from .errors import NoURL

if False:  # typing.TYPE_CHECKING
    from typing import (
        Any,
        BinaryIO,
        Callable,
        Collection,
        Dict,
        Iterator,
        IO,
        List,
        Optional,
        SupportsInt,
        Text,
        Tuple,
    )
    from .base import _OpendirFactory
    from .info import RawInfo
    from .subfs import SubFS

    _O = typing.TypeVar("_O", bound="OSFS")


log = logging.getLogger("fs.osfs")


_WINDOWS_PLATFORM = platform.system() == "Windows"


@six.python_2_unicode_compatible
class OSFS(FS):
    """Create an OSFS.

    Arguments:
        root_path (str or ~os.PathLike): An OS path or path-like object to
            the location on your HD you wish to manage.
        create (bool): Set to `True` to create the root directory if it
            does not already exist, otherwise the directory should exist
            prior to creating the ``OSFS`` instance (defaults to `False`).
        create_mode (int): The permissions that will be used to create
            the directory if ``create`` is `True` and the path doesn't
            exist, defaults to ``0o777``.

    Raises:
        `fs.errors.CreateFailed`: If ``root_path`` does not
            exist, or could not be created.

    Examples:
        >>> current_directory_fs = OSFS('.')
        >>> home_fs = OSFS('~/')
        >>> windows_system32_fs = OSFS('c://system32')

    """

    def __init__(
        self,
        root_path,  # type: Text
        create=False,  # type: bool
        create_mode=0o777,  # type: SupportsInt
    ):
        # type: (...) -> None
        """Create an OSFS instance.
        """
        super(OSFS, self).__init__()
        if isinstance(root_path, bytes):
            root_path = fsdecode(root_path)
        self.root_path = root_path
        _drive, _root_path = os.path.splitdrive(fsdecode(fspath(root_path)))
        _root_path = _drive + (_root_path or '/') if _drive else _root_path
        _root_path = os.path.expanduser(os.path.expandvars(_root_path))
        _root_path = os.path.normpath(os.path.abspath(_root_path))
        self._root_path = _root_path

        if create:
            try:
                if not os.path.isdir(_root_path):
                    os.makedirs(_root_path, mode=int(create_mode))
            except OSError as error:
                raise errors.CreateFailed(
                    "unable to create {} ({})".format(root_path, error), error
                )
        else:
            if not os.path.isdir(_root_path):
                raise errors.CreateFailed("root path does not exist")

        _meta = self._meta = {
            "case_insensitive": os.path.normcase("Aa") != "aa",
            "network": False,
            "read_only": False,
            "supports_rename": True,
            "thread_safe": True,
            "unicode_paths": os.path.supports_unicode_filenames,
            "virtual": False,
        }

        if _WINDOWS_PLATFORM:  # pragma: no cover
            _meta["invalid_path_chars"] = (
                "".join(six.unichr(n) for n in range(31)) + '\\:*?"<>|'
            )
        else:
            _meta["invalid_path_chars"] = "\0"

            if "PC_PATH_MAX" in os.pathconf_names:
                _meta["max_sys_path_length"] = os.pathconf(
                    fsencode(_root_path), os.pathconf_names["PC_PATH_MAX"]
                )

    def __repr__(self):
        # type: () -> str
        _fmt = "{}({!r})"
        _class_name = self.__class__.__name__
        return _fmt.format(_class_name, self.root_path)

    def __str__(self):
        # type: () -> str
        fmt = "<{} '{}'>"
        _class_name = self.__class__.__name__
        return fmt.format(_class_name.lower(), self.root_path)

    def _to_sys_path(self, path):
        # type: (Text) -> Text
        """Convert a FS path to a path on the OS.
        """
        sys_path = fsencode(
            os.path.join(self._root_path, path.lstrip("/").replace("/", os.sep))
        )
        return sys_path

    @classmethod
    def _make_details_from_stat(cls, stat_result):
        # type: (os.stat_result) -> Dict[Text, object]
        """Make a *details* info dict from an `os.stat_result` object.
        """
        details = {
            "_write": ["accessed", "modified"],
            "accessed": stat_result.st_atime,
            "modified": stat_result.st_mtime,
            "size": stat_result.st_size,
            "type": int(cls._get_type_from_stat(stat_result)),
        }
        # On other Unix systems (such as FreeBSD), the following
        # attributes may be available (but may be only filled out if
        # root tries to use them):
        details["created"] = getattr(stat_result, "st_birthtime", None)
        ctime_key = "created" if _WINDOWS_PLATFORM else "metadata_changed"
        details[ctime_key] = stat_result.st_ctime
        return details

    @classmethod
    def _make_access_from_stat(cls, stat_result):
        # type: (os.stat_result) -> Dict[Text, object]
        """Make an *access* info dict from an `os.stat_result` object.
        """
        access = {}  # type: Dict[Text, object]
        access["permissions"] = Permissions(mode=stat_result.st_mode).dump()
        access["gid"] = gid = stat_result.st_gid
        access["uid"] = uid = stat_result.st_uid
        if not _WINDOWS_PLATFORM:
            import grp
            import pwd

            try:
                access["group"] = grp.getgrgid(gid).gr_name
            except KeyError:  # pragma: no cover
                pass

            try:
                access["user"] = pwd.getpwuid(uid).pw_name
            except KeyError:  # pragma: no cover
                pass
        return access

    STAT_TO_RESOURCE_TYPE = {
        stat.S_IFDIR: ResourceType.directory,
        stat.S_IFCHR: ResourceType.character,
        stat.S_IFBLK: ResourceType.block_special_file,
        stat.S_IFREG: ResourceType.file,
        stat.S_IFIFO: ResourceType.fifo,
        stat.S_IFLNK: ResourceType.symlink,
        stat.S_IFSOCK: ResourceType.socket,
    }

    @classmethod
    def _get_type_from_stat(cls, _stat):
        # type: (os.stat_result) -> ResourceType
        """Get the resource type from an `os.stat_result` object.
        """
        st_mode = _stat.st_mode
        st_type = stat.S_IFMT(st_mode)
        return cls.STAT_TO_RESOURCE_TYPE.get(st_type, ResourceType.unknown)

    # --------------------------------------------------------
    # Required Methods
    # --------------------------------------------------------

    def _gettarget(self, sys_path):
        # type: (Text) -> Optional[Text]
        try:
            target = os.readlink(fsencode(sys_path))
        except OSError:
            return None
        else:
            return target

    def _make_link_info(self, sys_path):
        # type: (Text) -> Dict[Text, object]
        _target = self._gettarget(sys_path)
        return {"target": _target}

    def getinfo(self, path, namespaces=None):
        # type: (Text, Optional[Collection[Text]]) -> Info
        self.check()
        namespaces = namespaces or ()
        _path = self.validatepath(path)
        sys_path = self.getsyspath(_path)
        _lstat = None
        with convert_os_errors("getinfo", path):
            _stat = os.stat(fsencode(sys_path))
            if "lstat" in namespaces:
                _stat = os.lstat(fsencode(sys_path))

        info = {
            "basic": {"name": basename(_path), "is_dir": stat.S_ISDIR(_stat.st_mode)}
        }
        if "details" in namespaces:
            info["details"] = self._make_details_from_stat(_stat)
        if "stat" in namespaces:
            info["stat"] = {
                k: getattr(_stat, k) for k in dir(_stat) if k.startswith("st_")
            }
        if "lstat" in namespaces:
            info["lstat"] = {
                k: getattr(_lstat, k) for k in dir(_lstat) if k.startswith("st_")
            }
        if "link" in namespaces:
            info["link"] = self._make_link_info(sys_path)
        if "access" in namespaces:
            info["access"] = self._make_access_from_stat(_stat)

        return Info(info)

    def listdir(self, path):
        # type: (Text) -> List[Text]
        self.check()
        _path = self.validatepath(path)
        sys_path = self._to_sys_path(_path)
        with convert_os_errors("listdir", path, directory=True):
            names = os.listdir(fsencode(sys_path))
        return [fsdecode(name) for name in names]
        # return names

    def makedir(
        self,  # type: _O
        path,  # type: Text
        permissions=None,  # type: Optional[Permissions]
        recreate=False,  # type: bool
    ):
        # type: (...) -> SubFS[_O]
        self.check()
        mode = Permissions.get_mode(permissions)
        _path = self.validatepath(path)
        sys_path = self._to_sys_path(_path)
        with convert_os_errors("makedir", path, directory=True):
            try:
                os.mkdir(sys_path, mode)
            except OSError as error:
                if error.errno == errno.ENOENT:
                    raise errors.ResourceNotFound(path)
                elif error.errno == errno.EEXIST and recreate:
                    pass
                else:
                    raise
            return self.opendir(_path)

    def openbin(self, path, mode="r", buffering=-1, **options):
        # type: (Text, Text, int, **Any) -> BinaryIO
        _mode = Mode(mode)
        _mode.validate_bin()
        self.check()
        _path = self.validatepath(path)
        sys_path = self._to_sys_path(_path)
        with convert_os_errors("openbin", path):
            if six.PY2 and _mode.exclusive and self.exists(path):
                raise errors.FileExists(path)
            binary_file = io.open(
                sys_path, mode=_mode.to_platform_bin(), buffering=buffering, **options
            )
        return binary_file  # type: ignore

    def remove(self, path):
        # type: (Text) -> None
        self.check()
        _path = self.validatepath(path)
        sys_path = self._to_sys_path(_path)
        with convert_os_errors("remove", path):
            try:
                os.remove(sys_path)
            except OSError as error:
                if error.errno == errno.EACCES and sys.platform == "win32":
                    # sometimes windows says this for attempts to remove a dir
                    if os.path.isdir(sys_path):  # pragma: no cover
                        raise errors.FileExpected(path)
                if error.errno == errno.EPERM and sys.platform == "darwin":
                    # sometimes OSX says this for attempts to remove a dir
                    if os.path.isdir(sys_path):  # pragma: no cover
                        raise errors.FileExpected(path)
                raise

    def removedir(self, path):
        # type: (Text) -> None
        self.check()
        _path = self.validatepath(path)
        if _path == "/":
            raise errors.RemoveRootError()
        sys_path = self._to_sys_path(path)
        with convert_os_errors("removedir", path, directory=True):
            os.rmdir(sys_path)

    # --------------------------------------------------------
    # Optional Methods
    # --------------------------------------------------------

    if False:  # typing.TYPE_CHECKING

        def opendir(self, path, factory=None):
            # type: (_O, Text, Optional[_OpendirFactory]) -> SubFS[_O]
            pass

    def getsyspath(self, path):
        # type: (Text) -> Text
        sys_path = os.path.join(self._root_path, path.lstrip("/").replace("/", os.sep))
        return sys_path

    def geturl(self, path, purpose="download"):
        # type: (Text, Text) -> Text
        if purpose != "download":
            raise NoURL(path, purpose)
        return "file://" + self.getsyspath(path)

    def gettype(self, path):
        # type: (Text) -> ResourceType
        self.check()
        sys_path = self._to_sys_path(path)
        with convert_os_errors("gettype", path):
            stat = os.stat(sys_path)
        resource_type = self._get_type_from_stat(stat)
        return resource_type

    def islink(self, path):
        # type: (Text) -> bool
        self.check()
        _path = self.validatepath(path)
        sys_path = self._to_sys_path(_path)
        if not self.exists(path):
            raise errors.ResourceNotFound(path)
        with convert_os_errors("islink", path):
            return os.path.islink(sys_path)

    def open(
        self,
        path,  # type: Text
        mode="r",  # type: Text
        buffering=-1,  # type: int
        encoding=None,  # type: Optional[Text]
        errors=None,  # type: Optional[Text]
        newline="",  # type: Text
        line_buffering=False,  # type: bool
        **options  # type: Any
    ):
        # type: (...) -> IO
        _mode = Mode(mode)
        validate_open_mode(mode)
        self.check()
        _path = self.validatepath(path)
        sys_path = self._to_sys_path(_path)
        with convert_os_errors("open", path):
            if six.PY2 and _mode.exclusive:
                sys_path = os.open(sys_path, os.O_CREAT | os.O_EXCL)
            _encoding = encoding or "utf-8"
            return io.open(
                sys_path,
                mode=_mode.to_platform(),
                buffering=buffering,
                encoding=None if _mode.binary else _encoding,
                errors=errors,
                newline=None if _mode.binary else newline,
                **options
            )

    def setinfo(self, path, info):
        # type: (Text, RawInfo) -> None
        self.check()
        _path = self.validatepath(path)
        sys_path = self._to_sys_path(_path)
        if not os.path.exists(sys_path):
            raise errors.ResourceNotFound(path)
        if "details" in info:
            details = info["details"]
            if "accessed" in details or "modified" in details:
                _accessed = typing.cast(int, details.get("accessed"))
                _modified = typing.cast(int, details.get("modified", _accessed))
                accessed = int(_modified if _accessed is None else _accessed)
                modified = int(_modified)
                if accessed is not None or modified is not None:
                    with convert_os_errors("setinfo", path):
                        os.utime(sys_path, (accessed, modified))

    if scandir:

        def _scandir(self, path, namespaces=None):
            # type: (Text, Optional[Collection[Text]]) -> Iterator[Info]
            self.check()
            namespaces = namespaces or ()
            _path = self.validatepath(path)
            sys_path = self._to_sys_path(_path)
            with convert_os_errors("scandir", path, directory=True):
                for dir_entry in scandir(sys_path):
                    info = {
                        "basic": {
                            "name": fsdecode(dir_entry.name),
                            "is_dir": dir_entry.is_dir(),
                        }
                    }
                    if "details" in namespaces:
                        stat_result = dir_entry.stat()
                        info["details"] = self._make_details_from_stat(stat_result)
                    if "stat" in namespaces:
                        stat_result = dir_entry.stat()
                        info["stat"] = {
                            k: getattr(stat_result, k)
                            for k in dir(stat_result)
                            if k.startswith("st_")
                        }
                    if "lstat" in namespaces:
                        lstat_result = dir_entry.stat(follow_symlinks=False)
                        info["lstat"] = {
                            k: getattr(lstat_result, k)
                            for k in dir(lstat_result)
                            if k.startswith("st_")
                        }
                    if "link" in namespaces:
                        info["link"] = self._make_link_info(
                            os.path.join(sys_path, dir_entry.name)
                        )
                    if "access" in namespaces:
                        stat_result = dir_entry.stat()
                        info["access"] = self._make_access_from_stat(stat_result)

                    yield Info(info)

    else:

        def _scandir(self, path, namespaces=None):
            # type: (Text, Optional[Collection[Text]]) -> Iterator[Info]
            self.check()
            namespaces = namespaces or ()
            _path = self.validatepath(path)
            sys_path = self.getsyspath(_path)
            _sys_path = fsencode(sys_path)
            with convert_os_errors("scandir", path, directory=True):
                for entry_name in os.listdir(sys_path):
                    _entry_name = fsdecode(entry_name)
                    entry_path = os.path.join(sys_path, _entry_name)
                    stat_result = os.stat(fsencode(entry_path))
                    info = {
                        "basic": {
                            "name": _entry_name,
                            "is_dir": stat.S_ISDIR(stat_result.st_mode),
                        }
                    }  # type: Dict[Text, Dict[Text, Any]]
                    if "details" in namespaces:
                        info["details"] = self._make_details_from_stat(stat_result)
                    if "stat" in namespaces:
                        info["stat"] = {
                            k: getattr(stat_result, k)
                            for k in dir(stat_result)
                            if k.startswith("st_")
                        }
                    if "lstat" in namespaces:
                        lstat_result = os.lstat(entry_path)
                        info["lstat"] = {
                            k: getattr(lstat_result, k)
                            for k in dir(lstat_result)
                            if k.startswith("st_")
                        }
                    if "link" in namespaces:
                        info["link"] = self._make_link_info(
                            os.path.join(sys_path, entry_name)
                        )
                    if "access" in namespaces:
                        info["access"] = self._make_access_from_stat(stat_result)

                    yield Info(info)

    def scandir(
        self,
        path,  # type: Text
        namespaces=None,  # type: Optional[Collection[Text]]
        page=None,  # type: Optional[Tuple[int, int]]
    ):
        # type: (...) -> Iterator[Info]
        iter_info = self._scandir(path, namespaces=namespaces)
        if page is not None:
            start, end = page
            iter_info = itertools.islice(iter_info, start, end)
        return iter_info
