// ─────────────────────────────────────────────────────────────
// Fact-find validation rules
//
// Validators are pure functions: (data) => [{ name, label }] of
// missing required fields. The `name` is a stable string the
// matching <Field name="..."> uses to decide whether to show an
// error state. `label` is the human description shown in the
// "fields needing attention" banner.
//
// Conditional rules live here - e.g. "smokePerDay" is only
// required if `insurance.smoke === 'Currently or in the last 12 months'".
// Every section's validator follows the same shape.
// ─────────────────────────────────────────────────────────────

(function () {
  // Environment-safe global: this module runs BOTH in the browser (loaded
  // as a <script>, attaches window.validateSection) AND on the server
  // (required by the adviser dashboard to compute completion with the real
  // validator). globalThis === window in the browser, so DOC_CATALOGUE /
  // DEFAULT_REQUIRED_DOCS set on window are still reachable there; on the
  // server those are simply absent (|| [] fallbacks handle it).
  const GLOBAL = (typeof globalThis !== 'undefined') ? globalThis
               : (typeof window !== 'undefined') ? window : {};

  // The medical-history question catalogue. In the browser it's a
  // <script> loaded ahead of this file; on the server it's a sibling
  // module. Held loosely so a page that forgets to load it degrades to
  // the old all-or-nothing medical-history rule instead of blowing up.
  let MED_CAT = GLOBAL.MED_CATALOGUE || null;
  if (!MED_CAT && typeof require === 'function') {
    try { MED_CAT = require('./med-catalogue.js'); } catch (e) { MED_CAT = null; }
  }

  // ─── Helpers ──────────────────────────────────────────────
  function isEmpty(v) {
    if (v === null || v === undefined) return true;
    if (typeof v === 'string') return v.trim() === '';
    if (Array.isArray(v)) return v.length === 0;
    return false; // numbers (incl. 0), booleans (incl. false), objects → considered answered
  }
  function isBlank(v) {
    // For YesNo where false is a valid answer; only null/undefined are missing
    return v === null || v === undefined;
  }
  function need(errors, cond, name, label) {
    if (cond) errors.push({ name, label });
  }
  // Valid Australian mobile: 10 digits starting 04 (spaces/brackets/dashes
  // ignored), or the +61 / 61 international form (which we normalise to 04…).
  function isValidAuMobile(v) {
    let s = String(v || '').replace(/[\s()\-.]/g, '');
    if (s.startsWith('+61')) s = '0' + s.slice(3);
    else if (s.startsWith('61') && s.length === 11) s = '0' + s.slice(2);
    return /^04\d{8}$/.test(s);
  }
  // Tax file number: exactly 9 digits (spaces ignored). Returns false when
  // empty, so a single !isValidTfn() check covers both required + length.
  function isValidTfn(v) {
    return /^\d{9}$/.test(String(v || '').replace(/\D/g, ''));
  }
  // Does this person's residential address contain a street/unit number?
  function hasStreetNumber(person) {
    const a = String((person && person.address) || '').trim();
    if (!a) return false; // no address at all is caught by the address-required rule
    if (person.streetNo && String(person.streetNo).trim()) return true;
    if (person.addressParts && person.addressParts.streetNumber) return true;
    return /^\d/.test(a);
  }

  // ─── Personal ─────────────────────────────────────────────
  function validatePersonal(data) {
    const errors = [];
    const p = data?.personal?.primary || {};
    const tab = data?.personal?.activeTab || 'primary';

    const isCouple = ['Married','De facto'].includes(p.relStatus);
    // Partner panel only validates if the client said they're getting advice
    // as a couple. If "No" (or unanswered for non-coupled status) we never
    // require any partner data even if the user accidentally typed into the
    // hidden tab earlier.
    //
    // If the client picked "Send reminder for my partner to complete" at
    // the top of the partner tab, partner panel validation is SKIPPED
    // here on the primary side - the partner will fill their own
    // separate fact-find via their own invite link, and the /submit
    // endpoint blocks until the partner's fact-find is `submitted`.
    const partnerCompletion = data?.personal?.partnerCompletion;
    const partnerWillSelfComplete = partnerCompletion === 'remind';
    const partnerActive = isCouple && p.coupleAdvice === true && !partnerWillSelfComplete;

    // Helper: validate the partner tab using the same required set as
    // primary (less a few fields that are shared, like dependants/living
    // situation, which the primary owns).
    function pushPartnerErrors(target) {
      const pt = data?.personal?.partner || {};
      need(target, isEmpty(pt.firstName),  'pt.firstName',  'Partner first name');
      need(target, isEmpty(pt.lastName),   'pt.lastName',   'Partner surname');
      need(target, isEmpty(pt.dob),        'pt.dob',        'Partner date of birth');
      need(target, isEmpty(pt.gender),     'pt.gender',     'Partner gender');
      need(target, isEmpty(pt.citizenship),'pt.citizenship','Partner citizenship');
      if (pt.citizenship === 'Other') {
        need(target, isEmpty(pt.citizenshipOther), 'pt.citizenshipOther', 'Partner citizenship (please describe)');
      }
      if (pt.citizenship === 'Temporary Visa Holder') {
        need(target, isEmpty(pt.visaType), 'pt.visaType', 'Partner visa type');
      }
      need(target, isEmpty(pt.mobile) || !isValidAuMobile(pt.mobile), 'pt.mobile', 'Partner mobile (valid Australian mobile, e.g. 04XX XXX XXX)');
      need(target, isEmpty(pt.email),      'pt.email',      'Partner email');
      if (!isEmpty(pt.address)) {
        need(target, !hasStreetNumber(pt), 'pt.streetNo', 'Partner street / unit number');
      }
      need(target, isEmpty(pt.empBasis),   'pt.empBasis',   'Partner employment status');
      const ptHomemaker = pt.empBasis === 'Homemaker / Maternity leave';
      if (ptHomemaker) {
        need(target, isBlank(pt.intendReturn), 'pt.intendReturn', 'Does your partner intend to return to the workforce?');
        if (pt.intendReturn === true) {
          need(target, isEmpty(pt.returnWhen), 'pt.returnWhen', 'When does your partner intend to return?');
        }
      }
      if (!ptHomemaker || pt.intendReturn === true) {
        need(target, isEmpty(pt.occupation), 'pt.occupation', 'Partner occupation');
        need(target, isEmpty(pt.employer),   'pt.employer',   'Partner employer name');
        need(target, isEmpty(pt.industry),   'pt.industry',   'Partner industry');
        need(target, isEmpty(pt.tenure),     'pt.tenure',     'Partner time in current role');
        need(target, isBlank(pt.hasQualifications), 'pt.hasQualifications', 'Does your partner have a degree / qualifications for their role?');
        if (pt.hasQualifications === true) {
          need(target, isEmpty(pt.qualifications), 'pt.qualifications', 'Partner qualification');
        }
      }
      need(target, !isValidTfn(pt.tfn),    'pt.tfn',        'Partner tax file number (must be 9 digits)');
      need(target, isBlank(pt.hasWill),    'pt.hasWill',    'Partner current will?');
      if (pt.hasWill === true) {
        need(target, isEmpty(pt.willTrust), 'pt.willTrust', 'Partner - does will include a testamentary trust?');
      }
      need(target, isEmpty(pt.epoa),       'pt.epoa',       'Partner enduring power of attorney?');
      need(target, isEmpty(pt.superNom),   'pt.superNom',   'Partner super beneficiary nomination');
    }

    if (tab === 'primary') {
      need(errors, isEmpty(p.firstName), 'p.firstName', 'Legal first name');
      need(errors, isEmpty(p.lastName),  'p.lastName',  'Legal surname');
      need(errors, isEmpty(p.dob),       'p.dob',       'Date of birth');
      need(errors, isEmpty(p.gender),    'p.gender',    'Gender');
      need(errors, isEmpty(p.citizenship),'p.citizenship','Citizenship');
      if (p.citizenship === 'Other') {
        need(errors, isEmpty(p.citizenshipOther), 'p.citizenshipOther', 'Citizenship (please describe)');
      }
      if (p.citizenship === 'Temporary Visa Holder') {
        need(errors, isEmpty(p.visaType), 'p.visaType', 'Which visa are you on?');
      }
      need(errors, isEmpty(p.mobile) || !isValidAuMobile(p.mobile), 'p.mobile', 'Mobile (valid Australian mobile, e.g. 04XX XXX XXX)');
      need(errors, isEmpty(p.email),     'p.email',     'Email address');
      need(errors, isEmpty(p.address),   'p.address',   'Residential address');
      // If we couldn't detect a street number in the address, require one.
      if (!isEmpty(p.address)) {
        need(errors, !hasStreetNumber(p), 'p.streetNo', 'Street / unit number');
      }
      need(errors, isEmpty(p.relStatus), 'p.relStatus', 'Relationship status');
      // Divorced -> must say whether they're financially separated.
      if (p.relStatus === 'Divorced') {
        need(errors, isBlank(p.financiallySeparated), 'p.financiallySeparated', 'Are you financially separated?');
      }
      // Couple-advice answer is required as soon as status is Married/De facto
      if (isCouple) {
        need(errors, isBlank(p.coupleAdvice), 'p.coupleAdvice', 'Receiving advice as a couple?');
      }
      // Dependants Yes/No is mandatory regardless of relationship status.
      need(errors, isBlank(p.hasDeps), 'p.hasDeps', 'Do you have any dependants?');
      // Living situation mandatory.
      need(errors, isEmpty(p.living), 'p.living', 'Current living situation');
      if (p.living === 'Other') {
        need(errors, isEmpty(p.livingOther), 'p.livingOther', 'Living situation (please describe)');
      }
      need(errors, isEmpty(p.empBasis),  'p.empBasis',  'Working basis');
      // Homemaker / Maternity leave: ask whether they intend to return to
      // work. "No" skips the occupation/industry/tenure questions; "Yes"
      // requires a return timeframe plus the full work questions.
      const pHomemaker = p.empBasis === 'Homemaker / Maternity leave';
      if (pHomemaker) {
        need(errors, isBlank(p.intendReturn), 'p.intendReturn', 'Do you intend to return to the workforce?');
        if (p.intendReturn === true) {
          need(errors, isEmpty(p.returnWhen), 'p.returnWhen', 'When do you intend to return to work?');
        }
      }
      if (!pHomemaker || p.intendReturn === true) {
        need(errors, isEmpty(p.occupation),'p.occupation','Occupation / job title');
        need(errors, isEmpty(p.employer),  'p.employer',  'Employer name');
        need(errors, isEmpty(p.industry),  'p.industry',  'Industry');
        need(errors, isEmpty(p.tenure),    'p.tenure',    'Time in current role');
        need(errors, isBlank(p.hasQualifications), 'p.hasQualifications', 'Do you have a degree / qualifications for your role?');
        if (p.hasQualifications === true) {
          need(errors, isEmpty(p.qualifications), 'p.qualifications', 'Your qualification');
        }
      }
      need(errors, !isValidTfn(p.tfn),   'p.tfn',       'Tax file number (must be 9 digits)');

      // Postal address: if "different", postal address text required
      if (p.postalSame === false) {
        need(errors, isEmpty(p.postalAddress), 'p.postalAddress', 'Postal address');
      }

      // Dependants: if hasDeps=Yes, each row needs name + relationship + DOB.
      if (p.hasDeps === true) {
        const deps = p.deps || [];
        if (deps.length === 0) {
          errors.push({ name: 'p.deps', label: 'Dependant details' });
        } else {
          deps.forEach((dep, i) => {
            need(errors, isEmpty(dep.name), `p.dep${i}.name`, `Dependant ${i+1} - full name`);
            need(errors, isEmpty(dep.relationship), `p.dep${i}.rel`, `Dependant ${i+1} - relationship`);
            need(errors, isEmpty(dep.dob),  `p.dep${i}.dob`, `Dependant ${i+1} - date of birth`);
            need(errors, isEmpty(dep.gender), `p.dep${i}.gender`, `Dependant ${i+1} - gender`);
            if (dep.relationship === 'Other') {
              need(errors, isEmpty(dep.relationshipOther), `p.dep${i}.relOther`, `Dependant ${i+1} - relationship (please describe)`);
            }
          });
        }
      }

      // Will Yes/No mandatory, and if Yes the year is required.
      need(errors, isBlank(p.hasWill), 'p.hasWill', 'Do you have a current will?');
      if (p.hasWill === true) {
        need(errors, isEmpty(p.willYear), 'p.willYear', 'Year will last updated');
        need(errors, isEmpty(p.willTrust), 'p.willTrust', 'Does your will include a testamentary trust?');
      }
      // Enduring power of attorney is now mandatory.
      need(errors, isEmpty(p.epoa), 'p.epoa', 'Enduring power of attorney?');

      // Super beneficiary nomination mandatory.
      need(errors, isEmpty(p.superNom), 'p.superNom', 'Super beneficiary nomination');

      // If the client is in a couple AND getting joint advice, they MUST
      // pick how they're handling the partner's details (self vs remind).
      // Without this requirement the partner tab could be silently
      // skipped and the adviser ends up with no partner data + no
      // signal of intent.
      if (isCouple && p.coupleAdvice === true) {
        if (isEmpty(partnerCompletion)) {
          errors.push({ name: 'p.partnerCompletion', label: 'Pick how you\'ll handle your partner\'s details' });
        } else if (partnerActive) {
          // 'self' path - we still need the full partner panel data
          pushPartnerErrors(errors);
        }
        // 'remind' path - submission will be gated server-side; nothing
        // to enforce here on the primary tab.
      }
    } else if (partnerActive) {
      // Partner tab - only enforce if couple advice is active AND the
      // client opted to fill it themselves.
      pushPartnerErrors(errors);
    }
    return errors;
  }

  // ─── Assets ───────────────────────────────────────────────
  function validateAssets(data) {
    const errors = [];
    const a = data?.assets || {};

    // Personal assets. Vehicles are now a per-vehicle list (value + owner)
    // OR the explicit "I have no vehicles" toggle. Contents / jewellery
    // remain mandatory; otherAssets is optional.
    if (!a.vehiclesNone) {
      if (!(a.vehiclesList || []).length) {
        errors.push({ name: 'as.vehiclesList', label: 'Vehicles (add a vehicle, or tick "I have no vehicles")' });
      } else {
        (a.vehiclesList || []).forEach((v, i) => {
          need(errors, isEmpty(v.value), `as.veh${i}.value`, `Vehicle ${i+1} - value`);
          need(errors, isEmpty(v.owner), `as.veh${i}.owner`, `Vehicle ${i+1} - owner`);
        });
      }
    }
    need(errors, isEmpty(a.contents),  'as.contents',  'Home contents value');
    need(errors, isEmpty(a.jewellery), 'as.jewellery', 'Jewellery / collectables value');

    // Super funds - every added row must have its key fields. Member
    // number is now required (adviser needs it to lodge TPAs); investment
    // option remains optional.
    (a.supers || []).forEach((s, i) => {
      need(errors, isEmpty(s.fund),        `as.super${i}.fund`,         `Super fund ${i+1} - fund name`);
      need(errors, isEmpty(s.member),      `as.super${i}.member`,       `Super fund ${i+1} - member number`);
      need(errors, isEmpty(s.balance),     `as.super${i}.balance`,      `Super fund ${i+1} - balance`);
      need(errors, isEmpty(s.owner),       `as.super${i}.owner`,        `Super fund ${i+1} - owner`);
      need(errors, isBlank(s.hasInsurance),`as.super${i}.hasInsurance`, `Super fund ${i+1} - insurance inside super?`);
    });

    // Couple advice: prompt for the partner's super too. Require at least
    // one partner-owned super OR the explicit "[partner] has no super"
    // opt-out, mirroring the property/savings none-toggles so a super-less
    // partner isn't blocked.
    const personal = data?.personal || {};
    const pp = personal.primary || {};
    const partnerActive =
      (['Married', 'De facto'].includes(pp.relStatus) && pp.coupleAdvice === true)
      || personal.includePartner === true;
    if (partnerActive && !a.partnerSuperNone && !(a.supers || []).some(s => s.owner === 'Partner')) {
      errors.push({ name: 'as.partnerSuper', label: 'Add your partner\'s superannuation (or tick "my partner has no super")' });
    }

    // Properties - need either at least one property OR the explicit
    // "I don't own any property" toggle. Each row: full key set required;
    // year acquired captured as a mandatory field per Ashley's spec.
    if (!a.propertiesNone) {
      if (!(a.properties || []).length) {
        errors.push({ name: 'as.properties', label: 'Real estate (add a property, or tick "I don\'t own any property")' });
      } else {
        (a.properties || []).forEach((p, i) => {
          need(errors, isEmpty(p.address),     `as.prop${i}.address`,     `Property ${i+1} - address`);
          need(errors, isEmpty(p.use),         `as.prop${i}.use`,         `Property ${i+1} - use`);
          need(errors, isEmpty(p.ownership),   `as.prop${i}.ownership`,   `Property ${i+1} - ownership`);
          need(errors, isEmpty(p.value),       `as.prop${i}.value`,       `Property ${i+1} - estimated value`);
          need(errors, isEmpty(p.purchase),    `as.prop${i}.purchase`,    `Property ${i+1} - purchase price`);
          need(errors, isEmpty(p.yearAcquired),`as.prop${i}.yearAcquired`,`Property ${i+1} - year acquired`);
          need(errors, isBlank(p.hasLoan),     `as.prop${i}.hasLoan`,     `Property ${i+1} - has a loan against it?`);
        });
      }
    }

    // Cash & savings - need either at least one row OR the explicit
    // "I have no savings" toggle. Each row needs bank+type+balance.
    if (!a.savingsNone) {
      if (!(a.savings || []).length) {
        errors.push({ name: 'as.savings', label: 'Cash & savings (add at least one account, or tick "I have no savings")' });
      } else {
        (a.savings || []).forEach((s, i) => {
          need(errors, isEmpty(s.bank),      `as.cash${i}.bank`,      `Account ${i+1} - bank / institution`);
          need(errors, isEmpty(s.type),      `as.cash${i}.type`,      `Account ${i+1} - account type`);
          need(errors, isEmpty(s.balance),   `as.cash${i}.balance`,   `Account ${i+1} - balance`);
          need(errors, isEmpty(s.ownership), `as.cash${i}.ownership`, `Account ${i+1} - ownership`);
        });
      }
    }

    // Investments - same pattern: at least one row OR explicit None.
    if (!a.investmentsNone) {
      if (!(a.investments || []).length) {
        errors.push({ name: 'as.investments', label: 'Investments (add at least one, or tick "I have no investments")' });
      } else {
        (a.investments || []).forEach((inv, i) => {
          need(errors, isEmpty(inv.type),    `as.inv${i}.type`,    `Investment ${i+1} - type`);
          // For "Other" the typeOther free-text already describes the
          // holding, so the separate holding field isn't required.
          if (inv.type !== 'Other') {
            need(errors, isEmpty(inv.holding), `as.inv${i}.holding`, `Investment ${i+1} - holding / description`);
          }
          need(errors, isEmpty(inv.value),   `as.inv${i}.value`,   `Investment ${i+1} - current value`);
          need(errors, isEmpty(inv.owner),   `as.inv${i}.owner`,   `Investment ${i+1} - owner`);
          if (inv.type === 'Other') {
            need(errors, isEmpty(inv.typeOther), `as.inv${i}.typeOther`, `Investment ${i+1} - please describe`);
          }
          // Listed/managed/crypto holdings: which platform are they on?
          if (['Shares','ETFs','Managed fund','Crypto'].includes(inv.type)) {
            need(errors, isEmpty(inv.platform), `as.inv${i}.platform`, `Investment ${i+1} - platform held on`);
            if (inv.platform === 'Other') {
              need(errors, isEmpty(inv.platformOther), `as.inv${i}.platformOther`, `Investment ${i+1} - platform (please name)`);
            }
          }
        });
      }
    }

    // Cross-section: if the client said they're an owner with a mortgage
    // (Personal section), they must have at least one home-loan row here.
    // Without this it's easy to skip the loan section and have a fact-find
    // that says "Owner - with mortgage" but lists no mortgage.
    const living = data?.personal?.primary?.living;
    if (living === 'Owner - with mortgage') {
      const hasHomeLoan = (a.loans || []).some(ln => ln && ln.purpose === 'Home - owner occupied');
      if (!hasHomeLoan) {
        errors.push({ name: 'as.loans.homeloan', label: 'You told us you own with a mortgage - add a home loan in the Loans section below' });
      }
    }

    // Cross-section: every property flagged "has a loan against it"
    // needs at least one loan row in the Loans card. The UI auto-adds
    // one when the client answers Yes, but they can delete it - this
    // catches that gap (and legacy drafts created before the auto-add).
    const propsWithLoan = (a.properties || []).filter(p => p && p.hasLoan === true).length;
    if (propsWithLoan > 0 && !(a.loans || []).length) {
      errors.push({
        name: 'as.loans.homeloan',
        label: 'You told us a property has a loan against it - add the loan details in the Loans section below',
      });
    }

    // Loans - full set required; if rate type is Fixed, fixedEnd is also
    // required so the adviser knows when to revisit refinancing. Owner
    // (Primary / Partner / Joint) is also required so couple advice can
    // be modelled correctly.
    (a.loans || []).forEach((ln, i) => {
      need(errors, isEmpty(ln.lender),    `as.loan${i}.lender`,    `Loan ${i+1} - lender`);
      need(errors, isEmpty(ln.owner),     `as.loan${i}.owner`,     `Loan ${i+1} - owner`);
      need(errors, isEmpty(ln.purpose),   `as.loan${i}.purpose`,   `Loan ${i+1} - purpose`);
      need(errors, isEmpty(ln.balance),   `as.loan${i}.balance`,   `Loan ${i+1} - balance remaining`);
      need(errors, isEmpty(ln.repayment), `as.loan${i}.repayment`, `Loan ${i+1} - minimum repayment`);
      need(errors, isEmpty(ln.repaymentFreq), `as.loan${i}.repaymentFreq`, `Loan ${i+1} - repayment frequency`);
      need(errors, isBlank(ln.extraRepayYn), `as.loan${i}.extraRepayYn`, `Loan ${i+1} - paying extra on top of the minimum?`);
      if (ln.extraRepayYn === true) {
        need(errors, isEmpty(ln.extraRepayAmount), `as.loan${i}.extraRepayAmount`, `Loan ${i+1} - how much extra`);
        // Frequency only matters when the extra amount is non-zero ($0 means
        // "already included in the repayment figure above").
        const extraAmt = Number(String(ln.extraRepayAmount || '').replace(/[^0-9.]/g, ''));
        need(errors, extraAmt > 0 && isEmpty(ln.extraRepayFreq), `as.loan${i}.extraRepayFreq`, `Loan ${i+1} - extra repayment frequency`);
      }
      need(errors, isEmpty(ln.repaymentType), `as.loan${i}.repaymentType`, `Loan ${i+1} - repayment type`);
      need(errors, isEmpty(ln.rate),      `as.loan${i}.rate`,      `Loan ${i+1} - interest rate`);
      need(errors, isEmpty(ln.rateType),  `as.loan${i}.rateType`,  `Loan ${i+1} - rate type`);
      if (ln.rateType === 'Fixed') {
        need(errors, isEmpty(ln.fixedEnd), `as.loan${i}.fixedEnd`, `Loan ${i+1} - when does the fixed rate end?`);
      }
      if (ln.rateType === 'Split') {
        need(errors, isEmpty(ln.splitFixedAmount), `as.loan${i}.splitFixedAmount`, `Loan ${i+1} - how much of the loan is fixed?`);
        need(errors, isEmpty(ln.splitFixedEnd),    `as.loan${i}.splitFixedEnd`,    `Loan ${i+1} - when does the fixed portion end?`);
        need(errors, isEmpty(ln.splitFixedRate),   `as.loan${i}.splitFixedRate`,   `Loan ${i+1} - fixed interest rate`);
      }
      if (ln.repaymentType === 'Interest only') {
        need(errors, isEmpty(ln.ioEnd), `as.loan${i}.ioEnd`, `Loan ${i+1} - when does the interest-only period end?`);
      }
      // Novated lease: balloon / residual payment Y/N, plus due date and
      // amount when there is one.
      if (ln.purpose === 'Novated lease') {
        need(errors, isBlank(ln.balloonYn), `as.loan${i}.balloonYn`, `Loan ${i+1} - is there a balloon payment?`);
        if (ln.balloonYn === true) {
          need(errors, isEmpty(ln.balloonWhen),   `as.loan${i}.balloonWhen`,   `Loan ${i+1} - balloon payment due date`);
          need(errors, isEmpty(ln.balloonAmount), `as.loan${i}.balloonAmount`, `Loan ${i+1} - balloon payment amount`);
        }
      }
    });

    // Credit facilities - provider + owner + type + limit + balance. Credit
    // cards carrying a balance must also say whether it's paid off monthly.
    (a.credit || []).forEach((c, i) => {
      need(errors, isEmpty(c.provider), `as.credit${i}.provider`, `Credit facility ${i+1} - provider`);
      need(errors, isEmpty(c.owner),    `as.credit${i}.owner`,    `Credit facility ${i+1} - owner`);
      need(errors, isEmpty(c.type),     `as.credit${i}.type`,     `Credit facility ${i+1} - type`);
      need(errors, isEmpty(c.limit),    `as.credit${i}.limit`,    `Credit facility ${i+1} - credit limit`);
      need(errors, isEmpty(c.balance),  `as.credit${i}.balance`,  `Credit facility ${i+1} - current balance`);
      if (c.type === 'Credit card' && Number(String(c.balance).replace(/[^0-9.]/g, '')) > 0) {
        need(errors, isBlank(c.payFull), `as.credit${i}.payFull`, `Credit facility ${i+1} - paid off in full each month?`);
      }
    });

    // ATO debt: if Yes → amount + plan answer + owner required
    if (a.atoDebt === true) {
      need(errors, isEmpty(a.atoAmount), 'as.atoAmount', 'ATO debt amount');
      need(errors, isBlank(a.atoPlan),   'as.atoPlan',   'Payment plan in place?');
      need(errors, isEmpty(a.atoOwner),  'as.atoOwner',  'ATO debt owner');
    }
    // HELP/HECS: if Yes → at least one HELP debt, each with balance + owner.
    // (Repeatable so a couple can record one each.)
    if (a.hecs === true) {
      if (!(a.helpDebts || []).length) {
        errors.push({ name: 'as.help0.balance', label: 'HELP / HECS debt - add at least one (balance + owner)' });
      } else {
        (a.helpDebts || []).forEach((h, i) => {
          need(errors, isEmpty(h.balance), `as.help${i}.balance`, `HELP debt ${i+1} - estimated balance`);
          need(errors, isEmpty(h.owner),   `as.help${i}.owner`,   `HELP debt ${i+1} - owner`);
        });
      }
    }
    return errors;
  }

  // ─── Income ───────────────────────────────────────────────
  function validateIncome(data) {
    const errors = [];
    const i = data?.income || {};
    const primary = data?.personal?.primary || {};
    const isCouple = ['Married','De facto'].includes(primary.relStatus);
    const partnerIncomeActive = isCouple && primary.coupleAdvice === true;
    const hasInvestmentProperty = (data?.assets?.properties || []).some(p => p && p.use === 'Investment');

    // Primary employment income
    need(errors, isEmpty(i.baseSalary), 'i.baseSalary', 'Base salary');
    need(errors, isEmpty(i.payFreq),    'i.payFreq',    'Pay frequency');
    // Net take-home is informational - it's helpful for budgeting but
    // the adviser can derive it from base + super + tax, so the field
    // is no longer required.
    need(errors, isEmpty(i.sgRate),     'i.sgRate',     'Super guarantee rate');
    if (i.sgRate === 'Other') {
      need(errors, isEmpty(i.sgRateOther), 'i.sgRateOther', 'Specify SG rate');
    }
    // Salary packaging Y/N is mandatory, and if Yes the description is too.
    need(errors, isBlank(i.salPkgYn), 'i.salPkgYn', 'Do you have any salary packaging?');
    if (i.salPkgYn === true) {
      need(errors, isEmpty(i.salPkg), 'i.salPkg', 'Salary packaging description');
    }
    // Sick + annual leave balances - mandatory (drive IP waiting periods).
    need(errors, isEmpty(i.sickLeave),   'i.sickLeave',   'How much sick leave do you have?');
    need(errors, isEmpty(i.annualLeave), 'i.annualLeave', 'How much annual leave do you have?');
    // Secondary income Y/N mandatory; if Yes, amount + how-earned required.
    need(errors, isBlank(i.secondaryYn), 'i.secondaryYn', 'Do you have a secondary income?');
    if (i.secondaryYn === true) {
      need(errors, isEmpty(i.secondaryIncome), 'i.secondaryIncome', 'Secondary income - annual amount');
      need(errors, isEmpty(i.secondaryHow),    'i.secondaryHow',    'Secondary income - how is it earned?');
    }
    // Private hospital cover - mandatory (drives Medicare Levy Surcharge).
    need(errors, isBlank(i.privateHealth), 'i.privateHealth', 'Do you have private hospital cover?');

    // Bonus / commission: any non-zero figure requires a note on how
    // reliable it is year to year.
    if (Number(String(i.bonus || '').replace(/[^0-9.]/g, '')) > 0) {
      need(errors, isEmpty(i.bonusLikelihood), 'i.bonusLikelihood', 'How likely is this bonus on an annual basis?');
    }

    // Renter rent gate - if the client said "Renting" in Personal, the
    // standalone monthly rent field is required regardless of the
    // expense-mode they picked.
    if (primary.living === 'Renting') {
      need(errors, isEmpty(i.rentMonthly), 'i.rentMonthly', 'How much do you pay in rent? ($ per month)');
    }

    // Partner employment income - only when "advice as a couple" is true
    // AND the client hasn't said the partner is a homemaker.
    if (partnerIncomeActive && !i.partnerHomemaker) {
      need(errors, isEmpty(i.pBase),  'i.pBase',  'Partner base salary');
      need(errors, isEmpty(i.pFreq),  'i.pFreq',  'Partner pay frequency');
      // Net take-home is no longer required (same as primary).
      need(errors, isEmpty(i.pSg),    'i.pSg',    'Partner SG rate');
      if (i.pSg === 'Other') {
        need(errors, isEmpty(i.pSgOther), 'i.pSgOther', 'Specify partner SG rate');
      }
      need(errors, isEmpty(i.pSickLeave),   'i.pSickLeave',   'How much sick leave does your partner have?');
      need(errors, isEmpty(i.pAnnualLeave), 'i.pAnnualLeave', 'How much annual leave does your partner have?');
      // Partner salary packaging mirrors the primary requirement.
      need(errors, isBlank(i.pSalPkgYn), 'i.pSalPkgYn', 'Does your partner have salary packaging?');
      if (i.pSalPkgYn === true) {
        need(errors, isEmpty(i.pSalPkg), 'i.pSalPkg', 'Partner salary packaging description');
      }
      if (Number(String(i.pBonus || '').replace(/[^0-9.]/g, '')) > 0) {
        need(errors, isEmpty(i.pBonusLikelihood), 'i.pBonusLikelihood', 'How likely is your partner\'s bonus on an annual basis?');
      }
      need(errors, isBlank(i.pSecondaryYn), 'i.pSecondaryYn', 'Does your partner have a secondary income?');
      if (i.pSecondaryYn === true) {
        need(errors, isEmpty(i.pSecondaryIncome), 'i.pSecondaryIncome', 'Partner secondary income - annual amount');
        need(errors, isEmpty(i.pSecondaryHow),    'i.pSecondaryHow',    'Partner secondary income - how is it earned?');
      }
    }
    // Partner private hospital cover - required whenever advice is as a
    // couple (assessed per person for the MLS), even for a homemaker partner.
    if (partnerIncomeActive) {
      need(errors, isBlank(i.pPrivateHealth), 'i.pPrivateHealth', 'Does your partner have private hospital cover?');
    }

    // Rental income is mandatory only when the client has flagged an
    // investment property in the Assets section.
    if (hasInvestmentProperty) {
      need(errors, isEmpty(i.rental), 'i.rental', 'Rental income');
    }

    // Expenses - exactly one of three modes must be answered. Defaults to
    // 'quick' when not set so existing drafts validate against the old
    // quick-amount field if they had one.
    const mode = i.expMode === 'detail' ? 'detail'
              : i.expMode === 'surplus' ? 'surplus'
              : i.expMode === 'unknown' ? 'unknown'
              : 'quick';
    if (mode === 'quick') {
      need(errors, isEmpty(i.quickAmount), 'i.quickAmount', 'Quick total - amount');
      need(errors, isEmpty(i.quickFreq),   'i.quickFreq',   'Quick total - frequency');
    } else if (mode === 'surplus') {
      need(errors, isEmpty(i.surplusAmount), 'i.surplusAmount', 'State my surplus - amount');
      need(errors, isEmpty(i.surplusFreq),   'i.surplusFreq',   'State my surplus - frequency');
    } else if (mode === 'unknown') {
      // "I don't know" requires an explicit confirmation tick.
      need(errors, i.spendNotTracked !== true, 'i.spendNotTracked', 'Please tick to confirm you do not currently track your spending');
    } else { // detail
      const keys = ['exRent','exGroc','exUtil','exTrans','exIns','exSchool','exChild','exEnt','exHealth','exSubs','exCloth','exOther'];
      const anyFilled = keys.some(k => !isEmpty(i[k]) && Number(i[k]) > 0);
      need(errors, !anyFilled, 'i.expDetail', 'Itemised spending');
    }
    return errors;
  }

  // ─── Insurance ────────────────────────────────────────────
  // Public entry point: validates the primary scope, plus the partner
  // scope when advice is being given as a couple. The heavy lifting is
  // delegated to `validateInsuranceScope` (the full ~400-line body); we
  // pass a synthetic `data` object on the partner pass so the body can
  // read partner gender + partner insurance state without changes.
  function validateInsurance(data) {
    const primaryErrors = validateInsuranceScope(data);
    const primary = data?.personal?.primary || {};
    const isCouple = ['Married','De facto'].includes(primary.relStatus);
    const partnerActive = isCouple && primary.coupleAdvice === true;
    if (!partnerActive) return primaryErrors;
    // If the client picked "Send reminder for partner to complete" on
    // the insurance partner tab (same toggle as personal), skip the
    // partner-scope validation here. Submit gate on the server handles
    // the cross-check against the linked partner's fact-find.
    if (data?.insurance?.partnerCompletion === 'remind') return primaryErrors;
    // Require an explicit choice (self vs remind) on the insurance
    // partner tab when couple advice is on.
    if (isEmpty(data?.insurance?.partnerCompletion)) {
      primaryErrors.push({ name: 'ins.partnerCompletion', label: 'Pick how you\'ll handle your partner\'s insurance details' });
      return primaryErrors;
    }
    const partnerData = {
      ...data,
      personal: {
        ...(data?.personal || {}),
        // The body reads gender from personal.primary.gender to flip the
        // pregnancy block, so swap in partner's gender for that pass.
        primary: { ...(primary || {}), gender: data?.personal?.partner?.gender || '' },
      },
      insurance: data?.insurance?.partner || {},
    };
    const partnerErrors = validateInsuranceScope(partnerData).map(err => ({
      ...err,
      // Prefix the error name so it doesn't collide with primary errors
      // in the error banner / live-clear matching.
      name: `p_${err.name}`,
      // Label is what the user reads in the banner; tag it so they know
      // which person to fix.
      label: `Partner - ${err.label}`,
      _scope: 'partner',
    }));
    return [...primaryErrors, ...partnerErrors];
  }

  function validateInsuranceScope(data) {
    const errors = [];
    const d = data?.insurance || {};
    const isFemale = (data?.personal?.primary?.gender || '').toLowerCase() === 'female';

    // 1. Existing cover
    need(errors, isBlank(d.hasExisting), 'ins.hasExisting', 'Do you currently hold any insurance?');
    if (d.hasExisting === true) {
      const policies = d.policies || [];
      if (policies.length === 0) {
        errors.push({ name: 'ins.policies', label: 'At least one existing policy' });
      } else {
        policies.forEach((p, i) => {
          need(errors, isEmpty(p.insurer), `ins.pol${i}.insurer`, `Policy ${i+1} - insurer`);
          need(errors, isEmpty(p.owner),   `ins.pol${i}.owner`,   `Policy ${i+1} - owner`);
          need(errors, isEmpty(p.heldVia), `ins.pol${i}.heldVia`, `Policy ${i+1} - where held`);
          need(errors, !(p.types || []).length, `ins.pol${i}.types`, `Policy ${i+1} - cover types`);
          // Per-cover sum insured: required unless "unsure" ticked
          (p.types || []).forEach(t => {
            if (t === 'Income Protection') {
              if (!p.monthlyBenefit_unsure) {
                need(errors, isEmpty(p.monthlyBenefit), `ins.pol${i}.monthlyBenefit`, `Policy ${i+1} - IP monthly benefit`);
              }
            } else {
              if (!p[`sum_${t}_unsure`]) {
                need(errors, isEmpty(p[`sum_${t}`]), `ins.pol${i}.sum_${t}`, `Policy ${i+1} - ${t} sum insured`);
              }
            }
          });
        });
      }
    }

    // 2. Residency & travel
    need(errors, isBlank(d.residency), 'ins.residency', 'Australian citizen / PR / NZ status');
    if (d.residency === false) {
      need(errors, isEmpty(d.visaType),     'ins.visaType',     'Visa type');
      need(errors, isEmpty(d.visaExpiry),   'ins.visaExpiry',   'Visa expiry');
      need(errors, isEmpty(d.timeInAus),    'ins.timeInAus',    'Time in Australia');
      need(errors, isEmpty(d.countryBirth), 'ins.countryBirth', 'Country of birth');
      need(errors, isEmpty(d.prStatus),     'ins.prStatus',     'PR status');
    }
    need(errors, isBlank(d.travel), 'ins.travel', 'Definite overseas travel in next 12 months');
    if (d.travel === true) {
      const trips = d.travelTrips || [];
      if (trips.length === 0) {
        errors.push({ name: 'ins.travelTrips', label: 'At least one trip' });
      } else {
        trips.forEach((t, i) => {
          need(errors, isEmpty(t.country), `ins.trip${i}.country`, `Trip ${i+1} - country`);
          need(errors, isEmpty(t.from),    `ins.trip${i}.from`,    `Trip ${i+1} - departure`);
          need(errors, isEmpty(t.to),      `ins.trip${i}.to`,      `Trip ${i+1} - return`);
        });
      }
    }
    need(errors, isBlank(d.overseas), 'ins.overseas', 'Planning to live overseas in next 3 years');
    if (d.overseas === true) {
      need(errors, isEmpty(d.overseasWhere),    'ins.overseasWhere',    'Where (overseas living)');
      need(errors, isEmpty(d.overseasWhen),     'ins.overseasWhen',     'When (overseas living)');
      need(errors, isEmpty(d.overseasHowLong),  'ins.overseasHowLong',  'For how long (overseas living)');
    }

    // 3. Occupational duties
    // The duty split (desk / on-site / manual / driving) must total 100%.
    const occTotal = Math.round((Number(d.occDesk) || 0) + (Number(d.occField) || 0) + (Number(d.occManual) || 0) + (Number(d.occDriving) || 0));
    need(errors, occTotal !== 100, 'ins.occSplit', 'Occupational duties must total 100%');
    // Any on-site / field work → must describe what that work involves.
    if ((Number(d.occField) || 0) > 0) {
      need(errors, isEmpty(d.occFieldDetail), 'ins.occFieldDetail', 'Description of on-site / field work');
    }
    // Any manual / physical work → must summarise what that work involves.
    if ((Number(d.occManual) || 0) > 0) {
      need(errors, isEmpty(d.occManualDetail), 'ins.occManualDetail', 'Summary of manual / physical work');
    }
    need(errors, !(d.occHazards || []).length, 'ins.occHazards', 'Occupational hazards (or "None")');
    if ((d.occHazards || []).includes('Working at heights 10–20m')) {
      need(errors, isEmpty(d.heights1020Detail), 'ins.heights1020Detail', 'Heights 10–20m detail');
    }
    if ((d.occHazards || []).includes('Working at heights above 20m')) {
      need(errors, isEmpty(d.heights20pDetail), 'ins.heights20pDetail', 'Heights above 20m detail');
    }
    if ((d.occHazards || []).includes('Working underground / confined spaces')) {
      need(errors, isEmpty(d.undergroundDetail), 'ins.undergroundDetail', 'Underground / confined spaces detail');
    }
    if ((d.occHazards || []).includes('Working with explosives')) {
      need(errors, isEmpty(d.explosivesDetail), 'ins.explosivesDetail', 'Explosives detail');
    }
    if ((d.occHazards || []).includes('Underwater diving as part of work')) {
      need(errors, isEmpty(d.workDivingDetail), 'ins.workDivingDetail', 'Work diving detail');
    }
    need(errors, isBlank(d.selfEmployed), 'ins.selfEmployed', 'Self-employed / business owner');
    if (d.selfEmployed === true) {
      need(errors, isEmpty(d.businessYears),     'ins.businessYears',     'Years in current business');
      need(errors, isEmpty(d.businessShare),     'ins.businessShare',     'Share of business');
      need(errors, isEmpty(d.businessKeyperson), 'ins.businessKeyperson', '% revenue dependent on you');
    }
    need(errors, isEmpty(d.hoursPerWeek), 'ins.hoursPerWeek', 'Hours worked per week');
    need(errors, isEmpty(d.daysPerWeek),  'ins.daysPerWeek',  'Days worked per week');
    need(errors, isBlank(d.continuous2y), 'ins.continuous2y', 'Continuous 2y in occupation');
    if (d.continuous2y === false) {
      need(errors, isEmpty(d.continuous2yReason), 'ins.continuous2yReason', 'Reason for break in occupation');
    }
    need(errors, isBlank(d.currentlyOffWork), 'ins.currentlyOffWork', 'Currently off / reduced duties');
    if (d.currentlyOffWork === true) {
      need(errors, isEmpty(d.currentlyOffWorkReason), 'ins.currentlyOffWorkReason', 'Reason for being off work');
      need(errors, isEmpty(d.currentlyOffWorkSince),  'ins.currentlyOffWorkSince',  'When stopped / reduced');
      need(errors, isEmpty(d.currentlyOffWorkStatus), 'ins.currentlyOffWorkStatus', 'Current work status');
    }
    need(errors, isBlank(d.workChanges), 'ins.workChanges', 'Planned work changes');
    if (d.workChanges === true) {
      need(errors, isEmpty(d.workChangesType), 'ins.workChangesType', 'Type of work change');
      need(errors, isEmpty(d.workChangesDate), 'ins.workChangesDate', 'When work change starts');
    }
    need(errors, isBlank(d.secondOcc), 'ins.secondOcc', 'Second occupation');
    if (d.secondOcc === true) {
      need(errors, isEmpty(d.secondOccTitle), 'ins.secondOccTitle', 'Second occupation title');
      need(errors, isEmpty(d.secondOccHours), 'ins.secondOccHours', 'Second occupation hours');
    }

    // 4. Insolvency
    need(errors, isBlank(d.insolvency), 'ins.insolvency', 'Bankruptcy / insolvency in last 5 years');
    if (d.insolvency === true) {
      need(errors, isEmpty(d.insolvencyType),  'ins.insolvencyType',  'Type of insolvency event');
      need(errors, isEmpty(d.insolvencyStart), 'ins.insolvencyStart', 'When insolvency commenced');
      need(errors, isBlank(d.insolvencyDischarged), 'ins.insolvencyDischarged', 'Discharged / finalised?');
      if (d.insolvencyDischarged === true) {
        need(errors, isEmpty(d.insolvencyDischargedDate), 'ins.insolvencyDischargedDate', 'When discharged');
      }
      need(errors, isBlank(d.insolvencyMulti), 'ins.insolvencyMulti', 'Happened more than once?');
      need(errors, isEmpty(d.insolvencyCause), 'ins.insolvencyCause', 'Primary cause of insolvency');
    }

    // 5. Prior applications & claims
    need(errors, isBlank(d.priorAppsAny), 'ins.priorAppsAny', 'Prior insurance applications outcomes');
    if (d.priorAppsAny === true) {
      const apps = d.priorApps || [];
      if (apps.length === 0) {
        errors.push({ name: 'ins.priorApps', label: 'At least one prior application' });
      } else {
        apps.forEach((p, i) => {
          need(errors, isEmpty(p.insurer), `ins.app${i}.insurer`, `Prior app ${i+1} - insurer`);
          need(errors, isEmpty(p.type),    `ins.app${i}.type`,    `Prior app ${i+1} - cover type`);
          need(errors, isEmpty(p.year),    `ins.app${i}.year`,    `Prior app ${i+1} - year`);
          need(errors, isEmpty(p.outcome), `ins.app${i}.outcome`, `Prior app ${i+1} - outcome`);
        });
      }
    }
    need(errors, isBlank(d.claimsAny), 'ins.claimsAny', 'Prior insurance claims');
    if (d.claimsAny === true) {
      const claims = d.claims || [];
      if (claims.length === 0) {
        errors.push({ name: 'ins.claims', label: 'At least one claim' });
      } else {
        claims.forEach((c, i) => {
          need(errors, isEmpty(c.condition), `ins.claim${i}.condition`, `Claim ${i+1} - condition`);
          need(errors, isEmpty(c.type),      `ins.claim${i}.type`,      `Claim ${i+1} - type`);
          need(errors, isEmpty(c.year),      `ins.claim${i}.year`,      `Claim ${i+1} - year`);
          need(errors, isBlank(c.finalised), `ins.claim${i}.finalised`, `Claim ${i+1} - finalised?`);
          need(errors, isBlank(c.recovered), `ins.claim${i}.recovered`, `Claim ${i+1} - recovered?`);
        });
      }
    }

    // 6. Pursuits - must answer "None" or pick at least one
    const pursuits = d.pursuitsList || [];
    const noPursuits = (d.pursuitsNone === true) && pursuits.length === 0;
    need(errors, !noPursuits && pursuits.length === 0, 'ins.pursuits', 'Pursuits & high-risk activities');
    pursuits.forEach((p) => {
      need(errors, isEmpty(p.frequency), `ins.pursuit_${p.id}.frequency`, `Pursuit (${p.id}) - frequency`);
      need(errors, isEmpty(p.paid),      `ins.pursuit_${p.id}.paid`,      `Pursuit (${p.id}) - status`);
      need(errors, isBlank(p.licensed),  `ins.pursuit_${p.id}.licensed`,  `Pursuit (${p.id}) - licensed?`);
      if (p.licensed === true) {
        need(errors, isEmpty(p.licenseDetail), `ins.pursuit_${p.id}.licenseDetail`, `Pursuit (${p.id}) - licence detail`);
      }
    });

    // 7. Health - basic
    need(errors, isEmpty(d.height), 'ins.height', 'Height (cm)');
    need(errors, isEmpty(d.weight), 'ins.weight', 'Weight (kg)');
    need(errors, isBlank(d.weightLoss), 'ins.weightLoss', 'Weight loss > 10kg in last 12 months');
    if (d.weightLoss === true) {
      need(errors, isEmpty(d.weightLossKg),    'ins.weightLossKg',    'How many kg lost');
      need(errors, isBlank(d.bariatricSurgery),'ins.bariatricSurgery','Weight-loss surgery?');
      if (d.bariatricSurgery === true) {
        need(errors, isEmpty(d.bariatricDate), 'ins.bariatricDate', 'Surgery date');
        need(errors, isBlank(d.bariatricRecovered), 'ins.bariatricRecovered', 'Recovered without complications?');
      } else if (d.bariatricSurgery === false) {
        need(errors, isEmpty(d.weightLossCause), 'ins.weightLossCause', 'Cause of weight loss');
        need(errors, isBlank(d.weightLossSeenDoc),'ins.weightLossSeenDoc','Discussed with doctor?');
      }
    }

    // Smoking
    need(errors, isEmpty(d.smoke), 'ins.smoke', 'Tobacco / nicotine usage');
    if (d.smoke === 'Currently or in the last 12 months') {
      need(errors, !(d.smokeProducts || []).length, 'ins.smokeProducts', 'Which nicotine products');
      need(errors, isEmpty(d.smokePerDay),           'ins.smokePerDay',  'Cigarettes / equivalent per day');
      need(errors, isEmpty(d.smokeYears),            'ins.smokeYears',   'Years of nicotine use');
    }
    if (d.smoke === 'More than 12 months ago') {
      need(errors, isEmpty(d.smokeStopped),    'ins.smokeStopped',    'When you last had nicotine');
      need(errors, isEmpty(d.smokePeakPerDay), 'ins.smokePeakPerDay', 'Peak per day');
      need(errors, isEmpty(d.smokeTotalYears), 'ins.smokeTotalYears', 'Total years of use');
    }

    // Alcohol
    need(errors, isEmpty(d.alcohol), 'ins.alcohol', 'Standard drinks per week');
    if (d.alcohol && d.alcohol !== 'None') {
      need(errors, isEmpty(d.alcoholBinge), 'ins.alcoholBinge', '5+ standard drink frequency');
      if (['Once or twice a fortnight','Once or twice a week','3+ days a week'].includes(d.alcoholBinge)) {
        need(errors, isEmpty(d.alcoholBingeAmount), 'ins.alcoholBingeAmount', 'Drinks on binge occasions');
      }
    }
    need(errors, isBlank(d.alcoholAdvised), 'ins.alcoholAdvised', 'Advised to reduce alcohol?');
    if (d.alcoholAdvised === true) {
      need(errors, isEmpty(d.alcoholAdvisedWhen),  'ins.alcoholAdvisedWhen',  'When advice given');
      need(errors, isEmpty(d.alcoholAdvisedActed), 'ins.alcoholAdvisedActed', 'Acted on the advice?');
    }

    // Drugs
    need(errors, isBlank(d.drugs), 'ins.drugs', 'Recreational drug use in last 10 years');
    if (d.drugs === true) {
      const dl = d.drugsList || [];
      if (dl.length === 0) {
        errors.push({ name: 'ins.drugsList', label: 'At least one substance' });
      } else {
        dl.forEach((dr, i) => {
          need(errors, isEmpty(dr.substance), `ins.drug${i}.substance`, `Substance ${i+1} - name`);
          need(errors, isEmpty(dr.method),    `ins.drug${i}.method`,    `Substance ${i+1} - method`);
          need(errors, isEmpty(dr.frequency), `ins.drug${i}.frequency`, `Substance ${i+1} - frequency`);
          need(errors, isEmpty(dr.lastUsed),  `ins.drug${i}.lastUsed`,  `Substance ${i+1} - last used`);
          need(errors, isBlank(dr.stillUsing),`ins.drug${i}.stillUsing`,`Substance ${i+1} - still using?`);
        });
      }
    }
    need(errors, isBlank(d.dependency), 'ins.dependency', 'Sought help for dependency / addiction');
    if (d.dependency === true) {
      need(errors, isEmpty(d.dependencyDetail), 'ins.dependencyDetail', 'Dependency detail');
    }

    // Pregnancy (female only)
    if (isFemale) {
      need(errors, isEmpty(d.pregnancy), 'ins.pregnancy', 'Pregnancy / planning status');
      if (d.pregnancy === 'Currently pregnant') {
        need(errors, isEmpty(d.pregnancyDue),   'ins.pregnancyDue',   'Approximate due date');
        need(errors, isEmpty(d.pregnancyWeeks), 'ins.pregnancyWeeks', 'Weeks pregnant');
        need(errors, isBlank(d.pregnancyComplications), 'ins.pregnancyComplications', 'Pregnancy complications?');
        if (d.pregnancyComplications === true) {
          need(errors, isEmpty(d.pregnancyComplicationsDetail), 'ins.pregnancyComplicationsDetail', 'Complications detail');
        }
      }
      need(errors, isBlank(d.congenital), 'ins.congenital', 'Child with congenital abnormalities');
      if (d.congenital === true) {
        need(errors, isEmpty(d.congenitalDetail), 'ins.congenitalDetail', 'Congenital detail');
      }
    }

    // Family history
    const fcTicked = d.familyConditionsTicked || [];
    const familyNone = (d.familyNone === true) && fcTicked.length === 0;
    need(errors, !familyNone && fcTicked.length === 0, 'ins.familyHistory', 'Family medical history');
    if (fcTicked.length > 0) {
      const family = d.family || [];
      if (family.length === 0) {
        errors.push({ name: 'ins.family', label: 'At least one affected relative' });
      } else {
        family.forEach((f, i) => {
          need(errors, isEmpty(f.relationship),  `ins.fam${i}.rel`,  `Relative ${i+1} - relationship`);
          // The relative-condition chips write into f.conditions (array).
          // The old singular f.condition was a legacy field; accept either
          // so an empty f.condition with a populated f.conditions array
          // doesn't falsely block the user.
          const hasCondition = (Array.isArray(f.conditions) && f.conditions.length > 0)
                            || !isEmpty(f.condition);
          // The per-relative condition is only needed to DISAMBIGUATE which
          // relative had which when 2+ family categories were ticked. With a
          // single ticked category the relative's condition is already
          // implied by that category, so requiring a separate per-relative
          // selection is redundant - and was a common false-incomplete:
          // ticking the category + adding the relative (relationship / age /
          // living) reads as done at a glance. Still recordable for detail.
          need(errors, fcTicked.length >= 2 && !hasCondition, `ins.fam${i}.cond`, `Relative ${i+1} - condition`);
          // "Other" requires a free-text description.
          if (Array.isArray(f.conditions) && f.conditions.includes('Other')) {
            need(errors, isEmpty(f.conditionOther), `ins.family${i}.conditionOther`, `Relative ${i+1} - describe "Other" condition`);
          }
          // "Any other type of cancer" requires the cancer type so the
          // insurer / new adviser knows which cancer ran in the family.
          // Airtight: also required when the SOLE ticked family category is
          // "Any other type of cancer" (id 'cancer') and no per-relative
          // chip was picked - the cancer is implied for every listed
          // relative, so the type must still be captured. Kept in step with
          // section-insurance.jsx `impliedOtherCancer`.
          const relOtherCancer =
            (Array.isArray(f.conditions) && f.conditions.includes('Any other type of cancer'))
            || (fcTicked.length === 1 && fcTicked[0] === 'cancer' && (!Array.isArray(f.conditions) || f.conditions.length === 0));
          if (relOtherCancer) {
            need(errors, isEmpty(f.cancerType), `ins.family${i}.cancerType`, `Relative ${i+1} - which type of cancer`);
          }
          need(errors, isEmpty(f.ageDiagnosed),  `ins.fam${i}.age`,  `Relative ${i+1} - age at diagnosis`);
          need(errors, isBlank(f.living),        `ins.fam${i}.living`,`Relative ${i+1} - still living?`);
          if (f.living === false) {
            need(errors, isEmpty(f.ageDeath), `ins.fam${i}.death`, `Relative ${i+1} - age at death`);
          }
        });
      }
      // Hereditary Y/N/Unsure per ticked family condition.
      const heredMap = d.familyHereditary || {};
      fcTicked.forEach(condId => {
        need(errors, isEmpty(heredMap[condId]),
          `ins.family.heredity.${condId}`,
          `Is this condition hereditary? (${condId})`);
      });
    }
    need(errors, isEmpty(d.genetic), 'ins.genetic', 'Genetic test status');

    // Medical history - EVERY question must be answered: at least one
    // item ticked inside it, or that question's own "None of the above".
    //
    // The old rule was "one tick anywhere on the card, or the single
    // master banner at the foot of it". Clients were ticking that banner
    // on top of conditions they had already disclosed above it, so the
    // banner is gone and each question now stands on its own. Records
    // saved under the banner still pass - `unansweredMedQuestions` treats
    // the legacy `conditionsNone` as having answered every question.
    const conds = d.conditionsList || [];
    if (MED_CAT && typeof MED_CAT.unansweredMedQuestions === 'function') {
      MED_CAT.unansweredMedQuestions(d).forEach(q => {
        errors.push({ name: `ins.medCat.${q.key}`, label: `Medical history - ${q.title}` });
      });
    } else {
      // Catalogue not loaded - fall back to the old all-or-nothing rule
      // rather than wedging the section behind questions we can't name.
      const condNone = (d.conditionsNone === true) && conds.length === 0;
      need(errors, !condNone && conds.length === 0, 'ins.medicalHistory', 'Medical history');
    }
    // Per-condition follow-up. We've intentionally pared this back to
    // match the UI: the simplified flag rows (group === 'recent') show
    // only a "Briefly tell us about it" textarea, so we only require
    // `recentNote`. The deep-dive condition rows show many more fields
    // but only "Which condition?", the first/last symptom dates and the
    // severity must be filled - the rest are conditional or optional context.
    conds.forEach((c) => {
      const id = c.id;
      if (c.group === 'recent') {
        // Triggered recent flags (e.g. "Seen a psychologist" opens the
        // mental-health deep-dive) intentionally show NO free-text box in
        // the UI - the detail is captured in the deep-dive they open. So
        // don't require recentNote for them or the section is stuck
        // incomplete forever. `c.triggered` covers new rows; the mh_pro
        // item id covers legacy rows saved before this flag existed.
        if (c.triggered === true || c.item === 'mh_pro') return;
        // Simplified row - just the free-text note (matches the UI).
        need(errors, isEmpty(c.recentNote), `ins.cond_${id}.recentNote`, `${c.suggested} - briefly tell us about it`);
      } else if (c.group === 'last5' || c.group === 'ever') {
        // Deep-dive row - require the condition name; "Other" requires the
        // free-text description. Severity is helpful enough that we keep
        // it required. Everything else is optional. Only enforced for the
        // surfaces that actually render an editor (last5 / ever). A row
        // under any other group is a legacy trigger-added phantom that
        // never renders, so requiring its fields would wedge the section.
        need(errors, isEmpty(c.name), `ins.cond_${id}.name`, `${c.suggested} - which condition`);
        if (c.name === '__other__' || c.name === 'Other' || c.name === 'Other autoimmune') {
          need(errors, isEmpty(c.nameOther), `ins.cond_${id}.nameOther`, `${c.suggested} - describe the condition`);
        }
        // Insurers always ask when symptoms started and when they were last
        // present - a condition entry without those dates can't be underwritten.
        // Cancer sub-type: only asked once the client says they know it, so
        // only enforced then. The stage question is deliberately optional -
        // a benign cyst or a lump still under investigation has no stage.
        if (c.cancerTypeKnown === true) {
          need(errors, isEmpty(c.cancerTypeDetail), `ins.cond_${id}.cancerTypeDetail`, `${c.suggested} - what type of cancer`);
        }
        need(errors, isEmpty(c.firstDate), `ins.cond_${id}.firstDate`, `${c.suggested} - when did symptoms first occur`);
        need(errors, isEmpty(c.lastDate), `ins.cond_${id}.lastDate`, `${c.suggested} - when did you last have symptoms`);
        need(errors, isEmpty(c.severity), `ins.cond_${id}.severity`, `${c.suggested} - severity`);
      }
    });

    // Current medications. The "still taking?" follow-up was checked
    // here but never rendered in the UI, so completed medication rows
    // would fail validation forever - causing the "section incomplete"
    // bug reported in the review. Validate only the fields the UI
    // actually collects.
    need(errors, isBlank(d.meds), 'ins.meds', 'Currently on prescribed medication?');
    if (d.meds === true) {
      const meds = d.medsList || [];
      if (meds.length === 0) {
        errors.push({ name: 'ins.medsList', label: 'At least one medication' });
      } else {
        meds.forEach((m, i) => {
          need(errors, isEmpty(m.name),      `ins.med${i}.name`,    `Medication ${i+1} - name`);
          need(errors, isEmpty(m.dose),      `ins.med${i}.dose`,    `Medication ${i+1} - dose / measurement`);
          need(errors, isEmpty(m.freq),      `ins.med${i}.freq`,    `Medication ${i+1} - how often (frequency)`);
          need(errors, isEmpty(m.reason),    `ins.med${i}.reason`,  `Medication ${i+1} - what it's for`);
          need(errors, isEmpty(m.startedAt), `ins.med${i}.started`, `Medication ${i+1} - started`);
        });
      }
    }

    // Recent symptoms - client must either tick at least one symptom OR
    // confirm "None of the above". If any specific symptoms are ticked,
    // each needs a follow-up note.
    const recentSyms = d.recentSymptoms || [];
    const recentNone = d.recentSymptomsNone === true && recentSyms.length === 0;
    need(errors, !recentNone && recentSyms.length === 0, 'ins.recentSymptoms', 'In the last 3 months - tick at least one symptom or "None of the above"');
    recentSyms.forEach((sym) => {
      const detail = (d.recentSymptomsByItem || {})[sym];
      need(errors, isEmpty(detail), `ins.recentSym_${sym}`, `Recent symptom: "${sym}" - detail`);
    });

    // Pending medical
    need(errors, isBlank(d.pendingMedical), 'ins.pendingMedical', 'Pending medical advice / tests / surgery');
    if (d.pendingMedical === true) {
      const list = d.pendingMedicalList || [];
      if (list.length === 0) {
        errors.push({ name: 'ins.pendingMedicalList', label: 'At least one pending item' });
      } else {
        list.forEach((pm, i) => {
          need(errors, isEmpty(pm.type),    `ins.pm${i}.type`,    `Pending ${i+1} - type`);
          need(errors, isEmpty(pm.reason),  `ins.pm${i}.reason`,  `Pending ${i+1} - what it's for`);
          need(errors, isEmpty(pm.dueDate), `ins.pm${i}.dueDate`, `Pending ${i+1} - due date`);
        });
      }
    }

    // Beneficiaries - if any rows added, each must be complete and totals 100%
    const benes = d.beneficiaries || [];
    if (benes.length > 0) {
      benes.forEach((b, i) => {
        need(errors, isEmpty(b.name),         `ins.bene${i}.name`,    `Beneficiary ${i+1} - full name`);
        need(errors, isEmpty(b.relationship), `ins.bene${i}.rel`,     `Beneficiary ${i+1} - relationship`);
        need(errors, isEmpty(b.percent),      `ins.bene${i}.percent`, `Beneficiary ${i+1} - % of benefit`);
      });
      const total = benes.reduce((s, b) => s + (Number(b.percent) || 0), 0);
      if (total !== 100) {
        errors.push({ name: 'ins.beneTotal', label: 'Beneficiary % must total 100' });
      }
    }

    // Children - if any rows added, each must be complete
    (d.children || []).forEach((c, i) => {
      need(errors, isEmpty(c.firstName), `ins.child${i}.first`,  `Child ${i+1} - first name`);
      need(errors, isEmpty(c.lastName),  `ins.child${i}.last`,   `Child ${i+1} - last name`);
      need(errors, isEmpty(c.dob),       `ins.child${i}.dob`,    `Child ${i+1} - date of birth`);
      need(errors, isEmpty(c.gender),    `ins.child${i}.gender`, `Child ${i+1} - gender`);
    });

    // GP / medical practice - practice phone is no longer mandatory per
    // staff feedback (clients often don't have it on hand and the adviser
    // can look it up). The Previous-GP block was also dropped from the
    // required list - useful colour for the adviser but not blocking.
    need(errors, isBlank(d.hasGp), 'ins.hasGp', 'Have a regular GP / practice');
    if (d.hasGp !== false) {
      need(errors, isEmpty(d.gpName),      'ins.gpName',      'GP name');
      need(errors, isEmpty(d.gpPractice),  'ins.gpPractice',  'Practice name');
      need(errors, isEmpty(d.gpAddress),   'ins.gpAddress',   'Practice address');
      need(errors, isEmpty(d.gpLastVisit), 'ins.gpLastVisit', 'Last consultation');
      need(errors, isEmpty(d.gpYears),     'ins.gpYears',     'How long with this practice');
    }

    // Final declaration
    need(errors, isBlank(d.declaration), 'ins.declaration', 'Duty of disclosure acknowledgement');

    return errors;
  }

  // ─── Goals ────────────────────────────────────────────────
  function validateGoals(data) {
    const errors = [];
    const g = data?.goals || {};
    // All six risk-tolerance questions are mandatory.
    ['q1','q2','q3','q4','q5','q6'].forEach((k, i) => {
      need(errors, g[k] === undefined || g[k] === null, `goals.${k}`, `Risk question ${i+1}`);
    });

    // Partner risk profile: a separate, individual risk assessment is
    // required when advice is for a couple AND the primary is completing
    // the partner's details here (the "self" path). When the partner does
    // their own fact-find, their risk profile lives in their own
    // submission, so we don't require it here. Stored as goals.pq1..pq6.
    const gp = data?.personal?.primary || {};
    const goalsCouple = ['Married','De facto'].includes(gp.relStatus);
    const partnerRiskActive = goalsCouple && gp.coupleAdvice === true
      && (data?.personal?.partnerCompletion === 'self');
    if (partnerRiskActive) {
      ['pq1','pq2','pq3','pq4','pq5','pq6'].forEach((k, i) => {
        need(errors, g[k] === undefined || g[k] === null, `goals.${k}`, `Partner risk question ${i+1}`);
      });
    }

    // Mode is mandatory - the client must pick how they want to capture
    // their goals (articulate vs assist).
    need(errors, isEmpty(g.mode), 'goals.mode', 'How would you like to approach your financial goals?');

    if (g.mode === 'articulate') {
      // Three mandatory free-text goals, plus any extras the user added
      // (each extra must be non-empty if present).
      need(errors, isEmpty(g.custom1), 'goals.custom1', 'Goal 1');
      need(errors, isEmpty(g.custom2), 'goals.custom2', 'Goal 2');
      need(errors, isEmpty(g.custom3), 'goals.custom3', 'Goal 3');
      (g.customExtra || []).forEach((val, i) => {
        need(errors, isEmpty(val), `goals.extra${i}`, `Goal ${i + 4}`);
      });
    } else if (g.mode === 'assist') {
      // Need at least 3 selected goals from the catalogue, at most 5.
      const selected = g.selected || [];
      if (selected.length < 3) {
        errors.push({ name: 'goals.selected', label: 'Select at least 3 financial goals (minimum 3, maximum 5)' });
      }
      // Per-goal follow-up: every selected goal that has a defined
      // follow-up schema must have all its fields filled in. Schema
      // mirrors section-goals.jsx; we keep this map locally so the
      // validator doesn't need a runtime import.
      //
      // Field rule shapes:
      //   - string: regular required field (text / number / money / etc)
      //   - { yesno: 'key' }: Y/N answer required (use isBlank so false counts)
      //   - { key, requiredIf: { key, equals } }: field required only when
      //       another sibling field equals a specific value
      const REQUIRED_KEYS = {
        'Buy a first home':                              ['targetPrice','targetYear','depositSaved','location'],
        'Upgrade or buy a new home':                     ['targetPrice','targetYear','location', { yesno: 'sellCurrentHome' },'notes'],
        'Purchase an investment property':               ['targetPrice','targetYear','location','strategy'],
        'Build investment portfolio outside super':      ['monthlyContrib','preferences'],
        'Grow my superannuation':                        ['retirementIncome','extraContribution'],
        // 'Protect my family with insurance' deliberately omitted - the
        // goal is no longer in the catalogue; historical data is left
        // untouched and won't be validated (clients can't re-select it).
        // whichDebt is checked bespoke below (chips from Assets OR
        // free-text). currentBal was dropped - balances are already
        // captured against each debt in the Assets section.
        'Pay off debt faster':                           ['targetPayoffYear'],
        'Start or grow a business':                      ['stage','capital','timeframe','notes'],
        "Save for children's education":                 ['children','schools','yearStarting','annualBudget'],
        'Plan a major lifestyle purchase (car, travel, reno)': ['what','budget','targetDate'],
        'Save for a career break / sabbatical':          [
          'targetSavings','startDate','duration',
          { yesno: 'returnToWork' },
          { key: 'returnBasis',  requiredIf: { key: 'returnToWork', equals: true } },
          { key: 'returnDays',   requiredIf: { key: 'returnBasis', equals: 'Days returning to work per week' } },
          { key: 'returnIncome', requiredIf: { key: 'returnBasis', equals: 'Estimated annual income' } },
          { yesno: 'familyStart' },
          { key: 'childcareNeeded', requiredIf: { key: 'familyStart', equals: true } },
          'notes',
        ],
        'Plan for retirement':                           ['targetAge','targetIncome','lifestyle'],
        'Maximise tax efficiency':                       ['concerns'],
        'Structure a will / estate plan':                ['existing','concerns'],
        'Support parents or family financially':         ['who','type','amount','timeframe', { yesno: 'sendsOverseas' }],
      };
      const followups = g.followups || {};
      selected.forEach(goal => {
        const rules = REQUIRED_KEYS[goal];
        if (!rules) return;
        const vals = followups[goal] || {};
        rules.forEach(rule => {
          if (typeof rule === 'string') {
            need(errors, isEmpty(vals[rule]), `goals.fu.${goal}.${rule}`, `${goal} - ${rule}`);
          } else if (rule.yesno) {
            need(errors, isBlank(vals[rule.yesno]), `goals.fu.${goal}.${rule.yesno}`, `${goal} - ${rule.yesno}`);
          } else if (rule.requiredIf) {
            const trigger = vals[rule.requiredIf.key];
            if (trigger === rule.requiredIf.equals) {
              need(errors, isEmpty(vals[rule.key]), `goals.fu.${goal}.${rule.key}`, `${goal} - ${rule.key}`);
            }
          }
        });
      });

      // Bespoke check for the debt-payoff goal: the client must either
      // pick at least one debt chip (sourced from Assets) or describe
      // the debt in the free-text fallback. Legacy drafts that stored
      // the old whichDebt text field still pass.
      if (selected.includes('Pay off debt faster')) {
        const vals = followups['Pay off debt faster'] || {};
        const hasDebt = (Array.isArray(vals.whichDebtList) && vals.whichDebtList.length > 0)
          || !isEmpty(vals.whichDebtOther)
          || !isEmpty(vals.whichDebt);
        need(errors, !hasDebt,
          'goals.fu.Pay off debt faster.whichDebt',
          'Pay off debt faster - which debt are we targeting?');
      }
    }

    return errors;
  }

  // ─── Documents ────────────────────────────────────────────
  function validateDocuments(data) {
    const errors = [];
    const d = data?.documents || {};
    const required = d.required || (GLOBAL.DEFAULT_REQUIRED_DOCS || []);
    const files = d.files || {};
    const allIHave = d.allIHave || {};
    // Some documents are a PER-PERSON requirement (payslips: 3 each). A couple
    // advised together uploads to ONE shared household checklist, so the gate
    // asks for the catalogue entry's `coupleMinFiles` (6) instead of `minFiles`
    // (3). When the partner completes their own fact-find instead ('remind'),
    // each person supplies their own set in their own form, so the per-person
    // count still applies. Mirrors docForCouple() in section-documents.jsx.
    const primary = data?.personal?.primary || {};
    const isCouple = ['Married','De facto'].includes(primary.relStatus);
    const sharedChecklist = isCouple && primary.coupleAdvice === true
      && data?.personal?.partnerCompletion !== 'remind';
    required.forEach(docId => {
      const meta = (GLOBAL.DOC_CATALOGUE || []).find(c => c.id === docId);
      const uploaded = (files[docId] || []).length;
      const minFiles = (sharedChecklist && Number(meta?.coupleMinFiles) > 0)
        ? Number(meta.coupleMinFiles)
        : (meta?.minFiles || 1);
      const canOptOut = meta?.allowAllIHaveToggle === true;
      // If this doc accepts the "I have uploaded the only relevant
      // payslip information" style toggle, the toggle satisfies the
      // requirement (so long as the client has uploaded at least one
      // file - the toggle on its own with zero files is not enough).
      const optedOutWithFile = canOptOut && allIHave[docId] === true && uploaded >= 1;
      if (uploaded >= minFiles) return;
      if (optedOutWithFile) return;
      const label = meta ? ((sharedChecklist && meta.coupleLabel) || meta.label)
        : (String(docId).startsWith('custom:') ? (String(docId).slice(7).trim() || 'Custom document') : docId);
      // Surface a clearer message when the doc has a min-file rule so
      // the client knows what's missing.
      if (minFiles > 1 && !canOptOut) {
        errors.push({ name: `doc.${docId}`, label: `${label} (need ${minFiles} files - uploaded ${uploaded})` });
      } else if (minFiles > 1 && canOptOut) {
        errors.push({ name: `doc.${docId}`, label: `${label} (upload ${minFiles} files OR tick "I have uploaded the only relevant payslip information")` });
      } else {
        errors.push({ name: `doc.${docId}`, label });
      }
    });
    // CSV cashflow-accuracy gate: when the transaction summary is part
    // of the checklist, the client must confirm whether the uploaded
    // data is the best available picture of their cashflow. A "No"
    // answer additionally requires a note on what else they'll provide.
    if (required.includes('csv_transactions')) {
      need(errors, isBlank(d.csvBestData), 'doc.csvBestData',
        'CSV Transaction Summary - confirm the data reflects your cashflow position');
      if (d.csvBestData === false) {
        need(errors, isEmpty(d.csvBestDataDetail), 'doc.csvBestDataDetail',
          'CSV Transaction Summary - what additional information will you provide?');
      }
    }
    return errors;
  }

  // Classify a missing-field error as belonging to the primary applicant or
  // the partner, so the UI can make clear WHOSE information is missing when
  // advice is being given as a couple. Partner errors are already
  // distinguishable by convention: insurance tags `_scope:'partner'`;
  // personal uses `pt.*`; income uses `i.p<Field>` (but NOT i.privateHealth);
  // goals uses `pq*`.
  function personOfError(sectionId, err) {
    if (err && err._scope === 'partner') return 'partner';
    const n = (err && err.name) || '';
    if (sectionId === 'personal' && n.indexOf('pt.') === 0) return 'partner';
    // Partner income fields are all i.p + an UPPERCASE letter (pBase, pSg,
    // pSalPkgYn, pBonusLikelihood, pPrivateHealth, …). Primary fields like
    // i.privateHealth / i.payFreq are i.p + lowercase, so they're excluded.
    if (sectionId === 'income' && /^i\.p[A-Z]/.test(n)) return 'partner';
    if (sectionId === 'goals' && /(^|\.)pq\d/.test(n)) return 'partner';
    if (n.indexOf('p_') === 0) return 'partner';
    return 'primary';
  }

  // ─── Public API ──────────────────────────────────────────
  function validateSection(sectionId, data) {
    let errors;
    switch (sectionId) {
      case 'personal':  errors = validatePersonal(data); break;
      case 'assets':    errors = validateAssets(data); break;
      case 'income':    errors = validateIncome(data); break;
      case 'insurance': errors = validateInsurance(data); break;
      case 'goals':     errors = validateGoals(data); break;
      case 'documents': errors = validateDocuments(data); break;
      default:          return [];
    }
    // Tag each error with the person it belongs to (primary|partner). Only
    // meaningful for couples, but harmless to always set.
    errors.forEach(e => { if (e && !e.person) e.person = personOfError(sectionId, e); });
    return errors;
  }

  // Expose everywhere: browser global (window.validateSection, unchanged
  // behaviour) AND CommonJS export so the server can require the SAME
  // validator - no duplicate logic to drift out of sync.
  Object.assign(GLOBAL, { validateSection });
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = { validateSection };
  }
})();
