# Copyright 2017 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Internal utilities common to all modules."""

import io
import json
import socket

import googleapiclient
import httplib2
import requests

import firebase_admin
from firebase_admin import exceptions


_ERROR_CODE_TO_EXCEPTION_TYPE = {
    exceptions.INVALID_ARGUMENT: exceptions.InvalidArgumentError,
    exceptions.FAILED_PRECONDITION: exceptions.FailedPreconditionError,
    exceptions.OUT_OF_RANGE: exceptions.OutOfRangeError,
    exceptions.UNAUTHENTICATED: exceptions.UnauthenticatedError,
    exceptions.PERMISSION_DENIED: exceptions.PermissionDeniedError,
    exceptions.NOT_FOUND: exceptions.NotFoundError,
    exceptions.ABORTED: exceptions.AbortedError,
    exceptions.ALREADY_EXISTS: exceptions.AlreadyExistsError,
    exceptions.CONFLICT: exceptions.ConflictError,
    exceptions.RESOURCE_EXHAUSTED: exceptions.ResourceExhaustedError,
    exceptions.CANCELLED: exceptions.CancelledError,
    exceptions.DATA_LOSS: exceptions.DataLossError,
    exceptions.UNKNOWN: exceptions.UnknownError,
    exceptions.INTERNAL: exceptions.InternalError,
    exceptions.UNAVAILABLE: exceptions.UnavailableError,
    exceptions.DEADLINE_EXCEEDED: exceptions.DeadlineExceededError,
}


_HTTP_STATUS_TO_ERROR_CODE = {
    400: exceptions.INVALID_ARGUMENT,
    401: exceptions.UNAUTHENTICATED,
    403: exceptions.PERMISSION_DENIED,
    404: exceptions.NOT_FOUND,
    409: exceptions.CONFLICT,
    412: exceptions.FAILED_PRECONDITION,
    429: exceptions.RESOURCE_EXHAUSTED,
    500: exceptions.INTERNAL,
    503: exceptions.UNAVAILABLE,
}


# See https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
_RPC_CODE_TO_ERROR_CODE = {
    1: exceptions.CANCELLED,
    2: exceptions.UNKNOWN,
    3: exceptions.INVALID_ARGUMENT,
    4: exceptions.DEADLINE_EXCEEDED,
    5: exceptions.NOT_FOUND,
    6: exceptions.ALREADY_EXISTS,
    7: exceptions.PERMISSION_DENIED,
    8: exceptions.RESOURCE_EXHAUSTED,
    9: exceptions.FAILED_PRECONDITION,
    10: exceptions.ABORTED,
    11: exceptions.OUT_OF_RANGE,
    13: exceptions.INTERNAL,
    14: exceptions.UNAVAILABLE,
    15: exceptions.DATA_LOSS,
    16: exceptions.UNAUTHENTICATED,
}


def _get_initialized_app(app):
    """Returns a reference to an initialized App instance."""
    if app is None:
        return firebase_admin.get_app()

    if isinstance(app, firebase_admin.App):
        initialized_app = firebase_admin.get_app(app.name)
        if app is not initialized_app:
            raise ValueError('Illegal app argument. App instance not '
                             'initialized via the firebase module.')
        return app

    raise ValueError('Illegal app argument. Argument must be of type '
                     ' firebase_admin.App, but given "{0}".'.format(type(app)))



def get_app_service(app, name, initializer):
    app = _get_initialized_app(app)
    return app._get_service(name, initializer) # pylint: disable=protected-access


def handle_platform_error_from_requests(error, handle_func=None):
    """Constructs a ``FirebaseError`` from the given requests error.

    This can be used to handle errors returned by Google Cloud Platform (GCP) APIs.

    Args:
        error: An error raised by the requests module while making an HTTP call to a GCP API.
        handle_func: A function that can be used to handle platform errors in a custom way. When
            specified, this function will be called with three arguments. It has the same
            signature as ```_handle_func_requests``, but may return ``None``.

    Returns:
        FirebaseError: A ``FirebaseError`` that can be raised to the user code.
    """
    if error.response is None:
        return handle_requests_error(error)

    response = error.response
    content = response.content.decode()
    status_code = response.status_code
    error_dict, message = _parse_platform_error(content, status_code)
    exc = None
    if handle_func:
        exc = handle_func(error, message, error_dict)

    return exc if exc else _handle_func_requests(error, message, error_dict)


def handle_operation_error(error):
    """Constructs a ``FirebaseError`` from the given operation error.

    Args:
        error: An error returned by a long running operation.

    Returns:
        FirebaseError: A ``FirebaseError`` that can be raised to the user code.
    """
    if not isinstance(error, dict):
        return exceptions.UnknownError(
            message='Unknown error while making a remote service call: {0}'.format(error),
            cause=error)

    rpc_code = error.get('code')
    message = error.get('message')
    error_code = _rpc_code_to_error_code(rpc_code)
    err_type = _error_code_to_exception_type(error_code)
    return err_type(message=message)


def _handle_func_requests(error, message, error_dict):
    """Constructs a ``FirebaseError`` from the given GCP error.

    Args:
        error: An error raised by the requests module while making an HTTP call.
        message: A message to be included in the resulting ``FirebaseError``.
        error_dict: Parsed GCP error response.

    Returns:
        FirebaseError: A ``FirebaseError`` that can be raised to the user code or None.
    """
    code = error_dict.get('status')
    return handle_requests_error(error, message, code)


def handle_requests_error(error, message=None, code=None):
    """Constructs a ``FirebaseError`` from the given requests error.

    This method is agnostic of the remote service that produced the error, whether it is a GCP
    service or otherwise. Therefore, this method does not attempt to parse the error response in
    any way.

    Args:
        error: An error raised by the requests module while making an HTTP call.
        message: A message to be included in the resulting ``FirebaseError`` (optional). If not
            specified the string representation of the ``error`` argument is used as the message.
        code: A GCP error code that will be used to determine the resulting error type (optional).
            If not specified the HTTP status code on the error response is used to determine a
            suitable error code.

    Returns:
        FirebaseError: A ``FirebaseError`` that can be raised to the user code.
    """
    if isinstance(error, requests.exceptions.Timeout):
        return exceptions.DeadlineExceededError(
            message='Timed out while making an API call: {0}'.format(error),
            cause=error)
    if isinstance(error, requests.exceptions.ConnectionError):
        return exceptions.UnavailableError(
            message='Failed to establish a connection: {0}'.format(error),
            cause=error)
    if error.response is None:
        return exceptions.UnknownError(
            message='Unknown error while making a remote service call: {0}'.format(error),
            cause=error)

    if not code:
        code = _http_status_to_error_code(error.response.status_code)
    if not message:
        message = str(error)

    err_type = _error_code_to_exception_type(code)
    return err_type(message=message, cause=error, http_response=error.response)


def handle_platform_error_from_googleapiclient(error, handle_func=None):
    """Constructs a ``FirebaseError`` from the given googleapiclient error.

    This can be used to handle errors returned by Google Cloud Platform (GCP) APIs.

    Args:
        error: An error raised by the googleapiclient while making an HTTP call to a GCP API.
        handle_func: A function that can be used to handle platform errors in a custom way. When
            specified, this function will be called with three arguments. It has the same
            signature as ```_handle_func_googleapiclient``, but may return ``None``.

    Returns:
        FirebaseError: A ``FirebaseError`` that can be raised to the user code.
    """
    if not isinstance(error, googleapiclient.errors.HttpError):
        return handle_googleapiclient_error(error)

    content = error.content.decode()
    status_code = error.resp.status
    error_dict, message = _parse_platform_error(content, status_code)
    http_response = _http_response_from_googleapiclient_error(error)
    exc = None
    if handle_func:
        exc = handle_func(error, message, error_dict, http_response)

    return exc if exc else _handle_func_googleapiclient(error, message, error_dict, http_response)


def _handle_func_googleapiclient(error, message, error_dict, http_response):
    """Constructs a ``FirebaseError`` from the given GCP error.

    Args:
        error: An error raised by the googleapiclient module while making an HTTP call.
        message: A message to be included in the resulting ``FirebaseError``.
        error_dict: Parsed GCP error response.
        http_response: A requests HTTP response object to associate with the exception.

    Returns:
        FirebaseError: A ``FirebaseError`` that can be raised to the user code or None.
    """
    code = error_dict.get('status')
    return handle_googleapiclient_error(error, message, code, http_response)


def handle_googleapiclient_error(error, message=None, code=None, http_response=None):
    """Constructs a ``FirebaseError`` from the given googleapiclient error.

    This method is agnostic of the remote service that produced the error, whether it is a GCP
    service or otherwise. Therefore, this method does not attempt to parse the error response in
    any way.

    Args:
        error: An error raised by the googleapiclient module while making an HTTP call.
        message: A message to be included in the resulting ``FirebaseError`` (optional). If not
            specified the string representation of the ``error`` argument is used as the message.
        code: A GCP error code that will be used to determine the resulting error type (optional).
            If not specified the HTTP status code on the error response is used to determine a
            suitable error code.
        http_response: A requests HTTP response object to associate with the exception (optional).
            If not specified, one will be created from the ``error``.

    Returns:
        FirebaseError: A ``FirebaseError`` that can be raised to the user code.
    """
    if isinstance(error, socket.timeout) or (
            isinstance(error, socket.error) and 'timed out' in str(error)):
        return exceptions.DeadlineExceededError(
            message='Timed out while making an API call: {0}'.format(error),
            cause=error)
    if isinstance(error, httplib2.ServerNotFoundError):
        return exceptions.UnavailableError(
            message='Failed to establish a connection: {0}'.format(error),
            cause=error)
    if not isinstance(error, googleapiclient.errors.HttpError):
        return exceptions.UnknownError(
            message='Unknown error while making a remote service call: {0}'.format(error),
            cause=error)

    if not code:
        code = _http_status_to_error_code(error.resp.status)
    if not message:
        message = str(error)
    if not http_response:
        http_response = _http_response_from_googleapiclient_error(error)

    err_type = _error_code_to_exception_type(code)
    return err_type(message=message, cause=error, http_response=http_response)


def _http_response_from_googleapiclient_error(error):
    """Creates a requests HTTP Response object from the given googleapiclient error."""
    resp = requests.models.Response()
    resp.raw = io.BytesIO(error.content)
    resp.status_code = error.resp.status
    return resp


def _http_status_to_error_code(status):
    """Maps an HTTP status to a platform error code."""
    return _HTTP_STATUS_TO_ERROR_CODE.get(status, exceptions.UNKNOWN)

def _rpc_code_to_error_code(rpc_code):
    """Maps an RPC code to a platform error code."""
    return _RPC_CODE_TO_ERROR_CODE.get(rpc_code, exceptions.UNKNOWN)

def _error_code_to_exception_type(code):
    """Maps a platform error code to an exception type."""
    return _ERROR_CODE_TO_EXCEPTION_TYPE.get(code, exceptions.UnknownError)


def _parse_platform_error(content, status_code):
    """Parses an HTTP error response from a Google Cloud Platform API and extracts the error code
    and message fields.

    Args:
        content: Decoded content of the response body.
        status_code: HTTP status code.

    Returns:
        tuple: A tuple containing error code and message.
    """
    data = {}
    try:
        parsed_body = json.loads(content)
        if isinstance(parsed_body, dict):
            data = parsed_body
    except ValueError:
        pass

    error_dict = data.get('error', {})
    msg = error_dict.get('message')
    if not msg:
        msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(status_code, content)
    return error_dict, msg