// src/lib/semantic/analysis-runner.ts — cœur orchestrable du job
// `serp-semantics`. Fetch pool (4) → staging R2 best-effort → wasm
// (extractSemanticPage/buildSerpProfile, MÊME config aux deux appels) →
// exports full-grain (pages/terms .jsonl.gz) → merge jsonb par-locale sur
// `keywords.semantic`. TOUT en DI (db/fetch/putObject/deleteObject/now) pour
// être testé par test:all sur le vrai Postgres (pattern crawl-runner.ts).
// Le fichier service (Task 4, services/analytics/src/serp-semantics.ts) n'est
// qu'un adaptateur mince qui câble les vraies dépendances (fetch mobile réel,
// client S3/R2, updateJob* pour la progression).
import { gzipSync } from "node:zlib";
import { and, eq, sql } from "drizzle-orm";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { keywords } from "@/lib/db/domain-schema";
import { buildSerpProfile, extractSemanticPage } from "./engine";
import type {
  SemanticExports,
  SerpSemanticsPayload,
  StoredSemanticProfile,
} from "./profile-types";
import type { RenderProvider } from "./render";
import type { PageInput, PageSemantics, SemanticConfig, Zone } from "./types";

/** Concurrence du pool de fetch des positions SERP (spec : 4). */
const POOL = 4;

/**
 * Seuil minimal de `structure.wordCount` (body+h1+h2h3+anchors, cf. T1) en
 *-dessous duquel une page « 200 OK » est traitée comme une coquille
 * anti-bot/JS non rendue plutôt qu'une page analysable (CDC §5 — garde
 * thin-HTML/faux-200, levier de fiabilité n°1 du plan S5-bis). Constaté en
 * QA : murs anti-bot à 7-20 mots vs pages légitimes même minimalistes qui
 * restent largement au-dessus. Heuristique volontairement grossière — le
 * fallback de rendu headless (S7, `deps.render`) retente une extraction sur
 * ces pages avant de les exclure définitivement (cf. Task 2 du plan S7).
 */
const MIN_CONTENT_TOKENS = 120;

/**
 * Position SERP maximale au-delà de laquelle le fallback de rendu headless
 * (S7, `deps.render`) n'est jamais tenté — Chrome local est coûteux (spawn +
 * navigation, cf. `render.ts`), on ne le mobilise que pour les positions à
 * fort enjeu (top 5 par défaut). Overridable via `RunnerDeps.maxRenderPosition`.
 */
const DEFAULT_MAX_RENDER_POSITION = 5;

/** Zones du contrat `ZoneCounts` — ordre arbitraire, juste pour itérer. */
const ZONES: Zone[] = [
  "title",
  "h1",
  "h2h3",
  "slug",
  "alt",
  "anchor",
  "meta",
  "body",
];

/** DB Drizzle sur le schéma domaine — même type que `CrawlDb`
 *  (`src/lib/pages/crawl-runner.ts`) : le `db` de l'app (schéma plus large)
 *  n'est pas assignable tel quel (générique invariant), le test le cast. */
export type SemanticDb = PostgresJsDatabase<
  typeof import("@/lib/db/domain-schema")
>;

export type RunnerProgress = {
  phase: "fetch" | "extract" | "score" | "persist";
  done: number;
  total: number;
};

export type RunnerDeps = {
  db: SemanticDb;
  /** Fetch HTML d'une page (null = échec). Le fetch mobile réel
   *  (`fetchSemanticHtml`) est câblé par l'adaptateur service (Task 4). */
  fetchHtml: (
    url: string,
  ) => Promise<{ html: string; finalUrl: string } | null>;
  /** Écrit `body` (déjà gzippé par le runner) sous `key` sur R2 — staging ET
   *  exports partagent cette primitive. */
  putObject: (key: string, body: Uint8Array) => Promise<void>;
  /** Suppression best-effort (staging en fin de job, anciens exports D4). */
  deleteObject: (key: string) => Promise<void>;
  /** Horodatage ISO injectable (déterminisme des tests). */
  now: () => string;
  onProgress?: (p: RunnerProgress) => Promise<void>;
  /**
   * Fallback de rendu headless (S7, `render.ts`) — tenté uniquement quand le
   * fetch HTTP échoue ou produit une coquille thin-HTML (cf.
   * `MIN_CONTENT_TOKENS`), dans la limite de `maxRenderPosition`. `null`/
   * absent = pas de rendu (comportement pré-S7, dégradation identique à un
   * environnement sans Chrome local — cf. `resolveRenderProvider`). Le
   * runner reste testable sans Chrome : les tests injectent un provider fake.
   */
  render?: RenderProvider | null;
  /** Position SERP max pour tenter le rendu headless (défaut
   *  `DEFAULT_MAX_RENDER_POSITION` = 5). */
  maxRenderPosition?: number;
};

/**
 * Copie locale de `mapPool` (même sémantique que
 * `src/lib/keywords/serp-actions.ts` et `src/lib/pages/crawl-runner.ts` — ni
 * l'un ni l'autre n'exporte la fonction : le premier porte la directive Server
 * Action donc non importable ici, et dupliquer une helper de 15 lignes est le
 * choix déjà fait par crawl-runner plutôt que d'extraire un utilitaire
 * partagé).
 */
async function mapPool<T, R>(
  items: T[],
  limit: number,
  fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
  const out = new Array<R>(items.length);
  let next = 0;
  async function worker(): Promise<void> {
    while (next < items.length) {
      const i = next++;
      out[i] = await fn(items[i], i);
    }
  }
  await Promise.all(
    Array.from({ length: Math.min(limit, items.length) }, worker),
  );
  return out;
}

/** Sous-tag primaire d'un code langue (`fr-FR` → `fr`), insensible à la casse. */
function primarySubtag(lang: string): string {
  return (lang.split(/[-_]/)[0] ?? "").toLowerCase();
}

type SerpUrl = SerpSemanticsPayload["urls"][number];

/**
 * Clamp position [1,10] + dédup par position (contrainte globale du plan —
 * `PageInput.position` hors `[1,10]` fausse silencieusement le poids CTR côté
 * wasm). Hors-plage ignoré ; en cas de doublon, la première occurrence gagne.
 * Trié par position croissante.
 */
function clampAndDedupPositions(urls: SerpUrl[]): SerpUrl[] {
  const byPosition = new Map<number, SerpUrl>();
  for (const u of urls) {
    if (u.position < 1 || u.position > 10) continue;
    if (byPosition.has(u.position)) continue;
    byPosition.set(u.position, u);
  }
  return [...byPosition.values()].sort((a, b) => a.position - b.position);
}

function stagingKey(siteId: string, jobId: string, position: number): string {
  return `serp-semantics-staging/${siteId}/${jobId}/pos-${position}.html.gz`;
}

/** Clé de staging du HTML rendu (fallback S7) — même dossier/job que le HTML
 *  brut, suffixe `-rendered` pour ne jamais collisionner avec `stagingKey`. */
function renderedStagingKey(
  siteId: string,
  jobId: string,
  position: number,
): string {
  return `serp-semantics-staging/${siteId}/${jobId}/pos-${position}-rendered.html.gz`;
}

/** `structure.wordCount` sous le seuil (cf. `MIN_CONTENT_TOKENS`) — `null`
 *  (fetch en échec) n'est jamais thin, c'est un état distinct (« missing »). */
function isThinContent(semantics: PageSemantics | null): boolean {
  return semantics !== null && semantics.structure.wordCount < MIN_CONTENT_TOKENS;
}

/**
 * `true` si la zone `title` a produit au moins un terme indexé (follow-up
 * flag honnête, cf. commentaire de `thinContentPositions`/`poorContentPositions`
 * plus bas) — proxy pour « la page a un `<title>` exploitable ». `PageSemantics`
 * n'expose pas le texte brut du titre (seulement `termFreqs` par zone), donc
 * ce check passe par les compteurs de zone plutôt que par une chaîne : un
 * `<title>` présent mais réduit à des stop-words tomberait à `false` (edge
 * case accepté, pas rencontré en pratique — un vrai titre de page porte
 * toujours au moins un terme significatif).
 */
function hasTitleTerms(semantics: PageSemantics): boolean {
  return Object.values(semantics.termFreqs).some((zc) => zc.title > 0);
}

function resultKeys(
  siteId: string,
  keywordId: string,
  country: string,
  jobId: string,
): SemanticExports {
  const prefix = `serp-semantics-results/${siteId}/${keywordId}/${country}/${jobId}`;
  return {
    pagesKey: `${prefix}/pages.jsonl.gz`,
    termsKey: `${prefix}/terms.jsonl.gz`,
  };
}

function gzipText(text: string): Uint8Array {
  return gzipSync(Buffer.from(text, "utf-8"));
}

/**
 * `pages.jsonl` : une ligne par position du corpus (incluses, exclues et
 * manquantes) — `structure`/`archetype`/`lang` sont `null` quand la page n'a
 * pas de `semantics` (exclue, fetch/langue en échec).
 */
function buildPagesJsonl(pages: PageInput[]): string {
  const lines = pages.map((p) =>
    JSON.stringify({
      position: p.position,
      url: p.url,
      included: p.included,
      fetchOk: p.fetchOk,
      structure: p.semantics?.structure ?? null,
      archetype: p.semantics?.archetype ?? null,
      lang: p.semantics?.lang ?? null,
    }),
  );
  return lines.length > 0 ? `${lines.join("\n")}\n` : "";
}

/**
 * `terms.jsonl` : une ligne par terme × page × zone, uniquement les zones à
 * fréquence > 0 (pas de lignes `freq: 0` — le contrat ne les émet pas).
 */
function buildTermsJsonl(pages: PageInput[]): string {
  const lines: string[] = [];
  for (const p of pages) {
    if (!p.semantics) continue;
    for (const [term, counts] of Object.entries(p.semantics.termFreqs)) {
      for (const zone of ZONES) {
        const freq = counts[zone];
        if (freq > 0) {
          lines.push(
            JSON.stringify({ term, position: p.position, zone, freq }),
          );
        }
      }
    }
  }
  return lines.length > 0 ? `${lines.join("\n")}\n` : "";
}

/**
 * Fetch + staging best-effort + extraction wasm d'une position SERP incluse.
 * `stagingKeys` accumule les clés effectivement uploadées (pour le cleanup en
 * `finally` du runner) — jamais celles dont l'upload a échoué (rien à
 * nettoyer). Retourne `null` si le fetch échoue (page manquante).
 */
async function fetchAndExtract(
  deps: RunnerDeps,
  stagingKeys: string[],
  siteId: string,
  jobId: string,
  position: number,
  url: string,
  config: Partial<SemanticConfig>,
): Promise<PageSemantics | null> {
  const fetched = await deps.fetchHtml(url);
  if (!fetched) return null;
  const key = stagingKey(siteId, jobId, position);
  try {
    await deps.putObject(key, gzipText(fetched.html));
    stagingKeys.push(key);
  } catch {
    // staging best-effort : un échec d'upload ne bloque jamais l'analyse.
  }
  return extractSemanticPage(fetched.html, fetched.finalUrl, config);
}

/**
 * Exécute une analyse sémantique SERP pour un (site, mot-clé, payload de job)
 * donné. Logique imposée (design Phase B D2-D4, plan S3 Task 3) :
 *
 * 1. Charge `keywords` (`payload.keywordId`, scopé `siteId`) — introuvable →
 *    `{ ok:false }`. `keyword.lang` = langue de référence du garde-fou.
 * 2. Clamp positions [1,10] + dédup.
 * 3. Fetch pool (4) des positions incluses ; staging R2 best-effort de chaque
 *    HTML avant extraction wasm.
 * 3bis. Garde thin-HTML/faux-200 (CDC §5, S5-bis Task 3) : `structure.wordCount`
 *    (flux principal complet, cf. T1) `< MIN_CONTENT_TOKENS` (120) → page pas
 *    analysable — même traitement que la garde langue (`semantics:null,
 *    fetchOk:false`), et AVANT elle (une coquille n'a pas de langue fiable à
 *    comparer). Flag honnête (follow-up, cf. rapport .superpowers/sdd/
 *    followup-semantic-report.md — le flag unique historique `js_rendered_
 *    suspected` s'est révélé mensonger en prod sur des pages au HTML
 *    pleinement rendu, cf. `sanitizeRawTextElements` dans `engine.ts` pour le
 *    vrai bug de perte de corpus qu'il masquait) — deux libellés distincts
 *    selon `hasTitleTerms` :
 *      - pas de `<title>` exploitable → hypothèse JS/anti-bot plausible
 *        (fetch OK mais quasi rien, pas même un titre) →
 *        `js_rendered_suspected:${position}`.
 *      - `<title>` présent, corpus quand même sous le seuil → page
 *        effectivement pauvre, PAS un problème de rendu →
 *        `thin_content_suspected:${position}` (flag ajouté par ce follow-up
 *        — libellé UI `flagLabel` présent dans `semantic-labels.ts`).
 * 3ter. Fallback de rendu headless (S7, plan Task 2) : si le fetch a échoué
 *    OU produit une coquille thin-HTML, ET `position <= maxRenderPosition`
 *    (défaut 5), ET `deps.render` est fourni → `render(url)`. Non-`null` →
 *    staging R2 du HTML rendu (best-effort) + ré-extraction avec la MÊME
 *    config wasm. Toujours thin après rendu (ou rendu `null`, ou pas tenté) →
 *    comportement 3bis inchangé (même classification `js_rendered_suspected`/
 *    `thin_content_suspected` selon `hasTitleTerms` de la version rendue).
 *    Non-thin → page analysée, flag `js_rendered:${position}` (au lieu de
 *    l'un des deux flags ci-dessus).
 * 4. Garde langue : `pageSemantics.lang` non vide et ≠ `keyword.lang` (sous-tag
 *    primaire) → la page passe `semantics:null, fetchOk:false` (le crate la
 *    classe `missing`) et sa position est collectée pour un flag
 *    `lang_mismatch:${position}` ajouté après coup (le crate n'a pas de statut
 *    intermédiaire — adaptation runner documentée, follow-up S5+). S'applique
 *    aussi à la version rendue (3ter) — une page rendue dans une autre langue
 *    reste `lang_mismatch`, jamais `js_rendered`.
 * 5. `buildSerpProfile(pageInputs, client, config)` avec la MÊME config qu'à
 *    l'étape 3 (client = `payload.clientUrl` fetché/extrait, sinon `null`).
 * 6. Post-traitement TS : flags `lang_mismatch`, `StoredSemanticProfile`
 *    (`analyzedAt`, `jobId`, `exports`).
 * 7. Exports `pages.jsonl.gz`/`terms.jsonl.gz` sur R2 AVANT le merge DB — un
 *    profil persisté doit toujours pointer des exports existants. Les clés
 *    des exports de l'analyse précédente pour cette locale sont capturées ici
 *    (lecture faite à l'étape 1, avant tout écrit) mais leur suppression est
 *    différée après l'étape 8.
 * 8. Merge jsonb par-locale (`coalesce(...) || jsonb_build_object(...)`,
 *    jamais de read-modify-write — une écriture concurrente sur une autre
 *    locale n'est pas écrasée). Une fois la bascule DB réussie, les exports de
 *    l'analyse précédente (design D4 : une seule analyse conservée par
 *    locale) sont supprimés best-effort — JAMAIS avant : si on les supprimait
 *    avant un merge qui échoue ensuite, le profil encore en base pointerait
 *    des exports R2 déjà détruits (violation de l'invariant ci-dessus).
 * 9. `finally` : suppression best-effort de tout le staging uploadé, succès
 *    comme échec.
 *
 * Garde-fou additionnel (hors des 9 points, imposé par les tests du brief) :
 * si AUCUNE position incluse n'a pu être analysée (fetch total en échec), le
 * runner s'arrête avant tout écrit R2/DB et retourne `{ ok:false }` — pas de
 * profil vide/poubelle persisté.
 */
export async function runSerpSemantics(
  jobId: string,
  siteId: string,
  payload: SerpSemanticsPayload,
  deps: RunnerDeps,
): Promise<{ ok: true } | { ok: false; error: string }> {
  const stagingKeys: string[] = [];
  try {
    // --- Étape 1 : keyword de référence ------------------------------------
    const [kwRow] = await deps.db
      .select({ lang: keywords.lang, semantic: keywords.semantic })
      .from(keywords)
      .where(
        and(eq(keywords.id, payload.keywordId), eq(keywords.siteId, siteId)),
      );
    if (!kwRow) {
      return { ok: false, error: `mot-clé introuvable: ${payload.keywordId}` };
    }
    const refLang = primarySubtag(kwRow.lang);

    // --- Étape 2 : clamp + dédup --------------------------------------------
    const clamped = clampAndDedupPositions(payload.urls);

    // --- Étape 3+4 : fetch pool (4) + staging + extraction + garde langue --
    // MÊME config passée aux deux appels wasm (extractSemanticPage ici,
    // buildSerpProfile plus bas) — symétrie obligatoire (cf. engine.ts).
    // `fallbackLang: refLang` (fix A, CDC §3.7) : une page sans `<html lang>`
    // exploitable retombe sur la langue du mot-clé plutôt que sur
    // `Lang::Unsupported` (zéro stop-word filtré côté crate).
    const config: Partial<SemanticConfig> = { fallbackLang: refLang };
    const maxRenderPosition =
      deps.maxRenderPosition ?? DEFAULT_MAX_RENDER_POSITION;
    // Deux buckets pour un même garde-fou (thin-HTML) — flag honnête (follow-up,
    // cf. rapport .superpowers/sdd/followup-semantic-report.md) : pas de
    // `<title>` exploitable → hypothèse JS/anti-bot plausible
    // (`thinContentPositions` → `js_rendered_suspected`) ; `<title>` présent
    // mais corpus sous le seuil → page pauvre, PAS un problème de rendu
    // (`poorContentPositions` → `thin_content_suspected`, nouveau flag).
    const thinContentPositions: number[] = [];
    const poorContentPositions: number[] = [];
    const langMismatchPositions: number[] = [];
    const renderedPositions: number[] = [];
    const fetchTotal =
      clamped.filter((u) => u.included).length + (payload.clientUrl ? 1 : 0);
    let fetchDone = 0;
    let extractDone = 0;
    const emitFetch = async (): Promise<void> => {
      fetchDone++;
      await deps.onProgress?.({
        phase: "fetch",
        done: fetchDone,
        total: fetchTotal,
      });
    };
    const emitExtract = async (): Promise<void> => {
      extractDone++;
      await deps.onProgress?.({
        phase: "extract",
        done: extractDone,
        total: fetchTotal,
      });
    };

    const pageInputs: PageInput[] = await mapPool(
      clamped,
      POOL,
      async (u): Promise<PageInput> => {
        if (!u.included) {
          // Curation utilisateur : jamais fetchée, le crate la classe
          // `excludedByUser` via `included:false` (priment sur `fetchOk`).
          return {
            position: u.position,
            url: u.url,
            included: false,
            fetchOk: false,
            semantics: null,
          };
        }
        let semantics = await fetchAndExtract(
          deps,
          stagingKeys,
          siteId,
          jobId,
          u.position,
          u.url,
          config,
        );
        await emitFetch();

        // Fallback de rendu headless (S7, CDC §3.0/§5) : tenté uniquement
        // quand le fetch HTTP a échoué OU produit une coquille thin-HTML, et
        // seulement dans le top `maxRenderPosition` (Chrome local coûteux).
        // Ré-extraction avec la MÊME config wasm qu'au fetch initial —
        // symétrie obligatoire (cf. commentaire d'en-tête du fichier).
        let renderedOk = false;
        if (
          (semantics === null || isThinContent(semantics)) &&
          u.position <= maxRenderPosition &&
          deps.render
        ) {
          const rendered = await deps.render(u.url);
          if (rendered) {
            const renderedKey = renderedStagingKey(siteId, jobId, u.position);
            try {
              await deps.putObject(renderedKey, gzipText(rendered.html));
              stagingKeys.push(renderedKey);
            } catch {
              // staging best-effort : un échec d'upload ne bloque jamais
              // l'analyse (même contrat que `fetchAndExtract`).
            }
            const reExtracted = extractSemanticPage(
              rendered.html,
              rendered.finalUrl,
              config,
            );
            semantics = reExtracted;
            renderedOk = !isThinContent(reExtracted);
          }
        }

        if (!semantics) {
          return {
            position: u.position,
            url: u.url,
            included: true,
            fetchOk: false,
            semantics: null,
          };
        }
        await emitExtract();
        // Garde thin-HTML/faux-200 (CDC §5) — AVANT la garde langue : une
        // coquille anti-bot n'a pas de langue fiable à comparer. Sautée si le
        // rendu headless a produit du contenu exploitable (`renderedOk`).
        if (!renderedOk && isThinContent(semantics)) {
          if (hasTitleTerms(semantics)) {
            poorContentPositions.push(u.position);
          } else {
            thinContentPositions.push(u.position);
          }
          return {
            position: u.position,
            url: u.url,
            included: true,
            fetchOk: false,
            semantics: null,
          };
        }
        // Garde langue — s'applique aussi à la version rendue (ordre
        // inchangé : thin d'abord, langue ensuite). Une version rendue dans
        // une autre langue reste `lang_mismatch` (exclue) — jamais
        // `js_rendered`, réservé aux pages effectivement analysées.
        if (semantics.lang && primarySubtag(semantics.lang) !== refLang) {
          langMismatchPositions.push(u.position);
          return {
            position: u.position,
            url: u.url,
            included: true,
            fetchOk: false,
            semantics: null,
          };
        }
        if (renderedOk) {
          renderedPositions.push(u.position);
        }
        return {
          position: u.position,
          url: u.url,
          included: true,
          fetchOk: true,
          semantics,
        };
      },
    );

    // Garde-fou : fetch total en échec → aucune position analysable, on
    // s'arrête avant tout effet de bord (rien persisté, staging déjà vide ou
    // nettoyé en `finally`).
    const analyzedCount = pageInputs.filter(
      (p) => p.fetchOk && p.semantics,
    ).length;
    if (analyzedCount === 0) {
      return {
        ok: false,
        error: "aucune page de la SERP n'a pu être récupérée",
      };
    }

    // --- Client (gap analysis), même chemin fetch+extract -------------------
    let client: PageSemantics | null = null;
    if (payload.clientUrl) {
      const fetched = await deps.fetchHtml(payload.clientUrl);
      await emitFetch();
      if (fetched) {
        client = extractSemanticPage(fetched.html, fetched.finalUrl, config);
        await emitExtract();
      }
    }

    // --- Étape 5 : agrégation wasm — MÊME config -----------------------------
    const profile = buildSerpProfile(pageInputs, client, config);
    await deps.onProgress?.({ phase: "score", done: 1, total: 1 });

    // --- Étape 6 : post-traitement TS ----------------------------------------
    for (const pos of thinContentPositions) {
      profile.coverage.flags.push(`js_rendered_suspected:${pos}`);
    }
    for (const pos of poorContentPositions) {
      profile.coverage.flags.push(`thin_content_suspected:${pos}`);
    }
    for (const pos of langMismatchPositions) {
      profile.coverage.flags.push(`lang_mismatch:${pos}`);
    }
    for (const pos of renderedPositions) {
      profile.coverage.flags.push(`js_rendered:${pos}`);
    }
    const exports = resultKeys(
      siteId,
      payload.keywordId,
      payload.country,
      jobId,
    );
    const stored: StoredSemanticProfile = {
      ...profile,
      analyzedAt: deps.now(),
      jobId,
      exports,
    };

    // --- Étape 7 : exports R2 AVANT le merge DB ------------------------------
    await deps.putObject(
      exports.pagesKey,
      gzipText(buildPagesJsonl(pageInputs)),
    );
    await deps.putObject(
      exports.termsKey,
      gzipText(buildTermsJsonl(pageInputs)),
    );

    // Design D4 : une seule analyse conservée par locale — clés des exports
    // de la précédente (si elle existe) capturées ici (lecture de l'étape 1,
    // AVANT le merge) ; la suppression n'a lieu qu'après le merge réussi (voir
    // plus bas) pour ne jamais laisser un profil persisté pointer des exports
    // déjà supprimés.
    const previous = kwRow.semantic?.[payload.country];

    // --- Étape 8 : merge jsonb par-locale (jamais de read-modify-write) ------
    await deps.db
      .update(keywords)
      .set({
        semantic: sql`coalesce(${keywords.semantic}, '{}'::jsonb) || ${JSON.stringify({ [payload.country]: stored })}::jsonb`,
      })
      .where(eq(keywords.id, payload.keywordId));

    // La bascule DB a réussi : la référence pointe désormais vers les
    // nouveaux exports, on peut supprimer les anciens (best-effort).
    if (previous) {
      await deps.deleteObject(previous.exports.pagesKey).catch(() => {});
      await deps.deleteObject(previous.exports.termsKey).catch(() => {});
    }

    await deps.onProgress?.({ phase: "persist", done: 1, total: 1 });
    return { ok: true };
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e.message : String(e) };
  } finally {
    // --- Étape 9 : staging nettoyé best-effort, succès comme échec ----------
    await Promise.all(
      stagingKeys.map((k) => deps.deleteObject(k).catch(() => {})),
    );
  }
}
