// contact-info.jsx — Schema-aligned contact information block.
//
// Backed by:
//   astro.contact_entries        — one row per (party, contact_type, label_code, …)
//   ref.contact_labels           — typed roles (email/phone/address/website)
//   ref.territories              — ISO-2 + dial_code + emoji_flag for country & phone
//
// Emits a normalized `contactEntries: []` shape on every change. Each entry is:
//   { contact_type: 'email'|'phone'|'address'|'website',
//     label_code: 'GENERAL'|'ROYALTIES'|…,
//     // type-specific fields:
//     value?,                                          // email / website
//     country_iso?, dial_code?, number?,               // phone
//     line1?, line2?, city?, state?, postal_code?, country_iso?  // address
//   }
//
// Usage:
//   <ContactInfoSection value={form.contactEntries} onChange={list => set('contactEntries', list)} />
//
// The component owns its own internal "draft" state — parent only sees the
// committed list. This keeps the modal forms simple.

(function () {

const TYPES = [
  { code: 'email',   label: 'Email',    plural: 'emails'    },
  { code: 'phone',   label: 'Phone',    plural: 'phones'    },
  { code: 'address', label: 'Address',  plural: 'addresses' },
  { code: 'website', label: 'Website',  plural: 'websites'  },
];

// Pull contact_labels by contact_type from REF; provide a defensive fallback
// list that mirrors what's in db/ref/all.json so the component still renders
// before REF is hydrated.
const FALLBACK_LABELS = {
  email: [
    {code:'GENERAL',     label:'General'},
    {code:'ROYALTIES',   label:'Royalties'},
    {code:'SYNC',        label:'Sync Licensing'},
    {code:'LEGAL',       label:'Legal'},
    {code:'AR',          label:'A&R'},
    {code:'ACCOUNTING',  label:'Accounting'},
    {code:'MARKETING',   label:'Marketing'},
    {code:'LICENSING',   label:'Licensing'},
    {code:'COPYRIGHT',   label:'Copyright'},
    {code:'BOOKING',     label:'Booking'},
    {code:'PUBLISHING',  label:'Publishing'},
  ],
  phone: [
    {code:'GENERAL', label:'General'},
    {code:'OFFICE',  label:'Office'},
    {code:'MOBILE',  label:'Mobile'},
    {code:'FAX',     label:'Fax'},
    {code:'BOOKING', label:'Booking'},
    {code:'PRESS',   label:'Press'},
  ],
  address: [
    {code:'GENERAL',    label:'General'},
    {code:'MAILING',    label:'Mailing'},
    {code:'OFFICE',     label:'Office'},
    {code:'BILLING',    label:'Billing'},
    {code:'REGISTERED', label:'Registered Agent'},
  ],
  website: [
    {code:'GENERAL',   label:'Main'},
    {code:'PRESS',     label:'Press'},
    {code:'STORE',     label:'Store'},
    {code:'BLOG',      label:'Blog'},
    {code:'BOOKING',   label:'Booking'},
    {code:'PORTFOLIO', label:'Portfolio'},
  ],
};

function getLabelsFor(type) {
  const REF = window.REF;
  const live = REF && REF.ready && REF.raw && Array.isArray(REF.raw.contact_labels)
    ? REF.raw.contact_labels.filter(x => x.contact_type === type)
        .map(x => ({ code: x.code, label: x.label }))
    : null;
  return (live && live.length) ? live : FALLBACK_LABELS[type];
}

// Pinned ISO-2 codes for the country dropdown — most common music territories first.
// Order matters; these appear at the top of every country selector in this app.
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 || ''));
  });
}
window.COUNTRY_PIN = COUNTRY_PIN;
window.sortCountriesPinned = sortCountriesPinned;

// Per-country postal-code formats. Pattern is the human-readable mask shown as
// placeholder; regex is the validator. We seed the most common countries here
// because the ref.territories rows in the demo data have these columns blank.
// If the territory row already has postal_code_format / postal_code_regex set,
// those win.
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,                       // no postal codes
  AE: null,
  // Countries without a postal-code system get { format: null, regex: null }.
};

// Resolve the postal config for a given territory row. Prefers data on the
// row itself, then our seeded map, else { format: 'Postal code', regex: null }.
function getPostalConfig(territoryRow) {
  if (!territoryRow) return { format: 'Postal code', regex: null };
  const iso = territoryRow.iso_alpha_2;
  if (POSTAL_FORMATS[iso] === null) return { format: 'No postal code', regex: null };
  const seeded = POSTAL_FORMATS[iso];
  let format = territoryRow.postal_code_format || (seeded && seeded.format) || 'Postal code';
  let regex  = null;
  if (territoryRow.postal_code_regex) {
    try { regex = new RegExp(territoryRow.postal_code_regex); } catch (_) { regex = null; }
  } else if (seeded && seeded.regex) {
    regex = seeded.regex;
  }
  return { format, regex };
}
window.getPostalConfig = getPostalConfig;

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

// ── shared atoms ───────────────────────────────────────────────────────────

const 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',
  },
  rowLabel: {
    fontSize: 9, letterSpacing: '.12em', color: 'var(--ink-3)',
    fontWeight: 500,
  },
  miniBtn: {
    padding: '6px 10px', fontSize: 9, letterSpacing: '.1em', fontWeight: 500,
    background: 'transparent', border: '1px solid var(--rule)',
    cursor: 'pointer', color: 'var(--ink-2)',
  },
  removeBtn: {
    background: 'transparent', border: 0, color: 'var(--ink-3)',
    cursor: 'pointer', fontSize: 14, padding: '4px 8px', lineHeight: 1,
  },
  sectionHead: {
    fontSize: 9, letterSpacing: '.14em', color: 'var(--ink-3)',
    fontWeight: 600, paddingTop: 14, borderTop: '1px solid var(--rule-soft)',
    marginTop: 6, display: 'flex', justifyContent: 'space-between',
    alignItems: 'center',
  },
};

function LabelChips({ type, value, onChange }) {
  const opts = getLabelsFor(type);
  return (
    <select
      value={value || 'GENERAL'}
      onChange={e => onChange(e.target.value)}
      className="ff-mono upper"
      style={{
        ...css.smFld,
        fontSize: 10, letterSpacing: '.08em',
        padding: '0 8px', minWidth: 120,
      }}>
      {opts.map(o => (
        <option key={o.code} value={o.code}>{o.label}</option>
      ))}
    </select>
  );
}

// ── per-type rows ──────────────────────────────────────────────────────────

// Validate an email address: standard local@domain shape, dotted host with TLD ≥ 2 chars,
// no spaces, lowercase the domain on canonicalization.
// Returns { ok: boolean, normalized?: string, reason?: string }.
function validateEmail(raw) {
  const v = (raw || '').trim();
  if (!v) return { ok: true }; // empty is fine — not yet entered
  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);
  // Local part: any printable chars except spaces and a few specials, length ≤64
  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 @' };
  }
  // Domain: at least one dot, TLD ≥2 chars
  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)' };
  }
  // Canonicalize: lowercase domain, preserve case of local part (RFC-correct).
  const normalized = `${local}@${host.toLowerCase()}`;
  return { ok: true, normalized };
}

function EmailRow({ entry, onChange, onRemove }) {
  const [touched, setTouched] = React.useState(false);
  const v = entry.value || '';
  const result = validateEmail(v);
  const showError = touched && v && !result.ok;
  const showHint  = touched && v && result.ok && result.normalized && result.normalized !== v;

  return (
    <div style={{display:'grid',gridTemplateColumns:'140px 1fr auto',gap:8,alignItems:'start'}}>
      <LabelChips type="email" value={entry.label_code} onChange={x => onChange({...entry, label_code: x})}/>
      <div style={{display:'grid',gap:2}}>
        <input
          type="email"
          inputMode="email"
          autoComplete="email"
          value={v}
          placeholder="name@example.com"
          onChange={e => onChange({...entry, value: e.target.value})}
          onBlur={() => {
            setTouched(true);
            // Auto-normalize on blur: lowercase the domain.
            const r = validateEmail(v);
            if (r.ok && r.normalized && r.normalized !== v) {
              onChange({...entry, value: r.normalized});
            }
          }}
          aria-invalid={showError || undefined}
          style={{
            ...css.fld,
            borderColor: showError ? 'var(--danger, #b54545)' : 'var(--rule)',
          }}
        />
        {showError && (
          <span className="ff-mono" style={{fontSize:10,color:'var(--danger, #b54545)',letterSpacing:'.02em'}}>
            {result.reason}
          </span>
        )}
        {showHint && (
          <span className="ff-mono" style={{fontSize:10,color:'var(--ink-3)',letterSpacing:'.02em'}}>
            Will be saved as {result.normalized}
          </span>
        )}
      </div>
      <button onClick={onRemove} aria-label="Remove email" style={css.removeBtn}>✕</button>
    </div>
  );
}

// Validate a URL: must parse, must be http(s), must have a dotted host with a TLD ≥ 2 chars.
// Returns { ok: boolean, normalized?: string, reason?: string }.
// Note: we do NOT auto-prepend a scheme — both http:// and https:// are valid,
// so picking one for the user is wrong. We only validate what they typed, and
// strip a trailing slash for canonicalization.
function validateWebsite(raw) {
  const v = (raw || '').trim();
  if (!v) return { ok: true }; // empty is fine — not yet entered
  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;
  // Reject things like "localhost", "foo", or "192.168.1.1" for public websites.
  // Require at least one dot and a TLD of ≥2 letters. Allow IDN punycode (xn--).
  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)' };
  }
  // Canonicalize: strip trailing slash on root path only.
  const canonical = u.toString().replace(/\/$/, '');
  return { ok: true, normalized: canonical };
}

function WebsiteRow({ entry, onChange, onRemove }) {
  const [touched, setTouched] = React.useState(false);
  const v = entry.value || '';
  const result = validateWebsite(v);
  const showError = touched && v && !result.ok;
  const showHint  = touched && v && result.ok && result.normalized && result.normalized !== v;

  return (
    <div style={{display:'grid',gridTemplateColumns:'140px 1fr auto',gap:8,alignItems:'start'}}>
      <LabelChips type="website" value={entry.label_code} onChange={x => onChange({...entry, label_code: x})}/>
      <div style={{display:'grid',gap:2}}>
        <input
          type="url"
          inputMode="url"
          autoComplete="url"
          value={v}
          placeholder="https://example.com"
          onChange={e => onChange({...entry, value: e.target.value})}
          onBlur={() => {
            setTouched(true);
            // Auto-normalize on blur: prepend https:// and strip trailing slash if valid.
            const r = validateWebsite(v);
            if (r.ok && r.normalized && r.normalized !== v) {
              onChange({...entry, value: r.normalized});
            }
          }}
          aria-invalid={showError || undefined}
          style={{
            ...css.fld,
            borderColor: showError ? 'var(--danger, #b54545)' : 'var(--rule)',
          }}
        />
        {showError && (
          <span className="ff-mono" style={{fontSize:10,color:'var(--danger, #b54545)',letterSpacing:'.02em'}}>
            {result.reason}
          </span>
        )}
        {showHint && (
          <span className="ff-mono" style={{fontSize:10,color:'var(--ink-3)',letterSpacing:'.02em'}}>
            Will be saved as {result.normalized}
          </span>
        )}
      </div>
      <button onClick={onRemove} aria-label="Remove website" style={css.removeBtn}>✕</button>
    </div>
  );
}

// Per-country phone placeholder examples — used as `placeholder` in the input
// once the user picks a country code. Comes from libphonenumber's `getExampleNumber`
// when the lib is available; otherwise falls back to a small lookup table.
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 '';
  // Prefer the library's example, since it's locale-perfect for ~245 countries.
  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 (_) { /* fall through */ }
  return PHONE_EXAMPLES[iso] || '';
}

// Validate + format a national-format phone number against an ISO country.
// Returns { ok, formatted?, e164?, reason? }.
function validatePhone(rawNumber, iso) {
  const v = (rawNumber || '').trim();
  if (!v) return { ok: true };
  if (!iso) {
    // No country picked — accept anything looking phone-shaped, no formatting.
    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') {
    // Library not loaded — soft validation only.
    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' };
  }
}

function PhoneRow({ entry, onChange, onRemove }) {
  const [touched, setTouched] = React.useState(false);
  const territories = getTerritories();
  // Sort territories alphabetically by name; pin a few common ones to the top
  const PIN = COUNTRY_PIN;
  const dialOpts = territories
    .filter(t => t.dial_code && t.iso_alpha_2 && t.territory_type_id === 2)
    .slice()
    .sort((a, b) => {
      const ai = PIN.indexOf(a.iso_alpha_2);
      const bi = 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 || ''));
    });

  // The displayed selector value is the iso_alpha_2 (so duplicate dial codes
  // — e.g. US/CA both +1 — stay distinct).
  const selVal = entry.country_iso || '';
  const number = entry.number || '';
  const example = getPhoneExample(selVal);
  const result = validatePhone(number, selVal);
  const showError = touched && number && !result.ok;
  const showHint  = touched && number && result.ok && result.formatted && result.formatted !== number;

  return (
    <div style={{display:'grid',gridTemplateColumns:'140px 180px 1fr auto',gap:8,alignItems:'start'}}>
      <LabelChips type="phone" value={entry.label_code} onChange={v => onChange({...entry, label_code: v})}/>
      <select
        value={selVal}
        onChange={e => {
          const iso = e.target.value;
          const t = territories.find(x => x.iso_alpha_2 === iso);
          onChange({...entry, country_iso: iso, dial_code: t ? t.dial_code : ''});
        }}
        className="ff-mono"
        style={{...css.smFld, fontSize: 12}}
        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 => onChange({...entry, number: e.target.value})}
          onBlur={() => {
            setTouched(true);
            // Auto-normalize on blur: format as national-style for the chosen country.
            const r = validatePhone(number, selVal);
            if (r.ok && r.formatted && r.formatted !== number) {
              onChange({...entry, number: r.formatted});
            }
          }}
          placeholder={example || 'Phone number'}
          aria-invalid={showError || undefined}
          style={{
            ...css.fld,
            fontFamily: 'var(--ff-mono, monospace)',
            borderColor: showError ? 'var(--danger, #b54545)' : 'var(--rule)',
          }}/>
        {showError && (
          <span className="ff-mono" style={{fontSize:10,color:'var(--danger, #b54545)',letterSpacing:'.02em'}}>
            {result.reason}
          </span>
        )}
        {showHint && (
          <span className="ff-mono" style={{fontSize:10,color:'var(--ink-3)',letterSpacing:'.02em'}}>
            Will be saved as {result.formatted}
          </span>
        )}
      </div>
      <button onClick={onRemove} aria-label="Remove phone" style={css.removeBtn}>✕</button>
    </div>
  );
}

function AddressRow({ entry, onChange, onRemove }) {
  const territories = getTerritories();
  const countryOpts = sortCountriesPinned(
    territories.filter(t => t.iso_alpha_2 && t.territory_type_id === 2)
  );
  // Index of the last pinned country, so we can render a separator below it.
  const lastPinIdx = countryOpts.reduce((acc, t, i) => (
    COUNTRY_PIN.includes(t.iso_alpha_2) ? i : acc
  ), -1);

  // Per-country label for the state/region/province line. Falls back to "State / Region".
  const ctry = entry.country_iso ? territories.find(t => t.iso_alpha_2 === entry.country_iso) : null;
  const stateLabel = (ctry && ctry.subdivision_label) ? ctry.subdivision_label : 'State / Region';
  const subdivisions = (typeof window.getSubdivisions === 'function') ? window.getSubdivisions(entry.country_iso) : null;
  const postalCfg  = getPostalConfig(ctry);
  const postalFmt  = postalCfg.format;
  const postalRe   = postalCfg.regex;
  const postalVal  = entry.postal_code || '';
  // Only flag invalid once the user has typed something AND a regex exists for
  // this country. Empty value → no error (postal code may be optional).
  const postalInvalid = !!(postalVal && postalRe && !postalRe.test(postalVal.trim()));
  const postalDisabled = postalCfg.format === 'No postal code';

  return (
    <div style={{
      border: '1px solid var(--rule-soft)',
      padding: 12,
      display: 'grid',
      gap: 8,
      background: 'var(--bg)',
    }}>
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',gap:8}}>
        <LabelChips type="address" value={entry.label_code} onChange={v => onChange({...entry, label_code: v})}/>
        <button onClick={onRemove} aria-label="Remove address" style={css.removeBtn}>✕</button>
      </div>

      <select
        value={entry.country_iso || ''}
        onChange={e => onChange({...entry, country_iso: e.target.value})}
        style={{...css.smFld, width:'100%', fontFamily: 'inherit'}}
        aria-label="Country">
        <option value="">Country…</option>
        {countryOpts.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>

      <input type="text" value={entry.line1 || ''} onChange={e => onChange({...entry, line1: e.target.value})}
        placeholder="Street address" style={css.fld}/>
      <input type="text" value={entry.line2 || ''} onChange={e => onChange({...entry, line2: e.target.value})}
        placeholder="Apt, suite, floor (optional)" style={css.fld}/>

      <div style={{display:'grid',gridTemplateColumns: entry.country_iso === 'PR' ? '1.4fr 1fr' : '1.4fr 1fr 1fr',gap:8}}>
        {entry.country_iso !== 'PR' && (
          <input type="text" value={entry.city || ''} onChange={e => onChange({...entry, city: e.target.value})}
            placeholder="City" style={css.fld}/>
        )}
        {subdivisions ? (
          <select
            value={entry.state || ''}
            onChange={e => onChange({...entry, state: e.target.value})}
            style={{...css.smFld, width:'100%'}}
            aria-label={stateLabel}>
            <option value="">{stateLabel}…</option>
            {subdivisions.map(s => (
              <option key={s.code} value={s.code}>{s.name}</option>
            ))}
          </select>
        ) : (
          <input type="text" value={entry.state || ''} onChange={e => onChange({...entry, state: e.target.value})}
            placeholder={stateLabel} style={css.fld}/>
        )}
        <div style={{display:'grid',gap:2}}>
          <input
            type="text"
            value={postalVal}
            onChange={e => onChange({...entry, postal_code: e.target.value})}
            placeholder={postalFmt}
            disabled={postalDisabled}
            aria-invalid={postalInvalid || undefined}
            aria-describedby={postalInvalid ? `postal-err-${entry._uid || ''}` : undefined}
            style={{
              ...css.fld,
              borderColor: postalInvalid ? 'var(--danger, #b54545)' : 'var(--rule)',
              opacity: postalDisabled ? 0.5 : 1,
            }}
          />
          {postalInvalid && (
            <span
              id={`postal-err-${entry._uid || ''}`}
              className="ff-mono"
              style={{fontSize:10,color:'var(--danger, #b54545)',letterSpacing:'.02em'}}
            >
              Doesn't match {ctry?.iso_alpha_2 || 'country'} format · {postalFmt}
            </span>
          )}
        </div>
      </div>
    </div>
  );
}

// ── main component ────────────────────────────────────────────────────────

function ContactInfoSection({ value, onChange, defaultExpanded }) {
  // `value` is the canonical contact_entries array. Group it by contact_type
  // for rendering; persist the flat list back through onChange.
  const list = Array.isArray(value) ? value : [];
  const byType = React.useMemo(() => {
    const out = { email: [], phone: [], address: [], website: [] };
    list.forEach((e, i) => {
      if (out[e.contact_type]) out[e.contact_type].push({ ...e, _idx: i });
    });
    return out;
  }, [list]);

  const update = (idx, next) => {
    const copy = list.slice();
    copy[idx] = next;
    onChange(copy);
  };
  const remove = (idx) => {
    const copy = list.slice();
    copy.splice(idx, 1);
    onChange(copy);
  };
  const add = (type) => {
    const seed = {
      contact_type: type,
      label_code: 'GENERAL',
    };
    if (type === 'phone')   Object.assign(seed, { country_iso:'', dial_code:'', number:'' });
    if (type === 'address') Object.assign(seed, { line1:'', line2:'', city:'', state:'', postal_code:'', country_iso:'' });
    if (type === 'email' || type === 'website') seed.value = '';
    onChange([...list, seed]);
  };

  // Auto-seed one of each type the first time the section is rendered with an
  // empty list — that way the user doesn't have to click "Add" to start.
  const seededRef = React.useRef(false);
  React.useEffect(() => {
    if (seededRef.current) return;
    if (list.length === 0 && defaultExpanded) {
      seededRef.current = true;
      onChange([
        { contact_type:'email',   label_code:'GENERAL', value:'' },
        { contact_type:'phone',   label_code:'GENERAL', country_iso:'', dial_code:'', number:'' },
        { contact_type:'address', label_code:'GENERAL', line1:'', line2:'', city:'', state:'', postal_code:'', country_iso:'' },
      ]);
    } else {
      seededRef.current = true;
    }
  }, []);

  const renderGroup = (type, RowComp) => {
    const rows = byType[type];
    const meta = TYPES.find(t => t.code === type);
    return (
      <div style={{display:'grid',gap:8}}>
        <div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
          <span className="ff-mono upper" style={{...css.rowLabel}}>
            {meta.label}{rows.length > 1 ? `s (${rows.length})` : ''}
          </span>
          <button onClick={() => add(type)} className="ff-mono upper" style={css.miniBtn} type="button">
            + Add {meta.label.toLowerCase()}
          </button>
        </div>
        {rows.length === 0 && (
          <div className="ff-mono" style={{fontSize:10,color:'var(--ink-4)',padding:'6px 0',fontStyle:'italic'}}>
            No {meta.plural} yet.
          </div>
        )}
        {rows.map(r => (
          <RowComp
            key={r._idx}
            entry={r}
            onChange={(next) => update(r._idx, next)}
            onRemove={() => remove(r._idx)}
          />
        ))}
      </div>
    );
  };

  return (
    <div style={{display:'grid',gap:16}}>
      {renderGroup('email',   EmailRow)}
      {renderGroup('phone',   PhoneRow)}
      {renderGroup('address', AddressRow)}
      {renderGroup('website', WebsiteRow)}
    </div>
  );
}

window.ContactInfoSection = ContactInfoSection;

})();
