// ═════════════════════════════════════════════════════════════════════════════
// MindForge · Layer 2 · stores/decks-store.jsx
// ─────────────────────────────────────────────────────────────────────────────
// Role     : Domain store — the single source of truth for Library / Deck /
//            Card / CustomField. Owns the schema, the migrations, the SRS
//            scheduling, and the React hooks (`useLibrary`, `useDeck`).
// Loaded   : #4
// Reads    : window.{ DECK_TKD, React, mediaStore }
// Exposes  : window.{ SCHEMA, TONES, CATS, BUILTIN_DECK_ID, BUILTIN_FIELDS,
//            CORE_ATTRIBUTES, INTERVAL_OPTIONS, INTERVAL_BY_CODE,
//            CUSTOM_FIELD_TYPE_LABELS, CUSTOM_FIELD_TYPE_ICONS,
//            isExportableCustomFieldType, normalizeCard, completionOf,
//            fieldFilled, cardHasAttribute, cardAttributeKeys,
//            attributeLabel, attributeIcon, defaultQuizSides,
//            effectiveQuizSides, hasExplicitQuizSides, cardMatchesFilter,
//            resolveDeckCards, allTagsFromCards, allCatsFromCards,
//            isCardDue, nextDueFromNow, useLibrary, useDeck,
//            readLibrarySnapshot, readDeckSnapshot }
// See also : docs/DATA-MODEL.md (canonical reference for every field above)
// ═════════════════════════════════════════════════════════════════════════════
//
// MindForge — Library + Decks store (legacy header preserved below)
//
// Storage model — one entry in localStorage (key: mindforge.library.v1) holds:
//   {
//     cards: [ { id, fr, kr, ro, cat, tone, photo, tags, custom: { [fieldId]: any } } ],
//     decks: [
//       // Smart deck: cards are resolved by filter against the master pool.
//       // The built-in deck "all" is a smart deck with an empty filter and cannot be deleted.
//       { id, name, sub?, type: 'smart',
//         filter: { cats: string[], tags: string[], tagMode: 'any'|'all' } },
//       // Manual deck: cards are an explicit ordered list of ids.
//       { id, name, sub?, type: 'manual', cardIds: number[] }
//     ],
//     activeDeckId: string,   // which deck the play side uses
//     // Library-wide schema extensions — opt-in extra fields shown discreetly
//     // under each card's edit form. Type one of: 'audio'|'video'|'url'|'image'.
//     customFields: [ { id, name, type } ],
//   }
//
// On first load we migrate from the legacy single-deck key (mindforge.deck.v2)
// or seed from DECK_TKD.

const LIBRARY_KEY = 'mindforge.library.v1';
const LEGACY_DECK_KEY_V2 = 'mindforge.deck.v2';
const LEGACY_DECK_KEY_V1 = 'mindforge.deck.v1';

const TONES = ['a', 'b', 'c', 'd'];
const CATS = ['kick', 'stance', 'block'];   // legacy hints; real cats are free-form strings

// Schema description used by the Editor UI (card form).
const SCHEMA = [
  { id: 'fr',    name: 'Français',          type: 'text',     placeholder: 'Coup de pied avant',  primary: true },
  { id: 'kr',    name: '한국어',             type: 'text',     placeholder: '앞 차기',              script: 'kr'  },
  { id: 'ro',    name: 'Romaja',            type: 'text',     placeholder: 'Ap chagi',             muted: true   },
  { id: 'cat',   name: 'Thème / Catégorie', type: 'category', options: CATS,                       defaultValue: 'kick' },
  { id: 'tags',  name: 'Tags',              type: 'tags' },
  { id: 'photo', name: 'Photo',             type: 'image' },
  { id: 'tone',  name: 'Couleur',           type: 'tone',     options: TONES,                      defaultValue: 'a' },
];

const BUILTIN_DECK_ID = 'all';

const emptyFilter = () => ({ cats: [], tags: [], tagMode: 'any' });

const builtinAllDeck = () => ({
  id: BUILTIN_DECK_ID,
  name: 'Toutes les cartes',
  sub:  'Toutes les techniques de la bibliothèque',
  type: 'smart',
  filter: emptyFilter(),
  builtin: true,
});

// ── Field helpers ──────────────────────────────────────────────────────────

const fieldFilled = (card, fieldId) => {
  const v = card?.[fieldId];
  if (v === null || v === undefined) return false;
  if (Array.isArray(v)) return v.length > 0;
  if (typeof v === 'string') return v.trim().length > 0;
  return true;
};

// Card completion 0..1 over the meaningful fields (fr, kr, ro, cat).
const completionOf = (card) => {
  const keys = ['fr', 'kr', 'ro', 'cat'];
  const filled = keys.filter(k => fieldFilled(card, k)).length;
  return filled / keys.length;
};

const normalizeCard = (c) => ({
  id:       c.id,
  fr:       c.fr ?? '',
  kr:       c.kr ?? '',
  ro:       c.ro ?? '',
  cat:      c.cat ?? '',
  tone:     c.tone ?? TONES[0],
  photo:    c.photo ?? null,
  tags:     Array.isArray(c.tags) ? c.tags : [],
  custom:   (c.custom && typeof c.custom === 'object') ? c.custom : {},
  // Legacy opt-out list (per-card disabled quiz types). Kept so existing
  // libraries don't lose data; new UI is quizSides below.
  quizDisabled: Array.isArray(c.quizDisabled) ? [...c.quizDisabled] : [],
  // Q/A configuration — which attributes appear in the prompt vs the reveal
  // for this card. Each entry is an attribute key ('fr', 'kr', 'ro', 'cat',
  // 'tags', 'photo', or `custom:<fieldId>`). Empty = "auto" (a sensible
  // default is derived from the filled attributes).
  quizSides: c.quizSides && typeof c.quizSides === 'object'
    ? {
        question: Array.isArray(c.quizSides.question) ? [...c.quizSides.question] : [],
        answer:   Array.isArray(c.quizSides.answer)   ? [...c.quizSides.answer]   : [],
      }
    : { question: [], answer: [] },
  // SRS scheduling: opt-in. Cards without an interval play freely; cards
  // with one are snoozed for that duration after each answer.
  interval: typeof c.interval === 'string' ? c.interval : 'none',
  dueAt:    typeof c.dueAt === 'number' ? c.dueAt : null,
});

// ── Card-attribute taxonomy (for the Q/A picker) ──────────────────────────

// Stable ordering — also the order the picker renders rows in.
const CORE_ATTRIBUTES = ['photo', 'fr', 'kr', 'ro', 'cat', 'tags'];

const ATTR_LABELS = {
  photo: 'Photo',
  fr:    'Français',
  kr:    'Coréen',
  ro:    'Romaja',
  cat:   'Catégorie',
  tags:  'Tags',
};
const ATTR_ICONS = {
  photo: '📷',
  fr:    '🇫🇷',
  kr:    '🇰🇷',
  ro:    'Aa',
  cat:   '🏷',
  tags:  '#',
};

// True when the card has a usable value for an attribute key.
const cardHasAttribute = (card, key, customFields = []) => {
  if (key.startsWith('custom:')) {
    const id = key.slice(7);
    return !!card?.custom?.[id];
  }
  switch (key) {
    case 'photo': return !!card?.photo;
    case 'tags':  return Array.isArray(card?.tags) && card.tags.length > 0;
    case 'fr':
    case 'kr':
    case 'ro':
    case 'cat':   return !!(card?.[key] || '').trim();
    default:      return false;
  }
};

// Filled attributes on a card, in display order. Custom fields come last,
// in the order the user defined them.
const cardAttributeKeys = (card, customFields = []) => {
  const keys = [];
  for (const k of CORE_ATTRIBUTES) {
    if (cardHasAttribute(card, k, customFields)) keys.push(k);
  }
  for (const f of customFields || []) {
    if (cardHasAttribute(card, `custom:${f.id}`, customFields)) keys.push(`custom:${f.id}`);
  }
  return keys;
};

const attributeLabel = (key, customFields = []) => {
  if (key.startsWith('custom:')) {
    const f = (customFields || []).find(x => 'custom:' + x.id === key);
    return f ? f.name : 'Champ inconnu';
  }
  return ATTR_LABELS[key] || key;
};

const attributeIcon = (key, customFields = []) => {
  if (key.startsWith('custom:')) {
    const f = (customFields || []).find(x => 'custom:' + x.id === key);
    return (f && CUSTOM_FIELD_TYPE_ICONS[f.type]) || '🔧';
  }
  return ATTR_ICONS[key] || '·';
};

// Sensible Q/A split when the user hasn't picked one — photo (if present)
// becomes the question, everything else lands in the answer. Falls back to
// fr-as-question otherwise.
const defaultQuizSides = (card, customFields = []) => {
  const keys = cardAttributeKeys(card, customFields);
  if (keys.length === 0) return { question: [], answer: [] };
  const q = keys.includes('photo') ? ['photo'] : [keys[0]];
  const a = keys.filter(k => !q.includes(k));
  return { question: q, answer: a };
};

// Q/A actually used at play time. If the user touched neither side, use the
// default; otherwise keep their selection but drop attributes that no longer
// exist on the card (e.g. a deleted custom field).
const effectiveQuizSides = (card, customFields = []) => {
  const set = card?.quizSides;
  const q = Array.isArray(set?.question) ? set.question : [];
  const a = Array.isArray(set?.answer)   ? set.answer   : [];
  if (q.length === 0 && a.length === 0) return defaultQuizSides(card, customFields);
  const present = new Set(cardAttributeKeys(card, customFields));
  return {
    question: q.filter(k => present.has(k)),
    answer:   a.filter(k => present.has(k)),
  };
};

// True when the user actively configured both sides — that locks the card
// to the flashcard quiz so the other types stop ignoring their intent.
const hasExplicitQuizSides = (card) => {
  const q = card?.quizSides?.question;
  const a = card?.quizSides?.answer;
  return Array.isArray(q) && Array.isArray(a) && q.length > 0 && a.length > 0;
};

// ── Spaced-repetition intervals ────────────────────────────────────────────
//
// Discrete steps the user picks from in the card editor. 'none' means the
// card plays freely on every run (no snooze). Anything else schedules the
// next eligible appearance at now + ms.

const INTERVAL_OPTIONS = [
  { code: 'none', label: 'Aucun',       short: 'Aucun',  ms: null },
  { code: '30s',  label: '30 secondes', short: '30 s',   ms: 30 * 1000 },
  { code: '1m',   label: '1 minute',    short: '1 min',  ms: 60 * 1000 },
  { code: '6m',   label: '6 minutes',   short: '6 min',  ms: 6 * 60 * 1000 },
  { code: '10m',  label: '10 minutes',  short: '10 min', ms: 10 * 60 * 1000 },
  { code: '1h',   label: '1 heure',     short: '1 h',    ms: 60 * 60 * 1000 },
  { code: '1d',   label: '1 jour',      short: '1 j',    ms: 24 * 3600 * 1000 },
  { code: '3d',   label: '3 jours',     short: '3 j',    ms: 3 * 24 * 3600 * 1000 },
  { code: '5d',   label: '5 jours',     short: '5 j',    ms: 5 * 24 * 3600 * 1000 },
  { code: '1w',   label: '1 semaine',   short: '1 sem',  ms: 7 * 24 * 3600 * 1000 },
  { code: '2w',   label: '2 semaines',  short: '2 sem',  ms: 14 * 24 * 3600 * 1000 },
  { code: '1mo',  label: '1 mois',      short: '1 mois', ms: 30 * 24 * 3600 * 1000 },
  { code: '2mo',  label: '2 mois',      short: '2 mois', ms: 60 * 24 * 3600 * 1000 },
  { code: '6mo',  label: '6 mois',      short: '6 mois', ms: 180 * 24 * 3600 * 1000 },
  { code: '1y',   label: '1 an',        short: '1 an',   ms: 365 * 24 * 3600 * 1000 },
];

const INTERVAL_BY_CODE = INTERVAL_OPTIONS.reduce((m, o) => (m[o.code] = o, m), {});

// Compute the next due timestamp for a card given its interval. Returns null
// for the 'none' (or unrecognised) interval — caller should leave dueAt as-is.
const nextDueFromNow = (intervalCode, fromMs = Date.now()) => {
  const opt = INTERVAL_BY_CODE[intervalCode];
  if (!opt || opt.ms == null) return null;
  return fromMs + opt.ms;
};

// Is the card eligible to play right now? Cards never reviewed (dueAt null)
// are always due; cards with a future dueAt are snoozed.
const isCardDue = (card, now = Date.now()) => {
  if (!card?.dueAt) return true;
  return card.dueAt <= now;
};

// ── Adaptive rating schedule ────────────────────────────────────────────────
//
// The five reveal buttons (Again / Hard / Good / Easy / Super) don't show a
// fixed delay each — the delay is derived from the card's CURRENT interval and
// grows the further up the ladder the card already sits, the way human memory
// needs ever-wider gaps to consolidate a fact:
//
//   Again → relearn from scratch        (1 min)
//   Hard  → short relearn               (6 min on a fresh card, 10 min once matured)
//   Good / Easy / Super → climb 1 / 2 / 3 rungs up the ladder below.
//
// So a brand-new card offers 1 min · 6 min · 10 min · 1 j · 3 j; a card already
// scheduled at 1 jour offers 1 min · 10 min · 3 j · 1 sem · 2 sem; and so on —
// the gaps keep widening as the card is remembered.
const ADVANCE_LADDER = ['10m', '1d', '3d', '1w', '2w', '1mo', '2mo', '6mo', '1y'];

// Interval code offered for each rating value (1-5), given the card's current
// interval code. Returns a { 1, 2, 3, 4, 5 } map of interval codes.
const ratingIntervalCodes = (currentCode) => {
  const curMs = INTERVAL_BY_CODE[currentCode]?.ms ?? 0;
  const learning = curMs < INTERVAL_BY_CODE['10m'].ms;   // still in the sub-10-min steps
  // Highest ladder rung the card has already reached (-1 = not graduated yet).
  let rung = -1;
  for (let k = 0; k < ADVANCE_LADDER.length; k++) {
    if (INTERVAL_BY_CODE[ADVANCE_LADDER[k]].ms <= curMs) rung = k; else break;
  }
  const at = (i) => ADVANCE_LADDER[Math.max(0, Math.min(i, ADVANCE_LADDER.length - 1))];
  return {
    1: '1m',                       // Again — relearn
    2: learning ? '6m' : '10m',    // Hard  — short relearn
    3: at(rung + 1),               // Good
    4: at(rung + 2),               // Easy
    5: at(rung + 3),               // Super
  };
};

// Resolve a rating (1-5) to the { interval, dueAt } the card should take next.
// dueAt is null only for an unschedulable code (never happens here, but kept
// symmetric with nextDueFromNow).
const scheduleFromRating = (currentCode, rating, fromMs = Date.now()) => {
  const codes = ratingIntervalCodes(currentCode);
  const code = codes[rating] || codes[3];
  const ms = INTERVAL_BY_CODE[code]?.ms ?? null;
  return { interval: code, dueAt: ms == null ? null : fromMs + ms };
};

// The two built-in card fields, beyond the core attributes. They are FIXED:
// the user can't add, remove, rename, or retype them — the old user-managed
// "champs personnalisés" engine has been removed. Each card stores its value
// under `card.custom[<field id>]`; the ids are referenced verbatim by the
// seed data in core/data.jsx, so don't rename them.
//
//   cf-builtin-carte      → "Carte"             · type 'pdf_region'
//                           A { url, page, rect } region cropped out of a
//                           source PDF at reveal time (RevealPdfRegion).
//   cf-builtin-video-url  → "Démonstration URL" · type 'video_url'
//                           A YouTube/Vimeo/Drive/.mp4 URL (optionally with
//                           ?t=&end= timestamps) embedded inline at reveal.
const BUILTIN_FIELDS = [
  { id: 'cf-builtin-carte',     name: 'Carte',             type: 'pdf_region' },
  { id: 'cf-builtin-video-url', name: 'Démonstration URL', type: 'video_url' },
];

const CUSTOM_FIELD_TYPE_LABELS = {
  video_url:  'Vidéo (URL externe)',
  pdf_region: 'Région de PDF',
};
const CUSTOM_FIELD_TYPE_ICONS = {
  video_url:  '📺',
  pdf_region: '📄',
};

// True if a field type can round-trip through a CSV/XLSX cell. Both built-in
// types qualify: video_url is a plain URL string, pdf_region is serialized as
// JSON (the cell carries the full { url, page, rect } object).
const isExportableCustomFieldType = (t) =>
  t === 'video_url' || t === 'pdf_region';

// ── Seed / migration ───────────────────────────────────────────────────────

const seedLibrary = () => {
  const src = (typeof DECK_TKD !== 'undefined') ? DECK_TKD : { cards: [] };
  return {
    cards: src.cards.map(normalizeCard),
    decks: [builtinAllDeck()],
    activeDeckId: BUILTIN_DECK_ID,
    customFields: BUILTIN_FIELDS,
  };
};

// What "Réinitialiser tout" produces: an empty library with the built-in deck
// + the two fixed fields still wired up so the user can re-import or start
// fresh. The seed cards only ship on a brand new install (or post-migration).
const emptyLibrary = () => ({
  cards: [],
  decks: [builtinAllDeck()],
  activeDeckId: BUILTIN_DECK_ID,
  customFields: BUILTIN_FIELDS,
});

const tryMigrateLegacy = () => {
  // Prefer v2 (the one the remote was using).
  for (const key of [LEGACY_DECK_KEY_V2, LEGACY_DECK_KEY_V1]) {
    try {
      const raw = localStorage.getItem(key);
      if (!raw) continue;
      const parsed = JSON.parse(raw);
      if (!parsed?.cards || !Array.isArray(parsed.cards)) continue;
      return {
        cards: parsed.cards.map(normalizeCard),
        decks: [builtinAllDeck()],
        activeDeckId: BUILTIN_DECK_ID,
        customFields: [],
      };
    } catch {}
  }
  return null;
};

const loadLibrary = () => {
  try {
    const raw = localStorage.getItem(LIBRARY_KEY);
    if (raw) {
      const parsed = JSON.parse(raw);
      if (parsed?.cards && Array.isArray(parsed.cards) && Array.isArray(parsed.decks)) {
        parsed.cards = parsed.cards.map(normalizeCard);
        if (!parsed.decks.find(d => d.id === BUILTIN_DECK_ID)) {
          parsed.decks = [builtinAllDeck(), ...parsed.decks];
        }
        if (!parsed.activeDeckId || !parsed.decks.find(d => d.id === parsed.activeDeckId)) {
          parsed.activeDeckId = BUILTIN_DECK_ID;
        }
        // The field schema is fixed (BUILTIN_FIELDS) — overwrite whatever the
        // library stored. This also migrates libraries created back when users
        // could add their own "champs personnalisés": any extra fields are
        // dropped from the schema, though their values stay on the cards under
        // card.custom (harmless dead data, never rendered).
        parsed.customFields = BUILTIN_FIELDS;
        return parsed;
      }
    }
    const migrated = tryMigrateLegacy();
    return migrated ?? seedLibrary();
  } catch {
    return seedLibrary();
  }
};

const writeLibrary = (lib) => {
  try { localStorage.setItem(LIBRARY_KEY, JSON.stringify(lib)); }
  catch (e) { console.warn('Library save failed', e); }
};

const resetLibrary = () => {
  try { localStorage.removeItem(LIBRARY_KEY); } catch {}
  try { localStorage.removeItem(LEGACY_DECK_KEY_V2); } catch {}
  try { localStorage.removeItem(LEGACY_DECK_KEY_V1); } catch {}
};

// ── Deck resolution ────────────────────────────────────────────────────────

const cardMatchesFilter = (card, filter) => {
  if (!filter) return true;
  const { cats = [], tags = [], tagMode = 'any' } = filter;
  if (cats.length && !cats.includes(card.cat)) return false;
  if (tags.length) {
    const cardTags = card.tags || [];
    if (tagMode === 'all') {
      if (!tags.every(t => cardTags.includes(t))) return false;
    } else {
      if (!tags.some(t => cardTags.includes(t))) return false;
    }
  }
  return true;
};

const resolveDeckCards = (deck, allCards) => {
  if (!deck) return [];
  if (deck.type === 'manual') {
    const byId = new Map(allCards.map(c => [c.id, c]));
    return (deck.cardIds || []).map(id => byId.get(id)).filter(Boolean);
  }
  return allCards.filter(c => cardMatchesFilter(c, deck.filter));
};

const allTagsFromCards = (cards) => {
  const set = new Set();
  cards.forEach(c => (c.tags || []).forEach(t => t && set.add(t)));
  return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
};

const allCatsFromCards = (cards) => {
  const set = new Set();
  cards.forEach(c => c.cat && set.add(c.cat));
  return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
};

// ── React hook: full library ───────────────────────────────────────────────

const useLibrary = () => {
  const [library, setLibrary] = React.useState(loadLibrary);
  React.useEffect(() => { writeLibrary(library); }, [library]);

  const activeDeck = React.useMemo(
    () => library.decks.find(d => d.id === library.activeDeckId) || library.decks[0],
    [library.decks, library.activeDeckId]
  );

  const activeCards = React.useMemo(
    () => resolveDeckCards(activeDeck, library.cards),
    [activeDeck, library.cards]
  );

  // ── Cards ──
  const updateCard = React.useCallback((cardId, patch) => {
    setLibrary(lib => ({
      ...lib,
      cards: lib.cards.map(c => c.id === cardId ? normalizeCard({ ...c, ...patch }) : c),
    }));
  }, []);

  const addCard = React.useCallback(() => {
    let newCard;
    setLibrary(lib => {
      const maxId = lib.cards.reduce((m, c) => Math.max(m, c.id || 0), 0);
      newCard = normalizeCard({
        id: maxId + 1,
        fr: '', kr: '', ro: '',
        cat: '',
        tone: TONES[(maxId + 1) % TONES.length],
        photo: null,
        tags: [],
      });
      // If the active deck is manual, append the new card to it automatically.
      const decks = lib.decks.map(d =>
        (d.id === lib.activeDeckId && d.type === 'manual')
          ? { ...d, cardIds: [...(d.cardIds || []), newCard.id] }
          : d
      );
      return { ...lib, cards: [...lib.cards, newCard], decks };
    });
    return newCard;
  }, []);

  // Bulk-append partial cards (used by CSV/XLSX import).
  const addCards = React.useCallback((partials) => {
    let added = [];
    setLibrary(lib => {
      const maxId = lib.cards.reduce((m, c) => Math.max(m, c.id || 0), 0);
      added = partials.map((p, i) => normalizeCard({
        id: maxId + 1 + i,
        fr: '', kr: '', ro: '',
        cat: '',
        tone: TONES[(maxId + 1 + i) % TONES.length],
        photo: null,
        ...p,
      }));
      const addedIds = added.map(c => c.id);

      // Deck assignment from an optional `_decks` (array of deck names) carried
      // on a partial — produced by the CSV importer. A name matching an existing
      // manual deck adds the card; an unknown name spawns a fresh manual deck.
      // Smart decks (and the built-in deck) resolve by filter, so we never push
      // ids into them — we just don't shadow them with a same-named manual deck.
      let decks = lib.decks;
      if (partials.some(p => Array.isArray(p._decks) && p._decks.length)) {
        // Work on copies so we can push ids into manual decks safely.
        decks = lib.decks.map(d => d.type === 'manual'
          ? { ...d, cardIds: [...(d.cardIds || [])] }
          : d);
        const norm = (s) => (s || '').trim().toLowerCase();
        const manualByName = new Map();
        const takenNames = new Set();
        decks.forEach(d => {
          takenNames.add(norm(d.name));
          if (d.type === 'manual') manualByName.set(norm(d.name), d);
        });
        let seq = 0;
        partials.forEach((p, i) => {
          if (!Array.isArray(p._decks)) return;
          const cardId = added[i].id;
          for (const rawName of p._decks) {
            const name = (rawName || '').trim();
            if (!name) continue;
            const key = norm(name);
            let target = manualByName.get(key);
            if (!target) {
              if (takenNames.has(key)) continue;   // don't shadow a smart/built-in deck
              target = { id: `deck-${maxId}-${seq++}`, name, type: 'manual', cardIds: [] };
              decks = [...decks, target];
              manualByName.set(key, target);
              takenNames.add(key);
            }
            if (!target.cardIds.includes(cardId)) target.cardIds.push(cardId);
          }
        });
      }

      // Legacy behaviour: when a manual deck is active, imported cards join it too.
      decks = decks.map(d =>
        (d.id === lib.activeDeckId && d.type === 'manual')
          ? { ...d, cardIds: [...(d.cardIds || []), ...addedIds.filter(id => !(d.cardIds || []).includes(id))] }
          : d
      );
      return { ...lib, cards: [...lib.cards, ...added], decks };
    });
    return added;
  }, []);

  const deleteCard = React.useCallback((cardId) => {
    setLibrary(lib => {
      const removed = lib.cards.find(c => c.id === cardId);
      if (removed) {
        const orphans = Object.values(removed.custom || {}).filter(v => isMediaId(v));
        if (orphans.length) mediaStore.removeMany(orphans);   // fire-and-forget
      }
      return {
        ...lib,
        cards: lib.cards.filter(c => c.id !== cardId),
        decks: lib.decks.map(d => d.type === 'manual'
          ? { ...d, cardIds: (d.cardIds || []).filter(id => id !== cardId) }
          : d),
      };
    });
  }, []);

  // Bulk variants — batched into a single setLibrary so N cards = 1 render.
  // patchOrFn can be either a plain patch object applied to every selected
  // card, or a function (card) => patch for per-card-computed patches
  // (used by tag merge / remove).
  const bulkUpdateCards = React.useCallback((ids, patchOrFn) => {
    const idSet = new Set(ids);
    setLibrary(lib => ({
      ...lib,
      cards: lib.cards.map(c => {
        if (!idSet.has(c.id)) return c;
        const patch = typeof patchOrFn === 'function' ? patchOrFn(c) : patchOrFn;
        return normalizeCard({ ...c, ...patch });
      }),
    }));
  }, []);

  const bulkDeleteCards = React.useCallback((ids) => {
    const idSet = new Set(ids);
    setLibrary(lib => {
      const removed = lib.cards.filter(c => idSet.has(c.id));
      const orphans = removed.flatMap(c => Object.values(c.custom || {}).filter(isMediaId));
      if (orphans.length) mediaStore.removeMany(orphans);   // fire-and-forget
      return {
        ...lib,
        cards: lib.cards.filter(c => !idSet.has(c.id)),
        decks: lib.decks.map(d => d.type === 'manual'
          ? { ...d, cardIds: (d.cardIds || []).filter(id => !idSet.has(id)) }
          : d),
      };
    });
  }, []);

  // ── Decks ──
  const setActiveDeckId = React.useCallback((id) => {
    setLibrary(lib => lib.decks.find(d => d.id === id) ? { ...lib, activeDeckId: id } : lib);
  }, []);

  const addDeck = React.useCallback((kind = 'smart') => {
    let newDeck;
    setLibrary(lib => {
      const n = lib.decks.filter(d => !d.builtin).length + 1;
      newDeck = kind === 'manual'
        ? { id: `deck-${Date.now()}`, name: `Nouveau deck ${n}`, type: 'manual', cardIds: [] }
        : { id: `deck-${Date.now()}`, name: `Nouveau deck ${n}`, type: 'smart',  filter: emptyFilter() };
      return { ...lib, decks: [...lib.decks, newDeck], activeDeckId: newDeck.id };
    });
    return newDeck;
  }, []);

  const updateDeck = React.useCallback((deckId, patch) => {
    setLibrary(lib => ({
      ...lib,
      decks: lib.decks.map(d => d.id === deckId ? { ...d, ...patch } : d),
    }));
  }, []);

  const deleteDeck = React.useCallback((deckId) => {
    setLibrary(lib => {
      if (deckId === BUILTIN_DECK_ID) return lib;
      const decks = lib.decks.filter(d => d.id !== deckId);
      const activeDeckId = lib.activeDeckId === deckId ? BUILTIN_DECK_ID : lib.activeDeckId;
      return { ...lib, decks, activeDeckId };
    });
  }, []);

  const duplicateDeck = React.useCallback((deckId) => {
    let copy;
    setLibrary(lib => {
      const src = lib.decks.find(d => d.id === deckId);
      if (!src) return lib;
      copy = {
        ...src,
        id: `deck-${Date.now()}`,
        name: `${src.name} (copie)`,
      };
      delete copy.builtin;
      return { ...lib, decks: [...lib.decks, copy], activeDeckId: copy.id };
    });
    return copy;
  }, []);

  const setDeckCardIds = React.useCallback((deckId, cardIds) => {
    setLibrary(lib => ({
      ...lib,
      decks: lib.decks.map(d => d.id === deckId ? { ...d, cardIds: [...cardIds] } : d),
    }));
  }, []);

  const toggleCardInManualDeck = React.useCallback((deckId, cardId) => {
    setLibrary(lib => ({
      ...lib,
      decks: lib.decks.map(d => {
        if (d.id !== deckId || d.type !== 'manual') return d;
        const ids = d.cardIds || [];
        const next = ids.includes(cardId) ? ids.filter(i => i !== cardId) : [...ids, cardId];
        return { ...d, cardIds: next };
      }),
    }));
  }, []);

  // Convert a smart deck into a manual one, snapshotting its current resolved set.
  const materializeSmartDeck = React.useCallback((deckId) => {
    setLibrary(lib => {
      const d = lib.decks.find(x => x.id === deckId);
      if (!d || d.type !== 'smart') return lib;
      const resolved = resolveDeckCards(d, lib.cards);
      const next = { ...d, type: 'manual', cardIds: resolved.map(c => c.id) };
      delete next.filter;
      return { ...lib, decks: lib.decks.map(x => x.id === deckId ? next : x) };
    });
  }, []);

  const resetAll = React.useCallback(() => {
    mediaStore.clear();   // wipe IndexedDB blobs (fire-and-forget)
    setLibrary(emptyLibrary());
  }, []);

  // ── Built-in field values ──
  // Write/clear a card's value for one of the fixed BUILTIN_FIELDS. The schema
  // itself is immutable, so there's nothing to add/remove/rename here.
  const setCardCustomValue = React.useCallback((cardId, fieldId, value) => {
    setLibrary(lib => {
      return {
        ...lib,
        cards: lib.cards.map(c => {
          if (c.id !== cardId) return c;
          const nextCustom = { ...(c.custom || {}) };
          if (value === null || value === undefined || value === '') delete nextCustom[fieldId];
          else nextCustom[fieldId] = value;
          return { ...c, custom: nextCustom };
        }),
      };
    });
  }, []);

  const allTags = React.useMemo(() => allTagsFromCards(library.cards), [library.cards]);
  const allCats = React.useMemo(() => allCatsFromCards(library.cards), [library.cards]);

  return {
    library,
    cards: library.cards,
    decks: library.decks,
    activeDeck,
    activeCards,
    allTags,
    allCats,
    customFields: library.customFields || [],
    // cards
    updateCard, addCard, addCards, deleteCard, setCardCustomValue,
    bulkUpdateCards, bulkDeleteCards,
    // decks
    setActiveDeckId, addDeck, updateDeck, deleteDeck, duplicateDeck,
    setDeckCardIds, toggleCardInManualDeck, materializeSmartDeck,
    // misc
    resetAll,
  };
};

// ── Backwards-compat shim ─────────────────────────────────────────────────
// Exposes the active deck in the same shape the previous single-deck API used,
// so unchanged callers (App, HomeScreen) keep working.

const useDeck = () => {
  const lib = useLibrary();

  const deck = React.useMemo(() => ({
    id:    lib.activeDeck.id,
    name:  lib.activeDeck.name,
    sub:   lib.activeDeck.sub || '',
    cards: lib.activeCards,
  }), [lib.activeDeck, lib.activeCards]);

  const updateMeta = React.useCallback((patch) => {
    lib.updateDeck(lib.activeDeck.id, patch);
  }, [lib.updateDeck, lib.activeDeck.id]);

  // Reseed = reset the whole library (cards + custom decks). Same destructive
  // semantics as before from the caller's perspective.
  const reseed = lib.resetAll;

  return {
    deck,
    decks: lib.decks,
    setActiveDeckId: lib.setActiveDeckId,
    customFields: lib.customFields,
    updateCard: lib.updateCard,
    addCard:    lib.addCard,
    addCards:   lib.addCards,
    deleteCard: lib.deleteCard,
    updateMeta,
    reseed,
  };
};

// Snapshot read (for non-React contexts).
const readLibrarySnapshot = () => loadLibrary();
const readDeckSnapshot = () => {
  const lib = loadLibrary();
  const active = lib.decks.find(d => d.id === lib.activeDeckId) || lib.decks[0];
  return { ...active, cards: resolveDeckCards(active, lib.cards) };
};

Object.assign(window, {
  // schema / consts
  SCHEMA, TONES, CATS, BUILTIN_DECK_ID,
  BUILTIN_FIELDS, CUSTOM_FIELD_TYPE_LABELS, CUSTOM_FIELD_TYPE_ICONS,
  INTERVAL_OPTIONS, INTERVAL_BY_CODE,
  // helpers
  fieldFilled, completionOf, normalizeCard, emptyFilter,
  cardMatchesFilter, resolveDeckCards, allTagsFromCards, allCatsFromCards,
  isExportableCustomFieldType,
  nextDueFromNow, isCardDue,
  CORE_ATTRIBUTES, ATTR_LABELS, ATTR_ICONS,
  cardHasAttribute, cardAttributeKeys, attributeLabel, attributeIcon,
  defaultQuizSides, effectiveQuizSides, hasExplicitQuizSides,
  // persistence
  seedLibrary, loadLibrary, writeLibrary, resetLibrary,
  // hooks
  useLibrary, useDeck,
  readLibrarySnapshot, readDeckSnapshot,
});
