// ---------------------------------------------------------------------------
// settings / PermsEditor.tsx — THE USER PAGE. (wave 15 R9: the permission
// editor · wave 17 R7: everything else about the account, merged in.)
//
// "Manage users → click a user → the modal body BECOMES that user's page." A
// full-pane takeover, not a drawer beside the list and not a third dialog: the
// thing being edited is a whole account, and giving it a room of its own is
// what makes room for a filter builder and a field list per module without
// either being a 200px scroll-box.
//
// ⚠ WHY THE FILE NAME NO LONGER DESCRIBES THE FILE. Wave 17's R7, verbatim:
// *"Edit and clicking a user name should just be the EXACT same opening of a
// full page so its not confusing."* Before it there were two doors — the name
// opened this page, "Edit" unfolded a card at the BOTTOM of the accounts list —
// and which one an admin needed depended on a distinction only the code cared
// about (who the account IS vs what it may SEE). Both jobs live here now. The
// export keeps its name because renaming it would ripple through the shell, the
// gate's module list and the render smoke for no user-visible gain — the same
// trade R12 made for `SettingsModal`.
//
// ⛔ EVERY CONTROL HERE IS A COURTESY. `/admin/*` is refused to a member at the
// server, fail-closed, and the wall that acts on what this page writes is
// `core/perm_scope.permits()` — which re-validates the whole record. Nothing on
// this page is a permission check; this page is where an admin SAYS what the
// wall should be.
//
// THE PANELS ARE THE GRID'S OWN (contract C-KIT). Not lookalikes — the same
// `FilterBuilderPanel` and `FieldsHidePanel` the toolbar mounts, from
// `filter-kit/`, which is the entire reason that extraction happened before this
// file existed. An admin writing "customers where dba is Fisch" uses the control
// they already know, and there is one condition grammar in the product rather
// than two that drift within a wave.
//
// ⚠ WHAT THIS EDITOR CANNOT OFFER, AND WHY IT IS RIGHT THAT IT DOES NOT:
//   · **Cohort conditions** — cohorts are a user's own workspace objects and an
//     admin editing someone else's account holds no list of them. The prop is
//     absent, so the affordance is absent (never present-and-inert).
//   · **`status` choice lists** — the grid discovers those from ROWS it has
//     loaded; this page has loaded none. Select/multiselect/user fields still
//     offer their declared options, so only Odoo lifecycle columns come up bare.
// Both are C-KIT's documented absences, restated here because a reader of this
// file will meet them as "why is that dropdown empty".
// ---------------------------------------------------------------------------

import { useCallback, useEffect, useRef, useState } from "react";
import { FilterBuilderPanel, FieldsHidePanel } from "../filter-kit";
import {
  getUserPerms,
  patchUser,
  putUserPerms,
  setPassword,
} from "./settingsApi";
import type { AdminUser } from "./settingsApi";
import type { CopyScope, PermsPayload, PermsRecord } from "./permsModel";
import {
  accessSummary,
  copyDropped,
  copyTargetRecord,
  filterOf,
  hiddenSet,
  hideableKeys,
  identityKey,
  isDirty,
  moduleSummary,
  parsePermsPayload,
  setAccess,
  setFilter,
  setHidden,
  toPutBody,
  toggleHidden,
} from "./permsModel";

type Load =
  | { phase: "loading" }
  | { phase: "error"; message: string }
  | { phase: "ready"; payload: PermsPayload };

/** What the confirm step is asking about. `null` = nothing is being confirmed. */
type Ask = null | { kind: "save" } | { kind: "discard" };

// --- C-PERMCOPY -------------------------------------------------------------
//
// CLIENT-SIDE ONLY, by contract: `GET /admin/users/{u}/perms` per target,
// compose, then `PUT` the WHOLE tree back. No new route.
//
// ⛔ THE WHOLE-TREE LAW IS WHY THE GET IS NOT OPTIONAL, and why the composition
// itself lives in `permsModel.copyTargetRecord` rather than in this file: a PUT
// replaces the record, a key omitted from it is a DELETION, and that failure
// already has a named negative control on the OTHER producer of PUT bodies.
// A second producer with no gate is the same bug waiting for a different road.

/** The UI's scope: the model's `CopyScope` plus the label this page shows. */
type CopyScopeUI = CopyScope & { label?: string };

/** One target, already fetched, with everything the confirm has to disclose. */
interface CopyPlan {
  user: AdminUser;
  payload: PermsPayload;
  /** What that account's access says TODAY — the thing being overwritten. */
  before: string;
  /** Consequences beyond the overwrite itself, named per target. */
  notes: string[];
}

type Copy =
  | { phase: "pick"; scope: CopyScopeUI; picked: ReadonlySet<string> }
  | { phase: "preparing"; scope: CopyScopeUI }
  | {
      phase: "confirm";
      scope: CopyScopeUI;
      plans: CopyPlan[];
      /** Targets whose record could not be READ. Named, never dropped. */
      refused: Array<{ username: string; why: string }>;
    }
  | { phase: "writing"; scope: CopyScopeUI };

/** "a", "a and b", "a, b and c" — a list a person reads, in a message that
 *  names every account it touched. */
function nameList(items: readonly string[]): string {
  if (items.length <= 1) return items[0] ?? "";
  return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
}

function scopeWords(scope: CopyScopeUI): string {
  return scope.kind === "all" ? "this account's whole access" : `${scope.label} access`;
}

export function PermsEditor({
  user,
  userOptions,
  copyTargets,
  onBack,
  onSaved,
}: {
  /**
   * ⚠ THE WHOLE RECORD, not a username and a display name (wave 17 R7). The
   * page edits the account as well as its access, so it needs `role` and
   * `active` — and taking the record rather than five scalars means one place
   * (the pane's `reload`) refreshes everything after a write.
   */
  user: AdminUser;
  /** The people a `user`-typed field may name, for the condition builder. Real
   *  accounts, never invented: an admin filtering on an owner who does not exist
   *  writes a rule that matches nothing and looks like it should match.
   *  ⚠ DISPLAY NAMES — the cells hold names. Not the copy targets below. */
  userOptions?: string[];
  /** Every OTHER account, as records, for C-PERMCOPY.
   *  ⚠ RECORDS, because a copy addresses an account by USERNAME and puts it in
   *  a URL. Handing this the display-name list would build a wrong path that
   *  404s at best and writes to the wrong account at worst. */
  copyTargets?: readonly AdminUser[];
  onBack: () => void;
  /** "The server took a write" — the list behind this page reloads. It does NOT
   *  close the page (R7: one page, several things to save on it). */
  onSaved: (message: string) => void;
}) {
  const displayName = user.name || user.username;
  const username = user.username;

  const [load, setLoad] = useState<Load>({ phase: "loading" });
  /** The record as the SERVER holds it — the baseline `isDirty` compares to. */
  const [saved, setSaved] = useState<PermsRecord>({});
  const [draft, setDraft] = useState<PermsRecord>({});
  const [ask, setAsk] = useState<Ask>(null);
  const [copy, setCopy] = useState<Copy | null>(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  /** What just happened, said ON THIS PAGE. The pane's own notice renders in the
   *  accounts LIST, which the admin is no longer sent back to on save — so
   *  without this a successful write would land in an empty room. */
  const [said, setSaid] = useState("");

  // --- the account half (R7, moved here from the accounts list's `EditUser`) --
  const [name, setName] = useState(user.name ?? "");
  const [role, setRole] = useState(user.role ?? "user");
  const [pw, setPw] = useState("");

  useEffect(() => {
    let dead = false;
    setLoad({ phase: "loading" });
    void getUserPerms(username).then((r) => {
      if (dead) return;
      if (!r.ok) {
        setLoad({ phase: "error", message: r.message });
        return;
      }
      const payload = parsePermsPayload(r.data);
      if (!payload) {
        // An unreadable payload is NOT an empty one. Rendering "no modules"
        // here would invite an admin to save a record that grants nothing,
        // over a record they never saw.
        setLoad({
          phase: "error",
          message: "The permission record could not be read. Close and try again.",
        });
        return;
      }
      setSaved(payload.entries);
      setDraft(payload.entries);
      setLoad({ phase: "ready", payload });
    });
    return () => {
      dead = true;
    };
  }, [username]);

  const payload = load.phase === "ready" ? load.payload : null;
  const dirty = payload ? isDirty(payload, saved, draft) : false;
  /**
   * ⚠ `payload.isAdmin` ALONE GOES STALE THE MOMENT THIS PAGE CAN CHANGE THE
   * ROLE — which is new in wave 17. It is a snapshot taken by the perms GET on
   * mount; promote the account to Admin from the card above and that GET is not
   * repeated, so the "these rules are inert" banner would stay hidden over an
   * account whose rules had just become inert. `user.role` is the fresher
   * truth: the accounts list reloads after every write here, and the record
   * comes back with it. Both are read, because either can be the newer one —
   * and the failure this prevents is an admin restricting an administrator and
   * walking away believing they did, which is the exact misreading the banner
   * exists to stop.
   *
   * ⚠ NOT the local `role` state: that is the DRAFT, and an unsaved role change
   * has not made anything inert yet.
   */
  const subjectIsAdmin = user.role === "admin" || payload?.isAdmin === true;
  /**
   * ⚠ THE GUARD HAD TO WIDEN WITH THE PAGE. `dirty` describes the PERMISSION
   * draft only, and it was the whole story while this file was only a
   * permission editor. Merge the account form in and Escape would throw away a
   * half-typed display name or a role change with no prompt and no undo —
   * exactly the failure the capture-phase listener below exists to prevent,
   * arriving through a second door.
   *
   * ⚠ A TYPED PASSWORD IS DELIBERATELY NOT COUNTED. It is a one-shot field with
   * its own button, retyped in seconds, and prompting "discard changes?" because
   * a password box has characters in it trains people to click through the
   * prompt — which weakens the guard for the edits that actually cost something.
   */
  const accountDirty =
    name.trim() !== (user.name ?? "").trim() || role !== (user.role ?? "user");
  const anyDirty = dirty || accountDirty;

  const goBack = useCallback(() => {
    if (anyDirty) setAsk({ kind: "discard" });
    else onBack();
  }, [anyDirty, onBack]);

  // ⚠ CAPTURE PHASE, ON PURPOSE. The settings modal binds its own window
  // `keydown` and closes the WHOLE dialog on Escape — which, on this page,
  // would throw away an admin's unsaved edits with no prompt and no undo. A
  // capture-phase listener on the same target runs before that bubble-phase
  // one, so `stopPropagation()` here actually stops it.
  const dirtyRef = useRef(anyDirty);
  dirtyRef.current = anyDirty;
  const askRef = useRef(ask);
  askRef.current = ask;
  const copyRef = useRef(copy);
  copyRef.current = copy;
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Escape") return;
      // A copy in flight owns Escape first: it is the innermost thing open, and
      // dismissing it must not also dismiss the page under it.
      if (copyRef.current) {
        e.stopPropagation();
        if (copyRef.current.phase !== "writing") setCopy(null);
        return;
      }
      if (askRef.current) {
        e.stopPropagation();
        setAsk(null);
        return;
      }
      if (dirtyRef.current) {
        e.stopPropagation();
        setAsk({ kind: "discard" });
      }
    };
    window.addEventListener("keydown", onKey, true);
    return () => window.removeEventListener("keydown", onKey, true);
  }, []);

  // ⚠ THE CONFIRM MUST BE BROUGHT INTO VIEW, and this is not polish. Its scrim
  // is scoped to THIS PANE rather than the viewport (a second full-screen scrim
  // over a dialog that already has one reads as the application losing its
  // place) — but the pane grows with the module list while `.set-body` is what
  // scrolls, so a card centred in the PANE can sit hundreds of pixels above the
  // visible area. The admin clicks Save at the bottom of a long page and
  // nothing appears to happen. One module fits today; the second one does not.
  const confirmRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    if (!ask && !copy) return;
    confirmRef.current?.scrollIntoView({ block: "center" });
    // Focus follows the question, so Enter answers it and a screen reader is
    // taken to the alert rather than left where the pointer was.
    confirmRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
  }, [ask, copy]);

  const save = useCallback(() => {
    if (!payload) return;
    setBusy(true);
    setError("");
    const body = toPutBody(payload, draft);
    void putUserPerms(username, body).then((r) => {
      setBusy(false);
      setAsk(null);
      if (r.ok) {
        setSaved(body.perms);
        setSaid(`Saved access for ${username}.`);
        onSaved(`Saved access for ${username}.`);
      } else {
        setError(r.message);
      }
    });
  }, [payload, draft, username, onSaved]);

  /** The account half's write. ⚠ THE PATCH STILL CARRIES NO `bus` (R1), and
   *  that is load-bearing rather than tidy: with the picker gone this form
   *  cannot edit the field, so a PATCH that still sent it would be a client
   *  echoing back a value it only read — and once the migration converts `bus`
   *  into a permanent filter and clears the field, that echo would RESURRECT
   *  it. A form writes what it edits and nothing else. */
  const applyAccount = useCallback(
    (patch: Parameters<typeof patchUser>[1], said: string) => {
      setBusy(true);
      setError("");
      void patchUser(username, patch).then((r) => {
        setBusy(false);
        if (r.ok) {
          setSaid(said);
          onSaved(said);
        } else {
          setError(r.message);
        }
      });
    },
    [username, onSaved]
  );

  // --- C-PERMCOPY: prepare, then confirm, then write ------------------------

  /**
   * ⚠ THE TARGETS ARE FETCHED BEFORE THE CONFIRM, NOT AFTER IT. The contract
   * asks for "a confirm that names the overwrite", and a confirm written from
   * this page's knowledge alone can only name the SOURCE — it would say what is
   * being copied and stay silent on what is being destroyed. Reading each
   * target first lets the question state, per account, what that account's
   * access says today and what else this write does to it. The payload fetched
   * here is the same one the PUT is built from, so it costs nothing extra.
   */
  const prepareCopy = useCallback(
    async (scope: CopyScopeUI, picked: readonly string[]) => {
      setCopy({ phase: "preparing", scope });
      const plans: CopyPlan[] = [];
      const refused: Array<{ username: string; why: string }> = [];
      for (const target of copyTargets ?? []) {
        if (!picked.includes(target.username)) continue;
        const r = await getUserPerms(target.username);
        if (!r.ok) {
          refused.push({ username: target.username, why: r.message });
          continue;
        }
        const p = parsePermsPayload(r.data);
        if (!p) {
          refused.push({
            username: target.username,
            why: "its permission record could not be read",
          });
          continue;
        }
        const notes: string[] = [];
        // An admin bypasses `perms` entirely, so a copy onto one is stored and
        // inert. Silently writing rules that do nothing is how an admin comes
        // to believe an account is restricted when it is not.
        if (p.isAdmin) {
          notes.push(
            "is an administrator — the rules will be stored but stay inert until the account is changed to Member"
          );
        }
        // ⚠ A COPY MIGRATES THE TARGET. An un-migrated record runs under the
        // PREVIOUS wall; a PUT moves it to the per-module model, which changes
        // what governs that account from that moment on. Fresh accounts are
        // exactly the ones an admin reaches for the copy button over
        // (`create_user` stamps no perms), so this is the common case, not the
        // exotic one.
        if (!p.migrated) {
          notes.push(
            "still uses the previous access model — this write moves it to the per-module model"
          );
        }
        // The same disclosure this page already makes for the account it has
        // open. A whole-record replace drops what it does not name, and a copy
        // does it to somebody the admin never opened.
        if (p.orphanModules.length) {
          notes.push(
            `has rules for ${p.orphanModules.join(", ")}, which this deployment no longer offers — those are removed`
          );
        }
        // ⚠ DISCLOSED BEFORE THE WRITE, not only after it. `runCopy` also reports
        // this, but a report arrives when the admin can no longer decide
        // anything; the confirm is where the decision is. The payload needed to
        // know it is already in hand, so there is no reason to make them find out
        // the expensive way.
        const cannot = copyDropped(p, saved, scope);
        if (cannot.length) {
          const named = cannot.map(
            (k) => payload?.modules.find((m) => m.key === k)?.label ?? k
          );
          notes.push(
            `does not offer ${named.join(", ")}, so that part of this copy cannot be written at all`
          );
        }
        plans.push({
          user: target,
          payload: p,
          before:
            scope.kind === "all"
              ? accessSummary(p, p.entries) || "No access"
              : moduleSummary(
                  p.entries[scope.key],
                  (p.modules.find((m) => m.key === scope.key)?.fields.length ?? 0) === 0
                ),
          notes,
        });
      }
      setCopy({ phase: "confirm", scope, plans, refused });
    },
    // `saved` and `payload`: the drop disclosure is computed against the record
    // as STORED (what a copy actually sends) and labelled from this account's
    // own module list, which is the only place a label for an undeclared-on-the-
    // target module exists.
    [copyTargets, saved, payload]
  );

  const runCopy = useCallback(
    async (scope: CopyScopeUI, plans: readonly CopyPlan[]) => {
      setCopy({ phase: "writing", scope });
      setError("");
      const done: string[] = [];
      const failed: string[] = [];
      const dropped = new Set<string>();
      for (const plan of plans) {
        // ⚠ THE COMPOSITION LIVES IN `permsModel`, NOT HERE — it is the second
        // producer of a PUT body in the product and the first aimed at somebody
        // else's record, so it belongs where the gate can reach it and where its
        // negative control can prove it capable of going red. See
        // `copyTargetRecord`'s own note for the whole-tree rule it enforces.
        const merged = copyTargetRecord(plan.payload, saved, scope);
        for (const k of copyDropped(plan.payload, saved, scope)) dropped.add(k);
        const r = await putUserPerms(plan.user.username, toPutBody(plan.payload, merged));
        if (r.ok) done.push(plan.user.username);
        else failed.push(`${plan.user.username} (${r.message})`);
      }
      setCopy(null);
      // ⚠ ONE MESSAGE PER OUTCOME, NEVER ONE MESSAGE FOR THE BATCH. Three
      // targets with the second refused, reported as "that change could not be
      // saved", is a sentence that lies about the two that took it — and the
      // admin's only recovery is to re-copy onto accounts that already have it.
      if (done.length) {
        const msg = `Copied ${scopeWords(scope)} to ${nameList(done)}.`;
        setSaid(msg);
        onSaved(msg);
      }
      // ⚠ ONE MESSAGE, BUILT FROM WHAT ACTUALLY WENT WRONG — the refusals and
      // the modules that could not ride the write are different failures and
      // both have to survive into it. "Copied" alone over a dropped module would
      // be a success message for a write that did not fully happen.
      const problems: string[] = [];
      if (failed.length) problems.push(`Not copied to ${nameList(failed)}.`);
      if (dropped.size) {
        const list = nameList([...dropped].sort());
        problems.push(
          `${list} could not be copied — the accounts you picked do not offer ${dropped.size === 1 ? "it" : "them"}.`
        );
      }
      if (problems.length) setError(problems.join(" "));
    },
    [saved, onSaved]
  );

  const canCopy = (copyTargets?.length ?? 0) > 0 && !!payload && !dirty;
  const openCopy = useCallback((scope: CopyScopeUI) => {
    setAsk(null);
    setError("");
    setSaid("");
    setCopy({ phase: "pick", scope, picked: new Set<string>() });
  }, []);

  return (
    <div className="set-pane set-perms">
      <div className="set-perm-head">
        <button type="button" className="set-secondary" onClick={goBack}>
          Back to accounts
        </button>
        <div className="set-perm-who">
          <h3 className="set-h set-perm-title">{displayName}</h3>
          <span className="set-username">{username}</span>
        </div>
        <span className={role === "admin" ? "set-chip set-chip--admin" : "set-chip"}>
          {role === "admin" ? "Admin" : "Member"}
        </span>
        {user.active ? null : <span className="set-chip set-chip--off">Deactivated</span>}
      </div>

      {error ? <p className="set-error">{error}</p> : null}
      {said ? <p className="set-notice">{said}</p> : null}

      {/* ── the account (R7) ─────────────────────────────────────────────── */}
      <section className="set-card">
        <h4 className="set-h4">Account</h4>
        <div className="set-grid2">
          <div className="set-field">
            <label className="set-label" htmlFor="set-name">Display name</label>
            <input
              id="set-name"
              className="set-input"
              value={name}
              onChange={(e) => setName(e.target.value)}
            />
          </div>
          <div className="set-field">
            <label className="set-label" htmlFor="set-role">Role</label>
            <select
              id="set-role"
              className="pg-select"
              value={role}
              onChange={(e) => setRole(e.target.value)}
            >
              <option value="user">Member</option>
              <option value="admin">Admin</option>
            </select>
          </div>
        </div>
        {/* An admin bypasses every rule below. Changing the role here therefore
            changes whether this whole page means anything, and saying so at the
            control beats discovering it from an access page that does nothing. */}
        {role === "admin" && (user.role ?? "user") !== "admin" ? (
          <p className="set-help">
            Saving this makes the account an administrator, which bypasses every
            access rule set below.
          </p>
        ) : null}
        <div className="set-actions">
          <button
            type="button"
            className="set-primary"
            disabled={busy || !accountDirty}
            onClick={() => applyAccount({ name, role }, `Saved ${username}.`)}
          >
            Save account
          </button>
          <button
            type="button"
            className="set-secondary"
            disabled={busy}
            onClick={() =>
              applyAccount(
                { active: !user.active },
                `${user.active ? "Deactivated" : "Reactivated"} ${username}.`
              )
            }
          >
            {user.active ? "Deactivate account" : "Reactivate account"}
          </button>
        </div>

        {/* ⚠ THE SAME GRID as the two fields above it, so the password box is
            exactly the width of the display-name box. Left full-bleed it
            stretched the whole card while the field above it was half of one —
            an accident of markup that reads as the two being different kinds of
            thing. The warning moves alongside, which is also where it belongs:
            beside the control it is about, not under it. */}
        <div className="set-danger set-grid2">
          <div className="set-field">
            <label className="set-label" htmlFor="set-pw">Set a new password</label>
            <input
              id="set-pw"
              className="set-input"
              type="password"
              value={pw}
              autoComplete="new-password"
              onChange={(e) => setPw(e.target.value)}
            />
          </div>
          <div className="set-pw-side">
            {/* Said plainly BEFORE the click. Resetting a password bumps the
                session epoch, which signs the person out of every device they
                are using — an admin should not discover that from a support
                ticket. */}
            <p className="set-help">This signs {username} out everywhere immediately.</p>
            <button
              type="button"
              className="set-secondary"
              disabled={busy || pw.length === 0}
              onClick={() => {
                setError("");
                void setPassword(username, pw).then((r) => {
                  if (r.ok) {
                    setPw("");
                    setSaid(`Password reset; ${username} was signed out.`);
                  } else {
                    setError(r.message);
                  }
                });
              }}
            >
              Reset password
            </button>
          </div>
        </div>
      </section>

      {/* wave17 item 3 (R6) — the mark, not "Loading access…". The way OUT is
          rendered above this and stays rendered, which is what the loading state
          actually owed the reader; the sentence never was. */}
      {load.phase === "loading" ? (
        <div className="set-loading">
          <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
        </div>
      ) : null}
      {load.phase === "error" ? <p className="set-error">{load.message}</p> : null}

      {payload ? (
        <>
          {/* ⛔ AN ADMIN BYPASSES `perms` ENTIRELY (C-PERM amendment 4) — the
              clause that also keeps break-glass alive during a store outage. So
              every rule below is INERT for this account, and the page has to say
              so: an editor that renders a filter and a hidden-field list without
              a word is inviting somebody to "restrict" an administrator and walk
              away believing they did. The controls stay usable on purpose — the
              rules are real the moment the account stops being an admin. */}
          {subjectIsAdmin ? (
            <p className="set-notice set-perm-banner">
              This is an administrator account. Administrators see everything, so
              nothing set here applies to them — these rules take effect only if
              the account is changed to Member.
            </p>
          ) : null}

          {/* C-PERM amendment 4 — an un-migrated record still runs under the
              PREVIOUS wall, so its empty perms mean "not written yet", not "no
              access". Saying so is the difference between an admin reading this
              page correctly and one who thinks the account is locked out. */}
          {!payload.migrated && !subjectIsAdmin ? (
            <p className="set-notice set-perm-banner">
              This account still uses the previous access model, so the rules below
              are not in force yet. Saving moves it to the per-module model — set
              every module deliberately before you do.
            </p>
          ) : null}

          {/* A whole-record replace drops what it does not name. An admin should
              learn that here, not from a support ticket about a filter that
              vanished. */}
          {payload.orphanModules.length ? (
            <p className="set-error set-perm-banner">
              This account has rules for {payload.orphanModules.join(", ")}, which
              this deployment no longer offers. Saving removes them.
            </p>
          ) : null}

          <div className="set-perm-sect">
            <h4 className="set-h4">Access</h4>
            {copyTargets?.length ? (
              <button
                type="button"
                className="set-secondary set-copy-door"
                disabled={!canCopy || busy}
                title={
                  dirty
                    ? "Save this account's access first — a copy sends what is stored, not what is on screen."
                    : undefined
                }
                onClick={() => openCopy({ kind: "all" })}
              >
                Apply this access to…
              </button>
            ) : null}
          </div>

          <p className="set-help set-perm-intro">
            {accessSummary(payload, draft)}. Each module can be turned off entirely,
            narrowed to the records a condition describes, or shown with fields
            hidden. Restrictions apply everywhere the account looks — the tables,
            the exports and every number computed from them.
          </p>

          {/* ⚠ NOT A DISABLED BUTTON'S TOOLTIP ALONE. A control that is refused
              has to say why where the eye already is; a `title` only answers the
              reader who thought to hover. */}
          {dirty && copyTargets?.length ? (
            <p className="set-help set-copy-why">
              Copying is unavailable while there are unsaved changes — a copy sends
              what is stored, not what is on screen.
            </p>
          ) : null}

          {payload.modules.length === 0 ? (
            <p className="pg-empty">
              This deployment declares no modules that can be restricted.
            </p>
          ) : null}

          {payload.modules.map((m) => {
            const entry = draft[m.key];
            const on = entry?.access === true;
            const schemaless = m.fields.length === 0;
            return (
              <section className="set-card set-perm-mod" key={m.key}>
                <div className="set-perm-modhead">
                  <label className="set-check set-perm-toggle">
                    <input
                      type="checkbox"
                      checked={on}
                      onChange={(e) => setDraft((d) => setAccess(d, m.key, e.target.checked))}
                    />
                    <span className="set-perm-modname">{m.label}</span>
                  </label>
                  <span className="set-perm-sum">{moduleSummary(entry, schemaless)}</span>
                  {copyTargets?.length ? (
                    <button
                      type="button"
                      className="set-secondary set-copy-door"
                      disabled={!canCopy || busy}
                      onClick={() => openCopy({ kind: "module", key: m.key, label: m.label })}
                    >
                      Copy to…
                    </button>
                  ) : null}
                </div>

                {/* R9's fail-closed rendering: no readable schema means the
                    access toggle and nothing else. The record it saves says the
                    same thing — no filter, no hidden fields — so the editor and
                    the payload cannot disagree (permsModel.toPutBody). */}
                {on && schemaless ? (
                  <p className="set-help">
                    No field list is available for this module, so access is all this
                    editor can set for it. Conditions and hidden fields need a schema.
                  </p>
                ) : null}

                {on && !schemaless ? (
                  <div className="set-perm-panels">
                    <div className="cg-pop set-perm-pop">
                      <FilterBuilderPanel
                        fields={m.fields}
                        filters={filterOf(draft, m.key)}
                        onChange={(next) => setDraft((d) => setFilter(d, m.key, next))}
                        userOptions={userOptions}
                      />
                    </div>
                    <div className="cg-pop set-perm-pop">
                      <FieldsHidePanel
                        fields={m.fields}
                        hidden={hiddenSet(draft, m.key)}
                        onToggle={(key) => setDraft((d) => toggleHidden(d, m.key, key))}
                        // ⚠ `lockedKey` IS NOT OPTIONAL HERE, whatever the prop
                        // says. Absent, the panel locks nothing and one click on
                        // "Hide all" hides the row's own name too — a record
                        // whose faithful enforcement is a table of blank rows,
                        // which C-PERM's PUT validation would accept because the
                        // identity column is a perfectly KNOWN field key.
                        lockedKey={identityKey(m.fields)}
                        onHideAll={() =>
                          setDraft((d) => setHidden(d, m.key, hideableKeys(m.fields)))
                        }
                        onShowAll={() => setDraft((d) => setHidden(d, m.key, []))}
                      />
                    </div>
                  </div>
                ) : null}
              </section>
            );
          })}

          <div className="set-actions set-perm-actions">
            <button
              type="button"
              className="set-primary"
              disabled={busy || !dirty}
              onClick={() => setAsk({ kind: "save" })}
            >
              Save access
            </button>
            <button type="button" className="set-secondary" disabled={busy} onClick={goBack}>
              Cancel
            </button>
            {dirty ? <span className="set-help set-perm-dirty">Unsaved changes</span> : null}
          </div>
        </>
      ) : null}

      {ask ? (
        <div className="set-confirm-wrap">
          <div
            ref={confirmRef}
            className="set-confirm"
            role="alertdialog"
            aria-modal="true"
            aria-label={ask.kind === "save" ? "Confirm access change" : "Discard changes"}
          >
            {ask.kind === "save" ? (
              <>
                <h4 className="set-h4">Change what {displayName} can see?</h4>
                <p className="set-help">
                  This takes effect the next time they load a page. {accessSummaryLine(payload, draft)}
                </p>
              </>
            ) : (
              <>
                <h4 className="set-h4">Discard these changes?</h4>
                {/* ⚠ IT NAMES WHICH ONES. The page holds two kinds of unsaved
                    work now, and "your changes" over a form the reader cannot
                    see behind the scrim is a question they cannot answer. */}
                <p className="set-help">
                  {unsavedWords(dirty, accountDirty)} for {displayName} stay as they were.
                </p>
              </>
            )}
            <div className="set-actions">
              <button
                type="button"
                className="set-primary"
                disabled={busy}
                onClick={() => (ask.kind === "save" ? save() : (setAsk(null), onBack()))}
              >
                {ask.kind === "save" ? "Save access" : "Discard"}
              </button>
              <button
                type="button"
                className="set-secondary"
                disabled={busy}
                onClick={() => setAsk(null)}
              >
                Keep editing
              </button>
            </div>
          </div>
        </div>
      ) : null}

      {copy ? (
        <div className="set-confirm-wrap">
          <div
            ref={confirmRef}
            className="set-confirm set-copy"
            role="alertdialog"
            aria-modal="true"
            aria-label={`Copy ${scopeWords(copy.scope)}`}
          >
            {copy.phase === "pick" ? (
              <>
                <h4 className="set-h4">Copy {scopeWords(copy.scope)} to…</h4>
                <p className="set-help">
                  {copy.scope.kind === "all"
                    ? `Every account you pick has its whole access record replaced with ${displayName}'s.`
                    : `Every account you pick has its ${copy.scope.label} access replaced with ${displayName}'s. Their other modules are left alone.`}
                </p>
                <div className="set-copy-list">
                  {(copyTargets ?? []).map((t) => (
                    <label className="set-check set-copy-row" key={t.username}>
                      <input
                        type="checkbox"
                        checked={copy.picked.has(t.username)}
                        onChange={(e) => {
                          const next = new Set(copy.picked);
                          if (e.target.checked) next.add(t.username);
                          else next.delete(t.username);
                          setCopy({ ...copy, picked: next });
                        }}
                      />
                      <span className="set-copy-name">{t.name || t.username}</span>
                      <span className="set-username set-copy-user">{t.username}</span>
                      {t.role === "admin" ? (
                        <span className="set-chip set-chip--admin">Admin</span>
                      ) : null}
                      {t.active ? null : (
                        <span className="set-chip set-chip--off">Deactivated</span>
                      )}
                    </label>
                  ))}
                </div>
                <div className="set-actions">
                  <button
                    type="button"
                    className="set-primary"
                    disabled={copy.picked.size === 0}
                    onClick={() => void prepareCopy(copy.scope, [...copy.picked])}
                  >
                    Continue
                  </button>
                  <button type="button" className="set-secondary" onClick={() => setCopy(null)}>
                    Cancel
                  </button>
                </div>
              </>
            ) : null}

            {copy.phase === "preparing" || copy.phase === "writing" ? (
              <div className="set-loading">
                <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
              </div>
            ) : null}

            {copy.phase === "confirm" ? (
              <>
                <h4 className="set-h4">
                  Replace {scopeWords(copy.scope)} for {copy.plans.length} account
                  {copy.plans.length === 1 ? "" : "s"}?
                </h4>
                {copy.plans.length === 0 ? (
                  <p className="set-help">
                    None of the accounts picked could be read, so there is nothing to write.
                  </p>
                ) : (
                  <ul className="set-copy-plans">
                    {copy.plans.map((p) => (
                      <li key={p.user.username}>
                        {/* ⚠ THE OVERWRITE, NAMED PER TARGET. What is destroyed
                            is what this account has TODAY, so the question shows
                            it — a confirm that only describes the source asks
                            the admin to remember the rest. */}
                        <span className="set-copy-name">{p.user.name || p.user.username}</span>{" "}
                        <span className="set-copy-before">now: {p.before}</span>
                        {p.notes.map((n) => (
                          <span className="set-copy-note" key={n}>
                            {p.user.username} {n}
                          </span>
                        ))}
                      </li>
                    ))}
                  </ul>
                )}
                {copy.refused.length ? (
                  <p className="set-error">
                    {/* ⚠ The reason comes from the API and ends in a full stop
                        of its own ("Administrators only."), which read as
                        "(Administrators only.) could not be read" — a stray
                        period mid-sentence. Trimmed where the sentence is
                        built, not in the message the API returns. */}
                    {nameList(
                      copy.refused.map((r) => `${r.username} (${r.why.replace(/\.\s*$/, "")})`)
                    )}{" "}
                    could not be read and will not be changed.
                  </p>
                ) : null}
                <div className="set-actions">
                  <button
                    type="button"
                    className="set-primary"
                    disabled={copy.plans.length === 0}
                    onClick={() => void runCopy(copy.scope, copy.plans)}
                  >
                    Replace access
                  </button>
                  <button type="button" className="set-secondary" onClick={() => setCopy(null)}>
                    Cancel
                  </button>
                </div>
              </>
            ) : null}
          </div>
        </div>
      ) : null}
    </div>
  );
}

/** The confirm's one-line restatement. Deliberately the SAME sentence the pane
 *  header shows: a confirm that summarises differently from the page behind it
 *  is asking the reader to reconcile two descriptions under time pressure. */
function accessSummaryLine(payload: PermsPayload | null, draft: PermsRecord): string {
  const s = accessSummary(payload, draft);
  return s ? `${s} after this change.` : "";
}

/** Which kinds of unsaved work the discard question is about. Exported-shaped
 *  as a pure function so it reads once and cannot drift between the two places
 *  a reader meets it. */
function unsavedWords(permsDirty: boolean, accountDirty: boolean): string {
  if (permsDirty && accountDirty) return "The account details and access rules";
  if (accountDirty) return "The account details";
  return "The access rules";
}
