/* global React */
const { useState, useEffect, useCallback, useRef } = React;

// =============================================================
// Device state machine — production version (Sprint 2)
// =============================================================
// States:
//   domainInput    — waiting for player to type a domain
//   loading        — waiting on API (research, turn generation)
//   printing       — typewriter rendering current page
//   awaitingInput  — page rendered, choices shown (> cycles, GO confirms)
//   freedomInput   — OFF SCRIPT card confirmed; card region is a text entry
//   options        — options/settings menu (nested)
//   episodeComplete — episode finished, coda displayed
//
// Button semantics:
//   >   printing       → ADVANCE_PAGE (handled in device.jsx when page-paused)
//       awaitingInput  → CYCLE_CHOICE (only when choice card bottom is visible)
//       options        → CYCLE_MENU (next row; pages the biography view)
//   GO  domainInput    → SUBMIT_DOMAIN
//       awaitingInput  → CONFIRM_CHOICE (if choices) or no-op
//                        · isFreedom card → freedomInput (no API call yet)
//                        · isFriend card  → FRIEND_INVOKED (device calls /invoke-ally)
//       freedomInput   → SUBMIT_FREEDOM (device reads the typed text)
//       options        → DRILL_INTO (cycles the row's value in place;
//                        on BIOGRAPHY opens the text view)
//       episodeComplete → RESTART
//   OPT (the "..." key)
//       printing       → ENTER_OPTIONS
//       awaitingInput  → ENTER_OPTIONS
//       freedomInput   → CANCEL_FREEDOM (back to the choice cycle)
//       options        → BACK_OUT (closes menu; backs out of the C.V.)

// --- Options menu -------------------------------------------------------
// Flat by design: every setting is a root-level row, GO cycles its value in
// place, and the current value stays visible beside the label. The only
// drill-in is C.V., a read-only text view (id stays "biography" — code
// identifiers lag player-facing copy). Setting ids are load-bearing:
// mapSettingsToRetroParams and speedFromSetting look values up by id, and
// device.jsx's volume effect mirrors the SOUND options order. `section` is
// render-only: the menu draws a header whenever it changes between rows, so
// C.V. (no section) stands alone above the SETTINGS block. SOUND cycles
// NORMAL → SOFT → OFF so the three states read as a range, not a toggle.
const OPTIONS_TREE = {
  id: "root",
  label: "OPTIONS",
  kind: "submenu",
  items: [
    { id: "biography",     label: "C.V.",      kind: "text" },
    { id: "text.size",     label: "TEXT SIZE", kind: "choice", options: ["SMALL", "MEDIUM", "LARGE"], defaultIdx: 1, section: "SETTINGS" },
    { id: "text.speed",    label: "SPEED",     kind: "choice", options: ["SLOW", "NORMAL", "FAST", "INSTANT"], defaultIdx: 1, section: "SETTINGS" },
    { id: "volume",        label: "SOUND",     kind: "choice", options: ["NORMAL", "SOFT", "OFF"], defaultIdx: 0, section: "SETTINGS" },
  ],
};

function collectChoiceDefaults(node, into = {}) {
  if (node.kind === "choice") into[node.id] = node.defaultIdx ?? 0;
  if (node.items) node.items.forEach((c) => collectChoiceDefaults(c, into));
  return into;
}

// Settings envelope: bump the version to wipe everyone's stored settings on
// deploy (same convention as deviceRoster/deviceBiography in device.jsx).
const SETTINGS_KEY = "deviceSettings";
const SETTINGS_VERSION = 1;
function readSettingsEnvelope() {
  try {
    const rec = JSON.parse(sessionStorage.getItem(SETTINGS_KEY) || "null");
    return rec && rec.version === SETTINGS_VERSION ? rec : null;
  } catch (_) { return null; }
}

// --- Typewriter hook (page-aware: supports pause/resume) -----------------
function useTypewriter(text, charsPerSec, active, onDone) {
  const [out, setOut] = useState("");
  const [done, setDone] = useState(false);
  const [paused, setPaused] = useState(false);
  const skipRef = useRef(false);
  const pausedRef = useRef(false);
  const lastTextRef = useRef(null);

  useEffect(() => {
    if (lastTextRef.current !== text) {
      setOut("");
      setDone(false);
      skipRef.current = false;
      pausedRef.current = false;
      setPaused(false);
      lastTextRef.current = text;
    }
  }, [text]);

  useEffect(() => {
    if (!active || !text) return;
    if (done) return;
    let raf;
    let lastTick = performance.now();
    let pos = out.length;
    let frac = 0;
    const tick = (now) => {
      if (skipRef.current) {
        setOut(text); setDone(true); onDone && onDone(); return;
      }
      const dt = (now - lastTick) / 1000;
      lastTick = now;
      if (!pausedRef.current) {
        frac += dt * charsPerSec;
        const advance = Math.floor(frac);
        if (advance > 0) {
          frac -= advance;
          pos = Math.min(text.length, pos + advance);
          setOut(text.slice(0, pos));
        }
        if (pos >= text.length) { setDone(true); onDone && onDone(); return; }
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [text, charsPerSec, active, done]);

  const skip = useCallback(() => { skipRef.current = true; }, []);
  const pause = useCallback(() => { pausedRef.current = true; setPaused(true); }, []);
  const resume = useCallback(() => { pausedRef.current = false; setPaused(false); }, []);
  return { out, done, skip, paused, pause, resume };
}

// --- State machine ------------------------------------------------------
function useDeviceStateMachine({ printSpeed, emit }) {
  const [pages, setPages] = useState([]);
  const [pageIdx, setPageIdx] = useState(-1);
  const [appState, setAppState] = useState("domainInput");
  const [episodeTitle, setEpisodeTitle] = useState("");
  const [choiceIdx, setChoiceIdx] = useState(0);
  const [errorMsg, setErrorMsg] = useState(null);
  // Accumulated text from completed turns + confirmed choices (continuous scroll)
  const [narrative, setNarrative] = useState("");
  // Last confirmed decision text (for scroll targeting)
  const [lastDecision, setLastDecision] = useState(null);
  // Non-null while a fold-on-success call (freedom/friend) is in flight:
  // { text: string | null }. The display keeps the current body visible
  // during loading and renders `text` after it when present. Committed
  // into narrative by foldDecision, discarded by showError.
  const [pendingDecision, setPendingDecision] = useState(null);
  // Whether choices have been revealed (user pressed > after body finished)
  const [choicesRevealed, setChoicesRevealed] = useState(false);

  // Menu navigation
  const [menuStack, setMenuStack] = useState([]);
  // Settings persist per-tab (same versioned-envelope pattern as
  // deviceRoster): restore on boot, save on change. Restored indexes are
  // validated against the live tree so a stale envelope can never select an
  // out-of-range value after options are reordered or removed.
  const [settingValues, setSettingValues] = useState(() => {
    const defaults = collectChoiceDefaults(OPTIONS_TREE);
    const rec = readSettingsEnvelope();
    if (rec && rec.values && typeof rec.values === "object") {
      const byId = {};
      const index = (n) => { if (n.kind === "choice") byId[n.id] = n; if (n.items) n.items.forEach(index); };
      index(OPTIONS_TREE);
      for (const [id, idx] of Object.entries(rec.values)) {
        const node = byId[id];
        if (node && Number.isInteger(idx) && idx >= 0 && idx < node.options.length) defaults[id] = idx;
      }
    }
    return defaults;
  });
  const [touchedSettings, setTouchedSettings] = useState(() => {
    const rec = readSettingsEnvelope();
    if (rec && Array.isArray(rec.touched)) {
      return new Set(rec.touched.filter((t) => typeof t === "string"));
    }
    return new Set();
  });
  useEffect(() => {
    try {
      sessionStorage.setItem(SETTINGS_KEY, JSON.stringify({
        version: SETTINGS_VERSION,
        values: settingValues,
        touched: [...touchedSettings],
      }));
    } catch (_) {}
  }, [settingValues, touchedSettings]);

  const page = pageIdx >= 0 && pageIdx < pages.length ? pages[pageIdx] : null;
  const pageHasChoices = !!page?.choices?.length;

  const tw = useTypewriter(
    page?.body || "",
    printSpeed,
    appState === "printing",
    () => {
      setAppState((s) => {
        if (s !== "printing") return s;
        // All pages pause at awaitingInput — user presses > to continue
        // (episodeComplete triggers coda, isCoda triggers end screen)
        return "awaitingInput";
      });
    }
  );

  // --- Imperative API (called by device.jsx after API responses) ---

  const pushPage = useCallback((newPage) => {
    console.log("[sm:pushPage]", newPage?.isCoda ? "CODA" : newPage?.isCards ? "CARDS" : "turn", "body:", (newPage?.body || "").slice(0, 60));
    setPages(prev => [...prev, newPage]);
    setPageIdx(prev => prev + 1);
    setAppState("printing");
    setChoiceIdx(0);
    setChoicesRevealed(false);
    setErrorMsg(null);
    setPendingDecision(null); // pending never survives past loading
  }, []);

  const startLoading = useCallback(() => {
    setAppState("loading");
    setErrorMsg(null);
  }, []);

  const showError = useCallback((msg) => {
    setErrorMsg(msg);
    setPendingDecision(null); // narrative untouched — retry stays clean
    setAppState(prev => {
      if (prev !== "loading") return prev;
      // If no page exists yet (failed /start), go back to domain input
      // Otherwise go back to awaitingInput so player can retry the choice
      return pageIdx < 0 ? "domainInput" : "awaitingInput";
    });
  }, [pageIdx]);

  // Queued inter-episode nav page — consumed by END_EPISODE in place of the
  // episodeComplete dead end. Queue it before the coda page is pushed.
  const nextPageRef = useRef(null);
  const queueNextPage = useCallback((p) => { nextPageRef.current = p; }, []);

  const resetToDomain = useCallback(() => {
    setPages([]);
    setPageIdx(-1);
    setAppState("domainInput");
    setEpisodeTitle("");
    setChoiceIdx(0);
    setErrorMsg(null);
    setNarrative("");
    setLastDecision(null);
    setPendingDecision(null);
    nextPageRef.current = null;
    window.__domainDraft = "";
    window.__freedomDraft = "";
  }, []);

  // Fold the current page's body (plus an optional decision line) into the
  // narrative history. Called by device.jsx AFTER a freedom or friend call
  // succeeds — folding on success means a failed call leaves the narrative
  // untouched and the player can retry without duplicated text. (Regular
  // choices still fold at confirm time inside CONFIRM_CHOICE.)
  const foldDecision = useCallback((decisionText) => {
    setNarrative(prev => {
      const withBody = prev + (prev ? "\n\n" : "") + (page?.body || "");
      return decisionText ? withBody + decisionText : withBody;
    });
    setLastDecision(decisionText || null);
    setPendingDecision(null); // committed — same batch, so no flicker frame
  }, [page]);

  // Arm the pending display before a fold-on-success API call. Pass the
  // decision line for freedom (rendered verbatim during the wait — must be
  // character-identical to what foldDecision later commits) or null for a
  // friend invocation (body stays visible, no decision line).
  const beginPendingDecision = useCallback((text) => {
    setPendingDecision({ text: text ?? null });
  }, []);

  // --- Options menu ---

  const enterOptions = () => {
    setMenuStack([{ node: OPTIONS_TREE, idx: 0 }]);
    setAppState("options");
  };
  const exitOptions = () => {
    setMenuStack([]);
    setAppState(tw.done ? "awaitingInput" : "printing");
  };

  // --- Button handler ---

  const handlePress = useCallback((id) => {
    let semantic = null;

    if (appState === "domainInput") {
      if (id === "go") semantic = "SUBMIT_DOMAIN";
      // > and OPT do nothing in domain input
    } else if (appState === "loading") {
      // All buttons inert during loading
    } else if (appState === "episodeComplete") {
      if (id === "go" || id === "next") semantic = "RESTART";
    } else if (appState === "options") {
      if (id === "next") semantic = "CYCLE_MENU";
      else if (id === "go") semantic = "DRILL_INTO";
      else if (id === "opt") semantic = "BACK_OUT";
    } else if (appState === "freedomInput") {
      if (id === "go") semantic = "SUBMIT_FREEDOM";
      else if (id === "opt") semantic = "CANCEL_FREEDOM";
      // > inert while typing
    } else if (id === "next") {
      // > during printing is handled by device.jsx (page advance); no semantic here.
      if (appState === "awaitingInput" && pageHasChoices && !choicesRevealed) semantic = "REVEAL_CHOICES";
      else if (appState === "awaitingInput" && pageHasChoices && choicesRevealed) semantic = "CYCLE_CHOICE";
      else if (appState === "awaitingInput" && !pageHasChoices && page?.episodeComplete) semantic = "TRIGGER_CODA";
      else if (appState === "awaitingInput" && !pageHasChoices && page?.isCoda) semantic = "END_EPISODE";
    } else if (id === "go") {
      if (appState === "awaitingInput" && pageHasChoices) semantic = "CONFIRM_CHOICE";
    } else if (id === "opt") {
      if (appState === "printing" || appState === "awaitingInput") semantic = "ENTER_OPTIONS";
    }

    emit({ type: "ACTION", action: semantic, raw: id, state: appState, at: Date.now() });
    if (!semantic) return;

    switch (semantic) {
      case "SUBMIT_DOMAIN":
        emit({ type: "DOMAIN_SUBMITTED", at: Date.now() });
        break;
      case "REVEAL_CHOICES":
        setChoicesRevealed(true);
        break;
      case "CYCLE_CHOICE":
        setChoiceIdx((i) => (i + 1) % (page.choices?.length || 1));
        break;
      case "CONFIRM_CHOICE": {
        const choice = page.choices[choiceIdx];
        if (choice.isFreedom) {
          // OFF SCRIPT: swap the card region to a text entry. Nothing folds
          // and nothing loads until the player actually sends words.
          emit({ type: "FREEDOM_OPENED", at: Date.now() });
          setAppState("freedomInput");
          break;
        }
        if (choice.isFriend) {
          // Friend invocation: device.jsx calls /invoke-ally and pushes the
          // entrance page. Narrative folds on success (foldDecision), so a
          // failed call can retry without duplicating text. Pending and
          // loading must both be set BEFORE the emit: the emit synchronously
          // runs the device handler, and its guard-path showError only
          // transitions out of "loading" — set loading after the emit and
          // the guard's recovery is overwritten, freezing the UI.
          setPendingDecision({ text: null });
          setAppState("loading");
          emit({ type: "FRIEND_INVOKED", friend: choice.friend, at: Date.now() });
          break;
        }
        if (choice.isSwapChoice) {
          // Friend swap: device.jsx settles the roster and pushes the
          // resolution page synchronously — no API call, no narrative fold,
          // no loading state.
          emit({ type: "SWAP_RESOLVED", choice, at: Date.now() });
          break;
        }
        if (page.isCards) {
          // Nav cards: device.jsx routes the pick (next episode or domain
          // input) — no narrative fold, no loading state here.
          emit({ type: "CHOICE_CONFIRMED", index: choiceIdx, choice, at: Date.now() });
          break;
        }
        const decisionText = "\n\n[ " + choice.title + " ]\n" + choice.body;
        // Fold current turn body + confirmed choice into narrative history
        setNarrative(prev => {
          const withBody = prev + (prev ? "\n\n" : "") + page.body;
          return withBody + decisionText;
        });
        setLastDecision(decisionText);
        emit({ type: "CHOICE_CONFIRMED", index: choiceIdx, choice, at: Date.now() });
        setAppState("loading");
        break;
      }
      case "TRIGGER_CODA": {
        // Fold final turn body into narrative before coda loads
        setNarrative(prev => prev + (prev ? "\n\n" : "") + page.body);
        setLastDecision(null);
        emit({ type: "CODA_TRIGGERED", at: Date.now() });
        setAppState("loading");
        break;
      }
      case "END_EPISODE":
        if (nextPageRef.current) {
          const queued = nextPageRef.current;
          nextPageRef.current = null;
          // Fold the coda body into narrative so it stays on screen above
          // whatever comes next (nav cards, swap page) — same fold as
          // CONFIRM_CHOICE and TRIGGER_CODA.
          setNarrative(prev => prev + (prev ? "\n\n" : "") + page.body);
          pushPage(queued);
        } else {
          setAppState("episodeComplete");
        }
        break;
      case "SUBMIT_FREEDOM":
        // Device reads window.__freedomInputValue and drives the API call
        // (mirrors the SUBMIT_DOMAIN → DOMAIN_SUBMITTED pattern). Empty text
        // is the device's no-op, so state stays here until words exist.
        emit({ type: "FREEDOM_SUBMITTED", at: Date.now() });
        break;
      case "CANCEL_FREEDOM":
        setAppState("awaitingInput");
        break;
      case "RESTART":
        resetToDomain();
        break;
      case "ENTER_OPTIONS":
        enterOptions();
        break;
      case "CYCLE_MENU": {
        setMenuStack((stack) => {
          if (!stack.length) return stack;
          const top = stack[stack.length - 1];
          if (top.node.kind === "text") {
            // Biography view: > pages the text instead of cycling rows.
            const el = window.__bioScrollEl;
            if (el) el.scrollBy({ top: el.clientHeight * 0.8, behavior: "smooth" });
            return stack;
          }
          const len = top.node.items?.length || 1;
          const nextIdx = (top.idx + 1) % len;
          return [...stack.slice(0, -1), { ...top, idx: nextIdx }];
        });
        break;
      }
      case "DRILL_INTO": {
        setMenuStack((stack) => {
          if (!stack.length) return stack;
          const top = stack[stack.length - 1];
          if (top.node.kind !== "submenu") return stack; // GO is inert in the biography view
          const child = top.node.items[top.idx];
          if (!child) return stack;
          if (child.kind === "choice") {
            // Flat menu: GO cycles the highlighted setting's value in place.
            const newIdx = ((settingValues[child.id] ?? child.defaultIdx ?? 0) + 1) % child.options.length;
            setSettingValues((v) => ({ ...v, [child.id]: newIdx }));
            setTouchedSettings((s) => {
              if (s.has(child.id)) return s;
              const next = new Set(s); next.add(child.id); return next;
            });
            emit({ type: "SETTING_CHANGED", id: child.id, value: child.options[newIdx], index: newIdx, at: Date.now() });
            return stack;
          }
          return [...stack, { node: child, idx: 0 }];
        });
        break;
      }
      case "BACK_OUT": {
        setMenuStack((stack) => {
          if (stack.length <= 1) {
            queueMicrotask(() => exitOptions());
            return [];
          }
          return stack.slice(0, -1);
        });
        break;
      }
    }
  }, [appState, pageHasChoices, choicesRevealed, choiceIdx, page, settingValues, emit, resetToDomain, pushPage]);

  return {
    appState,
    page,
    pageIdx,
    episodeTitle,
    narrative,
    lastDecision,
    pendingDecision,
    choicesRevealed,
    body: tw.out,
    bodyDone: tw.done,
    choiceIdx,
    handlePress,
    menuStack,
    settingValues,
    touchedSettings,
    errorMsg,
    // Page-aware typewriter
    pagePaused: tw.paused,
    pauseForPage: tw.pause,
    advancePage: tw.resume,
    // Imperative API
    pushPage,
    queueNextPage,
    startLoading,
    showError,
    resetToDomain,
    setEpisodeTitle,
    foldDecision,
    beginPendingDecision,
  };
}

window.useDeviceStateMachine = useDeviceStateMachine;
window.OPTIONS_TREE = OPTIONS_TREE;
