# coding: utf-8
"""Implementation of `SSHFS`.
"""
from __future__ import unicode_literals
from __future__ import absolute_import

import itertools
import os
import stat
import socket
import sys

import paramiko
from property_cached import threaded_cached_property as cached_property

from fs import errors
from fs.base import FS
from fs.info import Info
from fs.enums import ResourceType
from fs.path import basename, dirname
from fs.permissions import Permissions
from fs.osfs import OSFS
from fs.mode import Mode

from .file import SSHFile
from .error_tools import convert_sshfs_errors, ignore_network_errors

from contextlib import contextmanager

import typing
if typing.TYPE_CHECKING:
    from typing import (
        Iterator,
        Optional,
        Text,
    )



@contextmanager
def get_sftp_client(fs, path=None, op=None, directory=False, reuse_sftp_client=True):
    # type: (SSHFS, Optional[Text], Optional[Text], Optional[bool], Optional[bool]) -> Iterator[paramiko.SFTPClient]
    """
    Handle FTP errors accordingly.

    In case of RemoteConnectioError, the internal network connection will be discarded,
    and on the next operation, a new connection to remote server will be attempted.
    """
    try:
        with convert_sshfs_errors(fs=fs, path=path, op=op, directory=directory):
            with fs._lock:
                if fs._ssh_client is None:
                    # Try to establish new SSH connection
                    fs._ssh_client = fs.open_ssh_client()   # this method can throw exception
                    fs._sftp_client = None

                if reuse_sftp_client:
                    # Re-use a single SFTP connection that we. Good for running FileSystem commands like "makedir, listdir"
                    if fs._sftp_client is None:
                        # Open SFTP client over SSH
                        fs._sftp_client = fs._ssh_client.open_sftp()  # this method can throw exception

                    sftp_client = fs._sftp_client
                else:
                    sftp_client = fs._ssh_client.open_sftp()  # create a unique SFTP connection. Good for opening files

            yield sftp_client

    except errors.RemoteConnectionError:
        with fs._lock:
            fs._ssh_client = None   # Discard the network connection. It will be reopened  on the next operation
            fs._sftp_client = None
        raise


class SSHFS(FS):
    """A SSH filesystem using SFTP.

    Arguments:
        host (str): A SSH host or IP adress, e.g. ``shell.openshells.net``
            or ``127.0.0.1``.
        user (str): the username to connect with (defaults to the current
            user). If ``None`` is given, the SSH configuration file will be
            used if available.
        passwd (str): Password for the server, or ``None`` for passwordless
            authentication. If given, it will be discarded immediately after
            establishing the connection.
        pkey (paramiko.PKey): A private key or a list of private key  to use.
            If ``None`` is supplied, then a password will be used.
        timeout (int): The timeout to use when connecting to the SSH server, in
            seconds (defaults to 10).
        port (int): Port number the SSH server is listening on (defaults to 22,
            the usual SSH port).
        keepalive (int): The number of seconds after which a keep-alive message
            is sent (set to 0 to disable keepalive, default is 10).
        compress (bool): Set to ``True`` to compress the messages (disabled by
            default).
        ssh_config (paramiko.SSHConfig): The OpenSSH config object, optional.
            For example, to load configuration from `~/.ssh/config`,
            create it with:
                SSHConfig.from_path("~/.ssh/config")
        exec_timeout (int): The timeout to use when executing arbitrary
            commands on the SSH server. Defaults to the value of ``timeout``.
        policy (paramiko.MissingHostKeyPolicy): The policy to use to resolve
            missing host keys. Defaults to ``None``, which will create a
            `paramiko.AutoAddPolicy` instance.
        use_posix_rename (bool): Use POSIX RENAME command if supporte by a server

    Raises:
        fs.errors.CreateFailed: when the filesystem could not be created. The
            source exception is stored as the ``exc`` attribute of the
            ``CreateFailed`` error.
    """

    _meta = {
        'case_insensitive': False,
        'invalid_path_chars': '\0',
        'network': True,
        'read_only': False,
        'supports_rename': True,
        'thread_safe': sys.version_info[:2] > (3, 4),
        'unicode_paths': True,
        'virtual': False,
    }

    @staticmethod
    def _get_ssh_config(config_path):
        """Extract the configuration located at ``config_path``.

        Returns:
            paramiko.SSHConfig: the configuration instance.
        """
        ssh_config = paramiko.SSHConfig()
        try:
            with open(os.path.realpath(os.path.expanduser(config_path))) as f:
                ssh_config.parse(f)
        except IOError:
            pass
        return ssh_config


    def __init__(
            self,
            host,
            user=None,
            passwd=None,
            pkey=None,
            timeout=10,
            port=22,
            keepalive=10,
            compress=False,
            ssh_config=None,
            config_path=None,
            exec_timeout=None,
            policy=None,
            disable_shell=True,   # Disable running shell commands like "uname -s"
                                  # On some SFTP servers shell is forbidden.
                                  # A network connection is forcibly closed by server if a client tries to run any commands
            use_posix_rename=False,
            **kwargs
    ):  # noqa: D102
        super(SSHFS, self).__init__()

        self._ssh_client = None
        self._sftp_client = None

        self.disable_shell = disable_shell
        self.use_posix_rename = use_posix_rename

        if ssh_config and config_path:
            raise ValueError("Only one of 'ssh_config' or 'config_path' must be specified")

        if config_path:
            ssh_config = self._get_ssh_config(config_path)

        if ssh_config is None:
            ssh_config = paramiko.SSHConfig()

        # Attempt to get a configuration for the given host
        config = ssh_config.lookup(host)
        pkey = config.get('identityfile') or pkey

        # Extract the given info
        self._pkey, self._keyfile = (pkey, None) \
            if isinstance(pkey, paramiko.PKey) else (None, pkey)

        self._user = user = user or config.get('user')
        self._passwd = passwd

        self._host = host = config.get('hostname')
        self._port = port = int(config.get('port', port))

        self._timeout = timeout
        self._compress = compress
        self._keepalive = keepalive
        self._exec_timeout = timeout if exec_timeout is None else exec_timeout

        self._policy = paramiko.AutoAddPolicy() if policy is None else policy

        self._client_argdict = {
            "pkey": self._pkey,
            "key_filename": self._keyfile,
            "look_for_keys": False,   # do not try to load keys from SSH Agent
            "allow_agent": False,     # do not allow SSH Agent
            "compress": self._compress,
            "timeout": self._timeout
        }

        self._client_argdict.update(kwargs)


        with convert_sshfs_errors(self, op='create_fs', connection_error=errors.CreateFailed):
            self._ssh_client = self.open_ssh_client()
            self._sftp_client = self._ssh_client.open_sftp()


    def close(self): # noqa: D102
        if self._sftp_client:
            with ignore_network_errors('closing sftp'):
                self._sftp_client.close()
            self._sftp_client = None

        if self._ssh_client:
            with ignore_network_errors('closing ssh'):
                self._ssh_client.close()
            self._ssh_client = None

        super().close()


    def open_ssh_client(self) -> paramiko.SSHClient:
        """
        Open SSH connection to the server.

        Note, to run SFTP commands, it is necessary to open SFTP client from it.
        """
        client = paramiko.SSHClient()

        # Do not call client.load_system_host_keys() due to potential security issues
        client.set_missing_host_key_policy(self._policy)

        client.connect(
            socket.gethostbyname(self._host), self._port, 
            self._user, self._passwd,
            **self._client_argdict
        )

        if self._keepalive > 0:
            client.get_transport().set_keepalive(self._keepalive)

        return client


    def getinfo(self, path, namespaces=None):  # noqa: D102
        self.check()
        namespaces = namespaces or ()
        _path = self.validatepath(path)

        with get_sftp_client(self, op='getinfo', path=path) as sftp:
            _stat = sftp.stat(_path)
            info = self._make_raw_info(basename(_path), _stat, namespaces)

            if "lstat" in namespaces or "link" in namespaces:
                _lstat = sftp.lstat(_path)
            if "lstat" in namespaces:
                info["lstat"] = {
                    k: getattr(_lstat, k)
                    for k in dir(_lstat)
                    if k.startswith("st_")
                }
            if "link" in namespaces:
                if OSFS._get_type_from_stat(_lstat) == ResourceType.symlink:
                    target = sftp.readlink(_path)
                    info["link"] = {"target": target}
                else:
                    info["link"] = {"target": None}

            return Info(info)

    def geturl(self, path, purpose='download'):  # noqa: D102
        _path = self.validatepath(path)
        if purpose != 'download':
            raise errors.NoURL(path, purpose)
        return "ssh://{}@{}:{}{}".format(
            self._user, self._host, self._port, _path)

    def islink(self, path):  # noqa: D102
        return self.getinfo(path, namespaces=["link"]).is_link

    def listdir(self, path):  # noqa: D102
        self.check()
        _path = self.validatepath(path)

        _type = self.gettype(_path)
        if _type is not ResourceType.directory:
            raise errors.DirectoryExpected(path)

        with get_sftp_client(self, op='listdir', path=path) as sftp:
            return sftp.listdir(_path)

    def scandir(self, path, namespaces=None, page=None):  # noqa: D102
        self.check()
        _path = self.validatepath(path)
        _namespaces = namespaces or ()
        start, stop = page or (None, None)
        try:
            with get_sftp_client(self, op='scandir', path=path, directory=True) as sftp:
                # We can't use listdir_iter here because it doesn't support
                # concurrent iteration over multiple directories, which can
                # happen during a search="depth" walk.
                listing = sftp.listdir_attr(_path)
                for _stat in itertools.islice(listing, start, stop):
                    yield Info(self._make_raw_info(_stat.filename, _stat, _namespaces))
        except errors.ResourceNotFound:
            # When given a bad path to listdir, the sftp client raises IOError
            # with an errno of ENOENT no matter if the path was missing or was
            # the wrong type (e.g., a file).  For the fs API, we need to figure
            # out if it was supposed to be ENOTDIR instead of ENOENT.
            if self.isfile(_path):
                raise errors.DirectoryExpected(path)
            raise

    def makedir(self, path, permissions=None, recreate=False):  # noqa: D102
        self.check()
        _permissions = permissions or Permissions(mode=0o755)
        _path = self.validatepath(path)

        try:
            info = self.getinfo(_path)
        except errors.ResourceNotFound:
            with self._lock:
                with get_sftp_client(self, op='makedir', path=path) as sftp:
                    sftp.mkdir(_path, _permissions.mode)
        else:
            if (info.is_dir and not recreate) or info.is_file:
                raise errors.DirectoryExists(path)

        return self.opendir(path)

    def move(self, src_path, dst_path, overwrite=False, preserve_time=False):
        info_ns = ("basic", "details") if preserve_time else ("basic",)

        self.check()
        _src_path = self.validatepath(src_path)
        _dst_path = self.validatepath(dst_path)

        with self._lock:
            # check src exists and is a file
            src_info = self.getinfo(_src_path, namespaces=info_ns)
            if src_info.is_dir:
                raise errors.FileExpected(src_path)
            # check dst is not a dir and can be created
            if self.isdir(_dst_path):
                raise errors.FileExpected(dst_path)
            if not self.isdir(dirname(_dst_path)):
                raise errors.ResourceNotFound(dirname(dst_path))
            # check dst does not exist or remove it
            if self.isfile(_dst_path):
                if not overwrite:
                    raise errors.DestinationExists(dst_path)
                with get_sftp_client(self, op='move', path=dst_path) as sftp:
                    sftp.remove(_dst_path)

            with get_sftp_client(self, op='move', path=dst_path) as sftp:
                if self.use_posix_rename:
                    sftp.posix_rename(_src_path, _dst_path)
                else:
                    # rename the file through SFTP's 'RENAME'
                    sftp.rename(_src_path, _dst_path)

            # preserve times if required
            if preserve_time:
                self._utime(
                    _dst_path,
                    src_info.raw["details"]["modified"],
                    src_info.raw["details"]["accessed"],
                )

    def openbin(self, path, mode='r', buffering=-1, **options):  # noqa: D102
        """Open a binary file-like object.

        Arguments:
            path (str): A path on the filesystem.
            mode (str): Mode to open the file (must be a valid, non-text mode).
                Since this method only opens binary files, the ``b`` in the
                mode is implied.
            buffering (int): the buffering policy (-1 to use default buffering,
                0 to disable completely, 1 to enable line based buffering, or
                any larger positive integer for a custom buffer size).

        Keyword Arguments:
            pipelined (bool): Set the transfer in pipelined mode (should
                improve transfer speed). Defaults to ``True``.
            prefetch (bool): Use background threading to prefetch the file
                content when opened in reading mode. Disable in case of
                threading issues. Defaults to ``True``.

        Raises:
            fs.errors.FileExpected: if the path if not a file.
            fs.errors.FileExists: if the file already exists and
                *exclusive mode* is specified (``x`` in the mode).
            fs.errors.ResourceNotFound: if the path does not exist.

        Returns:
            io.IOBase: a file handle.
        """
        self.check()
        _path = self.validatepath(path)
        _mode = Mode(mode)
        _mode.validate_bin()

        with self._lock:
            if _mode.exclusive:
                if self.exists(_path):
                    raise errors.FileExists(path)
                else:
                    _mode = Mode(''.join(set(mode.replace('x', 'w'))))
            elif not _mode.create and not self.exists(_path):
                raise errors.ResourceNotFound(path)
            elif self.isdir(_path):
                raise errors.FileExpected(path)
            with get_sftp_client(self, op='openbin', path=path, reuse_sftp_client=False) as sftp:
                handle = sftp.open(
                    _path,
                    mode=_mode.to_platform_bin(),
                    bufsize=buffering
                )
                handle.set_pipelined(options.get("pipelined", True))
                if options.get("prefetch", False):
                    if _mode.reading and not _mode.writing:
                        handle.prefetch(self.getsize(_path))
                return SSHFile(handle, mode=_mode.to_platform_bin(), path=_path, fs=self, sftp_client=sftp)

    def remove(self, path):  # noqa: D102
        self.check()
        _path = self.validatepath(path)

        # NB: this will raise ResourceNotFound
        # as expected by the specifications
        if self.getinfo(_path).is_dir:
            raise errors.FileExpected(path)

        with get_sftp_client(self, op='remove', path=path) as sftp:
            with self._lock:
                sftp.remove(_path)

    def removedir(self, path):  # noqa: D102
        self.check()
        _path = self.validatepath(path)

        # NB: this will raise ResourceNotFound
        # and DirectoryExpected as expected by
        # the specifications
        if not self.isempty(_path):
            raise errors.DirectoryNotEmpty(path)

        with get_sftp_client(self, op='removedir', path=path) as sftp:
            with self._lock:
                sftp.rmdir(_path)

    def setinfo(self, path, info):  # noqa: D102
        self.check()
        _path = self.validatepath(path)

        if not self.exists(path):
            raise errors.ResourceNotFound(path)

        access = info.get('access', {})
        details = info.get('details', {})

        if 'accessed' in details or 'modified' in details:
            self._utime(_path,
                        details.get("modified"),
                        details.get("accessed"))
        if 'uid' in access or 'gid' in access:
            self._chown(_path,
                        access.get('uid'),
                        access.get('gid'))
        if 'permissions' in access:
            self._chmod(_path, access['permissions'].mode)

    def download(self, path, file, chunk_size=None, callback=None, **options):
        """Copy a file from the filesystem to a file-like object.

        This method uses the underlying `paramiko.SFTP.getfo` method, which
        should be more efficient than manually opening and reading from a
        file.

        Arguments:
            path (str): Path to a resource.
            file (file-like): A file-like object open for writing in
                binary mode.
            chunk_size (int, optional): Ignored, kept for compatibility
                with the `fs.base.FS.download` signature.
            callback (callable, optional): An optional callback function
                (form: ``func(int, int)``) that accepts the bytes transferred
                so far and the total bytes to be transferred. Passed
                transparently to `~paramiko.SFTP.getfo`.

        Note that the file object ``file`` will *not* be closed by this
        method. Take care to close it after this method completes
        (ideally with a context manager).

        Example:
            >>> with open('starwars.mov', 'wb') as write_file:
            ...     my_fs.download('/movies/starwars.mov', write_file)

        """
        _path = self.validatepath(path)
        with self._lock:
            if not self.exists(_path):
                raise errors.ResourceNotFound(path)
            elif self.isdir(_path):
                raise errors.FileExpected(path)
            with get_sftp_client(self, op='download', path=path) as sftp:
                sftp.getfo(_path, file, callback=callback)

    def upload(self, path, file, chunk_size=None, callback=None, file_size=None, confirm=True, **options):
        """Set a file to the contents of a binary file object.

        This method uses the underlying `paramiko.SFTP.putfo` method, which
        should be more efficient than manually opening and writing to a file.

        Arguments:
            path (str): A path on the filesystem.
            file (io.IOBase): A file object open for reading in
                binary mode.
            chunk_size (int, optional): Ignored, kept for compatibility
                with the `fs.base.FS.download` signature.
            callback (callable, optional): An optional callback function
                (form: ``func(int, int)``) that accepts the bytes transferred
                so far and the total bytes to be transferred. Passed
                transparently to `~paramiko.SFTP.put`.
            file_size (int, optional): An optional size parameter, passed
                to ``callback``. If `None` is given, uses ``0``.
            confirm (bool): If `True` (the default), do a ``stat()`` call
                once finished to confirm the size of the uploaded file.

        Raises:
            fs.errors.ResourceNotFound: If a parent directory of
                ``path`` does not exist.

        Note that the file object ``file`` will *not* be closed by this
        method. Take care to close it after this method completes
        (ideally with a context manager).

        Example:
            >>> with open('~/movies/starwars.mov', 'rb') as read_file:
            ...     my_fs.upload('starwars.mov', read_file)

        """
        _path = self.validatepath(path)
        with self._lock:
            if not self.exists(dirname(_path)):
                raise errors.ResourceNotFound(path)
            elif self.isdir(_path):
                raise errors.FileExpected(path)
            with get_sftp_client(self, op='upload', path=path) as sftp:
                sftp.putfo(
                    file,
                    _path,
                    file_size=file_size,
                    callback=callback,
                    confirm=confirm
                )

    @cached_property
    def platform(self):
        """The platform the server is running on.

        Returns:
            str: the platform of the remote server, as in `sys.platform`.
        """
        # On some SFTP servers, a shell is forbidden.
        # The server may forcibly close a network connection if a client attempts to run any commands
        if self.disable_shell:
            return "unknown"

        try:
            uname_sys = self._exec_command("uname -s")
            sysinfo = self._exec_command("sysinfo")
        except paramiko.ssh_exception.SSHException:
            return "unknown"
        if uname_sys is not None:
            if uname_sys == b"FreeBSD":
                return "freebsd"
            elif uname_sys == b"Darwin":
                return "darwin"
            elif uname_sys == b"Linux":
                return "linux"
            elif uname_sys.startswith(b"CYGWIN"):
                return "cygwin"
        elif sysinfo is not None and sysinfo:
            return "win32"
        return "unknown"

    @cached_property
    def locale(self):
        """The locale the server is running on.

        Returns:
            str: the locale of the remote server if detected, or `None`.
        """
        if self.platform in ("linux", "darwin", "freebsd"):
            locale = self._exec_command('echo $LANG')
            if locale is not None:
                return locale.split(b'.')[-1].decode('ascii').lower()
        return None

    def _exec_command(self, cmd):
        """Run a command on the remote SSH server.

        Returns:
            bytes: the output of the command, if it didn't fail
            None: if the error pipe of the command was not empty
        """
        _, out, err = self._client.exec_command(cmd, timeout=self._exec_timeout)
        return out.read().strip() if not err.read().strip() else None

    def _make_raw_info(self, name, stat_result, namespaces):
        """Create an `Info` object from a stat result.
        """
        info = {
            'basic': {
                'name': name,
                'is_dir': stat.S_ISDIR(stat_result.st_mode)
            }
        }
        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 'access' in namespaces:
            info['access'] = self._make_access_from_stat(stat_result)
        return info

    def _make_details_from_stat(self, stat_result):
        """Make a *details* dictionnary from a stat result.
        """
        details = {
            '_write': ['accessed', 'modified'],
            'accessed': stat_result.st_atime,
            'modified': stat_result.st_mtime,
            'size': stat_result.st_size,
            'type': int(OSFS._get_type_from_stat(stat_result)),
        }

        details['created'] = getattr(stat_result, 'st_birthtime', None)
        ctime_key = 'created' if self.platform == "win32" else 'metadata_changed'
        details[ctime_key] = getattr(stat_result, 'st_ctime', None)
        return details

    def _make_access_from_stat(self, stat_result):
        """Make an *access* dictionnary from a stat result.
        """
        access = {}
        access['permissions'] = Permissions(mode=stat_result.st_mode).dump()
        access['gid'] = stat_result.st_gid
        access['uid'] = stat_result.st_uid

        if self.platform in ("linux", "darwin", "freebsd"):
            def entry_name(db, _id):
                entry = self._exec_command('getent {} {}'.format(db, _id))
                name = next(iter(entry.split(b':')))
                return name.decode(self.locale or 'utf-8')
            access['group'] = entry_name('group', access['gid'])
            access['user'] = entry_name('passwd', access['uid'])

        return access

    def _chmod(self, path, mode):
        """Change the *mode* of a resource.
        """
        with get_sftp_client(self, op='setinfo', path=path) as sftp:
            sftp.chmod(path, mode)

    def _chown(self, path, uid, gid):
        """Change the *owner* of a resource.
        """
        with get_sftp_client(self, op='setinfo', path=path) as sftp:
            if uid is None or gid is None:
                info = self.getinfo(path, namespaces=('access',))
                uid = uid or info.get('access', 'uid')
                gid = gid or info.get('access', 'gid')
            sftp.chown(path, uid, gid)

    def _utime(self, path, modified, accessed):
        """Set the *accessed* and *modified* times of a resource.
        """
        with get_sftp_client(self, op='setinfo', path=path) as sftp:
            if accessed is not None or modified is not None:
                accessed = float(modified if accessed is None else accessed)
                modified = float(accessed if modified is None else modified)
                sftp.utime(path, (accessed, modified))
            else:
                sftp.utime(path, None)
