/* app/set-notify.jsx — Settings pages: Notifications.
   ============================================================================================
   OWNERSHIP: this file and app/set-notify.css are owned by ONE agent. Nothing else in the repo needs
   to change to build these pages — the <script>/<link> tags and the `?v=` bumps are already wired,
   and the router finds a page purely by its key in the map below.

   CONTRACT
     - Read docs/SETTINGS-REFERENCE.md for what each page must contain, then the root CLAUDE.md.
     - Build every page out of `window.E8Set` primitives. Do not hand-roll a row, card or control;
       if a primitive is missing, say so rather than inventing a local one — dimensional drift
       between pages is the single most visible defect on this surface.
     - All CSS goes in app/set-notify.css under a `.e8-set-notify-*` prefix. Never edit app/set-core.css.
     - ZERO inline `style={{…}}` touching fontSize/color/display/gap/flex/margin/padding: a new
       file's inline-style budget is 0 and one such object fails `npm run lint`.
     - Colours are `--ui-*` tokens or `color-mix()` on them. Text sizes are `--ui-text-*`
       (11.5 / 12.5 / 13.5 / 15 / 18 / 24 / 28 — there is no 14px). Icons are `--ui-icon-*`.
   ============================================================================================

   WHY THE CHECKBOX COLUMNS ARE A GRID AND NOT A FLEX ROW

   This page's whole identity is that two checkbox columns line up with two captions in a header
   row that is a DIFFERENT row of the card. Flexbox cannot promise that: each row would size its
   own controls from its own content, so "App" (three characters) and a bare 18px checkbox settle
   at different widths and the columns visibly drift by a few pixels down the card. The reference
   spec names this as the failure mode of the page.

   So both the header captions and every body cell render the SAME element — `.e8-set-notify-cols`,
   a two-track grid with FIXED track widths — inside the primitive row's own control slot. That
   slot is `flex: none`, so the grid resolves to exactly `2 × track` in every row, its right edge
   is the card's padding in every row, and the two column centres are therefore identical by
   construction rather than by coincidence. Nothing measures anything; nothing can drift.

   The channel list `E8SN_CHANNELS` drives the captions AND the cells from one array, so a column
   cannot get out of step with its own heading either.

   WHY THE SELECT-ALL IS A TEXT BUTTON AND NOT A CHECKBOX

   docs/SETTINGS-REFERENCE.md §P3 allows a per-column select-all "if it can be done without
   clutter", and the reference's own header row has no control in it at all — just the two captions.
   The first cut used a `Check`, which broke that condition in both directions. Visually it sat on
   the same fixed track at the same 18px as every cell below it, so the heading read as a fourth
   data row with a caption floating above it. Semantically the shared control has two states and
   this job has three: the mixed bar for a partly-on column was drawn in this file's CSS while the
   button underneath still reported `aria-checked="false"`, so a screen reader was told "not
   checked" about a column that had rows on — the label and the paint disagreeing about the same
   control. `aria-checked="mixed"` would have fixed the contradiction but not the clutter, and it
   needs a third state on the shared `Check`, which is a core change. A text button needs neither:
   it has no checked state to contradict, it cannot be mistaken for a cell, and its name can state
   exactly how many rows the click will move (see `e8snAllLabel`).

   KNOWN, AND NOT FIXABLE HERE

   `.e8-set-check` draws its unchecked boundary with `--ui-border-strong`, and that token composites
   to 1.32:1 against the light card and 1.43:1 against the dark one. WCAG 2.1 SC 1.4.11 wants 3:1
   for the boundary of a control, so on a page that is nothing but checkboxes the unchecked ones
   have no visible edge in either theme. The control and the token both live outside this file, and
   the token is already the strongest border in the set — this is the token's value, not a wrong
   token choice, so raising it reaches selects, inputs and radios too. Overriding
   the border here would give Notifications a checkbox that does not match Platform's, which is the
   exact drift this surface exists to avoid. Left for a core fix — see the report.

   WHAT IS REAL HERE
     - The device section is the legacy shell.jsx control, ported: `window.E8Notify.state()`,
       `.enable('workspace')` and `.disable()`, re-read on the `notify:changed` bus event. Browser
       permission and the real push-subscription flag are displayed as-is, never assumed.
     - Quiet hours reads and writes the SHIPPED guardrail, `window.E8Policy`'s `quiet-hours` rule —
       the same object app/notify.js consults before it will render an OS notification. Not a
       second copy of the setting; the rule itself.
     - The matrix persists to localStorage keyed BY PERSONA, and re-reads on `persona:changed`,
       because these are one person's delivery preferences and personas share a browser here.
     - Two row descriptions carry live counts derived from `window.E8DATA` for the active persona,
       so the page states what the setting actually governs right now.

   WHAT IS NOT
     - The App/Email matrix is stored and honoured by this page alone. No delivery path reads it
       yet, so it is a real, persisted preference with no consumer — stated plainly rather than
       dressed up.
     - There is no "send a test notification" button. `E8Notify.show()` hard-gates on its own
       CATALOG (approval / timesheet / sweep / interview / note) and none of those five is a test:
       firing one would render copy asserting a pipeline sweep or an approval that did not happen,
       and its action button would execute a real handler. A `test` entry in app/notify.js is the
       missing piece, and that file is not this agent's to edit.
   ============================================================================================ */

const E8SNotify = window.E8Set;

const E8SN_KEY = 'e8-set-notify-v1';

/* The delivery channels, in column order. One array feeds the header captions, the select-all
   controls and every body cell — a column cannot drift away from its own caption. */
const E8SN_CHANNELS = [
  { id: 'app', label: 'App', icon: 'desktop_windows' },
  { id: 'email', label: 'Email', icon: 'mail' }
];

/* `note` is an optional live sentence appended to the static description. It receives the counts
   derived from E8DATA below and may return null, so an empty dataset degrades to the static copy
   rather than to "0 tasks". */
const E8SN_SECTIONS = [
  {
    id: 'tasks',
    title: 'Task Notifications',
    rows: [
      { id: 'task-assigned', label: 'Task assigned to me', app: true, email: false,
        desc: 'A teammate assigns you a task, or hands one of theirs over.',
        note: (live) => (live.tasks ? live.tasks + (live.tasks === 1 ? ' task is' : ' tasks are') + ' open on your queue right now.' : null) },
      { id: 'task-completed', label: 'Task completed', app: true, email: false,
        desc: 'A task you created or delegated is marked done by someone else.' },
      { id: 'task-mention', label: 'Mentioned on a task', app: true, email: false,
        desc: 'Someone puts your name in a task title or one of its comments.' }
    ]
  },
  {
    id: 'notes',
    title: 'Note Notifications',
    rows: [
      { id: 'note-mention', label: 'Mentioned in a note', app: true, email: false,
        desc: 'A colleague @-mentions you in a note, or sends one to you from the note composer.' }
    ]
  },
  {
    id: 'jobs',
    title: 'Job Notifications',
    rows: [
      { id: 'job-inbound', label: 'Inbound applications', app: true, email: false,
        desc: 'A new applicant lands on a job you own.',
        note: (live) => (live.inbound ? live.inbound + (live.inbound === 1 ? ' applicant is' : ' applicants are') + ' waiting on triage.' : null) },
      { id: 'job-comment', label: 'Job portal comments', app: true, email: false,
        desc: 'A hiring contact comments on one of your requisitions in the client portal.' },
      { id: 'job-portal', label: 'Client portal activity', app: true, email: true,
        desc: 'Anything a client does in their own portal: approvals, interview feedback, a new requisition.' }
    ]
  }
];

const E8SN_OFFLINE = { supported: false, permission: 'unsupported', enabled: false, pushSubscribed: false };

function e8snReadAll() {
  try {
    const v = JSON.parse(localStorage.getItem(E8SN_KEY));
    return v && typeof v === 'object' ? v : {};
  } catch (e) { return {}; }
}
function e8snWriteAll(all) {
  try { localStorage.setItem(E8SN_KEY, JSON.stringify(all)); } catch (e) {}
}
function e8snPersona() {
  const p = window.e8ActivePersona ? window.e8ActivePersona() : null;
  return (p && p.name) || ((window.E8DATA || {}).user || {}).name || 'You';
}
/* Defaults come from the row table, so the shipped state and the reference spec cannot disagree:
   App-only everywhere except client portal activity, which is App + Email. */
function e8snDefaults() {
  const out = {};
  E8SN_SECTIONS.forEach((s) => s.rows.forEach((r) => { out[r.id] = { app: !!r.app, email: !!r.email }; }));
  return out;
}
/* Merge saved over defaults rather than trusting the saved blob: a row added later must appear at
   its default instead of vanishing, and a hand-edited key cannot make a cell undefined. */
function e8snLoad(persona) {
  const saved = e8snReadAll()[persona] || {};
  const out = e8snDefaults();
  Object.keys(out).forEach((id) => {
    const row = saved[id];
    if (row && typeof row === 'object') out[id] = { app: !!row.app, email: !!row.email };
  });
  return out;
}
function e8snCounts(persona) {
  const D = window.E8DATA || {};
  const count = (list, pred) => {
    try { return (list || []).filter(pred).length; } catch (e) { return 0; }
  };
  return {
    tasks: count(D.tasks, (t) => t && t.owner === persona && t.state === 'open'),
    inbound: count(D.applications, (a) => a && a.owner === persona && a.stage === 'New')
  };
}
function e8snToast(message) {
  if (window.e8ShowToast) window.e8ShowToast(message);
}

/* ---------- the per-column select-all's accessible name --------------------------------------
   The visible word is only "All" or "None" — the whole affordance has to survive a 46px track at
   390px, and two characters of column heading is all that fits. Everything the two characters
   cannot say goes in the accessible name, which is also the `title`, so the tooltip and the screen
   reader get the same sentence.

   The count in that sentence is the count that is about to CHANGE, not the count that is on. The
   control this replaced was a `Check`, and a partly-on column made it lie twice: it drew a mixed
   bar while its own `aria-checked` stayed `false` (a screen reader heard "not checked" for a column
   with rows on), and its label said "1 of 3 on" while the click was about to turn on two. A button
   has no checked state to contradict, so the name can just describe the click. */
function e8snAllLabel(st, channel, section) {
  const count = (k) => k + ' ' + channel.label + ' notification' + (k === 1 ? '' : 's');
  /* A one-row section still gets the control — dropping it there would leave that card's header row
     ~28px shorter than its neighbours', and row-rhythm drift between cards on one page is the first
     thing the reference spec says a critic catches. It just does not say "all 1". */
  const every = st.total === 1 ? 'the ' + channel.label + ' notification' : 'all ' + count(st.total);
  const where = ' in ' + section.title;
  if (st.all) return 'Turn off ' + every + where;
  if (st.on) return 'Turn on the remaining ' + count(st.total - st.on) + where;
  return 'Turn on ' + every + where;
}

/* ---------- quiet hours ------------------------------------------------------------------------
   This is NOT a page-local preference. app/notify.js asks `E8Policy.check({ type: 'notify' })`
   before it will render an operating-system notification and drops anything below `urgent` while
   the window is shut, so the rule already exists and already governs delivery. The page reads and
   writes THAT rule.

   The live "holding right now" state is obtained by CALLING the same predicate the notification
   bridge calls, never by re-deriving the clock arithmetic here. Re-deriving it is how one question
   ends up with two answers that drift apart the first time the rule learns something (the shipped
   `inQuietHours` already handles an overnight window, which a naive `h < start || h >= end` does
   not). If the guardrail module is absent the section does not render at all, rather than showing
   a control that governs nothing. */
function e8snQuietRule() {
  const P = window.E8Policy;
  if (!P || !P.rule) return null;
  const r = P.rule('quiet-hours');
  if (!r) return null;
  const p = r.params || {};
  return {
    enabled: !!r.enabled,
    start: Number(p.start != null ? p.start : 8),
    end: Number(p.end != null ? p.end : 19)
  };
}
function e8snQuietNow() {
  const P = window.E8Policy;
  if (!P || !P.check) return false;
  try {
    const v = P.check({ type: 'notify', urgency: 'normal' });
    return !!(v && v.allowed === false && v.rule === 'quiet-hours');
  } catch (e) { return false; }
}
/* Whole hours only, because the rule's own params are whole hours — offering minutes would be a
   control that silently rounds. */
const E8SN_HOURS = Array.from({ length: 24 }, (_, h) => ({
  value: String(h),
  label: (h % 12 === 0 ? 12 : h % 12) + ':00 ' + (h < 12 ? 'AM' : 'PM')
}));

function SetNotificationsPage() {
  const { Page, Section, Card, Row, Icon, Check, Toggle, Select, Pill, Banner } = E8SNotify;
  const [persona, setPersona] = React.useState(e8snPersona);
  const [prefs, setPrefs] = React.useState(() => e8snLoad(e8snPersona()));
  const [device, setDevice] = React.useState(() => (window.E8Notify ? window.E8Notify.state() : E8SN_OFFLINE));
  const [quiet, setQuiet] = React.useState(e8snQuietRule);
  const [quietNow, setQuietNow] = React.useState(e8snQuietNow);

  /* The bus is the only writer of notification state that this page does not own — the legacy
     control in shell.jsx and the per-surface bell both go through E8Notify.save(). Subscribing
     keeps the three in agreement instead of showing a stale toggle. */
  React.useEffect(() => {
    if (!window.E8Events) return undefined;
    return window.E8Events.subscribe(['notify:changed'], () => {
      setDevice(window.E8Notify ? window.E8Notify.state() : E8SN_OFFLINE);
    });
  }, []);
  /* Persona is shared through localStorage and can change in another tab or from Today's switcher.
     These preferences belong to a person, so the whole matrix is re-read when it does. */
  React.useEffect(() => {
    if (!window.E8Events) return undefined;
    return window.E8Events.subscribe(['persona:changed'], () => {
      const name = e8snPersona();
      setPersona(name);
      setPrefs(e8snLoad(name));
    });
  }, []);
  /* The guardrail is edited in two other places (the Agents screen and the Approvals rail), so the
     rule is re-read on its own bus event. The interval is not decoration: the window opens and
     shuts on the wall clock, so "holding right now" is a question that has to be re-asked. A
     minute is finer than the rule's own hour resolution and coarse enough to cost nothing. */
  React.useEffect(() => {
    const refresh = () => { setQuiet(e8snQuietRule()); setQuietNow(e8snQuietNow()); };
    const off = window.E8Events ? window.E8Events.subscribe(['policy:changed'], refresh) : null;
    const timer = setInterval(refresh, 60000);
    return () => { if (off) off(); clearInterval(timer); };
  }, []);

  const live = React.useMemo(() => e8snCounts(persona), [persona]);

  const commit = (next) => {
    setPrefs(next);
    const all = e8snReadAll();
    all[persona] = next;
    e8snWriteAll(all);
  };
  const setCell = (rowId, channel, on) => {
    commit({ ...prefs, [rowId]: { ...(prefs[rowId] || {}), [channel]: !!on } });
  };
  /* Select-all is scoped to one section and one column, which is the only scope where "all" has an
     unambiguous meaning on this page. */
  const columnState = (section, channel) => {
    const on = section.rows.filter((r) => prefs[r.id] && prefs[r.id][channel]).length;
    return { on: on, total: section.rows.length, all: on === section.rows.length && on > 0 };
  };
  /* setRule merges the patch over the stored override and emits `policy:changed`, so the two other
     editors of this rule see the change too. Reading the rule BACK rather than trusting the patch
     keeps the page honest about what was actually stored — `setRule` refuses invariant rules and
     returns false. */
  const patchQuiet = (patch) => {
    if (!window.E8Policy || !window.E8Policy.setRule) return;
    window.E8Policy.setRule('quiet-hours', patch);
    setQuiet(e8snQuietRule());
    setQuietNow(e8snQuietNow());
  };
  const toggleColumn = (section, channel) => {
    const next = { ...prefs };
    const turnOn = !columnState(section, channel).all;
    section.rows.forEach((r) => { next[r.id] = { ...(next[r.id] || {}), [channel]: turnOn }; });
    commit(next);
  };

  const deviceOn = !!(device.supported && device.enabled && device.permission === 'granted');
  const deviceBlocked = !!(device.supported && device.permission === 'denied');
  const toggleDevice = async () => {
    if (!window.E8Notify || !device.supported) {
      e8snToast('This browser cannot show system notifications');
      return;
    }
    if (deviceOn) {
      window.E8Notify.disable();
      setDevice(window.E8Notify.state());
      e8snToast('System notifications off for this browser');
      return;
    }
    const res = await window.E8Notify.enable('workspace');
    setDevice(window.E8Notify.state());
    e8snToast(res.ok
      ? (res.pushed ? 'System notifications on - push is live on this browser' : 'System notifications on for this browser')
      : res.reason === 'denied'
        ? 'Your browser is blocking notifications for ELEV8'
        : 'System notifications were not enabled');
  };
  const deviceDesc = !device.supported
    ? 'This browser has no Notification API, so there is nothing to turn on here.'
    : deviceBlocked
      ? 'Your browser is blocking notifications for this site. Unblock ELEV8 in its site settings, then reload.'
      : deviceOn
        ? 'On for this browser. Approvals, submitted timesheets and pipeline sweeps arrive while ELEV8 sits in the background.'
        : 'Off for this browser. Turn it on to be told about approvals and timesheets while ELEV8 sits in the background.';

  return (
    <Page title="Notifications">
      {E8SN_SECTIONS.map((section) => (
        <Section title={section.title} key={section.id}>
          <Card>
            <Row tinted className="e8-set-notify-row"
              label={<span className="e8-set-notify-headlab"><Icon name="notifications" />Notify me about</span>}
              control={(
                <div className="e8-set-notify-cols">
                  {E8SN_CHANNELS.map((ch) => {
                    const st = columnState(section, ch.id);
                    const say = e8snAllLabel(st, ch, section);
                    return (
                      <span className="e8-set-notify-colhead" key={ch.id}>
                        <span className="e8-set-notify-colcap"><Icon name={ch.icon} />{ch.label}</span>
                        {/* Not a Check. A checkbox here sat on the same track at the same 18px as
                            every cell below it, so the heading read as a fourth data row — and the
                            shared control has two states where this needs three. A text button is
                            unmistakably part of the heading and says what the click does. */}
                        <button type="button" className="e8-set-notify-all" title={say} aria-label={say}
                          onClick={() => toggleColumn(section, ch.id)}>{st.all ? 'None' : 'All'}</button>
                      </span>
                    );
                  })}
                </div>
              )} />
            {section.rows.map((row) => {
              const extra = row.note ? row.note(live) : null;
              return (
                <Row key={row.id} className="e8-set-notify-row" label={row.label}
                  desc={extra ? row.desc + ' ' + extra : row.desc}
                  control={(
                    <div className="e8-set-notify-cols">
                      {E8SN_CHANNELS.map((ch) => (
                        <span className="e8-set-notify-cell" key={ch.id}>
                          <Check checked={!!(prefs[row.id] && prefs[row.id][ch.id])}
                            onChange={(v) => setCell(row.id, ch.id, v)}
                            label={row.label + ' - ' + ch.label} />
                        </span>
                      ))}
                    </div>
                  )} />
              );
            })}
          </Card>
        </Section>
      ))}

      {/* The real device control, ported from the legacy Settings panel. It is a separate section
          because it answers a different question: the matrix above decides WHAT you are told
          about, this decides whether the operating system is allowed to say it out loud. */}
      <Section title="This Device"
        desc="System notifications are granted per browser, so this switch covers this browser only.">
        {deviceBlocked ? (
          <Banner tone="warn">
            This browser is blocking notifications for ELEV8. The switch below cannot override that —
            unblock the site in your browser’s settings and reload the page.
          </Banner>
        ) : null}
        {/* No leading glyph boxes anywhere on this page. docs/SETTINGS-REFERENCE.md §2.4 lists
            Notifications among the pages that do not use them, and holding to that keeps every
            label on the page — matrix, device, quiet hours — on one left edge, which is the same
            argument the checkbox grid makes on the right. The bell beside "Notify me about" is an
            inline glyph in the label, which the reference does specify. */}
        <Card locked={!device.supported}>
          <Row label="System notifications" desc={deviceDesc}
            control={<Toggle checked={deviceOn} disabled={!device.supported || deviceBlocked}
              onChange={toggleDevice} label="System notifications on this browser" />} />
          {/* `locked` is conditioned on `supported` as well as `deviceOn` because opacity composites:
              a 0.42 row inside a 0.42 card paints at 0.18 and reads as a rendering fault rather
              than a disabled state. One dimming at a time — the card already owns this case. */}
          <Row locked={device.supported && !deviceOn} label="Reach my other devices"
            desc={device.pushSubscribed
              ? 'This browser holds a live push subscription, so alerts reach you with ELEV8 closed.'
              : 'This browser is not registered for push. Alerts arrive only while an ELEV8 tab is open.'}
            control={<Pill tone={device.pushSubscribed ? 'ok' : undefined}>
              {device.pushSubscribed ? 'Registered' : 'Not registered'}
            </Pill>} />
        </Card>
      </Section>

      {/* Quiet hours is the shipped `E8Policy` guardrail, not a new preference. Rendered only when
          that module is present, because a switch over a rule that does not exist is a mockup. */}
      {quiet ? (
        <Section title="Quiet Hours"
          desc="One shared guardrail. Outside these hours ELEV8 holds every notification below urgent, and holds agent-initiated sends until the window reopens — the same rule Ops edits under Agents.">
          {quiet.enabled && quiet.start === quiet.end ? (
            <Banner tone="warn">
              Start and end are the same hour, which leaves no working window at all — nothing but
              urgent notifications would ever reach you. Move one of them.
            </Banner>
          ) : null}
          <Card>
            <Row label="Hold notifications outside working hours"
              desc={quiet.enabled
                ? 'On. Urgent items still interrupt — an interview confirmation will always reach you. Everything else waits for the window.'
                : 'Off. Notifications arrive at whatever hour they are raised, and agent-initiated sends are not held overnight.'}
              control={(
                <>
                  {quiet.enabled && quietNow ? <Pill tone="info">Holding right now</Pill> : null}
                  <Toggle checked={quiet.enabled} label="Hold notifications outside working hours"
                    onChange={(v) => patchQuiet({ enabled: !!v })} />
                </>
              )} />
            <Row locked={!quiet.enabled} label="Working hours"
              desc="Notifications go straight through between these two times. An end earlier than the start is read as an overnight window, not as a mistake."
              control={(
                <span className="e8-set-notify-range">
                  <Select value={String(quiet.start)} options={E8SN_HOURS} ariaLabel="Working hours start"
                    onChange={(v) => patchQuiet({ params: { start: Number(v) } })} />
                  <span className="e8-set-notify-sep">to</span>
                  <Select value={String(quiet.end)} options={E8SN_HOURS} ariaLabel="Working hours end"
                    onChange={(v) => patchQuiet({ params: { end: Number(v) } })} />
                </span>
              )} />
          </Card>
        </Section>
      ) : null}

      <p className="e8-set-notify-foot">
        Saved for {persona} on this browser — every teammate keeps their own delivery preferences.
        App notifications also appear in ELEV8’s own bell, whether or not this browser is allowed to
        raise a system notification.
      </p>
    </Page>
  );
}

window.E8SetPages = window.E8SetPages || {};
Object.assign(window.E8SetPages, { notifications: SetNotificationsPage });
