// services/analytics/src/worker-pool.ts — pool de claim du worker analytics
// (finding Important de la revue finale firecrawl-onpage). Avant ce module, la
// boucle `tick` d'index.ts claimait UN job et l'`await`ait jusqu'au bout : un
// `crawl-onpage` de plusieurs minutes bloquait csv-import/serp-semantics/
// site-crawl pour TOUTES les orgs (tête de file).
//
// PUR et testable — zéro import DB/réseau. `startWorkerPool` maintient jusqu'à
// `concurrency` jobs en vol simultanément : `claim`/`process` sont injectés
// (DI), donc testable avec des fakes déterministes (cf. render-limiter.ts pour
// le même style de sémaphore pur).
export interface WorkerPoolOptions<Job> {
  /** Claim atomique d'un job (SKIP LOCKED côté SQL — cf. queue.ts) ; `null` si
   *  la file est vide. Un rejet est traité comme "pas de job disponible". */
  claim: () => Promise<Job | null>;
  /** Traite un job déjà claimé. Un rejet est capturé (jamais de crash de la
   *  boucle) et signalé via `onError` ; le slot est TOUJOURS libéré. */
  process: (job: Job) => Promise<void>;
  /** Nombre max de jobs en vol simultanément (>= 1 ; les valeurs non entières
   *  sont tronquées, les valeurs < 1 remontées à 1 — la clamp [1, 16] réelle
   *  vit dans env.ts, ce module fait juste confiance à l'appelant). */
  concurrency: number;
  /** Délai avant de retenter un claim après une file vide (ou une erreur de
   *  claim) — pas de busy-loop quand il n'y a rien à faire. */
  idleDelayMs: number;
  /** Jamais throw : appelé pour toute erreur de `claim`/`process`, la boucle
   *  continue toujours. */
  onError: (error: unknown) => void;
  /** Minuteur injectable (défaut `setTimeout`) — permet aux tests de contrôler
   *  le délai idle sans vrai délai (avancer un faux timer manuellement). */
  setTimer?: (fn: () => void, ms: number) => unknown;
}

export interface WorkerPoolHandle {
  /** Arrête le pool : aucun NOUVEAU claim n'est lancé après `stop()` (les
   *  claims/process déjà en vol se terminent naturellement, sans relancer de
   *  travail ensuite). Utile pour un arrêt propre en test (et, plus tard, pour
   *  un shutdown gracieux du process). */
  stop: () => void;
}

/**
 * Démarre un pool de claim : maintient jusqu'à `concurrency` jobs en vol.
 * Tant qu'un slot est libre, il tente un claim ; un job obtenu est traité SANS
 * bloquer les autres slots (chaque slot est une promesse indépendante, jamais
 * `await`ée par la boucle principale) ; un slot libéré (job terminé, échoué,
 * ou claim vide) est immédiatement retenté — drain rapide quand des jobs
 * arrivent, idle-poll (`idleDelayMs`) quand la file est vide.
 */
export function startWorkerPool<Job>(
  options: WorkerPoolOptions<Job>,
): WorkerPoolHandle {
  const {
    claim,
    process,
    concurrency,
    idleDelayMs,
    onError,
    setTimer = (fn, ms) => setTimeout(fn, ms),
  } = options;
  // Défensif : la vraie clamp [1, 16] vit dans env.ts (WORKER_CONCURRENCY) —
  // ici on se contente de ne jamais tourner avec 0 (ou moins) slot.
  const maxConcurrency = Math.max(1, Math.trunc(concurrency));

  let inFlight = 0;
  let stopped = false;

  /** Retente un `fill()` après `idleDelayMs` — jamais après `stop()`. */
  const scheduleRetry = (): void => {
    if (stopped) return;
    setTimer(() => fill(), idleDelayMs);
  };

  /** Un cycle de slot : un claim, puis (si un job est obtenu) un process — le
   *  slot est TOUJOURS libéré en `finally`/à chaque branche de sortie, jamais
   *  de fuite même si `claim`/`process` rejette. */
  const runOne = async (): Promise<void> => {
    let job: Job | null = null;
    try {
      job = await claim();
    } catch (e) {
      onError(e);
    }
    if (job == null) {
      // File vide (ou claim en erreur, traité pareil) : libère le slot et
      // retente après idleDelayMs plutôt que de marteler la DB.
      inFlight--;
      scheduleRetry();
      return;
    }
    try {
      await process(job);
    } catch (e) {
      onError(e);
    } finally {
      // Slot libéré immédiatement (drain rapide) — pas d'attente idle après un
      // process, réussi ou non : s'il y a eu un job, il peut y en avoir un
      // autre juste derrière.
      inFlight--;
      fill();
    }
  };

  /** Tope les slots libres jusqu'à `maxConcurrency`, une seule promesse
   *  trackée par slot (jamais `await`ée ici — `runOne` s'auto-relance via
   *  `finally`/`scheduleRetry`). No-op après `stop()`. */
  const fill = (): void => {
    if (stopped) return;
    while (inFlight < maxConcurrency) {
      inFlight++;
      void runOne();
    }
  };

  fill();

  return {
    stop(): void {
      stopped = true;
    },
  };
}
