export interface RetryOptions {
  attempts?: number;
  minDelayMs?: number;
  maxDelayMs?: number;
  factor?: number;
  jitter?: number;
  signal?: AbortSignal;
  retryIf?: (error: unknown, attempt: number) => boolean | Promise<boolean>;
  onRetry?: (error: unknown, attempt: number, delayMs: number) => void | Promise<void>;
}

function nonNegative(value: number, label: string): number {
  if (!Number.isFinite(value) || value < 0) {
    throw new RangeError(`${label} must be a non-negative number`);
  }
  return value;
}

export function backoffDelay(
  attempt: number,
  options: Pick<RetryOptions, "minDelayMs" | "maxDelayMs" | "factor" | "jitter"> = {},
): number {
  if (!Number.isInteger(attempt) || attempt < 1) {
    throw new RangeError("attempt must be a positive integer");
  }
  const min = nonNegative(options.minDelayMs ?? 100, "minDelayMs");
  const max = nonNegative(options.maxDelayMs ?? 30_000, "maxDelayMs");
  if (max < min) throw new RangeError("maxDelayMs must be at least minDelayMs");
  const factor = options.factor ?? 2;
  if (!Number.isFinite(factor) || factor < 1) {
    throw new RangeError("factor must be at least 1");
  }
  const jitter = Math.min(1, Math.max(0, options.jitter ?? 0.2));
  const raw = Math.min(max, min * factor ** Math.max(0, attempt - 1));
  return Math.round(raw * (1 - jitter + Math.random() * jitter * 2));
}

export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  nonNegative(ms, "sleep duration");
  if (signal?.aborted) {
    return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
  }

  return new Promise((resolve, reject) => {
    const abort = (): void => {
      clearTimeout(timer);
      reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener("abort", abort);
      resolve();
    }, ms);
    signal?.addEventListener("abort", abort, { once: true });
  });
}

export async function retry<T>(
  operation: (attempt: number, signal?: AbortSignal) => Promise<T>,
  options: RetryOptions = {},
): Promise<T> {
  const attempts = options.attempts ?? 3;
  if (!Number.isInteger(attempts) || attempts < 1) {
    throw new RangeError("attempts must be a positive integer");
  }

  let last: unknown;
  for (let attempt = 1; attempt <= attempts; attempt++) {
    options.signal?.throwIfAborted();
    try {
      return await operation(attempt, options.signal);
    } catch (error) {
      last = error;
      const retryAllowed = options.retryIf ? await options.retryIf(error, attempt) : true;
      if (attempt >= attempts || !retryAllowed) throw error;
      const delay = backoffDelay(attempt, options);
      await options.onRetry?.(error, attempt, delay);
      await sleep(delay, options.signal);
    }
  }
  throw last;
}

export async function withTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number,
  message = "Operation timed out",
  signal?: AbortSignal,
): Promise<T> {
  nonNegative(timeoutMs, "timeoutMs");
  signal?.throwIfAborted();

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(new Error(message)), timeoutMs);
  const abort = (): void => controller.abort(signal?.reason);
  signal?.addEventListener("abort", abort, { once: true });

  try {
    return await Promise.race([
      promise,
      new Promise<T>((_resolve, reject) => {
        controller.signal.addEventListener("abort", () => reject(controller.signal.reason), {
          once: true,
        });
      }),
    ]);
  } finally {
    clearTimeout(timeout);
    signal?.removeEventListener("abort", abort);
  }
}

export function stableStringify(value: unknown): string {
  const ancestors = new Set<object>();

  const normalize = (input: unknown): unknown => {
    if (!input || typeof input !== "object") return input;
    if (input instanceof Date) return input.toISOString();
    if (ancestors.has(input)) {
      throw new TypeError("Cannot stringify circular structure");
    }

    ancestors.add(input);
    try {
      if (Array.isArray(input)) return input.map(normalize);
      const record = input as Record<string, unknown>;
      return Object.fromEntries(
        Object.keys(record)
          .sort()
          .map((key) => [key, normalize(record[key])]),
      );
    } finally {
      ancestors.delete(input);
    }
  };

  return JSON.stringify(normalize(value));
}

export function safeJsonParse<T>(value: string, fallback: T): T {
  try {
    return JSON.parse(value) as T;
  } catch {
    return fallback;
  }
}

export function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

export function once<T extends (...args: any[]) => any>(fn: T): T {
  let called = false;
  let result: ReturnType<T>;
  return ((...args: Parameters<T>) => {
    if (!called) {
      called = true;
      result = fn(...args);
    }
    return result;
  }) as T;
}
