// src/components/keywords/reservoir.tsx
"use client";

import type {
  ColumnFiltersState,
  RowSelectionState,
  SortingState,
  Updater,
  VisibilityState,
} from "@tanstack/react-table";
import { useQueryState } from "nuqs";
import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  useTransition,
} from "react";
import { toast } from "sonner";
import { SyncMenu } from "@/components/keywords/sync-menu";
import { DataTable } from "@/components/ui/data-table";
import {
  rangeValue,
  ToolbarMetaRow,
  ToolbarPanel,
} from "@/components/ui/design-toolbar";
import { FloatingBulkBar } from "@/components/ui/floating-bulk-bar";
import { Skeleton } from "@/components/ui/skeleton";
import {
  didEnrichSettle,
  hasActiveEnrich,
  useEnrichPolling,
} from "@/hooks/use-enrich-polling";
import { useMangoolsSync } from "@/hooks/use-mangools-sync";
import { syncGscKeywordsAction } from "@/lib/gsc/actions";
import {
  addKeywordsToStageAction,
  deleteKeywordsAction,
  fetchKeywordFacetsAction,
  fetchKeywordsByFilterAction,
  fetchKeywordsPageAction,
  removeKeywordFromStageAction,
  setKeywordsStatusAction,
  setKeywordsThemeAction,
} from "@/lib/keywords/actions";
import { buildFullCsv } from "@/lib/keywords/export-full";
import type {
  KeywordFacets,
  KeywordListFilter,
  KeywordPage,
  KeywordSelection,
  KeywordSort,
  KeywordSortField,
} from "@/lib/keywords/list-query";
import {
  filterFromSearchParams,
  parseSort,
  reservoirParsers,
  resolveDefaultLang,
  serializeSort,
} from "@/lib/keywords/search-params";
import { useActiveSite } from "@/lib/keywords/site-context";
import type {
  Keyword,
  KeywordSource,
  KeywordStatus,
  KeywordTheme,
} from "@/lib/keywords/types";
import { BulkActionsBar } from "./bulk-actions-bar";
import { columns, DEFAULT_COLUMN_VISIBILITY } from "./columns";
import { ExportEnrichmentDialog } from "./export-enrichment-dialog";
import { KeywordSheet } from "./keyword-sheet";
import { KeywordsToolbar } from "./keywords-toolbar";
import { PromoteToContentDialog } from "./promote-content-dialog";
import {
  RelatedKeywordsButton,
  RelatedKeywordsDialog,
} from "./related-keywords-dialog";
import { ActiveFilterChips, ReservoirToolbar } from "./reservoir-toolbar";
import { useSerankingSync } from "./seranking-sync-dialog";
import { STATUS_LABELS } from "./status-badge";

// Facets whose values live in the URL (shareable / back-button friendly).
// `lang` stays local (see `lang` state below) — it needs a data-derived
// default (site's primary language, only when present in the dataset) that
// can't cleanly round-trip through a URL default without also losing the
// ability to explicitly clear it back to "toutes les langues".
const URL_FACET_IDS = ["status", "theme", "intent", "country"] as const;

// Every non-range facet id — everything else in a merged ColumnFiltersState
// is a numeric range (volume/kd/cpc/growthPct) and stays local.
const NON_RANGE_FACET_IDS = new Set<string>(["lang", ...URL_FACET_IDS]);

// Serialize a multi-value facet (TanStack stores it as string[]) to a
// comma-joined URL value; "" = unset. Facet values are slugs/enums, never
// containing commas, so the separator is safe.
function facetFromFilters(filters: ColumnFiltersState, id: string): string {
  return ((filters.find((f) => f.id === id)?.value as string[]) ?? []).join(
    ",",
  );
}

/** column id ("growthPct" for the header) ↔ server sort field ("growth"). */
function sortFieldToColumnId(field: KeywordSortField): string {
  return field === "growth" ? "growthPct" : field;
}
function columnIdToSortField(id: string): KeywordSortField | null {
  if (id === "volume" || id === "kd" || id === "cpc") return id;
  if (id === "growthPct") return "growth";
  return null;
}

/** Local numeric-range filters (volume/kd/cpc/growthPct) → the corresponding
 *  `KeywordListFilter` fields. */
function rangesFromColumnFilters(
  ranges: ColumnFiltersState,
): Pick<KeywordListFilter, "volume" | "kd" | "cpc" | "growth"> {
  const out: Pick<KeywordListFilter, "volume" | "kd" | "cpc" | "growth"> = {};
  for (const id of ["volume", "kd", "cpc"] as const) {
    const [min, max] = rangeValue(ranges, id);
    if (min !== undefined || max !== undefined) out[id] = { min, max };
  }
  const [gMin, gMax] = rangeValue(ranges, "growthPct");
  if (gMin !== undefined || gMax !== undefined)
    out.growth = { min: gMin, max: gMax };
  return out;
}

// Plafond des exports résolus côté serveur (aligné sur la Recherche).
const EXPORT_CAP = 20_000;

/** Identity key for "have we already loaded this (site, filter, sort,
 *  pageSize) combo" — see the `loadedKeyRef` comment below for why this
 *  replaces a boolean skip-next-run ref. */
function computeLoadKey(input: {
  siteId: string;
  filter: KeywordListFilter;
  sort: KeywordSort;
  pageSize: number;
}): string {
  return JSON.stringify(input);
}

export function Reservoir({
  canImportGsc,
  analyticsEnabled,
  serankingEnabled,
  mangoolsConnected,
  initialPage,
  initialFacets,
  initialFilter,
  initialSort,
  initialSiteId,
  initialSyncStatus,
}: {
  canImportGsc?: boolean;
  analyticsEnabled?: boolean;
  serankingEnabled?: boolean;
  /** Connecteur Mangools branché (org active) → entrée « Enrichir via Mangools »
   *  + bouton par-ligne. Résolu RSC via `getMangoolsStatus()`. */
  mangoolsConnected?: boolean;
  initialPage: KeywordPage;
  initialFacets: KeywordFacets;
  initialFilter: KeywordListFilter;
  initialSort: KeywordSort;
  initialSiteId: string;
  /** Horodatages de dernière sync (footer du menu « Sync »), fetch RSC. */
  initialSyncStatus: {
    lastGscSyncAt: string | null;
    lastSerankingSyncAt: string | null;
    lastMangoolsSyncAt: string | null;
  };
}) {
  const { activeSiteId, activeSite } = useActiveSite();
  // Depend on the primitive language (not the whole site object) so unrelated
  // site-list mutations don't reset the user's filters.
  const primaryLanguage = activeSite?.primaryLanguage;

  // The server already fetched this site's first page + facets (RSC initial
  // fetch). Seed state from props and start non-loading — no mount fetch.
  // `loading` covers only the site-switch full reload below.
  const [loading, setLoading] = useState(false);
  const [page, setPage] = useState<KeywordPage>(initialPage);
  const [facets, setFacets] = useState<KeywordFacets>(initialFacets);
  const [syncStatus, setSyncStatus] = useState(initialSyncStatus);
  const [isPending, startTransition] = useTransition();
  const [syncingGsc, startGscSync] = useTransition();
  // anchor for shift-click range selection
  const lastSelectedRef = useRef<string | null>(null);

  // --- URL-owned filters -----------------------------------------------
  const [searchCommitted, setSearchCommitted] = useQueryState(
    "q",
    reservoirParsers.q,
  );
  const [statusFilter, setStatusFilter] = useQueryState(
    "status",
    reservoirParsers.status,
  );
  const [themeFilter, setThemeFilter] = useQueryState(
    "theme",
    reservoirParsers.theme,
  );
  const [intentFilter, setIntentFilter] = useQueryState(
    "intent",
    reservoirParsers.intent,
  );
  const [countryFilter, setCountryFilter] = useQueryState(
    "country",
    reservoirParsers.country,
  );
  // Facette « Sans page » (module Pages) — toggle booléen URL-owned, drive
  // `KeywordListFilter.noTargetPage` via `filterFromSearchParams`.
  const [noPage, setNoPage] = useQueryState("nopage", reservoirParsers.nopage);
  const [sortParam, setSortParam] = useQueryState(
    "sort",
    reservoirParsers.sort,
  );
  // `sortParam` always carries a value (parser default "term.asc"), so
  // `initialSort` is only ever the seed for the very first render — kept for
  // the RSC→client contract; both sides parse the same URL with `parseSort`,
  // so they're guaranteed to agree once nuqs has read the URL.
  const sort = useMemo<KeywordSort>(
    () => (sortParam ? parseSort(sortParam) : initialSort),
    [sortParam, initialSort],
  );

  // Debounce the search box: the field updates instantly for a responsive
  // typing feel, the committed (URL + server-fetch-triggering) value follows
  // 300ms later. Also syncs back on external `q` changes (back/forward nav).
  const [searchInput, setSearchInput] = useState(searchCommitted);
  useEffect(() => setSearchInput(searchCommitted), [searchCommitted]);
  useEffect(() => {
    const t = setTimeout(() => {
      if (searchInput !== searchCommitted) void setSearchCommitted(searchInput);
    }, 300);
    return () => clearTimeout(t);
  }, [searchInput, searchCommitted, setSearchCommitted]);

  // --- Local-only filters: language, numeric ranges, source view --------
  // `lang` mirrors the old `deriveLanguageFilter`: seeded once from the
  // server-resolved default (`initialFilter.lang`), then a plain local
  // filter the user can freely clear (no URL default to fight with).
  const [lang, setLang] = useState<string>(initialFilter.lang ?? "");
  const [rangeFilters, setRangeFilters] = useState<ColumnFiltersState>([]);
  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
    DEFAULT_COLUMN_VISIBILITY,
  );
  // Global active data source (toolbar « Source » selector / dot click) —
  // also enters the server filter (containment). "" = each row's primary
  // source / no source filter.
  const [sourceView, setSourceView] = useState<KeywordSource | "">("");

  const [openKeyword, setOpenKeyword] = useState<Keyword | null>(null);
  const [promoteOpen, setPromoteOpen] = useState(false);
  // Seed du dialog « Mots-clés associés » (terme du kw source). `null` = fermé.
  const [relatedSeed, setRelatedSeed] = useState<string | null>(null);

  // --- Export enrichissement (mode portées) : pool = réservoir filtré complet
  // (cap 20 000), sélection = ids cochés — le dialog filtre par (outil, portée).
  const [exportOpen, setExportOpen] = useState(false);
  const [exportPool, setExportPool] = useState<Keyword[]>([]);
  const [exportSelectedIds, setExportSelectedIds] = useState<string[]>([]);
  const [exportLoading, setExportLoading] = useState(false);

  // --- Selection: page ids, or "the N filtered results" -----------------
  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
  const [selectAllFiltered, setSelectAllFiltered] = useState(false);
  const [filterExcept, setFilterExcept] = useState<string[]>([]);

  const clearSelectionState = useCallback(() => {
    setRowSelection({});
    setSelectAllFiltered(false);
    setFilterExcept([]);
  }, []);

  // --- Pagination: keyset cursor stack (never in the URL) ----------------
  const [pageSize, setPageSize] = useState(50);
  const [cursorStack, setCursorStack] = useState<string[]>([]);
  const pageIndex = cursorStack.length;
  const pageCount = Math.max(1, Math.ceil(page.total / pageSize));

  // --- Effective server filter -------------------------------------------
  const filter = useMemo<KeywordListFilter>(() => {
    const f: KeywordListFilter = {
      ...filterFromSearchParams({
        q: searchCommitted,
        status: statusFilter,
        theme: themeFilter,
        intent: intentFilter,
        lang,
        country: countryFilter,
        nopage: noPage,
      }),
      ...rangesFromColumnFilters(rangeFilters),
    };
    if (sourceView) f.source = sourceView;
    return f;
  }, [
    searchCommitted,
    statusFilter,
    themeFilter,
    intentFilter,
    lang,
    countryFilter,
    noPage,
    rangeFilters,
    sourceView,
  ]);

  // --- Refetch orchestration ----------------------------------------------
  // One effect drives both "filter/sort/pageSize changed" (reset stack,
  // refetch page 1) and "site changed" (also refetch facets + re-derive the
  // language default) — see the inline comments for why they're merged.
  const loadedSiteRef = useRef(initialSiteId);
  // The server already provided page 1 + facets for this exact (site,
  // filter, sort, pageSize) combo (RSC initial fetch) — track the loaded key
  // so we never refetch on mount. A boolean ref is defeated by React Strict
  // Mode's double-invoked effects (dev): the first invoke consumes the skip,
  // the second refetches at mount and deadlocks the next client navigation
  // (ELEVA — same failure as the backlink nav deadlock; see
  // domain-profile-container's `loadedKey` precedent). A key is idempotent:
  // recomputing it twice yields the same value, so the second Strict Mode
  // invoke is a no-op.
  const loadedKeyRef = useRef(
    computeLoadKey({ siteId: initialSiteId, filter, sort, pageSize }),
  );

  useEffect(() => {
    const loadKey = computeLoadKey({
      siteId: activeSiteId,
      filter,
      sort,
      pageSize,
    });
    if (loadKey === loadedKeyRef.current) return;
    loadedKeyRef.current = loadKey;

    const siteChanged = activeSiteId !== loadedSiteRef.current;
    loadedSiteRef.current = activeSiteId;
    clearSelectionState();
    setCursorStack([]);

    if (siteChanged) {
      // Firing a read server action on mount deadlocks the next client
      // navigation (ELEVA — see reservoir/research-container precedent); this
      // only runs on an actual site change, never on mount (guarded above).
      setLoading(true);
      startTransition(() => {
        fetchKeywordFacetsAction(activeSiteId)
          .then((f) => {
            const newLang = resolveDefaultLang(primaryLanguage, f.languages);
            const newFilter: KeywordListFilter = {
              ...filterFromSearchParams({
                q: searchCommitted,
                status: statusFilter,
                theme: themeFilter,
                intent: intentFilter,
                lang: newLang,
                country: countryFilter,
                nopage: noPage,
              }),
              ...(sourceView ? { source: sourceView } : {}),
            };
            return fetchKeywordsPageAction(activeSiteId, {
              filter: newFilter,
              sort,
              limit: pageSize,
              cursor: null,
            }).then((p) => {
              // Applying `lang`/`rangeFilters` here changes `filter`'s
              // identity on the next render; pre-sync the loaded key to that
              // future identity so the resulting effect run (site no longer
              // "changed") is a no-op instead of a redundant duplicate fetch.
              loadedKeyRef.current = computeLoadKey({
                siteId: activeSiteId,
                filter: newFilter,
                sort,
                pageSize,
              });
              setFacets(f);
              setLang(newLang);
              setRangeFilters([]);
              setPage(p);
              setLoading(false);
            });
          })
          .catch(() => setLoading(false));
      });
      return;
    }

    startTransition(() => {
      fetchKeywordsPageAction(activeSiteId, {
        filter,
        sort,
        limit: pageSize,
        cursor: null,
      }).then(setPage);
    });
    // `activeSiteId` drives the branch above; `filter`/`sort`/`pageSize`
    // drive the plain refetch — both paths are handled in this one effect.
    // The extra deps below (searchCommitted/statusFilter/themeFilter/
    // intentFilter/sourceView/primaryLanguage) are exactly the inputs
    // `filter` itself is derived from, so they always change in lockstep
    // with it — redundant for correctness, satisfies the linter.
  }, [
    activeSiteId,
    filter,
    sort,
    pageSize,
    searchCommitted,
    statusFilter,
    themeFilter,
    intentFilter,
    countryFilter,
    noPage,
    sourceView,
    primaryLanguage,
    clearSelectionState,
  ]);

  const refresh = useCallback(() => {
    const cursor = cursorStack.at(-1) ?? null;
    startTransition(() => {
      Promise.all([
        fetchKeywordsPageAction(activeSiteId, {
          filter,
          sort,
          limit: pageSize,
          cursor,
        }),
        fetchKeywordFacetsAction(activeSiteId),
      ])
        .then(([p, f]) => {
          setPage(p);
          setFacets(f);
        })
        .catch(() => {});
    });
  }, [activeSiteId, filter, sort, pageSize, cursorStack]);

  function goNext() {
    const nextCursor = page.nextCursor;
    if (!nextCursor) return;
    startTransition(() => {
      fetchKeywordsPageAction(activeSiteId, {
        filter,
        sort,
        limit: pageSize,
        cursor: nextCursor,
      }).then((p) => {
        setCursorStack((s) => [...s, nextCursor]);
        setPage(p);
        clearSelectionState();
      });
    });
  }

  function goPrev() {
    if (cursorStack.length === 0) return;
    const nextStack = cursorStack.slice(0, -1);
    const cursor = nextStack.at(-1) ?? null;
    startTransition(() => {
      fetchKeywordsPageAction(activeSiteId, {
        filter,
        sort,
        limit: pageSize,
        cursor,
      }).then((p) => {
        setCursorStack(nextStack);
        setPage(p);
        clearSelectionState();
      });
    });
  }

  // --- Toolbar/chips adapter: KeywordListFilter parts ↔ ColumnFiltersState -
  const facetFilters = useMemo<ColumnFiltersState>(() => {
    const out: ColumnFiltersState = [];
    if (statusFilter)
      out.push({ id: "status", value: statusFilter.split(",") });
    if (themeFilter) out.push({ id: "theme", value: themeFilter.split(",") });
    if (intentFilter)
      out.push({ id: "intent", value: intentFilter.split(",") });
    if (countryFilter)
      out.push({ id: "country", value: countryFilter.split(",") });
    if (lang) out.push({ id: "lang", value: [lang] });
    return out;
  }, [statusFilter, themeFilter, intentFilter, countryFilter, lang]);

  const mergedFilters = useMemo<ColumnFiltersState>(
    () => [...rangeFilters, ...facetFilters],
    [rangeFilters, facetFilters],
  );

  const handleColumnFilters = useCallback(
    (next: ColumnFiltersState) => {
      void setStatusFilter(facetFromFilters(next, "status"));
      void setThemeFilter(facetFromFilters(next, "theme"));
      void setIntentFilter(facetFromFilters(next, "intent"));
      void setCountryFilter(facetFromFilters(next, "country"));
      setLang(
        ((next.find((f) => f.id === "lang")?.value as string[]) ?? [])[0] ?? "",
      );
      setRangeFilters(next.filter((f) => !NON_RANGE_FACET_IDS.has(f.id)));
    },
    [setStatusFilter, setThemeFilter, setIntentFilter, setCountryFilter],
  );

  // --- Sorting: server field ↔ TanStack SortingState ----------------------
  const sortingState = useMemo<SortingState>(
    () =>
      sort.field === "term" ||
      sort.field === "gscPosition" ||
      sort.field === "gscOpportunity"
        ? []
        : [{ id: sortFieldToColumnId(sort.field), desc: sort.desc }],
    [sort],
  );

  function handleSortingChange(next: SortingState) {
    const entry = next[0];
    const field = entry ? columnIdToSortField(entry.id) : null;
    const nextSort: KeywordSort = field
      ? { field, desc: entry.desc }
      : { field: "term", desc: false };
    void setSortParam(serializeSort(nextSort));
  }

  // --- Selection derivation ------------------------------------------------
  const selectedCount = selectAllFiltered
    ? Math.max(0, page.total - filterExcept.length)
    : Object.values(rowSelection).filter(Boolean).length;

  // Gmail-style prompt: only offer "select all N filtered results" once every
  // row on the *loaded page* is already selected (not on a partial selection).
  const canSelectAllFiltered =
    !selectAllFiltered &&
    page.rows.length > 0 &&
    selectedCount === page.rows.length &&
    page.total > page.rows.length;

  const selection: KeywordSelection = useMemo(
    () =>
      selectAllFiltered
        ? { mode: "filter", filter, except: filterExcept }
        : {
            mode: "ids",
            ids: Object.keys(rowSelection).filter((id) => rowSelection[id]),
          },
    [selectAllFiltered, filter, filterExcept, rowSelection],
  );

  const effectiveRowSelection = useMemo<RowSelectionState>(() => {
    if (!selectAllFiltered) return rowSelection;
    const out: RowSelectionState = {};
    for (const k of page.rows) out[k.id] = !filterExcept.includes(k.id);
    return out;
  }, [selectAllFiltered, rowSelection, page.rows, filterExcept]);

  function handleRowSelectionChange(updater: Updater<RowSelectionState>) {
    if (selectAllFiltered) {
      const next =
        typeof updater === "function"
          ? updater(effectiveRowSelection)
          : updater;
      setFilterExcept((prev) => {
        const nextExcept = new Set(prev);
        for (const k of page.rows) {
          if (next[k.id]) nextExcept.delete(k.id);
          else nextExcept.add(k.id);
        }
        return [...nextExcept];
      });
      return;
    }
    setRowSelection((prev) =>
      typeof updater === "function" ? updater(prev) : updater,
    );
  }

  const selectedKeywordObjs = useMemo(
    () =>
      selectAllFiltered
        ? []
        : page.rows
            .filter((k) => rowSelection[k.id])
            .map((k) => ({ id: k.id, term: k.term })),
    [selectAllFiltered, page.rows, rowSelection],
  );

  // « Mots-clés associés » : actif si EXACTEMENT 1 kw sélectionné (jamais en
  // mode « tout filtré »). Le terme du kw sélectionné devient le seed KWFinder.
  const relatedSelectedTerm =
    !selectAllFiltered && selectedCount === 1
      ? (selectedKeywordObjs[0]?.term ?? null)
      : null;
  // Ouvre le dialog pour UN kw (bouton par-ligne) : résout le terme depuis la
  // page courante par id.
  const openRelatedById = useCallback(
    (id: string) => {
      const term = page.rows.find((k) => k.id === id)?.term;
      if (term) setRelatedSeed(term);
    },
    [page.rows],
  );

  // --- Facets-derived toolbar data -----------------------------------------
  const availableSources = useMemo<KeywordSource[]>(() => {
    const order: KeywordSource[] = [
      "seranking",
      "mangools",
      "semrush",
      "ahrefs",
    ];
    const present = new Set(facets.sources);
    return order.filter((s) => present.has(s));
  }, [facets.sources]);

  const cpcRange = facets.cpcRange ?? [0, 1];

  // --- Bulk mutations --------------------------------------------------------

  /** Options sonner « Annuler » (undo) pour un toast de mutation bulk.
   *  `revert` = null quand l'inverse n'est pas connu côté client (sélection
   *  par filtre) : le toast reste sans action. */
  function undoToastOptions(revert: (() => Promise<void>) | null) {
    if (!revert) return undefined;
    return {
      // Le défaut sonner (4 s) est trop court pour décider d'annuler.
      duration: 10_000,
      action: {
        label: "Annuler",
        onClick: () => {
          void revert()
            .then(() => {
              toast.success("Annulé");
              refresh();
            })
            .catch(() => toast.error("Échec de l'annulation"));
        },
      },
    };
  }

  /** Snapshot des lignes sélectionnées (mode ids uniquement — les ids d'une
   *  sélection page vivent dans `page.rows`). null = pas d'undo fiable. */
  function selectedRowsSnapshot(): Keyword[] | null {
    if (selection.mode !== "ids") return null;
    const wanted = new Set(selection.ids);
    const rows = page.rows.filter((k) => wanted.has(k.id));
    return rows.length === wanted.size ? rows : null;
  }

  /** Regroupe des lignes par valeur précédente → une restauration bulk par
   *  groupe (≤ nb de valeurs distinctes, jamais un appel par mot-clé). */
  function groupByValue<V>(rows: Keyword[], pick: (k: Keyword) => V) {
    const groups = new Map<V, string[]>();
    for (const k of rows) {
      const v = pick(k);
      const ids = groups.get(v);
      if (ids) ids.push(k.id);
      else groups.set(v, [k.id]);
    }
    return groups;
  }

  async function setStatusForSelected(status: KeywordStatus) {
    const n = selectedCount;
    try {
      const prev = selectedRowsSnapshot();
      await setKeywordsStatusAction(activeSiteId, selection, status);
      // Undo groupé : les statuts précédents peuvent être hétérogènes — on
      // restaure par groupe de statut (≤ 4 appels), jamais par mot-clé.
      const groups = prev ? groupByValue(prev, (k) => k.status) : null;
      toast.success(
        `Statut « ${STATUS_LABELS[status]} » appliqué à ${n} mot(s)-clé(s)`,
        undoToastOptions(
          groups
            ? async () => {
                await Promise.all(
                  [...groups].map(([st, ids]) =>
                    setKeywordsStatusAction(activeSiteId, ids, st),
                  ),
                );
              }
            : null,
        ),
      );
      clearSelectionState();
      refresh();
    } catch {
      toast.error("Échec de la mise à jour du statut");
    }
  }

  async function setThemeForSelected(theme: KeywordTheme) {
    const n = selectedCount;
    try {
      const prev = selectedRowsSnapshot();
      await setKeywordsThemeAction(activeSiteId, selection, theme);
      const groups = prev ? groupByValue(prev, (k) => k.theme) : null;
      toast.success(
        `Thème appliqué à ${n} mot(s)-clé(s)`,
        undoToastOptions(
          groups
            ? async () => {
                await Promise.all(
                  [...groups].map(([th, ids]) =>
                    setKeywordsThemeAction(activeSiteId, ids, th),
                  ),
                );
              }
            : null,
        ),
      );
      clearSelectionState();
      refresh();
    } catch {
      toast.error("Échec de la mise à jour du thème");
    }
  }

  async function sendSelectedToStage(stage: "research" | "planning") {
    const n = selectedCount;
    try {
      // Union idempotente sur `stages` : l'inverse ne retire le stage qu'aux
      // ids qui ne l'avaient PAS déjà (sinon « Annuler » sortirait de l'étape
      // des mots-clés qui y étaient avant l'action).
      const undoIds =
        selection.mode === "ids"
          ? page.rows
              .filter(
                (k) =>
                  selection.ids.includes(k.id) && !k.stages.includes(stage),
              )
              .map((k) => k.id)
          : null;
      await addKeywordsToStageAction(activeSiteId, selection, stage);
      toast.success(
        `${n} mot(s)-clé(s) envoyé(s) en ${stage === "research" ? "Recherche" : "Planning"}`,
        undoToastOptions(
          undoIds && undoIds.length > 0
            ? async () => {
                // L'action de retrait est mono-id — boucle en parallèle.
                await Promise.all(
                  undoIds.map((id) =>
                    removeKeywordFromStageAction(activeSiteId, id, stage),
                  ),
                );
              }
            : null,
        ),
      );
      clearSelectionState();
      refresh();
    } catch {
      toast.error("Échec de l'envoi vers l'étape");
    }
  }

  // --- GSC sync (entrée du menu « Sync » — les entrées sync du menu
  // Importer ont été dissoutes, cf. design finale-B) -------------------------
  function handleSyncGsc() {
    startGscSync(async () => {
      const res = await syncGscKeywordsAction(activeSiteId);
      if (!res.ok) {
        toast.error(res.error);
        return;
      }
      toast.success(
        `Sync GSC : ${res.matched} positionné(s), ${res.created} importé(s) sur ${res.queries} requêtes${
          res.countries.synced > 0 ? ` · ${res.countries.synced} pays` : ""
        }`,
      );
      setSyncStatus((s) => ({ ...s, lastGscSyncAt: new Date().toISOString() }));
      refresh();
    });
  }

  async function deleteSelected() {
    try {
      await deleteKeywordsAction(activeSiteId, selection);
      clearSelectionState();
      refresh();
      toast.success("Mots-clés supprimés");
    } catch {
      toast.error("Échec de la suppression");
    }
  }

  const {
    open: openSerankingSync,
    dialog: serankingSyncDialog,
    syncing: serankingSyncing,
  } = useSerankingSync(activeSiteId, {
    onDone: () => {
      setSyncStatus((s) => ({
        ...s,
        lastSerankingSyncAt: new Date().toISOString(),
      }));
      clearSelectionState();
      refresh();
    },
  });
  function syncSeranking() {
    openSerankingSync(selection);
  }

  // --- Enrichissement Mangools (rail par-ligne : spinner + polling) -----------
  // Poll UNIQUEMENT les ids de la page courante (l'action lecture cappe à 500,
  // une page est bien en-dessous).
  const visibleIds = useMemo(() => page.rows.map((k) => k.id), [page.rows]);
  const { statuses: enrichStatuses, refresh: refreshEnrich } = useEnrichPolling(
    activeSiteId,
    visibleIds,
  );
  const {
    syncSelection: syncMangoolsSelection,
    syncOne: syncMangoolsOne,
    syncing: mangoolsSyncing,
  } = useMangoolsSync(activeSiteId, {
    onQueued: () => {
      setSyncStatus((s) => ({
        ...s,
        lastMangoolsSyncAt: new Date().toISOString(),
      }));
      // Réamorce le spinner immédiatement (le worker vient de passer les kw
      // en `queued`) sans attendre le prochain tick de poll.
      void refreshEnrich();
    },
  });
  // Menu « Enrichir via Mangools » : on passe la SÉLECTION ENTIÈRE
  // (`KeywordSelection`) — le serveur résout les ids (ids explicites, « les N
  // filtrés » en mode filter, ou tout ce qui n'a pas de métriques si rien n'est
  // coché). Auparavant le mode filter était aplati en `[]` → le serveur
  // enrichissait tout le site au lieu des N filtrés (finding F1).
  function syncMangools() {
    syncMangoolsSelection(selection);
  }

  // F2 — quand un enrichissement se TERMINE (spinner passe true→false),
  // rafraîchir la table UNE FOIS : le poll ne met à jour que le rail de statut
  // par-ligne, jamais les métriques (Volume/KD/CPC) des lignes. `didEnrichSettle`
  // ne fire qu'à la transition ; tant que rien ne repart, prev=false → pas de
  // refresh en chaîne (le refresh re-fetch les statuts, tous `done` → inactif).
  const enrichActive = hasActiveEnrich(enrichStatuses);
  const wasEnrichActiveRef = useRef(false);
  useEffect(() => {
    if (didEnrichSettle(wasEnrichActiveRef.current, enrichActive)) refresh();
    wasEnrichActiveRef.current = enrichActive;
  }, [enrichActive, refresh]);

  // --- Exports (mêmes règles que la Recherche : sélection > filtres, cap 20k) --
  async function resolveExportRows(): Promise<{
    rows: Keyword[];
    truncated: boolean;
  }> {
    if (!selectAllFiltered) {
      const ids = Object.keys(rowSelection).filter((id) => rowSelection[id]);
      if (ids.length > 0) {
        return {
          rows: page.rows.filter((k) => rowSelection[k.id]),
          truncated: false,
        };
      }
    }
    const rows = await fetchKeywordsByFilterAction(activeSiteId, filter, {
      limit: EXPORT_CAP,
      except: filterExcept,
    });
    return { rows, truncated: page.total > rows.length };
  }

  async function exportFullCsv() {
    const { rows, truncated } = await resolveExportRows();
    if (rows.length === 0) {
      toast.error("Aucun mot-clé à exporter");
      return;
    }
    if (truncated) toast.info(`Export limité aux ${EXPORT_CAP} premiers`);
    // BOM UTF-8 pour Excel ; mêmes colonnes que l'export de la Recherche.
    const blob = new Blob(["﻿", buildFullCsv(rows)], {
      type: "text/csv;charset=utf-8",
    });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `reservoir-${activeSiteId}-complet.csv`;
    a.click();
    URL.revokeObjectURL(url);
  }

  function openExportEnrichment() {
    setExportOpen(true);
    setExportLoading(true);
    setExportPool([]);
    setExportSelectedIds([]);
    // Le pool des portées est le réservoir filtré entier (pas seulement la
    // sélection) : les balayages de fraîcheur s'appliquent à tout ce que les
    // filtres actifs laissent passer.
    fetchKeywordsByFilterAction(activeSiteId, filter, { limit: EXPORT_CAP })
      .then((pool) => {
        setExportPool(pool);
        setExportSelectedIds(
          selectAllFiltered
            ? pool.filter((k) => !filterExcept.includes(k.id)).map((k) => k.id)
            : Object.keys(rowSelection).filter((id) => rowSelection[id]),
        );
        if (page.total > pool.length)
          toast.info(`Portées limitées aux ${EXPORT_CAP} premiers`);
      })
      .catch(() => toast.error("Échec du chargement des mots-clés"))
      .finally(() => setExportLoading(false));
  }

  async function deleteOne(id: string) {
    try {
      await deleteKeywordsAction(activeSiteId, [id]);
      refresh();
      toast.success("Mot-clé supprimé");
    } catch {
      toast.error("Échec de la suppression");
    }
  }

  if (loading) {
    return (
      <div className="space-y-3 p-4">
        <Skeleton className="h-9 w-full max-w-xl" />
        <Skeleton className="h-64 w-full" />
      </div>
    );
  }

  return (
    <div className="p-4">
      {/* Design panel: bande sticky (toolbar / chips / meta) + table. */}
      <ToolbarPanel>
        {/* Bande de pilotage sticky — même pattern que la Recherche : colle
            en haut du viewport pendant le scroll de page ; la table scrolle
            naturellement en dessous. border-b = l'arête basse de la bande
            (la table n'a plus de border-t). */}
        <div className="sticky top-0 z-20 rounded-t-2xl border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
          <ReservoirToolbar
            search={searchInput}
            onSearch={setSearchInput}
            columnFilters={mergedFilters}
            onColumnFilters={handleColumnFilters}
            columnVisibility={columnVisibility}
            onColumnVisibility={setColumnVisibility}
            languages={facets.languages}
            countries={facets.countries}
            sources={availableSources}
            sourceView={sourceView}
            onSourceView={(v) => setSourceView(v as KeywordSource | "")}
            noPage={noPage}
            onNoPage={(v) => void setNoPage(v || null)}
            resultCount={page.total}
            onExportFullCsv={() => void exportFullCsv()}
            onOpenExportEnrichment={openExportEnrichment}
            actions={
              <KeywordsToolbar
                siteId={activeSiteId}
                onImported={refresh}
                canImportGsc={canImportGsc}
                analyticsEnabled={analyticsEnabled}
                defaultLang={primaryLanguage}
              />
            }
            syncMenu={
              <SyncMenu
                canSyncGsc={Boolean(canImportGsc)}
                serankingEnabled={Boolean(serankingEnabled)}
                mangoolsConnected={Boolean(mangoolsConnected)}
                selectedCount={selectedCount}
                syncingGsc={syncingGsc}
                syncingSeranking={serankingSyncing}
                syncingMangools={mangoolsSyncing}
                onSyncGsc={handleSyncGsc}
                onSyncSeranking={syncSeranking}
                onSyncMangools={syncMangools}
                lastGscSyncAt={syncStatus.lastGscSyncAt}
                lastSerankingSyncAt={syncStatus.lastSerankingSyncAt}
                lastMangoolsSyncAt={syncStatus.lastMangoolsSyncAt}
              />
            }
            relatedButton={
              mangoolsConnected ? (
                <RelatedKeywordsButton
                  disabled={relatedSelectedTerm == null}
                  onClick={() => {
                    if (relatedSelectedTerm)
                      setRelatedSeed(relatedSelectedTerm);
                  }}
                />
              ) : null
            }
          />
          <ActiveFilterChips
            columnFilters={mergedFilters}
            onColumnFilters={handleColumnFilters}
          />
          <ToolbarMetaRow
            resultCount={page.total}
            filterCount={mergedFilters.length}
            pagination={{
              pageIndex,
              pageCount,
              pageSize,
              pageSizeOptions: [20, 50, 100, 500],
              onPageSizeChange: (n) => setPageSize(n),
              onPrevious: goPrev,
              onNext: goNext,
            }}
          />
        </div>
        {/* Ancre du tutoriel. `data-tour-has-content` n'est posé QU'À PARTIR
            d'une ligne : sur un réservoir vide l'attribut est absent (et non
            "false"), donc le moteur reporte le tour au lieu de pointer un
            tableau sans données. */}
        <div
          data-tour="reservoir-table"
          data-tour-has-content={page.rows.length > 0 ? "true" : undefined}
        >
          <DataTable
            columns={columns}
            data={page.rows}
            getRowId={(k) => k.id}
            columnVisibility={columnVisibility}
            rowSelection={effectiveRowSelection}
            onRowSelectionChange={handleRowSelectionChange}
            sorting={sortingState}
            onSortingChange={handleSortingChange}
            onRowClick={(k) => setOpenKeyword(k)}
            meta={{
              cpcRange,
              onDeleteKeyword: deleteOne,
              lastSelectedRef,
              globalSource: sourceView || undefined,
              onSetActiveSource: (_id, s) => setSourceView(s),
              siteId: activeSiteId,
              onTargetPageChanged: refresh,
              enrichStatuses,
              onSyncKeyword: mangoolsConnected ? syncMangoolsOne : undefined,
              onRelatedKeyword: mangoolsConnected ? openRelatedById : undefined,
            }}
            caption="Réservoir de mots-clés du site actif"
            manual
            containerClassName={`rounded-none border-0 ${
              isPending ? "opacity-60 transition-opacity" : ""
            }`}
          />
        </div>
      </ToolbarPanel>

      {/* Barre de sélection flottante — reste montée (show anime seulement)
          pour ne pas re-monter les menus ni perdre les formulaires inline. */}
      <FloatingBulkBar show={selectedCount > 0}>
        <BulkActionsBar
          count={selectedCount}
          filterMode={selectAllFiltered}
          totalFiltered={page.total}
          onSelectAllFiltered={
            canSelectAllFiltered
              ? () => {
                  setSelectAllFiltered(true);
                  setFilterExcept([]);
                }
              : undefined
          }
          onSetStatus={setStatusForSelected}
          onSetTheme={setThemeForSelected}
          onSendToResearch={() => sendSelectedToStage("research")}
          onSendToPlanning={() => sendSelectedToStage("planning")}
          onSendToContent={() => setPromoteOpen(true)}
          serankingEnabled={Boolean(serankingEnabled)}
          syncingSeranking={serankingSyncing}
          onEnrich={syncSeranking}
          onExportFullCsv={() => void exportFullCsv()}
          onExportEnrichment={openExportEnrichment}
          onDelete={deleteSelected}
          onClear={clearSelectionState}
        />
      </FloatingBulkBar>
      <KeywordSheet
        keyword={openKeyword}
        open={openKeyword !== null}
        onOpenChange={(o) => !o && setOpenKeyword(null)}
        siteId={activeSiteId}
        enableBriefs
        onSaved={refresh}
        source={sourceView || undefined}
      />
      <PromoteToContentDialog
        siteId={activeSiteId}
        keywords={selectedKeywordObjs}
        open={promoteOpen}
        onOpenChange={setPromoteOpen}
        onDone={() => {
          clearSelectionState();
          refresh();
        }}
      />
      <ExportEnrichmentDialog
        open={exportOpen}
        onOpenChange={setExportOpen}
        keywords={exportPool}
        loading={exportLoading}
        scopeSelectedIds={exportSelectedIds}
        filePrefix="reservoir"
        siteId={activeSiteId}
        onMarked={refresh}
      />
      {serankingSyncDialog}

      <RelatedKeywordsDialog
        open={relatedSeed != null}
        onOpenChange={(o) => {
          if (!o) setRelatedSeed(null);
        }}
        siteId={activeSiteId}
        seedTerm={relatedSeed}
        onAdded={refresh}
      />
    </div>
  );
}
