// src/lib/pages/crawl-runner.test.ts — le cœur orchestrable du job site-crawl
// contre le VRAI Postgres (section gated DB_TESTS=1, pattern seranking/sync).
// DI totale : fetchText (sitemap) / fetchHtml (pages) / parseMeta injectés.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { eq, inArray } 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, sitePages, sites } from "@/lib/db/domain-schema";
import type { FetchHtml } from "@/lib/keywords/onpage-fetch";
import { type CrawlDb, runSiteCrawl } from "./crawl-runner";
import type { FetchText } from "./sitemap";
import type { PageMeta } from "./types";

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

const ORG = "org-pgcrawl-test";
const SITE = "site-pgcrawl";
const SITE_URL = "https://pgcrawl.example";
const ORIGIN = SITE_URL;

const URL_WIDGET = `${ORIGIN}/widget-bleu`;
const URL_CHAUSSURE = `${ORIGIN}/chaussure-rouge`;
const URL_DEAD = `${ORIGIN}/page-morte`;

const SITEMAP_XML = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>${URL_WIDGET}</loc></url>
  <url><loc>${URL_CHAUSSURE}</loc></url>
  <url><loc>${URL_DEAD}</loc></url>
</urlset>`;

const META: Record<string, PageMeta> = {
  [URL_WIDGET]: {
    title: "Widget Bleu Officiel",
    metaDescription:
      "Une description de widget bleu suffisamment longue pour rester saine.",
    h1: "Widget Bleu",
    h1Count: 1,
    canonical: URL_WIDGET,
    robots: null,
    lang: "fr",
    wordCount: 320,
  },
  [URL_CHAUSSURE]: {
    // title/desc absents → issues title-missing + description-missing.
    title: null,
    metaDescription: null,
    h1: "Chaussure Rouge",
    h1Count: 1,
    canonical: null,
    robots: null,
    lang: "fr",
    wordCount: 40,
  },
};

const fetchText: FetchText = async (url) =>
  url === `${ORIGIN}/sitemap.xml` ? SITEMAP_XML : null;

const fetchHtml: FetchHtml = async (url) => {
  if (url === URL_DEAD) return null; // fetch raté → status 0
  const m = META[url];
  return m ? JSON.stringify(m) : null;
};

const parseMeta = (html: string): PageMeta => JSON.parse(html) as PageMeta;

const KW_GSC = `${SITE}-kw-gsc`; // gsc.page → priorité sur le texte
const KW_TEXT = `${SITE}-kw-text`; // matché par le slug
const KW_MANUAL = `${SITE}-kw-manual`; // assignation manuelle protégée
const KW_UNMAPPED = `${SITE}-kw-unmapped`; // aucun signal → gap
const KW_IDS = [KW_GSC, KW_TEXT, KW_MANUAL, KW_UNMAPPED];

function kwRow(
  id: string,
  term: string,
  gscPage: string | null,
): typeof keywords.$inferInsert {
  return {
    id,
    siteId: SITE,
    term,
    lang: "fr",
    country: "FR",
    status: "idea",
    intent: [],
    primarySource: "manual",
    sources: ["manual"],
    metrics: {},
    trend: [],
    gsc: gscPage
      ? {
          clicks: 0,
          impressions: 0,
          ctr: 0,
          position: 0,
          updatedAt: "2026-07-01",
          page: gscPage,
        }
      : null,
  };
}

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

// Le `db` de l'app (schéma large, générique invariant) n'est pas assignable à
// CrawlDb (schéma domaine) tel quel — cast au point d'injection (même instance
// Postgres, seules les tables domaine sont touchées).
const crawlDb = db as unknown as CrawlDb;

const deps = () => ({ db: crawlDb, fetchText, fetchHtml, parseMeta });

describe.skipIf(!RUN)("runSiteCrawl (postgres)", () => {
  beforeAll(async () => {
    await assertTestDb();
    await cleanup();
    await db.insert(organization).values({
      id: ORG,
      name: "pgcrawl",
      slug: "pgcrawl",
      createdAt: new Date(),
      trialEndsAt: new Date(Date.now() + 864e5),
    });
    await db.insert(sites).values({
      id: SITE,
      orgId: ORG,
      name: "pgcrawl",
      url: SITE_URL,
      primaryLanguage: "fr",
      targetCountries: ["FR"],
    });
    await db.insert(keywords).values([
      // gsc pointe vers /chaussure-rouge alors que le slug "widget bleu" matche
      // /widget-bleu → prouve que gsc l'emporte sur le texte.
      kwRow(KW_GSC, "widget bleu", URL_CHAUSSURE),
      kwRow(KW_TEXT, "chaussure rouge", null),
      kwRow(KW_MANUAL, "libellé sans slug crawlé", null),
      kwRow(KW_UNMAPPED, "terme totalement introuvable zzz", null),
    ]);
  });

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

  test("crawl : 3 pages upsertées, status 0 sur l'échec, issues calculées", async () => {
    const totals = await runSiteCrawl(SITE, deps());
    expect(totals.phase).toBe("done");
    expect(totals.pages).toBe(3);
    expect(totals.errors).toBe(1);
    expect(totals.total).toBe(3);

    const rows = await db
      .select()
      .from(sitePages)
      .where(eq(sitePages.siteId, SITE));
    expect(rows).toHaveLength(3);

    const byUrl = new Map(rows.map((r) => [r.url, r]));
    const widget = byUrl.get(URL_WIDGET);
    const chaussure = byUrl.get(URL_CHAUSSURE);
    const dead = byUrl.get(URL_DEAD);

    expect(widget?.status).toBe(200);
    expect(widget?.issues).toEqual([]); // page saine
    expect(widget?.wordCount).toBe(320);

    expect(chaussure?.status).toBe(200);
    expect(chaussure?.issues).toContain("title-missing");
    expect(chaussure?.issues).toContain("description-missing");

    expect(dead?.status).toBe(0);
    expect(dead?.issues).toContain("error");
  });

  test("mapping : gsc prioritaire sur le texte, texte, et gap non mappé", async () => {
    // (le crawl du test précédent a déjà écrit le mapping)
    const rows = await db
      .select({
        id: keywords.id,
        targetPageId: keywords.targetPageId,
        targetPageSource: keywords.targetPageSource,
        targetPageScore: keywords.targetPageScore,
      })
      .from(keywords)
      .where(inArray(keywords.id, KW_IDS));
    const byId = new Map(rows.map((r) => [r.id, r]));

    const pages = await db
      .select({ id: sitePages.id, url: sitePages.url })
      .from(sitePages)
      .where(eq(sitePages.siteId, SITE));
    const pageIdByUrl = new Map(pages.map((p) => [p.url, p.id]));

    // gsc gagne : cible = /chaussure-rouge (la page GSC), source gsc.
    const gsc = byId.get(KW_GSC);
    expect(gsc?.targetPageSource).toBe("gsc");
    expect(gsc?.targetPageId).toBe(pageIdByUrl.get(URL_CHAUSSURE));
    expect(gsc?.targetPageScore).toBeNull();

    // texte : slug "chaussure rouge" dans le path → /chaussure-rouge.
    const text = byId.get(KW_TEXT);
    expect(text?.targetPageSource).toBe("text");
    expect(text?.targetPageId).toBe(pageIdByUrl.get(URL_CHAUSSURE));
    expect(text?.targetPageScore).toBeGreaterThanOrEqual(50);

    // gap : aucun signal → NULL.
    const gap = byId.get(KW_UNMAPPED);
    expect(gap?.targetPageId).toBeNull();
    expect(gap?.targetPageSource).toBeNull();
  });

  test("re-crawl : idempotent (pas de doublon) et manual préservé", async () => {
    // On fixe une assignation manuelle vers /widget-bleu.
    const [widget] = await db
      .select({ id: sitePages.id })
      .from(sitePages)
      .where(eq(sitePages.url, URL_WIDGET));
    expect(widget).toBeDefined();
    await db
      .update(keywords)
      .set({
        targetPageId: widget.id,
        targetPageSource: "manual",
        targetPageScore: null,
      })
      .where(eq(keywords.id, KW_MANUAL));

    const totals = await runSiteCrawl(SITE, deps());
    expect(totals.pages).toBe(3);

    // Toujours 3 pages, pas de doublon.
    const rows = await db
      .select({ id: sitePages.id })
      .from(sitePages)
      .where(eq(sitePages.siteId, SITE));
    expect(rows).toHaveLength(3);

    // Manual intact malgré le re-crawl.
    const [manual] = await db
      .select({
        targetPageId: keywords.targetPageId,
        targetPageSource: keywords.targetPageSource,
      })
      .from(keywords)
      .where(eq(keywords.id, KW_MANUAL));
    expect(manual.targetPageSource).toBe("manual");
    expect(manual.targetPageId).toBe(widget.id);
  });

  test("pas de sitemap → job done pages=0 + message", async () => {
    const totals = await runSiteCrawl(SITE, {
      db: crawlDb,
      fetchText: async () => null, // ni robots ni sitemap.xml
      fetchHtml,
      parseMeta,
    });
    expect(totals.phase).toBe("done");
    expect(totals.pages).toBe(0);
    expect(totals.message).toBe("Aucun sitemap trouvé");
  });

  test("site.url absente → throw explicite", async () => {
    const NOURL = "site-pgcrawl-nourl";
    await db.insert(sites).values({
      id: NOURL,
      orgId: ORG,
      name: "no-url",
      primaryLanguage: "fr",
      targetCountries: ["FR"],
    });
    try {
      await expect(runSiteCrawl(NOURL, deps())).rejects.toThrow(/site\.url/);
    } finally {
      await db.delete(sites).where(eq(sites.id, NOURL));
    }
  });

  test("clear concurrent : un manual posé PENDANT le crawl n'est pas effacé", async () => {
    // KW_UNMAPPED est un gap (aucun signal) → il entre dans la liste `clears`,
    // non-manual au moment du SELECT. On simule une assignation manuelle qui
    // atterrit APRÈS le SELECT mais AVANT l'application du clear, via un proxy
    // qui injecte l'écriture concurrente juste avant l'exécution de l'UPDATE de
    // clear. Sans le garde SQL, le clear nullifierait ce manual (régression).
    const [widget] = await db
      .select({ id: sitePages.id })
      .from(sitePages)
      .where(eq(sitePages.url, URL_WIDGET));
    expect(widget).toBeDefined();

    // Point de départ propre : KW_UNMAPPED non-manual (gap réel au SELECT).
    await db
      .update(keywords)
      .set({
        targetPageId: null,
        targetPageSource: null,
        targetPageScore: null,
      })
      .where(eq(keywords.id, KW_UNMAPPED));

    let injected = false;
    // L'écriture concurrente (manual) est posée une seule fois, au moment où
    // l'UPDATE de clear (set targetPageSource:null) est sur le point de partir.
    const injectManual = async (): Promise<void> => {
      if (injected) return;
      injected = true;
      await db
        .update(keywords)
        .set({
          targetPageId: widget.id,
          targetPageSource: "manual",
          targetPageScore: null,
        })
        .where(eq(keywords.id, KW_UNMAPPED));
    };

    const wrap = <T extends object>(obj: T, handler: ProxyHandler<T>): T =>
      new Proxy(obj, handler);
    const bind = (v: unknown, target: object): unknown =>
      typeof v === "function"
        ? (v as (...a: unknown[]) => unknown).bind(target)
        : v;

    // Proxy ciblé : seule `db.update(keywords)` HORS transaction (= la passe de
    // clear) traverse ce chemin ; la passe `updates` passe par `tx.update`.
    const racingDb = wrap(crawlDb as unknown as object, {
      get(target, prop) {
        if (prop === "update") {
          return (table: unknown) => {
            const builder = (
              target as { update: (t: unknown) => object }
            ).update(table);
            return wrap(builder, {
              get(b, p) {
                if (p === "set") {
                  return (values: Record<string, unknown>) => {
                    const setB = (b as { set: (v: unknown) => object }).set(
                      values,
                    );
                    const isClear =
                      "targetPageSource" in values &&
                      values.targetPageSource === null;
                    if (!isClear) return setB;
                    return wrap(setB, {
                      get(sb, sp) {
                        if (sp === "where") {
                          return async (cond: unknown) => {
                            await injectManual();
                            return (
                              sb as { where: (c: unknown) => unknown }
                            ).where(cond);
                          };
                        }
                        return bind(
                          (sb as Record<PropertyKey, unknown>)[sp],
                          sb,
                        );
                      },
                    });
                  };
                }
                return bind((b as Record<PropertyKey, unknown>)[p], b);
              },
            });
          };
        }
        return bind((target as Record<PropertyKey, unknown>)[prop], target);
      },
    }) as unknown as CrawlDb;

    const totals = await runSiteCrawl(SITE, {
      db: racingDb,
      fetchText,
      fetchHtml,
      parseMeta,
    });
    expect(totals.phase).toBe("done");
    expect(injected).toBe(true); // la course a bien été jouée

    // Le manual concurrent a survécu au clear (garde SQL is distinct from manual).
    const [kw] = await db
      .select({
        targetPageId: keywords.targetPageId,
        targetPageSource: keywords.targetPageSource,
      })
      .from(keywords)
      .where(eq(keywords.id, KW_UNMAPPED));
    expect(kw.targetPageSource).toBe("manual");
    expect(kw.targetPageId).toBe(widget.id);
  });
});

describe.skipIf(!RUN)("runSiteCrawl — parseMeta throw confiné", () => {
  const PSITE = "site-pgcrawl-parse";
  const PORG = "org-pgcrawl-parse";
  const PORIGIN = "https://parsethrow.example";
  const P_OK1 = `${PORIGIN}/ok-un`;
  const P_BAD = `${PORIGIN}/panique`;
  const P_OK2 = `${PORIGIN}/ok-deux`;

  const pXml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>${P_OK1}</loc></url>
  <url><loc>${P_BAD}</loc></url>
  <url><loc>${P_OK2}</loc></url>
</urlset>`;

  const healthyMeta = (canonical: string): PageMeta => ({
    title: "Page saine officielle",
    metaDescription:
      "Une description suffisamment longue pour ne déclencher aucune issue.",
    h1: "Page saine",
    h1Count: 1,
    canonical,
    robots: null,
    lang: "fr",
    wordCount: 300,
  });

  const pFetchText: FetchText = async (url) =>
    url === `${PORIGIN}/sitemap.xml` ? pXml : null;
  // Les 3 URLs renvoient du HTML : c'est parseMeta qui panique sur P_BAD.
  const pFetchHtml: FetchHtml = async (url) => `<html>${url}</html>`;
  const pParseMeta = (html: string): PageMeta => {
    if (html.includes(P_BAD)) {
      throw new Error("panic wasm simulé sur parse_page_meta");
    }
    return healthyMeta(html.includes(P_OK1) ? P_OK1 : P_OK2);
  };

  async function pCleanup(): Promise<void> {
    await db.delete(sitePages).where(eq(sitePages.siteId, PSITE));
    await db.delete(sites).where(eq(sites.id, PSITE));
    await db.delete(organization).where(eq(organization.id, PORG));
  }

  beforeAll(async () => {
    await assertTestDb();
    await pCleanup();
    await db.insert(organization).values({
      id: PORG,
      name: "parse",
      slug: "parse",
      createdAt: new Date(),
      trialEndsAt: new Date(Date.now() + 864e5),
    });
    await db.insert(sites).values({
      id: PSITE,
      orgId: PORG,
      name: "parse",
      url: PORIGIN,
      primaryLanguage: "fr",
      targetCountries: ["FR"],
    });
  });

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

  test("parseMeta throw sur 1 page → job done, page fautive status 0, autres saines", async () => {
    // Le panic wasm d'une page ne doit PAS rejeter le Promise.all : le job
    // reste vert, la page fautive tombe en status 0 (comme un fetch raté).
    const totals = await runSiteCrawl(PSITE, {
      db: crawlDb,
      fetchText: pFetchText,
      fetchHtml: pFetchHtml,
      parseMeta: pParseMeta,
    });
    expect(totals.phase).toBe("done");
    expect(totals.pages).toBe(3);
    expect(totals.errors).toBe(1); // uniquement la page qui a paniqué

    const rows = await db
      .select()
      .from(sitePages)
      .where(eq(sitePages.siteId, PSITE));
    const byUrl = new Map(rows.map((r) => [r.url, r]));

    const bad = byUrl.get(P_BAD);
    expect(bad?.status).toBe(0);
    expect(bad?.issues).toContain("error");

    const ok1 = byUrl.get(P_OK1);
    const ok2 = byUrl.get(P_OK2);
    expect(ok1?.status).toBe(200);
    expect(ok1?.issues).toEqual([]);
    expect(ok2?.status).toBe(200);
    expect(ok2?.issues).toEqual([]);
  });
});

describe.skipIf(!RUN)("runSiteCrawl — cap/troncature", () => {
  const TSITE = "site-pgcrawl-trunc";
  const TORG = "org-pgcrawl-trunc";
  const TORIGIN = "https://trunc.example";
  const N = 5001; // > SITEMAP_URL_CAP (5000)

  const truncXml = `<?xml version="1.0"?><urlset>${Array.from(
    { length: N },
    (_, i) => `<url><loc>${TORIGIN}/p${i}</loc></url>`,
  ).join("")}</urlset>`;

  async function tCleanup(): Promise<void> {
    await db.delete(sitePages).where(eq(sitePages.siteId, TSITE));
    await db.delete(sites).where(eq(sites.id, TSITE));
    await db.delete(organization).where(eq(organization.id, TORG));
  }

  beforeAll(async () => {
    await assertTestDb();
    await tCleanup();
    await db.insert(organization).values({
      id: TORG,
      name: "trunc",
      slug: "trunc",
      createdAt: new Date(),
      trialEndsAt: new Date(Date.now() + 864e5),
    });
    await db.insert(sites).values({
      id: TSITE,
      orgId: TORG,
      name: "trunc",
      url: TORIGIN,
      primaryLanguage: "fr",
      targetCountries: ["FR"],
    });
  });

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

  test("cap 5000 + truncated propagé dans les totals", async () => {
    const totals = await runSiteCrawl(TSITE, {
      db: crawlDb,
      fetchText: async (url) =>
        url === `${TORIGIN}/sitemap.xml` ? truncXml : null,
      fetchHtml: async () => null, // toutes en échec → status 0, rapide
      parseMeta,
    });
    expect(totals.truncated).toBe(true);
    expect(totals.pages).toBe(5000);
    expect(totals.total).toBe(5000);
  });
});
