import { joinUrl } from './endpoint.js';
import type { RealtimeEvent, SubscribeOptions, Unsubscribe } from './types.js';

interface SubscribeConfig {
  baseURL: string;
  fetchImpl: typeof fetch;
}

/**
 * Subscribe to an app pub/sub topic over SSE.
 *
 * Implemented over streaming `fetch` (not native `EventSource`) so the
 * lib can: detect a 401 on (re)connect and refresh the token, send a
 * `Last-Event-ID` header on resume, and apply its own exponential
 * backoff. See HANDBACK for the rationale. Returns an unsubscribe
 * function that aborts the connection and stops reconnecting.
 */
export function subscribeTopic<T = unknown>(
  cfg: SubscribeConfig,
  topic: string,
  onMessage: (event: RealtimeEvent<T>) => void,
  opts: SubscribeOptions<T> = {}
): Unsubscribe {
  const baseBackoff = opts.baseBackoffMs ?? 1_000;
  const maxBackoff = opts.maxBackoffMs ?? 30_000;
  let token = opts.token;
  let stopped = false;
  let attempt = 0;
  let lastEventId: string | undefined;
  let controller: AbortController | null = null;
  let backoffTimer: ReturnType<typeof setTimeout> | null = null;
  // F-T-003: bound the 401-refresh loop. If onTokenExpired keeps
  // returning a token that the server rejects (mis-issued refresh,
  // server-side authz drift, …) we'd otherwise reconnect immediately
  // forever with attempt=0 each iteration — the previous behavior.
  // Three consecutive 401s within the loop give up and surface the
  // error so the caller knows the credential is bad.
  let consecutive401 = 0;
  const MAX_CONSECUTIVE_401 = 3;

  const stop = () => {
    stopped = true;
    if (backoffTimer) clearTimeout(backoffTimer);
    controller?.abort();
  };

  const scheduleReconnect = () => {
    if (stopped) return;
    // Exponential backoff: base, 2x, 4x… capped at maxBackoff.
    const delay = Math.min(maxBackoff, baseBackoff * 2 ** attempt);
    attempt += 1;
    backoffTimer = setTimeout(() => void connect(), delay);
  };

  const connect = async (): Promise<void> => {
    if (stopped) return;
    controller = new AbortController();
    const url = buildUrl(cfg.baseURL, topic, token);
    const headers: Record<string, string> = { Accept: 'text/event-stream' };
    if (lastEventId) headers['Last-Event-ID'] = lastEventId;

    let res: Response;
    try {
      res = await cfg.fetchImpl(url, { headers, signal: controller.signal });
    } catch (err) {
      if (stopped || isAbort(err)) return;
      scheduleReconnect();
      return;
    }

    if (res.status === 401) {
      consecutive401 += 1;
      if (consecutive401 >= MAX_CONSECUTIVE_401) {
        opts.onError?.(
          new Error(
            `realtime subscribe stuck in 401-refresh loop (${consecutive401} consecutive); ` +
              'check that onTokenExpired returns a credential the server accepts'
          )
        );
        stop();
        return;
      }
      // Token expired / rejected — try to refresh, else give up.
      const fresh = opts.onTokenExpired ? await opts.onTokenExpired() : null;
      if (fresh) {
        token = fresh;
        attempt = 0; // fresh credential → reconnect immediately
        void connect();
      } else {
        opts.onError?.(new Error('realtime subscribe unauthorized (401)'));
        stop();
      }
      return;
    }

    if (!res.ok || !res.body) {
      if (!stopped) scheduleReconnect();
      return;
    }

    // Connected — reset backoff and the 401 counter; a successful
    // open proves the current credential works.
    attempt = 0;
    consecutive401 = 0;
    try {
      await readStream(res.body, (frame) => {
        if (frame.id !== undefined) lastEventId = frame.id;
        if (frame.data === undefined) return; // comment / heartbeat
        const parsed = parseEvent<T>(frame.data, opts);
        if (parsed) onMessage(parsed);
      });
    } catch (err) {
      if (stopped || isAbort(err)) return;
    }
    // Stream ended (server closed, e.g. topic deleted) → reconnect.
    if (!stopped) scheduleReconnect();
  };

  void connect();
  return stop;
}

function buildUrl(baseURL: string, topic: string, token?: string): string {
  const url = joinUrl(baseURL, `/realtime/topics/${encodeURIComponent(topic)}`);
  // EventSource can't set headers, so the token rides in the query
  // string — the same path a raw EventSource would use.
  return token ? `${url}?token=${encodeURIComponent(token)}` : url;
}

function parseEvent<T>(data: string, opts: SubscribeOptions<T>): RealtimeEvent<T> | null {
  let json: unknown;
  try {
    json = JSON.parse(data);
  } catch {
    return null;
  }
  if (!isRealtimeShape(json)) return null;
  const message = opts.validate ? opts.validate.parse(json.message) : (json.message as T);
  return { topic: json.topic, message, published_at: json.published_at };
}

function isRealtimeShape(v: unknown): v is RealtimeEvent<unknown> {
  return (
    typeof v === 'object' &&
    v !== null &&
    typeof (v as Record<string, unknown>)['topic'] === 'string' &&
    typeof (v as Record<string, unknown>)['published_at'] === 'string' &&
    'message' in (v as Record<string, unknown>)
  );
}

interface SseFrame {
  data?: string;
  id?: string;
}

/**
 * Read an SSE response body, invoking `onFrame` per event. Minimal
 * parser: accumulates `data:` lines (joined by `\n`) and `id:` until a
 * blank line dispatches the frame. Lines starting with `:` are comments
 * (heartbeats) — surfaced as a frame with no `data` so the id can still
 * advance.
 */
async function readStream(
  body: ReadableStream<Uint8Array>,
  onFrame: (frame: SseFrame) => void
): Promise<void> {
  const reader = body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let dataLines: string[] = [];
  let id: string | undefined;
  let sawComment = false;

  const dispatch = () => {
    if (dataLines.length > 0) {
      onFrame({ data: dataLines.join('\n'), id });
    } else if (sawComment) {
      onFrame({ id });
    }
    dataLines = [];
    sawComment = false;
  };

  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    let nl: number;
    while ((nl = buffer.indexOf('\n')) >= 0) {
      const line = buffer.slice(0, nl).replace(/\r$/, '');
      buffer = buffer.slice(nl + 1);
      if (line === '') {
        dispatch();
        continue;
      }
      if (line.startsWith(':')) {
        sawComment = true;
        continue;
      }
      const colon = line.indexOf(':');
      const field = colon === -1 ? line : line.slice(0, colon);
      const rawVal = colon === -1 ? '' : line.slice(colon + 1);
      const val = rawVal.startsWith(' ') ? rawVal.slice(1) : rawVal;
      if (field === 'data') dataLines.push(val);
      else if (field === 'id') id = val;
    }
  }
  // Flush a trailing frame if the stream ended without a blank line.
  dispatch();
}

function isAbort(err: unknown): boolean {
  return typeof err === 'object' && err !== null && (err as { name?: string }).name === 'AbortError';
}
