"""CertiSigma Census — Webhook management and HTTP receiver.

Provides:
- Secret file management (save/load with 0o600 permissions)
- Anti-replay guard for webhook deliveries (bounded dedup + timestamp window)
- Lightweight HTTP receiver with HMAC verification and hook dispatch
"""

from __future__ import annotations

import json
import logging
import os
import ssl
import threading
import time
from collections import OrderedDict
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from typing import Any, Callable

from certisigma import verify_webhook_signature

logger = logging.getLogger("certisigma_census")

MAX_PAYLOAD_BYTES = 1_048_576  # 1 MB
MAX_SIGNATURE_LEN = 128  # sha256= + 64 hex = 71 chars; generous ceiling
DEFAULT_PORT = 9514
DEFAULT_BIND = "127.0.0.1"
CALLBACK_PATH = "/webhook/callback"
HEALTH_PATH = "/health"
_SOCKET_TIMEOUT = 30.0  # seconds; prevents slow-client DoS

_SECRET_HEADER = (
    "# CertiSigma webhook signing secret\n"
    "# Webhook ID: {webhook_id}\n"
    "# Created: {timestamp}\n"
    "# DO NOT share, log, or commit this file\n"
)


# ---------------------------------------------------------------------------
# Secret management
# ---------------------------------------------------------------------------

def save_secret(path: Path, secret: str, webhook_id: str) -> None:
    """Write signing secret to *path* with owner-only permissions (0o600).

    On failure the partially-written file is removed to avoid leaving
    a world-readable stub.
    """
    resolved = Path(path).resolve()
    fd = os.open(str(resolved), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    try:
        with os.fdopen(fd, "w") as fh:
            header = _SECRET_HEADER.format(
                webhook_id=webhook_id,
                timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            )
            fh.write(header)
            fh.write(secret + "\n")
    except BaseException:
        try:
            resolved.unlink(missing_ok=True)
        except OSError as exc:
            logger.warning("Cannot clean up partial secret file %s: %s", resolved, exc)
        raise


MAX_SECRET_FILE_BYTES = 8192


def load_secret(path: Path) -> str:
    """Read signing secret from *path*, stripping comments and whitespace.

    Raises ``ValueError`` if the file contains no usable secret line or
    exceeds ``MAX_SECRET_FILE_BYTES``.
    """
    resolved = Path(path).resolve()
    size = resolved.stat().st_size
    if size > MAX_SECRET_FILE_BYTES:
        raise ValueError(
            f"Secret file {path} is {size} bytes (max {MAX_SECRET_FILE_BYTES})"
        )
    text = resolved.read_text()
    lines = [
        line.strip()
        for line in text.splitlines()
        if line.strip() and not line.strip().startswith("#")
    ]
    if not lines:
        raise ValueError(f"No signing secret found in {path}")
    return lines[0]


# ---------------------------------------------------------------------------
# Anti-replay
# ---------------------------------------------------------------------------

class ReplayGuard:
    """Bounded dedup for webhook delivery IDs with timestamp validation.

    Thread-safe.  Uses ``OrderedDict`` for O(1) lookup with FIFO eviction
    when capacity is exceeded.

    Parameters
    ----------
    max_ids
        Maximum number of delivery IDs to remember (oldest evicted first).
    max_age_seconds
        Reject deliveries with ``created_at`` older than this many seconds.
        A small negative tolerance (60 s) accommodates clock skew.
    """

    def __init__(self, max_ids: int = 10_000, max_age_seconds: float = 300.0) -> None:
        self._seen: OrderedDict[int | str, float] = OrderedDict()
        self._max_ids = max_ids
        self._max_age = max_age_seconds
        self._lock = threading.Lock()

    def check_and_record(self, delivery_id: int | str, created_at: str) -> bool:
        """Return ``True`` if *delivery_id* is fresh.  ``False`` on replay or stale."""
        with self._lock:
            if delivery_id in self._seen:
                return False

            try:
                ts = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
                age = (datetime.now(timezone.utc) - ts).total_seconds()
                if age > self._max_age or age < -60:
                    return False
            except (ValueError, TypeError, OverflowError):
                return False

            self._seen[delivery_id] = time.monotonic()
            while len(self._seen) > self._max_ids:
                self._seen.popitem(last=False)

            return True

    def size(self) -> int:
        """Number of delivery IDs currently tracked."""
        with self._lock:
            return len(self._seen)

    def reset(self) -> None:
        """Clear all tracked IDs."""
        with self._lock:
            self._seen.clear()


# ---------------------------------------------------------------------------
# HTTP receiver
# ---------------------------------------------------------------------------

class _WebhookHandler(BaseHTTPRequestHandler):
    """Request handler for the webhook receiver."""

    server: WebhookReceiver

    def log_message(self, format: str, *args: Any) -> None:  # noqa: A002
        logger.debug("webhook-receiver: " + format, *args)

    def do_GET(self) -> None:  # noqa: N802
        if self.path == HEALTH_PATH:
            self._json_response(200, {"status": "ok"})
        else:
            self.send_error(404)

    def do_POST(self) -> None:  # noqa: N802
        if self.path != CALLBACK_PATH:
            self.send_error(404)
            return

        content_type = self.headers.get("Content-Type", "")
        if "application/json" not in content_type:
            self.send_error(415, "Expected application/json")
            return

        try:
            content_length = int(self.headers.get("Content-Length", 0))
        except (ValueError, TypeError):
            self.send_error(400, "Invalid Content-Length")
            return

        if content_length < 0:
            self.send_error(400, "Invalid Content-Length")
            return
        if content_length > MAX_PAYLOAD_BYTES:
            self.send_error(413, "Payload too large")
            return
        if content_length == 0:
            self.send_error(400, "Empty body")
            return

        raw_body = self.rfile.read(content_length)
        signature = self.headers.get("X-CertiSigma-Signature", "")

        if len(signature) > MAX_SIGNATURE_LEN:
            self.send_error(400, "Signature header too long")
            return

        if not verify_webhook_signature(raw_body, signature, self.server.signing_secret):
            logger.warning("HMAC verification failed on webhook delivery")
            self.send_error(401, "Invalid signature")
            return

        try:
            payload: dict = json.loads(raw_body)
        except json.JSONDecodeError:
            self.send_error(400, "Invalid JSON")
            return

        delivery_id = payload.get("delivery_id")
        if delivery_id is None:
            delivery_id = payload.get("id")
        created_at = payload.get("created_at", "")

        if delivery_id is None:
            logger.warning("Webhook delivery missing delivery_id — replay guard bypassed")
        else:
            did: int | str
            try:
                did = int(delivery_id)
            except (ValueError, TypeError):
                did = str(delivery_id)

            if not self.server.replay_guard.check_and_record(did, created_at):
                logger.info("Duplicate/stale delivery %s — accepted but not dispatched", delivery_id)
                self._json_response(200, {"status": "duplicate"})
                return

        event_type = payload.get("event_type", payload.get("event", ""))
        try:
            if event_type == "t1_complete" and self.server.on_t1:
                self.server.on_t1(payload)
            elif event_type == "t2_complete" and self.server.on_t2:
                self.server.on_t2(payload)
            else:
                logger.debug("Unhandled webhook event type: %s", event_type)
        except Exception as exc:
            logger.warning("Webhook callback error for %s: %s", event_type, type(exc).__name__)

        self._json_response(200, {"status": "ok"})

    # ------------------------------------------------------------------

    def _json_response(self, code: int, body: dict) -> None:
        data = json.dumps(body).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)


class WebhookReceiver(HTTPServer):
    """Lightweight HTTP server for CertiSigma webhook deliveries.

    Verifies HMAC-SHA256 signatures, guards against replay, and dispatches
    ``t1_complete`` / ``t2_complete`` events to user-defined callbacks.

    Intended to run behind a reverse proxy for TLS termination in production.
    Optional built-in TLS via *tls_certfile* / *tls_keyfile* for direct use.

    Parameters
    ----------
    signing_secret
        The secret returned by ``register_webhook`` (hex string, 64 chars).
    bind / port
        Listen address.  Defaults to ``127.0.0.1:9514`` (loopback only).
    on_t1 / on_t2
        Callbacks invoked when a verified delivery of the respective event
        type arrives.  Receive the parsed JSON payload ``dict``.
    replay_max_ids / replay_max_age
        Tuning for the anti-replay guard.
    tls_certfile / tls_keyfile
        Optional PEM paths to enable built-in TLS.
    """

    def __init__(
        self,
        signing_secret: str,
        *,
        bind: str = DEFAULT_BIND,
        port: int = DEFAULT_PORT,
        on_t1: Callable[[dict[str, Any]], None] | None = None,
        on_t2: Callable[[dict[str, Any]], None] | None = None,
        replay_max_ids: int = 10_000,
        replay_max_age: float = 300.0,
        tls_certfile: str | None = None,
        tls_keyfile: str | None = None,
    ) -> None:
        self.signing_secret = signing_secret
        self.on_t1 = on_t1
        self.on_t2 = on_t2
        self.replay_guard = ReplayGuard(replay_max_ids, replay_max_age)

        super().__init__((bind, port), _WebhookHandler)
        self.socket.settimeout(_SOCKET_TIMEOUT)

        if tls_certfile:
            ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
            ctx.minimum_version = ssl.TLSVersion.TLSv1_2
            ctx.load_cert_chain(tls_certfile, tls_keyfile)
            self.socket = ctx.wrap_socket(self.socket, server_side=True)

    def serve_in_thread(self) -> threading.Thread:
        """Start ``serve_forever`` in a daemon thread and return it."""
        t = threading.Thread(
            target=self.serve_forever,
            daemon=True,
            name="census-webhook-receiver",
        )
        t.start()
        return t
