#!/usr/bin/env python3
"""Ghala event subscriptions - multi-tenant webhook receiver.

    pip install flask python-dotenv
    python multi_tenant_webhook.py

Register one endpoint per number, with the tenant in the path:

    https://you.example/ghala/acme/webhook
    https://you.example/ghala/bakari-ltd/webhook

Why the path and not a shared URL: every subscription has its own signing
secret. With one shared URL you cannot verify a delivery until you know the
tenant, and you cannot know the tenant until you have verified it. Trying each
secret in turn is linear in tenant count on every request and lets an attacker
make you brute-force your own key list.

For local development, tunnel this port (ngrok, cloudflared) and register the
tunnel URL: Ghala rejects http://, loopback, and private addresses.
"""

from __future__ import annotations

import hashlib
import hmac
import json
import logging
import os
import time

from flask import Flask, request, abort

try:
    from dotenv import load_dotenv

    load_dotenv()
except ImportError:
    pass

MAX_SKEW_SECONDS = 300

log = logging.getLogger("ghala")
logging.basicConfig(level=logging.INFO)

# Tenant registry. In production this is your database plus a secret manager,
# and you store a *reference* to each secret rather than the secret itself, so
# a database dump is not a credential dump.
TENANTS = {
    "acme": {
        "phone_number": "255712345678",
        "webhook_secret": os.environ.get("ACME_WEBHOOK_SECRET"),
        "access_token": os.environ.get("ACME_ACCESS_TOKEN"),
    },
    "bakari-ltd": {
        "phone_number": "255713456789",
        "webhook_secret": os.environ.get("BAKARI_WEBHOOK_SECRET"),
        "access_token": os.environ.get("BAKARI_ACCESS_TOKEN"),
    },
}

# Deliveries already processed, keyed by (tenant, delivery id). In production
# this is Redis or a table with a TTL, not a process-local set.
_handled: set[tuple[str, str]] = set()


def already_handled(tenant_id: str, delivery_id: str | None) -> bool:
    """Scoped by tenant so one pipeline can never swallow another's event."""
    key = (tenant_id, delivery_id or "")
    if key in _handled:
        return True
    _handled.add(key)
    return False


def is_valid(raw: bytes, signature: str, timestamp: str, secret: str) -> bool:
    try:
        if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
            return False
    except (TypeError, ValueError):
        return False

    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, (signature or "").removeprefix("sha256="))


app = Flask(__name__)


@app.post("/ghala/<tenant_id>/webhook")
def ghala_webhook(tenant_id: str):
    tenant = TENANTS.get(tenant_id)

    # Unknown tenant: 404 rather than 401, so this cannot be used to probe
    # which tenants exist by watching status codes.
    if not tenant or not tenant["webhook_secret"]:
        abort(404)

    raw = request.get_data()  # the exact bytes; a re-serialized body will not verify

    if not is_valid(
        raw,
        request.headers.get("X-Ghala-Signature", ""),
        request.headers.get("X-Ghala-Timestamp", ""),
        tenant["webhook_secret"],
    ):
        abort(401)

    delivery_id = request.headers.get("X-Ghala-Delivery")
    event = request.headers.get("X-Ghala-Event")

    if not already_handled(tenant_id, delivery_id):
        try:
            payload = json.loads(raw)
        except ValueError:
            log.error("delivery body was not JSON (tenant=%s)", tenant_id)
            return "", 200

        try:
            handle(tenant_id, event, payload)
        except Exception:
            # Never let a handler failure turn into a non-2xx: 20 consecutive
            # failures disable the endpoint. Log it and investigate out of band.
            log.exception("handler failed (tenant=%s event=%s)", tenant_id, event)

    return "", 200


def handle(tenant_id: str, event: str | None, payload: dict) -> None:
    if event == "message.received":
        on_inbound(tenant_id, payload)
    elif event == "message.status":
        on_status_change(tenant_id, payload)
    else:
        # An unrecognised event type is not an error. Log it and move on, so a
        # newly published event cannot take your endpoint down.
        log.info("unhandled ghala event (tenant=%s event=%s)", tenant_id, event)


def on_inbound(tenant_id: str, payload: dict) -> None:
    log.info("inbound message (tenant=%s): %s", tenant_id, payload)

    # Deliveries are unordered, so sequence with timestamps in the payload
    # rather than with arrival order. Make this idempotent: at-least-once
    # delivery means you will see the same event twice eventually.


def on_status_change(tenant_id: str, payload: dict) -> None:
    log.info("status change (tenant=%s): %s", tenant_id, payload)


if __name__ == "__main__":
    port = int(os.environ.get("PORT", 3000))
    for tenant_id in TENANTS:
        log.info("  POST /ghala/%s/webhook", tenant_id)
    # Flask's dev server is single-threaded; put a real WSGI server in front
    # of this in production so a slow handler cannot stall deliveries.
    app.run(port=port)
