/* app/set-connect.jsx — Settings pages: Integrations + Import + Templates.
   ============================================================================================
   OWNERSHIP: this file and app/set-connect.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-connect.css under a `.e8-set-connect-*` 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-*`.

   WHERE THE CONTENT COMES FROM. These three pages were not screenshotted, so the risk is inventing
   a plausible-looking surface with nothing behind it. Every list on all three pages is DERIVED from
   the live dataset instead:
     Integrations  E8DATA.enrichProviders (7 real providers, `enabled` is a real switch the
                   enrichment waterfall reads), E8DATA.syncQueue (systems of record + live status
                   counts), E8DATA.jobAds (job boards + real views/applies), E8DATA.enrichCredits.
     Import        E8DATA.fieldSchema (the same schema the Fields screen edits) drives the target
                   picker AND the column mapping; the dropzone parses the user's real CSV in the
                   browser and validates it against that schema. Recent imports = the persisted
                   local import log UNIONED with E8DATA.syncQueue's real inbound rows.
     Templates     E8DATA.templates (30 rows, one normalized list) for the defaults and the table;
                   owner/updated are recovered from every SOURCE collection the normalizer drops
                   them from — sequences and templateExtras for the owner, forms, templateExtras
                   and a case study's publication month for the date.

   The two page-local tables (recent imports, all templates) share ONE row class and ONE padding
   value on purpose: two tables with different vertical rhythm on neighbouring pages is failure
   mode #1 in the reference spec. A shared `SetTable` belongs in set-core — see the report.
   ============================================================================================ */

const E8SConnect = window.E8Set;

/* ---------- shared helpers -------------------------------------------------------------------- */

function e8sData() { return window.E8DATA || {}; }
function e8sToast(msg, action, onAction) { if (window.e8ShowToast) window.e8ShowToast(msg, action, onAction); }
/* Passes the `#/`-prefixed form, matching every other call site in this feature and in the app.
   `navigate` strips a leading `#/` itself, so the stripped form this used to pass worked too - but
   two spellings of the same call across one feature is a coin flip for whoever copies next, and it
   would stop being harmless the moment `navigate` was made stricter. The fallback still builds the
   hash from a stripped value, because assigning `location.hash` does no normalising of its own. */
function e8sGo(path) {
  const clean = String(path || '').replace(/^#\/?/, '');
  if (window.navigate) window.navigate('#/' + clean);
  else window.location.hash = '#/' + clean;
}
function e8sNum(n) { return Number(n || 0).toLocaleString(); }
function e8sRead(key, fallback) {
  try { const v = JSON.parse(window.localStorage.getItem(key)); return v == null ? fallback : v; } catch (e) { return fallback; }
}
function e8sWrite(key, value) { try { window.localStorage.setItem(key, JSON.stringify(value)); } catch (e) {} }
function e8sAgo(ts) {
  const s = Math.max(0, Math.round((Date.now() - Number(ts || 0)) / 1000));
  if (s < 60) return 'just now';
  const m = Math.round(s / 60); if (m < 60) return m + ' min ago';
  const h = Math.round(m / 60); if (h < 24) return h + 'h ago';
  return Math.round(h / 24) + 'd ago';
}
/* Status word -> the pill tone the reference uses. One map, so a "Synced" pill on Import reads
   identically to a "Connected" pill on Integrations. */
const E8S_TONE = {
  synced: 'ok', connected: 'ok', live: 'ok', queued: 'info', published: 'ok',
  pending: null, paused: 'warn', 'needs review': 'warn', draft: null,
  error: 'danger', 'needs attention': 'danger', available: null, disconnected: null,
};
function e8sTone(status) { return E8S_TONE[String(status || '').toLowerCase()] || undefined; }

/* ---------- the page-local table ---------------------------------------------------------------
   Two pages here need a real table and set-core has no table primitive. Rather than let each page
   grow its own, both use these two components and the same `.e8-set-connect-tr` padding, so the
   Import table and the Templates table are dimensionally identical. Rows are direct children of a
   SetCard, which is what earns them the card's inset hairline dividers for free. */

/* A column's FIRST click sorts in the direction that column is actually read in: A→Z for a name,
   an owner or a type; newest and biggest first for a date or a count. `c.dir` carries that, and
   the label announces the direction the click will apply rather than the one already in force. */
function e8sNextDir(c, sort) {
  if (sort && sort.key === c.key) return sort.dir === 'asc' ? 'desc' : 'asc';
  return c.dir || 'desc';
}

function ConnTh({ variant, cols, sort, onSort }) {
  return (
    <div className={'e8-set-connect-tr e8-set-connect-th is-' + variant}>
      {cols.map((c) => (c.sort && onSort ? (
        <button key={c.key} type="button" className={'e8-set-connect-sort ' + (c.cell || '')}
          aria-label={'Sort by ' + c.label + (e8sNextDir(c, sort) === 'asc' ? ', ascending' : ', descending')}
          onClick={() => onSort(c.key)}>
          {c.label}
          <E8SConnect.Icon className="e8-set-connect-sorti"
            name={sort && sort.key === c.key ? (sort.dir === 'asc' ? 'arrow_upward' : 'arrow_downward') : 'unfold_more'} />
        </button>
      ) : (
        <span key={c.key} className={'e8-set-connect-thc ' + (c.cell || '')}>{c.label}</span>
      )))}
    </div>
  );
}

/* A cell that carries its column name for the narrow layout, where the header row is gone. */
function ConnCell({ label, children, className }) {
  return (
    <span className={'e8-set-connect-cell' + (className ? ' ' + className : '')}>
      {label ? <span className="e8-set-connect-lbl">{label}</span> : null}
      <span className="e8-set-connect-cv">{children}</span>
    </span>
  );
}

/* The two-line first cell: title over a quiet secondary line, both single-line truncated. */
function ConnMain({ title, sub }) {
  return (
    <span className="e8-set-connect-cell e8-set-connect-c-main">
      <span className="e8-set-connect-c-t">{title}</span>
      {sub ? <span className="e8-set-connect-c-d">{sub}</span> : null}
    </span>
  );
}

/* ============================================================================================
   W6 · INTEGRATIONS — a provider grid, grouped by category.
   ============================================================================================ */

/* Marks. There are no brand logos on disk and no external requests are allowed, so every provider
   gets a material glyph in the SAME 36px/9px box set-core draws for a row's leading icon (mixing
   box sizes between pages is failure mode #5). Only the tint varies, by category. */
const E8S_SYS_META = {
  Bullhorn: { icon: 'hub', cat: 1, desc: 'System of record for jobs, candidates and submittals. Records sync both ways.' },
  QuickBooks: { icon: 'receipt_long', cat: 5, desc: 'Invoicing and PO burn for managed-service engagements.' },
};
const E8S_BOARD_ICON = {
  'LinkedIn Jobs': 'group', Indeed: 'travel_explore', Dice: 'memory',
  'Built In Memphis': 'location_city', 'Stand8 careers site': 'public',
};
const E8S_LAYER = {
  base: { label: 'Base layer', cat: 2, icon: 'database' },
  waterfall: { label: 'Contact waterfall', cat: 3, icon: 'water_drop' },
  compliance: { label: 'Compliance', cat: 6, icon: 'gavel' },
};
const E8S_LAYER_ORDER = ['base', 'waterfall', 'compliance'];

/* Real sync health per system of record, counted off E8DATA.syncQueue. */
function e8sSyncSystems() {
  const out = [];
  (e8sData().syncQueue || []).forEach((r) => {
    let row = out.find((x) => x.system === r.system);
    if (!row) { row = { system: r.system, total: 0, synced: 0, pending: 0, error: 0 }; out.push(row); }
    row.total += 1;
    const s = String(r.status || '').toLowerCase();
    if (s === 'synced') row.synced += 1; else if (s === 'error') row.error += 1; else row.pending += 1;
  });
  return out;
}

/* Real board performance, flattened across every job's live ads. */
function e8sBoards() {
  const ads = e8sData().jobAds || {};
  const out = [];
  Object.keys(ads).forEach((jobId) => (ads[jobId] || []).forEach((a) => {
    let row = out.find((x) => x.board === a.board);
    if (!row) { row = { board: a.board, jobId: jobId, posts: 0, live: 0, applies: 0, views: 0 }; out.push(row); }
    row.posts += 1;
    if (String(a.status || '').toLowerCase() === 'live') row.live += 1;
    row.applies += a.applies || 0;
    row.views += a.views || 0;
  }));
  return out.sort((a, b) => b.applies - a.applies);
}

/* The foot is a COLUMN at every width, and the meta reserves exactly two lines whether it needs
   them or not. It used to be a wrapping row: `3 synced · 2 queued` sat beside its button while
   `0 synced · 0 queued · 1 failed` wrapped above one, so two tiles in the same grid row presented
   their footer differently purely because one string was longer, and their meta lines landed 26px
   apart. Layout must not be a function of string length — one shape, one height, always. */
function ConnTile({ icon, cat, name, desc, chip, meta, action }) {
  return (
    <div className={'e8-set-connect-tile' + (cat ? ' is-cat' + cat : '')}>
      <div className="e8-set-connect-tile-top">
        <E8SConnect.Glyph name={icon} />
        {chip || null}
      </div>
      <div className="e8-set-connect-tile-name">{name}</div>
      <p className="e8-set-connect-tile-desc">{desc}</p>
      <div className="e8-set-connect-tile-foot">
        <span className="e8-set-connect-tile-meta">{meta}</span>
        {action || null}
      </div>
    </div>
  );
}

function SetIntegrationsPage() {
  const { Page, Section, Card, Seg, Btn, Pill, Empty } = E8SConnect;
  /* enrichProviders is the one genuinely switchable set here: `enabled` is what the enrichment
     waterfall runs off, so a toggle on this page changes the app's behaviour. Local state mirrors
     E8DATA so the grid repaints; E8Store.set writes the replayable op that survives reload. */
  const [providers, setProviders] = React.useState(() => (e8sData().enrichProviders || []).map((p) => ({ ...p })));
  const [filter, setFilter] = React.useState('all');

  const systems = e8sSyncSystems();
  const boards = e8sBoards();
  const credits = e8sData().enrichCredits || null;
  const creditPct = credits && credits.total ? Math.min(100, Math.round((credits.used / credits.total) * 100)) : 0;

  const setEnabled = (id, next) => {
    setProviders((list) => list.map((x) => (x.id === id ? { ...x, enabled: next } : x)));
    if (window.E8Store) window.E8Store.set('enrichProviders', id, { enabled: next });
  };
  const toggleProvider = (p) => {
    const next = !p.enabled;
    setEnabled(p.id, next);
    e8sToast(
      next ? p.name + ' connected — it now runs in the ' + (E8S_LAYER[p.layer] || {}).label + '.'
        : p.name + ' disconnected — the waterfall skips it from the next run.',
      'Undo', () => setEnabled(p.id, !next)
    );
  };

  const keep = (connected) => filter === 'all' || (filter === 'on' ? connected : !connected);
  const sysShown = systems.filter((s) => keep(true));
  const provShown = providers
    .filter((p) => keep(!!p.enabled))
    .slice()
    .sort((a, b) => E8S_LAYER_ORDER.indexOf(a.layer) - E8S_LAYER_ORDER.indexOf(b.layer));
  const boardShown = boards.filter((b) => keep(b.live > 0));
  const nothing = !sysShown.length && !provShown.length && !boardShown.length;
  const connectedCount = systems.length + providers.filter((p) => p.enabled).length + boards.filter((b) => b.live > 0).length;
  const totalCount = systems.length + providers.length + boards.length;

  return (
    <Page
      title="Integrations"
      subtitle={connectedCount + ' of ' + totalCount + ' available connections are live in this workspace.'}
      action={<Seg value={filter} ariaLabel="Filter integrations" onChange={setFilter}
        options={[{ value: 'all', label: 'All' }, { value: 'on', label: 'Connected' }, { value: 'off', label: 'Available' }]} />}
    >
      {nothing ? (
        <Card>
          <Empty icon="extension_off" title="Nothing matches this filter"
            desc="Every connection is either live or available — switch the filter to see them."
            action={<Btn kind="secondary" onClick={() => setFilter('all')}>Show all</Btn>} />
        </Card>
      ) : null}

      {sysShown.length ? (
        <Section title="Systems of record"
          desc="The systems that hold the master records. ELEV8 reads and writes both ways; the sync queue is where a failure surfaces.">
          <div className="e8-set-connect-grid">
            {sysShown.map((s) => {
              const meta = E8S_SYS_META[s.system] || { icon: 'lan', cat: 8, desc: 'Connected system of record.' };
              const bad = s.error > 0;
              return (
                <ConnTile key={s.system} icon={meta.icon} cat={meta.cat} name={s.system} desc={meta.desc}
                  chip={<Pill tone={bad ? 'danger' : 'ok'}>{bad ? 'Needs attention' : 'Connected'}</Pill>}
                  meta={s.synced + ' synced · ' + s.pending + ' queued' + (s.error ? ' · ' + s.error + ' failed' : '')}
                  action={<Btn kind="secondary" onClick={() => e8sGo('syncqueue')}>Sync queue</Btn>} />
              );
            })}
          </div>
        </Section>
      ) : null}

      {provShown.length ? (
        <Section title="Enrichment and contact data"
          desc="Two layers, not one: a base dataset resolves the person and the company, then a waterfall of contact providers is tried in order until one returns an email or a phone.">
          <div className="e8-set-connect-grid">
            {provShown.map((p) => {
              const layer = E8S_LAYER[p.layer] || { label: p.layer, cat: 8, icon: 'bolt' };
              return (
                <ConnTile key={p.id} icon={layer.icon} cat={layer.cat} name={p.name} desc={p.role}
                  chip={<Pill tone={p.enabled ? 'ok' : undefined}>{p.enabled ? 'Connected' : 'Available'}</Pill>}
                  meta={layer.label + ' · ' + p.costPerRecord + ' credits/record · ' + p.region}
                  action={<Btn kind={p.enabled ? 'quiet' : 'secondary'} onClick={() => toggleProvider(p)}>
                    {p.enabled ? 'Disconnect' : 'Connect'}
                  </Btn>} />
              );
            })}
          </div>
          {credits ? (
            <div className="e8-set-connect-meterrow">
              <span className="e8-set-connect-meter-txt">
                {e8sNum(credits.used)} of {e8sNum(credits.total)} enrichment credits used this cycle · resets {credits.resets}
              </span>
              <span className="e8-set-connect-meter">
                <span className="e8-set-connect-meter-fill" style={{ '--e8-set-connect-pct': creditPct + '%' }} />
              </span>
              <span className="e8-set-connect-meter-val">{creditPct}%</span>
            </div>
          ) : null}
        </Section>
      ) : null}

      {boardShown.length ? (
        <Section title="Job boards and advertising"
          desc="Where an ELEV8 job ad is published, and what each board has actually returned.">
          <div className="e8-set-connect-grid">
            {boardShown.map((b) => (
              <ConnTile key={b.board} icon={E8S_BOARD_ICON[b.board] || 'campaign'} cat={4} name={b.board}
                desc={b.live + ' of ' + b.posts + ' postings live from this workspace.'}
                chip={<Pill tone={b.live ? 'ok' : 'warn'}>{b.live ? 'Connected' : 'Paused'}</Pill>}
                meta={e8sNum(b.applies) + ' applies · ' + e8sNum(b.views) + ' views'}
                action={<Btn kind="secondary" onClick={() => e8sGo('job/' + b.jobId + '/sourcing')}>Postings</Btn>} />
            ))}
          </div>
        </Section>
      ) : null}
    </Page>
  );
}

/* ============================================================================================
   W7 · IMPORT — a CSV dropzone that really parses, mapped against the real field schema, plus a
   recent-imports table.

   WHAT IS REAL AND WHAT IS NOT. The file is read in the browser, parsed (quoted fields and all),
   its columns auto-matched against E8DATA.fieldSchema[target], and every data row validated
   against that target's REQUIRED fields — those counts are computed from the user's own file.
   What the primary button does NOT do is write 400 candidate rows into E8DATA: a malformed row
   from an arbitrary CSV would break screens that expect a well-formed record, so the action is a
   validate-and-queue, and it says so. It writes a real, persisted, undoable log entry.
   ============================================================================================ */

const E8S_IMPORT_KEY = 'e8-set-import-v1';
const E8S_MAX_BYTES = 8 * 1024 * 1024;

/* A real CSV reader: quoted fields, escaped quotes, CRLF. Small enough to be obviously correct. */
function e8sParseCsv(text) {
  const rows = [];
  let row = [], cur = '', quoted = false;
  for (let i = 0; i < text.length; i += 1) {
    const c = text[i];
    if (quoted) {
      if (c === '"') { if (text[i + 1] === '"') { cur += '"'; i += 1; } else quoted = false; }
      else cur += c;
    } else if (c === '"') quoted = true;
    else if (c === ',') { row.push(cur); cur = ''; }
    else if (c === '\n') { row.push(cur); rows.push(row); row = []; cur = ''; }
    else if (c !== '\r') cur += c;
  }
  if (cur.length || row.length) { row.push(cur); rows.push(row); }
  return rows.filter((r) => r.length > 1 || String(r[0] || '').trim() !== '');
}

function e8sNorm(s) { return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); }

/* The recent-imports columns. Static, like the Templates set, so the header and the rows cannot
   disagree about how many tracks the grid has. Nothing here sorts — the list is already newest
   first and it is short. */
const E8S_IMP_COLS = [
  { key: 'item', label: 'Item', cell: 'e8-set-connect-c-main' },
  { key: 'via', label: 'Via', cell: 'e8-set-connect-c-via' },
  { key: 'status', label: 'Status', cell: 'e8-set-connect-c-status' },
  { key: 'when', label: 'When', cell: 'e8-set-connect-c-when' },
  { key: 'act', label: '', cell: 'e8-set-connect-c-act' },
];

/* Auto-match by exact normalized label or key first, then a containment pass that is deliberately
   conservative — a 3-letter key would otherwise match half the file. Keyed by column INDEX, not by
   header text, because a CSV is perfectly entitled to repeat a header. */
function e8sAutoMap(headers, fields) {
  const out = {};
  headers.forEach((h, i) => {
    const n = e8sNorm(h);
    let hit = fields.find((f) => e8sNorm(f.label) === n || e8sNorm(f.key) === n);
    if (!hit && n.length >= 4) {
      hit = fields.find((f) => e8sNorm(f.label).length >= 4 && (e8sNorm(f.label).indexOf(n) === 0 || n.indexOf(e8sNorm(f.label)) === 0));
    }
    out[i] = hit ? hit.key : '';
  });
  return out;
}

function SetImportPage() {
  const { Page, Section, Card, CardHead, Row, NavRow, Select, Btn, Pill, Banner, Empty, Icon } = E8SConnect;
  const schema = e8sData().fieldSchema || {};
  const objects = Object.keys(schema);
  const [target, setTarget] = React.useState(objects[0] || 'candidate');
  const [file, setFile] = React.useState(null);
  const [mapping, setMapping] = React.useState({});
  const [over, setOver] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const [log, setLog] = React.useState(() => e8sRead(E8S_IMPORT_KEY, []) || []);
  const inputRef = React.useRef(null);

  const fields = (schema[target] || {}).fields || [];
  const targetLabel = (schema[target] || {}).label || target;

  /* Changing the target re-derives the auto-match against the new schema — the old mapping points
     at field keys that do not exist on this object. */
  React.useEffect(() => {
    setMapping((prev) => {
      const f = ((e8sData().fieldSchema || {})[target] || {}).fields || [];
      return file && file.headers.length ? e8sAutoMap(file.headers, f) : prev;
    });
  }, [target, file]);

  const audit = React.useMemo(() => {
    if (!file) return null;
    const req = fields.filter((f) => f.required);
    const unmapped = req.filter((f) => Object.keys(mapping).every((k) => mapping[k] !== f.key));
    const cols = req
      .map((f) => Object.keys(mapping).find((k) => mapping[k] === f.key))
      .filter((k) => k != null)
      .map(Number);
    let ready = 0;
    file.rows.forEach((r) => { if (cols.every((i) => String(r[i] || '').trim() !== '')) ready += 1; });
    if (unmapped.length) ready = 0;
    return { ready: ready, blocked: file.rows.length - ready, unmapped: unmapped };
  }, [file, mapping, target]);

  const mappedCount = Object.keys(mapping).filter((k) => mapping[k]).length;

  const take = (list) => {
    const f = list && list[0];
    if (!f) return;
    if (!/\.csv$/i.test(f.name) && f.type !== 'text/csv') {
      e8sToast('“' + f.name + '” is not a CSV. Drop a resume on a candidate record instead — ELEV8 parses those there.');
      return;
    }
    if (f.size > E8S_MAX_BYTES) {
      e8sToast('“' + f.name + '” is ' + Math.round(f.size / 1048576) + ' MB. The in-browser parser reads up to 8 MB.');
      return;
    }
    setBusy(true);
    const reader = new FileReader();
    reader.onerror = () => { setBusy(false); e8sToast('Could not read “' + f.name + '”.'); };
    reader.onload = () => {
      const grid = e8sParseCsv(String(reader.result || ''));
      const headers = (grid[0] || []).map((h, i) => String(h || '').trim() || ('Column ' + (i + 1)));
      const rows = grid.slice(1);
      setFile({ name: f.name, size: f.size, headers: headers, rows: rows });
      setMapping(e8sAutoMap(headers, ((e8sData().fieldSchema || {})[target] || {}).fields || []));
      setBusy(false);
      e8sToast(e8sNum(rows.length) + ' rows read from “' + f.name + '” · ' + headers.length + ' columns.');
    };
    reader.readAsText(f);
  };

  const clearFile = () => { setFile(null); setMapping({}); if (inputRef.current) inputRef.current.value = ''; };

  const queue = () => {
    const entry = {
      id: 'imp-' + Date.now(), name: file.name, target: targetLabel + 's',
      total: file.rows.length, ready: audit ? audit.ready : 0, blocked: audit ? audit.blocked : 0,
      at: Date.now(), status: audit && audit.blocked ? 'Needs review' : 'Queued',
    };
    const next = [entry].concat(log).slice(0, 20);
    setLog(next); e8sWrite(E8S_IMPORT_KEY, next);
    clearFile();
    e8sToast('“' + entry.name + '” queued · ' + e8sNum(entry.ready) + ' of ' + e8sNum(entry.total) + ' rows ready.',
      'Undo', () => { const back = next.filter((r) => r.id !== entry.id); setLog(back); e8sWrite(E8S_IMPORT_KEY, back); });
  };

  const dropEntry = (id) => {
    const gone = log.find((r) => r.id === id);
    const next = log.filter((r) => r.id !== id);
    setLog(next); e8sWrite(E8S_IMPORT_KEY, next);
    e8sToast('Removed “' + (gone ? gone.name : 'import') + '” from the queue.', 'Undo',
      () => { setLog(log); e8sWrite(E8S_IMPORT_KEY, log); });
  };

  const systems = e8sSyncSystems();
  const bullhorn = systems.find((s) => s.system === 'Bullhorn');
  const providersOn = (e8sData().enrichProviders || []).filter((p) => p.enabled).length;
  const providersAll = (e8sData().enrichProviders || []).length;
  const boards = e8sBoards();
  const applies = boards.reduce((n, b) => n + b.applies, 0);

  /* An unmapped required field WARNS, it does not block. `work_auth` is a required candidate field
     with 64% coverage in this dataset, so almost no real CSV carries a column for it — making that
     a hard stop would leave the happy path unreachable for the commonest import there is. The rows
     still count as incomplete and the queued entry lands as "Needs review", which is the truth. */
  const missingRequired = !!(audit && audit.unmapped.length);
  const canQueue = !!(file && file.rows.length && mappedCount);

  return (
    <Page title="Import" subtitle="Bring records in from a spreadsheet, or from a system already connected to this workspace.">
      <Section title="Upload a file"
        desc="The file is read and validated in your browser. Nothing leaves the device until you queue the import.">
        <Card>
          {/* The schema lives on Data Model, which is a real page in this rail. The copy used to
              name a "Fields screen" that exists nowhere in the 20-item nav — pointing a reader at
              a screen they cannot find is worse than not mentioning it, so it is a live link. */}
          <Row label="Import into"
            desc={
              <React.Fragment>
                {'Columns are matched against the ' + targetLabel.toLowerCase() + ' field schema — the same schema '}
                <a className="e8-set-connect-link" href="#/settings/datamodel">Data Model</a>
                {' defines.'}
              </React.Fragment>
            }
            control={<Select value={target} ariaLabel="Import into" onChange={setTarget}
              options={objects.map((k) => ({ value: k, label: (schema[k].label || k) + 's' }))} />} />
          <div className="e8-set-connect-dropwrap">
            {file ? (
              <div className="e8-set-connect-file">
                <span className="e8-set-connect-file-mark"><Icon name="description" /></span>
                <span className="e8-set-connect-file-txt">
                  <span className="e8-set-connect-file-name">{file.name}</span>
                  <span className="e8-set-connect-file-meta">
                    {e8sNum(file.rows.length)} rows · {file.headers.length} columns · {Math.max(1, Math.round(file.size / 1024))} KB
                  </span>
                </span>
                <Btn kind="quiet" icon="close" onClick={clearFile}>Remove</Btn>
              </div>
            ) : (
              <div className={'e8-set-connect-drop' + (over ? ' is-over' : '')}
                onDragOver={(e) => { e.preventDefault(); setOver(true); }}
                /* Only when the pointer leaves the zone itself — dragging across a child fires
                   dragleave too, and clearing on that makes the highlight flicker. */
                onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setOver(false); }}
                onDrop={(e) => { e.preventDefault(); setOver(false); take(e.dataTransfer && e.dataTransfer.files); }}>
                <span className="e8-set-connect-drop-mark"><Icon name="upload_file" /></span>
                <span className="e8-set-connect-drop-t">{busy ? 'Reading the file…' : 'Drop a CSV here'}</span>
                <span className="e8-set-connect-drop-d">Up to 8 MB. The first row is read as the column header.</span>
                <Btn kind="secondary" icon="folder_open" disabled={busy}
                  onClick={() => inputRef.current && inputRef.current.click()}>Choose a file</Btn>
                <input ref={inputRef} type="file" accept=".csv,text/csv" tabIndex={-1} aria-hidden="true"
                  className="e8-set-connect-vh" onChange={(e) => take(e.target.files)} />
              </div>
            )}
          </div>
        </Card>
      </Section>

      <Section title="Map the columns"
        desc="Every column in the file, matched to a field on the record. Unmapped columns are ignored.">
        {missingRequired ? (
          <Banner tone="warn">
            {audit.unmapped.map((f) => f.label).join(', ')} {audit.unmapped.length > 1 ? 'are required fields with no column' : 'is a required field with no column'} in
            this file. You can still queue it — every row lands needing {audit.unmapped.length > 1 ? 'those fields' : 'that field'} before the record can be used.
          </Banner>
        ) : null}
        <Card>
          {/* The header NAMES the card, the way every other cardhead on this surface does
              ('Candidate stages', 'Note labels'). It used to report emptiness instead — "No file
              loaded" over "Choose a CSV above and its columns appear here." — and then the body
              said the same thing a third time. One empty message is the whole job. */}
          <CardHead icon="swap_horiz" title="Column mapping"
            desc={file ? mappedCount + ' of ' + file.headers.length + ' columns matched automatically by name. Change any of them.' : null}
            action={file ? <Btn kind="secondary" icon="restart_alt"
              onClick={() => setMapping(e8sAutoMap(file.headers, fields))}>Re-match</Btn> : null} />
          {!file ? (
            <Empty inline title="No columns to map yet — the file's header row fills this in." />
          ) : (
            file.headers.map((h, i) => {
              const sample = (file.rows[0] || [])[i];
              const chosen = fields.find((f) => f.key === mapping[i]);
              return (
                <Row key={i} label={h}
                  desc={sample ? 'First value: ' + String(sample).slice(0, 60) : 'No value in the first row'}
                  control={
                    <React.Fragment>
                      {chosen && chosen.required ? <Pill tone="info">Required</Pill> : null}
                      <Select value={mapping[i] || ''} ariaLabel={'Map column ' + h}
                        onChange={(v) => setMapping({ ...mapping, [i]: v })}
                        options={[{ value: '', label: 'Skip this column' }].concat(
                          fields.map((f) => ({ value: f.key, label: f.label + (f.required ? ' *' : '') })))} />
                    </React.Fragment>
                  } />
              );
            })
          )}
          {file ? (
            <div className="e8-set-connect-foot">
              <span className="e8-set-connect-foot-txt">
                {!mappedCount
                  ? 'Map at least one column before queueing this file.'
                  : e8sNum(audit.ready) + ' of ' + e8sNum(file.rows.length) + ' rows have every required field filled'
                    + (audit.blocked ? ' · ' + e8sNum(audit.blocked) + ' need a fix' : '')}
              </span>
              <Btn kind="primary" icon="playlist_add_check" disabled={!canQueue}
                onClick={queue}>Validate and queue</Btn>
            </div>
          ) : null}
        </Card>
      </Section>

      <Section title="Import from a connected system"
        desc="These already run continuously. Nothing here needs a file.">
        <Card>
          <NavRow icon="hub" label="Bullhorn sync"
            desc="Jobs, candidates and submittals move both ways with the system of record."
            meta={bullhorn ? bullhorn.synced + ' synced · ' + bullhorn.pending + ' queued' : 'Not configured'}
            onClick={() => e8sGo('syncqueue')} />
          <NavRow icon="bolt" label="Enrichment waterfall"
            desc="Backfills a missing work email or direct dial from the connected data providers."
            meta={providersOn + ' of ' + providersAll + ' providers on'}
            onClick={() => e8sGo('enrichment')} />
          <NavRow icon="inbox" label="Inbound applications"
            desc="Candidates who apply through a live job ad or the careers site arrive as applications."
            meta={e8sNum(applies) + ' to date'}
            onClick={() => e8sGo('applicants')} />
        </Card>
      </Section>

      <Section title="Recent imports"
        desc="Files queued from this workspace, and the records the connected systems have moved.">
        <Card>
          <div className="e8-set-connect-toolbar">
            <span className="e8-set-connect-toolbar-t">
              {log.length + (e8sData().syncQueue || []).length} recent items
            </span>
            {log.length ? (
              <Btn kind="quiet" icon="clear_all" onClick={() => {
                const prev = log;
                setLog([]); e8sWrite(E8S_IMPORT_KEY, []);
                e8sToast('Cleared ' + prev.length + ' queued import' + (prev.length === 1 ? '' : 's') + '.',
                  'Undo', () => { setLog(prev); e8sWrite(E8S_IMPORT_KEY, prev); });
              }}>Clear queued</Btn>
            ) : null}
          </div>
          <ConnTh variant="imp" cols={E8S_IMP_COLS} />
          {log.map((r) => (
            <div className="e8-set-connect-tr is-imp" key={r.id}>
              <ConnMain title={r.name} sub={e8sNum(r.ready) + ' of ' + e8sNum(r.total) + ' rows ready'} />
              <ConnCell label="Via" className="e8-set-connect-c-via">{'CSV → ' + r.target}</ConnCell>
              <ConnCell label="Status" className="e8-set-connect-c-status"><Pill tone={e8sTone(r.status)}>{r.status}</Pill></ConnCell>
              <ConnCell label="When" className="e8-set-connect-c-when">{e8sAgo(r.at)}</ConnCell>
              <span className="e8-set-connect-cell e8-set-connect-c-act">
                {/* The glyph is aria-hidden, so the button needs its own text to have a name at all. */}
                <Btn kind="danger" icon="delete" title={'Remove ' + r.name} onClick={() => dropEntry(r.id)}>
                  <span className="e8-set-connect-vh">{'Remove ' + r.name}</span>
                </Btn>
              </span>
            </div>
          ))}
          {(e8sData().syncQueue || []).map((r) => (
            <div className="e8-set-connect-tr is-imp" key={r.id}>
              <ConnMain title={r.record} sub={r.type + (r.note ? ' · ' + r.note : '')} />
              <ConnCell label="Via" className="e8-set-connect-c-via">{r.system + ' sync'}</ConnCell>
              <ConnCell label="Status" className="e8-set-connect-c-status"><Pill tone={e8sTone(r.status)}>{r.status}</Pill></ConnCell>
              <ConnCell label="When" className="e8-set-connect-c-when">{r.when}</ConnCell>
              <span className="e8-set-connect-cell e8-set-connect-c-act" />
            </div>
          ))}
        </Card>
      </Section>
    </Page>
  );
}

/* ============================================================================================
   W8 · TEMPLATES — the settings surface for the template library that already exists at #/templates.
   Defaults per type on top, the whole library as a table underneath, every row linking into the
   real editor.
   ============================================================================================ */

const E8S_TPL_DEFAULTS_KEY = 'e8-set-tpl-defaults-v1';
const E8S_TPL_TYPES = [
  { key: 'email', label: 'Email', icon: 'mail', desc: 'Loaded when a recruiter starts an email from a record.' },
  { key: 'sms', label: 'SMS and InMail', icon: 'sms', desc: 'Loaded for a short-form message on mobile or in the inbox.' },
  { key: 'sequence', label: 'Sequence', icon: 'account_tree', desc: 'Offered first when someone enrolls a list into outreach.' },
  { key: 'form', label: 'Form', icon: 'assignment', desc: 'The capture form a new job ad publishes behind its apply button.' },
  { key: 'scorecard', label: 'Scorecard', icon: 'fact_check', desc: 'Attached to a new interview round unless the loop names another.' },
  { key: 'playbook', label: 'Playbook', icon: 'menu_book', desc: 'The call script the guided prescreen surface reads from.' },
  { key: 'document', label: 'Document', icon: 'draft', desc: 'Generated when a placement needs paperwork.' },
  { key: 'casestudy', label: 'Case study', icon: 'auto_stories', desc: 'Attached to a client-facing submittal packet.' },
];

/* The normalizer at the bottom of data.js flattens SIX source collections into one list and drops
   owner/lastEdited on the way through for all but the playbooks. Recover them from the source
   rather than printing a dash — every collection is checked, not just the two that were easy:
   sequences and templateExtras carry an owner, forms and templateExtras a `lastEdited`, and a case
   study carries its publication month as `date`. That is 14 of 30 rows with a real date instead of
   9, and 8 of the 30 with a real owner.

   The remaining 16 — the nine stock `messageTemplates`, the five sequences and the two documents —
   carry no edit timestamp ANYWHERE in E8DATA, so the dash on those rows is the truth and not a
   lookup that was not tried. Fixing that properly means an `updatedAt` on the template sources in
   app/data.js; see the report. */
function e8sTplOwner(t) {
  if (t.owner) return t.owner;
  const D = e8sData();
  const seq = (D.sequences || []).find((s) => s.id === t.id);
  if (seq && seq.owner) return seq.owner;
  const extra = (D.templateExtras || []).find((x) => x.id === t.id);
  if (extra && extra.owner) return extra.owner;
  return null;
}
function e8sTplUpdated(t) {
  if (t.lastEdited) return t.lastEdited;
  const D = e8sData();
  const form = (D.forms || []).find((f) => f.id === t.id);
  if (form && form.lastEdited) return form.lastEdited;
  const extra = (D.templateExtras || []).find((x) => x.id === t.id);
  if (extra && extra.lastEdited) return extra.lastEdited;
  /* Case studies are keyed `cs-<engagementId>` by the normalizer and dated by publication month. */
  const cs = (D.caseStudies || {})[String(t.id || '').replace(/^cs-/, '')];
  if (t.type === 'casestudy' && cs && cs.date) return cs.date;
  return null;
}

/* "2d ago" / "1w ago" / "today" / "May 2026" -> days ago, so the column can be SORTED and not just
   read. Anything unparseable returns null and those rows are held at the bottom in both
   directions — a row with no date is not "the oldest", it is unknown, and burying the known
   values under it in one direction is how a recency sort stops being useful. */
const E8S_MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const E8S_UNIT_DAYS = { d: 1, w: 7, m: 30, y: 365 };
function e8sTplRecency(str) {
  const s = String(str || '').trim().toLowerCase();
  if (!s) return null;
  if (s === 'today' || s === 'just now') return 0;
  if (s === 'yesterday') return 1;
  const rel = s.match(/^(\d+)\s*([dwmy])[a-z]*\s+ago$/);
  if (rel) return Number(rel[1]) * E8S_UNIT_DAYS[rel[2]];
  const mon = s.match(/^([a-z]{3})[a-z]*\.?\s+(\d{4})$/);
  if (mon && E8S_MONTHS.indexOf(mon[1]) >= 0) {
    const then = new Date(Number(mon[2]), E8S_MONTHS.indexOf(mon[1]), 1).getTime();
    return Math.max(0, Math.round((Date.now() - then) / 86400000));
  }
  return null;
}
function e8sTplTypeLabel(key) {
  const hit = E8S_TPL_TYPES.find((t) => t.key === key);
  return hit ? hit.label : key;
}

/* Static, so `onSort` can read a column's preferred first direction without a render-order dance.
   Owner and Updated are sortable: a library is browsed by who keeps it and by recency at least as
   often as by use count, and those two were the only inert headers on the row. */
const E8S_TPL_PAGE = 12;
const E8S_TPL_COLS = [
  { key: 'name', label: 'Template', sort: true, dir: 'asc', cell: 'e8-set-connect-c-main' },
  { key: 'type', label: 'Type', sort: true, dir: 'asc', cell: 'e8-set-connect-c-type' },
  { key: 'owner', label: 'Owner', sort: true, dir: 'asc', cell: 'e8-set-connect-c-owner' },
  { key: 'uses', label: 'Uses', sort: true, dir: 'desc', cell: 'e8-set-connect-c-uses' },
  { key: 'upd', label: 'Updated', sort: true, dir: 'desc', cell: 'e8-set-connect-c-upd' },
];

function SetTemplatesPage() {
  const { Page, Section, Card, Row, Select, Input, Btn, Pill, Empty } = E8SConnect;
  const all = e8sData().templates || [];
  const [defaults, setDefaults] = React.useState(() => e8sRead(E8S_TPL_DEFAULTS_KEY, {}) || {});
  const [type, setType] = React.useState('all');
  const [q, setQ] = React.useState('');
  /* A→Z, not most-used-first. Sorting by use count is what put twelve workspace-owned messaging
     and form templates above the fold, so the default view showed an Owner column with one
     repeated value and an Updated column with three dates in it — the two columns looked broken
     when it was the ORDER that was hiding the variety. Alphabetical is also what a library is
     normally read in, and "most used" is one click away on the Uses header. */
  const [sort, setSort] = React.useState({ key: 'name', dir: 'asc' });
  const [showAll, setShowAll] = React.useState(false);

  const byType = {};
  all.forEach((t) => { (byType[t.type] = byType[t.type] || []).push(t); });
  const liveTypes = E8S_TPL_TYPES.filter((t) => (byType[t.key] || []).length);

  const setDefault = (key, id) => {
    const next = { ...defaults, [key]: id };
    setDefaults(next); e8sWrite(E8S_TPL_DEFAULTS_KEY, next);
    const name = (byType[key] || []).find((t) => t.id === id);
    e8sToast(name ? '“' + name.name + '” is now the default ' + e8sTplTypeLabel(key).toLowerCase() + ' template.'
      : 'No default ' + e8sTplTypeLabel(key).toLowerCase() + ' template — the library opens instead.');
  };
  /* Absent an explicit choice the default is the most-used template of that type — a real number
     off the same rows the library ranks by, not an arbitrary first item. */
  const defaultFor = (key) => {
    if (defaults[key] !== undefined) return defaults[key];
    const best = (byType[key] || []).slice().sort((a, b) => (b.usage || 0) - (a.usage || 0))[0];
    return best ? best.id : '';
  };

  const ql = q.trim().toLowerCase();
  const filtered = all.filter((t) => (type === 'all' || t.type === type)
    && (!ql || t.name.toLowerCase().indexOf(ql) >= 0 || String(t.preview || '').toLowerCase().indexOf(ql) >= 0));
  const byName = (a, b) => String(a.name).localeCompare(String(b.name));
  const sorted = filtered.slice().sort((a, b) => {
    const dir = sort.dir === 'asc' ? 1 : -1;
    if (sort.key === 'name') return dir * byName(a, b);
    if (sort.key === 'type') {
      return dir * e8sTplTypeLabel(a.type).localeCompare(e8sTplTypeLabel(b.type)) || byName(a, b);
    }
    if (sort.key === 'owner') {
      const ao = e8sTplOwner(a) || 'Workspace', bo = e8sTplOwner(b) || 'Workspace';
      return dir * ao.localeCompare(bo) || byName(a, b);
    }
    if (sort.key === 'upd') {
      const av = e8sTplRecency(e8sTplUpdated(a)), bv = e8sTplRecency(e8sTplUpdated(b));
      /* Undated rows stay at the bottom whichever way the arrow points — see e8sTplRecency. */
      if (av == null || bv == null) return av == null && bv == null ? byName(a, b) : (av == null ? 1 : -1);
      return dir * (bv - av) || byName(a, b);
    }
    return dir * ((a.usage || 0) - (b.usage || 0)) || byName(a, b);
  });
  const shown = showAll ? sorted : sorted.slice(0, E8S_TPL_PAGE);
  const onSort = (key) => setSort((s) => {
    const col = E8S_TPL_COLS.find((c) => c.key === key) || {};
    return { key: key, dir: e8sNextDir({ key: key, dir: col.dir }, s) };
  });

  return (
    <Page
      title="Templates"
      subtitle={all.length + ' templates across ' + liveTypes.length + ' types. Editing happens in the library; what starts where is set here.'}
      action={
        <div className="e8-set-connect-filters">
          <Btn kind="secondary" icon="library_books" onClick={() => e8sGo('templates')}>Library</Btn>
          <Btn kind="primary" icon="add" onClick={() => e8sGo('templates/new-email')}>New template</Btn>
        </div>
      }
    >
      <Section title="Defaults"
        desc="What ELEV8 reaches for when a surface needs a template of that kind and nobody has chosen one.">
        <Card>
          {liveTypes.map((t) => (
            <Row key={t.key} icon={t.icon} label={t.label} desc={t.desc}
              control={<Select value={defaultFor(t.key)} ariaLabel={'Default ' + t.label + ' template'}
                onChange={(v) => setDefault(t.key, v)}
                options={[{ value: '', label: 'No default — ask each time' }]
                  .concat((byType[t.key] || []).slice().sort((a, b) => (b.usage || 0) - (a.usage || 0))
                    .map((x) => ({ value: x.id, label: x.name })))} />} />
          ))}
        </Card>
      </Section>

      <Section title="All templates"
        desc="One library across messaging, capture and documents. A row opens the editor.">
        <Card>
          {/* The filters live in the CARD, not the section head: at 640 the section head is a
              non-wrapping flex row, so a full-width control there squeezes the heading to nothing.
              Here they wrap onto their own line and the count keeps the strip meaningful. */}
          <div className="e8-set-connect-toolbar">
            <span className="e8-set-connect-toolbar-t">
              {sorted.length === all.length ? all.length + ' templates' : sorted.length + ' of ' + all.length + ' templates'}
            </span>
            <div className="e8-set-connect-filters">
              <Input value={q} size="md" placeholder="Search templates…" ariaLabel="Search templates" onChange={setQ} />
              <Select value={type} ariaLabel="Filter by type" onChange={(v) => { setType(v); setShowAll(false); }}
                options={[{ value: 'all', label: 'All types' }]
                  .concat(liveTypes.map((t) => ({ value: t.key, label: t.label + ' (' + (byType[t.key] || []).length + ')' })))} />
            </div>
          </div>
          <ConnTh variant="tpl" cols={E8S_TPL_COLS} sort={sort} onSort={onSort} />
          {shown.map((t) => {
            const owner = e8sTplOwner(t);
            const upd = e8sTplUpdated(t);
            return (
              <a className="e8-set-connect-tr is-tpl is-link" key={t.id} href={'#/templates/' + t.id}>
                <ConnMain title={t.name} sub={t.preview} />
                <ConnCell label="Type" className="e8-set-connect-c-type"><Pill>{e8sTplTypeLabel(t.type)}</Pill></ConnCell>
                <ConnCell label="Owner" className="e8-set-connect-c-owner">
                  {owner || <span className="e8-set-connect-quiet">Workspace</span>}
                </ConnCell>
                <ConnCell label="Uses" className="e8-set-connect-c-uses">{e8sNum(t.usage)}</ConnCell>
                {/* The dash is decoration; "Updated —" is what a screen reader would otherwise
                    announce for the 16 rows whose source collection records no edit date. */}
                <ConnCell label="Updated" className="e8-set-connect-c-upd">
                  {upd || (
                    <React.Fragment>
                      <span className="e8-set-connect-quiet" aria-hidden="true">—</span>
                      <span className="e8-set-connect-vh">Not recorded</span>
                    </React.Fragment>
                  )}
                </ConnCell>
              </a>
            );
          })}
          {!shown.length ? (
            <Empty inline title={ql
              ? 'No ' + (type === 'all' ? 'template' : e8sTplTypeLabel(type).toLowerCase() + ' template') + ' matches “' + q + '”.'
              : 'No ' + e8sTplTypeLabel(type).toLowerCase() + ' templates in the library yet.'} />
          ) : null}
          {/* Expanding used to be a one-way door: the control unmounted at 30 rows and there was
              no way back to the short list. Same button, both directions. */}
          {sorted.length > E8S_TPL_PAGE ? (
            <button type="button" className="e8-set-connect-more" aria-expanded={showAll}
              onClick={() => setShowAll(!showAll)}>
              {showAll ? 'Show fewer' : 'Show all ' + sorted.length + ' templates'}
            </button>
          ) : null}
        </Card>
      </Section>
    </Page>
  );
}

window.E8SetPages = window.E8SetPages || {};
Object.assign(window.E8SetPages, { integrations: SetIntegrationsPage, import: SetImportPage, templates: SetTemplatesPage });
