// ═════════════════════════════════════════════════════════════════════════════
// MindForge · Layer 4 · play/app.jsx
// ─────────────────────────────────────────────────────────────────────────────
// Role     : The play-side root. Hosts the 5-phase state machine
//            (home → play → reveal → summary | gameover), the question
//            generator (per-mode type list + per-card eligibility), the XP /
//            streak persistence, the survival timer, the global keyboard
//            handler (1-4 / Enter).
// Loaded   : #8
// Reads    : window.{ React, useDeck, loadSave, writeSave, pickN, shuffle,
//            comboMult, isCardDue, nextDueFromNow, canCardDoType,
//            effectiveQuizSides, HomeScreen, PlayScreen,
//            RevealScreen, SummaryScreen, GameOverScreen }
// Exposes  : window.{ App, canCardDoType }
// See also : docs/ARCHITECTURE.md §"Play state machine"
//            docs/DATA-MODEL.md §"SRS scheduling"
// ═════════════════════════════════════════════════════════════════════════════
//
// MindForge — main App with game state machine & question generator

// ─────────────── Question generator ───────────────
// Builds a queue of N questions for a session, mixing types based on mode.
//   mc_fr_kr   FR prompt → pick Korean (text MC)
//   mc_kr_fr   Korean prompt → pick FR (text MC)
//   image      Photo of the card → pick romaja (text MC)         [needs card.photo]
//   audio      Synthetic-or-real audio → pick romaja (text MC)
//   typing     Korean prompt → type romaja (free text)
//   find_photo Korean prompt → pick the right PHOTO (visual MC)  [needs ≥4 cards with photos]
//   flashcard  Renders card.quizSides.question, self-rate, reveal
//                shows card.quizSides.answer                      [needs ≥1 Q and ≥1 A]
const QTYPES = {
  quick:    ['mc_fr_kr', 'mc_kr_fr'],
  daily:    ['mc_fr_kr', 'mc_kr_fr', 'image', 'audio', 'typing', 'find_photo', 'flashcard'],
  survival: ['mc_fr_kr', 'mc_kr_fr', 'image', 'audio'],   // no typing in survival
};

// True when the card's Q/A split has at least one attribute on each side.
const hasFlashcardSides = (card, customFields) => {
  const s = effectiveQuizSides(card, customFields);
  return s.question.length > 0 && s.answer.length > 0;
};

const makeQuestion = (card, type, deck, customFields) => {
  // Flashcard is not an MC — it's a self-rated reveal driven by the card's
  // own Q/A split. No distractors required.
  if (type === 'flashcard') {
    return {
      type, card, options: [card], customFields,
      sides: effectiveQuizSides(card, customFields),
    };
  }
  // find_photo distractors must also have photos so the visual MC stays fair.
  if (type === 'find_photo') {
    const photoPool = deck.cards.filter(c => !!c.photo && c.id !== card.id);
    const distractors = pickN(photoPool, 3);
    const opts = shuffle([card, ...distractors]);
    return { type, card, options: opts, customFields };
  }
  const distractors = pickOtherN(deck.cards, card, 3);
  const opts = shuffle([card, ...distractors]);
  return { type, card, options: opts, customFields };
};

// Quiz types that need 4 distinct cards (the answer + 3 distractors).
// Decks below that threshold drop these from the rotation so we never render
// a 1-option MC.
const TYPES_NEED_DISTRACTORS = new Set([
  'mc_fr_kr', 'mc_kr_fr', 'image', 'audio', 'find_photo',
]);

// Filter the mode's type list to those that the deck can actually satisfy.
const typesForDeck = (deck, mode, customFields) => {
  const total           = deck.cards.length;
  const hasEnoughForMC  = total >= 4;
  const hasAnyPhoto     = deck.cards.some(c => !!c.photo);
  const photoCount      = deck.cards.filter(c => !!c.photo).length;
  const hasAnyFlashcard = deck.cards.some(c => hasFlashcardSides(c, customFields));
  const filtered = QTYPES[mode].filter(t => {
    if (TYPES_NEED_DISTRACTORS.has(t) && !hasEnoughForMC) return false;
    if (t === 'image'      && !hasAnyPhoto)     return false;
    if (t === 'find_photo' && photoCount < 4)   return false;
    if (t === 'flashcard'  && !hasAnyFlashcard) return false;
    return true;
  });
  // Ultimate fallback for tiny decks where the mode kept nothing — flashcard
  // works with as little as one card if its Q/A split has both sides.
  if (filtered.length === 0 && hasAnyFlashcard) return ['flashcard'];
  return filtered;
};

// Can this specific card be used for this quiz type?
// Combines attribute eligibility (does the card have a photo / ro?) with the
// user's explicit opt-outs stored in card.quizDisabled.
const canCardDoType = (card, type, customFields) => {
  if (Array.isArray(card.quizDisabled) && card.quizDisabled.includes(type)) return false;
  // When the user explicitly configured Q/A for a card, only the flashcard
  // honours that choice — every other type would silently ignore it (e.g.
  // typing would still ask for romaja even if "romaja" is on neither side).
  if (hasExplicitQuizSides(card) && type !== 'flashcard') return false;
  switch (type) {
    case 'image':      return !!card.photo;
    case 'find_photo': return !!card.photo;
    case 'flashcard':  return hasFlashcardSides(card, customFields);
    case 'typing':     return !!(card.ro || card.kr);
    case 'mc_fr_kr':
    case 'mc_kr_fr':
    case 'audio':
    default:           return true;
  }
};

// Per-card fallback for types that the card itself can't satisfy (missing
// attribute OR explicit opt-out). Walks a preference list of always-safe
// text types and picks the first the card accepts.
const pickType = (card, type, customFields) => {
  // Cards locked to Q/A always become flashcards — skip the MC fallback chain
  // entirely so we never hand them a quiz they shouldn't appear in.
  if (hasExplicitQuizSides(card)) return 'flashcard';
  if (canCardDoType(card, type, customFields)) return type;
  const fallbacks = type === 'find_photo'
    ? ['mc_kr_fr', 'mc_fr_kr', 'typing', 'audio']
    : ['mc_fr_kr', 'mc_kr_fr', 'typing', 'audio'];
  for (const t of fallbacks) {
    if (canCardDoType(card, t, customFields)) return t;
  }
  return 'mc_fr_kr';
};

// Cards eligible for the queue: due now (interval expired) or never reviewed.
// If too few are due, falls back to the full deck so we never block the run —
// the user can still play; they'll just see a few not-yet-due cards too.
const eligibleCards = (deck) => {
  const now = Date.now();
  const due = deck.cards.filter(c => isCardDue(c, now));
  // MC needs 4 distinct cards (the answer + 3 distractors). If we don't have
  // that many due, expand to the whole deck so play stays unblocked.
  return due.length >= 4 ? due : deck.cards;
};

const buildQueue = (deck, mode, length, customFields) => {
  const pool  = eligibleCards(deck);
  if (!pool.length) return [];
  const cards = shuffle(pool);
  const types = typesForDeck(deck, mode, customFields);
  if (!types.length) return [];   // no playable type for this deck/mode
  const queue = [];
  for (let i = 0; i < length; i++) {
    const card = cards[i % cards.length];
    const type = pickType(card, types[i % types.length], customFields);
    queue.push(makeQuestion(card, type, { ...deck, cards: pool }, customFields));
  }
  return queue;
};

// Survival generates infinitely
const nextSurvivalQuestion = (deck, customFields) => {
  const pool = eligibleCards(deck);
  const card = pool[rand(pool.length)];
  const types = typesForDeck(deck, 'survival', customFields);
  const type = pickType(card, types[rand(types.length)], customFields);
  return makeQuestion(card, type, { ...deck, cards: pool }, customFields);
};

// ─────────────── Profile helpers ───────────────
const LEVEL_BASE = 250;
const xpToLevel = (xp) => {
  // 250, 500, 750, 1000 -> simple linear
  let level = 1, need = LEVEL_BASE, sum = 0;
  while (xp >= sum + need) { sum += need; level++; need = LEVEL_BASE * level; }
  return { level, intoLevel: xp - sum, levelXp: need };
};

const initialProfile = () => {
  const save = loadSave();
  const xp = save.xp ?? 980;            // start with some XP for nice visual
  const streak = save.streak ?? 4;
  const lastPlayed = save.lastPlayed ?? null;
  const { level, intoLevel, levelXp } = xpToLevel(xp);
  return {
    xp, streak, lastPlayed,
    level, intoLevel, levelXp,
    levelPct: (intoLevel / levelXp) * 100,
    nextLevelXp: levelXp,
  };
};

// Analytics: a finished run (summary or game-over). Captures the cadence we
// need to size future ad placements (runs/visitor, mode mix, accuracy). Pure
// no-op if analytics isn't loaded.
const trackRunCompleted = (s, outcome) => {
  if (!s || !window.mfTrack) return;
  const answered = s.correct + s.wrong;
  window.mfTrack('run_completed', {
    mode: s.mode,
    outcome,                                  // 'complete' | 'gameover'
    correct: s.correct,
    wrong: s.wrong,
    accuracy: answered ? Math.round((s.correct / answered) * 100) : 0,
    xp: s.xp,
    score: s.idx,
  });
};

// ─────────────── App: state machine ───────────────
const App = ({ goToEditor } = {}) => {
  const { deck, decks, setActiveDeckId, customFields, updateCard } = useDeck(); // live, persisted, edited deck
  const [phase, setPhase] = useState('home');     // 'home' | 'play' | 'reveal' | 'summary' | 'gameover'
  const [profile, setProfile] = useState(initialProfile);
  const [session, setSession] = useState(null);
  const [lastResult, setLastResult] = useState(null);  // { card, ok, xp, combo, comboMult }
  const [surviveTimer, setSurviveTimer] = useState(null);
  const timerRef = useRef();

  // ─── start a run ───
  const startRun = useCallback((mode) => {
    if (!deck.cards || deck.cards.length < 1) {
      alert("Aucune carte à jouer. Ouvrez l'éditeur pour en ajouter une.");
      return;
    }
    const length = mode === 'quick' ? 5 : mode === 'daily' ? 24 : 6;
    const totalLen = mode === 'survival' ? 30 : length;
    const queue = buildQueue(deck, mode, totalLen, customFields);
    if (!queue.length) {
      alert(
        "Ce mode n'a aucun quiz jouable avec ce deck.\n" +
        "Ajoute des cartes (4 minimum pour les QCM) ou configure une carte avec Question + Réponse pour activer le flashcard."
      );
      return;
    }
    setSession({
      mode, deck, customFields,
      queue, idx: 0,
      lives: mode === 'survival' ? 3 : null,
      combo: 0, bestCombo: 0,
      xp: 0, correct: 0, wrong: 0,
      streakBumped: profile.streak > 0 && profile.lastPlayed === new Date().toDateString(),
      startedAt: Date.now(),
    });
    setPhase('play');
    if (mode === 'survival') startSurvivalTimer();
    if (window.mfTrack) window.mfTrack('run_started', { mode, deck: deck.name || deck.id, cards: queue.length });
  }, [profile, deck, customFields]);

  // ─── survival timer ───
  const startSurvivalTimer = useCallback(() => {
    setSurviveTimer(10);
    clearInterval(timerRef.current);
    timerRef.current = setInterval(() => {
      setSurviveTimer(t => {
        if (t <= 1) {
          clearInterval(timerRef.current);
          // timeout = wrong answer
          setTimeout(() => handleAnswer(false, null, true), 60);
          return 0;
        }
        return t - 1;
      });
    }, 1000);
  }, []);

  const stopSurvivalTimer = useCallback(() => {
    clearInterval(timerRef.current);
    setSurviveTimer(null);
  }, []);

  // ─── handle an answer ───
  const handleAnswer = useCallback((ok, picked, isTimeout = false) => {
    stopSurvivalTimer();
    setSession(s => {
      if (!s) return s;
      const q = s.queue[s.idx];
      const newCombo = ok ? s.combo + 1 : 0;
      const mult = comboMult(s.combo); // multiplier on the score being awarded
      const base = ok ? (q.type === 'typing' ? 14 : q.type === 'image' || q.type === 'audio' ? 12 : 10) : 0;
      const survivalBonus = s.mode === 'survival' && ok ? 4 : 0;
      const gained = ok ? Math.round((base + survivalBonus) * mult) : 0;
      setLastResult({
        card: q.card, ok, xp: gained,
        combo: newCombo, comboMult: mult,
        customFields: s.customFields,
        // For flashcard quizzes, the reveal filters fields by the answer side.
        sides: q.type === 'flashcard' ? q.sides : null,
      });
      // SRS rescheduling happens in advance(): in daily mode the chosen rating
      // drives the next interval; other modes fall back to the card's own
      // interval there. We don't snooze here so we don't double-count.
      return {
        ...s,
        combo: newCombo,
        bestCombo: Math.max(s.bestCombo, newCombo),
        correct: s.correct + (ok ? 1 : 0),
        wrong:   s.wrong   + (ok ? 0 : 1),
        xp: s.xp + gained,
        lives: s.mode === 'survival' ? s.lives - (ok ? 0 : 1) : s.lives,
      };
    });
    setPhase('reveal');
  }, [stopSurvivalTimer, updateCard]);

  // ─── advance after reveal ───
  const advance = useCallback((rating = 3) => {
    setSession(s => {
      if (!s) return s;
      const isSurvival = s.mode === 'survival';
      // Reschedule the just-answered card. Daily mode: the chosen rating drives
      // the next interval (and updates the card so it shows up in the CSV
      // export). Other modes: snooze by the card's own fixed interval.
      const answered = s.queue[s.idx];
      if (answered?.card) {
        if (s.mode === 'daily') {
          const sched = scheduleFromRating(answered.card.interval, rating);
          updateCard(answered.card.id, { interval: sched.interval, dueAt: sched.dueAt });
        } else {
          const nextDue = nextDueFromNow(answered.card.interval);
          if (nextDue != null) updateCard(answered.card.id, { dueAt: nextDue });
        }
      }
      // Game over: out of lives
      if (isSurvival && s.lives <= 0) {
        setTimeout(() => commitProfileGains(s), 50);
        trackRunCompleted(s, 'gameover');
        setPhase('gameover');
        return s;
      }
      const nextIdx = s.idx + 1;
      let newQueue = s.queue;
      if (isSurvival && nextIdx >= newQueue.length - 1) {
        // extend queue
        newQueue = [...newQueue, ...Array.from({length: 10}).map(() => nextSurvivalQuestion(s.deck, s.customFields))];
      }
      // Rating "Again" (1) — re-insert card a couple steps later
      if (rating === 1 && !isSurvival) {
        newQueue = [...newQueue];
        newQueue.splice(Math.min(nextIdx + 2, newQueue.length), 0, answered);
      }
      if (!isSurvival && nextIdx >= newQueue.length) {
        // Run complete
        setTimeout(() => commitProfileGains(s), 50);
        trackRunCompleted(s, 'complete');
        setPhase('summary');
        return s;
      }
      // Continue
      setPhase('play');
      if (isSurvival) setTimeout(() => startSurvivalTimer(), 100);
      return { ...s, idx: nextIdx, queue: newQueue };
    });
  }, [startSurvivalTimer, updateCard]);

  // ─── persist XP/streak when run completes ───
  const commitProfileGains = useCallback((s) => {
    setProfile(p => {
      const newXp = p.xp + s.xp;
      const today = new Date().toDateString();
      const newStreak = p.lastPlayed === today ? p.streak : (p.streak + 1);
      const lv = xpToLevel(newXp);
      const next = {
        xp: newXp, streak: newStreak, lastPlayed: today,
        level: lv.level, intoLevel: lv.intoLevel, levelXp: lv.levelXp,
        levelPct: (lv.intoLevel / lv.levelXp) * 100, nextLevelXp: lv.levelXp,
      };
      writeSave({ xp: next.xp, streak: next.streak, lastPlayed: next.lastPlayed });
      return next;
    });
  }, []);

  // ─── close (X) handler ───
  const closeRun = useCallback(() => {
    stopSurvivalTimer();
    // Quit mid-run (the X on PlayScreen) — measure where people drop off so we
    // don't place an ad on a screen they're already leaving. Read the live
    // session inside the updater so we capture progress before clearing it.
    setSession(s => {
      if (s) {
        const answered = s.correct + s.wrong;
        if (window.mfTrack) window.mfTrack('run_abandoned', {
          mode: s.mode,
          idx: s.idx,
          total: s.queue.length,
          answered,
          progress: s.queue.length ? Math.round((s.idx / s.queue.length) * 100) : 0,
        });
      }
      return null;
    });
    setLastResult(null);
    setPhase('home');
  }, [stopSurvivalTimer]);

  // ─── home from end screens ───
  const goHome = useCallback(() => {
    setSession(null);
    setLastResult(null);
    setPhase('home');
  }, []);

  const playAgain = useCallback(() => {
    const mode = session?.mode || 'daily';
    if (window.mfTrack) window.mfTrack('play_again', { mode });
    setSession(null);
    setLastResult(null);
    setTimeout(() => startRun(mode), 50);
  }, [session, startRun]);

  // ─── keyboard: 1-5 to rate on the reveal screen ───
  useEffect(() => {
    const onKey = (e) => {
      if (phase === 'reveal' && lastResult && !surviveTimer) {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          // Daily mode shows rating; others auto-continue
          if (session?.mode === 'daily') advance(3);
          else advance(3);
        }
        if (session?.mode === 'daily' && ['1','2','3','4','5'].includes(e.key)) {
          e.preventDefault();
          advance(parseInt(e.key, 10));
        }
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [phase, lastResult, session, advance, surviveTimer]);

  // cleanup timer on unmount
  useEffect(() => () => clearInterval(timerRef.current), []);

  // ─── render ───
  const showRating = session?.mode === 'daily';
  return (
    <div style={{height:'100%', width:'100%', overflow:'hidden', background: MF.bg}}>
      {phase === 'home'     && <HomeScreen profile={profile} deck={deck} decks={decks} onSelectDeck={setActiveDeckId} onStart={startRun} onEdit={goToEditor}/>}
      {phase === 'play' && session && (
        <PlayScreen
          session={session}
          onAnswer={handleAnswer}
          onClose={closeRun}
          surviveTimer={surviveTimer}
        />
      )}
      {phase === 'reveal' && lastResult && (
        <RevealScreen
          result={lastResult}
          onRate={advance}
          onContinue={() => advance(3)}
          showRating={showRating}
        />
      )}
      {phase === 'summary' && session && (
        <SummaryScreen session={session} profile={profile} onHome={goHome} onAgain={playAgain}/>
      )}
      {phase === 'gameover' && session && (
        <GameOverScreen session={session} onHome={goHome} onAgain={playAgain}/>
      )}
    </div>
  );
};

Object.assign(window, { App, canCardDoType });
