// ─────────────────────────────────────────────────────────────
// Fact-Find main app
// ─────────────────────────────────────────────────────────────

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

const SECTIONS = [
{ id: 'personal', label: 'Personal', num: '01' },
{ id: 'assets', label: 'Assets & Liabilities', num: '02' },
{ id: 'income', label: 'Income & Cashflow', num: '03' },
{ id: 'insurance', label: 'Insurance & Health', num: '04' },
{ id: 'goals', label: 'Risk & Goals', num: '05' },
{ id: 'documents', label: 'Document Upload', num: '06' }];


const STORAGE_KEY = 'tw_factfind_v5';

function computeProgress(data) {
  // Two-tier progress:
  //   1. A rough field-counting heuristic drives the BAR fill (visual
  //      "you're getting there" feedback while typing).
  //   2. The actual validator is the source of truth for the TICK -
  //      a section is "complete" iff validateSection returns no errors.
  //
  // Without (2) the heuristic drifts away from the validator every time
  // we relax or tighten a required field, and the user ends up either
  // (a) passing the gate but seeing no tick, or (b) seeing a tick but
  // still being told they've missed something. Tying completeness to
  // the validator removes both failure modes.
  const validator = typeof window !== 'undefined' ? window.validateSection : null;
  const sectionPasses = (id) => {
    if (!validator) return false;
    try { return (validator(id, data) || []).length === 0; }
    catch { return false; }
  };

  // Roughly count how many of the listed checks are filled, but never let
  // the bar reach the 0.9 "tick" threshold unless the validator agrees.
  // Conversely, if the validator passes, force 1.0 so the tick fires.
  const score = (id, checks) => {
    if (sectionPasses(id)) return 1;
    const raw = checks.length ? checks.filter(Boolean).length / checks.length : 0;
    return Math.min(raw, 0.89);
  };

  const scores = {
    personal: (() => {
      const p = data?.personal?.primary || {};
      const checks = [p.firstName, p.lastName, p.dob, p.email, p.mobile, p.address, p.relStatus, p.empBasis, p.occupation];
      // Couple completing the partner's details here (self): count the
      // partner's core fields too, so filling them raises the completion %.
      const isCouple = ['Married', 'De facto'].includes(p.relStatus);
      if (isCouple && p.coupleAdvice === true && (data?.personal?.partnerCompletion === 'self')) {
        const pt = data?.personal?.partner || {};
        checks.push(pt.firstName, pt.lastName, pt.dob, pt.email, pt.mobile, pt.empBasis, pt.occupation);
      }
      return score('personal', checks);
    })(),
    assets: (() => {
      const a = data?.assets || {};
      // For the bar-fill heuristic count how many asset buckets the user
      // has touched; the validator decides the tick.
      const has = [a.supers?.length, a.properties?.length, a.savings?.length, a.investments?.length].filter(Boolean).length;
      // synthesise enough "filled" checks to drive a proportional bar
      const checks = [has >= 1, has >= 2, has >= 3, has >= 4];
      return score('assets', checks);
    })(),
    income: (() => {
      const i = data?.income || {};
      const primary = data?.personal?.primary || {};
      const isCouple = ['Married','De facto'].includes(primary.relStatus);
      const partnerActive = isCouple && primary.coupleAdvice === true;
      const hasInvestmentProperty = (data?.assets?.properties || []).some(p => p && p.use === 'Investment');
      const expMode = i.expMode === 'detail' ? 'detail' : i.expMode === 'surplus' ? 'surplus' : 'quick';
      const itemKeys = ['exRent','exGroc','exUtil','exTrans','exIns','exSchool','exChild','exEnt','exHealth','exSubs','exCloth','exOther'];
      const expenseDone = expMode === 'quick'   ? Boolean(i.quickAmount   && i.quickFreq)
                       : expMode === 'surplus' ? Boolean(i.surplusAmount && i.surplusFreq)
                       :                         itemKeys.some(k => i[k] && Number(i[k]) > 0);
      const checks = [Boolean(i.baseSalary), Boolean(i.payFreq), Boolean(i.netPay), Boolean(i.sgRate), expenseDone];
      if (partnerActive)        checks.push(Boolean(i.pBase), Boolean(i.pFreq), Boolean(i.pNet), Boolean(i.pSg));
      if (hasInvestmentProperty) checks.push(Boolean(i.rental));
      return score('income', checks);
    })(),
    insurance: (() => {
      const ins = data?.insurance || {};
      // Medical history is answered once something is ticked OR any
      // question's "None of the above" is (the single master "no medical
      // history" banner is gone; `conditionsNone` is legacy records only).
      // The validator, not this heuristic, decides the green tick.
      const conditionsAnswered = (ins.conditionsList?.length || 0) > 0
        || ins.conditionsNone === true
        || Object.values(ins.catNone || {}).some(Boolean);
      const pursuitsAnswered = (ins.pursuitsList?.length || 0) > 0 || ins.pursuitsNone === true;
      // Heuristic for the bar fill only - the validator decides the tick,
      // so we no longer have to chase every conditional field here.
      const checks = [ins.smoke, conditionsAnswered, pursuitsAnswered, ins.height, ins.weight, ins.hasExisting, ins.residency, ins.travel];
      return score('insurance', checks);
    })(),
    goals: (() => {
      const g = data?.goals || {};
      const hasRisk = ['q1', 'q2', 'q3', 'q4', 'q5'].every((k) => g[k] !== undefined);
      const hasGoals = (g.selected || []).length >= 1;
      return score('goals', [hasRisk, hasRisk, hasRisk, hasGoals, hasGoals]);
    })(),
    documents: (() => {
      const d = data?.documents || {};
      const required = d.required || (window.DEFAULT_REQUIRED_DOCS || []);
      const files = d.files || {};
      // No documents requested -> nothing to upload -> section complete
      // (was 0, which wrongly dragged the overall % down).
      if (!required.length) return 1;
      const checks = required.map(id => (files[id] || []).length > 0);
      return score('documents', checks);
    })()
  };
  return scores;
}

function overallPct(scores) {
  const vals = Object.values(scores);
  return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length * 100);
}

// Stamp a validator-derived section-completion map into the data
// before it's persisted. The adviser portal computes its progress %
// from `meta.sectionStatus` (share of REQUESTED sections that pass
// the real validator) rather than re-deriving completeness from a
// server-side heuristic that drifts out of sync with the mandatory
// rules. A section is "complete" iff validateSection returns zero
// errors - exactly the same gate that lets the client submit. We
// stamp ALL six sections here; the server scopes to the requested
// ones. Runs on every create / autosave / pre-submit patch so the
// portal stays accurate for in-progress clients too.
function stampSectionStatus(data) {
  const validator = (typeof window !== 'undefined') ? window.validateSection : null;
  if (!validator) return data;
  const ids = ['personal', 'assets', 'income', 'insurance', 'goals', 'documents'];
  const sectionStatus = {};
  ids.forEach(id => {
    try { sectionStatus[id] = (validator(id, data) || []).length === 0; }
    catch { sectionStatus[id] = false; }
  });

  // Partner-scoped completion for the sections a primary can complete on
  // the partner's behalf ('self' path). The adviser portal reflects these
  // onto the partner's SEPARATE profile so it ticks when the primary has
  // done the partner's part - independent of whether the primary's own
  // half of the section is finished.
  const partnerSectionStatus = {};
  try {
    const p = (data && data.personal && data.personal.primary) || {};
    const isCouple = ['Married', 'De facto'].includes(p.relStatus) && p.coupleAdvice === true;
    if (isCouple && data?.personal?.partnerCompletion === 'self') {
      // Force the partner tab so the personal validator returns ONLY the
      // partner-panel errors.
      const pd = { ...data, personal: { ...data.personal, activeTab: 'partner' } };
      partnerSectionStatus.personal = (validator('personal', pd) || []).length === 0;
    }
    if (isCouple && data?.insurance?.partnerCompletion === 'self') {
      const insErrs = validator('insurance', data) || [];
      partnerSectionStatus.insurance = insErrs.filter(e => e && e._scope === 'partner').length === 0;
    }
  } catch {}

  return {
    ...data,
    meta: { ...(data && data.meta ? data.meta : {}), sectionStatus, partnerSectionStatus },
  };
}

// ─── Engagement timing ───────────────────────────────────────
// Tracks how long the client is *actively* working on the form, per section,
// excluding idle gaps (no interaction for > IDLE_MS) and time while the tab is
// hidden. The tracker only holds THIS page-load's deltas; stampTiming() merges
// them onto the persisted base in data.meta.timing at save time, so the base
// stays constant during a sitting (repeated autosaves are idempotent) and time
// accumulates across sittings. Only counts once the client is signed in.
const IDLE_MS = 60_000; // pause the timer after 60s of no interaction
const Timing = {
  bySection: {},          // sectionId -> ms accrued THIS sitting
  _section: 'personal',
  _lastTs: null,
  enabled: false,
  counted: false,         // did this sitting accrue any active time?
  firstActivityAt: null,  // ISO, this sitting
  lastActivityAt: null,   // ISO, this sitting
  setSection(id) {
    this.mark();
    if (id) this._section = id;
    this._lastTs = Date.now();
  },
  mark() {
    const now = Date.now();
    const hidden = (typeof document !== 'undefined' && document.hidden);
    if (this.enabled && this._lastTs != null && this._section && !hidden) {
      const gap = now - this._lastTs;
      if (gap > 0 && gap <= IDLE_MS) {
        this.bySection[this._section] = (this.bySection[this._section] || 0) + gap;
        if (!this.firstActivityAt) this.firstActivityAt = new Date(this._lastTs).toISOString();
        this.lastActivityAt = new Date(now).toISOString();
        this.counted = true;
      }
    }
    this._lastTs = now;
  },
  onHidden() { this.mark(); this._lastTs = null; },
};

// Merge the live engagement tracker onto the persisted timing base.
function stampTiming(data) {
  if (typeof window === 'undefined') return data;
  Timing.mark(); // accrue the tail up to now before snapshotting
  const prev = (data && data.meta && data.meta.timing) || {};
  const base = prev.activeBySection || {};
  const activeBySection = { ...base };
  for (const [sec, ms] of Object.entries(Timing.bySection)) {
    activeBySection[sec] = (activeBySection[sec] || 0) + ms;
  }
  // Safety clamp: active time (idle- and hidden-excluded) on one section
  // can't realistically exceed a few hours. Cap each section so a stray
  // accounting glitch can't run away, and so any already-inflated record
  // self-heals the next time the client saves.
  const MAX_SANE_SECTION_MS = 4 * 60 * 60 * 1000;
  for (const k of Object.keys(activeBySection)) {
    if (typeof activeBySection[k] === 'number' && activeBySection[k] > MAX_SANE_SECTION_MS) {
      activeBySection[k] = MAX_SANE_SECTION_MS;
    }
  }
  const activeTotalMs = Object.values(activeBySection).reduce((a, b) => a + (typeof b === 'number' && b > 0 ? b : 0), 0);
  return {
    ...data,
    meta: {
      ...(data && data.meta ? data.meta : {}),
      timing: {
        v: 1,
        idleMs: IDLE_MS,
        activeBySection,
        activeTotalMs,
        sessions: (prev.sessions || 0) + (Timing.counted ? 1 : 0),
        firstActivityAt: prev.firstActivityAt || Timing.firstActivityAt || null,
        lastActivityAt: Timing.lastActivityAt || prev.lastActivityAt || null,
      },
    },
  };
}

// ─── Save/Resume ─────────────────────────────────────────────
// Backed by the server: see api-bridge.js. localStorage is still used as
// an offline cache for autosave-while-typing UX.

async function serverCreate(payload) {
  const stamped = payload && payload.data
    ? { ...payload, data: stampTiming(stampSectionStatus(payload.data)) }
    : payload;
  const out = await window.TwApi.FactFind.create(stamped);
  return out.code;
}
async function serverResume(code) {
  return window.TwApi.FactFind.resume(code);
}
async function serverPatch(code, payload) {
  const stamped = payload && payload.data
    ? { ...payload, data: stampTiming(stampSectionStatus(payload.data)) }
    : payload;
  return window.TwApi.FactFind.autosave(code, stamped);
}

// ─── Shell ───────────────────────────────────────────────────
function FactFindApp() {
  // Adviser-set invite (server-resolved from the URL token). Loaded async.
  const [pendingInvite, setPendingInvite] = useState(null);
  useEffect(() => {
    try {
      const params = new URLSearchParams(window.location.search);
      const urlToken = params.get('invite');
      if (params.get('reset') === '1') {
        try { localStorage.removeItem('tw_pending_invite'); } catch {}
      }
      if (!urlToken) return;
      window.TwApi.Invites.get(urlToken).then(inv => {
        setPendingInvite({
          inviteToken: inv.token,
          clientId: inv.clientId,
          clientName: inv.clientName,
          clientFirstName: inv.clientFirstName || '',
          clientLastName:  inv.clientLastName  || '',
          clientEmail: inv.clientEmail,
          sectionMask: inv.sectionMask,
          requestedDocs: inv.requestedDocs,
          adviser: inv.adviser,
          // The owning adviser's Third Party Authority (per-adviser form,
          // resolved server-side from clients.invited_by). Null = not yet
          // available, and the Documents step says the adviser will email it.
          tpaForm: inv.tpaForm || null,
          // When the adviser invited a couple, the server returns the
          // OTHER half of the pair here so we can seed the partner tab
          // with the right name (and auto-set couple-advice = Yes)
          // before the user types anything.
          partner: inv.partner || null,
        });
      }).catch(() => { /* unknown token — no mask applied */ });
    } catch {}
  }, []);

  const visibleSections = useMemo(() => {
    if (!pendingInvite || !pendingInvite.sectionMask) return SECTIONS;
    const mask = pendingInvite.sectionMask;
    return SECTIONS.filter(s => {
      // Documents always visible if any docs requested OR if no invite at all
      if (s.id === 'documents') {
        return !pendingInvite.requestedDocs || pendingInvite.requestedDocs.length > 0;
      }
      return mask[s.id] !== false;
    }).map((s, i) => ({ ...s, num: String(i + 1).padStart(2, '0') }));
  }, [pendingInvite]);

  // Documents-only flow: the adviser requested no fact-find sections, so
  // the only step is uploading documents. Used to tune copy (e.g. the
  // final button reads "Submit documents" rather than "Submit fact-find").
  const docsOnlyFlow = visibleSections.length === 1 && visibleSections[0]?.id === 'documents';

  // Current section id. Initial load reads from localStorage so a
  // page-refresh or back-button restore lands on the same step. The
  // session-check effect below will reset to 'personal' if the cached
  // owner doesn't match the signed-in user.
  const [sectionId, setSectionId] = useState(() => {
    try {return localStorage.getItem(STORAGE_KEY + ':lastSection') || 'personal';} catch {return 'personal';}
  });
  // Initial data state. We DO restore from localStorage so refresh/back
  // works seamlessly for the *same* user, but the session-check effect
  // below verifies the cache owner matches the signed-in email and
  // wipes everything if not. The autosave effect also bails out until
  // signedIn=true, so nothing in this initial state can leak to the
  // server until we've confirmed who's signed in.
  const [data, setData] = useState(() => {
    try {
      const raw = localStorage.getItem(STORAGE_KEY + ':current');
      return raw ? JSON.parse(raw) : {};
    } catch {return {};}
  });
  // Track the in-flight invite token from the URL so we can pass it on first save
  const inviteToken = useMemo(() => {
    try { return new URLSearchParams(window.location.search).get('invite') || null; } catch { return null; }
  }, []);
  // Keep the client's required-documents list in sync with the adviser's
  // current selection on the invite.
  //
  // `documents.required` MIRRORS the invite's requestedDocs (the authoritative
  // adviser choice), so:
  //   - newly-added document requirements appear - even if the adviser added
  //     them AFTER the client started or submitted (the resume/mine response
  //     carries the live requestedDocs, see server inviteMaskForFactFind);
  //   - de-requested documents drop off;
  //   - an explicit empty array means "the adviser asked for nothing" (hides
  //     the section + stops the validator demanding uploads).
  // Uploaded files (documents.files, keyed by docId) are stored separately and
  // are never touched here, so reconciling the list can't lose an upload.
  // Earlier this only seeded when the list was empty, which meant a returning
  // client never picked up a requirement added after their first visit.
  useEffect(() => {
    if (!pendingInvite || !Array.isArray(pendingInvite.requestedDocs)) return;
    const want = pendingInvite.requestedDocs;
    setData(d => {
      const existing = (d && d.documents && d.documents.required) || [];
      const same = existing.length === want.length && existing.every((x, i) => x === want[i]);
      if (same) return d;
      return { ...d, documents: { ...(d.documents || {}), required: [...want] } };
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pendingInvite]);

  // Seed primary + partner identity from the invite payload so the
  // partner tab shows "Partner: <name>" on first paint - and so the
  // primary's identity card is pre-filled with whatever the adviser
  // typed at invite time. Runs once when pendingInvite first arrives.
  // Only ever fills EMPTY fields (never overwrites typed data).
  useEffect(() => {
    if (!pendingInvite) return;
    const primaryFirst = pendingInvite.clientFirstName || '';
    const primaryLast  = pendingInvite.clientLastName  || '';
    const primaryEmail = pendingInvite.clientEmail     || '';
    const partner = pendingInvite.partner || null;
    if (!primaryFirst && !primaryLast && !primaryEmail && !partner) return;
    setData(d => {
      const personal = d.personal || {};
      const primary  = personal.primary  || {};
      const pt       = personal.partner  || {};
      const next = { ...personal };
      const filledPrimary = { ...primary };
      if (!filledPrimary.firstName && primaryFirst) filledPrimary.firstName = primaryFirst;
      if (!filledPrimary.lastName  && primaryLast)  filledPrimary.lastName  = primaryLast;
      if (!filledPrimary.email     && primaryEmail) filledPrimary.email     = primaryEmail;
      if (partner && filledPrimary.coupleAdvice === undefined) filledPrimary.coupleAdvice = true;
      next.primary = filledPrimary;
      if (partner) {
        const filledPartner = { ...pt };
        if (!filledPartner.firstName && partner.firstName) filledPartner.firstName = partner.firstName;
        if (!filledPartner.lastName  && partner.lastName)  filledPartner.lastName  = partner.lastName;
        if (!filledPartner.email     && partner.email)     filledPartner.email     = partner.email;
        next.partner = filledPartner;
        if (!('includePartner' in next)) next.includePartner = true;
        // Durable marker: this couple was set up by the adviser at invite
        // time (not self-selected in-app). Used to decide whether the
        // client may opt out of completing partner details.
        next.partnerInvited = true;
      }
      return { ...d, personal: next };
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pendingInvite]);
  const [saveCode, setSaveCode] = useState(null);
  const [resumeInput, setResumeInput] = useState('');
  const [toast, setToast] = useState(null);
  const [submitted, setSubmitted] = useState(false);
  // Validation errors for the *current* section. We only run the validator
  // when the user clicks Next/Submit; once they fix things, the set is
  // recomputed on each subsequent attempt. Cleared when navigating away.
  const [errorList, setErrorList] = useState([]);
  const errorSet = useMemo(() => new Set(errorList.map(e => e.name)), [errorList]);
  // Also expose a Set of slugified error LABELS so any Field whose visible
  // label slug-matches an error can light up red without needing an
  // explicit `name` prop. The slug uses the same rules as the Field
  // primitive's `labelKey` derivation. We strip the "Item N -" / "Trip N
  // -" prefixes so per-row context doesn't break the match.
  const errorLabelSet = useMemo(() => {
    const slugify = (s) => (s || '')
      .replace(/^[A-Za-z]+\s+\d+\s+[-—–]\s+/, '')   // drop "Relative 1 - " etc.
      .replace(/[^a-z0-9]+/gi, '-')
      .toLowerCase()
      .replace(/^-+|-+$/g, '');
    return new Set(errorList.map(e => slugify(e.label)));
  }, [errorList]);

  // (Brand strip preview/decline toggle was removed — it was only for the
  // design-time review phase. The branding strip itself ships always.)
  const [signedIn, setSignedIn] = useState(false);
  const [signedInEmail, setSignedInEmail] = useState('');
  const [assisted, setAssisted] = useState(false);
  const [readOnly, setReadOnly] = useState(false);
  // Rank-5 back-office correction. `canEdit` is granted by the server on the
  // assist response; `editing` is the adviser explicitly switching the
  // read-only view into an editable one. Read-only stays the default so
  // simply reviewing a client can never change their answers.
  const [canEdit, setCanEdit] = useState(false);
  const [editing, setEditing] = useState(false);
  const [authChecking, setAuthChecking] = useState(true);
  // True whenever the form body should be inert. Read-only unless a rank-5
  // adviser has deliberately unlocked it.
  const locked = readOnly && !editing;

  // Helper: enforce that the local cache belongs to the email that
  // just signed in. If the cache was written by a different client
  // (e.g. shared computer), wipe both localStorage AND in-memory state
  // before showing the form. Without this, the next person to sign in
  // sees the previous client's personal details, super balances etc -
  // and worse, the autosave debounce would push that data to the
  // server under the new client's session.
  function ensureCacheOwnedBy(email) {
    const cachedOwner = window.TwApi.Cache.getOwner();
    const normalisedNew = (email || '').toLowerCase();
    if (cachedOwner && cachedOwner !== normalisedNew) {
      window.TwApi.Cache.clearAll();
      setData({});
      setSectionId('personal');
      setSaveCode(null);
    }
    if (normalisedNew) window.TwApi.Cache.setOwner(normalisedNew);
  }

  // Check server session on mount — survives refresh
  useEffect(() => {
    let cancelled = false;
    // Adviser-assisted entry: staff open the client's questionnaire from the
    // portal (?assist=1&invite=TOKEN). Authorised by the same-origin adviser
    // session; resume the client's fact-find by its save code and skip the
    // client OTP sign-in. Falls back to normal sign-in if not authorised.
    const _p = new URLSearchParams(window.location.search);
    if (_p.get('assist') === '1' && _p.get('invite')) {
      const _ro = _p.get('view') === '1';   // read-only "Open fact-find" view
      window.TwApi.Adviser.assist(_p.get('invite'), _ro).then(async (r) => {
        if (cancelled || !r || !r.code) throw new Error('no_assist');
        const resumed = await window.TwApi.FactFind.resume(r.code).catch(() => null);
        setSaveCode(r.code);
        // Read-only view never writes, so don't cache the code as a resume target.
        if (!_ro) { try { window.TwApi.Cache.setCode(r.code); } catch {} }
        if (resumed) { if (resumed.data) setData(resumed.data); if (resumed.sectionId) setSectionId(resumed.sectionId); }
        if (_ro) { setReadOnly(true); setCanEdit(!!r.canEdit); } else setAssisted(true);
        setSignedIn(true);
        setSignedInEmail(r.clientEmail || '');
      }).catch(() => { /* not an authorised staff session → normal sign-in */ })
        .finally(() => { if (!cancelled) setAuthChecking(false); });
      return () => { cancelled = true; };
    }
    window.TwApi.ClientAuth.session().then(s => {
      if (cancelled) return;
      if (s && s.signedIn) {
        ensureCacheOwnedBy(s.email || '');
        setSignedIn(true);
        setSignedInEmail(s.email || '');
      } else {
        // No active server session. If localStorage still has someone's
        // cached fact-find, treat it as orphaned and wipe so the next
        // sign-in starts clean (even if the new user types the same
        // email as the previous one, the ensureCacheOwnedBy check
        // below handles that case too).
        if (window.TwApi.Cache.getOwner()) {
          window.TwApi.Cache.clearAll();
          setData({});
          setSectionId('personal');
          setSaveCode(null);
        }
      }
    }).catch(() => {}).finally(() => { if (!cancelled) setAuthChecking(false); });
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  function handleSignedIn({ email }) {
    // Sign-in via verify-code on a fresh visit: same owner check as the
    // session-restore path so a different user can't pick up the
    // previous user's cached state.
    ensureCacheOwnedBy(email);
    setSignedIn(true);
    setSignedInEmail(email);
  }

  // Auto-fill primary email from the signed-in session. Only writes when the
  // field is empty so we never clobber something the client (or a partner-
  // resume flow) has already typed in. Triggers any time we have a session
  // email but the form doesn't.
  useEffect(() => {
    if (!signedInEmail) return;
    const currentEmail = data?.personal?.primary?.email;
    if (currentEmail) return;
    setData(d => {
      // Re-check inside the updater so we don't race with another setData.
      if (d?.personal?.primary?.email) return d;
      return {
        ...d,
        personal: {
          ...(d?.personal || {}),
          primary: { ...(d?.personal?.primary || {}), email: signedInEmail },
        },
      };
    });
  }, [signedInEmail, data?.personal?.primary?.email]);
  async function handleSignOut() {
    try { await window.TwApi.ClientAuth.logout(); } catch {}
    // Wipe everything client-side too. Previously we only cleared the
    // server session and React state - localStorage was left behind,
    // which meant the next client on the same browser saw the prior
    // client's data on initial mount.
    window.TwApi.Cache.clearAll();
    setData({});
    setSectionId('personal');
    setSaveCode(null);
    setSignedIn(false);
    setSignedInEmail('');
  }

  // (Tweaks panel removed — design is locked.)

  // Persist locally on every change (instant) + push to server (debounced).
  // We only write to localStorage AFTER sign-in so an anonymous visit
  // (e.g. someone landing on the page and bouncing) can't leave a
  // half-baked, untagged cache behind for the next user to inherit.
  // Re-asserts the owner tag on every save so the cache always knows
  // which signed-in email it belongs to.
  useEffect(() => {
    // Never cache in an adviser's "Open fact-find" session - not even when
    // they've unlocked editing. The cache is keyed to the CLIENT's email, so
    // writing it here would leave the client's fact-find sitting in the
    // adviser's browser and make their machine look like that client's
    // device on the next visit. Rank-5 edits are server-autosave only.
    if (readOnly) return;
    if (!signedIn || !signedInEmail) return;
    try {
      localStorage.setItem(STORAGE_KEY + ':current', JSON.stringify(data));
      localStorage.setItem(STORAGE_KEY + ':lastSection', sectionId);
      window.TwApi.Cache.setOwner(signedInEmail);
    } catch {}
  }, [data, sectionId, signedIn, signedInEmail]);

  // Debounced autosave to server. If no save code yet, create one on the first
  // save - BUT only once the user has actually typed something. Without that
  // guard, signing in with a cleared cache would create a fresh empty
  // fact_finds row before the /mine recovery effect had a chance to run,
  // and that empty row would then become the user's "most recent" forever.
  const autosaveTimer = useRef(null);
  const [autosaveStatus, setAutosaveStatus] = useState('idle'); // idle | saving | saved | error
  // Throttles the rank-5 edit stamp/audit ping so one editing sitting doesn't
  // put a ping on the wire behind every autosave. First save pings straight
  // away; after that at most once per window. The server dedupes the audit
  // EVENT independently, so this is purely about request volume.
  const editPingAt = useRef(0);
  const EDIT_PING_MS = 60_000;
  // Where the record stood the moment editing was switched on:
  //   .obj   - the data object itself. While `data` is still that very object
  //            nothing at all has happened, so there is nothing to save.
  //   .print - a fingerprint of the ANSWERS only. `meta` is bookkeeping, and
  //            `documents` is read-only in edit mode but gets rewritten on
  //            load by the requested-docs mirror. Neither is the adviser
  //            typing, so neither may earn an "edited by adviser" stamp.
  // Declared above the autosave effect so it's populated before that effect
  // runs on the same commit.
  const answerPrint = (d) => {
    try { const { meta, documents, ...answers } = (d || {}); return JSON.stringify(answers); }
    catch { return ''; }
  };
  const editBaseRef = useRef(null);
  useEffect(() => {
    editBaseRef.current = editing ? { obj: data, print: answerPrint(data) } : null;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [editing]);
  useEffect(() => {
    // No server autosave in the read-only view unless a rank-5 adviser has
    // explicitly unlocked editing.
    if (readOnly && !editing) return;
    if (!signedIn) return;
    if (authChecking) return;
    // Editing just switched on and nothing has been touched yet - nothing to
    // save, and nothing to attribute to the adviser.
    if (readOnly && editing && editBaseRef.current && data === editBaseRef.current.obj) return;
    if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
    autosaveTimer.current = setTimeout(async () => {
      try {
        setAutosaveStatus('saving');
        let code = saveCode;
        // Adviser edit mode always resumes an existing row (assist created it
        // if the client had none), so there is nothing to create here. Bailing
        // rather than falling through stops a lost code from silently spawning
        // a second fact-find under the client.
        if (!code && readOnly) { setAutosaveStatus('error'); return; }
        if (!code) {
          // Only spin up a brand-new fact-find row when there's something
          // worth saving. Until then, stay quiet so the /mine recovery
          // path can hand us an existing row to update instead.
          const hasMeaningfulData = Object.keys(data || {}).some(k => {
            const v = data[k];
            if (!v) return false;
            if (typeof v === 'object') return Object.keys(v).length > 0;
            return true;
          });
          if (!hasMeaningfulData) {
            setAutosaveStatus('idle');
            return;
          }
          code = await serverCreate({
            data, sectionId,
            clientEmail: signedInEmail,
            inviteToken,
          });
          setSaveCode(code);
          window.TwApi.Cache.setCode(code);
        } else {
          // In adviser edit mode, deliberately DON'T send sectionId. The
          // server only touches last_section when it's present, and moving
          // the client's resume point because a reviewer flicked to
          // Insurance would be wrong.
          await serverPatch(code, readOnly ? { data } : { data, sectionId });
        }
        setAutosaveStatus('saved');
        // Stamp who edited + write the audit trail. Fire-and-forget: the
        // answers are already saved, and a failed stamp must never look like
        // a failed save. A 403 means the adviser's rank was lowered
        // mid-session, so drop straight back to read-only.
        //
        // Only when an ANSWER actually moved. A save can also be triggered by
        // machinery the adviser didn't drive (the documents-required mirror
        // rewriting itself on load, a partner's change arriving via live
        // sync); attributing those to the adviser would put "edited by" on a
        // record nobody touched.
        const answersMoved = !!editBaseRef.current && answerPrint(data) !== editBaseRef.current.print;
        if (readOnly && editing && inviteToken && answersMoved) {
          const now = Date.now();
          if (now - editPingAt.current > EDIT_PING_MS) {
            editPingAt.current = now;
            window.TwApi.Adviser.assistEditPing(inviteToken, sectionId).catch(err => {
              if (err && err.status === 403) {
                setEditing(false);
                setCanEdit(false);
                setToast({ kind: 'err', msg: 'Your access level no longer allows editing. Back to read-only.' });
              } else {
                console.warn('[adviser-edit] stamp failed', err);
              }
            });
          }
        }
      } catch (err) {
        console.warn('[autosave] failed', err);
        setAutosaveStatus('error');
      }
    }, 1200);
    return () => { if (autosaveTimer.current) clearTimeout(autosaveTimer.current); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [data, sectionId, signedIn, authChecking, editing]);

  // ─── Couple live sync ──────────────────────────────────────
  // When two partners have their questionnaires open at once, poll the
  // server every few seconds (and on tab focus) for the OTHER partner's
  // latest data and merge it in. The 3-way merge (merge3.js) keeps this
  // user's own in-progress edits safe (a field they've changed is never
  // overwritten - which also protects whatever they're actively typing)
  // and only adopts values the partner changed. `syncBaseRef` is the last
  // reflected server snapshot we diffed against.
  const dataRef = useRef(data);
  useEffect(() => { dataRef.current = data; }, [data]);
  const syncBaseRef = useRef(null);
  useEffect(() => {
    // Runs in adviser edit mode too: if the partner is filling their half
    // while we're correcting this one, merge3 keeps both sets of edits.
    if (readOnly && !editing) return;
    if (!signedIn || !saveCode) return;
    if (typeof window.Merge3 === 'undefined') return;
    let cancelled = false;
    let timer = null;
    let toastTimer = null;
    let lastToast = 0;
    const POLL_MS = 4000;
    const tick = async () => {
      if (cancelled) return;
      // Don't poll while the tab is hidden - saves requests; a focus event
      // triggers an immediate catch-up when they come back.
      if (typeof document !== 'undefined' && document.hidden) { schedule(); return; }
      try {
        const r = await window.TwApi.FactFind.poll(saveCode);
        if (!cancelled && r && r.data) {
          // Sync only the questionnaire answers, never `meta` (per-record
          // bookkeeping: engagement timing, sectionStatus). Adopting the
          // server's stamped meta.timing back into local state would break
          // stampTiming's constant-base assumption and inflate active time
          // on every poll. Keep the local user's own meta untouched.
          const remote = { ...r.data };
          delete remote.meta;
          const base = (syncBaseRef.current == null) ? (dataRef.current || {}) : syncBaseRef.current;
          const merged = window.Merge3.merge3(base, dataRef.current || {}, remote);
          syncBaseRef.current = remote;
          if (merged.changed) {
            setData(merged.data);            // autosave persists the union; no loop (steady state is a no-op merge)
            // Brief, throttled heads-up so the user knows why a field moved.
            const now = Date.now();
            if (now - lastToast > 6000) {
              lastToast = now;
              setToast({ kind: 'ok', msg: 'Updated with your partner’s latest changes.' });
              if (toastTimer) clearTimeout(toastTimer);
              toastTimer = setTimeout(() => setToast(null), 3000);
            }
          }
        }
      } catch (e) { /* transient poll error - try again next tick */ }
      schedule();
    };
    const schedule = () => { if (!cancelled) timer = setTimeout(tick, POLL_MS); };
    const onFocus = () => { if (!cancelled) { if (timer) clearTimeout(timer); tick(); } };
    timer = setTimeout(tick, POLL_MS);
    window.addEventListener('focus', onFocus);
    return () => { cancelled = true; if (timer) clearTimeout(timer); if (toastTimer) clearTimeout(toastTimer); window.removeEventListener('focus', onFocus); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [signedIn, saveCode, readOnly, editing]);

  // ─── Engagement timing wiring ──────────────────────────────
  // Only count active time once signed in (skip the OTP/sign-in screen).
  useEffect(() => {
    // Never in an "Open fact-find" session, editing or not. meta.timing is
    // how long the CLIENT spent on their form - it feeds the completion-time
    // statistics and the per-client engagement panel. Counting an adviser's
    // review or correction time there would quietly corrupt both. stampTiming
    // is a no-op while disabled, so the client's existing totals survive an
    // adviser save untouched.
    Timing.enabled = !!signedIn && !readOnly;
    if (signedIn && !readOnly) Timing._lastTs = Date.now();
  }, [signedIn, readOnly]);
  // Attribute accrued time to whichever section is on screen.
  useEffect(() => { Timing.setSection(sectionId); }, [sectionId]);
  // Capture interaction (throttled) + pause when the tab is hidden.
  useEffect(() => {
    let last = 0;
    const onActivity = () => {
      const now = Date.now();
      if (now - last < 1000) return; // throttle to ~1/sec
      last = now;
      Timing.mark();
    };
    const onVis = () => {
      if (typeof document !== 'undefined' && document.hidden) Timing.onHidden();
      else Timing._lastTs = Date.now();
    };
    const evs = ['keydown', 'pointerdown', 'click', 'input', 'scroll', 'touchstart', 'mousemove'];
    evs.forEach(e => window.addEventListener(e, onActivity, { passive: true }));
    document.addEventListener('visibilitychange', onVis);
    return () => {
      evs.forEach(e => window.removeEventListener(e, onActivity));
      document.removeEventListener('visibilitychange', onVis);
    };
  }, []);

  // On sign-in, try to restore the client's most recent fact-find. The
  // browser cache holds the saveCode for fast reloads, BUT if the user
  // clears their browser cache (or signs in on a different device) that
  // cached code is gone. In that case we fall back to a server-side
  // lookup by their authenticated session email - this is the safety
  // net that prevents data loss in the cleared-cache scenario.
  useEffect(() => {
    if (!signedIn) return;
    if (saveCode) return; // already loaded for this session
    let cancelled = false;

    async function restore() {
      // 1) Fast path: cached code in localStorage.
      const cachedCode = window.TwApi.Cache.getCode();
      if (cachedCode) {
        try {
          const r = await window.TwApi.FactFind.resume(cachedCode);
          if (cancelled) return;
          if (r && r.code) {
            setSaveCode(r.code);
            setData(r.data || {});
            setSectionId(r.sectionId || 'personal');
            // Restore the adviser-specified section mask from the
            // invite linked to this fact-find. Without this, returning
            // clients (no ?invite=TOKEN in URL on their second visit)
            // would silently see every section regardless of the
            // adviser's selection. Only apply when present so we don't
            // wipe an already-resolved pendingInvite.
            if (r.sectionMask || Array.isArray(r.requestedDocs) || r.tpaForm) setPendingInvite(prev => ({ ...(prev || {}), sectionMask: r.sectionMask, requestedDocs: r.requestedDocs, tpaForm: r.tpaForm || (prev && prev.tpaForm) || null }));
            return;
          }
        } catch (_e) { /* cached code stale - fall through to /mine */ }
      }
      // 2) Server-side lookup by session - works on any device, even after
      //    a cache wipe.
      try {
        const r = await window.TwApi.FactFind.mine();
        if (cancelled) return;
        if (r && r.code) {
          setSaveCode(r.code);
          setData(r.data || {});
          setSectionId(r.sectionId || 'personal');
          window.TwApi.Cache.setCode(r.code);    // re-prime the cache
          if (r.sectionMask || Array.isArray(r.requestedDocs) || r.tpaForm) setPendingInvite(prev => ({ ...(prev || {}), sectionMask: r.sectionMask, requestedDocs: r.requestedDocs, tpaForm: r.tpaForm || (prev && prev.tpaForm) || null }));
          setToast({ kind: 'ok', msg: 'Welcome back - your previous progress has been restored.' });
          setTimeout(() => setToast(null), 4500);
        }
      } catch (_e) { /* no fact-find yet for this client - that's fine */ }
    }
    restore();
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [signedIn]);

  const scores = useMemo(() => computeProgress(data), [data]);
  // Only count sections that are actually visible to this client.
  const overall = useMemo(() => {
    const vals = visibleSections.map(s => scores[s.id] || 0);
    if (vals.length === 0) return 0;
    return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length * 100);
  }, [scores, visibleSections]);

  async function doSave() {
    try {
      let code = saveCode;
      if (!code) {
        code = await serverCreate({ data, sectionId, clientEmail: signedInEmail, inviteToken });
        setSaveCode(code);
        window.TwApi.Cache.setCode(code);
      } else {
        await serverPatch(code, { data, sectionId });
      }
      setToast({ kind: 'ok', msg: `Saved. Your code is ${code}` });
    } catch (err) {
      setToast({ kind: 'err', msg: 'Could not save. Try again.' });
    }
    setTimeout(() => setToast(null), 4000);
  }

  async function doResume() {
    const raw = window.TwApi.normCode(resumeInput);
    if (!raw) return;
    try {
      const stored = await serverResume(raw);
      setData(stored.data || {});
      setSectionId(stored.sectionId || 'personal');
      setSaveCode(stored.code);
      window.TwApi.Cache.setCode(stored.code);
      setToast({ kind: 'ok', msg: 'Restored. Pick up where you left off.' });
    } catch (err) {
      setToast({ kind: 'err', msg: 'That code didn\'t match any saved progress.' });
    }
    setTimeout(() => setToast(null), 4000);
  }

  const currentIdx = visibleSections.findIndex((s) => s.id === sectionId);
  // Guard: if persisted sectionId is no longer visible (mask changed), reset to first.
  useEffect(() => {
    if (currentIdx === -1 && visibleSections.length > 0) {
      setSectionId(visibleSections[0].id);
    }
  }, [currentIdx, visibleSections]);
  const safeIdx = Math.max(0, currentIdx);
  const errorBannerRef = useRef(null);
  const [submitting, setSubmitting] = useState(false);
  const [submitResult, setSubmitResult] = useState(null);
  // Partner-not-submitted gate. The submit endpoint returns 409 with
  // partner metadata when the primary picked "Send reminder" but their
  // partner hasn't finished their own fact-find yet. We pop a modal
  // with a Send reminder button so they can poke the partner without
  // leaving the page.
  const [partnerGate, setPartnerGate] = useState(null);
  const next = async () => {
    const isLast = safeIdx === visibleSections.length - 1;
    // On the final step, validate the WHOLE form; otherwise just the current section.
    let errs = [];
    if (isLast) {
      visibleSections.forEach(s => {
        const sectionErrs = (window.validateSection ? window.validateSection(s.id, data) : []) || [];
        sectionErrs.forEach(e => errs.push({ ...e, sectionId: s.id, sectionLabel: s.label }));
      });
    } else {
      errs = ((window.validateSection ? window.validateSection(sectionId, data) : []) || [])
        .map(e => ({ ...e, sectionId, sectionLabel: visibleSections[safeIdx]?.label }));
    }
    if (errs.length) {
      setErrorList(errs);
      // Pull the user all the way to the very top of the page so the missing
      // fields are immediately visible — the error banner sits at the top of
      // the section and the page header is above it. Use behavior:'auto'
      // (instant) instead of 'smooth' because some browsers / embedded
      // webviews silently ignore the smooth animation when it competes with
      // a fresh React render in the same tick, leaving the user stuck mid-
      // page wondering why nothing happened.
      setTimeout(() => {
        window.scrollTo({ top: 0, behavior: 'auto' });
      }, 50);
      return;
    }
    setErrorList([]);
    if (!isLast) {
      setSectionId(visibleSections[safeIdx + 1].id);
      window.scrollTo({ top: 0, behavior: 'smooth' });
    } else {
      // Submit. Ensure the latest data is persisted, then call submit.
      try {
        setSubmitting(true);
        let code = saveCode;
        if (!code) {
          code = await serverCreate({ data, sectionId, clientEmail: signedInEmail, inviteToken });
          setSaveCode(code);
          window.TwApi.Cache.setCode(code);
        } else {
          await serverPatch(code, { data, sectionId });
        }
        const out = await window.TwApi.FactFind.submit(code);
        setSubmitResult({ referenceNo: out.referenceNo, submittedAt: out.submittedAt, code });
        setSubmitted(true);
        window.TwApi.Cache.clearCurrent();
      } catch (err) {
        // The server gates submission on essential personal fields
        // (defense in depth behind the client validator). If it returns
        // 400 with an incomplete-list, surface it as the error banner so
        // the user sees exactly what's missing rather than a generic
        // toast - this path fires if validation.jsx soft-failed and let
        // them past the client gate.
        if (err?.status === 400 && err?.body?.error === 'incomplete') {
          const labels = Array.isArray(err.body.missing) ? err.body.missing : [];
          setErrorList(labels.map(label => ({
            name: label.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
            label,
            sectionId: 'personal',
            sectionLabel: 'Personal',
          })));
          setSectionId('personal');
          setTimeout(() => window.scrollTo({ top: 0, behavior: 'auto' }), 50);
          setToast({ kind: 'err', msg: 'Please complete the highlighted personal details before submitting.' });
        } else if (err?.status === 409 && err?.body?.error === 'partner_not_submitted') {
          // Server-gated submit blocked because the linked partner's
          // fact-find isn't submitted yet. Pop the modal.
          setPartnerGate({
            partner: err.body.partner || null,
            saveCode,
          });
        } else {
          setToast({ kind: 'err', msg: 'We couldn\'t submit. Please try again.' });
        }
        setTimeout(() => setToast(null), 4000);
      } finally {
        setSubmitting(false);
      }
    }
  };
  const back = () => {
    setErrorList([]);
    if (safeIdx > 0) {
      setSectionId(visibleSections[safeIdx - 1].id);
      window.scrollTo({ top: 0, behavior: 'smooth' });
    }
  };

  // Clear errors when the user jumps via the side nav / rail — but NOT when
  // we navigate programmatically from an error-banner click (we want the
  // banner to persist on the new section so they can keep working through it).
  const skipNextClear = useRef(false);
  useEffect(() => {
    if (skipNextClear.current) {
      skipNextClear.current = false;
      return;
    }
    setErrorList([]);
  }, [sectionId]);

  // Live re-validation while the user is "in error mode": once a Next-click
  // has surfaced a list of missing fields, we re-run the same validator
  // every time `data` changes so the red borders + banner shrink as the
  // user fills things in. (No-op when errorList is empty — keeps typing
  // cheap until they trip the gate.)
  useEffect(() => {
    if (errorList.length === 0) return;
    // Determine whether the last failed Next was a single-section check or
    // the final whole-form check, so we re-validate the same scope.
    const isFullForm = errorList.some(e => e.sectionId && e.sectionId !== sectionId);
    let next = [];
    if (isFullForm) {
      visibleSections.forEach(s => {
        const sectionErrs = (window.validateSection ? window.validateSection(s.id, data) : []) || [];
        sectionErrs.forEach(e => next.push({ ...e, sectionId: s.id, sectionLabel: s.label }));
      });
    } else {
      next = ((window.validateSection ? window.validateSection(sectionId, data) : []) || [])
        .map(e => ({ ...e, sectionId, sectionLabel: visibleSections[safeIdx]?.label }));
    }
    // Only update if the error set actually changed — prevents a render loop.
    const prevKey = errorList.map(e => e.name).sort().join('|');
    const nextKey = next.map(e => e.name).sort().join('|');
    if (prevKey !== nextKey) setErrorList(next);
  }, [data, errorList.length]);

  // Click an error-banner item → jump to its section (if needed), then scroll
  // to the field once that section has rendered.
  function jumpToError(err) {
    const targetSection = err.sectionId || sectionId;
    // Strip "Item N —" / "Trip N —" / "Policy N —" / "Relative N —" / "Dependant N —" prefixes
    // (they're list-context, not part of the label) but remember the index so we can pick the
    // nth matching node where helpful.
    const rawLabel = err.label || '';
    const idxMatch = rawLabel.match(/^[A-Za-z]+\s+(\d+)\s+[-—–]\s+/);
    const itemIdx = idxMatch ? Number(idxMatch[1]) - 1 : -1;
    const cleanLabel = rawLabel.replace(/^[A-Za-z]+\s+\d+\s+[-—–]\s+/, '');
    const slugify = (s) => (s || '')
      .replace(/[^a-z0-9]+/gi, '-').toLowerCase().replace(/^-+|-+$/g, '');
    const slug = slugify(cleanLabel);
    const tokens = slug.split('-').filter(t => t.length >= 3 && !['the','and','for','any','your','with'].includes(t));
    const scoreNode = (el) => {
      const lbl = el.getAttribute('data-ff-label') || '';
      if (!lbl) return 0;
      if (lbl === slug) return 1000;                // exact match
      if (lbl.startsWith(slug + '-')) return 800;   // prefix
      if (lbl.includes(slug)) return 600;           // contains
      if (!tokens.length) return 0;
      const lblTokens = new Set(lbl.split('-'));
      let hits = 0;
      tokens.forEach(t => { if (lblTokens.has(t)) hits++; });
      return hits === tokens.length ? 400 + hits : (hits >= 2 ? 200 + hits : 0);
    };
    const findNode = () => {
      // 1) Field with explicit `name` matching the error key — always trusted.
      // 1b) Otherwise an "anchor card" carrying data-ff-name with the same
      //     prefix (e.g. error "as.loan2.lender" -> card "as.loans"), so a
      //     low-confidence case still lands in the right card rather than
      //     fuzzy-matching a wrong field elsewhere.
      let anchorCard = null;
      if (err.name) {
        const byName = document.querySelector(`[data-ff-name="${err.name}"]`);
        if (byName) return { node: byName, confident: true };
        const prefix = err.name.split('.')[0];
        if (prefix) anchorCard = document.querySelector(`[data-ff-name^="${prefix}."]`);
      }
      // 2) Best-scoring Field by label token overlap.
      const all = [...document.querySelectorAll('[data-ff-label]')];
      const ranked = all
        .map(el => ({ el, score: scoreNode(el) }))
        .filter(r => r.score > 0)
        .sort((a, b) => b.score - a.score);
      if (!ranked.length) return { node: anchorCard, confident: false };
      const top = ranked[0];
      const tied = ranked.filter(r => r.score === top.score);
      const pick = (itemIdx >= 0 && tied.length > itemIdx) ? tied[itemIdx].el : top.el;
      // Only a strong match (exact / prefix / contains, score >= 600) is
      // trusted enough to HIGHLIGHT. Weak token-overlap guesses were the cause
      // of "the wrong field gets highlighted" — for those, land on the anchor
      // card (or the weak guess if there's no card) but don't paint a border.
      if (top.score >= 600) return { node: pick, confident: true };
      return { node: anchorCard || pick, confident: false };
    };
    const doScroll = () => {
      // Clear any previous sticky highlight before adding a new one
      document.querySelectorAll('.ff-field-target').forEach(n => n.classList.remove('ff-field-target'));
      const { node, confident } = findNode();
      const target = node || document.querySelector('.ff-section-head');
      if (!target) return;
      const top = target.getBoundingClientRect().top + window.scrollY - (confident && node ? 120 : 80);
      // Use 'auto' (instant) — when the section has just changed the document
      // height shifts sharply and a smooth scroll gets visibly cancelled.
      window.scrollTo({ top, behavior: 'auto' });
      // Persistent red border ONLY on a confident match, so a low-confidence
      // guess never highlights the wrong field.
      if (confident && node) {
        node.classList.add('ff-field-target');
        const clear = () => {
          node.classList.remove('ff-field-target');
          node.removeEventListener('focusin', clear);
          node.removeEventListener('click', clear);
        };
        node.addEventListener('focusin', clear);
        node.addEventListener('click', clear);
      }
    };
    if (targetSection !== sectionId) {
      skipNextClear.current = true;
      setSectionId(targetSection);
      // Wait long enough for the new section's React tree to commit + paint.
      // 200ms covers heavy sections (Insurance, Personal) on slower devices.
      setTimeout(doScroll, 200);
    } else {
      doScroll();
    }
  }

  if (authChecking) {
    return (
      <div className="ff-page" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
        <div style={{ color: '#6E6A5F', fontSize: 14 }}>Loading…</div>
      </div>
    );
  }
  if (!signedIn) return <SignInScreen onSignedIn={handleSignedIn} />;
  if (submitted) return <ConfirmScreen code={saveCode} result={submitResult} onBack={() => setSubmitted(false)} />;

  const sectionMeta = visibleSections[safeIdx] || SECTIONS[0];

  return (
    <div className="ff-page">
      <FFHeader email={signedInEmail} onSignOut={handleSignOut} />
      {assisted && (
        <div style={{ background: '#1B3058', color: '#fff', textAlign: 'center', padding: '8px 14px', fontSize: 13, fontWeight: 600 }}>
          Adviser-assisted mode - completing on behalf of {signedInEmail || 'the client'}. On submit, they'll be emailed to verify.
        </div>
      )}
      {readOnly && !editing && (
        <div className="ff-staff-bar ff-staff-bar-ro">
          <span>
            Read-only view - {signedInEmail || 'this client'}'s answers.
            {canEdit
              ? ' Switch on editing to correct them on the client\'s behalf.'
              : ' You can view but not edit this fact find.'}
          </span>
          {canEdit && (
            <button type="button" className="ff-staff-bar-btn" onClick={() => setEditing(true)}>
              Enable editing
            </button>
          )}
        </div>
      )}
      {readOnly && editing && (
        <div className="ff-staff-bar ff-staff-bar-edit">
          <span>
            Editing {signedInEmail || 'this client'}'s answers - changes save automatically to their record.
            {sectionId === 'documents' && ' Documents stay read-only.'}
          </span>
          <button type="button" className="ff-staff-bar-btn" onClick={() => setEditing(false)}>
            Stop editing
          </button>
        </div>
      )}
      <FFHero docsOnly={docsOnlyFlow} />
      <FFRail scores={scores} activeId={sectionId} setActiveId={setSectionId} overall={overall} sections={visibleSections} />

      <div className="ff-layout">
        <aside className="ff-sidebar">
          <nav className="ff-nav">
            {visibleSections.map((s, i) => {
              const done = scores[s.id] >= 0.9;
              const active = s.id === sectionId;
              return (
                <button key={s.id} type="button"
                className={cx('ff-nav-item', active && 'active', done && !active && 'complete')}
                onClick={() => setSectionId(s.id)}>
                  <span className="ff-nav-num">{s.num}</span>
                  <span>{s.label}</span>
                  {done && !active && <span className="ff-nav-check">✓</span>}
                </button>);

            })}
          </nav>
          <div className="ff-privacy">
            <div className="ff-privacy-title"><span className="lock">🔒</span> Your data is safe</div>
            Encrypted in transit and at rest. Stored on Australian servers. Your adviser is the only person who'll access it.
            <div style={{ marginTop: 8 }}>
              <a className="ff-privacy-link"
                href="https://static1.squarespace.com/static/6268876d1924f556e08871cd/t/6a38c742df75ec2ce68704ee/1782105922598/SFP+Privacy+Policy.pdf"
                target="_blank" rel="noopener noreferrer">
                Read our privacy policy →
              </a>
            </div>
          </div>
          {/* "Resume from a code" + "Admin preview" cards were design-time
              affordances and have been removed from the client view. Resume
              still works via the cached saveCode in localStorage and via the
              session cookie. Adviser portal is at /portal — staff bookmark it
              directly. */}
        </aside>

        <main className="ff-main">
          <div className="ff-section-head">
            <div className="eyebrow">Section {sectionMeta.num} · {visibleSections.length} total</div>
            <h2>{sectionMeta.label}</h2>
            <p>{
              {
                personal: 'Tell us about you and your household. This helps us tailor advice to your actual situation.',
                assets: 'A complete picture of what you own and what you owe.',
                income: 'Understanding your money coming in and going out lets us build a realistic plan.',
                insurance: 'Your current cover and health context. All information is kept strictly confidential.',
                goals: 'How you feel about risk, and what you want your money to do.',
                documents: 'Upload the files your adviser needs so they can complete your strategy without chasing paperwork.'
              }[sectionId]
              }</p>
          </div>

          {errorList.length > 0 && (() => {
            // Group by section so cross-form submit shows a clear breakdown
            const groups = [];
            const seen = new Map();
            errorList.forEach(e => {
              const key = e.sectionId || sectionId;
              if (!seen.has(key)) {
                const arr = [];
                seen.set(key, arr);
                groups.push({ sectionId: key, label: e.sectionLabel || '', items: arr });
              }
              seen.get(key).push(e);
            });
            const showSections = groups.length > 1;
            // For couples, make clear WHOSE information is missing by
            // prefixing each item with the person's first name (and dropping
            // the now-redundant "Partner - " prefix some labels carry).
            const _pri = data?.personal?.primary || {};
            const _isCouple = ['Married', 'De facto'].includes(_pri.relStatus) && _pri.coupleAdvice === true;
            const _priFirst = (_pri.firstName || '').trim();
            const _parFirst = ((data?.personal?.partner || {}).firstName || '').trim();
            const personLabel = (e) => {
              let lbl = e.label || '';
              if (!_isCouple) return lbl;
              if (e.person === 'partner') lbl = lbl.replace(/^Partner\s*-\s*/i, '').replace(/^Partner\s+/i, '');
              const who = e.person === 'partner' ? (_parFirst || 'Partner') : (_priFirst || 'You');
              return `${who}: ${lbl}`;
            };
            return (
              <div ref={errorBannerRef} className="ff-error-banner" role="alert">
                <div className="ff-error-banner-head">
                  <div className="ff-error-banner-icon">!</div>
                  <div className="ff-error-banner-title">
                    {errorList.length} {errorList.length === 1 ? 'field needs' : 'fields need'} your attention before continuing
                  </div>
                </div>
                <div className="ff-error-banner-helper">
                  <strong>Tip:</strong> click any link below to jump straight to the field that needs your attention.
                </div>
                {groups.map(g => (
                  <div key={g.sectionId} className="ff-error-banner-group">
                    {showSections && (
                      <div className="ff-error-banner-section-label">
                        <button type="button" className="ff-error-banner-section-link" onClick={() => jumpToError({ sectionId: g.sectionId, name: g.items[0]?.name })}>
                          {g.label} →
                        </button>
                        <span className="ff-error-banner-section-count">{g.items.length} {g.items.length === 1 ? 'item' : 'items'}</span>
                      </div>
                    )}
                    <ul className="ff-error-banner-list">
                      {g.items.map((e, i) => (
                        <li key={i}>
                          <button type="button" className="ff-error-banner-link" onClick={() => jumpToError(e)}>
                            <span className="ff-error-banner-link-text">{personLabel(e)}</span>
                            <span className="ff-error-banner-link-cta" aria-hidden="true">Jump to field →</span>
                          </button>
                        </li>
                      ))}
                    </ul>
                  </div>
                ))}
              </div>
            );
          })()}

          {/* Documents stay inert even with editing on: an adviser correcting
              a figure shouldn't be able to delete the client's uploaded
              evidence from this screen. Downloads live in the portal. */}
          <div className={(locked || (editing && sectionId === 'documents')) ? 'ff-ro-lock' : undefined}>
          <ValidationCtx.Provider value={{ errors: errorSet, errorLabels: errorLabelSet }}>
            {(() => {
              // Ensure a saveCode exists on demand. The Send-reminder
              // button (and Documents uploads) need a server-side row;
              // if the client hasn't typed enough to trigger autosave
              // yet, we create one synchronously.
              const ensureSaveCode = async () => {
                if (saveCode) return saveCode;
                const c = await serverCreate({ data, sectionId, clientEmail: signedInEmail, inviteToken });
                setSaveCode(c);
                window.TwApi.Cache.setCode(c);
                return c;
              };
              if (sectionId === 'personal')  return <SectionPersonal  data={data} set={setData} saveCode={saveCode} ensureSaveCode={ensureSaveCode} />;
              if (sectionId === 'assets')    return <SectionAssets    data={data} set={setData} />;
              if (sectionId === 'income')    return <SectionIncome    data={data} set={setData} />;
              if (sectionId === 'insurance') return <SectionInsurance data={data} set={setData} saveCode={saveCode} ensureSaveCode={ensureSaveCode} />;
              if (sectionId === 'goals')     return <SectionGoals     data={data} set={setData} />;
              if (sectionId === 'documents') return <SectionDocuments data={data} set={setData} saveCode={saveCode} ensureSaveCode={ensureSaveCode} tpaForm={(pendingInvite && pendingInvite.tpaForm) || null} />;
              return null;
            })()}
          </ValidationCtx.Provider>
          </div>

          <div className="ff-footer">
            {readOnly ? (
              // Staff view: plain section navigation. No "Save & continue
              // later" and no Submit even while editing - a back-office
              // correction must never re-stamp the client's submission or
              // fire the submitted/resubmitted chain. Edits autosave.
              <>
                <div className="row" style={{ gap: 10 }}>
                  {currentIdx > 0 &&
                    <button type="button" className="btn btn-ghost" onClick={() => { setSectionId(visibleSections[safeIdx - 1].id); window.scrollTo({ top: 0, behavior: 'smooth' }); }}>← Back</button>
                  }
                </div>
                <div className="ff-footer-status">
                  {editing ? (
                    <>
                      <span className="dot"></span>{' '}
                      <span className="ok">{
                        autosaveStatus === 'saving' ? 'Saving…'
                        : autosaveStatus === 'saved' ? 'Saved to the client\'s record'
                        : autosaveStatus === 'error' ? 'Save failed, will retry'
                        : 'Editing - changes save automatically'
                      }</span>
                    </>
                  ) : <span className="ok">Read-only view</span>}
                </div>
                {safeIdx < visibleSections.length - 1 &&
                  <button type="button" className="btn btn-primary" onClick={() => { setSectionId(visibleSections[safeIdx + 1].id); window.scrollTo({ top: 0, behavior: 'smooth' }); }}>
                    Next: {visibleSections[safeIdx + 1]?.label || ''} →
                  </button>
                }
              </>
            ) : (
              <>
                <div className="row" style={{ gap: 10 }}>
                  {currentIdx > 0 &&
                  <button type="button" className="btn btn-ghost" onClick={back}>← Back</button>
                  }
                  <button type="button" className="btn btn-ghost btn-sm" onClick={doSave}>
                    Save & continue later
                  </button>
                </div>
                <div className="ff-footer-status">
                  <span className="dot"></span>{' '}
                  <span className="ok">{
                    autosaveStatus === 'saving' ? 'Saving…'
                    : autosaveStatus === 'saved' ? (saveCode ? `Auto-saved · ${saveCode}` : 'Auto-saved')
                    : autosaveStatus === 'error' ? 'Save failed, will retry'
                    : 'Auto-saving'
                  }</span>
                </div>
                <button type="button" className={cx('btn', safeIdx === visibleSections.length - 1 ? 'btn-green' : 'btn-primary')} onClick={next} disabled={submitting}>
                  {submitting ? 'Submitting…' : safeIdx === visibleSections.length - 1 ? (docsOnlyFlow ? 'Submit documents →' : 'Submit fact-find →') : `Next: ${visibleSections[safeIdx + 1]?.label || ''} →`}
                </button>
              </>
            )}
          </div>
        </main>
      </div>

      {toast &&
      <div className={cx('ff-toast', `ff-toast-${toast.kind}`)}>{toast.msg}</div>
      }

      {partnerGate && (
        <PartnerSubmitGateModal
          partner={partnerGate.partner}
          saveCode={partnerGate.saveCode || saveCode}
          onClose={() => setPartnerGate(null)} />
      )}

      <SupportButton context={`Section ${sectionMeta.num} · ${sectionMeta.label}`} clientEmail={signedInEmail} />
    </div>);

}

// ─── Partner submit-gate modal ───────────────────────────────
// Fired when the server returns 409 partner_not_submitted on submit -
// i.e. the primary asked their partner to complete their own half, but
// the partner's fact-find isn't in `submitted` state yet. Surfaces the
// blocking copy + a one-click "Send reminder" button.
function PartnerSubmitGateModal({ partner, saveCode, onClose }) {
  const name = (() => {
    const n = [partner?.firstName, partner?.lastName].filter(Boolean).join(' ').trim();
    return n || 'your partner';
  })();
  const partnerEmail = partner?.email || '';
  const [sending, setSending] = useState(false);
  const [status, setStatus] = useState(null); // null | 'ok' | 'fail'
  const [errMsg, setErrMsg] = useState('');
  const [sentTo, setSentTo] = useState('');

  async function sendReminder() {
    if (sending) return;
    setSending(true); setErrMsg('');
    try {
      if (!saveCode) {
        setStatus('fail');
        setErrMsg('We could not find a save code for your fact-find. Please type a few details first, then try again.');
        return;
      }
      const r = await fetch(`/api/factfind/${encodeURIComponent(saveCode)}/remind-partner`, {
        method: 'POST', credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json' }, body: '{}',
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) {
        const code = j?.error || ('HTTP ' + r.status);
        const friendly = code === 'no_partner'
          ? 'Your invite is not linked to a partner. Please ask your adviser to update the invite.'
          : code === 'partner_no_email'
            ? 'We do not have an email address on file for your partner. Please ask your adviser to add it.'
            : `We could not send the reminder (${code}). Please try again or contact your adviser.`;
        setStatus('fail');
        setErrMsg(friendly);
        return;
      }
      const ok = j.emailDelivered !== false;
      setStatus(ok ? 'ok' : 'fail');
      if (!ok) setErrMsg(j.emailError || 'The reminder could not be delivered. Please try again or contact your adviser.');
      else setSentTo(j.sentTo || j.partner?.email || partnerEmail || '');
    } catch (e) {
      setStatus('fail');
      setErrMsg(e.message || 'Could not send the reminder.');
    } finally {
      setSending(false);
    }
  }

  // After a successful send, swap to a confirmation modal so the
  // primary gets an unambiguous "Yes - it's been sent to X" beat
  // rather than a small inline message under the action button.
  if (status === 'ok') {
    return (
      <div className="ff-modal-backdrop" onClick={onClose}>
        <div className="ff-modal" style={{ maxWidth: 460 }} onClick={(e) => e.stopPropagation()}>
          <div className="modal-title">Reminder sent</div>
          <div style={{ color: '#4A4A46', fontSize: 14, lineHeight: 1.55, marginBottom: 14 }}>
            We've sent {name} an email
            {sentTo ? <> at <strong>{sentTo}</strong></> : null}
            {' '}asking them to complete their part of the Fact Find.
          </div>
          <div style={{ color: 'var(--mute)', fontSize: 12, lineHeight: 1.55, marginBottom: 18 }}>
            Once they've submitted, you'll be able to submit yours too. Pop back in tomorrow if you haven't heard from them.
          </div>
          <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
            <button type="button" className="btn btn-primary" onClick={onClose}>Got it</button>
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="ff-modal-backdrop" onClick={onClose}>
      <div className="ff-modal" style={{ maxWidth: 520 }} onClick={(e) => e.stopPropagation()}>
        <div className="modal-title">{name} needs to complete their half first</div>
        <div style={{ color: '#4A4A46', fontSize: 14, lineHeight: 1.55, marginBottom: 14 }}>
          Your partner needs to complete their relevant sections before you can submit this information. You can send them a reminder now if it helps move things along.
        </div>
        {/* Reassure the primary about exactly which inbox the email
            will land in, before they hit Send. */}
        {partnerEmail && (
          <div style={{ marginBottom: 18, padding: '10px 12px', background: '#F7F5EC', border: '1px solid #DDD8C6', borderRadius: 6, fontSize: 13, color: 'var(--navy)' }}>
            <span style={{ color: 'var(--mute)', marginRight: 6 }}>Reminder will be sent to:</span>
            <strong>{partnerEmail}</strong>
          </div>
        )}
        {status === 'fail' && (
          <div style={{ marginBottom: 14, padding: '10px 12px', background: '#FBEAEE', border: '1px solid #A6412F', borderRadius: 6, color: '#7A0023', fontSize: 13 }}>
            ⚠ {errMsg}
          </div>
        )}
        <div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
          <button type="button" className="btn btn-ghost" onClick={onClose}>Close</button>
          <button type="button" className="btn btn-primary" onClick={sendReminder} disabled={sending}>
            {sending ? 'Sending…' : `Send ${name} a reminder`}
          </button>
        </div>
      </div>
    </div>
  );
}

// ─── Resume panel ────────────────────────────────────────────
function ResumePanel({ resumeInput, setResumeInput, doResume, currentCode }) {
  const [open, setOpen] = useState(false);
  return (
    <div className="ff-privacy" style={{ marginTop: 12 }}>
      <div className="ff-privacy-title">
        <span style={{ color: 'var(--olive-deep)' }}>↺</span> Resume from a code
      </div>
      {currentCode && (
        <div style={{ marginTop: 6, fontSize: 12 }}>
          Your save code: <strong style={{ letterSpacing: '0.04em' }}>{currentCode}</strong>
          <div style={{ color: 'var(--mute)', fontSize: 11, marginTop: 4 }}>
            Note this down. You can use it to come back later from another device.
          </div>
        </div>
      )}
      {!open ? (
        <button type="button" className="link-btn" style={{ marginTop: 6, fontSize: 12 }} onClick={() => setOpen(true)}>
          Got a code from a previous session? →
        </button>
      ) : (
        <div style={{ marginTop: 8, display: 'flex', gap: 6 }}>
          <input
            type="text"
            value={resumeInput}
            placeholder="ABC-DEF-GHJ"
            onChange={e => setResumeInput(e.target.value.toUpperCase())}
            onKeyDown={e => e.key === 'Enter' && (doResume(), setOpen(false))}
            style={{ flex: 1, padding: '6px 8px', borderRadius: 6, border: '1px solid var(--line)', fontSize: 13, fontFamily: 'inherit', letterSpacing: '0.04em', textTransform: 'uppercase' }}
          />
          <button type="button" className="btn btn-primary btn-sm" onClick={() => { doResume(); setOpen(false); }}>Go</button>
        </div>
      )}
    </div>
  );
}

// ─── Header ──────────────────────────────────────────────────
function FFHeader({ email, onSignOut }) {
  return (
    <header className="ff-header">
      <div className="ff-logo">
        <img src="/assets/logo-reverse.png" alt="Tallowwood Wealth Advice" className="ff-logo-img" />
      </div>
      <div className="ff-header-right">
        {email && (
          <>
            <div className="ff-header-user">
              <span className="ff-header-user-label">Signed in as</span>
              <span className="ff-header-user-email">{email}</span>
            </div>
            <button type="button" className="ff-header-signout" onClick={onSignOut}>Sign out</button>
          </>
        )}
        {!email && <div className="ff-header-tag">Client Portal</div>}
      </div>
    </header>);

}

// ─── Hero ────────────────────────────────────────────────────
function FFHero({ docsOnly }) {
  return (
    <section className="ff-hero">
      <div className="ff-hero-inner">
        <div className="ff-hero-copy">
          <div className="eyebrow ff-hero-eyebrow">{docsOnly ? 'Document Upload' : 'Financial Fact Find'}</div>
          <h1>{docsOnly ? <>Let's gather your <em>documents.</em></> : <>Let's map your <em>money story.</em></>}</h1>
          <p className="ff-hero-sub">{docsOnly
            ? 'Your adviser has asked you to securely upload a few documents so they can prepare your advice without chasing paperwork. It only takes a few minutes, and because you have signed in, your uploads are saved so you can sign back in at any time and pick up where you left off.'
            : 'The below questionnaire is designed to ensure we have all of the relevant information possible to provide you the most relevant and optimised financial advice. This questionnaire should take approximately 1 hour to complete. Given you have signed in, your progress will be auto-saved so you can sign back in at any time and pick up where you left off.'}</p>
          <div className="ff-hero-meta">
            <div><span className="dot"></span>Encrypted AES-256</div>
            <div><span className="dot"></span>Australian servers</div>
            <div><span className="dot"></span>Privacy Act compliant</div>
            <div><span className="dot"></span>Auto-saves every change</div>
          </div>
        </div>
      </div>
    </section>);
}

// ─── Progress rail ───────────────────────────────────────────
function FFRail({ scores, activeId, setActiveId, overall, sections = SECTIONS }) {
  return (
    <div className="ff-rail">
      <div className="ff-rail-inner">
        <div className="ff-rail-label">Progress</div>
        <div className="ff-rail-steps">
          {sections.map((s) => {
            const score = scores[s.id];
            const active = s.id === activeId;
            const complete = score >= 0.9;
            return (
              <button key={s.id} type="button"
              className={cx('ff-rail-step', active && 'active', complete && 'complete')}
              onClick={() => setActiveId(s.id)}
              title={s.label}>
                
                <div className="ff-rail-bar" />
                <div className="ff-rail-txt">{s.num} {s.label.split(' ')[0]}</div>
              </button>);

          })}
        </div>
        <div className="ff-rail-pct">{overall}%</div>
      </div>
    </div>);

}

// ─── Confirm ─────────────────────────────────────────────────
function ConfirmScreen({ code, result, onBack }) {
  const ref = result?.referenceNo || ('TW-' + new Date().getFullYear() + '-pending');
  // Client-side PDF download removed per Ashley's spec - clients shouldn't
  // be able to pull a copy of their submitted fact-find. The adviser
  // portal's "Download all (ZIP)" is the authorised path.
  const [fb, setFb] = useState('');
  const [fbState, setFbState] = useState('idle'); // idle | sending | sent | error
  // Land at the top of the page when the confirmation appears. Submit is
  // often triggered from the bottom of a long form, which would otherwise
  // leave the user scrolled half-way down the confirmation panel.
  useEffect(() => { window.scrollTo({ top: 0, behavior: 'auto' }); }, []);
  async function sendFeedback() {
    const msg = fb.trim();
    if (!msg || fbState === 'sending') return;
    setFbState('sending');
    try {
      const r = await fetch(`/api/factfind/${encodeURIComponent(code || 'unknown')}/feedback`, {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: msg }),
      });
      if (!r.ok) throw new Error('send_failed');
      setFbState('sent');
    } catch (e) {
      setFbState('error');
    }
  }
  return (
    <div className="ff-page">
      <FFHeader />
      <div className="ff-confirm">
        <div className="ff-confirm-icon">✓</div>
        <div className="eyebrow" style={{ color: 'var(--olive-deep)' }}>Submitted</div>
        <h2>Nice work.</h2>
        <p>Your fact-find is with your Tallowwood adviser. They'll review it and be in touch to book your strategy session.</p>
        <div className="ref">REF: {ref}</div>

        <div style={{ borderTop: '1px solid #E7E6DF', paddingTop: 24, marginTop: 28, textAlign: 'left' }}>
          <div style={{ color: '#1B3058', fontWeight: 700, fontSize: 15, marginBottom: 6 }}>Help us improve</div>
          <p style={{ color: '#5B6170', fontSize: 14, lineHeight: 1.6, margin: '0 0 14px' }}>
            We have recently made some changes to our data collection process and would love to hear how we could improve this further. Please provide any feedback below.
          </p>
          {fbState === 'sent' ? (
            <div style={{ background: '#EAF6EE', border: '1px solid #BBE3C9', borderRadius: 10, padding: '14px 16px', color: '#176B38', fontSize: 14, lineHeight: 1.55 }}>
              Thanks for your feedback. It's been sent to our team.
            </div>
          ) : (
            <>
              <textarea
                rows={4}
                value={fb}
                onChange={e => setFb(e.target.value)}
                disabled={fbState === 'sending'}
                placeholder="Your feedback (optional)"
                style={{ width: '100%', boxSizing: 'border-box', border: '1px solid #D9D8D0', borderRadius: 10, padding: '12px 14px', fontFamily: 'inherit', fontSize: 14, color: '#1B3058', resize: 'vertical' }}
              />
              {fbState === 'error' && (
                <div style={{ color: '#A6412F', fontSize: 13, marginTop: 8 }}>
                  Sorry, that didn't send. Please try again, or email tallowwood@tallowwoodwealth.com.au.
                </div>
              )}
              <div style={{ textAlign: 'center', marginTop: 12 }}>
                <button type="button" className="btn btn-primary"
                  onClick={sendFeedback}
                  disabled={!fb.trim() || fbState === 'sending'}>
                  {fbState === 'sending' ? 'Sending…' : 'Submit feedback'}
                </button>
              </div>
            </>
          )}
        </div>

        <div style={{ display: 'flex', gap: 10, justifyContent: 'center', flexWrap: 'wrap', marginTop: 24 }}>
          <button className="btn btn-ghost" onClick={onBack}>← Back to form</button>
        </div>
      </div>
      <SupportButton context="Submission confirmation" />
    </div>);

}

// ─── Floating support button ─────────────────────────────────
const SUPPORT_EMAIL = 'tallowwood@tallowwoodwealth.com.au';

function SupportButton({ context, clientEmail }) {
  const [open, setOpen] = useState(false);
  const [subject, setSubject] = useState('');
  const [message, setMessage] = useState('');
  const [name, setName] = useState('');
  const [from, setFrom] = useState(clientEmail || '');

  useEffect(() => {
    if (clientEmail && !from) setFrom(clientEmail);
  }, [clientEmail]);

  // Esc to close
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open]);

  function send() {
    const subj = subject.trim() || 'Support request: Tallowwood Wealth Advice fact-find';
    const lines = [
      message.trim() || '(no message)',
      '',
      '- - - - - - - - - - -',
      'Sent from the Tallowwood Wealth Advice client fact-find.',
      name && `Name: ${name}`,
      from && `Reply-to: ${from}`,
      context && `Context: ${context}`,
      `Time: ${new Date().toLocaleString()}`
    ].filter(Boolean).join('\n');
    const href = `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subj)}&body=${encodeURIComponent(lines)}`;
    window.location.href = href;
    setOpen(false);
    // Clear after a beat so the modal close animation is clean
    setTimeout(() => { setSubject(''); setMessage(''); }, 300);
  }

  return (
    <>
      <button type="button"
        className={cx('support-fab', open && 'open')}
        onClick={() => setOpen(o => !o)}
        aria-label="Contact support"
        title="Contact support">
        {open ? '×' : (
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
          </svg>
        )}
      </button>
      {open && (
        <div className="support-panel" role="dialog" aria-modal="false" aria-labelledby="supportTitle">
          <div className="support-head">
            <div className="eyebrow">Need a hand?</div>
            <h3 id="supportTitle">Contact your adviser team</h3>
            <p className="support-sub">Send us a message and we'll get back to you within 1 business day. This will open your email app, ready to send.</p>
          </div>
          <div className="support-body">
            <label className="support-field">
              <span>Your name</span>
              <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Optional" />
            </label>
            <label className="support-field">
              <span>Reply-to email</span>
              <input type="email" value={from} onChange={(e) => setFrom(e.target.value)} placeholder="you@example.com" />
            </label>
            <label className="support-field">
              <span>Subject</span>
              <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="What do you need help with?" />
            </label>
            <label className="support-field">
              <span>Message</span>
              <textarea rows="4" value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Tell us what's going on…" />
            </label>
            <div className="support-meta">
              We'll see which section you're on{context ? ` (${context})` : ''} so we can help faster.
            </div>
          </div>
          <div className="support-foot">
            <button type="button" className="btn btn-ghost btn-sm" onClick={() => setOpen(false)}>Cancel</button>
            <button type="button" className="btn btn-primary btn-sm" onClick={send}>Open in email →</button>
          </div>
          <div className="support-direct">
            Or email us directly: <a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
          </div>
        </div>
      )}
    </>
  );
}

// Mount
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<FactFindApp />);