#!/usr/bin/env python3
"""Sentinel Event v1 receiver: verify, durably accept, and deduplicate.

Python 3.11+, standard library only. Example inbox, not a business-action worker.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import re
import sqlite3
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

MAX_BODY = 2 * 1024 * 1024


def verify(raw: bytes, headers, secret: str, now: int | None = None) -> dict:
    timestamp = headers.get("X-Sentinel-Timestamp", "")
    if not re.fullmatch(r"[0-9]{1,12}", timestamp):
        raise ValueError("invalid timestamp")
    if abs((int(time.time()) if now is None else now) - int(timestamp)) > 300:
        raise ValueError("stale timestamp")
    expected = "v1=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(headers.get("X-Sentinel-Signature", "").encode(), expected.encode()):
        raise ValueError("invalid signature")
    event = json.loads(raw)
    if not isinstance(event, dict) or event.get("schema") != "sentinel.event.v1":
        raise ValueError("invalid schema")
    event_id = event.get("id", "")
    delivery = headers.get("X-Sentinel-Delivery-ID", "")
    if not isinstance(event_id, str) or not event_id.startswith("evt_"):
        raise ValueError("invalid event ID")
    if not delivery.startswith("whd_") or headers.get("Idempotency-Key") != delivery:
        raise ValueError("invalid delivery ID")
    if headers.get("X-Sentinel-Event-ID") != event_id:
        raise ValueError("event ID mismatch")
    if event.get("severity") not in {"safe", "suspicious", "critical"} or event.get("state") not in {"raised", "updated", "cleared"}:
        raise ValueError("invalid event state")
    if not isinstance(event.get("type"), str) or not event["type"]:
        raise ValueError("missing event type")
    return event


def accept(database: str, event: dict, delivery: str, raw: bytes) -> bool:
    # Commit before acknowledging; a separate worker can read this inbox.
    # Keep different deliveries of the same Event (e.g. raised -> cleared).
    with sqlite3.connect(database, timeout=5) as db:
        db.execute("CREATE TABLE IF NOT EXISTS inbox (delivery_id TEXT PRIMARY KEY, event_id TEXT NOT NULL, received_at INTEGER NOT NULL, payload BLOB NOT NULL)")
        previous = db.execute("SELECT event_id, payload FROM inbox WHERE delivery_id=?", (delivery,)).fetchone()
        if previous:
            if previous != (event["id"], raw):
                raise ValueError("delivery ID reused with different content")
            return False
        db.execute("INSERT INTO inbox VALUES (?, ?, ?, ?)", (delivery, event["id"], int(time.time()), raw))
    return True


def make_handler(secret: str, database: str):
    class Handler(BaseHTTPRequestHandler):
        def setup(self):
            super().setup()
            self.connection.settimeout(10)

        def do_POST(self):
            if self.path != "/events":
                self.send_error(404)
                return
            try:
                length = int(self.headers.get("Content-Length", "0"))
            except ValueError:
                self.send_error(400)
                return
            if self.headers.get("Transfer-Encoding") or not 0 < length <= MAX_BODY:
                self.send_error(413)
                return
            raw = self.rfile.read(length)
            if len(raw) != length:
                self.send_error(400)
                return
            try:
                event = verify(raw, self.headers, secret)
            except (ValueError, UnicodeError, TypeError):
                self.send_error(401, "Invalid event or signature")
                return
            delivery = self.headers["X-Sentinel-Delivery-ID"]
            try:
                fresh = accept(database, event, delivery, raw)
            except ValueError:
                self.send_error(409)
                return
            except sqlite3.Error:
                self.send_error(503, "Inbox unavailable; retry later")
                return
            print(json.dumps({"accepted": event["id"], "delivery": delivery, "severity": event["severity"], "duplicate": not fresh}), flush=True)
            self.send_response(204)
            self.end_headers()

        def log_message(self, fmt, *args):
            pass  # Do not log full URLs, secrets or camera content.
    return Handler


if __name__ == "__main__":
    secret = os.environ.get("SENTINEL_WEBHOOK_SIGNING_SECRET", "")
    if len(secret.encode()) < 32:
        raise SystemExit("Set SENTINEL_WEBHOOK_SIGNING_SECRET to the same 32+ byte secret as the node")
    database = os.environ.get("RECEIVER_DB", "./events.sqlite3")
    Path(database).parent.mkdir(parents=True, exist_ok=True)
    host = os.environ.get("RECEIVER_HOST", "127.0.0.1")
    port = int(os.environ.get("RECEIVER_PORT", "9090"))
    print(f"Receiver listening on {host}:{port}/events", flush=True)
    ThreadingHTTPServer((host, port), make_handler(secret, database)).serve_forever()
