"""§6.2 / v0.12.0 / roadmap item #10: CloudFlare Turnstile siteverify.

The OTC request endpoint (`/auth/otc/request`) is the abuse hot path
of the auth surface since v0.7.0 — the per-email cooldown stops the
trivial back-to-back loop, but it does not stop a distributed scraper
that fans out across a large invitee list to harvest the "this email
is admitted vs. this email is not" signal indirectly (timing
differences, SMTP bounce-rate observation). v0.12.0 gates the request
endpoint behind a one-step browser-side Turnstile challenge before the
bcrypt hash + SMTP send.

Stateless: no DB writes, no schema change. The siteverify call to
CloudFlare lives entirely in this module; the endpoint handler in
`main.py` thin-wraps `verify_token`.

Tunables (read at call time so tests can monkeypatch):

  * `CLOUDFLARE_TURNSTILE_SECRET`  — the operator-provisioned secret
    key from the Turnstile dashboard. Lives in GCP Secret Manager in
    production; absent in tests (which monkeypatch the siteverify
    transport). When unset, the behavior depends on `TURNSTILE_REQUIRED`:
      - `TURNSTILE_REQUIRED=true`  → fail closed (`misconfigured`).
      - `TURNSTILE_REQUIRED=false` (default) → skip verification entirely
        and admit the request. This is the dev/test path and the
        "operator hasn't wired the secret yet" path; production
        deployments **should** set `TURNSTILE_REQUIRED=true` once the
        secret is in place so a regression in the secret wiring fails
        loudly instead of silently disabling abuse defense.

  * `TURNSTILE_REQUIRED`           — `true` / `false` (default `false`).
    When `false` and the secret is absent, the gate is open. When
    `true` and the secret is absent, the endpoint refuses with a
    misconfigured-auth shape rather than silently letting requests
    through.

  * `TURNSTILE_SITEVERIFY_URL`     — points at the real CloudFlare
    endpoint by default. Override in tests to redirect at a mock
    URL when `httpx.MockTransport` isn't ergonomic for the case.

The siteverify contract is documented at
https://developers.cloudflare.com/turnstile/get-started/server-side-validation/.
We POST `secret` + `response` (and optionally `remoteip`) as form
fields and read back `{"success": true|false, ...}`. Any network /
parse failure on the siteverify call is treated as a verification
failure (`network`) — the abuse path is to skip the challenge, so
"can't reach CloudFlare" defaults to "refuse the request" when
`TURNSTILE_REQUIRED=true`, and "admit" when `TURNSTILE_REQUIRED=false`.
"""
from __future__ import annotations

import logging
import os
from dataclasses import dataclass

import httpx

log = logging.getLogger(__name__)


SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"


def _secret() -> str:
    return os.environ.get("CLOUDFLARE_TURNSTILE_SECRET", "").strip()


def _required() -> bool:
    raw = os.environ.get("TURNSTILE_REQUIRED", "").strip().lower()
    return raw in ("1", "true", "yes", "on")


def _siteverify_url() -> str:
    return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL


async def _siteverify_post(url: str, data: dict) -> httpx.Response:
    """Perform the siteverify POST on an `httpx.AsyncClient`.

    Isolated as a narrow seam (I4, security-audit-0026): the call is
    awaited so a slow CloudFlare response can't block the event loop,
    and tests patch *this function* rather than the shared
    `httpx.AsyncClient` (which other modules — gitea, docs — also
    construct, so a global patch would break app boot).
    """
    async with httpx.AsyncClient(timeout=10.0) as client:
        return await client.post(url, data=data)


@dataclass
class VerifyOutcome:
    """Result of a Turnstile siteverify call.

    `ok`: the request **may proceed**. True both for "siteverify said
    success" and for "no secret configured AND not required" (the
    dev/test soft-fail path).

    `reason`: one of
      * 'ok'             — siteverify returned success.
      * 'skipped'        — no secret configured, TURNSTILE_REQUIRED=false.
                            The gate is open; the endpoint admits the request.
      * 'misconfigured'  — TURNSTILE_REQUIRED=true but no secret in env.
                            The endpoint fails closed with 500.
      * 'missing-token'  — the client did not send a token at all and
                            verification is required.
      * 'failed'         — siteverify returned success=false. The
                            endpoint refuses with 400.
      * 'network'        — siteverify call raised. Treated as a failure
                            under TURNSTILE_REQUIRED=true.
    """
    ok: bool
    reason: str


async def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
    """Validate a Turnstile token against CloudFlare's siteverify endpoint.

    Returns a VerifyOutcome describing whether the calling endpoint
    should proceed. The endpoint maps `ok=False` to an HTTP status per
    the `reason`:

      * 'misconfigured' → 500 "auth misconfigured"
      * 'missing-token' / 'failed' / 'network' → 400 "verification failed"

    Async (I4, security-audit-0026): the siteverify call is awaited on an
    `httpx.AsyncClient` so a slow CloudFlare response can't block the
    event loop (the prior synchronous `httpx.post` stalled the single
    worker for up to the 10s timeout). Callers must `await` it.

    Tests monkeypatch `_siteverify_post` (the narrow async seam) to avoid
    touching the real CloudFlare endpoint and to keep the patch off the
    shared `httpx.AsyncClient`. No real keys are ever embedded in tests.
    """
    secret = _secret()
    required = _required()

    if not secret:
        if required:
            log.warning("Turnstile required but CLOUDFLARE_TURNSTILE_SECRET is unset; failing closed")
            return VerifyOutcome(ok=False, reason="misconfigured")
        # Dev/test/soft-fail path: no secret, not required → gate is open.
        return VerifyOutcome(ok=True, reason="skipped")

    if not token or not token.strip():
        # Secret is set, so verification is in force. A missing token
        # is a hard refuse — the frontend should have rendered the
        # widget and collected one.
        return VerifyOutcome(ok=False, reason="missing-token")

    data = {"secret": secret, "response": token.strip()}
    if client_ip:
        data["remoteip"] = client_ip

    try:
        response = await _siteverify_post(_siteverify_url(), data)
        payload = response.json()
    except Exception as exc:  # network, JSON parse, etc.
        log.warning("Turnstile siteverify call failed: %s", exc)
        return VerifyOutcome(ok=False, reason="network")

    if payload.get("success") is True:
        return VerifyOutcome(ok=True, reason="ok")

    # `error-codes` is a list of strings on failure; we log the codes
    # for the operator without surfacing them to the client.
    log.info("Turnstile siteverify rejected token: %s", payload.get("error-codes"))
    return VerifyOutcome(ok=False, reason="failed")
