from __future__ import annotations

import inspect
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Mapping

from .context import pop_request_context, push_request_context
from .exceptions import HTTPException, InternalServerError, MethodNotAllowed, NotFound
from .request import Request
from .response import Response, make_response
from .routing import CompiledRule, build_rule_path, compile_rule, convert_matched_params
from .typing import Message, Receive, Scope, Send

CallNext = Callable[[Request], Awaitable[Response]]
HTTPMiddlewareFunc = Callable[[Request, CallNext], Any]


@dataclass(slots=True)
class Route:
    rule: str
    methods: set[str]
    endpoint: str
    handler: Callable[..., Any]
    compiled_rule: CompiledRule


class Exile:
    def __init__(
        self,
        import_name: str,
        *,
        debug: bool = False,
        static_folder: str | None = "static",
        template_folder: str | None = "templates",
    ) -> None:
        self.import_name = import_name
        self.debug = debug
        self.static_folder = static_folder
        self.template_folder = template_folder
        self.config: dict[str, Any] = {
            "STATIC_MAX_AGE": 3600,
        }

        self._routes: list[Route] = []
        self._routes_by_endpoint: dict[str, Route] = {}
        self._error_handlers: dict[Any, Callable[..., Any]] = {}
        self._before_request_funcs: list[Callable[..., Any]] = []
        self._after_request_funcs: list[Callable[..., Any]] = []
        self._teardown_request_funcs: list[Callable[..., Any]] = []
        self._blueprint_before_request_funcs: dict[str, list[Callable[..., Any]]] = {}
        self._blueprint_after_request_funcs: dict[str, list[Callable[..., Any]]] = {}
        self._blueprint_teardown_request_funcs: dict[str, list[Callable[..., Any]]] = {}
        self._blueprint_error_handlers: dict[str, dict[Any, Callable[..., Any]]] = {}
        self._http_middlewares: list[HTTPMiddlewareFunc] = []
        self._asgi_middleware_defs: list[tuple[Callable[..., Any], dict[str, Any]]] = []
        self._asgi_app_cache: Callable[[Scope, Receive, Send], Awaitable[None]] | None = None
        self._template_env: Any = None
        self._startup_funcs: list[Callable[..., Any]] = []
        self._shutdown_funcs: list[Callable[..., Any]] = []

        if self.static_folder:
            self._register_default_static_route()

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        app = self._build_asgi_app()
        await app(scope, receive, send)

    def route(
        self,
        rule: str,
        *,
        methods: list[str] | None = None,
        endpoint: str | None = None,
    ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
        allowed_methods = {m.upper() for m in (methods or ["GET"])}
        if "GET" in allowed_methods:
            allowed_methods.add("HEAD")
        compiled_rule = compile_rule(rule)

        def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
            route_obj = Route(
                rule=rule,
                methods=set(allowed_methods),
                endpoint=endpoint or func.__name__,
                handler=func,
                compiled_rule=compiled_rule,
            )
            self._routes.append(route_obj)
            self._routes_by_endpoint[route_obj.endpoint] = route_obj
            return func

        return decorator

    def get(self, rule: str, **kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
        return self.route(rule, methods=["GET"], **kwargs)

    def post(self, rule: str, **kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
        return self.route(rule, methods=["POST"], **kwargs)

    def put(self, rule: str, **kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
        return self.route(rule, methods=["PUT"], **kwargs)

    def patch(self, rule: str, **kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
        return self.route(rule, methods=["PATCH"], **kwargs)

    def delete(self, rule: str, **kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
        return self.route(rule, methods=["DELETE"], **kwargs)

    def options(self, rule: str, **kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
        return self.route(rule, methods=["OPTIONS"], **kwargs)

    def head(self, rule: str, **kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
        return self.route(rule, methods=["HEAD"], **kwargs)

    def before_request(self, func: Callable[..., Any]) -> Callable[..., Any]:
        self._before_request_funcs.append(func)
        return func

    def after_request(self, func: Callable[..., Any]) -> Callable[..., Any]:
        self._after_request_funcs.append(func)
        return func

    def teardown_request(self, func: Callable[..., Any]) -> Callable[..., Any]:
        self._teardown_request_funcs.append(func)
        return func

    def middleware(self, kind: str = "http") -> Callable[[HTTPMiddlewareFunc], HTTPMiddlewareFunc]:
        normalized = kind.lower()
        if normalized != "http":
            raise ValueError(f"unsupported middleware type: {kind}")

        def decorator(func: HTTPMiddlewareFunc) -> HTTPMiddlewareFunc:
            self._http_middlewares.append(func)
            return func

        return decorator

    def add_middleware(self, middleware_cls: Callable[..., Any], **options: Any) -> None:
        self._asgi_middleware_defs.append((middleware_cls, dict(options)))
        self._asgi_app_cache = None

    def errorhandler(self, code_or_exception: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
        def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
            self.register_error_handler(code_or_exception, func)
            return func

        return decorator

    def register_error_handler(self, code_or_exception: Any, func: Callable[..., Any]) -> None:
        self._error_handlers[code_or_exception] = func

    def register_blueprint(self, blueprint: Any, *, url_prefix: str | None = None) -> None:
        blueprint.register(self, url_prefix=url_prefix)

    def on_startup(self, func: Callable[..., Any]) -> Callable[..., Any]:
        self._startup_funcs.append(func)
        return func

    def on_shutdown(self, func: Callable[..., Any]) -> Callable[..., Any]:
        self._shutdown_funcs.append(func)
        return func

    def _register_blueprint_hooks(
        self,
        blueprint_name: str,
        *,
        before_request_funcs: list[Callable[..., Any]] | None = None,
        after_request_funcs: list[Callable[..., Any]] | None = None,
        teardown_request_funcs: list[Callable[..., Any]] | None = None,
    ) -> None:
        self._blueprint_before_request_funcs.setdefault(blueprint_name, []).extend(before_request_funcs or [])
        self._blueprint_after_request_funcs.setdefault(blueprint_name, []).extend(after_request_funcs or [])
        self._blueprint_teardown_request_funcs.setdefault(blueprint_name, []).extend(teardown_request_funcs or [])

    def _register_blueprint_error_handler(
        self,
        blueprint_name: str,
        code_or_exception: Any,
        func: Callable[..., Any],
    ) -> None:
        handlers = self._blueprint_error_handlers.setdefault(blueprint_name, {})
        handlers[code_or_exception] = func

    def run(
        self,
        host: str = "127.0.0.1",
        port: int = 8000,
        *,
        reload: bool = False,
    ) -> None:
        try:
            import uvicorn
        except ModuleNotFoundError as exc:
            raise RuntimeError("uvicorn is required for app.run()") from exc
        uvicorn.run(self, host=host, port=port, reload=reload)

    def url_for(self, endpoint: str, **values: Any) -> str:
        route = self._routes_by_endpoint.get(endpoint)
        if route is None:
            raise KeyError(f"unknown endpoint: {endpoint}")
        return build_rule_path(route.rule, values)

    def get_template_environment(self) -> Any:
        if self._template_env is not None:
            return self._template_env

        if not self.template_folder:
            raise RuntimeError("template folder is disabled")
        try:
            from jinja2 import Environment, FileSystemLoader
        except ModuleNotFoundError as exc:
            raise RuntimeError("jinja2 is required for template rendering") from exc

        env = Environment(loader=FileSystemLoader(str(self.template_folder_path)), autoescape=True)
        env.globals["url_for"] = self.url_for
        self._template_env = env
        return env

    def _build_asgi_app(self) -> Callable[[Scope, Receive, Send], Awaitable[None]]:
        if self._asgi_app_cache is not None:
            return self._asgi_app_cache

        app: Callable[[Scope, Receive, Send], Awaitable[None]] = self._dispatch_asgi
        for middleware_cls, options in reversed(self._asgi_middleware_defs):
            app = middleware_cls(app, **options)

        self._asgi_app_cache = app
        return app

    async def _dispatch_asgi(self, scope: Scope, receive: Receive, send: Send) -> None:
        scope_type = scope.get("type")
        if scope_type == "http":
            await self._handle_http(scope, receive, send)
            return
        if scope_type == "lifespan":
            await self._handle_lifespan(receive, send)
            return
        if scope_type == "websocket":
            await send({"type": "websocket.close", "code": 1000})
            return

    async def _handle_http(self, scope: Scope, receive: Receive, send: Send) -> None:
        request = Request(scope, receive)
        context_tokens = push_request_context(self, request)
        err: Exception | None = None

        try:
            response = await self._run_http_middlewares(request)
        except Exception as exc:
            err = exc
            response = await self._handle_exception(exc, request=request)
        finally:
            await self._run_teardown_request(err, blueprint_name=self._get_request_blueprint(request))
            pop_request_context(context_tokens)

        await response.asgi(scope, receive, send)

    async def _handle_lifespan(self, receive: Receive, send: Send) -> None:
        while True:
            message: Message = await receive()
            message_type = message.get("type")
            if message_type == "lifespan.startup":
                try:
                    for func in self._startup_funcs:
                        await self._invoke_hook(func)
                except Exception as exc:
                    await send({"type": "lifespan.startup.failed", "message": str(exc)})
                    continue
                await send({"type": "lifespan.startup.complete"})
            elif message_type == "lifespan.shutdown":
                try:
                    for func in self._shutdown_funcs:
                        await self._invoke_hook(func)
                except Exception as exc:
                    await send({"type": "lifespan.shutdown.failed", "message": str(exc)})
                    return
                await send({"type": "lifespan.shutdown.complete"})
                return

    async def _run_http_middlewares(self, request: Request) -> Response:
        next_handler: CallNext = self._dispatch_request
        for middleware in reversed(self._http_middlewares):
            downstream = next_handler

            async def _wrap(req: Request, middleware: HTTPMiddlewareFunc = middleware, downstream: CallNext = downstream) -> Response:
                result = await self._invoke(middleware, req, downstream)
                return self._coerce_response(result)

            next_handler = _wrap
        return await next_handler(request)

    async def _dispatch_request(self, request: Request) -> Response:
        before_result = await self._run_before_request()
        if before_result is not None:
            response = self._coerce_response(before_result)
            return await self._run_after_request(response, blueprint_name=None)

        route, path_params, allowed = self._match_route(path=request.path, method=request.method)
        if route is None:
            if request.method == "OPTIONS" and allowed:
                allow = ", ".join(sorted(allowed | {"OPTIONS"}))
                return make_response("", status=204, headers={"Allow": allow})
            if allowed:
                raise MethodNotAllowed(allowed_methods=allowed)
            raise NotFound()

        blueprint_name = self._extract_blueprint_name(route.endpoint)
        request.scope["_exile.endpoint"] = route.endpoint
        request.scope["_exile.blueprint"] = blueprint_name

        before_blueprint_result = await self._run_blueprint_before_request(blueprint_name)
        if before_blueprint_result is not None:
            response = self._coerce_response(before_blueprint_result)
            return await self._run_after_request(response, blueprint_name=blueprint_name)

        result = await self._invoke(route.handler, **path_params)
        response = self._coerce_response(result)
        return await self._run_after_request(response, blueprint_name=blueprint_name)

    async def _invoke(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
        result = func(*args, **kwargs)
        if inspect.isawaitable(result):
            return await result
        return result

    async def _invoke_hook(self, func: Callable[..., Any], *preferred_args: Any) -> Any:
        signature = inspect.signature(func)
        params = list(signature.parameters.values())
        has_var_args = any(param.kind == inspect.Parameter.VAR_POSITIONAL for param in params)
        if has_var_args:
            args = preferred_args
        else:
            positional = [
                param
                for param in params
                if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
            ]
            args = preferred_args[: len(positional)]
        return await self._invoke(func, *args)

    def _match_route(self, *, path: str, method: str) -> tuple[Route | None, dict[str, Any], set[str]]:
        allowed_methods: set[str] = set()
        for route in self._routes:
            match = route.compiled_rule.pattern.fullmatch(path)
            if match is None:
                continue
            if method in route.methods:
                params = convert_matched_params(route.compiled_rule, match.groupdict())
                return route, params, set()
            allowed_methods.update(route.methods)
        return None, {}, allowed_methods

    def _coerce_response(self, result: Any) -> Response:
        if isinstance(result, tuple):
            if len(result) == 2:
                body, status = result
                return make_response(body, status=int(status))
            if len(result) == 3:
                body, status, headers = result
                if not isinstance(headers, Mapping):
                    raise TypeError("response headers must be a mapping")
                return make_response(body, status=int(status), headers=headers)
            raise TypeError("tuple responses must be (body, status) or (body, status, headers)")
        return make_response(result)

    async def _run_before_request(self) -> Any:
        for func in self._before_request_funcs:
            result = await self._invoke_hook(func)
            if result is not None:
                return result
        return None

    async def _run_blueprint_before_request(self, blueprint_name: str | None) -> Any:
        if not blueprint_name:
            return None
        for func in self._blueprint_before_request_funcs.get(blueprint_name, []):
            result = await self._invoke_hook(func)
            if result is not None:
                return result
        return None

    async def _run_after_request(self, response: Response, *, blueprint_name: str | None) -> Response:
        current = response
        if blueprint_name:
            for func in self._blueprint_after_request_funcs.get(blueprint_name, []):
                maybe_response = await self._invoke_hook(func, current)
                if maybe_response is not None:
                    current = self._coerce_response(maybe_response)
        for func in self._after_request_funcs:
            maybe_response = await self._invoke_hook(func, current)
            if maybe_response is not None:
                current = self._coerce_response(maybe_response)
        return current

    async def _run_teardown_request(self, error: Exception | None, *, blueprint_name: str | None) -> None:
        if blueprint_name:
            for func in self._blueprint_teardown_request_funcs.get(blueprint_name, []):
                await self._invoke_hook(func, error)
        for func in self._teardown_request_funcs:
            await self._invoke_hook(func, error)

    async def _handle_exception(self, exc: Exception, *, request: Request | None = None) -> Response:
        blueprint_name = self._get_request_blueprint(request) if request else None
        handler = self._find_error_handler(exc, blueprint_name=blueprint_name)
        if handler is not None:
            return self._coerce_response(await self._invoke_hook(handler, exc))

        if isinstance(exc, HTTPException):
            return make_response(exc.description, status=exc.status_code, headers=exc.headers)

        internal_error = InternalServerError()
        if self.debug:
            stack = traceback.format_exc()
            return make_response(stack, status=500, headers={"Content-Type": "text/plain; charset=utf-8"})
        return make_response(internal_error.description, status=internal_error.status_code)

    def _find_error_handler(self, exc: Exception, *, blueprint_name: str | None = None) -> Callable[..., Any] | None:
        if blueprint_name:
            blueprint_handlers = self._blueprint_error_handlers.get(blueprint_name, {})
            handler = self._lookup_error_handler(blueprint_handlers, exc)
            if handler is not None:
                return handler

        return self._lookup_error_handler(self._error_handlers, exc)

    def _lookup_error_handler(
        self,
        registry: Mapping[Any, Callable[..., Any]],
        exc: Exception,
    ) -> Callable[..., Any] | None:
        if isinstance(exc, HTTPException):
            code_handler = registry.get(exc.status_code)
            if code_handler is not None:
                return code_handler

        for cls in type(exc).__mro__:
            handler = registry.get(cls)
            if handler is not None:
                return handler
        return None

    def _extract_blueprint_name(self, endpoint: str) -> str | None:
        if "." not in endpoint:
            return None
        return endpoint.split(".", 1)[0]

    def _get_request_blueprint(self, request: Request | None) -> str | None:
        if request is None:
            return None
        value = request.scope.get("_exile.blueprint")
        if isinstance(value, str):
            return value
        return None

    def _register_default_static_route(self) -> None:
        async def _serve_static(filename: str) -> Response:
            from .staticfiles import send_from_directory

            configured_max_age = self.config.get("STATIC_MAX_AGE", 3600)
            try:
                max_age = int(configured_max_age)
            except (TypeError, ValueError):
                max_age = 3600

            return send_from_directory(self.static_folder_path, filename, max_age=max_age)

        self.route("/static/<path:filename>", methods=["GET"], endpoint="static")(_serve_static)

    @property
    def static_folder_path(self) -> Path:
        if not self.static_folder:
            raise RuntimeError("static folder is disabled")
        return Path(self.static_folder)

    @property
    def template_folder_path(self) -> Path:
        if not self.template_folder:
            raise RuntimeError("template folder is disabled")
        return Path(self.template_folder)
