"""The inbound webhook.

Two things shape this, and both are about what happens *outside* the handler.

**Acknowledge fast.** Meta retries a webhook it considers slow, and OCR takes
seconds. Doing the work inside the request would produce duplicate deliveries of
the document already being processed. So the handler parses, verifies, filters
and queues — then returns 200. The daemon does the work.

**Verify before anything else.** An unsigned endpoint accepts a POST from anyone,
and a forged webhook would put an attacker's document into a reviewer's queue
with a real client's name attached. Authenticate first, inbox rule second, work
last.

## The two providers authenticate differently, and one of them barely does

Meta signs every delivery: HMAC-SHA256 over the raw body with the app secret.
That is a real signature and it is checked before the body is parsed.

**Evolution signs nothing.** It posts a plain JSON body and offers only custom
headers, so the strongest thing available is a shared secret it sends back. That
is weaker — it proves the caller knows a secret, not that the body is untampered
— so the route leans on two things instead:

    the agent's HTTP server binds loopback, so only this machine can reach it
    the secret must be configured, or the route refuses everything

Both matter. The bind is what makes the secret sufficient rather than merely
better than nothing.
"""

from __future__ import annotations

import hmac
import json
import logging
import threading
from collections import deque

from app.whatsapp.messages import InboundMessage

logger = logging.getLogger(__name__)

#: The header Evolution is configured to send, and this route requires.
EVOLUTION_SECRET_HEADER = "X-TaxPilot-Token"

#: The only Evolution event that carries messages.
#:
#: Evolution will happily send connection updates, presence, typing indicators,
#: contact syncs and chat history on the same URL. Anything that is not this is
#: not a message, and parsing it as one is how a presence update becomes a
#: document.
EVOLUTION_MESSAGE_EVENT = "messages.upsert"


class InboundQueue:
    """Messages waiting for the daemon.

    Bounded. A flood — a retry storm, a misconfigured provider — must not grow
    until the process dies, and dropping the oldest is the right end to lose
    from: the newest message is the one somebody is waiting on.
    """

    def __init__(self, capacity: int = 500) -> None:
        self._items: deque[InboundMessage] = deque(maxlen=capacity)
        self._lock = threading.Lock()
        self.dropped = 0

    def put(self, message: InboundMessage) -> None:
        with self._lock:
            if len(self._items) == self._items.maxlen:
                self.dropped += 1
                logger.warning("Inbound queue is full; dropping the oldest message.")

            self._items.append(message)

    def drain(self) -> list[InboundMessage]:
        with self._lock:
            items = list(self._items)
            self._items.clear()

            return items

    def __len__(self) -> int:
        with self._lock:
            return len(self._items)


def meta_webhook_routes(server, provider, receiver, queue: InboundQueue) -> None:
    """Register Meta's two webhook paths.

    GET is the one-time verification handshake; POST carries messages.
    """
    from app.whatsapp.meta import verification_challenge, verify_signature

    config = provider.config

    def verify(request) -> tuple[int, object]:
        challenge = verification_challenge(request.query, config.verify_token)

        if challenge is None:
            # Echoing without checking the token would let anybody who guesses
            # the URL register it as their own webhook.
            logger.warning("Webhook verification refused: token did not match.")

            return 403, {"error": "verification_failed"}

        # Meta wants the bare challenge back, not JSON.
        return 200, challenge

    def receive(request) -> tuple[int, object]:
        raw_body = request.body

        if not verify_signature(config.app_secret, raw_body, request.header("X-Hub-Signature-256")):
            # Refused before parsing. A forged webhook would put an attacker's
            # document into a reviewer's queue with a client's name on it.
            logger.warning("Rejected a webhook with an invalid signature.")

            return 403, {"error": "bad_signature"}

        try:
            payload = json.loads(raw_body.decode("utf-8"))
        except (ValueError, UnicodeDecodeError):
            # 200, not 400: a payload that cannot be parsed will not parse on
            # the retry either, and an error response asks for that retry
            # forever.
            logger.warning("Ignored an unparseable webhook body.")

            return 200, {"ignored": True}

        accepted = receiver.receive(payload)

        for message in accepted:
            queue.put(message)

        # Always 200 once the signature is good. The work has not been done yet
        # and that is deliberate — see the module docstring.
        return 200, {"accepted": len(accepted)}

    server.route("GET", "/webhook/whatsapp", verify)
    server.route("POST", "/webhook/whatsapp", receive)


def evolution_webhook_routes(server, receiver, queue: InboundQueue, secret: str) -> None:
    """Register Evolution's delivery path.

    One route, no handshake — Evolution does not verify anything on setup, it
    simply starts posting to whatever URL its instance was configured with.

    An empty secret registers nothing at all. A route that accepted deliveries
    because it had no way to check them would be worse than an absent one: the
    absent route answers 404 and somebody notices, while the open one quietly
    admits anything that finds the port.
    """
    if not secret:
        logger.warning(
            "Not registering the Evolution webhook: no %s secret is configured, "
            "and an unauthenticated inbound route would accept a forged document.",
            EVOLUTION_SECRET_HEADER,
        )

        return

    def receive(request) -> tuple[int, object]:
        # Constant time. A comparison that returns early on the first wrong
        # character leaks the secret to anybody willing to time the responses.
        presented = request.header(EVOLUTION_SECRET_HEADER) or ""

        if not hmac.compare_digest(presented, secret):
            logger.warning("Rejected an Evolution webhook with a missing or wrong token.")

            return 403, {"error": "unauthorised"}

        try:
            payload = json.loads(request.body.decode("utf-8"))
        except (ValueError, UnicodeDecodeError):
            # 200, as with Meta: a body that cannot be parsed will not parse on
            # a retry either, and an error response asks for that retry forever.
            logger.warning("Ignored an unparseable Evolution webhook body.")

            return 200, {"ignored": True}

        if not isinstance(payload, dict):
            return 200, {"ignored": True}

        event = str(payload.get("event") or "").lower().replace("_", ".")

        if event != EVOLUTION_MESSAGE_EVENT:
            # Connection updates, presence, typing, contact syncs. Not errors,
            # and not messages.
            return 200, {"accepted": 0}

        accepted = receiver.receive(payload)

        for message in accepted:
            queue.put(message)

        return 200, {"accepted": len(accepted)}

    server.route("POST", "/webhook/evolution", receive)
