// ═════════════════════════════════════════════════════════════════════════════
// MindForge · Layer 5 · editor/editor.jsx
// ─────────────────────────────────────────────────────────────────────────────
// Role     : The whole authoring side — library/deck/card editor, custom-field
//            schema, media drag-drop, CSV/XLSX import + export, smart-deck
//            filter builder, bulk operations.
// Loaded   : #9 (last)
// Reads    : window.{ React, MF, useLibrary, useIsMobile, useMediaURL,
//            maybeCompressImage, ObjectDB, parseVideoEmbed,
//            PDFRegionFieldInput, RevealPdfRegion, CORE_ATTRIBUTES,
//            INTERVAL_OPTIONS, INTERVAL_BY_CODE, CUSTOM_FIELD_TYPE_ICONS,
//            CATS, TONES, fieldFilled, completionOf, cardAttributeKeys,
//            attributeLabel, attributeIcon, effectiveQuizSides,
//            isExportableCustomFieldType, normalizePdfRegion,
//            cardPlaceholderBg }
//            XLSX engine: window.XLSX from cdn.sheetjs.com (loaded in
//            index.html) — used for .xlsx import.
// Exposes  : window.{ Editor }
// See also : docs/ARCHITECTURE.md §"Editor seams" — natural split points
//            documented for the day this file gets broken up.
//            docs/DATA-MODEL.md §"CSV round-trip"
// ═════════════════════════════════════════════════════════════════════════════
//
// MindForge — Card Editor (legacy header preserved below)
//
// Layout:
//   ┌──── top bar: logo · deck name · "Play deck" ──────────────────┐
//   │ Tabs:  Cartes (N)   |   Édition #03                          │
//   ├──────────────────────────────────────────────────────────────┤
//   │  ListPane                  │  CardForm (when tab = edit)     │
//   │   modes strip · stats      │   Photo · fr · kr · ro          │
//   │   search · + Nouvelle      │   Catégorie · Tone · Delete     │
//   │   grid of CardRow          │                                 │
//   └──────────────────────────────────────────────────────────────┘
//
// Tabs replace the old two-column layout so the form gets full width,
// and the list view scrolls comfortably on mobile.

// Pre-compression upper bound — images larger than this are rejected outright.
// After compression (canvas → JPEG q=0.82, max 1920 px) the stored size is
// typically well under 1 Mo even from a 10–15 Mo source photo.
const MAX_PHOTO_MB = 20;
// Hard ceiling on the compressed result before we accept it.
const MAX_COMPRESSED_PHOTO_MB = 8;

// ─────────────── CSV import ───────────────
// Minimal RFC-4180-ish parser: handles quoted fields, escaped quotes,
// multi-line quoted cells, BOM, and auto-detects "," / ";" / "\t" from
// the first row.
const parseCSV = (text) => {
  if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
  text = text.replace(/\r\n?/g, '\n');

  // Delimiter auto-detection on the first row (ignoring quoted content).
  const firstNL = text.indexOf('\n');
  const head = firstNL < 0 ? text : text.slice(0, firstNL);
  const counts = { ',': 0, ';': 0, '\t': 0 };
  let q = false;
  for (let i = 0; i < head.length; i++) {
    const ch = head[i];
    if (ch === '"') { if (head[i+1] === '"') i++; else q = !q; }
    else if (!q && ch in counts) counts[ch]++;
  }
  const delim = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || ',';

  const rows = [];
  let row = [], cur = '', inQ = false;
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    if (inQ) {
      if (ch === '"') {
        if (text[i+1] === '"') { cur += '"'; i++; }
        else inQ = false;
      } else cur += ch;
    } else if (ch === '"' && cur === '') {
      inQ = true;
    } else if (ch === delim) {
      row.push(cur); cur = '';
    } else if (ch === '\n') {
      row.push(cur); rows.push(row); row = []; cur = '';
    } else {
      cur += ch;
    }
  }
  if (cur !== '' || row.length > 0) { row.push(cur); rows.push(row); }
  while (rows.length && rows[rows.length - 1].every(c => c.trim() === '')) rows.pop();
  if (!rows.length) return { headers: [], rows: [] };

  const headers = rows.shift().map(h => h.trim());
  const objs = rows.map(cells => {
    const o = {};
    for (let j = 0; j < headers.length; j++) o[headers[j]] = (cells[j] ?? '').trim();
    return o;
  });
  return { headers, rows: objs };
};

// Normalize header text for matching: lowercase, strip diacritics, collapse whitespace.
const normalizeHeader = (s) => (s || '').toLowerCase()
  .normalize('NFD').replace(/[̀-ͯ]/g, '')
  .replace(/\s+/g, ' ').trim();

// Priority-ordered: first rule that matches a normalized header wins.
// 'ro' is checked before 'kr' so headers like "Romaja (nom coréen)" go to ro,
// not kr (which would also match on "coréen").
const CSV_RULES = [
  ['ro',     /romaja/],
  ['kr',     /coreen|korean|hangul|한국어|\bkr\b/],
  ['fr',     /francais|french|\bfr\b/],
  ['cat',    /theme|categor|\bcat\b/],
  ['tags',   /\btags?\b/],
  // Deck membership — semicolon-separated deck names. On import, unknown names
  // spawn a new manual deck (see addCards). Optional.
  ['decks',  /\bdecks?\b|paquets?/],
  // Quiz Q/A composition columns. Diacritics are stripped by normalizeHeader,
  // so "Quiz · Réponse" → "quiz · reponse".
  ['quizQ',  /quiz.*quest/],
  ['quizA',  /quiz.*r[ée]?pons/],
  // Spaced-repetition columns — both optional on import.
  //   interval : the appearance frequency ("Fréquence" → "frequence")
  //   dueAt    : the next-appearance date ("Prochaine apparition")
  ['interval', /frequenc|interval/],
  ['dueAt',    /prochaine|apparition|echeance|\bdue\b/],
];

const headerToField = (raw) => {
  const h = normalizeHeader(raw);
  for (const [field, rx] of CSV_RULES) if (rx.test(h)) return field;
  return null;
};

// Build a lookup so a CSV header matches a custom-field by its (normalized)
// name. Only "exportable" custom field types round-trip through CSV —
// strings go raw, pdf_region is a JSON-serialized {url, page, rect} object.
// Media types (audio/video/image) stay editor-only since binaries can't
// ride in a spreadsheet.
const buildCustomFieldLookup = (customFields) => {
  const byHeader = new Map();
  for (const f of customFields || []) {
    if (!isExportableCustomFieldType(f.type)) continue;
    byHeader.set(normalizeHeader(f.name), f);
  }
  return byHeader;
};

// Decode the cell text for a custom-field column. Most types pass through
// as a string; pdf_region is JSON-parsed and validated.
const decodeCustomCell = (field, raw) => {
  const s = (raw || '').trim();
  if (!s) return null;
  if (field.type === 'pdf_region') {
    try {
      const parsed = JSON.parse(s);
      return normalizePdfRegion(parsed);   // null if shape is bad
    } catch { return null; }
  }
  return s;
};

// Parse the value of a "Quiz · Question" or "Quiz · Réponse" CSV cell into
// an attribute-key array. Tokens are case-insensitive; "custom:<name>" maps
// back to the field id via the (normalized-name → id) lookup. Tokens that
// don't match a core attribute or known custom field name are dropped.
const parseQuizSidesCell = (raw, customFields) => {
  if (!raw) return [];
  const nameToId = new Map(
    (customFields || []).map(f => [normalizeHeader(f.name), f.id])
  );
  const out = [];
  for (const tok of String(raw).split(/[,;|]/).map(t => t.trim()).filter(Boolean)) {
    const lower = tok.toLowerCase();
    if (lower.startsWith('custom:')) {
      const id = nameToId.get(normalizeHeader(tok.slice(7)));
      if (id) out.push('custom:' + id);
      continue;
    }
    if (CORE_ATTRIBUTES.includes(lower)) out.push(lower);
  }
  return out;
};

// Parse a "Fréquence" cell into an interval code. Accepts the human label
// ("1 jour"), the short label ("1 j") or the raw code ("1d"), case/diacritics
// insensitive. Returns null for empty/unknown cells (card keeps its default).
const intervalCodeFromCell = (raw) => {
  const s = normalizeHeader(raw);
  if (!s) return null;
  for (const o of INTERVAL_OPTIONS) {
    if (s === o.code || s === normalizeHeader(o.label) || s === normalizeHeader(o.short)) return o.code;
  }
  return null;
};

// Parse a "Prochaine apparition" cell into a timestamp (ms). Accepts ISO 8601
// (what we export) and anything Date.parse understands. null when empty/invalid.
const dueAtFromCell = (raw) => {
  const s = (raw || '').trim();
  if (!s) return null;
  const t = Date.parse(s);
  return Number.isFinite(t) ? t : null;
};

const csvRowToCard = (row, customFields = []) => {
  const card = {};
  const customLookup = buildCustomFieldLookup(customFields);
  let quizQ = null;
  let quizA = null;
  for (const [key, val] of Object.entries(row)) {
    const field = headerToField(key);
    if (field === 'quizQ') { quizQ = parseQuizSidesCell(val, customFields); continue; }
    if (field === 'quizA') { quizA = parseQuizSidesCell(val, customFields); continue; }
    // Spaced-repetition columns — optional, only applied when present + valid.
    if (field === 'interval') { const code = intervalCodeFromCell(val); if (code) card.interval = code; continue; }
    if (field === 'dueAt')    { const ts = dueAtFromCell(val);          if (ts != null) card.dueAt = ts; continue; }
    if (field) {
      if (field === 'tags') {
        const tags = val.split(/[,;|]/).map(t => t.trim()).filter(Boolean);
        if (tags.length) card.tags = tags;
      } else if (field === 'decks') {
        // Stashed on a non-card field; the store reads it to (re)create decks
        // and assign the card, then it's dropped by normalizeCard.
        const names = val.split(/[;|]/).map(d => d.trim()).filter(Boolean);
        if (names.length) card._decks = names;
      } else if (val) {
        card[field] = val;
      }
      continue;
    }
    // Custom field column? Match by normalized name.
    const cf = customLookup.get(normalizeHeader(key));
    if (cf && val) {
      const decoded = decodeCustomCell(cf, val);
      if (decoded != null) {
        if (!card.custom) card.custom = {};
        card.custom[cf.id] = decoded;
      }
    }
  }
  // Only emit quizSides when at least one side was filled in the CSV — keeps
  // empty cells out of the explicit-Q/A path so cards without it fall back
  // to the automatic split.
  if ((quizQ && quizQ.length) || (quizA && quizA.length)) {
    card.quizSides = { question: quizQ || [], answer: quizA || [] };
  }
  return card;
};

// ─────────────── CSV export ───────────────
// Round-trips with the import logic above: headers and tag separator are
// chosen so a freshly-exported CSV imports back identically.

const CSV_EXPORT_HEADERS = [
  'Nom français',
  'Nom coréen (한국어)',
  'Romaja (nom coréen)',
  'Thème / Catégorie',
  'Tags',
];

const csvEscape = (v) => {
  const s = String(v ?? '');
  // Quote when the field contains the delimiter, quotes, or any line break.
  if (/[",;\n\r\t]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
  return s;
};

// Encode a custom-field value for a CSV cell. Strings pass through; the
// pdf_region object is JSON-serialized so the full { url, page, rect } shape
// survives the round-trip (csvEscape wraps it in quotes because it contains
// commas).
const encodeCustomCell = (field, value) => {
  if (value == null || value === '') return '';
  if (field.type === 'pdf_region') {
    const v = normalizePdfRegion(value);
    return v ? JSON.stringify(v) : '';
  }
  return String(value);
};

// Serialize a Q/A attribute-key list to a CSV cell. Custom field IDs are
// rewritten to "custom:<name>" so the result stays portable across installs
// (IDs are local to a single localStorage). Unknown attribute keys are
// dropped silently.
const encodeQuizSidesCell = (keys, fieldIdToName) => {
  if (!Array.isArray(keys) || keys.length === 0) return '';
  const out = [];
  for (const k of keys) {
    if (k.startsWith('custom:')) {
      const id = k.slice(7);
      const name = fieldIdToName.get(id);
      if (name) out.push('custom:' + name);
      continue;
    }
    if (CORE_ATTRIBUTES.includes(k)) out.push(k);
  }
  return out.join('; ');
};

// Human-readable frequency label for export ("1 jour", "Aucun", …). Falls back
// to the 'none' label for unset / unknown interval codes.
const intervalLabelForExport = (code) => (INTERVAL_BY_CODE[code] || INTERVAL_BY_CODE.none).label;

const cardsToCSV = (cards, customFields = [], includeFrequency = true, decks = []) => {
  // Append one column per exportable custom field (url, video_url, pdf_region)
  // so what the user typed survives the export → spreadsheet → re-import
  // round-trip. Audio / video / image blobs aren't portable and stay editor-only.
  const exportFields = (customFields || []).filter(f => isExportableCustomFieldType(f.type));
  const fieldIdToName = new Map((customFields || []).map(f => [f.id, f.name]));
  // Deck membership per card. The built-in "all" deck is skipped (it holds
  // every card and can't be re-created). Manual decks match by id, smart decks
  // by filter — both round-trip as deck names the importer can rebuild.
  const assignableDecks = (decks || []).filter(d => d.id !== BUILTIN_DECK_ID && !d.builtin);
  const deckNamesForCard = (card) => assignableDecks
    .filter(d => d.type === 'manual'
      ? (d.cardIds || []).includes(card.id)
      : cardMatchesFilter(card, d.filter))
    .map(d => d.name)
    .join('; ');   // ';' is the deck-name separator the importer splits on
  const headers = [
    ...CSV_EXPORT_HEADERS,
    'Decks',
    // Spaced-repetition columns are optional — the user can opt out so the
    // export carries only the card content, not its play schedule.
    ...(includeFrequency ? ['Fréquence', 'Prochaine apparition'] : []),
    ...exportFields.map(f => f.name),
    'Quiz · Question',
    'Quiz · Réponse',
  ];
  const lines = [headers.map(csvEscape).join(',')];
  for (const c of cards) {
    const tags = (c.tags || []).join('; ');   // ';' is one of the accepted import separators
    const row = [c.fr, c.kr, c.ro, c.cat, tags];
    row.push(deckNamesForCard(c));
    // Spaced-repetition: frequency label + next-appearance date (ISO 8601).
    if (includeFrequency) {
      row.push(intervalLabelForExport(c.interval));
      row.push(c.dueAt ? new Date(c.dueAt).toISOString() : '');
    }
    for (const f of exportFields) row.push(encodeCustomCell(f, c.custom?.[f.id]));
    row.push(encodeQuizSidesCell(c.quizSides?.question, fieldIdToName));
    row.push(encodeQuizSidesCell(c.quizSides?.answer,   fieldIdToName));
    lines.push(row.map(csvEscape).join(','));
  }
  // UTF-8 BOM so Excel/Numbers open it as UTF-8 by default.
  return '﻿' + lines.join('\r\n');
};

const slugifyForFilename = (s) => (s || 'mindforge')
  .toString()
  .normalize('NFD').replace(/[̀-ͯ]/g, '')
  .toLowerCase()
  .replace(/[^a-z0-9]+/g, '-')
  .replace(/^-+|-+$/g, '')
  .slice(0, 50) || 'mindforge';

const downloadCSV = (filename, csv) => {
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100);
};

// ─────────────── Sub-components ───────────────

const CardThumb = ({ card, size = 44 }) => {
  const hasPhoto = fieldFilled(card, 'photo');
  return (
    <div style={{
      width: size, height: size, flexShrink: 0,
      borderRadius: 8, overflow:'hidden',
      background: hasPhoto ? `url(${card.photo}) center/cover` : cardPlaceholderBg(card.tone),
      border:`1px solid ${MF.line}`,
      position:'relative',
      display:'flex', alignItems:'center', justifyContent:'center',
    }}>
      {!hasPhoto && (
        <span className="mf-kr" style={{
          fontSize: Math.round(size * 0.42), color:'#fff', fontWeight:700, opacity:.95,
          textShadow:'0 1px 4px rgba(0,0,0,.4)',
        }}>{card.kr?.slice(0,2) || '?'}</span>
      )}
    </div>
  );
};

const CardRow = ({ card, selected, checked, onClick, onToggleCheck }) => {
  const complete = completionOf(card);
  const incomplete = complete < 1;
  const borderColor = checked ? MF.purple : selected ? MF.purple : 'transparent';
  const bg = checked ? 'rgba(167,139,250,.10)' : selected ? MF.card2 : 'transparent';
  return (
    <div
      onClick={onClick}
      className="mf-tap"
      style={{
        width:'100%', display:'flex', alignItems:'center', gap: 10,
        padding:'10px 12px', borderRadius: 10,
        background: bg,
        border:`1px solid ${borderColor}`,
        color: MF.text, textAlign:'left', cursor:'pointer',
        transition:'background .12s, border-color .12s',
      }}
    >
      {/* Selection checkbox — own click target so toggling doesn't open the card */}
      <button
        onClick={(e) => { e.stopPropagation(); onToggleCheck && onToggleCheck(); }}
        className="mf-tap"
        title={checked ? 'Décocher' : 'Cocher cette carte'}
        style={{
          width: 22, height: 22, padding: 0, borderRadius: 6, flexShrink: 0,
          background: checked ? MF.purple : 'transparent',
          border: `1.5px solid ${checked ? MF.purple : MF.line2}`,
          color: '#0e0d12', fontSize: 12, fontWeight: 800,
          display:'inline-flex', alignItems:'center', justifyContent:'center',
          cursor:'pointer', fontFamily:'inherit',
          transition: 'background .12s, border-color .12s',
        }}
      >{checked ? '✓' : ''}</button>
      <CardThumb card={card}/>
      <div style={{flex:1, minWidth:0}}>
        <div style={{
          fontSize:13, fontWeight:600, color: card.fr ? MF.text : MF.textMute,
          whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
        }}>{card.fr || 'Sans titre'}</div>
        <div style={{
          fontSize:11, color:MF.textDim, marginTop:2,
          whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
        }}>
          <span className="mf-kr">{card.kr || '—'}</span>
          {card.ro ? <span style={{color:MF.textMute}}> · {card.ro}</span> : null}
        </div>
      </div>
      {incomplete && (
        <span title="Carte incomplète" style={{
          color: MF.amber, fontSize: 12, flexShrink: 0,
        }}>⚠</span>
      )}
    </div>
  );
};

// Card photo picker. Two ways to set the photo, both writing to `card.photo`:
//   - a local file (drag-drop / click) → compressed and stored as a data URL
//   - a remote image link (paste a URL)   → stored as the URL string as-is
// CardArt and the quizzes render card.photo directly as an <img>/CSS background,
// so a data URL and an https URL are interchangeable downstream.
const PhotoDropzone = ({ card, onChange }) => {
  const inputRef = React.useRef();
  const [dragOver, setDragOver] = React.useState(false);
  const [urlDraft, setUrlDraft] = React.useState('');

  // Accept a pasted image link. We don't fetch/validate it (hotlink and CORS
  // rules make a load-probe unreliable), we just sanity-check the scheme and
  // store it; a broken link simply shows the empty placeholder.
  const applyUrl = () => {
    const u = urlDraft.trim();
    if (!u) return;
    if (!/^(https?:\/\/|data:image\/)/i.test(u)) {
      alert('Collez un lien commençant par https:// (ou une data-URL d\'image).');
      return;
    }
    onChange({ photo: u });
    setUrlDraft('');
  };

  const handleFile = async (file) => {
    if (!file) return;
    if (!file.type.startsWith('image/')) {
      alert('Sélectionnez une image.');
      return;
    }
    if (file.size > MAX_PHOTO_MB * 1024 * 1024) {
      alert(`Image trop lourde (${(file.size/1024/1024).toFixed(1)} Mo). Max ${MAX_PHOTO_MB} Mo en entrée.`);
      return;
    }
    // Compress to keep localStorage usage reasonable. Small images skip the
    // canvas round-trip; the compressor falls back to the original if it
    // somehow inflates the size.
    let blob = file;
    if (file.size > 250 * 1024) {
      blob = await maybeCompressImage(file, { maxDim: 1920, quality: 0.82 });
    }
    if (blob.size > MAX_COMPRESSED_PHOTO_MB * 1024 * 1024) {
      alert(`Photo encore trop lourde après compression (${(blob.size/1024/1024).toFixed(1)} Mo). Essayez une image plus petite.`);
      return;
    }
    const reader = new FileReader();
    reader.onload = () => onChange({ photo: reader.result });
    reader.onerror = () => alert("Lecture du fichier impossible.");
    reader.readAsDataURL(blob);
  };

  const onDrop = (e) => {
    e.preventDefault();
    setDragOver(false);
    handleFile(e.dataTransfer.files?.[0]);
  };

  const hasPhoto = fieldFilled(card, 'photo');

  return (
    <div>
      <div style={{
        fontSize: 11, color: MF.textMute, fontWeight: 700,
        letterSpacing: '.14em', textTransform:'uppercase', marginBottom: 8,
      }}>Photo de la technique</div>
      <div
        onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
        onDragLeave={() => setDragOver(false)}
        onDrop={onDrop}
        onClick={() => inputRef.current?.click()}
        className="mf-tap"
        style={{
          position: 'relative',
          width: '100%', aspectRatio: '4 / 3',
          borderRadius: 14,
          background: hasPhoto ? `url(${card.photo}) center/cover` : cardPlaceholderBg(card.tone),
          border: `2px ${dragOver ? 'solid' : 'dashed'} ${dragOver ? MF.purple : MF.line2}`,
          cursor: 'pointer',
          overflow: 'hidden',
          display:'flex', alignItems:'center', justifyContent:'center',
          transition: 'border-color .15s',
        }}
      >
        {!hasPhoto && (
          <div style={{textAlign:'center', color:'#fff', textShadow:'0 1px 4px rgba(0,0,0,.4)'}}>
            <div style={{fontSize: 28, marginBottom: 4}}>📷</div>
            <div style={{fontSize: 13, fontWeight: 600}}>Glissez une image, cliquez, ou collez un lien</div>
            <div style={{fontSize: 11, opacity: .75, marginTop: 2}}>PNG / JPG · max {MAX_PHOTO_MB} Mo · compressé auto</div>
          </div>
        )}
        {hasPhoto && (
          <div style={{
            position: 'absolute', bottom: 10, right: 10, display:'flex', gap:6,
          }}>
            <button
              onClick={(e) => { e.stopPropagation(); inputRef.current?.click(); }}
              style={{
                padding:'6px 12px', borderRadius:99, background:'rgba(0,0,0,.6)',
                color:'#fff', border:'1px solid rgba(255,255,255,.2)', fontSize:12, fontWeight:600, cursor:'pointer',
              }}
            >Changer</button>
            <button
              onClick={(e) => { e.stopPropagation(); onChange({ photo: null }); }}
              style={{
                padding:'6px 12px', borderRadius:99, background:'rgba(0,0,0,.6)',
                color: MF.bad, border:`1px solid ${MF.bad}`, fontSize:12, fontWeight:600, cursor:'pointer',
              }}
            >Retirer</button>
          </div>
        )}
        <input
          ref={inputRef}
          type="file"
          accept="image/*"
          style={{display: 'none'}}
          onChange={(e) => handleFile(e.target.files?.[0])}
        />
      </div>

      {/* Paste an image link as an alternative to uploading a file. */}
      <div style={{display:'flex', gap: 8, marginTop: 8}}>
        <input
          type="url"
          value={urlDraft}
          onChange={(e) => setUrlDraft(e.target.value)}
          onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyUrl(); } }}
          placeholder="https://… lien d'une image"
          spellCheck={false}
          style={{
            flex: 1, minWidth: 0,
            padding:'9px 12px', borderRadius: 10,
            background: MF.card, border:`1px solid ${MF.line}`,
            color: MF.text, fontSize: 13, outline:'none', fontFamily:'inherit',
          }}
        />
        <button
          onClick={applyUrl}
          disabled={!urlDraft.trim()}
          className="mf-tap"
          style={{
            padding:'9px 14px', borderRadius: 10,
            background: urlDraft.trim() ? MF.card2 : MF.card,
            border:`1px solid ${urlDraft.trim() ? MF.purple : MF.line}`,
            color: urlDraft.trim() ? MF.text : MF.textMute,
            fontSize: 13, fontWeight: 600,
            cursor: urlDraft.trim() ? 'pointer' : 'default',
            fontFamily:'inherit', whiteSpace:'nowrap',
          }}
        >Utiliser le lien</button>
      </div>
    </div>
  );
};

const Field = ({ label, hint, children }) => (
  <div>
    <div style={{
      fontSize:11, color:MF.textMute, fontWeight:700,
      letterSpacing:'.14em', textTransform:'uppercase', marginBottom:6,
    }}>{label}</div>
    {children}
    {hint && <div style={{fontSize:11, color:MF.textMute, marginTop:6}}>{hint}</div>}
  </div>
);

const TextInput = ({ value, onChange, placeholder, korean, big }) => (
  <input
    value={value || ''}
    onChange={(e) => onChange(e.target.value)}
    placeholder={placeholder}
    spellCheck={false}
    className={korean ? 'mf-kr' : undefined}
    style={{
      width:'100%', padding:'12px 14px',
      background: MF.card, border:`1px solid ${MF.line}`, borderRadius:10,
      color: MF.text, fontSize: big ? 18 : 15, fontWeight: big ? 600 : 500,
      outline:'none', fontFamily: 'inherit',
      transition: 'border-color .12s, box-shadow .12s',
    }}
    onFocus={(e) => { e.target.style.borderColor = MF.purple; e.target.style.boxShadow = `0 0 0 1px ${MF.purple}`; }}
    onBlur={(e) =>  { e.target.style.borderColor = MF.line;   e.target.style.boxShadow = 'none'; }}
  />
);

const ChipPicker = ({ value, options, onChange, labels }) => (
  <div style={{display:'flex', flexWrap:'wrap', gap:6}}>
    {options.map(opt => {
      const on = value === opt;
      return (
        <button
          key={opt}
          onClick={() => onChange(opt)}
          className="mf-tap"
          style={{
            padding:'8px 14px', borderRadius:99,
            background: on ? MF.card2 : MF.card,
            border:`1.5px solid ${on ? MF.purple : MF.line}`,
            color: on ? MF.text : MF.textDim,
            fontSize: 13, fontWeight: 600, cursor:'pointer',
            textTransform: 'capitalize',
          }}
        >{labels?.[opt] ?? opt}</button>
      );
    })}
  </div>
);

const ToneSwatches = ({ value, onChange }) => (
  <div style={{display:'flex', gap:8}}>
    {TONES.map(t => {
      const on = value === t;
      return (
        <button
          key={t}
          onClick={() => onChange(t)}
          className="mf-tap"
          aria-label={`Tone ${t}`}
          style={{
            width: 42, height: 42, borderRadius: 10,
            background: cardPlaceholderBg(t),
            border:`2px solid ${on ? MF.purple : 'transparent'}`,
            boxShadow: on ? `0 0 0 1px ${MF.purple}` : 'none',
            cursor:'pointer', position:'relative',
          }}
        >
          {on && <span style={{
            position:'absolute', top:2, right:4, color:'#fff', fontSize:12, fontWeight:700,
            textShadow:'0 1px 2px rgba(0,0,0,.5)',
          }}>✓</span>}
        </button>
      );
    })}
  </div>
);

// Format a future-dueAt timestamp into "dans 3 jours" / "il y a 2 h" style.
// Compact, French, no library dependency.
const formatRelativeFuture = (ts) => {
  if (!ts) return null;
  const diff = ts - Date.now();
  const abs = Math.abs(diff);
  const sec  = 1000, min = 60*sec, hour = 60*min, day = 24*hour;
  let value, unit;
  if (abs < min)        { value = Math.round(abs / sec);  unit = 'seconde'; }
  else if (abs < hour)  { value = Math.round(abs / min);  unit = 'minute'; }
  else if (abs < day)   { value = Math.round(abs / hour); unit = 'heure'; }
  else if (abs < 30*day){ value = Math.round(abs / day);  unit = 'jour'; }
  else if (abs < 365*day){value = Math.round(abs / (30*day)); unit = 'mois'; }
  else                  { value = Math.round(abs / (365*day)); unit = 'an'; }
  const plural = (value > 1 && unit !== 'mois') ? unit + 's' : unit;
  return diff >= 0 ? `dans ${value} ${plural}` : `il y a ${value} ${plural}`;
};

// IntervalPicker — discrete spaced-repetition steps (pills only). The current
// frequency + next-play values are surfaced by the caller, below the pills.
const IntervalPicker = ({ value, onChange }) => {
  const current = value || 'none';
  return (
    <div style={{display:'flex', flexWrap:'wrap', gap: 6}}>
      {INTERVAL_OPTIONS.map(opt => {
        const on = current === opt.code;
        return (
          <button
            key={opt.code}
            onClick={() => onChange(opt.code)}
            className="mf-tap"
            style={{
              padding:'7px 12px', borderRadius: 99,
              background: on ? 'rgba(167,139,250,.18)' : MF.card,
              border:`1.5px solid ${on ? MF.purple : MF.line}`,
              color: on ? MF.text : MF.textDim,
              fontSize: 12.5, fontWeight: 600, cursor:'pointer',
            }}
          >{opt.label}</button>
        );
      })}
    </div>
  );
};

// Small chevron button used in the CardForm header. Disabled state still
// occupies the same footprint so the row layout stays stable on the edges.
const NavCardButton = ({ direction, onClick, targetLabel }) => {
  const disabled = !onClick;
  const isPrev   = direction === 'prev';
  const arrow    = isPrev ? '‹' : '›';
  const label    = isPrev ? 'Précédente' : 'Suivante';
  const title    = disabled
    ? (isPrev ? 'Première carte' : 'Dernière carte')
    : (targetLabel ? `${label} · ${targetLabel}` : label);
  return (
    <button
      onClick={onClick || undefined}
      disabled={disabled}
      title={title}
      className={disabled ? '' : 'mf-tap'}
      style={{
        display:'inline-flex', alignItems:'center', gap: 6,
        padding:'8px 12px', borderRadius: 10,
        background: 'transparent', border:`1px solid ${MF.line}`,
        color: disabled ? MF.textMute : MF.text,
        fontSize: 12, fontWeight: 600,
        cursor: disabled ? 'default' : 'pointer',
        opacity: disabled ? .45 : 1,
        fontFamily: 'inherit',
      }}
    >
      {isPrev && <span style={{fontSize: 16, lineHeight: 1}}>{arrow}</span>}
      <span>{label}</span>
      {!isPrev && <span style={{fontSize: 16, lineHeight: 1}}>{arrow}</span>}
    </button>
  );
};

const CardForm = ({
  card, isMobile, onChange, onDelete,
  customFields = [], onCustomChange,
  onPrev, onNext, navIndex, navTotal, prevLabel, nextLabel,
}) => {
  const [showAdvanced, setShowAdvanced] = React.useState(false);

  if (!card) {
    return (
      <div style={{
        flex: 1, minHeight: 0,
        display:'flex', alignItems:'center', justifyContent:'center',
        flexDirection:'column', gap: 8, color: MF.textMute,
      }}>
        <div style={{fontSize: 40}}>📇</div>
        <div style={{fontSize: 14}}>Sélectionnez une carte dans la liste,</div>
        <div style={{fontSize: 14}}>ou créez-en une nouvelle.</div>
      </div>
    );
  }

  const update = (patch) => onChange(patch);

  // Measure the sticky nav row so the sticky "Nom français" can park exactly
  // below it — ResizeObserver tracks wrap (flex-wrap on narrow screens makes
  // the nav grow to two rows, the FR sticky slides down to match).
  const navRef = React.useRef(null);
  const [navH, setNavH] = React.useState(isMobile ? 56 : 62);
  React.useLayoutEffect(() => {
    const el = navRef.current;
    if (!el) return;
    const measure = () => setNavH(el.offsetHeight);
    measure();
    if (typeof ResizeObserver !== 'undefined') {
      const ro = new ResizeObserver(measure);
      ro.observe(el);
      return () => ro.disconnect();
    }
    window.addEventListener('resize', measure);
    return () => window.removeEventListener('resize', measure);
  }, []);

  const sides = effectiveQuizSides(card, customFields);
  const toggleSide = (key, side) => {
    const current = sides[side] || [];
    const otherSide = side === 'question' ? 'answer' : 'question';
    const next = current.includes(key)
      ? current.filter(k => k !== key)
      : [...current, key];
    // Same attribute on both sides is allowed but rare — leave the other side
    // untouched so the user is fully in control.
    update({
      quizSides: {
        question: side === 'question' ? next : (sides.question || []),
        answer:   side === 'answer'   ? next : (sides.answer   || []),
      },
    });
  };
  const resetSides = () => update({ quizSides: { question: [], answer: [] } });

  return (
    <div style={{
      flex: 1, minHeight: 0, overflow:'auto',
      WebkitOverflowScrolling: 'touch',
      padding: isMobile ? '16px 16px 32px' : '28px 36px 36px',
      // Reserve room so focused fields (Tab key, click on label, etc.) don't
      // scroll into view tucked under the two-row sticky header (nav row +
      // "Nom français").
      scrollPaddingTop: isMobile ? 160 : 180,
      display:'flex', flexDirection:'column', gap: isMobile ? 18 : 22,
      maxWidth: 760, margin: '0 auto', width: '100%',
    }}>
      {/* Card header — id + nav + delete. Sticky so the prev/next buttons
          stay reachable while scrolling through a long card form. Negative
          horizontal/top margins offset the parent's padding so the bar spans
          edge-to-edge inside the scroll viewport. marginBottom adds breathing
          room before the first field so content doesn't sit pressed against
          the sticky bar; combined with scrollPaddingTop on the scroller, the
          next field never lands hidden under the bar. */}
      <div
        ref={navRef}
        style={{
        display:'flex', alignItems:'center', gap: 10, flexWrap:'wrap',
        position: 'sticky', top: 0, zIndex: 5,
        background: MF.bg,
        marginTop:    isMobile ? -16 : -28,
        marginLeft:   isMobile ? -16 : -36,
        marginRight:  isMobile ? -16 : -36,
        marginBottom: isMobile ? 4  :  8,
        padding: isMobile ? '12px 16px' : '14px 36px',
        borderBottom: `1px solid ${MF.line}`,
        boxShadow: '0 2px 6px rgba(0,0,0,.35)',
      }}>
        <span style={{
          fontSize: 11, color: MF.textMute, fontWeight: 700,
          letterSpacing: '.16em', textTransform:'uppercase',
          fontFamily: MF.mono,
        }}>Carte #{String(card.id).padStart(2,'0')}</span>

        {(navIndex >= 0 && navTotal > 0) && (
          <span style={{
            display:'inline-flex', alignItems:'center', gap: 8,
            padding:'5px 10px', borderRadius: 99,
            background: MF.bg2, border:`1px solid ${MF.line}`,
            fontSize: 11, color: MF.textDim,
            fontFamily: MF.mono, fontVariantNumeric:'tabular-nums',
          }}>
            <span>{navIndex + 1} / {navTotal}</span>
          </span>
        )}

        <div style={{flex: 1}}/>

        <NavCardButton
          direction="prev"
          onClick={onPrev}
          targetLabel={prevLabel}
        />
        <NavCardButton
          direction="next"
          onClick={onNext}
          targetLabel={nextLabel}
        />

        <button
          onClick={() => {
            if (confirm(`Supprimer la carte "${card.fr || 'Sans titre'}" ?`)) onDelete();
          }}
          className="mf-tap"
          style={{
            padding:'8px 14px', borderRadius: 10,
            background: 'transparent', border:`1px solid ${MF.line}`,
            color: MF.bad, fontSize: 12, fontWeight: 600, cursor:'pointer',
          }}
        >🗑 Supprimer</button>
      </div>

      {/* "Nom français" — sticky right under the nav row. The main label of
          the card stays visible no matter how far the user scrolls into the
          photo, custom fields, Q/A picker, etc. Same edge-to-edge negative
          margins as the nav so it spans the viewport. */}
      <div style={{
        position: 'sticky', top: navH, zIndex: 4,
        background: MF.bg,
        marginLeft:   isMobile ? -16 : -36,
        marginRight:  isMobile ? -16 : -36,
        marginBottom: isMobile ? 4  :  8,
        padding: isMobile ? '12px 16px 14px' : '14px 36px 16px',
        borderBottom: `1px solid ${MF.line}`,
        boxShadow: '0 2px 6px rgba(0,0,0,.35)',
      }}>
        <Field label="Nom français" hint="Le libellé principal de la carte.">
          <TextInput
            value={card.fr}
            onChange={(v) => update({ fr: v })}
            placeholder="Coup de pied avant"
            big
          />
        </Field>
      </div>

      <PhotoDropzone card={card} onChange={update}/>

      <div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 18}}>
        <Field label="Nom coréen (한국어)">
          <TextInput
            value={card.kr}
            onChange={(v) => update({ kr: v })}
            placeholder="앞 차기"
            korean
            big
          />
        </Field>
        <Field label="Romaja" hint="Translittération romaine.">
          <TextInput
            value={card.ro}
            onChange={(v) => update({ ro: v })}
            placeholder="Ap chagi"
          />
        </Field>
      </div>

      <Field label="Thème / Catégorie">
        <ChipPicker
          value={card.cat}
          onChange={(v) => update({ cat: v })}
          options={card.cat && !CATS.includes(card.cat) ? [...CATS, card.cat] : CATS}
          labels={{ kick:'Coup de pied', stance:'Position', block:'Blocage' }}
        />
      </Field>

      <Field label="Tags" hint="Séparés par des virgules. Ex : « débutant, jambe, défense ».">
        <TextInput
          value={(card.tags || []).join(', ')}
          onChange={(v) => update({
            tags: v.split(',').map(t => t.trim()).filter(Boolean),
          })}
          placeholder="débutant, jambe, défense"
        />
      </Field>

      <Field label="Fréquence">
        <div style={{display:'flex', flexDirection:'column', gap: 10}}>
          <IntervalPicker
            value={card.interval}
            onChange={(v) => update({ interval: v })}
          />
          {/* Current values surfaced in place of the old descriptive hint. */}
          <div style={{
            display:'flex', flexDirection:'column', gap: 6,
            padding:'10px 12px', borderRadius: 10,
            background: MF.bg2, border:`1px dashed ${MF.line2}`,
            fontSize: 12, color: MF.textDim,
          }}>
            <span>
              Fréquence :{' '}
              <strong style={{color: MF.text}}>
                {(INTERVAL_BY_CODE[card.interval] || INTERVAL_BY_CODE.none).label}
              </strong>
            </span>
            <span style={{display:'inline-flex', alignItems:'center', gap: 10, flexWrap:'wrap'}}>
              <span>
                Prochaine apparition :{' '}
                <strong style={{color: MF.text}}>
                  {card.dueAt
                    ? `${formatRelativeFuture(card.dueAt)} · ${new Date(card.dueAt).toLocaleString('fr-FR', { dateStyle:'short', timeStyle:'short' })}`
                    : 'aucune (la carte rejoue librement)'}
                </strong>
              </span>
              {card.dueAt && (
                <button onClick={() => update({ dueAt: null })} className="mf-tap" style={{
                  padding:'4px 10px', borderRadius: 99,
                  background:'transparent', border:`1px solid ${MF.line}`,
                  color: MF.textDim, fontSize: 11, fontWeight: 600, cursor:'pointer',
                }}>Réinitialiser le délai</button>
              )}
            </span>
          </div>
        </div>
      </Field>

      <Field label="Couleur d'accent" hint="Utilisée pour le fond de la carte quand aucune photo n'est définie.">
        <ToneSwatches value={card.tone} onChange={(v) => update({ tone: v })}/>
      </Field>

      {/* The two built-in fields — Carte (région de PDF) and Démonstration URL
          (vidéo embarquée). Fixed schema, always shown. */}
      {customFields.length > 0 && (
        <div style={{
          marginTop: 4, paddingTop: 18,
          borderTop:`1px dashed ${MF.line}`,
          display:'flex', flexDirection:'column', gap: 16,
        }}>
          <span style={{fontSize:10, color:MF.textMute, fontWeight:700, letterSpacing:'.16em', textTransform:'uppercase'}}>
            Champs additionnels
          </span>
          {customFields.map(f => (
            <Field
              key={f.id}
              label={
                <span style={{display:'inline-flex', alignItems:'center', gap: 6}}>
                  <span style={{fontSize: 11}}>{CUSTOM_FIELD_TYPE_ICONS[f.type]}</span>
                  <span>{f.name}</span>
                </span>
              }
            >
              <CustomFieldEditor
                field={f}
                value={card.custom?.[f.id] ?? null}
                onChange={(v) => onCustomChange && onCustomChange(card.id, f.id, v)}
              />
            </Field>
          ))}
        </div>
      )}

      {/* Advanced parameters — Q/A composition for this card's flashcard. */}
      <AdvancedSection
        expanded={showAdvanced}
        onToggle={() => setShowAdvanced(s => !s)}
        card={card}
        customFields={customFields}
        sides={sides}
        onToggleSide={toggleSide}
        onResetSides={resetSides}
      />
    </div>
  );
};

// ─────────────── Advanced parameters: Q/A composition ───────────────
//
// Per-card flashcard configuration. For every filled attribute on the card,
// two checkboxes — one for the Question column, one for the Réponse column.
// The user decides which attribute appears as the prompt vs as the reveal.
// Empty selection on both sides → an auto default (photo if present, then
// everything else goes to the answer).

const AdvancedSection = ({
  expanded, onToggle, card, customFields, sides, onToggleSide, onResetSides,
}) => {
  const keys = cardAttributeKeys(card, customFields);
  const qSet = new Set(sides.question || []);
  const aSet = new Set(sides.answer   || []);
  const isUserCustomised =
    Array.isArray(card.quizSides?.question) && Array.isArray(card.quizSides?.answer) &&
    ((card.quizSides.question.length + card.quizSides.answer.length) > 0);

  return (
    <div style={{
      marginTop: 4, paddingTop: 18,
      borderTop:`1px dashed ${MF.line}`,
      display:'flex', flexDirection:'column', gap: 14,
    }}>
      <button
        onClick={onToggle}
        className="mf-tap"
        style={{
          display:'flex', alignItems:'center', gap: 10,
          background: 'transparent', border: 'none', padding: 0,
          color: MF.textDim, fontSize: 11, fontWeight: 700,
          letterSpacing: '.16em', textTransform:'uppercase',
          cursor: 'pointer', fontFamily:'inherit',
        }}
      >
        <span>Paramètres quiz · question / réponse</span>
        <span style={{
          display:'inline-flex', alignItems:'center', justifyContent:'center',
          width: 16, height: 16, fontSize: 9,
          color: MF.textMute, transform: expanded ? 'rotate(180deg)' : 'none',
          transition: 'transform .15s',
        }}>▼</span>
      </button>

      {expanded && (
        <div style={{display:'flex', flexDirection:'column', gap: 12}}>
          <div style={{fontSize: 12, color: MF.textDim, lineHeight: 1.5}}>
            Pour cette carte, choisis quels attributs apparaissent dans la{' '}
            <strong style={{color: MF.text}}>question</strong> et lesquels dans la{' '}
            <strong style={{color: MF.text}}>réponse</strong>. Seuls les attributs remplis
            sont listés. {isUserCustomised
              ? <a onClick={onResetSides} style={{
                  color: MF.purple, textDecoration:'underline dotted',
                  cursor:'pointer',
                }}>Réinitialiser (auto)</a>
              : <span style={{color: MF.textMute}}>Vide → répartition automatique : photo (si présente) en question, le reste en réponse.</span>}
          </div>

          {keys.length === 0 ? (
            <div style={{
              padding:'18px 14px', textAlign:'center',
              color: MF.textMute, fontSize: 12,
              border:`1px dashed ${MF.line2}`, borderRadius: 10, background: MF.bg2,
            }}>
              Aucun attribut rempli — renseigne au moins un champ pour configurer le quiz.
            </div>
          ) : (
            <div style={{
              border:`1px solid ${MF.line}`, borderRadius: 10, overflow:'hidden',
              background: MF.bg2,
            }}>
              {/* Column headers */}
              <div style={{
                display:'grid',
                gridTemplateColumns:'1fr 80px 80px',
                background: MF.bg, borderBottom:`1px solid ${MF.line}`,
                padding:'8px 12px',
                fontSize: 10, color: MF.textMute, fontWeight: 700,
                letterSpacing:'.14em', textTransform:'uppercase',
              }}>
                <span>Attribut</span>
                <span style={{textAlign:'center'}}>Question</span>
                <span style={{textAlign:'center'}}>Réponse</span>
              </div>
              {keys.map((key, idx) => (
                <QASideRow
                  key={key}
                  attrKey={key}
                  customFields={customFields}
                  inQ={qSet.has(key)}
                  inA={aSet.has(key)}
                  onToggleQ={() => onToggleSide(key, 'question')}
                  onToggleA={() => onToggleSide(key, 'answer')}
                  divider={idx < keys.length - 1}
                />
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
};

const QASideCheckbox = ({ checked, onToggle, color }) => (
  <button
    onClick={onToggle}
    className="mf-tap"
    style={{
      width: 22, height: 22, borderRadius: 6, padding: 0,
      background: checked ? color : 'transparent',
      border:`1.5px solid ${checked ? color : MF.line2}`,
      color: '#0e0d12', fontSize: 13, fontWeight: 800, cursor:'pointer',
      display:'flex', alignItems:'center', justifyContent:'center',
      margin: '0 auto',
    }}
    aria-pressed={checked}
  >{checked ? '✓' : ''}</button>
);

const QASideRow = ({ attrKey, customFields, inQ, inA, onToggleQ, onToggleA, divider }) => {
  const label = attributeLabel(attrKey, customFields);
  const icon  = attributeIcon(attrKey, customFields);
  return (
    <div style={{
      display:'grid',
      gridTemplateColumns:'1fr 80px 80px',
      alignItems:'center', gap: 0,
      padding:'10px 12px',
      borderBottom: divider ? `1px solid ${MF.line}` : 'none',
    }}>
      <div style={{display:'flex', alignItems:'center', gap: 10, minWidth: 0}}>
        <span style={{fontSize: 14, flexShrink: 0}}>{icon}</span>
        <span style={{
          fontSize: 13, fontWeight: 600, color: MF.text,
          whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
        }}>{label}</span>
      </div>
      <QASideCheckbox checked={inQ} onToggle={onToggleQ} color={MF.blue}/>
      <QASideCheckbox checked={inA} onToggle={onToggleA} color={MF.good}/>
    </div>
  );
};

// ─────────────── Editor screen ───────────────

const Editor = ({ onClose }) => {
  const lib = useLibrary();
  const {
    cards: allCards, decks, activeDeck, activeCards,
    updateCard, addCard, addCards, deleteCard, updateDeck,
    bulkUpdateCards, bulkDeleteCards,
    setActiveDeckId, addDeck, deleteDeck, duplicateDeck,
    setDeckCardIds, toggleCardInManualDeck, materializeSmartDeck,
    resetAll,
  } = lib;

  // Active deck shaped like the legacy single-deck object so the existing
  // Cartes / Photos / Édition tabs keep working. We surface the full library
  // as `cards` (the editor edits the master pool, not just the active view).
  const deck = React.useMemo(
    () => ({ id: activeDeck.id, name: activeDeck.name, sub: activeDeck.sub || '', cards: allCards }),
    [activeDeck, allCards]
  );
  const updateMeta = React.useCallback(
    (patch) => updateDeck(activeDeck.id, patch),
    [updateDeck, activeDeck.id]
  );
  const reseed = resetAll;

  const [selectedId, setSelectedId] = React.useState(deck.cards[0]?.id ?? null);
  const [query, setQuery] = React.useState('');
  const [activeTags, setActiveTags] = React.useState(() => new Set());
  // How active tags combine: 'any' = card matches if it has at least one,
  // 'all' = card must carry every selected tag.
  const [tagMode, setTagMode] = React.useState('any');
  const [activeTab, setActiveTab] = React.useState('list'); // 'list' | 'decks' | 'photos' | 'edit'
  const [selectedDeckId, setSelectedDeckId] = React.useState(() => activeDeck.id);
  const isMobile = useIsMobile(820);

  // Reset selection if the card disappears (deleted, library reset).
  React.useEffect(() => {
    if (selectedId != null && !allCards.find(c => c.id === selectedId)) setSelectedId(null);
  }, [allCards, selectedId]);
  React.useEffect(() => {
    if (!decks.find(d => d.id === selectedDeckId)) setSelectedDeckId(activeDeck.id);
  }, [decks, selectedDeckId, activeDeck.id]);

  const toggleTag = React.useCallback((tag) => {
    setActiveTags(prev => {
      const next = new Set(prev);
      if (next.has(tag)) next.delete(tag); else next.add(tag);
      return next;
    });
  }, []);
  const clearTags = React.useCallback(() => setActiveTags(new Set()), []);

  // Bulk selection — Set<cardId> built across the filtered list view.
  const [selectedIds, setSelectedIds] = React.useState(() => new Set());
  // Drop selections that no longer exist (deleted, library reset).
  React.useEffect(() => {
    setSelectedIds(prev => {
      const known = new Set(allCards.map(c => c.id));
      let changed = false;
      const next = new Set();
      for (const id of prev) {
        if (known.has(id)) next.add(id);
        else changed = true;
      }
      return changed ? next : prev;
    });
  }, [allCards]);

  const toggleCardSelect = React.useCallback((cardId) => {
    setSelectedIds(prev => {
      const next = new Set(prev);
      if (next.has(cardId)) next.delete(cardId); else next.add(cardId);
      return next;
    });
  }, []);
  const clearSelection = React.useCallback(() => setSelectedIds(new Set()), []);
  const selectAll = React.useCallback((ids) => setSelectedIds(new Set(ids)), []);

  // Bulk action handlers. Each operates on the current selectedIds snapshot,
  // then clears the selection (except where it'd be useful to keep editing).
  const bulkAddTag = React.useCallback(() => {
    if (!selectedIds.size) return;
    const raw = prompt(`Ajouter un tag à ${selectedIds.size} carte(s).\nSépare plusieurs tags par une virgule.`);
    if (!raw) return;
    const newTags = raw.split(',').map(t => t.trim()).filter(Boolean);
    if (!newTags.length) return;
    bulkUpdateCards([...selectedIds], (c) => ({
      tags: [...new Set([...(c.tags || []), ...newTags])],
    }));
  }, [selectedIds, bulkUpdateCards]);

  const bulkRemoveTag = React.useCallback(() => {
    if (!selectedIds.size) return;
    const raw = prompt(`Retirer un tag des ${selectedIds.size} carte(s) sélectionnées.\nSépare plusieurs tags par une virgule.`);
    if (!raw) return;
    const removed = new Set(raw.split(',').map(t => t.trim()).filter(Boolean));
    if (!removed.size) return;
    bulkUpdateCards([...selectedIds], (c) => ({
      tags: (c.tags || []).filter(t => !removed.has(t)),
    }));
  }, [selectedIds, bulkUpdateCards]);

  const bulkSetCategory = React.useCallback(() => {
    if (!selectedIds.size) return;
    const v = prompt(`Définir le thème/catégorie pour ${selectedIds.size} carte(s).`);
    if (v == null) return;
    bulkUpdateCards([...selectedIds], { cat: v.trim() });
  }, [selectedIds, bulkUpdateCards]);

  const bulkSetTone = React.useCallback((tone) => {
    if (!selectedIds.size) return;
    bulkUpdateCards([...selectedIds], { tone });
  }, [selectedIds, bulkUpdateCards]);

  const bulkDelete = React.useCallback(() => {
    if (!selectedIds.size) return;
    if (!confirm(`Supprimer ${selectedIds.size} carte(s) ? Cette action est irréversible.`)) return;
    bulkDeleteCards([...selectedIds]);
    setSelectedIds(new Set());
  }, [selectedIds, bulkDeleteCards]);

  const bulkToggleQuizDisabled = React.useCallback((type) => {
    if (!selectedIds.size) return;
    // If ALL selected already opt out of this type, the action becomes "re-enable"
    const ids = [...selectedIds];
    const idSet = new Set(ids);
    const allOff = allCards
      .filter(c => idSet.has(c.id))
      .every(c => (c.quizDisabled || []).includes(type));
    bulkUpdateCards(ids, (c) => {
      const cur = c.quizDisabled || [];
      const next = allOff ? cur.filter(t => t !== type) : [...new Set([...cur, type])];
      return { quizDisabled: next };
    });
  }, [selectedIds, allCards, bulkUpdateCards]);

  // Apply the same Q/A composition to every selected card. Attributes that
  // aren't present on a given card are silently filtered out at play time by
  // effectiveQuizSides, so we can pass the union list verbatim here.
  const bulkSetQuizSides = React.useCallback((question, answer) => {
    if (!selectedIds.size) return;
    bulkUpdateCards([...selectedIds], {
      quizSides: {
        question: Array.isArray(question) ? [...question] : [],
        answer:   Array.isArray(answer)   ? [...answer]   : [],
      },
    });
  }, [selectedIds, bulkUpdateCards]);

  // Reset to auto on every selected card.
  const bulkClearQuizSides = React.useCallback(() => {
    if (!selectedIds.size) return;
    bulkUpdateCards([...selectedIds], { quizSides: { question: [], answer: [] } });
  }, [selectedIds, bulkUpdateCards]);

  const [bulkSidesModalOpen, setBulkSidesModalOpen] = React.useState(false);

  const processSheetText = React.useCallback((text, sourceLabel) => {
    let parsed;
    try { parsed = parseCSV(text); }
    catch (e) { alert('Impossible de lire le fichier : ' + e.message); return; }
    const { headers, rows } = parsed;
    if (!rows.length) { alert('Aucune ligne trouvée.'); return; }

    // The field schema is fixed (the two built-in fields). A CSV column whose
    // header matches a built-in field's name — "Carte" or "Démonstration URL" —
    // lands on that field; every other non-core column is ignored. No fields
    // are ever created or retyped on import.
    const fields = lib.customFields || [];
    const partials = rows.map(r => csvRowToCard(r, fields)).filter(c => c.fr || c.kr);
    const skipped = rows.length - partials.length;
    if (!partials.length) {
      alert(
        "Aucune carte importable.\n\n" +
        "Colonnes attendues : « Nom français », « Nom coréen (한국어) », « Romaja (nom coréen) », « Thème / Catégorie », « Tags »\n" +
        "Optionnelles : « Quiz · Question », « Quiz · Réponse » (config par carte), « Fréquence », « Prochaine apparition », « Decks », « Carte », « Démonstration URL »" +
        "\nColonnes détectées : " + headers.join(', ')
      );
      return;
    }
    const msg =
      `Importer ${partials.length} carte(s) depuis ${sourceLabel} ?` +
      (skipped ? `\n${skipped} ligne(s) ignorée(s) (ni FR ni KR).` : '') +
      `\n\nLes cartes seront ajoutées au deck actuel.`;
    if (!confirm(msg)) return;
    addCards(partials);
    setActiveTab('list');
  }, [addCards, lib]);

  const handleImport = React.useCallback((file) => {
    if (!file) return;
    const name = (file.name || '').toLowerCase();
    const isXLSX =
      name.endsWith('.xlsx') || name.endsWith('.xls') || name.endsWith('.ods') ||
      /spreadsheet|excel/.test(file.type || '');

    const reader = new FileReader();
    reader.onerror = () => alert("Erreur de lecture du fichier.");

    if (isXLSX) {
      if (!window.XLSX) {
        alert("Le module XLSX n'est pas chargé (connexion réseau ?). Exportez votre fichier en CSV pour l'instant.");
        return;
      }
      reader.onload = () => {
        let csvText;
        try {
          const wb = window.XLSX.read(reader.result, { type: 'array' });
          const firstName = wb.SheetNames[0];
          if (!firstName) throw new Error('Aucune feuille dans le classeur.');
          csvText = window.XLSX.utils.sheet_to_csv(wb.Sheets[firstName], { blankrows: false });
        } catch (e) {
          alert('Impossible de lire le XLSX : ' + e.message);
          return;
        }
        processSheetText(csvText, `« ${file.name} »`);
      };
      reader.readAsArrayBuffer(file);
    } else {
      reader.onload = () => processSheetText(String(reader.result), `« ${file.name} »`);
      reader.readAsText(file, 'UTF-8');
    }
  }, [processSheetText]);

  // Export the currently visible cards (after search + tag filter). If no
  // filter is active this is the whole library. Filename includes the active
  // deck slug + ISO date so successive exports don't overwrite each other.
  const handleExport = React.useCallback((cardsToExport, includeFrequency = true) => {
    if (!cardsToExport || cardsToExport.length === 0) {
      alert("Aucune carte à exporter.");
      return;
    }
    const date = new Date().toISOString().slice(0, 10);   // YYYY-MM-DD
    const slug = slugifyForFilename(deck.name);
    const filename = `mindforge-${slug}-${date}.csv`;
    downloadCSV(filename, cardsToCSV(cardsToExport, lib.customFields, includeFrequency, lib.decks));
  }, [deck.name, lib.customFields, lib.decks]);

  // All tags in the deck, sorted by frequency descending (most common first).
  const allTags = React.useMemo(() => {
    const counts = new Map();
    for (const c of deck.cards) {
      for (const t of (c.tags || [])) counts.set(t, (counts.get(t) || 0) + 1);
    }
    return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
  }, [deck.cards]);

  const filtered = React.useMemo(() => {
    const q = normalizeHeader(query);          // accent/case-insensitive
    const kr = query.trim();                    // hangul: keep as-is
    const tagSet = activeTags;
    return deck.cards.filter(c => {
      // Tag filter. 'any' (OR): card matches if it has at least one active tag.
      // 'all' (AND): card must carry every active tag.
      if (tagSet.size > 0) {
        const cardTags = c.tags || [];
        const ok = tagMode === 'all'
          ? [...tagSet].every(t => cardTags.includes(t))
          : cardTags.some(t => tagSet.has(t));
        if (!ok) return false;
      }
      if (!q && !kr) return true;
      const inText = (v) => v && normalizeHeader(v).includes(q);
      return (
        inText(c.fr) ||
        inText(c.ro) ||
        inText(c.cat) ||
        (c.tags || []).some(inText) ||
        (kr && (c.kr || '').includes(kr))
      );
    });
  }, [deck.cards, query, activeTags, tagMode]);

  const selected = deck.cards.find(c => c.id === selectedId) ?? null;

  // Navigation list — prefer the user's current filtered view so prev/next
  // follow what they see in the Cartes tab. Falls back to the full deck if
  // the selected card has been filtered out (rare edge case).
  const navList = React.useMemo(() => {
    if (!selected) return filtered;
    return filtered.some(c => c.id === selected.id) ? filtered : deck.cards;
  }, [filtered, deck.cards, selected]);
  const navIndex = selected ? navList.findIndex(c => c.id === selected.id) : -1;
  const prevCard = navIndex > 0 ? navList[navIndex - 1] : null;
  const nextCard = navIndex >= 0 && navIndex < navList.length - 1 ? navList[navIndex + 1] : null;
  const goPrevCard = React.useCallback(() => {
    if (prevCard) setSelectedId(prevCard.id);
  }, [prevCard]);
  const goNextCard = React.useCallback(() => {
    if (nextCard) setSelectedId(nextCard.id);
  }, [nextCard]);

  // Arrow-key navigation inside the Édition tab. Skip when the user is
  // typing inside an input/textarea/contenteditable so editing isn't hijacked.
  React.useEffect(() => {
    if (activeTab !== 'edit') return;
    const isTyping = (el) => {
      if (!el) return false;
      const tag = el.tagName;
      return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
    };
    const onKey = (e) => {
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      if (isTyping(e.target)) return;
      if (e.key === 'ArrowLeft')  { e.preventDefault(); goPrevCard(); }
      if (e.key === 'ArrowRight') { e.preventDefault(); goNextCard(); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [activeTab, goPrevCard, goNextCard]);

  const openCard = (id) => {
    setSelectedId(id);
    setActiveTab('edit');
  };

  const handleAdd = () => {
    const c = addCard();
    setSelectedId(c.id);
    setActiveTab('edit');
  };

  const handleDelete = () => {
    if (!selectedId) return;
    const idx = deck.cards.findIndex(c => c.id === selectedId);
    deleteCard(selectedId);
    // Select neighbour and fall back to the list when the deck is now empty.
    const next = deck.cards[idx + 1] ?? deck.cards[idx - 1] ?? null;
    setSelectedId(next?.id ?? null);
    if (!next) setActiveTab('list');
  };

  // total stats
  const totalCards = deck.cards.length;
  const fullyComplete = deck.cards.filter(c => completionOf(c) === 1).length;
  const withPhoto = deck.cards.filter(c => fieldFilled(c, 'photo')).length;

  return (
    <div className="mf" style={{
      width:'100vw',
      // Clamp to the *visible* viewport. On mobile, 100vh ignores the browser's
      // address bar, so the editor would run taller than the screen and its
      // bottom (and the bottom of any scrolled pane) would sit unreachable
      // under the chrome. 100dvh tracks the real visible height; it's ignored
      // on browsers without dvh support, which keep the 100vh fallback.
      height:'100vh', maxHeight:'100dvh', overflow:'hidden',
      background: MF.bg, color: MF.text,
      display:'flex', flexDirection:'column',
      fontFamily: MF.font,
    }}>
      {/* Top bar — compacted on mobile: the logo/wordmark and full button
          labels are dropped so everything fits on a single row. */}
      <div style={{
        display:'flex', alignItems:'center', gap: isMobile ? 8 : 14, flexWrap:'wrap',
        padding: isMobile ? '10px 12px' : '14px 24px',
        borderBottom:`1px solid ${MF.line}`,
        background: MF.bg,
      }}>
        <button onClick={onClose} className="mf-tap mf-press" style={{
          height: 36, flexShrink: 0,
          padding: isMobile ? 0 : '0 14px 0 10px',
          width: isMobile ? 36 : 'auto',
          borderRadius: 10,
          background: MF.card, border:`1px solid ${MF.line2}`,
          color: MF.text, fontSize: isMobile ? 18 : 13, fontWeight: 600,
          cursor:'pointer',
          display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
        }} title="Retour au jeu" aria-label="Retour au jeu">
          <span style={{fontSize: 18, lineHeight: 1, color: MF.textDim}}>←</span>
          {!isMobile && <span>Retour</span>}
        </button>

        {/* Wordmark — desktop only; on mobile the screen is too narrow to spare
            the room, and the ← button already anchors the bar. */}
        {!isMobile && (
          <>
            <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
              <path d="M4 8 L12 4 L20 8 L20 12 L12 16 L4 12 Z" stroke={MF.purple} strokeWidth="1.8" strokeLinejoin="round"/>
              <path d="M12 16 L12 20 M8 20 L16 20" stroke={MF.purple} strokeWidth="1.8" strokeLinecap="round"/>
            </svg>
            <span style={{fontSize: 14, fontWeight: 700}}>MindForge</span>
            <span style={{color: MF.line2, fontSize: 18}}>·</span>
          </>
        )}

        <input
          value={deck.name}
          onChange={(e) => updateMeta({ name: e.target.value })}
          placeholder="Nom du deck"
          style={{
            background:'transparent', border:'none', outline:'none',
            color: MF.text, fontSize: isMobile ? 16 : 18, fontWeight: 700,
            letterSpacing:'-.01em',
            // On mobile the name input itself absorbs the free space (no extra
            // spacer); on desktop it stays content-sized and a spacer pushes
            // the action buttons to the right edge.
            minWidth: isMobile ? 0 : 200, flex: isMobile ? 1 : '0 1 auto',
            padding: '4px 6px', borderRadius: 6,
            fontFamily: 'inherit',
          }}
          onFocus={(e) => e.target.style.background = MF.card}
          onBlur={(e) => e.target.style.background = 'transparent'}
        />

        {!isMobile && <div style={{flex: 1}}/>}

        <button
          onClick={() => {
            if (confirm('Réinitialiser la bibliothèque (cartes + decks personnels) ? Vos modifications seront perdues.')) reseed();
          }}
          className="mf-tap"
          style={{
            padding: isMobile ? 0 : '9px 14px',
            width: isMobile ? 36 : 'auto', height: isMobile ? 36 : 'auto', flexShrink: 0,
            display:'inline-flex', alignItems:'center', justifyContent:'center',
            borderRadius: 10,
            background: 'transparent', border:`1px solid ${MF.line}`,
            color: MF.textDim, fontSize: isMobile ? 16 : 13, fontWeight: 600, cursor:'pointer',
          }}
          title="Recharger les cartes d'exemple"
        >{isMobile ? '↺' : '↺ Réinitialiser'}</button>

        <button onClick={onClose} className="mf-tap mf-btn-primary mf-press"
          title={isMobile ? 'Jouer ce deck' : undefined}
          style={{
            padding: isMobile ? 0 : '10px 18px',
            width: isMobile ? 44 : 'auto', height: isMobile ? 36 : 'auto', flexShrink: 0,
            borderRadius: 10,
            fontSize: 13, fontWeight: 700, cursor:'pointer',
            display:'inline-flex', alignItems:'center', justifyContent:'center', gap: 8,
          }}>
          <span>▶</span>
          {!isMobile && <span>Jouer ce deck</span>}
        </button>
      </div>

      {/* Tab bar */}
      <div style={{
        display:'flex', alignItems:'stretch', gap: 2,
        padding: isMobile ? '0 6px' : '0 18px',
        background: MF.bg, borderBottom:`1px solid ${MF.line}`,
      }}>
        <TabButton active={activeTab === 'list'} onClick={() => setActiveTab('list')}>
          <span>Cartes</span>
          <span style={{
            display:'inline-flex', alignItems:'center', justifyContent:'center',
            minWidth: 22, height: 18, padding: '0 6px', borderRadius: 99,
            background: activeTab === 'list' ? MF.purple : MF.card,
            color: activeTab === 'list' ? '#0e0d12' : MF.textDim,
            fontSize: 11, fontWeight: 700, fontVariantNumeric:'tabular-nums',
          }}>{totalCards}</span>
        </TabButton>
        <TabButton active={activeTab === 'decks'} onClick={() => setActiveTab('decks')}>
          <span>Decks</span>
          <span style={{
            display:'inline-flex', alignItems:'center', justifyContent:'center',
            minWidth: 22, height: 18, padding: '0 6px', borderRadius: 99,
            background: activeTab === 'decks' ? MF.purple : MF.card,
            color: activeTab === 'decks' ? '#0e0d12' : MF.textDim,
            fontSize: 11, fontWeight: 700, fontVariantNumeric:'tabular-nums',
          }}>{decks.length}</span>
        </TabButton>
        <TabButton active={activeTab === 'photos'} onClick={() => setActiveTab('photos')}>
          <span>Médias</span>
          <span style={{
            display:'inline-flex', alignItems:'center', justifyContent:'center',
            minWidth: 22, height: 18, padding: '0 6px', borderRadius: 99,
            background: activeTab === 'photos' ? MF.purple : MF.card,
            color: activeTab === 'photos' ? '#0e0d12' : MF.textDim,
            fontSize: 11, fontWeight: 700, fontFamily: MF.mono,
            letterSpacing:'.04em',
          }}>↧</span>
        </TabButton>
        <TabButton
          active={activeTab === 'edit'}
          disabled={!selected}
          onClick={() => selected && setActiveTab('edit')}
        >
          <span>Édition</span>
          {selected && (
            <span style={{
              fontSize: 11, color: activeTab === 'edit' ? MF.textDim : MF.textMute,
              fontWeight: 500, fontFamily: MF.mono,
            }}>#{String(selected.id).padStart(2,'0')}</span>
          )}
        </TabButton>
      </div>

      {/* Tab content */}
      <div style={{flex:1, minHeight:0, display:'flex', flexDirection:'column'}}>
        {activeTab === 'list' && (
          <ListPane
            deck={deck}
            filtered={filtered}
            query={query}
            setQuery={setQuery}
            allTags={allTags}
            activeTags={activeTags}
            tagMode={tagMode}
            onSetTagMode={setTagMode}
            onToggleTag={toggleTag}
            onClearTags={clearTags}
            selectedId={selectedId}
            selectedIds={selectedIds}
            onToggleSelect={toggleCardSelect}
            onClearSelection={clearSelection}
            onSelectAll={selectAll}
            bulk={{
              addTag:             bulkAddTag,
              removeTag:          bulkRemoveTag,
              setCategory:        bulkSetCategory,
              setTone:            bulkSetTone,
              del:                bulkDelete,
              toggleQuizDisabled: bulkToggleQuizDisabled,
              openQuizSides:      () => setBulkSidesModalOpen(true),
            }}
            onOpen={openCard}
            onAdd={handleAdd}
            onImport={handleImport}
            onExport={handleExport}
            isMobile={isMobile}
            totalCards={totalCards}
            fullyComplete={fullyComplete}
            withPhoto={withPhoto}
          />
        )}
        {activeTab === 'decks' && (
          <DecksPane
            isMobile={isMobile}
            decks={decks}
            allCards={allCards}
            allTags={lib.allTags}
            allCats={lib.allCats}
            activeDeck={activeDeck}
            selectedDeckId={selectedDeckId}
            onSelectDeck={setSelectedDeckId}
            onAddDeck={(kind) => { const d = addDeck(kind); setSelectedDeckId(d.id); }}
            onUpdateDeck={updateDeck}
            onDeleteDeck={(id) => { deleteDeck(id); setSelectedDeckId(BUILTIN_DECK_ID); }}
            onDuplicateDeck={(id) => { const d = duplicateDeck(id); if (d) setSelectedDeckId(d.id); }}
            onSetActive={setActiveDeckId}
            onToggleCard={toggleCardInManualDeck}
            onReorderCards={setDeckCardIds}
            onMaterialize={materializeSmartDeck}
          />
        )}
        {activeTab === 'photos' && (
          <DropPane
            deck={deck}
            isMobile={isMobile}
            customFields={lib.customFields}
            allTags={allTags}
            activeTags={activeTags}
            tagMode={tagMode}
            onSetTagMode={setTagMode}
            onToggleTag={toggleTag}
            onClearTags={clearTags}
            onSetPhoto={(id, dataUrl) => updateCard(id, { photo: dataUrl })}
            onSetCustomValue={lib.setCardCustomValue}
            onOpenCard={openCard}
          />
        )}
        {activeTab === 'edit' && (
          <main style={{flex: 1, minWidth: 0, minHeight: 0, background: MF.bg, display:'flex', flexDirection:'column'}}>
            <CardForm
              // Same key regardless of which card is selected — React reuses
              // the same scroll container so scrollTop is preserved when the
              // user clicks Précédente / Suivante. A separate "empty" key
              // forces a clean unmount when going to the no-selection
              // placeholder (different layout).
              key={selected ? 'card' : 'empty'}
              card={selected}
              isMobile={isMobile}
              onChange={(patch) => selected && updateCard(selected.id, patch)}
              onDelete={handleDelete}
              customFields={lib.customFields}
              onCustomChange={lib.setCardCustomValue}
              onPrev={prevCard ? goPrevCard : null}
              onNext={nextCard ? goNextCard : null}
              navIndex={navIndex}
              navTotal={navList.length}
              prevLabel={prevCard?.fr}
              nextLabel={nextCard?.fr}
            />
          </main>
        )}
      </div>

      {bulkSidesModalOpen && (
        <BulkQuizSidesModal
          selectedCards={allCards.filter(c => selectedIds.has(c.id))}
          customFields={lib.customFields}
          onApply={(q, a) => { bulkSetQuizSides(q, a); setBulkSidesModalOpen(false); }}
          onClear={()      => { bulkClearQuizSides();   setBulkSidesModalOpen(false); }}
          onClose={()      => setBulkSidesModalOpen(false)}
        />
      )}
    </div>
  );
};

// ─────────────── Editor sub-views ───────────────

const TabButton = ({ active, disabled, onClick, children }) => (
  <button
    onClick={onClick}
    disabled={disabled}
    className="mf-tap"
    style={{
      display:'inline-flex', alignItems:'center', gap: 8,
      padding: '12px 18px', minHeight: 44,
      background: 'transparent', border: 'none',
      borderBottom: `2px solid ${active ? MF.purple : 'transparent'}`,
      color: disabled ? MF.textMute : active ? MF.text : MF.textDim,
      fontSize: 13, fontWeight: 600, letterSpacing: '.02em',
      fontFamily: 'inherit', cursor: disabled ? 'not-allowed' : 'pointer',
      opacity: disabled ? .55 : 1, marginBottom: -1,
    }}
  >{children}</button>
);

const ListPane = ({
  deck, filtered, query, setQuery,
  allTags, activeTags, tagMode, onSetTagMode, onToggleTag, onClearTags,
  selectedId, selectedIds, onToggleSelect, onClearSelection, onSelectAll, bulk,
  onOpen, onAdd, onImport, onExport, isMobile,
  totalCards, fullyComplete, withPhoto,
}) => {
  const importInputRef = React.useRef();
  // Whether the CSV export carries the per-card frequency / next-due columns.
  const [includeFreq, setIncludeFreq] = React.useState(true);
  const hasActiveTags = activeTags.size > 0;
  const selectionCount = selectedIds ? selectedIds.size : 0;
  const filteredIds = React.useMemo(() => filtered.map(c => c.id), [filtered]);
  const allFilteredSelected = selectionCount > 0
    && filteredIds.every(id => selectedIds.has(id));
  return (
  <div style={{flex:1, minHeight:0, display:'flex', flexDirection:'column'}}>
    {/* Library stats — overview only, hidden in the edit tab. */}
    <div style={{
      padding: isMobile ? '10px 14px' : '14px 24px',
      borderBottom:`1px solid ${MF.line}`,
      background: MF.bg2,
      display:'flex', alignItems:'center', gap: 18, flexWrap:'wrap',
    }}>
      <Stat label="Cartes"    value={totalCards}/>
      <Stat label="Complètes" value={`${fullyComplete} / ${totalCards}`}/>
      <Stat label="Photos"    value={`${withPhoto} / ${totalCards}`}/>
    </div>

    {/* Search + add + import */}
    <div style={{
      padding: isMobile ? '12px 14px 8px' : '16px 24px 10px',
      display:'flex', gap: 10, alignItems:'center', flexWrap:'wrap',
    }}>
      <div style={{flex: 1, minWidth: 200, position:'relative'}}>
        <input
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Rechercher fr / 한국어 / romaja / thème / tag…"
          style={{
            width:'100%',
            padding:'10px 32px 10px 12px',
            background: MF.card, border:`1px solid ${MF.line}`,
            borderRadius: 10, color: MF.text, fontSize: 13,
            outline:'none', fontFamily: 'inherit',
          }}
        />
        {query && (
          <button
            onClick={() => setQuery('')}
            aria-label="Effacer la recherche"
            style={{
              position:'absolute', right: 4, top: '50%', transform:'translateY(-50%)',
              width: 26, height: 26, borderRadius: 99, padding: 0,
              background:'transparent', border:'none', color: MF.textMute,
              fontSize: 14, cursor:'pointer',
            }}
          >×</button>
        )}
      </div>
      <button
        onClick={() => importInputRef.current?.click()}
        className="mf-tap"
        title="CSV, TSV ou XLSX avec colonnes : Nom français, Nom coréen (한국어), Romaja (nom coréen), Thème / Catégorie, Tags. Optionnel : Fréquence, Prochaine apparition, Decks"
        style={{
          padding:'10px 14px', borderRadius: 10, fontSize: 13, fontWeight: 600,
          background: 'transparent', border:`1px solid ${MF.line}`,
          color: MF.textDim, cursor:'pointer',
          display:'inline-flex', alignItems:'center', gap: 8, whiteSpace:'nowrap',
        }}
      >
        <span style={{fontSize: 14, lineHeight: 1}}>↥</span>
        <span>Importer</span>
      </button>
      <input
        ref={importInputRef}
        type="file"
        accept=".csv,.tsv,.xlsx,.xls,.ods,text/csv,text/tab-separated-values,text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,application/vnd.oasis.opendocument.spreadsheet"
        style={{display:'none'}}
        onChange={(e) => {
          const f = e.target.files?.[0];
          onImport(f);
          e.target.value = '';
        }}
      />
      <button
        onClick={() => onExport(filtered, includeFreq)}
        disabled={filtered.length === 0}
        className="mf-tap"
        title={
          hasActiveTags || query
            ? `Exporter les ${filtered.length} carte${filtered.length > 1 ? 's' : ''} filtrée${filtered.length > 1 ? 's' : ''} en CSV`
            : `Exporter toute la bibliothèque (${filtered.length} cartes) en CSV`
        }
        style={{
          padding:'10px 14px', borderRadius: 10, fontSize: 13, fontWeight: 600,
          background: 'transparent', border:`1px solid ${MF.line}`,
          color: filtered.length === 0 ? MF.textMute : MF.textDim,
          cursor: filtered.length === 0 ? 'not-allowed' : 'pointer',
          opacity: filtered.length === 0 ? .55 : 1,
          display:'inline-flex', alignItems:'center', gap: 8, whiteSpace:'nowrap',
        }}
      >
        <span style={{fontSize: 14, lineHeight: 1}}>↧</span>
        <span>Exporter{(hasActiveTags || query) ? ` (${filtered.length})` : ''}</span>
      </button>
      <label
        title="Inclure les colonnes « Fréquence » et « Prochaine apparition » dans le CSV exporté"
        style={{
          display:'inline-flex', alignItems:'center', gap: 6, whiteSpace:'nowrap',
          fontSize: 12, color: MF.textMute, cursor:'pointer', userSelect:'none',
        }}
      >
        <input
          type="checkbox"
          checked={includeFreq}
          onChange={(e) => setIncludeFreq(e.target.checked)}
          style={{ accentColor: MF.purple, cursor:'pointer' }}
        />
        <span>fréquences</span>
      </label>
      <button
        onClick={onAdd}
        className="mf-tap mf-btn-primary"
        style={{
          padding:'10px 16px', borderRadius: 10, fontSize: 13, fontWeight: 700,
          cursor:'pointer', display:'inline-flex', alignItems:'center', gap: 8,
          whiteSpace:'nowrap',
        }}
      >
        <span style={{fontSize: 16, lineHeight: 1}}>＋</span>
        <span>Nouvelle carte</span>
      </button>
    </div>

    <TagFilterPopover
      allTags={allTags}
      activeTags={activeTags}
      tagMode={tagMode}
      onSetTagMode={onSetTagMode}
      onToggle={onToggleTag}
      onClear={onClearTags}
      padding={isMobile ? '0 14px 10px' : '0 24px 12px'}
    />

    <BulkActionBar
      selectionCount={selectionCount}
      allFilteredSelected={allFilteredSelected}
      filteredIds={filteredIds}
      onSelectAll={onSelectAll}
      onClearSelection={onClearSelection}
      bulk={bulk}
      isMobile={isMobile}
    />

    {/* Card list — single column on mobile, 2 columns on wider viewports */}
    <div className="mf-scroll" style={{
      flex: 1, minHeight: 0, overflow: 'auto',
      WebkitOverflowScrolling: 'touch',
      padding: isMobile ? '4px 10px 18px' : '6px 18px 24px',
    }}>
      {filtered.length === 0 ? (
        <div style={{padding:'40px 20px', textAlign:'center', color: MF.textMute, fontSize: 13}}>
          {query || hasActiveTags ? 'Aucun résultat pour ce filtre.' : 'Aucune carte — créez-en une.'}
        </div>
      ) : (
        <div style={{
          display:'grid',
          gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fill, minmax(280px, 1fr))',
          gap: 4,
        }}>
          {filtered.map(c => (
            <CardRow
              key={c.id}
              card={c}
              selected={c.id === selectedId}
              checked={selectedIds ? selectedIds.has(c.id) : false}
              onClick={() => onOpen(c.id)}
              onToggleCheck={() => onToggleSelect && onToggleSelect(c.id)}
            />
          ))}
        </div>
      )}
    </div>
  </div>
  );
};

// ─────────────── Bulk action bar (Cartes tab) ───────────────
const BulkActionBar = ({
  selectionCount, allFilteredSelected, filteredIds,
  onSelectAll, onClearSelection, bulk, isMobile,
}) => {
  if (!selectionCount || !bulk) return null;
  const btn = {
    padding:'7px 11px', borderRadius: 8, fontSize: 12, fontWeight: 600,
    background: 'transparent', border:`1px solid ${MF.line}`,
    color: MF.textDim, cursor:'pointer', fontFamily:'inherit',
    display:'inline-flex', alignItems:'center', gap: 5, whiteSpace:'nowrap',
  };
  return (
    <div style={{
      padding: isMobile ? '10px 14px' : '12px 24px',
      background: 'rgba(167,139,250,.08)',
      borderTop: `1px solid ${MF.line}`,
      borderBottom: `1px solid ${MF.line}`,
      display:'flex', alignItems:'center', gap: 10, flexWrap:'wrap',
    }}>
      <span style={{
        fontSize: 12, fontWeight: 700, color: MF.purple,
        fontVariantNumeric:'tabular-nums',
      }}>
        {selectionCount} sélectionnée{selectionCount > 1 ? 's' : ''}
      </span>
      <button
        onClick={() => allFilteredSelected ? onClearSelection() : onSelectAll(filteredIds)}
        className="mf-tap"
        style={{...btn, padding:'7px 10px'}}
      >
        {allFilteredSelected ? 'Tout désélectionner' : 'Tout cocher (filtre actif)'}
      </button>
      <div style={{flex: 1}}/>
      <button onClick={bulk.addTag}        className="mf-tap" style={btn}>＋ Tag</button>
      <button onClick={bulk.removeTag}     className="mf-tap" style={btn}>− Tag</button>
      <button onClick={bulk.setCategory}   className="mf-tap" style={btn}>Thème…</button>
      <button onClick={bulk.openQuizSides} className="mf-tap" style={btn}>📑 Quiz Q/A…</button>
      <BulkToneSwatches onPick={bulk.setTone}/>
      <button
        onClick={bulk.del}
        className="mf-tap"
        style={{...btn, color: MF.bad, borderColor:'rgba(244,138,138,.4)'}}
      >🗑 Supprimer</button>
    </div>
  );
};

const BulkToneSwatches = ({ onPick }) => (
  <div style={{
    display:'inline-flex', alignItems:'center', gap: 4,
    padding:'5px 8px 5px 10px', borderRadius: 8,
    border:`1px solid ${MF.line}`,
  }}>
    <span style={{fontSize: 11, color: MF.textMute, fontWeight: 600, marginRight: 4}}>Couleur</span>
    {TONES.map(t => (
      <button
        key={t}
        onClick={() => onPick(t)}
        className="mf-tap"
        title={`Tone ${t}`}
        style={{
          width: 18, height: 18, padding: 0, borderRadius: 4,
          background: cardPlaceholderBg(t),
          border:`1px solid ${MF.line2}`, cursor:'pointer',
        }}
      />
    ))}
  </div>
);

const Stat = ({ label, value }) => (
  <div style={{display:'flex', flexDirection:'column', gap: 2, minWidth: 60}}>
    <span style={{fontSize: 10, color: MF.textMute, fontWeight: 700, letterSpacing:'.14em', textTransform:'uppercase'}}>{label}</span>
    <span style={{fontSize: 15, fontWeight: 700, color: MF.text, fontVariantNumeric:'tabular-nums'}}>{value}</span>
  </div>
);

// ─────────────── Tag filter (popover) ───────────────
//
// Tag filter collapsed behind a single "Filtrer par tag" trigger. The trigger
// carries a count bubble (top-right) showing how many tags are active; clicking
// it opens a panel with a match-mode toggle ("Au moins un" / "Tous requis") and
// the tag chips you toggle on/off. Clicking the backdrop closes the panel.
// Shared by the Cartes and Médias tabs — collapsing it reclaims a row of
// vertical space, valuable on mobile.
const TagFilterPopover = ({ allTags, activeTags, tagMode = 'any', onSetTagMode, onToggle, onClear, padding }) => {
  const [open, setOpen] = React.useState(false);
  if (!allTags || allTags.length === 0) return null;
  const count = activeTags.size;
  return (
    <div style={{ padding }}>
      <div style={{ position: 'relative', display: 'inline-block' }}>
        <button
          onClick={() => setOpen(o => !o)}
          className="mf-tap"
          aria-expanded={open}
          style={{
            display:'inline-flex', alignItems:'center', gap: 7, position:'relative',
            padding:'7px 13px', borderRadius: 10,
            background: count > 0 ? 'rgba(167,139,250,.12)' : MF.card,
            border:`1px solid ${count > 0 ? MF.purple : MF.line}`,
            color: count > 0 ? MF.text : MF.textDim,
            fontSize: 13, fontWeight: 600, cursor:'pointer', fontFamily:'inherit',
          }}
        >
          {/* Funnel glyph */}
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M3 5 H21 L14 13 V20 L10 18 V13 Z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
          </svg>
          <span>Filtrer par tag</span>
          <span style={{ fontSize: 10, opacity: .7, transition:'transform .15s', transform: open ? 'rotate(180deg)' : 'none' }}>▾</span>
          {count > 0 && (
            <span style={{
              position:'absolute', top:-7, right:-7,
              minWidth: 18, height: 18, padding:'0 5px', borderRadius: 99,
              background: MF.purple, color:'#1a0f2e',
              fontSize: 10, fontWeight: 800,
              display:'inline-flex', alignItems:'center', justifyContent:'center',
              border:`2px solid ${MF.bg}`, fontVariantNumeric:'tabular-nums',
            }}>{count}</span>
          )}
        </button>

        {open && (
          <>
            {/* Click-away backdrop — closes the panel on any outside tap. */}
            <div onClick={() => setOpen(false)} style={{ position:'fixed', inset: 0, zIndex: 40 }}/>
            <div
              className="mf-scroll"
              style={{
                position:'absolute', top:'calc(100% + 6px)', left: 0, zIndex: 41,
                width:'max-content', maxWidth:'min(380px, 86vw)',
                maxHeight: 320, overflowY:'auto',
                background: MF.card2, border:`1px solid ${MF.line2}`,
                borderRadius: 12, boxShadow:'0 14px 36px rgba(0,0,0,.5)',
                padding: 12,
              }}
            >
              <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap: 12, marginBottom: 10 }}>
                <span style={{ fontSize: 10, color: MF.textMute, fontWeight: 700, letterSpacing:'.14em', textTransform:'uppercase' }}>
                  Filtrer par tag
                </span>
                {count > 0 && (
                  <button onClick={onClear} className="mf-tap" style={{
                    padding:'3px 8px', borderRadius: 99,
                    background:'transparent', border:`1px dashed ${MF.line2}`,
                    color: MF.textMute, fontSize: 11, fontWeight: 600, cursor:'pointer', fontFamily:'inherit',
                  }}>× Tout effacer</button>
                )}
              </div>

              {/* Match mode — how the selected tags combine. */}
              <div style={{
                display:'flex', gap: 4, marginBottom: 12,
                background: MF.bg, border:`1px solid ${MF.line}`,
                borderRadius: 99, padding: 3,
              }}>
                {[['any', 'Au moins un'], ['all', 'Tous requis']].map(([mode, label]) => {
                  const on = tagMode === mode;
                  return (
                    <button
                      key={mode}
                      onClick={() => onSetTagMode && onSetTagMode(mode)}
                      className="mf-tap"
                      style={{
                        flex: 1, padding:'6px 10px', borderRadius: 99, border:'none',
                        background: on ? MF.purple : 'transparent',
                        color: on ? '#1a0f2e' : MF.textDim,
                        fontSize: 12, fontWeight: 700, cursor:'pointer',
                        fontFamily:'inherit', whiteSpace:'nowrap',
                      }}
                    >{label}</button>
                  );
                })}
              </div>

              <div style={{ display:'flex', flexWrap:'wrap', gap: 8 }}>
                {allTags.map(([tag, c]) => {
                  const on = activeTags.has(tag);
                  return (
                    <button
                      key={tag}
                      onClick={() => onToggle(tag)}
                      className="mf-tap"
                      style={{
                        display:'inline-flex', alignItems:'center', gap: 5,
                        padding:'4px 9px', borderRadius: 99,
                        background: on ? 'rgba(167,139,250,.15)' : MF.card,
                        border:`1px solid ${on ? MF.purple : MF.line}`,
                        color: on ? MF.text : MF.textDim,
                        fontSize: 12, fontWeight: 600, cursor:'pointer', fontFamily:'inherit',
                      }}
                    >
                      <span>{tag}</span>
                      <span style={{ opacity:.55, fontSize: 11, fontWeight: 500, fontVariantNumeric:'tabular-nums' }}>{c}</span>
                    </button>
                  );
                })}
              </div>
            </div>
          </>
        )}
      </div>
    </div>
  );
};

// ─────────────── Photos pane ───────────────
//
// Grid of slots — one per card. Click or drag-drop an image onto a slot to
// assign it. By default only cards without a photo are shown, so the grid
// shrinks as the user works through the deck.

// ─────────────── Drop targets ───────────────
//
// The Médias tab lets the user drag-drop image files onto a grid of cards to
// set the main photo. The two built-in fields (Carte, Démonstration URL) are
// URL/region based — they have no file upload, so the only drop target is the
// card photo.
const buildDropTargets = () => [{
  id: 'photo', kind: 'photo',
  label: 'Photo principale', icon: '📷', accept: 'image/*',
}];

// Does the card already have a value for the given target?
const cardHasTarget = (card, target) => {
  if (target.kind === 'photo') return !!card.photo;
  return !!card.custom?.[target.fieldId];
};

const DropPane = ({
  deck, isMobile, customFields,
  allTags, activeTags, tagMode = 'any', onSetTagMode, onToggleTag, onClearTags,
  onSetPhoto, onSetCustomValue, onOpenCard,
}) => {
  const targets = React.useMemo(() => buildDropTargets(customFields), [customFields]);
  const [targetId, setTargetId] = React.useState(targets[0].id);
  // Stay valid if the user removes a custom field while this pane is open.
  React.useEffect(() => {
    if (!targets.find(t => t.id === targetId)) setTargetId(targets[0].id);
  }, [targets, targetId]);
  const target = targets.find(t => t.id === targetId) || targets[0];

  const [showAll, setShowAll] = React.useState(false);

  const cards = React.useMemo(() => {
    let list = showAll ? deck.cards : deck.cards.filter(c => !cardHasTarget(c, target));
    if (activeTags.size > 0) {
      // 'all' = card must carry every active tag; 'any' = at least one.
      list = list.filter(c => {
        const cardTags = c.tags || [];
        return tagMode === 'all'
          ? [...activeTags].every(t => cardTags.includes(t))
          : cardTags.some(t => activeTags.has(t));
      });
    }
    return list;
  }, [deck.cards, showAll, activeTags, tagMode, target]);

  const totalCards = deck.cards.length;
  const filled     = deck.cards.filter(c => cardHasTarget(c, target)).length;
  const missing    = totalCards - filled;

  return (
    <div style={{flex:1, minHeight:0, display:'flex', flexDirection:'column'}}>
      {/* Header strip */}
      <div style={{
        padding: isMobile ? '12px 14px' : '16px 24px',
        borderBottom:`1px solid ${MF.line}`, background: MF.bg2,
        display:'flex', alignItems:'center', gap: 14, flexWrap:'wrap',
      }}>
        <div style={{display:'flex', flexDirection:'column', gap: 2}}>
          <span style={{
            fontSize: 10, color: MF.textMute, fontWeight: 700,
            letterSpacing:'.14em', textTransform:'uppercase',
          }}>Drag &amp; drop</span>
          <span style={{fontSize: 14, color: MF.text, fontWeight: 600}}>
            {missing === 0
              ? `Toutes les cartes ont ${target.label.toLowerCase()} (${filled} / ${totalCards})`
              : `${missing} carte${missing > 1 ? 's' : ''} sans valeur · ${filled} / ${totalCards} complétées`}
          </span>
        </div>
        <div style={{flex: 1}}/>
        <label style={{
          display:'inline-flex', alignItems:'center', gap: 8,
          fontSize: 12, color: MF.textDim, cursor: 'pointer', userSelect: 'none',
        }}>
          <input
            type="checkbox"
            checked={showAll}
            onChange={(e) => setShowAll(e.target.checked)}
            style={{accentColor: MF.purple, width: 16, height: 16, cursor:'pointer'}}
          />
          Afficher aussi les cartes déjà remplies
        </label>
      </div>

      {/* Target picker */}
      <div style={{
        padding: isMobile ? '10px 14px 4px' : '12px 24px 4px',
        display:'flex', alignItems:'center', gap: 10, flexWrap:'wrap',
      }}>
        <span style={{
          fontSize: 10, color: MF.textMute, fontWeight: 700,
          letterSpacing:'.14em', textTransform:'uppercase', marginRight: 2,
        }}>Cible</span>
        {targets.map(t => {
          const on = t.id === targetId;
          return (
            <button
              key={t.id}
              onClick={() => setTargetId(t.id)}
              className="mf-tap"
              style={{
                padding:'6px 12px', borderRadius: 99,
                background: on ? 'rgba(167,139,250,.18)' : MF.card,
                border:`1.5px solid ${on ? MF.purple : MF.line}`,
                color: on ? MF.text : MF.textDim,
                fontSize: 12.5, fontWeight: 600, cursor:'pointer',
                display:'inline-flex', alignItems:'center', gap: 6,
              }}
            >
              <span style={{fontSize: 13, lineHeight: 1}}>{t.icon}</span>
              <span>{t.label}</span>
            </button>
          );
        })}
      </div>

      <TagFilterPopover
        allTags={allTags}
        activeTags={activeTags}
        tagMode={tagMode}
        onSetTagMode={onSetTagMode}
        onToggle={onToggleTag}
        onClear={onClearTags}
        padding={isMobile ? '6px 14px 4px' : '8px 24px 4px'}
      />

      {/* Grid */}
      <div className="mf-scroll" style={{
        flex: 1, minHeight: 0, overflow: 'auto',
        WebkitOverflowScrolling: 'touch',
        padding: isMobile ? '14px 12px' : '20px 24px',
      }}>
        {cards.length === 0 ? (
          <div style={{
            padding: '60px 20px', textAlign:'center',
            color: MF.textMute, fontSize: 13,
          }}>
            {activeTags.size > 0
              ? 'Aucune carte ne correspond aux tags filtrés.'
              : showAll
                ? 'Aucune carte dans le deck.'
                : `🎉 Toutes les cartes ont ${target.label.toLowerCase()}. Coche la case ci-dessus pour réafficher la grille complète.`}
          </div>
        ) : (
          <div style={{
            display:'grid',
            // Wider columns than the old Photos pane — gives each slot label
            // room to wrap the prefix-heavy enchaînement titles ("1er dan ·
            // Sup+Pos · n°1") onto multiple lines without truncation.
            gridTemplateColumns: isMobile
              ? 'repeat(auto-fill, minmax(150px, 1fr))'
              : 'repeat(auto-fill, minmax(200px, 1fr))',
            gap: isMobile ? 10 : 14,
            alignItems:'start',
          }}>
            {cards.map(c => (
              <DropSlot
                key={c.id + ':' + target.id}
                card={c}
                target={target}
                onSetPhoto={(dataUrl) => onSetPhoto(c.id, dataUrl)}
                onSetCustomValue={(v)  => onSetCustomValue(c.id, target.fieldId, v)}
                onOpenCard={() => onOpenCard(c.id)}
              />
            ))}
          </div>
        )}
      </div>
    </div>
  );
};

// One drop slot — adapts its preview and accepted file type to the selected
// target. Click to pick a file, drag-drop a matching file onto it, or open
// the full card editor via the small "édition" link.
const DropSlot = ({ card, target, onSetPhoto, onSetCustomValue, onOpenCard }) => {
  const inputRef = React.useRef();
  const [dragOver, setDragOver] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const filled = cardHasTarget(card, target);
  const currentValue = target.kind === 'photo'
    ? card.photo
    : card.custom?.[target.fieldId];
  // Resolve mediaStore IDs (and legacy dataURLs) into a renderable URL.
  // For photo target the value is already a dataURL — useMediaURL passes it
  // through unchanged.
  const previewUrl = useMediaURL(currentValue);

  const handleFile = async (file) => {
    if (!file) return;
    const expected = target.accept.replace('/*', '');
    if (!file.type.startsWith(expected + '/')) {
      alert(`Type de fichier attendu : ${expected}/*.`);
      return;
    }
    setBusy(true);
    try {
      // Photo is the only drop target — store it as a compressed dataURL.
      if (file.size > MAX_PHOTO_MB * 1024 * 1024) {
        alert(`Image trop lourde (${(file.size/1024/1024).toFixed(1)} Mo). Max ${MAX_PHOTO_MB} Mo en entrée.`);
        return;
      }
      let blob = file;
      if (file.size > 250 * 1024) {
        blob = await maybeCompressImage(file, { maxDim: 1920, quality: 0.82 });
      }
      if (blob.size > MAX_COMPRESSED_PHOTO_MB * 1024 * 1024) {
        alert(`Photo encore trop lourde après compression (${(blob.size/1024/1024).toFixed(1)} Mo).`);
        return;
      }
      const dataUrl = await new Promise((res, rej) => {
        const r = new FileReader();
        r.onload  = () => res(r.result);
        r.onerror = () => rej(r.error);
        r.readAsDataURL(blob);
      });
      onSetPhoto(dataUrl);
    } catch (e) {
      alert("Stockage du fichier impossible : " + (e?.message || e));
    } finally {
      setBusy(false);
    }
  };

  const handleClear = (e) => {
    e.stopPropagation();
    if (!confirm('Retirer cette photo ?')) return;
    onSetPhoto(null);
  };

  // Preview block — varies by target kind.
  const renderPreview = () => {
    if (!filled) return null;
    if (target.kind === 'photo' || target.kind === 'image') {
      return (
        <div style={{
          position:'absolute', inset: 0,
          background: previewUrl ? `url("${previewUrl}") center/cover` : MF.card,
        }}/>
      );
    }
    if (target.kind === 'audio') {
      return (
        <div style={{
          position:'absolute', inset: 0,
          background: 'linear-gradient(135deg, #1f2d4d 0%, #4d8eff 100%)',
          display:'flex', alignItems:'center', justifyContent:'center',
        }}>
          <span style={{fontSize: 36, color:'#fff', textShadow:'0 2px 8px rgba(0,0,0,.4)'}}>🔊</span>
        </div>
      );
    }
    if (target.kind === 'video') {
      return (
        <div style={{
          position:'absolute', inset: 0,
          background:'#000',
          display:'flex', alignItems:'center', justifyContent:'center',
        }}>
          {previewUrl ? (
            <video
              src={previewUrl}
              muted playsInline
              preload="metadata"
              style={{width:'100%', height:'100%', objectFit:'cover'}}
            />
          ) : (
            <span style={{fontSize: 36, color:'#fff'}}>🎬</span>
          )}
          <span style={{
            position:'absolute', display:'flex', alignItems:'center', justifyContent:'center',
            width: 36, height: 36, borderRadius: 99,
            background:'rgba(0,0,0,.55)', border:'1px solid rgba(255,255,255,.25)',
            color:'#fff', fontSize: 14, pointerEvents:'none',
          }}>▶</span>
        </div>
      );
    }
    return null;
  };

  return (
    <div style={{display:'flex', flexDirection:'column', gap: 6}}>
      <div
        onClick={() => !busy && inputRef.current?.click()}
        onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
        onDragLeave={() => setDragOver(false)}
        onDrop={(e) => {
          e.preventDefault();
          setDragOver(false);
          handleFile(e.dataTransfer.files?.[0]);
        }}
        className="mf-tap"
        style={{
          position:'relative', width:'100%',
          aspectRatio: target.kind === 'video' ? '16 / 10' : '4 / 3',
          borderRadius: 12, cursor: busy ? 'wait' : 'pointer', overflow:'hidden',
          background: filled ? '#000' : cardPlaceholderBg(card.tone),
          border:`2px ${dragOver ? 'solid' : (filled ? 'solid' : 'dashed')} ${dragOver ? MF.purple : (filled ? MF.line : MF.line2)}`,
          transition: 'border-color .15s, transform .12s',
          display:'flex', alignItems:'center', justifyContent:'center',
          opacity: busy ? .7 : 1,
        }}
      >
        {renderPreview()}
        {!filled && (
          <div style={{textAlign:'center', color:'#fff', textShadow:'0 1px 3px rgba(0,0,0,.5)', position:'relative', zIndex: 1}}>
            <div style={{fontSize: 22, marginBottom: 2}}>{target.icon}</div>
            <div style={{fontSize: 11, fontWeight: 600, opacity:.9}}>
              {busy ? 'Enregistrement…' : 'Glisser / cliquer'}
            </div>
          </div>
        )}
        {filled && !busy && (
          <button
            onClick={handleClear}
            title="Retirer"
            style={{
              position:'absolute', top: 6, right: 6,
              width: 24, height: 24, borderRadius: 99, padding: 0,
              background:'rgba(0,0,0,.55)', border:'1px solid rgba(255,255,255,.2)',
              color:'#fff', fontSize: 13, fontWeight: 700, cursor:'pointer',
              display:'flex', alignItems:'center', justifyContent:'center', zIndex: 2,
            }}
          >×</button>
        )}
        <input
          ref={inputRef}
          type="file"
          accept={target.accept}
          style={{display:'none'}}
          onChange={(e) => { handleFile(e.target.files?.[0]); e.target.value = ''; }}
        />
      </div>
      <div style={{display:'flex', alignItems:'flex-start', gap: 6, minWidth: 0}}>
        <div style={{flex: 1, minWidth: 0}}>
          <div
            title={card.fr || 'Sans titre'}
            style={{
              fontSize: 12.5, fontWeight: 600,
              color: card.fr ? MF.text : MF.textMute,
              lineHeight: 1.3,
              // Allow up to 3 lines so prefix-heavy titles like
              // "1er dan · Sup+Inf+Pos · n°1" stay fully legible.
              display: '-webkit-box',
              WebkitLineClamp: 3,
              WebkitBoxOrient: 'vertical',
              overflow: 'hidden',
              overflowWrap: 'anywhere',
            }}
          >{card.fr || 'Sans titre'}</div>
          {(card.ro || card.kr) && (
            <div
              title={card.ro || card.kr}
              className={card.kr && !card.ro ? 'mf-kr' : undefined}
              style={{
                fontSize: 11, color: MF.textDim,
                marginTop: 2, lineHeight: 1.25,
                display: '-webkit-box',
                WebkitLineClamp: 2,
                WebkitBoxOrient: 'vertical',
                overflow: 'hidden',
                overflowWrap: 'anywhere',
              }}
            >{card.ro || card.kr}</div>
          )}
        </div>
        <button
          onClick={onOpenCard}
          title="Éditer la carte"
          style={{
            background:'transparent', border:'none', padding: 0,
            color: MF.textMute, fontSize: 11, cursor:'pointer',
            fontFamily: 'inherit', textDecoration: 'underline dotted',
            flexShrink: 0,
          }}
        >éditer</button>
      </div>
    </div>
  );
};

// ─────────────── Decks pane ───────────────
//
// Tab content for managing the deck library. Two columns on desktop:
//   - left:  list of decks (built-in + user-created) with a "+ New" menu
//   - right: form for the selected deck (name, type, filter or manual picker,
//            preview of resolved cards, actions)
// On mobile the list collapses to a horizontal scroller above the form.

const DecksPane = ({
  isMobile,
  decks, allCards, allTags, allCats,
  activeDeck, selectedDeckId, onSelectDeck,
  onAddDeck, onUpdateDeck, onDeleteDeck, onDuplicateDeck, onSetActive,
  onToggleCard, onReorderCards, onMaterialize,
}) => {
  const selectedDeck = decks.find(d => d.id === selectedDeckId) || decks[0];
  const isActive = selectedDeck.id === activeDeck.id;

  return (
    <div style={{flex:1, minHeight:0, display:'flex', flexDirection:'column'}}>
      {/* Header strip */}
      <div style={{
        padding: isMobile ? '12px 14px' : '14px 24px',
        borderBottom:`1px solid ${MF.line}`, background: MF.bg2,
        display:'flex', alignItems:'center', gap: 12, flexWrap:'wrap',
      }}>
        <div style={{display:'flex', flexDirection:'column', gap: 2}}>
          <span style={{
            fontSize: 10, color: MF.textMute, fontWeight: 700,
            letterSpacing:'.14em', textTransform:'uppercase',
          }}>Deck actif</span>
          <span style={{display:'inline-flex', alignItems:'center', gap: 8, fontSize: 14, color: MF.text, fontWeight: 600}}>
            <span style={{width:7, height:7, borderRadius: 99, background: MF.good}}/>
            {activeDeck.name}
            <span style={{color: MF.textMute, fontWeight: 500}}>
              · {resolveDeckCards(activeDeck, allCards).length} cartes
            </span>
          </span>
        </div>
        <div style={{flex: 1}}/>
        <NewDeckMenu onAdd={onAddDeck}/>
      </div>

      {/* Body */}
      <div style={{
        flex:1, minHeight:0, display:'flex',
        flexDirection: isMobile ? 'column' : 'row',
      }}>
        {/* Deck list */}
        <aside style={{
          width: isMobile ? '100%' : 280, flexShrink: 0,
          background: MF.bg, borderRight: isMobile ? 'none' : `1px solid ${MF.line}`,
          borderBottom: isMobile ? `1px solid ${MF.line}` : 'none',
          display:'flex',
          flexDirection: isMobile ? 'row' : 'column',
          overflowX: isMobile ? 'auto' : 'visible',
          overflowY: isMobile ? 'visible' : 'auto',
          padding: isMobile ? '10px 12px' : '10px 8px',
          gap: 4,
        }} className="mf-scroll">
          {decks.map(d => (
            <DeckListItem
              key={d.id}
              deck={d}
              count={resolveDeckCards(d, allCards).length}
              isSelected={selectedDeck.id === d.id}
              isActive={activeDeck.id === d.id}
              isMobile={isMobile}
              onClick={() => onSelectDeck(d.id)}
            />
          ))}
        </aside>

        {/* Deck form */}
        <main style={{flex:1, minWidth:0, minHeight:0, overflow:'auto'}} className="mf-scroll">
          <DeckEditor
            isMobile={isMobile}
            deck={selectedDeck}
            allCards={allCards}
            allTags={allTags}
            allCats={allCats}
            isActive={isActive}
            onUpdate={(patch) => onUpdateDeck(selectedDeck.id, patch)}
            onSetActive={() => onSetActive(selectedDeck.id)}
            onDuplicate={() => onDuplicateDeck(selectedDeck.id)}
            onDelete={() => {
              if (confirm(`Supprimer le deck "${selectedDeck.name}" ?\n(Les cartes restent dans la bibliothèque.)`))
                onDeleteDeck(selectedDeck.id);
            }}
            onMaterialize={() => {
              if (confirm("Figer ce deck smart en deck manuel ?\nL'instantané actuel des cartes sera enregistré et les filtres seront effacés."))
                onMaterialize(selectedDeck.id);
            }}
            onToggleCard={(cardId) => onToggleCard(selectedDeck.id, cardId)}
            onReorderCards={(ids) => onReorderCards(selectedDeck.id, ids)}
          />
        </main>
      </div>
    </div>
  );
};

const DeckListItem = ({ deck, count, isSelected, isActive, isMobile, onClick }) => {
  const icon = deck.type === 'smart' ? '✨' : '📝';
  return (
    <button onClick={onClick} className="mf-tap" style={{
      flexShrink: 0,
      width: isMobile ? 'auto' : '100%',
      display:'flex', alignItems:'center', gap: 10,
      padding:'10px 12px', borderRadius: 10,
      background: isSelected ? MF.card2 : 'transparent',
      border:`1px solid ${isSelected ? MF.purple : 'transparent'}`,
      color: MF.text, textAlign: 'left', cursor:'pointer',
      minWidth: isMobile ? 200 : 0,
    }}>
      <span style={{
        width: 26, height: 26, borderRadius: 7, flexShrink: 0,
        display:'flex', alignItems:'center', justifyContent:'center',
        background: deck.type === 'smart' ? 'rgba(167,139,250,.14)' : 'rgba(244,198,116,.14)',
        color: deck.type === 'smart' ? MF.purple : MF.amber,
        fontSize: 13,
      }}>{icon}</span>
      <div style={{flex:1, minWidth:0}}>
        <div style={{
          fontSize: 13, fontWeight: 600,
          whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
          display:'flex', alignItems:'center', gap: 6,
        }}>
          {deck.name}
          {isActive && <span title="Deck actif" style={{
            width:6, height:6, borderRadius: 99, background: MF.good, flexShrink: 0,
          }}/>}
        </div>
        <div style={{fontSize: 11, color: MF.textDim, marginTop: 2}}>
          {count} carte{count > 1 ? 's' : ''}
          {deck.builtin ? ' · built-in' : ''}
        </div>
      </div>
    </button>
  );
};

const NewDeckMenu = ({ onAdd }) => {
  const [open, setOpen] = React.useState(false);
  React.useEffect(() => {
    if (!open) return;
    const close = () => setOpen(false);
    const t = setTimeout(() => window.addEventListener('click', close, { once: true }), 0);
    return () => clearTimeout(t);
  }, [open]);
  return (
    <div style={{position:'relative'}}>
      <button
        onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
        className="mf-tap mf-btn-primary"
        style={{
          padding:'9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 700,
          cursor:'pointer', display:'inline-flex', alignItems:'center', gap: 8,
        }}
      >
        <span style={{fontSize: 15, lineHeight: 1}}>＋</span>
        <span>Nouveau deck</span>
        <span style={{fontSize: 9, opacity: .75}}>▾</span>
      </button>
      {open && (
        <div style={{
          position:'absolute', top:'100%', right: 0, marginTop: 6, minWidth: 240,
          background: MF.card, border:`1px solid ${MF.line2}`, borderRadius: 10,
          boxShadow:'0 10px 28px rgba(0,0,0,.45)', zIndex: 20, overflow:'hidden',
        }}>
          {[
            { id: 'smart',  icon:'✨', label:'Smart (par critères)', sub:'Cartes filtrées par catégorie + tags' },
            { id: 'manual', icon:'📝', label:'Manuel',                sub:'Cartes choisies une par une' },
          ].map(opt => (
            <button key={opt.id} onClick={() => { setOpen(false); onAdd(opt.id); }}
              className="mf-tap"
              style={{
                width:'100%', display:'flex', alignItems:'center', gap: 12,
                padding:'12px 14px', background:'transparent', border:'none',
                color: MF.text, textAlign:'left', cursor:'pointer',
              }}
              onMouseOver={(e) => e.currentTarget.style.background = MF.card2}
              onMouseOut={(e) => e.currentTarget.style.background = 'transparent'}
            >
              <span style={{fontSize: 18}}>{opt.icon}</span>
              <div>
                <div style={{fontSize: 13, fontWeight: 600}}>{opt.label}</div>
                <div style={{fontSize: 11, color: MF.textDim, marginTop: 1}}>{opt.sub}</div>
              </div>
            </button>
          ))}
        </div>
      )}
    </div>
  );
};

const DeckTypeBadge = ({ type, builtin }) => {
  const cfg = builtin
    ? { label: 'Built-in', bg: 'rgba(127,180,255,.12)', fg: MF.blue, border: 'rgba(127,180,255,.4)' }
    : type === 'smart'
      ? { label: 'Smart (par critères)', bg: 'rgba(167,139,250,.14)', fg: MF.purple, border: 'rgba(167,139,250,.4)' }
      : { label: 'Manuel', bg: 'rgba(244,198,116,.14)', fg: MF.amber, border: 'rgba(244,198,116,.4)' };
  return (
    <span style={{
      padding: '4px 10px', borderRadius: 99,
      background: cfg.bg, color: cfg.fg, border: `1px solid ${cfg.border}`,
      fontSize: 11, fontWeight: 700, letterSpacing:'.04em',
    }}>{cfg.label}</span>
  );
};

// Multi-select chip picker used by Smart filter (cats + tags).
const MultiChipPicker = ({ values, options, onChange, labels, emptyMessage }) => {
  const set = new Set(values || []);
  const toggle = (opt) => {
    const next = new Set(set);
    if (next.has(opt)) next.delete(opt); else next.add(opt);
    onChange([...next]);
  };
  return (
    <div style={{display:'flex', flexWrap:'wrap', gap:6}}>
      {options.length === 0 && emptyMessage && (
        <span style={{fontSize: 12, color: MF.textMute, fontStyle:'italic'}}>{emptyMessage}</span>
      )}
      {options.map(opt => {
        const on = set.has(opt);
        return (
          <button
            key={opt}
            onClick={() => toggle(opt)}
            className="mf-tap"
            style={{
              padding:'7px 12px', borderRadius:99,
              background: on ? 'rgba(167,139,250,.18)' : MF.card,
              border:`1.5px solid ${on ? MF.purple : MF.line}`,
              color: on ? MF.text : MF.textDim,
              fontSize: 12.5, fontWeight: 600, cursor:'pointer',
            }}
          >{labels?.[opt] ?? opt}</button>
        );
      })}
    </div>
  );
};

const SmartFilterBuilder = ({ filter, allCats, allTags, onChange }) => {
  const f = filter || emptyFilter();
  const set = (patch) => onChange({ ...f, ...patch });
  return (
    <div style={{display:'flex', flexDirection:'column', gap: 22}}>
      <Field label="Catégories" hint="Plusieurs sélectionnées : les cartes de n'importe laquelle passeront.">
        <MultiChipPicker
          values={f.cats}
          options={allCats}
          onChange={(v) => set({ cats: v })}
          emptyMessage="Aucune catégorie dans la bibliothèque."
        />
      </Field>
      <Field label="Tags" hint={`Mode : ${f.tagMode === 'all' ? 'TOUS requis' : 'au moins UN requis'}`}>
        <div style={{display:'flex', flexDirection:'column', gap: 10}}>
          <div style={{display:'flex', gap:8}}>
            <button
              onClick={() => set({ tagMode: 'any' })}
              className="mf-tap"
              style={{
                padding:'5px 10px', borderRadius: 99,
                background: f.tagMode === 'any' ? MF.card2 : 'transparent',
                border:`1px solid ${f.tagMode === 'any' ? MF.purple : MF.line}`,
                color: f.tagMode === 'any' ? MF.text : MF.textDim,
                fontSize: 11, fontWeight: 600, cursor:'pointer',
              }}
            >au moins UN</button>
            <button
              onClick={() => set({ tagMode: 'all' })}
              className="mf-tap"
              style={{
                padding:'5px 10px', borderRadius: 99,
                background: f.tagMode === 'all' ? MF.card2 : 'transparent',
                border:`1px solid ${f.tagMode === 'all' ? MF.purple : MF.line}`,
                color: f.tagMode === 'all' ? MF.text : MF.textDim,
                fontSize: 11, fontWeight: 600, cursor:'pointer',
              }}
            >TOUS requis</button>
          </div>
          <MultiChipPicker
            values={f.tags}
            options={allTags}
            onChange={(v) => set({ tags: v })}
            emptyMessage="Aucun tag dans la bibliothèque pour l'instant."
          />
        </div>
      </Field>
    </div>
  );
};

const DeckCardChip = ({ card }) => (
  <div style={{
    display:'flex', alignItems:'center', gap: 8,
    padding:'6px 10px 6px 6px',
    background: MF.card, border:`1px solid ${MF.line}`, borderRadius: 99,
    maxWidth: 240, minWidth: 0,
  }}>
    <CardThumb card={card} size={26}/>
    <span style={{fontSize: 12, color: MF.text, whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis'}}>
      {card.fr || 'Sans titre'}
    </span>
  </div>
);

const ResolvedPreview = ({ cards }) => (
  <Field label={`Cartes correspondantes (${cards.length})`}>
    {cards.length === 0 ? (
      <div style={{
        padding:'18px 14px', textAlign:'center', color: MF.textMute, fontSize: 12,
        border:`1px dashed ${MF.line2}`, borderRadius: 10, background: MF.bg2,
      }}>
        Aucune carte ne correspond aux critères. Élargissez les filtres.
      </div>
    ) : (
      <div style={{
        display:'flex', flexWrap:'wrap', gap: 6,
        background: MF.bg2, padding: 10, borderRadius: 10, border:`1px solid ${MF.line}`,
        maxHeight: 220, overflow:'auto',
      }} className="mf-scroll">
        {cards.map(c => <DeckCardChip key={c.id} card={c}/>)}
      </div>
    )}
  </Field>
);

const ManualPicker = ({ deck, allCards, onToggle, onReorder, isMobile }) => {
  const [query, setQuery] = React.useState('');
  const inDeck = new Set(deck.cardIds || []);

  const filtered = React.useMemo(() => {
    if (!query.trim()) return allCards;
    const q = query.toLowerCase();
    return allCards.filter(c =>
      (c.fr || '').toLowerCase().includes(q) ||
      (c.kr || '').includes(query) ||
      (c.ro || '').toLowerCase().includes(q) ||
      (c.cat || '').toLowerCase().includes(q) ||
      (c.tags || []).some(t => t.toLowerCase().includes(q))
    );
  }, [allCards, query]);

  const ordered = (deck.cardIds || [])
    .map(id => allCards.find(c => c.id === id))
    .filter(Boolean);

  const move = (idx, delta) => {
    const next = [...(deck.cardIds || [])];
    const j = idx + delta;
    if (j < 0 || j >= next.length) return;
    [next[idx], next[j]] = [next[j], next[idx]];
    onReorder(next);
  };

  return (
    <div style={{
      display:'grid',
      gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
      gap: 18,
    }}>
      {/* Selected */}
      <div>
        <div style={{fontSize:11, color:MF.textMute, fontWeight:700, letterSpacing:'.14em', textTransform:'uppercase', marginBottom:8}}>
          Cartes du deck ({ordered.length})
        </div>
        <div style={{
          display:'flex', flexDirection:'column', gap: 2,
          border:`1px solid ${MF.line}`, borderRadius: 10,
          background: MF.bg2, padding: 6,
          maxHeight: 460, overflow:'auto',
        }} className="mf-scroll">
          {ordered.length === 0 && (
            <div style={{padding:'26px 12px', textAlign:'center', color: MF.textMute, fontSize: 12}}>
              Aucune carte. Cliquez sur des cartes dans la liste ci-{isMobile ? 'dessous' : 'contre'} pour les ajouter.
            </div>
          )}
          {ordered.map((c, i) => (
            <div key={c.id} style={{display:'flex', alignItems:'center', gap: 4}}>
              <div style={{flex: 1, minWidth: 0}}>
                <CardRow card={c} selected={false} onClick={() => onToggle(c.id)}/>
              </div>
              <div style={{display:'flex', flexDirection:'column', gap:2, paddingRight: 4}}>
                <button onClick={() => move(i, -1)} disabled={i === 0} className="mf-tap" style={{
                  width: 22, height: 18, borderRadius: 5,
                  background: MF.card, border:`1px solid ${MF.line}`,
                  color: i === 0 ? MF.textMute : MF.textDim, fontSize: 10,
                  cursor: i === 0 ? 'default' : 'pointer',
                }} aria-label="Monter">▲</button>
                <button onClick={() => move(i, +1)} disabled={i === ordered.length - 1} className="mf-tap" style={{
                  width: 22, height: 18, borderRadius: 5,
                  background: MF.card, border:`1px solid ${MF.line}`,
                  color: i === ordered.length - 1 ? MF.textMute : MF.textDim, fontSize: 10,
                  cursor: i === ordered.length - 1 ? 'default' : 'pointer',
                }} aria-label="Descendre">▼</button>
              </div>
            </div>
          ))}
        </div>
      </div>

      {/* Available */}
      <div>
        <div style={{fontSize:11, color:MF.textMute, fontWeight:700, letterSpacing:'.14em', textTransform:'uppercase', marginBottom:8}}>
          Bibliothèque ({allCards.length}) · cliquez pour {ordered.length ? 'ajouter / retirer' : 'ajouter'}
        </div>
        <input
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Rechercher fr / 한국어 / romaja / cat / tag…"
          style={{
            width:'100%', padding:'8px 12px', marginBottom: 6,
            background: MF.card, border:`1px solid ${MF.line}`,
            borderRadius: 10, color: MF.text, fontSize: 12.5,
            outline:'none', fontFamily: 'inherit',
          }}
        />
        <div style={{
          display:'flex', flexDirection:'column', gap: 2,
          border:`1px solid ${MF.line}`, borderRadius: 10,
          background: MF.bg2, padding: 6,
          maxHeight: 420, overflow:'auto',
        }} className="mf-scroll">
          {filtered.length === 0 && (
            <div style={{padding:'24px 12px', textAlign:'center', color: MF.textMute, fontSize: 12}}>
              Aucun résultat
            </div>
          )}
          {filtered.map(c => {
            const present = inDeck.has(c.id);
            return (
              <div key={c.id} style={{display:'flex', alignItems:'center', gap: 4}}>
                <div style={{flex: 1, minWidth: 0, opacity: present ? .55 : 1}}>
                  <CardRow card={c} selected={false} onClick={() => onToggle(c.id)}/>
                </div>
                <span style={{
                  color: present ? MF.good : MF.textDim,
                  fontSize: 13, padding: '0 8px', flexShrink: 0,
                }}>{present ? '✓' : '+'}</span>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
};

const DeckEditor = ({
  isMobile, deck, allCards, allTags, allCats, isActive,
  onUpdate, onSetActive, onDuplicate, onDelete, onMaterialize,
  onToggleCard, onReorderCards,
}) => {
  if (!deck) {
    return (
      <div style={{
        padding: '48px 20px', textAlign:'center', color: MF.textMute, fontSize: 13,
      }}>Aucun deck sélectionné.</div>
    );
  }

  const resolved = resolveDeckCards(deck, allCards);
  const isSmart = deck.type === 'smart';

  return (
    <div style={{
      padding: isMobile ? '16px 16px 32px' : '22px 28px 32px',
      display:'flex', flexDirection:'column', gap: 20,
      maxWidth: 980, margin: '0 auto', width: '100%',
    }}>
      {/* Action bar */}
      <div style={{display:'flex', flexWrap:'wrap', alignItems:'center', gap: 10}}>
        <DeckTypeBadge type={deck.type} builtin={deck.builtin}/>
        {isActive && (
          <span style={{
            padding: '4px 10px', borderRadius: 99,
            background:'rgba(116,216,161,.14)', color: MF.good, border:'1px solid rgba(116,216,161,.4)',
            fontSize: 11, fontWeight: 700, letterSpacing:'.04em',
          }}>● Deck actif</span>
        )}
        <div style={{flex: 1}}/>
        {!isActive && (
          <button
            onClick={onSetActive}
            className="mf-tap"
            style={{
              padding:'8px 14px', borderRadius: 10,
              background: MF.card, border:`1px solid ${MF.line}`,
              color: MF.text, fontSize: 12, fontWeight: 600, cursor:'pointer',
            }}
          >Définir comme actif</button>
        )}
        <button
          onClick={onDuplicate}
          className="mf-tap"
          style={{
            padding:'8px 14px', borderRadius: 10,
            background: 'transparent', border:`1px solid ${MF.line}`,
            color: MF.textDim, fontSize: 12, fontWeight: 600, cursor:'pointer',
          }}
        >Dupliquer</button>
        {!deck.builtin && (
          <button
            onClick={onDelete}
            className="mf-tap"
            style={{
              padding:'8px 14px', borderRadius: 10,
              background: 'transparent', border:`1px solid ${MF.line}`,
              color: MF.bad, fontSize: 12, fontWeight: 600, cursor:'pointer',
            }}
          >🗑 Supprimer</button>
        )}
      </div>

      <Field label="Nom du deck">
        <TextInput
          value={deck.name}
          onChange={(v) => onUpdate({ name: v })}
          placeholder="Coups de pied — débutant"
          big
        />
      </Field>
      <Field label="Sous-titre (optionnel)">
        <TextInput
          value={deck.sub || ''}
          onChange={(v) => onUpdate({ sub: v })}
          placeholder="Court descriptif affiché sous le nom"
        />
      </Field>

      {isSmart ? (
        <>
          <div style={{
            padding:'12px 14px', borderRadius: 12,
            background: MF.bg2, border:`1px solid ${MF.line}`,
            display:'flex', alignItems:'flex-start', gap: 12,
          }}>
            <span style={{fontSize: 16, lineHeight: 1}}>✨</span>
            <div style={{fontSize: 12.5, color: MF.textDim, lineHeight: 1.5}}>
              <strong style={{color: MF.text}}>Deck smart.</strong> Les cartes sont sélectionnées
              automatiquement depuis la bibliothèque selon les critères ci-dessous. Si vous ajoutez
              plus tard une carte qui correspond, elle apparaîtra ici sans rien refaire.
              {!deck.builtin && (
                <> · <a onClick={onMaterialize} style={{color: MF.purple, cursor:'pointer', borderBottom:`1px dashed ${MF.purple}`}}>
                  Figer en deck manuel
                </a></>
              )}
            </div>
          </div>
          <SmartFilterBuilder
            filter={deck.filter}
            allCats={allCats}
            allTags={allTags}
            onChange={(f) => onUpdate({ filter: f })}
          />
          <ResolvedPreview cards={resolved}/>
        </>
      ) : (
        <>
          <div style={{
            padding:'12px 14px', borderRadius: 12,
            background: MF.bg2, border:`1px solid ${MF.line}`,
            display:'flex', alignItems:'flex-start', gap: 12,
          }}>
            <span style={{fontSize: 16, lineHeight: 1}}>📝</span>
            <div style={{fontSize: 12.5, color: MF.textDim, lineHeight: 1.5}}>
              <strong style={{color: MF.text}}>Deck manuel.</strong> Vous choisissez les cartes une par une.
              L'ordre est conservé.
            </div>
          </div>
          <ManualPicker
            deck={deck}
            allCards={allCards}
            onToggle={onToggleCard}
            onReorder={onReorderCards}
            isMobile={isMobile}
          />
        </>
      )}
    </div>
  );
};

// ─────────────── Custom fields modal ───────────────
//
// Opened via the ⚙ button in the top bar. Manages the library-wide list of
// optional fields (audio, video, URL, extra photo). Add by typing a name +
// picking a type, rename inline by clicking the name, delete with ✕.

// ─────────────── Bulk Quiz Q/A modal ───────────────
//
// Opened from the BulkActionBar's "Quiz Q/A…" button. Shows the union of
// filled attributes across every selected card and lets the user assign each
// to the Question column, the Answer column, both, or neither. Applying
// writes the same quizSides to every selected card — effectiveQuizSides will
// silently filter per-card to attributes actually present, so cards without
// the chosen attribute fall back to auto behaviour at play time.

const BulkQuizSidesModal = ({ selectedCards, customFields, onApply, onClear, onClose }) => {
  // Union of filled attribute keys across the selection, in stable order
  // (core attributes first in CORE_ATTRIBUTES order, then custom fields).
  const keys = React.useMemo(() => {
    const present = new Set();
    for (const c of selectedCards) {
      for (const k of cardAttributeKeys(c, customFields)) present.add(k);
    }
    const ordered = [];
    for (const k of CORE_ATTRIBUTES) if (present.has(k)) ordered.push(k);
    for (const f of customFields) {
      const k = 'custom:' + f.id;
      if (present.has(k)) ordered.push(k);
    }
    return ordered;
  }, [selectedCards, customFields]);

  // Count cards in the selection that actually have each attribute filled —
  // surfaces in the row so the user knows a chosen attribute might be missing
  // on some cards.
  const presenceCount = React.useMemo(() => {
    const m = {};
    for (const k of keys) {
      m[k] = selectedCards.reduce(
        (acc, c) => acc + (cardHasAttribute(c, k, customFields) ? 1 : 0),
        0
      );
    }
    return m;
  }, [keys, selectedCards, customFields]);

  const [draftQ, setDraftQ] = React.useState(new Set());
  const [draftA, setDraftA] = React.useState(new Set());

  React.useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);

  const toggle = (setFn, key) => setFn(prev => {
    const next = new Set(prev);
    if (next.has(key)) next.delete(key); else next.add(key);
    return next;
  });

  const apply = () => onApply([...draftQ], [...draftA]);
  const total = selectedCards.length;
  const qReady = draftQ.size > 0;
  const aReady = draftA.size > 0;

  return (
    <div
      onClick={onClose}
      style={{
        position:'fixed', inset: 0, zIndex: 100,
        background:'rgba(6,5,10,.7)', backdropFilter:'blur(4px)',
        display:'flex', alignItems:'center', justifyContent:'center',
        padding: 18,
        animation: 'mf-fade-in .15s ease both',
      }}
    >
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          width: '100%', maxWidth: 580, maxHeight: '88vh',
          background: MF.bg2, border:`1px solid ${MF.line2}`,
          borderRadius: 16, overflow:'hidden',
          display:'flex', flexDirection:'column',
          boxShadow:'0 30px 80px rgba(0,0,0,.5)',
        }}
      >
        {/* Header */}
        <div style={{
          padding: '16px 20px',
          borderBottom:`1px solid ${MF.line}`,
          display:'flex', alignItems:'center', gap: 12,
        }}>
          <span style={{fontSize: 18}}>📑</span>
          <div style={{flex: 1, minWidth: 0}}>
            <div style={{fontSize: 15, fontWeight: 700, color: MF.text}}>
              Question / Réponse — {total} carte{total > 1 ? 's' : ''}
            </div>
            <div style={{fontSize: 11, color: MF.textDim, marginTop: 2}}>
              Composition de quiz appliquée à toutes les cartes cochées
            </div>
          </div>
          <button onClick={onClose} className="mf-tap" aria-label="Fermer" style={{
            width: 30, height: 30, borderRadius: 8,
            background:'transparent', border:`1px solid ${MF.line}`,
            color: MF.textDim, fontSize: 14, cursor:'pointer',
            display:'flex', alignItems:'center', justifyContent:'center',
          }}>×</button>
        </div>

        {/* Body */}
        <div className="mf-scroll" style={{
          padding: '12px 16px', overflow:'auto', flex: 1,
          display:'flex', flexDirection:'column', gap: 10,
        }}>
          {keys.length === 0 ? (
            <div style={{
              padding:'24px 12px', textAlign:'center',
              color: MF.textMute, fontSize: 12.5,
              border:`1px dashed ${MF.line2}`, borderRadius: 10, background: MF.bg,
            }}>
              Les cartes sélectionnées n'ont aucun attribut rempli en commun.
              Remplis au moins un champ pour pouvoir configurer un quiz.
            </div>
          ) : (
            <>
              <div style={{fontSize: 11.5, color: MF.textDim, lineHeight: 1.5}}>
                Coche les attributs à mettre en{' '}
                <strong style={{color: MF.text}}>question</strong> et en{' '}
                <strong style={{color: MF.text}}>réponse</strong>. Les attributs
                absents d'une carte sont ignorés pour cette carte.
              </div>
              <div style={{
                border:`1px solid ${MF.line}`, borderRadius: 10, overflow:'hidden',
                background: MF.bg,
              }}>
                <div style={{
                  display:'grid', gridTemplateColumns:'1fr 80px 80px',
                  background: MF.bg2, borderBottom:`1px solid ${MF.line}`,
                  padding:'8px 12px',
                  fontSize: 10, color: MF.textMute, fontWeight: 700,
                  letterSpacing:'.14em', textTransform:'uppercase',
                }}>
                  <span>Attribut</span>
                  <span style={{textAlign:'center'}}>Question</span>
                  <span style={{textAlign:'center'}}>Réponse</span>
                </div>
                {keys.map((key, idx) => {
                  const count = presenceCount[key] || 0;
                  const partial = count > 0 && count < total;
                  return (
                    <div
                      key={key}
                      style={{
                        display:'grid', gridTemplateColumns:'1fr 80px 80px',
                        alignItems:'center', padding:'10px 12px',
                        borderBottom: idx < keys.length - 1 ? `1px solid ${MF.line}` : 'none',
                      }}
                    >
                      <div style={{display:'flex', alignItems:'center', gap: 10, minWidth: 0}}>
                        <span style={{fontSize: 14, flexShrink: 0}}>{attributeIcon(key, customFields)}</span>
                        <span style={{
                          fontSize: 13, fontWeight: 600, color: MF.text,
                          whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
                        }}>{attributeLabel(key, customFields)}</span>
                        {partial && (
                          <span style={{
                            fontSize: 10, color: MF.amber, fontWeight: 600,
                            padding:'1px 6px', borderRadius: 99,
                            background:'rgba(244,198,116,.10)',
                            border:'1px solid rgba(244,198,116,.35)',
                            whiteSpace:'nowrap', flexShrink: 0,
                          }}>{count}/{total}</span>
                        )}
                      </div>
                      <QASideCheckbox
                        checked={draftQ.has(key)}
                        onToggle={() => toggle(setDraftQ, key)}
                        color={MF.blue}
                      />
                      <QASideCheckbox
                        checked={draftA.has(key)}
                        onToggle={() => toggle(setDraftA, key)}
                        color={MF.good}
                      />
                    </div>
                  );
                })}
              </div>
              {(qReady && !aReady) || (!qReady && aReady) ? (
                <span style={{fontSize: 11, color: MF.amber}}>
                  ⚠ Coche au moins un attribut sur chaque côté pour activer le quiz flashcard.
                </span>
              ) : null}
            </>
          )}
        </div>

        {/* Footer actions */}
        <div style={{
          padding: '14px 18px', borderTop:`1px solid ${MF.line}`,
          background: MF.bg, display:'flex', alignItems:'center', gap: 10, flexWrap:'wrap',
        }}>
          <button
            onClick={() => { if (confirm(`Réinitialiser le quiz de ${total} carte(s) en mode automatique ?`)) onClear(); }}
            className="mf-tap"
            style={{
              padding:'9px 14px', borderRadius: 10,
              background:'transparent', border:`1px solid ${MF.line}`,
              color: MF.textDim, fontSize: 12, fontWeight: 600, cursor:'pointer',
            }}
          >Réinitialiser (auto)</button>
          <div style={{flex: 1}}/>
          <button
            onClick={onClose}
            className="mf-tap"
            style={{
              padding:'9px 14px', borderRadius: 10,
              background: MF.card, border:`1px solid ${MF.line}`,
              color: MF.text, fontSize: 12, fontWeight: 600, cursor:'pointer',
            }}
          >Annuler</button>
          <button
            onClick={apply}
            disabled={!qReady || !aReady}
            className="mf-tap mf-btn-primary"
            style={{
              padding:'10px 14px', borderRadius: 10, fontSize: 13, fontWeight: 700,
              cursor: (qReady && aReady) ? 'pointer' : 'not-allowed',
              opacity: (qReady && aReady) ? 1 : .5,
            }}
          >Appliquer à {total} carte{total > 1 ? 's' : ''}</button>
        </div>
      </div>
    </div>
  );
};

// ─────────────── Built-in field value widgets ───────────────
//
// One per built-in field type — used inside CardForm to render the value
// editor for the two fixed fields (video_url, pdf_region) on the current card.

const _looksLikePdfRegionJson = (parsed) =>
  !!parsed
  && typeof parsed.url === 'string'
  && /^https?:\/\//i.test(parsed.url)
  && typeof parsed.page === 'number'
  && parsed.rect && typeof parsed.rect === 'object'
  && ['x','y','w','h'].every(k => typeof parsed.rect[k] === 'number');

// Returns { kind, value, payload? } where kind ∈ 'plain' | 'pdfRegion'.
// 'pdfRegion' means the input is a JSON {url,page,rect} blob:
//   - `value`  = parsed.url    (fallback when the field can't host page/rect)
//   - `payload`= the full parsed object (used to upgrade a url field into a
//                pdf_region field on the fly)
const _smartUrlPaste = (raw) => {
  const trimmed = (raw || '').trim();
  if (!trimmed.startsWith('{')) return { kind: 'plain', value: raw };
  try {
    const parsed = JSON.parse(trimmed);
    if (_looksLikePdfRegionJson(parsed)) return { kind: 'pdfRegion', value: parsed.url, payload: parsed };
  } catch { /* not JSON — fall through */ }
  return { kind: 'plain', value: raw };
};

// Convert seconds back to a compact "M:SS" / "H:MM:SS" so the TimeField
// shows what the user originally typed (or close to it).
const formatTimeForInput = (sec) => {
  if (!sec) return '';
  const h = Math.floor(sec / 3600);
  const m = Math.floor((sec % 3600) / 60);
  const s = sec % 60;
  if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
  return `${m}:${String(s).padStart(2,'0')}`;
};

// Tiny labelled time input. Parses on every keystroke so the URL keeps
// updating live; accepts "2m45s", "165", "2:45", "1:02:45". Clearing the
// field passes null upstream.
const TimeField = ({ label, value, onChange, placeholder, disabled, disabledHint }) => {
  // Local text state so the user can type freely (e.g. "2", "2m", "2m45s")
  // without us reformatting on every keystroke.
  const [text, setText] = React.useState(() => formatTimeForInput(value));
  const lastCommittedRef = React.useRef(value || null);

  // External value changes (someone else edits the URL) sync into the text.
  React.useEffect(() => {
    if (value !== lastCommittedRef.current) {
      setText(formatTimeForInput(value));
      lastCommittedRef.current = value || null;
    }
  }, [value]);

  const commit = (raw) => {
    const trimmed = (raw || '').trim();
    if (!trimmed) {
      lastCommittedRef.current = null;
      onChange(null);
      return;
    }
    const parsed = parseTimeString(trimmed);
    if (parsed != null && parsed !== lastCommittedRef.current) {
      lastCommittedRef.current = parsed;
      onChange(parsed);
    }
  };

  const valid = !text.trim() || parseTimeString(text) != null;

  return (
    <label style={{display:'flex', flexDirection:'column', gap: 4, flex: 1, minWidth: 100}}>
      <span style={{fontSize: 10, color: MF.textMute, fontWeight: 700, letterSpacing:'.14em', textTransform:'uppercase'}}>
        {label}
      </span>
      <input
        value={text}
        disabled={disabled}
        onChange={(e) => { setText(e.target.value); commit(e.target.value); }}
        placeholder={placeholder}
        spellCheck={false}
        style={{
          width:'100%', padding:'8px 10px',
          background: disabled ? MF.bg2 : MF.card,
          border:`1px solid ${valid ? MF.line : MF.amber}`,
          borderRadius: 8, color: disabled ? MF.textMute : MF.text,
          fontSize: 13, fontFamily: MF.mono, outline:'none',
          opacity: disabled ? .6 : 1,
        }}
      />
      {disabled && disabledHint && (
        <span style={{fontSize: 10, color: MF.textMute, fontStyle:'italic'}}>{disabledHint}</span>
      )}
      {!disabled && !valid && (
        <span style={{fontSize: 10, color: MF.amber}}>Format : 2m45s · 165 · 2:45 · 1:02:45</span>
      )}
    </label>
  );
};

// External-host video URL — paste a YouTube/Vimeo/Drive/raw-.mp4 link and the
// widget auto-detects the host and shows an inline embed preview. URL is stored
// as a plain string, so CSV import/export round-trips cleanly. The "Paramètres
// avancés" disclosure exposes start/end time inputs that rewrite the URL in
// place (using the host's native convention: ?t=&end= for YouTube, #t=Ns for
// Vimeo, #t=start,end for direct video).
const VideoUrlFieldInput = ({ value, onChange }) => {
  const trimmed = (value || '').trim();
  const embed = parseVideoEmbed(trimmed);
  const looksLikeUrl = /^https?:\/\//i.test(trimmed);
  const [advancedOpen, setAdvancedOpen] = React.useState(false);
  const [pdfHint, setPdfHint] = React.useState(false);
  const previewRef = React.useRef(null);

  // If the user pastes a pdf_region JSON blob in here, extract the inner URL
  // (page + rect can't live on a video_url field) and flag it so they know
  // page/zone were dropped — the "Carte" field is where that data belongs.
  const handleChange = (raw) => {
    const { kind, value: next } = _smartUrlPaste(raw);
    setPdfHint(kind === 'pdfRegion');
    onChange(next);
  };

  const host = embed?.host || detectVideoHost(trimmed);
  const startSec = embed?.startSec || null;
  const endSec   = embed?.endSec   || null;
  const endSupported = hostSupportsEnd(host);

  const setStart = (sec) => onChange(buildVideoUrl(trimmed, sec, endSec));
  const setEnd   = (sec) => onChange(buildVideoUrl(trimmed, startSec, sec));

  const goFullscreen = () => {
    const el = previewRef.current;
    if (!el) return;
    // Use whichever vendor-prefixed entry the current browser exposes.
    const fn = el.requestFullscreen
            || el.webkitRequestFullscreen
            || el.msRequestFullscreen;
    if (!fn) { alert("Le plein écran n'est pas supporté par ce navigateur."); return; }
    try { fn.call(el)?.catch?.((err) => console.warn('Fullscreen rejected:', err)); }
    catch (err) { console.warn('Fullscreen failed:', err); }
  };

  return (
    <div style={{display:'flex', flexDirection:'column', gap: 8}}>
      <input
        value={value || ''}
        onChange={(e) => handleChange(e.target.value)}
        placeholder="https://youtu.be/abc?t=2m45s · https://vimeo.com/123#t=165 · https://…/demo.mp4#t=165"
        spellCheck={false}
        autoComplete="off"
        autoCorrect="off"
        autoCapitalize="off"
        style={{
          width:'100%', padding:'10px 12px',
          background: MF.card,
          border:`1px solid ${pdfHint ? MF.purple : MF.line}`,
          borderRadius: 10,
          color: MF.text, fontSize: 13, outline:'none', fontFamily:'inherit',
          transition:'border-color .15s ease',
        }}
      />
      {pdfHint && (
        <span style={{fontSize: 11, color: MF.purple, lineHeight: 1.5}}>
          🗂 URL extraite du JSON <code style={{fontFamily: MF.mono}}>{`pdf_region`}</code>. Pour conserver page + zone, utilise le champ <b>Carte</b>.
        </span>
      )}
      {trimmed && !looksLikeUrl && !pdfHint && (
        <span style={{fontSize: 11, color: MF.amber}}>
          ⚠ L'URL doit commencer par http:// ou https://
        </span>
      )}
      {trimmed && looksLikeUrl && !embed && (
        <span style={{fontSize: 11, color: MF.textDim}}>
          ℹ Hébergeur non reconnu — sera ouvert dans un onglet (YouTube, Vimeo, Drive et .mp4 direct s'embed automatiquement).
        </span>
      )}
      {embed && (startSec || endSec) && (
        <span style={{
          display:'inline-flex', alignItems:'center', gap: 6,
          padding:'4px 10px', borderRadius: 99, alignSelf:'flex-start',
          background:'rgba(167,139,250,.14)', border:`1px solid ${MF.purple}`,
          color: MF.purple, fontSize: 11, fontWeight: 700,
        }}>
          <span>▶</span>
          <span>
            {startSec ? `Démarre à ${formatSeconds(startSec)}` : 'Démarre à 0'}
            {endSec ? ` · arrête à ${formatSeconds(endSec)}` : ''}
          </span>
        </span>
      )}

      {/* Discreet advanced disclosure — only shown when there's an embed-able URL. */}
      {embed && (
        <div>
          <button
            onClick={() => setAdvancedOpen(o => !o)}
            className="mf-tap"
            style={{
              background:'transparent', border:'none', padding: 0,
              color: MF.textDim, fontSize: 11, fontWeight: 600, cursor:'pointer',
              fontFamily:'inherit', textDecoration: 'underline dotted',
              display:'inline-flex', alignItems:'center', gap: 4,
            }}
          >
            <span style={{transform: advancedOpen ? 'rotate(90deg)' : 'none', transition:'transform .15s', display:'inline-block'}}>▸</span>
            <span>Paramètres avancés (début / fin)</span>
          </button>
          {advancedOpen && (
            <div style={{
              marginTop: 8, padding: 10, borderRadius: 10,
              background: MF.bg2, border:`1px dashed ${MF.line2}`,
              display:'flex', flexDirection:'column', gap: 10,
            }}>
              <div style={{display:'flex', gap: 10, alignItems:'flex-start'}}>
                <TimeField
                  label="Début"
                  value={startSec}
                  onChange={setStart}
                  placeholder="2m45s"
                />
                <TimeField
                  label="Fin"
                  value={endSec}
                  onChange={setEnd}
                  placeholder="3m30s"
                  disabled={!endSupported}
                  disabledHint={
                    host === 'vimeo' ? 'Non supporté par Vimeo' :
                    host === 'drive' ? 'Non supporté par Google Drive' :
                    'Non supporté'
                  }
                />
              </div>
              <span style={{fontSize: 10, color: MF.textMute}}>
                Édite ces champs pour réécrire l'URL ci-dessus automatiquement.{' '}
                Formats : <code style={{fontFamily:MF.mono}}>2m45s</code> · <code style={{fontFamily:MF.mono}}>165</code> · <code style={{fontFamily:MF.mono}}>2:45</code> · <code style={{fontFamily:MF.mono}}>1:02:45</code>
              </span>
            </div>
          )}
        </div>
      )}

      {embed && (
        <div
          ref={previewRef}
          style={{
            width:'100%', borderRadius: 10, overflow:'hidden',
            aspectRatio: '16 / 9',
            background:'#000', border:`1px solid ${MF.line}`,
          }}
        >
          {embed.kind === 'iframe' && (
            <iframe
              src={embed.src}
              title="Vidéo (URL)"
              allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
              allowFullScreen
              referrerPolicy="strict-origin-when-cross-origin"
              style={{width:'100%', height:'100%', border:0}}
            />
          )}
          {embed.kind === 'video' && (
            <video
              src={embed.src}
              controls
              playsInline
              style={{width:'100%', height:'100%', objectFit:'contain', background:'#000'}}
            />
          )}
        </div>
      )}

      {embed && (
        <div style={{display:'flex', gap: 6, flexWrap:'wrap'}}>
          <button
            onClick={goFullscreen}
            className="mf-tap"
            title="Tester en plein écran"
            style={{
              padding:'6px 12px', borderRadius: 99,
              background: MF.card, border:`1px solid ${MF.line}`,
              color: MF.text, fontSize: 11.5, fontWeight: 600, cursor:'pointer',
              display:'inline-flex', alignItems:'center', gap: 6,
            }}
          >
            <span style={{fontSize: 13, lineHeight: 1}}>⛶</span>
            <span>Plein écran</span>
          </button>
          {trimmed && looksLikeUrl && (
            <a
              href={trimmed}
              target="_blank"
              rel="noreferrer noopener"
              className="mf-tap"
              title="Ouvrir l'URL d'origine dans un nouvel onglet"
              style={{
                padding:'6px 12px', borderRadius: 99,
                background:'transparent', border:`1px solid ${MF.line}`,
                color: MF.textDim, fontSize: 11.5, fontWeight: 600,
                textDecoration:'none',
                display:'inline-flex', alignItems:'center', gap: 6,
              }}
            >
              <span>↗</span>
              <span>Ouvrir l'URL</span>
            </a>
          )}
        </div>
      )}
    </div>
  );
};

const CustomFieldEditor = ({ field, value, onChange }) => {
  switch (field.type) {
    case 'video_url':  return <VideoUrlFieldInput  value={value} onChange={onChange}/>;
    case 'pdf_region': return <PDFRegionFieldInput value={value} onChange={onChange}/>;
    default:           return null;
  }
};

// Re-export the CSV import primitives so other modules (e.g. the static
// resource launcher) can parse a spreadsheet through the exact same pipeline
// the editor uses, instead of duplicating the parser.
Object.assign(window, { Editor, parseCSV, csvRowToCard });
