'use client';

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Block from './Block.client.js';
import SlashMenu, { getSlashItems } from './SlashMenu.client.js';
import InlineToolbar from './inline/Toolbar.client.js';
import LinkPopover from './inline/LinkPopover.client.js';
import { getBlockDef, DEFAULT_BLOCK_TYPE } from './blockRegistry.js';
import { registerBuiltInBlocks } from './blockTypes/index.js';
import {
  inlineLength,
  inlineToPlainText,
  inlineFromText,
  sliceInline,
  concatInline,
  toggleMark,
  setMark,
  removeMark,
  removeAllMarks,
  marksInRange,
  linkRangeAt,
  collectUsedColors,
} from './inline/types.js';
import { blocksToHtml, blocksToPlainText, htmlToBlocks } from './inline/clipboard.js';
import { newBlockId } from './utils/ids.js';

registerBuiltInBlocks();

const UNDO_DEBOUNCE_MS = 600;
const MAX_HISTORY = 50;

// Mappe les préfixes markdown à un type de bloc.
const SHORTCUT_TO_TYPE = {
  '# ': 'heading_1',
  '## ': 'heading_2',
  '### ': 'heading_3',
  '#### ': 'heading_4',
  '##### ': 'heading_5',
  '###### ': 'heading_6',
  '- ': 'bullet_item',
  '1. ': 'numbered_item',
  '> ': 'quote',
  '```': 'code',
  '---': 'divider',
  '[] ': 'checklist',
  '[ ] ': 'checklist',
};

function makeBlock(type, init) {
  const def = getBlockDef(type) || getBlockDef(DEFAULT_BLOCK_TYPE);
  return def.create(init || {});
}

function sameSet(a, b) {
  if (a === b) return true;
  if (a.size !== b.size) return false;
  for (const v of a) if (!b.has(v)) return false;
  return true;
}

function ensureNonEmpty(blocks) {
  if (!Array.isArray(blocks) || blocks.length === 0) {
    return [makeBlock(DEFAULT_BLOCK_TYPE)];
  }
  return blocks;
}

export default function BlockEditor({
  value,
  onChange,
  label,
  error,
  disabled = false,
  className = '',
  enabledBlocks,
  minHeight,
}) {
  const blocks = useMemo(() => ensureNonEmpty(value), [value]);
  const blockRefs = useRef(new Map());
  const containerRef = useRef(null);
  const outerRef = useRef(null);
  const [focusBlockId, setFocusBlockId] = useState(null);
  const [focusOffset, setFocusOffset] = useState(null);
  const [selectedBlockIds, setSelectedBlockIds] = useState(() => new Set());

  function clearBlockSelection() {
    setSelectedBlockIds(prev => (prev.size === 0 ? prev : new Set()));
  }

  function selectAllBlocks() {
    setSelectedBlockIds(new Set(blocks.map(b => b.id)));
    const sel = typeof window !== 'undefined' ? window.getSelection() : null;
    sel?.removeAllRanges();
    if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
  }

  function selectBlock(id) {
    setSelectedBlockIds(new Set([id]));
    const sel = typeof window !== 'undefined' ? window.getSelection() : null;
    sel?.removeAllRanges();
    if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
  }

  function deleteSelectedBlocks() {
    if (selectedBlockIds.size === 0) return;
    const next = blocks.filter(b => !selectedBlockIds.has(b.id));
    const finalNext = next.length === 0 ? [makeBlock(DEFAULT_BLOCK_TYPE)] : next;
    commitChange(finalNext, { immediate: true });
    setSelectedBlockIds(new Set());
    setFocusBlockId(finalNext[0].id);
    setFocusOffset(0);
  }

  // --- Undo / Redo ---
  const [undoStack, setUndoStack] = useState([]);
  const [redoStack, setRedoStack] = useState([]);
  const undoDebounceRef = useRef(null);

  function pushUndo(prev) {
    setUndoStack(s => {
      const next = [...s, prev];
      return next.length > MAX_HISTORY ? next.slice(1) : next;
    });
    setRedoStack([]);
  }

  function commitChange(next, opts = {}) {
    if (opts.immediate) {
      if (undoDebounceRef.current) {
        clearTimeout(undoDebounceRef.current);
        undoDebounceRef.current = null;
      }
      pushUndo(blocks);
    } else {
      if (!undoDebounceRef.current) {
        const snapshot = blocks;
        undoDebounceRef.current = setTimeout(() => {
          pushUndo(snapshot);
          undoDebounceRef.current = null;
        }, UNDO_DEBOUNCE_MS);
      }
    }
    onChange?.(next);
  }

  function handleUndo() {
    if (undoStack.length === 0) return;
    if (undoDebounceRef.current) {
      clearTimeout(undoDebounceRef.current);
      undoDebounceRef.current = null;
    }
    const prev = undoStack[undoStack.length - 1];
    setRedoStack(r => [...r, blocks]);
    setUndoStack(s => s.slice(0, -1));
    onChange?.(prev);
  }

  function handleRedo() {
    if (redoStack.length === 0) return;
    if (undoDebounceRef.current) {
      clearTimeout(undoDebounceRef.current);
      undoDebounceRef.current = null;
    }
    const next = redoStack[redoStack.length - 1];
    setUndoStack(u => [...u, blocks]);
    setRedoStack(r => r.slice(0, -1));
    onChange?.(next);
  }

  useEffect(() => () => {
    if (undoDebounceRef.current) clearTimeout(undoDebounceRef.current);
  }, []);

  // --- Numérotation des items numérotés (consécutifs) ---
  const numberedIndexByBlockId = useMemo(() => {
    const map = new Map();
    let n = 0;
    for (const b of blocks) {
      if (b.type === 'numbered_item') {
        n += 1;
        map.set(b.id, n);
      } else {
        n = 0;
      }
    }
    return map;
  }, [blocks]);

  // --- Focus impératif sur un bloc après mutation ---
  useEffect(() => {
    if (!focusBlockId) return;
    const ref = blockRefs.current.get(focusBlockId);
    if (ref?.focus) {
      ref.focus(focusOffset);
    }
    setFocusBlockId(null);
    setFocusOffset(null);
  }, [focusBlockId, focusOffset, blocks]);

  function setBlockRef(id, ref) {
    if (ref) blockRefs.current.set(id, ref);
    else blockRefs.current.delete(id);
  }

  // --- Mutations ---
  function removeBlock(id, focusPrev = true) {
    const idx = blocks.findIndex(b => b.id === id);
    if (idx < 0) return;
    const next = blocks.filter(b => b.id !== id);
    const finalNext = next.length === 0 ? [makeBlock(DEFAULT_BLOCK_TYPE)] : next;
    commitChange(finalNext, { immediate: true });
    if (focusPrev) {
      const prev = idx > 0 ? blocks[idx - 1] : finalNext[0];
      setFocusBlockId(prev.id);
      setFocusOffset(inlineLength(prev.content));
    }
  }

  // --- Évènements provenant des blocs ---
  function handleContentChange(id, content) {
    const next = blocks.map(b => (b.id === id ? { ...b, content } : b));
    commitChange(next);
  }

  // Patch arbitraire d'un bloc (ex : checklist `checked`, image `src`).
  function handleBlockPatch(id, patch) {
    const next = blocks.map(b => (b.id === id ? { ...b, ...patch } : b));
    commitChange(next, { immediate: true });
  }

  function handleEnter({ blockId, offset, content }) {
    const idx = blocks.findIndex(b => b.id === blockId);
    if (idx < 0) return;
    const current = blocks[idx];
    const liveContent = content ?? current.content ?? [];

    // Sur un item de liste / checklist vide, sortir en paragraphe
    const isListLike =
      current.type === 'bullet_item' ||
      current.type === 'numbered_item' ||
      current.type === 'checklist';
    if (isListLike && inlineLength(liveContent) === 0) {
      const replaced = makeBlock(DEFAULT_BLOCK_TYPE);
      const next = blocks.map(b => (b.id === blockId ? replaced : b));
      commitChange(next, { immediate: true });
      setFocusBlockId(replaced.id);
      setFocusOffset(0);
      return;
    }

    const before = sliceInline(liveContent, 0, offset);
    const after = sliceInline(liveContent, offset, inlineLength(liveContent));

    const updated = { ...current, content: before };
    // Pour la checklist, le nouvel item garde le type mais remet `checked: false`.
    let newType;
    let newInit = { content: after };
    if (current.type === 'bullet_item' || current.type === 'numbered_item') {
      newType = current.type;
    } else if (current.type === 'checklist') {
      newType = 'checklist';
      newInit = { content: after, checked: false };
    } else {
      newType = DEFAULT_BLOCK_TYPE;
    }
    const newBlock = makeBlock(newType, newInit);

    const next = [
      ...blocks.slice(0, idx),
      updated,
      newBlock,
      ...blocks.slice(idx + 1),
    ];
    commitChange(next, { immediate: true });
    setFocusBlockId(newBlock.id);
    setFocusOffset(0);
  }

  // Paste « inline » : un seul paragraphe collé → splice du contenu au caret
  // sans toucher à la liste des blocs (préserve le type du bloc courant).
  function handlePasteInline({ blockId, inline }) {
    const idx = blocks.findIndex(b => b.id === blockId);
    if (idx < 0) return;
    if (!Array.isArray(inline) || inline.length === 0) return;
    const ref = blockRefs.current.get(blockId);
    const range = ref?.getCaretRange?.();
    const current = blocks[idx];
    const c = current.content ?? [];
    const total = inlineLength(c);
    const start = Math.min(range?.start ?? total, total);
    const end = Math.min(Math.max(range?.end ?? start, start), total);
    const before = sliceInline(c, 0, start);
    const after = sliceInline(c, end, total);
    const merged = concatInline(before, concatInline(inline, after));
    const next = blocks.map(b => (b.id === blockId ? { ...b, content: merged } : b));
    commitChange(next, { immediate: true });
    setFocusBlockId(blockId);
    setFocusOffset(start + inlineLength(inline));
  }

  // Paste multi-blocs : on coupe le bloc courant en deux, on insère les
  // blocs collés au milieu, et on fusionne les paragraphes en tête/queue
  // si la première/dernière entrée collée est elle-même un paragraphe
  // (le bloc courant absorbe / s'absorbe dans le contenu paragraphe).
  function handlePasteBlocks({ blockId, blocks: pasted }) {
    const idx = blocks.findIndex(b => b.id === blockId);
    if (idx < 0) return;
    if (!Array.isArray(pasted) || pasted.length === 0) return;
    const ref = blockRefs.current.get(blockId);
    const range = ref?.getCaretRange?.();
    const current = blocks[idx];
    const c = current.content ?? [];
    const total = inlineLength(c);
    const start = Math.min(range?.start ?? total, total);
    const end = Math.min(Math.max(range?.end ?? start, start), total);
    const before = sliceInline(c, 0, start);
    const after = sliceInline(c, end, total);

    const list = pasted.map(b => ({ ...b }));

    // Fusion tête.
    if (list[0].type === 'paragraph' && inlineLength(before) > 0) {
      list[0] = {
        ...list[0],
        content: concatInline(before, list[0].content ?? []),
      };
    } else if (inlineLength(before) > 0) {
      list.unshift({ ...current, id: newBlockId(), content: before });
    }

    // Fusion queue.
    const lastIdx = list.length - 1;
    const last = list[lastIdx];
    if (last.type === 'paragraph' && inlineLength(after) > 0) {
      list[lastIdx] = {
        ...last,
        content: concatInline(last.content ?? [], after),
      };
    } else if (inlineLength(after) > 0) {
      list.push({ ...current, id: newBlockId(), content: after });
    }

    const next = [...blocks.slice(0, idx), ...list, ...blocks.slice(idx + 1)];
    commitChange(next, { immediate: true });

    // Focus à la fin du dernier bloc collé.
    const target = list[list.length - 1];
    setFocusBlockId(target.id);
    if (target.content) setFocusOffset(inlineLength(target.content));
    else setFocusOffset(0);
  }

  function handleBackspaceAtStart({ blockId, content }) {
    const idx = blocks.findIndex(b => b.id === blockId);
    if (idx < 0) return;
    const current = blocks[idx];
    const liveContent = content ?? current.content ?? [];

    // Si le bloc est typé (heading, list, quote, code, checklist) et non
    // vide → repasse en paragraphe sans rien supprimer.
    if (current.type !== DEFAULT_BLOCK_TYPE) {
      const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: liveContent });
      const next = blocks.map(b => (b.id === blockId ? replaced : b));
      commitChange(next, { immediate: true });
      setFocusBlockId(replaced.id);
      setFocusOffset(0);
      return;
    }

    // Sinon : merge avec le bloc précédent s'il est texte
    if (idx === 0) return;
    const prev = blocks[idx - 1];
    const prevDef = getBlockDef(prev.type);
    if (!prevDef?.isText) {
      // précédent non-texte : on supprime juste le bloc courant
      removeBlock(blockId, true);
      return;
    }
    const mergedOffset = inlineLength(prev.content);
    const merged = { ...prev, content: concatInline(prev.content ?? [], liveContent) };
    const next = [...blocks.slice(0, idx - 1), merged, ...blocks.slice(idx + 1)];
    commitChange(next, { immediate: true });
    setFocusBlockId(merged.id);
    setFocusOffset(mergedOffset);
  }

  function handleShortcutMatch({ blockId, prefix, rest }) {
    const newType = SHORTCUT_TO_TYPE[prefix.trimEnd()] || SHORTCUT_TO_TYPE[prefix];
    if (!newType) return;
    const def = getBlockDef(newType);
    if (!def) return;

    if (newType === 'divider') {
      // Insère un divider et place le caret dans un nouveau paragraphe vide
      const idx = blocks.findIndex(b => b.id === blockId);
      if (idx < 0) return;
      const divider = makeBlock('divider');
      const after = makeBlock(DEFAULT_BLOCK_TYPE);
      const next = [
        ...blocks.slice(0, idx),
        divider,
        after,
        ...blocks.slice(idx + 1),
      ];
      commitChange(next, { immediate: true });
      setFocusBlockId(after.id);
      setFocusOffset(0);
      return;
    }

    const replaced = def.create({ content: inlineFromText(rest ?? '') });
    const next = blocks.map(b => (b.id === blockId ? replaced : b));
    commitChange(next, { immediate: true });
    setFocusBlockId(replaced.id);
    setFocusOffset(0);
  }

  // --- Slash menu ---
  const [slashState, setSlashState] = useState(null);
  // { blockId, query, anchorRect, selectedIndex }

  function openSlashFor(blockId, query = '') {
    const ref = blockRefs.current.get(blockId);
    const el = ref?.getElement?.();
    if (!el) return;
    const rect = el.getBoundingClientRect();
    setSlashState(prev => ({
      blockId,
      query,
      anchorRect: rect,
      selectedIndex: (prev?.blockId === blockId && prev?.query === query) ? (prev.selectedIndex ?? 0) : 0,
    }));
  }

  function handleSlashOpen({ blockId, query }) {
    openSlashFor(blockId, query);
  }

  function handleSlashClose() {
    setSlashState(null);
  }

  function handleSlashSelect(type) {
    if (!slashState) return;
    const { blockId } = slashState;
    const def = getBlockDef(type);
    if (!def) return;

    const idx = blocks.findIndex(b => b.id === blockId);
    if (idx < 0) return;
    const current = blocks[idx];

    if (def.isText) {
      // Remplace le bloc courant en gardant son contenu (purgé du / de query)
      const text = inlineToPlainText(current.content ?? []).replace(/^\/\S*/, '');
      const replaced = def.create({ content: inlineFromText(text) });
      const next = blocks.map(b => (b.id === blockId ? replaced : b));
      commitChange(next, { immediate: true });
      setFocusBlockId(replaced.id);
      setFocusOffset(text.length);
    } else {
      // Bloc non-texte : insère après et nettoie le bloc courant
      const text = inlineToPlainText(current.content ?? []).replace(/^\/\S*/, '');
      const cleared = { ...current, content: inlineFromText(text) };
      const inserted = def.create();
      const after =
        text.length === 0
          ? // remplacer le bloc courant par le bloc non-texte puis ajouter un paragraphe vide
            [
              ...blocks.slice(0, idx),
              inserted,
              makeBlock(DEFAULT_BLOCK_TYPE),
              ...blocks.slice(idx + 1),
            ]
          : [
              ...blocks.slice(0, idx),
              cleared,
              inserted,
              makeBlock(DEFAULT_BLOCK_TYPE),
              ...blocks.slice(idx + 1),
            ];
      commitChange(after, { immediate: true });
    }
    setSlashState(null);
  }

  function handleTransformBlock(blockId, newType) {
    const idx = blocks.findIndex(b => b.id === blockId);
    if (idx < 0) return;
    const current = blocks[idx];
    const def = getBlockDef(newType);
    if (!def) return;
    const created = def.create(def.isText ? { content: current.content ?? [] } : {});
    const next = blocks.map(b => (b.id === blockId ? created : b));
    commitChange(next, { immediate: true });
    if (def.isText) {
      setFocusBlockId(created.id);
      setFocusOffset(inlineLength(current.content ?? []));
    }
  }

  function handleDuplicateBlock(blockId) {
    const idx = blocks.findIndex(b => b.id === blockId);
    if (idx < 0) return;
    const copy = { ...blocks[idx], id: newBlockId() };
    const next = [...blocks.slice(0, idx + 1), copy, ...blocks.slice(idx + 1)];
    commitChange(next, { immediate: true });
    setFocusBlockId(copy.id);
    setFocusOffset(inlineLength(copy.content ?? []));
  }

  function handleDeleteBlock(blockId) {
    removeBlock(blockId, true);
  }

  function handleInsertBlock(blockId, type) {
    const idx = blocks.findIndex(b => b.id === blockId);
    if (idx < 0) return;
    const def = getBlockDef(type);
    if (!def) return;
    const inserted = def.create();
    const next = [...blocks.slice(0, idx + 1), inserted, ...blocks.slice(idx + 1)];
    commitChange(next, { immediate: true });
    if (def.isText) {
      setFocusBlockId(inserted.id);
      setFocusOffset(0);
    }
  }

  // --- Drag & drop ---
  const [dragOver, setDragOver] = useState(null); // { blockId, position: 'top'|'bottom' }

  function handleDragOver(blockId, position) {
    setDragOver({ blockId, position });
  }

  function handleDragLeave() {
    // ne rien faire de spécial : le prochain dragOver écrasera l'état
  }

  function handleDragEnd() {
    setDragOver(null);
  }

  function handleDrop(sourceId, targetId) {
    setDragOver(null);
    if (sourceId === targetId) return;
    const sourceIdx = blocks.findIndex(b => b.id === sourceId);
    const targetIdx = blocks.findIndex(b => b.id === targetId);
    if (sourceIdx < 0 || targetIdx < 0) return;
    const position = dragOver?.position || 'bottom';
    const without = blocks.filter(b => b.id !== sourceId);
    let insertIdx = without.findIndex(b => b.id === targetId);
    if (position === 'bottom') insertIdx += 1;
    const moved = blocks[sourceIdx];
    const next = [...without.slice(0, insertIdx), moved, ...without.slice(insertIdx)];
    commitChange(next, { immediate: true });
  }

  // --- Toolbar de formatage inline ---
  // Ancrée au-dessus de la sélection courante quand elle est non-vide et
  // qu'elle se trouve dans un contentEditable de l'éditeur.
  const [toolbar, setToolbar] = useState(null);
  // { blockId, start, end, rect }
  const toolbarPinnedRef = useRef(false);
  const [linkPopover, setLinkPopover] = useState(null);
  // { rect: DOMRect, mark, blockId, start, end }

  // Couleurs déjà utilisées dans le document (hors palette par défaut).
  // Présentées comme choix rapides dans le popover de couleur du toolbar.
  const usedColors = useMemo(() => collectUsedColors(blocks), [blocks]);

  const updateToolbar = useCallback(() => {
    if (disabled) {
      setToolbar(null);
      return;
    }
    // Quand un popover du toolbar est ouvert (lien, couleur…), le focus
    // passe sur l'input/bouton et la sélection peut paraître hors-bloc :
    // on garde la toolbar visible.
    if (toolbarPinnedRef.current) return;
    if (typeof document !== 'undefined') {
      const active = document.activeElement;
      if (active instanceof Element && active.closest('[data-inline-toolbar]')) return;
    }
    const sel = typeof window !== 'undefined' ? window.getSelection() : null;
    if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
      setToolbar(null);
      return;
    }
    const range = sel.getRangeAt(0);
    const container = containerRef.current;
    if (!container || !container.contains(range.startContainer) || !container.contains(range.endContainer)) {
      setToolbar(null);
      return;
    }
    // Trouve le bloc qui contient la sélection (les deux extrémités doivent
    // être dans le même bloc).
    const startEl = range.startContainer.nodeType === 1
      ? range.startContainer
      : range.startContainer.parentElement;
    const blockEl = startEl?.closest?.('[data-block-id]');
    if (!blockEl) { setToolbar(null); return; }
    const endEl = range.endContainer.nodeType === 1
      ? range.endContainer
      : range.endContainer.parentElement;
    const endBlockEl = endEl?.closest?.('[data-block-id]');
    if (!endBlockEl || endBlockEl !== blockEl) { setToolbar(null); return; }

    const blockId = blockEl.getAttribute('data-block-id');
    const ref = blockRefs.current.get(blockId);
    const r = ref?.getCaretRange?.();
    if (!r || r.start === r.end) { setToolbar(null); return; }
    const vpRect = range.getBoundingClientRect();
    const cRect = container.getBoundingClientRect();
    const rect = {
      top: vpRect.top - cRect.top,
      left: vpRect.left - cRect.left,
      bottom: vpRect.bottom - cRect.top,
      width: vpRect.width,
      height: vpRect.height,
    };
    setLinkPopover(null);
    setToolbar({ blockId, start: r.start, end: r.end, rect });
  }, [disabled]);

  useEffect(() => {
    function onSel() { updateToolbar(); }
    document.addEventListener('selectionchange', onSel);
    return () => document.removeEventListener('selectionchange', onSel);
  }, [updateToolbar]);

  function applyToggleMark(mark) {
    if (!toolbar) return;
    const { blockId, start, end } = toolbar;
    const block = blocks.find(b => b.id === blockId);
    if (!block) return;
    const next = toggleMark(block.content ?? [], start, end, mark);
    const nextBlocks = blocks.map(b => (b.id === blockId ? { ...b, content: next } : b));
    commitChange(nextBlocks, { immediate: true });
    requestAnimationFrame(() => {
      const ref = blockRefs.current.get(blockId);
      ref?.setCaretRange?.(start, end);
    });
  }

  function applySetMark(mark) {
    if (!toolbar) return;
    const { blockId, start, end } = toolbar;
    const block = blocks.find(b => b.id === blockId);
    if (!block) return;
    const next = setMark(block.content ?? [], start, end, mark);
    const nextBlocks = blocks.map(b => (b.id === blockId ? { ...b, content: next } : b));
    commitChange(nextBlocks, { immediate: true });
    requestAnimationFrame(() => {
      const ref = blockRefs.current.get(blockId);
      ref?.setCaretRange?.(start, end);
    });
  }

  function handleLinkPopoverSet(href, newTab) {
    if (!linkPopover) return;
    const { blockId, start, end } = linkPopover;
    const block = blocks.find(b => b.id === blockId);
    if (!block) return;
    const mark = { type: 'link', href, ...(newTab ? { newTab: true } : {}) };
    const next = setMark(block.content ?? [], start, end, mark);
    const nextBlocks = blocks.map(b => (b.id === blockId ? { ...b, content: next } : b));
    commitChange(nextBlocks, { immediate: true });
    setLinkPopover(null);
  }

  function handleLinkPopoverRemove() {
    if (!linkPopover) return;
    const { blockId, start, end } = linkPopover;
    const block = blocks.find(b => b.id === blockId);
    if (!block) return;
    const next = removeMark(block.content ?? [], start, end, 'link');
    const nextBlocks = blocks.map(b => (b.id === blockId ? { ...b, content: next } : b));
    commitChange(nextBlocks, { immediate: true });
    setLinkPopover(null);
  }

  function applyRemoveAllMarks() {
    if (!toolbar) return;
    const { blockId, start, end } = toolbar;
    const block = blocks.find(b => b.id === blockId);
    if (!block) return;
    const next = removeAllMarks(block.content ?? [], start, end);
    const nextBlocks = blocks.map(b => (b.id === blockId ? { ...b, content: next } : b));
    commitChange(nextBlocks, { immediate: true });
    requestAnimationFrame(() => {
      const ref = blockRefs.current.get(blockId);
      ref?.setCaretRange?.(start, end);
    });
  }

  // --- Raccourcis globaux (Undo/Redo + formatting inline) ---
  function handleGlobalKeyDown(e) {
    const meta = e.ctrlKey || e.metaKey;
    if (meta && e.key === 'z') {
      e.preventDefault();
      if (e.shiftKey) handleRedo();
      else handleUndo();
      return;
    }
    if (meta && e.key === 'y') {
      e.preventDefault();
      handleRedo();
      return;
    }
    if (!meta || e.altKey) return;
    const k = e.key.toLowerCase();
    let mark = null;
    if (k === 'b') mark = { type: 'bold' };
    else if (k === 'i') mark = { type: 'italic' };
    else if (k === 'u') mark = { type: 'underline' };
    else if (k === 'e') mark = { type: 'code' };
    if (mark && toolbar) {
      e.preventDefault();
      applyToggleMark(mark);
      return;
    }
    if (k === 'k' && toolbar) {
      e.preventDefault();
      const href = window.prompt('URL du lien :', '');
      if (href) applyToggleMark({ type: 'link', href });
    }
  }

  // --- Sélection multi-blocs (souris) ---
  // Deux modes selon où le mousedown a démarré :
  //   - `text`   : sur un contentEditable. Tant que la souris reste dans le
  //                bloc d'origine, sélection de texte native. Dès qu'elle en
  //                sort, on bascule en mode `block` : caret défocus, sélection
  //                native effacée, on étend la sélection « bloc » de l'origine
  //                jusqu'au bloc sous le curseur.
  //   - `marquee`: mousedown ailleurs (padding du container). Rectangle invisible
  //                de la position de départ jusqu'au curseur ; tous les blocs
  //                qui intersectent sont sélectionnés.
  const dragRef = useRef(null);

  useEffect(() => {
    function onMove(e) {
      const drag = dragRef.current;
      if (!drag) return;
      const container = containerRef.current;
      if (!container) return;

      if (drag.mode === 'text') {
        if (!drag.startBlockId) return;
        const startEl = container.querySelector(`[data-block-id="${drag.startBlockId}"]`);
        if (!startEl) return;
        const r = startEl.getBoundingClientRect();
        const outside = e.clientY < r.top || e.clientY > r.bottom;
        if (outside) {
          drag.mode = 'block';
          const sel = document.getSelection();
          sel?.removeAllRanges();
          if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
        }
      }

      if (drag.mode === 'block') {
        e.preventDefault();
        const sel = document.getSelection();
        if (sel && sel.rangeCount > 0 && !sel.isCollapsed) sel.removeAllRanges();
        const elAt = document.elementFromPoint(e.clientX, e.clientY);
        const blockEl = elAt?.closest?.('[data-block-id]');
        let endId = blockEl?.getAttribute('data-block-id') ?? null;
        if (!endId) {
          // curseur entre deux blocs : prendre le bloc dont le rect est le
          // plus proche verticalement
          const all = container.querySelectorAll('[data-block-id]');
          let best = null;
          let bestDist = Infinity;
          for (const el of all) {
            const r = el.getBoundingClientRect();
            const cy = (r.top + r.bottom) / 2;
            const d = Math.abs(cy - e.clientY);
            if (d < bestDist) { bestDist = d; best = el; }
          }
          endId = best?.getAttribute('data-block-id') ?? drag.startBlockId;
        }
        const startIdx = blocks.findIndex(b => b.id === drag.startBlockId);
        const endIdx = blocks.findIndex(b => b.id === endId);
        if (startIdx >= 0 && endIdx >= 0) {
          const [a, b] = startIdx <= endIdx ? [startIdx, endIdx] : [endIdx, startIdx];
          const ids = new Set(blocks.slice(a, b + 1).map(x => x.id));
          setSelectedBlockIds(prev => sameSet(prev, ids) ? prev : ids);
        }
      } else if (drag.mode === 'marquee') {
        e.preventDefault();
        const x1 = Math.min(drag.startX, e.clientX);
        const y1 = Math.min(drag.startY, e.clientY);
        const x2 = Math.max(drag.startX, e.clientX);
        const y2 = Math.max(drag.startY, e.clientY);
        const ids = new Set();
        const all = container.querySelectorAll('[data-block-id]');
        for (const el of all) {
          const r = el.getBoundingClientRect();
          if (r.right >= x1 && r.left <= x2 && r.bottom >= y1 && r.top <= y2) {
            const id = el.getAttribute('data-block-id');
            if (id) ids.add(id);
          }
        }
        setSelectedBlockIds(prev => sameSet(prev, ids) ? prev : ids);
      }
    }
    function onUp() {
      dragRef.current = null;
    }
    document.addEventListener('mousemove', onMove);
    document.addEventListener('mouseup', onUp);
    return () => {
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
    };
  }, [blocks]);

  // Touches actives pendant qu'une sélection de blocs existe : Backspace/Delete
  // suppriment, Escape efface, frappe alphanumérique remplace.
  useEffect(() => {
    if (selectedBlockIds.size === 0) return;
    function onKey(e) {
      if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
        // Déjà tout sélectionné — laisser passer (no-op).
        if (selectedBlockIds.size === blocks.length) return;
        e.preventDefault();
        selectAllBlocks();
        return;
      }
      if (e.key === 'Backspace' || e.key === 'Delete') {
        e.preventDefault();
        deleteSelectedBlocks();
        return;
      }
      if (e.key === 'Escape') {
        e.preventDefault();
        clearBlockSelection();
        return;
      }
      if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C' || e.key === 'x' || e.key === 'X')) {
        // Copy/cut multi-blocs : on écrit text/html ET text/plain dans le
        // presse-papier via l'API async (ClipboardItem). Ce chemin est
        // utilisé hors d'un contentEditable focusé — donc le `copy`/`cut`
        // event natif ne se déclenche pas, on doit écrire manuellement.
        const selected = blocks.filter(b => selectedBlockIds.has(b.id));
        const html = blocksToHtml(selected);
        const text = blocksToPlainText(selected);
        if (navigator.clipboard && typeof window !== 'undefined' && window.ClipboardItem) {
          try {
            const item = new window.ClipboardItem({
              'text/html': new Blob([html], { type: 'text/html' }),
              'text/plain': new Blob([text], { type: 'text/plain' }),
            });
            navigator.clipboard.write([item]).catch(() => {
              navigator.clipboard.writeText(text).catch(() => {});
            });
          } catch {
            navigator.clipboard.writeText(text).catch(() => {});
          }
        } else if (navigator.clipboard) {
          navigator.clipboard.writeText(text).catch(() => {});
        }
        if (e.key === 'x' || e.key === 'X') {
          e.preventDefault();
          deleteSelectedBlocks();
        }
        return;
      }
      if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V')) {
        // Paste multi-blocs : remplace les blocs sélectionnés par le contenu
        // du presse-papier. On lit le HTML de manière asynchrone via la
        // Clipboard API (aucun contentEditable n'est focus, donc pas de
        // paste event natif).
        e.preventDefault();
        if (!navigator.clipboard) return;
        const replace = (pasted) => {
          if (!Array.isArray(pasted) || pasted.length === 0) return;
          const next = [];
          let injected = false;
          for (const b of blocks) {
            if (selectedBlockIds.has(b.id)) {
              if (!injected) { next.push(...pasted); injected = true; }
            } else {
              next.push(b);
            }
          }
          if (!injected) next.push(...pasted);
          const finalNext = next.length === 0 ? [makeBlock(DEFAULT_BLOCK_TYPE)] : next;
          commitChange(finalNext, { immediate: true });
          setSelectedBlockIds(new Set());
          const target = pasted[pasted.length - 1];
          setFocusBlockId(target.id);
          setFocusOffset(target.content ? inlineLength(target.content) : 0);
        };
        if (navigator.clipboard.read) {
          navigator.clipboard.read().then(async (items) => {
            for (const item of items) {
              if (item.types.includes('text/html')) {
                const blob = await item.getType('text/html');
                const html = await blob.text();
                replace(htmlToBlocks(html));
                return;
              }
            }
            for (const item of items) {
              if (item.types.includes('text/plain')) {
                const blob = await item.getType('text/plain');
                const text = await blob.text();
                if (text) replace([{ id: newBlockId(), type: DEFAULT_BLOCK_TYPE, content: inlineFromText(text) }]);
                return;
              }
            }
          }).catch(() => {
            // Permission refusée → fallback texte brut
            navigator.clipboard.readText?.().then(text => {
              if (text) replace([{ id: newBlockId(), type: DEFAULT_BLOCK_TYPE, content: inlineFromText(text) }]);
            }).catch(() => {});
          });
        } else if (navigator.clipboard.readText) {
          navigator.clipboard.readText().then(text => {
            if (text) replace([{ id: newBlockId(), type: DEFAULT_BLOCK_TYPE, content: inlineFromText(text) }]);
          }).catch(() => {});
        }
        return;
      }
      // Frappe utile (caractère imprimable) → supprimer puis insérer un nouveau
      // paragraphe avec ce caractère.
      if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.length === 1) {
        e.preventDefault();
        const ch = e.key;
        const next = blocks.filter(b => !selectedBlockIds.has(b.id));
        const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: inlineFromText(ch) });
        const finalNext = next.length === 0 ? [replaced] : [replaced, ...next];
        commitChange(finalNext, { immediate: true });
        setSelectedBlockIds(new Set());
        setFocusBlockId(replaced.id);
        setFocusOffset(ch.length);
      }
    }
    document.addEventListener('keydown', onKey, true);
    return () => document.removeEventListener('keydown', onKey, true);
  }, [selectedBlockIds, blocks]);

  function handleContainerMouseDown(e) {
    if (e.button !== 0) return;
    const target = e.target;
    // Tout contrôle de formulaire (input/textarea/select) à l'intérieur d'un
    // bloc non-texte doit recevoir le focus normalement et ne PAS déclencher
    // la sélection multi-blocs. Sans ce garde, le clic sur la légende ou le
    // texte alternatif d'une image sélectionne le bloc image entier ; le
    // listener clavier global remplace alors chaque frappe par un nouveau
    // paragraphe (cf. effet ligne ~931).
    const formControl = target instanceof Element
      ? target.closest('input, textarea, select, [contenteditable="true"]')
      : null;
    if (formControl && !(target instanceof Element && target.closest('[data-inline-toolbar], [data-link-popover]'))) {
      // Les inputs des popovers d'inline (lien) sont gérés par leur propre
      // logique — on ne change pas leur comportement ici. Pour les autres
      // inputs, on annule juste la sélection bloc en cours.
      if (selectedBlockIds.size > 0) clearBlockSelection();
      return;
    }
    const blockEl = target instanceof Element ? target.closest('[data-block-id]') : null;
    const editableEl = target instanceof Element ? target.closest('[contenteditable="true"]') : null;
    const onHandle = target instanceof Element ? target.closest('button') : null;
    const inToolbar = target instanceof Element ? target.closest('[data-inline-toolbar]') : null;
    const inLinkPopover = target instanceof Element ? target.closest('[data-link-popover]') : null;

    // Boutons (poignée +, drag handle…) : ne pas démarrer de sélection.
    if (onHandle || inToolbar || inLinkPopover) {
      if (onHandle && selectedBlockIds.size > 0) clearBlockSelection();
      return;
    }

    if (linkPopover) setLinkPopover(null);

    // Toute nouvelle interaction → reset de la sélection bloc en cours.
    if (selectedBlockIds.size > 0) clearBlockSelection();

    if (editableEl && blockEl) {
      // Démarre potentiellement en mode texte : la bascule en mode bloc se
      // fait dans onMove dès que le curseur quitte le bloc d'origine.
      dragRef.current = {
        mode: 'text',
        startBlockId: blockEl.getAttribute('data-block-id'),
        startX: e.clientX,
        startY: e.clientY,
      };
      return;
    }

    if (blockEl) {
      // Clic sur un bloc non-texte (image, divider, …) : commencer en bloc.
      dragRef.current = {
        mode: 'block',
        startBlockId: blockEl.getAttribute('data-block-id'),
        startX: e.clientX,
        startY: e.clientY,
      };
      setSelectedBlockIds(new Set([blockEl.getAttribute('data-block-id')]));
      return;
    }

    // Mousedown ailleurs (padding du container) : marquee invisible.
    e.preventDefault();
    dragRef.current = {
      mode: 'marquee',
      startBlockId: null,
      startX: e.clientX,
      startY: e.clientY,
    };
  }

  function handleContainerMouseUp(e) {
    if (e.button !== 0) return;
    const sel = typeof window !== 'undefined' ? window.getSelection() : null;
    if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return;
    const target = e.target;
    const blockEl = target instanceof Element ? target.closest('[data-block-id]') : null;
    if (!blockEl) return;
    const blockId = blockEl.getAttribute('data-block-id');
    const block = blocks.find(b => b.id === blockId);
    if (!block) return;
    const ref = blockRefs.current.get(blockId);
    const r = ref?.getCaretRange?.();
    if (!r) return;
    const linkRange = linkRangeAt(block.content ?? [], r.start);
    if (!linkRange) return;
    const targetEl = e.target instanceof Element ? e.target : null;
    const aEl = targetEl?.closest?.('a') ?? null;
    const container = containerRef.current;
    if (!container) return;
    const cRect = container.getBoundingClientRect();
    const vpRect = aEl
      ? aEl.getBoundingClientRect()
      : sel.getRangeAt(0).getBoundingClientRect();
    const rect = {
      top: vpRect.top - cRect.top,
      left: vpRect.left - cRect.left,
      bottom: vpRect.bottom - cRect.top,
      width: vpRect.width,
      height: vpRect.height,
    };
    setLinkPopover({ rect, mark: linkRange.mark, blockId, start: linkRange.start, end: linkRange.end });
  }

  // Escalade Ctrl+A : appelé par un bloc quand son contenu est déjà entièrement
  // sélectionné — on bascule en sélection « tous les blocs ».
  function handleBlockSelectAll() {
    selectAllBlocks();
  }

  // Ferme le slash menu quand on clique en dehors de l'éditeur ou sur un
  // bloc différent de celui qui a ouvert le menu.
  useEffect(() => {
    if (!slashState) return;
    function onDocMouseDown(e) {
      if (e.target instanceof Element && e.target.closest('[data-slash-menu]')) return;
      const outer = outerRef.current;
      if (!outer || !outer.contains(e.target)) {
        setSlashState(null);
        return;
      }
      const blockEl = e.target instanceof Element ? e.target.closest('[data-block-id]') : null;
      if (!blockEl || blockEl.getAttribute('data-block-id') !== slashState.blockId) {
        setSlashState(null);
      }
    }
    document.addEventListener('mousedown', onDocMouseDown, true);
    return () => document.removeEventListener('mousedown', onDocMouseDown, true);
  }, [slashState]);

  // Le slash menu utilise un listener au niveau document en phase capture pour
  // intercepter les touches avant que le contentEditable ne gère ses défauts
  // (sinon ↑/↓ déplacent le caret et Entrée insère une nouvelle ligne).
  useEffect(() => {
    if (!slashState) return;
    function onKey(e) {
      if (!slashState) return;
      const items = getSlashItems(slashState.query, enabledBlocks);
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        e.stopPropagation();
        setSlashState(s => s && ({
          ...s,
          selectedIndex: items.length === 0 ? 0 : ((s.selectedIndex ?? 0) + 1) % items.length,
        }));
      } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        e.stopPropagation();
        setSlashState(s => s && ({
          ...s,
          selectedIndex: items.length === 0 ? 0 : ((s.selectedIndex ?? 0) - 1 + items.length) % items.length,
        }));
      } else if (e.key === 'Enter') {
        if (items.length > 0) {
          e.preventDefault();
          e.stopPropagation();
          const def = items[slashState.selectedIndex ?? 0];
          if (def) handleSlashSelect(def.type);
        }
      } else if (e.key === 'Escape') {
        e.preventDefault();
        e.stopPropagation();
        setSlashState(null);
      }
    }
    document.addEventListener('keydown', onKey, true);
    return () => document.removeEventListener('keydown', onKey, true);
  }, [slashState, enabledBlocks]);

  return (
    <div ref={outerRef} className="space-y-2">
      {label && (
        <label className="block text-xs font-medium text-neutral-700 dark:text-neutral-400">
          {label}
        </label>
      )}
      <div
        ref={containerRef}
        onKeyDown={handleGlobalKeyDown}
        onMouseDownCapture={handleContainerMouseDown}
        onMouseUp={handleContainerMouseUp}
        style={minHeight != null ? { minHeight: typeof minHeight === 'number' ? `${minHeight}px` : minHeight } : undefined}
        className={`block-editor relative border rounded-xl bg-white dark:bg-neutral-900/60 pl-0 pr-[45px] py-5 ${error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'} ${blocks.length === 1 && inlineLength(blocks[0].content ?? []) === 0 ? 'block-editor--sole-empty' : ''} ${className}`}
      >
        <BlockEditorStyles />
        {blocks.map((block, i) => (
          <Block
            key={block.id}
            ref={(r) => setBlockRef(block.id, r)}
            block={block}
            index={i}
            numberedIndex={numberedIndexByBlockId.get(block.id)}
            disabled={disabled}
            isDragOverTop={dragOver?.blockId === block.id && dragOver.position === 'top'}
            isDragOverBottom={dragOver?.blockId === block.id && dragOver.position === 'bottom'}
            isSelected={selectedBlockIds.has(block.id)}
            onContentChange={handleContentChange}
            onBlockPatch={handleBlockPatch}
            onEnter={handleEnter}
            onBackspaceAtStart={handleBackspaceAtStart}
            onShortcutMatch={handleShortcutMatch}
            onSlashOpen={handleSlashOpen}
            onSlashClose={handleSlashClose}
            onSelectAllBlocks={handleBlockSelectAll}
            onSelectBlock={selectBlock}
            onPasteInline={handlePasteInline}
            onPasteBlocks={handlePasteBlocks}
            onDragStart={() => setDragOver(null)}
            onDragEnd={handleDragEnd}
            onDragOver={handleDragOver}
            onDragLeave={handleDragLeave}
            onDrop={handleDrop}
            onTransformBlock={handleTransformBlock}
            onDuplicateBlock={handleDuplicateBlock}
            onDeleteBlock={handleDeleteBlock}
            onInsertBlock={handleInsertBlock}
            enabledBlocks={enabledBlocks}
          />
        ))}
        {toolbar && (() => {
          const block = blocks.find(b => b.id === toolbar.blockId);
          const marks = block ? marksInRange(block.content ?? [], toolbar.start, toolbar.end) : [];
          return (
            <InlineToolbar
              rect={toolbar.rect}
              activeMarks={marks}
              usedColors={usedColors}
              onToggleMark={applyToggleMark}
              onSetMark={applySetMark}
              onClearMarks={applyRemoveAllMarks}
              onPinChange={(p) => { toolbarPinnedRef.current = p; }}
            />
          );
        })()}
        {linkPopover && (
          <LinkPopover
            rect={linkPopover.rect}
            mark={linkPopover.mark}
            onSetLink={handleLinkPopoverSet}
            onRemoveLink={handleLinkPopoverRemove}
            onClose={() => setLinkPopover(null)}
          />
        )}
      </div>
      {slashState && (
        <SlashMenu
          query={slashState.query}
          anchorRect={slashState.anchorRect}
          enabledBlocks={enabledBlocks}
          selectedIndex={slashState.selectedIndex ?? 0}
          onSelect={handleSlashSelect}
          onHoverIndex={(i) => setSlashState(s => ({ ...s, selectedIndex: i }))}
        />
      )}
      {error && (
        <p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
      )}
    </div>
  );
}

// Styles minimaux : placeholder pour les contentEditable vides.
function BlockEditorStyles() {
  return (
    <style>{`
      .block-editor [contenteditable][data-placeholder]:empty::before {
        content: attr(data-placeholder);
        opacity: 0;
        color: rgb(163 163 163);
        pointer-events: none;
        transition-property: opacity;
        transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
        transition-duration: 150ms;
      }
      .block-editor .group:hover [contenteditable][data-placeholder]:empty::before,
      .block-editor [contenteditable][data-placeholder]:empty:focus::before,
      .block-editor--sole-empty [contenteditable][data-placeholder]:empty::before {
        opacity: 1;
      }
      .dark .block-editor [contenteditable][data-placeholder]:empty::before {
        color: rgb(82 82 82);
      }
    `}</style>
  );
}
