// src/components/keywords/research/research-container.tsx
"use client";

import type {
  ColumnFiltersState,
  RowSelectionState,
  SortingState,
  Updater,
  VisibilityState,
} from "@tanstack/react-table";
import { parseAsStringEnum, useQueryState } from "nuqs";
import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  useTransition,
} from "react";
import { toast } from "sonner";
import { PromoteToContentDialog } from "@/components/keywords/promote-content-dialog";
import { SyncMenu } from "@/components/keywords/sync-menu";
import { ViewTabs } from "@/components/keywords/view-tabs";
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog";
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,
  addToShortlistAction,
  deleteKeywordsAction,
  excludeKeywordsAction,
  fetchExclusionsAction,
  fetchKeywordFacetsAction,
  fetchKeywordsByFilterAction,
  fetchKeywordsPageAction,
  fetchShortlistAction,
  getTargetsForSiteAction,
  removeFromShortlistAction,
  removeKeywordFromStageAction,
  setKeywordTargetAction,
  setKeywordTargetsBulkAction,
} 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 type { Exclusion } from "@/lib/keywords/queries";
import {
  filterFromSearchParams,
  parseSort,
  type ResearchTab,
  researchParsers,
  resolveDefaultLang,
  serializeSort,
} from "@/lib/keywords/search-params";
import type { ShortlistItem } from "@/lib/keywords/shortlist";
import { useActiveSite } from "@/lib/keywords/site-context";
import {
  setCell,
  setForSelected,
  type TargetStatus,
  type TargetsState,
} from "@/lib/keywords/targets";
import type { Keyword, KeywordSource } from "@/lib/keywords/types";
import { DEFAULT_COLUMN_VISIBILITY } from "../columns";
import { ExportEnrichmentDialog } from "../export-enrichment-dialog";
import { KeywordSheet } from "../keyword-sheet";
import { KeywordsToolbar } from "../keywords-toolbar";
import {
  RelatedKeywordsButton,
  RelatedKeywordsDialog,
} from "../related-keywords-dialog";
import { useSerankingSync } from "../seranking-sync-dialog";
import { CoverageMatrix } from "./coverage-matrix";
import { ExclusionsPanel } from "./exclusions-panel";
import { researchColumns } from "./research-columns";
import { ResearchFilterChips, ResearchToolbar } from "./research-toolbar";
import { TargetingBar } from "./targeting-bar";

// Facets whose values live in the URL — same set as the réservoir (T4a) plus
// the GSC positioning band, recherche-specific.
const NON_RANGE_FACET_IDS = new Set<string>([
  "lang",
  "status",
  "theme",
  "intent",
  "country",
  "gscPosition",
]);

// Server-side export/matrice caps (P1) — mirror `MAX_FILTER_EXPORT` in
// actions.ts and the matrix limit from the plan (§ Task 4b step 3).
const EXPORT_CAP = 20_000;
const MATRIX_CAP = 2_000;

// Serialize a multi-value facet (TanStack stores it as string[]) to a
// comma-joined URL value; "" = unset.
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").
 *  `gscPosition` is a pass-through (column id === field). */
function sortFieldToColumnId(field: KeywordSortField): string {
  return field === "growth" ? "growthPct" : field;
}
function columnIdToSortField(id: string): KeywordSortField | null {
  if (id === "volume" || id === "kd" || id === "cpc" || id === "gscPosition")
    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;
}

/** 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);
}

const GSC_BAND_VALUES = ["all", "top10", "quickwin", "beyond", "none"] as const;
type GscBandValue = (typeof GSC_BAND_VALUES)[number];

export function ResearchContainer({
  canSync,
  countries,
  analyticsEnabled,
  serankingEnabled,
  mangoolsConnected,
  initialPage,
  initialFacets,
  initialFilter,
  initialSort,
  initialSiteId,
  initialTargets,
  initialExclusions,
  initialShortlist,
  initialSyncStatus,
}: {
  canSync?: boolean;
  countries: string[];
  analyticsEnabled?: boolean;
  /** Feature SE-Ranking configurée (undefined/false → item « Sync SE-Ranking » absent). */
  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;
  initialTargets: TargetsState;
  initialExclusions: Exclusion[];
  initialShortlist: ShortlistItem[];
  /** 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();
  const primaryLanguage = activeSite?.primaryLanguage;

  // The server already fetched this site's first page + facets + persisted
  // targeting/exclusions/shortlist (RSC initial fetch). Seed from props and
  // start non-loading — no mount fetch (site-switch full reload only, below).
  const [loading, setLoading] = useState(false);
  const [page, setPage] = useState<KeywordPage>(initialPage);
  const [facets, setFacets] = useState<KeywordFacets>(initialFacets);
  const [targets, setTargets] = useState<TargetsState>(initialTargets);
  const [exclusions, setExclusions] = useState<Exclusion[]>(initialExclusions);
  const [shortlist, setShortlist] = useState<ShortlistItem[]>(initialShortlist);
  const [syncStatus, setSyncStatus] = useState(initialSyncStatus);
  const [isPending, startTransition] = useTransition();
  const [syncing, startSync] = useTransition();
  const [syncMsg, setSyncMsg] = useState<string | null>(null);
  // anchor for shift-click range selection
  const lastSelectedRef = useRef<string | null>(null);

  // --- URL-owned filters (réservoir parsers + recherche's own) -----------
  const [searchCommitted, setSearchCommitted] = useQueryState(
    "q",
    researchParsers.q,
  );
  const [statusFilter, setStatusFilter] = useQueryState(
    "status",
    researchParsers.status,
  );
  const [themeFilter, setThemeFilter] = useQueryState(
    "theme",
    researchParsers.theme,
  );
  const [intentFilter, setIntentFilter] = useQueryState(
    "intent",
    researchParsers.intent,
  );
  const [countryFilter, setCountryFilter] = useQueryState(
    "country",
    researchParsers.country,
  );
  const [sortParam, setSortParam] = useQueryState("sort", researchParsers.sort);
  const [gscBand, setGscBand] = useQueryState("gsc", researchParsers.gsc);
  // View toggle (list/matrix) — display-only, never enters `filter`.
  const [view, setView] = useQueryState(
    "view",
    parseAsStringEnum(["list", "matrix"])
      .withDefault("list")
      .withOptions({ clearOnDefault: true }),
  );
  // Onglet de vue (Tous / Shortlist / Exclus). « shortlist » entre dans le
  // filtre serveur (filter.shortlisted) donc dans la clé de chargement ;
  // « exclus » ne touche pas au filtre (pas de table — panneau inline).
  const [tab, setTab] = useQueryState("tab", researchParsers.tab);
  // Matrice réservée à l'onglet Tous : ailleurs, rendu liste forcé.
  const effectiveView = tab === "tous" ? view : "list";

  // Compteur de l'onglet « Tous » : page.total quand le filtre courant est
  // celui partagé par Tous/Exclus (ni shortlist-scopé, ni quick-win-scopé),
  // sinon la dernière valeur connue. Deep link ?tab=shortlist|quickwins →
  // inconnue au premier rendu (badge masqué) : leur page est filtrée
  // autrement, page.total ne reflète pas le total « Tous ».
  const [allTotal, setAllTotal] = useState<number | null>(
    tab === "tous" || tab === "exclus" ? initialPage.total : null,
  );

  const sort = useMemo<KeywordSort>(
    () => (sortParam ? parseSort(sortParam) : initialSort),
    [sortParam, initialSort],
  );

  // Debounce the search box (300ms), same pattern as the réservoir.
  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 ---------
  const [lang, setLang] = useState<string>(initialFilter.lang ?? "");
  const [rangeFilters, setRangeFilters] = useState<ColumnFiltersState>([]);
  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
    ...DEFAULT_COLUMN_VISIBILITY,
    gscOpportunity: false,
  });
  const [sourceView, setSourceView] = useState<KeywordSource | "">("");

  const [openKeyword, setOpenKeyword] = useState<Keyword | null>(null);
  const [promoteOpen, setPromoteOpen] = useState(false);
  const [exportOpen, setExportOpen] = useState(false);
  // Mode « portées » (parité réservoir) : `exportKeywords` est le pool = la
  // Recherche filtrée entière (cap 20k), la sélection n'est qu'une portée.
  const [exportKeywords, setExportKeywords] = useState<Keyword[]>([]);
  const [exportSelectedIds, setExportSelectedIds] = useState<string[]>([]);
  const [exportLoading, setExportLoading] = useState(false);
  const [excludeOpen, setExcludeOpen] = useState(false);
  const [selectedCountries, setSelectedCountries] = useState<string[]>([]);

  // --- Selection: page ids, or "the N filtered results" -------------------
  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
  const [selectAllFiltered, setSelectAllFiltered] = useState(false);
  // Seed du dialog « Mots-clés associés » (terme du kw source). `null` = fermé.
  const [relatedSeed, setRelatedSeed] = useState<string | null>(null);
  const [filterExcept, setFilterExcept] = useState<string[]>([]);

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

  // --- 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 (always stage="research") ------------------
  const filter = useMemo<KeywordListFilter>(() => {
    const f: KeywordListFilter = {
      ...filterFromSearchParams({
        q: searchCommitted,
        status: statusFilter,
        theme: themeFilter,
        intent: intentFilter,
        lang,
        country: countryFilter,
      }),
      ...rangesFromColumnFilters(rangeFilters),
      stage: "research",
    };
    if (sourceView) f.source = sourceView;
    if (gscBand !== "all") f.gscBand = gscBand as KeywordListFilter["gscBand"];
    // Onglet Shortlist : filtre serveur (listing + count + byFilter). Entre
    // dans computeLoadKey via `filter` — la garde anti-refetch-au-mount
    // (loadedKeyRef) couvre donc le tab sans ref booléenne supplémentaire.
    if (tab === "shortlist") f.shortlisted = true;
    // Onglet Quick-wins GSC : la bande quick-win (11–20) est IMPOSÉE, quelle
    // que soit la facette GSC de l'URL (l'utilisateur peut avoir `?gsc=none` —
    // l'onglet gagne). Après le `if (gscBand …)` ci-dessus → il l'écrase.
    if (tab === "quickwins") f.gscBand = "quickwin";
    return f;
  }, [
    searchCommitted,
    statusFilter,
    themeFilter,
    intentFilter,
    lang,
    countryFilter,
    rangeFilters,
    sourceView,
    gscBand,
    tab,
  ]);

  // Mémorise le total « Tous » tant que le filtre reste celui partagé par
  // Tous/Exclus. Shortlist (filter.shortlisted) et Quick-wins (gscBand forcé)
  // filtrent la page autrement → page.total ne serait pas le total « Tous ».
  useEffect(() => {
    if (tab === "tous" || tab === "exclus") setAllTotal(page.total);
  }, [tab, page.total]);

  // --- Refetch orchestration -----------------------------------------------
  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, and the réservoir's
  // T4a twin of this effect). 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 — reservoir/research-container precedent); this
      // only runs on an actual site change, never on mount (guarded above).
      setLoading(true);
      startTransition(() => {
        Promise.all([
          fetchKeywordFacetsAction(activeSiteId, "research"),
          getTargetsForSiteAction(activeSiteId),
          fetchExclusionsAction(activeSiteId),
          fetchShortlistAction(activeSiteId),
        ])
          .then(([f, t, ex, sl]) => {
            const newLang = resolveDefaultLang(primaryLanguage, f.languages);
            const newFilter: KeywordListFilter = {
              ...filterFromSearchParams({
                q: searchCommitted,
                status: statusFilter,
                theme: themeFilter,
                intent: intentFilter,
                lang: newLang,
                country: countryFilter,
              }),
              stage: "research",
              ...(sourceView ? { source: sourceView } : {}),
              ...(gscBand !== "all"
                ? { gscBand: gscBand as KeywordListFilter["gscBand"] }
                : {}),
              ...(tab === "shortlist" ? { shortlisted: true } : {}),
              // Quick-wins impose la bande (écrase la facette GSC) — cf. memo
              // `filter` : le spread après le gscBand ci-dessus le remplace.
              ...(tab === "quickwins" ? { gscBand: "quickwin" as const } : {}),
            };
            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
              // (same guard as the réservoir, T4a).
              loadedKeyRef.current = computeLoadKey({
                siteId: activeSiteId,
                filter: newFilter,
                sort,
                pageSize,
              });
              setFacets(f);
              setLang(newLang);
              setRangeFilters([]);
              setTargets(t);
              setExclusions(ex);
              setShortlist(sl);
              setPage(p);
              setLoading(false);
            });
          })
          .catch(() => setLoading(false));
      });
      return;
    }

    startTransition(() => {
      fetchKeywordsPageAction(activeSiteId, {
        filter,
        sort,
        limit: pageSize,
        cursor: null,
      }).then(setPage);
    });
  }, [
    activeSiteId,
    filter,
    sort,
    pageSize,
    searchCommitted,
    statusFilter,
    themeFilter,
    intentFilter,
    countryFilter,
    sourceView,
    gscBand,
    tab,
    primaryLanguage,
    clearSelectionState,
  ]);

  // Light refresh: page + facets (status/theme not mutated here, but rows'
  // metrics/enrichment flags can change — e.g. after a save or an export mark).
  const refresh = useCallback(() => {
    const cursor = cursorStack.at(-1) ?? null;
    startTransition(() => {
      Promise.all([
        fetchKeywordsPageAction(activeSiteId, {
          filter,
          sort,
          limit: pageSize,
          cursor,
        }),
        fetchKeywordFacetsAction(activeSiteId, "research"),
      ])
        .then(([p, f]) => {
          setPage(p);
          setFacets(f);
        })
        .catch(() => {});
    });
  }, [activeSiteId, filter, sort, pageSize, cursorStack]);

  // Full refresh: also re-pulls persisted targeting + exclusions — used after
  // mutations that touch those (exclude, GSC sync, re-instate).
  const refreshAll = useCallback(() => {
    const cursor = cursorStack.at(-1) ?? null;
    startTransition(() => {
      Promise.all([
        fetchKeywordsPageAction(activeSiteId, {
          filter,
          sort,
          limit: pageSize,
          cursor,
        }),
        fetchKeywordFacetsAction(activeSiteId, "research"),
        getTargetsForSiteAction(activeSiteId),
        fetchExclusionsAction(activeSiteId),
      ])
        .then(([p, f, t, ex]) => {
          setPage(p);
          setFacets(f);
          setTargets(t);
          setExclusions(ex);
        })
        .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();
      });
    });
  }

  // --- Matrix view: fetches its own (uncapped-within-2000) rows -----------
  const [matrixRows, setMatrixRows] = useState<Keyword[]>([]);
  const [matrixLoading, setMatrixLoading] = useState(false);

  useEffect(() => {
    // La matrice n'existe que sur l'onglet Tous (rendu liste forcé ailleurs).
    if (view !== "matrix" || tab !== "tous") return;
    let cancelled = false;
    setMatrixLoading(true);
    fetchKeywordsByFilterAction(activeSiteId, filter, {
      limit: MATRIX_CAP,
      except: filterExcept,
    })
      .then((rows) => {
        if (!cancelled) setMatrixRows(rows);
      })
      .finally(() => {
        if (!cancelled) setMatrixLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [view, tab, activeSiteId, filter, filterExcept]);

  const matrixTruncated = page.total > matrixRows.length;

  function setCellStatus(
    keywordId: string,
    country: string,
    status: TargetStatus | null,
  ) {
    let snapshot: TargetsState | null = null;
    setTargets((prev) => {
      snapshot = prev;
      return setCell(prev, keywordId, country, status);
    });
    void setKeywordTargetAction(keywordId, country, status).catch(() => {
      if (snapshot) setTargets(snapshot);
      toast.error("Échec de l'enregistrement du ciblage");
    });
  }

  // --- 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] });
    if (gscBand !== "all") out.push({ id: "gscPosition", value: [gscBand] });
    return out;
  }, [statusFilter, themeFilter, intentFilter, countryFilter, lang, gscBand]);

  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] ?? "",
      );
      const band = (
        next.find((f) => f.id === "gscPosition")?.value as string[] | undefined
      )?.[0];
      void setGscBand(
        (GSC_BAND_VALUES as readonly string[]).includes(band ?? "")
          ? (band as GscBandValue)
          : "all",
      );
      setRangeFilters(next.filter((f) => !NON_RANGE_FACET_IDS.has(f.id)));
    },
    [
      setStatusFilter,
      setThemeFilter,
      setIntentFilter,
      setCountryFilter,
      setGscBand,
    ],
  );

  // --- Sorting: server field ↔ TanStack SortingState -----------------------
  const sortingState = useMemo<SortingState>(
    () =>
      sort.field === "term"
        ? []
        : [{ 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));
  }

  const oppActive = sort.field === "gscOpportunity";
  function toggleOpportunity() {
    void setSortParam(
      serializeSort(
        oppActive
          ? { field: "term", desc: false }
          : { field: "gscOpportunity", desc: true },
      ),
    );
  }

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

  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;
  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];

  // --- GSC sync --------------------------------------------------------------
  function handleSync() {
    setSyncMsg(null);
    startSync(async () => {
      const res = await syncGscKeywordsAction(activeSiteId);
      if (!res.ok) {
        setSyncMsg(res.error);
        toast.error(res.error);
        return;
      }
      setSyncMsg(
        `${res.matched} positionné(s), ${res.created} importé(s) (sur ${res.queries} requêtes GSC)${
          res.countries.synced > 0 ? ` · ${res.countries.synced} pays` : ""
        }`,
      );
      toast.success("Synchronisation GSC terminée");
      setSyncStatus((s) => ({
        ...s,
        lastGscSyncAt: new Date().toISOString(),
      }));
      refreshAll();
    });
  }

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

  // --- SE-Ranking sync --------------------------------------------------------
  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).
  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(),
      }));
      void refreshEnrich();
    },
  });
  // 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 (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]);

  // --- Targeting bar bulk actions ---------------------------------------------
  function targetSelected() {
    const targetCountries = selectedCountries;
    if (targetCountries.length === 0) return;
    if (selection.mode === "ids") {
      const ids = selection.ids;
      if (ids.length === 0) return;
      let snapshot: TargetsState | null = null;
      setTargets((prev) => {
        snapshot = prev;
        return setForSelected(prev, ids, targetCountries);
      });
      clearSelectionState();
      setKeywordTargetsBulkAction(activeSiteId, ids, targetCountries)
        .then((next) => {
          setTargets(next);
          toast.success("Ciblage enregistré");
        })
        .catch(() => {
          if (snapshot) setTargets(snapshot);
          toast.error("Échec de l'enregistrement du ciblage");
        });
      return;
    }
    // Filter mode: ids are resolved server-side — no optimistic update
    // possible (the concrete ids aren't known client-side).
    clearSelectionState();
    setKeywordTargetsBulkAction(activeSiteId, selection, targetCountries)
      .then((next) => {
        setTargets(next);
        toast.success("Ciblage enregistré");
      })
      .catch(() => toast.error("Échec de l'enregistrement du ciblage"));
  }

  async function excludeSelected() {
    setExcludeOpen(false);
    try {
      const { excluded } = await excludeKeywordsAction(activeSiteId, selection);
      toast.success(`${excluded} mot(s)-clé(s) exclu(s)`);
      clearSelectionState();
      refreshAll();
    } catch {
      toast.error("Échec de l'exclusion");
    }
  }

  async function refreshShortlist() {
    setShortlist(await fetchShortlistAction(activeSiteId));
  }

  /** Options sonner « Annuler » (undo) pour un toast de mutation bulk.
   *  `ids` = null quand la sélection est en mode filtre (ids inconnus côté
   *  client → pas d'inverse fiable) : le toast reste sans action. */
  function undoAction(
    ids: string[] | null,
    revert: (ids: string[]) => Promise<void>,
  ) {
    if (!ids || ids.length === 0) 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(ids)
            .then(() => toast.success("Annulé"))
            .catch(() => toast.error("Échec de l'annulation"));
        },
      },
    };
  }

  async function addSelectedToShortlist() {
    try {
      // L'action est idempotente (insert-ignore) : l'inverse exact ne retire
      // que les ids qui N'ÉTAIENT PAS déjà shortlistés avant l'action — sinon
      // « Annuler » détruirait une appartenance pré-existante.
      const already = new Set(shortlist.map((s) => s.id));
      const undoIds =
        selection.mode === "ids"
          ? selection.ids.filter((id) => !already.has(id))
          : null;
      const { count } = await addToShortlistAction(activeSiteId, selection);
      toast.success(
        `${count} ajouté(s) à la shortlist`,
        undoAction(undoIds, async (ids) => {
          await removeFromShortlistAction(activeSiteId, ids);
          await refreshShortlist();
          refresh();
        }),
      );
      clearSelectionState();
      await refreshShortlist();
    } catch {
      toast.error("Échec de l'ajout à la shortlist");
    }
  }
  // Onglet Shortlist : « Retirer de la shortlist » sur la sélection. En mode
  // « les N filtrés », les ids concrets sont résolus serveur (cap 20k) —
  // toutes les lignes de l'onglet sont shortlistées par construction.
  async function removeSelectedFromShortlist() {
    try {
      const ids =
        selection.mode === "ids"
          ? selection.ids
          : (
              await fetchKeywordsByFilterAction(activeSiteId, filter, {
                limit: EXPORT_CAP,
                except: filterExcept,
              })
            ).map((k) => k.id);
      if (ids.length === 0) return;
      await removeFromShortlistAction(activeSiteId, ids);
      toast.success(
        `${ids.length} retiré(s) de la shortlist`,
        // ids toujours concrets ici (résolus serveur en mode filtre).
        undoAction(ids, async (undoIds) => {
          await addToShortlistAction(activeSiteId, undoIds);
          await refreshShortlist();
          refresh();
        }),
      );
      clearSelectionState();
      await refreshShortlist();
      refresh(); // les lignes quittent le listing shortlist-filtré
    } catch {
      toast.error("Échec du retrait");
    }
  }

  async function sendSelectedToPlanning() {
    const n = selectedCount;
    if (n === 0) return;
    try {
      // Union idempotente sur `stages` : l'inverse ne retire le stage qu'aux
      // ids qui ne l'avaient PAS déjà (sinon « Annuler » sortirait du Planning
      // 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("planning"),
              )
              .map((k) => k.id)
          : null;
      await addKeywordsToStageAction(activeSiteId, selection, "planning");
      toast.success(
        `${n} mot(s)-clé(s) envoyé(s) en Planning`,
        undoAction(undoIds, async (ids) => {
          // L'action de retrait est mono-id — boucle en parallèle.
          await Promise.all(
            ids.map((id) =>
              removeKeywordFromStageAction(activeSiteId, id, "planning"),
            ),
          );
          refresh();
        }),
      );
      clearSelectionState();
      refresh();
    } catch {
      toast.error("Échec de l'envoi en Planning");
    }
  }

  // --- Exports (P1: mode ids-page → rows déjà chargées ; mode filter →
  // fetchKeywordsByFilterAction, plafonné à 20 000, toast si tronqué) --------
  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,
        };
      }
    }
    // No selection, or "select all filtered": export the whole matching set
    // (server-side), capped at 20 000. `filterExcept` is only ever non-empty
    // in "select all filtered" mode (cleared as soon as it's left — see
    // clearSelectionState), so it's safe to always pass it through.
    const rows = await fetchKeywordsByFilterAction(activeSiteId, filter, {
      limit: EXPORT_CAP,
      except: filterExcept,
    });
    return { rows, truncated: page.total > rows.length };
  }

  function downloadCsv(rows: Keyword[], suffix: "complet" | "selection") {
    // BOM UTF-8 pour Excel.
    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 = `recherche-${activeSiteId}-${suffix}.csv`;
    a.click();
    URL.revokeObjectURL(url);
  }

  // Menu Exporter de la toolbar : TOUJOURS les résultats filtrés (spec Finale B
  // — « CSV complet (N) ») ; la sélection est ignorée, c'est le rôle de la barre
  // flottante.
  async function exportFilteredCsv() {
    const rows = await fetchKeywordsByFilterAction(activeSiteId, filter, {
      limit: EXPORT_CAP,
    });
    if (rows.length === 0) {
      toast.error("Aucun mot-clé à exporter");
      return;
    }
    if (page.total > rows.length)
      toast.info(`Export limité aux ${EXPORT_CAP} premiers`);
    downloadCsv(rows, "complet");
  }

  // Barre de sélection flottante : la sélection (sélection > filtres).
  async function exportSelectionCsv() {
    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`);
    downloadCsv(rows, "selection");
  }

  function openExportEnrichment() {
    setExportOpen(true);
    setExportLoading(true);
    setExportKeywords([]);
    setExportSelectedIds([]);
    // Pool des portées = toute la Recherche filtrée (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) => {
        setExportKeywords(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));
  }

  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 (
    // [contain:inline-size] keeps the wide table (GSC + targeting columns)
    // from propagating its intrinsic width to the flex main — the page never
    // scrolls horizontally; the table scrolls inside the panel instead.
    <div className="p-4 [contain:inline-size]">
      <ToolbarPanel>
        {/* Bande de pilotage sticky (onglets + toolbar + chips + meta) : colle
            en haut du viewport pendant le scroll de page — la table, elle,
            scrolle naturellement (pas de bloc à hauteur plafonnée ; un thead
            sticky page-level est impossible avec le scroll horizontal interne,
            le conteneur overflow piégeant le sticky). Fond quasi opaque +
            blur pour masquer les lignes qui défilent dessous ; border-b =
            l'arête basse de la bande (les sections en dessous n'ont plus de
            border-t pour éviter le double filet au repos). */}
        <div className="sticky top-0 z-20 rounded-t-2xl border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
          <ViewTabs
            className="px-2"
            aria-label="Vues de la recherche"
            value={tab}
            onValueChange={(t) => {
              const next = t as ResearchTab;
              // Tri par défaut lié à l'onglet Quick-wins : à l'entrée, force
              // gscOpportunity desc ; à la sortie, si le tri est encore ce
              // forçage, remets le défaut (term asc). Les deux setters nuqs
              // (sort + tab) batchent dans le même render → un seul recalcul de
              // computeLoadKey → un seul refetch pour la bascule.
              if (next === "quickwins") {
                void setSortParam(
                  serializeSort({ field: "gscOpportunity", desc: true }),
                );
              } else if (
                tab === "quickwins" &&
                sort.field === "gscOpportunity" &&
                sort.desc
              ) {
                void setSortParam(
                  serializeSort({ field: "term", desc: false }),
                );
              }
              void setTab(next);
              clearSelectionState();
            }}
            tabs={[
              { id: "tous", label: "Tous", count: allTotal ?? undefined },
              { id: "shortlist", label: "Shortlist", count: shortlist.length },
              { id: "exclus", label: "Exclus", count: exclusions.length },
              {
                id: "quickwins",
                label: "Quick-wins GSC",
                count: facets.quickwinCount,
              },
            ]}
          />
          <ResearchToolbar
            search={searchInput}
            onSearch={setSearchInput}
            columnFilters={mergedFilters}
            onColumnFilters={handleColumnFilters}
            columnVisibility={columnVisibility}
            onColumnVisibility={setColumnVisibility}
            languages={facets.languages}
            countries={facets.countries}
            view={view}
            onViewChange={(v) => void setView(v)}
            showViewToggle={tab === "tous"}
            quickwinsLocked={tab === "quickwins"}
            oppActive={oppActive}
            onToggleOpportunity={toggleOpportunity}
            resultCount={page.total}
            onExportFullCsv={() => void exportFilteredCsv()}
            onOpenExportEnrichment={openExportEnrichment}
            sources={availableSources}
            sourceView={sourceView}
            onSourceView={(v) => setSourceView(v as KeywordSource | "")}
            importButton={
              <KeywordsToolbar
                siteId={activeSiteId}
                onImported={refresh}
                canImportGsc={canSync}
                analyticsEnabled={analyticsEnabled}
                defaultLang={primaryLanguage}
              />
            }
            syncMenu={
              <SyncMenu
                canSyncGsc={Boolean(canSync)}
                serankingEnabled={Boolean(serankingEnabled)}
                mangoolsConnected={Boolean(mangoolsConnected)}
                selectedCount={selectedCount}
                syncingGsc={syncing}
                syncingSeranking={serankingSyncing}
                syncingMangools={mangoolsSyncing}
                onSyncGsc={handleSync}
                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
            }
          />
          <ResearchFilterChips
            columnFilters={mergedFilters}
            onColumnFilters={handleColumnFilters}
          />
          {syncMsg ? (
            <div className="border-b px-4 py-2 text-muted-foreground text-xs">
              {syncMsg}
            </div>
          ) : null}
          {tab !== "exclus" ? (
            <ToolbarMetaRow
              resultCount={page.total}
              filterCount={mergedFilters.length}
              pagination={
                // The matrix doesn't paginate — meta row keeps the count only.
                effectiveView === "list"
                  ? {
                      pageIndex,
                      pageCount,
                      pageSize,
                      pageSizeOptions: [20, 50, 100, 500],
                      onPageSizeChange: (n) => setPageSize(n),
                      onPrevious: goPrev,
                      onNext: goNext,
                    }
                  : undefined
              }
            />
          ) : null}
        </div>
        {tab === "exclus" ? (
          // Les exclus ne sont pas des lignes keywords (slugs dans
          // keyword_exclusions) : panneau inline à la place de la table.
          <ExclusionsPanel
            exclusions={exclusions}
            siteId={activeSiteId}
            onChanged={refreshAll}
          />
        ) : effectiveView === "list" ? (
          // Ancre du tutoriel. `data-tour-has-content` n'est posé QU'À PARTIR
          // d'une ligne : sur une recherche vide l'attribut est absent (et non
          // "false"), donc le moteur reporte le tour.
          <div
            data-tour="research-table"
            data-tour-has-content={page.rows.length > 0 ? "true" : undefined}
          >
            <DataTable
              columns={researchColumns}
              data={page.rows}
              getRowId={(k) => k.id}
              columnFilters={mergedFilters}
              columnVisibility={columnVisibility}
              rowSelection={effectiveRowSelection}
              onRowSelectionChange={handleRowSelectionChange}
              onRowClick={(k) => setOpenKeyword(k)}
              sorting={sortingState}
              onSortingChange={handleSortingChange}
              meta={{
                cpcRange,
                targets,
                countries,
                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="Recherche — mots-clés en étape recherche du site actif"
              manual
              // Wider than the reservoir table (GSC + targeting columns): keep
              // the default scrollable table container instead of the sticky
              // header's overflow-visible variant, so the page never scrolls
              // horizontally — the table scrolls inside the panel. Le sticky au
              // scroll de page = la bande de pilotage au-dessus (pas le thead).
              // Pas de border-t : l'arête vient du border-b de la bande sticky.
              containerClassName={`rounded-none border-0 ${
                isPending ? "opacity-60 transition-opacity" : ""
              }`}
            />
          </div>
        ) : (
          // Pas de border-t : l'arête vient du border-b de la bande sticky.
          <div className="p-4">
            <CoverageMatrix
              keywords={matrixRows}
              countries={countries}
              targets={targets}
              onSetCell={setCellStatus}
              truncated={matrixTruncated}
              emptyLabel={
                matrixLoading
                  ? "Chargement…"
                  : facets.total > 0
                    ? "Aucun résultat pour ces filtres."
                    : "Aucun keyword pour ce site."
              }
            />
          </div>
        )}
      </ToolbarPanel>

      {/* Barre de sélection flottante — reste montée (show anime seulement)
          pour ne pas re-monter les DropdownMenu enfants. Visible seulement en
          vue liste (la matrice ne sélectionne pas de lignes) et hors onglet
          Exclus (pas de table — sélection cleared au changement d'onglet). */}
      <FloatingBulkBar
        show={selectedCount > 0 && tab !== "exclus" && effectiveView === "list"}
      >
        <TargetingBar
          count={selectedCount}
          filterMode={selectAllFiltered}
          totalFiltered={page.total}
          onSelectAllFiltered={
            canSelectAllFiltered
              ? () => {
                  setSelectAllFiltered(true);
                  setFilterExcept([]);
                }
              : undefined
          }
          selectedCountries={selectedCountries}
          onSelectedCountriesChange={setSelectedCountries}
          onTarget={targetSelected}
          onExclude={() => setExcludeOpen(true)}
          shortlistMode={tab === "shortlist"}
          onAddToShortlist={() => void addSelectedToShortlist()}
          onRemoveFromShortlist={() => void removeSelectedFromShortlist()}
          onSendToPlanning={() => void sendSelectedToPlanning()}
          onSendToContent={() => setPromoteOpen(true)}
          serankingEnabled={Boolean(serankingEnabled)}
          syncingSeranking={serankingSyncing}
          onEnrich={syncSeranking}
          onExportSelectionCsv={() => void exportSelectionCsv()}
          onExportEnrichment={openExportEnrichment}
          onClear={clearSelectionState}
        />
      </FloatingBulkBar>

      <KeywordSheet
        keyword={openKeyword}
        open={openKeyword !== null}
        onOpenChange={(o) => !o && setOpenKeyword(null)}
        siteId={activeSiteId}
        enableBriefs
        source={sourceView || undefined}
        onSaved={refresh}
      />

      <AlertDialog open={excludeOpen} onOpenChange={setExcludeOpen}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>
              Exclure {selectedCount} mot(s)-clé(s) ?
            </AlertDialogTitle>
            <AlertDialogDescription>
              Ils sont supprimés et ne reviendront pas au prochain sync GSC.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>Annuler</AlertDialogCancel>
            <AlertDialogAction onClick={excludeSelected}>
              Exclure
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>

      <ExportEnrichmentDialog
        open={exportOpen}
        onOpenChange={setExportOpen}
        keywords={exportKeywords}
        loading={exportLoading}
        scopeSelectedIds={exportSelectedIds}
        siteId={activeSiteId}
        onMarked={refresh}
      />

      <PromoteToContentDialog
        siteId={activeSiteId}
        keywords={selectedKeywordObjs}
        open={promoteOpen}
        onOpenChange={setPromoteOpen}
        onDone={() => {
          clearSelectionState();
          refresh();
        }}
      />
      {serankingSyncDialog}

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