/**
 * The lesson player.
 *
 * A lesson is a sequence of steps that advance one at a time, so the session
 * reads as a taught class rather than a page of documentation. Steps that carry
 * an obligation — answer a checkpoint, commit to a slice in a find exercise,
 * pass an assessment — hold the "continue" control closed until it is met.
 */

(function () {
'use strict';

const { useState, useEffect, useRef, useCallback, useMemo } = React;

/** Id for one play-through of a lesson, stored in its history entries. */
const newRun = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);

/** The instructor's voice. */
function Narration({ text }) {
  return (
    <div className="narration animate-in">
      <div className="narration__avatar" aria-hidden="true">◉</div>
      <div>
        <div className="narration__speaker">מנחה השיעור</div>
        <p className="narration__text">{text}</p>
      </div>
    </div>
  );
}

function Prose({ html }) {
  // Content is authored in this repository, not user input. It is the only
  // place raw HTML is injected, and it must stay that way.
  return (
    <div className="card animate-in">
      <div className="prose" dangerouslySetInnerHTML={{ __html: html }} />
    </div>
  );
}

function Callout({ variant, title, text }) {
  const cls = { key: 'callout--key', warn: 'callout--warn', note: 'callout--note', caution: 'callout--caution' };
  return (
    <div className={`callout ${cls[variant] || ''} animate-in`}>
      {title && <strong className="callout__title">{title}</strong>}
      {text}
    </div>
  );
}

function prefersReducedMotion() {
  return Boolean(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}

/**
 * A looping ultrasound clip. GIFs have no controls of their own, so pausing
 * swaps in a still of the first frame. Clips start paused for a learner who
 * asked the system to reduce motion.
 */
function ClipPlayer({ item }) {
  const [isPlaying, setIsPlaying] = useState(() => !prefersReducedMotion());
  return (
    <figure className="clip">
      <img
        className="clip__image"
        src={isPlaying ? item.file : item.poster}
        width={item.width}
        height={item.height}
        alt={`קליפ אולטרסאונד: ${item.titleHe}`}
        loading="lazy"
      />
      <button
        type="button"
        className="btn btn--secondary btn--sm clip__toggle"
        aria-pressed={!isPlaying}
        onClick={() => setIsPlaying((playing) => !playing)}
      >
        {isPlaying ? 'עצור' : 'הפעל'}
      </button>
    </figure>
  );
}

/**
 * External teaching media from MediaCatalog. A clip with a local file plays in
 * the lesson, unchanged, beside its credit, licence and a visible link to its
 * source page. An article opens on its original site. Items the content policy
 * hides were already removed when the curriculum loaded.
 */
const MEDIA_KIND_LABEL = { clip: 'קליפ', image: 'צילום', article: 'מאמר', resource: 'משאב חיצוני' };

/**
 * A radiograph copied unchanged from its source. Film stays black in both
 * themes, as on a reading station. The caption is the source's own description
 * of the image, translated: the lesson never says more about a picture than
 * the people who published it did.
 */
function RadiographFigure({ item }) {
  return (
    <figure className="radiograph">
      <img
        className="radiograph__image"
        src={item.file}
        width={item.width}
        height={item.height}
        alt={`צילום רנטגן: ${item.titleHe}`}
        loading="lazy"
      />
      {item.caption && <figcaption className="radiograph__caption">{item.caption}</figcaption>}
    </figure>
  );
}

/**
 * The attribution line Radiopaedia's licence prescribes for an image used
 * online: contributor, Radiopaedia.org and the rID, the last two as links.
 */
function RadiopaediaAttribution({ item }) {
  return (
    <div className="media-item__attribution" dir="ltr">
      Case courtesy of {item.credit},{' '}
      <a href="https://radiopaedia.org/" target="_blank" rel="noopener noreferrer">Radiopaedia.org</a>, rID:{' '}
      <a href={item.url} target="_blank" rel="noopener noreferrer">{item.rid}</a>
    </div>
  );
}

/**
 * The citation line for an open-access article: for a copied figure, the
 * figure number and the authors' citation; for a linked article (no figure),
 * the citation alone. Either way a DOI link follows.
 */
function ArticleFigureCitation({ item }) {
  return (
    <div className="media-item__attribution" dir="ltr">
      {item.figure ? `${item.figure} from ${item.citation}` : item.citation}.{' '}
      <a href={`https://doi.org/${item.doi}`} target="_blank" rel="noopener noreferrer">doi:{item.doi}</a>
    </div>
  );
}

function MediaStep({ step }) {
  const items = window.MediaCatalog.visibleIds(step.items).map((id) => window.MediaCatalog.get(id));
  return (
    <div className="card animate-in">
      {step.title && <h3 className="media__heading">{step.title}</h3>}
      {step.note && <p className="media__note">{step.note}</p>}
      <ul className="media-list">
        {items.map((item) => {
          const source = window.MediaCatalog.sourceOf(item);
          const license = window.MediaCatalog.licenseOf(item);
          return (
            <li key={item.id} className="media-item">
              <div className="media-item__head">
                <span className="media-item__kind">{MEDIA_KIND_LABEL[item.kind] || 'מאמר'}</span>
                <strong className="media-item__title">{item.titleHe}</strong>
              </div>
              {item.file && item.kind === 'clip' && <ClipPlayer item={item} />}
              {item.file && item.kind === 'image' && <RadiographFigure item={item} />}
              <div className="media-item__orig" dir="ltr">“{item.title}”</div>
              <div className="media-item__meta">
                <span>מקור: {source.name}</span>
                {item.credit && (
                  <span dir="ltr" className="media-item__credit">{item.credit}</span>
                )}
                {license.url ? (
                  <a href={license.url} target="_blank" rel="noopener noreferrer" className="media-item__license">
                    {license.name}
                  </a>
                ) : (
                  <span className="media-item__license">{license.name}</span>
                )}
              </div>
              {item.source === 'radiopaedia' && item.kind === 'image' && <RadiopaediaAttribution item={item} />}
              {item.source === 'pmc' && item.citation && <ArticleFigureCitation item={item} />}
              {item.file ? (
                <>
                  <div className="media-item__hint">
                    {item.kind === 'clip'
                      ? 'הקליפ מוצג ללא שינוי. בעצירה מוצג הפריים הראשון שלו.'
                      : 'התמונה מוצגת ללא שינוי, כפי שפורסמה במקור.'}
                  </div>
                  <div className="media-item__source">
                    קישור למקור:{' '}
                    <a href={item.url} target="_blank" rel="noopener noreferrer" dir="ltr">
                      {item.url.replace(/^https:\/\/www\./, '')}
                    </a>
                  </div>
                </>
              ) : (
                <a className="btn btn--secondary btn--sm media-item__open" href={item.url} target="_blank" rel="noopener noreferrer">
                  פתח במקור ↗
                </a>
              )}
            </li>
          );
        })}
      </ul>
    </div>
  );
}

function HuTable() {
  return (
    <div className="card animate-in">
      <table className="data-table">
        <thead>
          <tr>
            <th>רקמה</th>
            <th>טווח HU</th>
          </tr>
        </thead>
        <tbody>
          {window.Windowing.HU_REFERENCE.map((row) => (
            <tr key={row.label}>
              <td>{row.label}</td>
              <td dir="ltr">
                {row.min} … {row.max}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function SpecTable({ rows }) {
  return (
    <div className="card animate-in">
      <table className="data-table">
        <tbody>
          {rows.map((row, i) => (
            <tr key={i}>
              {row.map((cell, j) => (
                <td key={j} style={j === 0 ? { fontWeight: 600, color: 'var(--text-strong)' } : null}>
                  {cell}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function TaskList({ tasks }) {
  if (!tasks || !tasks.length) return null;
  return (
    <ol className="prose" style={{ paddingInlineStart: 'var(--space-5)', fontSize: 'var(--text-sm)' }}>
      {tasks.map((task, i) => (
        <li key={i} dangerouslySetInnerHTML={{ __html: task }} />
      ))}
    </ol>
  );
}

function CohortBadge({ entry }) {
  if (entry.referenceOnly) return <span className="chip chip--neutral">מקרה ייחוס — תקינות טרם אומתה</span>;
  const isNormal = entry.cohort === 'normal';
  return (
    <span className={`chip ${isNormal ? 'chip--normal' : 'chip--disease'}`}>
      {isNormal ? (entry.normalEvidence === 'source-healthy-volunteer' ? 'מתנדב בריא לפי מקור המחקר' : 'קבוצת ביקורת') : 'קבוצת מחלה לפי מקור הנתונים'}
    </span>
  );
}

/**
 * Whether the collection annotated the series exhaustively. On a partially
 * annotated series an unmarked finding is not evidence of absence, which
 * changes what a find exercise can prove — so it is stated, not buried.
 */
function CoverageBadge({ entry }) {
  if (!entry.annotationCoverage) return null;
  const partial = entry.annotationCoverage === 'partial';
  return (
    <span
      className={`chip ${partial ? 'chip--accent' : 'chip--expert'}`}
      title={
        partial
          ? 'סומן חלקית: היעדר סימון אינו ראיה להיעדר ממצא'
          : 'סומן במלואו: כל הממצאים שזוהו סומנו'
      }
    >
      {partial ? 'סימון חלקי' : 'סימון מלא'}
    </span>
  );
}

/** A plain review step: look at the scan, work the checklist. */
function ScanStep({ step, onSatisfied }) {
  const entry = window.Catalog.getSeries(step.seriesId);
  const markedRef = useRef(false);

  const handleReady = useCallback(() => {
    if (markedRef.current) return;
    markedRef.current = true;
    window.Progress.markScanReviewed(step.seriesId);
    onSatisfied();
  }, [step.seriesId, onSatisfied]);

  return (
    <div className="animate-in" style={{ display: 'grid', gap: 'var(--space-4)' }}>
      <div className="card">
        <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap', marginBottom: 'var(--space-3)' }}>
          <strong style={{ color: 'var(--text-strong)' }}>{step.instruction}</strong>
          <CohortBadge entry={entry} />
          {entry.seg && <span className="chip chip--expert">סימון מומחה</span>}
          <CoverageBadge entry={entry} />
          <span className="chip chip--neutral">{entry.modality}</span>
          <span className="chip chip--neutral">{entry.collection}</span>
        </div>
        <p style={{ fontSize: 'var(--text-sm)', color: 'var(--text-muted)' }}>{entry.cohortNote}</p>
        <TaskList tasks={step.tasks} />
        <a className="btn btn--secondary btn--sm" href={`#compare?left=${encodeURIComponent(entry.id)}`}>השווה לסריקה נוספת</a>
      </div>

      <window.DicomViewer entry={entry} initialPreset={step.preset} onReady={handleReady} revealed />
    </div>
  );
}

/**
 * Find-the-finding.
 *
 * Scored against the expert segmentation rather than an authored answer: the
 * learner commits to the slice where they believe the finding is clearest, and
 * that is compared to the slice carrying the largest cross-section of the
 * annotated segment. Nothing here is asserted by the course itself.
 */
function FindStep({ step, saved, onAnswer, onSatisfied }) {
  const entry = window.Catalog.getSeries(step.seriesId);
  const [study, setStudy] = useState(null);
  const [index, setIndex] = useState(0);
  // A locked answer is saved with the lesson, so a learner who leaves after
  // locking comes back to the revealed result, not to an unanswered exercise
  // whose Continue button is already open and whose score could be overwritten.
  const [result, setResult] = useState(() =>
    saved && typeof saved.committedIndex === 'number' ? saved : null
  );

  const stepKey = `${step.seriesId}:${(step.findingLabels || []).join('+')}`;

  const handleReady = useCallback((loaded) => setStudy(loaded), []);
  const handleIndex = useCallback((i) => setIndex(i), []);

  const segmentation = study && study.segmentation && !study.segmentation.error
    ? study.segmentation
    : null;

  const findingSegment = segmentation
    ? segmentation.segments.find((s) =>
        (step.findingLabels || []).some(
          (label) => label.toLowerCase() === (s.originalLabel || '').toLowerCase()
        )
      )
    : null;

  function commit() {
    if (!findingSegment) return;
    const locked = window.Assessment.gradeSlice(segmentation, findingSegment.number, index, entry.annotationCoverage);
    setResult(locked);
    if (locked.status !== 'inconclusive') window.Progress.recordFind(stepKey, locked);
    if (onAnswer) onAnswer(locked);
    onSatisfied();
  }

  const unavailable = study && !segmentation;

  return (
    <div className="animate-in" style={{ display: 'grid', gap: 'var(--space-4)' }}>
      <div className="card">
        <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap', marginBottom: 'var(--space-3)' }}>
          <strong style={{ color: 'var(--text-strong)' }}>תרגיל איתור</strong>
          <CohortBadge entry={entry} />
          <span className="chip chip--expert">נבדק מול סימון מומחה</span>
          <CoverageBadge entry={entry} />
          <span className="chip chip--neutral">{entry.patientId}</span>
        </div>
        <p className="prose" style={{ fontSize: 'var(--text-sm)' }}>{step.prompt}</p>
        <TaskList tasks={step.tasks} />

        {unavailable && (
          <div className="callout callout--warn">
            <strong className="callout__title">לא ניתן לנקד את התרגיל</strong>
            סימון המומחה לא נטען, ולכן אי אפשר להשוות את תשובתך אליו. אפשר עדיין לסקור
            את הסדרה — אך המערכת לא תאשר או תפריך את האיתור.
          </div>
        )}

        {!result && !unavailable && (
          <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', marginTop: 'var(--space-4)' }}>
            <button
              type="button"
              className="btn btn--primary"
              onClick={commit}
              disabled={!findingSegment}
            >
              נעל תשובה על חתך {index + 1}
            </button>
            <span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-muted)' }}>
              {findingSegment
                ? 'הסימון ייחשף מיד לאחר הנעילה.'
                : 'אפשר כבר לסרוק את הסדרה — הנעילה תיפתח כשכל החתכים והסימון ייטענו.'}
            </span>
          </div>
        )}

        {result && (
          <div className="quiz__explain">
            <strong style={{ color: result.correct ? 'var(--normal)' : 'var(--disease)' }}>
              {result.status === 'inconclusive' ? 'לא ניתן להכריע מהסימון הזמין' : result.correct ? '✓ בחרת חתך עם המבנה המסומן' : 'בחתך שנבחר אין סימון של המבנה המבוקש'}
            </strong>
            <p style={{ marginTop: 'var(--space-2)' }}>
              נעלת על חתך {result.committedIndex + 1}. שטח הסימון המרבי נמצא בחתך{' '}
              {result.peak + 1}. הניקוד מבוסס על נוכחות הסימון בחתך שנבחר, ולא על קרבה לחתך השיא.
            </p>
            <p>זהו תרגיל בחירת חתך: הוא אינו בודק שסימנת את המיקום בתוך התמונה ואינו מאשר אבחנה. כשאין סימון מלא, היעדר סימון אינו שולל ממצא.</p>
            <p style={{ marginTop: 'var(--space-2)' }}>{step.explanation}</p>
          </div>
        )}
      </div>

      <window.DicomViewer
        entry={entry}
        initialPreset={step.preset}
        onReady={handleReady}
        onIndexChange={handleIndex}
        revealed={Boolean(result)}
      />
    </div>
  );
}

/**
 * Display order of a question's options, as original indices.
 *
 * The course is authored with the correct answer first in every question, so
 * shown as written, "always pick א" scored 100%. The order is shuffled here,
 * deterministically from the question text: the same on every device and every
 * visit, so a half-answered quiz resumes looking the same. Answers are still
 * stored as ORIGINAL option indices — the shuffle is display only, and nothing
 * saved before this change is reinterpreted.
 */
function optionOrder(q) {
  const order = q.options.map((_, i) => i);
  let seed = 2166136261;
  const text = String(q.question || '') + '|' + q.options.join('|');
  for (let i = 0; i < text.length; i++) {
    seed ^= text.charCodeAt(i);
    seed = Math.imul(seed, 16777619);
  }
  const rand = () => {
    // mulberry32
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
  for (let i = order.length - 1; i > 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    [order[i], order[j]] = [order[j], order[i]];
  }
  return order;
}

const optionLetter = (displayIndex) => String.fromCharCode(1488 + displayIndex);

/** Single inline question. */
function Checkpoint({ step, saved, onAnswer, onSatisfied }) {
  const [picked, setPicked] = useState(typeof saved === 'number' ? saved : null);

  function choose(i) {
    if (picked !== null) return;
    setPicked(i);
    onAnswer(i);
    onSatisfied();
  }

  const order = useMemo(() => optionOrder(step), [step]);

  return (
    <div className="card animate-in">
      <div className="quiz__stem">{step.question}</div>
      <div className="quiz__options">
        {order.map((i, pos) => {
          const option = step.options[i];
          let cls = 'quiz__option';
          if (picked !== null) {
            if (i === step.answer) cls += ' quiz__option--correct';
            else if (i === picked) cls += ' quiz__option--wrong';
          }
          return (
            <button key={i} type="button" className={cls} onClick={() => choose(i)} disabled={picked !== null}>
              <span className="quiz__marker">{optionLetter(pos)}</span>
              <span>{option}</span>
            </button>
          );
        })}
      </div>
      {picked !== null && (
        <div className="quiz__explain">
          <strong>הסבר:</strong> {step.explanation}
        </div>
      )}
    </div>
  );
}

/**
 * A picture shown with a question's explanation. Served from the source's own
 * file server (Wikimedia Commons allows this), with author and licence.
 */
function QuestionImage({ id }) {
  const item = window.MediaCatalog.get(id);
  const license = window.MediaCatalog.licenseOf(item);
  const source = window.MediaCatalog.sourceOf(item);
  return (
    <figure className="quiz__figure">
      <img src={item.imageUrl} alt={item.titleHe} loading="lazy" referrerPolicy="no-referrer" />
      <figcaption>
        <div>{item.titleHe}</div>
        <div className="quiz__figure-credit">
          <span dir="ltr">{item.credit}</span>
          {' · '}
          {license.url ? (
            <a href={license.url} target="_blank" rel="noopener noreferrer">{license.name}</a>
          ) : (
            license.name
          )}
          {' · '}
          <a href={item.url} target="_blank" rel="noopener noreferrer">{source.name}</a>
        </div>
      </figcaption>
    </figure>
  );
}

/** Multi-question assessment with a pass mark. */
function Quiz({ step, lessonId, saved, onAnswer, onSatisfied }) {
  // Answers are saved as they are picked, so a learner who answers half the
  // questions and leaves comes back to the same half — on any device.
  const [answers, setAnswers] = useState(() => (saved && saved.answers) || {});
  const [submitted, setSubmitted] = useState(() => Boolean(saved && saved.submitted));

  // reveal: 'each' (practice exams) shows right/wrong, the explanation and the
  // question's image as soon as a question is answered, and locks it. The score
  // still comes at the end, from the same answers.
  const each = step.reveal === 'each';
  const locked = (qi) => submitted || (each && answers[qi] !== undefined);

  function pick(qi, oi) {
    if (locked(qi)) return;
    setAnswers((a) => {
      const next = { ...a, [qi]: oi };
      onAnswer({ answers: next, submitted: false });
      return next;
    });
  }

  const total = step.questions.length;
  const answeredAll = Object.keys(answers).length === total;
  const correct = step.questions.filter((q, i) => answers[i] === q.answer).length;
  const passMark = step.passMark ?? 0.75;
  const passed = correct / total >= passMark;
  const orders = useMemo(() => step.questions.map(optionOrder), [step]);

  function submit() {
    setSubmitted(true);
    window.Progress.recordQuiz(lessonId, { correct, total, passed });
    onAnswer({ answers, submitted: true });
    onSatisfied();
  }

  function retry() {
    setAnswers({});
    setSubmitted(false);
    onAnswer({ answers: {}, submitted: false });
  }

  return (
    <div className="animate-in" style={{ display: 'grid', gap: 'var(--space-4)' }}>
      {step.questions.map((q, qi) => (
        <div className="card" key={qi}>
          <div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-faint)', marginBottom: 'var(--space-2)' }}>
            שאלה {qi + 1} מתוך {total}
          </div>
          <div className="quiz__stem">{q.question}</div>
          <div className="quiz__options">
            {orders[qi].map((oi, pos) => {
              const option = q.options[oi];
              let cls = 'quiz__option';
              if (locked(qi)) {
                if (oi === q.answer) cls += ' quiz__option--correct';
                else if (answers[qi] === oi) cls += ' quiz__option--wrong';
              } else if (answers[qi] === oi) {
                cls += ' quiz__option--selected';
              }
              return (
                <button
                  key={oi}
                  type="button"
                  className={cls}
                  aria-pressed={answers[qi] === oi}
                  onClick={() => pick(qi, oi)}
                  disabled={locked(qi)}
                  style={!locked(qi) && answers[qi] === oi ? { borderColor: 'var(--accent)', background: 'var(--accent-wash)' } : null}
                >
                  <span className="quiz__marker">{optionLetter(pos)}</span>
                  <span>{option}</span>
                </button>
              );
            })}
          </div>
          {locked(qi) && (
            <div className="quiz__explain">
              {each && (
                <strong className={answers[qi] === q.answer ? 'quiz__verdict quiz__verdict--ok' : 'quiz__verdict quiz__verdict--bad'}>
                  {answers[qi] === q.answer ? 'נכון. ' : 'לא נכון. '}
                </strong>
              )}
              <strong>הסבר:</strong> {q.explanation}
            </div>
          )}
          {locked(qi) && q.image && window.MediaCatalog.isVisible(q.image) && (
            <QuestionImage id={q.image} />
          )}
        </div>
      ))}

      {!submitted ? (
        <button type="button" className="btn btn--primary" onClick={submit} disabled={!answeredAll}>
          {answeredAll ? 'הגש מבחן' : `ענה על כל השאלות (${Object.keys(answers).length}/${total})`}
        </button>
      ) : (
        <div className="card" style={{ borderColor: passed ? 'var(--normal)' : 'var(--disease)' }}>
          <div style={{ fontSize: 'var(--text-lg)', fontWeight: 800, color: passed ? 'var(--normal)' : 'var(--disease)' }}>
            {correct}/{total} — {passed ? 'עברת' : 'לא עברת'}
          </div>
          <p style={{ fontSize: 'var(--text-sm)', marginTop: 'var(--space-2)' }}>
            ציון עובר למודול זה הוא {Math.round(passMark * 100)}%.
            {!passed && ' קרא את ההסברים וחזור על המבחן.'}
          </p>
          {!passed && (
            <button type="button" className="btn btn--secondary" onClick={retry} style={{ marginTop: 'var(--space-3)' }}>
              נסה שוב
            </button>
          )}
        </div>
      )}
    </div>
  );
}

const BLOCKING_STEPS = new Set(['checkpoint', 'quiz', 'find']);
const SCAN_STEPS = new Set(['scan', 'find']);
const COMPARE_STEPS = new Set(['compare']);

/**
 * Warms the next scan in the lesson while the learner is on the current step.
 *
 * Lessons deliberately put narration and prose between scans, and that reading
 * time is otherwise dead air on a connection that delivers ~0.5 s per slice.
 * Prefetching into cornerstone's cache during it means the next exercise is
 * often already there when the learner reaches it.
 */
function usePrefetchNextScan(steps, cursor) {
  const startedRef = useRef(new Set());

  useEffect(() => {
    const upcoming = steps
      .slice(cursor + 1)
      .find((s) => SCAN_STEPS.has(s.type) || COMPARE_STEPS.has(s.type));

    if (!upcoming) return;

    // A comparison step needs both of its series, and they load into the same
    // bandwidth — warming both is what makes that step tolerable at all.
    const ids = COMPARE_STEPS.has(upcoming.type)
      ? [upcoming.leftId, upcoming.rightId]
      : [upcoming.seriesId];

    ids.filter(Boolean).forEach((id) => {
      if (startedRef.current.has(id)) return;
      startedRef.current.add(id);

      const entry = window.Catalog.getSeries(id);
      if (entry.volume) return;
      window.StudyLoader.initCornerstone();
      window.TCIA.prefetchSeries(entry.seriesInstanceUID, {
        maxSlices: entry.seg ? Math.max(entry.sliceCount, 200) : entry.maxSlices || 70
      });
    });
  }, [steps, cursor]);
}

/**
 * A short signature of the lesson's step list. Resume state is stored by step
 * index, so if a step is added, removed or reordered after a learner stopped
 * mid-lesson, their saved cursor, answers and "done" marks would land on the
 * wrong steps — a satisfied checkpoint could unlock a new quiz. A saved state
 * whose signature differs from the lesson as it is now is discarded.
 */
function lessonFingerprint(lesson) {
  // Everything a saved answer or done-mark depends on: the step list, each
  // question's options and key (answers are stored as option indices), and a
  // find exercise's target and tolerance (its locked result was scored
  // against them). Prose and explanations are left out on purpose — fixing a
  // typo should not send every learner back to step 1.
  const question = (q) => [q.question || '', (q.options || []).join('¦'), q.answer ?? ''].join('^');
  const text = lesson.steps
    .map((s) => [
      s.type,
      s.seriesId || '',
      s.leftId || '',
      s.rightId || '',
      s.type === 'checkpoint' ? question(s) : '',
      s.questions ? s.questions.map(question).join('|') : '',
      s.type === 'find' ? `${(s.findingLabels || []).join('+')}@annotation-presence-v2` : ''
    ].join('~'))
    .join('#');
  let h = 2166136261; // FNV-1a, enough to tell versions apart
  for (let i = 0; i < text.length; i++) {
    h ^= text.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return (h >>> 0).toString(36);
}

/**
 * Review mode (admin only): the answer key and the data behind each step, so
 * content can be checked without playing the lesson as a learner.
 */
function ReviewPanel({ step, index }) {
  // The letter the learner sees, after the display shuffle (see optionOrder).
  const shownLetter = (q) => optionLetter(optionOrder(q).indexOf(q.answer));
  const rows = [];
  if (step.seriesId) rows.push(['סדרה', step.seriesId]);
  if (step.leftId) rows.push(['השוואה', `${step.leftId} ↔ ${step.rightId}`]);
  if (step.type === 'find') {
    rows.push(['תוויות ממצא', (step.findingLabels || []).join(', ') || '— חסר —']);
    rows.push(['סף מרחק', `${step.tolerance ?? 5} חתכים`]);
  }
  if (step.type === 'checkpoint') {
    rows.push(['תשובה נכונה', `${shownLetter(step)} — ${step.options[step.answer] ?? '⚠ אינדקס לא קיים'}`]);
  }

  return (
    <div className="review-panel" role="note">
      <div className="review-panel__head">
        <span>מצב סקירה · שלב {index + 1}</span>
        <code dir="ltr">{step.type}</code>
      </div>
      {rows.length > 0 && (
        <dl className="review-panel__rows">
          {rows.map(([k, v]) => (
            <React.Fragment key={k}>
              <dt>{k}</dt>
              <dd dir="auto">{v}</dd>
            </React.Fragment>
          ))}
        </dl>
      )}
      {step.type === 'quiz' && (
        <ol className="review-panel__key">
          {step.questions.map((q, qi) => (
            <li key={qi}>
              <strong>{shownLetter(q)}</strong> — {q.options[q.answer] ?? '⚠ אינדקס לא קיים'}
              {q.explanation
                ? <div className="review-panel__note">הסבר: {q.explanation}</div>
                : <div className="review-panel__note">⚠ אין הסבר לשאלה</div>}
            </li>
          ))}
        </ol>
      )}
      {/* Shown for every step that has one, checkpoints included: in review mode
          the admin should read the rationale without answering first. */}
      {step.explanation && (
        <p className="review-panel__note">הסבר: {step.explanation}</p>
      )}
    </div>
  );
}

function LessonPlayer({ region, lesson, onExit, onComplete, onNavigateLesson, review = false }) {
  const version = useMemo(() => lessonFingerprint(lesson), [lesson]);

  // The player is keyed by lesson id, so each lesson mounts fresh. It resumes
  // from wherever the learner stopped, read from their saved progress — unless
  // the lesson has changed since then (see lessonFingerprint).
  const [stored] = useState(() => window.Progress.lessonState(lesson.id, version));
  // A resume point with content but no fingerprint predates fingerprints, so
  // which version of the lesson it belongs to is unknown; treat it as stale
  // rather than risk applying it to a changed step list. An empty one (a
  // restart or a finished lesson) carries nothing to misapply.
  const hasContent = (s) => Boolean(s && (
    (s.cursor || 0) > 0 ||
    Object.keys(s.answers || {}).length > 0 ||
    Object.keys(s.satisfied || {}).length > 0
  ));
  const outdated = Boolean(stored && stored.v !== version && (stored.v || hasContent(stored)));
  const saved = outdated ? null : stored;
  const [cursor, setCursor] = useState(() => {
    const c = saved ? saved.cursor || 0 : 0;
    return Math.min(Math.max(0, c), lesson.steps.length - 1);
  });
  const [satisfied, setSatisfied] = useState(() => (saved && saved.satisfied) || {});
  const [answers, setAnswers] = useState(() => (saved && saved.answers) || {});
  const [resumedAt, setResumedAt] = useState(() => (saved && saved.cursor > 0 ? saved.cursor : null));
  const [showUpdated, setShowUpdated] = useState(outdated && stored.cursor > 0);
  const topRef = useRef(null);

  // A stale resume point is not cleared: the first save below, stamped with
  // the current version, sets it aside (Progress.saveLessonState) so its
  // answers never land on the new step list, while a tab still open on the
  // old version can keep using it.
  useEffect(() => {
    window.Progress.saveLessonState(lesson.id, { cursor, v: version });
  }, [lesson.id, cursor, version]);

  const saveAnswer = useCallback((index, value) => {
    setAnswers((a) => ({ ...a, [index]: value }));
    window.Progress.saveLessonState(lesson.id, { answers: { [index]: value }, v: version });
  }, [lesson.id, version]);

  function restart() {
    window.Progress.clearLessonState(lesson.id);
    setSatisfied({});
    setAnswers({});
    setResumedAt(null);
    // A new run: the entries of the old one are skipped by back (see onPop),
    // so back after a restart leaves the lesson instead of undoing the restart.
    runRef.current = newRun();
    window.history.replaceState({ lessonId: lesson.id, step: 0, run: runRef.current }, '');
    setCursor(0);
  }

  useEffect(() => {
    if (topRef.current) topRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
  }, [cursor]);

  // ── Browser / phone back ──────────────────────────────────────────────
  // Steps live in component state, not in the URL, so the phone's back
  // gesture used to skip the whole lesson and land on the home page. Each step
  // the learner moves to gets its own history entry (same URL, step in the
  // entry's state), so back walks the steps first and only leaves the lesson
  // from its opening step.
  const cursorRef = useRef(cursor);
  cursorRef.current = cursor;
  // Each play-through of a lesson tags its entries with a run id.
  const runRef = useRef(null);

  useEffect(() => {
    // Tag the entry the lesson was opened on, so popping back to it restores
    // the step the learner started (or resumed) at.
    const st = window.history.state;
    if (st && st.lessonId === lesson.id && st.run) {
      // Back/forward landed on an entry of this lesson (e.g. back from the
      // next lesson after finishing this one): show the step it records, not
      // the saved resume point, which may already be cleared.
      runRef.current = st.run;
      if (typeof st.step === 'number' && st.step !== cursorRef.current) {
        setCursor(Math.min(Math.max(0, st.step), lesson.steps.length - 1));
      }
    } else {
      // A lesson resumed mid-way gets one entry per earlier step, so back
      // walks down to step 0 before leaving, exactly as if it had been played.
      runRef.current = newRun();
      const start = cursorRef.current;
      window.history.replaceState({ ...(st || {}), lessonId: lesson.id, step: 0, run: runRef.current }, '');
      for (let i = 1; i <= start; i += 1) {
        window.history.pushState({ lessonId: lesson.id, step: i, run: runRef.current }, '');
      }
    }
    const onPop = (event) => {
      const s = event.state;
      if (!s || s.lessonId !== lesson.id || typeof s.step !== 'number') return;
      if (s.run !== runRef.current) {
        window.history.back(); // left over from before a restart: skip it
        return;
      }
      setCursor(Math.min(Math.max(0, s.step), lesson.steps.length - 1));
    };
    window.addEventListener('popstate', onPop);
    return () => window.removeEventListener('popstate', onPop);
  }, [lesson.id, lesson.steps.length]);

  /** Moves to a step and records it, so back returns here. */
  const goTo = useCallback((target) => {
    const n = Math.min(Math.max(0, target), lesson.steps.length - 1);
    if (n === cursorRef.current) return;
    window.history.pushState({ lessonId: lesson.id, step: n, run: runRef.current }, '');
    setCursor(n);
  }, [lesson.id, lesson.steps.length]);

  /** The on-screen back button and the phone's back do the same thing. */
  function goBack() {
    const st = window.history.state;
    if (st && st.lessonId === lesson.id && st.step === cursor && cursor > 0) {
      window.history.back(); // popstate moves the cursor
    } else {
      goTo(cursor - 1);
    }
  }

  const steps = lesson.steps;
  const step = steps[cursor];
  const isLast = cursor === steps.length - 1;

  usePrefetchNextScan(steps, cursor);

  const markSatisfied = useCallback(() => {
    setSatisfied((s) => ({ ...s, [cursor]: true }));
    window.Progress.saveLessonState(lesson.id, { satisfied: { [cursor]: true }, v: version });
  }, [cursor, lesson.id, version]);

  // In review mode nothing blocks: the admin can walk the lesson without
  // answering, to read and check every step.
  const blocked = !review && BLOCKING_STEPS.has(step.type) && !satisfied[cursor];

  function next() {
    if (isLast) {
      window.Progress.completeLesson(lesson.id);
      onComplete(lesson.id);
      return;
    }
    goTo(cursor + 1);
  }

  function renderStep() {
    switch (step.type) {
      case 'narration':
        return <Narration text={step.text} />;
      case 'prose':
        return <Prose html={step.html} />;
      case 'callout':
        return <Callout variant={step.variant} title={step.title} text={step.text} />;
      case 'hu-table':
        return <HuTable />;
      case 'spec-table':
        return <SpecTable rows={step.rows} />;
      case 'media':
        return <MediaStep step={step} />;
      case 'scan':
        return <ScanStep key={step.seriesId} step={step} onSatisfied={markSatisfied} />;
      case 'ultrasound-image':
        return <window.UltrasoundCase key={step.caseId} caseId={step.caseId} />;
      case 'find':
        return (
          <FindStep
            key={`${cursor}:${step.seriesId}`}
            step={step}
            saved={answers[cursor]}
            onAnswer={(v) => saveAnswer(cursor, v)}
            onSatisfied={markSatisfied}
          />
        );
      case 'compare':
        return (
          <window.CompareViewer
            key={`${step.leftId}|${step.rightId}`}
            leftId={step.leftId}
            rightId={step.rightId}
            note={step.note}
            hideSliceLink={step.hideSliceLink}
          />
        );
      case 'checkpoint':
        return (
          <Checkpoint
            key={cursor}
            step={step}
            saved={answers[cursor]}
            onAnswer={(v) => saveAnswer(cursor, v)}
            onSatisfied={markSatisfied}
          />
        );
      case 'quiz':
        return (
          <Quiz
            key={cursor}
            step={step}
            lessonId={lesson.id}
            saved={answers[cursor]}
            onAnswer={(v) => saveAnswer(cursor, v)}
            onSatisfied={markSatisfied}
          />
        );
      default:
        return (
          <div className="callout callout--warn">
            סוג שלב לא מוכר: <code>{step.type}</code>
          </div>
        );
    }
  }

  const lessonIndex = region.lessons.findIndex((l) => l.id === lesson.id);
  const nextLesson = region.lessons[lessonIndex + 1] || null;

  return (
    <div className="lesson" ref={topRef}>
      <div>
        <div className="lesson__head">
          <div className="lesson__eyebrow">
            <button type="button" className="btn btn--ghost btn--sm" onClick={onExit}>
              → חזרה למודולים
            </button>
            <span>{region.title}</span>
            <span aria-hidden="true">·</span>
            <span>שיעור {lessonIndex + 1} מתוך {region.lessons.length}</span>
          </div>
          <h1 className="lesson__title">{lesson.title}</h1>
          {lesson.objective && <p className="lesson__sub">מטרת השיעור: {lesson.objective}</p>}

          {review ? (
            // Review mode: every step is a button, so any step is one click away.
            <div className="progress progress--jump" role="group" aria-label="קפיצה לשלב">
              {steps.map((s, i) => (
                <button
                  key={i}
                  type="button"
                  className={`progress__seg ${i < cursor ? 'progress__seg--done' : i === cursor ? 'progress__seg--active' : ''}`}
                  onClick={() => goTo(i)}
                  aria-current={i === cursor ? 'step' : undefined}
                  aria-label={`שלב ${i + 1}: ${s.type}`}
                  title={`שלב ${i + 1} · ${s.type}`}
                />
              ))}
            </div>
          ) : (
            <div className="progress" role="progressbar" aria-valuenow={cursor + 1} aria-valuemin={1} aria-valuemax={steps.length}>
              {steps.map((_, i) => (
                <div
                  key={i}
                  className={`progress__seg ${i < cursor ? 'progress__seg--done' : i === cursor ? 'progress__seg--active' : ''}`}
                />
              ))}
            </div>
          )}
        </div>

        {showUpdated && (
          <div className="callout callout--note" role="status" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
            <span style={{ flex: 1 }}>
              השיעור עודכן מאז שעצרת בו, ולכן הוא מתחיל מההתחלה. שיעורים שכבר סיימת נשארו מסומנים.
            </span>
            <button type="button" className="btn btn--ghost btn--sm" onClick={() => setShowUpdated(false)} aria-label="סגור הודעה">
              ✕
            </button>
          </div>
        )}

        {resumedAt !== null && (
          <div className="callout callout--note" role="status" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
            <span style={{ flex: 1 }}>
              המשכת מהמקום שבו עצרת — שלב {resumedAt + 1} מתוך {steps.length}. תשובות שכבר נתת נשמרו.
            </span>
            <button type="button" className="btn btn--ghost btn--sm" onClick={restart}>
              התחל את השיעור מההתחלה
            </button>
            <button type="button" className="btn btn--ghost btn--sm" onClick={() => setResumedAt(null)} aria-label="סגור הודעה">
              ✕
            </button>
          </div>
        )}

        {review && <ReviewPanel step={step} index={cursor} />}

        {renderStep()}

        <div className="step-nav">
          <button
            type="button"
            className="btn btn--secondary"
            onClick={goBack}
            disabled={cursor === 0}
          >
            → הקודם
          </button>

          <span className="step-nav__status">
            {blocked
              ? step.type === 'find'
                ? 'נעל תשובה כדי להמשיך'
                : step.type === 'quiz'
                  ? 'הגש את המבחן כדי להמשיך'
                  : 'ענה על השאלה כדי להמשיך'
              : `שלב ${cursor + 1} מתוך ${steps.length}`}
          </span>

          <button type="button" className="btn btn--primary" onClick={next} disabled={blocked}>
            {isLast ? 'סיים שיעור ✓' : 'המשך ←'}
          </button>
        </div>
      </div>

      <aside className="lesson__aside">
        {region.teachingRole === 'technique' && (
          <div className="callout callout--note" style={{ margin: 0 }}>
            <strong className="callout__title">מודול טכניקה</strong>
            <span style={{ fontSize: 'var(--text-xs)' }}>{region.teachingRoleNote}</span>
          </div>
        )}

        {region.normalCohortUnavailable && (
          <div className="callout callout--caution" style={{ margin: 0 }}>
            <strong className="callout__title">אין במודול זה סריקות ביקורת</strong>
            <span style={{ fontSize: 'var(--text-xs)' }}>{region.normalCohortNote}</span>
          </div>
        )}

        {lesson.pedagogyNote && (
          <div className="card">
            <div style={{ fontSize: 'var(--text-xs)', fontWeight: 700, color: 'var(--accent)', marginBottom: 'var(--space-2)' }}>
              על הנתונים בשיעור זה
            </div>
            <p style={{ fontSize: 'var(--text-xs)', lineHeight: 'var(--leading-prose)' }}>{lesson.pedagogyNote}</p>
          </div>
        )}

        <div className="card">
          <div style={{ fontSize: 'var(--text-xs)', fontWeight: 700, color: 'var(--text-faint)', marginBottom: 'var(--space-3)' }}>
            שיעורי המודול
          </div>
          {region.lessons.map((l, i) => {
            const done = window.Progress.isLessonComplete(l.id);
            const active = l.id === lesson.id;
            return (
              <button
                key={l.id}
                type="button"
                className="nav-sub"
                aria-current={active}
                style={{ marginInlineStart: 0, width: '100%' }}
                onClick={() => onNavigateLesson(l.id)}
              >
                <span className={`nav-sub__dot ${done ? 'nav-sub__dot--done' : active ? 'nav-sub__dot--active' : ''}`} />
                <span style={{ flex: 1 }}>{l.title}</span>
                <span style={{ color: 'var(--text-faint)' }}>{l.duration}</span>
              </button>
            );
          })}
        </div>

        {nextLesson && (
          <div className="card">
            <div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-faint)' }}>השיעור הבא</div>
            <div style={{ fontWeight: 600, color: 'var(--text-strong)', margin: 'var(--space-1) 0 var(--space-3)' }}>
              {nextLesson.title}
            </div>
            <button type="button" className="btn btn--secondary btn--block btn--sm" onClick={() => onNavigateLesson(nextLesson.id)}>
              דלג לשיעור הבא
            </button>
          </div>
        )}
      </aside>
    </div>
  );
}

window.LessonPlayer = LessonPlayer;
window.LessonOptionOrder = optionOrder;
window.LessonFingerprint = lessonFingerprint;

})();
