import re
from functools import lru_cache
from typing import Any, Awaitable, Callable, Dict, FrozenSet, Iterable, Optional, Union

from blacksheep.baseapp import BaseApplication
from blacksheep.messages import Request, Response
from blacksheep.server.routing import Route, Router

from .responses import not_found, ok, status_code


class CORSPolicy:
    def __init__(
        self,
        *,
        allow_methods: Union[None, str, Iterable[str]] = None,
        allow_headers: Union[None, str, Iterable[str]] = None,
        allow_origins: Union[None, str, Iterable[str]] = None,
        allow_credentials: bool = False,
        max_age: int = 5,
        expose_headers: Union[None, str, Iterable[str]] = None,
    ) -> None:
        if expose_headers is None:
            expose_headers = self.default_expose_headers()
        self._max_age: int = 300
        self._allow_methods: FrozenSet[str]
        self._allow_headers: FrozenSet[str]
        self._allow_origins: FrozenSet[str]
        self._expose_headers: FrozenSet[str]
        self.allow_methods = allow_methods or []
        self.allow_headers = allow_headers or []
        self.allow_origins = allow_origins or []
        self.allow_credentials = bool(allow_credentials)
        self.expose_headers = expose_headers
        self.max_age = max_age

    def default_expose_headers(self) -> FrozenSet[str]:
        return frozenset(
            value.lower()
            for value in {
                "Transfer-Encoding",
                "Content-Encoding",
                "Vary",
                "Request-Context",
                "Set-Cookie",
                "Server",
                "Date",
            }
        )

    def _normalize_set(
        self, value: Union[None, str, Iterable[str]], ci_function: Callable[[str], str]
    ) -> FrozenSet[str]:
        if value is None:
            return frozenset()
        if isinstance(value, str):
            value = re.split(r"\s|,\s?|;\s?", value)
        return frozenset(map(ci_function, value))

    @property
    def allow_methods(self) -> FrozenSet[str]:
        return self._allow_methods

    @allow_methods.setter
    def allow_methods(self, value) -> None:
        self._allow_methods = self._normalize_set(value, str.upper)

    @property
    def allow_headers(self) -> FrozenSet[str]:
        return self._allow_headers

    @allow_headers.setter
    def allow_headers(self, value) -> None:
        self._allow_headers = self._normalize_set(value, str.lower)

    @property
    def allow_origins(self) -> FrozenSet[str]:
        return self._allow_origins

    @allow_origins.setter
    def allow_origins(self, value) -> None:
        self._allow_origins = self._normalize_set(value, str.lower)

    @property
    def max_age(self) -> int:
        return self._max_age

    @max_age.setter
    def max_age(self, value) -> None:
        int_value = int(value)
        if int_value < 0:
            raise ValueError("max_age must be a positive number")
        self._max_age = int_value

    @property
    def expose_headers(self) -> FrozenSet[str]:
        return self._expose_headers

    @expose_headers.setter
    def expose_headers(self, value) -> None:
        self._expose_headers = self._normalize_set(value, str.lower)

    def allow_any_header(self) -> "CORSPolicy":
        self.allow_headers = frozenset("*")
        return self

    def allow_any_method(self) -> "CORSPolicy":
        self.allow_methods = frozenset("*")
        return self

    def allow_any_origin(self) -> "CORSPolicy":
        self.allow_origins = frozenset("*")
        return self


class CORSConfigurationError(Exception):
    pass


class CORSPolicyNotConfiguredError(CORSConfigurationError):
    def __init__(self, name: str) -> None:
        super().__init__(
            f'The policy with name "{name}" is not configured. '
            "Configure this policy before applying it to request handlers."
        )


class NotRequestHandlerError(CORSConfigurationError):
    def __init__(self) -> None:
        super().__init__(
            "The decorated function is not a request handler. "
            "Apply the @cors() decorator after decorators that define routes."
        )


class CORSStrategy:
    def __init__(self, default_policy: CORSPolicy, router: Router) -> None:
        self.default_policy = default_policy
        self._router = router
        self._policies: Dict[str, CORSPolicy] = {}
        self._policies_by_route: Dict[Route, CORSPolicy] = {}

    @property
    def router(self) -> Router:
        return self._router

    @property
    def policies(self) -> Dict[str, CORSPolicy]:
        return self._policies

    def add_policy(self, name: str, policy: CORSPolicy) -> "CORSStrategy":
        """
        Adds a new CORS policy by name to the overall CORS configuration.

        The CORS policy can then be associated to specific request handlers,
        using the instance of `CORSStrategy` as a function decorator:

        @app.cors("example")
        @app.route("/")
        async def foo():
            ....
        """
        if not name:
            raise CORSConfigurationError(
                "A name is required to configure additional CORS policies."
            )

        if name in self.policies:
            raise CORSConfigurationError(
                f"A policy with name {name} is already configured. "
                "The name of CORS policies must be unique."
            )

        self.policies[name] = policy
        return self

    def get_policy_by_route(self, route: Route) -> Optional[CORSPolicy]:
        return self._policies_by_route.get(route)

    def get_policy_by_route_or_default(self, route: Route) -> CORSPolicy:
        return self.get_policy_by_route(route) or self.default_policy

    def __call__(self, policy: str):
        """Decorates a request handler to bind it to a specific policy by name."""

        def decorator(fn):
            is_match = False
            policy_object = self.policies.get(policy)
            if not policy_object:
                raise CORSPolicyNotConfiguredError(policy)

            for route in self.router:
                if route.handler is fn:
                    self._policies_by_route[route] = policy_object
                    is_match = True

            if not is_match:
                raise NotRequestHandlerError()

            return fn

        return decorator


def _get_cors_error_response(message: str) -> Response:
    response = status_code(400)
    response.add_header(b"CORS-Error", message.encode())
    return response


def _get_invalid_origin_response() -> Response:
    return _get_cors_error_response(
        "The origin of the request is not enabled by CORS rules."
    )


def _get_invalid_method_response() -> Response:
    return _get_cors_error_response(
        "The method of the request is not enabled by CORS rules."
    )


def _get_invalid_header_response(header_name: str) -> Response:
    return _get_cors_error_response(
        f'The "{header_name}" request header is not enabled by CORS rules.'
    )


@lru_cache(maxsize=100)
def _get_encoded_value_for_set(items: FrozenSet[str]) -> bytes:
    if not items:
        return b""
    return ", ".join(items).encode()


@lru_cache(maxsize=20)
def _get_encoded_value_for_max_age(max_age: int) -> bytes:
    return str(max_age).encode()


def get_cors_middleware(
    app: BaseApplication,
    strategy: CORSStrategy,
) -> Callable[[Request, Callable[..., Any]], Awaitable[Response]]:
    async def cors_middleware(request: Request, handler):
        origin = request.get_first_header(b"Origin")

        if not origin:
            # not a CORS request
            return await handler(request)

        next_request_method = request.get_first_header(b"Access-Control-Request-Method")

        # match policy by route to support route-specific CORS rules,
        # instead of supporting only global CORS rules
        # this approach has the added value that destination routes are validated for
        # OPTIONS requests, instead of assuming a path is handled
        route = strategy.router.get_matching_route(
            next_request_method or request.method, request.url.path
        )

        if route is None:
            return not_found()

        policy = strategy.get_policy_by_route_or_default(route)
        allowed_origins = _get_encoded_value_for_set(policy.allow_origins)
        allowed_methods = _get_encoded_value_for_set(policy.allow_methods)
        expose_headers = _get_encoded_value_for_set(policy.expose_headers)
        max_age = _get_encoded_value_for_max_age(policy.max_age)

        if (
            "*" not in policy.allow_origins
            and origin.decode() not in policy.allow_origins
        ):
            return _get_invalid_origin_response()

        if next_request_method:
            # This is a preflight request;
            if (
                "*" not in policy.allow_methods
                and next_request_method.decode() not in policy.allow_methods
            ):
                return _get_invalid_method_response()

            next_request_headers = request.get_first_header(
                b"Access-Control-Request-Headers"
            )

            if next_request_headers and "*" not in policy.allow_headers:
                for value in next_request_headers.split(b","):
                    str_value = value.strip().decode()
                    if str_value.lower() not in policy.allow_headers:
                        return _get_invalid_header_response(str_value)

            response = ok()
            response.set_header(b"Access-Control-Allow-Methods", allowed_methods)
            response.set_header(b"Access-Control-Allow-Origin", allowed_origins)

            if next_request_headers:
                response.set_header(
                    b"Access-Control-Allow-Headers", next_request_headers
                )

            if policy.allow_credentials:
                response.set_header(b"Access-Control-Allow-Credentials", b"true")

            response.set_header(b"Access-Control-Max-Age", max_age)
            return response

        # regular CORS request (non-preflight)
        if (
            "*" not in policy.allow_methods
            and request.method not in policy.allow_methods
        ):
            return _get_invalid_method_response()

        # CORS response headers must be set even if exceptions are used to
        # control the flow of the application.
        # For example if a request handler throws a "Conflict" exception to handle
        # unique constraints conflicts in a relational database.
        # If CORS response headers weren't set, error details wouldn't be available
        # to the client code, in such circumstances.
        try:
            response = await handler(request)
        except Exception as exc:
            response = await app.handle_request_handler_exception(request, exc)

        response.set_header(b"Access-Control-Allow-Origin", allowed_origins)
        response.set_header(b"Access-Control-Expose-Headers", expose_headers)

        return response

    return cors_middleware
