// LiveModulePreview — the editor's Module preview, drawn by the REAL Player.
//
// Omar, 2026-09-29: *"every layout shown in the editor's right-column Module preview looks
// exactly as the same layout does in Course Preview. Course Preview is the reference because
// it matches the final course. Authors make decisions on what the Module preview shows; today
// it misleads them."*
//
// ── WHY THIS REPLACED `preview-renderers.jsx` HERE ─────────────────────────────────────
// Course Preview has run the real pinned Player since 2026-08-13 (`preview-modal.jsx`). This
// column kept React that re-drew every layout type from memory — its own font, its own
// corners, its own spacing and colour fallbacks — so the two disagreed about exactly the
// things Omar photographed: dot and number colours, a rounded text box that is square in the
// course, tab buttons smaller than their arrows, text wider than the tab strip. A second
// renderer cannot be made to agree with the first by copying CSS; it drifts again with every
// runtime change nobody thinks to mirror (`feedback_a_test_double_that_reimplements_drifts`).
//
// So there is now ONE renderer. The gateway builds this module through the same route and the
// same `assembleScormPackage` call as Course Preview (`live: true`), and this component shows
// it. Nothing in the editor decides what a layout looks like any more.
//
// ── WHAT STAYS THE SAME FOR THE AUTHOR ────────────────────────────────────────────────
//  · It follows what is being edited: the layout picked in the list below, and — for a
//    sequence — the step open in the editor (`dynamoModulePreview: 'show'`).
//  · Quizzes do not execute. The bridge inside the package stops clicks on answers and
//    submit buttons before the Player sees them (`module-preview-bridge.ts`).
//  · Unsaved edits show. The body posted is the one a Save would send
//    (`window.dynamoDraftSaveBody`), so nothing has to be saved first.
//
// ── WHAT IS DIFFERENT, stated rather than hidden ───────────────────────────────────────
//  · An edit appears about a second after typing pauses (a rebuild of this module on the
//    server), not on every keystroke. The picture is replaced in place — no reload, no flash.
//  · Opening another layout with "Edit" starts the Player again (the editor is remounted per
//    layout): about two seconds with the course's own media, measured on QA data.
//  · It needs the server. When a build cannot be made, the last good picture stays and a
//    line under the frame says why; before the first picture it says so in the frame, and a
//    failed first build is tried again.
//  · The frame is Course Preview's own screen for this window, scaled to the column
//    (`coursePreviewScreen`); a layout taller than that screen scrolls inside the frame,
//    exactly as it does for a learner.
//  · It is drawn in the course's default language, and without the Player's QA menu.
//  · The Player's own focus() does not take the keyboard from the editor (the bridge).

// How long typing must pause before a rebuild. Long enough that a word is one build, short
// enough that the picture keeps up with the author.
const LIVE_PREVIEW_DEBOUNCE_MS = 600;
// A FIRST build that fails (the gateway redeploying, the connection dropping as a layout
// opens) is tried again this often, this many times, rather than leaving a permanent error.
const LIVE_PREVIEW_RETRY_MS = 4000;
const LIVE_PREVIEW_RETRIES = 5;
// A preview token lives 30 minutes on the server and editing does not extend it
// (`replaceLivePreview`). A little before that, the next build starts a fresh one, so media
// the Player has not loaded yet cannot fail to load for an author who paused.
const LIVE_PREVIEW_TOKEN_MS = 25 * 60 * 1000;
// ★ ONE live package per course per tab. Every "Edit" remounts the editor (app.jsx keys it by
// layout), and each mount used to leave its own ~9 MB package in the server's shared store for
// 30 minutes. Handing the last token back lets the server replace that package in place.
const liveTokens = new Map();   // courseId → { token, born }

function LiveModulePreview({ courseId, moduleId, layoutIndex, editingThisLayout, contentKey, onNote }) {
  const frameRef = React.useRef(null);
  const [src, setSrc] = React.useState(null);
  // 'building' until the Player has drawn this frame; 'ready' once it has; 'error' when no
  // picture could be made at all. A failed REBUILD keeps 'ready' and sets `problem`.
  const [status, setStatus] = React.useState('building');
  const [problem, setProblem] = React.useState(null);
  const [updating, setUpdating] = React.useState(false);

  const base = (window.DYNAMO_ENV && window.DYNAMO_ENV.gatewayBase) || '';
  const gatewayOrigin = React.useMemo(() => {
    try { return new URL(base, window.location.href).origin; } catch (e) { return '*'; }
  }, [base]);

  // Everything the Player needs to be told lives in refs, so a message can be sent from an
  // event handler without waiting for a render.
  const tokenRef = React.useRef(null);
  const bornRef = React.useRef(0);
  const versionRef = React.useRef(0);
  const readyRef = React.useRef(false);
  const startedRef = React.useRef(false);   // this mount has framed a package
  const wantRef = React.useRef({ layoutIndex, step: null });
  const inFlightRef = React.useRef(false);
  const againRef = React.useRef(false);
  const failuresRef = React.useRef(0);
  const retryRef = React.useRef(null);
  // ★ Which course+module the answers belong to. Bumped when either changes and on unmount,
  // so an answer still in flight for the PREVIOUS module is never framed as this one — and the
  // queued rebuild runs the CURRENT `build`, not the one that was in flight.
  const generationRef = React.useRef(0);
  const buildRef = React.useRef(null);

  const scoWindow = () => {
    const f = frameRef.current;
    try { return f && f.contentWindow && f.contentWindow.frames[0]; } catch (e) { return null; }
  };
  const tell = React.useCallback((msg) => {
    const w = scoWindow();
    if (!w || !readyRef.current) return;
    w.postMessage(msg, gatewayOrigin);
  }, [gatewayOrigin]);
  // What a Player that has just become ready must hear: the newest version if the package was
  // replaced while it was booting (a build that lands before 'ready' must not be lost), and
  // otherwise just what to show.
  const tellCurrent = React.useCallback(() => {
    tell(versionRef.current > 0
      ? { dynamoModulePreview: 'reload', version: versionRef.current, ...wantRef.current }
      : { dynamoModulePreview: 'show', ...wantRef.current });
  }, [tell]);
  const humanize = (m) => (typeof window.dynamoHumanizeSchemaFailure === 'function'
    ? window.dynamoHumanizeSchemaFailure(m) : m);

  // ── The build: the first frames a package, later ones replace it in place ──────────
  const build = React.useCallback(async () => {
    if (inFlightRef.current) { againRef.current = true; return; }
    inFlightRef.current = true;
    const generation = generationRef.current;
    const stale = () => generation !== generationRef.current;
    let retryIn = 0;
    setUpdating(true);
    try {
      const body = window.dynamoDraftSaveBody ? window.dynamoDraftSaveBody() : null;
      if (!body || !body.content) throw new Error('the course content is not available yet');
      if (tokenRef.current && Date.now() - bornRef.current > LIVE_PREVIEW_TOKEN_MS) tokenRef.current = null;
      const token = await window.dynamoGetAccessToken();
      const res = await fetch(`${base}/v1/courses/${courseId}/preview`, {
        method: 'POST',
        headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          live: true, moduleId, content: body.content,
          ...(tokenRef.current ? { previewId: tokenRef.current } : {}),
        }),
      });
      if (stale()) return;
      // One live build at a time per person (the server's rule): try again shortly, quietly.
      if (res.status === 429) { retryIn = 1000; return; }
      if (!res.ok) {
        let detail = '';
        try { detail = (await res.json()).message || ''; } catch (e) { /* not JSON */ }
        throw new Error(detail ? humanize(detail) : `the preview could not be built (${res.status})`);
      }
      const data = await res.json();
      if (stale()) return;
      failuresRef.current = 0;
      setProblem(null);
      const sameToken = data.replaced === true && data.previewId === tokenRef.current;
      if (sameToken && startedRef.current) {
        versionRef.current += 1;
        tellCurrent();                       // if not ready yet, 'ready' sends it
      } else {
        if (!sameToken) bornRef.current = Date.now();
        tokenRef.current = data.previewId;
        liveTokens.set(courseId, { token: data.previewId, born: bornRef.current });
        versionRef.current = 0;
        readyRef.current = false;
        // ★ The Player runs with `allow-scripts allow-same-origin` (it reads its own XML), which
        // is only safe because it is served from the GATEWAY's origin, never the editor's: there
        // it cannot reach the editor's session. Refuse to frame it anywhere else.
        const frameUrl = base + data.url;
        let frameOrigin = null;
        try { frameOrigin = new URL(frameUrl, window.location.href).origin; } catch (e) { /* refused below */ }
        if (!frameOrigin || frameOrigin === window.location.origin) {
          // Deterministic: trying again would build again and be refused again.
          throw Object.assign(new Error("the course player must come from the gateway's own address, not the editor's"), { refused: true });
        }
        startedRef.current = true;
        setStatus('building');
        setSrc(frameUrl);
      }
    } catch (err) {
      if (stale()) return;
      setProblem((err && err.message) || String(err));
      if (!startedRef.current) {
        setStatus('error');
        if (!(err && err.refused) && failuresRef.current < LIVE_PREVIEW_RETRIES) { failuresRef.current += 1; retryIn = LIVE_PREVIEW_RETRY_MS; }
      }
    } finally {
      inFlightRef.current = false;
      setUpdating(false);
      // A retry belongs to THIS generation only. A build queued while this one was in flight
      // is always run — it may be the NEW module's first build, which arrived while an answer
      // for the old module was still on its way (and runs the current `build`, via the ref).
      if (retryIn && !stale()) retryRef.current = setTimeout(() => buildRef.current && buildRef.current(), retryIn);
      else if (againRef.current) { againRef.current = false; if (buildRef.current) buildRef.current(); }
    }
  }, [base, courseId, moduleId, tellCurrent]);
  buildRef.current = build;

  // A new course or module is a new Player — reusing this course's last token, so the server
  // replaces that package instead of storing another. The content changing is a rebuild in place.
  React.useEffect(() => {
    generationRef.current += 1;
    const cached = liveTokens.get(courseId);
    const young = cached && Date.now() - cached.born < LIVE_PREVIEW_TOKEN_MS;
    tokenRef.current = young ? cached.token : null;
    bornRef.current = young ? cached.born : 0;
    versionRef.current = 0;
    readyRef.current = false;
    startedRef.current = false;
    failuresRef.current = 0;
    againRef.current = false;
    setSrc(null);
    setStatus('building');
    setProblem(null);
    return () => {
      generationRef.current += 1;              // unmounted, or about to change module
      clearTimeout(retryRef.current);
    };
  }, [courseId, moduleId]);
  React.useEffect(() => {
    if (!courseId || moduleId === null || moduleId === undefined) return undefined;
    const t = setTimeout(() => buildRef.current && buildRef.current(),
      startedRef.current ? LIVE_PREVIEW_DEBOUNCE_MS : 0);
    return () => clearTimeout(t);
  }, [build, courseId, moduleId, contentKey]);

  // ── What to show: the layout picked, and the sequence step open in the editor ──────
  const agedOut = () => tokenRef.current && Date.now() - bornRef.current > LIVE_PREVIEW_TOKEN_MS;
  React.useEffect(() => {
    wantRef.current = { layoutIndex, step: null };
    if (agedOut() && buildRef.current) buildRef.current();
    tell({ dynamoModulePreview: 'show', ...wantRef.current });
  }, [layoutIndex, tell]);
  React.useEffect(() => {
    if (!editingThisLayout) return undefined;
    const onStep = (e) => {
      // A collapsed step list announces `null`; the React preview kept the last step then,
      // and so does this — only a real step moves the picture.
      const idx = e && e.detail && typeof e.detail.idx === 'number' ? e.detail.idx : null;
      if (idx === null) return;
      wantRef.current = { layoutIndex: wantRef.current.layoutIndex, step: idx };
      if (agedOut() && buildRef.current) buildRef.current();
      tell({ dynamoModulePreview: 'show', ...wantRef.current });
    };
    window.addEventListener('dynamo:seq-active', onStep);
    return () => window.removeEventListener('dynamo:seq-active', onStep);
  }, [editingThisLayout, tell]);

  // The line under the frame, never drawn OVER the Player — anything on top of the picture
  // would be a difference from Course Preview.
  React.useEffect(() => {
    if (typeof onNote !== 'function') return;
    onNote(status === 'ready' && problem
      ? (problem.startsWith('the course player reported') ? problem : `Showing the last version that could be built — ${problem}`)
      : status === 'ready' && updating ? 'Updating…' : null);
  }, [onNote, status, problem, updating]);

  // ── What the Player says back ────────────────────────────────────────────────────
  React.useEffect(() => {
    const onMessage = (ev) => {
      const msg = ev.data;
      if (!msg || typeof msg.dynamoModulePreview !== 'string') return;
      if (ev.source !== scoWindow()) return;
      if (gatewayOrigin !== '*' && ev.origin && ev.origin !== gatewayOrigin) return;
      if (msg.dynamoModulePreview === 'ready') {
        readyRef.current = true;
        tellCurrent();
      } else if (msg.dynamoModulePreview === 'shown') {
        setStatus('ready');
      } else if (msg.dynamoModulePreview === 'error') {
        // ★ Show what the Player DID draw. When it fails on a layout, Course Preview shows the
        // same partly-drawn module — hiding it behind "Loading…" would be the Module preview
        // disagreeing with Course Preview again, just in a different way. The reason goes in
        // the line under the frame. (Measured 2026-09-29: the Player throws on a quiz_images
        // question with discovery hotspots, in Course Preview and here alike.)
        setStatus('ready');
        setProblem(`the course player reported: ${msg.message || 'a problem'}`);
      }
    };
    window.addEventListener('message', onMessage);
    return () => window.removeEventListener('message', onMessage);
  }, [tellCurrent, gatewayOrigin]);

  return (
    <div style={{ position: 'absolute', inset: 0, background: '#000' }}
      data-testid="live-module-preview" data-status={status}>
      {src && (
        // The same frame permissions as Course Preview's (`preview-modal.jsx`): a link cannot
        // open a pop-up from the editor, and autoplay and fullscreen behave the same.
        <iframe ref={frameRef} src={window.safeFrameSrc ? window.safeFrameSrc(src) : 'about:blank'}
          title="Module preview — the course player"
          data-testid="live-module-preview-frame" data-cross-origin-player=""
          sandbox="allow-scripts allow-same-origin allow-forms allow-presentation"
          allow="fullscreen; autoplay"
          style={{ border: 0, width: '100%', height: '100%', display: 'block',
            visibility: status === 'ready' ? 'visible' : 'hidden' }} />
      )}
      {status !== 'ready' && (
        <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
          justifyContent: 'center', color: 'rgba(255,255,255,.75)', fontSize: 30,
          textAlign: 'center', padding: 80, fontFamily: 'var(--font-sans)' }}>
          {status === 'error'
            ? `The preview could not be shown: ${problem || 'no answer from the server'}`
            : 'Loading the course player…'}
        </div>
      )}
    </div>
  );
}

Object.assign(window, { LiveModulePreview, LIVE_PREVIEW_DEBOUNCE_MS, __liveModulePreviewTokens: liveTokens });
