import errno
import logging
from pathlib import PurePosixPath
import requests
from urllib.parse import urljoin
from types import TracebackType
from typing import Any, cast, Dict, Generator, Iterable, Optional
from xml.etree.ElementTree import Element

import defusedxml.ElementTree as ET     # type: ignore

from cerulean.credential import Credential, PasswordCredential
from cerulean.file_system import FileSystem, UnsupportedOperationError
from cerulean.file_system_impl import FileSystemImpl
from cerulean.path import AbstractPath, EntryType, Path, Permission
from cerulean.util import BaseExceptionType


logger = logging.getLogger(__name__)


class WebdavFileSystem(FileSystemImpl):
    """A FileSystem implementation that connects to a WebDAV server.

    WebdavFileSystem supports the / operation:

    .. code-block:: python

      fs / 'path'

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

    It is also a context manager, so that you can (and should!) use it \
    with a ``with`` statement, which will ensure that the connection \
    is closed when you are done with the it. Alternatively, you can \
    call :meth:`close` to close the connection.

    The WebDAV protocol does not support all operations specified by \
    the Cerulean API. In particular, symbolic links are not supported, \
    nor are ownership and permissions. Read-access to these properties \
    is emulated, e.g. `is_symlink()` simply always returns false, all \
    files and directories are owned by uid 0 and gid 0, with access \
    permissions determined by whether the server will let us access \
    them.

    By default, if you try to run any of the related modifying methods, \
    e.g. `symlink_to()` or `set_permissions()`, an \
    :class:`UnsupportedOperationError` will be raised. If you set \
    `unsupported_methods_raise` to `False` when creating a \
    WebdavFileSystem, then these methods will simply return without \
    doing anything.

    WebdavFileSystem supports both HTTP and HTTPS, but not (yet) \
    client-side certificates.

    Args:
        url: The server base location, e.g. http://example.com/webdav
        credential: The credential to use to connect.
        host_ca_cert_file: Path to a certificate file to use for \
                authentication. Useful for servers that use a self-signed \
                certificate.
        unsupported_methods_raise: Raise on using an unsupported \
                method, see above.
    """
    def __init__(self, url: str,
                 credential: Optional[Credential] = None,
                 host_ca_cert_file: Optional[str] = None,
                 unsupported_methods_raise: Optional[bool] = True) -> None:
        self.__base_url = url.rstrip('/')
        self.__credential = credential
        self.__host_ca_cert_file = host_ca_cert_file
        self.__unsupported_methods_raise = unsupported_methods_raise
        self.__ensure_http(True)
        self.__max_tries = 3

    def __enter__(self) -> 'WebdavFileSystem':
        """Enter context manager."""
        return self

    def __exit__(self, exc_type: Optional[BaseExceptionType],
                 exc_value: Optional[BaseException],
                 traceback: Optional[TracebackType]) -> None:
        """Exit context manager."""
        self.close()

    def close(self) -> None:
        self.__session.close()
        logger.info('Disconnected from WebDAV server')

    def __eq__(self, other: Any) -> bool:
        """Returns True iff this file system equals other."""
        if not isinstance(other, FileSystem):
            return NotImplemented
        if isinstance(other, WebdavFileSystem):
            return self.__base_url == other.__base_url
        else:
            return False

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

    def __truediv__(self, segment: str) -> Path:
        return Path(self, PurePosixPath('/' + segment.strip('/')))

    def _supports(self, feature: str) -> bool:
        if feature not in self._features:
            raise ValueError('Invalid argument for "feature"')
        # supports symlinks nor permissions nor devices
        return False

    def _exists(self, path: AbstractPath) -> bool:
        self.__ensure_http()
        response = self.__session.head(self.__url(path))
        return response.status_code == 200

    def _mkdir(
            self, path: AbstractPath, mode: Optional[int] = None,
            parents: bool = False, exists_ok: bool = False) -> None:

        def handle_mkcol_error(response: requests.Response) -> None:
            if response.status_code == 201:
                pass
            elif response.status_code == 403:
                raise PermissionError(('Permission denied while accessing {}'
                                       ).format(self.__url(path)))
            elif response.status_code == 405:
                raise FileExistsError(('File or directory {} already exists'
                                       ).format(self.__url(path)))
            elif response.status_code == 409:
                raise FileNotFoundError(('One or more parent directories of'
                                         ' {} do not exist').format(
                                             self.__url(path)))
            elif response.status_code == 507:
                raise IOError(('Out of storage space while making dir {}'
                               ).format(self.__url(path)))
            else:
                raise RuntimeError(('An error occurred while making'
                                    ' dir {}, the server said {}').format(
                                        self.__url(path), response.reason))

        if mode is not None and self.__unsupported_methods_raise:
            raise UnsupportedOperationError('Tried to make a directory with a'
                                            ' permission mask, but WebDAV does'
                                            ' not support permissions.')
        self.__ensure_http()
        lpath = cast(PurePosixPath, path)
        if parents:
            for parent in reversed(lpath.parents):
                if not self._exists(parent):
                    response = self.__session.request('MKCOL',
                                                      self.__url(parent))
                    handle_mkcol_error(response)
        if self._exists(lpath):
            if not exists_ok:
                raise FileExistsError(
                    'File {} exists and exists_ok was False'.format(lpath))
            else:
                return

        response = self.__session.request('MKCOL', self.__url(lpath))
        handle_mkcol_error(response)

    def _iterdir(self, path: AbstractPath) -> Generator[PurePosixPath, None, None]:
        self.__ensure_http()
        url = self.__url(path)
        collection = self.__propfind(url, None, 1)
        for child_url in collection:
            child_abs_url = urljoin(self.__base_url, child_url)
            if child_abs_url != url:
                if not child_abs_url.startswith(self.__base_url):
                    raise RuntimeError(('Something went wrong processing a'
                                        ' URL returned by the WebDAV server'
                                        ' in iterdir(). The URL is {} and'
                                        ' the base URL is {}.').format(
                                            child_url, self.__base_url))
                rel_child_path = child_abs_url[len(self.__base_url):]
                rel_child = PurePosixPath(rel_child_path)
                if self._exists(rel_child):
                    yield rel_child

    def _rmdir(self, path: AbstractPath, recursive: bool = False) -> None:
        self.__ensure_http()
        url = self.__url(path)
        if not self._exists(path):
            return

        if not self._is_dir(path):
            raise RuntimeError("Path must refer to a directory")

        props = self.__propfind(url, None, 1)
        if not recursive and len(props) > 1:
            raise OSError(errno.ENOTEMPTY, 'Directory not empty', url)

        self.__session.delete(url)

    def _touch(self, path: AbstractPath) -> None:
        self.__ensure_http()
        url = self.__url(path)
        if not self._exists(path):
            response = self.__session.put(url, bytes())
            if response.status_code < 400 or response.status_code == 409:
                # we ignore 409 Conflict, since that means that a resource
                # exists at this point, which is what we're trying to achieve
                pass
            else:
                raise RuntimeError(('Error trying to create file {}: {}'
                                   ).format(url, response.reason))

    def _streaming_read(self, path: AbstractPath) -> Generator[bytes, None, None]:
        self.__ensure_http()
        url = self.__url(path)

        with self.__session.get(url, stream=True) as response:
            yield from response.iter_content(24576)

    def _streaming_write(self, path: AbstractPath, data: Iterable[bytes]) -> None:
        def data_generator(data: Iterable[bytes]) -> Generator[bytes, None, None]:
            for chunk in data:
                yield chunk

        self.__ensure_http()
        url = self.__url(path)
        self.__session.put(url, data=data_generator(data))    # type: ignore

    def _rename(self, path: AbstractPath, target: AbstractPath) -> None:
        self.__ensure_http()
        url = self.__url(path)
        target_url = self.__url(target)
        headers = {
                'Destination': target_url,
                'Overwrite': 'T'
                }
        response = self.__session.request('MOVE', url, headers=headers)
        if response.status_code in [201, 204]:
            pass
        else:
            raise RuntimeError(('An error occurred while moving'
                                ' {}, the server said {}').format(
                                    url, response.reason))

    def _unlink(self, path: AbstractPath) -> None:
        self.__ensure_http()
        if not self._is_file(path):
            raise IsADirectoryError(errno.EISDIR, 'Is a directory', str(path))

        url = self.__url(path)
        response = self.__session.delete(url)
        if response.status_code != 204:
            raise RuntimeError(('An error occurred when deleting'
                                ' file {}, the server said {}').format(
                                    self.__url(path), response.reason))

    def _is_dir(self, path: AbstractPath) -> bool:
        self.__ensure_http()
        if not self._exists(path):
            return False

        url = self.__url(path)
        props = self.__propfind(url, '{DAV:}resourcetype')
        if props[url] is None:
            return False
        return props[url].find('{DAV:}collection') is not None

    def _is_file(self, path: AbstractPath) -> bool:
        self.__ensure_http()
        if not self._exists(path):
            return False

        return not self._is_dir(path)

    def _is_symlink(self, path: AbstractPath) -> bool:
        return False

    def _entry_type(self, path: AbstractPath) -> EntryType:
        if not self._exists(path):
            raise OSError(errno.ENOENT, 'No such file or directory', path)
        if self._is_dir(path):
            return EntryType.DIRECTORY
        else:
            return EntryType.FILE

    def _size(self, path: AbstractPath) -> int:
        self.__ensure_http()
        url = self.__url(path)
        props = self.__propfind(url, '{DAV:}getcontentlength')
        return int(''.join(props[url].itertext()))

    def _uid(self, path: AbstractPath) -> int:
        return 0

    def _gid(self, path: AbstractPath) -> int:
        return 0

    def _has_permission(self, path: AbstractPath, permission: Permission
                        ) -> bool:
        permissions = [Permission.OWNER_READ, Permission.OWNER_WRITE]
        if self._is_dir(path):
            permissions.append(Permission.OWNER_EXECUTE)
        return permission in permissions

    def _set_permission(self,
                       path: AbstractPath,
                       permission: Permission,
                       value: bool = True) -> None:
        if self.__unsupported_methods_raise:
            raise UnsupportedOperationError(
                    'WebDAV does not support Posix permissions')

    def _chmod(self, path: AbstractPath, mode: int) -> None:
        if self.__unsupported_methods_raise:
            raise UnsupportedOperationError(
                'WebDAV does not support Posix permissions')

    def _symlink_to(self, path: AbstractPath, target: AbstractPath) -> None:
        if self.__unsupported_methods_raise:
            raise UnsupportedOperationError(
                    'WebDAV does not support symbolic links')

    def _readlink(self, path: AbstractPath, recursive: bool) -> Path:
        url = self.__url(path)
        raise OSError(errno.EINVAL, 'Invalid argument', url)

    def __ensure_http(self, first: bool = False) -> None:
        # Note: first can be removed, session will do all this stuff
        # automatically if the connection was closed and we make another
        # request on the session.
        if first:
            logger.info('Connecting to WebDAV server')
            self.__session = requests.Session()
            if self.__credential is not None:
                if isinstance(self.__credential, PasswordCredential):
                    self.__session.auth = (
                            self.__credential.username,
                            self.__credential.password)
                else:
                    raise ValueError('Only a PasswordCredential can be used'
                                     ' with WebdavFileSystem.')
            if self.__host_ca_cert_file is not None:
                if isinstance(self.__host_ca_cert_file, str):
                    self.__session.verify = self.__host_ca_cert_file
            response = self.__session.head(self.__base_url)
            if response.status_code != 200:
                raise RuntimeError('Error connecting to WebDAV server at {}:'
                                   ' {}'.format(self.__base_url,
                                       response.reason))
            logger.info('Connected to WebDAV server')

    def __url(self, path: AbstractPath) -> str:
        return self.__base_url + str(path)

    def __propfind(self, url: str, req_prop: Optional[str], depth: int = 0
                   ) -> Dict[str, Element]:
        """Runs a PROPFIND command for a given resource.

        Actually, just requests everything and searches the result.

        Args:
            url: The url to query.
            req_prop: The property to request.
            depth: Depth to recurse to, either 0 or 1.

        Returns:
            A dictionary mapping urls to XML subtrees.

        """
        headers = {'Depth': str(depth)}
        response = self.__session.request('PROPFIND', url, headers=headers)
        if response.status_code < 400:
            print(response.text)
            xml_props = ET.fromstring(response.text)
            results = dict()
            for resp in xml_props.iter('{DAV:}response'):
                prop_url = resp.findtext('{DAV:}href')
                prop_url = urljoin(self.__base_url, prop_url)
                propstat = resp.find('{DAV:}propstat')
                prop = propstat.find('{DAV:}prop')
                if req_prop is not None:
                    results[prop_url] = prop.find(req_prop)
                else:
                    results[prop_url] = None
            return results
        if response.status_code == 401:
            raise PermissionError('Invalid credentials supplied')
        if response.status_code == 403:
            raise PermissionError(('Permission denied while accessing {}'
                                  ).format(url))

        raise RuntimeError(
                ('An error occurred while checking dir {}, the server said {}'
                    ).format(url, response.reason))
