"""FastCC application server.

This module provides the `Application` class, which acts as an
MQTT-based server that receives messages, routes them to registered
handlers, and sends responses back to the client.
"""

import asyncio
import logging
import typing

if typing.TYPE_CHECKING:
    from collections.abc import Callable

    import aiomqtt

    from .types import Packet

import paho.mqtt.packettypes
import paho.mqtt.properties

from .client import Client
from .constants import (
    DEFAULT_MQTT_HOST,
    DEFAULT_MQTT_PORT,
    PACKET_PARAM_NAME,
    PATH_PARAM_PATTERN,
    TOPIC_SEPARATOR,
    WILDCARD_PARAM_NAME,
)
from .exceptions import FastCCError, RouteExecutionError
from .qos import QoS
from .router import Route, Router
from .serialization import deserialize

_logger = logging.getLogger(__name__)


class Application(Client):
    """MQTT application server for FastCC.

    The application server manages routes, dependency injectors, and
    exception handlers. It listens for incoming MQTT messages,
    dispatches them to the appropriate handler, and sends responses
    back via MQTT v5 response topics.

    Parameters
    ----------
    host
        MQTT broker host.
    port
        MQTT broker port.
    kwargs
        Additional keyword arguments passed to `Client`.
    """

    def __init__(
        self,
        host: str = DEFAULT_MQTT_HOST,
        port: int = DEFAULT_MQTT_PORT,
        **kwargs: typing.Any,
    ) -> None:
        self._router = Router()
        self._injectors: dict[str, typing.Any] = {}
        self._exception_handlers: dict[
            type[Exception],
            Callable[[Exception], RouteExecutionError],
        ] = {}
        super().__init__(host, port, **kwargs)

    def add_router(self, router: Router) -> None:
        """Add another router to this application.

        Parameters
        ----------
        router
            Router to add.
        """
        self._router.add_router(router)

    def add_injector(self, **injectors: typing.Any) -> None:
        """Add a dependency injector to this application.

        Dependency injectors are used to inject dependencies into route
        handlers. The injector is identified by its name, which is then
        used in the route handler's signature.

        Parameters
        ----------
        injectors
            Injectors to add.
        """
        self._injectors.update(injectors)

    def add_exception_handler(
        self,
        exception_type: type[Exception],
        handler: Callable[[Exception], RouteExecutionError],
    ) -> None:
        """Add an exception handler to this application.

        Exception handlers are called when a route handler raises an
        exception. The exception handler should return a
        `RouteExecutionError`, which is then sent back to the client as
        an error response.

        Parameters
        ----------
        exception_type
            Type of exceptions to handle.
        handler
            Exception handler to add.

        Raises
        ------
        TypeError
            If the exception type is a system exception (e.g.
            `KeyboardInterrupt` or `SystemExit`), which are not allowed
            to be caught.
        """
        if issubclass(exception_type, (KeyboardInterrupt, SystemExit)):
            error_message = (
                f"Cannot register handler for "
                f"{exception_type.__name__!r}: system exceptions must "
                f"not be caught."
            )
            raise TypeError(error_message)
        self._exception_handlers[exception_type] = handler

    async def run(self) -> None:
        """Start the application server.

        Validates all injectors, subscribes to all registered routes,
        and begins listening for incoming messages.
        """
        self.__validate_injectors()
        await self.__subscribe_all()

        _logger.info("Application started")
        try:
            await self.__listen()
        finally:
            _logger.info("Application shut down")

    async def __listen(self) -> None:
        async with asyncio.TaskGroup() as tg:
            async for message in self.messages:
                topic = message.topic.value

                _logger.debug("Received message on topic %r", topic)

                route = self._router.get(topic)
                if route is None:
                    _logger.warning(
                        "No route found for topic %r, ignoring message",
                        topic,
                    )
                    continue

                if route.is_stream:
                    tg.create_task(self.__handle_stream(route, message))
                else:
                    tg.create_task(self.__handle_request(route, message))

    def __validate_injectors(self) -> None:
        for _, route in self._router.routes:
            missing = route.expected_injectors - self._injectors.keys()
            if missing:
                names = ", ".join(sorted(missing))
                error_message = (
                    f"Route {route.topic!r} requires missing "
                    f"injector(s): {names}"
                )
                _logger.error(error_message)
                raise FastCCError(error_message)

    async def __subscribe_all(self) -> None:
        for topic, route in self._router.routes:
            await self.subscribe(
                topic,
                qos=route.qos,
                options=route.options,
                properties=route.properties,
                timeout=route.timeout,
            )

    async def __handle_request(
        self,
        route: Route,
        message: aiomqtt.Message,
    ) -> None:
        response_topic = getattr(message.properties, "ResponseTopic", None)
        properties = None
        try:
            params = self.__collect_route_params(route, message)
            response = await route.handler(**params)
        except Exception as e:  # noqa: BLE001
            _logger.debug(
                "Error while processing request on topic %r: '%s.%s' "
                "raised %s(%r)",
                route.topic,
                route.handler.__module__,
                route.handler.__qualname__,
                type(e).__name__,
                str(e),
            )

            if response_topic is None:
                return

            response, properties = self.__build_error_response(e)

        if response_topic is None:
            return

        await self._send_response(response_topic, response, properties)

    async def __handle_stream(
        self,
        route: Route,
        message: aiomqtt.Message,
    ) -> None:
        response_topic = getattr(message.properties, "ResponseTopic", None)
        if response_topic is None:
            _logger.warning(
                "Stream route %r requires ResponseTopic property, but "
                "none was provided - ignoring message",
                route.topic,
            )
            return

        properties = None
        try:
            params = self.__collect_route_params(route, message)
            async for response in route.handler(**params):
                await self._send_response(response_topic, response)
            await self._send_response(response_topic, None)
        except Exception as e:  # noqa: BLE001
            _logger.debug(
                "Error while processing stream on topic %r: '%s.%s' "
                "raised %s(%r)",
                route.topic,
                route.handler.__module__,
                route.handler.__qualname__,
                type(e).__name__,
                str(e),
            )
            response, properties = self.__build_error_response(e)
            await self._send_response(response_topic, response, properties)

    def __collect_route_params(
        self,
        route: Route,
        message: aiomqtt.Message,
    ) -> dict[str, typing.Any]:
        params: dict[str, typing.Any] = {}
        if route.expects_packet:
            params[PACKET_PARAM_NAME] = deserialize(
                message.payload,
                route.expected_packet_type,  # type: ignore[arg-type]
            )

        if route.expects_wildcard:
            params[WILDCARD_PARAM_NAME] = _extract_wildcard(
                message.topic.value,
                route.topic,
            )

        path_params = _extract_path_parameters(message.topic.value, route.topic)
        params.update(path_params)

        injectors = {
            key: value
            for key, value in self._injectors.items()
            if key in route.expected_injectors
        }
        params.update(injectors)
        return params

    def __build_error_response(
        self,
        exc: Exception,
    ) -> tuple[str, paho.mqtt.properties.Properties]:
        error = self._to_execution_error(exc)
        properties = paho.mqtt.properties.Properties(
            paho.mqtt.packettypes.PacketTypes.PUBLISH,
        )
        properties.UserProperty = [
            ("error", str(error.error_code)),
        ]
        return str(error), properties

    def _to_execution_error(self, exc: Exception) -> RouteExecutionError:
        for cls in type(exc).__mro__:
            if not issubclass(cls, Exception):
                break
            handler = self._exception_handlers.get(cls)
            if handler is not None:
                return handler(exc)
        return RouteExecutionError(str(exc))

    async def _send_response(
        self,
        topic: str,
        response: Packet | None,
        properties: paho.mqtt.properties.Properties | None = None,
    ) -> None:
        try:
            await self.publish(
                topic,
                response,
                qos=QoS.AT_LEAST_ONCE,
                properties=properties,
            )
        except Exception:
            _logger.exception("Failed to send response on topic %r", topic)


def _extract_wildcard(
    topic: str,
    pattern: str,
) -> str:
    """Extract the wildcard portion from a concrete topic.

    Parameters
    ----------
    topic
        Concrete MQTT topic.
    pattern
        The matched route pattern (must end with `#`).

    Returns
    -------
    str
        The remaining topic segments after the `#` position, joined
        with `/`.
    """
    # Number of segments before the `#`.
    prefix_len = len(pattern.split(TOPIC_SEPARATOR)) - 1
    return TOPIC_SEPARATOR.join(topic.split(TOPIC_SEPARATOR)[prefix_len:])


def _extract_path_parameters(topic: str, pattern: str) -> dict[str, str]:
    """Extract path parameters from a concrete topic.

    Parameters
    ----------
    topic
        Concrete MQTT topic.
    pattern
        The matched route pattern (must have the same number of segments
        as the topic, and may contain path parameters in the form of
        `{param_name}`).

    Returns
    -------
    dict[str, str]
        Mapping of parameter name to extracted segment value.
    """
    topic_parts = topic.split("/")
    pattern_parts = pattern.split("/")

    params: dict[str, str] = {}
    for index, p in enumerate(pattern_parts):
        match = PATH_PARAM_PATTERN.fullmatch(p)
        if match:
            params[match.group(1)] = topic_parts[index]

    return params
