// ─────────────────────────────────────────────────────────────
// Shared form primitives used by the fact-find
// ─────────────────────────────────────────────────────────────

function cx(...parts) { return parts.filter(Boolean).join(' '); }

// ─── Validation glue ───
// app.jsx provides two sets:
//   errors:      Set of validation `name` keys (e.g. "ins.smoke")
//   errorLabels: Set of slugified validation LABELS (e.g.
//                "tobacco-nicotine-usage" from the label
//                "Tobacco / nicotine usage")
// Field lights up red if EITHER its `name` matches an error name OR its
// label slug matches an error label slug. The label-slug fallback means
// big sections (e.g. Insurance) get the live red border + clear-on-fill
// behaviour without every <Field> needing an explicit `name` prop.
const ValidationCtx = React.createContext({ errors: new Set(), errorLabels: new Set() });

function Field({ label, required, hint, children, span, name }) {
  const ctx = React.useContext(ValidationCtx);
  const errors = ctx.errors || new Set();
  const errorLabels = ctx.errorLabels || new Set();
  // Derive a stable label slug so the validation jump-to-field can locate
  // any Field by its visible label even when it has no explicit `name`.
  const labelKey = (typeof label === 'string')
    ? label.replace(/[^a-z0-9]+/gi, '-').toLowerCase().replace(/^-+|-+$/g, '')
    : undefined;
  const hasError = (name && errors.has(name))
                || (labelKey && errorLabels.has(labelKey));
  return (
    <div
      className={cx('field', hasError && 'field-error')}
      style={span ? { gridColumn: `span ${span}` } : undefined}
      data-ff-name={name}
      data-ff-label={labelKey}
    >
      {label && (
        <label className="field-label">
          {label}{required && <span className="req">*</span>}
        </label>
      )}
      {children}
      {hasError && <div className="field-error-msg">This field is required</div>}
      {hint && !hasError && <div className="field-hint">{hint}</div>}
    </div>
  );
}

function Input(props) { return <input className="input" {...props} />; }
function Textarea(props) { return <textarea className="textarea" {...props} />; }
// Select primitive — wraps a native <select>. We deliberately AVOID using
// rest destructuring (`{ children, ...rest }`) here because Babel-standalone
// compiles each <script type="text/babel"> separately and hoists its
// internal `var _excluded` helper to the *global* scope. If another
// transpiled script in the same page also declares `var _excluded` (e.g.
// section-insurance.jsx's PercentInput/MoneyInput use `{ value, onChange,
// ...rest }`), it overwrites ours and Select silently strips `value` and
// `onChange` from props — which is exactly the bug that meant Title,
// Gender, Citizenship, Working-basis and Time-in-role never saved.
//
// Picking props manually sidesteps the Babel helper collision entirely.
function Select(props) {
  const children = props.children;
  // Build a shallow copy of props without `children` so the spread below
  // doesn't end up rendering them twice.
  const passProps = {};
  for (const k in props) if (k !== 'children') passProps[k] = props[k];
  // Also defensively normalise every <option> child to carry an explicit
  // `value` attribute equal to its text content. Without this, controlled
  // <select>s can intermittently fail to round-trip e.target.value in some
  // React + Babel-standalone combinations.
  const normalisedChildren = React.Children.map(children, (child) => {
    if (!child || typeof child !== 'object' || child.type !== 'option') return child;
    if (child.props && child.props.value !== undefined) return child;
    const text = typeof child.props.children === 'string' ? child.props.children : '';
    return React.cloneElement(child, { value: text });
  });
  return <select className="select" {...passProps}>{normalisedChildren}</select>;
}

// ChipGroup: single-select (radio) or multi-select (multi)
function ChipGroup({ options, value, onChange, multi }) {
  const isOn = (o) => multi ? (value || []).includes(o) : value === o;
  const toggle = (o) => {
    if (multi) {
      const curr = value || [];
      onChange(curr.includes(o) ? curr.filter(x => x !== o) : [...curr, o]);
    } else {
      onChange(o);
    }
  };
  return (
    <div className="chip-group">
      {options.map(o => (
        <button
          type="button"
          key={o}
          className={cx('chip', isOn(o) && 'on')}
          onClick={() => toggle(o)}
        >{o}</button>
      ))}
    </div>
  );
}

// Yes/No radio
function YesNo({ value, onChange, yesLabel = 'Yes', noLabel = 'No' }) {
  return (
    <div className="chip-group">
      <button type="button" className={cx('chip', value === true && 'on')} onClick={() => onChange(true)}>{yesLabel}</button>
      <button type="button" className={cx('chip', value === false && 'on')} onClick={() => onChange(false)}>{noLabel}</button>
    </div>
  );
}

// Shown conditionally, with subtle animation
function Reveal({ show, children }) {
  if (!show) return null;
  return <div className="reveal">{children}</div>;
}

function SubSectionHeading({ children }) {
  return <div className="sub-heading">{children}</div>;
}

function Callout({ kind = 'info', icon, children }) {
  return (
    <div className={cx('callout-box', `callout-${kind}`)}>
      {icon && <div className="callout-icon">{icon}</div>}
      <div className="callout-body">{children}</div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Repeatable item card (for assets, loans, policies etc.)
// ─────────────────────────────────────────────────────────────
function RepeatItem({ title, onRemove, children }) {
  return (
    <div className="repeat-item">
      <div className="repeat-item-header">
        <div className="repeat-item-title">{title}</div>
        {onRemove && <button type="button" className="repeat-remove" onClick={onRemove}>Remove</button>}
      </div>
      <div className="repeat-item-body">{children}</div>
    </div>
  );
}

function AddBtn({ onClick, children }) {
  return (
    <button type="button" className="add-btn" onClick={onClick}>
      <span className="add-btn-plus">+</span>{children}
    </button>
  );
}

// ─────────────────────────────────────────────────────────────
// Money / Percent / TFN inputs
//
// Same Babel-standalone collision warning applies here as it does to
// Select — avoid `{ value, onChange, ...rest }` rest destructuring so we
// don't emit a top-level `var _excluded = ["value", "onChange"]` that
// could clobber another file's helper. Each function reads from `props`
// directly and builds a clean spread object by hand.
// ─────────────────────────────────────────────────────────────

// MoneyInput — formats numbers as $12,345.67 on blur, raw decimals while editing.
// Stores the raw numeric string in state so validation can still treat it
// as a number; the formatted display is only for the visual layer.
function MoneyInput(props) {
  const value = props.value;
  const onChange = props.onChange;
  const passProps = {};
  for (const k in props) if (k !== 'value' && k !== 'onChange' && k !== 'type' && k !== 'inputMode' && k !== 'className') passProps[k] = props[k];
  const [draft, setDraft] = React.useState('');
  const [editing, setEditing] = React.useState(false);
  const num = (value === '' || value == null) ? '' : Number(value);
  // Use 'en-AU' with 2 decimals — formal $XX,XXX.XX display.
  const formatted = (num === '' || isNaN(num)) ? '' :
    '$' + num.toLocaleString('en-AU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  const display = editing ? draft : formatted;
  return (
    <input
      type="text" inputMode="decimal" className="input"
      value={display}
      onFocus={() => { setDraft(num === '' ? '' : String(num)); setEditing(true); }}
      onChange={(e) => {
        // Allow digits + one decimal point; strip everything else.
        let cleaned = e.target.value.replace(/[^0-9.]/g, '');
        const dot = cleaned.indexOf('.');
        if (dot >= 0) cleaned = cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1).replace(/\./g, '');
        setDraft(cleaned);
        if (onChange) onChange({ target: { value: cleaned } });
      }}
      onBlur={() => setEditing(false)}
      {...passProps}
    />
  );
}

// PercentInput — formats numbers as 4.25% on blur, raw decimals while editing.
function PercentInput(props) {
  const value = props.value;
  const onChange = props.onChange;
  const passProps = {};
  for (const k in props) if (k !== 'value' && k !== 'onChange' && k !== 'type' && k !== 'inputMode' && k !== 'className') passProps[k] = props[k];
  const [draft, setDraft] = React.useState('');
  const [editing, setEditing] = React.useState(false);
  const num = (value === '' || value == null) ? '' : Number(value);
  const formatted = (num === '' || isNaN(num)) ? '' : num.toFixed(2) + '%';
  const display = editing ? draft : formatted;
  return (
    <input
      type="text" inputMode="decimal" className="input"
      value={display}
      onFocus={() => { setDraft(num === '' ? '' : String(num)); setEditing(true); }}
      onChange={(e) => {
        let cleaned = e.target.value.replace(/[^0-9.]/g, '');
        const dot = cleaned.indexOf('.');
        if (dot >= 0) cleaned = cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1).replace(/\./g, '');
        setDraft(cleaned);
        if (onChange) onChange({ target: { value: cleaned } });
      }}
      onBlur={() => setEditing(false)}
      {...passProps}
    />
  );
}

// TfnInput — Tax File Number masked except the last 3 digits when blurred,
// fully revealed while focused so the client can edit. Stored value is the
// raw 9-digit string (digits only). Visual format on focus is "XXX XXX XXX".
function TfnInput(props) {
  const value = props.value;
  const onChange = props.onChange;
  const passProps = {};
  for (const k in props) if (k !== 'value' && k !== 'onChange' && k !== 'type' && k !== 'inputMode' && k !== 'className' && k !== 'maxLength') passProps[k] = props[k];
  const [editing, setEditing] = React.useState(false);
  const raw = (value || '').toString().replace(/\D/g, '');
  // Format with spaces every 3 digits for readability when focused.
  const spaced = raw.replace(/(\d{3})(\d{3})(\d{1,3}).*/, '$1 $2 $3').trim();
  // Mask: bullets for all but the last 3 visible digits.
  const lastThree = raw.slice(-3);
  const maskedCount = Math.max(0, raw.length - 3);
  // "••• ••• 789" style — 3 dots, space, 3 dots, space, last 3
  const masked = (() => {
    if (raw.length === 0) return '';
    if (raw.length <= 3) return raw;
    const bullets = '●'.repeat(maskedCount);
    // Re-insert spaces every 3 chars in the bullet block so width matches "XXX XXX"
    const groups = [];
    for (let i = 0; i < bullets.length; i += 3) groups.push(bullets.slice(i, i + 3));
    return groups.join(' ') + ' ' + lastThree;
  })();
  const display = editing ? spaced : masked;
  return (
    <input
      type="text"
      inputMode="numeric"
      autoComplete="off"
      className="input"
      value={display}
      maxLength={11}
      onFocus={() => setEditing(true)}
      onBlur={() => setEditing(false)}
      onChange={(e) => {
        const next = e.target.value.replace(/\D/g, '').slice(0, 9);
        if (onChange) onChange({ target: { value: next } });
      }}
      {...passProps}
    />
  );
}

// ─────────────────────────────────────────────────────────────
// AddressInput — Australian address typeahead backed by the free
// photon.komoot.io OSM geocoder. No API key, no signup. Drops down a
// suggestion list as the client types (≥4 chars), debounced 300ms.
// Picking a suggestion fills the input. If the network fails or no
// suggestions return, falls back to behaving as a plain text input so the
// client can always type the address by hand.
// ─────────────────────────────────────────────────────────────
function AddressInput(props) {
  const value = props.value || '';
  const onChange = props.onChange;
  // Optional onPick(label, parts) fires when the user picks a
  // suggestion. `parts` is { streetNumber, streetName, suburb, state,
  // postcode, country } so the consumer can store each component
  // separately for reporting + adviser export.
  const onPick = props.onPick;
  const passProps = {};
  for (const k in props) if (k !== 'value' && k !== 'onChange' && k !== 'onPick' && k !== 'className' && k !== 'type') passProps[k] = props[k];

  const [suggestions, setSuggestions] = React.useState([]);
  const [open, setOpen] = React.useState(false);
  const [loading, setLoading] = React.useState(false);
  const [highlight, setHighlight] = React.useState(-1);
  // Open-direction (down by default, flips to up if the dropdown would
  // be clipped by the bottom of the viewport). Recomputed every time
  // `open` flips to true so the same input can switch between modes
  // when the screen is scrolled or rotated.
  const [openUp, setOpenUp] = React.useState(false);
  const fetchSeq = React.useRef(0);
  const debounceTimer = React.useRef(null);
  const skipNextFetch = React.useRef(false);
  const containerRef = React.useRef(null);
  const inputRef = React.useRef(null);

  // Debounced fetch — only fires once the user pauses typing for 300ms
  // and only when the query is long enough to be useful.
  React.useEffect(() => {
    if (debounceTimer.current) clearTimeout(debounceTimer.current);
    if (skipNextFetch.current) { skipNextFetch.current = false; return; }
    const q = String(value || '').trim();
    if (q.length < 4) { setSuggestions([]); return; }
    debounceTimer.current = setTimeout(async () => {
      const mySeq = ++fetchSeq.current;
      setLoading(true);
      try {
        const url = `https://photon.komoot.io/api/?q=${encodeURIComponent(q)}&limit=6&lang=en&osm_tag=place&osm_tag=highway&osm_tag=building`;
        const r = await fetch(url);
        const json = await r.json();
        if (mySeq !== fetchSeq.current) return; // a newer request superseded us
        const feats = (json?.features || [])
          .filter(f => (f.properties?.countrycode || '').toUpperCase() === 'AU')
          .map(f => formatPhotonFeature(f))
          .filter(Boolean);
        setSuggestions(feats);
        setOpen(feats.length > 0);
        setHighlight(-1);
      } catch (_err) {
        // Silent fallback — keep typing in the plain field works regardless.
        setSuggestions([]);
        setOpen(false);
      } finally {
        if (mySeq === fetchSeq.current) setLoading(false);
      }
    }, 300);
    return () => clearTimeout(debounceTimer.current);
  }, [value]);

  // Click-outside closes the dropdown. On mobile (especially iOS) a tap
  // outside fires `touchstart` and may not synthesise a `mousedown`, so
  // we listen for both to make sure the dropdown closes when the user
  // taps a different field or scrolls away.
  React.useEffect(() => {
    function onDocClick(e) {
      if (containerRef.current && !containerRef.current.contains(e.target)) setOpen(false);
    }
    document.addEventListener('mousedown', onDocClick);
    document.addEventListener('touchstart', onDocClick, { passive: true });
    return () => {
      document.removeEventListener('mousedown', onDocClick);
      document.removeEventListener('touchstart', onDocClick);
    };
  }, []);

  // When the dropdown opens, decide whether to render it above or below
  // the input based on available viewport space. Without this the panel
  // is often hidden under the on-screen keyboard on mobile (the
  // keyboard pushes the input near the bottom of the visible area, the
  // dropdown opens down, and the user can't see any suggestions).
  React.useEffect(() => {
    if (!open || !inputRef.current) return;
    const rect = inputRef.current.getBoundingClientRect();
    // Use visualViewport when available - it accounts for the iOS
    // keyboard overlay; falls back to window.innerHeight otherwise.
    const vh = (window.visualViewport && window.visualViewport.height) || window.innerHeight;
    const spaceBelow = vh - rect.bottom;
    const spaceAbove = rect.top;
    // Need ~220px of usable space below. If there's less, flip up - but
    // only if there's more space above.
    const minBelow = 220;
    setOpenUp(spaceBelow < minBelow && spaceAbove > spaceBelow);
  }, [open, suggestions.length]);

  function pick(s) {
    skipNextFetch.current = true; // don't re-query the just-selected text
    // Invalidate any in-flight Photon request and cancel a pending
    // debounce. The debounce can kick off a fetch in the moment between
    // the user's last keystroke and their click on a suggestion; if that
    // request resolved after we close the panel below, it would call
    // setSuggestions()/setOpen(true) and reopen the dropdown on top of
    // the just-selected address. Bumping the sequence makes that late
    // response fail its `mySeq !== fetchSeq.current` staleness check.
    fetchSeq.current++;
    if (debounceTimer.current) clearTimeout(debounceTimer.current);
    // Photon often returns street-level matches with no housenumber, so
    // the picked label drops the leading "59" the user typed. If the
    // user's query begins with a house number (or unit/number like 12/45
    // or 5A) and the picked label doesn't already start with it, prepend
    // the user's number to the suggestion.
    const userLeadingNum = String(value || '').trim().match(/^(\d+[A-Za-z]?(?:[\/\-]\d+[A-Za-z]?)?)\b/);
    let finalLabel = s.label;
    if (userLeadingNum) {
      const num = userLeadingNum[1];
      // Only prepend if it isn't already present at the start of the line1.
      const labelStartsWithNum = new RegExp('^' + num.replace(/[/\-]/g, '[/\\-]') + '\\b', 'i').test(s.line1 || '');
      if (!labelStartsWithNum) {
        const newLine1 = `${num} ${s.line1}`.trim();
        finalLabel = [newLine1, s.line2].filter(Boolean).join(', ');
      }
    }
    if (onChange) onChange({ target: { value: finalLabel } });
    // Re-derive the structured parts using the user's leading-number
    // override if we patched the label. That keeps the streetNumber
    // captured even when Photon's housenumber field was empty.
    let finalParts = s.parts || null;
    if (finalParts && userLeadingNum) {
      const num = userLeadingNum[1];
      if (!finalParts.streetNumber) finalParts = { ...finalParts, streetNumber: num };
    }
    if (onPick) onPick(finalLabel, finalParts);
    setSuggestions([]);
    setOpen(false);
    setHighlight(-1);
  }

  function onKeyDown(e) {
    if (!open || !suggestions.length) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, suggestions.length - 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)); }
    else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(suggestions[highlight]); }
    else if (e.key === 'Escape') { setOpen(false); setHighlight(-1); }
  }

  // Position styles for the dropdown panel: anchored under the input by
  // default, flipped above when openUp is true. `position: absolute`
  // is sufficient because the parent has `position: relative`.
  const dropdownPos = openUp
    ? { bottom: 'calc(100% + 4px)', top: 'auto' }
    : { top: 'calc(100% + 4px)', bottom: 'auto' };

  return (
    <div ref={containerRef} style={{ position: 'relative' }}>
      <input
        ref={inputRef}
        type="text"
        autoComplete="off"
        className="input"
        value={value}
        onChange={(e) => { if (onChange) onChange({ target: { value: e.target.value } }); }}
        onFocus={() => {
          if (suggestions.length) setOpen(true);
          // Pull the input into the visible viewport so the soft keyboard
          // doesn't immediately bury it. block:'nearest' avoids jumping
          // the page if the input is already comfortably in view.
          setTimeout(() => {
            try { inputRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch {}
          }, 100);
        }}
        onKeyDown={onKeyDown}
        // 16px font-size prevents iOS Safari from auto-zooming when the
        // input gains focus (any size < 16 triggers the zoom). The
        // global .input rule uses 14px so we override here just on the
        // address field. Doesn't affect desktop rendering noticeably.
        style={{ fontSize: 16 }}
        {...passProps}
      />
      {loading && (
        <div style={{
          position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
          width: 14, height: 14, borderRadius: '50%',
          border: '2px solid #DDD8C6', borderTopColor: '#1B3058',
          animation: 'docSpin 0.8s linear infinite', pointerEvents: 'none',
        }} />
      )}
      {open && suggestions.length > 0 && (
        <div className="address-suggest" role="listbox" style={{
          position: 'absolute', left: 0, right: 0,
          ...dropdownPos,
          background: '#fff', border: '1px solid #DDD8C6', borderRadius: 10,
          boxShadow: '0 8px 24px rgba(63,74,54,0.12)', maxHeight: 280, overflowY: 'auto',
          zIndex: 100,
          // Mobile: prevent browser delegating the touchstart inside the
          // dropdown to the document-level click-outside listener, which
          // would otherwise close the panel before the tap-to-select
          // fires.
          WebkitOverflowScrolling: 'touch',
        }}
        onTouchStart={(e) => e.stopPropagation()}>
          {suggestions.map((s, idx) => (
            <button
              key={idx}
              type="button"
              role="option"
              aria-selected={highlight === idx}
              // Use onMouseDown so the click fires before the input's
              // onBlur closes the dropdown on desktop. On mobile,
              // touchend doesn't conflict with blur the same way so we
              // also wire onClick for touch devices.
              onMouseDown={(e) => { e.preventDefault(); pick(s); }}
              onClick={(e) => { e.preventDefault(); pick(s); }}
              onMouseEnter={() => setHighlight(idx)}
              style={{
                display: 'block', width: '100%', textAlign: 'left',
                // 14px vertical padding -> ~46px tall button, meets Apple HIG
                // 44px minimum touch target for mobile usability.
                padding: '14px 14px', border: 'none', background: highlight === idx ? '#F4F6FB' : '#fff',
                cursor: 'pointer', borderBottom: idx === suggestions.length - 1 ? 'none' : '1px solid #F0EFE9',
                fontSize: 14, color: '#1B3058', fontFamily: 'inherit',
                // touch-action manipulation lets the browser optimise tap
                // handling (skips the 300ms tap delay on some Android).
                touchAction: 'manipulation',
              }}>
              <div style={{ fontWeight: 600 }}>{s.line1}</div>
              {s.line2 && <div style={{ fontSize: 12, color: '#6E6A5F', marginTop: 2 }}>{s.line2}</div>}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

// Photon feature → flat "street, suburb STATE postcode" string. We also
// return a 2-line split so the dropdown can show a richer preview.
//
// Photon labels AU localities inconsistently — sometimes the suburb sits
// in `city`, sometimes `suburb`, sometimes `locality`, sometimes
// `district`, sometimes `county`. We pick the most-specific one available
// so the dropdown always shows the suburb name, e.g. "Sherington Road,
// Bayswater Western Australia 6053" instead of just "Sherington Road,
// Western Australia 6053" (which was happening before for matches that
// only had suburb-level metadata).
// Map AU state full-names ("New South Wales") to the standard 2-3 letter
// abbreviation so downstream reporting always sees a normalised value.
const AU_STATE_ABBR = {
  'new south wales': 'NSW',
  'victoria': 'VIC',
  'queensland': 'QLD',
  'south australia': 'SA',
  'western australia': 'WA',
  'tasmania': 'TAS',
  'australian capital territory': 'ACT',
  'northern territory': 'NT',
  'jervis bay territory': 'JBT',
};
function normaliseAuState(s) {
  if (!s) return '';
  const key = String(s).trim().toLowerCase();
  return AU_STATE_ABBR[key] || String(s).trim().toUpperCase();
}

function formatPhotonFeature(f) {
  const p = f.properties || {};
  if (!p.name && !p.street) return null;
  const streetPart = [p.housenumber, p.street].filter(Boolean).join(' ');
  const line1 = streetPart || p.name || '';
  // Australian suburb resolution. Photon tags Australian addresses
  // peculiarly: `city` is almost always the metro (Sydney, Melbourne,
  // Perth, Brisbane) and the actual suburb appears under `district`,
  // `suburb`, or `neighbourhood`. The old logic ("suburb || city ||
  // ...") incorrectly returned "Perth" as the suburb for a Greenwood
  // address. We now prefer the OSM tags that map to the AU suburb
  // before falling back to city.
  const suburb = p.suburb
              || p.district
              || p.neighbourhood
              || p.city
              || p.locality
              || p.county
              || '';
  // Also capture the metro city when it's distinct from the suburb -
  // useful for reporting (e.g. "Greenwood (Perth metro)") without
  // muddling the postal-address suburb field.
  const city = (p.city && p.city !== suburb) ? p.city : '';
  const stateAbbr = normaliseAuState(p.state || '');
  const cityParts = [suburb, stateAbbr, p.postcode || ''].filter(Boolean);
  const line2 = cityParts.join(' ').replace(/\s+/g, ' ').trim();
  const label = [line1, line2].filter(Boolean).join(', ');
  // Structured components so downstream reporting (and the adviser PDF
  // export) gets every piece separately - street number, street name,
  // suburb, city (metro), state, postcode - rather than a single
  // mashed label.
  const parts = {
    streetNumber: p.housenumber || '',
    streetName:   p.street || (streetPart ? '' : p.name) || '',
    suburb,
    city,
    state:        stateAbbr,
    postcode:     p.postcode || '',
    country:      p.country || 'Australia',
  };
  return { label, line1, line2, parts };
}

// PhoneInput — formats an Australian phone number as the client types.
// Mobile (04XX): "0412 345 678". Landline (0X): "(02) 1234 5678".
// 13/1300/1800 numbers: pass-through grouping. Stores the digit-only
// string internally so adviser-side downstream consumers always get a
// clean number; the display layer wraps it in human-readable spacing.
function PhoneInput(props) {
  const value = props.value || '';
  const onChange = props.onChange;
  const passProps = {};
  for (const k in props) if (k !== 'value' && k !== 'onChange' && k !== 'className' && k !== 'type' && k !== 'inputMode') passProps[k] = props[k];
  // Strip any non-digit the client may paste (spaces, parens, dashes).
  const digits = String(value).replace(/\D/g, '').slice(0, 10);
  function format(d) {
    if (!d) return '';
    // Landline: starts with 02/03/07/08, length 10 -> "(0X) XXXX XXXX"
    if (/^0[2378]/.test(d) && d.length > 2) {
      const area = d.slice(0, 2);
      const a = d.slice(2, 6);
      const b = d.slice(6, 10);
      return `(${area})${a ? ' ' + a : ''}${b ? ' ' + b : ''}`;
    }
    // Mobile: 04XX -> "04XX XXX XXX"
    if (/^04/.test(d) && d.length > 4) {
      return `${d.slice(0, 4)} ${d.slice(4, 7)}${d.length > 7 ? ' ' + d.slice(7, 10) : ''}`.trim();
    }
    // 13/1300/1800
    if (/^13/.test(d)) {
      if (d.length <= 4) return d;
      if (/^1[38]00/.test(d)) return `${d.slice(0, 4)} ${d.slice(4, 7)}${d.length > 7 ? ' ' + d.slice(7) : ''}`.trim();
      return `${d.slice(0, 2)} ${d.slice(2, 4)}${d.length > 4 ? ' ' + d.slice(4) : ''}`.trim();
    }
    return d;
  }
  return (
    <input
      type="tel" inputMode="numeric" className="input"
      value={format(digits)}
      onChange={(e) => {
        const next = e.target.value.replace(/\D/g, '').slice(0, 10);
        if (onChange) onChange({ target: { value: next } });
      }}
      {...passProps}
    />
  );
}

// InfoIcon - small hover-revealed tooltip for jargon-heavy labels
// (executor, EPOA, trustee, super beneficiary, etc.). Composes inline
// with the Field label by passing it as part of a fragment:
//   <Field label={<>Will <InfoIcon tip="An executor is..." /></>} ...>
// Hover OR keyboard-focus opens; click toggles for touch devices.
function InfoIcon(props) {
  const tip = props.tip || '';
  const [open, setOpen] = React.useState(false);
  // The bubble is centred on a 16px icon, so within ~130px of either screen
  // edge - routine on a phone - it used to hang off-screen and the definition
  // was unreadable. Measure once per open and nudge it back inside. When
  // nothing overflows the shift is 0 and the bubble sits exactly where it did.
  const [shift, setShift] = React.useState(0);
  const tipRef = React.useRef(null);
  React.useLayoutEffect(() => {
    if (!open || !tipRef.current) { setShift(0); return; }
    const r = tipRef.current.getBoundingClientRect();
    const pad = 8, vw = document.documentElement.clientWidth;
    let dx = 0;
    if (r.left < pad) dx = pad - r.left;
    else if (r.right > vw - pad) dx = (vw - pad) - r.right;
    if (dx) setShift(dx);
  }, [open]);
  return (
    <span style={{ position: 'relative', display: 'inline-flex', verticalAlign: 'middle', marginLeft: 6 }}>
      <button
        type="button"
        aria-label="More info"
        onMouseEnter={() => setOpen(true)}
        onMouseLeave={() => setOpen(false)}
        onFocus={() => setOpen(true)}
        onBlur={() => setOpen(false)}
        onClick={(e) => { e.preventDefault(); setOpen(o => !o); }}
        style={{
          width: 16, height: 16, borderRadius: '50%',
          border: '1px solid #1B3058', background: '#fff', color: '#1B3058',
          fontSize: 11, fontWeight: 800, lineHeight: 1,
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          cursor: 'help', padding: 0,
        }}>i</button>
      {open && tip && (
        <span
          role="tooltip"
          ref={tipRef}
          style={{
            position: 'absolute', left: '50%', bottom: 'calc(100% + 8px)',
            transform: `translateX(calc(-50% + ${shift}px))`,
            background: '#1B3058', color: '#fff',
            padding: '8px 10px', borderRadius: 6,
            fontSize: 12, lineHeight: 1.45, fontWeight: 500,
            width: 'max-content', maxWidth: 260,
            boxShadow: '0 6px 20px rgba(63,74,54, 0.25)',
            zIndex: 30,
            whiteSpace: 'normal',
            textTransform: 'none', letterSpacing: 0,
          }}>{tip}</span>
      )}
    </span>
  );
}

Object.assign(window, {
  cx, Field, Input, Textarea, Select, ChipGroup, YesNo,
  Reveal, SubSectionHeading, Callout, RepeatItem, AddBtn,
  MoneyInput, PercentInput, TfnInput, AddressInput, PhoneInput,
  InfoIcon,
  ValidationCtx
});
