"""HTTP receiver for Nextcloud webhooks.

Routes inbound webhooks to the same processor send-stream the scanner uses.
The receiver is registered as a Starlette route at ``/webhooks/nextcloud``
in :mod:`nextcloud_mcp_server.app`.
"""

import hmac
import logging

import anyio
from starlette.requests import Request
from starlette.responses import JSONResponse

from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.webhook_parser import extract_document_task

logger = logging.getLogger(__name__)

_warned_about_missing_secret = False


def _warn_missing_secret_once() -> None:
    """Log a one-time WARNING when WEBHOOK_SECRET is unset.

    The receiver refuses unauthenticated POSTs in this case (returns 503), and
    ``app.py`` does not even mount the route without a secret — this branch is
    defense-in-depth for any caller that wires the handler directly. The
    operator should set WEBHOOK_SECRET to enable webhook-driven vector sync.
    """
    global _warned_about_missing_secret
    if _warned_about_missing_secret:
        return
    _warned_about_missing_secret = True
    logger.warning(
        "WEBHOOK_SECRET is not set; /webhooks/nextcloud rejects all requests "
        "(503). Set WEBHOOK_SECRET and re-register webhooks to enable "
        "Authorization: Bearer validation and webhook-driven vector sync."
    )


async def handle_nextcloud_webhook(request: Request) -> JSONResponse:
    """Receive a Nextcloud webhook and queue a DocumentTask for vector sync.

    Returns quickly so NC's webhook worker is not blocked. The task producer is
    read from ``request.app.state.task_producer`` (the in-memory send stream when
    ``INGEST_QUEUE=memory``, or the procrastinate producer when
    ``INGEST_QUEUE=postgres``); when vector sync isn't running we return 503 so
    NC retries delivery.

    ``WEBHOOK_SECRET`` is **required** (GHSA-8vh3-g2qg-2h2c). The endpoint is
    only mounted by ``app.py`` when the secret is configured, and every request
    must carry ``Authorization: Bearer <secret>`` (registered via ``authData``
    so NC forwards it on every delivery). The ``user.uid`` in the payload is
    attacker-controllable and is fed to Qdrant, so an unauthenticated request
    could delete or re-index any user's embeddings — never process one. Missing
    or invalid headers are rejected with 401 before any further work; if the
    secret is somehow unset we refuse with 503 rather than fall back to
    unauthenticated processing.
    """
    secret = get_settings().webhook_secret
    if not secret:
        # Defense-in-depth: the route should not be mounted without a secret,
        # but if this handler is reached anyway, refuse rather than process an
        # unauthenticated, attacker-controlled payload.
        _warn_missing_secret_once()
        return JSONResponse(
            {
                "status": "unavailable",
                "reason": "webhook authentication not configured",
            },
            status_code=503,
        )

    provided = request.headers.get("authorization", "").encode("utf-8")
    expected = f"Bearer {secret}".encode("utf-8")
    # Use compare_digest to avoid the character-by-character short-circuit
    # of `==`. Comparing as bytes is the conventional form and avoids any
    # surprise with non-ASCII input. compare_digest still returns False
    # for differing lengths but isn't fully constant-time across them;
    # that's fine here — a secret length leak is not a sensitive signal.
    if not hmac.compare_digest(provided, expected):
        # Intentionally omit WWW-Authenticate. RFC 7235 §4.1 says a 401
        # SHOULD carry it, but Nextcloud's webhook delivery worker has no
        # auth-flow state machine to negotiate against — the bearer is a
        # static shared secret configured out-of-band via WEBHOOK_SECRET,
        # and a challenge response wouldn't change client behaviour.
        # Surfacing it would only mislead operators into expecting a
        # renegotiation that doesn't exist.
        logger.warning("Webhook rejected: missing or invalid Authorization header")
        return JSONResponse(
            {"status": "unauthorized"},
            status_code=401,
        )

    try:
        payload = await request.json()
    except Exception as e:
        logger.warning("Webhook payload was not valid JSON: %s", e)
        return JSONResponse(
            {"status": "error", "message": "invalid JSON"},
            status_code=400,
        )

    task = extract_document_task(payload)
    if task is None:
        event_class = (payload.get("event") or {}).get("class", "<missing>")
        logger.debug("Webhook ignored (unsupported event): %s", event_class)
        return JSONResponse(
            {"status": "ignored", "reason": "unsupported event"},
            status_code=200,
        )

    producer = getattr(request.app.state, "task_producer", None)
    if producer is None:
        logger.warning(
            "Webhook received but vector sync is not running; rejecting so NC retries"
        )
        return JSONResponse(
            {"status": "unavailable", "reason": "vector sync not running"},
            status_code=503,
        )

    try:
        with anyio.fail_after(1.0):
            await producer.send(task)
    except TimeoutError:
        # Queue is saturated (default 10 000 tasks). Returning 503 lets NC
        # retry rather than pinning this handler until its outbound timeout
        # fires; the queue-pressure signal also surfaces in metrics.
        logger.warning(
            "Webhook task drop: queue full for %s_%s",
            task.doc_type,
            task.doc_id,
        )
        return JSONResponse(
            {"status": "unavailable", "reason": "queue full"},
            status_code=503,
        )
    except Exception as e:
        logger.error(
            "Failed to queue webhook task for %s_%s: %s",
            task.doc_type,
            task.doc_id,
            e,
        )
        return JSONResponse(
            {"status": "error", "message": "queue unavailable"},
            status_code=500,
        )

    logger.info(
        "Webhook queued %s_%s (%s) for user %s",
        task.doc_type,
        task.doc_id,
        task.operation,
        task.user_id,
    )
    return JSONResponse(
        {
            "status": "queued",
            "doc_type": task.doc_type,
            "doc_id": task.doc_id,
            "operation": task.operation,
        },
        status_code=200,
    )
