/* Brain of Reese — document viewer (phase 10) + the shared document * renderer (phase 26). * * Two jobs: * * 1. renderDocument(doc, { titleEl, metaEl, contentEl }) — the * EXPORTED rendering core. The /document.html page and the document * modal (assets/document-modal.js, phase 26) both render through it, * so the two surfaces can never drift: * * • md / markdown → the shared escape-first renderer (markdown.js) * in a ≤46rem centered column; * • any other → the raw content as a text node inside *
 (mono, horizontal
 *                         scroll);
 *       • doc.summary non-empty (phase 36 — phase 30 summaries exist
 *                         only on non-markdown docs) → a labeled
 *                         .doc-summary section ABOVE the content;
 *                         null / empty / whitespace renders nothing, so
 *                         markdown docs and fail-soft rows are
 *                         byte-for-byte unchanged.
 *
 *  2. The /document.html page itself: reads `source`/`path` query
 *     params, fetches the stateless content endpoint
 *     (GET /api/documents/content — database only, no filesystem), and
 *     renders via renderDocument into #doc-title / #doc-meta /
 *     #doc-content. A missing document (unknown pair, missing params,
 *     network error) shows the designed not-found card with a link back
 *     to the Sources page. Phase 79 (task 05): the content endpoint is
 *     require_user-gated — a direct ANONYMOUS URL meets the inline
 *     token gate instead (the page document loads; the gated data does
 *     not): mountGate settles the auth (silent re-auth of a cached
 *     token, then the shared cached whoami) and the boot sequence
 *     (initSharedHeader + load) runs as its onAuthed — only a
 *     signed-in role (admin or token user) ever fetches the content.
 *
 * XSS-safe by construction: markdown is escaped before transform, raw
 * formats are set via textContent, and every document-derived string
 * (title, badges, path) is written with textContent — never innerHTML
 * (innerHTML goes through renderMarkdown, which escapes first).
 *
 * Phase 19: the viewer joins the shared header (assets/header.js) — the
 * whoami fetch is the module's cached promise (one request per page,
 * shared with initSharedHeader's toggling). Phase 34 task 02: the New
 * chat binding is module-owned (assets/header.js, the SINGLE one) — on
 * this non-chat page it clears the phase-14 conversation key and
 * navigates to the chat's empty state.
 *
 * Phase 26 (import safety): the modal module imports renderDocument
 * from THIS file on the chat/sources pages, so everything
 * /document.html-specific runs only when #doc-title exists (the guard
 * around the page block below). Importing renderDocument elsewhere has
 * no side effects: no back-link resolution, no whoami, no content
 * fetch, no New Chat binding.
 *
 * Phase 36: the renderer owns the optional summary panel — a non-empty
 * doc.summary renders as a labeled .doc-summary section above the
 * original content on BOTH surfaces (page + modal) through this one
 * core; the summary text is a text node (XSS contract unchanged).
 *
 * Phase 57 (task 02, D4): the panel gains an ADMIN-ONLY edit
 * affordance (docAdminReady() gate on the module-cached whoami): an
 * Edit button in the panel's header row opens an inline editor
 * (prefilled textarea + Save/Cancel + a role=status live region), and
 * Save PATCHes /api/documents/summary. The section is built for
 * everyone exactly as phase 36 — the public viewer stays
 * byte-for-byte identical (no button, no wiring, no admin-only
 * network call) until the admin gate resolves true.
 *
 * Phase 106 (task 08, D8): the .doc-meta badge row gains the Created
 * badge BEFORE the Indexed one (the owner's verbatim position — the
 * date at the top of a clicked document): `Created ` via
 * fmtDate(doc.created_at), the full ISO timestamp on the badge's
 * title (the titleEl ellipsis-precision idiom). ONE shared core —
 * the modal and /document.html both render it (no per-surface copy);
 * the date arrives in the same content payload (task 05 added
 * created_at to GET /api/documents/content). The date EDITOR (the
 * admin inline edit + PATCH /api/documents/date) is the task-09
 * section below.
 *
 * Phase 106 (task 09, D7): the badge row gains the ADMIN-ONLY date
 * editor (the phase-57 wireSummaryEdit idiom — the same
 * docAdminReady() gate on the module-cached whoami promise, so no
 * second request per page): an "Edit date" text button after the
 * Created badge (task 08's insertion point) swaps in-place for an
 * inline editor — a native type=date input + Save / Cancel + the
 * muted "Revert to sync" clear affordance (the phase-57 "clear =
 * explicit" contrast) + a role=status live line and a role=alert
 * error line. Save PATCHes /api/documents/date with { source, path,
 * date } and the badge re-renders from the RESPONSE's created_at
 * (never the input's optimistic value); "Revert to sync" sends
 * date: null (the D7 CLEAR — the manual flag drops, the stored date
 * stands until the next sync). An EMPTY date input can't clear (Save
 * disables itself — the explicit Revert is the only clear path, no
 * accidental wipes). §7.4 never-stale: the controls disable
 * immediately on submit (one PATCH at a time); a failure (non-2xx
 * or network) lands the server detail (or the canned retry copy) in
 * the role=alert line, reverts the input to the stored date, and
 * re-enables — the UI never claims a state the server didn't save.
 * A non-admin / token holder / failed whoami keeps the byte-for-byte
 * task-08 badge row (no button, no wiring, no admin-only network
 * call — the phase-57 split). ONE shared core — the modal and
 * /document.html both get it (document-modal.js imports
 * renderDocument from this module — no per-surface copy).
 *
 * Phase 122 (task 04): image documents — when doc.is_image is true,
 * #doc-content renders the persistent bytes FIRST (the  block
 * from doc.image_url — the /api/documents/{id}/image route — alt =
 * the summary, the WCAG alt contract; a NULL summary falls back to
 * the title), and the description (doc.content — the ONLY readable
 * text of the doc) follows in the EXISTING plain-content slot below
 * (the .doc-raw path; a description is prose, not markdown). The
 * labeled Summary panel is suppressed for the verbatim-description
 * case (summary === content — the importer invariant; the panel
 * would duplicate the text right below the image); an admin-edited
 * summary (different text) still renders in its panel with the
 * phase-57 edit affordance. An  load failure (the route's 404 —
 * the row exists but the copy was lost) swaps the block for a small
 * "Image unavailable" note (role=status): the page still shows the
 * description. ONE shared core — page + modal both get it.
 */

import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js";
import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the inline token gate

// The shared header's control bindings (sign-out / mobile hamburger) —
// EXPLICIT init, once per document (header.js is bundle-inlined per
// entry; import-time side effects would double-bind — 2026-09-08 fix).
bindSharedHeaderControls();

/* Phase 39: the page title's display name — window.BOR_BRAND (set at
 * parse time by the classic assets/brand.js, refreshed from
 * /api/config). This is a module, so the global is set by the time this
 * evaluates; the literal is the no-config fallback only. */
const brand = () => window.BOR_BRAND || "Brain of Reese";

function fmtDate(iso) {
  try {
    return new Date(iso).toLocaleString();
  } catch {
    return iso;
  }
}

function contentUrl(s, p) {
  return "/api/documents/content?source=" + encodeURIComponent(s) + "&path=" + encodeURIComponent(p);
}

function metaBadge(cls, text, title) {
  const el = document.createElement("span");
  el.className = cls;
  el.textContent = text;
  // Phase 106 (task 08): optional hover precision — the meta row may
  // clip (nowrap), so the exact value stays reachable on the badge's
  // title (the titleEl ellipsis-precision idiom). Omitted → the
  // pre-phase two-argument shape (the other badges are unchanged).
  if (title !== undefined) el.setAttribute("title", title);
  return el;
}

/* ---------- shared renderer (phase 26, task 02) ----------
 * Populates the three elements every render surface provides: a title,
 * a .doc-meta badge row (source · format · mono path · created ·
 * indexed · chunks — phase 106 (task 08, D8) added created BEFORE
 * indexed), and a content container — an optional .doc-summary
 * section first (phase 36: only when doc.summary is non-empty —
 * markdown docs and fail-soft rows carry none, so they render exactly
 * as before), then .doc-md for md/markdown (the shared escape-first
 * renderer), 
 otherwise. The XSS contract:
 * innerHTML only through renderMarkdown; every document-derived
 * string (summary text included) is a text node. */
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
  titleEl.textContent = doc.title;
  // Phase 34 task 04: the titlebar title ellipsizes — the full title
  // stays reachable on hover via the `title` attribute.
  titleEl.setAttribute("title", doc.title);

  const pathCode = document.createElement("code");
  pathCode.className = "doc-path";
  pathCode.textContent = doc.path;
  metaEl.replaceChildren(
    metaBadge("doc-source-badge", doc.source),
    metaBadge("format-badge", doc.format),
    pathCode,
    // Phase 106 (task 08, D8): the Created badge BEFORE the Indexed
    // one (the owner's verbatim position — the date at the top of a
    // clicked document). textContent = the locale date (fmtDate — the
    // Indexed idiom), and the badge's title = the FULL ISO timestamp
    // (the titleEl ellipsis-precision idiom — the row clips, the exact
    // value stays reachable).
    metaBadge("doc-created", `Created ${fmtDate(doc.created_at)}`, doc.created_at),
    metaBadge("doc-indexed", `Indexed ${fmtDate(doc.indexed_at)}`),
    metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
  );

  // Phase 106 (task 09, D7): the ADMIN-ONLY date editor — the
  // module-cached whoami promise (docAdminReady — the SAME single
  // request per page the summary edit shares) gates the wiring, so a
  // non-admin / token holder / failed whoami keeps EXACTLY the task-08
  // badge row: no button, no wiring, no admin-only network call (the
  // phase-57 split). The button lands after the Created badge (task
  // 08's insertion point); the editor itself lives in wireDateEdit.
  void docAdminReady().then((admin) => {
    if (admin) wireDateEdit(metaEl, doc);
  });

  contentEl.replaceChildren();
  // Phase 122 (task 04): an image document — the persistent bytes
  // render as the  block FIRST; the description (= doc.content)
  // follows in the normal content slot below (the plain-content path
  // — the last branch in this function).
  if (doc.is_image) {
    contentEl.appendChild(docImageBlock(doc));
  }
  // Phase 36: the summary panel — labeled section ABOVE the original
  // content, on BOTH surfaces (page + modal) through this one core.
  // Only a non-empty summary renders: markdown docs carry none (phase
  // 30) and the fail-soft path leaves summary NULL, so both are
  // byte-for-byte unchanged here. Phase 122 (task 04): for an IMAGE
  // doc whose summary IS the verbatim description (summary ===
  // content — the importer invariant), the panel would duplicate the
  // text right below the image, so it is suppressed; an admin-edited
  // summary (different text) still renders with the phase-57 edit
  // affordance.
  const imageSummaryIsContent = doc.is_image && doc.summary === doc.content;
  if (doc.summary && doc.summary.trim() !== "" && !imageSummaryIsContent) {
    const section = document.createElement("section");
    section.className = "doc-summary";
    section.setAttribute("aria-label", "Summary");
    const title = document.createElement("h2");
    title.className = "doc-summary-title";
    title.textContent = "Summary";
    const body = document.createElement("p");
    body.className = "doc-summary-text";
    body.textContent = doc.summary; // text node — XSS contract unchanged
    section.append(title, body);
    contentEl.appendChild(section);
    // Phase 57 (task 02, D4): the section above is the phase-36 shape
    // for EVERYONE — only an authenticated admin (docAdminReady(), the
    // module-cached whoami promise) then gains the header row + Edit
    // button + editor wiring. Anonymous / fetch failure: the panel is
    // exactly what phase 36 built (byte-for-byte unchanged).
    void docAdminReady().then((admin) => {
      if (admin) wireSummaryEdit(section, doc);
    });
  }
  if (doc.format === "md" || doc.format === "markdown") {
    const wrap = document.createElement("div");
    wrap.className = "doc-md";
    wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe
    contentEl.appendChild(wrap);
  } else {
    const pre = document.createElement("pre");
    pre.className = "doc-raw";
    pre.textContent = doc.content; // text node: never parsed as HTML
    contentEl.appendChild(pre);
  }
}

/* ---------- image block (phase 122, task 04) ----------
 * The viewer's image block for an ``is_image`` document: the
 * document's PERSISTENT bytes (doc.image_url — the
 * /api/documents/{id}/image route) as a block  (max-width 100%;
 * the theme's surface treatment lives in .doc-image). alt = the
 * summary (the vision description — the WCAG alt contract everywhere);
 * a NULL summary (the fail-soft backfill corner) falls back to the
 * title. On a load failure (the route's 404 — the row exists but the
 * copy was lost) the block shows a small "Image unavailable."
 * note (role=status) in its place; the description in the content
 * slot below still renders (the doc is still readable). Properties
 * only (src, alt) — every document-derived value is a text node /
 * property, never innerHTML (the XSS contract, unchanged). */
function docImageBlock(doc) {
  const wrap = document.createElement("div");
  wrap.className = "doc-image";
  const img = document.createElement("img");
  img.className = "doc-image-img";
  img.src = doc.image_url;
  img.alt =
    typeof doc.summary === "string" && doc.summary.trim() !== ""
      ? doc.summary
      : doc.title;
  img.addEventListener("error", () => {
    const note = document.createElement("p");
    note.className = "doc-image-unavailable";
    note.setAttribute("role", "status");
    note.textContent = "Image unavailable — the file is missing.";
    wrap.replaceChildren(note);
  });
  wrap.appendChild(img);
  return wrap;
}

/* ---------- summary editing (phase 57, task 02 — D4, admin-only) ----------
 * The .doc-summary panel is the ONE place the stored summary is edited
 * (page + modal through this core). Only an admin (docAdminReady) ever
 * gets the affordance; the public viewer is byte-for-byte unchanged. */

/* The admin gate (D4 — the viewer stays public): the module-cached
 * /api/whoami promise (header.js's fetchIsAdmin — the SAME single
 * request per page the shared header already makes on every surface,
 * so this adds no request of its own). A non-admin or any fetch
 * failure resolves false → the anonymous viewer. */
async function docAdminReady() {
  try {
    return (await fetchIsAdmin()) === true;
  } catch {
    return false;
  }
}

/* The edit affordance on one rendered .doc-summary section. The bare
 * h2 becomes a header row (label left, Edit button right). Edit swaps
 * the .doc-summary-text node for the inline editor — a prefilled
 * textarea (value, never innerHTML — XSS contract), Save / Cancel,
 * and a role=status live region. Save PATCHes /api/documents/summary
 * with { source, path, summary } — the pair comes from the doc object
 * (the same values the modal core carries, document-modal.js). Success
 * re-renders the text node via textContent and announces "Summary
 * updated."; an empty save that clears announces "Summary cleared."
 * and removes the panel a short beat later — the confirmation stays
 * readable, and the renderer only draws the panel for non-empty
 * summaries. Cancel restores the text node. A failure keeps the
 * editor open with the user's text and shows neutral retry copy
 * (phase-55 convention). */
function wireSummaryEdit(section, doc) {
  const title = section.querySelector(".doc-summary-title");
  const body = section.querySelector(".doc-summary-text");
  if (!title || !body) return;

  /* Header row: label left, Edit button right (admin-only — the
   * anonymous section keeps its bare h2). */
  const head = document.createElement("div");
  head.className = "doc-summary-head";
  const editBtn = document.createElement("button");
  editBtn.type = "button";
  editBtn.className = "doc-summary-edit";
  editBtn.textContent = "Edit";
  head.append(title, editBtn);
  section.replaceChildren(head, body);

  /* Editor parts (built once; the textarea is rebuilt on every open so
   * it always starts from the CURRENT stored summary). */
  let editor = null;
  const actions = document.createElement("div");
  actions.className = "doc-summary-actions";
  const saveBtn = document.createElement("button");
  saveBtn.type = "button";
  saveBtn.className = "doc-summary-save";
  saveBtn.textContent = "Save";
  const cancelBtn = document.createElement("button");
  cancelBtn.type = "button";
  cancelBtn.className = "doc-summary-cancel";
  cancelBtn.textContent = "Cancel";
  actions.append(saveBtn, cancelBtn);
  const status = document.createElement("p");
  status.className = "doc-summary-status";
  status.setAttribute("role", "status");
  status.setAttribute("aria-live", "polite");

  /* Back to the display state: the text node re-rendered from the
   * doc object (the CURRENT stored summary), the live region (the
   * announced message), the Edit button available again. If the
   * summary is gone (a clear landed while the editor was open — e.g.
   * Cancel right after a successful empty save) the panel is gone
   * too: the renderer only draws it for non-empty summaries. */
  function closeEditor(message) {
    status.textContent = message;
    editBtn.hidden = false;
    if (typeof doc.summary !== "string" || doc.summary.trim() === "") {
      section.remove();
      return;
    }
    body.textContent = doc.summary; // text node — the CURRENT stored summary
    section.replaceChildren(head, body, status);
    editBtn.focus();
  }

  async function saveSummary() {
    const value = editor.value;
    saveBtn.disabled = true; // one PATCH at a time (never stale)
    status.textContent = "";
    try {
      const res = await fetch("/api/documents/summary", {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ source: doc.source, path: doc.path, summary: value }),
      });
      if (!res.ok) {
        // Neutral retry copy (phase-55 convention) — the user's text
        // stays in the editor (the editor stays open on failure).
        status.textContent = "Couldn't update the summary — try again.";
        return;
      }
      const data = await res.json();
      if (data.summary === null) {
        // Cleared (D4): the panel disappears — the renderer only draws
        // it for non-empty summaries. The live-region confirmation
        // stays visible for a short beat before the panel leaves the
        // DOM (a screen reader must be able to read it; the removal
        // is a no-op if the surface re-rendered or closed meanwhile).
        doc.summary = null;
        status.textContent = "Summary cleared.";
        setTimeout(() => section.remove(), 2000);
        return;
      }
      doc.summary = data.summary;
      closeEditor("Summary updated.");
    } catch {
      // Network failure: same neutral shape, the reachable? copy.
      status.textContent = "Couldn't update the summary — is the app reachable?";
    } finally {
      saveBtn.disabled = false;
    }
  }

  function openEditor() {
    editor = document.createElement("textarea");
    editor.className = "doc-summary-editor";
    editor.value = typeof doc.summary === "string" ? doc.summary : ""; // value, never innerHTML
    status.textContent = "";
    editBtn.hidden = true;
    section.replaceChildren(head, editor, actions, status);
    editor.focus();
  }

  editBtn.addEventListener("click", openEditor);
  saveBtn.addEventListener("click", () => {
    void saveSummary();
  });
  cancelBtn.addEventListener("click", () => closeEditor(""));
}

/* ---------- date editing (phase 106, task 09 — D7, admin-only) ----------
 * The .doc-meta badge row (task 08's Created badge) is the ONE place
 * the stored creation date is edited (page + modal through the
 * shared renderDocument core — no per-surface copy). Only an admin
 * (docAdminReady) ever gets the affordance; a non-admin / token
 * holder / failed whoami keeps exactly the task-08 badge row (no
 * button, no wiring, no admin-only network call — the phase-57
 * split). Save PATCHes /api/documents/date with { source, path,
 * date } — a non-empty date sets it (the server normalizes it, D3,
 * and stores the manual flag, D1); the explicit "Revert to sync"
 * affordance sends date: null (the D7 CLEAR — the manual flag drops
 * and the stored date stands until the next sync refreshes it).
 * The badge re-renders from the RESPONSE's created_at — the UI shows
 * exactly what the server stored, never the input's optimistic
 * value. §7.4 never-stale: the controls disable IMMEDIATELY on
 * Save/Revert (no double-submit); a failure (non-2xx or network)
 * lands the server detail (or the canned retry copy) in the
 * role=alert line, reverts the input to the stored date, and
 * re-enables — the UI never claims a state the server didn't save.
 * An EMPTY date input can't clear: Save disables itself on an empty
 * input (the explicit Revert is the only clear path — no accidental
 * wipes). */

/* The server detail of a non-2xx response (the git-sources.js
 * apiDetail shape — the phase-89 error-line idiom: a string detail
 * or the 422 array's first msg), with the neutral fallback for a
 * non-JSON body. */
async function apiDetail(res, fallback) {
  try {
    const data = await res.json();
    if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
      return String(data.detail[0].msg);
    }
    if (typeof data.detail === "string" && data.detail) return data.detail;
  } catch {
    /* non-JSON error body */
  }
  return fallback;
}

/* The date editor on one rendered .doc-meta badge row: the "Edit
 * date" text button after the Created badge (admin-only — the public
 * row keeps task 08's shape). Edit swaps the button for the inline
 * editor in place — a native  (value, never
 * innerHTML — XSS contract) prefilled with the stored date's UTC
 * date part, Save / Cancel, the muted "Revert to sync" clear, and
 * the role=status / role=alert live lines. One PATCH at a time (the
 * controls disable before the fetch and re-enable in the finally —
 * every outcome, never stale, PLAN §7.4). */
function wireDateEdit(metaEl, doc) {
  const createdBadge = metaEl.querySelector(".doc-created");
  if (!createdBadge) return;

  /* Edit affordance (admin-only — the public badge row keeps exactly
   * task 08's shape: no button, no wiring). setAttribute, never
   * innerHTML (the document-derived pair is user-storable). */
  const editBtn = document.createElement("button");
  editBtn.type = "button";
  editBtn.className = "doc-date-edit";
  editBtn.textContent = "Edit date";
  editBtn.setAttribute(
    "aria-label",
    `Edit creation date: ${doc.source}/${doc.path}`,
  );
  createdBadge.insertAdjacentElement("afterend", editBtn);

  /* Editor parts (built once; the input is rebuilt on every open so
   * it always starts from the CURRENT stored date). */
  let input = null;
  const box = document.createElement("span");
  box.className = "doc-date-editor";
  const saveBtn = document.createElement("button");
  saveBtn.type = "button";
  saveBtn.className = "doc-date-save";
  saveBtn.textContent = "Save";
  const cancelBtn = document.createElement("button");
  cancelBtn.type = "button";
  cancelBtn.className = "doc-date-cancel";
  cancelBtn.textContent = "Cancel";
  const revertBtn = document.createElement("button");
  revertBtn.type = "button";
  revertBtn.className = "doc-date-revert";
  revertBtn.textContent = "Revert to sync";
  const status = document.createElement("span");
  status.className = "doc-date-status";
  status.setAttribute("role", "status");
  status.setAttribute("aria-live", "polite");
  const errorLine = document.createElement("span");
  errorLine.className = "doc-date-error";
  errorLine.setAttribute("role", "alert");
  errorLine.setAttribute("aria-live", "assertive");

  /* The stored date as a native date-input value: the UTC date part
   * of the stored ISO timestamp (D3 stores UTC timestamptz). */
  function storedValue() {
    return new Date(doc.created_at).toISOString().slice(0, 10);
  }

  /* One PATCH at a time (PLAN §7.4): the controls disable
   * IMMEDIATELY on submit; Save ADDITIONALLY disables on an EMPTY
   * input (the explicit Revert is the only clear path — no
   * accidental wipes). */
  function setControlsLocked(locked) {
    if (input) input.disabled = locked;
    saveBtn.disabled = locked || input.value === "";
    cancelBtn.disabled = locked;
    revertBtn.disabled = locked;
  }

  /* Back to the display state: the badge row + the Edit button (the
   * task-08 shape restored) and focus back on the opener. Idempotent
   * (a stale success-beat timer may call it after a Cancel — the
   * re-insert is a same-position move and the box is already out).
   * The last announcement stays readable a short beat because the
   * caller defers the call (the phase-57 "confirmation stays
   * readable" precedent). */
  function closeEditor() {
    editBtn.hidden = false;
    createdBadge.insertAdjacentElement("afterend", editBtn);
    box.remove();
    editBtn.focus();
  }

  async function saveDate(dateValue) {
    setControlsLocked(true); // one PATCH at a time (never stale)
    errorLine.textContent = "";
    try {
      const r = await fetch("/api/documents/date", {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          source: doc.source,
          path: doc.path,
          date: dateValue,
        }),
      });
      if (!r.ok) {
        // The server detail into the role=alert line (the canned
        // retry copy on a non-JSON body); the input reverts to the
        // stored date; the controls re-enable in the finally. The
        // editor stays open — the user's attempt is visible.
        errorLine.textContent = await apiDetail(
          r,
          "Couldn't save the date — try again.",
        );
        input.value = storedValue();
        return;
      }
      const res = await r.json();
      // Response-driven: the badge shows exactly what the server
      // stored (never the input's optimistic value), and the doc
      // object syncs so a re-open prefills the CURRENT date.
      doc.created_at = res.created_at;
      doc.created_at_manual = res.created_at_manual;
      createdBadge.textContent = `Created ${fmtDate(res.created_at)}`;
      createdBadge.setAttribute("title", res.created_at);
      // The confirmation lands AFTER the badge update (the phase-89
      // last-announce order), then the row collapses back to the
      // task-08 shape a short beat later (the announcement stays
      // readable — the status line leaves with the box).
      status.textContent =
        dateValue === null
          ? "Reverted to sync-managed date."
          : `Date saved for ${doc.source}/${doc.path}.`;
      setTimeout(closeEditor, 2000);
    } catch {
      // Network failure: the canned retry copy (the phase-55
      // neutral shape), the input reverts to the stored date, the
      // editor stays open.
      errorLine.textContent = "Couldn't save the date — try again.";
      input.value = storedValue();
    } finally {
      setControlsLocked(false);
    }
  }

  function openEditor() {
    input = document.createElement("input");
    input.type = "date";
    input.className = "doc-date-input";
    input.setAttribute("aria-label", "Document creation date");
    input.value = storedValue(); // value, never innerHTML
    input.addEventListener("input", () => {
      // An EMPTY input can't clear: Save disables (the explicit
      // Revert below is the only clear path — no accidental wipes).
      // The locked state owns the controls while a PATCH is in
      // flight (the input is disabled then, so this can't race it).
      if (!input.disabled) saveBtn.disabled = input.value === "";
    });
    status.textContent = "";
    errorLine.textContent = "";
    box.replaceChildren(input, saveBtn, cancelBtn, revertBtn, status, errorLine);
    editBtn.hidden = true;
    createdBadge.insertAdjacentElement("afterend", box);
    setControlsLocked(false);
    input.focus();
  }

  editBtn.addEventListener("click", openEditor);
  saveBtn.addEventListener("click", () => {
    // Save is only ever enabled with a NON-empty input (the empty
    // state disables it) — the clear path is the explicit Revert.
    void saveDate(input.value);
  });
  cancelBtn.addEventListener("click", () => {
    // Cancel: back to the display state, no PATCH (the stored value
    // is untouched — the badge was never mutated).
    status.textContent = "";
    errorLine.textContent = "";
    closeEditor();
  });
  revertBtn.addEventListener("click", () => {
    void saveDate(null); // the D7 CLEAR: { source, path, date: null }
  });
}

/* ---------- /document.html page (phases 10/13/19) ----------
 * Phase 26: viewer-page-specific — see the import-safety note in the
 * header. The guard is #doc-title: it exists only on this page, so the
 * modal module's `import { renderDocument } from "./document.js"` on
 * the chat/sources pages evaluates none of the code below. */
if (document.querySelector("#doc-title")) {
  const params = new URLSearchParams(window.location.search);
  const source = params.get("source") || "";
  const path = params.get("path") || "";

  const titleEl = document.querySelector("#doc-title");
  const metaEl = document.querySelector("#doc-meta");
  const contentEl = document.querySelector("#doc-content");
  const notFoundEl = document.querySelector("#doc-not-found");
  const mainEl = document.querySelector("#main");
  const backLink = document.querySelector("#doc-back");

  /* Back button (phase 13): the return target comes from the `back`
   * query param, not the browser history — the dedicated page is
   * reachable directly (no history to go back to). The param is honored
   * only for same-origin relative URLs (starts with "/" but not "//"),
   * so absolute (https://…), protocol-relative (//…), and
   * pseudo-protocol (javascript:…) values are rejected; anything else
   * falls back to the Sources page. The static href="/sources.html" in
   * document.html remains the no-JS fallback, and with the href set the
   * anchor's default click behavior IS the deterministic navigation (no
   * browser-history heuristics). */
  const backParam = params.get("back") || "";
  const backTarget =
    backParam.startsWith("/") && !backParam.startsWith("//")
      ? backParam
      : "/sources.html";
  backLink.href = backTarget;
  const backLabel = backLink.querySelector("span");
  if (backLabel) {
    backLabel.textContent =
      backTarget === "/"
        ? "Chat"
        : backTarget === "/sources.html"
          ? "Sources"
          : "Back";
  }

  /* renderDocument fills the page elements; the page additionally owns
   * the document.title (the modal keeps the page title untouched). */
  function render(doc) {
    renderDocument(doc, { titleEl, metaEl, contentEl });
    document.title = `${doc.title} · ${brand()}`;
  }

  function showNotFound() {
    titleEl.textContent = "Document not found";
    titleEl.removeAttribute("title"); // no stale full-title tooltip
    document.title = `Document not found · ${brand()}`;
    metaEl.replaceChildren();
    contentEl.replaceChildren();
    notFoundEl.hidden = false;
  }

  /* Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the
   * viewer now carries the SAME standard bar as every other page —
   * nav incl. the admin-only links, the auth pair — toggled on the
   * module's cached whoami (the single whoami per page; fetchIsAdmin
   * is imported for docAdminReady's parity — the same cached promise
   * either way).
   *
   * Phase 79 (task 05): the gate settles the auth FIRST — silent
   * re-auth of a cached token, then the role check (on the shared
   * cached whoami) — and the EXISTING boot sequence runs only on the
   * SETTLED role: onAuthed (the content load) for a signed-in role
   * ONLY (anonymous never fetches the content — the inline gate is
   * the surface, no not-found card for an auth failure; #main is
   * inert while the gate is visible — WCAG, the inert-pair contract),
   * and the shared header boots in the .then AFTER the gate settles
   * for EVERY role (the gate locks #main, not the header — the
   * anonymous contract is byte-identical to the shell: Sign in
   * offered, admin links ship hidden, the steering panel removed).
   * Awaiting the gate first is what makes the header race-free: a
   * silent re-auth lands before the first whoami fires, so the header
   * reads the post-auth role exactly once (no stale anonymous bar for
   * a returning token user, no second whoami). An admin (or a
   * validly cached token user) gets onAuthed immediately — the gate
   * (which ships hidden + inert) never shows. The admin-only edit
   * affordance (docAdminReady() inside renderDocument) stays
   * admin-only — it runs only in the post-auth render path. */
  mountGate(document.getElementById("main"), () => {
    load();
  }).then(() => {
    void initSharedHeader(); // every role — on the SETTLED whoami
  });

  /* The New chat binding is module-owned (assets/header.js, phase 34
   * task 02 — the SINGLE binding): on this non-chat page it clears the
   * phase-14 conversation key and navigates to the chat's empty state. */

  async function load() {
    try {
      if (!source || !path) {
        showNotFound();
        return;
      }
      const r = await fetch(contentUrl(source, path));
      if (!r.ok) {
        showNotFound();
        return;
      }
      render(await r.json());
    } catch {
      showNotFound();
    } finally {
      mainEl.focus(); // move focus to main on load (a11y)
    }
  }

}