/* ═══════════════════════════════════════════════════════════════════
   FileManager.jsx — MOD-016 consolidated File Manager (Drop 1)
   ═══════════════════════════════════════════════════════════════════

   Four tabs (locked decision context #31): Library · Convert · Destinations · Jobs.
   Absorbs the former demo-only FileLibrary.jsx (now archived) and wires the
   real MOD-016 /convert/* endpoints shipped in API v1.24.0+ :

     GET  /convert/files            Library list (faceted, paginated)  [v1.26.2]
     GET  /convert/allowance        Doc Ops meter for the Convert/Review step
     POST /convert/uploads          mint signed direct-to-Supabase upload URLs
     (PUT to each signed URL)       browser uploads bytes straight to storage
     POST /convert/batch            enqueue conversion (Doc Ops gate + overage)
     GET  /convert/batch/:id        Jobs polling — per-file batch status

   Conversion engine = self-hosted Gotenberg (tagged PDF/UA), server-side only.
   The backend is INERT until GOTENBERG_URL + the Doc Ops op-type migration are
   live on Railway (Jordan-side) — every tab degrades to a friendly empty/notice
   state (503 CONVERT_UNAVAILABLE / MIGRATION_PENDING) rather than erroring.

   Pattern: IIFE, window.WPSB.FileManager (+ back-compat window.FileManager /
   window.FileLibrary shims), CSS variables only (no hardcoded semantic hex),
   WCAG 2.1 AA, generic placeholders only (example.com / Acme Inc).

   WATERMARK (locked this build): v2, NOT shipped. The Convert wizard shows a
   DISABLED "coming soon" control — never a live no-op, no backend wiring — so we
   never risk the PDF/UA tag tree the ADA audit depends on.
   ═══════════════════════════════════════════════════════════════════ */

(function () {
  'use strict';

  const { useState, useMemo, useEffect, useCallback, useRef } = React;

  const VERSION = '1.0.0';

  /* ── CONFIG ──────────────────────────────────────────────────── */
  const RAILWAY_URL = (window.WPSB_CONFIG && window.WPSB_CONFIG.RAILWAY_URL)
    || 'https://wpsitebeam-railway-api-production.up.railway.app';
  const SUPABASE_URL = (window.WPSB_CONFIG && window.WPSB_CONFIG.SUPABASE_URL)
    || 'https://qohnprukknkscxrllolp.supabase.co';

  // Shared shell helpers (defensive — degrade gracefully if a module is absent).
  const PageHead = window.PageHead || (window.Pages1 && window.Pages1.PageHead) || function (p) {
    return (
      <div style={{ marginBottom: 16 }}>
        <div className="mono" style={{ fontSize: '.6rem', color: 'var(--dim)', letterSpacing: 1, textTransform: 'uppercase' }}>{p.crumb}</div>
        <h1 style={{ margin: '2px 0', fontFamily: 'var(--font-brand)', color: 'var(--text)' }}>{p.title}</h1>
        {p.sub && <div style={{ fontSize: '.82rem', color: 'var(--dim)', maxWidth: 720 }}>{p.sub}</div>}
        {p.actions && <div style={{ display: 'flex', gap: 6, marginTop: 8, flexWrap: 'wrap' }}>{p.actions}</div>}
      </div>
    );
  };
  const toast = (m, t) => { if (window.wpsbToast) window.wpsbToast(m, t || 'info'); };

  /* ── DOMAIN CONSTANTS (match server-side file_objects + Gotenberg) ── */
  // Office formats the Gotenberg LibreOffice route converts to tagged PDF/UA.
  const SUPPORTED_EXTS = ['doc', 'docx', 'odt', 'rtf', 'txt', 'xls', 'xlsx', 'ppt', 'pptx'];
  // Naming-preset library (Drop 1 = selectable data; the inference engine is v2).
  const NAMING_PRESETS = [
    { value: '', label: 'No preset (keep names)' },
    { value: 'minutes', label: 'Meeting minutes' },
    { value: 'agendas', label: 'Agendas' },
    { value: 'policies', label: 'Policies' },
    { value: 'resolutions', label: 'Resolutions' },
    { value: 'ordinances', label: 'Ordinances' },
    { value: 'contracts', label: 'Contracts' },
    { value: 'disclosures', label: 'Disclosures' },
    { value: 'reports', label: 'Reports' },
    { value: 'listings', label: 'Listings' },
  ];
  const ORIGINS = [
    { value: '', label: 'All origins' },
    { value: 'uploaded', label: 'Uploaded' },
    { value: 'generated', label: 'Generated' },
    { value: 'discovered', label: 'Discovered' },
  ];
  const STATUS_OPTS = [
    { value: '', label: 'All statuses' },
    { value: 'staged', label: 'Staged' },
    { value: 'queued', label: 'Queued' },
    { value: 'converting', label: 'Converting' },
    { value: 'converted', label: 'Converted' },
    { value: 'routing', label: 'Routing' },
    { value: 'done', label: 'Done' },
    { value: 'failed', label: 'Failed' },
    { value: 'manual_fallback', label: 'Manual fallback' },
    { value: 'held_dlp', label: 'Held — DLP' },
  ];
  const REVIEW_OPTS = [
    { value: '', label: 'Any review state' },
    { value: 'pending', label: 'Pending' },
    { value: 'approved', label: 'Approved' },
    { value: 'excluded', label: 'Excluded' },
    { value: 'manual', label: 'Manual' },
  ];
  const EXT_OPTS = [
    { value: '', label: 'All types' },
    ...SUPPORTED_EXTS.map(e => ({ value: e, label: e.toUpperCase() })),
    { value: 'pdf', label: 'PDF' },
  ];

  const STATUS_TONE = {
    done: 'ok', converted: 'ok', routing: 'beam', converting: 'beam',
    queued: '', staged: '', failed: 'red', manual_fallback: 'warn', held_dlp: 'warn',
  };

  // Discovered-origin PREVIEW rows (v2). Hardcoded, non-real samples shown in the
  // Library so the tab visually previews how scanner-discovered files will appear.
  // NEVER wired to anything — clicks show a coming-soon note, never a real action.
  // Generic placeholders only (example.com).
  const DISCOVERED_PREVIEW = [
    { id: 'prev-1', source_name: 'council-minutes-2024-03.pdf', source_ext: 'pdf', linked_from: 'example.com/minutes', ada: 2 },
    { id: 'prev-2', source_name: 'annual-report.docx', source_ext: 'docx', linked_from: 'example.com/about', ada: 0 },
    { id: 'prev-3', source_name: 'budget-summary.xlsx', source_ext: 'xlsx', linked_from: 'example.com/budget', ada: 1 },
  ];

  /* ── SMALL UTILITIES ─────────────────────────────────────────── */
  const extOf = (name) => {
    const m = String(name || '').toLowerCase().match(/\.([a-z0-9]+)$/);
    return m ? m[1] : '';
  };
  const isSupportedExt = (name) => SUPPORTED_EXTS.includes(extOf(name));
  const fmtDate = (iso) => {
    if (!iso) return '—';
    try { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); }
    catch (_) { return String(iso).slice(0, 10); }
  };
  const baseName = (p) => String(p || '').split('/').pop();
  const adaIssues = (f) => (f && f.ada && Array.isArray(f.ada.issues)) ? f.ada.issues.length : 0;
  const adaTagged = (f) => !!(f && f.ada && f.ada.tagged);
  const dlpStatus = (f) => (f && f.dlp && f.dlp.status) ? f.dlp.status : null;

  // supabase-js createSignedUploadUrl returns a path; PUT needs an absolute URL.
  const absoluteSignedUrl = (u) => {
    if (!u) return u;
    if (/^https?:\/\//i.test(u)) return u;
    const path = u.startsWith('/storage/v1') ? u.slice('/storage/v1'.length) : u;
    return SUPABASE_URL + '/storage/v1' + (path.startsWith('/') ? path : '/' + path);
  };

  /* ── API CLIENT ──────────────────────────────────────────────── */
  function getFallbackToken() {
    return (window.WPSB && window.WPSB.getToken && window.WPSB.getToken())
      || localStorage.getItem('wpsb-auth-token') || null;
  }
  // Calls Railway with WPSiteBeam-aud auth. Prefers the canonical authedFetch
  // (handles the Supabase->wpsb-app token exchange + 401 retry); falls back to a
  // raw Bearer token. Always resolves — never throws — to a normalized shape.
  async function api(path, opts) {
    opts = opts || {};
    const init = { method: opts.method || 'GET', headers: {} };
    if (opts.body !== undefined) {
      init.headers['Content-Type'] = 'application/json';
      init.body = JSON.stringify(opts.body);
    }
    let resp;
    try {
      if (window.WPSB_Auth && typeof window.WPSB_Auth.authedFetch === 'function') {
        resp = await window.WPSB_Auth.authedFetch(RAILWAY_URL + path, init);
      } else {
        const token = getFallbackToken();
        if (token) init.headers['Authorization'] = 'Bearer ' + token;
        resp = await fetch(RAILWAY_URL + path, init);
      }
    } catch (_) {
      return { ok: false, status: 0, code: 'NETWORK', error: 'Network error — could not reach the server.' };
    }
    let data = null;
    try { data = await resp.json(); } catch (_) { data = null; }
    if (!resp.ok) {
      return { ok: false, status: resp.status, code: (data && data.code) || 'ERROR', error: (data && data.error) || ('Request failed (' + resp.status + ')'), data };
    }
    return { ok: true, status: resp.status, data: data || {} };
  }

  // PUT a File's bytes straight to its signed staging URL. Exercised end-to-end
  // once GOTENBERG_URL is live; throws a friendly message on failure.
  async function putToSignedUrl(uploadUrl, file) {
    const resp = await fetch(absoluteSignedUrl(uploadUrl), {
      method: 'PUT',
      headers: { 'content-type': file.type || 'application/octet-stream', 'x-upsert': 'true' },
      body: file,
    });
    if (!resp.ok) throw new Error('Upload failed for "' + file.name + '" (' + resp.status + ')');
  }

  /* ── JOBS PERSISTENCE (no list-batches endpoint yet — track locally) ── */
  const LS_BATCHES = 'wpsb-fm-batches';
  function loadBatches() {
    try { return JSON.parse(localStorage.getItem(LS_BATCHES) || '[]'); } catch (_) { return []; }
  }
  function saveBatches(arr) {
    try { localStorage.setItem(LS_BATCHES, JSON.stringify((arr || []).slice(0, 50))); } catch (_) { /* quota */ }
  }

  /* ── SCOPED UI PRIMITIVES (self-contained — no global pollution) ── */
  function Kpi({ label, value, tone, icon }) {
    const color = tone === 'warn' ? 'var(--warn)' : tone === 'ok' ? 'var(--green)' : tone === 'red' ? 'var(--red)' : tone === 'dim' ? 'var(--dim)' : 'var(--text)';
    return (
      <div className="card" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
        <div className="card-body" style={{ display: 'flex', gap: 12, alignItems: 'flex-start', textAlign: 'left', flex: 1 }}>
          <div style={{ width: 36, height: 36, borderRadius: 8, background: 'var(--surface-3)', border: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', color, flexShrink: 0 }}>
            <Icon name={icon} size={17} />
          </div>
          <div style={{ minWidth: 0, flex: 1 }}>
            <div className="mono" style={{ fontSize: '.58rem', color: 'var(--dim)', letterSpacing: 1, textTransform: 'uppercase' }}>{label}</div>
            <div style={{ fontSize: '1.2rem', fontWeight: 700, fontFamily: 'var(--font-brand)', color }}>{value}</div>
          </div>
        </div>
      </div>
    );
  }

  function FilterSelect({ label, value, onChange, options }) {
    return (
      <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '.72rem', color: 'var(--dim)' }}>
        <span className="mono" style={{ fontSize: '.58rem', letterSpacing: 1, textTransform: 'uppercase' }}>{label}</span>
        <select value={value} onChange={e => onChange(e.target.value)}
          style={{ background: 'var(--surface-3)', border: '1px solid var(--border)', color: 'var(--text)', borderRadius: 6, padding: '4px 8px', fontSize: '.78rem' }}>
          {options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
        </select>
      </label>
    );
  }

  function EmptyState({ icon, title, hint, primary, secondary }) {
    return (
      <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 10, color: 'var(--muted)', textAlign: 'center' }}>
        <div style={{ width: 44, height: 44, borderRadius: 10, background: 'var(--surface-3)', border: '1px dashed var(--border-2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          <Icon name={icon} size={20} />
        </div>
        <div style={{ fontWeight: 600, color: 'var(--text)' }}>{title}</div>
        {hint && <div style={{ fontSize: '.78rem', color: 'var(--dim)', maxWidth: 380, lineHeight: 1.5 }}>{hint}</div>}
        {(primary || secondary) && (
          <div style={{ display: 'flex', gap: 6 }}>
            {primary && <button className="btn btn-primary btn-sm" onClick={primary.onClick}>{primary.label}</button>}
            {secondary && <button className="btn btn-ghost btn-sm" onClick={secondary.onClick}>{secondary.label}</button>}
          </div>
        )}
      </div>
    );
  }

  function StatusBadge({ status }) {
    if (!status) return <span style={{ color: 'var(--dim)' }}>—</span>;
    const tone = STATUS_TONE[status] || '';
    const label = (STATUS_OPTS.find(s => s.value === status) || {}).label || status;
    return <span className={`tag ${tone}`} style={{ fontSize: '.58rem', padding: '1px 6px' }}>{label}</span>;
  }

  // Edwiser/Moodle-style notice card (12px radius, 4px left accent, inner callout).
  function Notice({ tone, icon, title, children }) {
    const accent = tone === 'warn' ? 'var(--warn)' : tone === 'beam' ? 'var(--beam)' : tone === 'green' ? 'var(--green)' : 'var(--border-2)';
    return (
      <div style={{ borderRadius: 12, border: '1px solid var(--border)', borderLeft: `4px solid ${accent}`, background: 'var(--surface-2)', padding: '14px 16px', display: 'flex', gap: 12 }}>
        {icon && <div style={{ color: accent, flexShrink: 0, marginTop: 1 }}><Icon name={icon} size={18} /></div>}
        <div style={{ minWidth: 0 }}>
          {title && <div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: 4 }}>{title}</div>}
          <div style={{ fontSize: '.8rem', color: 'var(--dim)', lineHeight: 1.55 }}>{children}</div>
        </div>
      </div>
    );
  }

  /* ════════════════════════════════════════════════════════════════
     LIBRARY TAB — faceted, paginated browse over file_objects
     ════════════════════════════════════════════════════════════════ */
  function LibraryTab() {
    const PAGE = 50;
    const [q, setQ] = useState('');
    const [origin, setOrigin] = useState('');
    const [ext, setExt] = useState('');
    const [status, setStatus] = useState('');
    const [review, setReview] = useState('');
    const [offset, setOffset] = useState(0);
    const [resp, setResp] = useState({ files: [], total: 0, has_more: false });
    const [state, setState] = useState('loading'); // loading | ready | empty | error | migration
    const [errMsg, setErrMsg] = useState('');
    const [sel, setSel] = useState(new Set());
    const [drawer, setDrawer] = useState(null);

    const load = useCallback(async () => {
      setState('loading');
      const params = new URLSearchParams();
      params.set('limit', String(PAGE));
      params.set('offset', String(offset));
      if (q) params.set('q', q);
      if (origin) params.set('origin', origin);
      if (ext) params.set('source_ext', ext);
      if (status) params.set('status', status);
      if (review) params.set('review_status', review);
      const r = await api('/convert/files?' + params.toString());
      if (!r.ok) {
        if (r.code === 'MIGRATION_PENDING') { setState('migration'); return; }
        setErrMsg(r.error || 'Could not load files.');
        setState('error');
        return;
      }
      setResp(r.data || { files: [], total: 0, has_more: false });
      setState((r.data && r.data.files && r.data.files.length) ? 'ready' : 'empty');
    }, [q, origin, ext, status, review, offset]);

    // Debounce the search box; immediate for select/page changes.
    useEffect(() => {
      const t = setTimeout(load, q ? 350 : 0);
      return () => clearTimeout(t);
    }, [load, q]);

    // Reset to first page whenever a facet changes.
    useEffect(() => { setOffset(0); setSel(new Set()); }, [q, origin, ext, status, review]);

    const files = resp.files || [];
    const stats = useMemo(() => ({
      total: resp.total || 0,
      converted: files.filter(f => ['converted', 'routing', 'done'].includes(f.status)).length,
      ada: files.filter(f => adaIssues(f) > 0).length,
      flagged: files.filter(f => dlpStatus(f) === 'flagged' || f.status === 'held_dlp').length,
    }), [resp, files]);

    const toggleOne = (id) => {
      const n = new Set(sel);
      if (n.has(id)) n.delete(id); else n.add(id);
      setSel(n);
    };
    const toggleAll = () => {
      if (sel.size === files.length) setSel(new Set());
      else setSel(new Set(files.map(f => f.file_id)));
    };

    const clearFilters = () => { setQ(''); setOrigin(''); setExt(''); setStatus(''); setReview(''); };

    // Show the Discovered-origin v2 preview rows only in the unfiltered view or
    // when explicitly filtering to Discovered — and never alongside other active
    // facets/search (they aren't a searchable dataset, just a visual preview).
    const showPreview = (origin === '' || origin === 'discovered') && !q && !ext && !status && !review;

    return (
      <div>
        <div className="kpi-strip" style={{ marginBottom: 16 }}>
          <Kpi label="Files tracked" value={stats.total} icon="archive" />
          <Kpi label="Converted" value={stats.converted} tone="ok" icon="check" />
          <Kpi label="ADA issues (page)" value={stats.ada} tone="warn" icon="accessibility" />
          <Kpi label="DLP flagged (page)" value={stats.flagged} tone="red" icon="shield" />
        </div>

        <div className="card">
          <div className="card-head" style={{ flexWrap: 'wrap', gap: 12 }}>
            <div>
              <h2 className="card-title">Inventory</h2>
              <div style={{ fontSize: '.72rem', color: 'var(--dim)' }}>
                {files.length ? `${offset + 1}–${offset + files.length}` : '0'} of {resp.total || 0} files
                {sel.size > 0 && <> · <strong style={{ color: 'var(--beam)' }}>{sel.size} selected</strong></>}
              </div>
            </div>
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              <button className="btn btn-ghost btn-sm" onClick={load}><Icon name="refresh" size={12} />Refresh</button>
            </div>
          </div>
          <div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 6, flex: '1 1 200px', minWidth: 160, background: 'var(--surface-3)', border: '1px solid var(--border)', borderRadius: 6, padding: '4px 8px' }}>
                <Icon name="search" size={13} />
                <input type="search" value={q} onChange={e => setQ(e.target.value)} placeholder="Filter by file name…" aria-label="Filter by file name"
                  style={{ flex: 1, background: 'transparent', border: 'none', color: 'var(--text)', outline: 'none', fontSize: '.78rem', fontFamily: 'var(--font-mono)' }} />
                {q && <button className="btn btn-ghost btn-sm" onClick={() => setQ('')} aria-label="Clear search" style={{ padding: '2px 4px' }}><Icon name="x" size={10} /></button>}
              </div>
              <FilterSelect label="Origin" value={origin} onChange={setOrigin} options={ORIGINS} />
              <FilterSelect label="Type" value={ext} onChange={setExt} options={EXT_OPTS} />
              <FilterSelect label="Status" value={status} onChange={setStatus} options={STATUS_OPTS} />
              <FilterSelect label="Review" value={review} onChange={setReview} options={REVIEW_OPTS} />
              <button className="btn btn-ghost btn-sm" onClick={clearFilters}>Clear</button>
            </div>

            {state === 'loading' && <EmptyState icon="loader" title="Loading files…" />}
            {state === 'error' && <EmptyState icon="warn" title="Could not load files." hint={errMsg} primary={{ label: 'Retry', onClick: load }} />}
            {state === 'migration' && (
              <Notice tone="beam" icon="info" title="File Manager backend not provisioned yet">
                The conversion data layer is live, but this environment is still being set up. Files appear here once the WordPress plugin imports an inventory or you run a conversion batch.
              </Notice>
            )}
            {state === 'empty' && !showPreview && (
              <EmptyState icon="archive" title="No files match these filters." hint="Try clearing filters, or convert a batch from the Convert tab to populate the library." />
            )}

            {(state === 'ready' || (state === 'empty' && showPreview)) && (
              <div style={{ overflowX: 'auto' }}>
                {showPreview && (
                  <div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, fontSize: '.72rem', color: 'var(--dim)', marginBottom: 8, lineHeight: 1.5 }}>
                    <Icon name="info" size={12} />
                    <span>
                      Rows badged <span className="tag warn" style={{ fontSize: '.52rem', padding: '1px 5px' }}>PREVIEW · v2</span> are
                      samples showing how files discovered by site scans will appear here. They are not real files — they can&apos;t be opened, selected, or acted on.
                    </span>
                  </div>
                )}
                <table className="table">
                  <thead>
                    <tr>
                      <th style={{ width: 28 }}>
                        <input type="checkbox" checked={files.length > 0 && sel.size === files.length}
                          ref={el => { if (el) el.indeterminate = sel.size > 0 && sel.size < files.length; }}
                          onChange={toggleAll} aria-label="Select all files" disabled={files.length === 0} />
                      </th>
                      <th>File name</th>
                      <th style={{ width: 90 }}>Origin</th>
                      <th style={{ width: 70 }}>Type</th>
                      <th style={{ width: 110 }}>Status</th>
                      <th style={{ width: 90 }}>Review</th>
                      <th>Flags</th>
                      <th style={{ width: 96 }}>Created</th>
                      <th style={{ width: 56, textAlign: 'right' }}></th>
                    </tr>
                  </thead>
                  <tbody>
                    {files.map(f => {
                      const name = f.source_name || baseName(f.staging_path) || f.file_id;
                      const dlp = dlpStatus(f);
                      return (
                        <tr key={f.file_id} style={sel.has(f.file_id) ? { background: 'var(--beam-dim)' } : null}>
                          <td><input type="checkbox" checked={sel.has(f.file_id)} onChange={() => toggleOne(f.file_id)} aria-label={`Select ${name}`} /></td>
                          <td className="mono" style={{ fontSize: '.74rem', color: 'var(--text)', fontWeight: 500, maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</td>
                          <td style={{ fontSize: '.72rem', color: 'var(--dim)', textTransform: 'capitalize' }}>{f.origin || 'uploaded'}</td>
                          <td><span className="tag">{(f.source_ext || extOf(name) || '—').toUpperCase()}</span></td>
                          <td><StatusBadge status={f.status} /></td>
                          <td style={{ fontSize: '.72rem', color: 'var(--dim)', textTransform: 'capitalize' }}>{f.review_status || 'pending'}</td>
                          <td>
                            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
                              {adaTagged(f) && <span className="tag ok" style={{ fontSize: '.54rem', padding: '1px 5px' }}>TAGGED</span>}
                              {adaIssues(f) > 0 && <span className="tag warn" style={{ fontSize: '.54rem', padding: '1px 5px' }}>ADA {adaIssues(f)}</span>}
                              {dlp === 'flagged' && <span className="tag red" style={{ fontSize: '.54rem', padding: '1px 5px' }}>DLP</span>}
                              {f.layout_risk && f.layout_risk !== 'low' && <span className="tag warn" style={{ fontSize: '.54rem', padding: '1px 5px' }}>LAYOUT {String(f.layout_risk).toUpperCase()}</span>}
                            </div>
                          </td>
                          <td className="mono" style={{ fontSize: '.7rem', color: 'var(--dim)' }}>{fmtDate(f.created_at)}</td>
                          <td style={{ textAlign: 'right' }}>
                            <button className="btn btn-ghost btn-sm" onClick={() => setDrawer(f)} aria-label={`Details for ${name}`}><Icon name="chevron-right" size={11} /></button>
                          </td>
                        </tr>
                      );
                    })}
                    {showPreview && DISCOVERED_PREVIEW.map(p => (
                      // Non-real v2 preview row — dimmed, badged, fully non-interactive.
                      <tr key={p.id} aria-disabled="true" title="Preview — Discovered origin (coming in v2)"
                        style={{ opacity: 0.5, fontStyle: 'italic', background: 'rgba(0,0,0,.15)' }}>
                        <td><input type="checkbox" disabled checked={false} aria-label="Preview row — not selectable" /></td>
                        <td className="mono" style={{ fontSize: '.74rem', color: 'var(--dim)', maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                          {p.source_name}
                          <span className="tag warn" style={{ marginLeft: 6, fontSize: '.5rem', padding: '1px 5px', fontStyle: 'normal' }}>PREVIEW · v2</span>
                        </td>
                        <td style={{ fontSize: '.72rem', color: 'var(--dim)', textTransform: 'capitalize' }}>Discovered</td>
                        <td><span className="tag">{p.source_ext.toUpperCase()}</span></td>
                        <td><span style={{ color: 'var(--dim)' }}>—</span></td>
                        <td style={{ fontSize: '.72rem', color: 'var(--dim)' }}>—</td>
                        <td>
                          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
                            {p.ada > 0
                              ? <span className="tag warn" style={{ fontSize: '.54rem', padding: '1px 5px', fontStyle: 'normal' }}>ADA {p.ada}</span>
                              : <span className="tag" style={{ fontSize: '.54rem', padding: '1px 5px', fontStyle: 'normal', color: 'var(--dim)' }}>{p.linked_from}</span>}
                          </div>
                        </td>
                        <td className="mono" style={{ fontSize: '.7rem', color: 'var(--dim)' }}>—</td>
                        <td style={{ textAlign: 'right' }}>
                          <button className="btn btn-ghost btn-sm" aria-label="Preview row — Discovered origin, coming in v2"
                            onClick={() => toast('Discovered files (from site scans) arrive in v2 — this is a non-real preview row.', 'info')}>
                            <Icon name="lock" size={11} />
                          </button>
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            )}

            {(state === 'ready' || state === 'empty') && (resp.total || 0) > PAGE && (
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
                <button className="btn btn-ghost btn-sm" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
                  <Icon name="chevron-left" size={12} />Previous
                </button>
                <div className="mono" style={{ fontSize: '.68rem', color: 'var(--dim)' }}>Page {Math.floor(offset / PAGE) + 1}</div>
                <button className="btn btn-ghost btn-sm" disabled={!resp.has_more} onClick={() => setOffset(offset + PAGE)}>
                  Next<Icon name="chevron-right" size={12} />
                </button>
              </div>
            )}
          </div>
        </div>

        {drawer && <DetailsDrawer file={drawer} onClose={() => setDrawer(null)} />}
      </div>
    );
  }

  function DetailsDrawer({ file, onClose }) {
    const name = file.source_name || baseName(file.staging_path) || file.file_id;
    const Row = ({ k, v }) => (
      <div style={{ display: 'flex', gap: 10, padding: '6px 0', borderBottom: '1px solid var(--border)' }}>
        <div className="mono" style={{ fontSize: '.6rem', color: 'var(--dim)', textTransform: 'uppercase', letterSpacing: 1, width: 120, flexShrink: 0 }}>{k}</div>
        <div style={{ fontSize: '.78rem', color: 'var(--text)', minWidth: 0, wordBreak: 'break-word' }}>{v}</div>
      </div>
    );
    return (
      <div role="dialog" aria-label={`File details: ${name}`} onClick={onClose}
        style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', display: 'flex', justifyContent: 'flex-end', zIndex: 60 }}>
        <div onClick={e => e.stopPropagation()} style={{ width: 'min(440px, 92vw)', height: '100%', background: 'var(--panel)', borderLeft: '1px solid var(--border)', padding: 18, overflowY: 'auto' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10, marginBottom: 12 }}>
            <h3 style={{ margin: 0, color: 'var(--text)', fontSize: '.95rem', wordBreak: 'break-word' }}>{name}</h3>
            <button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close details"><Icon name="x" size={14} /></button>
          </div>
          <Row k="Status" v={<StatusBadge status={file.status} />} />
          <Row k="Origin" v={file.origin || 'uploaded'} />
          <Row k="Type" v={(file.source_ext || extOf(name) || '—').toUpperCase()} />
          <Row k="Review" v={file.review_status || 'pending'} />
          <Row k="Layout risk" v={file.layout_risk || '—'} />
          <Row k="ADA tagged" v={adaTagged(file) ? 'Yes' : 'No'} />
          <Row k="ADA issues" v={adaIssues(file)} />
          <Row k="DLP" v={dlpStatus(file) || 'not scanned'} />
          <Row k="Batch" v={file.batch_id || '—'} />
          <Row k="Created" v={fmtDate(file.created_at)} />
          <Row k="Updated" v={fmtDate(file.updated_at)} />
          {file.converted_ref_url && (
            <div style={{ marginTop: 14 }}>
              <a className="btn btn-primary btn-sm" href={file.converted_ref_url} target="_blank" rel="noopener noreferrer">
                <Icon name="download" size={12} />Download converted PDF
              </a>
            </div>
          )}
        </div>
      </div>
    );
  }

  /* ════════════════════════════════════════════════════════════════
     CONVERT TAB — upload → options → review → run wizard
     ════════════════════════════════════════════════════════════════ */
  function ConvertTab({ onBatchCreated, goJobs }) {
    const [step, setStep] = useState(1);
    const [files, setFiles] = useState([]);          // File[]
    const [preset, setPreset] = useState('');
    const [aiMeta, setAiMeta] = useState(false);     // opt-in OFF by default
    const [destination] = useState('media_library'); // Tier 0 only this pass
    const [allowance, setAllowance] = useState(null);
    const [overage, setOverage] = useState(null);     // pending consent payload
    const [running, setRunning] = useState(false);
    const fileInput = useRef(null);

    const supported = files.filter(f => isSupportedExt(f.name));
    const rejected = files.filter(f => !isSupportedExt(f.name));

    const loadAllowance = useCallback(async () => {
      const r = await api('/convert/allowance');
      if (r.ok) setAllowance(r.data);
    }, []);
    useEffect(() => { if (step === 3) loadAllowance(); }, [step, loadAllowance]);

    const pickFiles = (e) => {
      const picked = Array.from(e.target.files || []);
      setFiles(prev => {
        const seen = new Set(prev.map(f => f.name + ':' + f.size));
        return prev.concat(picked.filter(f => !seen.has(f.name + ':' + f.size)));
      });
      if (fileInput.current) fileInput.current.value = '';
    };
    const removeFile = (idx) => setFiles(files.filter((_, i) => i !== idx));

    async function run(acceptOverage) {
      if (!supported.length) { toast('Add at least one supported document', 'warn'); return; }
      setRunning(true);
      setOverage(null);
      try {
        // 1) mint signed upload URLs
        const up = await api('/convert/uploads', { method: 'POST', body: { files: supported.map(f => ({ name: f.name })) } });
        if (!up.ok) {
          toast(up.code === 'CONVERT_UNAVAILABLE' ? 'Conversion service is not configured yet.' : (up.error || 'Could not start upload'), 'warn');
          setRunning(false); return;
        }
        const { batch_id, files: minted } = up.data;
        // 2) PUT bytes to each signed URL (minted preserves input order)
        for (let i = 0; i < minted.length; i++) {
          await putToSignedUrl(minted[i].upload_url, supported[i]);
        }
        // 3) enqueue the batch (Tier 0 / convert-only this pass; no live route)
        const options = { naming_preset: preset || null, ai_metadata: !!aiMeta };
        const body = {
          batch_id,
          files: minted.map(m => ({ source_path: m.source_path, source_name: m.source_name })),
          route: null,
          options,
        };
        if (acceptOverage) body.accept_overage = true;
        const res = await api('/convert/batch', { method: 'POST', body });
        if (!res.ok) {
          if (res.status === 402 && res.code === 'DOCOPS_OVERAGE_CONSENT_REQUIRED' && res.data) {
            setOverage(res.data);  // show consent prompt — user can accept or buy an add-on
            setRunning(false); return;
          }
          toast(res.error || 'Could not enqueue conversion', 'warn');
          setRunning(false); return;
        }
        const rec = { batch_id, created_at: new Date().toISOString(), count: supported.length, mode: res.data.mode || 'convert_only' };
        onBatchCreated(rec);
        toast(`Conversion queued — ${supported.length} file${supported.length === 1 ? '' : 's'}`, 'ok');
        // reset + hand off to Jobs
        setFiles([]); setStep(1); setRunning(false);
        goJobs();
      } catch (err) {
        toast(err.message || 'Upload failed', 'warn');
        setRunning(false);
      }
    }

    const StepDot = ({ n, label }) => (
      <div style={{ display: 'flex', alignItems: 'center', gap: 6, opacity: step >= n ? 1 : 0.5 }}>
        <div style={{ width: 22, height: 22, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '.7rem', fontWeight: 700, background: step >= n ? 'var(--beam)' : 'var(--surface-3)', color: step >= n ? '#fff' : 'var(--dim)', border: '1px solid var(--border)' }}>{n}</div>
        <span style={{ fontSize: '.72rem', color: step >= n ? 'var(--text)' : 'var(--dim)' }}>{label}</span>
      </div>
    );

    return (
      <div className="card">
        <div className="card-head"><h2 className="card-title">Convert documents</h2></div>
        <div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
          <div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'center' }}>
            <StepDot n={1} label="Source" />
            <Icon name="chevron-right" size={12} />
            <StepDot n={2} label="Options" />
            <Icon name="chevron-right" size={12} />
            <StepDot n={3} label="Review & run" />
          </div>

          {/* Step 1 — Source */}
          {step === 1 && (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
              <input ref={fileInput} type="file" multiple onChange={pickFiles} accept={SUPPORTED_EXTS.map(e => '.' + e).join(',')} style={{ display: 'none' }} aria-hidden="true" />
              <button className="btn btn-primary" onClick={() => fileInput.current && fileInput.current.click()} style={{ alignSelf: 'flex-start' }}>
                <Icon name="upload" size={14} />Choose documents
              </button>
              <div style={{ fontSize: '.74rem', color: 'var(--dim)' }}>Supported: {SUPPORTED_EXTS.map(e => e.toUpperCase()).join(', ')} · up to 50 MB each.</div>

              {rejected.length > 0 && (
                <Notice tone="warn" icon="warn" title={`${rejected.length} file${rejected.length === 1 ? '' : 's'} not supported`}>
                  Only office documents convert to tagged PDF. These will be skipped: {rejected.map(f => f.name).join(', ')}.
                </Notice>
              )}

              {supported.length === 0
                ? <EmptyState icon="upload" title="No documents added yet." hint="Choose one or more office documents to convert into accessible, tagged PDFs." />
                : (
                  <table className="table">
                    <thead><tr><th>File</th><th style={{ width: 70 }}>Type</th><th style={{ width: 100, textAlign: 'right' }}>Size</th><th style={{ width: 40 }}></th></tr></thead>
                    <tbody>
                      {files.map((f, i) => (
                        <tr key={f.name + i} style={isSupportedExt(f.name) ? null : { opacity: 0.5 }}>
                          <td className="mono" style={{ fontSize: '.74rem', color: 'var(--text)' }}>{f.name}</td>
                          <td><span className={`tag ${isSupportedExt(f.name) ? '' : 'warn'}`}>{(extOf(f.name) || '—').toUpperCase()}</span></td>
                          <td className="mono" style={{ fontSize: '.72rem', color: 'var(--dim)', textAlign: 'right' }}>{(f.size / 1024 >= 1024) ? (f.size / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(f.size / 1024)) + ' KB'}</td>
                          <td style={{ textAlign: 'right' }}><button className="btn btn-ghost btn-sm" onClick={() => removeFile(i)} aria-label={`Remove ${f.name}`}><Icon name="x" size={11} /></button></td>
                        </tr>
                      ))}
                    </tbody>
                  </table>
                )}

              <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
                <button className="btn btn-primary btn-sm" disabled={supported.length === 0} onClick={() => setStep(2)}>Next<Icon name="chevron-right" size={12} /></button>
              </div>
            </div>
          )}

          {/* Step 2 — Options */}
          {step === 2 && (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
              <Notice tone="green" icon="check" title="Tagged PDF/UA — always on">
                Every document is exported as a tagged, accessible PDF and run through the ADA audit automatically. This is locked on and can't be disabled.
              </Notice>

              <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
                <label className="mono" style={{ fontSize: '.6rem', color: 'var(--dim)', textTransform: 'uppercase', letterSpacing: 1 }}>Naming preset</label>
                <select value={preset} onChange={e => setPreset(e.target.value)}
                  style={{ background: 'var(--surface-3)', border: '1px solid var(--border)', color: 'var(--text)', borderRadius: 6, padding: '6px 10px', fontSize: '.82rem', maxWidth: 320 }}>
                  {NAMING_PRESETS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
                </select>
                <div style={{ fontSize: '.72rem', color: 'var(--dim)' }}>Applies a consistent, ADA-friendly naming pattern to the batch.</div>
              </div>

              <label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', maxWidth: 460 }}>
                <input type="checkbox" checked={aiMeta} onChange={e => setAiMeta(e.target.checked)} style={{ marginTop: 3 }} />
                <span>
                  <span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--text)', fontWeight: 500 }}>
                    <Icon name="ai" size={13} />AI-assisted metadata <span className="tag" style={{ fontSize: '.54rem' }}>OPTIONAL</span>
                  </span>
                  <span style={{ fontSize: '.72rem', color: 'var(--dim)' }}>Off by default. Suggests SEO titles and descriptions using your AI allowance.</span>
                </span>
              </label>

              {/* Watermark — DISABLED, coming soon (v2). Never a live no-op. */}
              <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, maxWidth: 460, opacity: 0.6 }}>
                <input type="checkbox" disabled checked={false} aria-disabled="true" style={{ marginTop: 3 }} />
                <span>
                  <span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--dim)', fontWeight: 500 }}>
                    <Icon name="lock" size={13} />Watermark / stamp <span className="tag warn" style={{ fontSize: '.54rem' }}>COMING SOON</span>
                  </span>
                  <span style={{ fontSize: '.72rem', color: 'var(--dim)' }}>Custom DRAFT / CONFIDENTIAL stamps arrive in a later release — we won't ship a stamp step until it can be applied without affecting the PDF accessibility tags.</span>
                </span>
              </div>

              <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                <button className="btn btn-ghost btn-sm" onClick={() => setStep(1)}><Icon name="chevron-left" size={12} />Back</button>
                <button className="btn btn-primary btn-sm" onClick={() => setStep(3)}>Next<Icon name="chevron-right" size={12} /></button>
              </div>
            </div>
          )}

          {/* Step 3 — Review & run */}
          {step === 3 && (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
              <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
                <Kpi label="Documents" value={supported.length} icon="docs" />
                <Kpi label="Preset" value={(NAMING_PRESETS.find(p => p.value === preset) || {}).label || 'None'} icon="type" />
                <Kpi label="Destination" value="Media Library" icon="globe" />
              </div>

              {allowance && (
                <Notice tone={allowance.remaining !== -1 && allowance.remaining < supported.length ? 'warn' : 'beam'} icon="chart" title="Doc Ops allowance">
                  {allowance.allowance === -1
                    ? 'Unlimited conversions on your plan.'
                    : <>Using <strong>{allowance.used}</strong> of <strong>{allowance.allowance}</strong> this month · <strong>{allowance.remaining}</strong> remaining.
                      {allowance.remaining < supported.length && <> This batch of {supported.length} would exceed your allowance; overage is ${Number(allowance.overage_rate_usd || 0.05).toFixed(2)}/doc.</>}</>}
                </Notice>
              )}

              <Notice tone="beam" icon="info" title="Convert-only this batch">
                Files convert to tagged PDFs you can download. Routing into WordPress folders and document libraries unlocks once the WordPress plugin is connected.
              </Notice>

              {overage && (
                <div style={{ borderRadius: 12, border: '1px solid var(--warn)', borderLeft: '4px solid var(--warn)', background: 'var(--warn-dim)', padding: '14px 16px' }}>
                  <div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: 4 }}>This batch goes over your allowance</div>
                  <div style={{ fontSize: '.8rem', color: 'var(--text)', lineHeight: 1.55 }}>
                    {overage.overage_docs} document{overage.overage_docs === 1 ? '' : 's'} would bill as overage
                    {overage.overage_cost_usd != null && <> (~${Number(overage.overage_cost_usd).toFixed(2)})</>}.
                    {overage.suggestion && <> A {overage.suggestion.tier} add-on (${overage.suggestion.monthly_usd}/mo) raises your allowance to {overage.suggestion.new_allowance}.</>}
                  </div>
                  <div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
                    <button className="btn btn-primary btn-sm" disabled={running} onClick={() => run(true)}>Accept overage &amp; convert</button>
                    <button className="btn btn-ghost btn-sm" onClick={() => setOverage(null)}>Cancel</button>
                  </div>
                </div>
              )}

              {!overage && (
                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                  <button className="btn btn-ghost btn-sm" onClick={() => setStep(2)}><Icon name="chevron-left" size={12} />Back</button>
                  <button className="btn btn-primary" disabled={running} onClick={() => run(false)}>
                    {running ? 'Converting…' : <>Convert {supported.length} document{supported.length === 1 ? '' : 's'}</>}
                  </button>
                </div>
              )}
            </div>
          )}
        </div>
      </div>
    );
  }

  /* ════════════════════════════════════════════════════════════════
     DESTINATIONS TAB — Tier 0 default + plugin-gated routing (downstream)
     ════════════════════════════════════════════════════════════════ */
  function DestinationsTab() {
    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        <div className="card">
          <div className="card-head"><h2 className="card-title">Destinations</h2></div>
          <div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', borderRadius: 10, border: '1px solid var(--border)', background: 'var(--surface-2)' }}>
              <div style={{ width: 38, height: 38, borderRadius: 8, background: 'var(--surface-3)', border: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--green)' }}>
                <Icon name="globe" size={18} />
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontWeight: 600, color: 'var(--text)' }}>Media Library <span className="tag ok" style={{ fontSize: '.54rem' }}>ALWAYS AVAILABLE</span></div>
                <div style={{ fontSize: '.76rem', color: 'var(--dim)' }}>The universal destination — converted PDFs import to the site's Media Library, with an optional physical subfolder.</div>
              </div>
            </div>

            <Notice tone="beam" icon="plugin" title="More destinations with the WordPress plugin">
              Connect the WordPress plugin to route converted documents into organizational folders and document libraries, mapped to the right categories and tags. Gated and secure destinations follow in a later phase.
            </Notice>
          </div>
        </div>
      </div>
    );
  }

  /* ════════════════════════════════════════════════════════════════
     JOBS TAB — poll tracked batches (no list-batches endpoint yet)
     ════════════════════════════════════════════════════════════════ */
  function JobsTab({ batches, setBatches }) {
    const [statuses, setStatuses] = useState({}); // batch_id -> batch status payload
    const [manualId, setManualId] = useState('');

    // Latest statuses read through a ref so the poll scheduler never closes over a
    // stale snapshot (the old setInterval captured the first-render empty statuses,
    // so its "anyOpen" check was always true -> it polled every 6s forever).
    const statusesRef = useRef(statuses);
    useEffect(() => { statusesRef.current = statuses; }, [statuses]);

    const poll = useCallback(async () => {
      const next = {};
      for (const b of batches) {
        const r = await api('/convert/batch/' + encodeURIComponent(b.batch_id));
        if (r.ok) next[b.batch_id] = r.data;
        else if (r.status === 404) next[b.batch_id] = { not_found: true };
      }
      setStatuses(next);
    }, [batches]);

    // Adaptive, visibility-aware poll loop. Stops entirely when every tracked
    // batch is terminal (complete / not found); pauses while the tab is hidden;
    // runs at the base cadence only while a batch is actively converting and
    // backs off toward POLL_MAX_MS when batches sit idle (e.g. queued but the
    // converter hasn't picked them up). Self-rescheduling setTimeout (not
    // setInterval) so each next delay reflects current state, and it is fully
    // torn down on unmount — no leaked timers, no continuous idle polling.
    useEffect(() => {
      if (!batches.length) return undefined;
      const POLL_BASE_MS = 6000, POLL_MAX_MS = 60000;
      let stopped = false, timer = null, backoff = POLL_BASE_MS;

      const isTerminal = (b) => { const s = statusesRef.current[b.batch_id]; return !!(s && (s.complete || s.not_found)); };
      const anyActive  = () => batches.some(b => !isTerminal(b));            // unknown or in-flight or idle-pending
      const anyWorking = () => batches.some(b => {
        const s = statusesRef.current[b.batch_id];
        if (!s || s.complete || s.not_found) return false;
        const c = s.counts || {};
        return (c.converting || 0) + (c.routing || 0) > 0;                  // actually processing right now
      });
      const schedule = (ms) => { if (!stopped) { clearTimeout(timer); timer = setTimeout(tick, ms); } };

      async function tick() {
        if (stopped) return;
        if (typeof document !== 'undefined' && document.hidden) { schedule(POLL_BASE_MS); return; } // paused; cheap re-check
        await poll();
        if (stopped) return;
        if (!anyActive()) return;                                           // all terminal -> stop (no reschedule)
        backoff = anyWorking() ? POLL_BASE_MS : Math.min(POLL_MAX_MS, Math.round(backoff * 1.5));
        schedule(backoff);
      }

      // initial poll, then start the loop only if something is still active
      poll().then(() => { if (!stopped && anyActive()) schedule(POLL_BASE_MS); });

      const onVis = () => { if (typeof document !== 'undefined' && !document.hidden && !stopped && anyActive()) { backoff = POLL_BASE_MS; schedule(1000); } };
      if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);

      return () => {
        stopped = true;
        clearTimeout(timer);
        if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
      };
    }, [batches, poll]);

    const trackManual = () => {
      const id = manualId.trim();
      if (!id) return;
      if (!batches.some(b => b.batch_id === id)) {
        const next = [{ batch_id: id, created_at: new Date().toISOString(), count: null, mode: 'tracked' }, ...batches];
        setBatches(next);
      }
      setManualId('');
    };
    const forget = (id) => setBatches(batches.filter(b => b.batch_id !== id));

    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        <div className="card">
          <div className="card-head" style={{ flexWrap: 'wrap', gap: 10 }}>
            <h2 className="card-title">Conversion jobs</h2>
            <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
              <input type="text" value={manualId} onChange={e => setManualId(e.target.value)} placeholder="Track a batch ID…" aria-label="Track a batch ID"
                style={{ background: 'var(--surface-3)', border: '1px solid var(--border)', color: 'var(--text)', borderRadius: 6, padding: '4px 8px', fontSize: '.74rem', fontFamily: 'var(--font-mono)' }} />
              <button className="btn btn-ghost btn-sm" onClick={trackManual}>Track</button>
              <button className="btn btn-ghost btn-sm" onClick={poll}><Icon name="refresh" size={12} />Refresh</button>
            </div>
          </div>
          <div className="card-body">
            {batches.length === 0
              ? <EmptyState icon="clock" title="No conversion jobs yet." hint="Convert a batch from the Convert tab — its progress shows up here." />
              : (
                <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
                  {batches.map(b => {
                    const s = statuses[b.batch_id];
                    const counts = s && s.counts ? s.counts : null;
                    const done = s && s.complete;
                    return (
                      <div key={b.batch_id} style={{ borderRadius: 10, border: '1px solid var(--border)', background: 'var(--surface-2)', padding: '12px 14px' }}>
                        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
                          <div style={{ minWidth: 0 }}>
                            <div className="mono" style={{ fontSize: '.74rem', color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis' }}>{b.batch_id}</div>
                            <div style={{ fontSize: '.7rem', color: 'var(--dim)' }}>
                              {fmtDate(b.created_at)}{b.count != null && <> · {b.count} file{b.count === 1 ? '' : 's'}</>}{b.mode && <> · {b.mode.replace(/_/g, ' ')}</>}
                            </div>
                          </div>
                          <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
                            {!s && <span className="tag" style={{ fontSize: '.56rem' }}>CHECKING…</span>}
                            {s && s.not_found && <span className="tag warn" style={{ fontSize: '.56rem' }}>NOT FOUND</span>}
                            {s && !s.not_found && <span className={`tag ${done ? 'ok' : 'beam'}`} style={{ fontSize: '.56rem' }}>{done ? 'COMPLETE' : 'IN PROGRESS'}</span>}
                            <button className="btn btn-ghost btn-sm" onClick={() => forget(b.batch_id)} aria-label="Stop tracking this batch"><Icon name="x" size={11} /></button>
                          </div>
                        </div>
                        {s && !s.not_found && (
                          <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
                            {s.total != null && <span style={{ fontSize: '.7rem', color: 'var(--dim)' }}>{s.total} file{s.total === 1 ? '' : 's'}:</span>}
                            {counts && Object.keys(counts).map(k => (
                              <span key={k} className={`tag ${STATUS_TONE[k] || ''}`} style={{ fontSize: '.56rem', padding: '1px 6px' }}>{counts[k]} {(STATUS_OPTS.find(o => o.value === k) || {}).label || k}</span>
                            ))}
                          </div>
                        )}
                      </div>
                    );
                  })}
                </div>
              )}
          </div>
        </div>
      </div>
    );
  }

  /* ════════════════════════════════════════════════════════════════
     SHELL — tab switcher
     ════════════════════════════════════════════════════════════════ */
  const TABS = [
    { id: 'library', label: 'Library', icon: 'archive' },
    { id: 'convert', label: 'Convert', icon: 'upload' },
    { id: 'destinations', label: 'Destinations', icon: 'globe' },
    { id: 'jobs', label: 'Jobs', icon: 'clock' },
  ];

  function FileManager() {
    const [tab, setTab] = useState('library');
    const [batches, setBatchesState] = useState(loadBatches);
    const setBatches = useCallback((arr) => { setBatchesState(arr); saveBatches(arr); }, []);
    const onBatchCreated = useCallback((rec) => {
      setBatchesState(prev => { const next = [rec, ...prev.filter(b => b.batch_id !== rec.batch_id)]; saveBatches(next); return next; });
    }, []);

    return (
      <div>
        <style>{`
          .kpi-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; align-items: stretch; }
          .kpi-strip > * { height: 100%; }
          @media (max-width: 980px) { .kpi-strip { grid-template-columns: repeat(2, 1fr); } }
          @media (max-width: 560px) { .kpi-strip { grid-template-columns: 1fr; } }
          .fm-tabs { display: flex; gap: 4px; flex-wrap: wrap; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
          .fm-tab { display: flex; align-items: center; gap: 6px; padding: 8px 14px; font-size: .82rem; color: var(--dim); background: transparent; border: none; border-bottom: 2px solid transparent; cursor: pointer; }
          .fm-tab[aria-selected="true"] { color: var(--text); border-bottom-color: var(--beam); font-weight: 600; }
          .fm-tab:hover { color: var(--text); }
        `}</style>

        <PageHead crumb="Tools" title="File Manager"
          sub="Convert office documents into accessible, tagged PDFs, browse your file inventory, and route documents to WordPress — all in one place." />

        <div className="fm-tabs" role="tablist" aria-label="File Manager sections">
          {TABS.map(t => (
            <button key={t.id} className="fm-tab" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}>
              <Icon name={t.icon} size={14} />{t.label}
            </button>
          ))}
        </div>

        {tab === 'library' && <LibraryTab />}
        {tab === 'convert' && <ConvertTab onBatchCreated={onBatchCreated} goJobs={() => setTab('jobs')} />}
        {tab === 'destinations' && <DestinationsTab />}
        {tab === 'jobs' && <JobsTab batches={batches} setBatches={setBatches} />}
      </div>
    );
  }

  /* ── EXPORTS + back-compat shims ─────────────────────────────── */
  window.WPSB = window.WPSB || {};
  window.WPSB.FileManager = FileManager;
  window.FileManager = FileManager;
  // Back-compat JS shim only — the canonical route id is 'filemanager' (the
  // 'filelibrary' route was removed). Any lingering global ref resolves to the
  // new manager; there is no second live route id.
  window.FileLibrary = FileManager;

  console.log('[WPSB FileManager] loaded v' + VERSION + ' — Library/Convert/Destinations/Jobs. Railway:', RAILWAY_URL);
})();
