"""Check that a webhook delivery came from Caute, before trusting it.

Standard library only: copy this file into a consumer. Caute signs each
delivery the Standard Webhooks way (https://www.standardwebhooks.com), so
their libraries also work.

    from verify import verify, Forged
    event = verify(secret, request.headers, request.body)   # raises Forged

`secret` is the whsec_… string shown when the webhook was made. Pass the body
exactly as received, before any JSON parsing. A repeat delivery carries the
same `webhook-id` (the event's id), so a consumer that keeps the ids it has
handled can drop repeats; deliveries for one item arrive in order.
"""

import base64
import hashlib
import hmac
import json
import time
from collections.abc import Mapping

TOLERANCE_SECONDS = 5 * 60


class Forged(Exception):
    """The delivery is not from Caute, or is too old to trust."""


def verify(secret: str, headers: Mapping[str, str], body: bytes, *, now: float | None = None) -> dict:
    lower = {k.lower(): v for k, v in headers.items()}
    msg_id, stamp, signatures = (lower.get("webhook-id"), lower.get("webhook-timestamp"),
                                 lower.get("webhook-signature"))
    if not (msg_id and stamp and signatures):
        raise Forged("missing signature headers")
    try:
        when = int(stamp)
    except ValueError as exc:
        raise Forged("bad timestamp") from exc
    if abs((time.time() if now is None else now) - when) > TOLERANCE_SECONDS:
        raise Forged("timestamp too old or in the future")
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = base64.b64encode(
        hmac.new(key, f"{msg_id}.{when}.".encode() + body, hashlib.sha256).digest()).decode()
    # Several signatures may be sent, space-separated, while a secret changes.
    for candidate in signatures.split():
        version, _, value = candidate.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            event = json.loads(body)
            if event.get("id") != msg_id:
                raise Forged("the body is not the event the headers name")
            return event
    raise Forged("signature does not match")
