// src/lib/pages/actions-core.test.ts — actions du module Pages contre le VRAI
// Postgres (seo_insight_test, script `test` → DB_TESTS=1). Pattern sync.test.ts.
// La DI (`assertSite`/`enqueue`) est injectée depuis actions-core (server-only,
// jamais exposé au réseau — les wrappers "use server" d'actions.ts n'exposent
// aucun `deps`). next/cache stubé : revalidateTag/Path jettent hors d'un scope
// requête Next.
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";

mock.module("next/cache", () => ({
  revalidatePath: () => {},
  revalidateTag: () => {},
  unstable_cache: (fn: unknown) => fn,
}));

import { eq, inArray } from "drizzle-orm";
import type { EnqueueInput } from "@/lib/analytics/enqueue";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { organization } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import {
  analyticsJobs,
  keywords,
  sitePages,
  sites,
} from "@/lib/db/domain-schema";
import { getKeywordFacets, searchKeywords } from "@/lib/keywords/list-query";
import {
  fetchPageDetail,
  fetchSitePages,
  getActiveSiteCrawl,
  searchSitePages,
  setKeywordTargetPage,
  startSiteCrawl,
} from "./actions-core";

const ORG = "org-pgact-test";
const SITE_A = "site-pgact-a";
const SITE_B = "site-pgact-b";
const SITE_NOURL = "site-pgact-nourl";
const PG_A1 = "site-pgact-a-p1";
const PG_A2 = "site-pgact-a-p2";
const PG_B1 = "site-pgact-b-p1";
const KW_MAP = "site-pgact-a-kwmap";
const KW_FREE = "site-pgact-a-kwfree";
const JOB = "job-pgact-active";

const SITE_IDS = [SITE_A, SITE_B, SITE_NOURL];
const ASSERT_OK = async () => {};
const ENQUEUE_UNUSED: (i: EnqueueInput) => Promise<string> = async () => {
  throw new Error("enqueue should not be called");
};

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

async function cleanup() {
  await db.delete(analyticsJobs).where(inArray(analyticsJobs.siteId, SITE_IDS));
  await db.delete(keywords).where(inArray(keywords.siteId, SITE_IDS));
  await db.delete(sitePages).where(inArray(sitePages.siteId, SITE_IDS));
  await db.delete(sites).where(inArray(sites.id, SITE_IDS));
  await db.delete(organization).where(eq(organization.id, ORG));
}

describe.skipIf(!RUN)("pages actions-core (postgres)", () => {
  beforeAll(async () => {
    await assertTestDb();
    await cleanup();
    await db.insert(organization).values({
      id: ORG,
      name: "pgact",
      slug: "pgact",
      createdAt: new Date(),
    });
    await db.insert(sites).values([
      {
        id: SITE_A,
        orgId: ORG,
        name: "A",
        primaryLanguage: "fr",
        url: "https://a.example",
      },
      {
        id: SITE_B,
        orgId: ORG,
        name: "B",
        primaryLanguage: "fr",
        url: "https://b.example",
      },
      { id: SITE_NOURL, orgId: ORG, name: "NoUrl", primaryLanguage: "fr" },
    ]);
    await db.insert(sitePages).values([
      {
        id: PG_A1,
        siteId: SITE_A,
        url: "https://a.example/bague-or",
        status: 200,
        title: "Bague en or",
        metaDescription: null,
        h1: "Bague en or",
        h1Count: 1,
        wordCount: 500,
        issues: ["description-missing", "title-short"],
        crawledAt: new Date(),
      },
      {
        id: PG_A2,
        siteId: SITE_A,
        url: "https://a.example/collier",
        status: 200,
        title: "Collier argent premium longueur suffisante",
        h1: "Collier",
        h1Count: 1,
        wordCount: 800,
        issues: [],
        crawledAt: new Date(),
      },
      {
        id: PG_B1,
        siteId: SITE_B,
        url: "https://b.example/autre",
        status: 200,
        issues: [],
        crawledAt: new Date(),
      },
    ]);
    const baseKw = {
      lang: "fr",
      country: "FR",
      status: "idea" as const,
      intent: ["informational" as const],
      primarySource: "semrush" as const,
      sources: ["semrush" as const],
      metrics: {},
      trend: [],
    };
    await db.insert(keywords).values([
      { id: KW_MAP, siteId: SITE_A, term: "bague or", ...baseKw },
      { id: KW_FREE, siteId: SITE_A, term: "collier argent", ...baseKw },
    ]);
  });

  afterAll(cleanup);

  // --- Gates -------------------------------------------------------------
  test("gate: assertSite rejette → chaque action propage", async () => {
    const boom = async () => {
      throw new Error("forbidden");
    };
    await expect(
      startSiteCrawl(SITE_A, { assertSite: boom, enqueue: ENQUEUE_UNUSED }),
    ).rejects.toThrow("forbidden");
    await expect(
      fetchSitePages(SITE_A, {}, { assertSite: boom }),
    ).rejects.toThrow("forbidden");
    await expect(
      fetchPageDetail(SITE_A, PG_A1, { assertSite: boom }),
    ).rejects.toThrow("forbidden");
    await expect(
      setKeywordTargetPage(SITE_A, KW_MAP, PG_A1, { assertSite: boom }),
    ).rejects.toThrow("forbidden");
    await expect(
      searchSitePages(SITE_A, "b", { assertSite: boom }),
    ).rejects.toThrow("forbidden");
    await expect(
      getActiveSiteCrawl(SITE_A, { assertSite: boom }),
    ).rejects.toThrow("forbidden");
  });

  // --- startSiteCrawl ----------------------------------------------------
  test("startSiteCrawl refuse sans url (enqueue jamais appelé)", async () => {
    const res = await startSiteCrawl(SITE_NOURL, {
      assertSite: ASSERT_OK,
      enqueue: ENQUEUE_UNUSED,
    });
    expect(res).toEqual({ ok: false, error: "missing-url" });
  });

  test("startSiteCrawl refuse si un job site-crawl est actif", async () => {
    await db.insert(analyticsJobs).values({
      id: JOB,
      siteId: SITE_A,
      type: "site-crawl",
      status: "running",
    });
    const res = await startSiteCrawl(SITE_A, {
      assertSite: ASSERT_OK,
      enqueue: ENQUEUE_UNUSED,
    });
    expect(res).toEqual({ ok: false, error: "already-running" });
    await db.delete(analyticsJobs).where(eq(analyticsJobs.id, JOB));
  });

  test("startSiteCrawl happy: enqueue reçoit {type,siteId} → jobId", async () => {
    const calls: EnqueueInput[] = [];
    const res = await startSiteCrawl(SITE_A, {
      assertSite: ASSERT_OK,
      enqueue: async (input) => {
        calls.push(input);
        return "job-created-1";
      },
    });
    expect(res).toEqual({ ok: true, jobId: "job-created-1" });
    expect(calls).toEqual([{ type: "site-crawl", siteId: SITE_A }]);
  });

  // --- getActiveSiteCrawl ------------------------------------------------
  test("getActiveSiteCrawl: job queued trouvé, done → null, autre site → null", async () => {
    const JOB_QUEUED = "job-pgact-getactive-queued";
    const JOB_DONE = "job-pgact-getactive-done";
    await db.insert(analyticsJobs).values([
      { id: JOB_QUEUED, siteId: SITE_A, type: "site-crawl", status: "queued" },
      { id: JOB_DONE, siteId: SITE_B, type: "site-crawl", status: "done" },
    ]);
    try {
      // SITE_A a un job queued → rattachable.
      expect(
        await getActiveSiteCrawl(SITE_A, { assertSite: ASSERT_OK }),
      ).toEqual({ jobId: JOB_QUEUED });
      // SITE_B n'a qu'un job done → rien à rattacher.
      expect(
        await getActiveSiteCrawl(SITE_B, { assertSite: ASSERT_OK }),
      ).toBeNull();
      // SITE_NOURL n'a aucun job → null.
      expect(
        await getActiveSiteCrawl(SITE_NOURL, { assertSite: ASSERT_OK }),
      ).toBeNull();
    } finally {
      await db
        .delete(analyticsJobs)
        .where(inArray(analyticsJobs.id, [JOB_QUEUED, JOB_DONE]));
    }
  });

  // --- fetchSitePages / detail / picker ----------------------------------
  test("fetchSitePages: keywordCount, filtre issue, recherche, tri issues d'abord", async () => {
    // KW_MAP pointe PG_A1 → keywordCount 1 sur PG_A1.
    await setKeywordTargetPage(SITE_A, KW_MAP, PG_A1, {
      assertSite: ASSERT_OK,
    });

    const all = await fetchSitePages(SITE_A, {}, { assertSite: ASSERT_OK });
    expect(all.map((p) => p.id)).toEqual([PG_A1, PG_A2]); // PG_A1 a 2 issues → en tête
    const a1 = all.find((p) => p.id === PG_A1);
    expect(a1?.keywordCount).toBe(1);
    expect(a1?.titleLength).toBe("Bague en or".length);
    expect(a1?.descriptionLength).toBe(0);
    expect(typeof a1?.crawledAt).toBe("string");

    const byIssue = await fetchSitePages(
      SITE_A,
      { issue: "title-short" },
      { assertSite: ASSERT_OK },
    );
    expect(byIssue.map((p) => p.id)).toEqual([PG_A1]);

    const byQ = await fetchSitePages(
      SITE_A,
      { q: "collier" },
      { assertSite: ASSERT_OK },
    );
    expect(byQ.map((p) => p.id)).toEqual([PG_A2]);

    // Cleanup pour ne pas polluer les tests de mapping suivants.
    await setKeywordTargetPage(SITE_A, KW_MAP, null, { assertSite: ASSERT_OK });
  });

  test("fetchPageDetail: métas + kw assignés ; null hors site", async () => {
    await setKeywordTargetPage(SITE_A, KW_MAP, PG_A1, {
      assertSite: ASSERT_OK,
    });
    const detail = await fetchPageDetail(SITE_A, PG_A1, {
      assertSite: ASSERT_OK,
    });
    expect(detail?.page.wordCount).toBe(500);
    expect(detail?.keywords).toEqual([
      { id: KW_MAP, term: "bague or", source: "semrush" },
    ]);
    // PG_B1 appartient à SITE_B → depuis SITE_A, null (tenancy).
    expect(
      await fetchPageDetail(SITE_A, PG_B1, { assertSite: ASSERT_OK }),
    ).toBeNull();
    await setKeywordTargetPage(SITE_A, KW_MAP, null, { assertSite: ASSERT_OK });
  });

  test("searchSitePages: picker filtré par url/title", async () => {
    const res = await searchSitePages(SITE_A, "bague", {
      assertSite: ASSERT_OK,
    });
    expect(res.map((p) => p.id)).toEqual([PG_A1]);
  });

  // --- setKeywordTargetPage: manual set/clear + tenancy croisée -----------
  test("setKeywordTargetPage: assignation manuelle (source manual, score null)", async () => {
    const res = await setKeywordTargetPage(SITE_A, KW_MAP, PG_A1, {
      assertSite: ASSERT_OK,
    });
    expect(res).toEqual({ ok: true });
    const [row] = await db
      .select({
        pid: keywords.targetPageId,
        src: keywords.targetPageSource,
        score: keywords.targetPageScore,
      })
      .from(keywords)
      .where(eq(keywords.id, KW_MAP));
    expect(row).toEqual({ pid: PG_A1, src: "manual", score: null });
  });

  test("setKeywordTargetPage: clear (null) → tout NULL", async () => {
    await setKeywordTargetPage(SITE_A, KW_MAP, PG_A1, {
      assertSite: ASSERT_OK,
    });
    const res = await setKeywordTargetPage(SITE_A, KW_MAP, null, {
      assertSite: ASSERT_OK,
    });
    expect(res).toEqual({ ok: true });
    const [row] = await db
      .select({
        pid: keywords.targetPageId,
        src: keywords.targetPageSource,
      })
      .from(keywords)
      .where(eq(keywords.id, KW_MAP));
    expect(row).toEqual({ pid: null, src: null });
  });

  test("setKeywordTargetPage: page d'un autre site refusée (tenancy croisée)", async () => {
    const res = await setKeywordTargetPage(SITE_A, KW_MAP, PG_B1, {
      assertSite: ASSERT_OK,
    });
    expect(res).toEqual({ ok: false });
    // KW_MAP reste non assigné.
    const [row] = await db
      .select({ pid: keywords.targetPageId })
      .from(keywords)
      .where(eq(keywords.id, KW_MAP));
    expect(row?.pid).toBeNull();
  });

  test("setKeywordTargetPage: kw d'un autre site refusé", async () => {
    // KW_MAP appartient à SITE_A ; depuis SITE_B → refus.
    const res = await setKeywordTargetPage(SITE_B, KW_MAP, PG_B1, {
      assertSite: ASSERT_OK,
    });
    expect(res).toEqual({ ok: false });
  });

  // --- Exposition réservoir (LEFT JOIN) + facette noTargetPage -----------
  test("réservoir: LEFT JOIN expose targetPage {url, source, score}", async () => {
    await setKeywordTargetPage(SITE_A, KW_MAP, PG_A1, {
      assertSite: ASSERT_OK,
    });
    const page = await searchKeywords(SITE_A, {
      filter: {},
      sort: { field: "term", desc: false },
      limit: 100,
    });
    const mapped = page.rows.find((r) => r.id === KW_MAP);
    const free = page.rows.find((r) => r.id === KW_FREE);
    expect(mapped?.targetPage).toEqual({
      url: "https://a.example/bague-or",
      source: "manual",
      score: null,
    });
    expect(free?.targetPage).toBeNull();
  });

  test("réservoir: filtre noTargetPage ne garde que les kw sans page", async () => {
    // KW_MAP est mappé (test précédent), KW_FREE non.
    const page = await searchKeywords(SITE_A, {
      filter: { noTargetPage: true },
      sort: { field: "term", desc: false },
      limit: 100,
    });
    const ids = page.rows.map((r) => r.id);
    expect(ids).toContain(KW_FREE);
    expect(ids).not.toContain(KW_MAP);
    expect(page.total).toBe(1);
  });

  test("facettes intactes malgré le LEFT JOIN (getKeywordFacets)", async () => {
    const facets = await getKeywordFacets(SITE_A);
    expect(facets.total).toBe(2);
    expect(facets.sources).toContain("semrush");
    expect(facets.languages).toContain("fr");
  });
});
