"""
Sheet webhook subscriptions and ingest endpoints.
"""

from __future__ import annotations

import asyncio
from collections import deque
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import json
import logging
import os
import secrets
import time
from typing import Any, Deque, Dict, List, Optional
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse, Response
import httpx
import redis
from rq import Queue
from sqlalchemy import select
from sqlalchemy import func as sa_func
from sqlalchemy.exc import (
    DBAPIError,
    IntegrityError,
    OperationalError,
    TimeoutError as SQLAlchemyTimeoutError,
)
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from starlette.requests import ClientDisconnect

from ... import settings as saas_settings
from ...auth_security import require_account
from ...db import AsyncSessionLocal, get_session
from ...model_defs.sheet_webhook_models import SheetWebhookSubscription
from fmatch.saas.model_defs.webhook_models import InboundWebhook
from ...models import Account, User
from ...security.crypto import decrypt_str
from ...services.admin_alerts import notify_sheet_webhook_delivery_failed
from ...services.direct_sheets_writer import (
    DirectSheetsWriter,
    DirectSheetsWriterError,
    RateLimitedError,
    SheetNotFoundError,
    TransientGoogleSheetsError,
)
from ...services.email_notifications import send_sheet_webhook_failure_notification
from ...services.google_sheets_oauth import ensure_google_access_token
from ...services.usage_gateway import UsageGateway

try:
    import redis.asyncio as redis_async
    from redis.exceptions import ConnectionError as RedisConnectionError, LockError
except Exception:  # pragma: no cover - import/env dependent
    redis_async = None
    RedisConnectionError = Exception
    LockError = Exception

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/v2/webhooks/sheets", tags=["sheet_webhooks"])

WRITE_MODE_APPEND = "append"
WRITE_MODE_REPLACE = "replace"
MAX_PAYLOAD_BYTES = 1_000_000
MAX_PAYLOAD_PREVIEW_BYTES = 10_000
SENSITIVE_INBOUND_HEADER_NAMES = {
    "authorization",
    "cookie",
    "proxy-authorization",
    "set-cookie",
    "x-api-key",
    "x-hub-signature",
    "x-hub-signature-256",
    "x-signature",
    "x-webhook-signature",
    "x-webhook-signature-256",
}
SHEET_WEBHOOK_SIGNATURE_HEADER_NAMES = (
    "X-Webhook-Signature-256",
    "X-Webhook-Signature",
    "X-Hub-Signature-256",
    "X-Hub-Signature",
    "X-Signature",
)
DEFAULT_SHEET_WEBHOOK_RETRY_BASE_SECONDS = 2
SHEET_WEBHOOK_MAX_RETRIES = max(
    0,
    int(
        os.getenv(
            "SHEET_WEBHOOK_MAX_RETRIES",
            str(getattr(saas_settings, "WEBHOOK_MAX_RETRIES", 3)),
        )
    ),
)
SHEET_WEBHOOK_JOB_TIMEOUT_SECONDS = max(
    30,
    int(os.getenv("SHEET_WEBHOOK_JOB_TIMEOUT_SECONDS", "600")),
)
SHEET_WEBHOOK_RESULT_TTL_SECONDS = max(
    60,
    int(os.getenv("SHEET_WEBHOOK_RESULT_TTL_SECONDS", "3600")),
)
SHEET_WEBHOOK_FAILURE_TTL_SECONDS = max(
    60,
    int(os.getenv("SHEET_WEBHOOK_FAILURE_TTL_SECONDS", "86400")),
)

_rq_conn: Optional[redis.Redis] = None
_rq_queue: Optional[Queue] = None
_sheet_webhook_db_overloaded_until_monotonic = 0.0
_sheet_webhook_write_lock_redis_client: Any = None
_sheet_webhook_ingest_redis_client: Any = None
_sheet_webhook_local_write_locks: Dict[str, asyncio.Lock] = {}
_sheet_webhook_local_rate_limit_hits: Dict[str, Deque[float]] = {}
_sheet_webhook_local_rate_limit_lock = asyncio.Lock()
QUEUED_DELIVERY_STATUSES = {"received", "queued", "processing"}


def _env_int(name: str, default: int, *, minimum: int) -> int:
    try:
        value = int(os.getenv(name, str(default)))
    except (TypeError, ValueError):
        value = default
    return max(minimum, value)


def _env_float(name: str, default: float, *, minimum: float) -> float:
    try:
        value = float(os.getenv(name, str(default)))
    except (TypeError, ValueError):
        value = default
    return max(minimum, value)


def _env_bool(name: str, default: bool) -> bool:
    raw = os.getenv(name)
    if raw is None:
        return default
    return str(raw).strip().lower() in {"1", "true", "yes", "on"}


def _utcnow() -> datetime:
    # Preserve existing naive-UTC persistence semantics while avoiding utcnow().
    return datetime.now(timezone.utc).replace(tzinfo=None)


SHEET_WEBHOOK_RETRY_AFTER_SECONDS = _env_int(
    "SHEET_WEBHOOK_RETRY_AFTER_SECONDS",
    2,
    minimum=1,
)
SHEET_WEBHOOK_INGEST_MAX_INFLIGHT = _env_int(
    "SHEET_WEBHOOK_INGEST_MAX_INFLIGHT",
    max(1, int(getattr(saas_settings, "DATABASE_POOL_SIZE", 10))),
    minimum=1,
)
SHEET_WEBHOOK_INGEST_ACQUIRE_TIMEOUT_SECONDS = _env_float(
    "SHEET_WEBHOOK_INGEST_ACQUIRE_TIMEOUT_SECONDS",
    0.1,
    minimum=0.01,
)
SHEET_WEBHOOK_DB_COOLDOWN_SECONDS = _env_float(
    "SHEET_WEBHOOK_DB_COOLDOWN_SECONDS",
    float(SHEET_WEBHOOK_RETRY_AFTER_SECONDS),
    minimum=0.1,
)
SHEET_WEBHOOK_WRITE_LOCK_TIMEOUT_SECONDS = _env_int(
    "SHEET_WEBHOOK_WRITE_LOCK_TIMEOUT_SECONDS",
    SHEET_WEBHOOK_JOB_TIMEOUT_SECONDS + 30,
    minimum=30,
)
SHEET_WEBHOOK_STALE_PROCESSING_SECONDS = _env_int(
    "SHEET_WEBHOOK_STALE_PROCESSING_SECONDS",
    SHEET_WEBHOOK_JOB_TIMEOUT_SECONDS + 60,
    minimum=max(30, SHEET_WEBHOOK_JOB_TIMEOUT_SECONDS),
)
SHEET_WEBHOOK_STALE_RECOVERY_LIMIT = _env_int(
    "SHEET_WEBHOOK_STALE_RECOVERY_LIMIT",
    10,
    minimum=1,
)
SHEET_WEBHOOK_WRITE_LOCK_BLOCKING_TIMEOUT_SECONDS = _env_float(
    "SHEET_WEBHOOK_WRITE_LOCK_BLOCKING_TIMEOUT_SECONDS",
    60.0,
    minimum=1.0,
)
SHEET_WEBHOOK_INGEST_DISTRIBUTED_SLOT_TTL_SECONDS = _env_int(
    "SHEET_WEBHOOK_INGEST_DISTRIBUTED_SLOT_TTL_SECONDS",
    max(5, SHEET_WEBHOOK_RETRY_AFTER_SECONDS * 5),
    minimum=5,
)
SHEET_WEBHOOK_REQUIRE_SIGNATURE = _env_bool(
    "SHEET_WEBHOOK_REQUIRE_SIGNATURE",
    True,
)
SHEET_WEBHOOK_SECRET_RATE_LIMIT_PER_MINUTE = _env_int(
    "SHEET_WEBHOOK_SECRET_RATE_LIMIT_PER_MINUTE",
    120,
    minimum=1,
)
SHEET_WEBHOOK_SECRET_RATE_LIMIT_BURST_MULTIPLIER = _env_float(
    "SHEET_WEBHOOK_SECRET_RATE_LIMIT_BURST_MULTIPLIER",
    1.5,
    minimum=1.0,
)
SHEET_WEBHOOK_SECRET_RATE_LIMIT_WINDOW_SECONDS = _env_int(
    "SHEET_WEBHOOK_SECRET_RATE_LIMIT_WINDOW_SECONDS",
    60,
    minimum=1,
)
_sheet_webhook_ingest_semaphore = asyncio.Semaphore(SHEET_WEBHOOK_INGEST_MAX_INFLIGHT)


def _is_db_capacity_error(exc: BaseException) -> bool:
    if isinstance(exc, SQLAlchemyTimeoutError):
        return True

    markers = (
        "toomanyconnectionserror",
        "remaining connection slots",
        "remaining connection slots are reserved",
        "queuepool limit",
        "connection pool",
        "connection pool is full",
        "timed out waiting for connection",
        "sorry, too many clients already",
        "too many connections",
        "too many clients",
    )

    seen = set()
    stack: List[Optional[BaseException]] = [exc]
    while stack:
        current = stack.pop()
        if current is None:
            continue

        marker = id(current)
        if marker in seen:
            continue
        seen.add(marker)

        text = str(current).lower()
        if any(candidate in text for candidate in markers):
            return True

        stack.append(getattr(current, "orig", None))
        stack.append(getattr(current, "__cause__", None))
        stack.append(getattr(current, "__context__", None))

    return False


def _is_db_lock_contention_error(exc: BaseException) -> bool:
    markers = (
        "could not obtain lock on row",
        "canceling statement due to lock timeout",
        "lock not available",
        "lock timeout",
        "deadlock detected",
        "database is locked",
    )

    seen = set()
    stack: List[Optional[BaseException]] = [exc]
    while stack:
        current = stack.pop()
        if current is None:
            continue

        marker = id(current)
        if marker in seen:
            continue
        seen.add(marker)

        text = str(current).lower()
        if any(candidate in text for candidate in markers):
            return True

        stack.append(getattr(current, "orig", None))
        stack.append(getattr(current, "__cause__", None))
        stack.append(getattr(current, "__context__", None))

    return False


def _mark_db_overloaded() -> None:
    global _sheet_webhook_db_overloaded_until_monotonic
    overloaded_until = time.monotonic() + SHEET_WEBHOOK_DB_COOLDOWN_SECONDS
    if overloaded_until > _sheet_webhook_db_overloaded_until_monotonic:
        _sheet_webhook_db_overloaded_until_monotonic = overloaded_until


def _db_overload_window_active() -> bool:
    return time.monotonic() < _sheet_webhook_db_overloaded_until_monotonic


def _service_overloaded_response(message: str) -> JSONResponse:
    return JSONResponse(
        status_code=503,
        content={
            "ok": False,
            "error": "service_overloaded",
            "message": message,
        },
        headers={"Retry-After": str(SHEET_WEBHOOK_RETRY_AFTER_SECONDS)},
    )


def _ingest_over_capacity_response(message: str) -> JSONResponse:
    return JSONResponse(
        status_code=429,
        content={
            "ok": False,
            "error": "service_overloaded",
            "code": "ingest_over_capacity",
            "message": message,
        },
        headers={"Retry-After": str(SHEET_WEBHOOK_RETRY_AFTER_SECONDS)},
    )


def _service_overloaded_exception(message: str) -> HTTPException:
    return HTTPException(
        status_code=503,
        detail=message,
        headers={"Retry-After": str(SHEET_WEBHOOK_RETRY_AFTER_SECONDS)},
    )


def _sheet_webhook_signature_required() -> bool:
    return SHEET_WEBHOOK_REQUIRE_SIGNATURE


def _extract_sheet_webhook_signature(request: Request) -> Optional[str]:
    for header_name in SHEET_WEBHOOK_SIGNATURE_HEADER_NAMES:
        signature = request.headers.get(header_name)
        if signature:
            return signature
    return None


def _compute_sheet_webhook_signature(secret: str, body: bytes) -> str:
    digest = hmac.new(
        str(secret or "").encode("utf-8"),
        body,
        hashlib.sha256,
    ).hexdigest()
    return f"sha256={digest}"


def _verify_sheet_webhook_signature(secret: str, body: bytes, signature: str) -> bool:
    provided = str(signature or "").strip()
    if not provided:
        return False
    expected = _compute_sheet_webhook_signature(secret, body)
    if provided.lower().startswith("sha256="):
        return hmac.compare_digest(expected.lower(), provided.lower())
    return hmac.compare_digest(expected.split("=", 1)[1], provided.lower())


def _sheet_webhook_secret_rate_limit_key(secret: str) -> str:
    digest = hashlib.sha256(str(secret or "").encode("utf-8")).hexdigest()
    return f"sheet_webhook:rate:{digest}"


def _sheet_webhook_secret_rate_limit_burst_limit() -> int:
    return max(
        SHEET_WEBHOOK_SECRET_RATE_LIMIT_PER_MINUTE,
        int(
            SHEET_WEBHOOK_SECRET_RATE_LIMIT_PER_MINUTE
            * SHEET_WEBHOOK_SECRET_RATE_LIMIT_BURST_MULTIPLIER
        ),
    )


def _sheet_webhook_rate_limit_response(retry_after: int) -> JSONResponse:
    safe_retry_after = max(1, int(retry_after or 1))
    return JSONResponse(
        status_code=429,
        content={
            "ok": False,
            "error": "rate_limit_exceeded",
            "code": "sheet_webhook_rate_limit",
            "message": "Webhook rate limit exceeded; retry shortly",
            "retry_after": safe_retry_after,
        },
        headers={"Retry-After": str(safe_retry_after)},
    )


async def _check_sheet_webhook_secret_rate_limit(secret: str) -> tuple[bool, int]:
    limit = _sheet_webhook_secret_rate_limit_burst_limit()
    now = time.time()
    window_seconds = float(SHEET_WEBHOOK_SECRET_RATE_LIMIT_WINDOW_SECONDS)
    window_start = now - window_seconds
    rate_key = _sheet_webhook_secret_rate_limit_key(secret)
    redis_client = _get_sheet_ingest_redis()

    if redis_client is not None:
        try:
            await redis_client.zremrangebyscore(rate_key, 0, window_start)
            current_count = await redis_client.zcard(rate_key)
            if current_count >= limit:
                oldest = await redis_client.zrange(rate_key, 0, 0, withscores=True)
                if oldest:
                    retry_after = int(float(oldest[0][1]) + window_seconds - now) + 1
                else:
                    retry_after = int(window_seconds)
                return False, max(1, retry_after)

            member = f"{time.monotonic_ns()}".encode("utf-8")
            await redis_client.zadd(rate_key, {member: now})
            await redis_client.expire(rate_key, int(window_seconds))
            return True, 0
        except Exception as exc:  # pragma: no cover - env/network dependent
            logger.warning(
                "Falling back to local sheet webhook rate limiting after Redis failure: %s",
                exc,
            )

    async with _sheet_webhook_local_rate_limit_lock:
        hits = _sheet_webhook_local_rate_limit_hits.setdefault(rate_key, deque())
        while hits and hits[0] <= window_start:
            hits.popleft()
        if len(hits) >= limit:
            retry_after = int(hits[0] + window_seconds - now) + 1 if hits else int(window_seconds)
            return False, max(1, retry_after)
        hits.append(now)
        return True, 0


def _is_retryable_google_oauth_error(exc: BaseException) -> bool:
    if isinstance(exc, httpx.HTTPError):
        return True
    if _is_db_capacity_error(exc) or _is_db_lock_contention_error(exc):
        return True
    text = str(exc).lower()
    if "google token refresh failed (" in text and any(
        f"({status})" in text for status in ("429", "500", "502", "503", "504")
    ):
        return True
    return any(
        marker in text
        for marker in (
            "concurrent token refresh in progress",
            "connection reset",
            "deadline exceeded",
            "service unavailable",
            "temporarily unavailable",
            "timed out",
            "timeout",
            "try again later",
        )
    )


async def _acquire_ingest_slot() -> Optional[_SheetWebhookIngestLease]:
    distributed_acquired, distributed_key, distributed_active = (
        await _acquire_distributed_ingest_slot()
    )
    if not distributed_acquired:
        return None

    try:
        await asyncio.wait_for(
            _sheet_webhook_ingest_semaphore.acquire(),
            timeout=SHEET_WEBHOOK_INGEST_ACQUIRE_TIMEOUT_SECONDS,
        )
        return _SheetWebhookIngestLease(
            distributed_key=distributed_key,
            distributed_active=distributed_active,
        )
    except asyncio.TimeoutError:
        if distributed_active and distributed_key:
            redis_client = _get_sheet_ingest_redis()
            if redis_client is not None:
                try:
                    remaining = await redis_client.decr(distributed_key)
                    if remaining <= 0:
                        await redis_client.delete(distributed_key)
                except Exception as exc:  # pragma: no cover - env/network dependent
                    logger.warning(
                        "Failed to roll back distributed sheet ingest slot for %s: %s",
                        distributed_key,
                        exc,
                    )
        return None


class SheetWebhookWriteLockError(RuntimeError):
    pass


class CreateSheetWebhookRequest(BaseModel):
    name: str = Field(..., min_length=1, max_length=255)
    spreadsheet_id: str = Field(..., min_length=1, max_length=255)
    sheet_name: str = Field(..., min_length=1, max_length=255)
    create_sheet: bool = False
    write_mode: str = WRITE_MODE_APPEND
    active: bool = True


class UpdateSheetWebhookRequest(BaseModel):
    name: Optional[str] = Field(default=None, min_length=1, max_length=255)
    spreadsheet_id: Optional[str] = Field(default=None, min_length=1, max_length=255)
    sheet_name: Optional[str] = Field(default=None, min_length=1, max_length=255)
    create_sheet: Optional[bool] = None
    write_mode: Optional[str] = None
    active: Optional[bool] = None


def _normalize_write_mode(mode: Optional[str]) -> str:
    value = str(mode or WRITE_MODE_APPEND).strip().lower()
    if value not in {WRITE_MODE_APPEND, WRITE_MODE_REPLACE}:
        raise HTTPException(status_code=400, detail="write_mode must be append or replace")
    return value


def _normalize_sheet_name(name: Optional[str]) -> str:
    value = str(name or "").strip()
    if not value:
        raise HTTPException(status_code=400, detail="sheet_name is required")
    return value


def _generate_secret() -> str:
    return secrets.token_hex(16)


def _build_ingest_url(request: Request, secret: str) -> str:
    configured_base = (
        os.getenv("PUBLIC_API_BASE_URL")
        or getattr(saas_settings, "PUBLIC_API_BASE_URL", None)
        or getattr(saas_settings, "PUBLIC_BASE_URL", None)
    )
    if configured_base:
        normalized_base = str(configured_base).strip().rstrip("/")
        if normalized_base.startswith("https://") or normalized_base.startswith("http://"):
            return f"{normalized_base}/api/v2/webhooks/sheets/{secret}/ingest"
    return str(request.url_for("ingest_sheet_webhook", secret=secret))


def _get_sheet_webhook_queue() -> Queue:
    global _rq_conn
    global _rq_queue

    if _rq_queue is not None:
        return _rq_queue

    redis_url = saas_settings.REDIS_URL or "redis://127.0.0.1:6379/0"
    _rq_conn = redis.from_url(redis_url)
    _rq_queue = Queue(
        getattr(saas_settings, "RQ_QUEUE_NAME", "default"),
        connection=_rq_conn,
    )
    return _rq_queue


def _enqueue_sheet_webhook_delivery(
    inbound_id: str,
    *,
    delay_seconds: int = 0,
    db_retry_attempt: int = 0,
) -> str:
    queue = _get_sheet_webhook_queue()

    safe_db_retry_attempt = max(0, int(db_retry_attempt))
    enqueue_kwargs: Dict[str, Any] = {
        "job_timeout": SHEET_WEBHOOK_JOB_TIMEOUT_SECONDS,
        "result_ttl": SHEET_WEBHOOK_RESULT_TTL_SECONDS,
        "failure_ttl": SHEET_WEBHOOK_FAILURE_TTL_SECONDS,
        "description": (
            f"sheet_webhook_delivery:{inbound_id}:db_retry={safe_db_retry_attempt}"
        ),
    }

    if delay_seconds > 0:
        job = queue.enqueue_in(
            timedelta(seconds=delay_seconds),
            "fmatch.saas.worker.run_sheet_webhook_delivery",
            inbound_id,
            safe_db_retry_attempt,
            **enqueue_kwargs,
        )
    else:
        job = queue.enqueue(
            "fmatch.saas.worker.run_sheet_webhook_delivery",
            inbound_id,
            safe_db_retry_attempt,
            **enqueue_kwargs,
        )
    return str(job.id)


def _compute_retry_delay_seconds(retry_attempt: int) -> int:
    # Exponential backoff with a conservative cap to avoid huge retry gaps.
    delay = DEFAULT_SHEET_WEBHOOK_RETRY_BASE_SECONDS * (2 ** max(0, retry_attempt - 1))
    return max(DEFAULT_SHEET_WEBHOOK_RETRY_BASE_SECONDS, min(60, delay))


def _next_month_start_utc(now: Optional[datetime] = None) -> datetime:
    current = now or datetime.now(timezone.utc)
    if current.month == 12:
        return datetime(current.year + 1, 1, 1, tzinfo=timezone.utc)
    return datetime(current.year, current.month + 1, 1, tzinfo=timezone.utc)


def _retry_after_seconds_until(reset_at: datetime) -> int:
    now = datetime.now(timezone.utc)
    return max(1, int((reset_at - now).total_seconds()))


def _build_quota_exceeded_response(
    *,
    used: int,
    limit: int,
    rows_attempted: int,
) -> JSONResponse:
    reset_at = _next_month_start_utc()
    retry_after_seconds = _retry_after_seconds_until(reset_at)
    return JSONResponse(
        status_code=429,
        headers={"Retry-After": str(retry_after_seconds)},
        content={
            "ok": False,
            "error": (
                f"Monthly webhook row limit reached "
                f"({used}/{limit}, attempted +{rows_attempted})."
            ),
            "reset_at": reset_at.isoformat(),
        },
    )


def _serialize_subscription(
    sub: SheetWebhookSubscription,
    request: Request,
    include_secret: bool = False,
    activity: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    has_error = bool(str(sub.last_error or "").strip())
    data = {
        "id": str(sub.id),
        "name": sub.name,
        "account_id": sub.account_id,
        "spreadsheet_id": sub.spreadsheet_id,
        "sheet_name": sub.sheet_name,
        "create_sheet": bool(sub.create_sheet),
        "write_mode": sub.write_mode,
        "active": bool(sub.active),
        "webhook_url": _build_ingest_url(request, sub.secret),
        "last_received_at": sub.last_received_at.isoformat() if sub.last_received_at else None,
        "total_deliveries": int(sub.total_deliveries or 0),
        "total_rows_written": int(sub.total_rows_written or 0),
        "last_error": sub.last_error,
        "needs_attention": has_error,
        "health_status": "attention" if has_error else "healthy",
        "created_at": sub.created_at.isoformat() if sub.created_at else None,
        "updated_at": sub.updated_at.isoformat() if sub.updated_at else None,
        "last_changed_at": sub.updated_at.isoformat() if sub.updated_at else None,
        "pending_delivery_count": 0,
        "latest_delivery": None,
        "last_test_at": None,
        "last_successful_write_at": None,
        "last_successful_rows_written": None,
    }
    if activity:
        data["pending_delivery_count"] = int(activity.get("pending_delivery_count") or 0)
        data["latest_delivery"] = activity.get("latest_delivery")
        data["last_test_at"] = activity.get("last_test_at")
        data["last_successful_write_at"] = activity.get("last_successful_write_at")
        data["last_successful_rows_written"] = activity.get(
            "last_successful_rows_written"
        )
    if include_secret:
        data["secret"] = sub.secret
    return data


def _is_truthy_header_flag(value: Any) -> bool:
    if value is True:
        return True
    if isinstance(value, str):
        return value.strip().lower() in {"1", "true", "yes"}
    return False


def _inbound_is_test_delivery(inbound: InboundWebhook) -> bool:
    headers = inbound.headers if isinstance(inbound.headers, dict) else {}
    if _is_truthy_header_flag(headers.get("X-Webhook-Test")):
        return True

    response_body = inbound.response_body if isinstance(inbound.response_body, dict) else {}
    if response_body.get("test") is True:
        return True

    parsed_data = inbound.parsed_data if isinstance(inbound.parsed_data, dict) else {}
    records = parsed_data.get("records")
    if isinstance(records, list) and records:
        first = records[0]
        if isinstance(first, dict) and str(first.get("event") or "") == "webhook_test":
            return True

    return False


def _inbound_is_replay_delivery(inbound: InboundWebhook) -> bool:
    headers = inbound.headers if isinstance(inbound.headers, dict) else {}
    if _is_truthy_header_flag(headers.get("X-Webhook-Replay")):
        return True

    response_body = inbound.response_body if isinstance(inbound.response_body, dict) else {}
    return bool(response_body.get("replay_of_delivery_id"))


def _serialize_delivery_summary(inbound: InboundWebhook) -> Dict[str, Any]:
    response_body = inbound.response_body if isinstance(inbound.response_body, dict) else {}
    queued = inbound.status in QUEUED_DELIVERY_STATUSES
    rows_written = response_body.get("rows_written")
    if queued:
        rows_written = None
    return {
        "received_at": inbound.received_at.isoformat() if inbound.received_at else None,
        "status": inbound.status,
        "queued": queued,
        "record_count": int(inbound.record_count or 0),
        "rows_written": rows_written,
        "response_status_code": inbound.response_status_code,
        "error": inbound.error,
        "error_code": inbound.error_code,
        "is_test": _inbound_is_test_delivery(inbound),
        "is_replay": _inbound_is_replay_delivery(inbound),
    }


async def _collect_subscription_activity(
    session: AsyncSession,
    subscriptions: List[SheetWebhookSubscription],
) -> Dict[str, Dict[str, Any]]:
    sub_ids = [str(sub.id) for sub in subscriptions if sub and sub.id]
    if not sub_ids:
        return {}

    activity: Dict[str, Dict[str, Any]] = {
        sub_id: {"pending_delivery_count": 0} for sub_id in sub_ids
    }

    pending_counts_result = await session.execute(
        select(
            InboundWebhook.integration_id,
            sa_func.count(InboundWebhook.id),
        )
        .where(
            InboundWebhook.source == "sheet_webhook",
            InboundWebhook.integration_id.in_(sub_ids),
            InboundWebhook.status.in_(list(QUEUED_DELIVERY_STATUSES)),
        )
        .group_by(InboundWebhook.integration_id)
    )
    for integration_id, pending_count in pending_counts_result.all():
        key = str(integration_id or "")
        if key in activity:
            activity[key]["pending_delivery_count"] = int(pending_count or 0)

    recent_limit = max(100, min(500, len(sub_ids) * 30))
    recent_rows_result = await session.execute(
        select(InboundWebhook)
        .where(
            InboundWebhook.source == "sheet_webhook",
            InboundWebhook.integration_id.in_(sub_ids),
        )
        .order_by(InboundWebhook.received_at.desc())
        .limit(recent_limit)
    )
    recent_rows = recent_rows_result.scalars().all()

    for row in recent_rows:
        key = str(row.integration_id or "")
        if key not in activity:
            continue

        entry = activity[key]
        if entry.get("latest_delivery") is None:
            entry["latest_delivery"] = _serialize_delivery_summary(row)

        if entry.get("last_test_at") is None and _inbound_is_test_delivery(row):
            entry["last_test_at"] = (
                row.received_at.isoformat() if row.received_at else None
            )

        if entry.get("last_successful_write_at") is None:
            summary = _serialize_delivery_summary(row)
            rows_written = summary.get("rows_written")
            if rows_written is not None and row.status == "processed":
                write_time = row.processed_at or row.received_at
                entry["last_successful_write_at"] = (
                    write_time.isoformat() if write_time else None
                )
                entry["last_successful_rows_written"] = int(rows_written or 0)

    return activity


def _truncate_json_payload_for_storage(payload: Any) -> Dict[str, Any]:
    if isinstance(payload, dict):
        out: Dict[str, Any] = payload
    elif isinstance(payload, list):
        out = {"records": payload}
    else:
        out = {"value": payload}

    try:
        raw = json.dumps(out, ensure_ascii=False, default=str)
    except Exception:
        return {"preview": "<unserializable payload>"}

    if len(raw.encode("utf-8")) <= MAX_PAYLOAD_PREVIEW_BYTES:
        return out

    preview = raw.encode("utf-8")[:MAX_PAYLOAD_PREVIEW_BYTES].decode("utf-8", "ignore")
    return {"_truncated": True, "preview": preview}


def _normalize_payload_to_records(payload: Any) -> List[Dict[str, Any]]:
    if isinstance(payload, dict):
        return [payload]
    if not isinstance(payload, list):
        raise HTTPException(
            status_code=400,
            detail="Payload must be a JSON object or an array of JSON objects",
        )

    records: List[Dict[str, Any]] = []
    for idx, item in enumerate(payload):
        if not isinstance(item, dict):
            raise HTTPException(
                status_code=400,
                detail=f"Payload item at index {idx} must be a JSON object",
            )
        records.append(item)
    return records


def _parse_and_validate_payload(raw_body: str) -> Dict[str, Any]:
    try:
        parsed_payload = json.loads(raw_body or "{}")
    except json.JSONDecodeError as exc:
        raise HTTPException(status_code=400, detail=f"Invalid JSON payload: {exc.msg}") from exc

    records = _normalize_payload_to_records(parsed_payload)
    if not records:
        raise HTTPException(status_code=400, detail="Payload must contain at least one record")

    incoming_keys = _collect_incoming_keys(records)
    if not incoming_keys:
        raise HTTPException(
            status_code=400,
            detail="Payload records must contain at least one key",
        )

    return {
        "parsed_payload": parsed_payload,
        "records": records,
        "incoming_keys": incoming_keys,
        "payload_preview": _truncate_json_payload_for_storage(parsed_payload),
    }


def _collect_incoming_keys(records: List[Dict[str, Any]]) -> List[str]:
    ordered: List[str] = []
    seen = set()
    for record in records:
        for key in record.keys():
            k = str(key)
            if k not in seen:
                seen.add(k)
                ordered.append(k)
    return ordered


def _normalize_cell_value(value: Any) -> Any:
    if value is None:
        return ""
    if isinstance(value, (str, int, float, bool)):
        return value
    if isinstance(value, datetime):
        return value.isoformat()
    try:
        return json.dumps(value, ensure_ascii=False, default=str)
    except Exception:
        return str(value)


def _payload_preview_from_inbound(webhook: InboundWebhook) -> str:
    if webhook.raw_body:
        text = webhook.raw_body
    else:
        try:
            text = json.dumps(webhook.payload or {}, ensure_ascii=False, default=str)
        except Exception:
            text = "{}"
    if len(text.encode("utf-8")) <= 1024:
        return text
    return text.encode("utf-8")[:1024].decode("utf-8", "ignore") + "..."


def _redact_sheet_webhook_path(path: str, secret: Optional[str]) -> str:
    redacted_path = str(path or "")
    normalized_secret = str(secret or "").strip()
    if not normalized_secret:
        return redacted_path
    needle = f"/api/v2/webhooks/sheets/{normalized_secret}/ingest"
    return redacted_path.replace(needle, "/api/v2/webhooks/sheets/[redacted]/ingest")


def _sanitize_inbound_headers(
    headers: Optional[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
    if not isinstance(headers, dict):
        return headers
    sanitized: Dict[str, Any] = {}
    for key, value in headers.items():
        header_name = str(key or "")
        if header_name.lower() in SENSITIVE_INBOUND_HEADER_NAMES:
            sanitized[header_name] = "[redacted]"
        else:
            sanitized[header_name] = value
    return sanitized


def _records_blob_from_inbound(inbound: InboundWebhook) -> Optional[List[Dict[str, Any]]]:
    if not isinstance(inbound.parsed_data, dict):
        return None
    candidate = inbound.parsed_data.get("records")
    if isinstance(candidate, list):
        return candidate
    return None


def _processing_started_at(inbound: InboundWebhook) -> Optional[datetime]:
    return inbound.processed_at or inbound.received_at


def _inbound_processing_is_stale(
    inbound: InboundWebhook,
    *,
    now: Optional[datetime] = None,
) -> bool:
    if str(inbound.status or "") != "processing":
        return False
    processing_started_at = _processing_started_at(inbound)
    if not processing_started_at:
        return False
    current_time = now or _utcnow()
    age_seconds = max(
        0.0, (current_time - processing_started_at).total_seconds()
    )
    return age_seconds >= float(SHEET_WEBHOOK_STALE_PROCESSING_SECONDS)


def _normalize_correlation_id(value: Optional[str]) -> Optional[str]:
    normalized = str(value or "").strip()
    if not normalized:
        return None
    if len(normalized) > 255:
        raise HTTPException(
            status_code=400,
            detail="Correlation identifier must be 255 characters or fewer",
        )
    return normalized


def _declared_payload_size(request: Request) -> Optional[int]:
    raw_value = request.headers.get("Content-Length")
    if raw_value is None:
        return None
    try:
        return max(0, int(raw_value))
    except (TypeError, ValueError):
        return None


async def _find_existing_sheet_delivery_by_correlation(
    session: AsyncSession,
    *,
    subscription_id: UUID,
    correlation_id: str,
) -> Optional[InboundWebhook]:
    result = await session.execute(
        select(InboundWebhook)
        .where(
            InboundWebhook.source == "sheet_webhook",
            InboundWebhook.integration_id == str(subscription_id),
            InboundWebhook.correlation_id == correlation_id,
        )
        .order_by(InboundWebhook.received_at.desc())
    )
    return result.scalars().first()


def _build_duplicate_delivery_response(inbound: InboundWebhook) -> JSONResponse:
    status = str(inbound.status or "received")
    queued = status in {"received", "queued", "processing"}
    content: Dict[str, Any] = {
        "ok": True,
        "duplicate": True,
        "delivery_id": str(inbound.id),
        "queued": queued,
        "status": status,
    }
    if inbound.error and not queued:
        content["error"] = inbound.error
    if isinstance(inbound.response_body, dict):
        rows_written = inbound.response_body.get("rows_written")
        if rows_written is not None:
            content["rows_written"] = rows_written
    return JSONResponse(status_code=202 if queued else 200, content=content)


def _set_inbound_queued_state(
    inbound: InboundWebhook,
    *,
    records: List[Dict[str, Any]],
    payload_for_storage: Dict[str, Any],
    started_at: datetime,
    extra_response_body: Optional[Dict[str, Any]] = None,
) -> None:
    inbound.payload = payload_for_storage
    inbound.parsed_data = {"records": records}
    inbound.record_count = len(records)
    inbound.status = "queued"
    inbound.should_retry = True
    inbound.error = None
    inbound.error_code = None
    inbound.response_status_code = 202
    response_body: Dict[str, Any] = {"ok": True, "queued": True}
    if extra_response_body:
        response_body.update(extra_response_body)
    inbound.response_body = response_body
    inbound.processed_at = _utcnow()
    inbound.processing_time_ms = max(
        0,
        int((inbound.processed_at - started_at).total_seconds() * 1000),
    )


def _set_inbound_processing_state(
    inbound: InboundWebhook,
    *,
    started_at: datetime,
) -> None:
    inbound.status = "processing"
    inbound.error = None
    inbound.error_code = None
    inbound.processed_at = started_at
    inbound.processing_time_ms = None


async def _create_queued_inbound_delivery(
    session: AsyncSession,
    *,
    sub: SheetWebhookSubscription,
    started_at: datetime,
    raw_body: str,
    body_bytes: bytes,
    records: List[Dict[str, Any]],
    payload_for_storage: Dict[str, Any],
    method: str,
    path: str,
    headers: Optional[Dict[str, Any]],
    query_params: Optional[Dict[str, Any]],
    source_ip: Optional[str],
    user_agent: Optional[str],
    content_type: Optional[str],
    correlation_id: Optional[str],
    extra_response_body: Optional[Dict[str, Any]] = None,
) -> InboundWebhook:
    inbound = InboundWebhook(
        account_id=sub.account_id,
        source="sheet_webhook",
        source_ip=source_ip,
        user_agent=user_agent,
        method=method,
        path=path,
        headers=headers,
        query_params=query_params,
        raw_body=raw_body[:MAX_PAYLOAD_PREVIEW_BYTES],
        content_type=content_type,
        payload_size=len(body_bytes),
        status="received",
        data_format="json",
        integration_id=str(sub.id),
        correlation_id=correlation_id,
    )
    session.add(inbound)
    await session.flush()
    _set_inbound_queued_state(
        inbound,
        records=records,
        payload_for_storage=payload_for_storage,
        started_at=started_at,
        extra_response_body=extra_response_body,
    )
    await session.commit()
    await session.refresh(inbound)
    await session.refresh(sub)
    return inbound


def _explicit_sheet_webhook_redis_url() -> Optional[str]:
    if os.getenv("FM_TEST_MODE") == "1":
        return None

    env_redis_url = os.getenv("REDIS_URL")
    if env_redis_url:
        return env_redis_url

    configured = saas_settings.REDIS_URL
    if configured in {"redis://localhost:6379/0", "redis://127.0.0.1:6379/0"}:
        return None
    return configured


def _get_sheet_ingest_redis():
    global _sheet_webhook_ingest_redis_client

    if redis_async is None:
        return None

    redis_url = _explicit_sheet_webhook_redis_url()
    if not redis_url:
        return None

    client = _sheet_webhook_ingest_redis_client
    if client is False:
        return None
    if client is None:
        try:
            _sheet_webhook_ingest_redis_client = redis_async.from_url(
                redis_url,
                decode_responses=False,
            )
        except Exception as exc:  # pragma: no cover - env/network dependent
            logger.warning(
                "Distributed sheet ingest backpressure disabled; Redis unavailable: %s",
                exc,
            )
            _sheet_webhook_ingest_redis_client = False

    client = _sheet_webhook_ingest_redis_client
    return client if client not in (None, False) else None


async def _recover_stale_sheet_webhook_deliveries(
    *,
    exclude_inbound_id: Optional[str] = None,
    limit: Optional[int] = None,
) -> int:
    stale_limit = max(1, int(limit or SHEET_WEBHOOK_STALE_RECOVERY_LIMIT))
    stale_cutoff = _utcnow() - timedelta(seconds=SHEET_WEBHOOK_STALE_PROCESSING_SECONDS)
    recovered = 0

    async with AsyncSessionLocal() as session:
        query = (
            select(InboundWebhook)
            .where(
                InboundWebhook.source == "sheet_webhook",
                InboundWebhook.status == "processing",
                InboundWebhook.should_retry.is_(True),
                InboundWebhook.processed_at.is_not(None),
                InboundWebhook.processed_at <= stale_cutoff,
            )
            .order_by(InboundWebhook.processed_at.asc())
            .limit(stale_limit)
        )
        try:
            query = query.with_for_update(skip_locked=True)
        except Exception:
            pass

        result = await session.execute(query)
        stale_inbounds = result.scalars().all()
        if not stale_inbounds:
            return 0

        now = _utcnow()
        for inbound in stale_inbounds:
            inbound_id = str(inbound.id)
            if exclude_inbound_id and inbound_id == str(exclude_inbound_id):
                continue

            next_retry = int(inbound.retry_count or 0) + 1
            if next_retry > SHEET_WEBHOOK_MAX_RETRIES:
                started_at = _processing_started_at(inbound) or now
                inbound.status = "failed"
                inbound.should_retry = False
                inbound.error = "Delivery recovery exhausted after stale processing timeout"
                inbound.error_code = "STALE_PROCESSING_EXHAUSTED"
                inbound.response_status_code = 503
                inbound.response_body = {
                    "ok": False,
                    "error": inbound.error,
                }
                inbound.processed_at = now
                inbound.processing_time_ms = max(
                    0, int((now - started_at).total_seconds() * 1000)
                )
                continue

            try:
                job_id = await asyncio.to_thread(
                    _enqueue_sheet_webhook_delivery,
                    inbound_id,
                    delay_seconds=1,
                )
            except Exception:
                logger.exception(
                    "Failed to enqueue stale sheet webhook recovery for inbound %s",
                    inbound_id,
                )
                continue
            inbound.status = "queued"
            inbound.should_retry = True
            inbound.retry_count = next_retry
            inbound.error = "Recovered stale processing delivery"
            inbound.error_code = "STALE_PROCESSING_RECOVERY"
            inbound.response_status_code = 202
            inbound.response_body = {
                "ok": False,
                "queued": True,
                "recovered_from_stale_processing": True,
                "retry_count": next_retry,
                "job_id": job_id,
            }
            inbound.processed_at = now
            inbound.processing_time_ms = 0
            recovered += 1

        await session.commit()

    if recovered:
        logger.warning(
            "Recovered %s stale sheet webhook deliveries older than %ss",
            recovered,
            SHEET_WEBHOOK_STALE_PROCESSING_SECONDS,
        )
    return recovered


class _SheetWebhookIngestLease:
    def __init__(self, distributed_key: Optional[str], distributed_active: bool):
        self.distributed_key = distributed_key
        self.distributed_active = distributed_active

    async def release(self) -> None:
        _sheet_webhook_ingest_semaphore.release()
        if not self.distributed_active or not self.distributed_key:
            return

        redis_client = _get_sheet_ingest_redis()
        if redis_client is None:
            return

        try:
            remaining = await redis_client.decr(self.distributed_key)
            if remaining <= 0:
                await redis_client.delete(self.distributed_key)
        except Exception as exc:  # pragma: no cover - env/network dependent
            logger.warning(
                "Failed to release distributed sheet ingest slot for %s: %s",
                self.distributed_key,
                exc,
            )


async def _acquire_distributed_ingest_slot() -> tuple[bool, Optional[str], bool]:
    redis_client = _get_sheet_ingest_redis()
    if redis_client is None:
        return True, None, False

    counter_key = "sheet_webhook:ingest:inflight"
    try:
        async with redis_client.pipeline(transaction=True) as pipeline:
            pipeline.incr(counter_key)
            pipeline.expire(
                counter_key,
                SHEET_WEBHOOK_INGEST_DISTRIBUTED_SLOT_TTL_SECONDS,
            )
            count, _ = await pipeline.execute()
        if count > SHEET_WEBHOOK_INGEST_MAX_INFLIGHT:
            await redis_client.decr(counter_key)
            return False, counter_key, True
        return True, counter_key, True
    except Exception as exc:  # pragma: no cover - env/network dependent
        logger.warning(
            "Falling back to local sheet ingest backpressure after Redis failure: %s",
            exc,
        )
        return True, None, False


def _get_sheet_write_lock_redis():
    global _sheet_webhook_write_lock_redis_client

    if redis_async is None:
        return None
    redis_url = _explicit_sheet_webhook_redis_url()
    if not redis_url:
        return None

    client = _sheet_webhook_write_lock_redis_client
    if client is False:
        return None
    if client is None:
        try:
            _sheet_webhook_write_lock_redis_client = redis_async.from_url(
                redis_url,
                decode_responses=False,
            )
        except Exception as exc:  # pragma: no cover - env/network dependent
            logger.warning(
                "Distributed sheet write locking disabled; Redis unavailable: %s",
                exc,
            )
            _sheet_webhook_write_lock_redis_client = False

    client = _sheet_webhook_write_lock_redis_client
    return client if client not in (None, False) else None


def _get_local_sheet_write_lock(lock_name: str) -> asyncio.Lock:
    lock = _sheet_webhook_local_write_locks.get(lock_name)
    if lock is None:
        lock = _sheet_webhook_local_write_locks.setdefault(lock_name, asyncio.Lock())
    return lock


def _sheet_write_lock_name(sub: SheetWebhookSubscription) -> str:
    spreadsheet_id = str(sub.spreadsheet_id or "").strip() or "unknown-spreadsheet"
    sheet_name = str(sub.sheet_name or "").strip() or "unknown-sheet"
    return f"sheet_webhook:write:{spreadsheet_id}:{sheet_name}"


@asynccontextmanager
async def _sheet_destination_write_lock(sub: SheetWebhookSubscription):
    lock_name = _sheet_write_lock_name(sub)
    redis_client = _get_sheet_write_lock_redis()

    if redis_client is None:
        local_lock = _get_local_sheet_write_lock(lock_name)
        await local_lock.acquire()
        try:
            yield
        finally:
            local_lock.release()
        return

    redis_lock = redis_client.lock(
        lock_name,
        timeout=SHEET_WEBHOOK_WRITE_LOCK_TIMEOUT_SECONDS,
    )

    try:
        acquired = await redis_lock.acquire(
            blocking=True,
            blocking_timeout=SHEET_WEBHOOK_WRITE_LOCK_BLOCKING_TIMEOUT_SECONDS,
        )
    except RedisConnectionError as exc:  # pragma: no cover - env/network dependent
        logger.warning(
            "Falling back to local sheet write lock after Redis connection failure: %s",
            exc,
        )
        local_lock = _get_local_sheet_write_lock(lock_name)
        await local_lock.acquire()
        try:
            yield
        finally:
            local_lock.release()
        return
    except Exception as exc:  # pragma: no cover - env/network dependent
        raise SheetWebhookWriteLockError(
            f"Could not acquire sheet destination write lock: {exc}"
        ) from exc

    if not acquired:
        raise SheetWebhookWriteLockError(
            "Timed out waiting for sheet destination write lock"
        )

    try:
        yield
    finally:
        try:
            await redis_lock.release()
        except LockError:
            return
        except Exception as exc:  # pragma: no cover - env/network dependent
            logger.warning("Failed to release sheet destination write lock: %s", exc)


async def _get_subscription_for_account(
    session: AsyncSession, account_id: str, sub_id: UUID
) -> SheetWebhookSubscription:
    result = await session.execute(
        select(SheetWebhookSubscription).where(
            SheetWebhookSubscription.id == sub_id,
            SheetWebhookSubscription.account_id == str(account_id),
        )
    )
    sub = result.scalar_one_or_none()
    if not sub:
        raise HTTPException(status_code=404, detail="Sheet webhook subscription not found")
    return sub


async def _check_active_webhook_limit(
    session: AsyncSession,
    account_id: str,
    *,
    exclude_sub_id: Optional[UUID] = None,
) -> None:
    """Raise 429 when the account is at its active webhook cap."""
    gateway = UsageGateway(session)
    limits = await gateway.get_webhook_limits(str(account_id))
    max_webhooks = limits.get("max_webhooks")
    if max_webhooks is None:
        return

    # Serialize active-count checks per account to reduce race windows on create/reactivate.
    try:
        await session.execute(
            select(Account.id)
            .where(Account.id == str(account_id))
            .with_for_update(nowait=True)
        )
    except (DBAPIError, OperationalError, RuntimeError) as exc:
        if _is_db_lock_contention_error(exc):
            raise HTTPException(
                status_code=503,
                detail="Webhook settings are busy for this account; retry shortly.",
                headers={"Retry-After": str(SHEET_WEBHOOK_RETRY_AFTER_SECONDS)},
            ) from exc
        raise

    query = select(sa_func.count(SheetWebhookSubscription.id)).where(
        SheetWebhookSubscription.account_id == str(account_id),
        SheetWebhookSubscription.active.is_(True),
    )
    if exclude_sub_id is not None:
        query = query.where(SheetWebhookSubscription.id != exclude_sub_id)
    active_count = int((await session.execute(query)).scalar() or 0)

    if active_count >= int(max_webhooks):
        raise HTTPException(
            status_code=429,
            detail=(
                f"Webhook limit reached ({active_count}/{max_webhooks}). "
                "Upgrade your plan for more webhooks."
            ),
        )


async def _mark_failed_delivery(
    session: AsyncSession,
    sub: SheetWebhookSubscription,
    inbound: Optional[InboundWebhook],
    *,
    error_message: str,
    error_code: str,
    status_code: int,
    payload_for_storage: Dict[str, Any],
    started_at: datetime,
) -> None:
    now = _utcnow()
    sub.last_received_at = now
    sub.total_deliveries = int(sub.total_deliveries or 0) + 1
    sub.last_error = error_message[:500]
    sub.last_payload = payload_for_storage
    sub.updated_at = now

    if inbound:
        inbound.status = "failed"
        inbound.error = error_message[:500]
        inbound.error_code = error_code
        inbound.should_retry = False
        inbound.response_status_code = status_code
        inbound.response_body = {"ok": False, "error": error_message[:500]}
        inbound.processed_at = now
        inbound.processing_time_ms = max(0, int((now - started_at).total_seconds() * 1000))

    await session.commit()


async def _safe_mark_failed_delivery(
    session: AsyncSession,
    sub: SheetWebhookSubscription,
    inbound: Optional[InboundWebhook],
    *,
    error_message: str,
    error_code: str,
    status_code: int,
    payload_for_storage: Dict[str, Any],
    started_at: datetime,
) -> None:
    try:
        await _mark_failed_delivery(
            session,
            sub,
            inbound,
            error_message=error_message,
            error_code=error_code,
            status_code=status_code,
            payload_for_storage=payload_for_storage,
            started_at=started_at,
        )
    except Exception:
        logger.warning(
            "Failed to record sheet webhook delivery error",
            exc_info=True,
        )
        try:
            await session.rollback()
        except Exception:
            logger.warning("Failed to rollback session after delivery logging error", exc_info=True)


async def _mark_failed_inbound_only(
    session: AsyncSession,
    inbound: InboundWebhook,
    *,
    error_message: str,
    error_code: str,
    status_code: int,
    started_at: datetime,
) -> None:
    now = _utcnow()
    inbound.status = "failed"
    inbound.error = error_message[:500]
    inbound.error_code = error_code
    inbound.should_retry = False
    inbound.response_status_code = status_code
    inbound.response_body = {"ok": False, "error": error_message[:500]}
    inbound.processed_at = now
    inbound.processing_time_ms = max(0, int((now - started_at).total_seconds() * 1000))
    await session.commit()


async def _notify_sheet_webhook_failure(
    session: AsyncSession,
    *,
    sub: SheetWebhookSubscription,
    error_message: str,
    error_code: str,
    received_at: Optional[datetime],
) -> None:
    try:
        result = await session.execute(
            select(User)
            .where(User.account_id == str(sub.account_id))
            .order_by(User.created_at.asc())
            .limit(1)
        )
        user = result.scalar_one_or_none()
    except Exception as exc:
        logger.warning(
            "Failed to look up webhook notification recipient for account=%s: %s",
            sub.account_id,
            exc,
        )
        user = None

    if user and user.email:
        try:
            await send_sheet_webhook_failure_notification(
                user_email=user.email,
                webhook_name=sub.name,
                sheet_name=sub.sheet_name,
                error_message=error_message[:500],
                account_id=str(sub.account_id),
            )
        except Exception as exc:
            logger.warning(
                "Failed to send user sheet webhook failure notification for subscription=%s: %s",
                sub.id,
                exc,
            )

    try:
        await notify_sheet_webhook_delivery_failed(
            account_id=str(sub.account_id),
            webhook_name=sub.name,
            sheet_name=sub.sheet_name,
            error_code=error_code,
            error_message=error_message[:500],
            received_at=received_at,
        )
    except Exception as exc:
        logger.warning(
            "Failed to send admin sheet webhook failure alert for subscription=%s: %s",
            sub.id,
            exc,
        )


async def _safe_mark_failed_worker_delivery(
    session: AsyncSession,
    sub: SheetWebhookSubscription,
    inbound: Optional[InboundWebhook],
    *,
    error_message: str,
    error_code: str,
    status_code: int,
    payload_for_storage: Dict[str, Any],
    started_at: datetime,
) -> None:
    trimmed_error = error_message[:500]
    previous_error = str(sub.last_error or "")
    received_at = _utcnow()
    await _safe_mark_failed_delivery(
        session,
        sub,
        inbound,
        error_message=trimmed_error,
        error_code=error_code,
        status_code=status_code,
        payload_for_storage=payload_for_storage,
        started_at=started_at,
    )
    if previous_error != trimmed_error:
        await _notify_sheet_webhook_failure(
            session,
            sub=sub,
            error_message=trimmed_error,
            error_code=error_code,
            received_at=received_at,
        )


async def _schedule_sheet_webhook_retry(
    session: AsyncSession,
    sub: SheetWebhookSubscription,
    inbound: InboundWebhook,
    *,
    error_message: str,
    retry_error_code: str,
    final_error_code: str,
    status_code: int,
    payload_for_storage: Dict[str, Any],
    started_at: datetime,
) -> bool:
    retry_count = int(inbound.retry_count or 0)
    if retry_count >= SHEET_WEBHOOK_MAX_RETRIES:
        await _safe_mark_failed_worker_delivery(
            session,
            sub,
            inbound,
            error_message=error_message,
            error_code=final_error_code,
            status_code=status_code,
            payload_for_storage=payload_for_storage,
            started_at=started_at,
        )
        return False

    next_retry = retry_count + 1
    delay_seconds = _compute_retry_delay_seconds(next_retry)
    now = _utcnow()

    inbound.status = "queued"
    inbound.should_retry = True
    inbound.retry_count = next_retry
    inbound.error = str(error_message)[:500]
    inbound.error_code = retry_error_code
    inbound.response_status_code = status_code
    inbound.response_body = {
        "ok": False,
        "queued": True,
        "retry_in_seconds": delay_seconds,
        "retry_count": next_retry,
    }
    inbound.processed_at = now
    inbound.processing_time_ms = max(
        0, int((now - started_at).total_seconds() * 1000)
    )

    sub.last_received_at = now
    sub.last_error = inbound.error
    sub.last_payload = payload_for_storage
    sub.updated_at = now
    await session.commit()

    try:
        job_id = await asyncio.to_thread(
            _enqueue_sheet_webhook_delivery,
            str(inbound.id),
            delay_seconds=delay_seconds,
        )
        logger.info(
            "Requeued sheet webhook inbound delivery %s (retry=%s delay=%ss rq_job=%s code=%s)",
            inbound.id,
            next_retry,
            delay_seconds,
            job_id,
            retry_error_code,
        )
        return True
    except Exception as enqueue_exc:
        logger.exception(
            "Failed to enqueue retry for sheet webhook inbound delivery %s",
            inbound.id,
        )
        await _safe_mark_failed_worker_delivery(
            session,
            sub,
            inbound,
            error_message=(
                f"{str(error_message)[:250]}; retry enqueue failed: "
                f"{str(enqueue_exc)[:200]}"
            ),
            error_code="QUEUE_RETRY_FAILED",
            status_code=503,
            payload_for_storage=payload_for_storage,
            started_at=started_at,
        )
        return False


def _build_test_webhook_payload(sub: SheetWebhookSubscription) -> Dict[str, Any]:
    return {
        "event": "webhook_test",
        "webhook_name": sub.name,
        "sheet_name": sub.sheet_name,
        "sent_at": datetime.now(timezone.utc).isoformat(),
    }


def _reconstruct_payload_from_inbound(inbound: InboundWebhook) -> Dict[str, Any]:
    records_blob = _records_blob_from_inbound(inbound)
    if records_blob is None:
        if inbound.raw_body:
            parsed = _parse_and_validate_payload(inbound.raw_body or "")
            parsed["raw_body"] = inbound.raw_body or ""
            return parsed
        raise HTTPException(status_code=400, detail="Original delivery payload unavailable")

    records = _normalize_payload_to_records(records_blob)
    incoming_keys = _collect_incoming_keys(records)
    if not incoming_keys:
        raise HTTPException(
            status_code=400,
            detail="Original delivery payload has no writable columns",
        )

    raw_source: Any = records if len(records) != 1 else records[0]
    raw_body = json.dumps(raw_source, ensure_ascii=False, default=str)
    return {
        "parsed_payload": raw_source,
        "records": records,
        "incoming_keys": incoming_keys,
        "payload_preview": _truncate_json_payload_for_storage(raw_source),
        "raw_body": raw_body,
    }


async def process_queued_sheet_webhook_delivery(inbound_id: str) -> None:
    started_at = _utcnow()
    try:
        inbound_uuid = UUID(str(inbound_id))
    except Exception:
        logger.warning("Invalid sheet webhook inbound id: %s", inbound_id)
        return

    async with AsyncSessionLocal() as session:
        inbound_query = select(InboundWebhook).where(InboundWebhook.id == inbound_uuid)
        try:
            inbound_query = inbound_query.with_for_update()
        except Exception:
            pass
        inbound_result = await session.execute(inbound_query)
        inbound = inbound_result.scalar_one_or_none()
        if not inbound:
            logger.warning("Sheet webhook inbound delivery not found: %s", inbound_id)
            return

        if inbound.status == "processed":
            logger.debug("Sheet webhook inbound delivery already processed: %s", inbound_id)
            return
        if inbound.status == "failed" and not bool(inbound.should_retry):
            logger.debug("Sheet webhook inbound delivery already failed: %s", inbound_id)
            return
        if inbound.status == "processing" and not _inbound_processing_is_stale(
            inbound,
            now=started_at,
        ):
            logger.debug(
                "Sheet webhook inbound delivery already in progress: %s",
                inbound_id,
            )
            return

        payload_for_storage: Dict[str, Any] = (
            inbound.payload if isinstance(inbound.payload, dict) else {"preview": inbound.raw_body or ""}
        )

        sub: Optional[SheetWebhookSubscription] = None
        integration_id = str(inbound.integration_id or "").strip()
        try:
            sub_uuid = UUID(integration_id) if integration_id else None
        except Exception:
            sub_uuid = None

        if sub_uuid:
            result = await session.execute(
                select(SheetWebhookSubscription).where(
                    SheetWebhookSubscription.id == sub_uuid,
                    SheetWebhookSubscription.account_id == str(inbound.account_id or ""),
                )
            )
            sub = result.scalar_one_or_none()

        if not sub:
            await _mark_failed_inbound_only(
                session,
                inbound,
                error_message="Sheet webhook subscription not found",
                error_code="SUBSCRIPTION_NOT_FOUND",
                status_code=404,
                started_at=started_at,
            )
            return

        if not bool(sub.active):
            await _safe_mark_failed_delivery(
                session,
                sub,
                inbound,
                error_message="Webhook not found",
                error_code="SUBSCRIPTION_INACTIVE",
                status_code=404,
                payload_for_storage=payload_for_storage,
                started_at=started_at,
            )
            return

        write_progress = "pre_write"
        try:
            gateway = UsageGateway(session)
            records_blob = _records_blob_from_inbound(inbound)

            if records_blob is None:
                parsed = _parse_and_validate_payload(inbound.raw_body or "")
                records = parsed["records"]
                incoming_keys = parsed["incoming_keys"]
                payload_for_storage = parsed["payload_preview"]
                inbound.payload = payload_for_storage
            else:
                records = _normalize_payload_to_records(records_blob)
                incoming_keys = _collect_incoming_keys(records)
                if not incoming_keys:
                    raise HTTPException(
                        status_code=400,
                        detail="Payload records must contain at least one key",
                    )

            limits = await gateway.get_webhook_limits(str(sub.account_id))
            webhook_limit = limits.get("webhook_rows_per_month")
            webhook_used = int(limits.get("webhook_rows_used") or 0)
            if webhook_limit is not None and webhook_used + len(records) > int(webhook_limit):
                reset_at = _next_month_start_utc()
                retry_after_seconds = _retry_after_seconds_until(reset_at)
                now = _utcnow()
                previous_error = str(sub.last_error or "")

                sub.last_received_at = now
                sub.total_deliveries = int(sub.total_deliveries or 0) + 1
                sub.last_error = (
                    f"Monthly webhook row limit reached ({webhook_used}/{webhook_limit})"
                )
                sub.last_payload = payload_for_storage
                sub.updated_at = now

                inbound.status = "failed"
                inbound.error = sub.last_error
                inbound.error_code = "WEBHOOK_ROWS_LIMIT"
                inbound.should_retry = False
                inbound.response_status_code = 429
                inbound.response_body = {
                    "ok": False,
                    "error": sub.last_error,
                    "reset_at": reset_at.isoformat(),
                    "retry_after_seconds": retry_after_seconds,
                }
                inbound.processed_at = now
                inbound.processing_time_ms = max(
                    0, int((now - started_at).total_seconds() * 1000)
                )
                await session.commit()
                if previous_error != sub.last_error:
                    await _notify_sheet_webhook_failure(
                        session,
                        sub=sub,
                        error_message=sub.last_error,
                        error_code="WEBHOOK_ROWS_LIMIT",
                        received_at=now,
                    )
                return

            _set_inbound_processing_state(inbound, started_at=started_at)
            await session.commit()

            try:
                access_token, integration = await ensure_google_access_token(
                    sub.account_id,
                    session,
                )
            except Exception as exc:
                if _is_retryable_google_oauth_error(exc):
                    await _schedule_sheet_webhook_retry(
                        session,
                        sub,
                        inbound,
                        error_message=(
                            "Temporary Google authorization failure: " + str(exc)
                        ),
                        retry_error_code="GOOGLE_AUTH_TEMP_RETRY",
                        final_error_code="GOOGLE_AUTH_TEMP_FAILURE",
                        status_code=503,
                        payload_for_storage=payload_for_storage,
                        started_at=started_at,
                    )
                    return
                raise HTTPException(
                    status_code=403,
                    detail=(
                        "Google Sheets authorization expired or invalid. "
                        "Reconnect Google OAuth in Schedule Manager."
                    ),
                ) from exc
            if not access_token:
                raise HTTPException(
                    status_code=403,
                    detail=(
                        "Google Sheets authorization required. "
                        "Connect Google OAuth in Schedule Manager."
                    ),
                )

            refresh_token = None
            if integration and integration.refresh_token:
                refresh_token = (
                    decrypt_str(integration.refresh_token)
                    if saas_settings.ENCRYPTION_KEY
                    else integration.refresh_token
                )

            writer = DirectSheetsWriter(
                access_token=access_token,
                refresh_token=refresh_token,
                expires_at=integration.token_expires_at if integration else None,
                account_id=sub.account_id,
                max_429_retries=1,
                max_transient_retries=1,
            )

            async with _sheet_destination_write_lock(sub):
                if sub.create_sheet:
                    await asyncio.to_thread(
                        writer.ensure_sheet_exists,
                        sub.spreadsheet_id,
                        sub.sheet_name,
                    )

                header_info = await asyncio.to_thread(
                    writer.read_header_row,
                    spreadsheet_id=sub.spreadsheet_id,
                    sheet_name=sub.sheet_name,
                    cached_title=sub.sheet_name,
                    create_sheet=sub.create_sheet,
                )
                existing_headers = [
                    str(h).strip()
                    for h in (header_info.get("headers") or [])
                    if str(h).strip()
                ]
                resolved_sheet_name = str(header_info.get("sheet_name") or sub.sheet_name)

                merged_headers = list(existing_headers)
                seen_headers = set(existing_headers)
                new_headers: List[str] = []
                for key in incoming_keys:
                    if key not in seen_headers:
                        seen_headers.add(key)
                        merged_headers.append(key)
                        new_headers.append(key)

                if not merged_headers:
                    raise HTTPException(
                        status_code=400,
                        detail="No writable columns detected in payload",
                    )

                if not existing_headers or new_headers:
                    await asyncio.to_thread(
                        writer.update_header_row,
                        spreadsheet_id=sub.spreadsheet_id,
                        sheet_name=resolved_sheet_name,
                        headers=merged_headers,
                    )

                aligned_rows: List[List[Any]] = []
                for record in records:
                    aligned_rows.append(
                        [
                            _normalize_cell_value(record.get(header, ""))
                            for header in merged_headers
                        ]
                    )

                if sub.write_mode == WRITE_MODE_REPLACE:
                    write_progress = "replace_write"
                    await asyncio.to_thread(
                        writer.write_to_sheet,
                        spreadsheet_id=sub.spreadsheet_id,
                        sheet_name=resolved_sheet_name,
                        headers=merged_headers,
                        rows=aligned_rows,
                        cached_title=resolved_sheet_name,
                    )
                else:
                    write_progress = "append_write"
                    await asyncio.to_thread(
                        writer.append_to_sheet,
                        spreadsheet_id=sub.spreadsheet_id,
                        sheet_name=resolved_sheet_name,
                        rows=aligned_rows,
                        cached_title=resolved_sheet_name,
                        create_sheet=sub.create_sheet,
                    )

            write_progress = "post_write"
            rows_written = len(aligned_rows)
            if rows_written > 0:
                consume = await gateway.check_and_consume(
                    account_id=sub.account_id,
                    resource="webhook_rows",
                    amount=rows_written,
                    source=f"sheet_webhook:{sub.id}",
                    idempotency_key=f"sheet_webhook_rows:{inbound.id}",
                )
                if not consume.allowed:
                    logger.warning(
                        "Post-write webhook row consumption blocked account=%s inbound=%s used=%s limit=%s",
                        sub.account_id,
                        inbound.id,
                        consume.used,
                        consume.limit,
                    )

            now = _utcnow()
            sub.last_received_at = now
            sub.total_deliveries = int(sub.total_deliveries or 0) + 1
            sub.total_rows_written = int(sub.total_rows_written or 0) + rows_written
            sub.last_error = None
            sub.last_payload = payload_for_storage
            sub.updated_at = now

            inbound.status = "processed"
            inbound.payload = payload_for_storage
            inbound.record_count = len(records)
            inbound.should_retry = False
            inbound.error = None
            inbound.error_code = None
            inbound.response_status_code = 200
            inbound.response_body = {"ok": True, "rows_written": rows_written}
            inbound.processed_at = now
            inbound.processing_time_ms = max(0, int((now - started_at).total_seconds() * 1000))

            await session.commit()
            return

        except HTTPException as exc:
            await _safe_mark_failed_worker_delivery(
                session,
                sub,
                inbound,
                error_message=str(exc.detail),
                error_code="INGEST_HTTP_ERROR",
                status_code=exc.status_code,
                payload_for_storage=payload_for_storage,
                started_at=started_at,
            )
            return
        except SheetWebhookWriteLockError as exc:
            await _schedule_sheet_webhook_retry(
                session,
                sub,
                inbound,
                error_message=str(exc),
                retry_error_code="SHEET_WRITE_LOCK_RETRY",
                final_error_code="SHEET_WRITE_LOCK",
                status_code=503,
                payload_for_storage=payload_for_storage,
                started_at=started_at,
            )
            return
        except RateLimitedError as exc:
            await _schedule_sheet_webhook_retry(
                session,
                sub,
                inbound,
                error_message=str(exc),
                retry_error_code="GOOGLE_RATE_LIMIT_RETRY",
                final_error_code="GOOGLE_RATE_LIMIT",
                status_code=429,
                payload_for_storage=payload_for_storage,
                started_at=started_at,
            )
            return
        except TransientGoogleSheetsError as exc:
            if write_progress == "append_write":
                await _safe_mark_failed_worker_delivery(
                    session,
                    sub,
                    inbound,
                    error_message=(
                        "Google Sheets append may have succeeded before a temporary "
                        f"failure: {str(exc)}"
                    ),
                    error_code="GOOGLE_APPEND_UNKNOWN_STATE",
                    status_code=503,
                    payload_for_storage=payload_for_storage,
                    started_at=started_at,
                )
                return
            await _schedule_sheet_webhook_retry(
                session,
                sub,
                inbound,
                error_message=str(exc),
                retry_error_code="GOOGLE_TRANSIENT_RETRY",
                final_error_code="GOOGLE_TRANSIENT_FAILURE",
                status_code=503,
                payload_for_storage=payload_for_storage,
                started_at=started_at,
            )
            return
        except (DBAPIError, OperationalError, SQLAlchemyTimeoutError) as exc:
            try:
                await session.rollback()
            except Exception:
                logger.debug("Failed to rollback session after DB error", exc_info=True)

            is_retryable_db_error = _is_db_capacity_error(exc) or _is_db_lock_contention_error(exc)
            if write_progress == "pre_write" and is_retryable_db_error:
                await _schedule_sheet_webhook_retry(
                    session,
                    sub,
                    inbound,
                    error_message=str(exc),
                    retry_error_code="DB_TRANSIENT_RETRY",
                    final_error_code="DB_TRANSIENT_FAILURE",
                    status_code=503,
                    payload_for_storage=payload_for_storage,
                    started_at=started_at,
                )
                return

            logger.exception("Sheet webhook worker DB failure")
            await _safe_mark_failed_worker_delivery(
                session,
                sub,
                inbound,
                error_message=str(exc),
                error_code=(
                    "DB_FAILURE_AFTER_WRITE"
                    if write_progress != "pre_write"
                    else "DB_WRITE_FAILED"
                ),
                status_code=503 if is_retryable_db_error else 500,
                payload_for_storage=payload_for_storage,
                started_at=started_at,
            )
            return
        except SheetNotFoundError as exc:
            await _safe_mark_failed_worker_delivery(
                session,
                sub,
                inbound,
                error_message=str(exc),
                error_code="SHEET_NOT_FOUND",
                status_code=404,
                payload_for_storage=payload_for_storage,
                started_at=started_at,
            )
            return
        except DirectSheetsWriterError as exc:
            await _safe_mark_failed_worker_delivery(
                session,
                sub,
                inbound,
                error_message=str(exc),
                error_code="GOOGLE_WRITE_FAILED",
                status_code=502,
                payload_for_storage=payload_for_storage,
                started_at=started_at,
            )
            return
        except Exception as exc:  # pragma: no cover
            logger.exception("Sheet webhook worker delivery failed")
            await _safe_mark_failed_worker_delivery(
                session,
                sub,
                inbound,
                error_message=str(exc),
                error_code="INGEST_INTERNAL_ERROR",
                status_code=500,
                payload_for_storage=payload_for_storage,
                started_at=started_at,
            )
            return


@router.get("")
async def list_sheet_webhooks(
    request: Request,
    spreadsheet_id: Optional[str] = Query(default=None),
    account_id: str = Depends(require_account),
    session: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    spreadsheet_filter = str(spreadsheet_id or "").strip() or None
    query = select(SheetWebhookSubscription).where(
        SheetWebhookSubscription.account_id == str(account_id)
    )
    if spreadsheet_filter:
        query = query.where(
            SheetWebhookSubscription.spreadsheet_id == spreadsheet_filter
        )
    query = query.order_by(SheetWebhookSubscription.created_at.desc())

    result = await session.execute(query)
    subscriptions = result.scalars().all()
    activity_by_sub_id = await _collect_subscription_activity(session, subscriptions)
    active_count_result = await session.execute(
        select(sa_func.count(SheetWebhookSubscription.id)).where(
            SheetWebhookSubscription.account_id == str(account_id),
            SheetWebhookSubscription.active.is_(True),
        )
    )
    active_count = int(active_count_result.scalar() or 0)
    account_subscription_count_result = await session.execute(
        select(sa_func.count(SheetWebhookSubscription.id)).where(
            SheetWebhookSubscription.account_id == str(account_id)
        )
    )
    account_subscription_count = int(account_subscription_count_result.scalar() or 0)
    account_spreadsheet_count_result = await session.execute(
        select(sa_func.count(sa_func.distinct(SheetWebhookSubscription.spreadsheet_id))).where(
            SheetWebhookSubscription.account_id == str(account_id)
        )
    )
    account_spreadsheet_count = int(account_spreadsheet_count_result.scalar() or 0)
    current_spreadsheet_active_count = sum(1 for sub in subscriptions if sub.active)
    current_spreadsheet_count = len(subscriptions)
    current_spreadsheet_is_counted = bool(spreadsheet_filter and current_spreadsheet_count)
    gateway = UsageGateway(session)
    limits = await gateway.get_webhook_limits(str(account_id))
    return {
        "ok": True,
        "data": {
            "subscriptions": [
                _serialize_subscription(
                    sub,
                    request,
                    activity=activity_by_sub_id.get(str(sub.id), {}),
                )
                for sub in subscriptions
            ],
            "limits": {
                **limits,
                "active_webhooks": active_count,
            },
            "summary": {
                "scope": "spreadsheet" if spreadsheet_filter else "account",
                "current_spreadsheet": {
                    "spreadsheet_id": spreadsheet_filter,
                    "subscription_count": current_spreadsheet_count,
                    "active_count": current_spreadsheet_active_count,
                },
                "account": {
                    "subscription_count": account_subscription_count,
                    "active_count": active_count,
                    "spreadsheet_count": account_spreadsheet_count,
                    "other_spreadsheet_count": max(
                        0,
                        account_spreadsheet_count
                        - (1 if current_spreadsheet_is_counted else 0),
                    ),
                    "other_subscription_count": max(
                        0,
                        account_subscription_count - current_spreadsheet_count,
                    ),
                    "other_active_count": max(
                        0,
                        active_count - current_spreadsheet_active_count,
                    ),
                },
            },
        },
    }


@router.post("")
async def create_sheet_webhook(
    request: Request,
    body: CreateSheetWebhookRequest,
    account_id: str = Depends(require_account),
    session: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    write_mode = _normalize_write_mode(body.write_mode)
    sheet_name = _normalize_sheet_name(body.sheet_name)
    name = str(body.name or "").strip()
    spreadsheet_id = str(body.spreadsheet_id or "").strip()
    if not name:
        raise HTTPException(status_code=400, detail="name is required")
    if not spreadsheet_id:
        raise HTTPException(status_code=400, detail="spreadsheet_id is required")
    if bool(body.active):
        await _check_active_webhook_limit(session, account_id)
    secret = _generate_secret()

    for _ in range(5):
        sub = SheetWebhookSubscription(
            account_id=str(account_id),
            name=name,
            spreadsheet_id=spreadsheet_id,
            sheet_name=sheet_name,
            create_sheet=bool(body.create_sheet),
            write_mode=write_mode,
            secret=secret,
            active=bool(body.active),
            created_at=_utcnow(),
            updated_at=_utcnow(),
        )
        session.add(sub)
        try:
            await session.commit()
            await session.refresh(sub)
            return {
                "ok": True,
                "data": {
                    "subscription": _serialize_subscription(
                        sub,
                        request,
                        include_secret=True,
                    )
                },
            }
        except IntegrityError:
            await session.rollback()
            secret = _generate_secret()

    raise HTTPException(status_code=500, detail="Failed to allocate unique webhook secret")


@router.put("/{sub_id}")
async def update_sheet_webhook(
    sub_id: UUID,
    request: Request,
    body: UpdateSheetWebhookRequest,
    account_id: str = Depends(require_account),
    session: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    sub = await _get_subscription_for_account(session, account_id, sub_id)

    if body.name is not None:
        name = body.name.strip()
        if not name:
            raise HTTPException(status_code=400, detail="name cannot be empty")
        sub.name = name
    if body.spreadsheet_id is not None:
        spreadsheet_id = body.spreadsheet_id.strip()
        if not spreadsheet_id:
            raise HTTPException(status_code=400, detail="spreadsheet_id cannot be empty")
        sub.spreadsheet_id = spreadsheet_id
    if body.sheet_name is not None:
        sub.sheet_name = _normalize_sheet_name(body.sheet_name)
    if body.create_sheet is not None:
        sub.create_sheet = bool(body.create_sheet)
    if body.write_mode is not None:
        sub.write_mode = _normalize_write_mode(body.write_mode)
    if body.active is not None:
        new_active = bool(body.active)
        if new_active and not bool(sub.active):
            await _check_active_webhook_limit(
                session,
                account_id,
                exclude_sub_id=sub.id,
            )
        sub.active = new_active

    sub.updated_at = _utcnow()
    await session.commit()
    await session.refresh(sub)
    return {
        "ok": True,
        "data": {"subscription": _serialize_subscription(sub, request)},
    }


@router.delete("/{sub_id}")
async def delete_sheet_webhook(
    sub_id: UUID,
    account_id: str = Depends(require_account),
    session: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    sub = await _get_subscription_for_account(session, account_id, sub_id)
    await session.delete(sub)
    await session.commit()
    return {"ok": True}


@router.get("/{sub_id}/history")
async def get_sheet_webhook_history(
    sub_id: UUID,
    limit: int = Query(default=20, ge=1, le=50),
    account_id: str = Depends(require_account),
    session: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    sub = await _get_subscription_for_account(session, account_id, sub_id)
    gateway = UsageGateway(session)
    limits = await gateway.get_webhook_limits(str(account_id))
    retention_days = int(limits.get("history_retention_days") or 1)
    retention_cutoff = _utcnow() - timedelta(days=retention_days)
    result = await session.execute(
        select(InboundWebhook)
        .where(
            InboundWebhook.integration_id == str(sub.id),
            InboundWebhook.source == "sheet_webhook",
            InboundWebhook.received_at >= retention_cutoff,
        )
        .order_by(InboundWebhook.received_at.desc())
        .limit(limit)
    )
    rows = result.scalars().all()

    deliveries: List[Dict[str, Any]] = []
    for row in rows:
        summary = _serialize_delivery_summary(row)
        deliveries.append(
            {
                "id": str(row.id),
                "received_at": summary["received_at"],
                "status": summary["status"],
                "queued": summary["queued"],
                "record_count": summary["record_count"],
                "rows_written": summary["rows_written"],
                "response_status_code": summary["response_status_code"],
                "error": row.error,
                "error_code": row.error_code,
                "replayable": row.status == "failed",
                "is_test": summary["is_test"],
                "is_replay": summary["is_replay"],
                "payload_preview": _payload_preview_from_inbound(row),
            }
        )

    return {
        "ok": True,
        "data": {
            "subscription_id": str(sub.id),
            "deliveries": deliveries,
        },
    }


@router.post("/{sub_id}/history/{delivery_id}/replay", status_code=202)
async def replay_sheet_webhook_delivery(
    sub_id: UUID,
    delivery_id: UUID,
    request: Request,
    account_id: str = Depends(require_account),
    session: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    started_at = _utcnow()
    sub = await _get_subscription_for_account(session, account_id, sub_id)
    result = await session.execute(
        select(InboundWebhook).where(
            InboundWebhook.id == delivery_id,
            InboundWebhook.integration_id == str(sub.id),
            InboundWebhook.source == "sheet_webhook",
        )
    )
    original = result.scalar_one_or_none()
    if not original:
        raise HTTPException(status_code=404, detail="Delivery not found")
    if original.status != "failed":
        raise HTTPException(
            status_code=409,
            detail="Only failed deliveries can be replayed",
        )

    original_delivery_id = str(original.id)
    original_content_type = original.content_type or "application/json"
    payload = _reconstruct_payload_from_inbound(original)
    records = payload["records"]
    payload_for_storage = payload["payload_preview"]
    raw_body = str(payload.get("raw_body") or "")
    body_bytes = raw_body.encode("utf-8")

    gateway = UsageGateway(session)
    limits = await gateway.get_webhook_limits(str(account_id))
    webhook_limit = limits.get("webhook_rows_per_month")
    webhook_used = int(limits.get("webhook_rows_used") or 0)
    if webhook_limit is not None and webhook_used + len(records) > int(webhook_limit):
        raise HTTPException(
            status_code=429,
            detail=(
                f"Monthly webhook row limit reached ({webhook_used}/{webhook_limit}, "
                f"attempted +{len(records)})."
            ),
        )

    inbound = await _create_queued_inbound_delivery(
        session,
        sub=sub,
        started_at=started_at,
        raw_body=raw_body,
        body_bytes=body_bytes,
        records=records,
        payload_for_storage=payload_for_storage,
        method=request.method,
        path=str(request.url.path),
        headers={
            "X-Webhook-Replay": "true",
            "X-Replay-Of-Delivery": original_delivery_id,
        },
        query_params=dict(request.query_params),
        source_ip=request.client.host if request.client else None,
        user_agent=request.headers.get("User-Agent") or "sheet-webhook-replay",
        content_type=original_content_type,
        correlation_id=None,
        extra_response_body={"replay_of_delivery_id": original_delivery_id},
    )
    delivery_id = str(inbound.id)

    try:
        rq_job_id = await asyncio.to_thread(
            _enqueue_sheet_webhook_delivery,
            delivery_id,
        )
    except Exception as exc:
        logger.exception("Failed to enqueue replayed sheet webhook delivery %s", delivery_id)
        await _safe_mark_failed_delivery(
            session,
            sub,
            inbound,
            error_message=f"Failed to enqueue replayed delivery: {str(exc)[:200]}",
            error_code="QUEUE_ENQUEUE_FAILED",
            status_code=503,
            payload_for_storage=payload_for_storage,
            started_at=started_at,
        )
        raise _service_overloaded_exception("Webhook queue unavailable") from exc

    return {
        "ok": True,
        "queued": True,
        "delivery_id": delivery_id,
        "job_id": rq_job_id,
        "replay_of_delivery_id": original_delivery_id,
    }


@router.post("/{sub_id}/test", status_code=202)
async def enqueue_sheet_webhook_test_delivery(
    sub_id: UUID,
    request: Request,
    account_id: str = Depends(require_account),
    session: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    started_at = _utcnow()
    sub = await _get_subscription_for_account(session, account_id, sub_id)

    test_payload = _build_test_webhook_payload(sub)
    raw_body = json.dumps(test_payload, ensure_ascii=False, default=str)
    body_bytes = raw_body.encode("utf-8")
    parsed = _parse_and_validate_payload(raw_body)
    records = parsed["records"]
    payload_for_storage = parsed["payload_preview"]

    gateway = UsageGateway(session)
    limits = await gateway.get_webhook_limits(str(account_id))
    webhook_limit = limits.get("webhook_rows_per_month")
    webhook_used = int(limits.get("webhook_rows_used") or 0)
    if webhook_limit is not None and webhook_used + len(records) > int(webhook_limit):
        raise HTTPException(
            status_code=429,
            detail=(
                f"Monthly webhook row limit reached ({webhook_used}/{webhook_limit}, "
                f"attempted +{len(records)})."
            ),
        )

    inbound = await _create_queued_inbound_delivery(
        session,
        sub=sub,
        started_at=started_at,
        raw_body=raw_body,
        body_bytes=body_bytes,
        records=records,
        payload_for_storage=payload_for_storage,
        method=request.method,
        path=str(request.url.path),
        headers={"X-Webhook-Test": "true"},
        query_params=dict(request.query_params),
        source_ip=request.client.host if request.client else None,
        user_agent=request.headers.get("User-Agent") or "sheet-webhook-test",
        content_type="application/json",
        correlation_id=None,
        extra_response_body={"test": True},
    )
    delivery_id = str(inbound.id)

    try:
        rq_job_id = await asyncio.to_thread(
            _enqueue_sheet_webhook_delivery,
            delivery_id,
        )
    except Exception as exc:
        logger.exception("Failed to enqueue sheet webhook test delivery %s", delivery_id)
        await _safe_mark_failed_delivery(
            session,
            sub,
            inbound,
            error_message=f"Failed to enqueue test delivery: {str(exc)[:200]}",
            error_code="QUEUE_ENQUEUE_FAILED",
            status_code=503,
            payload_for_storage=payload_for_storage,
            started_at=started_at,
        )
        raise _service_overloaded_exception("Webhook queue unavailable") from exc

    return {
        "ok": True,
        "queued": True,
        "delivery_id": delivery_id,
        "job_id": rq_job_id,
        "test": True,
    }


@router.post("/{sub_id}/rotate-secret")
async def rotate_sheet_webhook_secret(
    sub_id: UUID,
    request: Request,
    account_id: str = Depends(require_account),
    session: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    sub = await _get_subscription_for_account(session, account_id, sub_id)
    for _ in range(5):
        sub.secret = _generate_secret()
        sub.updated_at = _utcnow()
        try:
            await session.commit()
            await session.refresh(sub)
            return {
                "ok": True,
                "data": {
                    "subscription": _serialize_subscription(
                        sub,
                        request,
                        include_secret=True,
                    )
                },
            }
        except IntegrityError:
            await session.rollback()

    raise HTTPException(status_code=500, detail="Failed to rotate webhook secret")


@router.post("/{secret}/ingest", name="ingest_sheet_webhook")
async def ingest_sheet_webhook(
    secret: str,
    request: Request,
):
    started_at = _utcnow()

    declared_size = _declared_payload_size(request)
    if declared_size is not None and declared_size > MAX_PAYLOAD_BYTES:
        raise HTTPException(status_code=413, detail="Payload exceeds 1MB limit")

    if _db_overload_window_active():
        return _ingest_over_capacity_response(
            "Database temporarily overloaded; retry shortly"
        )

    ingest_lease = await _acquire_ingest_slot()
    if ingest_lease is None:
        logger.warning(
            "Sheet webhook ingest backpressure for secret=%s... inflight_limit=%s",
            secret[:8],
            SHEET_WEBHOOK_INGEST_MAX_INFLIGHT,
        )
        return _ingest_over_capacity_response("Webhook ingest overloaded; retry shortly")

    try:
        body_bytes = await request.body()
    except ClientDisconnect:
        logger.warning(
            "Client disconnected before payload body could be read for sheet webhook secret=%s...",
            secret[:8],
        )
        return Response(status_code=499)
    if len(body_bytes) > MAX_PAYLOAD_BYTES:
        raise HTTPException(status_code=413, detail="Payload exceeds 1MB limit")

    signature = _extract_sheet_webhook_signature(request)
    if _sheet_webhook_signature_required() and not signature:
        raise HTTPException(status_code=401, detail="Webhook signature required")
    if signature and not _verify_sheet_webhook_signature(secret, body_bytes, signature):
        raise HTTPException(status_code=401, detail="Invalid webhook signature")

    rate_limit_allowed, retry_after = await _check_sheet_webhook_secret_rate_limit(secret)
    if not rate_limit_allowed:
        return _sheet_webhook_rate_limit_response(retry_after)

    raw_body = body_bytes.decode("utf-8", errors="replace")
    parsed = _parse_and_validate_payload(raw_body)
    records = parsed["records"]
    payload_for_storage = parsed["payload_preview"]

    try:
        async with AsyncSessionLocal() as session:
            try:
                result = await session.execute(
                    select(SheetWebhookSubscription).where(
                        SheetWebhookSubscription.secret == secret,
                        SheetWebhookSubscription.active == True,
                    )
                )
            except Exception as exc:
                if _is_db_capacity_error(exc):
                    _mark_db_overloaded()
                    raise _service_overloaded_exception(
                        "Database temporarily overloaded; retry shortly"
                    ) from exc
                raise HTTPException(status_code=500, detail="Database query failed") from exc

            sub = result.scalar_one_or_none()
            if not sub:
                raise HTTPException(status_code=404, detail="Webhook not found")

            correlation_id = _normalize_correlation_id(
                request.headers.get("X-Correlation-Id")
            )
            if correlation_id:
                existing_inbound = await _find_existing_sheet_delivery_by_correlation(
                    session,
                    subscription_id=sub.id,
                    correlation_id=correlation_id,
                )
                if existing_inbound:
                    return _build_duplicate_delivery_response(existing_inbound)

            gateway = UsageGateway(session)
            limits = await gateway.get_webhook_limits(str(sub.account_id))
            webhook_limit = limits.get("webhook_rows_per_month")
            webhook_used = int(limits.get("webhook_rows_used") or 0)
            if (
                webhook_limit is not None
                and webhook_used + len(records) > int(webhook_limit)
            ):
                return _build_quota_exceeded_response(
                    used=webhook_used,
                    limit=int(webhook_limit),
                    rows_attempted=len(records),
                )

            try:
                inbound = await _create_queued_inbound_delivery(
                    session,
                    sub=sub,
                    started_at=started_at,
                    raw_body=raw_body,
                    body_bytes=body_bytes,
                    records=records,
                    payload_for_storage=payload_for_storage,
                    method=request.method,
                    path=_redact_sheet_webhook_path(str(request.url.path), secret),
                    headers=_sanitize_inbound_headers(dict(request.headers)),
                    query_params=dict(request.query_params),
                    source_ip=request.client.host if request.client else None,
                    user_agent=request.headers.get("User-Agent"),
                    content_type=request.headers.get("Content-Type"),
                    correlation_id=correlation_id,
                )
                delivery_id = str(inbound.id)
            except IntegrityError as exc:
                await session.rollback()
                if correlation_id:
                    existing_inbound = await _find_existing_sheet_delivery_by_correlation(
                        session,
                        subscription_id=sub.id,
                        correlation_id=correlation_id,
                    )
                    if existing_inbound:
                        return _build_duplicate_delivery_response(existing_inbound)
                raise HTTPException(
                    status_code=500,
                    detail="Failed to persist webhook event",
                ) from exc
            except Exception as exc:
                if _is_db_capacity_error(exc):
                    _mark_db_overloaded()
                    raise _service_overloaded_exception(
                        "Database temporarily overloaded; retry shortly"
                    ) from exc
                raise HTTPException(status_code=500, detail="Failed to persist webhook event") from exc

            try:
                rq_job_id = await asyncio.to_thread(
                    _enqueue_sheet_webhook_delivery,
                    delivery_id,
                )
            except Exception as exc:
                logger.exception("Failed to enqueue sheet webhook delivery %s", delivery_id)
                await _safe_mark_failed_delivery(
                    session,
                    sub,
                    inbound,
                    error_message=f"Failed to enqueue webhook delivery: {str(exc)[:200]}",
                    error_code="QUEUE_ENQUEUE_FAILED",
                    status_code=503,
                    payload_for_storage=payload_for_storage,
                    started_at=started_at,
                )
                raise _service_overloaded_exception("Webhook queue unavailable") from exc

            return JSONResponse(
                status_code=202,
                content={
                    "ok": True,
                    "queued": True,
                    "delivery_id": delivery_id,
                    "job_id": rq_job_id,
                },
            )
    finally:
        await ingest_lease.release()
