// src/lib/semantic/analysis-runner.test.ts — cœur du pipeline serp-semantics
// contre le VRAI Postgres (section gated DB_TESTS=1, pattern crawl-runner).
// DI totale : fetchHtml/putObject/deleteObject/now injectés (fakes), wasm réel
// (extractSemanticPage/buildSerpProfile ne sont PAS mockés).
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { eq } from "drizzle-orm";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { organization } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { keywords, sites } from "@/lib/db/domain-schema";
import {
  type RunnerDeps,
  runSerpSemantics,
  type SemanticDb,
} from "./analysis-runner";
import type {
  SerpSemanticsPayload,
  StoredSemanticProfile,
} from "./profile-types";

const RUN = process.env.DB_TESTS === "1";

const ORG = "org-semrun-test";
const SITE = "site-semrun";
const KW = "site-semrun-kw";

// Le `db` de l'app (schéma large, générique invariant) n'est pas assignable à
// SemanticDb (schéma domaine) tel quel — cast au point d'injection (même
// instance Postgres, mêmes tables domaine touchées). Miroir de crawl-runner.test.ts.
const semanticDb = db as unknown as SemanticDb;

// repeat(25) : garde le wordCount (body+h1+h2h3+anchors, cf. T1) bien
// au-dessus de MIN_CONTENT_TOKENS (120, garde thin-HTML Task 3) pour que ces
// fixtures « normales » restent analysées comme avant.
function htmlPage(lang: string, title: string, body: string): string {
  return `<html lang="${lang}"><head><title>${title}</title></head><body><main><h1>${title}</h1><p>${body.repeat(25)}</p></main></body></html>`;
}

// Page thin AVEC `<title>` exploitable : ~16 mots, très en-dessous de
// MIN_CONTENT_TOKENS (120) — mirrors les shells constatés en QA (ootbjewelry
// ~20 mots, wholesalejewelrysupply ~7 mots) qui, contrairement à l'intuition
// initiale, ont quand même un `<title>` renseigné (constat prod, follow-up
// js_rendered_suspected, cf. .superpowers/sdd/followup-semantic-report.md).
// Flag honnête : `<title>` présent → `thin_content_suspected` (page pauvre),
// PAS `js_rendered_suspected` (réservé à l'absence de `<title>`, cf.
// `thinHtmlPageNoTitle` ci-dessous).
function thinHtmlPage(lang: string): string {
  return `<html lang="${lang}"><head><title>Accès refusé</title></head><body><h1>Vérification en cours</h1><p>Veuillez patienter pendant que nous vérifions votre navigateur avant de vous donner accès au site merci.</p></body></html>`;
}

// Coquille anti-bot/JS non rendue SANS aucun `<title>` exploitable — le cas
// où l'hypothèse JS/anti-bot reste plausible (fetch OK mais quasi rien, pas
// même un titre) → seule fixture qui doit encore produire
// `js_rendered_suspected` après le fix du flag honnête.
function thinHtmlPageNoTitle(lang: string): string {
  return `<html lang="${lang}"><body><h1>Vérification en cours</h1><p>Veuillez patienter pendant que nous vérifions votre navigateur avant de vous donner accès au site merci.</p></body></html>`;
}

const URL_1 = "https://semrun.example/produit-un";
const URL_2 = "https://semrun.example/produit-deux";
const URL_3_EN = "https://semrun.example/product-three"; // lang mismatch (EN sur mot-clé FR)
const URL_4_EXCLUDED = "https://semrun.example/exclue";
const URL_5_MISSING = "https://semrun.example/introuvable";

const HTML_BY_URL: Record<string, string> = {
  [URL_1]: htmlPage(
    "fr",
    "Bijoux acier un",
    "Bijoux en acier inoxydable pour bijoutiers. ",
  ),
  [URL_2]: htmlPage(
    "fr",
    "Bijoux acier deux",
    "Collier acier massif pour grossistes. ",
  ),
  [URL_3_EN]: htmlPage(
    "en",
    "Steel jewelry three",
    "Stainless steel jewelry for wholesalers. ",
  ),
};

function makeFetchHtml(
  fails: Set<string> = new Set(),
): RunnerDeps["fetchHtml"] {
  return async (url: string) => {
    if (fails.has(url)) return null;
    const html = HTML_BY_URL[url];
    if (!html) return null;
    return { html, finalUrl: url };
  };
}

function basePayload(country: string): SerpSemanticsPayload {
  return {
    keywordId: KW,
    country,
    urls: [
      { position: 1, url: URL_1, included: true },
      { position: 2, url: URL_2, included: true },
      { position: 3, url: URL_3_EN, included: true },
      { position: 4, url: URL_4_EXCLUDED, included: false },
      { position: 5, url: URL_5_MISSING, included: true },
    ],
  };
}

function makeDeps(overrides: Partial<RunnerDeps> = {}): {
  deps: RunnerDeps;
  putKeys: string[];
  deletedKeys: string[];
} {
  const putKeys: string[] = [];
  const deletedKeys: string[] = [];
  const deps: RunnerDeps = {
    db: semanticDb,
    fetchHtml: makeFetchHtml(new Set([URL_5_MISSING])),
    putObject: async (key) => {
      putKeys.push(key);
    },
    deleteObject: async (key) => {
      deletedKeys.push(key);
    },
    now: () => "2026-07-21T00:00:00.000Z",
    ...overrides,
  };
  return { deps, putKeys, deletedKeys };
}

/** Supprime uniquement le mot-clé — le site/l'org sont seedés une fois par
 *  `beforeAll` et réutilisés par tous les tests (chaque test reseed son
 *  propre mot-clé). */
async function cleanupKeyword(): Promise<void> {
  await db.delete(keywords).where(eq(keywords.id, KW));
}

async function cleanup(): Promise<void> {
  await cleanupKeyword();
  await db.delete(sites).where(eq(sites.id, SITE));
  await db.delete(organization).where(eq(organization.id, ORG));
}

async function seedKeyword(
  semantic: Record<string, StoredSemanticProfile> | null = null,
): Promise<void> {
  await db.insert(keywords).values({
    id: KW,
    siteId: SITE,
    term: "bijoux acier",
    lang: "fr",
    country: "FR",
    status: "idea",
    intent: [],
    primarySource: "manual",
    sources: ["manual"],
    metrics: {},
    trend: [],
    semantic: semantic ?? undefined,
  });
}

describe.skipIf(!RUN)("runSerpSemantics (postgres)", () => {
  beforeAll(async () => {
    await assertTestDb();
    await cleanup();
    await db.insert(organization).values({
      id: ORG,
      name: "semrun",
      slug: "semrun",
      createdAt: new Date(),
      trialEndsAt: new Date(Date.now() + 864e5),
    });
    await db.insert(sites).values({
      id: SITE,
      orgId: ORG,
      name: "semrun",
      url: "https://semrun.example",
      primaryLanguage: "fr",
      targetCountries: ["FR"],
    });
  });

  afterAll(async () => {
    await cleanup();
  });

  test("pipeline complet : profil persisté par-locale + exports écrits + staging nettoyé", async () => {
    await seedKeyword();
    const { deps, putKeys, deletedKeys } = makeDeps();

    const res = await runSerpSemantics("job-1", SITE, basePayload("FR"), deps);
    expect(res.ok).toBe(true);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    expect(stored?.jobId).toBe("job-1");
    expect(stored?.analyzedAt).toBe("2026-07-21T00:00:00.000Z");
    expect(stored?.coverage.excludedByUser).toEqual([4]);
    expect(stored?.coverage.missing).toContain(5); // fetch null
    expect(stored?.coverage.flags).toContain("lang_mismatch:3");
    // pages normales (>= MIN_CONTENT_TOKENS) inchangées par la garde thin-HTML
    expect(stored?.coverage.flags).not.toContain("js_rendered_suspected:1");
    expect(stored?.coverage.flags).not.toContain("js_rendered_suspected:2");
    expect(stored?.exports.termsKey).toContain("job-1/terms.jsonl.gz");
    expect(stored?.exports.pagesKey).toContain("job-1/pages.jsonl.gz");

    expect(putKeys.some((k) => k.endsWith("terms.jsonl.gz"))).toBe(true);
    expect(putKeys.some((k) => k.endsWith("pages.jsonl.gz"))).toBe(true);
    expect(putKeys.some((k) => k.includes("staging"))).toBe(true);
    expect(
      deletedKeys.filter((k) => k.includes("staging")).length,
    ).toBeGreaterThan(0);

    await cleanupKeyword();
  });

  test("merge par-locale : une analyse US ne détruit pas le profil FR existant", async () => {
    const previousFr: StoredSemanticProfile = {
      intent: { curve: "standard", mode: "inferred", mixedIntent: false },
      coverage: {
        analyzed: 1,
        total: 1,
        excludedByUser: [],
        missing: [],
        flags: [],
      },
      terms: [],
      ngrams: { bigrams: [], trigrams: [] },
      questions: [],
      structure: {
        wordCount: { p25: 0, median: 0, p75: 0 },
        h2Count: 0,
        lists: 0,
        tables: 0,
        schemaTypes: {},
        archetypes: {},
        top3VsTop10: {
          wordCountTop3: 0,
          wordCountTop10: 0,
          h2CountTop3: 0,
          h2CountTop10: 0,
        },
      },
      guide: {
        terms: [],
        ngrams: [],
        questions: [],
        objectives: {
          wordCount: { p25: 0, median: 0, p75: 0 },
          h2Count: 0,
          lists: 0,
          tables: 0,
        },
      },
      gap: null,
      analyzedAt: "2026-01-01T00:00:00.000Z",
      jobId: "job-fr-old",
      exports: {
        pagesKey: "serp-semantics-results/site/kw/FR/job-fr-old/pages.jsonl.gz",
        termsKey: "serp-semantics-results/site/kw/FR/job-fr-old/terms.jsonl.gz",
      },
    };
    await seedKeyword({ FR: previousFr });
    const { deps, deletedKeys } = makeDeps();

    const res = await runSerpSemantics(
      "job-us-1",
      SITE,
      basePayload("US"),
      deps,
    );
    expect(res.ok).toBe(true);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    expect(row?.semantic?.FR?.jobId).toBe("job-fr-old"); // intact
    expect(row?.semantic?.US?.jobId).toBe("job-us-1");
    // Pas de suppression : aucune analyse US précédente à ce locale.
    expect(deletedKeys.some((k) => k.includes("job-fr-old"))).toBe(false);

    await cleanupKeyword();
  });

  test("ré-analyse même locale : les anciens exports sont supprimés (design D4)", async () => {
    const oldExports = {
      pagesKey: "serp-semantics-results/site/kw/FR/job-old/pages.jsonl.gz",
      termsKey: "serp-semantics-results/site/kw/FR/job-old/terms.jsonl.gz",
    };
    const previousFr: StoredSemanticProfile = {
      intent: { curve: "standard", mode: "inferred", mixedIntent: false },
      coverage: {
        analyzed: 1,
        total: 1,
        excludedByUser: [],
        missing: [],
        flags: [],
      },
      terms: [],
      ngrams: { bigrams: [], trigrams: [] },
      questions: [],
      structure: {
        wordCount: { p25: 0, median: 0, p75: 0 },
        h2Count: 0,
        lists: 0,
        tables: 0,
        schemaTypes: {},
        archetypes: {},
        top3VsTop10: {
          wordCountTop3: 0,
          wordCountTop10: 0,
          h2CountTop3: 0,
          h2CountTop10: 0,
        },
      },
      guide: {
        terms: [],
        ngrams: [],
        questions: [],
        objectives: {
          wordCount: { p25: 0, median: 0, p75: 0 },
          h2Count: 0,
          lists: 0,
          tables: 0,
        },
      },
      gap: null,
      analyzedAt: "2026-01-01T00:00:00.000Z",
      jobId: "job-old",
      exports: oldExports,
    };
    await seedKeyword({ FR: previousFr });
    const { deps, deletedKeys } = makeDeps();

    const res = await runSerpSemantics(
      "job-new",
      SITE,
      basePayload("FR"),
      deps,
    );
    expect(res.ok).toBe(true);
    expect(deletedKeys).toContain(oldExports.pagesKey);
    expect(deletedKeys).toContain(oldExports.termsKey);

    await cleanupKeyword();
  });

  test("échec fetch total → ok:false, rien persisté, staging nettoyé", async () => {
    await seedKeyword();
    const { deps, putKeys, deletedKeys } = makeDeps({
      fetchHtml: async () => null,
    });

    const res = await runSerpSemantics(
      "job-fail",
      SITE,
      basePayload("FR"),
      deps,
    );
    expect(res.ok).toBe(false);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    expect(row?.semantic).toBeFalsy(); // rien persisté
    expect(putKeys.some((k) => k.includes("results"))).toBe(false); // pas d'exports écrits
    expect(deletedKeys.length).toBe(0); // rien n'a été staged → rien à nettoyer

    await cleanupKeyword();
  });

  test("mot-clé introuvable → ok:false, aucun effet de bord", async () => {
    const { deps, putKeys, deletedKeys } = makeDeps();
    const res = await runSerpSemantics(
      "job-nope",
      SITE,
      basePayload("FR"),
      deps,
    );
    expect(res.ok).toBe(false);
    expect(putKeys).toEqual([]);
    expect(deletedKeys).toEqual([]);
  });

  test("ordre delete-vs-merge (revue) : les anciens exports ne sont supprimés qu'après la bascule DB", async () => {
    const oldExports = {
      pagesKey:
        "serp-semantics-results/site/kw/FR/job-old-order/pages.jsonl.gz",
      termsKey:
        "serp-semantics-results/site/kw/FR/job-old-order/terms.jsonl.gz",
    };
    const previousFr: StoredSemanticProfile = {
      intent: { curve: "standard", mode: "inferred", mixedIntent: false },
      coverage: {
        analyzed: 1,
        total: 1,
        excludedByUser: [],
        missing: [],
        flags: [],
      },
      terms: [],
      ngrams: { bigrams: [], trigrams: [] },
      questions: [],
      structure: {
        wordCount: { p25: 0, median: 0, p75: 0 },
        h2Count: 0,
        lists: 0,
        tables: 0,
        schemaTypes: {},
        archetypes: {},
        top3VsTop10: {
          wordCountTop3: 0,
          wordCountTop10: 0,
          h2CountTop3: 0,
          h2CountTop10: 0,
        },
      },
      guide: {
        terms: [],
        ngrams: [],
        questions: [],
        objectives: {
          wordCount: { p25: 0, median: 0, p75: 0 },
          h2Count: 0,
          lists: 0,
          tables: 0,
        },
      },
      gap: null,
      analyzedAt: "2026-01-01T00:00:00.000Z",
      jobId: "job-old-order",
      exports: oldExports,
    };
    await seedKeyword({ FR: previousFr });

    // `deleteObject` espion : au moment où il reçoit une clé d'un ANCIEN
    // export, on relit la ligne `keywords` en base et on vérifie que la
    // bascule (merge DB) a DÉJÀ eu lieu — jobId déjà le nouveau. Si le runner
    // supprimait avant le merge (bug de revue), cette lecture verrait encore
    // `job-old-order` au moment du premier `deleteObject` d'ancien export.
    const deletedKeys: string[] = [];
    const observedJobIdsAtDelete: (string | undefined)[] = [];
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: makeFetchHtml(new Set([URL_5_MISSING])),
      putObject: async () => {},
      deleteObject: async (key) => {
        deletedKeys.push(key);
        if (key === oldExports.pagesKey || key === oldExports.termsKey) {
          const row = await db.query.keywords.findFirst({
            where: eq(keywords.id, KW),
          });
          observedJobIdsAtDelete.push(row?.semantic?.FR?.jobId);
        }
      },
      now: () => "2026-07-21T00:00:00.000Z",
    };

    const res = await runSerpSemantics(
      "job-new-order",
      SITE,
      basePayload("FR"),
      deps,
    );
    expect(res.ok).toBe(true);
    expect(deletedKeys).toContain(oldExports.pagesKey);
    expect(deletedKeys).toContain(oldExports.termsKey);
    expect(observedJobIdsAtDelete.length).toBeGreaterThan(0);
    expect(observedJobIdsAtDelete.every((j) => j === "job-new-order")).toBe(
      true,
    );

    await cleanupKeyword();
  });

  test("garde langue : sous-tag primaire identique (fr-CA vs fr) n'est pas un mismatch", async () => {
    await seedKeyword();
    const URL_FRCA = "https://semrun.example/produit-frca";
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        if (url === URL_FRCA) {
          return {
            html: htmlPage(
              "fr-CA",
              "Bijoux acier Québec",
              "Bijoux en acier inoxydable pour bijoutiers québécois. ",
            ),
            finalUrl: url,
          };
        }
        return null;
      },
      putObject: async () => {},
      deleteObject: async () => {},
      now: () => "2026-07-21T00:00:00.000Z",
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        { position: 2, url: URL_FRCA, included: true },
      ],
    };

    const res = await runSerpSemantics("job-langca", SITE, payload, deps);
    expect(res.ok).toBe(true);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    expect(stored?.coverage.flags).not.toContain("lang_mismatch:2");
    expect(stored?.coverage.missing).not.toContain(2);
    expect(stored?.coverage.analyzed).toBe(2);

    await cleanupKeyword();
  });

  test("garde thin-HTML/faux-200 (CDC §5) : page pauvre AVEC title exclue du profil (thin_content_suspected)", async () => {
    await seedKeyword();
    const URL_THIN = "https://semrun.example/coquille";
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        if (url === URL_THIN) {
          return { html: thinHtmlPage("fr"), finalUrl: url };
        }
        return null;
      },
      putObject: async () => {},
      deleteObject: async () => {},
      now: () => "2026-07-21T00:00:00.000Z",
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        { position: 2, url: URL_THIN, included: true },
      ],
    };

    const res = await runSerpSemantics("job-thin", SITE, payload, deps);
    expect(res.ok).toBe(true);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    // `<title>` présent (thinHtmlPage) → flag honnête "page pauvre", pas une
    // hypothèse de rendu JS (follow-up, cf. rapport dédié).
    expect(stored?.coverage.flags).toContain("thin_content_suspected:2");
    expect(stored?.coverage.flags).not.toContain("js_rendered_suspected:2");
    expect(stored?.coverage.missing).toContain(2);
    expect(stored?.coverage.analyzed).toBe(1); // seule la position 1 (>= 120 mots) compte

    await cleanupKeyword();
  });

  test("garde thin-HTML/faux-200 (CDC §5) : coquille anti-bot SANS title exclue du profil (js_rendered_suspected)", async () => {
    await seedKeyword();
    const URL_THIN_NOTITLE = "https://semrun.example/coquille-sans-title";
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        if (url === URL_THIN_NOTITLE) {
          return { html: thinHtmlPageNoTitle("fr"), finalUrl: url };
        }
        return null;
      },
      putObject: async () => {},
      deleteObject: async () => {},
      now: () => "2026-07-21T00:00:00.000Z",
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        { position: 2, url: URL_THIN_NOTITLE, included: true },
      ],
    };

    const res = await runSerpSemantics("job-thin-notitle", SITE, payload, deps);
    expect(res.ok).toBe(true);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    // Pas de `<title>` exploitable → hypothèse JS/anti-bot plausible, flag
    // conservé.
    expect(stored?.coverage.flags).toContain("js_rendered_suspected:2");
    expect(stored?.coverage.flags).not.toContain("thin_content_suspected:2");
    expect(stored?.coverage.missing).toContain(2);
    expect(stored?.coverage.analyzed).toBe(1);

    await cleanupKeyword();
  });

  test("garde thin-HTML/faux-200 : toutes les positions sont des coquilles → ok:false", async () => {
    await seedKeyword();
    const URL_THIN_A = "https://semrun.example/coquille-a";
    const URL_THIN_B = "https://semrun.example/coquille-b";
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_THIN_A || url === URL_THIN_B) {
          return { html: thinHtmlPage("fr"), finalUrl: url };
        }
        return null;
      },
      putObject: async () => {},
      deleteObject: async () => {},
      now: () => "2026-07-21T00:00:00.000Z",
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_THIN_A, included: true },
        { position: 2, url: URL_THIN_B, included: true },
      ],
    };

    const res = await runSerpSemantics("job-all-thin", SITE, payload, deps);
    expect(res.ok).toBe(false); // aucune page réellement analysable → guard 0-page existant

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    expect(row?.semantic).toBeFalsy(); // rien persisté

    await cleanupKeyword();
  });

  test("clamp + dédup : positions hors [1,10] et doublons ignorés", async () => {
    await seedKeyword();
    const { deps } = makeDeps();
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 0, url: "https://semrun.example/zero", included: true },
        { position: 1, url: URL_1, included: true },
        {
          position: 1,
          url: "https://semrun.example/doublon-un",
          included: true,
        }, // ignoré (1er gagne)
        { position: 2, url: URL_2, included: true },
        { position: 11, url: "https://semrun.example/onze", included: true },
      ],
    };
    const res = await runSerpSemantics("job-clamp", SITE, payload, deps);
    expect(res.ok).toBe(true);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    expect(stored?.coverage.total).toBe(2); // seules positions 1 et 2 retenues
    expect(stored?.coverage.analyzed).toBe(2);

    await cleanupKeyword();
  });

  test("fix A — repli de langue (CDC §3.7) : page FR sans <html lang> analysée via fallbackLang", async () => {
    await seedKeyword(); // lang: "fr"
    const URL_NOLANG = "https://semrun.example/sans-lang";
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        if (url === URL_NOLANG) {
          // Pas de `<html lang>` — cas QA « destruction nid de guêpes ».
          const body =
            "La destruction du nid de guêpes se fait dans le jardin avec un professionnel. ".repeat(
              25,
            );
          return {
            html: `<html><head><title>Nid de guêpes</title></head><body><main><h1>Nid de guêpes</h1><p>${body}</p></main></body></html>`,
            finalUrl: url,
          };
        }
        return null;
      },
      putObject: async () => {},
      deleteObject: async () => {},
      now: () => "2026-07-21T00:00:00.000Z",
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        { position: 2, url: URL_NOLANG, included: true },
      ],
    };

    const res = await runSerpSemantics("job-fallback-lang", SITE, payload, deps);
    expect(res.ok).toBe(true);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    // Pas de `lang` brut → pas de mismatch (le garde-fou langue ne se
    // déclenche que sur une langue non-vide différente du mot-clé).
    expect(stored?.coverage.flags).not.toContain("lang_mismatch:2");
    expect(stored?.coverage.analyzed).toBe(2);
    // Sans le fix A, la page sans `<html lang>` tombe en `Lang::Unsupported`
    // côté crate → aucun stop-word filtré → bigrammes poubelle comme
    // « dans le ». Avec `fallbackLang: "fr"`, ils disparaissent.
    expect(
      stored?.ngrams.bigrams.some((b) => b.text === "dans le"),
    ).toBe(false);

    await cleanupKeyword();
  });

  test("fallback de rendu (S7) : page thin + render riche → analysée, flag js_rendered:pos, staging du rendu", async () => {
    await seedKeyword();
    const URL_THIN_R = "https://semrun.example/coquille-rendue";
    const putKeys: string[] = [];
    const deletedKeys: string[] = [];
    let renderCalls = 0;
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        if (url === URL_THIN_R) {
          return { html: thinHtmlPage("fr"), finalUrl: url };
        }
        return null;
      },
      putObject: async (key) => {
        putKeys.push(key);
      },
      deleteObject: async (key) => {
        deletedKeys.push(key);
      },
      now: () => "2026-07-21T00:00:00.000Z",
      render: async (url) => {
        renderCalls++;
        if (url === URL_THIN_R) {
          return {
            html: htmlPage(
              "fr",
              "Bijoux acier rendu",
              "Bijoux en acier inoxydable rendus en JS pour bijoutiers. ",
            ),
            finalUrl: url,
          };
        }
        return null;
      },
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        { position: 2, url: URL_THIN_R, included: true },
      ],
    };

    const res = await runSerpSemantics("job-render-ok", SITE, payload, deps);
    expect(res.ok).toBe(true);
    expect(renderCalls).toBe(1);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    expect(stored?.coverage.flags).toContain("js_rendered:2");
    expect(stored?.coverage.flags).not.toContain("js_rendered_suspected:2");
    expect(stored?.coverage.flags).not.toContain("thin_content_suspected:2");
    expect(stored?.coverage.missing).not.toContain(2);
    expect(stored?.coverage.analyzed).toBe(2);
    expect(putKeys.some((k) => k.includes("pos-2-rendered.html.gz"))).toBe(
      true,
    );
    expect(
      deletedKeys.some((k) => k.includes("pos-2-rendered.html.gz")),
    ).toBe(true);

    await cleanupKeyword();
  });

  test("fallback de rendu (S7) : page pauvre AVEC title + render null → comportement 3bis inchangé (thin_content_suspected)", async () => {
    await seedKeyword();
    const URL_THIN_R = "https://semrun.example/coquille-rendu-null";
    let renderCalls = 0;
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        if (url === URL_THIN_R) {
          return { html: thinHtmlPage("fr"), finalUrl: url };
        }
        return null;
      },
      putObject: async () => {},
      deleteObject: async () => {},
      now: () => "2026-07-21T00:00:00.000Z",
      render: async () => {
        renderCalls++;
        return null;
      },
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        { position: 2, url: URL_THIN_R, included: true },
      ],
    };

    const res = await runSerpSemantics("job-render-null", SITE, payload, deps);
    expect(res.ok).toBe(true);
    expect(renderCalls).toBe(1);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    // `<title>` présent (thinHtmlPage) → flag honnête, même après échec du
    // rendu (comportement 3bis inchangé, cf. doc-comment de `runSerpSemantics`).
    expect(stored?.coverage.flags).toContain("thin_content_suspected:2");
    expect(stored?.coverage.flags).not.toContain("js_rendered_suspected:2");
    expect(stored?.coverage.flags).not.toContain("js_rendered:2");
    expect(stored?.coverage.missing).toContain(2);
    expect(stored?.coverage.analyzed).toBe(1);

    await cleanupKeyword();
  });

  test("fallback de rendu (S7) : coquille SANS title + render null → comportement 3bis inchangé (js_rendered_suspected)", async () => {
    await seedKeyword();
    const URL_THIN_NOTITLE_R =
      "https://semrun.example/coquille-sans-title-rendu-null";
    let renderCalls = 0;
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        if (url === URL_THIN_NOTITLE_R) {
          return { html: thinHtmlPageNoTitle("fr"), finalUrl: url };
        }
        return null;
      },
      putObject: async () => {},
      deleteObject: async () => {},
      now: () => "2026-07-21T00:00:00.000Z",
      render: async () => {
        renderCalls++;
        return null;
      },
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        { position: 2, url: URL_THIN_NOTITLE_R, included: true },
      ],
    };

    const res = await runSerpSemantics(
      "job-render-null-notitle",
      SITE,
      payload,
      deps,
    );
    expect(res.ok).toBe(true);
    expect(renderCalls).toBe(1);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    expect(stored?.coverage.flags).toContain("js_rendered_suspected:2");
    expect(stored?.coverage.flags).not.toContain("thin_content_suspected:2");
    expect(stored?.coverage.flags).not.toContain("js_rendered:2");
    expect(stored?.coverage.missing).toContain(2);
    expect(stored?.coverage.analyzed).toBe(1);

    await cleanupKeyword();
  });

  test("fallback de rendu (S7) : fetchHtml null (≤ maxRenderPosition) + render riche → analysée, flag js_rendered:pos, staging du rendu", async () => {
    await seedKeyword();
    const URL_FETCH_NULL_R = "https://semrun.example/fetch-null-rendu";
    const putKeys: string[] = [];
    const deletedKeys: string[] = [];
    let renderCalls = 0;
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        // URL_FETCH_NULL_R : fetch HTTP en échec (pas de coquille thin, un
        // `null` — cf. finding de revue : le fallback couvre aussi ce cas).
        return null;
      },
      putObject: async (key) => {
        putKeys.push(key);
      },
      deleteObject: async (key) => {
        deletedKeys.push(key);
      },
      now: () => "2026-07-21T00:00:00.000Z",
      render: async (url) => {
        renderCalls++;
        if (url === URL_FETCH_NULL_R) {
          return {
            html: htmlPage(
              "fr",
              "Bijoux acier fetch-null rendu",
              "Bijoux en acier inoxydable rendus en JS après échec du fetch. ",
            ),
            finalUrl: url,
          };
        }
        return null;
      },
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        { position: 2, url: URL_FETCH_NULL_R, included: true },
      ],
    };

    const res = await runSerpSemantics(
      "job-render-fetchnull-ok",
      SITE,
      payload,
      deps,
    );
    expect(res.ok).toBe(true);
    expect(renderCalls).toBe(1);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    expect(stored?.coverage.flags).toContain("js_rendered:2");
    expect(stored?.coverage.flags).not.toContain("js_rendered_suspected:2");
    expect(stored?.coverage.missing).not.toContain(2);
    expect(stored?.coverage.analyzed).toBe(2);
    expect(putKeys.some((k) => k.includes("pos-2-rendered.html.gz"))).toBe(
      true,
    );
    expect(
      deletedKeys.some((k) => k.includes("pos-2-rendered.html.gz")),
    ).toBe(true);

    await cleanupKeyword();
  });

  test("fallback de rendu (S7) : fetchHtml null + render null → position en missing, sans flag js_rendered*", async () => {
    await seedKeyword();
    const URL_FETCH_NULL_NORENDER = "https://semrun.example/fetch-null-sans-rendu";
    let renderCalls = 0;
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        // Fetch HTTP en échec, PAS de coquille thin — cas distinct du garde
        // thin-HTML (3bis), c'est la branche `semantics === null` du 3ter.
        return null;
      },
      putObject: async () => {},
      deleteObject: async () => {},
      now: () => "2026-07-21T00:00:00.000Z",
      render: async () => {
        renderCalls++;
        return null;
      },
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        { position: 2, url: URL_FETCH_NULL_NORENDER, included: true },
      ],
    };

    const res = await runSerpSemantics(
      "job-render-fetchnull-null",
      SITE,
      payload,
      deps,
    );
    expect(res.ok).toBe(true);
    expect(renderCalls).toBe(1);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    // Comportement pré-S7 inchangé : `missing`, mais SANS flag `js_rendered*`
    // (ni `_suspected`) — cette position n'a jamais été une coquille
    // thin-HTML, juste un fetch en échec dont le rendu n'a rien produit.
    expect(stored?.coverage.missing).toContain(2);
    expect(stored?.coverage.flags).not.toContain("js_rendered:2");
    expect(stored?.coverage.flags).not.toContain("js_rendered_suspected:2");
    expect(stored?.coverage.analyzed).toBe(1);

    await cleanupKeyword();
  });

  test("fallback de rendu (S7) : position > maxRenderPosition → aucune tentative de rendu (spy)", async () => {
    await seedKeyword();
    const URL_THIN_FAR = "https://semrun.example/coquille-loin";
    let renderCalls = 0;
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        if (url === URL_THIN_FAR) {
          return { html: thinHtmlPage("fr"), finalUrl: url };
        }
        return null;
      },
      putObject: async () => {},
      deleteObject: async () => {},
      now: () => "2026-07-21T00:00:00.000Z",
      render: async () => {
        renderCalls++;
        return null;
      },
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        // 6 > DEFAULT_MAX_RENDER_POSITION (5) : jamais de tentative de rendu.
        { position: 6, url: URL_THIN_FAR, included: true },
      ],
    };

    const res = await runSerpSemantics("job-render-far", SITE, payload, deps);
    expect(res.ok).toBe(true);
    expect(renderCalls).toBe(0);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    // `<title>` présent (thinHtmlPage) → flag honnête.
    expect(stored?.coverage.flags).toContain("thin_content_suspected:6");
    expect(stored?.coverage.flags).not.toContain("js_rendered_suspected:6");
    expect(stored?.coverage.flags).not.toContain("js_rendered:6");

    await cleanupKeyword();
  });

  test("fallback de rendu (S7) : deps.render absent → comportement actuel strict", async () => {
    await seedKeyword();
    const URL_THIN_NORENDER = "https://semrun.example/coquille-sans-render";
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        if (url === URL_THIN_NORENDER) {
          return { html: thinHtmlPage("fr"), finalUrl: url };
        }
        return null;
      },
      putObject: async () => {},
      deleteObject: async () => {},
      now: () => "2026-07-21T00:00:00.000Z",
      // `render` intentionnellement absent (undefined) — pas juste `null`.
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        { position: 2, url: URL_THIN_NORENDER, included: true },
      ],
    };

    const res = await runSerpSemantics(
      "job-render-absent",
      SITE,
      payload,
      deps,
    );
    expect(res.ok).toBe(true);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    // `<title>` présent (thinHtmlPage) → flag honnête.
    expect(stored?.coverage.flags).toContain("thin_content_suspected:2");
    expect(stored?.coverage.flags).not.toContain("js_rendered_suspected:2");
    expect(stored?.coverage.flags).not.toContain("js_rendered:2");
    expect(stored?.coverage.missing).toContain(2);

    await cleanupKeyword();
  });

  test("fallback de rendu (S7) : version rendue dans une autre langue → lang_mismatch s'applique", async () => {
    await seedKeyword(); // lang: "fr"
    const URL_THIN_ENRENDER = "https://semrun.example/coquille-rendue-en";
    const deps: RunnerDeps = {
      db: semanticDb,
      fetchHtml: async (url) => {
        if (url === URL_1) return { html: HTML_BY_URL[URL_1], finalUrl: url };
        if (url === URL_THIN_ENRENDER) {
          return { html: thinHtmlPage("fr"), finalUrl: url };
        }
        return null;
      },
      putObject: async () => {},
      deleteObject: async () => {},
      now: () => "2026-07-21T00:00:00.000Z",
      render: async (url) => {
        if (url === URL_THIN_ENRENDER) {
          return {
            html: htmlPage(
              "en",
              "Steel jewelry rendered",
              "Stainless steel jewelry rendered via JS for wholesalers. ",
            ),
            finalUrl: url,
          };
        }
        return null;
      },
    };
    const payload: SerpSemanticsPayload = {
      keywordId: KW,
      country: "FR",
      urls: [
        { position: 1, url: URL_1, included: true },
        { position: 2, url: URL_THIN_ENRENDER, included: true },
      ],
    };

    const res = await runSerpSemantics(
      "job-render-lang-mismatch",
      SITE,
      payload,
      deps,
    );
    expect(res.ok).toBe(true);

    const row = await db.query.keywords.findFirst({
      where: eq(keywords.id, KW),
    });
    const stored = row?.semantic?.FR;
    expect(stored?.coverage.flags).toContain("lang_mismatch:2");
    expect(stored?.coverage.flags).not.toContain("js_rendered:2");
    expect(stored?.coverage.flags).not.toContain("js_rendered_suspected:2");
    expect(stored?.coverage.flags).not.toContain("thin_content_suspected:2");
    expect(stored?.coverage.missing).toContain(2);
    expect(stored?.coverage.analyzed).toBe(1);

    await cleanupKeyword();
  });
});
