/**
 * User preferences sync endpoint.
 *
 * GET  /api/user-prefs?variant=<variant>  — returns current cloud prefs for signed-in user
 * POST /api/user-prefs                     — saves prefs blob for signed-in user
 *
 * Authentication: Clerk Bearer token in Authorization header.
 * Requires CONVEX_URL + CLERK_JWT_ISSUER_DOMAIN env vars.
 */

export const config = { runtime: 'edge' };

// @ts-expect-error — JS module, no declaration file
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
// @ts-expect-error — JS module, no declaration file
import { jsonResponse } from './_json-response.js';
// @ts-expect-error — JS module, no declaration file
import { captureSilentError } from './_sentry-edge.js';
// @ts-expect-error — JS module, no declaration file
import { extractConvexErrorKind, isOpaqueConvexServerError, readConvexErrorNumber } from './_convex-error.js';
import {
  beginStandaloneIdempotency,
  completeStandaloneIdempotency,
  getIdempotencyKey,
  peekStandaloneIdempotency,
} from './_idempotency.js';
import { ConvexHttpClient } from 'convex/browser';
import { validateBearerToken } from '../server/auth-session';
import { checkScopedRateLimit } from '../server/_shared/rate-limit';

export const USER_PREFS_WRITE_RATE_SCOPE = 'user-prefs-write';
// Keep in lockstep with convex/constants.ts; tests/user-prefs-rate-limit.test.mts
// guards the duplicated Edge/Convex rate-limit contract from drifting.
export const USER_PREFS_WRITE_RATE_LIMIT = 30;
export const USER_PREFS_WRITE_RATE_WINDOW = '60 s';

type SessionValidator = typeof validateBearerToken;
type ScopedRateLimiter = typeof checkScopedRateLimit;

interface UserPrefsConvexClient {
  setAuth(token: string): void;
  query(name: unknown, args: Record<string, unknown>): Promise<unknown>;
  mutation(name: unknown, args: Record<string, unknown>): Promise<unknown>;
}

interface UserPrefsDeps {
  validateBearerToken: SessionValidator;
  checkScopedRateLimit: ScopedRateLimiter;
  createConvexClient: (
    convexUrl: string,
    options: ConstructorParameters<typeof ConvexHttpClient>[1],
  ) => UserPrefsConvexClient;
}

function createDefaultUserPrefsDeps(): UserPrefsDeps {
  return {
    validateBearerToken,
    checkScopedRateLimit,
    createConvexClient: (convexUrl, options) =>
      new ConvexHttpClient(convexUrl, options) as UserPrefsConvexClient,
  };
}

let userPrefsDeps: UserPrefsDeps = createDefaultUserPrefsDeps();

export function __setUserPrefsDepsForTests(overrides: Partial<UserPrefsDeps> | null): void {
  userPrefsDeps = overrides
    ? { ...createDefaultUserPrefsDeps(), ...overrides }
    : createDefaultUserPrefsDeps();
}

type SetPreferencesResult =
  | { ok: true; syncVersion: number }
  | { ok: false; reason: 'CONFLICT'; actualSyncVersion: number }
  | { ok: false; reason: 'BLOB_TOO_LARGE'; size: number; max: number }
  | { ok: false; reason: 'RATE_LIMITED'; limit: number; reset: number };

function rateLimitHeaders(
  cors: Record<string, string>,
  limit: number,
  reset: number,
): Record<string, string> {
  const retryAfter = Math.max(1, Math.ceil((reset - Date.now()) / 1000));
  return {
    ...cors,
    'X-RateLimit-Limit': String(limit),
    'X-RateLimit-Remaining': '0',
    'X-RateLimit-Reset': String(reset),
    'Retry-After': String(retryAfter),
  };
}

export default async function handler(
  req: Request,
  ctx?: { waitUntil: (p: Promise<unknown>) => void },
): Promise<Response> {
  if (isDisallowedOrigin(req)) {
    return jsonResponse({ error: 'Origin not allowed' }, 403);
  }

  const cors = getCorsHeaders(req, 'GET, POST, OPTIONS');

  if (req.method === 'OPTIONS') {
    return new Response(null, { status: 204, headers: cors });
  }

  if (req.method !== 'GET' && req.method !== 'POST') {
    return jsonResponse({ error: 'Method not allowed' }, 405, cors);
  }

  const authHeader = req.headers.get('Authorization') ?? '';
  const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
  if (!token) {
    return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors);
  }

  const session = await userPrefsDeps.validateBearerToken(token);
  if (!session.valid || !session.userId) {
    return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors);
  }

  const idempotencyKey = req.method === 'POST' ? getIdempotencyKey(req) : null;
  if (idempotencyKey) {
    const peek = await peekStandaloneIdempotency({
      request: req,
      pathname: '/api/user-prefs',
      scope: `user:${session.userId}`,
      idempotencyKey,
      corsHeaders: cors,
    });
    if (peek.kind !== 'miss' && peek.kind !== 'disabled') {
      return peek.response;
    }
  }

  const convexUrl = process.env.CONVEX_URL;
  if (!convexUrl) {
    return jsonResponse({ error: 'Service unavailable' }, 503, cors);
  }

  if (req.method === 'POST') {
    const scoped = await userPrefsDeps.checkScopedRateLimit(
      USER_PREFS_WRITE_RATE_SCOPE,
      USER_PREFS_WRITE_RATE_LIMIT,
      USER_PREFS_WRITE_RATE_WINDOW,
      session.userId,
    );
    // Redis-degraded scoped limits intentionally fail open for prefs writes:
    // the sync blob is low-stakes, while a limiter outage should not strand a
    // legitimate user's local settings. checkScopedRateLimit logs Redis errors;
    // this warning also surfaces missing-config fail-open windows.
    if (scoped.degraded) {
      console.warn('[user-prefs] POST write rate limit unavailable; failing open');
    } else if (!scoped.allowed) {
      const retryAfter = Math.max(1, Math.ceil((scoped.reset - Date.now()) / 1000));
      console.warn('[user-prefs] POST write rate limit exceeded');
      return jsonResponse(
        { error: 'RATE_LIMITED' },
        429,
        {
          ...cors,
          'X-RateLimit-Limit': String(scoped.limit),
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': String(scoped.reset),
          'Retry-After': String(retryAfter),
        },
      );
    }
  }

  const idempotency = idempotencyKey
    ? await beginStandaloneIdempotency({
      request: req,
      pathname: '/api/user-prefs',
      scope: `user:${session.userId}`,
      idempotencyKey,
      corsHeaders: cors,
    })
    : null;
  if (
    idempotency &&
    idempotency.kind !== 'proceed' &&
    idempotency.kind !== 'disabled'
  ) {
    return idempotency.response;
  }
  const finish = (response: Response): Promise<Response> =>
    completeStandaloneIdempotency(idempotency, response);

  // Bound the Convex round-trip below Vercel's 25s edge wall-clock so a
  // stalled platform aborts cleanly into the SERVICE_UNAVAILABLE → 503 +
  // Retry-After path instead of getting killed by Vercel with a generic
  // 500 ("function did not return an initial response within 25s"), which
  // bypasses our typed error plumbing entirely. 20s leaves headroom for
  // the JWKS verify above + response packaging below. Injected via the
  // public `fetch` constructor option (the `setFetchOptions` instance
  // method is marked `@internal` in convex's d.ts and not safe to depend on).
  const client = userPrefsDeps.createConvexClient(convexUrl, {
    fetch: (input, init) =>
      fetch(input, { ...init, signal: init?.signal ?? AbortSignal.timeout(20_000) }),
  });
  client.setAuth(token);

  if (req.method === 'GET') {
    const url = new URL(req.url);
    const variant = url.searchParams.get('variant') ?? 'full';

    try {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const prefs = await client.query('userPreferences:getPreferences' as any, { variant });
      return jsonResponse(prefs ?? null, 200, cors);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      const kind = extractConvexErrorKind(err, msg);
      // UNAUTHENTICATED on this path means the Clerk token PASSED our edge's
      // `validateBearerToken` but Convex still rejected it — i.e. genuine
      // auth/audience/issuer drift between our Clerk JWKS validation and
      // Convex's auth config (a Clerk JWKS rotation lag, an audience mismatch,
      // a stale CLERK_JWT_ISSUER_DOMAIN env var). User-bad-token cases are
      // caught earlier (the `validateBearerToken` 401 above) and never reach
      // this catch. Capture before returning 401 so the drift surfaces under
      // a stable Sentry bucket instead of silently 401'ing every request.
      //
      // `level: 'warning'` because the observed pattern is one transient
      // event per user (5ev/5u over a week — WORLDMONITOR-QK), which a
      // client retry recovers cleanly. Keeping the capture at error
      // drowned real bugs in the dashboard while delivering no operational
      // signal beyond "drift happened" (already evident from the warning
      // bucket). A genuine systemic drift incident would still surface
      // because volume would escalate and reopen the archived issue.
      if (kind === 'UNAUTHENTICATED') {
        console.warn('[user-prefs] GET convex auth drift:', err);
        captureSilentError(err, buildSentryContext(err, msg, {
          method: 'GET', convexFn: 'userPreferences:getPreferences',
          userId: session.userId, variant, ctx,
          level: 'warning',
        }));
        return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors);
      }
      if (kind === 'SERVICE_UNAVAILABLE') {
        // Convex platform-level 503 — transient and self-recovering. Map to
        // 503 with `Retry-After` so the client backs off rather than treating
        // it as a permanent 500. Still capture so we can spot regressions /
        // sustained outages, but use `level: 'warning'` so this expected
        // transient external-system event doesn't drown the error
        // dashboard or page on-call (WORLDMONITOR-QA).
        console.warn('[user-prefs] GET convex service unavailable:', msg);
        captureSilentError(err, buildSentryContext(err, msg, {
          method: 'GET', convexFn: 'userPreferences:getPreferences',
          userId: session.userId, variant, ctx,
          level: 'warning',
        }));
        return jsonResponse({ error: 'SERVICE_UNAVAILABLE' }, 503, { ...cors, 'Retry-After': '5' });
      }
      console.error('[user-prefs] GET error:', err);
      captureSilentError(err, buildSentryContext(err, msg, {
        method: 'GET', convexFn: 'userPreferences:getPreferences',
        userId: session.userId, variant, ctx,
      }));
      return jsonResponse({ error: 'Failed to fetch preferences' }, 500, cors);
    }
  }

  // POST — save prefs
  let body: { variant?: unknown; data?: unknown; expectedSyncVersion?: unknown; schemaVersion?: unknown };
  try {
    body = await req.json();
  } catch {
    return finish(jsonResponse({ error: 'Invalid JSON' }, 400, cors));
  }

  if (
    typeof body.variant !== 'string' ||
    body.data === undefined ||
    typeof body.expectedSyncVersion !== 'number'
  ) {
    return finish(jsonResponse({ error: 'MISSING_FIELDS' }, 400, cors));
  }

  try {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const result = (await client.mutation('userPreferences:setPreferences' as any, {
      variant: body.variant,
      data: body.data,
      expectedSyncVersion: body.expectedSyncVersion,
      schemaVersion: typeof body.schemaVersion === 'number' ? body.schemaVersion : undefined,
    })) as SetPreferencesResult;
    // Expected write denials return as a discriminated result so Convex can
    // commit limiter bookkeeping and duplicate-counter cleanup. Wire shape to
    // clients stays the same as the older thrown ConvexError paths below.
    if (result.ok === false) {
      if (result.reason === 'BLOB_TOO_LARGE') {
        return finish(jsonResponse({ error: 'BLOB_TOO_LARGE' }, 400, cors));
      }
      if (result.reason === 'RATE_LIMITED') {
        console.warn('[user-prefs] POST convex write rate limit exceeded');
        return finish(jsonResponse(
          { error: 'RATE_LIMITED' },
          429,
          rateLimitHeaders(cors, result.limit, result.reset),
        ));
      }
      return finish(jsonResponse(
        { error: 'CONFLICT', actualSyncVersion: result.actualSyncVersion },
        409,
        cors,
      ));
    }
    return finish(jsonResponse({ syncVersion: result.syncVersion }, 200, cors));
  } catch (err: unknown) {
    const msg = err instanceof Error ? err.message : String(err);
    const kind = extractConvexErrorKind(err, msg);
    // Defensive: during the deploy window where the edge function may run
    // against an OLD convex deployment (CONFLICT still throws), route via
    // handleConflictResponse so we still capture stuck-bundle attribution
    // at level=warning for the deploy-ordering window. Once both layers
    // have soaked on the new code, this branch is unreachable and can be
    // removed (along with handleConflictResponse).
    if (kind === 'CONFLICT') {
      return finish(handleConflictResponse(err, msg, {
        userId: session.userId,
        variant: body.variant,
        ctx,
        schemaVersion: typeof body.schemaVersion === 'number' ? body.schemaVersion : null,
        expectedSyncVersion: body.expectedSyncVersion,
        blobSize: body.data !== undefined ? JSON.stringify(body.data).length : 0,
        cors,
      }));
    }
    if (kind === 'BLOB_TOO_LARGE') {
      return finish(jsonResponse({ error: 'BLOB_TOO_LARGE' }, 400, cors));
    }
    if (kind === 'RATE_LIMITED') {
      const limit = readConvexErrorNumber(err, 'limit') ?? USER_PREFS_WRITE_RATE_LIMIT;
      const reset = readConvexErrorNumber(err, 'reset') ?? Date.now() + 60_000;
      console.warn('[user-prefs] POST convex write rate limit exceeded');
      return finish(jsonResponse(
        { error: 'RATE_LIMITED' },
        429,
        rateLimitHeaders(cors, limit, reset),
      ));
    }
    if (kind === 'UNAUTHENTICATED') {
      // See GET branch above — UNAUTHENTICATED here means Clerk-vs-Convex
      // auth drift (token already passed validateBearerToken). Capture
      // at `warning` for visibility without paging — the observed pattern
      // is transient single-event-per-user that recovers on client retry
      // (WORLDMONITOR-QK).
      console.warn('[user-prefs] POST convex auth drift:', err);
      captureSilentError(err, buildSentryContext(err, msg, {
        method: 'POST', convexFn: 'userPreferences:setPreferences',
        userId: session.userId, variant: body.variant, ctx,
        schemaVersion: typeof body.schemaVersion === 'number' ? body.schemaVersion : null,
        expectedSyncVersion: body.expectedSyncVersion,
        blobSize: body.data !== undefined ? JSON.stringify(body.data).length : 0,
        level: 'warning',
      }));
      return finish(jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors));
    }
    if (kind === 'SERVICE_UNAVAILABLE') {
      // See GET branch above — Convex 503, transient. 503 + Retry-After
      // so the client backs off rather than burning a 500-failed-write.
      // `level: 'warning'` so the expected transient external-system
      // event stays queryable but doesn't page on-call (WORLDMONITOR-QA).
      console.warn('[user-prefs] POST convex service unavailable:', msg);
      captureSilentError(err, buildSentryContext(err, msg, {
        method: 'POST', convexFn: 'userPreferences:setPreferences',
        userId: session.userId, variant: body.variant, ctx,
        schemaVersion: typeof body.schemaVersion === 'number' ? body.schemaVersion : null,
        expectedSyncVersion: body.expectedSyncVersion,
        blobSize: body.data !== undefined ? JSON.stringify(body.data).length : 0,
        level: 'warning',
      }));
      return finish(jsonResponse({ error: 'SERVICE_UNAVAILABLE' }, 503, { ...cors, 'Retry-After': '5' }));
    }
    console.error('[user-prefs] POST error:', err);
    captureSilentError(err, buildSentryContext(err, msg, {
      method: 'POST', convexFn: 'userPreferences:setPreferences',
      userId: session.userId, variant: body.variant, ctx,
      schemaVersion: typeof body.schemaVersion === 'number' ? body.schemaVersion : null,
      expectedSyncVersion: body.expectedSyncVersion,
      blobSize: body.data !== undefined ? JSON.stringify(body.data).length : 0,
    }));
    return finish(jsonResponse({ error: 'Failed to save preferences' }, 500, cors));
  }
}


/**
 * 409-CONFLICT response builder for setPreferences — DEPLOY-WINDOW BRIDGE.
 *
 * Post PR 3 (post-launch-stabilization), CAS-guard CONFLICTs RETURN from
 * `userPreferences:setPreferences` rather than throw, so this catch-side
 * helper is only reached during the deploy-ordering window where the edge
 * runs against an OLD convex deployment that still throws. Once both
 * layers have soaked, this helper becomes unreachable dead code and can
 * be removed.
 *
 * While reachable, it preserves stuck-bundle Sentry attribution: captures
 * the user_id + actualSyncVersion at level=warning so we can spot a single
 * stuck client looping (constant actualSyncVersion across timestamps) vs.
 * real concurrency (broadly-distributed user_ids). At level=error it
 * drowned real bugs; level=warning keeps it queryable but out of error
 * totals and alerting (per WORLDMONITOR-PX 2026-04-30 triage).
 *
 * Echoes `actualSyncVersion` from the structured ConvexError when present
 * and numeric so the client can refresh its local sync state without a
 * follow-up GET. Type-guarded — drops non-numeric values rather than
 * forwarding them as `unknown`.
 */
function handleConflictResponse(
  err: unknown,
  msg: string,
  opts: {
    userId: string;
    variant: unknown;
    ctx?: { waitUntil: (p: Promise<unknown>) => void };
    schemaVersion: number | null;
    expectedSyncVersion: unknown;
    blobSize: number;
    cors: Record<string, string>;
  },
): Response {
  const actualSyncVersion = readConvexErrorNumber(err, 'actualSyncVersion');
  // CONFLICT is an EXPECTED outcome of optimistic concurrency (multi-tab
  // / multi-device sync, or a stuck-bundle user retrying with an old
  // expectedSyncVersion). The capture exists to surface stuck-bundle
  // users via user_id distribution (see WORLDMONITOR-PX 2026-04-30:
  // 316 events / 59 users at 18 distinct actualSyncVersions). At
  // level=error it drowned real bugs; level=warning keeps it queryable
  // in Sentry but drops it out of error totals and alerting.
  captureSilentError(err, buildSentryContext(err, msg, {
    method: 'POST',
    convexFn: 'userPreferences:setPreferences',
    userId: opts.userId,
    variant: opts.variant,
    ctx: opts.ctx,
    schemaVersion: opts.schemaVersion,
    expectedSyncVersion: opts.expectedSyncVersion,
    blobSize: opts.blobSize,
    errorShapeOverride: 'setPreferences_conflict',
    extraTags: actualSyncVersion !== undefined ? { actual_sync_version: actualSyncVersion } : undefined,
    level: 'warning',
  }));
  return jsonResponse(
    actualSyncVersion !== undefined ? { error: 'CONFLICT', actualSyncVersion } : { error: 'CONFLICT' },
    409,
    opts.cors,
  );
}

/**
 * Build a captureSilentError context that carries enough provenance to triage
 * a 500 from this endpoint without re-running the request:
 *   - `convex_request_id` tag: the `[Request ID: X]` from Convex's error message,
 *     queryable in Sentry and grep-able against Convex's dashboard logs.
 *   - `error_shape` tag: classifies what KIND of failure this is so a single
 *     Sentry filter splits "Convex internal 500" from "transport timeout" from
 *     "everything else", instead of every flavor sharing the same opaque bucket.
 *   - Stable `fingerprint`: forces Sentry to group by (route, method, error_shape)
 *     rather than by the ever-varying request-id-bearing message — without this,
 *     each request_id would create a new "issue" and drown the dashboard.
 *
 * Exported for unit tests. The Vercel edge runtime ignores non-default
 * exports, so this has no production-side effect.
 */
export function buildSentryContext(
  err: unknown,
  msg: string,
  opts: {
    method: 'GET' | 'POST';
    convexFn: string;
    userId: string;
    variant?: unknown;
    ctx?: { waitUntil: (p: Promise<unknown>) => void };
    schemaVersion?: number | null;
    expectedSyncVersion?: unknown;
    blobSize?: number;
    // Override the message-pattern classification when the caller already
    // knows the error shape (e.g. CONFLICT, where the throw is intentional
    // and routing through msg-pattern matching would mis-classify it as
    // 'unknown'). Skipped through the same `errorShape` field so
    // fingerprint and tags stay stable.
    errorShapeOverride?: string;
    // Additional tags (queryable in Sentry, unlike `extra`). Used e.g. to
    // pass `actual_sync_version` so on-call can group/filter by it.
    extraTags?: Record<string, string | number>;
    // Sentry severity. Default 'error'. Pass 'warning' for expected-but-
    // trackable conditions (CONFLICT from optimistic-concurrency) so the
    // capture stays queryable in the dashboard but doesn't count toward
    // error totals or page on-call.
    level?: 'warning' | 'info' | 'error' | 'fatal';
  },
): {
  tags: Record<string, string | number>;
  extra: Record<string, unknown>;
  fingerprint: string[];
  ctx?: { waitUntil: (p: Promise<unknown>) => void };
  level?: 'warning' | 'info' | 'error' | 'fatal';
} {
  const errName = err instanceof Error ? err.name : 'unknown';
  const requestIdMatch = msg.match(/\[Request ID:\s*([a-f0-9]+)\]/i);
  const convexRequestId = requestIdMatch?.[1];
  // Order matters: UNAUTHENTICATED is more specific than the request-id
  // server-error shape and must be checked first. Auth drift is its own bucket
  // so it groups separately from genuine Convex 5xx in the Sentry dashboard.
  // SERVICE_UNAVAILABLE (Convex platform 503) is also its own bucket — it
  // would otherwise fall into 'unknown' and conflate transient outages with
  // genuinely-novel failure modes that haven't been classified yet.
  const errorShape = opts.errorShapeOverride
    // Match both the structured-data `UNAUTHENTICATED` kind (uppercase, from
    // `ConvexError({kind:'UNAUTHENTICATED'})`) AND the platform-level JSON-
    // shape `"code":"Unauthenticated"` (mixed case, from Convex's runtime
    // when Clerk OIDC token verification fails). Both are auth drift —
    // WORLDMONITOR-PG: the JSON-cased variant was previously falling
    // through to 'unknown' because the `/UNAUTHENTICATED/` regex is
    // case-sensitive.
    // The `"code":\s*"X"` forms tolerate the optional post-colon whitespace a
    // non-default serializer may emit (`"code": "X"`), mirroring `hasConvexCode`
    // in _convex-error.js so this Sentry bucket and the kind→503 mapping stay in
    // lockstep — a with-whitespace body classifies identically on both sides.
    ?? (/UNAUTHENTICATED|"code":\s*"Unauthenticated"/.test(msg) ? 'convex_auth_drift'
      : /"code":\s*"ServiceUnavailable"/.test(msg) ? 'convex_service_unavailable'
      // Convex platform 500 — runtime can't recover the request. Same
      // 503-with-Retry-After remediation as ServiceUnavailable in
      // _convex-error.js, but kept as its own Sentry bucket so on-call can
      // tell internal-500s apart from genuine 503s when triaging
      // (WORLDMONITOR-PG/PH).
      : /"code":\s*"InternalServerError"/.test(msg) ? 'convex_internal_error'
      // Convex platform worker saturation: `{"code":"WorkerOverloaded",
      // "message":"There are no available workers to process the request"}`.
      // Mapped to SERVICE_UNAVAILABLE (503 + Retry-After) in _convex-error.js,
      // same as InternalServerError/ServiceUnavailable; kept as its own Sentry
      // bucket so on-call can tell worker-saturation apart from internal-500s
      // and genuine 503s when triaging (WORLDMONITOR-PG).
      : /"code":\s*"WorkerOverloaded"/.test(msg) ? 'convex_worker_overloaded'
      : isOpaqueConvexServerError(msg) ? 'convex_server_error'
      // Cloudflare edge error (520-527) fronting the Convex deployment — see
      // _convex-error.js. Mapped to SERVICE_UNAVAILABLE (503 + Retry-After)
      // there; kept as its own Sentry bucket so on-call can tell CDN-layer
      // transients apart from genuine Convex platform 5xx (WORLDMONITOR-PG).
      // Checked BEFORE the /timeout/ branch: Cloudflare 524's error page body
      // is literally "A timeout occurred", so a 524 whose message carries the
      // CF body text would otherwise be mis-bucketed as transport_timeout.
      // A genuine client AbortSignal.timeout never carries an `error code: 52x`
      // substring, so this ordering steals no real-timeout events.
      : /error code:\s*52[0-7]\b/i.test(msg) ? 'transport_cloudflare'
      : /timeout|timed out|aborted/i.test(msg) ? 'transport_timeout'
      : /fetch failed|network|ECONN|ENOTFOUND|getaddrinfo/i.test(msg) ? 'transport_network'
      : 'unknown');

  return {
    tags: {
      route: 'api/user-prefs',
      method: opts.method,
      convex_fn: opts.convexFn,
      error_shape: errorShape,
      // Promote userId from `extra` to `tags` so Sentry can group conflicts
      // by user. Clerk user IDs are opaque strings (e.g. `user_2x8K3...`),
      // not numbers — pass through as-is.
      user_id: opts.userId,
      ...(convexRequestId ? { convex_request_id: convexRequestId } : {}),
      // Skip the minified `errName` (e.g. 'I') — it's noise, not signal — but
      // keep meaningful names like ConvexError / TypeError / SyntaxError.
      // `> 1` is the minimal guard for single-character noise; all real built-in
      // error class names are well above that.
      ...(errName !== 'unknown' && errName !== 'Error' && errName.length > 1
        ? { error_name: errName }
        : {}),
      ...(opts.extraTags ?? {}),
    },
    extra: {
      variant: typeof opts.variant === 'string' ? opts.variant : 'unknown',
      messageHead: msg.slice(0, 300),
      ...(opts.schemaVersion !== undefined ? { schemaVersion: opts.schemaVersion } : {}),
      ...(opts.expectedSyncVersion !== undefined ? { expectedSyncVersion: opts.expectedSyncVersion } : {}),
      ...(opts.blobSize !== undefined ? { blobSize: opts.blobSize } : {}),
    },
    fingerprint: ['api/user-prefs', opts.method, errorShape],
    ctx: opts.ctx,
    ...(opts.level ? { level: opts.level } : {}),
  };
}
