// A course opens from the SERVER, always, and lives in the editor until Save.
//
// ★ CHANGES ARE KEPT ONLY WHEN THEY ARE SAVED (Omar, 2026-10-09): "I don't want that
// copy anymore. One simple rule, tied to the Save button: changes are kept only when
// they are saved. If a user leaves without saving, reloads, closes the page or has
// network problems, unsaved changes are not kept in a browser copy."
//
// Until then this file was `persisted-draft.jsx`: a 1.5 s autosave of the whole course
// into IndexedDB, put back at the next start whenever its marker named the server's
// current version. That restore is how a Language Reviewer's REFUSED edit came back
// after every reload (QA, 2026-10-08). It is gone, with every message about it;
// `forget-browser-copy.js` deletes any copy an earlier version left behind.
//
// What this file still owns:
//  · SAVE_CONFLICT_TEXT — the one instruction for a refused or paused save;
//  · draftFingerprint — what "unsaved changes" means (the course's content, resume
//    keys excluded): the question behind "Leave site?" and the app's own asks;
//  · useServerDraft — open a course from the server, or say it could not be opened;
//  · useLeaveWarning — the browser's own "Leave site?" (his decision, same day);
//  · the banners, the "could not be opened" panel and the error boundary.

// ── ONE instruction for a refused or paused save (Omar, 2026-10-07) ───────────
// His decision: "remove Restore and finalise the Reload workflow … Reload the
// latest saved version from the server. The author reapplies their intended
// changes. The author clicks Save. I accept that unsaved changes will need to be
// re-entered. Make this clear before Reload: "Reload will open the latest saved
// version. Your unsaved changes will need to be re-entered.""
//
// EVERY place that tells the author a save was refused, or that saving is paused,
// says this (independent review of a3f8973, F9: the server said "re-apply yours"
// while the banner said "Restore"): the message at the top, "Not saved: …" beside
// Save, Course preview, Export and Subtitles. The gateway's 409 sends the same
// words (routes/courses.ts `SAVE_CONFLICT_MESSAGE`); a test pins the two equal.
// Read through `window.SAVE_CONFLICT_TEXT` from other files — a top-level const is
// not shared by every way this app's scripts are loaded. (Its start-up sentence
// about "this device's copy" went with the copy, 2026-10-09.)
const SAVE_CONFLICT_TEXT = (() => {
  const why = 'someone else saved this course after you opened it.';
  const reenter = 'Your unsaved changes will need to be re-entered.';
  // His sentence. Also the Reload button's own tooltip, so the button says it too.
  const reload = 'Reload will open the latest saved version. ' + reenter;
  const paused = 'Saving is paused until you reload.';
  return Object.freeze({
    why, reload, paused,
    // One passage: "Not saved: …", Course preview, Export, Subtitles — and the server's 409.
    message: why.charAt(0).toUpperCase() + why.slice(1) + ' ' + reload + ' ' + paused,
  });
})();

// Minimal inline stand-in for DraftContentSchema.parse (no Zod in the
// prototype). Goal per the plan: catch *obviously broken* / older-shape state
// and start fresh — not full validation (that's Phase 3). We assert the slice
// keys are the right TYPE when present, and reject the legacy full-course shape
// (a top-level `modules` array) which a prior version may have stored.
function isValidDraftContent(c) {
  if (!c || typeof c !== 'object' || Array.isArray(c)) return false;
  // Legacy / foreign shape (e.g. a whole course object with `modules`).
  if ('modules' in c) return false;
  const objOk = (v) => v == null || (typeof v === 'object' && !Array.isArray(v));
  const arrOk = (v) => v == null || Array.isArray(v);
  const objKeys = ['layoutDrafts', 'moduleOverrides', 'layoutOrders',
    'groupTitleOverrides', 'layoutStatusOverrides', 'addedLayouts', 'courseSettings'];
  const arrKeys = ['addedModules', 'addedGroups', 'deletedLayoutIds',
    'deletedModuleIds', 'deletedGroupIds'];
  for (const k of objKeys) if (!objOk(c[k])) return false;
  for (const k of arrKeys) if (!arrOk(c[k])) return false;
  if (!(c.moduleOrder == null || Array.isArray(c.moduleOrder))) return false;
  if (!(c.groupOrder == null || Array.isArray(c.groupOrder))) return false;
  return true;
}

// ── What "unsaved changes" means ─────────────────────────────────────────────────
// The course's CONTENT differs from the last version this page loaded or saved.
// Asked by "Leave site?", by opening another course, by signing out and by an
// expired session — never on every keystroke: canonical JSON of a large course
// costs tens of milliseconds, which is nothing once and too much per character.
//
// Two details are load-bearing (both from the days of the browser copy, unchanged):
//  · CANONICAL key order. The server stores the aggregate as jsonb, which does not
//    keep key order, while this page keeps whatever order React built.
//  · The RESUME keys are left out. They record where the author is standing and
//    change with every click: moving to another screen is not an unsaved change.
const DRAFT_NAV_KEYS = ['selectedModuleId', 'selectedLayoutId', 'surface'];

// JSON with object keys sorted, following JSON.stringify's rules for what is
// dropped (undefined and functions in objects) or nulled (the same in arrays).
function canonicalDraftJson(v) {
  if (v !== null && typeof v === 'object' && typeof v.toJSON === 'function') {
    return canonicalDraftJson(v.toJSON());
  }
  if (v === null || typeof v !== 'object') {
    const s = JSON.stringify(v);
    return s === undefined ? 'null' : s;
  }
  if (Array.isArray(v)) return '[' + v.map(canonicalDraftJson).join(',') + ']';
  const parts = [];
  for (const k of Object.keys(v).sort()) {
    const x = v[k];
    if (x === undefined || typeof x === 'function' || typeof x === 'symbol') continue;
    parts.push(JSON.stringify(k) + ':' + canonicalDraftJson(x));
  }
  return '{' + parts.join(',') + '}';
}

// A short fingerprint of a draft's CONTENT (resume keys excluded): its length
// plus two 32-bit hash lanes. Kept for the version this page last loaded or saved,
// so asking "is anything unsaved?" never holds two whole courses at once.
function draftFingerprint(content) {
  if (!content || typeof content !== 'object' || Array.isArray(content)) return null;
  const rest = {};
  for (const k of Object.keys(content)) {
    if (DRAFT_NAV_KEYS.indexOf(k) === -1) rest[k] = content[k];
  }
  const s = canonicalDraftJson(rest);
  let h1 = 0xdeadbeef;
  let h2 = 0x41c6ce57;
  for (let i = 0; i < s.length; i++) {
    const ch = s.charCodeAt(i);
    h1 = Math.imul(h1 ^ ch, 2654435761);
    h2 = Math.imul(h2 ^ ch, 1597334677);
  }
  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
  return s.length.toString(36) + '.' + (h2 >>> 0).toString(36) + '.' + (h1 >>> 0).toString(36);
}

// useServerDraft(courseId, hydrate, { loadRemote })
//   loadRemote(courseId) resolves to
//     { kind: 'saved', authoringState, currentVersionId } — the server's copy
//                         (`authoringState` null: nothing has been saved yet);
//     { kind: 'local-only' } — the built-in demo course, never read from the server;
//     { kind: 'failed', message } — the server could not be read.
// Returns { bootStatus: 'loading' | 'ready' | 'failed', bootError, readyFor, bootSeq, retry }:
//   readyFor — the course the editor holds once 'ready' (null otherwise). For one render
//              after a switch, `bootStatus` still says 'ready' about the PREVIOUS course;
//              this says which, so nothing may be saved under the new one meanwhile.
//   bootSeq  — counts the opens, for whoever records what was opened.
//
// ★ A START THAT CANNOT READ THE SERVER OPENS NOTHING. Until 2026-10-09 a failed
// read fell back to this browser's copy — and, with none, to an EMPTY editor whose
// next Save would replace the course with nothing. With no copy, an empty editor is
// the only fallback there is, and it is never offered: the screen says the course
// could not be opened, offers Try again, and every save is refused meanwhile
// (`saveDraftToServer` asks `bootStatus`). The editor is reset all the same, so a
// failed switch never shows the previous course's content under this one's name.
function useServerDraft(courseId, hydrate, opts) {
  const [bootStatus, setBootStatus] = React.useState('loading');
  const [bootError, setBootError] = React.useState(null);
  const [readyFor, setReadyFor] = React.useState(null);
  const [bootSeq, setBootSeq] = React.useState(0);
  const [attempt, setAttempt] = React.useState(0);
  // Read through a ref so a new callback identity cannot re-run the start (which
  // would re-hydrate mid-edit and discard what the author just typed).
  const loadRemoteRef = React.useRef(opts && opts.loadRemote);
  loadRemoteRef.current = opts && opts.loadRemote;

  React.useEffect(() => {
    let cancelled = false;
    setBootStatus('loading');
    setBootError(null);
    setReadyFor(null);
    // Bounded: `fetch` has no timeout of its own, and a gateway that accepts the
    // connection and never answers would otherwise leave the course "opening" for
    // ever. Overridable so a test can prove the bound without waiting. Never set in the app.
    const REMOTE_TIMEOUT_MS = window.DYNAMO_DRAFT_REMOTE_TIMEOUT_MS || 10000;
    let timer = null;
    (async () => {
      let r;
      try {
        r = await Promise.race([
          Promise.resolve(loadRemoteRef.current
            ? loadRemoteRef.current(courseId)
            : { kind: 'failed', message: 'there is nothing to load it from' }),
          new Promise((resolve) => {
            timer = setTimeout(() => resolve({ kind: 'failed',
              message: 'the server did not answer within ' + Math.round(REMOTE_TIMEOUT_MS / 1000) + ' seconds' }),
            REMOTE_TIMEOUT_MS);
          }),
        ]);
      } catch (e) {
        r = { kind: 'failed', message: String((e && e.message) || e) };
      } finally {
        if (timer) clearTimeout(timer);
      }
      if (cancelled) return;
      const opened = () => {
        setBootStatus('ready');
        setReadyFor(courseId);
        setBootSeq((n) => n + 1);
      };
      if (r && r.kind === 'local-only') { hydrate(null); opened(); return; }
      if (r && r.kind === 'saved') {
        const state = r.authoringState;
        if (state == null || isValidDraftContent(state)) {
          hydrate(state || null);
          opened();
          return;
        }
        r = { kind: 'failed', message: 'its saved version is in a shape this version of the app cannot read' };
      }
      hydrate(null);
      setBootError((r && r.message) || 'unknown reason');
      setBootStatus('failed');
    })();
    return () => { cancelled = true; if (timer) clearTimeout(timer); };
    // hydrate is stable (useCallback in the consumer); courseId and the attempt are the keys.
  }, [courseId, attempt]);

  const retry = React.useCallback(() => setAttempt((n) => n + 1), []);
  return { bootStatus, bootError, readyFor, bootSeq, retry };
}

// ── "Leave site?" (Omar, 2026-10-09) ─────────────────────────────────────────────
// His words: "Warning when leaving with unsaved changes: yes, the browser's own
// "Leave site?" prompt. This replaces my earlier "no warning"." (The earlier record:
// "No rescue for typing before a reload either", 2026-10-08.) The browser writes its
// own question; a page may only ask for it, and only after the person has interacted
// with the page. It KEEPS nothing: it asks.
//
// The app's own departures that have already said what they cost do not ask twice:
// Reload after a refused save (its message says "Reload will open the latest saved
// version…"), and signing out once its own question was answered.
let leavingOnPurpose = false;
function dynamoLeaveOnPurpose() { leavingOnPurpose = true; }
// The departure did not happen (a sign-out whose logout failed): this page stays, with
// what it holds, so leaving it must ask again (review of #353, finding 6).
function dynamoLeaveCancelled() { leavingOnPurpose = false; }
function dynamoReloadOnPurpose() { leavingOnPurpose = true; window.location.reload(); }

// `hasUnsavedWork` is read at the moment of leaving, never captured: a stale answer
// from an earlier render is exactly the wrong one here. If it throws, the page asks —
// a needless question costs a click; a missing one costs the author's work.
function useLeaveWarning(hasUnsavedWork) {
  const ref = React.useRef(hasUnsavedWork);
  ref.current = hasUnsavedWork;
  React.useEffect(() => {
    const onBeforeUnload = (e) => {
      if (leavingOnPurpose) return undefined;
      let unsaved;
      try { unsaved = !!(ref.current && ref.current()); } catch (err) { unsaved = true; }
      if (!unsaved) return undefined;
      e.preventDefault();
      e.returnValue = '';
      return '';
    };
    window.addEventListener('beforeunload', onBeforeUnload);
    return () => window.removeEventListener('beforeunload', onBeforeUnload);
  }, []);
}

// ─── CourseOpenFailed ────────────────────────────────────────────────────────
// In place of the screen when `useServerDraft` could not read the course. Says what
// happened, that nothing changed, and the one thing to do.
function CourseOpenFailed({ message, onRetry }) {
  return (
    <div role="alert" style={{ padding: 24, maxWidth: 620 }}>
      <div style={{ fontSize: 15, fontWeight: 600, marginBottom: 8 }}>
        This course couldn’t be opened
      </div>
      <div style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-muted)' }}>
        Its saved version couldn’t be loaded from the server ({message}). Nothing has
        been changed, and nothing can be saved until it opens. Check your connection,
        then try again.
      </div>
      <button className="btn sm" onClick={onRetry} style={{ marginTop: 14 }}>Try again</button>
    </div>
  );
}

// ─── ReadOnlyBanner ──────────────────────────────────────────────────────────
// A `reviewer` may open every course and change nothing (12_SECURITY §4.1). That
// is a standing condition, not an event, so unlike the save messages this one is
// NOT dismissable — dismissing it would leave someone typing into fields whose
// contents can never be stored, which is the kind of quiet lie this project
// keeps having to remove.
// ⚠️ NOT `position: fixed` — Omar, 2026-08-18, on the first real Reviewer login:
// *"since the banner is always placed at the top the navigation is not fully
// available"*. All three banners in this file floated at `top: 0` over a shell
// that is a `100vh` grid, so each one covered the header and the top of the left
// rail — and, when two showed at once, each other.
//
// They are now ordinary blocks in a flex column above the grid (app.jsx), so the
// shell is SHORTER when a banner is up rather than partly hidden underneath it.
// Fixed at the class, not at the instance: the same defect was in all three, and
// fixing only the one he happened to see would have left the other two waiting
// (`feedback_fix_the_sibling_branch_of_the_condition`).
function ReadOnlyBanner({ readOnly }) {
  if (!readOnly) return null;
  const Icon = I.Eye || I.Info || I.AlertCircle;
  return (
    <div role="status" aria-live="polite" style={{
      flex: '0 0 auto',
      display: 'flex', alignItems: 'center', gap: 10,
      padding: '8px 16px', background: '#1e3a5f', color: '#fff',
      fontSize: 12.5, lineHeight: 1.4, fontFamily: 'inherit',
      boxShadow: '0 1px 4px rgba(0,0,0,.3)',
    }}>
      {Icon ? <Icon size={15} style={{ flex: '0 0 auto' }} /> : null}
      <span style={{ flex: 1 }}>
        {/* The wording has to be TRUE. "nothing … can be saved or built" stopped
            being true on 2026-08-18: a Reviewer now holds `preview:build`, so
            Preview is a place they can walk to (§2.11 decision D). A banner that
            tells somebody they cannot do the one thing the role exists for is
            the same defect as a button that does nothing
            (`feedback_honest_gates_over_standins`). */}
        <strong>Read-only.</strong> Your role is <em>Reviewer</em>, so you can open,
        read and <strong>preview</strong> every course — but nothing you change here
        is saved. Ask an admin for editor access.
      </span>
    </div>
  );
}

// ─── SaveConflictBanner ──────────────────────────────────────────────────────
// A save was refused (409) because someone else saved after this browser last
// read the course. Two people editing one course used to overwrite each other
// silently, with a 200 and a green tick; the loser's work survived only as an old
// draft_version nobody looks at.
//
// ★ EVERY SENTENCE IS A PROMISE A BUTTON KEEPS (Omar, 2026-10-07: "every message
// says what its button really does"). Saving stays paused until the reload
// (app.jsx refuses every door), and the reload opens the latest saved version.
//
// ★ AND IT SAYS WHAT THAT COSTS, BEFORE THE RELOAD. His decision the same day:
// "remove Restore … I accept that unsaved changes will need to be re-entered.
// Make this clear before Reload: "Reload will open the latest saved version. Your
// unsaved changes will need to be re-entered."" Until then this message offered
// "Restore this device's copy" after the reload, and Restore then Save stored
// this device's whole old copy over a colleague's change to another field.
// ✕ only hides this. Each claim is measured in fe-save-conflict-two-browsers.test.ts.
function SaveConflictBanner({ conflict, onDismiss }) {
  if (!conflict) return null;
  const Icon = I.AlertCircle || I.Info;
  const T = SAVE_CONFLICT_TEXT;
  return (
    <div role="alert" aria-live="assertive" style={{
      flex: '0 0 auto',
      display: 'flex', alignItems: 'center', gap: 10,
      padding: '8px 16px', background: '#92610a', color: '#fff',
      fontSize: 12.5, lineHeight: 1.4, fontFamily: 'inherit',
      boxShadow: '0 1px 4px rgba(0,0,0,.3)',
    }}>
      {Icon ? <Icon size={15} style={{ flex: '0 0 auto' }} /> : null}
      <span style={{ flex: 1 }}>
        <strong>Not saved — {T.why}</strong>{' '}{T.reload}{' '}{T.paused}
      </span>
      <button className="btn sm" onClick={dynamoReloadOnPurpose}
        title={T.reload}
        style={{ flex: '0 0 auto' }}>Reload</button>
      <button className="btn sm ghost" onClick={onDismiss}
        aria-label="Dismiss" title="Hide this message. Saving stays paused until you reload."
        style={{ flex: '0 0 auto', color: '#fff' }}>✕</button>
    </div>
  );
}

// ── The app's first error boundary ──────────────────────────────────────────
//
// There was none anywhere in this frontend, so ANY render-time throw produced a
// blank white page with the author's unsaved slices still in memory and no way
// back — and this app has a history of exactly that (a LocalizedString rendered
// as a JSX child; two editor crashes found in PR #86/#87). The reason to add it
// HERE and NOW is that authored state can now arrive from the SERVER: it is
// stored verbatim, checked only for shape, and spread straight into React state.
// A blob written by an older client, or a slice whose shape has since changed,
// can therefore throw during render on a machine that never authored it.
//
// Deliberately narrow: it catches the SURFACE, so the header, the rail and the
// banner survive and the author can still switch screens or reload. It never
// swallows the error — that goes to the console.
class DraftErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { error: null };
  }
  static getDerivedStateFromError(error) {
    return { error };
  }
  componentDidCatch(error, info) {
    console.error('[surface crashed]', error, info && info.componentStack);
  }
  componentDidUpdate(prev) {
    // A new screen gets a fresh attempt: the crash is usually specific to the
    // content of one surface, and trapping the author on an error card would be
    // its own dead end.
    if (this.state.error && prev.surfaceKey !== this.props.surfaceKey) {
      this.setState({ error: null });
    }
  }
  render() {
    if (!this.state.error) return this.props.children;
    return (
      <div role="alert" style={{ padding: 24, maxWidth: 620 }}>
        <div style={{ fontSize: 15, fontWeight: 600, marginBottom: 8 }}>
          This screen couldn’t be displayed
        </div>
        <div style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-muted)' }}>
          Something in this course’s content stopped the screen from drawing. Nothing
          has been deleted. Try another screen from the menu on the left. Changes you
          have not saved are kept only while this page stays open: press Save before
          reloading it. If it keeps happening, the details are in the browser console.
        </div>
        <div style={{ fontSize: 11.5, marginTop: 12, fontFamily: 'var(--font-mono)',
          color: 'var(--text-faint)', wordBreak: 'break-word' }}>
          {String((this.state.error && this.state.error.message) || this.state.error)}
        </div>
      </div>
    );
  }
}

Object.assign(window, {
  useServerDraft, useLeaveWarning, dynamoLeaveOnPurpose, dynamoLeaveCancelled, dynamoReloadOnPurpose,
  CourseOpenFailed, ReadOnlyBanner, SaveConflictBanner,
  isValidDraftContent, draftFingerprint, DraftErrorBoundary, SAVE_CONFLICT_TEXT,
});
