// Integration test — gated on DB_TESTS=1, run against the *_test DB only.
import { beforeEach, describe, expect, mock, test } from "bun:test";
import * as wasm from "import-core";
import type { SearchIntent } from "./types";

// actions/queries import "server-only" (throws outside Next) — stub it.
mock.module("server-only", () => ({}));
// next/cache primitives throw outside a Next request scope — stub them.
// `unstable_cache` (used by getCachedKeywordOverview) is stubbed as a
// pass-through so the wrapped read runs directly against the test DB.
mock.module("next/cache", () => ({
  revalidatePath: () => {},
  revalidateTag: () => {},
  updateTag: () => {},
  unstable_cache:
    (fn: (...args: unknown[]) => unknown) =>
    (...args: unknown[]) =>
      fn(...args),
}));
// `after` (next/server) schedules post-response work outside a request scope in
// tests — stub it to a no-op so addKeywordAction's best-effort enrich enqueue
// (I1) neither runs nor throws here; it's background/best-effort, not under test.
mock.module("next/server", () => ({
  after: () => {},
}));
// the tenancy guard needs a session (headers()) — stub it; site-scoping is
// covered at the SQL level by these tests and by assert-site.test.ts.
mock.module("@/lib/sites/assert-site", () => ({
  siteBelongsToOrg: async () => true,
  assertSiteInActiveOrg: async () => {},
  assertKeywordInActiveOrg: async () => "mock-site",
  assertContentItemInActiveOrg: async () => "mock-site",
}));

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

// No DB needed: an empty siteId (org with zero sites) must resolve to an empty
// result BEFORE the tenancy guard / any query — a legitimate state, not a 500.
describe("read actions tolerate an empty siteId (no active site)", () => {
  test("fetchThemes/Page/Facets/ByFilter return empty, never throw", async () => {
    const actions = await import("./actions");
    expect(await actions.fetchThemesForSiteAction("")).toEqual([]);
    expect(await actions.fetchKeywordsByFilterAction("", {})).toEqual([]);
    expect(await actions.fetchKeywordFacetsAction("")).toEqual({
      languages: [],
      countries: [],
      sources: [],
      cpcRange: null,
      total: 0,
      quickwinCount: 0,
    });
    const page = await actions.fetchKeywordsPageAction("", {
      filter: {},
      sort: { field: "term", desc: false },
      limit: 50,
    });
    expect(page).toEqual({ rows: [], total: 0, nextCursor: null });
  });
});

describe.skipIf(!RUN_DB)("keyword write actions (postgres)", () => {
  let db: typeof import("@/lib/db/client").db;
  let schema: typeof import("@/lib/db/domain-schema");
  let actions: typeof import("./actions");

  const SITE_A = "lustre-lab";
  const SITE_B = "atelier-forge";

  async function insertKeyword(id: string, siteId: string, term: string) {
    await db.insert(schema.keywords).values({
      id,
      siteId,
      term,
      lang: "fr",
      country: "FR",
      theme: "lab-diamonds",
      status: "idea",
      intent: ["informational"],
      primarySource: "mangools",
      sources: ["mangools"],
      metrics: {
        mangools: {
          volume: 100,
          kd: 10,
          cpc: 1.2,
          growthPct: 5,
          updatedAt: "",
        },
      },
      trend: [],
    });
  }

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    schema = await import("@/lib/db/domain-schema");
    actions = await import("./actions");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();
    await db.delete(schema.keywords);
    await db.delete(schema.sites);
    await db.insert(schema.sites).values([
      {
        id: SITE_A,
        orgId: "demo-org",
        ownerUserId: "seed-owner",
        name: "Lustre Lab",
        url: "https://lustrelab.com",
        primaryLanguage: "fr",
        languages: [],
        targetCountries: ["FR"],
      },
      {
        id: SITE_B,
        orgId: "demo-org",
        ownerUserId: "seed-owner",
        name: "Atelier Forge",
        url: "https://atelierforge.com",
        primaryLanguage: "fr",
        languages: [],
        targetCountries: ["FR"],
      },
    ]);
  });

  test("setKeywordsStatusAction updates only the given ids, site-scoped", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await insertKeyword("k2", SITE_A, "bravo");
    await insertKeyword("kx", SITE_B, "foreign");
    await actions.setKeywordsStatusAction(SITE_A, ["k1"], "active");
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    expect(all.find((k) => k.id === "k1")?.status).toBe("active");
    expect(all.find((k) => k.id === "k2")?.status).toBe("idea");
    const foreign = await actions.fetchKeywordsForSiteAction(SITE_B);
    expect(foreign[0].status).toBe("idea"); // wrong-site id ignored
  });

  test("setKeywordsThemeAction updates theme for the given ids", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await actions.setKeywordsThemeAction(SITE_A, ["k1"], "metals");
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    expect(all.find((k) => k.id === "k1")?.theme).toBe("metals");
  });

  test("deleteKeywordsAction removes only the given ids, site-scoped", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await insertKeyword("k2", SITE_A, "bravo");
    await insertKeyword("kx", SITE_B, "foreign");
    await actions.deleteKeywordsAction(SITE_A, ["k1", "kx"]); // kx is wrong-site
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    expect(all.map((k) => k.id)).toEqual(["k2"]);
    const foreign = await actions.fetchKeywordsForSiteAction(SITE_B);
    expect(foreign.map((k) => k.id)).toEqual(["kx"]); // untouched
  });

  test("fetchKeywordsByFilterAction excludes `except` ids from the filtered set", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await insertKeyword("k2", SITE_A, "bravo");
    await insertKeyword("k3", SITE_A, "charlie");
    await insertKeyword("kx", SITE_B, "foreign");
    const rows = await actions.fetchKeywordsByFilterAction(
      SITE_A,
      {},
      { except: ["k2"] },
    );
    expect(rows.map((k) => k.id).sort()).toEqual(["k1", "k3"]);
  });

  test("markKeywordsExportedAction marks only the targeted ids with a date (ELEVA-49)", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await insertKeyword("k2", SITE_A, "bravo");
    await insertKeyword("kx", SITE_B, "foreign");
    await actions.markKeywordsExportedAction(SITE_A, ["k1", "kx"], "mangools");
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    const k1 = all.find((k) => k.id === "k1");
    expect(typeof k1?.enrichmentExports.mangools).toBe("string");
    expect(k1?.enrichmentExports.mangools?.length).toBeGreaterThan(0);
    expect(all.find((k) => k.id === "k2")?.enrichmentExports).toEqual({});
    // wrong-site id ignored: kx untouched
    const foreign = await actions.fetchKeywordsForSiteAction(SITE_B);
    expect(foreign[0].enrichmentExports).toEqual({});
  });

  test("markKeywordsExportedAction merges a second tool (both keys present)", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await actions.markKeywordsExportedAction(SITE_A, ["k1"], "mangools");
    await actions.markKeywordsExportedAction(SITE_A, ["k1"], "semrush");
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    const exports = all.find((k) => k.id === "k1")?.enrichmentExports;
    expect(typeof exports?.mangools).toBe("string");
    expect(typeof exports?.semrush).toBe("string");
  });

  test("excludeKeywordsAction deletes keywords and records their slugs", async () => {
    await insertKeyword("k1", SITE_A, "Foo Bar");
    const res = await actions.excludeKeywordsAction(SITE_A, ["k1"]);
    expect(res).toEqual({ excluded: 1 });
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    expect(all.find((k) => k.id === "k1")).toBeUndefined();
    const { getExcludedSlugs } = await import("./queries");
    const excluded = await getExcludedSlugs(SITE_A);
    expect(excluded.has(wasm.slugify("Foo Bar"))).toBe(true);
  });

  test("unexcludeKeywordsAction lifts the exclusion (re-syncable)", async () => {
    await insertKeyword("k1", SITE_A, "Foo Bar");
    await actions.excludeKeywordsAction(SITE_A, ["k1"]);
    const slug = wasm.slugify("Foo Bar");
    const res = await actions.unexcludeKeywordsAction(SITE_A, [slug]);
    expect(res).toEqual({ removed: 1 });
    const { getExclusionsForSite } = await import("./queries");
    expect(await getExclusionsForSite(SITE_A)).toHaveLength(0);
  });

  test("setKeywordTargetsBulkAction drops wrong-site ids (ELEVA-72)", async () => {
    const { inArray } = await import("drizzle-orm");
    await insertKeyword("k1", SITE_A, "alpha");
    await insertKeyword("kx", SITE_B, "foreign");
    const next = await actions.setKeywordTargetsBulkAction(
      SITE_A,
      ["k1", "kx"],
      ["FR"],
    );
    expect(next.k1?.FR).toBe("candidate");
    expect(next.kx).toBeUndefined();
    const rows = await db
      .select({ keywordId: schema.keywordTargets.keywordId })
      .from(schema.keywordTargets)
      .where(inArray(schema.keywordTargets.keywordId, ["k1", "kx"]));
    expect(rows.map((r) => r.keywordId)).toEqual(["k1"]); // pas de ligne kx
    // tout-étranger : aucune écriture, état renvoyé inchangé
    const noop = await actions.setKeywordTargetsBulkAction(
      SITE_B,
      ["k1"],
      ["FR"],
    );
    expect(noop.k1).toBeUndefined();
  });

  test("setKeywordTargetsBulkAction chunks past the Postgres bind-param cap (P5)", async () => {
    const { count, eq } = await import("drizzle-orm");
    await insertKeyword("k1", SITE_A, "alpha");
    // `country` has no FK — any distinct string works. 600 > the chunk size
    // (500) so this exercises >1 chunk inside the transaction.
    const countries = Array.from({ length: 600 }, (_, i) => `C${i}`);
    const next = await actions.setKeywordTargetsBulkAction(
      SITE_A,
      ["k1"],
      countries,
    );
    expect(Object.keys(next.k1 ?? {})).toHaveLength(600);
    const [{ value: rowCount }] = await db
      .select({ value: count() })
      .from(schema.keywordTargets)
      .where(eq(schema.keywordTargets.keywordId, "k1"));
    expect(rowCount).toBe(600);
  });

  test("addToShortlistAction drops wrong-site ids (ELEVA-72)", async () => {
    const { eq } = await import("drizzle-orm");
    await insertKeyword("k1", SITE_A, "alpha");
    await insertKeyword("kx", SITE_B, "foreign");
    const res = await actions.addToShortlistAction(SITE_A, ["k1", "kx"]);
    expect(res).toEqual({ count: 1 });
    const { getShortlistForSite } = await import("./shortlist");
    expect(await getShortlistForSite(SITE_A)).toEqual([
      { id: "k1", term: "alpha", country: "FR" },
    ]);
    // pas de ligne fantôme kx attachée au site A
    const ghost = await db
      .select({ keywordId: schema.keywordShortlist.keywordId })
      .from(schema.keywordShortlist)
      .where(eq(schema.keywordShortlist.keywordId, "kx"));
    expect(ghost).toEqual([]);
    // tout-étranger : early return sans insertion
    expect(await actions.addToShortlistAction(SITE_A, ["kx"])).toEqual({
      count: 0,
    });
  });

  test("shortlist add/remove round-trips, site-scoped reads", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await insertKeyword("k2", SITE_A, "bravo");
    const added = await actions.addToShortlistAction(SITE_A, ["k1", "k2"]);
    expect(added).toEqual({ count: 2 });
    // Idempotent: re-adding already-shortlisted keywords inserts nothing.
    const readded = await actions.addToShortlistAction(SITE_A, ["k1", "k2"]);
    expect(readded).toEqual({ count: 0 });
    const { getShortlistForSite } = await import("./shortlist");
    expect((await getShortlistForSite(SITE_A)).map((s) => s.id).sort()).toEqual(
      ["k1", "k2"],
    );
    const removed = await actions.removeFromShortlistAction(SITE_A, ["k1"]);
    expect(removed).toEqual({ count: 1 });
    expect(await getShortlistForSite(SITE_A)).toEqual([
      { id: "k2", term: "bravo", country: "FR" },
    ]);
  });

  test("updateKeywordAction patches only provided fields, site-scoped", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await actions.updateKeywordAction(SITE_A, "k1", {
      status: "watch",
      intent: ["commercial", "transactional"],
    });
    const [k] = await actions.fetchKeywordsForSiteAction(SITE_A);
    expect(k.status).toBe("watch");
    expect(k.intent).toEqual(["commercial", "transactional"]);
    expect(k.theme).toBe("lab-diamonds"); // untouched (not in patch)
  });

  test("addKeywordAction inserts a manual keyword (status idea, source manual)", async () => {
    const res = await actions.addKeywordAction(SITE_A, {
      term: "Bagues Or Éthique",
      lang: "fr",
      country: "FR",
      theme: "metals",
      intent: ["commercial"],
    });
    expect(res.ok).toBe(true);
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    const k = all.find((x) => x.term === "Bagues Or Éthique");
    expect(k).toBeDefined();
    expect(k?.id).toBe("lustre-lab-bagues-or-ethique");
    expect(k?.status).toBe("idea");
    expect(k?.primarySource).toBe("manual");
    expect(k?.sources).toEqual(["manual"]);
    expect(k?.theme).toBe("metals");
    expect(k?.intent).toEqual(["commercial"]);
  });

  test("addKeywordAction rejects an empty term", async () => {
    const res = await actions.addKeywordAction(SITE_A, {
      term: "   ",
      lang: "fr",
      country: "FR",
      intent: [],
    });
    expect(res.ok).toBe(false);
  });

  test("addKeywordsToStageAction adds a stage (idempotent, site-scoped)", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await insertKeyword("k2", SITE_A, "bravo");
    await insertKeyword("kx", SITE_B, "foreign");
    await actions.addKeywordsToStageAction(SITE_A, ["k1"], "research");
    await actions.addKeywordsToStageAction(SITE_A, ["k1"], "research"); // idempotent
    await actions.addKeywordsToStageAction(SITE_A, ["k1"], "planning");
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    const k1 = all.find((k) => k.id === "k1");
    expect([...(k1?.stages ?? [])].sort()).toEqual(["planning", "research"]);
    expect(all.find((k) => k.id === "k2")?.stages).toEqual([]);
    const foreign = await actions.fetchKeywordsForSiteAction(SITE_B);
    expect(foreign[0].stages).toEqual([]); // wrong-site untouched
  });

  test("removeKeywordFromStageAction removes only the given stage, site-scoped", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await insertKeyword("k2", SITE_A, "bravo");
    await insertKeyword("kx", SITE_B, "foreign");
    await actions.addKeywordsToStageAction(SITE_A, ["k1", "k2"], "research");
    await actions.addKeywordsToStageAction(SITE_A, ["k1", "k2"], "planning");
    await actions.addKeywordsToStageAction(SITE_B, ["kx"], "planning");
    await actions.removeKeywordFromStageAction(SITE_A, "k1", "planning");
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    // only the given stage is removed on the given keyword
    expect(all.find((k) => k.id === "k1")?.stages).toEqual(["research"]);
    // other keywords untouched
    expect([...(all.find((k) => k.id === "k2")?.stages ?? [])].sort()).toEqual([
      "planning",
      "research",
    ]);
    // wrong-site no-op: kx keeps its stage
    await actions.removeKeywordFromStageAction(SITE_A, "kx", "planning");
    const foreign = await actions.fetchKeywordsForSiteAction(SITE_B);
    expect(foreign[0].stages).toEqual(["planning"]);
  });

  test("addKeywordAction rejects a duplicate term", async () => {
    const input = {
      term: "alpha",
      lang: "fr",
      country: "FR",
      intent: [] as SearchIntent[],
    };
    await actions.addKeywordAction(SITE_A, input);
    const res = await actions.addKeywordAction(SITE_A, input);
    expect(res.ok).toBe(false);
  });

  test("importKeywordBriefAction stores a brief keyed by country", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    const briefTxt = [
      "Lumenia.io SEO Brief",
      "Query: alpha",
      "Language: English (USA)",
      "",
      "=======================",
      "Context and Objective",
      "=======================",
      "Primary Intent: Informational",
      "Objective:",
      "  - Educate readers",
    ].join("\n");
    const res = await actions.importKeywordBriefAction(SITE_A, "k1", [
      briefTxt,
    ]);
    expect(res.ok).toBe(true);
    if (res.ok) expect(res.country).toBe("US");
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    const k1 = all.find((k) => k.id === "k1");
    expect(k1?.briefs.US?.query).toBe("alpha");
    expect(k1?.briefs.US?.objectives[0]).toBe("Educate readers");
  });

  test("importKeywordBriefAction honors a country override (same keyword, two countries)", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    const txt = [
      "Lumenia.io SEO Brief",
      "Query: alpha",
      "Language: English (USA)",
      "",
      "=======================",
      "Context and Objective",
      "=======================",
      "Objective:",
      "  - Educate readers",
    ].join("\n");
    await actions.importKeywordBriefAction(SITE_A, "k1", [txt]); // parsed -> US
    await actions.importKeywordBriefAction(SITE_A, "k1", [txt], "GB"); // override -> GB
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    const k1 = all.find((k) => k.id === "k1");
    expect(Object.keys(k1?.briefs ?? {}).sort()).toEqual(["GB", "US"]);
    expect(k1?.briefs.GB?.country).toBe("GB");
    expect(k1?.briefs.US?.country).toBe("US");
  });
});
