// test/worker-pool.test.ts — pool de claim du worker (finding revue
// firecrawl-onpage). Pur : `claim`/`process` sont des fakes en mémoire (pas de
// DB, pas de mock.module — DI comme render-limiter.test.ts), un `setTimer`
// fake contrôle le délai idle sans vrai délai quand le test le nécessite.
import { expect, test } from "bun:test";
import { startWorkerPool } from "../src/worker-pool";

/** Promesse pilotée manuellement par le test (contrôle exact du moment où un
 *  `process(job)` se termine, sans dépendre d'un vrai délai). */
function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
  let resolve!: (v: T) => void;
  const promise = new Promise<T>((res) => {
    resolve = res;
  });
  return { promise, resolve };
}

/** Poll jusqu'à ce que `predicate()` soit vrai — évite les `sleep` fixes
 *  (flaky) tout en restant borné dans le temps si le pool ne progresse pas. */
async function waitFor(
  predicate: () => boolean,
  timeoutMs = 2000,
): Promise<void> {
  const start = Date.now();
  while (!predicate()) {
    if (Date.now() - start > timeoutMs) {
      throw new Error("waitFor: timeout waiting for condition");
    }
    await new Promise((r) => setTimeout(r, 1));
  }
}

test("startWorkerPool: jamais plus de `concurrency` jobs en vol, tous traités", async () => {
  const jobs = [0, 1, 2, 3, 4, 5];
  let claimIndex = 0;
  const claim = async (): Promise<number | null> =>
    claimIndex < jobs.length ? jobs[claimIndex++] : null;

  const gates = jobs.map(() => deferred<void>());
  let inFlight = 0;
  let peak = 0;
  const started: number[] = [];
  const finished: number[] = [];
  const process = async (job: number): Promise<void> => {
    inFlight++;
    peak = Math.max(peak, inFlight);
    started.push(job);
    await gates[job].promise;
    inFlight--;
    finished.push(job);
  };

  const handle = startWorkerPool({
    claim,
    process,
    concurrency: 3,
    idleDelayMs: 10_000, // n'intervient jamais : la file n'est vide qu'à la fin
    onError: () => {
      throw new Error("onError ne doit pas être appelé dans ce scénario");
    },
  });

  await waitFor(() => started.length === 3);
  expect(peak).toBe(3); // jamais plus que `concurrency` slots occupés à la fois
  expect(finished).toHaveLength(0);

  // Libère 2 slots → 2 nouveaux claims (jobs 3 et 4), job 2 encore en vol.
  gates[0].resolve();
  gates[1].resolve();
  await waitFor(() => started.length === 5);
  expect(peak).toBeLessThanOrEqual(3);

  gates[2].resolve();
  gates[3].resolve();
  gates[4].resolve();
  gates[5].resolve();
  await waitFor(() => finished.length === 6);

  handle.stop();
  expect([...finished].sort((a, b) => a - b)).toEqual(jobs);
  expect(peak).toBeLessThanOrEqual(3);
});

test("startWorkerPool: un `process` qui throw libère son slot, onError est appelé, les suivants passent", async () => {
  const jobs = [0, 1, 2];
  let idx = 0;
  const claim = async (): Promise<number | null> =>
    idx < jobs.length ? jobs[idx++] : null;
  const errors: unknown[] = [];
  const finished: number[] = [];
  const process = async (job: number): Promise<void> => {
    if (job === 1) throw new Error("boom");
    finished.push(job);
  };

  const handle = startWorkerPool({
    claim,
    process,
    concurrency: 1, // sériel : force l'ordre 0 -> 1 (throw) -> 2
    idleDelayMs: 10_000,
    onError: (e) => errors.push(e),
  });

  await waitFor(() => finished.length === 2 && errors.length === 1);
  handle.stop();

  expect(finished).toEqual([0, 2]);
  expect(errors).toHaveLength(1);
  expect((errors[0] as Error).message).toBe("boom");
});

test("startWorkerPool: un `claim` qui throw ne casse pas la boucle — retenté après idleDelayMs", async () => {
  let calls = 0;
  const errors: unknown[] = [];
  const claim = async (): Promise<null> => {
    calls++;
    if (calls === 1) throw new Error("db down");
    return null;
  };
  const timers: Array<() => void> = [];
  const setTimer = (fn: () => void): unknown => {
    timers.push(fn);
    return undefined;
  };

  const handle = startWorkerPool({
    claim,
    process: async () => {},
    concurrency: 1,
    idleDelayMs: 5,
    onError: (e) => errors.push(e),
    setTimer,
  });

  await waitFor(() => errors.length === 1 && timers.length === 1);
  expect(calls).toBe(1);
  expect((errors[0] as Error).message).toBe("db down");

  // Personne ne déclenche le vrai délai (setTimer est fake) : la boucle
  // attend — tant que le timer n'est pas tiré à la main, pas de 2e claim.
  expect(calls).toBe(1);

  timers.shift()?.();
  await waitFor(() => calls === 2);
  expect(calls).toBe(2); // la boucle a survécu et a retenté le claim

  handle.stop();
});

test("startWorkerPool: claim() qui rend null attend idleDelayMs puis re-claim (setTimer fake)", async () => {
  let calls = 0;
  const claim = async (): Promise<null> => {
    calls++;
    return null;
  };
  const timers: Array<{ fn: () => void; ms: number }> = [];
  const setTimer = (fn: () => void, ms: number): unknown => {
    timers.push({ fn, ms });
    return undefined;
  };

  const handle = startWorkerPool({
    claim,
    process: async () => {},
    concurrency: 1,
    idleDelayMs: 2000,
    onError: () => {},
    setTimer,
  });

  await waitFor(() => timers.length === 1);
  expect(calls).toBe(1);
  expect(timers[0].ms).toBe(2000);
  expect(calls).toBe(1); // pas de 2e claim tant que le timer fake n'a pas tiré

  timers.shift()?.fn();
  await waitFor(() => calls === 2);
  expect(calls).toBe(2);

  handle.stop();
});

test("startWorkerPool: concurrency=1 traite strictement en série, ordre préservé", async () => {
  const jobs = [0, 1, 2, 3, 4];
  let idx = 0;
  const claim = async (): Promise<number | null> =>
    idx < jobs.length ? jobs[idx++] : null;
  let inFlight = 0;
  let peak = 0;
  const order: number[] = [];
  const process = async (job: number): Promise<void> => {
    inFlight++;
    peak = Math.max(peak, inFlight);
    await new Promise((r) => setTimeout(r, 1));
    order.push(job);
    inFlight--;
  };

  const handle = startWorkerPool({
    claim,
    process,
    concurrency: 1,
    idleDelayMs: 10_000,
    onError: () => {
      throw new Error("onError ne doit pas être appelé dans ce scénario");
    },
  });

  await waitFor(() => order.length === 5);
  handle.stop();

  expect(order).toEqual(jobs);
  expect(peak).toBe(1);
});

test("startWorkerPool: stop() empêche tout nouveau claim", async () => {
  let calls = 0;
  const claim = async (): Promise<null> => {
    calls++;
    return null;
  };
  let timers: Array<() => void> = [];
  const setTimer = (fn: () => void): unknown => {
    timers.push(fn);
    return undefined;
  };

  const handle = startWorkerPool({
    claim,
    process: async () => {},
    concurrency: 2,
    idleDelayMs: 5,
    onError: () => {},
    setTimer,
  });

  await waitFor(() => calls === 2 && timers.length === 2);
  handle.stop();

  const callsBeforeStop = calls;
  const pending = timers;
  timers = [];
  for (const fn of pending) fn(); // simule l'écoulement du délai idle
  await new Promise((r) => setTimeout(r, 20)); // laisse filer un éventuel claim fantôme

  expect(calls).toBe(callsBeforeStop); // aucun claim après stop()
  expect(timers).toHaveLength(0); // stop() empêche aussi une re-planification
});
