// src/lib/mangools/related-actions.ts — server actions du dialog « Mots-clés
// associés ». "use server" : appelées directement par l'UI, donc AUCUN
// paramètre `deps` (les arguments d'une Server Action sont contrôlés par le
// client — surface réseau). La logique testable/DI vit dans related-core.ts.
"use server";
import { and, eq, inArray, sql } from "drizzle-orm";
import * as wasm from "import-core";
import { updateTag } from "next/cache";
import {
  assertWithinQuotaTx,
  getOrgQuotaLimits,
  lockOrgForQuota,
  QuotaError,
  type QuotaExceeded,
  quotaPayload,
} from "@/lib/billing/quotas";
import { chunk } from "@/lib/db/chunk";
import { db } from "@/lib/db/client";
import { keywords, sites } from "@/lib/db/domain-schema";
import { keywordId } from "@/lib/keywords/import";
import { keywordOverviewTag } from "@/lib/keywords/queries";
import type { KeywordStage } from "@/lib/keywords/types";
import { assertSiteInActiveOrg } from "@/lib/sites/assert-site";
import {
  getRelatedKeywords,
  MAX_DISPLAY_ROWS,
  type RelatedRow,
} from "./related-core";

/** Métrique acceptable dans un payload de dialog : `null` (inconnu) ou un nombre
 *  fini ≥ 0. Rejette NaN/Infinity/négatif (donnée forgée ou corrompue). */
function isMetric(v: number | null): boolean {
  return v == null || (Number.isFinite(v) && v >= 0);
}

/** Wrapper "use server" SANS deps de `getRelatedKeywords` : appelé par le
 *  dialog à l'ouverture. La tenancy + la résolution du token vivent dans le
 *  core (defaults câblés sur le vrai store + client throttlé par org). */
export async function getRelatedKeywordsAction(
  siteId: string,
  seedTerm: string,
): Promise<Awaited<ReturnType<typeof getRelatedKeywords>>> {
  // Surface réseau : `seedTerm` est contrôlé par le client. Un seed vide (ou
  // blanc) n'a pas de related exploitable — court-circuit propre AVANT tout
  // appel Mangools (pas de requête upstream inutile ni de consommation de quota).
  const seed = seedTerm.trim();
  if (!seed) return { ok: false, error: "upstream" };
  return getRelatedKeywords(siteId, seed);
}

/**
 * Insère (ou merge) les mots-clés associés sélectionnés dans le réservoir du
 * site, DÉJÀ enrichis (metrics.mangools) — AUCUN job d'enrichissement derrière.
 *
 * - `metrics: { mangools: {...} }`, `primarySource: "mangools"`,
 *   `sources: ["mangools"]`, lang/country du site.
 * - `stage` selon `destination` : "recherche" → `["research"]`, "reservoir" → `[]`.
 * - id déterministe `<siteId>-<slug(term)>` (même chemin que `addKeywordAction`
 *   / `upsertCanonical`). Sur conflit : merge metrics + union sources/stages,
 *   comme `upsertCanonical` — un re-add est idempotent (pas de doublon).
 * - Quota billing keywords : MÊME garde que `addKeywordAction`
 *   (`lockOrgForQuota` + `assertWithinQuotaTx`), mais l'incrément ne compte que
 *   les mots-clés RÉELLEMENT nouveaux — un batch de 50 ne contourne pas le quota
 *   du plan, et un re-add (déjà présents) n'en consomme aucun.
 *
 * Retour : `added` = mots-clés RÉELLEMENT nouveaux (créés), `updated` = déjà
 * présents mergés (métriques mangools rafraîchies) — l'UI dit la vérité au lieu
 * de compter tout le batch.
 *
 * Validation (surface réseau — `rows`/`destination` sont contrôlés par le
 * client) : cap dur MAX_DISPLAY_ROWS (miroir de la troncature d'affichage du
 * core) → au-delà, erreur propre sans toucher la DB ; par row, `term` string non
 * vide ≤ 512 chars (après trim) et `volume`/`kd`/`cpc` soit `null` soit fini ≥ 0,
 * sinon la row est écartée SILENCIEUSEMENT (mêmes règles que la coercion null→0
 * du stockage — une valeur absurde n'a pas à corrompre le réservoir).
 */
export async function addRelatedKeywordsAction(
  siteId: string,
  rows: RelatedRow[],
  destination: "reservoir" | "recherche",
): Promise<
  | { ok: true; added: number; updated: number }
  | { ok: false; error: string; quota?: QuotaExceeded }
> {
  await assertSiteInActiveOrg(siteId);
  if (rows.length === 0) return { ok: true, added: 0, updated: 0 };
  // Cap dur : un payload au-delà du plafond d'affichage du dialog est forcément
  // forgé (l'UI ne peut en sélectionner plus) → rejet explicite, aucune écriture.
  if (rows.length > MAX_DISPLAY_ROWS) {
    return {
      ok: false,
      error: `Trop de mots-clés sélectionnés (max ${MAX_DISPLAY_ROWS}).`,
    };
  }

  const [siteRow] = await db
    .select({
      orgId: sites.orgId,
      lang: sites.primaryLanguage,
      targetCountries: sites.targetCountries,
    })
    .from(sites)
    .where(eq(sites.id, siteId))
    .limit(1);
  if (!siteRow) return { ok: false, error: "Site introuvable." };

  const lang = siteRow.lang || "en";
  const country = siteRow.targetCountries?.[0] || "US";
  const now = new Date().toISOString();
  const stages: KeywordStage[] =
    destination === "recherche" ? ["research"] : [];

  // Construit les valeurs, dédupliquées par id (deux termes peuvent slugifier
  // vers le même id — on garde le premier). Termes vides/invalides écartés.
  const byId = new Map<string, typeof keywords.$inferInsert>();
  for (const r of rows) {
    if (typeof r.term !== "string") continue;
    const term = r.term.trim();
    // Terme : non vide, borné (≤ 512 chars) contre les payloads abusifs.
    if (!term || term.length > 512) continue;
    // Métriques : `null` toléré (inconnu → « — »/0 au stockage) ; sinon un
    // nombre fini positif. NaN/Infinity/négatif → row écartée (donnée corrompue).
    if (!isMetric(r.volume) || !isMetric(r.kd) || !isMetric(r.cpc)) continue;
    const slug = wasm.slugify(term);
    if (!slug) continue;
    const id = keywordId(siteId, slug);
    if (byId.has(id)) continue;
    byId.set(id, {
      id,
      siteId,
      term,
      lang,
      country,
      status: "idea",
      intent: [],
      primarySource: "mangools",
      sources: ["mangools"],
      metrics: {
        mangools: {
          // Mêmes sémantiques que `metricsFromMangools` : null → 0 au stockage
          // (le champ SourceMetrics est numérique). growthPct indisponible ici
          // (le dialog ne transporte pas la série msv) → null.
          volume: r.volume ?? 0,
          kd: r.kd ?? 0,
          cpc: r.cpc ?? 0,
          growthPct: null,
          competitiveDensity: null,
          numberOfResults: null,
          updatedAt: now,
        },
      },
      trend: [],
      stages,
    });
  }
  const values = [...byId.values()];
  if (values.length === 0) return { ok: true, added: 0, updated: 0 };
  const ids = [...byId.keys()];

  // Limite résolue AVANT la transaction (cf. doc de `assertWithinQuotaTx` : la
  // résoudre dedans déclencherait une seconde connexion → deadlock du pool).
  const limits = await getOrgQuotaLimits(siteRow.orgId);
  // Hissé hors de la transaction : sépare `added` (créés) de `updated` (mergés)
  // pour le retour, sans relancer un count après coup.
  let newCount = 0;
  try {
    await db.transaction(async (tx) => {
      // Sérialise les writes quota-gardés de l'org (advisory lock).
      await lockOrgForQuota(tx, siteRow.orgId);
      // Incrément = uniquement les mots-clés qui n'existent pas déjà pour ce
      // site (les re-add mergent, ne comptent pas dans le quota du plan).
      const existing = await tx
        .select({ id: keywords.id })
        .from(keywords)
        .where(and(eq(keywords.siteId, siteId), inArray(keywords.id, ids)));
      newCount = ids.length - existing.length;
      if (newCount > 0) {
        await assertWithinQuotaTx(
          tx,
          siteRow.orgId,
          "keywords",
          limits.keywords,
          newCount,
        );
      }
      // Chunk sous le cap Postgres ~65 535 bind-params (~13/row) — atomique.
      for (const slice of chunk(values, 1000)) {
        await tx
          .insert(keywords)
          .values(slice)
          .onConflictDoUpdate({
            target: [keywords.id],
            set: {
              // Merge PROFOND sur la sous-clé `mangools` (pas un remplacement de
              // l'objet entier) : `existing.mangools || strip_nulls(new.mangools)`.
              // — jsonb_set préserve les AUTRES sources (seranking/gsc…) intactes ;
              // — `|| new` fait gagner la fraîcheur mangools sur vol/kd/cpc ;
              // — `jsonb_strip_nulls(new)` retire growthPct/competitiveDensity/
              //   numberOfResults (toujours null depuis le dialog, qui ne
              //   transporte pas la série msv) → ils N'ÉCRASENT PAS une valeur
              //   déjà calculée par le job d'enrichissement. Sur une ligne neuve,
              //   l'objet inséré garde bien growthPct:null (SourceMetrics complet).
              metrics: sql`jsonb_set(${keywords.metrics}, '{mangools}', coalesce(${keywords.metrics} -> 'mangools', '{}'::jsonb) || jsonb_strip_nulls(excluded.metrics -> 'mangools'))`,
              // primarySource bascule vers mangools UNIQUEMENT si la source
              // primaire actuelle ne porte aucun volume (cas GSC/manuel sans
              // métrique) — sinon inchangée. Miroir de seranking/sync +
              // keyword-enrich : évite l'UI « — » alors que metrics.mangools existe.
              primarySource: sql`CASE WHEN (${keywords.metrics} -> ${keywords.primarySource} ->> 'volume') IS NULL THEN 'mangools' ELSE ${keywords.primarySource} END`,
              // Union sources/stages, comme `upsertCanonical`. term/lang/country
              // NE sont PAS réécrits : un mot-clé déjà présent garde sa locale.
              sources: sql`(select array_agg(distinct s) from unnest(${keywords.sources} || excluded.sources) as s)`,
              stages: sql`(select coalesce(array_agg(distinct s), '{}') from unnest(${keywords.stages} || excluded.stages) as s)`,
            },
          });
      }
    });
  } catch (e) {
    if (e instanceof QuotaError) {
      return { ok: false, error: e.message, quota: quotaPayload(e) };
    }
    throw e;
  }

  updateTag(keywordOverviewTag(siteId));
  return { ok: true, added: newCount, updated: values.length - newCount };
}
