from __future__ import absolute_import

import json
import os.path
from io import StringIO
from urllib.parse import urlencode

import connexion
import werkzeug.wrappers

import fleece.log
from fleece import httperror
from fleece.handlers.wsgi import build_wsgi_environ_from_event

_app_cache = {}

RESPONSE_CONTRACT_VIOLATION = "Response body does not conform to specification"


class FleeceApp(connexion.App):
    """Wrapper around `connexion.App` with added helpers for Lambda."""

    def __init__(self, *args, **kwargs):
        """Create a FleeceApp.

        Parameters are identical to a `connexion.App, with the exception of the
        below keyword argument.

        :param logging.Logger logger:
            A Logger instance returned by `fleece.log.get_logger()` to be used
            for capturing details about errors.

            If `logger` is None, a default logger object will be created.
        """
        logger = kwargs.pop("logger", None)
        if logger is None:
            self.logger = fleece.log.get_logger(__name__)
        else:
            self.logger = logger

        super(FleeceApp, self).__init__(*args, **kwargs)

        # Capture and log any unexpected exceptions raise by handler code:
        def error_handler(exception):
            self.logger.exception(exception)
            # A `None` return value will not modify the response.
            # It's possible to return new types of responses from an error
            # handler.
            # To do so, simply return a new `werkzeug.wrappers.Response`
            # object.

        self.add_error_handler(Exception, error_handler)

    def call_api(self, event):
        """Make a request against the API defined by this app.

        Return any result from the API call as a JSON object/dict. In the event
        of a client or server error response from the API endpoint handler (4xx
        or 5xx), raise a :class:`fleece.httperror.HTTPError` containing some
        context about the error. Any unexpected exception encountered while
        executing an API endpoint handler will be appropriately raised as a
        generic 500 error with no error context (in order to not expose too
        much of the internals of the application).

        :param dict event:
            Dictionary containing the entire request payload passed to the
            Lambda function handler.
        :returns:
            JSON object (as a `list` or `dict`) containing the response data
            from the API endpoint.
        :raises:
            :class:`fleece.httperror.HTTPError` on 4xx or 5xx responses from
            the API endpoint handler, or a 500 response on an unexpected
            failure (due to a bug in handler code, for example).
        """
        try:
            environ = _build_wsgi_env(event, self.import_name)
            response = werkzeug.wrappers.Response.from_app(self, environ)
            response_dict = json.loads(response.get_data())

            if 400 <= response.status_code < 500:
                if "error" in response_dict and "message" in response_dict["error"]:
                    # FIXME(larsbutler): If 'error' is not a collection
                    # (list/dict) and is a scalar such as an int, the check
                    # `message in response_dict['error']` will blow up and
                    # result in a generic 500 error. This check assumes too
                    # much about the format of error responses given by the
                    # handler. It might be good to allow this handling to
                    # support custom structures.
                    msg = response_dict["error"]["message"]
                elif "detail" in response_dict:
                    # Likely this is a generated 400 response from Connexion.
                    # Reveal the 'detail' of the message to the user.
                    # NOTE(larsbutler): If your API handler explicitly returns
                    # a 'detail' key in in the response, be aware that this
                    # will be exposed to the client.
                    msg = response_dict["detail"]
                else:
                    # Respond with a generic client error.
                    msg = "Client Error"
                # FIXME(larsbutler): The logic above still assumes a lot about
                # the API response. That's not great. It would be nice to make
                # this more flexible and explicit.
                self.logger.error(
                    "Raising 4xx error", http_status=response.status_code, message=msg,
                )
                raise httperror.HTTPError(
                    status=response.status_code, message=msg,
                )
            elif 500 <= response.status_code < 600:
                if response_dict["title"] == RESPONSE_CONTRACT_VIOLATION:
                    # This case is generally enountered if the API endpoint
                    # handler code does not conform to the contract dictated by
                    # the Swagger specification.
                    self.logger.error(
                        RESPONSE_CONTRACT_VIOLATION, detail=response_dict["detail"]
                    )
                else:
                    # This case will trigger if
                    # a) the handler code raises an unexpected exception
                    # or
                    # b) the handler code explicitly returns a 5xx error.
                    self.logger.error(
                        "Raising 5xx error",
                        response=response_dict,
                        http_status=response.status_code,
                    )
                raise httperror.HTTPError(status=response.status_code)
            else:
                return response_dict
        except httperror.HTTPError:
            self.logger.exception("HTTPError")
            raise
        except Exception:
            self.logger.exception("Unhandled exception")
            raise httperror.HTTPError(status=500)

    def call_proxy_api(self, event):
        """Make a request against the API defined by this app.

        Return any result from the API call as a dict that is expected by the
        AWS_PROXY type integration in API Gateway.

        :param dict event:
            Dictionary containing the entire request payload passed to the
            Lambda function handler.
        :returns:
            Dictionary containing the response data from the API endpoint.
        :raises:
            :class:`fleece.httperror.HTTPError` on 4xx or 5xx responses from
            the API endpoint handler, or a 500 response on an unexpected
            failure (due to a bug in handler code, for example).
        """
        try:
            environ = build_wsgi_environ_from_event(event)
            response = werkzeug.wrappers.Response.from_app(self, environ)
            return {
                "statusCode": response.status_code,
                "headers": {
                    header: value for header, value in response.headers.items()
                },
                "body": response.get_data(as_text=True),
            }
        except Exception:
            # We should actually never get here, because exceptions raised
            # within the application are handled by the error handlers, and the
            # default one will return a clean 500 error during the happy path
            # above.
            self.logger.exception("Unhandled exception")
            return {
                "statusCode": 500,
                "headers": {},
                "body": json.dumps({"error": {"message": "Internal server error."}}),
            }


def _build_wsgi_env(event, app_name):
    """Turn the Lambda/API Gateway request event into a WSGI environment dict.

    :param dict event:
        The event parameters passed to the Lambda function entrypoint.
    :param str app_name:
        Name of the API application.
    """
    gateway = event["parameters"]["gateway"]
    request = event["parameters"]["request"]
    ctx = event["rawContext"]
    headers = request["header"]
    body = str(json.dumps(request["body"]))

    # Render the path correctly so connexion/flask will pass the path params to
    # the handler function correctly.
    # Basically, this replaces "/foo/{param1}/bar/{param2}" with
    # "/foo/123/bar/456".
    path = gateway["resource-path"].format(**event["parameters"]["request"]["path"])
    environ = {
        "PATH_INFO": path,
        "QUERY_STRING": urlencode(request["querystring"]),
        "REMOTE_ADDR": ctx["identity"]["sourceIp"],
        "REQUEST_METHOD": ctx["httpMethod"],
        "SCRIPT_NAME": app_name,
        "SERVER_NAME": app_name,
        "SERVER_PORT": headers.get("X-Forwarded-Port", "80"),
        "SERVER_PROTOCOL": "HTTP/1.1",
        "wsgi.version": (1, 0),
        "wsgi.url_scheme": headers.get("X-Forwarded-Proto", "http"),
        "wsgi.input": StringIO(body),
        "wsgi.errors": StringIO(),
        "wsgi.multiprocess": False,
        "wsgi.multithread": False,
        "wsgi.run_once": False,
        "CONTENT_TYPE": headers.get("Content-Type", "application/json"),
    }
    if ctx["httpMethod"] in ["POST", "PUT", "PATCH"]:
        environ["CONTENT_LENGTH"] = str(len(body))

    for header_name, header_value in headers.items():
        formatted_header_name = header_name.upper().replace("-", "_")
        wsgi_name = f"HTTP_{formatted_header_name}"
        environ[wsgi_name] = str(header_value)

    return environ


def get_connexion_app(
    app_name,
    app_swagger_path,
    strict_validation=True,
    validate_responses=True,
    cache_app=True,
    logger=None,
):
    # Optionally cache application instances, because it takes a significant
    # amount of time to process the Swagger definition, and we shouldn't be
    # doing it on every single request.
    if app_name not in _app_cache or not cache_app:
        full_path_to_swagger_yaml = os.path.abspath(app_swagger_path)
        app = FleeceApp(
            app_name,
            specification_dir=os.path.dirname(full_path_to_swagger_yaml),
            logger=logger,
        )
        app.add_api(
            os.path.basename(full_path_to_swagger_yaml),
            strict_validation=strict_validation,
            validate_responses=validate_responses,
        )
        _app_cache[app_name] = app

    return _app_cache[app_name]


def call_api(
    event,
    app_name,
    app_swagger_path,
    logger,
    strict_validation=True,
    validate_responses=True,
    cache_app=True,
):
    """Wire up the incoming Lambda/API Gateway request to an application.

    :param dict event:
        Dictionary containing the entire request template. This can vary wildly
        depending on the template structure and contents.
    :param str app_name:
        Name of the API application.
    :param str app_swagger_path:
        Local path to the Swagger API YAML file.
    :param logging.Logger logger:
        A Logger instance returned by `fleece.log.get_logger()` to be used for
        capturing details about errors.
    :param bool strict_validation:
        Toggle to enable/disable Connexion's parameter validation.
    :param bool validate_responses:
        Toggle to enable/disable Connexion's response validation.
    :param bool cache_app:
        Toggle to enable/disable the caching of the Connextion/Flask app
        instance. It's on by default, because it provides a significant
        performance improvement in the Lambda runtime environment.
    """
    app = get_connexion_app(
        app_name=app_name,
        app_swagger_path=app_swagger_path,
        strict_validation=strict_validation,
        validate_responses=validate_responses,
        cache_app=cache_app,
    )
    return app.call_api(event)


def call_proxy_api(
    event,
    app_name,
    app_swagger_path,
    logger,
    strict_validation=True,
    validate_responses=True,
    cache_app=True,
):
    """Wire up the incoming Lambda/API Gateway request to an application.

    Integration type of the resource must be AWS_LAMBDA in order for this to
    work.

    :param dict event:
        Dictionary containing the entire request template. This can vary wildly
        depending on the template structure and contents.
    :param str app_name:
        Name of the API application.
    :param str app_swagger_path:
        Local path to the Swagger API YAML file.
    :param logging.Logger logger:
        A Logger instance returned by `fleece.log.get_logger()` to be used for
        capturing details about errors.
    :param bool strict_validation:
        Toggle to enable/disable Connexion's parameter validation.
    :param bool validate_responses:
        Toggle to enable/disable Connexion's response validation.
    :param bool cache_app:
        Toggle to enable/disable the caching of the Connextion/Flask app
        instance. It's on by default, because it provides a significant
        performance improvement in the Lambda runtime environment.
    """
    app = get_connexion_app(
        app_name=app_name,
        app_swagger_path=app_swagger_path,
        strict_validation=strict_validation,
        validate_responses=validate_responses,
        cache_app=cache_app,
    )
    return app.call_proxy_api(event)