"""Transactional outbox: events are rows written with the change; a relay delivers them as signed webhooks.

Delivery is at-least-once: a crash between sending and recording "delivered" means a resend,
so receivers must deduplicate on the webhook-id header.
"""
import asyncio
import base64
import hashlib
import hmac
import json
import logging
from collections.abc import Callable
from datetime import datetime, timedelta

import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from .config import Settings
from .db import utcnow
from .models import Order, OutboxEvent

log = logging.getLogger("orders.outbox")


def order_event(topic: str, order: Order) -> OutboxEvent:
    payload = {"type": topic, "orderId": order.id, "customerId": order.customer_id,
               "status": order.status, "totalMinor": order.total_minor, "currency": order.currency,
               "occurredAt": utcnow().isoformat()}
    return OutboxEvent(topic=topic, payload=json.dumps(payload, separators=(",", ":")))


def sign(secret: str, message_id: str, timestamp: str, body: str) -> str:
    """Standard Webhooks style signature: HMAC-SHA256 over "id.timestamp.body"."""
    mac = hmac.new(secret.encode(), f"{message_id}.{timestamp}.{body}".encode(), hashlib.sha256)
    return "v1," + base64.b64encode(mac.digest()).decode()


def backoff(attempts: int) -> timedelta:
    return timedelta(seconds=min(2 ** attempts, 300))


async def relay_once(sessions: async_sessionmaker[AsyncSession], client: httpx.AsyncClient,
                     settings: Settings, now: Callable[[], datetime] = utcnow) -> dict[str, int]:
    counts = {"delivered": 0, "retry": 0, "dead": 0}
    async with sessions() as session:
        due = (select(OutboxEvent)
               .where(OutboxEvent.status == "pending", OutboxEvent.next_attempt_at <= now())
               .order_by(OutboxEvent.id).limit(50)
               .with_for_update(skip_locked=True))          # PostgreSQL: replicas never grab the same rows
        for event in (await session.scalars(due)).all():
            message_id, timestamp = f"evt_{event.id}", str(int(now().timestamp()))
            headers = {"content-type": "application/json", "webhook-id": message_id,
                       "webhook-timestamp": timestamp,
                       "webhook-signature": sign(settings.webhook_secret.get_secret_value(),
                                                 message_id, timestamp, event.payload)}
            try:
                response = await client.post(settings.webhook_url, content=event.payload, headers=headers)
                error = None if response.is_success else f"HTTP {response.status_code}"
            except httpx.HTTPError as exc:
                error = f"{type(exc).__name__}: {exc}"[:300]
            if error is None:
                event.status, event.delivered_at = "delivered", now()
                counts["delivered"] += 1
                continue
            event.attempts += 1
            event.last_error = error
            if event.attempts >= settings.outbox_max_attempts:
                event.status = "dead"                       # alert on these; replay after fixing the receiver
                counts["dead"] += 1
                log.error("webhook dead", extra={"fields": {"event_id": event.id, "error": error}})
            else:
                event.next_attempt_at = now() + backoff(event.attempts)
                counts["retry"] += 1
                log.warning("webhook failed, will retry",
                            extra={"fields": {"event_id": event.id, "attempt": event.attempts, "error": error}})
        await session.commit()
    return counts


async def relay_forever(sessions: async_sessionmaker[AsyncSession], client: httpx.AsyncClient,
                        settings: Settings, stop: asyncio.Event) -> None:
    while not stop.is_set():
        try:
            await relay_once(sessions, client, settings)
        except Exception:
            log.exception("outbox relay pass failed")
        try:
            await asyncio.wait_for(stop.wait(), timeout=settings.outbox_poll_seconds)
        except TimeoutError:
            pass
