/**
 * ptx-scorm-events.js — PreTeXt SCORM Tracking Adapter
 *
 * PURPOSE
 * -------
 * When a PreTeXt book is exported as a SCORM package (compression="scorm" in
 * project.ptx), the resulting zip is uploaded to an LMS (Canvas, Moodle, …).
 * The LMS hosts the content in an iframe and exposes a JavaScript object
 * called the "SCORM API" on the parent window.  This script acts as the
 * bridge between PreTeXt/Runestone interactive exercises and that SCORM API.
 *
 * WHAT IT DOES
 * ------------
 * 1. Discovers the SCORM API exposed by the LMS (supports both SCORM 1.2 and
 *    SCORM 2004).
 * 2. Initializes a SCORM session the first time the learner answers a question.
 * 3. Monkey-patches RunestoneBase.prototype.logBookEvent so that every
 *    Runestone exercise submission is intercepted.
 * 4. For each graded submission, writes a SCORM "interaction" record (type,
 *    learner response, result, timestamp) to the LMS grade book.
 * 5. Maintains a running score (percentage correct across all answered
 *    questions on all pages visited in this session) and reports it as
 *    cmi.score.scaled (SCORM 2004) or cmi.core.score.raw (SCORM 1.2).
 * 6. Persists the running totals in cmi.suspend_data so that navigating to
 *    a new page does not reset the accumulated score.
 * 7. Marks the SCO as "completed" after the first question is answered
 *    (completion means "visited and interacted", not "all correct").
 *
 * SCORING POLICY
 * --------------
 * The score is simply:  (sum of per-question percentage scores) / (questions answered)
 * No pass/fail threshold is applied here.  The LMS can apply its own mastery
 * score to determine pass/fail based on the reported scaled score.
 *
 * INCLUDED EXERCISE TYPES
 * -----------------------
 * All standard Runestone interactive types are handled:
 *   - multiplechoice / clickablearea  → SCORM interaction type "choice"
 *   - fillintheblank / webwork        → "fill-in"
 *   - parsons / hparsons              → "sequencing"
 *   - dragndrop / matching            → "matching"
 *   - unittest (ActiveCode tests)     → "performance"
 *   - shortanswer / journal           → "long-fill-in" (no automatic score;
 *                                       recorded as neutral result)
 *
 * HOW TO INCLUDE
 * --------------
 * This file is placed in _static/pretext/js/ by the PreTeXt build system and
 * injected into every HTML page via a <script> tag generated by the XSL
 * template named "scorm-js" in pretext-html.xsl.  It is only emitted when
 * the build target has html.scorm = 'yes' (i.e. compression="scorm").
 *
 * SCORM 1.2 vs SCORM 2004
 * -----------------------
 * The two versions use different function names (LMSInitialize vs Initialize,
 * different cmi.* paths, different interaction field names).  This script
 * abstracts those differences through the thin wrapper functions Get/Set/Commit
 * defined in Section 2 below.
 */

(function () {
    'use strict';

    // ── Debug logging (initialised first so it is available during API discovery)
    //
    // Enable by any of these methods before the page loads:
    //   • Add  ?ptxscormdebug  to the page URL
    //   • Run  localStorage.setItem('ptxscormdebug', '1')  in the console, then reload
    //   • Set  window.ptxScormDebug = true  before this script runs
    //
    // When enabled, every Get/Set/Commit/Terminate call — and every step of the
    // SCORM API discovery — is logged with its result and LMS error code.
    var _debug = !!(
        window.ptxScormDebug ||
        (window.location.search.indexOf('ptxscormdebug') !== -1) ||
        (typeof localStorage !== 'undefined' && localStorage.getItem('ptxscormdebug'))
    );

    function dbg(msg) {
        if (_debug) console.log('[PTX-SCORM DEBUG] ' + msg);
    }


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 1 — SCORM API DISCOVERY
    // ═══════════════════════════════════════════════════════════════════════
    //
    // The LMS embeds the SCO content in an iframe (or nested iframes).  The
    // SCORM API object lives somewhere on the parent window chain — usually
    // window.parent, but sometimes higher up.  We walk up to 50 levels to
    // handle unusually deep LMS frame hierarchies (Blackboard Ultra, for
    // example, nests content in several wrapper frames).
    //
    // SCORM 2004 exposes: window.API_1484_11
    // SCORM 1.2  exposes: window.API
    //
    // We store the discovered object in _api and record the version in _ver
    // so the wrapper functions in Section 2 can use the right method names.

    var _api = null;   // the LMS SCORM API object once found
    var _ver = null;   // '2004' or '1.2'

    (function discoverApi() {
        // Try to read the SCORM API from a single window reference.
        // Returns true and sets _api/_ver on success; false if not present;
        // throws SecurityError if the window is cross-origin.
        function checkWin(w, label) {
            if (w.API_1484_11) {
                _api = w.API_1484_11;
                _ver = '2004';
                dbg('Found SCORM 2004 API at ' + label);
                return true;
            }
            if (w.API) {
                _api = w.API;
                _ver = '1.2';
                dbg('Found SCORM 1.2 API at ' + label);
                return true;
            }
            dbg('No API at ' + label);
            return false;
        }

        // ── 1. Walk up the parent-frame chain ──────────────────────────────
        // Cross-origin frames throw on property access; we catch the error,
        // log it, and keep climbing — the API may be on a higher same-origin
        // frame above the cross-origin one.
        var win = window;
        for (var i = 0; i < 50; i++) {
            try {
                if (checkWin(win, 'frame level ' + i)) return;
            } catch (e) {
                dbg('Cross-origin frame at level ' + i + ' — skipping, continuing up.');
                console.warn('[PTX-SCORM] Cross-origin frame at level ' + i +
                             ' — cannot read API properties here, continuing to climb.');
            }
            if (win.parent === win) break;   // reached the topmost frame
            win = win.parent;
        }

        // ── 2. Try window.top directly ─────────────────────────────────────
        // This jumps straight to the topmost frame without iterating every
        // level, which helps when the chain above is very deep or when an
        // intermediate cross-origin frame blocked the loop.
        try {
            if (window.top !== win && checkWin(window.top, 'window.top')) return;
        } catch (e) {
            dbg('window.top is cross-origin — cannot check for API there.');
        }

        // ── 3. Try window.opener ───────────────────────────────────────────
        // Some SCORM players open content in a popup; the API lives on the
        // opener window rather than a parent frame.
        try {
            if (window.opener && checkWin(window.opener, 'window.opener')) return;
        } catch (e) {
            dbg('window.opener is cross-origin — cannot check for API there.');
        }

        // Running without a SCORM LMS is fine — exercises still work, we just
        // cannot report to any grade book.
        console.warn('[PTX-SCORM] No SCORM API found. ' +
                     'Exercises will work normally but scores will not be reported. ' +
                     'If this is inside a SCORM-capable LMS, verify the content was ' +
                     'added as a SCORM package, not a plain file or web link.');
    })();


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 2 — THIN WRAPPER AROUND THE SCORM API
    // ═══════════════════════════════════════════════════════════════════════
    //
    // SCORM 1.2 and 2004 have different function names for the same operations.
    // These three helpers (Get, Set, Commit) hide that difference so the rest
    // of the code can be version-agnostic.

    // (_debug and dbg() are defined at the top of the IIFE so they are
    // available during API discovery in Section 1.)

    /**
     * Read a SCORM data model element from the LMS.
     * Returns an empty string when no API is present.
     */
    function Get(key) {
        if (!_api) return '';
        var val = (_ver === '2004')
            ? _api.GetValue(key)
            : _api.LMSGetValue(key);
        var result = String(val);
        var err = lastError();
        dbg('Get(' + key + ') = ' + JSON.stringify(result) + '  err=' + err);
        return result;
    }

    /**
     * Write a value to a SCORM data model element.
     * Does nothing when no API is present.
     */
    function Set(key, val) {
        if (!_api) return;
        var result;
        if (_ver === '2004') {
            result = _api.SetValue(key, String(val));
        } else {
            result = _api.LMSSetValue(key, String(val));
        }
        var err = lastError();
        dbg('Set(' + key + ', ' + JSON.stringify(String(val)) + ') = ' + result + '  err=' + err);
    }

    /**
     * Flush pending writes to the LMS (equivalent to a "save").
     * The SCORM spec calls this Commit (2004) or LMSCommit (1.2).
     * Should be called after every batch of SetValue calls that
     * represents a meaningful checkpoint (e.g. after each answer).
     */
    function Commit() {
        if (!_api) return;
        var result;
        if (_ver === '2004') {
            result = _api.Commit('');
        } else {
            result = _api.LMSCommit('');
        }
        var err = lastError();
        dbg('Commit() = ' + result + '  err=' + err);
        return (err === '0' && result !== 'false');
    }

    /**
     * End the SCORM session.  This is the signal most LMSes (Blackboard
     * in particular) need before they will post the score to the gradebook.
     * Sets _initialized = false so that a subsequent page in the same package
     * will re-initialize cleanly.
     */
    function Terminate() {
        if (!_api) return;
        var result;
        if (_ver === '2004') {
            result = _api.Terminate('');
        } else {
            result = _api.LMSFinish('');
        }
        var err = lastError();
        dbg('Terminate() = ' + result + '  err=' + err);
        _initialized = false;
    }

    /**
     * Return the most recent SCORM error code as a string.
     * Error code '0' means "no error".
     */
    function lastError() {
        if (!_api) return '0';
        var code = (_ver === '2004')
            ? _api.GetLastError()
            : _api.LMSGetLastError();
        return String(code);
    }


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 3 — SESSION STATE
    // ═══════════════════════════════════════════════════════════════════════
    //
    // A SCORM "session" corresponds to one visit by the learner.  Within a
    // session the learner may navigate through many pages of the book.  Each
    // page is a separate HTML file, so each page load starts a fresh
    // JavaScript context — there is no shared memory between pages.
    //
    // We persist the running score totals in cmi.suspend_data (a free-form
    // string field provided by the SCORM spec for exactly this purpose) so
    // that when the learner arrives on page 3, we can pick up the scores
    // accumulated on pages 1 and 2.
    //
    // _state fields:
    //   correct  — running sum of per-question percentage scores (0.0–1.0 each)
    //   graded   — count of questions that have been answered (and can be graded)
    //   iCount   — index of the next interaction slot to write in cmi.interactions.*
    //              (interactions are append-only; we must never reuse an index)
    //   answers  — map of {divId: {answer, correct, percent}} for every question
    //              answered in any session; persisted in cmi.suspend_data so the
    //              UI can be restored when the learner returns in a future session.
    //              (cmi.interactions.* would also work but the LMS resets those
    //              records at the start of each new attempt, making them useless
    //              for cross-session restore.  suspend_data persists forever.)

    var _initialized = false;           // true once Initialize() has succeeded
    var _submitted   = false;           // true after submitSession() commits the grade (session stays open)
    var _state = {
        correct: 0,   // sum of percentage scores for graded questions
        graded:  0,   // total number of graded question answers received
        iCount:  0,   // next available cmi.interactions index (session-local)
        answers: {}   // {divId: {answer, correct, percent}} — persisted across sessions
    };

    // In-memory cache of Doenet activity state blobs, keyed by divId.
    // Populated when the student answers a DoenetActivity (SPLICE.reportScoreAndState).
    // Used to respond to SPLICE.getState requests when the Doenet iframe reloads.
    // Also persisted to localStorage (not cmi.suspend_data — state blobs can be large).
    var _doenetStates = {};

    // Queue for SPLICE.getState requests that arrive before loadRestoreData() has run
    // (i.e., before _learnerId is set and localStorage can be read correctly).
    // In practice this never fires — external iframes have network latency — but the
    // queue makes the code correct under any load order.
    var _doenetRestoreReady = false;    // true once loadRestoreData() has initialized _learnerId
    var _pendingGetStateRequests = [];  // queued { source } entries waiting for restore-ready

    // When we respond to SPLICE.getState we record the timestamp so that any
    // SPLICE.reportScoreAndState that arrives within the initialization window can
    // be identified as an auto-report from Doenet's own startup sequence rather
    // than a genuine new student submission.  Doenet reports its current (restored)
    // score when it finishes initializing — we must not treat that as a re-answer.
    var _doenetSentStateAt = {};        // divId → Date.now() when SPLICE.getState.response was sent
    var DOENET_INIT_WINDOW_MS = 8000;   // 8 seconds: generous window for iframe init

    // Total auto-gradeable questions found on this page (computed at DOM ready).
    // Used as the score denominator so unanswered questions count as zero,
    // rather than being silently excluded.  Not persisted in suspend_data because
    // it is page-specific; for a single-page SCORM assignment (the expected case)
    // this is simply the total number of questions on the assignment.
    var _totalQuestions = 0;

    // True once we have set completion_status / lesson_status to "completed" in
    // this session.  Used to skip redundant Set calls on subsequent interactions.
    var _statusCompleted = false;

    // ── localStorage persistence ──────────────────────────────────────────────
    //
    // Many LMSes (Canvas in particular) reset cmi.suspend_data at the start of
    // each new session/attempt, making it unreliable for cross-session restore.
    // We therefore also persist _state in the browser's localStorage, keyed by
    // the learner's ID so that different students sharing a browser stay separate.
    //
    // localStorage survives page reloads and browser restarts for the same student
    // on the same device.  It is not shared across devices, so for cross-device
    // scenarios we still write to suspend_data (and read it if localStorage is
    // empty — e.g. the student's first visit on a new device).
    //
    // _learnerId is populated once, immediately after Initialize() succeeds.
    var _learnerId = '';   // cmi.learner_id (2004) or cmi.core.student_id (1.2)

    /**
     * Build the localStorage key for the current learner and page.
     *
     * window.location.pathname includes the LMS course ID in its path (e.g.
     * Canvas embeds "/courses/12345/" in the iframe URL), so different course
     * instances of the same package get distinct keys automatically.
     */
    function lsKey() {
        return 'ptx-scorm:' + (_learnerId || 'anon') + ':' + window.location.pathname;
    }

    /** Serialize _state to localStorage.  Silently ignores storage errors. */
    function saveToLocalStorage() {
        try {
            localStorage.setItem(lsKey(), JSON.stringify(_state));
        } catch (e) {
            // Quota exceeded, private browsing, or storage disabled — not fatal.
        }
    }

    /** Read _state from localStorage.  Returns null if nothing is stored yet. */
    function loadFromLocalStorage() {
        try {
            var raw = localStorage.getItem(lsKey());
            return raw ? JSON.parse(raw) : null;
        } catch (e) {
            return null;
        }
    }

    // ── suspend_data size budget ─────────────────────────────────────────────
    //
    // Our manifest declares SCORM 2004 3rd Edition, whose spec limit (SPM) for
    // cmi.suspend_data is 4,000 characters (SCORM 1.2 likewise ~4K; only 2004
    // 4th Edition raised it to 64,000).  A single answered DoenetActivity puts
    // a 5-10 KB state blob into _state.answers[divId].answer, so serializing
    // _state verbatim blows the limit immediately.
    //
    // Exceeding the limit does not fail cleanly: Blackboard's client API
    // returns "true" (no client-side SPM check) but the server-side persist of
    // the attempt can fail — taking the score committed in the SAME session
    // down with it, while the gradebook shows the attempt stuck at "Draft".
    // This matches the observed behavior: grade writes record fine in sessions
    // where nothing was answered (small suspend_data) and never record in
    // sessions where a Doenet exercise was answered.  Canvas's SCORM engine
    // tolerates oversized suspend_data, which is why the same package works there.
    //
    // buildSuspendData() therefore serializes a SLIM copy of _state for the
    // LMS: oversized answer payloads are dropped (they are only needed for
    // same-device UI restore, which localStorage already covers — Doenet
    // restore in particular reads localStorage via SPLICE.getState and never
    // looks at answers[divId].answer).  Grading fields (correct/percent) are
    // always kept, so cross-device score integrity is unaffected.

    var SUSPEND_ANSWER_LIMIT = 600;   // per-answer cap inside suspend_data
    var SUSPEND_TOTAL_LIMIT  = 3800;  // total budget; SPM is 4000, keep headroom

    function buildSuspendData() {
        var slim = {
            correct: _state.correct,
            graded:  _state.graded,
            iCount:  _state.iCount,
            answers: {}
        };
        Object.keys(_state.answers).forEach(function (divId) {
            var a = _state.answers[divId];
            var ans = a.answer;
            if (typeof ans === 'string' && ans.length > SUSPEND_ANSWER_LIMIT) {
                ans = '';  // too big for suspend_data; survives in localStorage
            }
            slim.answers[divId] = { answer: ans, correct: a.correct, percent: a.percent };
        });
        var json = JSON.stringify(slim);
        if (json.length > SUSPEND_TOTAL_LIMIT) {
            // Still over budget (page with many questions): drop all answer
            // text and keep only the grading bookkeeping.
            Object.keys(slim.answers).forEach(function (divId) {
                slim.answers[divId].answer = '';
            });
            json = JSON.stringify(slim);
        }
        dbg('suspend_data: ' + json.length + ' chars' +
            (json.length > SUSPEND_TOTAL_LIMIT ? '  (STILL OVER ' + SUSPEND_TOTAL_LIMIT + ')' : ''));
        return json;
    }

    // ── Doenet / SPLICE state persistence ────────────────────────────────────
    //
    // When a DoenetActivity reports its result via SPLICE.reportScoreAndState, the
    // message carries a full "state" blob that encodes all of the student's work
    // (selected answers, dragged objects, etc.).  When the iframe reloads it sends
    // SPLICE.getState requesting that blob back so it can pre-populate the UI.
    //
    // We cannot store state in cmi.suspend_data because SCORM 1.2 limits that field
    // to ~4 KB and Doenet state objects are typically 10-100 KB.  We store it in
    // localStorage instead (keyed per-learner, per-page, per-divId).  localStorage
    // survives page reloads and browser restarts on the same device; cross-device
    // restore is not supported (consistent with the general localStorage strategy).

    // State is stored as a JSON string in localStorage (for compactness) but
    // loaded back as a parsed JS object.  Doenet's SPLICE.getState.response handler
    // checks Ri.data.state.cid — this only works if state is an object, not a string.
    // Runestone stores JSON.stringify(data.state) server-side but its API returns the
    // parsed object; we replicate that by parsing on load.
    function saveDoenetState(divId, state) {
        if (!divId || state === null || state === undefined) return;
        _doenetStates[divId] = state;  // keep as object in memory
        try {
            var str = typeof state === 'string' ? state : JSON.stringify(state);
            localStorage.setItem(lsKey() + '|doenet|' + divId, str);
        } catch (e) {}
    }

    // Returns the state as a JS object (Doenet needs state.cid for identity check).
    function loadDoenetState(divId) {
        if (_doenetStates[divId]) return _doenetStates[divId];
        try {
            var raw = localStorage.getItem(lsKey() + '|doenet|' + divId);
            if (raw) {
                _doenetStates[divId] = JSON.parse(raw);
                return _doenetStates[divId];
            }
        } catch (e) {}
        return null;
    }

    // Walk the page's iframes and return the divId of the one whose contentWindow
    // matches sourceWindow.  Returns null if no match is found.
    function resolveIframeId(sourceWindow) {
        var iframes = document.getElementsByTagName('iframe');
        for (var i = 0; i < iframes.length; i++) {
            if (iframes[i].contentWindow === sourceWindow) {
                var c = iframes[i].closest('[data-component="splice"],[data-component="doenet"]');
                return (c && c.id) || iframes[i].id || null;
            }
        }
        return null;
    }

    // Respond to a SPLICE.getState request from a Doenet iframe.
    // Subject must be "SPLICE.getState.response" with the message_id echoed back.
    // State must be a JS object (not a JSON string) because Doenet checks state.cid directly.
    function respondToGetState(sourceWindow, messageId) {
        var divId = resolveIframeId(sourceWindow);
        var stateObj = divId ? loadDoenetState(divId) : null;  // JS object; Doenet checks state.cid
        console.log('[PTX-SCORM] SPLICE.getState from "' + (divId || '?') + '" — ' +
                    (stateObj ? 'sending saved state (cid: ' + (stateObj.cid || '?') + ')' : 'no saved state (first visit)'));
        if (divId) _doenetSentStateAt[divId] = Date.now();
        sourceWindow.postMessage({
            subject:    'SPLICE.getState.response',
            message_id: messageId,
            state:      stateObj || null
        }, '*');
    }

    /**
     * initSession() — Start (or re-join) the SCORM session.
     *
     * This is called lazily on the first graded answer rather than on page
     * load, so that pages without exercises don't create spurious incomplete
     * records in the LMS.
     *
     * Safe to call multiple times — subsequent calls are no-ops once
     * _initialized is true.
     */
    function initSession() {
        if (_initialized || !_api) return;

        // Initialize() tells the LMS we are starting a communication session.
        // Error code 103 ("already initialized") is harmless — it means another
        // script or frame already called Initialize() and we can safely proceed.
        var result = (_ver === '2004')
            ? _api.Initialize('')
            : _api.LMSInitialize('');
        var err = lastError();

        if (result !== 'true' && err !== '103') {
            console.warn('[PTX-SCORM] Initialize() failed (error=' + err + '). ' +
                         'Score reporting will be disabled for this page.');
            return;
        }
        _initialized = true;
        dbg('Initialize() = ' + result + '  err=' + err + '  ver=' + _ver);

        // ── Log key LMS-supplied values so Blackboard behaviour is visible ──
        var entryKey = _ver === '2004' ? 'cmi.entry' : 'cmi.core.entry';
        var learnerId = Get(_ver === '2004' ? 'cmi.learner_id' : 'cmi.core.student_id');
        var entry = Get(entryKey);
        var rawForLog = Get('cmi.suspend_data');
        dbg('entry=' + JSON.stringify(entry) +
            '  learner_id=' + JSON.stringify(learnerId) +
            '  suspend_data length=' + rawForLog.length);

        // ── Restore accumulated totals from previous pages ──────────────────
        // cmi.suspend_data is a free-form string that persists across pages
        // within a session.  We use it to store our running score as JSON.
        var raw = rawForLog;
        if (raw) {
            try {
                // Merge saved values into _state; unknown keys are ignored
                Object.assign(_state, JSON.parse(raw));
                dbg('Restored _state from suspend_data: correct=' + _state.correct +
                    '  graded=' + _state.graded +
                    '  answers=' + Object.keys(_state.answers || {}).length + ' keys');
            } catch (e) {
                // Malformed suspend_data is not fatal; we just start fresh
                console.warn('[PTX-SCORM] Could not parse suspend_data:', e);
            }
        }

        // ── Set session interaction index from LMS ──────────────────────────
        // cmi.interactions._count is session-local: the LMS resets it to 0 at
        // the start of each new attempt.  We always derive iCount from the LMS
        // value (not from anything saved in suspend_data) so that new interaction
        // records are appended correctly after whatever the LMS already wrote
        // this session, without trying to reuse or skip existing indices.
        var lmsCount = parseInt(Get('cmi.interactions._count') || '0', 10);
        _state.iCount = isNaN(lmsCount) ? 0 : lmsCount;

        // ── Mark the SCO as "incomplete" on first page visit ────────────────
        // This lets the LMS distinguish "never opened" from "opened but not
        // finished".  We only set this if the status is still the default
        // ("not attempted" in SCORM 2004, "" in SCORM 1.2).
        var completionKey = (_ver === '2004')
            ? 'cmi.completion_status'
            : 'cmi.core.lesson_status';
        var currentStatus = Get(completionKey);
        dbg('completion_status on entry = ' + JSON.stringify(currentStatus));
        if (currentStatus === 'not attempted' ||
            currentStatus === 'unknown'       ||
            currentStatus === '') {
            Set(completionKey, 'incomplete');
            Commit();
        }
        // Remember if the LMS already shows "completed" (e.g. returning student on
        // an LMS that preserves status across attempts, such as Blackboard).
        _statusCompleted = (currentStatus === 'completed' || currentStatus === 'passed');
        dbg('_statusCompleted=' + _statusCompleted);

        console.log('[PTX-SCORM] Session ready (SCORM ' + _ver + ').' +
                    '  Prior interactions: ' + _state.iCount + ',' +
                    '  Prior score: ' + _state.correct + '/' + _state.graded);
    }


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 4 — EXERCISE TYPE MAPPING
    // ═══════════════════════════════════════════════════════════════════════
    //
    // Runestone fires logBookEvent with an "event" field that names the
    // exercise type.  SCORM requires us to classify each interaction into one
    // of its own type codes.  This table maps Runestone event names to the
    // corresponding SCORM 2004 interaction type.
    //
    // For SCORM 1.2, "long-fill-in" is not a valid type and is narrowed to
    // "fill-in" at the point of use (see recordInteraction below).
    //
    // Events NOT present in this map (e.g. video plays, page visits, activecode
    // edits that are not test runs) are ungraded and are silently ignored.

    var RUNESTONE_TO_SCORM_TYPE = {
        mChoice:                   'choice',       // Multiple-choice question
        clickableArea:             'choice',       // Clickable-area question (pick the right region)
        fillb:                     'fill-in',      // Fill-in-the-blank
        webwork:                   'fill-in',      // WeBWorK problem (scored as fill-in)
        parsons:                   'sequencing',   // Parsons problem (arrange code blocks in order)
        hparsonsAnswer:            'sequencing',   // Horizontal Parsons variant
        dragNdrop:                 'matching',     // Drag-and-drop matching
        matching:                  'matching',     // Traditional matching exercise
        unittest:                  'performance',  // ActiveCode with unit tests (auto-graded)
        shortanswer:               'long-fill-in', // Short-answer / journal (no automatic grade)
        // SPLICE/Doenet responses are JSON blobs.  SCORM 2004 validates
        // learner_response per type, and "performance" demands a strict
        // step[.]answer format — JSON is always rejected with error 406
        // ("type mismatch").  Type "other" accepts a free characterstring.
        splice:                    'other',        // SPLICE protocol iframe activity (see Section 10)
        // DoenetActivity events: Runestone's SpliceWrapper fires logBookEvent with
        // event="SPLICE.reportScoreAndState" (carries score/percent/answer) and
        // event="SPLICE.sendEvent" (notification only, no score).  Only the former
        // should be recorded; the latter has no score and is silently ignored.
        'SPLICE.reportScoreAndState': 'other',     // JSON response — see "splice" note above
    };


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 5 — PAGE QUESTION COUNT
    // ═══════════════════════════════════════════════════════════════════════
    //
    // To report a score out of the total number of questions (not just the
    // number attempted), we count auto-gradeable Runestone components in the
    // DOM after the page loads.  Unanswered questions then implicitly score 0.
    //
    // Each graded exercise type has a distinct data-component value on its
    // root element.  Short-answer / journal questions (data-component="shortanswer")
    // are excluded because they have no automatic score and would unfairly
    // reduce the percentage.
    //
    // ActiveCode unit tests share data-component="activecode" with non-graded
    // code windows; we distinguish them by the "--unittest--" separator that
    // the PreTeXt XSL embeds in the textarea when a <tests> block is present.

    // CSS attribute selectors for every auto-gradeable Runestone component type.
    // These map 1-to-1 with the event names in RUNESTONE_TO_SCORM_TYPE (minus
    // shortanswer, which has no automatic score, and unittest, handled below).
    var GRADEABLE_SELECTORS = [
        '[data-component="multiplechoice"]',
        '[data-component="parsons"]',
        '[data-component="hparsons"]',
        '[data-component="fillintheblank"]',
        '[data-component="dragndrop"]',
        '[data-component="matching"]',
        '[data-component="clickablearea"]',
        '[data-component="webwork"]',
        '[data-component="splice"]',       // SPLICE protocol iframe activities
        '[data-component="doenet"]',       // DoenetActivity (SCORM/Runestone builds)
    ];

    /**
     * Count all auto-gradeable questions on the current page and store the
     * result in _totalQuestions.  Called once at DOMContentLoaded.
     */
    function countPageQuestions() {
        var count = 0;

        // Count each straightforward component type
        GRADEABLE_SELECTORS.forEach(function (sel) {
            count += document.querySelectorAll(sel).length;
        });

        // ActiveCode with unit tests: the XSL writes "--unittest--" into the
        // textarea as a separator between student code and test code.  We look
        // for that marker to distinguish graded activecodes from plain ones.
        document.querySelectorAll('[data-component="activecode"] textarea').forEach(function (ta) {
            if ((ta.value       && ta.value.indexOf('--unittest--')       !== -1) ||
                (ta.textContent && ta.textContent.indexOf('--unittest--') !== -1)) {
                count++;
            }
        });

        _totalQuestions = count;
        console.log('[PTX-SCORM] Gradeable questions on this page: ' + _totalQuestions);
    }


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 6 — SCORE EXTRACTION
    // ═══════════════════════════════════════════════════════════════════════
    //
    // Runestone events carry score information in several different formats
    // depending on the exercise type.  This function normalizes them all to
    // a single float in [0.0, 1.0], or null for exercises without an
    // automatic score (e.g. short-answer journals).

    /**
     * Extract a [0.0, 1.0] score from a Runestone event object.
     *
     * Runestone uses several conventions:
     *
     *   ev.percent        — a float already in [0,1] (most types)
     *   ev.correct        — 'T'/'F' or true/false (multiple choice)
     *   ev.act            — a colon-delimited string encoding the result;
     *                       examples:
     *                         unittest: "percent:100.0:passed:2:failed:0"
     *                         webwork:  "actual:3:expected:5:...pct:0.6"
     *
     * Returns null for short-answer exercises (no automatic score).
     */
    function extractScore(ev) {
        // Most Runestone types set ev.percent directly
        if (typeof ev.percent === 'number') {
            return ev.percent;
        }

        if (ev.act) {
            // ActiveCode unit tests: "percent:75.0:passed:3:failed:1:..."
            var unitMatch = ev.act.match(/(?:^|:)percent:([\d.]+)/);
            if (unitMatch) return parseFloat(unitMatch[1]) / 100;

            // WeBWorK: "actual:3:expected:5:...pct:0.6"
            var pctMatch = ev.act.match(/pct:([\d.]+)/);
            if (pctMatch) return parseFloat(pctMatch[1]);
        }

        // Simple correct/incorrect flag (multiple choice, clickable area)
        var c = ev.correct;
        if (c === 'T' || c === true)  return 1.0;
        if (c === 'F' || c === false) return 0.0;

        // Short-answer / journal: no automatic grade
        return null;
    }


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 7 — TIMESTAMP HELPERS
    // ═══════════════════════════════════════════════════════════════════════
    //
    // SCORM 2004 wants timestamps in ISO-8601 format (e.g. "2025-01-15T14:23:00Z").
    // SCORM 1.2 wants a simpler "HH:MM:SS" string.

    /** Return the current local time as an ISO-8601 string (SCORM 2004 format). */
    function isoTimestamp() {
        // SCORM 2004's "time" type is a restricted ISO-8601: strict engines
        // (Canvas) reject a trailing 'Z'/UTC designator with error 406
        // ("not a valid time type").  Local time with NO timezone designator
        // is accepted everywhere, and second precision satisfies the spec.
        var d = new Date();
        function p(n) { return ('0' + n).slice(-2); }
        return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
               'T' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
    }

    /** Return the current local time as "HH:MM:SS" (SCORM 1.2 format). */
    function hhmmssTime() {
        var d = new Date();
        return ('0' + d.getHours()).slice(-2)   + ':' +
               ('0' + d.getMinutes()).slice(-2) + ':' +
               ('0' + d.getSeconds()).slice(-2);
    }


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 8 — CORE INTERACTION RECORDER
    // ═══════════════════════════════════════════════════════════════════════
    //
    // This function is called for every Runestone logBookEvent.  It:
    //   a) Checks whether the event type is one we track (RUNESTONE_TO_SCORM_TYPE)
    //   b) Initializes the SCORM session if not already done
    //   c) Writes a cmi.interactions.N.* record describing this answer
    //   d) Updates the running score in cmi.score.*
    //   e) Marks the SCO as "completed" (the learner has engaged with it)
    //   f) Saves state to cmi.suspend_data for the next page

    function recordInteraction(ev) {
        // ── a) Guard: only handle graded exercise types ──────────────────────
        var scormType = RUNESTONE_TO_SCORM_TYPE[ev.event];
        if (!scormType) {
            // This event type is not an exercise submission (e.g. a video play,
            // page view, or activecode edit).  Silently ignore it.
            return;
        }

        // Note: reports arriving after "Submit Assignment" are still handled.
        // submitSession() does not terminate the session, so a late Doenet
        // SPLICE.reportScoreAndState (they can lag an answer by seconds) is
        // still written and committed — re-committing the freshly computed
        // score so the submit never locks in a stale (under-credited) grade.
        // The LMS honors the latest commit while the session is open.

        // ── b) Lazy session initialization ───────────────────────────────────
        initSession();
        if (!_initialized) {
            console.warn('[PTX-SCORM] recordInteraction: aborting — session not initialized.',
                         '_api:', !!_api, '_ver:', _ver);
            return;  // API unavailable; nothing to report
        }

        // SCORM 1.2 does not support the "long-fill-in" or "other" interaction
        // types (only SCORM 2004 does).  Narrow to the closest valid 1.2 type.
        if (_ver === '1.2' && (scormType === 'long-fill-in' || scormType === 'other')) {
            scormType = 'fill-in';
        }

        // ── b2) Extract score early so the Doenet init-guard can run ─────────
        //
        // When we respond to SPLICE.getState, Doenet restores its previous state
        // and fires SPLICE.reportScoreAndState to confirm its current score —
        // even when the student hasn't touched anything.  If we treated this as
        // a new submission, the re-submission logic would subtract the prior score
        // and add the (possibly zero) initialization score, wiping the student's
        // record.  We detect this auto-report by:
        //   1. Checking that this is a SPLICE.reportScoreAndState event.
        //   2. Checking that we sent SPLICE.sendState to this divId recently
        //      (within DOENET_INIT_WINDOW_MS, typically 8 seconds).
        //   3. Checking that the reported score is ≤ the already-saved score
        //      (a legitimate new answer that increases the score is always allowed).
        var score = extractScore(ev);

        if (ev.event === 'SPLICE.reportScoreAndState' && score !== null) {
            var sentAt = _doenetSentStateAt[ev.div_id];
            if (sentAt && (Date.now() - sentAt) < DOENET_INIT_WINDOW_MS) {
                var prevSaved = _state.answers[ev.div_id];
                var prevPct = prevSaved && typeof prevSaved.percent === 'number' ? prevSaved.percent : null;
                if (prevPct !== null && score <= prevPct) {
                    console.log('[PTX-SCORM] Ignoring Doenet init auto-report for "' + ev.div_id +
                                '" — score ' + score + ' ≤ saved ' + prevPct + ' (initialization window)');
                    // Still save the state blob if Doenet included it (belt-and-suspenders).
                    if (ev.state) saveDoenetState(ev.div_id, ev.state);
                    return;
                }
            }
            delete _doenetSentStateAt[ev.div_id];  // clear after first accepted report
        }

        // ── c) Write the interaction record ──────────────────────────────────
        //
        // SCORM interactions are indexed from 0.  Each call to this function
        // appends a new record.  The LMS uses these records to show question-
        // by-question detail in its grade book.
        //
        // _state.iCount is our local counter; we increment it after each write
        // so the next interaction lands in the next slot.

        var idx = _state.iCount;
        var prefix = 'cmi.interactions.' + idx + '.';

        // Interaction ID: use the exercise's xml:id (div_id in Runestone),
        // truncated to 255 characters (SCORM spec maximum)
        Set(prefix + 'id', (ev.div_id || 'unknown').substring(0, 255));

        // Interaction type: the SCORM category (choice, fill-in, sequencing, …)
        Set(prefix + 'type', scormType);

        // Timestamp: format differs between SCORM versions
        if (_ver === '2004') {
            Set(prefix + 'timestamp', isoTimestamp());
        } else {
            Set(prefix + 'time', hhmmssTime());
        }

        // Learner's response: the actual answer they submitted.
        // Use ev.answer when available; fall back to ev.act (the raw action
        // string used by some Runestone types).
        var response = (ev.answer != null) ? ev.answer : (ev.act || '');
        if (typeof response !== 'string') {
            response = JSON.stringify(response);
        }
        var responseKey = (_ver === '2004') ? 'learner_response' : 'student_response';
        Set(prefix + responseKey, response.substring(0, 255));

        // Result: translate our [0,1] score to a SCORM result string.
        //   "correct"   — full marks
        //   "incorrect" — zero marks
        //   "neutral"   — no automatic grade (short-answer, journal)
        //   0.0000–1.0000 — partial credit (SCORM 2004 only; we still use
        //                   "correct"/"incorrect" at the boundaries)
        var result;
        if      (score === null) result = 'neutral';
        else if (score >= 1.0)  result = 'correct';
        else if (score <= 0.0)  result = 'incorrect';
        else                    result = score.toFixed(4);   // partial credit

        Set(prefix + 'result', result);

        _state.iCount++;   // advance to the next available interaction slot

        // ── d) Update the running score ───────────────────────────────────────
        //
        // Short-answer / journal questions have no automatic score (score === null).
        // We accumulate fractional points only for auto-graded questions, but the
        // denominator is always the total number of gradeable questions on the page
        // so that unanswered questions count as zero (not as an absence).
        //
        // _totalQuestions is set at DOMContentLoaded by countPageQuestions().
        // If for some reason it is 0 (e.g. dynamic content not yet in DOM), we
        // fall back to _state.graded so the score still reflects something sensible.
        if (score !== null) {
            // ── Handle re-submission ──────────────────────────────────────────────
            // If the student submits the same question again (e.g. changes their
            // answer), we must remove the previous attempt's contribution before
            // adding the new one.  Without this, each re-submission inflates both
            // _state.correct and _state.graded, producing a score above 100%.
            var prevEntry = _state.answers[ev.div_id || 'unknown'];
            if (prevEntry !== undefined &&
                prevEntry.percent !== null &&
                prevEntry.percent !== undefined) {
                _state.correct -= prevEntry.percent;
                _state.graded  -= 1;
            }

            _state.correct += score;    // accumulate fractional points
            _state.graded  += 1;        // one more question has been answered

            // Use page total as denominator; fall back to answered count if needed
            var denom = _totalQuestions > 0 ? _totalQuestions : _state.graded;
            var scaled = _state.correct / denom;

            if (_ver === '2004') {
                // SCORM 2004 uses separate score fields
                Set('cmi.score.scaled', scaled.toFixed(4));         // 0.0000–1.0000
                Set('cmi.score.raw',    (scaled * 100).toFixed(1)); // 0.0–100.0
                Set('cmi.score.min',    '0');
                Set('cmi.score.max',    '100');
                // We intentionally do NOT set cmi.success_status here.
                // Pass/fail is determined by the LMS using its own mastery score
                // setting, not by this script.
            } else {
                // SCORM 1.2 has a single score field (0–100 range)
                Set('cmi.core.score.raw', (scaled * 100).toFixed(1));
            }
        }

        // ── e) Persist state for the next page ───────────────────────────────
        //
        // NOTE: completion_status / lesson_status is intentionally NOT set here.
        // Setting it to "completed" on the first answer is what caused Blackboard
        // to treat a page-navigation-away (exit="suspend") as a finalized
        // submission, locking the attempt.  We keep status as "incomplete" until
        // the student explicitly clicks "Submit Assignment" (submitSession()).
        // The score fields above are committed independently and remain visible
        // in the LMS gradebook regardless of completion status.
        //
        // cmi.suspend_data is our cross-page scratchpad.  We serialise _state
        // to JSON and write it here so that when the learner navigates to the
        // next page, initSession() can restore the running totals.
        //
        // We also save this answer to _state.answers so that loadRestoreData()
        // can rebuild the UI restore map from suspend_data on future visits.
        // We cannot rely on cmi.interactions.* for this because many LMSes reset
        // that collection at the start of each new attempt — suspend_data persists.
        _state.answers[ev.div_id || 'unknown'] = {
            answer:  response,
            correct: score === null ? null : (score >= 1.0),
            percent: score
        };

        // For DoenetActivity: persist the full Doenet state blob so we can send
        // it back via SPLICE.getState.response when the iframe reloads.
        if (ev.state) {
            saveDoenetState(ev.div_id || 'unknown', ev.state);
        }

        var suspendJson = buildSuspendData();
        Set('cmi.suspend_data', suspendJson);
        var setErr = lastError();
        if (setErr !== '0') {
            console.warn('[PTX-SCORM] Set(cmi.suspend_data) error code:', setErr);
        }

        // Flush all pending writes to the LMS
        Commit();
        var commitErr = lastError();
        if (commitErr !== '0') {
            console.warn('[PTX-SCORM] Commit() error code:', commitErr);
        }

        // Also save to localStorage so that answers survive even when the LMS
        // resets suspend_data at the start of the next session (Canvas does this).
        saveToLocalStorage();

        // ── Diagnostic log ────────────────────────────────────────────────────
        var logDenom = _totalQuestions > 0 ? _totalQuestions : _state.graded;
        var pctLabel = logDenom > 0
            ? (_state.correct / logDenom * 100).toFixed(0) + '%'
              + ' (' + _state.correct.toFixed(2) + ' / ' + logDenom + ')'
            : 'n/a';
        console.log('[PTX-SCORM] ' + ev.event + ' "' + (ev.div_id || '?') +
                    '" → ' + result +
                    '  Score: ' + pctLabel +
                    '  (' + _state.iCount + ' total interactions)');

        // Keep the on-page score readout above the Submit button in sync.
        updateScoreDisplay();
    }


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 9 — RUNESTONE HOOK INSTALLATION
    // ═══════════════════════════════════════════════════════════════════════
    //
    // Every Runestone interactive component (multiple choice, Parsons, FITB, …)
    // extends RunestoneBase and calls this.logBookEvent(eventData) when the
    // learner submits an answer.
    //
    // We intercept that call by replacing RunestoneBase.prototype.logBookEvent
    // with a wrapper that first calls our recordInteraction() and then delegates
    // to the original method.  This technique is called "monkey-patching" and
    // is safe here because:
    //   - We hold a reference to the original function and always call it.
    //   - We guard against double-patching with the __ptxScormHooked sentinel.
    //   - Any exceptions in our code are caught so Runestone's own logging
    //     is never disrupted.
    //
    // TIMING: The Runestone bundle loads synchronously, so window.RunestoneBase
    // should already be defined when this script runs.  The DOMContentLoaded
    // and runestone:pre-login-complete listeners are fallbacks for edge cases
    // (e.g. deferred or async loading).

    function installHook() {
        // Only patch once; bail if RunestoneBase isn't available yet
        if (!window.RunestoneBase || window.RunestoneBase.__ptxScormHooked) return;

        // Save a reference to the original method so we can chain to it
        var originalLogBookEvent = window.RunestoneBase.prototype.logBookEvent;

        // Replace with our wrapper
        window.RunestoneBase.prototype.logBookEvent = function (eventData) {
            // Our tracking code runs first; any error is caught so it cannot
            // prevent Runestone's own event handling from executing
            try {
                recordInteraction(eventData);
            } catch (err) {
                console.error('[PTX-SCORM] Error in recordInteraction:', err);
            }
            // Always call the original so Runestone continues to function normally
            return originalLogBookEvent.call(this, eventData);
        };

        // Sentinel prevents double-patching if installHook() is called again
        window.RunestoneBase.__ptxScormHooked = true;

        var rsVersion = (window.eBookConfig && window.eBookConfig.runestone_version)
            ? ' (Runestone v' + window.eBookConfig.runestone_version + ')'
            : '';
        console.log('[PTX-SCORM] Runestone hook installed' + rsVersion + '.');
    }

    // Attempt installation immediately (Runestone is usually already loaded)
    installHook();

    // Fallback: Runestone fires this event when its components are ready after
    // the user logs in.  In some configurations RunestoneBase isn't available
    // until this point.
    document.addEventListener('runestone:pre-login-complete', installHook);

    // Final fallback: re-try hook installation after the full DOM is built,
    // count gradeable questions, then initialize SCORM eagerly so we can
    // restore saved answers before Runestone's components call checkServer.
    document.addEventListener('DOMContentLoaded', function () {
        installHook();
        countPageQuestions();
        var restoreMap = loadRestoreData();
        installRestoreHook(restoreMap);
        requestParentResize();
        installWeBWorKSrcdocIntercept();
        installWeBWorKAjaxIntercept();
        addSubmitButton();

        // Add status badges to WeBWorK problems that the student has already answered.
        // WeBWorK is not a RunestoneBase component so the restore hook's decorateStatus
        // path never fires for it.  We add a lightweight badge to .problem-buttons so
        // it appears on page load (before Activate), matching the position of the
        // checkmarks Runestone shows for other exercise types.
        Object.keys(_state.answers).forEach(function (divId) {
            var container = document.getElementById(divId);
            if (!container || !container.dataset) return;

            // WeBWorK: data-domain identifies exercise-wrapper elements.
            if (container.dataset.domain) {
                addWeBWorKBadge(container, _state.answers[divId]);
            }

            // DoenetActivity: data-component="doenet" identifies the wrapper div.
            // Doenet resets its credit counter on load (by design), so we add a
            // lightweight badge showing the student's saved score from prior work.
            if (container.dataset.component === 'doenet') {
                addDoenetBadge(container, _state.answers[divId]);
            }
        });
    });


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 10 — PARENT FRAME RESIZE REQUEST
    // ═══════════════════════════════════════════════════════════════════════
    //
    // Canvas (and many other LMSes) embed SCORM content in an iframe whose
    // default height is too small for a page full of interactive exercises.
    // Canvas honors the same "lti.frameResize" postMessage that LTI tools and
    // SPLICE activities use to request a taller frame.
    //
    // We send this message at DOMContentLoaded (initial render) and again
    // whenever the page body grows taller — Runestone loads its components
    // asynchronously via webpack dynamic imports, so the page height can
    // increase significantly after the initial paint.
    //
    // If the parent frame is not Canvas (or does not support lti.frameResize),
    // the message is silently ignored — it has no effect on SCORM behavior.

    /**
     * Measure the current page height and ask the parent frame to match it.
     * Called at DOMContentLoaded and again by the ResizeObserver below.
     */
    function requestParentResize() {
        // Don't send if we are already the top-level window (not in an iframe)
        if (window.parent === window) return;

        // scrollHeight reflects the full content height even when the iframe
        // itself is clipped.  We take the larger of the two scroll height
        // properties for cross-browser compatibility, with a 600px floor so
        // the frame is never unreasonably tiny even on a nearly empty page.
        var height = Math.max(
            document.documentElement.scrollHeight,
            document.body ? document.body.scrollHeight : 0,
            600
        );

        var msg = { subject: 'lti.frameResize', height: height };

        // Send to the immediate parent (the SCORM player frame).
        window.parent.postMessage(msg, '*');

        // Also send to the top-level Canvas page.  Canvas's SCORM player
        // (iad.scorm.canvaslms.com) wraps our content in its own iframe, so
        // window.parent is the SCORM player — not Canvas itself.  The outer
        // iframe's height is constrained by CSS on the Canvas page, and the
        // SCORM player does not forward resize requests upward on our behalf.
        // Sending directly to window.top gives Canvas's own JS a chance to
        // intercept the message and resize the outer iframe.  Whether Canvas
        // honours lti.frameResize from SCORM content (vs. LTI tools) is
        // LMS-version-dependent and not guaranteed, but the message is harmless.
        if (window.top !== window.parent) {
            try {
                window.top.postMessage(msg, '*');
            } catch (e) {
                // Blocked by browser security in rare sandboxed-iframe configs
            }
        }
    }

    // Watch the document body for size changes and re-request a resize each
    // time it grows (e.g. as Runestone components finish rendering, or as a
    // student expands a knowl or feedback panel).
    //
    // ResizeObserver is supported by all modern browsers.  If it is somehow
    // unavailable we rely on the single DOMContentLoaded call above, which
    // is better than nothing.
    if (window.ResizeObserver) {
        var _resizeObserver = new ResizeObserver(function () {
            requestParentResize();
        });
        // Start observing once the body exists.  At script-parse time the body
        // is already present (this script runs before </body>), but we guard
        // with a DOMContentLoaded fallback just in case.
        if (document.body) {
            _resizeObserver.observe(document.body);
        } else {
            document.addEventListener('DOMContentLoaded', function () {
                _resizeObserver.observe(document.body);
            });
        }
    }


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 11 — SPLICE ACTIVITY EVENT HANDLING
    // ═══════════════════════════════════════════════════════════════════════
    // SPLICE (Supporting Pedagogy and Learning In Computing Education) is a
    // protocol that lets third-party interactive activities embedded in iframes
    // report events and scores to a parent page via window.postMessage.
    // See: https://cssplice.github.io/
    //
    // In a PreTeXt/Runestone context, a SPLICE activity lives in an <iframe>
    // wrapped by a <div data-component="splice"> element.  When the student
    // completes an activity, the iframe sends a "SPLICE.sendEvent" postMessage.
    // Runestone's SpliceComponent intercepts this and calls logBookEvent() —
    // but it does NOT pass the score through to logBookEvent (it only passes
    // the event name in the act field).  Our logBookEvent monkey-patch in
    // Section 9 therefore sees these events but gets no score to record.
    //
    // We solve this by listening for "message" events directly on the window,
    // extracting the score from the raw SPLICE payload, and forwarding a
    // synthetic event to recordInteraction().
    //
    // SPLICE sendEvent message format (from the SPLICE spec):
    //   {
    //     subject:          "SPLICE.sendEvent",
    //     activity_id:      "...",   // tool-assigned identifier for this activity
    //     name:             "...",   // event type, e.g. "answer", "attempt", "hint"
    //     score:            0.8,     // [0.0, 1.0] learner score (optional)
    //     completionStatus: "...",   // "completed", "incomplete", etc. (optional)
    //     data:             { ... }  // additional event-specific data (optional)
    //   }
    //
    // We only record events that carry a numeric score.  Unscored events (e.g.
    // "hint", "started") are silently ignored, consistent with how we treat
    // short-answer submissions that have no automatic grade.

    window.addEventListener('message', function (event) {
        var data = event.data;
        if (!data || typeof data !== 'object') return;

        // ── SPLICE.getState — Doenet requests its saved state when the iframe loads ──
        //
        // When a Doenet iframe initializes it sends { subject: 'SPLICE.getState' } to
        // the parent window.  We respond with the state blob we saved when the student
        // last answered.  If there is no saved state (first visit), we send null so
        // Doenet knows to start fresh rather than waiting indefinitely.
        //
        // If this arrives before loadRestoreData() has run (before DOMContentLoaded),
        // _learnerId is not yet set and localStorage reads would use the wrong key.
        // We queue the request and flush it once loadRestoreData() completes.
        if (data.subject === 'SPLICE.getState') {
            if (!_doenetRestoreReady) {
                // _learnerId not yet known — queue and handle after loadRestoreData() runs
                _pendingGetStateRequests.push({ source: event.source, messageId: data.message_id });
            } else {
                respondToGetState(event.source, data.message_id);
            }
            return;
        }

        // Guard: only handle SPLICE.sendEvent messages with a numeric score.
        // Accept SPLICE.sendEvent (generic SPLICE activities that carry a score)
        // and SPLICE.reportScoreAndState (DoenetActivity's scored event).
        // All other subjects are silently ignored.
        var isSpliceEvent = (data.subject === 'SPLICE.sendEvent' ||
                             data.subject === 'SPLICE.reportScoreAndState');
        if (!isSpliceEvent) return;

        // When the Runestone logBookEvent hook is installed, SpliceWrapper intercepts
        // SPLICE.reportScoreAndState and calls logBookEvent → recordInteraction.
        // Handling it here too would write a duplicate SCORM interaction record.
        // We still save the Doenet state blob so SPLICE.getState can restore it,
        // then return so only the Runestone hook records the interaction.
        if (data.subject === 'SPLICE.reportScoreAndState' &&
            window.RunestoneBase && window.RunestoneBase.__ptxScormHooked) {
            // Belt-and-suspenders: save state for SPLICE.getState, but only when
            // this is a genuine student submission (score > 0, or we have no saved
            // score yet).  The init auto-report fires with score=0 after we send
            // SPLICE.getState.response; saving that would overwrite the better saved state.
            if (data.state && typeof data.score === 'number') {
                var reportId = resolveIframeId(event.source);
                if (reportId) {
                    var savedEntry = _state.answers[reportId];
                    var savedPct = savedEntry && typeof savedEntry.percent === 'number' ? savedEntry.percent : null;
                    if (data.score > 0 || savedPct === null || data.score >= savedPct) {
                        saveDoenetState(reportId, data.state);
                    }
                }
            }
            return;
        }

        if (typeof data.score !== 'number') return;

        // ── Resolve the exercise div_id from the source iframe ────────────────
        //
        // The SPLICE activity runs inside an <iframe>.  We identify which iframe
        // sent the message by matching its contentWindow against event.source,
        // then walk up the DOM to the enclosing component wrapper div, which
        // carries the Runestone/SCORM-assigned xml:id for this activity.
        //
        // Priority:
        //   1. [data-component="splice"] wrapper — plain SPLICE iframe exercise
        //   2. [data-component="doenet"] wrapper — DoenetActivity in SCORM/Runestone
        //   3. The iframe's own id attribute — SCORM fallback when no wrapper exists
        //   4. data.activity_id from the SPLICE message itself — last resort
        var divId = data.activity_id || 'splice-unknown';
        var iframeMatched = false;
        var iframes = document.getElementsByTagName('iframe');
        for (var i = 0; i < iframes.length; i++) {
            if (iframes[i].contentWindow === event.source) {
                iframeMatched = true;
                var container = iframes[i].closest('[data-component="splice"],[data-component="doenet"]');
                if (container && container.id) {
                    divId = container.id;
                } else if (iframes[i].id) {
                    divId = iframes[i].id;
                }
                break;
            }
        }
        if (!iframeMatched) {
            console.warn('[PTX-SCORM] SPLICE: could not match event.source to any iframe on page.',
                         'Using div_id:', divId,
                         '| iframes on page:', iframes.length);
        }

        // ── Normalize the score to [0.0, 1.0] ────────────────────────────────
        //
        // The SPLICE spec defines score as a value in [0, 1], but some activities
        // send a percentage in [0, 100].  Values greater than 1 are treated as
        // percentage points and divided by 100.
        var score = data.score;
        if (score > 1) score = score / 100;
        score = Math.max(0, Math.min(1, score));

        // ── Forward to recordInteraction as a synthetic event ─────────────────
        //
        // We reuse the same recordInteraction() path as all other exercise types.
        // The synthetic event must have:
        //   ev.event   — key in RUNESTONE_TO_SCORM_TYPE ('splice' → 'performance')
        //   ev.div_id  — the exercise's stable Runestone/xml:id
        //   ev.percent — numeric score in [0, 1], read by extractScore()
        //   ev.answer  — the learner's response; prefer data.data (DoenetActivity
        //                sends answer detail there) and fall back to the event name.
        var answer = data.data ? JSON.stringify(data.data) : (data.name || '');
        try {
            recordInteraction({
                event:   'splice',
                div_id:  divId,
                answer:  answer,
                percent: score
            });
        } catch (err) {
            console.error('[PTX-SCORM] Error recording SPLICE event:', err);
        }
    });


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 12 — WEBWORK AJAX EVENT HANDLING
    // ═══════════════════════════════════════════════════════════════════════
    //
    // WeBWorK problems in PreTeXt are rendered in iframes but graded by an
    // external WeBWorK server.  Runestone's pretext-webwork.js makes a jQuery
    // AJAX call from the parent page to the WeBWorK server and, upon success,
    // fires a "runestone_ww_check" event on the document body — BUT only when
    // eBookConfig.username !== '' (i.e. the user is logged in to Runestone).
    //
    // In a SCORM package, eBookConfig.username is always '' (no Runestone login),
    // so runestone_ww_check never fires and our logBookEvent hook in Section 9
    // never sees WeBWorK submissions.
    //
    // FIX: We intercept jQuery's ajaxComplete event directly.  ajaxComplete fires
    // for every jQuery AJAX call regardless of Runestone login state.  We inspect
    // the response and, if it looks like a WeBWorK grading result, we extract the
    // score and answer data and call recordInteraction() ourselves.
    //
    // WeBWorK server response format:
    //   {
    //     rh_result: {
    //       answers: {
    //         ans1: { score: 0|1, student_value: "...", correct_value: "..." },
    //         ans2: { ... },
    //         ...
    //       }
    //     },
    //     inputs_ref: {
    //       problemUUID: "my-ww-problem-ww-rs",  // Runestone container id
    //       WWcorrectAnsOnly: 1,                 // present for "show answer" only
    //       ans1: "student answer value",         // newer WW versions
    //       ...
    //     },
    //     ww_version: "2.19"                     // or "2.20", etc.
    //   }
    //
    // Score calculation mirrors Runestone's processCurrentAnswers():
    //   overall = (sum of per-part scores) / (number of answer parts)
    //
    // Answer object format (matching what Runestone's logBookEvent receives):
    //   JSON.stringify({ answers: {ans1: "val"}, mqAnswers: {MaThQuIlL_ans1: "val"} })

    // Add a small status badge to a WeBWorK container's .problem-buttons row,
    // matching the visual position of Runestone's checkmarks on other components.
    // Called at page-load time so it appears before the student clicks Activate.
    // Show a small score badge on a DoenetActivity container when the student has
    // prior work saved.  Doenet resets its own credit display on every page load
    // (it only shows credit after the student clicks Submit again), so this badge
    // provides a persistent visual reminder of the student's saved score.
    function addDoenetBadge(container, saved) {
        var existing = container.querySelector('.ptx-scorm-doenet-badge');
        if (existing) existing.remove();

        var pct = typeof saved.percent === 'number' ? saved.percent : null;
        if (pct === null) return;

        var badge = document.createElement('div');
        badge.className = 'ptx-scorm-doenet-badge';

        var pctLabel = Math.round(pct * 100) + '%';
        if (pct >= 1) {
            badge.textContent = '✓ Previously answered correctly (' + pctLabel + ')';
            badge.style.cssText = 'padding:4px 8px;margin-bottom:4px;background:#d4edda;color:#155724;border:1px solid #c3e6cb;border-radius:4px;font-size:0.9em;';
        } else if (pct > 0) {
            badge.textContent = '▶ Previously answered — score: ' + pctLabel;
            badge.style.cssText = 'padding:4px 8px;margin-bottom:4px;background:#fff3cd;color:#856404;border:1px solid #ffeeba;border-radius:4px;font-size:0.9em;';
        } else {
            badge.textContent = '✗ Previously answered — no credit earned';
            badge.style.cssText = 'padding:4px 8px;margin-bottom:4px;background:#f8d7da;color:#721c24;border:1px solid #f5c6cb;border-radius:4px;font-size:0.9em;';
        }

        container.insertBefore(badge, container.firstChild);
    }

    function addWeBWorKBadge(container, saved) {
        var existing = container.querySelector('.ptx-scorm-ww-badge');
        if (existing) existing.remove();

        var badge = document.createElement('span');
        badge.className = 'ptx-scorm-ww-badge';

        var pct = typeof saved.percent === 'number' ? saved.percent : null;
        var correct = saved.correct;
        if (correct === true || pct >= 1) {
            badge.textContent = '✓';
            badge.style.cssText = 'float:right;color:green;font-weight:bold;';
        } else if (correct === false || (pct !== null && pct < 1)) {
            badge.textContent = '✗';
            badge.style.cssText = 'float:right;color:#c00;font-weight:bold;';
        } else {
            return; // no meaningful status — skip badge
        }

        // Add to .problem-buttons so it sits on the same row as the Activate button.
        var buttons = container.querySelector('.problem-buttons');
        if (buttons) {
            buttons.appendChild(badge);
        } else {
            container.insertBefore(badge, container.firstChild);
        }
    }

    // Answer restoration for WeBWorK uses two complementary steps:
    //
    // 1. ajaxSend pre-seeds the answer values in the WeBWorK init request so the
    //    server renders the form inputs with value= attributes already filled in.
    //    We do NOT use WWsubmit=1 here: submitted-state rendering causes MathQuill
    //    to re-initialise from scratch (discarding any pre-rendered overlay spans),
    //    producing the "flash then blank" symptom.
    //
    // 2. patchWeBWorKSrcdoc / installWeBWorKSrcdocIntercept intercept the srcdoc
    //    set by pretext-webwork.js.  After the iframe loads, we drive the MathQuill
    //    display directly via window.MathQuill.MathField(input).latex(value), which
    //    is the only reliable way to update MathQuill's overlay spans after the
    //    library has already initialised.

    /**
     * Called synchronously when pretext-webwork.js sets iframe.srcdoc.
     * Injects saved answer values into empty <input value=""> elements (belt),
     * then schedules a post-load MathQuill API call to update the overlay display
     * (suspenders) — necessary because MathQuill re-initialises its overlay from
     * the <input> element's value at load time, ignoring pre-rendered markup.
     */
    function patchWeBWorKSrcdoc(iframe, html) {
        try {
            // Walk up from the iframe to the exercise-wrapper element (has data-domain).
            var container = iframe.parentElement;
            while (container && !container.dataset.domain) {
                container = container.parentElement;
            }
            if (!container) return html;

            var divId = container.id.replace(/-ww-rs$/, '');
            var saved = _state.answers[divId] || _state.answers[container.id];
            if (!saved || !saved.answer) return html;

            var answerObj;
            try { answerObj = JSON.parse(saved.answer); } catch (e) { return html; }
            if (!answerObj || !answerObj.answers) return html;

            var answers = answerObj.answers;
            var keys = Object.keys(answers);
            if (keys.length === 0) return html;

            var doc = new DOMParser().parseFromString(html, 'text/html');
            var injected = [];

            keys.forEach(function (k) {
                var input = doc.getElementById(k);
                if (!input) return;
                var cur = input.getAttribute('value');
                // Only inject when absent or empty; trust the server value otherwise.
                if (cur === null || cur === '') {
                    input.setAttribute('value', String(answers[k]));
                    injected.push(k);
                }
            });

            if (injected.length > 0) {
                html = '<!DOCTYPE html>' + doc.documentElement.outerHTML;
            }

            // After the iframe loads, drive MathQuill's overlay display via its API.
            // Setting the HTML value= attribute alone is not enough: MathQuill manages
            // its own overlay spans and re-initialises them from the <input> on load.
            // window.MathQuill (the v2 interface) and window.answerQuills (WeBWorK's
            // per-answer MathField registry) are both available at this point.
            iframe.addEventListener('load', function () {
                setTimeout(function () {
                    try {
                        var iWin = iframe.contentWindow;
                        var iDoc = iframe.contentDocument;
                        if (!iWin || !iDoc) return;

                        var answerQuills = iWin.answerQuills;

                        // Resolve the MathQuill v2 interface.  In this WeBWorK version
                        // window.MathQuill IS the interface (not the factory), so we use
                        // .MathField(el) rather than calling MQI(el) directly.
                        var MQraw = iWin.MathQuill || iWin.MQ;
                        var MQI = MQraw
                            ? (typeof MQraw.getInterface === 'function' ? MQraw.getInterface(2) : MQraw)
                            : null;

                        function getMathField(inp) {
                            if (!MQI) return null;
                            if (typeof MQI.MathField === 'function') return MQI.MathField(inp);
                            if (typeof MQI === 'function')            return MQI(inp);
                            return null;
                        }

                        var restored = [];
                        keys.forEach(function (k) {
                            var val = String(answers[k]);

                            // Try WeBWorK's answerQuills registry first (MathField stored
                            // as a named property on the wrapper object).
                            var aq = answerQuills && answerQuills[k];
                            if (aq) {
                                var mqf = aq.mathField || aq.mathfield || aq.mq || aq._mathField;
                                if (mqf && typeof mqf.latex === 'function') {
                                    try { mqf.latex(val); restored.push(k); return; } catch (e) {}
                                }
                                if (typeof aq.setLatex === 'function') {
                                    try { aq.setLatex(val); restored.push(k); return; } catch (e) {}
                                }
                                if (typeof aq.latex === 'function') {
                                    try { aq.latex(val); restored.push(k); return; } catch (e) {}
                                }
                            }

                            var inp = iDoc.getElementById(k);
                            if (!inp) return;

                            // Fall back to MathQuill API on the input element.
                            try {
                                var mf = getMathField(inp);
                                if (mf && typeof mf.latex === 'function') {
                                    mf.latex(val); restored.push(k); return;
                                }
                            } catch (e) { /* fall through */ }

                            // Last resort: plain value assignment for non-MathQuill inputs.
                            inp.value = val;
                            inp.dispatchEvent(new Event('input', { bubbles: true }));
                            restored.push(k);
                        });

                        if (restored.length > 0) {
                            console.log('[PTX-SCORM] WeBWorK: restored "' + divId + '" (' + restored.join(', ') + ').');
                        } else {
                            console.warn('[PTX-SCORM] WeBWorK: no answer inputs found for "' + divId + '".');
                        }
                    } catch (e) {
                        console.error('[PTX-SCORM] WeBWorK post-load error:', e);
                    }
                }, 150);
            }, { once: true });

            return html;
        } catch (e) {
            console.error('[PTX-SCORM] WeBWorK srcdoc patch error:', e);
            return html;
        }
    }

    /**
     * Override HTMLIFrameElement.prototype.srcdoc so that whenever
     * pretext-webwork.js sets iframe content on a .problem-iframe element,
     * we inject the saved answer into the <input value=""> before MathQuill
     * initialises.  This is the synchronous counterpart to Runestone's own
     * DOM patching in pretext-webwork.js lines 223-240.
     */
    function installWeBWorKSrcdocIntercept() {
        var proto = HTMLIFrameElement.prototype;
        var desc = Object.getOwnPropertyDescriptor(proto, 'srcdoc');
        if (!desc || !desc.set) {
            console.warn('[PTX-SCORM] Cannot intercept iframe.srcdoc — descriptor unavailable.');
            return;
        }
        var originalSet = desc.set;
        Object.defineProperty(proto, 'srcdoc', {
            configurable: true,
            get: desc.get,
            set: function (html) {
                if (this.classList && this.classList.contains('problem-iframe')) {
                    html = patchWeBWorKSrcdoc(this, html);
                }
                originalSet.call(this, html);
            }
        });
        console.log('[PTX-SCORM] WeBWorK srcdoc intercept installed.');
    }

    /**
     * Parse one WeBWorK AJAX response and call recordInteraction() if it is a
     * student submission.  Called from the jQuery ajaxComplete handler below.
     */
    function handleWeBWorKAjaxResponse(xhr) {
        var responseText = xhr && xhr.responseText;
        if (!responseText) return;

        var data;
        try { data = JSON.parse(responseText); } catch (e) { return; }

        // Identify WeBWorK grading responses by their distinctive structure
        if (!data || !data.rh_result || !data.rh_result.answers) return;
        if (!data.inputs_ref) return;

        // Skip "show correct answer" (reveal) requests — these are not student submissions
        if (data.inputs_ref.WWcorrectAnsOnly) return;

        // Resolve the Runestone component div_id from problemUUID.
        // Embedded WeBWorK appends "-ww-rs" to the Runestone container id;
        // strip that suffix to get the actual div_id used in _state.answers.
        var wwId = data.inputs_ref.problemUUID || '';
        if (!wwId) return;
        var divId = wwId.replace(/-ww-rs$/, '');

        var rhAnswers = data.rh_result.answers;
        var keys = Object.keys(rhAnswers);
        if (keys.length === 0) return;

        // ── Calculate overall score ───────────────────────────────────────────
        // Each answer part has a 0–1 score.  Overall percent = mean of parts.
        var totalScore = 0;
        var numCorrect = 0;
        keys.forEach(function (key) {
            var partScore = parseFloat(rhAnswers[key].score) || 0;
            totalScore += partScore;
            if (partScore >= 1) numCorrect++;
        });
        var percent = totalScore / keys.length;

        // ── Reconstruct answerObj (matches Runestone's this.answerObj) ────────
        // Newer WeBWorK (2.20+) stores student answers in inputs_ref[key].
        // or rh_result.answers[key].original_student_ans.
        // MathQuill answers are keyed "MaThQuIlL_<key>" and go into mqAnswers.
        var answerValues = {};
        var mqAnswerValues = {};
        keys.forEach(function (key) {
            var part = rhAnswers[key];
            var studentAns = (data.inputs_ref[key] != null)
                ? data.inputs_ref[key]
                : (part.student_value || part.original_student_ans || '');

            if (key.indexOf('MaThQuIlL_') === 0) {
                mqAnswerValues[key] = studentAns;
            } else {
                answerValues[key] = studentAns;
            }
        });
        var answerObj = { answers: answerValues, mqAnswers: mqAnswerValues };

        // ── Build actString matching pretext-webwork.js format ────────────────
        // Format: "check:actual:N:expected:M:correct:K:count:T:pct:P"
        var actString = 'check:actual:' + numCorrect +
                        ':expected:' + keys.length +
                        ':correct:' + numCorrect +
                        ':count:' + keys.length +
                        ':pct:' + percent.toFixed(4);

        console.log('[PTX-SCORM] WeBWorK AJAX response intercepted — div_id: "' + divId +
                    '", score: ' + (percent * 100).toFixed(0) + '%');

        try {
            recordInteraction({
                event:   'webwork',
                div_id:  divId,
                act:     actString,
                answer:  JSON.stringify(answerObj),
                percent: percent
            });
        } catch (err) {
            console.error('[PTX-SCORM] Error recording WeBWorK event:', err);
        }
    }

    /**
     * Register the jQuery ajaxComplete listener that drives handleWeBWorKAjaxResponse.
     *
     * jQuery is loaded by the Runestone bundle asynchronously; at DOMContentLoaded it
     * may or may not be available yet.  We try immediately, then fall back to
     * window "load" (fired after all scripts have executed).
     *
     * We guard the actual interception on eBookConfig.username === '' so that we
     * never double-count in a Runestone-logged-in context (where runestone_ww_check
     * would fire AND we would also see the AJAX response).
     */

    function installWeBWorKAjaxIntercept() {
        // Tracks which jQuery XHR objects correspond to student "check" submissions.
        // ajaxComplete fires for ALL jQuery AJAX calls — including the automatic
        // "render problem" request that WeBWorK makes on activation, which also
        // returns rh_result.answers (the correct answer key, not a student submission).
        // We use ajaxSend to inspect the outgoing request parameters and mark only
        // genuine check submissions, then ignore everything else in ajaxComplete.
        // Plain arrays (not Set) for maximum compatibility — short-lived entries only.
        var _pendingChecks = [];


        function doInstall() {
            if (!window.jQuery) return false;

            // Only intercept when Runestone services are off (SCORM context).
            // If someone is logged in to Runestone, runestone_ww_check will fire
            // and our logBookEvent hook (Section 9) handles it — no need for AJAX interception.
            var runestoneLoggedIn = (typeof eBookConfig !== 'undefined' && eBookConfig.username !== '');
            if (runestoneLoggedIn) {
                console.log('[PTX-SCORM] Runestone login detected — WeBWorK AJAX intercept skipped.');
                return true;
            }

            // ajaxSend fires before the request is sent.  Inspect the posted form data
            // (URL-encoded string) to identify WeBWorK "Check Answers" submissions.
            //
            // pretext-webwork.js uses two different calls to the WeBWorK render_rpc endpoint:
            //   Init/activation (no action arg): answersSubmitted=0  — renders the problem UI
            //   Student check  (action="check"): answersSubmitted=1  — grades the answers
            //   Reveal answer  (action="reveal"): answersSubmitted=1 + WWcorrectAnsOnly=1
            //
            // Note: "action" is a local JS variable in handleWW(), NOT a form field.
            // The correct discriminator in the request body is answersSubmitted.
            jQuery(document).on('ajaxSend', function (event, xhr, settings) {
                try {
                    var urlData = settings.data;
                    if (typeof urlData !== 'string') return;
                    var params = new URLSearchParams(urlData);
                    // answersSubmitted=1 means student submitted; WWcorrectAnsOnly=1 means reveal
                    if (params.get('answersSubmitted') === '1' && !params.get('WWcorrectAnsOnly')) {
                        _pendingChecks.push(xhr);
                    } else if (params.get('answersSubmitted') === '0' && params.get('problemUUID')) {
                        // Init/activation call.  Pre-seed saved answer values so WeBWorK renders
                        // the inputs with value="..." baked into the HTML.  Step 2 (patchWeBWorKSrcdoc
                        // post-load) then drives MathQuill to display those values visually.
                        var problemUUID = params.get('problemUUID');
                        var divId = problemUUID.replace(/-ww-rs$/, '');
                        var saved = _state.answers[divId] || _state.answers[problemUUID];
                        if (saved && saved.answer) {
                            try {
                                var ansObj = JSON.parse(saved.answer);
                                if (ansObj && ansObj.answers) {
                                    var ansKeys = Object.keys(ansObj.answers);
                                    if (ansKeys.length > 0) {
                                        ansKeys.forEach(function (k) {
                                            params.set(k, String(ansObj.answers[k]));
                                        });
                                        // Do NOT set WWsubmit=1: submitted-state rendering causes
                                        // MathQuill to re-init from an empty <input>, overwriting the
                                        // pre-rendered overlay spans with a blank widget ("flash then blank").
                                        // Instead we send a fresh render with the values pre-populated and
                                        // inject via MathQuill API after the iframe loads (patchWeBWorKSrcdoc).
                                        settings.data = params.toString();
                                        console.log('[PTX-SCORM] WeBWorK init: pre-seeded form for "' + divId + '" — keys: ' + ansKeys.join(', '));
                                    }
                                }
                            } catch (e) { /* leave data unmodified on error */ }
                        }
                    }
                } catch (e) { /* ignore parse errors */ }
            });

            // ajaxComplete fires after every request (success or error).
            // Only process requests we marked in ajaxSend above.
            jQuery(document).on('ajaxComplete', function (event, xhr) {
                var checkIdx = _pendingChecks.indexOf(xhr);
                if (checkIdx !== -1) {
                    _pendingChecks.splice(checkIdx, 1);
                    try { handleWeBWorKAjaxResponse(xhr); } catch (err) {
                        console.error('[PTX-SCORM] Error in WeBWorK AJAX handler:', err);
                    }
                }
            });

            console.log('[PTX-SCORM] WeBWorK AJAX intercept registered (check-action only).');
            return true;
        }

        if (!doInstall()) {
            // jQuery not yet available — retry after all scripts have loaded
            window.addEventListener('load', doInstall);
        }
    }


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 13 — ANSWER RESTORATION
    // ═══════════════════════════════════════════════════════════════════════
    //
    // Runestone normally fetches each student's saved answer from the Runestone
    // Academy server in RunestoneBase.checkServer().  A SCORM package has no
    // such server, so the original checkServer falls through to checkLocalStorage()
    // — which is empty when the student opens the page in a new browser session.
    //
    // We solve this by:
    //   1. Calling SCORM Initialize() eagerly at DOMContentLoaded so we can read
    //      cmi.suspend_data before any Runestone component calls checkServer.
    //   2. Building a "restore map" from _state.answers (which is persisted in
    //      suspend_data): a plain object keyed by div_id whose values match the
    //      data shape that restoreAnswers() expects — {answer, correct, percent, div_id}.
    //   3. Monkey-patching RunestoneBase.prototype.checkServer so that, if we have
    //      a saved answer for a component, we call its restoreAnswers() directly
    //      instead of going to the (absent) server.
    //
    // WHY suspend_data AND NOT cmi.interactions.*
    // -------------------------------------------
    // cmi.interactions.* would seem like the natural place to read saved answers
    // from — after all, that is where we write them.  The problem is that the SCORM
    // spec allows (and many LMSes implement) resetting cmi.interactions at the start
    // of each new attempt, so a student who opens the course a second time would see
    // an empty interactions collection and get no answers restored.
    //
    // cmi.suspend_data is explicitly defined in the SCORM spec as a persistent store
    // for SCO state and is guaranteed to survive across sessions.  We therefore store
    // the answers map in _state.answers (part of suspend_data) in addition to writing
    // individual records to cmi.interactions.* (for the LMS grade-book display).
    //
    // TIMING: Runestone lazy-loads component code via webpack dynamic imports.
    // checkServer() is therefore called asynchronously — well after DOMContentLoaded
    // has fired.  Our patched checkServer is installed synchronously during the
    // DOMContentLoaded handler, so it is always in place before any component runs.
    //
    // ANSWER FORMAT: ev.answer is stored in _state.answers[divId].answer.  For
    // compound types (FITB arrays, Parsons arrangements) this is the JSON-stringified
    // form that Runestone wrote; restoreAnswers() calls JSON.parse() internally so
    // the round-trip is transparent.
    //
    // NOTE ON STORAGE SIZE: cmi.suspend_data is typically limited to 4096 characters
    // by the SCORM spec (though many LMSes allow more).  For assignments with many
    // long answers (e.g. verbose short-answer responses) this limit could be reached.
    // In practice, auto-graded exercise answers (MC selections, Parsons orderings, etc.)
    // are short and this limit is rarely hit for realistic assignments.

    /**
     * loadRestoreData() — Initialize SCORM eagerly and return a map of saved answers.
     *
     * Reads the answers map from cmi.suspend_data (where recordInteraction() stored it).
     * Called at DOMContentLoaded so the session is open and _state is fully populated
     * before Runestone's components call checkServer() (which happens asynchronously,
     * but always after DOMContentLoaded).
     *
     * After this call, initSession() is effectively a no-op (it guards on _initialized).
     *
     * Returns {divId: {answer, correct, percent, div_id}} for every previously answered
     * question, or {} if this is the student's first visit.
     */
    function loadRestoreData() {
        if (!_api) return {};

        // Open the SCORM session.  Error 103 ("already initialized") is harmless —
        // it means another frame already called Initialize() and we can proceed.
        var initResult = (_ver === '2004') ? _api.Initialize('') : _api.LMSInitialize('');
        var err = lastError();
        if (initResult !== 'true' && err !== '103') {
            console.warn('[PTX-SCORM] Initialize() failed during restore (error=' + err + ').');
            return {};
        }
        _initialized = true;

        // Cache the learner ID now that the session is open.  This is used as part
        // of the localStorage key so different students on the same browser are kept
        // separate.  (SCORM read calls only work after Initialize().)
        _learnerId = (_ver === '2004')
            ? Get('cmi.learner_id')
            : Get('cmi.core.student_id');

        // cmi.entry (2004) / cmi.core.entry (1.2) tells us how the LMS launched this
        // session:
        //   "ab-initio" — fresh start; the LMS has reset all runtime data
        //   "resume"    — the LMS is resuming a suspended attempt; data should be intact
        //   ""          — first visit, or the LMS does not distinguish
        //
        // If we see "ab-initio" on a return visit it confirms the LMS started a new
        // attempt (and has already cleared suspend_data).  We log it here so that
        // unexpected data-loss can be diagnosed by looking at the console.
        var entry = Get(_ver === '2004' ? 'cmi.entry' : 'cmi.core.entry');
        console.log('[PTX-SCORM] Session entry mode: ' + (entry || '(empty — first visit or LMS omits field)'));

        // ── Restore state — try suspend_data first, then localStorage ─────────
        //
        // We attempt both stores and use whichever has more saved answers,
        // because different LMSes behave differently:
        //
        //   • Some LMSes (Moodle, SCORM Cloud) persist suspend_data across sessions
        //     → suspend_data will have the answers.
        //   • Canvas and others reset suspend_data at the start of each new session
        //     → suspend_data will be empty; localStorage has the answers.
        //   • First visit: both are empty — student starts fresh.
        //
        // We also read from localStorage when suspend_data is present so that a
        // student who answered on two devices gets the union of both sets of answers
        // (or the richer one, since we pick whichever has more keys).

        // --- suspend_data ---
        var raw = Get('cmi.suspend_data');
        if (raw) {
            try {
                var saved = JSON.parse(raw);
                if (typeof saved.correct === 'number') _state.correct = saved.correct;
                if (typeof saved.graded  === 'number') _state.graded  = saved.graded;
                if (saved.answers && typeof saved.answers === 'object') {
                    _state.answers = saved.answers;
                }
            } catch (e) {
                console.warn('[PTX-SCORM] Could not parse suspend_data:', e);
            }
        }

        // --- localStorage ---
        // Use localStorage data when it has more answers than suspend_data
        // (e.g. suspend_data was just reset by the LMS at session start).
        var lsData = loadFromLocalStorage();
        if (lsData && lsData.answers) {
            var lsCount = Object.keys(lsData.answers).length;
            var sdCount = Object.keys(_state.answers).length;
            if (lsCount > sdCount) {
                if (typeof lsData.correct === 'number') _state.correct = lsData.correct;
                if (typeof lsData.graded  === 'number') _state.graded  = lsData.graded;
                _state.answers = lsData.answers;
                console.log('[PTX-SCORM] Using localStorage state (' + lsCount +
                            ' answers — more than suspend_data\'s ' + sdCount + ').');
            }
        }

        // ── Set session interaction index from LMS ───────────────────────────
        //
        // cmi.interactions._count is the number of interactions already recorded in
        // this session.  Using it as the starting iCount ensures new answers are
        // appended rather than overwriting slots that were already written this session.
        // We do NOT carry over iCount from suspend_data here — in a new attempt the
        // LMS has reset interactions to 0 and we must start from there.
        var lmsCount = parseInt(Get('cmi.interactions._count') || '0', 10);
        _state.iCount = isNaN(lmsCount) ? 0 : lmsCount;

        // ── Mark the SCO "incomplete" on very first visit ─────────────────────
        //
        // "incomplete" signals "the student has opened this content but not yet
        // finished".  We set it here so the LMS doesn't keep showing "not attempted"
        // after the student's first page load, even before they answer anything.
        var completionKey = (_ver === '2004') ? 'cmi.completion_status' : 'cmi.core.lesson_status';
        var currentStatus = Get(completionKey);
        if (currentStatus === 'not attempted' || currentStatus === 'unknown' || currentStatus === '') {
            Set(completionKey, 'incomplete');
            Commit();
        }
        // Remember if the LMS already shows "completed" (e.g. returning student on
        // an LMS that preserves status across attempts, such as Blackboard).
        _statusCompleted = (currentStatus === 'completed' || currentStatus === 'passed');

        // ── Build restore map from saved answers ─────────────────────────────
        //
        // _state.answers holds every answer the student has ever submitted, keyed by
        // div_id.  Turn it into the shape that installRestoreHook/restoreAnswers expect.
        var map = {};
        var divIds = Object.keys(_state.answers || {});

        divIds.forEach(function (divId) {
            var entry = _state.answers[divId];
            var restoreEntry = {
                answer:  entry.answer,
                correct: entry.correct,
                percent: entry.percent,
                div_id:  divId
            };
            map[divId] = restoreEntry;
            // WeBWorK: we strip the '-ww-rs' suffix when saving (from inputs_ref.problemUUID)
            // but the Runestone component's this.divid retains the suffix in the DOM.
            // Add both keys so the restore hook fires regardless of which form is used.
            if (divId.indexOf('-ww-rs') === -1) {
                map[divId + '-ww-rs'] = restoreEntry;
            }
        });

        // ── Re-report accumulated score to LMS ───────────────────────────────
        //
        // Without this the LMS shows 0 (or the new-session default) until the
        // student explicitly answers another question.  Re-reporting here keeps the
        // grade book accurate even when the student only reviews the page.
        //
        // _totalQuestions is already set: countPageQuestions() runs just before this
        // function in the DOMContentLoaded handler.
        if (_state.graded > 0) {
            var denom = _totalQuestions > 0 ? _totalQuestions : _state.graded;
            var restoredScaled = _state.correct / denom;

            if (_ver === '2004') {
                Set('cmi.score.scaled', restoredScaled.toFixed(4));
                Set('cmi.score.raw',    (restoredScaled * 100).toFixed(1));
                Set('cmi.score.min',    '0');
                Set('cmi.score.max',    '100');
            } else {
                Set('cmi.core.score.raw', (restoredScaled * 100).toFixed(1));
            }
            // Write back the (slimmed) state so suspend_data stays coherent
            // with what we have in memory without exceeding the LMS size limit.
            Set('cmi.suspend_data', buildSuspendData());
            Commit();

            console.log('[PTX-SCORM] Restored score: ' +
                        (restoredScaled * 100).toFixed(0) + '%' +
                        '  (' + _state.correct.toFixed(2) + ' / ' + denom + ')' +
                        '  [' + _state.graded + ' answered, ' + _totalQuestions + ' total]');
        }

        var n = Object.keys(map).length;
        if (n === 0) {
            console.log('[PTX-SCORM] No saved answers found (first visit or no prior submissions).');
        } else {
            console.log('[PTX-SCORM] Loaded ' + n + ' saved answer(s) for UI restoration.');
        }

        // Flush any SPLICE.getState requests that arrived before _learnerId was set.
        // Mark ready first so any requests arriving during the flush are handled inline.
        _doenetRestoreReady = true;
        _pendingGetStateRequests.forEach(function (req) { respondToGetState(req.source, req.messageId); });
        _pendingGetStateRequests = [];

        return map;
    }

    /**
     * installRestoreHook(restoreMap) — Patch checkServer to restore SCORM answers.
     *
     * RunestoneBase.prototype.checkServer is the method each Runestone component
     * calls during initialization to fetch the student's previously saved answer.
     * We replace it with a version that consults restoreMap first.
     *
     * If we have SCORM data for a component:
     *   - Call component.restoreAnswers(data) with the saved answer.
     *   - Call component.setLocalStorage(data) so the answer is also cached in
     *     the browser, enabling the component's own internal restore logic.
     *   - Call component.decorateStatus() to show the correct/incorrect indicator.
     *
     * If we have no data for a component (e.g. the student never answered it):
     *   - Fall through to the original checkServer, which calls checkLocalStorage().
     *
     * The checkServerComplete Promise is created and resolved in both paths so
     * that any Runestone code awaiting component initialization can proceed.
     */
    function installRestoreHook(restoreMap) {
        if (!window.RunestoneBase || window.RunestoneBase.__ptxScormRestoreHooked) return;

        var n = Object.keys(restoreMap).length;
        if (n === 0) {
            console.log('[PTX-SCORM] Skipping restore hook (no saved answers).');
            return;
        }

        var originalCheckServer = window.RunestoneBase.prototype.checkServer;

        window.RunestoneBase.prototype.checkServer = function (event, skipSelf) {
            var self = this;

            // checkServerComplete is a Promise that Runestone uses to sequence
            // component setup steps.  We must create and resolve it whether or
            // not we have SCORM data for this component.
            this.checkServerComplete = new Promise(function (resolve) {
                self.csresolver = resolve;
            });

            var data = restoreMap[this.divid];

            if (data !== undefined && data.answer) {
                // ── Restore from SCORM ──────────────────────────────────────────
                try {
                    // WeBWorK stores answerObj as a JSON string (since recordInteraction
                    // always stringifies the answer).  WeBWorK's restoreAnswers(e) does
                    // `this.answers = e.answer` and expects the parsed object, not the
                    // string.  We detect WeBWorK components by checking the DOM element
                    // and parse the answer back to an object before calling restoreAnswers.
                    var restoreData = data;
                    var compEl = document.getElementById(this.divid);
                    var isWeBWorK = compEl &&
                                    compEl.getAttribute('data-component') === 'webwork';
                    if (isWeBWorK && typeof data.answer === 'string') {
                        try {
                            var parsed = JSON.parse(data.answer);
                            if (parsed && typeof parsed === 'object' && parsed.answers) {
                                restoreData = { answer: parsed, correct: data.correct,
                                                percent: data.percent, div_id: data.div_id };
                            }
                        } catch (e) { /* leave as string if parse fails */ }
                    }

                    // Repopulate the component's UI with the saved answer.
                    this.restoreAnswers(restoreData);

                    // restoreAnswers() repopulates the UI (e.g. checks the right
                    // radio buttons) but most components do NOT set their internal
                    // result properties (this.correct, this.percent, this.answer)
                    // from the data argument.  decorateStatus() reads those properties
                    // to decide which badge to show, so we set them explicitly here.
                    if (data.correct !== null && data.correct !== undefined) {
                        this.correct = data.correct;
                    }
                    if (data.percent !== null && data.percent !== undefined) {
                        this.percent = data.percent;
                    }
                    // Use restoreData.answer so WeBWorK gets the parsed object form
                    if (restoreData.answer) {
                        this.answer = restoreData.answer;
                    }

                    // Cache the answer in localStorage so the component's own
                    // internal machinery (which may read localStorage later) also
                    // sees the restored state.  Pass restoreData so WeBWorK caches
                    // the parsed answerObj rather than the raw JSON string.
                    if (typeof this.setLocalStorage === 'function') {
                        this.setLocalStorage(restoreData);
                    }
                } catch (err) {
                    // Restoration failed for this component; let Runestone try
                    // its own fallback (localStorage) rather than showing nothing.
                    console.error('[PTX-SCORM] restoreAnswers failed for "' + this.divid + '":', err);
                    return originalCheckServer.call(this, event, skipSelf);
                }

                // decorateStatus() is called OUTSIDE the main try-catch so that a failure
                // here (e.g. WeBWorK iframe not yet rendered before activation) does not
                // cause us to fall back to originalCheckServer and lose the restored answer.
                // The badge will appear after the student's next submission if this fails.
                try {
                    if (typeof this.decorateStatus === 'function') {
                        this.decorateStatus();
                    }
                } catch (e) { /* WeBWorK: iframe not yet active — badge deferred */ }

                var statusLabel = data.correct === true  ? 'correct'
                                : data.correct === false ? 'incorrect'
                                : 'neutral';
                console.log('[PTX-SCORM] Restored "' + this.divid + '" (' + statusLabel + ').');
            } else {
                // ── No SCORM answer; use original Runestone flow ──────────────
                // This handles: components the student never answered, and any
                // answers that may have been cached in localStorage this session.
                return originalCheckServer.call(this, event, skipSelf);
            }

            // Resolve the promise so downstream Runestone initialization can proceed.
            if (typeof this.csresolver === 'function') this.csresolver();
        };

        window.RunestoneBase.__ptxScormRestoreHooked = true;
        console.log('[PTX-SCORM] Restore hook installed (' + n + ' saved answers available).');
    }


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 14 — EXPLICIT SUBMIT
    // ═══════════════════════════════════════════════════════════════════════
    //
    // submitSession() writes the final grade and commits it — and deliberately
    // does NOT call Terminate() or set cmi.exit.  Blackboard testing showed a
    // perfect separation: every sequence that ended with a bare Commit() and
    // left the session open recorded its score in the gradebook, while every
    // sequence that called Terminate() mid-page (regardless of exit value)
    // left the attempt stuck at "draft saved" with no score — even when the
    // grade had been committed successfully beforehand.  Terminating while
    // Blackboard's player frame is still up apparently voids the attempt;
    // terminating during real page unload (handlePageExit) finalizes it.
    //
    // So the submit flow is: commit the grade here, leave the session open,
    // and let handlePageExit() run its proven sequence (suspend_data,
    // exit="suspend", Commit, Terminate) when the student leaves the page.
    //
    // addSubmitButton() injects a visible "Submit Assignment" button at the
    // bottom of the page content.

    /**
     * Record the student's final grade as a submission: completion + score,
     * then Commit.  The SCORM session stays open; the attempt is finalized
     * by handlePageExit() when the student navigates away.
     */
    function submitSession() {
        if (!_api) {
            console.warn('[PTX-SCORM] submitSession: no SCORM API — nothing to submit.');
            return;
        }
        if (_submitted) {
            dbg('submitSession: already submitted this session — ignoring repeat click.');
            return;
        }
        initSession();  // normally a no-op — the session opens at page load
        if (!_initialized) {
            console.warn('[PTX-SCORM] submitSession: SCORM session is not open — nothing submitted.');
            return;
        }

        // Mark the SCO completed and write the final score, then Commit.  This
        // mirrors the sequence that reliably recorded a score in Blackboard:
        // completion + full score set (+ success_status on 2004), then a bare
        // Commit with no exit value pending and nothing else in the batch.
        var compKey = (_ver === '2004') ? 'cmi.completion_status' : 'cmi.core.lesson_status';
        Set(compKey, 'completed');
        _statusCompleted = true;

        // Re-assert the final score so this Commit carries it even if an
        // earlier per-question Commit was rejected.  A student who submits
        // without answering anything records a 0, which is correct for a
        // submitted assignment.
        var denom = _totalQuestions > 0 ? _totalQuestions : _state.graded;
        var scaled = denom > 0 ? (_state.correct / denom) : 0;
        if (_ver === '2004') {
            Set('cmi.score.scaled', scaled.toFixed(4));
            Set('cmi.score.raw',    (scaled * 100).toFixed(1));
            Set('cmi.score.min',    '0');
            Set('cmi.score.max',    '100');
            Set('cmi.success_status', 'passed');
        } else {
            Set('cmi.core.score.raw', (scaled * 100).toFixed(1));
            Set('cmi.core.score.min', '0');
            Set('cmi.core.score.max', '100');
        }

        Commit();

        // Deliberately NO cmi.exit write and NO Terminate() here.  On Blackboard,
        // a mid-page Terminate() voided the attempt ("draft saved", committed
        // grade discarded), while this bare grade-commit records the score.
        // suspend_data is already current (recordInteraction commits it after
        // every answer); the attempt is finalized by handlePageExit() when the
        // student leaves the page.
        saveToLocalStorage();

        _submitted = true;
        dbg('submitSession() complete — grade committed (scaled=' + scaled.toFixed(4) +
            '), session left open for page-exit finalization.');
        console.log('[PTX-SCORM] Assignment submitted — score committed. ' +
                    'The attempt is finalized when you leave this page.');
    }

    // Reference to the on-page score readout shown above the Submit button.
    // Updated after every answer so the student always sees their live score.
    var _scoreDisplayEl = null;

    /**
     * Refresh the on-page "current score" readout from _state.  Uses the same
     * formula as the submitted grade — earned points over the total number of
     * gradeable questions on the page — so unanswered questions count as zero,
     * matching exactly what the LMS records when the student submits.
     */
    function updateScoreDisplay() {
        if (!_scoreDisplayEl) return;
        var denom = _totalQuestions > 0 ? _totalQuestions : _state.graded;
        if (denom === 0) {           // no gradeable questions on this page
            _scoreDisplayEl.style.display = 'none';
            return;
        }
        var pct    = Math.round((_state.correct / denom) * 100);
        var points = Math.round(_state.correct * 100) / 100;   // trim to 2 decimals
        _scoreDisplayEl.style.display = 'block';
        _scoreDisplayEl.innerHTML =
            '<span style="font-size:1.15em;font-weight:bold;">Your current score: ' + pct + '%</span>' +
            '<br><span style="font-size:0.85em;color:#666;">' +
            points + ' of ' + denom + ' point' + (denom === 1 ? '' : 's') +
            ' · ' + _state.graded + ' of ' + denom + ' question' + (denom === 1 ? '' : 's') + ' answered' +
            '</span>';
    }

    /**
     * Insert a "Submit Assignment" button at the bottom of the page content,
     * just before the previous/next navigation footer.
     * Only added when a SCORM API is present (i.e. we are inside an LMS).
     */
    function addSubmitButton() {
        if (!_api) return;

        var wrapper = document.createElement('div');
        wrapper.id = 'ptx-scorm-submit-wrapper';
        wrapper.style.cssText = 'margin:2em 0 1em;padding:1.2em 1em 0.8em;border-top:2px solid #ccc;text-align:center;';

        // Live score readout, shown above the button and refreshed on every answer.
        var scoreDisplay = document.createElement('div');
        scoreDisplay.id = 'ptx-scorm-score';
        scoreDisplay.style.cssText = 'margin:0 0 0.9em;line-height:1.4;';
        _scoreDisplayEl = scoreDisplay;

        var btn = document.createElement('button');
        btn.id = 'ptx-scorm-submit-btn';
        btn.type = 'button';
        btn.textContent = 'Submit Assignment';
        btn.style.cssText = [
            'padding:0.55em 2em',
            'font-size:1.05em',
            'font-weight:bold',
            'background:#006db0',
            'color:#fff',
            'border:none',
            'border-radius:4px',
            'cursor:pointer',
        ].join(';');

        var statusMsg = document.createElement('p');
        statusMsg.style.cssText = 'margin:0.6em 0 0;font-size:1em;font-weight:bold;color:#155724;display:none;';

        btn.addEventListener('mouseover', function () { this.style.background = '#00508a'; });
        btn.addEventListener('mouseout',  function () { if (!_submitted) this.style.background = '#006db0'; });

        btn.addEventListener('click', function () {
            submitSession();

            if (!_submitted) {
                // submitSession could not run (no API, or session not open).
                // Leave the button usable and say what happened rather than
                // showing a misleading "Submitted ✓".
                statusMsg.style.display = 'block';
                statusMsg.style.color = '#a33';
                statusMsg.textContent = 'Submission did not run — please reload the page and try again.';
                return;
            }

            btn.disabled = true;
            btn.textContent = 'Submitted ✓';
            btn.style.background = '#5a9e6f';
            btn.style.cursor = 'default';

            // No Terminate happens here (see submitSession), so the LMS will
            // not navigate away on its own — tell the student to leave, which
            // is what finalizes the attempt.
            statusMsg.style.display = 'block';
            statusMsg.style.color = '#155724';
            statusMsg.textContent = 'Assignment submitted. You may now close this window.';
        });

        wrapper.appendChild(scoreDisplay);
        wrapper.appendChild(btn);
        wrapper.appendChild(statusMsg);
        updateScoreDisplay();   // reflect any score restored from a prior session

        // Place the wrapper just before the previous/next navigation footer so
        // the submit button sits at the end of the reading content, above the
        // page navigation.  Fall back to appending to the content region (then
        // main/body) if the footer is absent (e.g. bottom nav disabled).
        var footer = document.getElementById('ptx-content-footer');
        if (footer && footer.parentNode) {
            footer.parentNode.insertBefore(wrapper, footer);
        } else {
            var content = document.getElementById('ptx-content') ||
                          document.querySelector('main') ||
                          document.body;
            content.appendChild(wrapper);
        }

        console.log('[PTX-SCORM] Submit button added.');
    }


    // ═══════════════════════════════════════════════════════════════════════
    // SECTION 15 — PAGE-UNLOAD SAVE
    // ═══════════════════════════════════════════════════════════════════════
    //
    // When the learner navigates away we flush state and terminate the SCORM
    // session.  We register on BOTH beforeunload and pagehide:
    //
    //   beforeunload — fires synchronously during in-page navigation (reliable
    //                  for browser Back, address-bar changes, link clicks).
    //   pagehide     — fires on mobile browsers and when the *outer* Blackboard
    //                  frame navigates away (destroying the SCORM iframe), which
    //                  can bypass beforeunload.  We only act when event.persisted
    //                  is false (page is truly being discarded, not cached).
    //
    // This unload sequence (suspend_data, exit="suspend", Commit, Terminate)
    // is the one that finalized the attempt correctly — WITH the committed
    // grade — in every successful Blackboard test.  A Terminate() issued
    // mid-page instead (while Blackboard's player frame is still up) voided
    // the attempt to "draft saved" every time, so this handler is the ONLY
    // place we terminate.  It runs after "Submit Assignment" too: submit
    // commits the grade and leaves the session open, and this handler closes
    // the attempt when the student actually leaves.
    // localStorage provides same-device restore without relying on the LMS to
    // resume a suspended attempt.

    function handlePageExit(isPersisted) {
        if (isPersisted || !_initialized) return;

        // Re-save the current state in case anything changed since the last Commit.
        Set('cmi.suspend_data', buildSuspendData());

        // exit="suspend" tells the LMS to preserve this attempt as "In Progress"
        // so the student can resume where they left off.  The grade is already
        // recorded from each question submission; "In Progress" is the correct
        // display state until the student explicitly clicks "Submit Assignment".
        //
        // SCORM 2004: cmi.exit       (valid values: suspend, logout, time-out, "")
        // SCORM 1.2:  cmi.core.exit  (valid values: suspend, logout, time-out, "")
        var exitKey = _ver === '2004' ? 'cmi.exit' : 'cmi.core.exit';
        Set(exitKey, 'suspend');

        // Commit flushes pending data, then Terminate() formally closes the
        // SCORM communication session.  Calling Terminate() here (not just Commit)
        // is what clears Blackboard's "open session" flag — without it the next
        // visit shows "You may have this course open in another window."
        Commit();
        Terminate();
        saveToLocalStorage();

        dbg('Page exit: suspend_data saved, exit=suspend, Terminated.');
        console.log('[PTX-SCORM] Page unloading — state saved, session terminated (suspended).');
    }

    window.addEventListener('beforeunload', function () { handlePageExit(false); });

    // pagehide is the reliable companion: fires on mobile and when Blackboard's
    // outer frame navigates (which destroys the iframe without firing beforeunload).
    // event.persisted === true means the page is going into the back-forward cache
    // (not destroyed), so we skip Terminate() in that case.
    window.addEventListener('pagehide', function (event) { handlePageExit(event.persisted); });

})();  // end IIFE — no symbols leak into the global scope
