// contact-fields.jsx — Shared validation logic + atomic field components
// for contact data (email, phone, website, address) used across the app.
//
// Surfaces using these components:
//   • Settings → Contact Information   (contact-info.jsx — canonical)
//   • Entity drawer → Contact tab      (entities.jsx)
//   • Directory entity create/edit     (directory.jsx)
//   • Add Song / Rights wizard         (add-song-rights.jsx)
//   • Roster detail inline edits       (screens2.jsx)
//   • Settings → owner transfer        (screens3.jsx)
//   • Reports recipients               (entities.jsx)
//
// Three layers:
//   1. Pure validators        — validateEmail, validatePhone, validateWebsite
//   2. Helpers                — getPhoneExample, getPostalConfig, sortCountriesPinned
//   3. React field components — <EmailField>, <PhoneField>, <WebsiteField>,
//                               <CountrySelect>, <StateField>, <PostalField>,
//                               <AddressBlock>
//
// All exported on `window` so other Babel scripts can use them.
// Validators are also exported as standalone functions for non-React callers.

(function () {

// ── pinned countries ──────────────────────────────────────────────────────
const COUNTRY_PIN = ['US','PR','GB','CA','MX','BR','FR','DE','JP','AU','IT','ES','NL','SE','KR'];

function sortCountriesPinned(list) {
  return list.slice().sort((a, b) => {
    const ai = COUNTRY_PIN.indexOf(a.iso_alpha_2);
    const bi = COUNTRY_PIN.indexOf(b.iso_alpha_2);
    if (ai !== -1 || bi !== -1) {
      if (ai === -1) return 1;
      if (bi === -1) return -1;
      return ai - bi;
    }
    return String(a.common_name || '').localeCompare(String(b.common_name || ''));
  });
}

function getTerritories() {
  const REF = window.REF;
  if (REF && REF.ready && REF.raw && Array.isArray(REF.raw.territories)) {
    return REF.raw.territories;
  }
  return [];
}

// ── postal-code formats ───────────────────────────────────────────────────
const POSTAL_FORMATS = {
  US: { format: '90210 or 90210-1234', regex: /^\d{5}(-\d{4})?$/ },
  PR: { format: '00901 or 00901-1234', regex: /^\d{5}(-\d{4})?$/ },
  CA: { format: 'A1A 1A1',             regex: /^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$/ },
  GB: { format: 'SW1A 1AA',            regex: /^([A-Za-z]{1,2}\d[A-Za-z\d]?)\s*\d[A-Za-z]{2}$/ },
  FR: { format: '75001',               regex: /^\d{5}$/ },
  DE: { format: '10115',               regex: /^\d{5}$/ },
  JP: { format: '100-0001',            regex: /^\d{3}-?\d{4}$/ },
  AU: { format: '2000',                regex: /^\d{4}$/ },
  BR: { format: '01310-100',           regex: /^\d{5}-?\d{3}$/ },
  MX: { format: '01000',               regex: /^\d{5}$/ },
  IT: { format: '00100',               regex: /^\d{5}$/ },
  ES: { format: '28001',               regex: /^\d{5}$/ },
  NL: { format: '1011 AB',             regex: /^\d{4}\s?[A-Za-z]{2}$/ },
  SE: { format: '111 22',              regex: /^\d{3}\s?\d{2}$/ },
  KR: { format: '12345',               regex: /^\d{5}$/ },
  CH: { format: '8001',                regex: /^\d{4}$/ },
  AT: { format: '1010',                regex: /^\d{4}$/ },
  BE: { format: '1000',                regex: /^\d{4}$/ },
  IE: { format: 'D02 X285',            regex: /^[A-Za-z]\d[\dA-Za-z]?\s?[\dA-Za-z]{4}$/ },
  PT: { format: '1000-001',            regex: /^\d{4}-\d{3}$/ },
  PL: { format: '00-001',              regex: /^\d{2}-\d{3}$/ },
  CZ: { format: '110 00',              regex: /^\d{3}\s?\d{2}$/ },
  DK: { format: '1050',                regex: /^\d{4}$/ },
  FI: { format: '00100',               regex: /^\d{5}$/ },
  NO: { format: '0150',                regex: /^\d{4}$/ },
  IN: { format: '110001',              regex: /^\d{6}$/ },
  CN: { format: '100000',              regex: /^\d{6}$/ },
  RU: { format: '101000',              regex: /^\d{6}$/ },
  ZA: { format: '0001',                regex: /^\d{4}$/ },
  AR: { format: 'C1000AAA',            regex: /^[A-Za-z]?\d{4}[A-Za-z]{0,3}$/ },
  NZ: { format: '6011',                regex: /^\d{4}$/ },
  SG: { format: '238859',              regex: /^\d{6}$/ },
  HK: null, AE: null,
};

function getPostalConfig(territoryRowOrIso) {
  let row = territoryRowOrIso;
  if (typeof row === 'string') {
    const ts = getTerritories();
    row = ts.find(t => t.iso_alpha_2 === row) || null;
  }
  if (!row) return { format: 'Postal code', regex: null };
  const iso = row.iso_alpha_2;
  if (POSTAL_FORMATS[iso] === null) return { format: 'No postal code', regex: null };
  const seeded = POSTAL_FORMATS[iso];
  let format = row.postal_code_format || (seeded && seeded.format) || 'Postal code';
  let regex  = null;
  if (row.postal_code_regex) {
    try { regex = new RegExp(row.postal_code_regex); } catch (_) { regex = null; }
  } else if (seeded && seeded.regex) {
    regex = seeded.regex;
  }
  return { format, regex };
}

// ── email validator ───────────────────────────────────────────────────────
function validateEmail(raw) {
  const v = (raw || '').trim();
  if (!v) return { ok: true };
  if (v.includes(' ')) return { ok: false, reason: 'Email cannot contain spaces' };
  const at = v.lastIndexOf('@');
  if (at < 1 || at === v.length - 1) {
    return { ok: false, reason: 'Must include @ and a domain' };
  }
  const local = v.slice(0, at);
  const host = v.slice(at + 1);
  if (local.length > 64) return { ok: false, reason: 'Local part is too long' };
  if (!/^[a-z0-9._%+\-!#$&'*/=?^`{|}~]+$/i.test(local)) {
    return { ok: false, reason: 'Invalid characters before @' };
  }
  if (!/^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(host)) {
    return { ok: false, reason: 'Domain looks incomplete (e.g. domain.com)' };
  }
  const normalized = `${local}@${host.toLowerCase()}`;
  return { ok: true, normalized };
}

// ── website validator ─────────────────────────────────────────────────────
function validateWebsite(raw) {
  const v = (raw || '').trim();
  if (!v) return { ok: true };
  if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) {
    return { ok: false, reason: 'Must start with http:// or https://' };
  }
  let u;
  try { u = new URL(v); }
  catch { return { ok: false, reason: 'Not a valid URL' }; }
  if (u.protocol !== 'http:' && u.protocol !== 'https:') {
    return { ok: false, reason: 'Must start with http:// or https://' };
  }
  const host = u.hostname;
  if (!/^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(host)) {
    return { ok: false, reason: 'Domain looks incomplete (e.g. example.com)' };
  }
  const canonical = u.toString().replace(/\/$/, '');
  return { ok: true, normalized: canonical };
}

// ── phone helpers + validator ─────────────────────────────────────────────
const PHONE_EXAMPLES = {
  US: '(555) 123-4567', CA: '(555) 123-4567', PR: '(787) 555-1234',
  GB: '07400 123456',   IE: '085 123 4567',
  DE: '030 12345678',   FR: '06 12 34 56 78',  ES: '612 34 56 78',  IT: '312 345 6789',
  NL: '06 12345678',    SE: '070 123 45 67',   CH: '079 123 45 67', AT: '0664 1234567',
  BE: '0470 12 34 56',  PT: '912 345 678',     PL: '512 345 678',   CZ: '601 123 456',
  DK: '20 12 34 56',    FI: '040 1234567',     NO: '406 12 345',    GR: '697 1234567',
  RU: '8 (912) 345-67-89',
  JP: '090-1234-5678',  KR: '010-1234-5678',   CN: '138 0013 8000', HK: '5123 4567',
  TW: '0912 345 678',   SG: '8123 4567',       MY: '012-345 6789',  TH: '081 234 5678',
  VN: '091 234 56 78',  ID: '0812-3456-7890',  PH: '0917 123 4567', IN: '98765 43210',
  AU: '0412 345 678',   NZ: '021 123 4567',
  MX: '55 1234 5678',   BR: '(11) 91234-5678', AR: '011 15-2345-6789',
  CL: '9 1234 5678',    CO: '300 1234567',     PE: '912 345 678',   VE: '0414-1234567',
  UY: '094 123 456',    EC: '099 123 4567',
  AE: '050 123 4567',   SA: '050 123 4567',    IL: '050-123-4567',  TR: '0501 234 56 78',
  EG: '0100 123 4567',  ZA: '071 123 4567',    NG: '0801 234 5678', KE: '0712 123456',
};

function getPhoneExample(iso) {
  if (!iso) return '';
  try {
    if (window.libphonenumber && typeof window.libphonenumber.getExampleNumber === 'function' && window.libphonenumber.examples) {
      const ex = window.libphonenumber.getExampleNumber(iso, window.libphonenumber.examples);
      if (ex) return ex.formatNational();
    }
  } catch (_) {}
  return PHONE_EXAMPLES[iso] || '';
}

function validatePhone(rawNumber, iso) {
  const v = (rawNumber || '').trim();
  if (!v) return { ok: true };
  if (!iso) {
    if (!/^[\d+\-().\s]{4,}$/.test(v)) return { ok: false, reason: 'Pick a country code first' };
    return { ok: true };
  }
  const lib = window.libphonenumber;
  if (!lib || typeof lib.parsePhoneNumber !== 'function') {
    return /^[\d+\-().\s]{4,}$/.test(v) ? { ok: true } : { ok: false, reason: 'Invalid phone format' };
  }
  try {
    const phone = lib.parsePhoneNumber(v, iso);
    if (!phone) return { ok: false, reason: 'Could not parse number' };
    if (!phone.isValid()) {
      const example = getPhoneExample(iso);
      return { ok: false, reason: example ? `Doesn't match ${iso} format · e.g. ${example}` : `Invalid ${iso} number` };
    }
    return { ok: true, formatted: phone.formatNational(), e164: phone.number };
  } catch (e) {
    return { ok: false, reason: 'Invalid phone format' };
  }
}

// ── shared field styles ───────────────────────────────────────────────────
const cf_css = {
  fld: {
    padding: '0 10px', fontSize: 13, fontFamily: 'inherit',
    border: '1px solid var(--rule)', background: 'var(--bg-2)',
    outline: 'none', color: 'var(--ink)', width: '100%',
    height: 31, lineHeight: '29px', boxSizing: 'border-box',
  },
  smFld: {
    padding: '0 24px 0 10px', fontSize: 13, fontFamily: 'inherit',
    border: '1px solid var(--rule)', background: 'var(--bg-2)',
    outline: 'none', color: 'var(--ink)',
    height: 31, lineHeight: '29px', boxSizing: 'border-box',
    appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none',
    backgroundImage: 'linear-gradient(45deg, transparent 50%, var(--ink-3) 50%), linear-gradient(135deg, var(--ink-3) 50%, transparent 50%)',
    backgroundPosition: 'calc(100% - 12px) 50%, calc(100% - 7px) 50%',
    backgroundSize: '5px 5px, 5px 5px',
    backgroundRepeat: 'no-repeat',
  },
  err: { fontSize: 10, color: 'var(--danger, #b54545)', letterSpacing: '.02em' },
  hint: { fontSize: 10, color: 'var(--ink-3)', letterSpacing: '.02em' },
};

function mergeStyle(base, extra) {
  if (!extra) return base;
  const out = Object.assign({}, base, extra);
  // If height is overridden but lineHeight isn't, recompute lineHeight to
  // match (height - 2px borders) so vertical centering survives shrink.
  if (extra.height != null && extra.lineHeight == null && base.lineHeight != null) {
    const h = typeof extra.height === 'number' ? extra.height : parseInt(extra.height, 10);
    if (Number.isFinite(h)) out.lineHeight = (h - 2) + 'px';
  }
  return out;
}

// Build a select-style that preserves the chevron room + appearance reset
// from cf_css.smFld even when the caller passes their own style overrides.
function selectStyle(callerStyle, extras) {
  const base = mergeStyle(cf_css.smFld, Object.assign({ fontFamily: 'inherit' }, extras || {}));
  if (!callerStyle) return base;
  const out = Object.assign({}, base, callerStyle);
  out.paddingRight = 24; // always reserve room for the chevron
  if (extras && extras.width) out.width = extras.width;
  if (callerStyle.height != null && callerStyle.lineHeight == null) {
    const h = typeof callerStyle.height === 'number' ? callerStyle.height : parseInt(callerStyle.height, 10);
    if (Number.isFinite(h)) out.lineHeight = (h - 2) + 'px';
  }
  return out;
}

// ── EmailField ────────────────────────────────────────────────────────────
// Drop-in replacement for any free-text email input.
// Props: value, onChange(string), placeholder, style, className, autoComplete
function EmailField({ value, onChange, placeholder = 'name@example.com', style, className, autoComplete = 'email', disabled }) {
  const [touched, setTouched] = React.useState(false);
  const v = value || '';
  const result = validateEmail(v);
  const showError = touched && v && !result.ok;
  const showHint  = touched && v && result.ok && result.normalized && result.normalized !== v;
  const fldStyle = style || cf_css.fld;

  return (
    <div style={{display:'grid',gap:2}}>
      <input
        type="email"
        inputMode="email"
        autoComplete={autoComplete}
        value={v}
        placeholder={placeholder}
        disabled={disabled}
        onChange={e => onChange(e.target.value)}
        onBlur={() => {
          setTouched(true);
          const r = validateEmail(v);
          if (r.ok && r.normalized && r.normalized !== v) onChange(r.normalized);
        }}
        aria-invalid={showError || undefined}
        className={className}
        style={mergeStyle(fldStyle, { borderColor: showError ? 'var(--danger, #b54545)' : (fldStyle.borderColor || fldStyle.border ? undefined : 'var(--rule)') })}
      />
      {showError && <span className="ff-mono" style={cf_css.err}>{result.reason}</span>}
      {showHint && <span className="ff-mono" style={cf_css.hint}>Will be saved as {result.normalized}</span>}
    </div>
  );
}

// ── WebsiteField ──────────────────────────────────────────────────────────
function WebsiteField({ value, onChange, placeholder = 'https://example.com', style, className, autoComplete = 'url', disabled }) {
  const [touched, setTouched] = React.useState(false);
  const v = value || '';
  const result = validateWebsite(v);
  const showError = touched && v && !result.ok;
  const showHint  = touched && v && result.ok && result.normalized && result.normalized !== v;
  const fldStyle = style || cf_css.fld;

  return (
    <div style={{display:'grid',gap:2}}>
      <input
        type="url"
        inputMode="url"
        autoComplete={autoComplete}
        value={v}
        placeholder={placeholder}
        disabled={disabled}
        onChange={e => onChange(e.target.value)}
        onBlur={() => {
          setTouched(true);
          const r = validateWebsite(v);
          if (r.ok && r.normalized && r.normalized !== v) onChange(r.normalized);
        }}
        aria-invalid={showError || undefined}
        className={className}
        style={mergeStyle(fldStyle, { borderColor: showError ? 'var(--danger, #b54545)' : undefined })}
      />
      {showError && <span className="ff-mono" style={cf_css.err}>{result.reason}</span>}
      {showHint && <span className="ff-mono" style={cf_css.hint}>Will be saved as {result.normalized}</span>}
    </div>
  );
}

// ── PhoneField ────────────────────────────────────────────────────────────
// Compact phone field with built-in country dial-code dropdown.
// Two value shapes supported:
//  (a) Object value: { country_iso, number }   — pass via valueObj/onChangeObj
//  (b) Single string: pass via value/onChange. Stored as "+CC NUMBER".
//      Country defaults to defaultCountry (or 'US').
function PhoneField({
  valueObj, onChangeObj,
  value, onChange,
  defaultCountry = 'US',
  style, className,
  layout = 'inline', // 'inline' (code + number side by side) or 'stacked'
}) {
  const isObjectMode = !!onChangeObj;
  const [touched, setTouched] = React.useState(false);

  let iso, number;
  if (isObjectMode) {
    iso = (valueObj && valueObj.country_iso) || defaultCountry || '';
    number = (valueObj && valueObj.number) || '';
  } else {
    // String mode: parse "+CC rest" if present, else assume defaultCountry
    const v = (value || '').trim();
    if (v) {
      // Try to parse with libphonenumber if available
      const lib = window.libphonenumber;
      if (lib && lib.parsePhoneNumber) {
        try {
          const parsed = v.startsWith('+')
            ? lib.parsePhoneNumber(v)
            : lib.parsePhoneNumber(v, defaultCountry);
          if (parsed) {
            iso = parsed.country || defaultCountry;
            number = parsed.formatNational ? parsed.formatNational() : v;
          } else {
            iso = defaultCountry; number = v;
          }
        } catch (_) {
          iso = defaultCountry; number = v;
        }
      } else {
        iso = defaultCountry; number = v;
      }
    } else {
      iso = defaultCountry; number = '';
    }
  }

  const territories = getTerritories();
  const dialOpts = territories
    .filter(t => t.dial_code && t.iso_alpha_2 && t.territory_type_id === 2)
    .slice()
    .sort((a, b) => {
      const ai = COUNTRY_PIN.indexOf(a.iso_alpha_2);
      const bi = COUNTRY_PIN.indexOf(b.iso_alpha_2);
      if (ai !== -1 || bi !== -1) {
        if (ai === -1) return 1;
        if (bi === -1) return -1;
        return ai - bi;
      }
      return String(a.common_name || '').localeCompare(String(b.common_name || ''));
    });

  const example = getPhoneExample(iso);
  const result = validatePhone(number, iso);
  const showError = touched && number && !result.ok;
  const showHint  = touched && number && result.ok && result.formatted && result.formatted !== number;

  const setIso = (newIso) => {
    if (isObjectMode) {
      const t = territories.find(x => x.iso_alpha_2 === newIso);
      onChangeObj({ ...(valueObj||{}), country_iso: newIso, dial_code: t ? t.dial_code : '', number });
    } else {
      // Re-emit as "+CC number" if we have a dial code
      const t = territories.find(x => x.iso_alpha_2 === newIso);
      const dial = t ? t.dial_code : '';
      onChange && onChange(number ? `${dial} ${number}`.trim() : '');
    }
  };
  const setNumber = (newNumber) => {
    if (isObjectMode) {
      onChangeObj({ ...(valueObj||{}), country_iso: iso, number: newNumber });
    } else {
      const t = territories.find(x => x.iso_alpha_2 === iso);
      const dial = t ? t.dial_code : '';
      onChange && onChange(newNumber ? `${dial} ${newNumber}`.trim() : '');
    }
  };

  const fldStyle = style || cf_css.fld;
  const codeStyle = selectStyle(style, { fontSize: 12, width: 130 });
  const numStyle = mergeStyle(fldStyle, {
    fontFamily: 'var(--ff-mono, monospace)',
    borderColor: showError ? 'var(--danger, #b54545)' : undefined,
  });

  const wrap = layout === 'stacked'
    ? { display:'grid', gap:6 }
    : { display:'grid', gridTemplateColumns:'130px 1fr', gap:6, alignItems:'start' };

  return (
    <div style={wrap}>
      <select
        value={iso}
        onChange={e => setIso(e.target.value)}
        className="ff-mono"
        style={codeStyle}
        aria-label="Country code">
        <option value="">Code…</option>
        {dialOpts.map(t => (
          <option key={t.iso_alpha_2} value={t.iso_alpha_2}>
            {t.emoji_flag ? `${t.emoji_flag}  ` : ''}{t.dial_code} · {t.iso_alpha_2}
          </option>
        ))}
      </select>
      <div style={{display:'grid',gap:2}}>
        <input
          type="tel"
          inputMode="tel"
          autoComplete="tel"
          value={number}
          onChange={e => setNumber(e.target.value)}
          onBlur={() => {
            setTouched(true);
            const r = validatePhone(number, iso);
            if (r.ok && r.formatted && r.formatted !== number) setNumber(r.formatted);
          }}
          placeholder={example || 'Phone number'}
          aria-invalid={showError || undefined}
          className={className}
          style={numStyle}/>
        {showError && <span className="ff-mono" style={cf_css.err}>{result.reason}</span>}
        {showHint && <span className="ff-mono" style={cf_css.hint}>Will be saved as {result.formatted}</span>}
      </div>
    </div>
  );
}

// ── CountrySelect ─────────────────────────────────────────────────────────
// Dropdown of ISO-2 countries with pinned-to-top + flag emojis + separator.
function CountrySelect({ value, onChange, style, className, placeholder = 'Country…', allTerritories = false }) {
  const territories = getTerritories();
  const opts = sortCountriesPinned(
    territories.filter(t =>
      t.iso_alpha_2 && (allTerritories || t.territory_type_id === 2)
    )
  );
  const lastPinIdx = opts.reduce((acc, t, i) => (
    COUNTRY_PIN.includes(t.iso_alpha_2) ? i : acc
  ), -1);

  return (
    <select
      value={value || ''}
      onChange={e => onChange(e.target.value)}
      style={selectStyle(style, { width: (style && style.width) || undefined })}
      className={className}
      aria-label="Country">
      <option value="">{placeholder}</option>
      {opts.map((t, i) => (
        <React.Fragment key={t.iso_alpha_2}>
          <option value={t.iso_alpha_2}>
            {t.emoji_flag ? `${t.emoji_flag}  ` : ''}{t.common_name} · {t.iso_alpha_2}
          </option>
          {i === lastPinIdx && <option disabled value="__sep__">──────────</option>}
        </React.Fragment>
      ))}
    </select>
  );
}

// ── StateField ────────────────────────────────────────────────────────────
// Renders a state/region/province dropdown if subdivisions exist for the
// country, else a free-text input. Uses per-country label.
function StateField({ countryIso, value, onChange, style, className }) {
  const territories = getTerritories();
  const ctry = countryIso ? territories.find(t => t.iso_alpha_2 === countryIso) : null;
  const stateLabel = (ctry && ctry.subdivision_label) ? ctry.subdivision_label : 'State / Region';
  const subdivisions = (typeof window.getSubdivisions === 'function')
    ? window.getSubdivisions(countryIso) : null;
  const fldStyle = style || cf_css.fld;
  const selStyle = selectStyle(style, { width: '100%' });

  if (subdivisions) {
    return (
      <select
        value={value || ''}
        onChange={e => onChange(e.target.value)}
        style={selStyle}
        className={className}
        aria-label={stateLabel}>
        <option value="">{stateLabel}…</option>
        {subdivisions.map(s => (
          <option key={s.code} value={s.code}>{s.name}</option>
        ))}
      </select>
    );
  }
  return (
    <input
      type="text"
      value={value || ''}
      onChange={e => onChange(e.target.value)}
      placeholder={stateLabel}
      style={fldStyle}
      className={className}/>
  );
}

// ── PostalField ───────────────────────────────────────────────────────────
function PostalField({ countryIso, value, onChange, style, className }) {
  const cfg = getPostalConfig(countryIso);
  const v = value || '';
  const invalid = !!(v && cfg.regex && !cfg.regex.test(v.trim()));
  const disabled = cfg.format === 'No postal code';
  const fldStyle = style || cf_css.fld;
  const ctry = countryIso || 'country';

  return (
    <div style={{display:'grid',gap:2}}>
      <input
        type="text"
        value={v}
        onChange={e => onChange(e.target.value)}
        placeholder={cfg.format}
        disabled={disabled}
        aria-invalid={invalid || undefined}
        className={className}
        style={mergeStyle(fldStyle, {
          borderColor: invalid ? 'var(--danger, #b54545)' : undefined,
          opacity: disabled ? 0.5 : 1,
        })}/>
      {invalid && (
        <span className="ff-mono" style={cf_css.err}>
          Doesn't match {ctry} format · {cfg.format}
        </span>
      )}
    </div>
  );
}

// ── AddressBlock ──────────────────────────────────────────────────────────
// Full address block with country-first ordering, per-country state dropdown,
// per-country postal mask, PR special-case (no city — municipality lives in state).
//
// Value shape (flat): { line1, line2, city, state, postal, country }
// or pass a custom keys map to adapt to different field names.
function AddressBlock({ value, onChange, style, columns = 'auto', keys }) {
  const k = Object.assign({
    line1: 'line1', line2: 'line2', city: 'city',
    state: 'state', postal: 'postal_code', country: 'country_iso',
  }, keys || {});
  const get = (key) => (value && value[k[key]]) || '';
  const set = (key, val) => onChange({ ...(value||{}), [k[key]]: val });

  const country = get('country');
  const isPR = country === 'PR';
  const fldStyle = style || cf_css.fld;

  // Layout: 2-fr city + state + postal (or 1.4 + 1 when PR hides city)
  const gridCols = isPR ? '1.4fr 1fr' : '1.4fr 1fr 1fr';

  return (
    <div style={{display:'grid',gap:8}}>
      <CountrySelect
        value={country}
        onChange={v => set('country', v)}
        style={fldStyle}
      />
      <input type="text" value={get('line1')} onChange={e => set('line1', e.target.value)}
        placeholder="Street address" style={fldStyle}/>
      <input type="text" value={get('line2')} onChange={e => set('line2', e.target.value)}
        placeholder="Apt, suite, floor (optional)" style={fldStyle}/>
      <div style={{display:'grid',gridTemplateColumns:gridCols,gap:8}}>
        {!isPR && (
          <input type="text" value={get('city')} onChange={e => set('city', e.target.value)}
            placeholder="City" style={fldStyle}/>
        )}
        <StateField countryIso={country} value={get('state')} onChange={v => set('state', v)} style={fldStyle}/>
        <PostalField countryIso={country} value={get('postal')} onChange={v => set('postal', v)} style={fldStyle}/>
      </div>
    </div>
  );
}

// ── exports ───────────────────────────────────────────────────────────────
Object.assign(window, {
  // validators (pure)
  validateEmail, validateWebsite, validatePhone,
  // helpers
  getPhoneExample, getPostalConfig, getTerritories,
  sortCountriesPinned, COUNTRY_PIN,
  // React components
  EmailField, WebsiteField, PhoneField,
  CountrySelect, StateField, PostalField, AddressBlock,
});

})();
