/**
 * Browser-side drainer for the pull-mode outbound queue.
 *
 * Background task that long-polls the server's `/queue` endpoint,
 * decodes each event, and dispatches it into the consumer's Shade
 * instance via `shade.acceptTransferEnvelope`. Same wire-shape as the
 * server-to-server case where the engine receives chunks via direct
 * HTTP POSTs — we just flip the direction so the browser pulls
 * instead of accepts.
 */
import type { ShadeBridge } from '../integration/shade-bridge.js';

export interface QueueDrainerOptions {
  /**
   * Server endpoint that hosts `transferQueueRoute()`. Typically:
   * `https://server.example.com/api/v1/shade-files/queue`.
   */
  outboundQueueUrl: string;
  /** Peer the queue is pulled FROM (the server's address). */
  peerAddress: string;
  /** Address we identify ourselves with on the long-poll. */
  senderAddress: string;
  /** Optional `fetch` override. Default `globalThis.fetch`. */
  fetch?: typeof globalThis.fetch;
  /** Extra headers applied to every poll request. */
  headers?: Record<string, string>;
  /**
   * Long-poll request timeout (server-side block). Default 30_000.
   * Server clamps to its own `maxBlockMs` (default 55_000).
   */
  blockMs?: number;
  /**
   * Backoff after a network error before re-polling. Default 2_000.
   * Doubles up to `maxBackoffMs` on consecutive failures.
   */
  initialBackoffMs?: number;
  maxBackoffMs?: number;
  /**
   * Called when a poll cycle fails. Defaults to logging via `console.error`.
   * Throwing from this hook does NOT stop the drainer — it backs off
   * and retries.
   */
  onError?: (err: unknown) => void;
}

export interface QueueDrainerHandle {
  /** Stop the drainer. Pending fetch is aborted; the loop exits. */
  stop(): void;
  /** Promise that resolves once the drainer has fully stopped. */
  stopped: Promise<void>;
}

interface PolledEvent {
  id: number;
  timestampMs: number;
  kind: 'envelope' | 'chunk';
  bytesB64: string;
  meta?: { streamId: string; laneId: number; seq: number };
}

interface PollResponseBody {
  events: PolledEvent[];
  nextSince: number;
}

const DEFAULT_BLOCK_MS = 30_000;
const DEFAULT_INITIAL_BACKOFF_MS = 2_000;
const DEFAULT_MAX_BACKOFF_MS = 30_000;

/**
 * Start a long-poll loop that drains queued envelopes + chunks from
 * the server and dispatches them into the local Shade instance.
 *
 * Returns a handle the caller can use to stop the drainer when the
 * `httpClient` is closed (e.g. on tab unload).
 */
export function startQueueDrainer(
  shade: ShadeBridge,
  options: QueueDrainerOptions,
): QueueDrainerHandle {
  if (shade.acceptTransferEnvelope === undefined) {
    throw new Error(
      'startQueueDrainer: shade.acceptTransferEnvelope is required for pull-mode streams. The supplied ShadeBridge implementation must surface it.',
    );
  }
  const accept = shade.acceptTransferEnvelope.bind(shade);
  const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
  const blockMs = options.blockMs ?? DEFAULT_BLOCK_MS;
  const onError = options.onError ?? ((err: unknown) => console.error('[shade.files queue-drainer]', err));
  const initialBackoffMs = options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS;
  const maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
  const ac = new AbortController();
  let stopped = false;
  let resolveStopped!: () => void;
  const stoppedPromise = new Promise<void>((r) => {
    resolveStopped = r;
  });

  void (async () => {
    let since = 0;
    let backoff = initialBackoffMs;
    while (!stopped) {
      try {
        const response = await fetchFn(options.outboundQueueUrl, {
          method: 'POST',
          signal: ac.signal,
          headers: {
            'Content-Type': 'application/json',
            'X-Shade-Sender-Address': options.senderAddress,
            ...(options.headers ?? {}),
          },
          body: JSON.stringify({ since, blockMs }),
        });
        if (!response.ok) {
          throw new Error(`queue poll → ${response.status} ${response.statusText}`);
        }
        const body = (await response.json()) as PollResponseBody;
        if (Array.isArray(body.events) && body.events.length > 0) {
          for (const event of body.events) {
            if (stopped) break;
            try {
              const bytes = base64ToBytes(event.bytesB64);
              await accept(options.peerAddress, bytes);
            } catch (err) {
              // Per-event dispatch failure should not kill the loop —
              // resume picks up missing chunks via @shade/transfer's
              // built-in lane-resume protocol.
              onError(err);
            }
          }
        }
        since = typeof body.nextSince === 'number' ? body.nextSince : since;
        backoff = initialBackoffMs;
      } catch (err) {
        if (stopped || ac.signal.aborted) break;
        onError(err);
        // Exponential backoff with jitter — caps at maxBackoffMs.
        const jitter = Math.floor(Math.random() * Math.min(backoff, 1_000));
        await new Promise<void>((r) => {
          const t = setTimeout(r, backoff + jitter);
          (t as unknown as { unref?: () => void }).unref?.();
          ac.signal.addEventListener(
            'abort',
            () => {
              clearTimeout(t);
              r();
            },
            { once: true },
          );
        });
        backoff = Math.min(maxBackoffMs, backoff * 2);
      }
    }
    resolveStopped();
  })();

  return {
    stop(): void {
      if (stopped) return;
      stopped = true;
      ac.abort(new Error('queue drainer stopped'));
    },
    stopped: stoppedPromise,
  };
}

function base64ToBytes(b64: string): Uint8Array {
  const bin = atob(b64);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}
