__all__ = [
    'Dropbox',
    'DropboxTeam',
    'create_session',
]

# This should always be 0.0.0 in master. Only update this after tagging
# before release.
__version__ = '0.0.0'

import contextlib
import json
import logging
import random
import time

import requests
import six

from . import files, stone_serializers
from .auth import (
    AuthError_validator,
    RateLimitError_validator,
)
from .base import DropboxBase
from .base_team import DropboxTeamBase
from .exceptions import (
    ApiError,
    AuthError,
    BadInputError,
    HttpError,
    InternalServerError,
    RateLimitError,
)
from .session import (
    API_HOST,
    API_CONTENT_HOST,
    API_NOTIFICATION_HOST,
    HOST_API,
    HOST_CONTENT,
    HOST_NOTIFY,
    pinned_session,
)

class RouteResult(object):
    """The successful result of a call to a route."""

    def __init__(self, obj_result, http_resp=None):
        """
        :param str obj_result: The result of a route not including the binary
            payload portion, if one exists. Must be serialized JSON.
        :param requests.models.Response http_resp: A raw HTTP response. It will
            be used to stream the binary-body payload of the response.
        """
        assert isinstance(obj_result, six.string_types), \
            'obj_result: expected string, got %r' % type(obj_result)
        if http_resp is not None:
            assert isinstance(http_resp, requests.models.Response), \
                'http_resp: expected requests.models.Response, got %r' % \
                type(http_resp)
        self.obj_result = obj_result
        self.http_resp = http_resp

class RouteErrorResult(object):
    """The error result of a call to a route."""

    def __init__(self, request_id, obj_result):
        """
        :param str request_id: A request_id can be shared with Dropbox Support
            to pinpoint the exact request that returns an error.
        :param str obj_result: The result of a route not including the binary
            payload portion, if one exists.
        """
        self.request_id = request_id
        self.obj_result = obj_result

def create_session(max_connections=8, proxies=None):
    """
    Creates a session object that can be used by multiple :class:`Dropbox` and
    :class:`DropboxTeam` instances. This lets you share a connection pool
    amongst them, as well as proxy parameters.

    :param int max_connections: Maximum connection pool size.
    :param dict proxies: See the `requests module
            <http://docs.python-requests.org/en/latest/user/advanced/#proxies>`_
            for more details.
    :rtype: :class:`requests.sessions.Session`. `See the requests module
        <http://docs.python-requests.org/en/latest/user/advanced/#session-objects>`_
        for more details.
    """
    # We only need as many pool_connections as we have unique hostnames.
    session = pinned_session(pool_maxsize=max_connections)
    if proxies:
        session.proxies = proxies
    return session

class _DropboxTransport(object):
    """
    Responsible for implementing the wire protocol for making requests to the
    Dropbox API.
    """

    _API_VERSION = '2'

    # Download style means that the route argument goes in a Dropbox-API-Arg
    # header, and the result comes back in a Dropbox-API-Result header. The
    # HTTP response body contains a binary payload.
    _ROUTE_STYLE_DOWNLOAD = 'download'

    # Upload style means that the route argument goes in a Dropbox-API-Arg
    # header. The HTTP request body contains a binary payload. The result
    # comes back in a Dropbox-API-Result header.
    _ROUTE_STYLE_UPLOAD = 'upload'

    # RPC style means that the argument and result of a route are contained in
    # the HTTP body.
    _ROUTE_STYLE_RPC = 'rpc'

    # This is the default longest time we'll block on receiving data from the server
    _DEFAULT_TIMEOUT = 30

    def __init__(self,
                 oauth2_access_token,
                 max_retries_on_error=4,
                 max_retries_on_rate_limit=None,
                 user_agent=None,
                 session=None,
                 headers=None,
                 timeout=_DEFAULT_TIMEOUT):
        """
        :param str oauth2_access_token: OAuth2 access token for making client
            requests.

        :param int max_retries_on_error: On 5xx errors, the number of times to
            retry.
        :param Optional[int] max_retries_on_rate_limit: On 429 errors, the
            number of times to retry. If `None`, always retries.
        :param str user_agent: The user agent to use when making requests. This
            helps us identify requests coming from your application. We
            recommend you use the format "AppName/Version". If set, we append
            "/OfficialDropboxPythonSDKv2/__version__" to the user_agent,
        :param session: If not provided, a new session (connection pool) is
            created. To share a session across multiple clients, use
            :func:`create_session`.
        :type session: :class:`requests.sessions.Session`
        :param dict headers: Additional headers to add to requests.
        :param Optional[float] timeout: Maximum duration in seconds that
            client will wait for any single packet from the
            server. After the timeout the client will give up on
            connection. If `None`, client will wait forever. Defaults
            to 30 seconds.
        """
        assert len(oauth2_access_token) > 0, \
            'OAuth2 access token cannot be empty.'
        assert headers is None or isinstance(headers, dict), \
            'Expected dict, got %r' % headers
        self._oauth2_access_token = oauth2_access_token

        self._max_retries_on_error = max_retries_on_error
        self._max_retries_on_rate_limit = max_retries_on_rate_limit
        if session:
            assert isinstance(session, requests.sessions.Session), \
                'Expected requests.sessions.Session, got %r' % session
            self._session = session
        else:
            self._session = create_session()
        self._headers = headers

        base_user_agent = 'OfficialDropboxPythonSDKv2/' + __version__
        if user_agent:
            self._raw_user_agent = user_agent
            self._user_agent = '{}/{}'.format(user_agent, base_user_agent)
        else:
            self._raw_user_agent = None
            self._user_agent = base_user_agent

        self._logger = logging.getLogger('dropbox')

        self._host_map = {HOST_API: API_HOST,
                          HOST_CONTENT: API_CONTENT_HOST,
                          HOST_NOTIFY: API_NOTIFICATION_HOST}

        self._timeout = timeout

    def request(self,
                route,
                namespace,
                request_arg,
                request_binary,
                timeout=None):
        """
        Makes a request to the Dropbox API and in the process validates that
        the route argument and result are the expected data types. The
        request_arg is converted to JSON based on the arg_data_type. Likewise,
        the response is deserialized from JSON and converted to an object based
        on the {result,error}_data_type.

        :param host: The Dropbox API host to connect to.
        :param route: The route to make the request to.
        :type route: :class:`.datatypes.stone_base.Route`
        :param request_arg: Argument for the route that conforms to the
            validator specified by route.arg_type.
        :param request_binary: String or file pointer representing the binary
            payload. Use None if there is no binary payload.
        :param Optional[float] timeout: Maximum duration in seconds
            that client will wait for any single packet from the
            server. After the timeout the client will give up on
            connection. If `None`, will use default timeout set on
            Dropbox object.  Defaults to `None`.
        :return: The route's result.
        """
        host = route.attrs['host'] or 'api'
        route_name = namespace + '/' + route.name
        route_style = route.attrs['style'] or 'rpc'
        serialized_arg = stone_serializers.json_encode(route.arg_type,
                                                       request_arg)

        if (timeout is None and
                route == files.list_folder_longpoll):
            # The client normally sends a timeout value to the
            # longpoll route. The server will respond after
            # <timeout> + random(0, 90) seconds. We increase the
            # socket timeout to the longpoll timeout value plus 90
            # seconds so that we don't cut the server response short
            # due to a shorter socket timeout.
            # NB: This is done here because base.py is auto-generated
            timeout = request_arg.timeout + 90

        res = self.request_json_string_with_retry(host,
                                                  route_name,
                                                  route_style,
                                                  serialized_arg,
                                                  request_binary,
                                                  timeout=timeout)
        decoded_obj_result = json.loads(res.obj_result)
        if isinstance(res, RouteResult):
            returned_data_type = route.result_type
            obj = decoded_obj_result
        elif isinstance(res, RouteErrorResult):
            returned_data_type = route.error_type
            obj = decoded_obj_result['error']
            user_message = decoded_obj_result.get('user_message')
            user_message_text = user_message and user_message.get('text')
            user_message_locale = user_message and user_message.get('locale')
        else:
            raise AssertionError('Expected RouteResult or RouteErrorResult, '
                                 'but res is %s' % type(res))

        deserialized_result = stone_serializers.json_compat_obj_decode(
            returned_data_type, obj, strict=False)

        if isinstance(res, RouteErrorResult):
            raise ApiError(res.request_id,
                           deserialized_result,
                           user_message_text,
                           user_message_locale)
        elif route_style == self._ROUTE_STYLE_DOWNLOAD:
            return (deserialized_result, res.http_resp)
        else:
            return deserialized_result

    def request_json_object(self,
                            host,
                            route_name,
                            route_style,
                            request_arg,
                            request_binary,
                            timeout=None):
        """
        Makes a request to the Dropbox API, taking a JSON-serializable Python
        object as an argument, and returning one as a response.

        :param host: The Dropbox API host to connect to.
        :param route_name: The name of the route to invoke.
        :param route_style: The style of the route.
        :param str request_arg: A JSON-serializable Python object representing
            the argument for the route.
        :param Optional[bytes] request_binary: Bytes representing the binary
            payload. Use None if there is no binary payload.
        :param Optional[float] timeout: Maximum duration in seconds
            that client will wait for any single packet from the
            server. After the timeout the client will give up on
            connection. If `None`, will use default timeout set on
            Dropbox object.  Defaults to `None`.
        :return: The route's result as a JSON-serializable Python object.
        """
        serialized_arg = json.dumps(request_arg)
        res = self.request_json_string_with_retry(host,
                                                  route_name,
                                                  route_style,
                                                  serialized_arg,
                                                  request_binary,
                                                  timeout=timeout)
        # This can throw a ValueError if the result is not deserializable,
        # but that would be completely unexpected.
        deserialized_result = json.loads(res.obj_result)
        if isinstance(res, RouteResult) and res.http_resp is not None:
            return (deserialized_result, res.http_resp)
        else:
            return deserialized_result

    def request_json_string_with_retry(self,
                                       host,
                                       route_name,
                                       route_style,
                                       request_json_arg,
                                       request_binary,
                                       timeout=None):
        """
        See :meth:`request_json_object` for description of parameters.

        :param request_json_arg: A string representing the serialized JSON
            argument to the route.
        """
        attempt = 0
        rate_limit_errors = 0
        while True:
            self._logger.info('Request to %s', route_name)
            try:
                return self.request_json_string(host,
                                                route_name,
                                                route_style,
                                                request_json_arg,
                                                request_binary,
                                                timeout=timeout)
            except InternalServerError as e:
                attempt += 1
                if attempt <= self._max_retries_on_error:
                    # Use exponential backoff
                    backoff = 2**attempt * random.random()
                    self._logger.info(
                        'HttpError status_code=%s: Retrying in %.1f seconds',
                        e.status_code, backoff)
                    time.sleep(backoff)
                else:
                    raise
            except RateLimitError as e:
                rate_limit_errors += 1
                if (self._max_retries_on_rate_limit is None or
                        self._max_retries_on_rate_limit >= rate_limit_errors):
                    # Set default backoff to 5 seconds.
                    backoff = e.backoff if e.backoff is not None else 5.0
                    self._logger.info(
                        'Ratelimit: Retrying in %.1f seconds.', backoff)
                    time.sleep(backoff)
                else:
                    raise

    def request_json_string(self,
                            host,
                            func_name,
                            route_style,
                            request_json_arg,
                            request_binary,
                            timeout=None):
        """
        See :meth:`request_json_string_with_retry` for description of
        parameters.
        """
        if host not in self._host_map:
            raise ValueError('Unknown value for host: %r' % host)

        if not isinstance(request_binary, (six.binary_type, type(None))):
            # Disallow streams and file-like objects even though the underlying
            # requests library supports them. This is to prevent incorrect
            # behavior when a non-rewindable stream is read from, but the
            # request fails and needs to be re-tried at a later time.
            raise TypeError('expected request_binary as binary type, got %s' %
                            type(request_binary))

        # Fully qualified hostname
        fq_hostname = self._host_map[host]
        url = self._get_route_url(fq_hostname, func_name)

        headers = {'User-Agent': self._user_agent}
        if host != HOST_NOTIFY:
            headers['Authorization'] = 'Bearer %s' % self._oauth2_access_token
            if self._headers:
                headers.update(self._headers)

        # The contents of the body of the HTTP request
        body = None
        # Whether the response should be streamed incrementally, or buffered
        # entirely. If stream is True, the caller is responsible for closing
        # the HTTP response.
        stream = False

        if route_style == self._ROUTE_STYLE_RPC:
            headers['Content-Type'] = 'application/json'
            body = request_json_arg
        elif route_style == self._ROUTE_STYLE_DOWNLOAD:
            headers['Dropbox-API-Arg'] = request_json_arg
            stream = True
        elif route_style == self._ROUTE_STYLE_UPLOAD:
            headers['Content-Type'] = 'application/octet-stream'
            headers['Dropbox-API-Arg'] = request_json_arg
            body = request_binary
        else:
            raise ValueError('Unknown operation style: %r' % route_style)

        if timeout is None:
            timeout = self._timeout

        r = self._session.post(url,
                               headers=headers,
                               data=body,
                               stream=stream,
                               verify=True,
                               timeout=timeout,
                               )

        request_id = r.headers.get('x-dropbox-request-id')
        if r.status_code >= 500:
            raise InternalServerError(request_id, r.status_code, r.text)
        elif r.status_code == 400:
            raise BadInputError(request_id, r.text)
        elif r.status_code == 401:
            assert r.headers.get('content-type') == 'application/json', (
                'Expected content-type to be application/json, got %r' %
                r.headers.get('content-type'))
            err = stone_serializers.json_compat_obj_decode(
                AuthError_validator, r.json()['error'])
            raise AuthError(request_id, err)
        elif r.status_code == 429:
            err = None
            if r.headers.get('content-type') == 'application/json':
                err = stone_serializers.json_compat_obj_decode(
                    RateLimitError_validator, r.json()['error'])
                retry_after = err.retry_after
            else:
                retry_after_str = r.headers.get('retry-after')
                if retry_after_str is not None:
                    retry_after = int(retry_after_str)
                else:
                    retry_after = None
            raise RateLimitError(request_id, err, retry_after)
        elif 200 <= r.status_code <= 299:
            if route_style == self._ROUTE_STYLE_DOWNLOAD:
                raw_resp = r.headers['dropbox-api-result']
            else:
                assert r.headers.get('content-type') == 'application/json', (
                    'Expected content-type to be application/json, got %r' %
                    r.headers.get('content-type'))
                raw_resp = r.content.decode('utf-8')
            if route_style == self._ROUTE_STYLE_DOWNLOAD:
                return RouteResult(raw_resp, r)
            else:
                return RouteResult(raw_resp)
        elif r.status_code in (403, 404, 409):
            raw_resp = r.content.decode('utf-8')
            return RouteErrorResult(request_id, raw_resp)
        else:
            raise HttpError(request_id, r.status_code, r.text)

    def _get_route_url(self, hostname, route_name):
        """Returns the URL of the route.

        :param str hostname: Hostname to make the request to.
        :param str route_name: Name of the route.
        :rtype: str
        """
        return 'https://{hostname}/{version}/{route_name}'.format(
            hostname=hostname,
            version=Dropbox._API_VERSION,
            route_name=route_name,
        )

    def _save_body_to_file(self, download_path, http_resp, chunksize=2**16):
        """
        Saves the body of an HTTP response to a file.

        :param str download_path: Local path to save data to.
        :param http_resp: The HTTP response whose body will be saved.
        :type http_resp: :class:`requests.models.Response`
        :rtype: None
        """
        with open(download_path, 'wb') as f:
            with contextlib.closing(http_resp):
                for c in http_resp.iter_content(chunksize):
                    f.write(c)

class Dropbox(_DropboxTransport, DropboxBase):
    """
    Use this class to make requests to the Dropbox API using a user's access
    token. Methods of this class are meant to act on the corresponding user's
    Dropbox.
    """
    pass

class DropboxTeam(_DropboxTransport, DropboxTeamBase):
    """
    Use this class to make requests to the Dropbox API using a team's access
    token. Methods of this class are meant to act on the team, but there is
    also an :meth:`as_user` method for assuming a team member's identity.
    """

    def as_user(self, team_member_id):
        """
        Allows a team credential to assume the identity of a member of the
        team.

        :return: A :class:`Dropbox` object that can be used to query on behalf
            of this member of the team.
        :rtype: Dropbox
        """
        new_headers = self._headers.copy() if self._headers else {}
        new_headers['Dropbox-API-Select-User'] = team_member_id
        return Dropbox(
            self._oauth2_access_token,
            max_retries_on_error=self._max_retries_on_error,
            max_retries_on_rate_limit=self._max_retries_on_rate_limit,
            user_agent=self._raw_user_agent,
            session=self._session,
            headers=new_headers,
        )
