"""API Gateway Local Service"""

import base64
import json
import logging
from datetime import datetime
from io import StringIO
from time import time
from typing import Any, Dict, List, Optional, Tuple, Union

from flask import Flask, Request, request
from werkzeug.datastructures import Headers
from werkzeug.routing import BaseConverter
from werkzeug.serving import WSGIRequestHandler

from samcli.commands.local.lib.exceptions import UnsupportedInlineCodeError
from samcli.commands.local.lib.local_lambda import LocalLambdaRunner
from samcli.lib.providers.exceptions import MissingFunctionNameException
from samcli.lib.providers.provider import Api, Cors
from samcli.lib.telemetry.event import EventName, EventTracker, UsedFeature
from samcli.lib.utils.stream_writer import StreamWriter
from samcli.local.apigw.authorizers.lambda_authorizer import LambdaAuthorizer
from samcli.local.apigw.event_constructor import construct_v1_event, construct_v2_event_http
from samcli.local.apigw.exceptions import (
    AuthorizerUnauthorizedRequest,
    InvalidLambdaAuthorizerResponse,
    InvalidSecurityDefinition,
    LambdaResponseParseException,
    PayloadFormatVersionValidateException,
)
from samcli.local.apigw.path_converter import PathConverter
from samcli.local.apigw.route import Route
from samcli.local.apigw.service_error_responses import ServiceErrorResponses
from samcli.local.docker.exceptions import DockerContainerCreationFailedException
from samcli.local.events.api_event import (
    ContextHTTP,
    ContextIdentity,
    RequestContext,
    RequestContextV2,
)
from samcli.local.lambdafn.exceptions import FunctionNotFound
from samcli.local.services.base_local_service import BaseLocalService, LambdaOutputParser

LOG = logging.getLogger(__name__)


class CatchAllPathConverter(BaseConverter):
    regex = ".+"
    weight = 300
    part_isolating = False

    def to_python(self, value):
        return value

    def to_url(self, value):
        return value


class LocalApigwService(BaseLocalService):
    _DEFAULT_PORT = 3000
    _DEFAULT_HOST = "127.0.0.1"

    def __init__(
        self,
        api: Api,
        lambda_runner: LocalLambdaRunner,
        static_dir: Optional[str] = None,
        port: Optional[int] = None,
        host: Optional[str] = None,
        stderr: Optional[StreamWriter] = None,
        ssl_context: Optional[Tuple[str, str]] = None,
    ):
        """
        Creates an ApiGatewayService

        Parameters
        ----------
        api : Api
           an Api object that contains the list of routes and properties
        lambda_runner : samcli.commands.local.lib.local_lambda.LocalLambdaRunner
            The Lambda runner class capable of invoking the function
        static_dir : str
            Directory from which to serve static files
        port : int
            Optional. port for the service to start listening on
            Defaults to 3000
        host : str
            Optional. host to start the service on
            Defaults to '127.0.0.1
        ssl_context : (str, str)
            Optional. tuple(str, str) indicating the cert and key files to use to start in https mode
            Defaults to None
        stderr : samcli.lib.utils.stream_writer.StreamWriter
            Optional stream writer where the stderr from Docker container should be written to
        """
        super().__init__(lambda_runner.is_debugging(), port=port, host=host, ssl_context=ssl_context)
        self.api = api
        self.lambda_runner = lambda_runner
        self.static_dir = static_dir
        self._dict_of_routes: Dict[str, Route] = {}
        self.stderr = stderr

        self._click_session_id = None

        try:
            # save the session ID for telemetry event sending
            from samcli.cli.context import Context

            ctx = Context.get_current_context()

            if ctx:
                self._click_session_id = ctx.session_id
        except RuntimeError:
            LOG.debug("Not able to get click context in APIGW service")

    def create(self):
        """
        Creates a Flask Application that can be started.
        """
        # Setting sam local start-api to respond using HTTP/1.1 instead of the default HTTP/1.0
        WSGIRequestHandler.protocol_version = "HTTP/1.1"

        self._app = Flask(
            __name__,
            static_url_path="",  # Mount static files at root '/'
            static_folder=self.static_dir,  # Serve static files from this directory
        )

        # add converter to support catch-all route
        self._app.url_map.converters["path"] = CatchAllPathConverter

        # Prevent the dev server from emitting headers that will make the browser cache response by default
        self._app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0

        # This will normalize all endpoints and strip any trailing '/'
        self._app.url_map.strict_slashes = False
        default_route = None

        for api_gateway_route in self.api.routes:
            if api_gateway_route.path == "$default":
                default_route = api_gateway_route
                continue
            path = PathConverter.convert_path_to_flask(api_gateway_route.path)
            for route_key in self._generate_route_keys(api_gateway_route.methods, path):
                self._dict_of_routes[route_key] = api_gateway_route
            self._app.add_url_rule(
                path,
                endpoint=path,
                view_func=self._request_handler,
                methods=api_gateway_route.methods,
                provide_automatic_options=False,
            )

        if default_route:
            LOG.debug("add catch-all route")
            all_methods = Route.ANY_HTTP_METHODS
            try:
                rules_iter = self._app.url_map.iter_rules("/")
                while True:
                    rule = next(rules_iter)
                    all_methods = [method for method in all_methods if method not in rule.methods]
            except (KeyError, StopIteration):
                pass

            self._add_catch_all_path(all_methods, "/", default_route)
            self._add_catch_all_path(Route.ANY_HTTP_METHODS, "/<path:any_path>", default_route)

        self._construct_error_handling()

    def _add_catch_all_path(self, methods: List[str], path: str, route: Route):
        """
        Add the catch all route to the _app and the dictionary of routes.

        :param list(str) methods: List of HTTP Methods
        :param str path: Path off the base url
        :param Route route: contains the default route configurations
        """

        self._app.add_url_rule(
            path,
            endpoint=path,
            view_func=self._request_handler,
            methods=methods,
            provide_automatic_options=False,
        )
        for route_key in self._generate_route_keys(methods, path):
            self._dict_of_routes[route_key] = Route(
                function_name=route.function_name,
                path=path,
                methods=methods,
                event_type=Route.HTTP,
                payload_format_version=route.payload_format_version,
                is_default_route=True,
                stack_path=route.stack_path,
                authorizer_name=route.authorizer_name,
                authorizer_object=route.authorizer_object,
                use_default_authorizer=route.use_default_authorizer,
            )

    def _generate_route_keys(self, methods, path):
        """
        Generates the key to the _dict_of_routes based on the list of methods
        and path supplied

        Parameters
        ----------
        methods : List[str]
            List of HTTP Methods
        path : str
            Path off the base url

        Yields
        ------
        route_key : str
            the route key in the form of "Path:Method"
        """
        for method in methods:
            yield self._route_key(method, path)

    @staticmethod
    def _v2_route_key(method, path, is_default_route):
        if is_default_route:
            return "$default"
        return "{} {}".format(method, path)

    @staticmethod
    def _route_key(method, path):
        return "{}:{}".format(path, method)

    def _construct_error_handling(self):
        """
        Updates the Flask app with Error Handlers for different Error Codes
        """
        # Both path and method not present
        self._app.register_error_handler(404, ServiceErrorResponses.route_not_found)
        # Path is present, but method not allowed
        self._app.register_error_handler(405, ServiceErrorResponses.route_not_found)
        # Something went wrong
        self._app.register_error_handler(500, ServiceErrorResponses.lambda_failure_response)

    def _create_method_arn(self, flask_request: Request, event_type: str) -> str:
        """
        Creates a method ARN with fake AWS values

        Parameters
        ----------
        flask_request: Request
            Flask request object to get method and path
        event_type: str
            Type of event (API or HTTP)

        Returns
        -------
        str
            A built method ARN with fake values
        """
        context = RequestContext() if event_type == Route.API else RequestContextV2()
        method, path = flask_request.method, flask_request.path

        return (
            f"arn:aws:execute-api:us-east-1:{context.account_id}:"  # type: ignore
            f"{context.api_id}/{self.api.stage_name}/{method}{path}"
        )

    def _generate_lambda_token_authorizer_event(
        self, flask_request: Request, route: Route, lambda_authorizer: LambdaAuthorizer
    ) -> dict:
        """
        Creates a Lambda authorizer token event

        Parameters
        ----------
        flask_request: Request
            Flask request object to get method and path
        route: Route
            Route object representing the endpoint to be invoked later
        lambda_authorizer: LambdaAuthorizer
            The Lambda authorizer the route is using

        Returns
        -------
        dict
            Basic dictionary containing a type and authorizationToken
        """
        method_arn = self._create_method_arn(flask_request, route.event_type)

        headers = {"headers": flask_request.headers}

        # V1 token based authorizers should always have a single identity source
        if len(lambda_authorizer.identity_sources) != 1:
            raise InvalidSecurityDefinition(
                "An invalid token based Lambda Authorizer was found, there should be one header identity source"
            )

        identity_source = lambda_authorizer.identity_sources[0]
        authorization_token = identity_source.find_identity_value(**headers)

        return {
            "type": LambdaAuthorizer.TOKEN.upper(),
            "authorizationToken": str(authorization_token),
            "methodArn": method_arn,
        }

    def _generate_lambda_request_authorizer_event_http(
        self, lambda_authorizer_payload: str, identity_values: list, method_arn: str
    ) -> dict:
        """
        Helper method to generate part of the event required for different payload versions
        for API Gateway V2

        Parameters
        ----------
        lambda_authorizer_payload: str
            The payload version of the Lambda authorizer
        identity_values: list
            A list of string identity values
        method_arn: str
            The method ARN for the endpoint

        Returns
        -------
        dict
            Dictionary containing partial Lambda authorizer event
        """
        if lambda_authorizer_payload == LambdaAuthorizer.PAYLOAD_V2:
            # payload 2.0 expects a list of strings
            return {"identitySource": identity_values, "routeArn": method_arn}
        else:
            # payload 1.0 expects a comma deliminated string that is the same
            # for both identitySource and authorizationToken
            all_identity_values_string = ",".join(identity_values)

            return {
                "identitySource": all_identity_values_string,
                "authorizationToken": all_identity_values_string,
                "methodArn": method_arn,
            }

    def _generate_lambda_request_authorizer_event(
        self, flask_request: Request, route: Route, lambda_authorizer: LambdaAuthorizer
    ) -> dict:
        """
        Creates a Lambda authorizer request event

        Parameters
        ----------
        flask_request: Request
            Flask request object to get method and path
        route: Route
            Route object representing the endpoint to be invoked later
        lambda_authorizer: LambdaAuthorizer
            The Lambda authorizer the route is using

        Returns
        -------
        dict
            A Lambda authorizer event
        """
        method_arn = self._create_method_arn(flask_request, route.event_type)
        method, endpoint = self.get_request_methods_endpoints(flask_request)

        # generate base lambda event and load it into a dict
        lambda_event = self._generate_lambda_event(flask_request, route, method, endpoint)
        lambda_event.update({"type": LambdaAuthorizer.REQUEST.upper()})

        # build context to form identity values
        context = (
            self._build_v1_context(route)
            if lambda_authorizer.payload_version == LambdaAuthorizer.PAYLOAD_V1
            else self._build_v2_context(route)
        )

        if route.event_type == Route.API:
            # v1 requests only add method ARN
            lambda_event.update({"methodArn": method_arn})
        else:
            # kwargs to pass into identity value finder
            kwargs = {
                "headers": flask_request.headers,
                "querystring": flask_request.query_string.decode("utf-8"),
                "context": context,
                "stageVariables": self.api.stage_variables,
            }

            # find and build all identity sources
            all_identity_values = []
            for identity_source in lambda_authorizer.identity_sources:
                value = identity_source.find_identity_value(**kwargs)

                if value:
                    # all identity values must be a string
                    all_identity_values.append(str(value))

            lambda_event.update(
                self._generate_lambda_request_authorizer_event_http(
                    lambda_authorizer.payload_version, all_identity_values, method_arn
                )
            )

        return lambda_event

    def _generate_lambda_authorizer_event(
        self, flask_request: Request, route: Route, lambda_authorizer: LambdaAuthorizer
    ) -> dict:
        """
        Generate a Lambda authorizer event

        Parameters
        ----------
        flask_request: Request
            Flask request object to get method and endpoint
        route: Route
            Route object representing the endpoint to be invoked later
        lambda_authorizer: LambdaAuthorizer
            The Lambda authorizer the route is using

        Returns
        -------
        str
            A JSON string containing event properties
        """
        authorizer_events = {
            LambdaAuthorizer.TOKEN: self._generate_lambda_token_authorizer_event,
            LambdaAuthorizer.REQUEST: self._generate_lambda_request_authorizer_event,
        }

        kwargs: Dict[str, Any] = {
            "flask_request": flask_request,
            "route": route,
            "lambda_authorizer": lambda_authorizer,
        }

        return authorizer_events[lambda_authorizer.type](**kwargs)

    def _generate_lambda_event(self, flask_request: Request, route: Route, method: str, endpoint: str) -> dict:
        """
        Helper function to generate the correct Lambda event

        Parameters
        ----------
        flask_request: Request
            The global Flask Request object
        route: Route
            The Route that was called
        method: str
            The method of the request (eg. GET, POST) from the Flask request
        endpoint: str
            The endpoint of the request from the Flask request

        Returns
        -------
        str
            JSON string of event properties
        """
        # TODO: Rewrite the logic below to use version 2.0 when an invalid value is provided
        # the Lambda Event 2.0 is only used for the HTTP API gateway with defined payload format version equal 2.0
        # or none, as the default value to be used is 2.0
        # https://docs.aws.amazon.com/apigatewayv2/latest/api-reference/apis-apiid-integrations.html#apis-apiid-integrations-prop-createintegrationinput-payloadformatversion
        if route.event_type == Route.HTTP and route.payload_format_version in [None, "2.0"]:
            apigw_endpoint = PathConverter.convert_path_to_api_gateway(endpoint)
            route_key = self._v2_route_key(method, apigw_endpoint, route.is_default_route)

            return construct_v2_event_http(
                flask_request=flask_request,
                port=self.port,
                binary_types=self.api.binary_media_types,
                stage_name=self.api.stage_name,
                stage_variables=self.api.stage_variables,
                route_key=route_key,
            )

        # For Http Apis with payload version 1.0, API Gateway never sends the OperationName.
        route_key = route.operation_name if route.event_type == Route.API else None

        return construct_v1_event(
            flask_request=flask_request,
            port=self.port,
            binary_types=self.api.binary_media_types,
            stage_name=self.api.stage_name,
            stage_variables=self.api.stage_variables,
            operation_name=route_key,
            api_type=route.event_type,
        )

    def _build_v1_context(self, route: Route) -> Dict[str, Any]:
        """
        Helper function to a 1.0 request context

        Parameters
        ----------
        route: Route
            The Route object that was invoked

        Returns
        -------
        dict
            JSON object containing context variables
        """
        identity = ContextIdentity(source_ip=request.remote_addr)

        protocol = request.environ.get("SERVER_PROTOCOL", "HTTP/1.1")
        host = request.host

        operation_name = route.operation_name if route.event_type == Route.API else None

        endpoint = PathConverter.convert_path_to_api_gateway(request.endpoint)
        method = request.method

        context = RequestContext(
            resource_path=endpoint,
            http_method=method,
            stage=self.api.stage_name,
            identity=identity,
            path=endpoint,
            protocol=protocol,
            domain_name=host,
            operation_name=operation_name,
        )

        return context.to_dict()

    def _build_v2_context(self, route: Route) -> Dict[str, Any]:
        """
        Helper function to a 2.0 request context

        Parameters
        ----------
        route: Route
            The Route object that was invoked

        Returns
        -------
        dict
            JSON object containing context variables
        """
        endpoint = PathConverter.convert_path_to_api_gateway(request.endpoint)
        method = request.method

        apigw_endpoint = PathConverter.convert_path_to_api_gateway(endpoint)
        route_key = self._v2_route_key(method, apigw_endpoint, route.is_default_route)

        request_time_epoch = int(time())
        request_time = datetime.utcnow().strftime("%d/%b/%Y:%H:%M:%S +0000")

        context_http = ContextHTTP(method=method, path=request.path, source_ip=request.remote_addr)
        context = RequestContextV2(
            http=context_http,
            route_key=route_key,
            stage=self.api.stage_name,
            request_time_epoch=request_time_epoch,
            request_time=request_time,
        )

        return context.to_dict()

    def _valid_identity_sources(self, request: Request, route: Route) -> bool:
        """
        Validates if the route contains all the valid identity sources defined in the route's Lambda Authorizer

        Parameters
        ----------
        request: Request
            Flask request object containing incoming request variables
        route: Route
            the Route object that contains the Lambda Authorizer definition

        Returns
        -------
        bool
            true if all the identity sources are present and valid
        """
        lambda_auth = route.authorizer_object

        if not isinstance(lambda_auth, LambdaAuthorizer):
            return False

        identity_sources = lambda_auth.identity_sources

        context = (
            self._build_v1_context(route)
            if lambda_auth.payload_version == LambdaAuthorizer.PAYLOAD_V1
            else self._build_v2_context(route)
        )

        kwargs = {
            "headers": request.headers,
            "querystring": request.query_string.decode("utf-8"),
            "context": context,
            "stageVariables": self.api.stage_variables,
            "validation_expression": lambda_auth.validation_string,
        }

        for validator in identity_sources:
            if not validator.is_valid(**kwargs):
                return False

        return True

    def _invoke_lambda_function(self, lambda_function_name: str, event: dict) -> Union[str, bytes]:
        """
        Helper method to invoke a function and setup stdout+stderr

        Parameters
        ----------
        lambda_function_name: str
            The name of the Lambda function to invoke
        event: dict
            The event object to pass into the Lambda function

        Returns
        -------
        Union[str, bytes]
            A string or bytes containing the output from the Lambda function
        """
        with StringIO() as stdout:
            event_str = json.dumps(event, sort_keys=True)
            stdout_writer = StreamWriter(stdout, auto_flush=True)

            self.lambda_runner.invoke(lambda_function_name, event_str, stdout=stdout_writer, stderr=self.stderr)
            lambda_response, is_lambda_user_error_response = LambdaOutputParser.get_lambda_output(stdout)
            if is_lambda_user_error_response:
                raise LambdaResponseParseException

        return lambda_response

    def _request_handler(self, **kwargs):
        """
        We handle all requests to the host:port. The general flow of handling a request is as follows

        * Fetch request from the Flask Global state. This is where Flask places the request and is per thread so
          multiple requests are still handled correctly
        * Find the Lambda function to invoke by doing a look up based on the request.endpoint and method
        * If we don't find the function, we will throw a 502 (just like the 404 and 405 responses we get
          from Flask.
        * Since we found a Lambda function to invoke, we construct the Lambda Event from the request
        * Then Invoke the Lambda function (docker container)
        * We then transform the response or errors we get from the Invoke and return the data back to
          the caller

        Parameters
        ----------
        kwargs dict
            Keyword Args that are passed to the function from Flask. This happens when we have path parameters

        Returns
        -------
        Response object
        """

        route: Route = self._get_current_route(request)

        request_origin = request.headers.get("Origin")
        cors_headers = Cors.cors_to_headers(self.api.cors, request_origin, route.event_type)

        lambda_authorizer = route.authorizer_object

        # payloadFormatVersion can only support 2 values: "1.0" and "2.0"
        # so we want to do strict validation to make sure it has proper value if provided
        # https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
        if route.payload_format_version not in [None, "1.0", "2.0"]:
            raise PayloadFormatVersionValidateException(
                f'{route.payload_format_version} is not a valid value. PayloadFormatVersion must be "1.0" or "2.0"'
            )

        method, endpoint = self.get_request_methods_endpoints(request)
        if method == "OPTIONS" and self.api.cors:
            headers = Headers(cors_headers)
            return self.service_response("", headers, 200)

        # check for LambdaAuthorizer since that is the only authorizer we currently support
        if isinstance(lambda_authorizer, LambdaAuthorizer) and not self._valid_identity_sources(request, route):
            return ServiceErrorResponses.missing_lambda_auth_identity_sources()

        try:
            route_lambda_event = self._generate_lambda_event(request, route, method, endpoint)
            auth_lambda_event = None

            if lambda_authorizer:
                auth_lambda_event = self._generate_lambda_authorizer_event(request, route, lambda_authorizer)
        except UnicodeDecodeError as error:
            LOG.error("UnicodeDecodeError while processing HTTP request: %s", error)
            return ServiceErrorResponses.lambda_failure_response()

        lambda_authorizer_exception = None
        try:
            auth_service_error = None

            if lambda_authorizer:
                self._invoke_parse_lambda_authorizer(lambda_authorizer, auth_lambda_event, route_lambda_event, route)
        except AuthorizerUnauthorizedRequest as ex:
            auth_service_error = ServiceErrorResponses.lambda_authorizer_unauthorized()
            lambda_authorizer_exception = ex
        except InvalidLambdaAuthorizerResponse as ex:
            auth_service_error = ServiceErrorResponses.lambda_failure_response()
            lambda_authorizer_exception = ex
        except FunctionNotFound as ex:
            lambda_authorizer_exception = ex

            LOG.warning(
                "Failed to find a Function to invoke a Lambda authorizer, verify that "
                "this Function is defined and exists locally in the template."
            )
        except Exception as ex:
            # re-raise the catch all exception after we track it in our telemetry
            lambda_authorizer_exception = ex
            raise ex
        finally:
            exception_name = type(lambda_authorizer_exception).__name__ if lambda_authorizer_exception else None

            EventTracker.track_event(
                event_name=EventName.USED_FEATURE.value,
                event_value=UsedFeature.INVOKED_CUSTOM_LAMBDA_AUTHORIZERS.value,
                session_id=self._click_session_id,
                exception_name=exception_name,
            )

            if lambda_authorizer_exception:
                LOG.error("Lambda authorizer failed to invoke successfully: %s", str(lambda_authorizer_exception))

            if auth_service_error:
                # Return the Flask service error if there is one, since these are the only exceptions
                # we are anticipating from the authorizer, anything else indicates a local issue.
                #
                # Note that returning within a finally block will have the effect of swallowing
                # any reraised exceptions.
                return auth_service_error

        endpoint_service_error = None
        try:
            # invoke the route's Lambda function
            lambda_response = self._invoke_lambda_function(route.function_name, route_lambda_event)
        except FunctionNotFound:
            endpoint_service_error = ServiceErrorResponses.lambda_not_found_response()
        except UnsupportedInlineCodeError:
            endpoint_service_error = ServiceErrorResponses.not_implemented_locally(
                "Inline code is not supported for sam local commands. Please write your code in a separate file."
            )
        except LambdaResponseParseException:
            endpoint_service_error = ServiceErrorResponses.lambda_body_failure_response()
        except DockerContainerCreationFailedException as ex:
            endpoint_service_error = ServiceErrorResponses.container_creation_failed(ex.message)
        except MissingFunctionNameException as ex:
            endpoint_service_error = ServiceErrorResponses.lambda_failure_response(
                f"Failed to execute endpoint. Got an invalid function name ({str(ex)})",
            )

        if endpoint_service_error:
            return endpoint_service_error

        try:
            if route.event_type == Route.HTTP and (
                not route.payload_format_version or route.payload_format_version == "2.0"
            ):
                (status_code, headers, body) = self._parse_v2_payload_format_lambda_output(
                    lambda_response, self.api.binary_media_types, request
                )
            else:
                (status_code, headers, body) = self._parse_v1_payload_format_lambda_output(
                    lambda_response, self.api.binary_media_types, request, route.event_type
                )
        except LambdaResponseParseException as ex:
            LOG.error("Invalid lambda response received: %s", ex)
            return ServiceErrorResponses.lambda_failure_response()

        # Add CORS headers to the response
        headers.update(cors_headers)

        return self.service_response(body, headers, status_code)

    def _invoke_parse_lambda_authorizer(
        self, lambda_authorizer: LambdaAuthorizer, auth_lambda_event: dict, route_lambda_event: dict, route: Route
    ) -> None:
        """
        Helper method to invoke and parse the output of a Lambda authorizer

        Parameters
        ----------
        lambda_authorizer: LambdaAuthorizer
            The route's Lambda authorizer
        auth_lambda_event: dict
            The event to pass to the Lambda authorizer
        route_lambda_event: dict
            The event to pass into the route
        route: Route
            The route that is being called
        """
        lambda_auth_response = self._invoke_lambda_function(lambda_authorizer.lambda_name, auth_lambda_event)
        method_arn = self._create_method_arn(request, route.event_type)

        if not lambda_authorizer.is_valid_response(lambda_auth_response, method_arn):
            raise AuthorizerUnauthorizedRequest(f"Request is not authorized for {method_arn}")

        # update route context to include any context that may have been passed from authorizer
        original_context = route_lambda_event.get("requestContext", {})

        context = lambda_authorizer.get_context(lambda_auth_response)

        # payload V2 responses have the passed context under the "lambda" key
        if route.event_type == Route.HTTP and route.payload_format_version in [None, "2.0"]:
            original_context.update({"authorizer": {"lambda": context}})
        else:
            original_context.update({"authorizer": context})

        route_lambda_event.update({"requestContext": original_context})

    def _get_current_route(self, flask_request):
        """
        Get the route (Route) based on the current request

        :param request flask_request: Flask Request
        :return: Route matching the endpoint and method of the request
        """
        method, endpoint = self.get_request_methods_endpoints(flask_request)

        route_key = self._route_key(method, endpoint)
        route = self._dict_of_routes.get(route_key, None)

        if not route:
            LOG.debug(
                "Lambda function for the route not found. This should not happen because Flask is "
                "already configured to serve all path/methods given to the service. "
                "Path=%s Method=%s RouteKey=%s",
                endpoint,
                method,
                route_key,
            )
            raise KeyError("Lambda function for the route not found")

        return route

    @staticmethod
    def get_request_methods_endpoints(flask_request):
        """
        Separated out for testing requests in request handler
        :param request flask_request: Flask Request
        :return: the request's endpoint and method
        """
        endpoint = flask_request.endpoint
        method = flask_request.method
        return method, endpoint

    # Consider moving this out to its own class. Logic is started to get dense and looks messy @jfuss
    @staticmethod
    def _parse_v1_payload_format_lambda_output(lambda_output: str, binary_types, flask_request, event_type):
        """
        Parses the output from the Lambda Container

        :param str lambda_output: Output from Lambda Invoke
        :param binary_types: list of binary types
        :param flask_request: flash request object
        :param event_type: determines the route event type
        :return: Tuple(int, dict, str, bool)
        """
        # pylint: disable-msg=too-many-statements
        try:
            json_output = json.loads(lambda_output)
        except ValueError as ex:
            raise LambdaResponseParseException("Lambda response must be valid json") from ex

        if not isinstance(json_output, dict):
            raise LambdaResponseParseException(f"Lambda returned {type(json_output)} instead of dict")

        if event_type == Route.HTTP and json_output.get("statusCode") is None:
            raise LambdaResponseParseException(f"Invalid API Gateway Response Key: statusCode is not in {json_output}")

        status_code = json_output.get("statusCode") or 200
        headers = LocalApigwService._merge_response_headers(
            json_output.get("headers") or {}, json_output.get("multiValueHeaders") or {}
        )

        body = json_output.get("body")
        if body is None:
            LOG.warning("Lambda returned empty body!")

        is_base_64_encoded = LocalApigwService.get_base_64_encoded(event_type, json_output)

        try:
            status_code = int(status_code)
            if status_code <= 0:
                raise ValueError
        except ValueError as ex:
            raise LambdaResponseParseException("statusCode must be a positive int") from ex

        try:
            if body:
                body = str(body)
        except ValueError as ex:
            raise LambdaResponseParseException(
                f"Non null response bodies should be able to convert to string: {body}"
            ) from ex

        invalid_keys = LocalApigwService._invalid_apig_response_keys(json_output, event_type)
        # HTTP API Gateway just skip the non allowed lambda response fields, but Rest API gateway fail on
        # the non allowed fields
        if event_type == Route.API and invalid_keys:
            raise LambdaResponseParseException(f"Invalid API Gateway Response Keys: {invalid_keys} in {json_output}")

        # If the customer doesn't define Content-Type default to application/json
        if "Content-Type" not in headers:
            LOG.info("No Content-Type given. Defaulting to 'application/json'.")
            headers["Content-Type"] = "application/json"

        try:
            # HTTP API Gateway always decode the lambda response only if isBase64Encoded field in response is True
            # regardless the response content-type
            # Rest API Gateway depends on the response content-type and the API configured BinaryMediaTypes to decide
            # if it will decode the response or not
            if (event_type == Route.HTTP and is_base_64_encoded) or (
                event_type == Route.API
                and LocalApigwService._should_base64_decode_body(
                    binary_types, flask_request, headers, is_base_64_encoded
                )
            ):
                body = base64.b64decode(body)
        except ValueError as ex:
            LambdaResponseParseException(str(ex))

        return status_code, headers, body

    @staticmethod
    def get_base_64_encoded(event_type, json_output):
        # The following behaviour is undocumented behaviour, and based on some trials
        # Http API gateway checks lambda response for isBase64Encoded field, and ignore base64Encoded
        # Rest API gateway checks first the field base64Encoded field, if not exist, it checks isBase64Encoded field

        if event_type == Route.API and json_output.get("base64Encoded") is not None:
            is_base_64_encoded = json_output.get("base64Encoded")
            field_name = "base64Encoded"
        elif json_output.get("isBase64Encoded") is not None:
            is_base_64_encoded = json_output.get("isBase64Encoded")
            field_name = "isBase64Encoded"
        else:
            is_base_64_encoded = False
            field_name = "isBase64Encoded"

        if isinstance(is_base_64_encoded, str) and is_base_64_encoded in ["true", "True", "false", "False"]:
            is_base_64_encoded = is_base_64_encoded in ["true", "True"]
        elif not isinstance(is_base_64_encoded, bool):
            raise LambdaResponseParseException(
                f"Invalid API Gateway Response Key: {is_base_64_encoded} is not a valid" f"{field_name}"
            )

        return is_base_64_encoded

    @staticmethod
    def _parse_v2_payload_format_lambda_output(lambda_output: str, binary_types, flask_request):
        """
        Parses the output from the Lambda Container. V2 Payload Format means that the event_type is only HTTP

        :param str lambda_output: Output from Lambda Invoke
        :param binary_types: list of binary types
        :param flask_request: flash request object
        :return: Tuple(int, dict, str, bool)
        """
        # pylint: disable-msg=too-many-statements
        # pylint: disable=too-many-branches
        try:
            json_output = json.loads(lambda_output)
        except ValueError as ex:
            raise LambdaResponseParseException("Lambda response must be valid json") from ex

        # lambda can return any valid json response in payload format version 2.0.
        # response can be a simple type like string, or integer
        # https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.response
        if isinstance(json_output, dict):
            body = json_output.get("body") if "statusCode" in json_output else json.dumps(json_output)
        else:
            body = json_output
            json_output = {}

        if body is None:
            LOG.warning("Lambda returned empty body!")

        status_code = json_output.get("statusCode") or 200
        headers = Headers(json_output.get("headers") or {})

        # cookies is a new field in payload format version 2.0 (a list)
        # https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
        # we need to move cookies to Set-Cookie headers.
        # each cookie becomes a set-cookie header
        # MDN link: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
        cookies = json_output.get("cookies")

        # cookies needs to be a list, otherwise the format is wrong and we can skip it
        if isinstance(cookies, list):
            for cookie in cookies:
                headers.add("Set-Cookie", cookie)

        is_base_64_encoded = json_output.get("isBase64Encoded") or False

        try:
            status_code = int(status_code)
            if status_code <= 0:
                raise ValueError
        except ValueError as ex:
            raise LambdaResponseParseException("statusCode must be a positive int") from ex

        try:
            if body:
                body = str(body)
        except ValueError as ex:
            raise LambdaResponseParseException(
                f"Non null response bodies should be able to convert to string: {body}"
            ) from ex

        # If the customer doesn't define Content-Type default to application/json
        if "Content-Type" not in headers:
            LOG.info("No Content-Type given. Defaulting to 'application/json'.")
            headers["Content-Type"] = "application/json"

        try:
            # HTTP API Gateway always decode the lambda response only if isBase64Encoded field in response is True
            # regardless the response content-type
            if is_base_64_encoded:
                # Note(xinhol): here in this method we change the type of the variable body multiple times
                # and confused mypy, we might want to avoid this and use multiple variables here.
                body = base64.b64decode(body)  # type: ignore
        except ValueError as ex:
            LambdaResponseParseException(str(ex))

        return status_code, headers, body

    @staticmethod
    def _invalid_apig_response_keys(output, event_type):
        allowable = {"statusCode", "body", "headers", "multiValueHeaders", "isBase64Encoded", "cookies"}
        if event_type == Route.API:
            allowable.add("base64Encoded")
        invalid_keys = output.keys() - allowable
        return invalid_keys

    @staticmethod
    def _should_base64_decode_body(binary_types, flask_request, lamba_response_headers, is_base_64_encoded):
        """
        Whether or not the body should be decoded from Base64 to Binary

        Parameters
        ----------
        binary_types list(basestring)
            Corresponds to self.binary_types (aka. what is parsed from SAM Template
        flask_request flask.request
            Flask request
        lamba_response_headers werkzeug.datastructures.Headers
            Headers Lambda returns
        is_base_64_encoded bool
            True if the body is Base64 encoded

        Returns
        -------
        True if the body from the request should be converted to binary, otherwise false

        """
        best_match_mimetype = flask_request.accept_mimetypes.best_match(lamba_response_headers.get_all("Content-Type"))
        is_best_match_in_binary_types = best_match_mimetype in binary_types or "*/*" in binary_types

        return best_match_mimetype and is_best_match_in_binary_types and is_base_64_encoded

    @staticmethod
    def _merge_response_headers(headers, multi_headers):
        """
        Merge multiValueHeaders headers with headers

        * If you specify values for both headers and multiValueHeaders, API Gateway merges them into a single list.
        * If the same key-value pair is specified in both, the value will only appear once.

        Parameters
        ----------
        headers dict
            Headers map from the lambda_response_headers
        multi_headers dict
            multiValueHeaders map from the lambda_response_headers

        Returns
        -------
        Merged list in accordance to the AWS documentation within a Flask Headers object

        """

        processed_headers = Headers(multi_headers)

        for header in headers:
            # Prevent duplication of values when the key-value pair exists in both
            # headers and multi_headers, but preserve order from multi_headers
            if header in multi_headers and headers[header] in multi_headers[header]:
                continue

            processed_headers.add(header, headers[header])

        return processed_headers
