// PreviewModal — a full-viewport window onto the REAL pinned Player.
//
// Omar, 2026-08-13: *"the Preview button whould visualise the real Player with all the
// images, interaction, icons with the right size and text. There is no need for the course
// to have all the scorm elements, but visually should represent exactly what has been
// developed, from the language selection through to the thank you screen."*
//
// ── WHAT THIS REPLACED, and why it could never have satisfied that ────────────────────
// Until today this modal rendered `preview-renderers.jsx` — 2271 lines of React that
// re-implement the Player's appearance layout by layout. A re-implementation is wrong by
// construction here: every icon size, every font, every bit of spacing and every
// interaction is a SECOND opinion about what the runtime does, and the two drift with every
// runtime change nobody thinks to mirror (`feedback_a_test_double_that_reimplements_drifts`).
// It also could not show the language-selection screen or the thank-you screen at all,
// because those belong to the shell rather than to any layout.
//
// So the modal now asks the gateway to BUILD the course — through the very same
// `assembleScormPackage` call an export uses — and points an iframe at the result. What the
// author sees is the runtime that ships, driving their own content.
//
// `preview-renderers.jsx` is NOT deleted: the editor's small inline Module preview still
// uses it, where a fast in-process thumbnail is the right tool.
//
// ── THREE THINGS THIS DELIBERATELY DOES NOT HAVE ──────────────────────────────────────
//  1. **No language picker of its own.** The real Player has one, and showing it is half of
//     what Omar asked for. A second picker in our chrome would be a control competing with
//     the runtime's — and ours could not actually change the language the shell booted in.
//  2. **No prev/next arrows and no module pills.** Same reason: the Player navigates itself,
//     and its own progress rules (blocking sections, gates) are part of what a preview is
//     for. Arrows that skipped past a gate would misrepresent the course.
//  3. **No list of what is missing.** Asked directly whether a preview should refuse an
//     incomplete course, name its gaps, or just show it, Omar chose: *"Show it and do not
//     name the gaps."* Build remains the strict gate. An untranslated field renders blank
//     and an empty media slot renders empty — never a stand-in image, which would show him
//     content he never made.
//
// It DOES save first. A preview of the last-saved state would quietly answer a different
// question than the one the author asked, so the shared save door runs before the build.
//
// ── WHAT IT DOES HAVE, that an export never will (2026-08-13) ─────────────────────────
// A preview is built with the runtime's `qaMode` ON, which is what puts the Player's own
// "QA Menu" in the top-right corner: Finish all modules, One question CORRECT/WRONG, All
// questions CORRECT/WRONG/RANDOM. Omar asked for it because checking a course otherwise
// means clicking every question and every layout by hand. It is an EXISTING runtime feature
// (`js/scripts.js:1744`) that was simply hardcoded off — nothing here reimplements it, which
// is the same reasoning that replaced the React lookalike. Exported packages keep it off;
// `build-package.ts` says why that must never change.

/**
 * The four stages a Preview actually passes through, with the share of the bar each one
 * owns.
 *
 * ★ These are REAL milestones, not a timer dressed up as progress. Each `at` is reached
 * when the step before it has genuinely finished, so the bar cannot claim 80% while the
 * server has not answered. Omar asked for "a loading bar so that user know the progress";
 * a bar that invents a percentage tells him something false the first time a build is slow
 * (`feedback_honest_gates_over_standins`).
 *
 * The widths are unequal because the stages are: building dominates, so it gets the room.
 * While a stage is in flight the bar sits at its start and an indeterminate sheen moves
 * across the filled part — motion that says "working", without claiming a position.
 */
const PREVIEW_STAGES = [
  { phase: 'saving', at: 4, label: 'Saving your changes' },
  { phase: 'building', at: 22, label: 'Building the course' },
  { phase: 'booting', at: 78, label: 'Starting the course player' },
  { phase: 'ready', at: 100, label: 'Ready' },
];

function PreviewModal({ course, moduleId = null, moduleTitle = null, onClose }) {
  // What this window is showing, in the author's words. Derived from the SCOPE rather than
  // stored, so the title and the thing being built can never disagree.
  const scoped = moduleId !== null && moduleId !== undefined;
  const heading = scoped ? 'Module preview' : 'Course preview';
  const subject = scoped ? (moduleTitle || `Module ${moduleId}`) : course.title;

  // phase: 'saving' → 'building' → 'booting' → 'ready' | 'error'
  const [state, setState] = React.useState({ phase: 'saving' });
  // Seconds the current build has been running. A real measurement, shown instead of a
  // fabricated percentage during the one stage whose duration we cannot know in advance.
  const [elapsed, setElapsed] = React.useState(0);

  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        // ① Save, so the preview shows what is on screen rather than what was last sent.
        // The SAME door every Save button uses — not a second save path
        // (`feedback_one_rule_one_place`). A failure here is reported rather than
        // swallowed: previewing a stale course while saying nothing is the dishonest
        // outcome.
        //
        // ★ …EXCEPT for a caller who cannot save at all. Omar, 2026-08-18, the
        // first time a real Reviewer pressed Preview: *"The preview could not be
        // built — Could not save your latest changes… your role is reviewer"*.
        //
        // That is decision D failing at the last step. The gateway grants a
        // `reviewer` the `preview:build` capability precisely so Preview becomes
        // *"a place they can walk to"* — and then the screen refused before ever
        // calling it, because step ① is unconditional. A Reviewer has no unsaved
        // changes worth keeping: they cannot make any. The server's copy IS what
        // they should see, so the honest thing is to skip the save and build it.
        //
        // `dynamoReadOnly` and not a role name: a `language_reviewer` CAN save
        // (their own languages), so they still take the normal path and get a
        // legible refusal if the save itself is out of bounds.
        if (!window.dynamoReadOnly && window.dynamoSaveDraftToServer) {
          const saved = await window.dynamoSaveDraftToServer();
          if (cancelled) return;
          if (saved && saved.ok === false) {
            setState({ phase: 'error', message:
              'Could not save your latest changes, so the preview would not have shown them. '
              + (saved.message || 'Try saving from the toolbar first.') });
            return;
          }
        }

        // ② Build. Returns a short-lived token, not the package.
        setState({ phase: 'building' });
        const base = window.DYNAMO_ENV.gatewayBase;
        const token = await window.dynamoGetAccessToken();
        if (cancelled) return;
        // A course preview sends NO body at all — an empty JSON body is a 400 on some
        // routes, and the server reads an absent body as "the whole course". A module
        // preview sends the numeric module id, and only then is a content-type needed.
        const res = await fetch(`${base}/v1/courses/${course.id}/preview`, scoped
          ? {
              method: 'POST',
              headers: {
                Authorization: 'Bearer ' + token,
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({ moduleId }),
            }
          : { method: 'POST', headers: { Authorization: 'Bearer ' + token } });
        if (cancelled) return;
        if (!res.ok) {
          let detail = '';
          try { detail = (await res.json()).message || ''; } catch { /* non-JSON body */ }
          setState({ phase: 'error', message: detail
            || `The preview could not be built (${res.status}).` });
          return;
        }
        const data = await res.json();
        if (cancelled) return;
        // ③ The player itself now has to load and boot. That is a real stage with a real
        // end (the iframe's `load`), so it gets its own step rather than being hidden
        // behind a bar that already said "Ready".
        //
        // `data.url` is root-relative (`/preview/<token>/`) so it works under whatever
        // host the gateway is reached by.
        setState({ phase: 'booting', src: base + data.url, reused: data.reused === true });
      } catch (err) {
        if (!cancelled) {
          setState({ phase: 'error', message: String(err && err.message ? err.message : err) });
        }
      }
    })();
    return () => { cancelled = true; };
    // `moduleId` is in the deps: opening a module preview after a course preview must
    // rebuild, not reuse the frame that is already on screen.
  }, [course.id, moduleId]);

  // Tick only while a build is actually in flight. Stopping on 'booting' matters: a
  // counter that kept running would attribute the player's boot time to the build.
  React.useEffect(() => {
    if (state.phase !== 'building') return;
    const started = Date.now();
    setElapsed(0);
    const id = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 250);
    return () => clearInterval(id);
  }, [state.phase]);

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

  return (
    <div onClick={onClose} style={{
      position: 'fixed', inset: 0, zIndex: 90,
      background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(2px)',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }}>
      <div onClick={e => e.stopPropagation()} className="slide-in" style={{
        ['--pv-w']: '95vw', ['--pv-h']: '90vh',
        width: 'var(--pv-w)', height: 'var(--pv-h)',
        maxWidth: 1600, maxHeight: 900,
        background: 'var(--surface)', borderRadius: 'var(--radius-lg)',
        boxShadow: 'var(--shadow-xl)', overflow: 'hidden',
        display: 'flex', flexDirection: 'column',
      }}>
        {/* Top bar — title and close only. Everything else belongs to the Player. */}
        <div style={{ flexShrink: 0, height: 48, display: 'flex', alignItems: 'center',
          gap: 12, padding: '0 14px', borderBottom: '1px solid var(--border)',
          background: 'var(--surface)' }}>
          {/* The heading names the SCOPE, because the two previews look identical once
              the Player has booted — the same shell, the same chrome — and the only
              difference is how much course is inside. Without it an author who left a
              module preview open would reasonably conclude their course had lost seven
              modules (`feedback_honest_gates_over_standins`). */}
          <I.Eye size={15} style={{ color: scoped ? '#A53E53' : 'var(--text-muted)' }} />
          <span style={{ fontSize: 13, fontWeight: 600 }}>{heading}</span>
          <span style={{ fontSize: 13, color: 'var(--text-muted)' }} className="truncate">
            · {subject}
          </span>
          {/* ── A scope note used to sit here, removed 2026-08-13 on Omar's instruction ──
              It read "this module's layouts only — no assessment, roles or intro screens".
              That is the SECOND standing explanation removed from this window's header in one
              day (the first was "Nothing is saved or reported from a preview"), which is the
              signal worth keeping: he wants this window to show the course, not to caption
              itself. The heading already names the scope — "Module preview · <title>" — and
              that is the whole of what needs saying. The button that opens it still carries
              the longer explanation as a hover tooltip, where help is asked for rather than
              permanently occupying the frame. */}
          <div style={{ flex: 1 }} />
          {/* Removed 2026-08-13 at Omar's request: a standing "Nothing is saved or reported
              from a preview" line. It was true, but it sat permanently in the chrome of a
              window whose job is to show the course — and he is the only person who opens
              it, so it told him something he already knew, every time. */}
          <button className="btn sm ghost" onClick={onClose} title="Close preview (Esc)"
            style={{ width: 30, height: 30, padding: 0, justifyContent: 'center' }}>
            <I.X size={15} />
          </button>
        </div>

        {/* The Player itself */}
        <div style={{ flex: 1, minHeight: 0, position: 'relative', background: '#000' }}>
          {/* Mounted as soon as there is a URL — during 'booting', UNDER the overlay. The
              iframe has to be in the document to load at all, and its `load` event is what
              ends the last stage. Rendering it only at 'ready' would mean the bar reached
              100% while the course had not started painting, which is the exact dishonesty
              this rework is fixing. */}
          {state.src ? (
            <iframe
              title="Course preview"
              src={state.src}
              onLoad={() => setState(s => (s.phase === 'booting' ? { ...s, phase: 'ready' } : s))}
              /* `allow-same-origin` is required: the Player reads its own XML with
                 XMLHttpRequest, which a sandbox without it blocks — the course would boot
                 to a blank shell. `allow-scripts` is obviously required. Deliberately NOT
                 granting `allow-top-navigation` or `allow-popups`: nothing in a preview
                 should be able to move the authoring app out from under the author. */
              sandbox="allow-scripts allow-same-origin allow-forms allow-presentation"
              allow="fullscreen; autoplay"
              allowFullScreen
              style={{ position: 'absolute', inset: 0, width: '100%', height: '100%',
                border: 0, display: 'block' }} />
          ) : null}

          {state.phase === 'ready' ? null : (
            <div style={{ position: 'absolute', inset: 0, display: 'grid',
              placeItems: 'center', padding: 24, textAlign: 'center',
              background: '#000' }}>
              <div style={{ maxWidth: 460, width: '100%', color: '#e5e7eb' }}>
                {state.phase === 'error' ? (
                  <>
                    <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>
                      The preview could not be built
                    </div>
                    {/* The server's own sentence, verbatim. A generic "something went
                        wrong" would hide the one thing the author can act on. */}
                    <div style={{ fontSize: 12.5, color: '#9ca3af', lineHeight: 1.55 }}>
                      {state.message}
                    </div>
                  </>
                ) : (
                  <PreviewProgress phase={state.phase} elapsed={elapsed} />
                )}
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

/**
 * The Preview loading bar. Omar, 2026-08-13: *"When I click the preview add a loading bar so
 * that user know the progress."*
 *
 * ── What it claims, and what it refuses to claim ──────────────────────────────────────
 * The fill position comes from `PREVIEW_STAGES` — each step's percentage is reached only
 * once that step has genuinely completed, so the bar is a statement about work FINISHED. It
 * never interpolates toward the next step on a timer: a bar that crept to 90% and waited
 * there would be a guess presented as a measurement, and the first slow build would expose
 * it (`feedback_honest_gates_over_standins`, `feedback_a_setting_that_describes_state_is_not_a_preference`).
 *
 * Motion during a step comes from a sheen travelling across the FILLED portion, which reads
 * as "still working" without implying a position. The one extra real number is the elapsed
 * seconds of the build, shown once it exceeds a couple of seconds — a measurement, not an
 * estimate, and deliberately not a countdown, because nothing here knows how long a course
 * takes to package.
 *
 * Bar geometry (4px, `--accent` on `--surface`, 400ms ease) is copied from `IngestProgress`
 * in `surface-sources.jsx` rather than invented, so the app has one progress-bar look
 * (`feedback_one_door_per_field_reuse_existing_style`).
 */
function PreviewProgress({ phase, elapsed }) {
  const idx = Math.max(0, PREVIEW_STAGES.findIndex(s => s.phase === phase));
  const stage = PREVIEW_STAGES[idx];
  const pct = stage ? stage.at : 0;

  return (
    <div>
      <style>{`@keyframes pv-sheen {
        0% { transform: translateX(-60%); } 100% { transform: translateX(160%); }
      }`}</style>

      <I.Loader size={22} className="spin" style={{ color: 'var(--accent)' }} />
      <div style={{ marginTop: 10, fontSize: 14, fontWeight: 600 }}>
        {stage ? stage.label : 'Working'}…
      </div>

      {/* Which of the real steps we are on. Named rather than counted alone, so "2 of 3"
          never has to stand in for what is actually happening. */}
      <div style={{ fontSize: 12.5, color: '#9ca3af', marginTop: 3 }}>
        Step {idx + 1} of {PREVIEW_STAGES.length - 1}
        {phase === 'building' && elapsed >= 2 ? ` · ${elapsed}s` : ''}
      </div>

      <div style={{ height: 4, background: 'rgba(255,255,255,0.12)', borderRadius: 2,
        marginTop: 14, overflow: 'hidden' }}>
        <div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)',
          transition: 'width 400ms ease', position: 'relative', overflow: 'hidden' }}>
          <div style={{ position: 'absolute', inset: 0,
            background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.55), transparent)',
            width: '40%', animation: 'pv-sheen 1.4s ease-in-out infinite' }} />
        </div>
      </div>

      {/* Sets the expectation ONCE, on the step where it applies. The second Preview of
          unchanged content is served from the previous build and skips this entirely. */}
      {phase === 'building' ? (
        <div style={{ fontSize: 11.5, color: '#6b7280', marginTop: 12, lineHeight: 1.5 }}>
          This builds the real course player with your media, so the first one after an edit
          takes a few seconds.
        </div>
      ) : null}
    </div>
  );
}

/* ── Removed with the React lookalike, 2026-08-13 ──────────────────────────────────────
   `ModulePill`, `PreviewNavArrow` and `PreviewLanguagePicker` lived here to navigate and
   translate a preview this modal no longer renders itself. They are deleted rather than
   left in place: a component nobody mounts reads as available, and the next person to want
   a language picker would wire one up beside the Player's own
   (`feedback_no_false_affordance_toggles`).

   `preview-renderers.jsx` and `PreviewInteractiveContext` remain — the editor's inline
   Module preview still renders through them. */

Object.assign(window, { PreviewModal, PreviewProgress });
