"""Taking delivery of inbound messages.

Webhook delivery is **at-least-once**. Both providers retry when a webhook does
not return quickly enough, and a retry after a slow-but-successful handling
delivers the same message again. Without deduplication that files one client's
bank statement twice and starts two workflows over it.
"""

from __future__ import annotations

from collections import OrderedDict
from collections.abc import Callable

from app.whatsapp.inbox import InboxFilter, SelfChatPolicy
from app.whatsapp.messages import InboundMessage
from app.whatsapp.provider import MessageSender, WhatsAppProvider


class SeenMessages:
    """Remembers which provider message ids have already been handled.

    Bounded: a deployment runs for months and an unbounded set is a slow leak.
    Oldest ids are dropped first, which is safe because provider retries happen
    within minutes — an id old enough to be evicted will never be retried.
    """

    def __init__(self, capacity: int = 10_000) -> None:
        self._capacity = capacity
        self._seen: OrderedDict[str, None] = OrderedDict()

    def add(self, message_id: str) -> bool:
        """Record an id. Returns False if it had already been seen."""
        if message_id in self._seen:
            # Refreshed so a message being retried repeatedly is not evicted
            # while the retries are still arriving.
            self._seen.move_to_end(message_id)

            return False

        self._seen[message_id] = None

        if len(self._seen) > self._capacity:
            self._seen.popitem(last=False)

        return True

    def __contains__(self, message_id: str) -> bool:
        return message_id in self._seen

    def __len__(self) -> int:
        return len(self._seen)


class WebhookReceiver:
    """Parses a webhook, deduplicates, and hands each new message onward.

    Deliberately does no business work itself. It records the session window,
    filters duplicates, and calls a handler — so what happens to a message is
    the workflow engine's decision, not the transport's.
    """

    def __init__(
        self,
        provider: WhatsAppProvider,
        sender: MessageSender,
        handler: Callable[[InboundMessage], None],
        seen: SeenMessages | None = None,
        inbox: InboxFilter | None = None,
    ) -> None:
        self._provider = provider
        self._sender = sender
        self._handler = handler
        self._seen = seen if seen is not None else SeenMessages()

        # Fails closed when absent. This process can see every conversation on a
        # real person's account, so "no filter given" must mean "touch nothing"
        # — never "touch everything". A policy with no owner permits nothing.
        self._inbox = inbox if inbox is not None else InboxFilter(SelfChatPolicy())

    @property
    def inbox(self) -> InboxFilter:
        return self._inbox

    def receive(self, payload: dict) -> list[InboundMessage]:
        """Handle one webhook delivery, returning the messages actually processed."""
        try:
            messages = self._provider.parse_webhook(payload)
        except Exception:  # noqa: BLE001
            # A malformed payload must not raise back at the provider: an error
            # response triggers a retry, and a payload that cannot be parsed
            # will not parse on the retry either. Dropping it ends the loop.
            return []

        processed: list[InboundMessage] = []

        for message in messages:
            # First, before deduplication, the session window or anything else.
            # A message from a conversation this deployment may not touch should
            # leave no trace at all — not a remembered id, not a window entry.
            #
            # The whole message, not extracted fields: the rule is about who
            # wrote it and who it was addressed to as well as which conversation
            # it is, and a caller that picks out arguments is a caller that can
            # pick out the wrong ones.
            if not self._inbox.permits(message):
                continue

            if not self._seen.add(message.provider_message_id):
                continue

            # Recorded before handling: the window opened when the client wrote,
            # not when we got round to processing it. A slow handler must not
            # shorten the reply window.
            self._sender.record_inbound(message)

            try:
                self._handler(message)
            except Exception:  # noqa: BLE001
                # One message failing must not stop the rest of a batch. The id
                # stays marked as seen — a retry would fail identically, and
                # retrying it forever is worse than one lost message that the
                # audit trail records.
                continue

            processed.append(message)

        return processed
