"""
minfraud.webservice
~~~~~~~~~~~~~~~~~~~

This module contains the webservice client class.

"""

import requests
from requests.utils import default_user_agent
from voluptuous import MultipleInvalid

from .version import __version__
from .errors import (
    MinFraudError,
    HTTPError,
    AuthenticationError,
    InsufficientFundsError,
    InvalidRequestError,
    PermissionRequiredError,
)
from .models import Factors, Insights, Score
from .validation import validate_report, validate_transaction


class Client(object):
    """Client for accessing the minFraud Score and Insights web services."""

    def __init__(
        self,
        account_id=None,
        license_key=None,
        host="minfraud.maxmind.com",
        locales=("en",),
        timeout=None,
        # This is deprecated and not documented for that reason.
        # It can be removed if we do a major release in the future.
        user_id=None,
    ):
        """Constructor for Client.

        :param account_id: Your MaxMind account ID
        :type account_id: int
        :param license_key: Your MaxMind license key
        :type license_key: str
        :param host: The host to use when connecting to the web service.
        :type host: str
        :param locales: A tuple of locale codes to use in name property
        :type locales: tuple[str]
        :param timeout: The timeout to use for the request.
        :type timeout: float
        :return: Client object
        :rtype: Client
        """
        # pylint: disable=too-many-arguments
        if account_id is None:
            account_id = user_id

        if account_id is None:
            raise TypeError("The account_id is a required parameter")
        if license_key is None:
            raise TypeError("The license_key is a required parameter")

        # pylint: disable=too-many-arguments
        self._locales = locales
        # requests 2.12.2 requires that the username passed to auth be a
        # string
        self._account_id = (
            account_id if isinstance(account_id, bytes) else str(account_id)
        )
        self._license_key = license_key
        self._base_uri = u"https://{0:s}/minfraud/v2.0".format(host)
        self._timeout = timeout

    def factors(self, transaction, validate=True):
        """Query Factors endpoint with transaction data.

        :param transaction: A dictionary containing the transaction to be
          sent to the minFraud Insights web service as specified in the `REST
          API documentation
          <https://dev.maxmind.com/minfraud/#Request_Body>`_.
        :type transaction: dict
        :param validate: If set to false, validation of the transaction
          dictionary will be disabled. This validation helps ensure that your
          request is correct before sending it to MaxMind. Validation raises an
          InvalidRequestError.
        :type validate: bool
        :return: A Factors model object
        :rtype: Factors
        :raises: AuthenticationError, InsufficientFundsError,
          InvalidRequestError, HTTPError, MinFraudError,
        """
        return self._response_for("factors", Factors, transaction, validate)

    def insights(self, transaction, validate=True):
        """Query Insights endpoint with transaction data.

        :param transaction: A dictionary containing the transaction to be
          sent to the minFraud Insights web service as specified in the `REST
          API documentation
          <https://dev.maxmind.com/minfraud/#Request_Body>`_.
        :type transaction: dict
        :param validate: If set to false, validation of the transaction
          dictionary will be disabled. This validation helps ensure that your
          request is correct before sending it to MaxMind. Validation raises an
          InvalidRequestError.
        :type validate: bool
        :return: An Insights model object
        :rtype: Insights
        :raises: AuthenticationError, InsufficientFundsError,
          InvalidRequestError, HTTPError, MinFraudError,
        """
        return self._response_for("insights", Insights, transaction, validate)

    def score(self, transaction, validate=True):
        """Query Score endpoint with transaction data.

        :param transaction: A dictionary containing the transaction to be
          sent to the minFraud Score web service as specified in the `REST API
          documentation
          <https://dev.maxmind.com/minfraud/#Request_Body>`_.
        :type transaction: dict
        :param validate: If set to false, validation of the transaction
          dictionary will be disabled. This validation helps ensure that your
          request is correct before sending it to MaxMind. Validation raises an
          InvalidRequestError.
        :type validate: bool
        :return: A Score model object
        :rtype: Score
        :raises: AuthenticationError, InsufficientFundsError,
          InvalidRequestError, HTTPError, MinFraudError,
        """
        return self._response_for("score", Score, transaction, validate)

    def _response_for(self, path, model_class, request, validate):
        """Send request and create response object."""
        cleaned_request = self._copy_and_clean(request)
        if validate:
            try:
                validate_transaction(cleaned_request)
            except MultipleInvalid as ex:
                raise InvalidRequestError("Invalid transaction data: {0}".format(ex))
        uri = "/".join([self._base_uri, path])
        response = self._do_request(uri, cleaned_request)
        if response.status_code != 200:
            raise self._exception_for_error(response, uri)
        return self._handle_success(response, uri, model_class)

    def report(self, report, validate=True):
        """Send a transaction report to the Report Transaction endpoint.

        :param report: A dictionary containing the transaction report to be sent
          to the Report Transations web service as specified in the `REST API`
          documentation
          <https://dev.maxmind.com/minfraud/report-transaction/#Request_Body>_.
        :type report: dict
        :param validate: If set to false, validation of the report dictionary
          will be disabled. This validation helps ensure that your request is
          correct before sending it to MaxMind. Validation raises an
          InvalidRequestError.
        :type validate: bool
        :return: Nothing
        :rtype: None
        :raises: AuthenticationError, InvalidRequestError, HTTPError,
          MinFraudError,
        """

        cleaned_request = self._copy_and_clean(report)
        if validate:
            try:
                validate_report(cleaned_request)
            except MultipleInvalid as ex:
                raise InvalidRequestError("Invalid report data: {0}".format(ex))
        uri = "/".join([self._base_uri, "transactions", "report"])
        response = self._do_request(uri, cleaned_request)
        if response.status_code != 204:
            raise self._exception_for_error(response, uri)

    def _do_request(self, uri, data):
        return requests.post(
            uri,
            json=data,
            auth=(self._account_id, self._license_key),
            headers={"Accept": "application/json", "User-Agent": self._user_agent()},
            timeout=self._timeout,
        )

    def _copy_and_clean(self, data):
        """Create a copy of the data structure with Nones removed."""
        if isinstance(data, dict):
            return dict(
                (k, self._copy_and_clean(v)) for (k, v) in data.items() if v is not None
            )
        if isinstance(data, (list, set, tuple)):
            return [self._copy_and_clean(x) for x in data if x is not None]
        return data

    def _user_agent(self):
        """Create User-Agent header."""
        return "minFraud-API/%s %s" % (__version__, default_user_agent())

    def _handle_success(self, response, uri, model_class):
        """Handle successful response."""
        try:
            body = response.json()
        except ValueError:
            raise MinFraudError(
                "Received a 200 response"
                " but could not decode the response as "
                "JSON: {0}".format(response.content),
                200,
                uri,
            )
        if "ip_address" in body:
            body["ip_address"]["_locales"] = self._locales
        return model_class(body)

    def _exception_for_error(self, response, uri):
        """Returns the exception for the error responses."""
        status = response.status_code

        if 400 <= status < 500:
            return self._exception_for_4xx_status(response, status, uri)
        if 500 <= status < 600:
            return self._exception_for_5xx_status(status, uri)
        return self._exception_for_unexpected_status(status, uri)

    def _exception_for_4xx_status(self, response, status, uri):
        """Returns exception for error responses with 4xx status codes."""
        if not response.content:
            return HTTPError(
                "Received a {0} error with no body".format(status), status, uri
            )
        if response.headers.get("Content-Type", "").find("json") == -1:
            return HTTPError(
                "Received a {0} with the following "
                "body: {1}".format(status, response.content),
                status,
                uri,
            )
        try:
            body = response.json()
        except ValueError:
            return HTTPError(
                "Received a {status:d} error but it did not include"
                " the expected JSON body: {content}".format(
                    status=status, content=response.content
                ),
                status,
                uri,
            )
        else:
            if "code" in body and "error" in body:
                return self._exception_for_web_service_error(
                    body.get("error"), body.get("code"), status, uri
                )
            return HTTPError(
                "Error response contains JSON but it does not specify code"
                " or error keys: {0}".format(response.content),
                status,
                uri,
            )

    def _exception_for_web_service_error(self, message, code, status, uri):
        """Returns exception for error responses with the JSON body."""
        if code in (
            "ACCOUNT_ID_REQUIRED",
            "AUTHORIZATION_INVALID",
            "LICENSE_KEY_REQUIRED",
            "USER_ID_REQUIRED",
        ):
            return AuthenticationError(message)
        if code == "INSUFFICIENT_FUNDS":
            return InsufficientFundsError(message)
        if code == "PERMISSION_REQUIRED":
            return PermissionRequiredError(message)

        return InvalidRequestError(message, code, status, uri)

    def _exception_for_5xx_status(self, status, uri):
        """Returns exception for error response with 5xx status codes."""
        return HTTPError(
            u"Received a server error ({0}) for " u"{1}".format(status, uri),
            status,
            uri,
        )

    def _exception_for_unexpected_status(self, status, uri):
        """Returns exception for responses with unexpected status codes."""
        return HTTPError(
            u"Received an unexpected HTTP status " u"({0}) for {1}".format(status, uri),
            status,
            uri,
        )
