"""MQTT topic routing.

This module provides the core routing infrastructure for mapping MQTT
topic patterns to asynchronous handler functions. It supports
single-level wildcards by adding path parameters (e.g. `{param}`) and
a multi-level wildcard by adding a `#` at the end of the topic.
"""

import contextlib
import dataclasses
import inspect
import typing
from collections.abc import AsyncIterator

if typing.TYPE_CHECKING:
    import datetime
    from collections.abc import Callable, Iterator

    import paho.mqtt.properties
    import paho.mqtt.subscribeoptions

    from .types import AnyCallable

import google.protobuf.message

from .constants import (
    DEFAULT_MESSAGING_TIMEOUT,
    INVALID_TOPIC_CHARS,
    MAX_TOPIC_DEPTH,
    MULTI_LEVEL_WILDCARD,
    PACKET_PARAM_NAME,
    PATH_PARAM_PATTERN,
    SINGLE_LEVEL_WILDCARD,
    TOPIC_SEPARATOR,
    WILDCARD_PARAM_NAME,
)
from .exceptions import RouteConflictError, RouteValidationError
from .qos import QoS


@dataclasses.dataclass(
    repr=False,
    eq=False,
    frozen=True,
    match_args=False,
    slots=True,
)
class Route:
    """A registered route mapping a topic pattern to a handler.

    Attributes
    ----------
    topic
        The full MQTT topic pattern including any router prefix. May
        contain path parameters (e.g. `{device_id}`) and the
        multi-level wildcard `#`.
    handler
        The asynchronous handler function invoked when a message
        matches this route.
    qos
        QoS level for the MQTT subscription.
    options
        MQTT subscribe options.
    properties
        MQTT subscribe properties.
    timeout
        Timeout for the subscribe operation.
    is_stream
        Whether the handler is an async generator function, indicating
        that it produces a stream of responses.
    expects_packet
        Whether the handler expects a `packet` parameter containing the
        raw message payload.
    expected_packet_type
        If `expects_packet` is `True`, the expected type of the
        deserialized packet.
    expects_wildcard
        Whether the handler expects a `wildcard` parameter containing
        the matched portion of the topic corresponding to a `#`
        wildcard.
    expected_injectors
        The set of injector names expected by the handler, derived from
        its parameters excluding path parameters and reserved names.
    """

    topic: str
    handler: AnyCallable

    _: dataclasses.KW_ONLY
    qos: QoS
    options: paho.mqtt.subscribeoptions.SubscribeOptions | None
    properties: paho.mqtt.properties.Properties | None
    timeout: datetime.timedelta | None

    is_stream: bool = dataclasses.field(init=False)
    expects_packet: bool = dataclasses.field(init=False)
    expected_packet_type: type | None = dataclasses.field(init=False)
    expects_wildcard: bool = dataclasses.field(init=False)
    expected_injectors: set[str] = dataclasses.field(init=False)
    expected_response_type: type | None = dataclasses.field(init=False)

    def __post_init__(self) -> None:
        object.__setattr__(
            self,
            "is_stream",
            inspect.isasyncgenfunction(self.handler),
        )
        sig = inspect.signature(self.handler)
        object.__setattr__(
            self,
            "expects_packet",
            PACKET_PARAM_NAME in sig.parameters,
        )
        object.__setattr__(self, "expected_packet_type", None)
        if self.expects_packet:
            packet_param = sig.parameters[PACKET_PARAM_NAME]
            if packet_param.annotation is inspect.Parameter.empty:
                error_message = (
                    f"Handler {self.handler.__name__!r} has a "
                    f"{PACKET_PARAM_NAME!r} parameter but it is "
                    f"missing a type annotation."
                )
                raise RouteValidationError(error_message)
            _validate_packet_type(packet_param.annotation)
            object.__setattr__(
                self,
                "expected_packet_type",
                packet_param.annotation,
            )
        object.__setattr__(
            self,
            "expects_wildcard",
            WILDCARD_PARAM_NAME in sig.parameters,
        )
        return_annotation = sig.return_annotation
        if return_annotation is inspect.Parameter.empty:
            error_message = (
                f"Handler {self.handler.__name__!r} is missing return "
                f"type annotation."
            )
            raise RouteValidationError(error_message)
        _validate_packet_type(return_annotation)
        object.__setattr__(
            self,
            "expected_response_type",
            return_annotation,
        )
        object.__setattr__(
            self,
            "expected_injectors",
            set(sig.parameters.keys())
            - set(PATH_PARAM_PATTERN.findall(self.topic))
            - ({WILDCARD_PARAM_NAME} if self.expects_wildcard else set())
            - ({PACKET_PARAM_NAME} if self.expects_packet else set()),
        )


class Router:
    """MQTT topic router for registering and resolving handlers.

    A router maintains a registry of topic patterns mapped to
    asynchronous handler functions. Topics may include path parameters
    (e.g. `{param}`) and the multi-level wildcard `#`. An optional
    prefix is prepended to all registered topics, enabling hierarchical
    composition of routers.

    Parameters
    ----------
    prefix
        A topic prefix prepended to every route registered on this
        router. Defaults to an empty string (no prefix).

    Examples
    --------
    >>> router = Router(prefix="home/devices")
    >>> @router.route("{device_id}/status")
    ... async def on_status(device_id: str) -> None:
    ...     pass
    """

    def __init__(self, prefix: str = "") -> None:
        self._prefix = prefix
        self._routes: dict[str, Route] = {}

    @property
    def routes(self) -> Iterator[tuple[str, Route]]:
        """Iterate over registered routes.

        Yields
        ------
        tuple[str, Route]
            Registered routes as (topic, Route) pairs.
        """
        yield from self._routes.items()

    def add_router(self, router: Router) -> None:
        """Add routes from another router.

        Parameters
        ----------
        router
            Router whose routes should be added.
        """
        for _, route in router.routes:
            self.route(
                route.topic,
                qos=route.qos,
                options=route.options,
                properties=route.properties,
                timeout=route.timeout,
            )(route.handler)

    def route(
        self,
        topic: str,
        *,
        qos: QoS = QoS.AT_LEAST_ONCE,
        options: paho.mqtt.subscribeoptions.SubscribeOptions | None = None,
        properties: paho.mqtt.properties.Properties | None = None,
        timeout: datetime.timedelta | None = DEFAULT_MESSAGING_TIMEOUT,
    ) -> Callable[[AnyCallable], AnyCallable]:
        """Register a handler function for a given topic pattern.

        Parameters
        ----------
        topic
            MQTT topic pattern which may contain single-level wildcards
            (e.g. `{param}`) and a multi-level wildcard `#`.
        qos
            QoS level to use for subscribing.
        options
            Options to include with the subscription.
        properties
            Properties to include with the subscription.
        timeout
            Maximum time to wait for the subscription. If `None`, wait
            indefinitely.

        Returns
        -------
        Callable[[AnyCallable], AnyCallable]
            Decorator function which takes a handler function and
            returns it unchanged.
        """
        full_topic = f"{self._prefix}/{topic}" if self._prefix else topic
        _validate_topic(full_topic)

        def decorator(func: AnyCallable) -> AnyCallable:
            _validate_handler(func, full_topic)
            route = Route(
                full_topic,
                func,
                qos=qos,
                options=options,
                properties=properties,
                timeout=timeout,
            )
            subscribable_topic = PATH_PARAM_PATTERN.sub(
                SINGLE_LEVEL_WILDCARD,
                full_topic,
            )
            if subscribable_topic in self._routes:
                handler = self._routes[subscribable_topic].handler
                error_message = (
                    f"Topic pattern {full_topic!r} conflicts with "
                    f"existing route {handler.__name__!r}."
                )
                raise RouteConflictError(error_message)
            self._routes[subscribable_topic] = route
            return func

        return decorator

    def get(self, topic: str) -> Route | None:
        """Get the Route matching a concrete topic.

        Parameters
        ----------
        topic
            Concrete MQTT topic.

        Returns
        -------
        Route | None
            The matching Route, or `None` if no match is found.
        """
        for route in self._routes.values():
            if _topic_matches(topic, route.topic):
                return route
        return None


def _topic_matches(topic: str, pattern: str) -> bool:
    """Check whether a concrete topic matches a route pattern.

    Parameters
    ----------
    topic
        Concrete MQTT topic (e.g. `home/devices/42/status`).
    pattern
        MQTT topic pattern which may contain single-level wildcards
        (e.g. `{param}`) and a multi-level wildcard `#`.

    Returns
    -------
    bool
        `True` if the topic matches the pattern.
    """
    topic_parts = topic.split("/")
    pattern_parts = pattern.split("/")

    for i, p in enumerate(pattern_parts):
        # Multi-level wildcard matches everything remaining
        if p == MULTI_LEVEL_WILDCARD:
            return True

        if i >= len(topic_parts):
            return False

        # Path parameter matches any single segment
        if PATH_PARAM_PATTERN.fullmatch(p):
            continue

        # Literal segment must match exactly
        if topic_parts[i] != p:
            return False

    # All pattern parts consumed — topic must have no extra segments
    # remaining
    return len(topic_parts) == len(pattern_parts)


def _validate_topic(topic: str) -> None:  # noqa: C901
    """Validate an MQTT topic pattern.

    Checks that the topic is well-formed: non-empty, no empty
    segments, multi-level wildcard only at the end, and path
    parameters occupy entire segments without using reserved
    names.

    Parameters
    ----------
    topic
        The topic pattern to validate.

    Raises
    ------
    RouteValidationError
        If the topic pattern is invalid.
    """
    if not topic:
        error_message = "Topic must not be empty."
        raise RouteValidationError(error_message)

    if INVALID_TOPIC_CHARS.search(topic):
        error_message = (
            f"Topic {topic!r} contains invalid characters. Control "
            f"characters are not allowed."
        )
        raise RouteValidationError(error_message)

    if SINGLE_LEVEL_WILDCARD in topic:
        error_message = (
            f"Single-level wildcard {SINGLE_LEVEL_WILDCARD!r} is not "
            f"allowed in topic {topic!r}. Use path parameters (e.g. "
            f"'{{param}}') instead."
        )
        raise RouteValidationError(error_message)

    segments = topic.split(TOPIC_SEPARATOR)

    if len(segments) > MAX_TOPIC_DEPTH:
        error_message = (
            f"Topic {topic!r} exceeds maximum depth of "
            f"{MAX_TOPIC_DEPTH} segments."
        )
        raise RouteValidationError(error_message)

    for index, segment in enumerate(segments):
        # Multi-level wildcard must be the sole character in the last
        # segment.
        if MULTI_LEVEL_WILDCARD in segment:
            if segment != MULTI_LEVEL_WILDCARD:
                error_message = (
                    f"Segment {segment!r} in topic {topic!r} is "
                    f"invalid. {MULTI_LEVEL_WILDCARD!r} must be the "
                    f"only character in its segment."
                )
                raise RouteValidationError(error_message)
            if index != len(segments) - 1:
                error_message = (
                    f"Multi-level wildcard {MULTI_LEVEL_WILDCARD!r} "
                    f"must be the last segment in topic {topic!r}."
                )
                raise RouteValidationError(error_message)

        # Path parameters must occupy an entire segment and must not
        # use reserved names.
        params_in_segment = PATH_PARAM_PATTERN.findall(segment)
        if params_in_segment:
            expected = f"{{{params_in_segment[0]}}}"
            if len(params_in_segment) != 1 or segment != expected:
                error_message = (
                    f"Segment {segment!r} in topic {topic!r} is "
                    f"invalid. A path parameter must occupy the entire "
                    f"segment (e.g. '{{param}}')."
                )
                raise RouteValidationError(error_message)

            reserved = (WILDCARD_PARAM_NAME, PACKET_PARAM_NAME)
            if params_in_segment[0] in reserved:
                error_message = (
                    f"Path parameter name {params_in_segment[0]!r} in "
                    f"topic {topic!r} is reserved."
                )
                raise RouteValidationError(error_message)


def _validate_handler(func: AnyCallable, topic_pattern: str) -> None:
    """Validate a handler against a topic pattern.

    Ensures the handler is an async function and that its parameter
    names are consistent with the path parameters and wildcard in the
    topic.

    Parameters
    ----------
    func
        The handler function to validate.
    topic_pattern
        The full topic pattern (including any prefix).

    Raises
    ------
    RouteValidationError
        If the handler is not async, has missing or unexpected
        parameters, or is otherwise incompatible with the topic.
    """
    is_async = inspect.iscoroutinefunction(func) or inspect.isasyncgenfunction(
        func,
    )
    if not is_async:
        error_message = (
            f"Handler {func.__name__!r} must be an async function or "
            f"async generator function."
        )
        raise RouteValidationError(error_message)

    sig = inspect.signature(func)
    handler_params = set(sig.parameters.keys())
    path_params = set(PATH_PARAM_PATTERN.findall(topic_pattern))
    has_wildcard = topic_pattern.endswith(MULTI_LEVEL_WILDCARD)

    # All path parameters must have matching handler params.
    missing = path_params - handler_params
    if missing:
        error_message = (
            f"Handler {func.__name__!r} is missing parameters for "
            f"topic path segments: {missing}"
        )
        raise RouteValidationError(error_message)

    # A 'wildcard' parameter is only valid with a '#' topic.
    if WILDCARD_PARAM_NAME in handler_params and not has_wildcard:
        error_message = (
            f"Handler {func.__name__!r} has a {WILDCARD_PARAM_NAME!r} "
            f"parameter but topic {topic_pattern!r} does not contain "
            f"{MULTI_LEVEL_WILDCARD!r}."
        )
        raise RouteValidationError(error_message)


def _validate_packet_type(expected_type: type) -> None:
    """Validate that a packet type is supported.

    Parameters
    ----------
    expected_type
        The expected packet type to validate.

    Raises
    ------
    RouteValidationError
        If the expected packet type is not supported.
    """
    if expected_type in {bytes, str, int, float, None}:
        return

    with contextlib.suppress(TypeError):
        if issubclass(
            expected_type,
            google.protobuf.message.Message,
        ):
            return

    origin = typing.get_origin(expected_type)
    if origin is not None:
        args = typing.get_args(expected_type)
        if len(args) == 1 and origin is AsyncIterator:
            _validate_packet_type(args[0])
            return

    error_message = f"Unsupported packet type {expected_type!r}"
    raise RouteValidationError(error_message)
