// ---------------------------------------------------------------------------
// settings / SettingsModal.tsx — EXIT wave 2 (W2-8, contract Y4).
//
// Settings and Manage users as ONE centred modal with a left rail, matching the
// shipped Streamlit design (`app.py:settings_dialog`, item 8) and the reference
// the owner named: *"when I click 'Setting' this whole thing pops up? I want
// that exact thing for when we click Settings and Manage User, where the
// background is clearly blurred too."*
//
// ⚠ WAVE 19 R12 — THE SURFACE IS CALLED "SETTINGS" AGAIN, and this note replaces
// the wave-15 one that said the opposite ("the surface is called PROFILE now, in
// this shell only"). The owner has ruled the rename back: one account-menu row,
// called Settings, with a gear, shown to everyone, landing on Account. The file,
// the export and the `SettingsSection` type were never renamed in either
// direction — which is exactly why the wave-15 trade was the right one to make
// and the cheap one to undo. A LABEL moved twice; no import ever did.
//
// ⛔ `verify_ui.py`'s naming check enforced the wave-15 wording, so the correct
// edit would have turned it red. It is inverted in the same change (bare
// "Profile" must not survive as copy; bare "Settings" must be PRESENT) and
// booked as wave-19 amendment 1. A gate that pins a superseded ruling is not a
// gate, it is a second place the old decision lives.
//
// A MODAL, NOT A PAGE, and the shipped version's reasoning holds here too:
// settings are a detour, not a destination — you change a toggle and go back to
// what you were doing. The Streamlit app kept a settings PAGE alongside the
// modal because a deep link is a poor thing to land on a modal; the shell has
// no settings route at all, so there is nothing to keep.
//
// ⛔ EVERY ADMIN CONTROL HERE IS A COURTESY, NOT A CHECK. The server refuses a
// non-admin on every `/admin/*` route, fail-closed, and Y4's gate proves it by
// having a viewer try. Hiding the rail entry only spares a member a door that
// would not open.
// ---------------------------------------------------------------------------

import { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import type { SessionUser } from "../shell/session";
import { isAdmin, parseUserEnvelope } from "../shell/session";
import type { AdminUser, SettingsPayload } from "./settingsApi";
// ⚠ `patchUser` and `setPassword` left this file with `EditUser` (R7) — they are
// imported by `PermsEditor` now, where the form that writes them lives, and it
// spells the patch shape `Parameters<typeof patchUser>[1]` rather than importing
// `UserPatch` separately. `noUnusedLocals` is what walks a move like this down to
// the last orphaned import, which is the tsconfig earning its keep again.
import { clearAvatar, createUser, getSettings, listUsers, setAvatar } from "./settingsApi";
import {
  addKeychainEntry,
  deleteKeychainEntry,
  getConnectors,
  listKeychain,
  setKeychainScope,
  pauseConnector,
  testKeychainEntry,
} from "./settingsApi";
import type { ConnectorRow, KeyEntry, UnsyncedInfo } from "./settingsApi";
import { PermsEditor } from "./PermsEditor";
// Wave 19 contract C2 — session D's self-contained plane; see the mount below.
import { AdminPane } from "./AdminPane";
import { StatementsPane } from "./StatementsPane";
import { moduleSummary, parseEntry, reachableSection } from "./permsModel";

// The vocabulary lives beside the rule that gates it (permsModel), and is
// re-exported here so `Shell.tsx`'s import does not have to know that.
export type { SettingsSection } from "./permsModel";
import type { SettingsSection } from "./permsModel";

// ⛔ `BU_LABELS`, `BU_IDS` and `scopeWords` ALL WENT WITH THE PICKER (R1).
// Nothing offers, writes or RENDERS a business unit in this shell any more —
// the last reader was the accounts list, and its column is now "Access". The
// concept survives only as a legacy value on the record, which S1's migration
// converts to a permanent filter once. `noUnusedLocals` is what walked the
// removal down to the last orphan, which is the tsconfig earning its keep.

function moduleWords(mods: string[] | "all"): string {
  // ⚠ `[]` READS as unrestricted — the existing semantic Y4 says not to "fix"
  // this wave. Saying "All modules" here is therefore the TRUE statement, and
  // the write side is what refuses to create the ambiguity (see settingsApi).
  if (mods === "all" || !Array.isArray(mods) || mods.length === 0) return "All modules";
  return `${mods.length} module${mods.length === 1 ? "" : "s"}: ${mods.join(", ")}`;
}

// ------------------------------------------------------------------ the users pane

/** The trailing affordance on an account card. SVG, never a character glyph. */
function ChevIcon() {
  return (
    <svg className="set-acct-chev" viewBox="0 0 12 12" aria-hidden="true">
      <path d="M4.4 2.4 8 6l-3.6 3.6" />
    </svg>
  );
}

// ── WAVE 19 R13: one mark per rail tab ───────────────────────────────────────
//
// The rail was five words in a column, and at a glance five words in a column is
// a list you read rather than a place you navigate. A mark per row is what makes
// "Keychains" findable without reading "Connectors" first.
//
// ⚠ PURE FUNCTIONS OF NOTHING, and that is a requirement here rather than a
// style: `verify_ui.py` renders this module through `react-dom/server`, where
// `window` and `document` do not exist. An icon that measured anything, or read
// a theme off the DOM at module scope, would take the whole gate down with it —
// and the failure would look like a broken gate rather than a broken icon.
// SVG only, never emoji (the platform's own standing rule).
function RailIcon({ children }: { children: ReactNode }) {
  return (
    <svg className="set-rail-icon" viewBox="0 0 16 16" aria-hidden="true">
      {children}
    </svg>
  );
}

/** Account — the person whose settings these are. */
const IconAccount = (
  <RailIcon>
    <circle cx="8" cy="5.6" r="2.7" />
    <path d="M2.9 13.4c0-2.5 2.3-4.1 5.1-4.1s5.1 1.6 5.1 4.1" />
  </RailIcon>
);
/** Your access — a closed padlock: what this account may open, and what narrows it. */
const IconAccess = (
  <RailIcon>
    <rect x="3.3" y="7.1" width="9.4" height="6.3" rx="1.3" />
    <path d="M5.6 7.1V5.3a2.4 2.4 0 0 1 4.8 0v1.8" />
  </RailIcon>
);
/** Manage users — more than one person. */
const IconUsers = (
  <RailIcon>
    <circle cx="6.2" cy="5.9" r="2.3" />
    <path d="M1.9 13.2c0-2.2 1.9-3.5 4.3-3.5s4.3 1.3 4.3 3.5" />
    <path d="M10.7 4.1a2.3 2.3 0 0 1 0 4.3M11.6 9.9c1.5.4 2.5 1.5 2.5 3.3" />
  </RailIcon>
);
/** Keychains — a key. */
const IconKey = (
  <RailIcon>
    <circle cx="5.4" cy="10.6" r="2.6" />
    <path d="M7.3 8.8 13.1 3M11 5.1l1.5 1.5M9.4 6.7l1.4 1.4" />
  </RailIcon>
);
/** Connectors — two links joined: where the data comes from. */
/** Statements — an envelope. The one room in here that sends something OUT of
 *  the building, so it wears the only outbound-shaped mark on the rail. */
const IconMail = (
  <RailIcon>
    <path d="M2 4.5h12v7H2z" />
    <path d="M2.4 5 8 9l5.6-4" />
  </RailIcon>
);
const IconPlug = (
  <RailIcon>
    <path d="M6.6 9.4 4.5 11.5a2.6 2.6 0 0 1-3.7-3.7l2.1-2.1" />
    <path d="M9.4 6.6l2.1-2.1a2.6 2.6 0 0 1 3.7 3.7l-2.1 2.1" />
    <path d="M6.2 9.8l3.6-3.6" />
  </RailIcon>
);
/** Loopable admin — stacked plates, i.e. MANY tenants. Deliberately not another
 *  lock or shield: "Your access" already wears a padlock, and two authority
 *  glyphs in one rail would read as two grades of the same thing rather than as
 *  a different scope entirely. */
const IconTenants = (
  <RailIcon>
    <path d="M8 1.9 14.3 5 8 8.1 1.7 5z" />
    <path d="M2.4 8.1 8 10.9l5.6-2.8M2.4 11.2 8 14l5.6-2.8" />
  </RailIcon>
);

function UsersPane() {
  const [users, setUsers] = useState<AdminUser[] | null>(null);
  const [error, setError] = useState("");
  const [notice, setNotice] = useState("");
  /**
   * ⚠ WAVE 17 R7 — ONE STATE, WHERE THERE WERE TWO. This pane held `editing`
   * (the inline account-details card, at the BOTTOM of the list) and `permsFor`
   * (the full-pane access page) as separate things, on the wave-15 reasoning
   * that *"they are two different jobs: one changes who the account IS, the
   * other changes what it may SEE"*. True about the data, wrong about the
   * person: an admin opening an account does not know in advance which of the
   * two they came for, and the product offered them two doors, one of which
   * scrolled the page instead of changing it. The owner met that as *"Edit and
   * clicking a user name should just be the EXACT same opening of a full page
   * so its not confusing"*. There is one door now, and one thing behind it.
   */
  const [open, setOpen] = useState<string | null>(null);

  const reload = useCallback(() => {
    void listUsers().then((r) => {
      if (r.ok) {
        setUsers(r.data.users ?? []);
        setError("");
      } else {
        setUsers([]);
        setError(r.message);
      }
    });
  }, []);

  useEffect(reload, [reload]);

  // wave17 item 3 (R6). ⚠ `.pg-empty` was the WRONG BOX as well as the wrong
  // words: it is the dashed empty-state frame, so a list that was merely still
  // arriving was drawn in the chrome that means "there is nothing here".
  if (users === null)
    return (
      <div className="set-loading">
        <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
      </div>
    );

  // The FULL-PANE TAKEOVER: the users pane IS the modal body, so returning the
  // page here replaces the whole body and nothing else has to know. The rail
  // stays — it is chrome, not body, and an admin who wants out of this page
  // entirely should not have to find a Back button first.
  if (open) {
    const who = users.find((u) => u.username === open);
    // The record can genuinely vanish under an open page: `reload()` runs after
    // every write, and another admin may have removed the account in between.
    // Saying so beats rendering a page about nobody.
    if (!who) {
      return (
        <div className="set-pane">
          <section className="set-card">
            <h4 className="set-h4">That account is no longer listed</h4>
            <p className="set-help">It may have been removed since this list was loaded.</p>
            <div className="set-actions">
              <button type="button" className="set-secondary" onClick={() => setOpen(null)}>
                Back to accounts
              </button>
            </div>
          </section>
        </div>
      );
    }
    return (
      <PermsEditor
        // Remount per account: the page holds a DRAFT of somebody's permissions
        // and an unsaved display name, and carrying either across a switch of
        // subject is how one account's edits get saved onto another.
        key={who.username}
        user={who}
        // ⚠ TWO LISTS, TWO JOBS, AND MIXING THEM MAKES A WRONG URL. These are
        // DISPLAY NAMES, for `user`-typed filter conditions — the cells hold
        // names, so the builder must offer names. `copyTargets` below carries
        // the RECORDS, because a copy addresses accounts by USERNAME.
        userOptions={users.map((u) => u.name || u.username)}
        copyTargets={users.filter((u) => u.username !== who.username)}
        onBack={() => setOpen(null)}
        // ⚠ IT DOES NOT CLOSE THE PAGE ANY MORE. Wave 15's version returned to
        // the list on save, which was right when this was only an access
        // editor. It is one page with several things to save now, and a page
        // that ejects you after the first of them is a page you have to
        // re-open to finish the job. The way out stays visible instead.
        onSaved={(msg) => {
          setNotice(msg);
          setError("");
          reload();
        }}
      />
    );
  }

  return (
    <div className="set-pane">
      {/* ⚠ WAVE 19 (owner item 9) — THE HEADING MATCHES THE RAIL ENTRY THAT
          OPENS IT. It said "Accounts" while the only way in was a tab reading
          "Manage users", so the pane appeared to be somewhere other than the
          place that was clicked — the cheapest kind of "am I in the right
          screen?" there is. The rail is the name; the body agrees with it. */}
      <h3 className="set-h">Manage users</h3>
      {error ? <p className="set-error">{error}</p> : null}
      {notice ? <p className="set-notice">{notice}</p> : null}

      {users.length === 0 && !error ? (
        <p className="pg-empty">No accounts are configured on this deployment yet.</p>
      ) : (
        /* ⚠ WAVE 17 R7 — A LIST OF CARDS, NOT A TABLE, and the shape is the
           feature. The owner asked to "click the whole user's Card, not just
           name, so it has bigger hit box": in a table the only click target
           that could exist was the name itself, because a `<tr onClick>` is
           invisible to a keyboard and wrapping every cell in a button is five
           tab stops per row. One `<button>` per account is ONE control with the
           whole card as its hit box, focusable and Enter-able for free.
           ⛔ "Business units" IS STILL GONE (R1) and did not come back as a
           card line. What replaced it is the access sentence, which is a page
           now rather than a cell because a permanent filter does not fit in
           one. */
        <ul className="set-acctlist">
          {users.map((u) => (
            <li key={u.username}>
              <button
                type="button"
                className={"set-acct" + (u.active ? "" : " is-inactive")}
                onClick={() => setOpen(u.username)}
              >
                <Monogram name={u.name || u.username} />
                <span className="set-acct-body">
                  <span className="set-acct-top">
                    <span className="set-acct-name">{u.name || u.username}</span>
                    <RoleChip admin={u.role === "admin"} />
                    {u.active ? null : <span className="set-chip set-chip--off">Deactivated</span>}
                  </span>
                  <span className="set-acct-sub">{u.username}</span>
                  {/* ⚠ An admin BYPASSES perms entirely (C-PERM amendment 4) —
                      the clause that also keeps break-glass alive during a
                      store outage. Saying "Everything (admin)" rather than
                      rendering a rule prevents the one dangerous misreading:
                      that the restrictions on an admin's access page are in
                      force. */}
                  <span className="set-acct-access">
                    {u.role === "admin"
                      ? "Everything (admin)"
                      : (u.accessSummary ?? moduleWords(u.modules))}
                  </span>
                </span>
                {/* ⚠ A SPAN, NOT A BUTTON, AND THAT IS THE POINT OF R7. It is
                    inside the card's own button, so clicking the word "Edit"
                    opens exactly the page clicking anywhere else on the card
                    opens — which is the ruling, literally. A real nested button
                    is invalid markup, and the stretched-link alternative would
                    put two tab stops on one destination: the confusion the
                    ruling names, rebuilt in the accessibility tree. */}
                <span className="set-acct-go">
                  Edit
                  <ChevIcon />
                </span>
              </button>
            </li>
          ))}
        </ul>
      )}

      <AddUser
        onCreated={(msg) => {
          setNotice(msg);
          setError("");
          reload();
        }}
        onError={setError}
      />
    </div>
  );
}

// ⛔ `BuPicker` WAS HERE, AND IT IS NOT COMING BACK (wave 15, R1). Business unit
// died as a USER-FACING CONCEPT: BU access is now one permanent filter among
// others under the per-module permissioning engine — "dba is any of [Fisch,
// Both]" says exactly what the two checkboxes used to say, in the same grammar
// as every other restriction, and an admin who wants a different cut is no
// longer limited to the two cuts somebody hard-coded.
//
// The picker's old help text is worth keeping in one form, because the property
// it named still holds and is still the point: a member scoped to one unit sees
// no other unit's data anywhere. What changed is WHERE that is enforced — the
// permanent filter and its server-side wall (C-PERM), not a `bus` list.
//
// ⚠ `bus` still exists on the RECORD and in `AdminUser` during the strangler
// period (legacy Streamlit pages read it; S1's migration converts it once). What
// no longer exists is a UI that WRITES it — see EditUser/AddUser below, which
// deliberately stopped sending the field they can no longer edit.

// ⛔ `EditUser` WAS HERE. It is not deleted so much as RELOCATED — every control
// it held (display name, role, deactivate/reactivate, password reset with its
// signs-them-out-everywhere warning) now lives at the top of the user page in
// `PermsEditor`, which is the whole of R7: *"Edit and clicking a user name
// should just be the EXACT same opening of a full page"*. What genuinely died
// is the SHAPE — a card that appeared at the bottom of the accounts list, below
// the fold, describing a row somewhere above it. Two doors, two layouts and one
// of them scrolled instead of navigating.
//
// ⚠ The load-bearing note from this block MOVES WITH THE FORM and is restated
// there: the PATCH still carries no `bus` (R1). With the picker gone the form
// cannot edit that field, so sending 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.

function AddUser({
  onCreated,
  onError,
}: {
  onCreated: (msg: string) => void;
  onError: (msg: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const [username, setUsername] = useState("");
  const [name, setName] = useState("");
  const [role, setRole] = useState("user");
  const [password, setPassword_] = useState("");
  const [busy, setBusy] = useState(false);

  if (!open)
    return (
      <button type="button" className="set-primary set-add" onClick={() => setOpen(true)}>
        Add an account
      </button>
    );

  const valid = username.trim().length > 0 && password.length > 0;
  return (
    <section className="set-card">
      <h4 className="set-h4">New account</h4>
      <div className="set-field">
        <label className="set-label" htmlFor="set-new-user">Username</label>
        <input
          id="set-new-user"
          className="set-input"
          value={username}
          autoComplete="off"
          onChange={(e) => setUsername(e.target.value)}
        />
        {/* S1's Y4 amendment 1, surfaced as guidance rather than as a 409. */}
        <p className="set-help">
          Must be new. To change an existing account, edit it above — re-creating
          one would revive sessions that were revoked.
        </p>
      </div>
      <div className="set-field">
        <label className="set-label" htmlFor="set-new-name">Display name</label>
        <input id="set-new-name" className="set-input" value={name} onChange={(e) => setName(e.target.value)} />
      </div>
      <div className="set-field">
        <label className="set-label" htmlFor="set-new-role">Role</label>
        <select id="set-new-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 className="set-field">
        <label className="set-label" htmlFor="set-new-pw">Password</label>
        <input
          id="set-new-pw"
          className="set-input"
          type="password"
          value={password}
          autoComplete="new-password"
          onChange={(e) => setPassword_(e.target.value)}
        />
        {/* Said before the click, because a new account is created OPEN and is
            narrowed afterwards. That order is the server's (an absent `bus`
            defaults to every unit) and this wave does not change it — what the
            wave changes is that "afterwards" now leads somewhere: open the
            account and set its per-module access, filters and hidden fields. */}
        <p className="set-help">
          A new account starts with access to everything this deployment shows.
          Open it from the list afterwards to set what they may see.
        </p>
      </div>
      <div className="set-actions">
        <button
          type="button"
          className="set-primary"
          disabled={busy || !valid}
          onClick={() => {
            setBusy(true);
            void createUser({
              username: username.trim().toLowerCase(),
              name: name || username.trim(),
              role,
              // ⚠ `bus` IS DELIBERATELY ABSENT (R1) — see the note where the
              // picker used to be. The route defaults an absent `bus` to "all",
              // so this is the same account it always created, minus a client
              // asserting a value it cannot edit.
              // ⚠ `"all"`, never `[]`. An empty list READS as unrestricted, so
              // sending one to mean "nothing" would grant everything.
              modules: "all",
              password,
            }).then((r) => {
              setBusy(false);
              if (r.ok) {
                onCreated(`Created ${username.trim().toLowerCase()}.`);
                setOpen(false);
                setUsername("");
                setName("");
                setPassword_("");
              } else {
                onError(r.message);
              }
            });
          }}
        >
          Create account
        </button>
        <button type="button" className="set-secondary" onClick={() => setOpen(false)}>
          Cancel
        </button>
      </div>
    </section>
  );
}

// ------------------------------------------------------------------ profile photo (wave 14)

/** Downscale + centre-crop to a <=128px JPEG data URL. JPEG at this size lands ~5-10KB,
 *  comfortably under the server's 64KB decoded wall; the server re-validates regardless. */
async function fileToAvatarDataUrl(file: File): Promise<string | null> {
  const url = URL.createObjectURL(file);
  try {
    const img = new Image();
    await new Promise<void>((res, rej) => {
      img.onload = () => res();
      img.onerror = () => rej(new Error("unreadable image"));
      img.src = url;
    });
    if (!img.width || !img.height) return null;
    const side = Math.min(128, Math.max(img.width, img.height));
    const canvas = document.createElement("canvas");
    canvas.width = side;
    canvas.height = side;
    const ctx = canvas.getContext("2d");
    if (!ctx) return null;
    const s = Math.min(img.width, img.height);
    ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, side, side);
    return canvas.toDataURL("image/jpeg", 0.85);
  } catch {
    return null;
  } finally {
    URL.revokeObjectURL(url);
  }
}

/** The first letter of a name, the way both shells compute it.
 *  ⚠ Unicode letter/number, not `[a-z0-9]` — the app's `_account_css` uses
 *  Python's `str.isalnum()`, so an ASCII-only class here would give a non-Latin
 *  name an initial in one door and a blank circle in the other. */
function initialOf(name: string): string {
  return (name.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toUpperCase();
}

/**
 * ⚠ WAVE 17 (16b) — THE INLINE STYLES ARE GONE, AND SO IS THEIR JUSTIFICATION.
 * This block used to carry twelve declarations in a `style` object under the
 * note *"the `.set-*`/`.shell-*` stylesheet is another wave-14 session's file"*.
 * That was true then and is not true now — SHELL owns the `.set-*` region this
 * wave — and an inline `background: "var(--lp-blue-tint)"` is still a colour
 * decision living outside the stylesheet, which is the thing the file-wide
 * token rule exists to stop. Two sizes are needed (the identity header and an
 * accounts card), so there are two classes rather than a size prop threading a
 * number into CSS.
 */
function AvatarPreview({ user, large }: { user: SessionUser; large?: boolean }) {
  const cls = large ? "set-avatar set-avatar--lg" : "set-avatar";
  if (user.avatar) {
    return <img className={cls} src={user.avatar} alt="" aria-hidden="true" />;
  }
  return (
    <span className={cls} aria-hidden="true">
      {initialOf(user.name)}
    </span>
  );
}

/** The accounts list's monogram. A separate component because `AdminUser`
 *  carries no `avatar` — only the SIGNED-IN user's photo is on the wire — so
 *  offering a photo slot here would be a hole nothing can fill. */
function Monogram({ name }: { name: string }) {
  return (
    <span className="set-avatar" aria-hidden="true">
      {initialOf(name)}
    </span>
  );
}

/** The role, as a chip rather than a table cell (16b). Two roles, one of which
 *  bypasses every rule in the product — that is a property of the account, and a
 *  property belongs beside the name, not in a row of key/value pairs. */
function RoleChip({ admin }: { admin: boolean }) {
  return (
    <span className={admin ? "set-chip set-chip--admin" : "set-chip"}>
      {admin ? "Admin" : "Member"}
    </span>
  );
}

function ProfilePhotoBlock({
  user,
  admin,
  onUser,
}: {
  user: SessionUser;
  admin: boolean;
  onUser?: (u: SessionUser) => void;
}) {
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  const fileRef = useRef<HTMLInputElement>(null);

  const upload = useCallback(
    async (file: File) => {
      setBusy(true);
      setError("");
      const dataUrl = await fileToAvatarDataUrl(file);
      if (!dataUrl) {
        setBusy(false);
        setError("That file could not be read as an image.");
        return;
      }
      const r = await setAvatar(dataUrl);
      setBusy(false);
      if (r.ok) {
        const fresh = parseUserEnvelope(r.data);
        if (fresh) onUser?.(fresh);
      } else {
        setError(r.message);
      }
    },
    [onUser]
  );

  const remove = useCallback(async () => {
    setBusy(true);
    setError("");
    const r = await clearAvatar();
    setBusy(false);
    if (r.ok) {
      const fresh = parseUserEnvelope(r.data);
      if (fresh) onUser?.(fresh);
    } else {
      setError(r.message);
    }
  }, [onUser]);

  // ⛔ THE HELP SENTENCE IS DELETED (wave 17, item 16b, owner's instruction:
  // "replace with layout, not copy"). It read "Shown on Assignee fields and your
  // account chip. Square images look best; large ones are scaled down
  // automatically." Every clause was work the interface should do rather than
  // explain: WHERE the photo appears is answered by showing it at the size and
  // shape it will be worn, beside the name it belongs to; SQUARE IS BEST and
  // LARGE ONES ARE SCALED are apologies for a centre-crop-and-downscale that
  // already happens without asking (`fileToAvatarDataUrl`) and that the user
  // cannot influence. A sentence describing behaviour the reader has no control
  // over is decoration.
  return (
    <div className="set-identity">
      <AvatarPreview user={user} large />
      <div className="set-identity-body">
        <div className="set-identity-head">
          <span className="set-identity-name">{user.name}</span>
          <RoleChip admin={admin} />
        </div>
        <div className="set-identity-sub">{user.username}</div>
        <div className="set-identity-acts">
          <input
            ref={fileRef}
            type="file"
            accept="image/png,image/jpeg"
            className="set-filepick"
            onChange={(e) => {
              const f = e.target.files?.[0];
              e.target.value = "";
              if (f) void upload(f);
            }}
          />
          <button
            type="button"
            className="set-secondary"
            disabled={busy}
            onClick={() => fileRef.current?.click()}
          >
            {user.avatar ? "Change photo" : "Upload photo"}
          </button>
          {user.avatar ? (
            <button type="button" className="set-secondary" disabled={busy} onClick={remove}>
              Remove
            </button>
          ) : null}
        </div>
        {error ? <p className="set-error set-identity-err">{error}</p> : null}
      </div>
    </div>
  );
}

// ------------------------------------------------------------------ the modal

export function SettingsModal({
  user,
  section,
  onSection,
  onClose,
  onUser,
  moduleLabels,
}: {
  user: SessionUser;
  section: SettingsSection;
  onSection: (s: SettingsSection) => void;
  onClose: () => void;
  /** Wave 14 C-AVATAR — the shell's session setter, so a photo change updates the account
   *  chip live instead of on the next full reload. Optional: absent, the photo still saves. */
  onUser?: (u: SessionUser) => void;
  /** Registry key → the label the nav already shows for it. Passed down rather
   *  than fetched or hardcoded: the shell holds a server-filtered nav with every
   *  label in it, and a second copy of that mapping in this file would be a
   *  place for "Customer" to become "Customers" in one door only. Absent ⇒ rows
   *  fall back to the key, which is honest if ugly. */
  moduleLabels?: Record<string, string>;
}) {
  const [settings, setSettings] = useState<SettingsPayload | null>(null);
  const admin = isAdmin(user);
  // Wave 19 (C2) — `=== true`, not truthy: absent or malformed reads as NO.
  const platformAdmin = settings?.platformAdmin === true;

  useEffect(() => {
    void getSettings().then((r) => {
      if (r.ok) setSettings(r.data);
    });
  }, []);

  // Escape closes. A modal a keyboard cannot dismiss is a trap, and this one
  // covers the whole application.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  // WAVE 19 R13 — the tuple grew a third member: the tab's mark. ONE data change
  // and one line in the render loop below, which is the whole of the ruling.
  const rail: Array<[SettingsSection, string, ReactNode]> = [
    ["account", "Account", IconAccount],
    ["scope", "Your access", IconAccess],
    ...(admin
      ? ([
          ["users", "Manage users", IconUsers],
          // Wave 18 (C7): the tenant's credential store + data-source status board.
          ["keychains", "Keychains", IconKey],
          ["connectors", "Connectors", IconPlug],
          // EXIT-6: the statement sender, ported off app.py. Admin-only, and the
          // server ALSO refuses it to any tenant but Royal (the send client is
          // env-credentialed) — this entry is the courtesy, not the wall.
          ["statements", "Statements", IconMail],
        ] as Array<[SettingsSection, string, ReactNode]>)
      : []),
    // Wave 19 (R3 / C2): the Loopable admin plane. Gated on the SERVER's
    // `platformAdmin`, never on `admin` — R3 is explicit that a tenant-scoped
    // admin does not qualify, so this entry appears for exactly one account and
    // is invisible to every tenant admin in the product.
    ...(platformAdmin
      ? ([["padmin", "Loopable admin", IconTenants]] as Array<
          [SettingsSection, string, ReactNode]
        >)
      : []),
  ];
  // A non-admin deep-linked to the users pane lands on Account rather than on
  // an empty frame. The server would refuse the data anyway; this just avoids
  // showing a room with nothing in it. The rule is a NAMED function so the gate
  // can negative-control it — see `permsModel.reachableSection`.
  //
  // ⛔ `padmin` IS GATED HERE, NOT IN THAT FUNCTION, and the reason is that its
  // input does not exist there. `reachableSection` takes `admin` — a boolean off
  // the user record — while `platformAdmin` arrives asynchronously on the
  // settings payload and (per R3) is NOT implied by tenant admin. Passing it
  // through `reachableSection` would have meant either widening that function's
  // signature and re-aiming a negative control that guards a different rule, or
  // conflating the two admin concepts the ruling exists to keep apart. Composed
  // instead: the section falls back to Account until the server has said yes.
  const active =
    section === "padmin" && !platformAdmin ? "account" : reachableSection(section, admin);

  return (
    <div
      className="set-backdrop"
      onMouseDown={(e) => {
        if (e.target === e.currentTarget) onClose();
      }}
    >
      {/* ⚠ WAVE 19 R12 — the modal is "Settings", and the ACCESSIBLE name tracks
          the visible one, which is the half that is easy to forget. A dialog a
          screen reader announces as "Profile" while the rail reads "Settings" is
          the same drift as a stale comment, only harder to notice: nobody
          looking at the screen can see it. Manage users is still a SECTION
          inside it (R12 is a rename, not a re-architecture). */}
      <div className="set-modal" role="dialog" aria-modal="true" aria-label="Settings">
        <nav className="set-rail">
          <span className="set-rail-cap">Settings</span>
          {rail.map(([key, label, icon]) => (
            <button
              key={key}
              type="button"
              className={"set-rail-item" + (active === key ? " is-active" : "")}
              onClick={() => onSection(key)}
            >
              {icon}
              {label}
            </button>
          ))}
        </nav>

        <div className="set-body">
          <button type="button" className="set-close" onClick={onClose} aria-label="Close settings">
            ×
          </button>

          {active === "keychains" ? <KeychainPane /> : null}
          {active === "connectors" ? <ConnectorsPane /> : null}
          {active === "statements" ? <StatementsPane /> : null}
          {/* Wave 19 contract C2 — the wiring record, verified at the MOUNT SITE
              (the wave-14 lesson: an occurrence-grep proves an import, not a
              render): `settings/AdminPane.tsx → SettingsModal.tsx pane switch →
              prop user`. The pane is session D's, self-contained: it fetches its
              own data and owns its loading/empty/error states, so mounting it
              cannot fail on plumbing. Its prop is typed STRUCTURALLY on D's side
              ({username; name; role}), so the shell's `SessionUser` satisfies it
              by structural typing — no cast here, and no import from `shell/`
              there. `active` cannot be "padmin" unless the server said so. */}
          {active === "padmin" ? <AdminPane user={user} /> : null}

          {active === "account" ? (
            <div className="set-pane">
              <h3 className="set-h">Account</h3>
              {/* ⚠ WAVE 17 (16b) — THE THREE KEY/VALUE ROWS ARE NOT DELETED,
                  THEY ARE LAID OUT. "Signed in as", "Username" and "Role" were a
                  three-row table restating what a photo, a name and a chip say
                  at a glance — and the photo block sat UNDER them, so the pane
                  opened on a label reading "Signed in as" above the picture of
                  the person it was labelling. The identity header carries all
                  three now: the name at title weight, the username beneath it,
                  the role as a chip beside it. Nothing was dropped; the labels
                  were, because each one named the obvious. */}
              <ProfilePhotoBlock user={user} admin={admin} onUser={onUser} />
            </div>
          ) : null}

          {active === "scope" ? (
            <div className="set-pane">
              <h3 className="set-h">Your access</h3>
              {/* ⛔ THE "Business units" ROW IS GONE (R1) — the concept it
                  reported no longer exists on this side of the product. It is
                  NOT replaced by nothing: what shapes a restricted account's
                  rows is now a permanent filter per module, and the rows below
                  say so. A pane reporting "All modules" while a filter quietly
                  halves somebody's book is worse than the row it replaced —
                  that is the user who files a bug about the numbers. */}
              <p className="set-help set-pane-intro">
                What this account may open, and anything that narrows it. Only an
                administrator can change this.
              </p>
              {/* 16b — one card, so the restrictions read as a group belonging to
                  the heading rather than as loose rows floating under it. */}
              <section className="set-card set-card--rows">
                <div className="pg-drill-row">
                  <span className="pg-drill-key">Modules</span>
                  <span className="pg-drill-val">
                    {moduleWords(settings?.scope.modules ?? user.modules)}
                  </span>
                </div>
                {/* An admin's "All modules" is true but does not say WHY, and the
                    why is the part that matters: it is not a grant somebody can
                    revoke module by module, it is a bypass. The accounts list
                    already says "Everything (admin)" — this is the same fact, on
                    the account's own page, so the two doors agree. */}
                {admin ? (
                  <div className="pg-drill-row">
                    <span className="pg-drill-key">Restrictions</span>
                    <span className="pg-drill-val">
                      None apply — administrators bypass every rule
                    </span>
                  </div>
                ) : null}
              {/* One row per module the account is restricted in. `moduleSummary`
                  is the SAME sentence the admin editor shows, from the same
                  function — two dialects of "2 conditions, 3 fields hidden"
                  would eventually disagree about what a rule says. Admins are
                  skipped: they bypass perms entirely, so any stored rule is
                  inert and printing it here would be a lie about their access. */}
              {!admin
                ? Object.entries(settings?.scope.perms ?? {}).map(([key, raw]) => {
                    const entry = parseEntry(raw);
                    const words = moduleSummary(entry);
                    // "Full access" adds nothing beside the Modules row above;
                    // only a REAL narrowing earns a line.
                    if (words === "Full access") return null;
                    return (
                      <div className="pg-drill-row" key={key}>
                        <span className="pg-drill-key">{moduleLabels?.[key] ?? key}</span>
                        <span className="pg-drill-val">{words}</span>
                      </div>
                    );
                  })
                : null}
              </section>
            </div>
          ) : null}

          {active === "users" ? <UsersPane /> : null}
        </div>
      </div>
    </div>
  );
}

// ── Wave 18 (C7): the Keychain pane ─────────────────────────────────────────────────────────
// Secrets go IN and never come back out: rows carry a masked preview, the add form clears on
// success, and there is no reveal affordance by design (the server has no route for one).
/**
 * ⭐⭐ WAVE 32 · R4 / contract C1 — WHO A CONNECTION IS FOR, in the two words the ruling uses.
 *
 * ⛔ THE VOCABULARY IS THE SERVER'S. `routes_keychain.SCOPES` declares it and `verify_meta`
 * asserts these two literals appear on both sides, so the label map is a PRESENTATION of a
 * server word and never a client-side union — a `type Scope = …` here would turn "the server
 * added a scope" into "the client drops the row" (the wave-9 law).
 */
const SCOPE_LABEL: Record<string, string> = {
  business: "Business-wide",
  personal: "Personal",
};
const scopeLabel = (s: string | undefined): string =>
  SCOPE_LABEL[String(s || "business")] || String(s);

function KeychainPane() {
  const [entries, setEntries] = useState<KeyEntry[]>([]);
  const [locked, setLocked] = useState(false);
  const [loaded, setLoaded] = useState(false);
  const [err, setErr] = useState("");
  /** R4: only an administrator may make a connection business-wide. Served, not inferred from
   *  the account record — the server is the wall and the client renders what it will honour. */
  const [canBusiness, setCanBusiness] = useState(false);
  const [tenantWide, setTenantWide] = useState<string[]>([]);
  const [form, setForm] = useState<{
    type: "odoo" | "generic";
    label: string;
    scope: string;
    fields: Record<string, string>;
    busy: boolean;
  } | null>(null);
  const [testResult, setTestResult] = useState<Record<string, string>>({});

  const reload = useCallback(async () => {
    const r = await listKeychain();
    if (r.ok) {
      setEntries(r.data.entries);
      setLocked(r.data.locked);
      setCanBusiness(Boolean(r.data.canBusiness));
      setTenantWide(Array.isArray(r.data.tenantWideTypes) ? r.data.tenantWideTypes : []);
      setErr("");
    } else {
      setErr(r.message);
    }
    setLoaded(true);
  }, []);
  useEffect(() => {
    void reload();
  }, [reload]);

  /** The scope a NEW key of this type may take. `odoo` and `meta_ads` are what the whole
   *  workspace's databases are read through, so the server refuses to make them personal — the
   *  picker says so here instead of letting the save fail. */
  const forcedBusiness = (t: string) => tenantWide.includes(t);

  const submit = async () => {
    if (!form || form.busy) return;
    setForm({ ...form, busy: true });
    const fields = Object.fromEntries(
      Object.entries(form.fields).filter(([, v]) => v.trim() !== "")
    );
    const r = await addKeychainEntry(form.label, form.type, fields, form.scope);
    if (r.ok) {
      setForm(null);
      void reload();
    } else {
      setErr(r.message);
      setForm({ ...form, busy: false });
    }
  };

  const odooFields: Array<[string, string]> = [
    ["url", "Server URL (https://...)"],
    ["db", "Database"],
    ["user", "API user"],
    ["api_key", "API key"],
  ];

  return (
    <div className="set-pane">
      <h3 className="set-h">Keychains</h3>
      <p className="set-help set-pane-intro">
        Credentials for this workspace's data sources, encrypted at rest. A stored secret is
        never shown again — only its last four characters identify it.
      </p>
      {locked ? (
        <p className="set-error">
          The keychain is locked: the platform's AIOS_KEYCHAIN_KEY is not configured on this
          server. Entries can be listed but nothing can be added or decrypted.
        </p>
      ) : null}
      {err ? <p className="set-error">{err}</p> : null}
      {!loaded ? (
        <p className="set-help">Loading…</p>
      ) : entries.length === 0 ? (
        <p className="set-help">No keys stored yet.</p>
      ) : (
        <div className="set-card">
          {entries.map((e) => (
            <div key={e.id} className="set-kv-row kc-row">
              <div className="kc-main">
                <strong>{e.label}</strong>
                <span className="kc-meta">
                  {e.type}
                  {e.preview ? " · " + e.preview : ""} · {scopeLabel(e.scope)}
                  {e.scope === "personal" && e.owner ? " (" + e.owner + ")" : ""} · added by{" "}
                  {e.createdBy || "?"}
                </span>
                {testResult[e.id] ? (
                  <span className="kc-test">{testResult[e.id]}</span>
                ) : null}
              </div>
              <div className="kc-actions">
                {/* ⭐ R4's scope door. Shown ONLY where it can be honoured: a member cannot make
                    anything business-wide, and `odoo`/`meta_ads` are business-wide by
                    construction — offering a control that the server would refuse is the
                    "button that leads nowhere" R7 banned one module over. */}
                {canBusiness && !forcedBusiness(e.type) ? (
                  <button
                    type="button"
                    className="set-btn"
                    onClick={async () => {
                      const next = e.scope === "personal" ? "business" : "personal";
                      const r = await setKeychainScope(e.id, next);
                      if (r.ok) void reload();
                      else setErr(r.message);
                    }}
                  >
                    {e.scope === "personal" ? "Make business-wide" : "Make personal"}
                  </button>
                ) : null}
                <button
                  type="button"
                  className="set-btn"
                  onClick={async () => {
                    setTestResult((t) => ({ ...t, [e.id]: "Testing…" }));
                    const r = await testKeychainEntry(e.id);
                    setTestResult((t) => ({
                      ...t,
                      [e.id]: r.ok
                        ? (r.data.ok ? "OK — " : "Failed — ") + r.data.message
                        : r.message,
                    }));
                  }}
                >
                  Test
                </button>
                <button
                  type="button"
                  className="set-btn kc-danger"
                  onClick={async () => {
                    const sure = window.confirm(
                      'Delete the key "' + e.label + '"? This cannot be undone.'
                    );
                    if (!sure) return;
                    const r = await deleteKeychainEntry(e.id);
                    if (r.ok) void reload();
                    else setErr(r.message);
                  }}
                >
                  Delete
                </button>
              </div>
            </div>
          ))}
        </div>
      )}
      {form ? (
        <div className="set-card kc-form">
          <div className="set-field">
            <label>Type</label>
            <select
              value={form.type}
              onChange={(e) => {
                const type = e.target.value as "odoo" | "generic";
                setForm({
                  ...form,
                  type,
                  fields: {},
                  // A tenant-wide type has exactly one legal scope; snapping the picker is the
                  // honest move, because the alternative is a save that 400s on a choice the
                  // form offered.
                  scope: forcedBusiness(type) ? "business" : form.scope,
                });
              }}
            >
              <option value="odoo">Odoo</option>
              <option value="generic">Generic secret</option>
            </select>
          </div>
          {/* ⭐⭐ WAVE 32 · R4 — WHO IS THIS CONNECTION FOR. Two options, no more: the ruling says
              there is a business-wide kind and a personal kind, and business-wide is an
              administrator's to give. A member sees the sentence instead of a disabled radio,
              because a control you cannot use is worse than a line telling you why. */}
          <div className="set-field">
            <label>Who can use it</label>
            {forcedBusiness(form.type) ? (
              <p className="set-help">
                Business-wide. This workspace's databases are read through an {form.type}{" "}
                connection, so it applies to everyone here and cannot be personal.
              </p>
            ) : canBusiness ? (
              <select
                value={form.scope}
                onChange={(e) => setForm({ ...form, scope: e.target.value })}
              >
                <option value="business">Business-wide — everyone in this workspace</option>
                <option value="personal">Personal — only me</option>
              </select>
            ) : (
              <p className="set-help">
                Personal — only you. An administrator can add a connection for the whole
                workspace.
              </p>
            )}
          </div>
          <div className="set-field">
            <label>Label</label>
            <input
              value={form.label}
              maxLength={80}
              placeholder={form.type === "odoo" ? "Production Odoo" : "e.g. Shipping API"}
              onChange={(e) => setForm({ ...form, label: e.target.value })}
            />
          </div>
          {(form.type === "odoo"
            ? odooFields
            : ([
                ["name", "Name"],
                ["value", "Secret value"],
              ] as Array<[string, string]>)
          ).map(([k, label]) => (
            <div className="set-field" key={k}>
              <label>{label}</label>
              <input
                value={form.fields[k] ?? ""}
                type={k === "api_key" || k === "value" ? "password" : "text"}
                onChange={(e) =>
                  setForm({ ...form, fields: { ...form.fields, [k]: e.target.value } })
                }
              />
            </div>
          ))}
          <div className="set-actions">
            <button type="button" className="set-btn" onClick={() => setForm(null)}>
              Cancel
            </button>
            <button
              type="button"
              className="set-btn set-btn-primary"
              disabled={form.busy || !form.label.trim() || locked}
              onClick={() => void submit()}
            >
              {form.busy ? "Saving…" : "Save key"}
            </button>
          </div>
        </div>
      ) : (
        <button
          type="button"
          className="set-btn set-btn-primary"
          disabled={locked}
          onClick={() =>
            setForm({
              type: "odoo",
              label: "",
              // ⚠ The default is the SAFE one for the account opening the form: a member can only
              // create a personal connection, so starting on `business` would put a value in the
              // body the server refuses.
              scope: canBusiness ? "business" : "personal",
              fields: {},
              busy: false,
            })
          }
        >
          Add a key
        </button>
      )}
    </div>
  );
}

// ── Wave 18 (C7): the Connectors pane — sources + R3's Unsynced-records guardrail ───────────
function ConnectorsPane() {
  const [rows, setRows] = useState<ConnectorRow[]>([]);
  const [note, setNote] = useState("");
  const [unsynced, setUnsynced] = useState<UnsyncedInfo | null>(null);
  const [showOrphans, setShowOrphans] = useState(false);
  const [loaded, setLoaded] = useState(false);
  const [err, setErr] = useState("");

  const reload = useCallback(async () => {
    const r = await getConnectors();
    if (r.ok) {
      setRows(r.data.connectors);
      setNote(r.data.pausedNote || "");
      setUnsynced(r.data.unsynced ?? null);
      setErr("");
    } else {
      setErr(r.message);
    }
    setLoaded(true);
  }, []);
  useEffect(() => {
    void reload();
  }, [reload]);

  return (
    <div className="set-pane">
      <h3 className="set-h">Connectors</h3>
      <p className="set-help set-pane-intro">
        Where this workspace's data comes from. Add credentials under Keychains; sources appear
        here with their status.
      </p>
      {err ? <p className="set-error">{err}</p> : null}
      {!loaded ? (
        <p className="set-help">Loading…</p>
      ) : rows.length === 0 ? (
        <p className="set-help">
          No connectors yet. Store a key under Keychains to register a source — nothing syncs
          until a later step turns it on.
        </p>
      ) : (
        <div className="set-card">
          {rows.map((r) => (
            <div key={r.key} className="set-kv-row kc-row">
              <div className="kc-main">
                <strong>{r.label}</strong>
                <span className="kc-meta">
                  {r.type} · {r.source === "env" ? "environment credentials" : "keychain"}
                  {r.paused ? " · paused" : " · active"}
                </span>
              </div>
              <div className="kc-actions">
                <button
                  type="button"
                  className="set-btn"
                  onClick={async () => {
                    const res = await pauseConnector(r.key, !r.paused);
                    if (res.ok) void reload();
                    else setErr(res.message);
                  }}
                >
                  {r.paused ? "Resume" : "Pause"}
                </button>
              </div>
            </div>
          ))}
        </div>
      )}
      {note ? <p className="set-help kc-note">{note}</p> : null}
      {unsynced ? (
        <div className="set-card kc-unsynced">
          <strong>Unsynced records</strong>
          {unsynced.known ? (
            unsynced.count === 0 ? (
              <p className="set-help">
                None — every record holding notes or custom-field data matches a row the
                connector currently serves.
              </p>
            ) : (
              <>
                <p className="set-help">
                  {unsynced.count} record{unsynced.count === 1 ? "" : "s"} carry saved data
                  (notes, custom fields) but no longer match a synced row. Nothing is deleted —
                  they reattach by ID if the source serves them again.
                </p>
                <button
                  type="button"
                  className="set-btn"
                  onClick={() => setShowOrphans((s) => !s)}
                >
                  {showOrphans ? "Hide" : "Show " + String(unsynced.shown ?? unsynced.rows.length)}
                </button>
                {showOrphans ? (
                  <ul className="kc-orphans">
                    {unsynced.rows.map((o) => (
                      <li key={String(o.pid)}>
                        #{o.pid} — {o.fields} saved field{o.fields === 1 ? "" : "s"}
                        {o.hint ? " · " + o.hint : ""}
                      </li>
                    ))}
                  </ul>
                ) : null}
              </>
            )
          ) : (
            <p className="set-help">
              Count unavailable right now{unsynced.note ? " (" + unsynced.note + ")" : ""} — the
              pool could not be consulted; nothing was assumed.
            </p>
          )}
        </div>
      ) : null}
    </div>
  );
}
