// Draft persistence hook + save-status banner.
//
// NOTE ON SHAPE (deviation from the plan's pseudocode): app.jsx does NOT keep
// the draft in a single useState store — it's ~13 independent slices
// (layoutDrafts, moduleOverrides, addedModules, …, courseSettings). The plan's
// `[state, setState]` swap would force a big state restructure, which the
// prompt's "do not touch the shape of course state" rule forbids. So instead of
// returning a state tuple, this hook takes the live aggregate snapshot + a
// `hydrate(content|null)` callback and owns only the persistence lifecycle
// (boot-restore, debounced save, retry/backoff, reset). Consumers keep their
// existing setters untouched. See the report for rationale.

const DRAFT_DEBOUNCE_MS = 1500;
const DRAFT_MAX_RETRIES = 4;

// 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;
  return true;
}

// usePersistedDraft(courseId, aggregate, hydrate, opts)
//   aggregate — the live draft object to persist (a useMemo of all slices)
//   hydrate(content|null) — apply a restored content object to the slices;
//                           null means "reset everything to defaults"
//   opts.loadRemote(courseId) — optional. Resolves to
//                           { authoringState, currentVersionId } | null, i.e.
//                           what the SERVER has stored for this course. Omit it
//                           (or resolve null) and the hook behaves exactly as it
//                           did when the browser was the only source of truth.
// Returns { bootStatus, saveBlocked, reset }.
//   bootStatus: 'loading' | 'restored' | 'restored-server' | 'superseded'
//             | 'fresh' | 'invalid'
//
// WHERE A COURSE LIVES (changed 2026-07-28). Until now the answer was "in this
// browser". `content` on the server is a lossy projection with no inverse, so
// opening a course on a second machine showed it EMPTY — and saving from there
// replaced a full course with a nearly-empty one, silently, with a 200. The
// server now also stores the authoring aggregate verbatim
// (draft_version.authoring_state), so boot has two candidate copies and has to
// choose between them. The rule:
//
//   neither        → fresh
//   server only    → server  ('restored-server')  ← the second-machine case
//   local only     → local   ('restored')         ← offline / pre-migration
//   both, and the local copy was last saved AS the server's current version
//                  → local   ('restored')  it is local edits on top of it
//   both, diverged → server  ('superseded') and the local copy is PARKED, not
//                    deleted, and the author is told
//
// The divergence test is a version-id comparison, not a timestamp: clocks on two
// machines are not comparable, and "the server moved on since this browser last
// wrote" is exactly what the id answers.
function usePersistedDraft(courseId, aggregate, hydrate, opts) {
  const loadRemote = opts && opts.loadRemote;
  const [bootStatus, setBootStatus] = React.useState('loading');
  const [saveBlocked, setSaveBlocked] = React.useState(false);
  const debounceRef = React.useRef(null);
  const failuresRef = React.useRef(0);
  const skipNextSaveRef = React.useRef(false);
  const tokenRef = React.useRef(0);
  // The not-yet-saved {courseId, aggregate} behind the debounce timer, so a
  // course switch can FLUSH it instead of silently dropping the last edits.
  const pendingRef = React.useRef(null);
  // Kept in a ref so changing the callback identity cannot re-trigger boot
  // (which would re-hydrate mid-edit and discard what the author just typed).
  const loadRemoteRef = React.useRef(loadRemote);
  loadRemoteRef.current = loadRemote;

  // Boot — restore from IndexedDB and/or the server once per courseId. Re-runs
  // on every courseId change (course switch): status returns to 'loading' FIRST
  // so the debounced save effect can't write the previous course's slices under
  // the new key while the new course's record is still loading.
  React.useEffect(() => {
    let cancelled = false;
    setBootStatus('loading');
    const P = window.DraftPersistence;
    if (!P) { setBootStatus('fresh'); return; }
    // Course switch: the save effect's cleanup only clears the timer — flush
    // the PREVIOUS course's pending aggregate now so its last edits survive.
    const pending = pendingRef.current;
    if (pending && pending.courseId !== courseId) {
      pendingRef.current = null;
      P.saveDraft(pending.courseId, pending.aggregate).catch(() => {});
    }

    const apply = (content, status) => {
      skipNextSaveRef.current = true;   // don't re-save what we just restored
      hydrate(content);
      setBootStatus(status);
    };

    // When we ADOPT the server's copy, make the local store agree with what is
    // now on screen, THEN record which server version that is.
    //
    // Both halves are load-bearing:
    //  · Writing the state locally first: the marker says "this device's local
    //    copy IS server version X". Writing the marker while the local record
    //    still held the REJECTED copy made that a lie, and the next reload then
    //    took the "local, marker matches" branch and silently restored the very
    //    copy this boot had just set aside.
    //  · Recording the version at all: without it the next reload sees "a local
    //    copy, no marker, a server copy", takes the diverged branch, parks an
    //    identical copy and shows the superseded banner on EVERY reload until the
    //    author happens to press Save. A warning that cries wolf is as dishonest
    //    as a green tick that saved nothing.
    const adopt = async (versionId, adopted) => {
      try { await P.saveDraft(courseId, adopted); } catch (e) { /* best effort */ }
      if (!versionId || !P.saveMeta) return;
      try { await P.saveMeta(courseId, { serverVersionId: versionId }); }
      catch (e) { /* a lost marker costs a conservative boot, never data */ }
    };

    (async () => {
      // Local and remote are independent; a slow or failing gateway must never
      // stop the local copy from loading. Hence allSettled, not all.
      //
      // AND THE REMOTE READ IS BOUNDED. `bootStatus` gates the debounced local
      // autosave (see the next effect: it returns early while 'loading'), so an
      // unbounded await here does not merely delay the editor — a gateway that
      // accepts the connection and never answers would leave the status at
      // 'loading' forever and DISABLE LOCAL PERSISTENCE for the whole session,
      // losing everything typed after it. `fetch` has no default timeout, so the
      // bound has to be here. A timeout resolves to null, which the decision
      // below already treats as "no server copy" — i.e. exactly the old,
      // local-only behaviour.
      // Overridable so a test can prove the timeout actually fires without
      // waiting 8 seconds. Never set in the app.
      const REMOTE_TIMEOUT_MS = window.DYNAMO_DRAFT_REMOTE_TIMEOUT_MS || 8000;
      const remoteWithTimeout = () => {
        if (!loadRemoteRef.current) return Promise.resolve(null);
        let timer = null;
        return Promise.race([
          Promise.resolve(loadRemoteRef.current(courseId))
            .then((v) => { if (timer) clearTimeout(timer); return v; })
            .catch(() => { if (timer) clearTimeout(timer); return null; }),
          new Promise((resolve) => {
            timer = setTimeout(() => {
              console.warn(
                '[draft] the gateway did not answer within ' + REMOTE_TIMEOUT_MS +
                'ms — opening this course from the copy on this device');
              resolve(null);
            }, REMOTE_TIMEOUT_MS);
          }),
        ]);
      };

      const [localR, remoteR, metaR] = await Promise.allSettled([
        P.loadDraft(courseId),
        remoteWithTimeout(),
        P.loadMeta ? P.loadMeta(courseId) : Promise.resolve(null),
      ]);
      if (cancelled) return;

      const record = localR.status === 'fulfilled' ? localR.value : null;
      const remote = remoteR.status === 'fulfilled' ? remoteR.value : null;
      const meta = metaR.status === 'fulfilled' ? metaR.value : null;

      const localBad = !!record && !isValidDraftContent(record.content);
      if (localBad) P.clearDraft(courseId).catch(() => {});
      const local = localBad ? null : (record ? record.content : null);

      const serverState = remote && remote.authoringState;
      const server = serverState && isValidDraftContent(serverState) ? serverState : null;

      if (!local && !server) {
        // Nothing anywhere — reset every slice to defaults. Without this,
        // switching from another course would leak its in-memory slices into
        // the fresh course (and then save them here).
        apply(null, localBad ? 'invalid' : 'fresh');
        return;
      }
      if (!local) {
        await adopt(remote.currentVersionId, server);
        if (cancelled) return;
        apply(server, 'restored-server');
        return;
      }
      if (!server) {
        // The server has a course but its authoring state is unreadable to us
        // (written by a client that did not send one). Keep the local copy — but
        // if the server has moved on since this browser last saved, the author
        // must know their next Save will overwrite whatever that newer version
        // holds. Silence here was how a stale tab's save could quietly win.
        const seenId = meta && meta.serverVersionId;
        const currentId = remote && remote.currentVersionId;
        const movedOn = !!(seenId && currentId && seenId !== currentId);
        apply(local, localBad ? 'invalid' : (movedOn ? 'server-ahead' : 'restored'));
        return;
      }

      // Both exist. Did the server move on since this browser last saved?
      const seen = meta && meta.serverVersionId;
      const current = remote.currentVersionId;
      if (seen && current && seen === current) {
        apply(local, 'restored');            // local edits on top of this state
        return;
      }
      // Diverged (or this browser has never saved, so it cannot claim to be
      // ahead). Prefer the server, but PARK the local copy first — an author's
      // work is never dropped silently.
      //
      // The park is a PRECONDITION, not a courtesy: if it cannot be written we
      // must not replace what is on screen, because the banner would then promise
      // that the local edits were "set aside" when nothing was set aside. Keep the
      // local copy and say plainly that the server is ahead.
      let parked = false;
      if (P.saveSuperseded) {
        try { await P.saveSuperseded(courseId, local); parked = true; }
        catch (e) { parked = false; }
      }
      if (cancelled) return;
      if (!parked) {
        apply(local, 'server-ahead');
        return;
      }
      await adopt(remote.currentVersionId, server);
      if (cancelled) return;
      apply(server, 'superseded');
    })().catch(() => {
      if (cancelled) return;
      // Total failure: still reset the slices — otherwise a course switch under
      // a failing IndexedDB leaks the previous course's data into this course
      // (and then saves it under this course's key).
      apply(null, 'fresh');
    });

    return () => { cancelled = true; };
    // hydrate is stable (useCallback in the consumer); courseId is the key.
    // loadRemote is read through a ref for the reason noted at its declaration.
  }, [courseId]);

  // Debounced save on any aggregate change (after boot completes).
  React.useEffect(() => {
    if (bootStatus === 'loading') {
      // A course switch runs this effect ONCE with the stale pre-switch
      // bootStatus before 'loading' lands, which can leave pendingRef holding
      // the PREVIOUS course's aggregate under the NEW courseId. Discard that
      // mis-keyed snapshot here (the boot effect already flushed the previous
      // course's pending save under its own key).
      if (pendingRef.current && pendingRef.current.courseId === courseId) {
        pendingRef.current = null;
      }
      return;                                        // never write during boot
    }
    if (skipNextSaveRef.current) { skipNextSaveRef.current = false; return; }
    const P = window.DraftPersistence;
    if (!P) return;
    clearTimeout(debounceRef.current);
    const token = ++tokenRef.current;
    pendingRef.current = { courseId, aggregate };
    debounceRef.current = setTimeout(async () => {
      for (let attempt = 0; attempt < DRAFT_MAX_RETRIES; attempt++) {
        if (token !== tokenRef.current) return;       // superseded by a newer edit
        try {
          await P.saveDraft(courseId, aggregate);
          if (pendingRef.current && pendingRef.current.aggregate === aggregate) {
            pendingRef.current = null;                // this snapshot is now saved
          }
          failuresRef.current = 0;
          setSaveBlocked(false);
          return;
        } catch (e) {
          failuresRef.current += 1;
          if (failuresRef.current >= 3) setSaveBlocked(true);
          // exponential backoff: 1s, 2s, 4s, 8s
          await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
        }
      }
    }, DRAFT_DEBOUNCE_MS);
    return () => clearTimeout(debounceRef.current);
  }, [aggregate, bootStatus, courseId]);

  const reset = React.useCallback(async () => {
    const P = window.DraftPersistence;
    skipNextSaveRef.current = true;
    if (P) { try { await P.clearDraft(courseId); } catch (e) {} }
    hydrate(null);
    failuresRef.current = 0;
    setSaveBlocked(false);
  }, [courseId, hydrate]);

  return { bootStatus, saveBlocked, reset };
}

// Top-of-app banner. Fixed strip so it never disturbs the grid layout.
//  - saveBlocked  → red, persistent (clears only when a save succeeds)
//  - bootStatus 'invalid'    → amber, dismissible
//  - bootStatus 'superseded' → amber, dismissible: the server held a newer copy
//    of this course than this browser did, so we loaded the server's. Saying so
//    is not optional — the author's local edits are set aside (recoverable via
//    `await loadSuperseded(courseId)`), and a screen that quietly changed under
//    them would be the same class of lie as a green "Saved" that saved nothing.
// ─── ReadOnlyBanner ──────────────────────────────────────────────────────────
// A `reviewer` may open every course and change nothing (12_SECURITY §4.1). That
// is a standing condition, not an event, so unlike SaveStatusBanner 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.
//
// The banner earns its wording. It only says "kept on this device" when the park
// actually succeeded — a rescue claim needs a recovery path, and the Restore
// button below is that path (it reads the same capped park list the boot
// divergence branch writes). If the park failed, it says so instead, because
// telling someone their work is safe when it is not is worse than saying nothing.
function SaveConflictBanner({ conflict, onDismiss }) {
  if (!conflict) return null;
  const Icon = I.AlertCircle || I.Info;
  const parked = !!conflict.parked;
  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 — someone else changed this course.</strong>{' '}
        {parked
          ? 'Your version is set aside on this device. Reload to see theirs, then '
            + 'use “Restore this device’s copy” to bring yours back and merge by hand.'
          : 'Your version could NOT be set aside on this device, so do not reload — '
            + 'copy anything you need out first.'}
      </span>
      <button className="btn sm" onClick={() => window.location.reload()}
        style={{ flex: '0 0 auto' }}>Reload</button>
      <button className="btn sm ghost" onClick={onDismiss}
        aria-label="Dismiss" style={{ flex: '0 0 auto', color: '#fff' }}>✕</button>
    </div>
  );
}

function SaveStatusBanner({ bootStatus, saveBlocked }) {
  const [dismissed, setDismissed] = React.useState(false);
  React.useEffect(() => {
    if (bootStatus !== 'invalid' && bootStatus !== 'superseded'
        && bootStatus !== 'server-ahead') setDismissed(false);
  }, [bootStatus]);

  const showBlocked = !!saveBlocked;
  const showInvalid = bootStatus === 'invalid' && !dismissed;
  const showSuperseded = bootStatus === 'superseded' && !dismissed;
  // 'server-ahead' = we KEPT this device's copy, and the server holds something
  // newer that we could not adopt (its authoring state is unreadable, or the
  // local copy could not be set aside safely). Saying so matters more than the
  // other two: the next Save will overwrite that newer version.
  const showAhead = bootStatus === 'server-ahead' && !dismissed;
  if (!showBlocked && !showInvalid && !showSuperseded && !showAhead) return null;

  // saveBlocked takes priority if somehow both are true.
  const blocked = showBlocked;
  const bg = blocked ? '#7f1d1d' : '#92610a';
  const Icon = I.AlertCircle || I.Info;

  return (
    <div role="alert" aria-live={blocked ? 'assertive' : 'polite'} style={{
      flex: '0 0 auto',
      display: 'flex', alignItems: 'center', gap: 10,
      padding: '8px 16px', background: bg, color: '#fff',
      fontSize: 12.5, lineHeight: 1.4, fontFamily: 'inherit',
      boxShadow: '0 1px 4px rgba(0,0,0,.3)',
    }}>
      <Icon size={15} style={{ flex: '0 0 auto' }} />
      <span style={{ flex: 1 }}>
        {blocked
          ? 'Your changes aren’t being saved locally. Copy any unsaved work to a safe place, then refresh.'
          : showSuperseded
            ? 'Loaded the saved copy of this course from the server — it was newer than the one on this device. Any unsaved local edits were set aside, not deleted.'
            : showAhead
              ? 'You’re looking at this device’s copy of the course. The server has a newer save that couldn’t be loaded here — if you save now, you’ll replace it. Check with whoever edited it last, or open the course in a fresh browser window to see the server’s version.'
              : 'Your saved draft was from an older version and couldn’t be restored — starting fresh.'}
      </span>
      {/* A real recovery path for the parked copy. Without this, "set aside, not
          deleted" was only true for someone who knows to open a console and type
          `await loadSuperseded(courseId)` — which is nobody who needs it. */}
      {showSuperseded && (
        <button
          onClick={async () => {
            const P = window.DraftPersistence;
            const id = window.dynamoDraftCourseId;
            if (!P || !P.restoreSuperseded || !id) return;
            const ok = await P.restoreSuperseded(id, 0);
            if (ok) window.location.reload();
          }}
          style={{ flex: '0 0 auto', padding: '3px 9px', border: 0, borderRadius: 4,
            background: 'rgba(255,255,255,.2)', color: '#fff', cursor: 'default',
            fontFamily: 'inherit', fontSize: 12 }}>
          Restore this device’s copy
        </button>
      )}
      {!blocked && (
        <button onClick={() => setDismissed(true)} aria-label="Dismiss"
          style={{ flex: '0 0 auto', display: 'inline-flex', alignItems: 'center',
            justifyContent: 'center', width: 22, height: 22, padding: 0,
            border: 0, borderRadius: 4, background: 'rgba(255,255,255,.15)',
            color: '#fff', cursor: 'default', fontFamily: 'inherit' }}>
          <I.X size={13} />
        </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, reload, or recover a
// parked copy. 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. Your
          work has not been deleted. Try another screen from the menu on the left,
          or reload the page. 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, {
  usePersistedDraft, SaveStatusBanner, ReadOnlyBanner, SaveConflictBanner,
  isValidDraftContent,
  DraftErrorBoundary,
});
