/**
 * Fetch with a hard wall-clock timeout. Wraps `globalThis.fetch` with an
 * AbortController so a hung upstream cannot pin a worker concurrency slot
 * indefinitely (per docs/audit-comprehensive-2026-05-05.md HIGH §§5–6 and
 * MED §13 - Documenso, OCR, and IMAP all needed this).
 *
 * - Default timeout is 30s; pass `timeoutMs` to override.
 * - When the caller already supplies an AbortSignal via `init.signal`, both
 *   sources can abort the request - first one to fire wins.
 * - On timeout the rejection is a `DOMException('TimeoutError')` from
 *   AbortController; callers can introspect via `err.name === 'AbortError'`
 *   plus the timeout flag we attach.
 */
export interface FetchWithTimeoutOptions extends RequestInit {
  /** Hard timeout in ms. Default 30_000. */
  timeoutMs?: number;
}

export class FetchTimeoutError extends Error {
  override readonly name = 'FetchTimeoutError';
  constructor(
    public readonly url: string,
    public readonly timeoutMs: number,
  ) {
    super(`Request to ${url} exceeded ${timeoutMs}ms timeout`);
  }
}

export async function fetchWithTimeout(
  url: string,
  init: FetchWithTimeoutOptions = {},
): Promise<Response> {
  const { timeoutMs = 30_000, signal: callerSignal, ...rest } = init;
  const controller = new AbortController();

  // Compose: if the caller already passed a signal, abort our controller
  // when theirs aborts. We can't reuse theirs directly because we still
  // own the timeout lifecycle.
  if (callerSignal) {
    if (callerSignal.aborted) {
      controller.abort(callerSignal.reason);
    } else {
      callerSignal.addEventListener('abort', () => controller.abort(callerSignal.reason), {
        once: true,
      });
    }
  }

  const timeoutId = setTimeout(() => {
    controller.abort(new FetchTimeoutError(url, timeoutMs));
  }, timeoutMs);

  try {
    return await fetch(url, { ...rest, signal: controller.signal });
  } catch (err) {
    // If we hit our own timeout, surface a typed error instead of the
    // generic AbortError so call sites can branch on it.
    if (
      err instanceof Error &&
      (err.name === 'AbortError' || err.name === 'TimeoutError') &&
      controller.signal.aborted
    ) {
      const reason = controller.signal.reason;
      if (reason instanceof FetchTimeoutError) throw reason;
    }
    throw err;
  } finally {
    clearTimeout(timeoutId);
  }
}
