/**
 * Retry policy for LLM calls.
 *
 * Exponential backoff with full jitter, base 1000ms, cap 16000ms. Only
 * retries on transient HTTP statuses (408, 425, 429, 5xx, 529). Honours a
 * `Retry-After` hint up to 60 seconds; longer waits fail fast. Never retries
 * on AbortError.
 */

const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504, 529]);
const BASE_DELAY_MS = 1000;
const CAP_DELAY_MS = 16000;
const MAX_RETRY_AFTER_MS = 60 * 1000;

export class RetryableError extends Error {
  constructor(status, retryAfterMs = null, message) {
    super(message || `Retryable HTTP ${status}`);
    this.name = 'RetryableError';
    this.status = status;
    this.retryAfterMs = retryAfterMs;
  }
}

export function isRetryableStatus(status) {
  return RETRYABLE_STATUSES.has(status);
}

function backoffWithJitter(attempt) {
  const exp = Math.min(CAP_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
  return Math.floor(Math.random() * exp);
}

/**
 * Parse a `Retry-After` header. Returns null when absent or unusable, or
 * a millisecond delay otherwise. Supports both seconds and HTTP-date forms.
 */
export function parseRetryAfter(value, now = Date.now()) {
  if (value == null) return null;
  const s = String(value).trim();
  if (!s) return null;
  if (/^\d+$/.test(s)) return Number(s) * 1000;
  const dateMs = Date.parse(s);
  if (Number.isFinite(dateMs)) {
    const delta = dateMs - now;
    return delta > 0 ? delta : 0;
  }
  return null;
}

function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
    const t = setTimeout(() => {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, ms);
    const onAbort = () => {
      clearTimeout(t);
      reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
    };
    signal?.addEventListener('abort', onAbort, { once: true });
  });
}

/**
 * Token-bucket rate limiter shared by callers that hit the same upstream
 * quota (e.g. handbook extraction loops). Replaces the static
 * `setTimeout(..., 12000)` / 15s sleeps that Phase 1 relied on. The bucket
 * refills continuously; `acquire` resolves either immediately (token
 * available) or after the next refill tick.
 *
 * `rps` is "requests per second" (use fractional values for per-minute
 * limits: `5/60` for 5 req/min). `burst` is the maximum number of tokens
 * the bucket can hold; default 1 means strict spacing.
 *
 * Call `pauseUntil(timestampMs)` after a 429 with a `Retry-After` hint —
 * no acquire returns before that timestamp.
 *
 * @param {{ rps?: number, burst?: number }} [opts]
 */
export function createLimiter({ rps = 1, burst = 1 } = {}) {
  if (rps <= 0) throw new Error('createLimiter: rps must be > 0');
  if (burst < 1) throw new Error('createLimiter: burst must be >= 1');
  const intervalMs = 1000 / rps;
  let tokens = burst;
  let lastRefill = Date.now();
  let pausedUntil = 0;
  const waiters = [];

  function refill(now) {
    const elapsed = now - lastRefill;
    if (elapsed <= 0) return;
    const earned = elapsed / intervalMs;
    if (earned >= 1) {
      tokens = Math.min(burst, tokens + Math.floor(earned));
      lastRefill = now;
    }
  }

  function drain() {
    while (waiters.length) {
      const now = Date.now();
      if (now < pausedUntil) {
        scheduleWake(pausedUntil - now);
        return;
      }
      refill(now);
      if (tokens >= 1) {
        tokens -= 1;
        const w = waiters.shift();
        w.resolve();
      } else {
        const wait = Math.max(intervalMs - (now - lastRefill), 0);
        scheduleWake(wait);
        return;
      }
    }
  }

  let wakeTimer = null;
  function scheduleWake(ms) {
    if (wakeTimer) return;
    wakeTimer = setTimeout(() => {
      wakeTimer = null;
      drain();
    }, ms);
  }

  return {
    /** @param {{signal?:AbortSignal}} [opts] */
    async acquire({ signal } = {}) {
      if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
      return new Promise((resolve, reject) => {
        const entry = { resolve, reject };
        const onAbort = () => {
          const i = waiters.indexOf(entry);
          if (i !== -1) waiters.splice(i, 1);
          reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
        };
        if (signal) signal.addEventListener('abort', onAbort, { once: true });
        const wrapped = {
          resolve: () => { if (signal) signal.removeEventListener('abort', onAbort); resolve(); },
          reject,
        };
        waiters.push(wrapped);
        drain();
      });
    },
    /** Block all `acquire`s until `untilMs` (epoch milliseconds). */
    pauseUntil(untilMs) {
      if (untilMs > pausedUntil) pausedUntil = untilMs;
      drain();
    },
    /** Inspect state — primarily for tests. */
    _state() {
      return { tokens, pausedUntil, waiters: waiters.length };
    },
  };
}

/**
 * Shared limiter for the multi-call extraction loops (source chunks,
 * handbook file sync). 20 req/min sits comfortably under Anthropic Tier 1
 * (50/min for Sonnet) and lets the handbook sync finish in a reasonable
 * time without us redesigning the rate-limit story; if a 429 still slips
 * through, `withRetry` honours Retry-After.
 */
export const extractionLimiter = createLimiter({ rps: 20 / 60, burst: 2 });

/**
 * Run `fn(attempt)` with retry. `fn` may throw a `RetryableError` to request
 * a retry, or any other error to fail immediately.
 *
 * @template T
 * @param {(attempt:number) => Promise<T>} fn
 * @param {{ maxRetries?: number, signal?: AbortSignal }} [opts]
 * @returns {Promise<T>}
 */
export async function withRetry(fn, { maxRetries = 4, signal } = {}) {
  let attempt = 0;
  for (;;) {
    if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
    try {
      return await fn(attempt);
    } catch (err) {
      if (err?.name === 'AbortError') throw err;
      const retryable = err instanceof RetryableError;
      if (!retryable || attempt >= maxRetries) throw err;

      let delayMs;
      if (err.retryAfterMs != null) {
        if (err.retryAfterMs > MAX_RETRY_AFTER_MS) throw err;
        delayMs = err.retryAfterMs;
      } else {
        delayMs = backoffWithJitter(attempt);
      }
      await sleep(delayMs, signal);
      attempt++;
    }
  }
}
