/* global React, MegaFooter */
const { useState: useStateQA, useEffect: useEffectQA, useRef: useRefQA } = React;

// ====================================================================
// /quotes — operator picker for the quote system.
//
// Password-gated. Once you're in:
//   • Lists open productions from the Notion productions DB.
//   • Click a row to either OPEN an existing quote (if a Quote Token is
//     already set on the Notion page) or REQUEST a new one (sets
//     Financial Status → "Requested", which the LaunchAgent monitor
//     picks up within ~30s; we poll for the token and redirect when it
//     lands).
//
// All quote-editing happens in the live quote at /quotes/<token> — that
// page is served HTML-first by /api/quotes/serve via vercel.json rewrite,
// and carries the full inline editor from quote_generator.py / template.html.
// ====================================================================

function QuotesAdminPage({ onGoto }) {
  // session === undefined → loading; null → not signed in; obj → signed in
  const [session, setSession] = useStateQA(undefined);

  useEffectQA(() => {
    fetch('/api/quotes/admin?action=session', { credentials: 'same-origin' })
      .then((r) => r.ok ? r.json() : null)
      .then((j) => setSession(j && j.ok ? { ok: true } : null))
      .catch(() => setSession(null));
  }, []);

  const onSignedIn = () => setSession({ ok: true });
  const onSignOut = async () => {
    try { await fetch('/api/quotes/admin', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ action: 'logout' }),
      credentials: 'same-origin',
    }); } catch (e) { /* */ }
    setSession(null);
  };

  if (session === undefined) {
    return (
      <main className="page active about-light quotes-admin" data-screen-label="QX Quotes · Booting">
        <div className="quotes-boot"><div className="quotes-boot-spin" /></div>
      </main>);
  }

  if (!session) {
    return (
      <main className="page active about-light quotes-admin" data-screen-label="QX Quotes · Sign in">
        <QuotesLogin onSignedIn={onSignedIn} />
      </main>);
  }

  return (
    <main className="page active about-light quotes-admin" data-screen-label="QX Quotes · Dashboard">
      <QuotesDashboard onSignOut={onSignOut} />
    </main>);
}

// ───────────────────────────────────────────────────────────────────
function QuotesLogin({ onSignedIn }) {
  const [pwd, setPwd] = useStateQA('');
  const [busy, setBusy] = useStateQA(false);
  const [err, setErr] = useStateQA('');
  // Magic-link backup auth state — separate so it doesn't fight the
  // password form's busy/error state.
  const [magicMode, setMagicMode] = useStateQA(false);
  const [magicEmail, setMagicEmail] = useStateQA('');
  const [magicSent, setMagicSent] = useStateQA(false);
  const [magicBusy, setMagicBusy] = useStateQA(false);

  const submit = async (e) => {
    e.preventDefault();
    setErr('');
    if (!pwd) { setErr('Enter the password.'); return; }
    setBusy(true);
    try {
      const r = await fetch('/api/quotes/admin', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'login', password: pwd }),
        credentials: 'same-origin',
      });
      const j = await r.json().catch(() => ({}));
      if (r.ok && j.ok) {
        onSignedIn();
      } else {
        setErr('Wrong password.');
      }
    } catch (_) {
      setErr('Couldn’t reach the server. Try again.');
    } finally {
      setBusy(false);
    }
  };

  const sendMagic = async (e) => {
    e.preventDefault();
    if (!magicEmail) return;
    setMagicBusy(true);
    try {
      // Server always returns ok regardless of allowlist match (no enumeration).
      await fetch('/api/quotes/admin', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'magic_request', email: magicEmail.trim() }),
        credentials: 'same-origin',
      });
    } catch (_) { /* show success anyway — link only arrives if email is allowlisted */ }
    setMagicSent(true);
    setMagicBusy(false);
  };

  return (
    <section className="quotes-login-wrap">
      <div className="quotes-login-card">
        <div className="quotes-login-eyebrow">VALLEY FILMS · QUOTES</div>
        <h1 className="quotes-login-title">Operator sign-in</h1>
        {!magicMode && (
          <React.Fragment>
            <p className="quotes-login-sub">Enter the shared password to build and edit quotes.</p>
            <form onSubmit={submit} className="quotes-login-form">
              <input
                type="password"
                placeholder="Password"
                value={pwd}
                onChange={(e) => setPwd(e.target.value)}
                disabled={busy}
                autoFocus
                className="quotes-login-input"
              />
              {err && <div className="quotes-login-err">{err}</div>}
              <button type="submit" disabled={busy || !pwd} className="quotes-login-btn">
                {busy ? 'Signing in…' : 'Sign in'}
              </button>
            </form>
            <div className="quotes-login-alt">
              <button type="button" className="quotes-login-link" onClick={() => setMagicMode(true)}>
                Forgot the password? Email a sign-in link →
              </button>
            </div>
          </React.Fragment>
        )}
        {magicMode && (
          <React.Fragment>
            {!magicSent && (
              <React.Fragment>
                <p className="quotes-login-sub">Enter an allowlisted operator email — we'll send a one-time link (valid 15 min).</p>
                <form onSubmit={sendMagic} className="quotes-login-form">
                  <input
                    type="email"
                    placeholder="you@valley.film"
                    value={magicEmail}
                    onChange={(e) => setMagicEmail(e.target.value)}
                    disabled={magicBusy}
                    autoFocus
                    className="quotes-login-input"
                  />
                  <button type="submit" disabled={magicBusy || !magicEmail} className="quotes-login-btn">
                    {magicBusy ? 'Sending…' : 'Send sign-in link'}
                  </button>
                </form>
                <div className="quotes-login-alt">
                  <button type="button" className="quotes-login-link" onClick={() => setMagicMode(false)}>
                    ← Back to password
                  </button>
                </div>
              </React.Fragment>
            )}
            {magicSent && (
              <React.Fragment>
                <p className="quotes-login-sub">
                  If <strong>{magicEmail}</strong> is allowlisted, a sign-in link is on its way.
                  Check your inbox — click the link to land back on the picker.
                </p>
                <div className="quotes-login-alt">
                  <button type="button" className="quotes-login-link" onClick={() => { setMagicSent(false); setMagicMode(false); }}>
                    ← Back to password
                  </button>
                </div>
              </React.Fragment>
            )}
          </React.Fragment>
        )}
      </div>
    </section>);
}

// ───────────────────────────────────────────────────────────────────
function QuotesDashboard({ onSignOut }) {
  const [productions, setProductions] = useStateQA(null);
  const [err, setErr] = useStateQA('');
  const [monitor, setMonitor] = useStateQA(null); // {heartbeat, serverTime}

  const load = async () => {
    setErr('');
    try {
      const r = await fetch('/api/quotes/admin?action=list_productions', { credentials: 'same-origin' });
      if (!r.ok) {
        if (r.status === 401) { onSignOut(); return; }
        throw new Error(`HTTP ${r.status}`);
      }
      const j = await r.json();
      setProductions(j.productions || []);
    } catch (e) {
      setErr(String(e?.message || e));
    }
  };

  const loadMonitor = async () => {
    try {
      const r = await fetch('/api/quotes/admin?action=monitor_status', { credentials: 'same-origin' });
      if (r.ok) setMonitor(await r.json());
    } catch (_) { /* leave stale */ }
  };

  useEffectQA(() => { load(); loadMonitor(); }, []);
  // Re-check the heartbeat every 60s so a fresh "offline" state appears
  // without the operator having to hit Refresh.
  useEffectQA(() => {
    const t = setInterval(loadMonitor, 60_000);
    return () => clearInterval(t);
  }, []);

  // No filter toolbar anymore — every loaded production goes straight
  // to the grouped picker. Kept the `filtered` variable name so the
  // empty/loading branches below don't need rewording.
  const filtered = productions;

  return (
    <React.Fragment>
      {/* Standard subhero block — matches /contact, /about, /rentals/about,
          /crew/onboard etc. so the operator pages don't feel like a
          different product. General rule going forward: every new page on
          valley.film, internal or external, uses this structure (eyebrow
          with VF—n.n index + italic-blue emphasis in the H1 + .lead). */}
      <section className="subhero">
        <div className="container">
          <div className="eyebrow"><span className="idx">VF—0.1</span> Operator quotes</div>
          <h1>Build, edit, and ship a <em>quote</em>.</h1>
          <p className="lead">Each production below mirrors a row in Notion. Click into one to edit prices, packages and add-ons, then send it off — or just mark it as already sent if you handled it elsewhere.</p>
        </div>
      </section>

      <section className="quotes-dash-wrap">
        <div className="container">
          <MonitorBanner monitor={monitor} />
          {err && <div className="quotes-dash-err">Couldn’t load productions: {err}</div>}

          {filtered === null && (
            <div className="quotes-dash-loading">Loading…</div>
          )}

          {filtered && filtered.length === 0 && (
            <div className="quotes-dash-empty">No matching productions.</div>
          )}

          {filtered && filtered.length > 0 && (
            <GroupedProductionList productions={filtered} onChanged={load} />
          )}

          {/* Discrete sign-out link at the bottom of the page — the
              toolbar Refresh/Sign-out buttons were too noisy on a card
              picker that already auto-refreshes on every Mark Sent.
              Hard refresh (F5/⌘R) covers the explicit refresh case. */}
          <footer className="quotes-dash-footer">
            <button type="button" onClick={onSignOut} className="quotes-dash-footer-signout">Sign out</button>
          </footer>
        </div>
      </section>
    </React.Fragment>);
}

// ───────────────────────────────────────────────────────────────────
// Picker grouping. Display labels are UI-only — the underlying Notion
// option names (Quotation Sent, Revision Generated, etc.) are unchanged
// so the monitor's republish poll and the Approve & Send PATCH keep
// working. Buckets:
//   To Quote   → not yet generated; trigger Generate
//   Generated  → has a quote draft to edit / regenerate
//   Sent       → quote out with the client, no client-side opens yet
//   Viewed     → Quotation Sent + the Last Viewed At property is
//                populated (client has opened the link at least once)
//   Signed     → signed within the last 48h (older ones drop off — see
//                listProductions in api/quotes/admin.js)
const PICKER_GROUPS = [
  { id: 'to-quote',  label: 'To Quote',  test: (p) => ['To Quote', 'Requested'].includes(p.financialStatus) },
  { id: 'generated', label: 'Generated', test: (p) => ['Generated', 'Revision Generated', 'Revision Requested', 'Pencil', 'Processing', 'Generation Failed'].includes(p.financialStatus) },
  { id: 'sent',      label: 'Sent',      test: (p) => p.financialStatus === 'Quotation Sent' && !p.lastViewedAt },
  { id: 'viewed',    label: 'Viewed',    test: (p) => p.financialStatus === 'Quotation Sent' &&  p.lastViewedAt },
  { id: 'signed',    label: 'Signed',    test: (p) => p.financialStatus === 'Signed' },
];

// Productions are sorted by next shooting date (closest first, then
// productions without a date, then anything still further out). Same
// order is applied to every bucket so the operator always sees the
// nearest work at the top of each section.
function sortByShootDateAsc(a, b) {
  const ta = a.productionDate ? Date.parse(a.productionDate) : Number.POSITIVE_INFINITY;
  const tb = b.productionDate ? Date.parse(b.productionDate) : Number.POSITIVE_INFINITY;
  if (ta === tb) return (a.title || '').localeCompare(b.title || '');
  return ta - tb;
}

// Anything in the To Quote bucket whose shoot date is more than this far
// out gets hidden behind a "View more" toggle — the operator's pencilled
// it in but doesn't need a quote yet. Anything without a known shoot
// date falls into the "far" pile too so it doesn't crowd the top.
const TO_QUOTE_NEAR_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;

function GroupedProductionList({ productions, onChanged }) {
  // Sort the whole input first so every bucket inherits the same ordering.
  const sorted = [...productions].sort(sortByShootDateAsc);

  // Assign each production to the FIRST bucket that accepts it. Anything
  // unbucketed lands under "Other" so it doesn't silently disappear.
  const bucketed = PICKER_GROUPS.map((g) => ({ ...g, items: [] }));
  const other = [];
  for (const p of sorted) {
    const idx = bucketed.findIndex((g) => g.test(p));
    if (idx >= 0) bucketed[idx].items.push(p);
    else other.push(p);
  }
  const groups = bucketed.concat(other.length ? [{ id: 'other', label: 'Other', items: other }] : []);

  return (
    <div className="quotes-dash-groups">
      {groups.map((g) => (
        <BucketSection
          key={g.id}
          group={g}
          onChanged={onChanged}
        />
      ))}
    </div>
  );
}

function BucketSection({ group, onChanged }) {
  // Only the To Quote bucket splits into near/far — everything else
  // just renders flat.
  if (group.id !== 'to-quote') {
    return (
      <section className={`quotes-dash-group quotes-dash-group-${group.id}`}>
        <header className="quotes-dash-group-head">
          <span className="quotes-dash-group-label">{group.label}</span>
          <span className="quotes-dash-group-count">{group.items.length}</span>
        </header>
        {group.items.length > 0 ? (
          <ul className="quotes-dash-list">
            {group.items.map((p) => <ProductionRow key={p.id} p={p} onChanged={onChanged} />)}
          </ul>
        ) : (
          <div className="quotes-dash-group-empty">No productions in this stage.</div>
        )}
      </section>);
  }

  // To Quote — split into near (≤ 31 days from now) and far (> 31 days
  // or no date set). "Far" items hide behind an expand button. The
  // expanded state is local to this section so collapsing one bucket
  // doesn't collapse another.
  const now = Date.now();
  const near = [];
  const far  = [];
  for (const p of group.items) {
    const t = p.productionDate ? Date.parse(p.productionDate) : NaN;
    const within = Number.isFinite(t) && (t - now) <= TO_QUOTE_NEAR_WINDOW_MS;
    (within ? near : far).push(p);
  }

  const [showFar, setShowFar] = useStateQA(false);

  return (
    <section className={`quotes-dash-group quotes-dash-group-${group.id}`}>
      <header className="quotes-dash-group-head">
        <span className="quotes-dash-group-label">{group.label}</span>
        <span className="quotes-dash-group-count">{group.items.length}</span>
      </header>
      {group.items.length === 0 ? (
        <div className="quotes-dash-group-empty">No productions in this stage.</div>
      ) : (
        <React.Fragment>
          {near.length > 0 ? (
            <ul className="quotes-dash-list">
              {near.map((p) => <ProductionRow key={p.id} p={p} onChanged={onChanged} />)}
            </ul>
          ) : (
            <div className="quotes-dash-group-empty">Nothing in the next month — anything further out is below.</div>
          )}
          {far.length > 0 && (
            <div className={`quotes-dash-bucket-more${showFar ? ' is-open' : ''}`}>
              <button
                type="button"
                className="quotes-dash-bucket-more-toggle"
                onClick={() => setShowFar((v) => !v)}
                aria-expanded={showFar}
              >
                <span className="quotes-dash-bucket-more-label">
                  View {showFar ? 'less' : 'more'}
                  <svg className="quotes-dash-bucket-more-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
                    <path d="M3 4.5 L6 7.5 L9 4.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
                  </svg>
                </span>
                {!showFar && (
                  <span className="quotes-dash-bucket-more-hint">
                    {far.map((p) => p.title || '(untitled)').join(' · ')}
                  </span>
                )}
              </button>
              {showFar && (
                <ul className="quotes-dash-list quotes-dash-list-far">
                  {far.map((p) => <ProductionRow key={p.id} p={p} onChanged={onChanged} />)}
                </ul>
              )}
            </div>
          )}
        </React.Fragment>
      )}
    </section>);
}

// ───────────────────────────────────────────────────────────────────
function ProductionRow({ p, onChanged }) {
  // local state: idle | requesting | polling | ready | error
  const [state, setState] = useStateQA(p.quoteToken ? 'ready' : 'idle');
  const [token, setToken] = useStateQA(p.quoteToken || '');
  const [err, setErr] = useStateQA('');
  // Mark-as-Sent button state: idle | confirming | working | done | error
  const [sendState, setSendState] = useStateQA('idle');
  const [sendErr,   setSendErr]   = useStateQA('');
  // Refresh-from-Notion button state: idle | working | done | error
  const [refreshState, setRefreshState] = useStateQA('idle');
  const pollRef = useRefQA(null);

  useEffectQA(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);

  const startPolling = (pageId) => {
    // Poll the Notion page every 4s for a populated Quote Token. The
    // LaunchAgent monitor picks up Requested status within ~30s, then
    // generates + uploads + writes the token back, usually within 30–90s
    // total. We cap polling at ~5 minutes.
    const startedAt = Date.now();
    setState('polling');
    pollRef.current = setInterval(async () => {
      try {
        const r = await fetch(`/api/quotes/admin?action=token_for&pageId=${encodeURIComponent(pageId)}`,
                              { credentials: 'same-origin' });
        const j = await r.json().catch(() => ({}));
        if (j.token) {
          clearInterval(pollRef.current);
          pollRef.current = null;
          setToken(j.token);
          setState('ready');
          return;
        }
        if (Date.now() - startedAt > 5 * 60 * 1000) {
          clearInterval(pollRef.current);
          pollRef.current = null;
          setState('error');
          setErr('Still generating after 5 minutes — check the monitor.');
        }
      } catch (e) {
        // transient — keep polling
      }
    }, 4000);
  };

  const onGenerate = async () => {
    setErr('');
    setState('requesting');
    try {
      const r = await fetch('/api/quotes/admin', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'trigger', pageId: p.id }),
        credentials: 'same-origin',
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
      startPolling(p.id);
    } catch (e) {
      setState('error');
      setErr(String(e?.message || e));
    }
  };

  // "Refresh from Notion" — re-pulls the latest Notion data (location,
  // deliverables, brief paste, etc.) and regenerates the quote HTML.
  // Doesn't touch editor_state, so any in-quote operator edits stick.
  // Behind the scenes: PATCH Notion's Financial Status to 'Requested';
  // the monitor's LaunchAgent picks that up within ~30s and runs the
  // Python regen. Polls the KV-meta envelope's uploadedAt timestamp
  // (via the existing token_for endpoint's freshness) until it ticks.
  const onRefresh = async () => {
    if (refreshState === 'working') return;
    setRefreshState('working');
    const startedAt = Date.now();
    const baselineEdited = p.lastEdited || '';
    try {
      const r = await fetch('/api/quotes/admin', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ action: 'trigger', pageId: p.id }),
        credentials: 'same-origin',
      });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      // Poll the Notion page until last_edited_time advances OR 90s elapses.
      const tick = async () => {
        try {
          const r2 = await fetch(
            '/api/quotes/admin?action=token_for&pageId=' + encodeURIComponent(p.id),
            { credentials: 'same-origin' },
          );
          if (r2.ok) {
            const j = await r2.json();
            // token_for returns financialStatus too — when it flips back to
            // Generated/Revision Generated we know the regen ran.
            if (j.financialStatus === 'Generated' || j.financialStatus === 'Revision Generated') {
              setRefreshState('done');
              setTimeout(() => {
                setRefreshState('idle');
                if (onChanged) onChanged();
              }, 1200);
              return;
            }
          }
        } catch (_) { /* keep polling */ }
        if (Date.now() - startedAt > 90 * 1000) {
          setRefreshState('error');
          setTimeout(() => setRefreshState('idle'), 4000);
          return;
        }
        setTimeout(tick, 3500);
      };
      setTimeout(tick, 5000);   // give the monitor a head start before polling
    } catch (e) {
      setRefreshState('error');
      setTimeout(() => setRefreshState('idle'), 4000);
    }
  };

  // "Mark as Sent" — flips Notion's Financial Status to "Quotation Sent"
  // without going through the in-editor Approve & Send (no email send,
  // no client preview). For when you've sent the quote to the client via
  // some other channel and want the portal + monitor's republish loop
  // to catch up. Two-step click so the operator doesn't fat-finger it.
  const onMarkSent = async () => {
    if (sendState === 'idle') {
      setSendState('confirming');
      // Auto-revert after 5s if they don't follow through — keeps the
      // card from sticking in "are you sure?" mode if they walked away.
      setTimeout(() => {
        setSendState((s) => (s === 'confirming' ? 'idle' : s));
      }, 5000);
      return;
    }
    if (sendState === 'confirming') {
      setSendErr('');
      setSendState('working');
      try {
        const r = await fetch('/api/quotes/admin', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ action: 'mark_sent', pageId: p.id }),
          credentials: 'same-origin',
        });
        const j = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
        setSendState('done');
        // Refresh the picker so the card moves to the Sent bucket. Small
        // delay so the operator sees the success state for a beat.
        setTimeout(() => { if (onChanged) onChanged(); }, 600);
      } catch (e) {
        setSendState('error');
        setSendErr(String(e?.message || e));
        setTimeout(() => setSendState('idle'), 4000);
      }
    }
  };

  const fmtDate = (iso) => {
    if (!iso) return '';
    try {
      const d = new Date(iso);
      return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
    } catch { return ''; }
  };

  // Use the matching client-facing label for the chip — operator-side
  // workflow strings ('Generated', 'Quotation Sent') aren't the right
  // thing to show inline on a card. Keeps the underlying value if no
  // mapping found (e.g. 'Other' bucket).
  const STATUS_DISPLAY = {
    'To Quote':            'To Quote',
    'Requested':           'Pending',
    'Processing':          'Generating',
    'Generated':           'Generated',
    'Revision Generated':  'Revision ready',
    'Revision Requested':  'Revision pending',
    'Generation Failed':   'Failed',
    'Pencil':              'Pencil',
    'Quotation Sent':      'Sent',
    'Signed':              'Signed',
  };
  // Override the Sent label when the client has opened the link — keeps
  // the picker's status pill in step with the Viewed bucket they're now
  // sitting in.
  const displayedStatus = (p.financialStatus === 'Quotation Sent' && p.lastViewedAt)
    ? 'Viewed'
    : (STATUS_DISPLAY[p.financialStatus] || p.financialStatus);
  // Compact "X ago" formatter — only used on Viewed cards' meta row so
  // the operator can see at a glance whether the client opened it 5
  // minutes ago or yesterday.
  const fmtAgo = (iso) => {
    if (!iso) return '';
    const t = Date.parse(iso);
    if (!Number.isFinite(t)) return '';
    const m = Math.max(0, Math.round((Date.now() - t) / 60000));
    if (m < 1)      return 'just now';
    if (m < 60)     return `${m}m ago`;
    const h = Math.round(m / 60);
    if (h < 24)     return `${h}h ago`;
    const d = Math.round(h / 24);
    return d === 1 ? 'yesterday' : `${d}d ago`;
  };

  return (
    <li className={`quotes-card quotes-card-${(p.financialStatus || '').replace(/\s+/g, '-').toLowerCase()}`}>
      <div className="quotes-card-cover" aria-hidden="true">
        {p.coverUrl ? (
          <img src={p.coverUrl} alt="" loading="lazy" />
        ) : (
          // Blue gradient placeholder — picks up the Valley Films blue
          // palette so a coverless production still feels on-brand.
          <div className="quotes-card-cover-placeholder" />
        )}
      </div>

      <div className="quotes-card-main">
        <div className="quotes-card-head">
          <h3 className="quotes-card-title">{p.title}</h3>
          {p.financialStatus && (
            <StatusPill
              status={p.financialStatus}
              viewedAt={p.lastViewedAt}
              label={displayedStatus}
              sendState={sendState}
              sendErr={sendErr}
              onMarkSent={onMarkSent}
            />
          )}
        </div>
        <div className="quotes-card-meta">
          {p.quoteRef && <span className="quotes-card-ref">{p.quoteRef}</span>}
          {p.jobType && <span className="quotes-card-tag">{p.jobType}</span>}
          {p.productionDate && <span className="quotes-card-date">{fmtDate(p.productionDate)}</span>}
          {/* Client has opened the link — show how long ago so the
              operator can tell a fresh open from a months-old one. */}
          {p.lastViewedAt && p.financialStatus === 'Quotation Sent' && (
            <span className="quotes-card-viewedago" title={`First opened: ${new Date(p.lastViewedAt).toLocaleString('en-GB')}`}>
              Viewed {fmtAgo(p.lastViewedAt)}
            </span>
          )}
        </div>

        <div className="quotes-card-actions">
          {state === 'idle' && (
            <button type="button" onClick={onGenerate} className="quotes-row-btn quotes-row-btn-primary">
              <IconSpark/> Generate quote
            </button>
          )}
          {state === 'requesting' && (
            <span className="quotes-row-status"><IconSpinner/> Requesting…</span>
          )}
          {state === 'polling' && (
            <span className="quotes-row-status"><IconSpinner/> Generating… (up to ~1 min)</span>
          )}
          {state === 'ready' && token && (
            <React.Fragment>
              {/* Edit — disabled once the client has signed; the deal is
                  locked and edits would silently invalidate the signed
                  document the client is referring back to. Operator can
                  still view the confirmation; if a genuine revision is
                  needed they flip Notion's Financial Status back to
                  Revision Requested first. */}
              {p.financialStatus === 'Signed' ? (
                <span className="quotes-row-btn quotes-row-btn-primary is-disabled"
                      aria-disabled="true"
                      title="Quote is signed — editing is locked. Move it back to a draft state in Notion if you need to revise.">
                  <IconPencil/> Edit
                </span>
              ) : (
                <a href={`/quotes/${token}/edit`} target="_blank" rel="noopener"
                   className="quotes-row-btn quotes-row-btn-primary"
                   title="Open the operator editor — Approve & Send, autosave, etc.">
                  <IconPencil/> Edit
                </a>
              )}
              {/* View — opens the bare /quotes/<token>. For Signed
                  productions the same URL is the confirmation view (no
                  editor chrome, signed status pill), so the label flips
                  to make the intent obvious. */}
              <a href={`/quotes/${token}`} target="_blank" rel="noopener"
                 className="quotes-row-btn quotes-row-btn-ghost"
                 title={p.financialStatus === 'Signed'
                   ? 'Open the signed confirmation view.'
                   : 'Exactly what the client sees — no editor JS or chrome.'}>
                <IconEye/> {p.financialStatus === 'Signed' ? 'View confirmation' : 'View'}
              </a>
              {/* Refresh from Notion — re-pulls location, deliverables,
                  brief paste etc. and regenerates the quote HTML.
                  Editor-state edits (line items, package edits, etc.)
                  are preserved. Disabled in two cases:
                    • Closed states (Signed / Invoiced / Paid / Cancelled /
                      N/A) — the underlying data shouldn't change.
                    • Viewed — the client has already opened the link and
                      may still have it open; refreshing while they're
                      reading would change content mid-view.
                  Use Republish (or just wait for them to refresh) if you
                  need to push updates after they've viewed. */}
              {(() => {
                const isClosed = ['Signed', 'Invoiced', 'Paid', 'Cancelled', 'N/A'].includes(p.financialStatus);
                const isViewed = p.financialStatus === 'Quotation Sent' && p.lastViewedAt;
                if (isClosed) return null;
                if (isViewed) {
                  return (
                    <span className="quotes-row-btn quotes-row-btn-ghost is-disabled"
                          aria-disabled="true"
                          title="Client has already opened this link — refreshing would change content while they may still be reading. Re-send (or move to Revision Requested in Notion) to push updates.">
                      <IconCloudDown/> Refresh from Notion
                    </span>);
                }
                return <RefreshFromNotionButton state={refreshState} onClick={onRefresh} />;
              })()}
              {/* Mark Sent lives on the status pill itself now — see
                  StatusPill below. Only renders the hover affordance
                  when the underlying status is Generated / Revision
                  Generated; other statuses get a non-interactive pill. */}
              <HistoryButton pageId={p.id} />
            </React.Fragment>
          )}
          {state === 'error' && (
            <React.Fragment>
              <span className="quotes-row-err"><IconWarning/> {err || 'Error'}</span>
              <button type="button" onClick={onGenerate} className="quotes-row-btn quotes-row-btn-ghost">
                <IconRefresh/> Retry
              </button>
            </React.Fragment>
          )}
        </div>
      </div>
    </li>);
}

// ───────────────────────────────────────────────────────────────────
// Icon set — monochrome SVGs that inherit currentColor. Used on every
// action button so the picker reads at a glance.
const _svg = (children) => (
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
       strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
       className="quotes-btn-icon">{children}</svg>
);
const IconPencil   = () => _svg(<><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></>);
const IconEye      = () => _svg(<><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></>);
const IconRefresh  = () => _svg(<><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/></>);
const IconUpload   = () => _svg(<><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></>);
const IconClock    = () => _svg(<><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></>);
const IconSpark    = () => _svg(<><path d="M12 2v6"/><path d="M12 16v6"/><path d="m4.93 4.93 4.24 4.24"/><path d="m14.83 14.83 4.24 4.24"/><path d="M2 12h6"/><path d="M16 12h6"/><path d="m4.93 19.07 4.24-4.24"/><path d="m14.83 9.17 4.24-4.24"/></>);
const IconWarning  = () => _svg(<><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" y1="9"  x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></>);
const IconSend     = () => _svg(<><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></>);
const IconCheck    = () => _svg(<><polyline points="20 6 9 17 4 12"/></>);
// Cloud-with-down-arrow — used by the "Refresh from Notion" button to
// communicate "pull new data from elsewhere" without implying a regen
// from scratch (which the IconRefresh's circular-arrow visually does).
const IconCloudDown = () => _svg(<><path d="M20 16.2A4.5 4.5 0 0 0 17.5 7h-1.8A7 7 0 1 0 4 14.9"/><path d="M12 12v9"/><path d="M16 17l-4 4-4-4"/></>);
// Spinner: drawn into the icon container with a CSS animation that spins
// the whole SVG. Class on the wrapper instead of the path so the rotation
// transform-origin lines up correctly.
const IconSpinner = () => (
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
       strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
       className="quotes-btn-icon quotes-btn-icon-spinning">
    <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
  </svg>
);

Object.assign(window, { IconPencil, IconEye, IconRefresh, IconUpload, IconClock, IconSpark, IconWarning, IconSpinner, IconSend, IconCheck, IconCloudDown });

// ───────────────────────────────────────────────────────────────────
// Refresh-from-Notion button — re-pulls the latest Notion data (new
// location, updated brief, edited deliverables) and regenerates the
// quote HTML. Used after the operator edits Notion fields they want
// reflected on the quote. Different visual to Mark Sent / Edit so the
// operator can scan their options at a glance.
function RefreshFromNotionButton({ state, onClick }) {
  const content = {
    idle:    <React.Fragment><IconCloudDown/> Refresh from Notion</React.Fragment>,
    working: <React.Fragment><IconSpinner/> Refreshing… (~30–60s)</React.Fragment>,
    done:    <React.Fragment><IconCheck/> Refreshed</React.Fragment>,
    error:   <React.Fragment><IconWarning/> Refresh failed</React.Fragment>,
  };
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={state === 'working'}
      className="quotes-row-btn quotes-row-btn-ghost"
      title="Pull the latest Notion data (location, deliverables, brief) and regenerate the quote. Editor-state edits (line items, prices) are preserved."
    >
      {content[state]}
    </button>);
}

// ───────────────────────────────────────────────────────────────────
// Status pill — top-right of every card. Most statuses render a plain
// non-interactive pill. For the Generated bucket (Generated / Revision
// Generated) the pill becomes a hover-revealed "Mark Sent" trigger so
// the operator can flip the production into the Sent bucket without
// going through the in-editor Approve & Send flow.
//
// State (sendState) is owned by the parent ProductionRow so a list
// refresh doesn't unmount the pill mid-animation. The two-step click
// (idle → confirming → working) lives on `onMarkSent` upstream.
const MARK_SENT_ELIGIBLE = new Set(['Generated', 'Revision Generated']);

function StatusPill({ status, viewedAt, label, sendState, sendErr, onMarkSent }) {
  // Base class from the underlying Notion status; layer on `quotes-fs-viewed`
  // when Quotation Sent + Last Viewed At is set so the pill colour shifts
  // from blue → purple without inventing a new status name.
  let cls = `quotes-card-status quotes-fs-${(status || '').replace(/\s+/g, '-').toLowerCase()}`;
  if (status === 'Quotation Sent' && viewedAt) cls += ' quotes-fs-viewed';
  const eligible = MARK_SENT_ELIGIBLE.has(status);

  // Non-eligible statuses → plain text pill, no hover affordance.
  if (!eligible) {
    return <span className={cls}>{label}</span>;
  }

  // Eligible — render as a button so the hover/confirm states are
  // semantically correct and keyboard-accessible.
  if (sendState === 'confirming') {
    return (
      <button
        type="button"
        onClick={onMarkSent}
        className={`${cls} quotes-card-status-confirm`}
        title="Click again to flip Notion's Financial Status to Quotation Sent."
        aria-label="Confirm mark as Sent"
      >
        Confirm: mark Sent?
      </button>);
  }
  if (sendState === 'working') {
    return (
      <span className={`${cls} quotes-card-status-working`} aria-live="polite">
        <IconSpinner/> Marking…
      </span>);
  }
  if (sendState === 'done') {
    return (
      <span className={`${cls} quotes-card-status-done`} aria-live="polite">
        <IconCheck/> Marked Sent
      </span>);
  }
  if (sendState === 'error') {
    return (
      <span
        className={`${cls} quotes-card-status-error`}
        title={sendErr || 'Mark Sent failed'}
      >
        <IconWarning/> Try again
      </span>);
  }

  // idle — hover replaces the label text with "Mark Sent →" via CSS.
  return (
    <button
      type="button"
      onClick={onMarkSent}
      className={`${cls} quotes-card-status-actionable`}
      title="Hover to mark this quote as Sent in Notion."
      aria-label={`${label} — click to mark as Sent`}
    >
      <span className="quotes-card-status-text-default">{label}</span>
      <span className="quotes-card-status-text-hover" aria-hidden="true">Mark Sent →</span>
    </button>);
}

// ───────────────────────────────────────────────────────────────────
// Monitor health banner — surfaces "⚠ Monitor offline" when the heartbeat
// from the Mac-side monitor goes stale (Mac asleep, network outage,
// process crashed, etc.). Silent when everything is healthy.
function MonitorBanner({ monitor }) {
  if (!monitor) return null; // still loading or call failed
  const hb = monitor.heartbeat;
  const now = monitor.serverTime || Date.now();
  if (!hb || !hb.receivedAt) {
    // No heartbeat ever — fresh setup or KV cleared. Worth flagging.
    return (
      <div className="quotes-monitor-banner quotes-monitor-banner--warn">
        <strong>Monitor status unknown.</strong>
        <span> No heartbeat received yet. Generate or Republish actions may not run automatically until the monitor checks in.</span>
      </div>);
  }
  const ageMs = Math.max(0, now - hb.receivedAt);
  const ageMin = Math.floor(ageMs / 60_000);
  // Monitor polls every 30s + sends heartbeat at end of each poll. So a
  // healthy heartbeat is < 2 min old. Warn at 5+ min, alarm at 15+ min.
  if (ageMin < 5) return null;
  const level = ageMin >= 15 ? 'alarm' : 'warn';
  return (
    <div className={'quotes-monitor-banner quotes-monitor-banner--' + level}>
      <strong>⚠ Monitor offline</strong>
      <span> — last heartbeat {ageMin} min ago{hb.host ? ` (${hb.host})` : ''}. </span>
      <span>Generate / Republish won't run automatically until it's back. </span>
      <span>Check the Mac that hosts the monitor (is it asleep, awake but offline?).</span>
    </div>);
}

// ───────────────────────────────────────────────────────────────────
// Editor-state history button — opens a dropdown of recent autosave
// snapshots, lets the operator restore one. Designed for "I think I
// accidentally wiped my customisations" recovery, not for active
// rollback during editing.
function HistoryButton({ pageId }) {
  const [open, setOpen] = useStateQA(false);
  const [snaps, setSnaps] = useStateQA(null); // null = unloaded
  const [busy, setBusy] = useStateQA('');     // savedAt of restoring entry

  const load = async () => {
    try {
      const r = await fetch('/api/quotes/history?pageId=' + encodeURIComponent(pageId));
      if (!r.ok) { setSnaps([]); return; }
      const j = await r.json();
      setSnaps(j.snapshots || []);
    } catch (_) { setSnaps([]); }
  };

  const toggle = () => {
    if (!open) { load(); }
    setOpen(!open);
  };

  const restore = async (snap) => {
    if (!snap || !snap.state) return;
    const when = new Date(snap.savedAt).toLocaleString('en-GB');
    if (!confirm(`Restore the editor state from ${when}? This overwrites the current state in Notion. The quote will then regenerate.`)) return;
    setBusy(String(snap.savedAt));
    try {
      // 1. Save the snapshot's state back into Notion via the existing
      // vf-quotes webhook (same path the in-editor autosave uses).
      const sr = await fetch('https://vf-quotes.vercel.app/api/webhook', {
        method:  'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({
          action:       'save_draft',
          page_id:      pageId,
          editor_state: snap.state,
        }),
      });
      if (!sr.ok) throw new Error('save_draft failed: HTTP ' + sr.status);
      // 2. Trigger a regen so the live URL reflects the restored state.
      await fetch('/api/quotes/admin', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({ action: 'trigger', pageId }),
        credentials: 'same-origin',
      });
      setOpen(false);
      alert('Snapshot restored. Regeneration started — refresh in ~60 s to see the change.');
    } catch (e) {
      alert('Restore failed: ' + (e?.message || e));
    } finally {
      setBusy('');
    }
  };

  return (
    <div className="quotes-history-wrap">
      <button
        type="button"
        onClick={toggle}
        className="quotes-row-btn quotes-row-btn-ghost"
        title="See and restore previous editor-state snapshots."
        aria-expanded={open}
      >
        <IconClock/> History
      </button>
      {open && (
        <div className="quotes-history-dropdown">
          {snaps === null && <div className="quotes-history-empty">Loading…</div>}
          {snaps && snaps.length === 0 && <div className="quotes-history-empty">No snapshots yet — they appear after editor autosaves.</div>}
          {snaps && snaps.length > 0 && snaps.map((s) => {
            const when = new Date(s.savedAt).toLocaleString('en-GB', {
              day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit',
            });
            return (
              <button
                key={s.savedAt}
                type="button"
                onClick={() => restore(s)}
                className="quotes-history-row"
                disabled={busy === String(s.savedAt)}
              >
                <span className="quotes-history-when">{when}</span>
                <span className="quotes-history-summary">{s.summary || '—'}</span>
                <span className="quotes-history-action">{busy === String(s.savedAt) ? 'Restoring…' : '↶ Restore'}</span>
              </button>);
          })}
        </div>
      )}
    </div>);
}

// ───────────────────────────────────────────────────────────────────
// Republish button — instant draft-marker flip on the live KV HTML.
// Useful as a manual fallback when the post-Send auto-republish missed
// (rare, but the bug exists if the operator manually regenerates after
// Approve & Send and then sends a stale URL). Click → POST to
// /api/quotes/publish?token=… → live URL becomes client-safe in <1s.
function RepublishButton({ token }) {
  const [state, setState] = useStateQA('idle'); // idle | working | done | error
  const onClick = async () => {
    setState('working');
    try {
      const r = await fetch('/api/quotes/publish?token=' + encodeURIComponent(token), { method: 'POST' });
      const j = await r.json().catch(() => ({}));
      if (r.ok) {
        setState('done');
        setTimeout(() => setState('idle'), 2500);
      } else {
        setState('error');
        setTimeout(() => setState('idle'), 4000);
      }
    } catch (_) {
      setState('error');
      setTimeout(() => setState('idle'), 4000);
    }
  };
  const content = {
    idle:    <React.Fragment><IconUpload/> Republish</React.Fragment>,
    working: <React.Fragment><IconSpinner/> Working…</React.Fragment>,
    done:    <React.Fragment><IconUpload/> Republished</React.Fragment>,
    error:   <React.Fragment><IconWarning/> Try again</React.Fragment>,
  };
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={state === 'working'}
      className="quotes-row-btn quotes-row-btn-ghost"
      title="Re-strip operator chrome from the live URL. Use if the client opened the quote and saw editor tools."
    >
      {content[state]}
    </button>);
}
