// src/lib/keywords/actions.ts — server actions for keywords.
"use server";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import * as wasm from "import-core";
import { revalidatePath, updateTag } from "next/cache";
import { after } from "next/server";
import {
  AnalyticsJobError,
  enqueueAnalyticsJob,
} from "@/lib/analytics/enqueue";
import {
  assertWithinQuotaTx,
  getOrgQuotaLimits,
  lockOrgForQuota,
  QuotaError,
  type QuotaExceeded,
  quotaPayload,
} from "@/lib/billing/quotas";
import { getConnectorToken } from "@/lib/connectors/store";
import { chunk } from "@/lib/db/chunk";
import { db } from "@/lib/db/client";
import {
  keywordExclusions,
  keywordShortlist,
  keywords,
  keywordTargets,
  keywordThemes,
  sitePages,
  sites,
} from "@/lib/db/domain-schema";
import {
  assertKeywordInActiveOrg,
  assertSiteInActiveOrg,
} from "@/lib/sites/assert-site";
import { setEnrichStatus } from "./enrich-status";
import { keywordId } from "./import";
import {
  getKeywordFacets,
  type KeywordFacets,
  type KeywordListFilter,
  type KeywordPage,
  type KeywordPageParams,
  type KeywordSelection,
  searchKeywords,
  selectionWhere,
} from "./list-query";
import type { KeywordOverview } from "./overview";
import {
  type Exclusion,
  filterKeywordIdsForSite,
  getCachedKeywordOverview,
  getExclusionsForSite,
  getKeywordsForSite,
  getTargetsForSite,
  getThemesForSite,
  keywordOverviewTag,
  keywordSelect,
  toKeyword,
} from "./queries";
import { getShortlistForSite, type ShortlistItem } from "./shortlist";
import {
  setForSelected,
  type TargetStatus,
  type TargetsState,
} from "./targets";
import { buildThemeRow, insertThemeRows } from "./theme-store";
import type {
  Keyword,
  KeywordStage,
  KeywordStatus,
  KeywordTheme,
  SearchIntent,
} from "./types";
import { parseYtgTexts } from "./ytg-parse";

/** `Array.isArray` discriminates the legacy `string[]` (plain ids) call shape
 *  from the new `KeywordSelection` shape — every bulk action below stays
 *  backward-compatible with existing `ids: string[]` callers. */
function normalizeSelection(
  selection: string[] | KeywordSelection,
): KeywordSelection {
  return Array.isArray(selection) ? { mode: "ids", ids: selection } : selection;
}

const MAX_FILTER_EXPORT = 20_000;

/** Fetch a site's keywords from the DB (callable from client components). */
export async function fetchKeywordsForSiteAction(
  siteId: string,
): Promise<Keyword[]> {
  await assertSiteInActiveOrg(siteId);
  return getKeywordsForSite(siteId);
}

/** Persisted targeting (keywordId → country → status) for a site. */
export async function getTargetsForSiteAction(
  siteId: string,
): Promise<TargetsState> {
  await assertSiteInActiveOrg(siteId);
  return getTargetsForSite(siteId);
}

/** A site's excluded slugs (for the manage-exclusions UI). */
export async function fetchExclusionsAction(
  siteId: string,
): Promise<Exclusion[]> {
  await assertSiteInActiveOrg(siteId);
  return getExclusionsForSite(siteId);
}

/** Keyset-paginated, filtered, sorted keyword page (réservoir/recherche list
 *  views — replaces client-side filtering of the full `Keyword[]` for sites
 *  where that no longer scales). */
export async function fetchKeywordsPageAction(
  siteId: string,
  params: KeywordPageParams,
): Promise<KeywordPage> {
  // No active site (org with zero sites, or org switch to an empty org) →
  // legitimate empty state, not a tenancy violation.
  if (!siteId) return { rows: [], total: 0, nextCursor: null };
  await assertSiteInActiveOrg(siteId);
  return searchKeywords(siteId, params);
}

/** Facets (languages/sources present, cpc range, total) for the filter bar —
 *  scoped to a stage when given (e.g. `research` for the Recherche page). */
export async function fetchKeywordFacetsAction(
  siteId: string,
  stage?: KeywordStage,
): Promise<KeywordFacets> {
  if (!siteId)
    return {
      languages: [],
      countries: [],
      sources: [],
      cpcRange: null,
      total: 0,
      quickwinCount: 0,
    };
  await assertSiteInActiveOrg(siteId);
  return getKeywordFacets(siteId, stage);
}

/** The full (unpaginated) filtered set, sorted by term, capped at 20 000 —
 *  feeds exports/the enrichment matrix, not the paginated table. `except`
 *  (when given) excludes those ids from the result — same semantics as the
 *  "sélection filtrée sauf N" mode in `KeywordSelection`, so a caller that
 *  unchecked a few rows out of a "select all filtered" doesn't get them back
 *  in the exported/matrix set (reuses `selectionWhere`'s filter-mode WHERE). */
export async function fetchKeywordsByFilterAction(
  siteId: string,
  filter: KeywordListFilter,
  opts?: { limit?: number; except?: string[] },
): Promise<Keyword[]> {
  if (!siteId) return [];
  await assertSiteInActiveOrg(siteId);
  const limit = Math.min(
    MAX_FILTER_EXPORT,
    Math.max(1, opts?.limit ?? MAX_FILTER_EXPORT),
  );
  const rows = await db
    .select(keywordSelect)
    .from(keywords)
    .leftJoin(sitePages, eq(keywords.targetPageId, sitePages.id))
    .where(
      selectionWhere(siteId, { mode: "filter", filter, except: opts?.except }),
    )
    .orderBy(asc(keywords.term))
    .limit(limit);
  return rows.map(toKeyword);
}

/** Upsert (or clear, when status is null) one keyword × country target cell. */
export async function setKeywordTargetAction(
  keywordIdValue: string,
  country: string,
  status: TargetStatus | null,
): Promise<void> {
  const siteId = await assertKeywordInActiveOrg(keywordIdValue);
  await db.transaction(async (tx) => {
    if (status === null) {
      await tx
        .delete(keywordTargets)
        .where(
          and(
            eq(keywordTargets.keywordId, keywordIdValue),
            eq(keywordTargets.country, country),
          ),
        );
      return;
    }
    await tx
      .insert(keywordTargets)
      .values({ keywordId: keywordIdValue, country, status, siteId })
      .onConflictDoUpdate({
        target: [keywordTargets.keywordId, keywordTargets.country],
        set: { status },
      });
    // Bridge: committing a country to production surfaces the keyword in the
    // Chambre de Briefing (stage gate). Idempotent array union by keyword id.
    if (status === "planned") {
      await tx
        .update(keywords)
        .set({
          stages: sql`(select array_agg(distinct s) from unnest(${keywords.stages} || array['planning']::text[]) as s)`,
        })
        .where(eq(keywords.id, keywordIdValue));
    }
  });
}

/** Spec alias for setKeywordTargetAction (one action, two effects). */
export const updateKeywordTargetAction = setKeywordTargetAction;

/**
 * Bulk-target keywords × countries (never downgrades a more-advanced status).
 * Reuses the pure `setForSelected` logic against the current persisted state,
 * then upserts only the cells that actually changed. `selection` accepts a
 * plain id array (legacy/ids-page callers) or a `KeywordSelection` — filter
 * mode resolves ids via `selectionWhere` (one SELECT, never trusts
 * client-supplied ids), same pattern as `addToShortlistAction`.
 */
export async function setKeywordTargetsBulkAction(
  siteId: string,
  selection: string[] | KeywordSelection,
  countries: string[],
  status: TargetStatus = "candidate",
): Promise<TargetsState> {
  await assertSiteInActiveOrg(siteId);
  const sel = normalizeSelection(selection);
  if ((sel.mode === "ids" && sel.ids.length === 0) || countries.length === 0) {
    return getTargetsForSite(siteId);
  }
  // ELEVA-72 — keyword_targets is FK-scoped to keywords(id, site_id): filter the
  // ids against the site before writing (foreign ids are silently dropped, and
  // the stamped site_id keeps the composite FK satisfied). Filter mode already
  // scopes to the site via `selectionWhere`, so the extra filter is ids-only.
  const validIds =
    sel.mode === "ids"
      ? await filterKeywordIdsForSite(siteId, sel.ids)
      : (
          await db
            .select({ id: keywords.id })
            .from(keywords)
            .where(selectionWhere(siteId, sel))
        ).map((r) => r.id);
  if (validIds.length === 0) return getTargetsForSite(siteId);
  const current = await getTargetsForSite(siteId);
  const next = setForSelected(current, validIds, countries, status);
  const ops: {
    keywordId: string;
    country: string;
    status: TargetStatus;
    siteId: string;
  }[] = [];
  for (const kwId of validIds) {
    for (const country of countries) {
      const ns = next[kwId]?.[country];
      if (ns && ns !== current[kwId]?.[country]) {
        ops.push({ keywordId: kwId, country, status: ns, siteId });
      }
    }
  }
  if (ops.length > 0) {
    // P5 — a large selection × several countries can exceed Postgres' ~65 535
    // bind-parameter cap in a single INSERT; chunk under it, atomically (one
    // transaction), same pattern as gsc/discovery.ts.
    await db.transaction(async (tx) => {
      for (const slice of chunk(ops, 500)) {
        await tx
          .insert(keywordTargets)
          .values(slice)
          .onConflictDoUpdate({
            target: [keywordTargets.keywordId, keywordTargets.country],
            set: { status: sql`excluded.status` },
          });
      }
    });
  }
  return next;
}

/** Bulk-set status on the given selection (ids, or a server-side filter —
 *  scoped to the site). */
export async function setKeywordsStatusAction(
  siteId: string,
  selection: string[] | KeywordSelection,
  status: KeywordStatus,
): Promise<void> {
  await assertSiteInActiveOrg(siteId);
  const sel = normalizeSelection(selection);
  if (sel.mode === "ids" && sel.ids.length === 0) return;
  // Filter mode: a single direct UPDATE ... WHERE — the matched row set is
  // never materialized as an id list client- or server-side.
  await db.update(keywords).set({ status }).where(selectionWhere(siteId, sel));
  updateTag(keywordOverviewTag(siteId));
}

/** Bulk-set theme on the given selection (ids, or a server-side filter —
 *  scoped to the site). `null` clears the theme (used by the bulk-bar undo
 *  to restore keywords that had no theme). */
export async function setKeywordsThemeAction(
  siteId: string,
  selection: string[] | KeywordSelection,
  theme: KeywordTheme | null,
): Promise<void> {
  await assertSiteInActiveOrg(siteId);
  const sel = normalizeSelection(selection);
  if (sel.mode === "ids" && sel.ids.length === 0) return;
  await db.update(keywords).set({ theme }).where(selectionWhere(siteId, sel));
  updateTag(keywordOverviewTag(siteId));
}

/** Bulk-delete the given selection (ids, or a server-side filter — scoped to
 *  the site). */
export async function deleteKeywordsAction(
  siteId: string,
  selection: string[] | KeywordSelection,
): Promise<void> {
  await assertSiteInActiveOrg(siteId);
  const sel = normalizeSelection(selection);
  if (sel.mode === "ids" && sel.ids.length === 0) return;
  await db.delete(keywords).where(selectionWhere(siteId, sel));
  updateTag(keywordOverviewTag(siteId));
}

/** Delete the given selection AND record their slugs in keyword_exclusions so
 *  a GSC re-sync won't recreate them. Scoped to the site.
 *  Needs the actual rows (to slugify `term`), so — unlike the direct-UPDATE
 *  actions above — this always resolves `selectionWhere` to concrete rows
 *  first (one SELECT, filter mode included), then writes in id-chunks of 500
 *  inside one transaction (a large filtered selection must not build a single
 *  giant INSERT/DELETE statement). */
export async function excludeKeywordsAction(
  siteId: string,
  selection: string[] | KeywordSelection,
): Promise<{ excluded: number }> {
  await assertSiteInActiveOrg(siteId);
  const sel = normalizeSelection(selection);
  if (sel.mode === "ids" && sel.ids.length === 0) return { excluded: 0 };
  const rows = await db
    .select({ id: keywords.id, term: keywords.term })
    .from(keywords)
    .where(selectionWhere(siteId, sel));
  if (rows.length === 0) return { excluded: 0 };
  await db.transaction(async (tx) => {
    for (const slice of chunk(rows, 500)) {
      await tx
        .insert(keywordExclusions)
        .values(slice.map((r) => ({ siteId, slug: wasm.slugify(r.term) })))
        .onConflictDoNothing();
      await tx.delete(keywords).where(
        and(
          eq(keywords.siteId, siteId),
          inArray(
            keywords.id,
            slice.map((r) => r.id),
          ),
        ),
      );
    }
  });
  updateTag(keywordOverviewTag(siteId));
  return { excluded: rows.length };
}

/** Lift the exclusion on the given slugs for a site — they can be re-synced from
 *  GSC again (this does NOT recreate the keyword; the next sync does, if it still
 *  ranks). Site-scoped. */
export async function unexcludeKeywordsAction(
  siteId: string,
  slugs: string[],
): Promise<{ removed: number }> {
  await assertSiteInActiveOrg(siteId);
  if (slugs.length === 0) return { removed: 0 };
  const removed = await db
    .delete(keywordExclusions)
    .where(
      and(
        eq(keywordExclusions.siteId, siteId),
        inArray(keywordExclusions.slug, slugs),
      ),
    )
    .returning({ slug: keywordExclusions.slug });
  return { removed: removed.length };
}

/** Read a site's shortlist (callable from client components). Returns
 *  `{id, term, country}` per entry (joined) — NOTE: the consuming container
 *  (research-container.tsx) and keyword-sheet.tsx still treat this as
 *  `string[]` (id-only) as of this commit; their adaptation is T4b's job. */
export async function fetchShortlistAction(
  siteId: string,
): Promise<ShortlistItem[]> {
  await assertSiteInActiveOrg(siteId);
  return getShortlistForSite(siteId);
}

/** Add a selection (ids, or a server-side filter) to the site's shortlist
 *  (idempotent). Needs the concrete ids to insert — ids mode reuses the
 *  existing tenancy filter; filter mode resolves `selectionWhere` to ids with
 *  one SELECT (never trusts client-supplied ids either way). Both modes write
 *  in chunks of 500. */
export async function addToShortlistAction(
  siteId: string,
  selection: string[] | KeywordSelection,
): Promise<{ count: number }> {
  await assertSiteInActiveOrg(siteId);
  const sel = normalizeSelection(selection);
  if (sel.mode === "ids" && sel.ids.length === 0) return { count: 0 };
  const ids =
    sel.mode === "ids"
      ? // ELEVA-72 — n'insérer que des mots-clés appartenant réellement au site
        // (un id étranger attacherait un mot-clé d'une autre org à la shortlist).
        await filterKeywordIdsForSite(siteId, sel.ids)
      : (
          await db
            .select({ id: keywords.id })
            .from(keywords)
            .where(selectionWhere(siteId, sel))
        ).map((r) => r.id);
  if (ids.length === 0) return { count: 0 };
  let insertedCount = 0;
  for (const slice of chunk(ids, 500)) {
    const inserted = await db
      .insert(keywordShortlist)
      .values(slice.map((keywordId) => ({ siteId, keywordId })))
      .onConflictDoNothing()
      .returning({ id: keywordShortlist.keywordId });
    insertedCount += inserted.length;
  }
  return { count: insertedCount };
}

/** Remove keywords from the site's shortlist. */
export async function removeFromShortlistAction(
  siteId: string,
  ids: string[],
): Promise<{ count: number }> {
  await assertSiteInActiveOrg(siteId);
  if (ids.length === 0) return { count: 0 };
  const removed = await db
    .delete(keywordShortlist)
    .where(
      and(
        eq(keywordShortlist.siteId, siteId),
        inArray(keywordShortlist.keywordId, ids),
      ),
    )
    .returning({ id: keywordShortlist.keywordId });
  return { count: removed.length };
}

/** Patch a single keyword's workflow fields (status/theme/intent). Site-scoped. */
export async function updateKeywordAction(
  siteId: string,
  id: string,
  patch: {
    status?: KeywordStatus;
    theme?: KeywordTheme | null;
    intent?: SearchIntent[];
  },
): Promise<void> {
  await assertSiteInActiveOrg(siteId);
  const set: Partial<typeof patch> = {};
  if (patch.status !== undefined) set.status = patch.status;
  if (patch.theme !== undefined) set.theme = patch.theme;
  if (patch.intent !== undefined) set.intent = patch.intent;
  if (Object.keys(set).length === 0) return;
  await db
    .update(keywords)
    .set(set)
    .where(and(eq(keywords.siteId, siteId), eq(keywords.id, id)));
  updateTag(keywordOverviewTag(siteId));
}

/** Add a stage (research/planning) to the given selection (union, idempotent,
 *  ids or a server-side filter). Site-scoped. The union expression reads each
 *  row's own `stages`, so a direct filtered UPDATE is correct without
 *  materializing ids. */
export async function addKeywordsToStageAction(
  siteId: string,
  selection: string[] | KeywordSelection,
  stage: KeywordStage,
): Promise<void> {
  await assertSiteInActiveOrg(siteId);
  const sel = normalizeSelection(selection);
  if (sel.mode === "ids" && sel.ids.length === 0) return;
  await db
    .update(keywords)
    .set({
      stages: sql`(select array_agg(distinct s) from unnest(${keywords.stages} || array[${stage}]::text[]) as s)`,
    })
    .where(selectionWhere(siteId, sel));
  updateTag(keywordOverviewTag(siteId));
}

/** Retire un stage d'un mot-clé (ex. sortir de la Chambre de Briefing). */
export async function removeKeywordFromStageAction(
  siteId: string,
  keywordId: string,
  stage: KeywordStage,
): Promise<{ ok: boolean }> {
  await assertSiteInActiveOrg(siteId);
  await db
    .update(keywords)
    .set({ stages: sql`array_remove(${keywords.stages}, ${stage})` })
    .where(and(eq(keywords.siteId, siteId), eq(keywords.id, keywordId)));
  revalidatePath("/keywords/planning");
  updateTag(keywordOverviewTag(siteId));
  return { ok: true };
}

/** Fetch a site's themes (client-callable). */
export async function fetchThemesForSiteAction(
  siteId: string,
): Promise<{ slug: string; label: string }[]> {
  // No active site (org with zero sites, or before the first is created) is a
  // legitimate empty state, not a tenancy violation — return empty instead of
  // letting assertSiteInActiveOrg throw a 500 on an empty id.
  if (!siteId) return [];
  await assertSiteInActiveOrg(siteId);
  return getThemesForSite(siteId);
}

/** Create a per-site theme; returns its slug+label (idempotent on slug).
 *
 *  Slug ET borne du libellé viennent de `theme-store`, PARTAGÉS avec l'import
 *  des listes Mangools : le même libellé peut entrer par les deux chemins, il
 *  doit y produire exactement le même slug (`keywords.theme` n'a aucune clé
 *  étrangère vers `keyword_themes.slug` — une divergence ne lèverait rien et
 *  rendrait le thème introuvable dans les filtres du réservoir). Un libellé
 *  hors borne est ÉCARTÉ (retour `null`), jamais tronqué. */
export async function createThemeAction(
  siteId: string,
  label: string,
): Promise<{ slug: string; label: string } | null> {
  await assertSiteInActiveOrg(siteId);
  const row = buildThemeRow(siteId, label);
  if (!row) return null;
  await insertThemeRows(db, [row]);
  return { slug: row.slug, label: row.label };
}

/** Rename a per-site theme's label. */
export async function renameThemeAction(
  siteId: string,
  slug: string,
  label: string,
): Promise<void> {
  await assertSiteInActiveOrg(siteId);
  const trimmed = label.trim();
  if (!trimmed) return;
  await db
    .update(keywordThemes)
    .set({ label: trimmed })
    .where(and(eq(keywordThemes.siteId, siteId), eq(keywordThemes.slug, slug)));
}

/** Manually add a single keyword (no provider metrics). Site-scoped. */
export async function addKeywordAction(
  siteId: string,
  input: {
    term: string;
    lang: string;
    country: string;
    theme?: KeywordTheme | null;
    intent: SearchIntent[];
    destination?: "reservoir" | "recherche";
  },
): Promise<{ ok: true } | { ok: false; error: string; quota?: QuotaExceeded }> {
  await assertSiteInActiveOrg(siteId);
  // The org is the site's org (tenancy asserted just above), read from the
  // DB so the check doesn't depend on the session a second time.
  const [siteRow] = await db
    .select({ orgId: sites.orgId })
    .from(sites)
    .where(eq(sites.id, siteId));
  const term = input.term.trim();
  if (!term) return { ok: false, error: "Le terme est requis." };
  const slug = wasm.slugify(term);
  if (!slug) return { ok: false, error: "Terme invalide." };
  const id = keywordId(siteId, slug);
  const existing = await db
    .select({ id: keywords.id })
    .from(keywords)
    .where(and(eq(keywords.siteId, siteId), eq(keywords.id, id)))
    .limit(1);
  if (existing.length > 0)
    return { ok: false, error: "Ce mot-clé existe déjà pour ce site." };
  // Plan quota (sec audit A2/P8): the check and the insert now share ONE
  // transaction, gated by lockOrgForQuota, so a concurrent addKeywordAction
  // for the same org can't read the same pre-write count and both pass.
  // Limit resolved BEFORE the transaction — see assertWithinQuotaTx's doc.
  const limits = siteRow ? await getOrgQuotaLimits(siteRow.orgId) : null;
  try {
    await db.transaction(async (tx) => {
      if (siteRow && limits) {
        await lockOrgForQuota(tx, siteRow.orgId);
        await assertWithinQuotaTx(
          tx,
          siteRow.orgId,
          "keywords",
          limits.keywords,
          1,
        );
      }
      await tx.insert(keywords).values({
        id,
        siteId,
        term,
        lang: input.lang || "en",
        country: input.country || "US",
        theme: input.theme ?? null,
        status: "idea",
        intent: input.intent,
        primarySource: "manual",
        sources: ["manual"],
        metrics: {},
        trend: [],
        stages:
          input.destination === "recherche"
            ? (["research"] as KeywordStage[])
            : [],
      });
    });
  } catch (e) {
    if (e instanceof QuotaError) {
      return { ok: false, error: e.message, quota: quotaPayload(e) };
    }
    throw e;
  }
  updateTag(keywordOverviewTag(siteId));
  // Trigger auto (Task 9) : le mot-clé ajouté par le dialog arrive SANS
  // métriques par définition. Si l'org a un connecteur Mangools actif, on
  // enfile un enrichissement en tâche de fond. Best-effort ABSOLU : l'ajout ne
  // doit JAMAIS échouer à cause de l'enrichissement, et un 409 (enrichissement
  // déjà en cours pour l'org+provider) est toléré sans bruit.
  // I1 — exécuté dans `after()` (post-réponse) : un service analytics qui pend
  // ne doit JAMAIS ajouter de latence à l'ajout de mot-clé (l'insert est déjà
  // commité). Repli inline hors scope de requête (scripts/seed), même pattern
  // que auth.ts. Le try/catch best-effort + tolérance 409 restent identiques.
  if (siteRow) {
    const orgId = siteRow.orgId;
    const enrich = async () => {
      try {
        const token = await getConnectorToken(orgId, "mangools");
        if (token) {
          await enqueueAnalyticsJob({
            type: "keyword-enrich",
            siteId,
            payload: { provider: "mangools", keywordIds: [id] },
          });
          // Fix spinner : pose `queued` dès l'enqueue réussi (même best-effort)
          // pour que le rail par-ligne s'allume sans attendre le claim du
          // worker (qui re-posera `queued`, écrasement idempotent). Sans ça, le
          // `refreshEnrich()` de l'UI ne voit aucun statut actif → pas de poll.
          await setEnrichStatus([id], "mangools", {
            status: "queued",
            at: new Date().toISOString(),
          });
        }
      } catch (e) {
        // Jamais de throw : l'ajout est déjà commité et réussi. On ne loggue que
        // les échecs non-409 (le 409 est un no-op attendu, pas une anomalie).
        if (!(e instanceof AnalyticsJobError && e.status === 409)) {
          console.error("addKeywordAction: enqueue keyword-enrich failed", e);
        }
      }
    };
    try {
      after(enrich);
    } catch {
      void enrich();
    }
  }
  return { ok: true };
}

/** Aggregated keyword overview for a site (active-site dashboard). The heavy
 *  aggregate read is cached per-site (see `getCachedKeywordOverview`); the
 *  auth/tenancy check stays here, outside the cache. */
export async function getKeywordOverviewAction(
  siteId: string,
): Promise<KeywordOverview> {
  await assertSiteInActiveOrg(siteId);
  return getCachedKeywordOverview(siteId);
}

/** Import a YTG brief (1..2 .txt contents) onto a keyword, keyed by country. */
export async function importKeywordBriefAction(
  siteId: string,
  keywordId: string,
  texts: string[],
  countryOverride?: string,
): Promise<{ ok: true; country: string } | { ok: false; error: string }> {
  await assertSiteInActiveOrg(siteId);
  const parsed = parseYtgTexts(texts, new Date().toISOString());
  if (!parsed.ok) return parsed;
  const [row] = await db
    .select({ country: keywords.country })
    .from(keywords)
    .where(and(eq(keywords.siteId, siteId), eq(keywords.id, keywordId)))
    .limit(1);
  if (!row) return { ok: false, error: "Mot-clé introuvable." };
  const country =
    countryOverride?.trim() || parsed.brief.country || row.country;
  const brief = { ...parsed.brief, country };
  await db
    .update(keywords)
    .set({
      briefs: sql`${keywords.briefs} || ${JSON.stringify({ [country]: brief })}::jsonb`,
    })
    .where(and(eq(keywords.siteId, siteId), eq(keywords.id, keywordId)));
  return { ok: true, country };
}

/** Marque une sélection (ids ou filtre serveur) comme exportée vers un outil
 *  d'enrichissement (ELEVA-49). Fusionne `{ tool: now() }` dans
 *  enrichment_exports — ils restent « en attente » tant que les métriques de
 *  l'outil ne sont pas revenues. The merged patch is the SAME jsonb literal
 *  for every matched row (not a per-row value), so — unlike
 *  excludeKeywordsAction/addToShortlistAction — a direct filtered UPDATE is
 *  correct without materializing ids first. */
export async function markKeywordsExportedAction(
  siteId: string,
  selection: string[] | KeywordSelection,
  tool: "mangools" | "semrush" | "ahrefs",
): Promise<{ ok: boolean }> {
  await assertSiteInActiveOrg(siteId);
  const sel = normalizeSelection(selection);
  if (sel.mode === "ids" && sel.ids.length === 0) return { ok: true };
  await db
    .update(keywords)
    .set({
      enrichmentExports: sql`${keywords.enrichmentExports} || jsonb_build_object(${tool}::text, now()::text)`,
    })
    .where(selectionWhere(siteId, sel));
  return { ok: true };
}
