// bulk-reg-engine.jsx — Bulk Registration Pipeline (engine layer)
// ─────────────────────────────────────────────────────────────────
// Multi-format registration / notification builder. Sits ALONGSIDE
// CWR (cwr-gen.jsx) and CAF (caf.jsx). CWR/CAF cover society work-
// registration + agreement filings; this module covers everything
// else a publisher / label has to push out:
//
//   01 MLC BDF              — Mechanical Licensing Collective Bulk Data File
//   02 HFA SONGFILE         — Harry Fox bulk song registration / mech licenses
//   03 SOUNDEXCHANGE ISRC   — SX recording registration (sound recording rights)
//   04 XPERI / TIVO         — Music recognition / metadata pool
//   05 DDEX MWN             — Musical Work Notification (work registration)
//   06 DDEX ERN             — Electronic Release Notification (DSP delivery)
//   07 DDEX RDR-N           — Recording Data & Rights Notification
//   08 ASCAP / BMI DIRECT   — Direct repertoire upload (CSV/XLSX) per society
//
// Every adapter exposes the same shape:
//
//   { id, label, version, ext, kind, mime,
//     entity: 'works'|'recordings'|'releases',
//     fields: [{ k, t, req, syn, validate?, mapper?, ex }],
//     build(rows, opts): { content, name, mime, summary, errors }
//     parse?(content): { rows, errors }     // optional, for replay
//     example: '...'                        // 2-line preview
//   }
//
// Exports: window.BULK_REG_ENGINE, window.BULK_REG_ADAPTERS
// ─────────────────────────────────────────────────────────────────
(function () {
  if (typeof window === 'undefined') return;

  // ════════════════════════════════════════════════════════════════
  // SHARED HELPERS
  // ════════════════════════════════════════════════════════════════
  const pad = (s, n, c = ' ') => String(s == null ? '' : s).slice(0, n).padEnd(n, c);
  const padL = (s, n, c = '0') => String(s == null ? '' : s).slice(0, n).padStart(n, c);
  const today = () => {
    const d = new Date();
    const y = d.getUTCFullYear();
    const m = String(d.getUTCMonth() + 1).padStart(2, '0');
    const dd = String(d.getUTCDate()).padStart(2, '0');
    return `${y}${m}${dd}`;
  };
  const todayISO = () => new Date().toISOString().slice(0, 10);
  const nowMs = () => Date.now();
  const ascii = (s) => (s || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\x20-\x7E]/g, '?').toUpperCase();
  const csvEscape = (v) => {
    const s = v == null ? '' : String(v);
    if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
    return s;
  };
  const xmlEscape = (v) => String(v == null ? '' : v).replace(/[<>&"']/g, (c) => ({ '<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&apos;' }[c]));
  const fmtDur = (sec) => {
    const s = Number(sec) || 0;
    const m = Math.floor(s / 60);
    const r = Math.floor(s % 60);
    return `${String(m).padStart(2, '0')}${String(r).padStart(2, '0')}00`;
  };
  const stripIswc = (i) => (i || '').replace(/[^0-9TtRr]/g, '').toUpperCase();
  const stripIsrc = (i) => (i || '').replace(/[^A-Z0-9]/gi, '').toUpperCase();
  const stripIpi  = (i) => (i || '').replace(/\D/g, '');
  const isoCountry = (c) => {
    const s = (c || '').trim().toUpperCase();
    if (s.length === 2) return s;
    const map = { 'UNITED STATES': 'US', 'USA': 'US', 'UNITED KINGDOM': 'GB', 'UK': 'GB', 'GERMANY': 'DE', 'JAPAN': 'JP', 'FRANCE': 'FR', 'CANADA': 'CA', 'AUSTRALIA': 'AU', 'SPAIN': 'ES', 'ITALY': 'IT', 'WORLD': 'XX' };
    return map[s] || s.slice(0, 2);
  };
  const id6 = (prefix) => prefix + Math.random().toString(36).slice(2, 8).toUpperCase();

  // ════════════════════════════════════════════════════════════════
  // ENTITY EXTRACTORS — flatten window.RS into row-shaped data
  // ════════════════════════════════════════════════════════════════
  function getWorks() {
    const RS = window.RS;
    if (!RS || !RS.works) return [];
    return RS.works.map((w) => ({
      workId: w['Work ID'],
      title: w['Work Title'] || '',
      iswc: stripIswc(w.ISWC),
      duration: w.Duration || w['Duration (sec)'] || 180,
      lang: w.Language || 'EN',
      cLineYear: (w['Copyright Date'] || '').slice(0, 4) || new Date().getFullYear(),
      writers: (w.writerShares || []).map((ws) => ({
        name: ws.Writer || ws.profile?.artist_name || '',
        ipi: stripIpi(ws.IPI || ws.profile?.IPI),
        role: ws.Role || ws.role || 'CA',
        prShare: Number(ws['PR Share'] ?? ws.PR ?? 0),
        mrShare: Number(ws['MR Share'] ?? ws.MR ?? 0),
        srShare: Number(ws['SR Share'] ?? ws.SR ?? 0),
        publishers: (ws.publishingShares || []).map((ps) => ({
          name: ps.Publisher || '',
          ipi: stripIpi(ps.IPI),
          prShare: Number(ps['PR Share'] ?? ps.PR ?? 0),
          mrShare: Number(ps['MR Share'] ?? ps.MR ?? 0),
          srShare: Number(ps['SR Share'] ?? ps.SR ?? 0),
        })),
      })),
      recordings: (w.recordings || []).map((r) => ({
        isrc: stripIsrc(r.ISRC),
        title: r['Recording Title'] || w['Work Title'],
        artist: r['Display Artist'] || r.Artist || '',
        duration: r.Duration || 180,
        releaseDate: r['Release Date'] || '',
        upc: r.UPC || '',
      })),
    }));
  }

  function getRecordings() {
    const RS = window.RS;
    if (!RS || !RS.recordings) return [];
    return RS.recordings.map((r) => ({
      recId: r['Recording ID'] || id6('RREC'),
      isrc: stripIsrc(r.ISRC),
      title: r['Recording Title'] || '',
      artist: r['Display Artist'] || r.Artist || '',
      duration: r.Duration || 180,
      pLineYear: (r['P-line'] || r['Release Date'] || '').slice(0, 4) || new Date().getFullYear(),
      label: r.Label || r['Owning Label'] || '',
      releaseDate: r['Release Date'] || '',
      upc: r.UPC || '',
      workId: r['Work ID'] || '',
      iswc: stripIswc(r.ISWC),
    }));
  }

  function getReleases() {
    const RS = window.RS;
    if (!RS) return [];
    const releases = RS.releases || RS.RELEASE_GROUPS || [];
    return releases.map((rel) => ({
      relId: rel['Release ID'] || rel.id || id6('RREL'),
      upc: rel.UPC || rel.barcode || '',
      title: rel['Release Title'] || rel.title || '',
      artist: rel['Display Artist'] || rel.artist || '',
      releaseDate: rel['Release Date'] || rel.releaseDate || '',
      label: rel.Label || rel.label || '',
      catalog: rel['Catalog Number'] || rel.catNo || '',
      tracks: rel.tracks || rel.Tracks || [],
    }));
  }

  // ════════════════════════════════════════════════════════════════
  // ADAPTER 01 — MLC BDF (Bulk Data File)
  // ────────────────────────────────────────────────────────────────
  // CSV format MLC accepts. Real BDF is XML; this is the simplified
  // CSV variant the MLC's "Submit a song" tool generates and what
  // the Songfile/Spider migration scripts produce.
  // ════════════════════════════════════════════════════════════════
  const MLC_BDF = {
    id: 'mlc-bdf',
    label: 'MLC · Bulk Data File',
    version: '1.4',
    ext: 'csv',
    mime: 'text/csv',
    kind: 'work-registration',
    entity: 'works',
    fields: [
      { k: 'song_code',     t:'string', req:false, syn:['mlc song code','song id'], ex:'M-1234567' },
      { k: 'song_title',    t:'string', req:true,  syn:['title','work title'], ex:'Somos Los Que Faltan' },
      { k: 'iswc',          t:'iswc',   req:false, syn:['iswc'], ex:'T9150768084' },
      { k: 'pub_name',      t:'string', req:true,  syn:['publisher','publisher name'], ex:'Pluralis Music' },
      { k: 'pub_ipi',       t:'ipi',    req:true,  syn:['publisher ipi','pub ipi'], ex:'00578913241' },
      { k: 'pub_share',     t:'pct',    req:true,  syn:['publisher share','pub share'], ex:'50.00' },
      { k: 'writer_name',   t:'string', req:true,  syn:['writer','writer name'], ex:'Solange Knowles' },
      { k: 'writer_ipi',    t:'ipi',    req:true,  syn:['writer ipi','songwriter ipi'], ex:'00578913241' },
      { k: 'writer_share',  t:'pct',    req:true,  syn:['writer share'], ex:'50.00' },
      { k: 'writer_role',   t:'string', req:false, syn:['role','writer role'], ex:'CA' },
      { k: 'isrc',          t:'isrc',   req:false, syn:['isrc'], ex:'USRC11700001' },
    ],
    build(works, opts = {}) {
      const errors = [];
      const lines = ['song_code,song_title,iswc,pub_name,pub_ipi,pub_share,writer_name,writer_ipi,writer_share,writer_role,isrc'];
      let rowCount = 0;
      let writersExpanded = 0;
      works.forEach((w) => {
        if (!w.title) errors.push({ workId: w.workId, severity: 'error', msg: 'Missing song_title' });
        const isrc = (w.recordings || [])[0]?.isrc || '';
        (w.writers || []).forEach((wr) => {
          const pubs = wr.publishers && wr.publishers.length ? wr.publishers : [{ name: '(self-published)', ipi: '', prShare: 0, mrShare: wr.mrShare }];
          pubs.forEach((p) => {
            lines.push([
              w.workId || '',
              csvEscape(w.title),
              w.iswc || '',
              csvEscape(p.name),
              p.ipi || '',
              (p.mrShare || 0).toFixed(2),
              csvEscape(wr.name),
              wr.ipi || '',
              (wr.mrShare || 0).toFixed(2),
              wr.role || 'CA',
              isrc || '',
            ].join(','));
            rowCount++;
          });
          writersExpanded++;
        });
      });
      const content = lines.join('\r\n') + '\r\n';
      return {
        content,
        name: `MLC_BDF_${todayISO().replace(/-/g, '')}.csv`,
        mime: 'text/csv',
        summary: { works: works.length, lines: rowCount, writers: writersExpanded, format: 'CSV·UTF-8·CRLF' },
        errors,
      };
    },
    example: 'song_code,song_title,iswc,pub_name,pub_ipi,...\nM-001,SOMOS LOS QUE FALTAN,T9150768084,...',
  };

  // ════════════════════════════════════════════════════════════════
  // ADAPTER 02 — HFA SongFile (mech bulk reg)
  // ════════════════════════════════════════════════════════════════
  const HFA_SONGFILE = {
    id: 'hfa-songfile',
    label: 'HFA · Songfile bulk',
    version: '2.3',
    ext: 'csv',
    mime: 'text/csv',
    kind: 'work-registration',
    entity: 'works',
    fields: [
      { k: 'HFA Song Code', t:'string', req:false, syn:['hfa code','song code'], ex:'A1B2C3' },
      { k: 'Song Title',    t:'string', req:true,  syn:['title'], ex:'Somos Los Que Faltan' },
      { k: 'Alt Title',     t:'string', req:false, syn:['alt title','alternate title'], ex:'' },
      { k: 'ISWC',          t:'iswc',   req:false, syn:['iswc'], ex:'T9150768084' },
      { k: 'Duration',      t:'duration', req:false, syn:['duration','length'], ex:'0324' },
      { k: 'Writer 1 Name', t:'string', req:true,  syn:['writer','writer 1'], ex:'Solange Knowles' },
      { k: 'Writer 1 IPI',  t:'ipi',    req:true,  syn:['writer 1 ipi'], ex:'00578913241' },
      { k: 'Writer 1 Share',t:'pct',    req:true,  syn:['writer 1 share'], ex:'50' },
      { k: 'Writer 1 Role', t:'string', req:false, syn:['writer 1 role'], ex:'CA' },
      { k: 'Pub 1 Name',    t:'string', req:true,  syn:['publisher 1','pub 1'], ex:'Pluralis Music' },
      { k: 'Pub 1 IPI',     t:'ipi',    req:true,  syn:['pub 1 ipi'], ex:'00578913241' },
      { k: 'Pub 1 Share',   t:'pct',    req:true,  syn:['pub 1 share'], ex:'50' },
    ],
    build(works) {
      const errors = [];
      const cols = ['HFA Song Code','Song Title','Alt Title','ISWC','Duration','Writer 1 Name','Writer 1 IPI','Writer 1 Share','Writer 1 Role','Pub 1 Name','Pub 1 IPI','Pub 1 Share','Writer 2 Name','Writer 2 IPI','Writer 2 Share','Writer 2 Role','Pub 2 Name','Pub 2 IPI','Pub 2 Share'];
      const lines = [cols.join(',')];
      works.forEach((w) => {
        if (!w.title) errors.push({ workId: w.workId, severity: 'error', msg: 'Missing Song Title' });
        const writers = w.writers || [];
        const w1 = writers[0] || {};
        const w2 = writers[1] || {};
        const p1 = (w1.publishers || [])[0] || {};
        const p2 = (w2.publishers || [])[0] || {};
        lines.push([
          w.workId || '',
          csvEscape(w.title),
          '',
          w.iswc || '',
          fmtDur(w.duration),
          csvEscape(w1.name),
          w1.ipi || '',
          (w1.mrShare || 0).toFixed(2),
          w1.role || 'CA',
          csvEscape(p1.name),
          p1.ipi || '',
          (p1.mrShare || 0).toFixed(2),
          csvEscape(w2.name),
          w2.ipi || '',
          (w2.mrShare || 0).toFixed(2),
          w2.role || '',
          csvEscape(p2.name),
          p2.ipi || '',
          (p2.mrShare || 0).toFixed(2),
        ].join(','));
      });
      return {
        content: lines.join('\r\n') + '\r\n',
        name: `HFA_Songfile_${todayISO().replace(/-/g, '')}.csv`,
        mime: 'text/csv',
        summary: { works: works.length, lines: works.length, format: 'CSV·19-col' },
        errors,
      };
    },
    example: 'HFA Song Code,Song Title,Alt Title,ISWC,...\n,SOMOS LOS QUE FALTAN,,T9150768084,...',
  };

  // ════════════════════════════════════════════════════════════════
  // ADAPTER 03 — SoundExchange ISRC bulk registration
  // ════════════════════════════════════════════════════════════════
  const SX_ISRC = {
    id: 'sx-isrc',
    label: 'SoundExchange · ISRC bulk',
    version: '4.2',
    ext: 'csv',
    mime: 'text/csv',
    kind: 'recording-registration',
    entity: 'recordings',
    fields: [
      { k: 'ISRC',           t:'isrc',   req:true,  syn:['isrc'], ex:'USRC11700001' },
      { k: 'Recording Title',t:'string', req:true,  syn:['recording title','sound recording title','track title'], ex:'Somos Los Que Faltan' },
      { k: 'Featured Artist',t:'string', req:true,  syn:['artist','featured artist'], ex:'Solange Knowles' },
      { k: 'Album Title',    t:'string', req:false, syn:['album','album title'], ex:'A Seat at the Table' },
      { k: 'Label',          t:'string', req:true,  syn:['label','rights owner'], ex:'Saint Records' },
      { k: 'P-Line Year',    t:'year',   req:true,  syn:['p-line year','p year','p line'], ex:'2017' },
      { k: 'Release Date',   t:'date',   req:false, syn:['release date'], ex:'2017-04-03' },
      { k: 'Duration (sec)', t:'integer',req:true,  syn:['duration','length'], ex:'204' },
      { k: 'Country of Recording', t:'iso2', req:false, syn:['country','country of recording'], ex:'US' },
      { k: 'UPC',            t:'string', req:false, syn:['upc','barcode'], ex:'886446485041' },
    ],
    build(recs) {
      const errors = [];
      const cols = ['ISRC','Recording Title','Featured Artist','Album Title','Label','P-Line Year','Release Date','Duration (sec)','Country of Recording','UPC'];
      const lines = [cols.join(',')];
      recs.forEach((r) => {
        if (!r.isrc) errors.push({ recId: r.recId, severity: 'error', msg: 'Missing ISRC' });
        if (r.isrc && !/^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$/.test(r.isrc)) errors.push({ recId: r.recId, severity: 'error', msg: 'Invalid ISRC format' });
        if (!r.title) errors.push({ recId: r.recId, severity: 'error', msg: 'Missing Recording Title' });
        if (!r.artist) errors.push({ recId: r.recId, severity: 'error', msg: 'Missing Featured Artist' });
        if (!r.label) errors.push({ recId: r.recId, severity: 'warn', msg: 'Missing Label (rights owner)' });
        lines.push([
          r.isrc || '',
          csvEscape(r.title),
          csvEscape(r.artist),
          '',
          csvEscape(r.label),
          r.pLineYear || '',
          r.releaseDate || '',
          Math.round(Number(r.duration) || 0),
          'US',
          r.upc || '',
        ].join(','));
      });
      return {
        content: lines.join('\r\n') + '\r\n',
        name: `SX_ISRC_${todayISO().replace(/-/g, '')}.csv`,
        mime: 'text/csv',
        summary: { recordings: recs.length, lines: recs.length, format: 'CSV·UTF-8·CRLF' },
        errors,
      };
    },
    example: 'ISRC,Recording Title,Featured Artist,Album Title,Label,...\nUSRC11700001,SOMOS LOS QUE FALTAN,SOLANGE,A SEAT AT THE TABLE,...',
  };

  // ════════════════════════════════════════════════════════════════
  // ADAPTER 04 — Xperi / TiVo (formerly Gracenote / Rovi) metadata pool
  // Tab-delimited per their bulk-feed spec.
  // ════════════════════════════════════════════════════════════════
  const XPERI = {
    id: 'xperi-tivo',
    label: 'Xperi · TiVo metadata feed',
    version: '6.1',
    ext: 'tsv',
    mime: 'text/tab-separated-values',
    kind: 'metadata-feed',
    entity: 'recordings',
    fields: [
      { k: 'isrc',         t:'isrc',   req:true,  syn:['isrc'], ex:'USRC11700001' },
      { k: 'track_title',  t:'string', req:true,  syn:['title','track title','recording title'], ex:'Somos Los Que Faltan' },
      { k: 'artist',       t:'string', req:true,  syn:['artist','featured artist'], ex:'Solange' },
      { k: 'album',        t:'string', req:false, syn:['album','album title'], ex:'A Seat at the Table' },
      { k: 'duration_ms',  t:'integer',req:true,  syn:['duration ms','length ms'], ex:'204000' },
      { k: 'genre',        t:'string', req:false, syn:['genre'], ex:'R&B' },
      { k: 'mood',         t:'string', req:false, syn:['mood'], ex:'introspective' },
      { k: 'tempo_bpm',    t:'integer',req:false, syn:['tempo','bpm'], ex:'72' },
      { k: 'key',          t:'string', req:false, syn:['key','musical key'], ex:'C minor' },
      { k: 'iswc',         t:'iswc',   req:false, syn:['iswc'], ex:'T9150768084' },
    ],
    build(recs) {
      const errors = [];
      const cols = ['isrc','track_title','artist','album','duration_ms','genre','mood','tempo_bpm','key','iswc','release_date','label','upc'];
      const rows = [cols.join('\t')];
      recs.forEach((r) => {
        if (!r.isrc) errors.push({ recId: r.recId, severity: 'error', msg: 'Missing ISRC' });
        rows.push([
          r.isrc || '',
          r.title || '',
          r.artist || '',
          '',
          Math.round((Number(r.duration) || 0) * 1000),
          '',
          '',
          '',
          '',
          r.iswc || '',
          r.releaseDate || '',
          r.label || '',
          r.upc || '',
        ].join('\t'));
      });
      return {
        content: rows.join('\n') + '\n',
        name: `Xperi_TiVo_${todayISO().replace(/-/g, '')}.tsv`,
        mime: 'text/tab-separated-values',
        summary: { recordings: recs.length, lines: recs.length, format: 'TSV·13-col' },
        errors,
      };
    },
    example: 'isrc\ttrack_title\tartist\talbum\tduration_ms\t...\nUSRC11700001\tSomos Los Que Faltan\tSolange\t...',
  };

  // ════════════════════════════════════════════════════════════════
  // ADAPTER 05 — DDEX MWN (Musical Work Notification)
  // ════════════════════════════════════════════════════════════════
  const DDEX_MWN = {
    id: 'ddex-mwn',
    label: 'DDEX · MWN (Work Notification)',
    version: 'MWN/12',
    ext: 'xml',
    mime: 'application/xml',
    kind: 'work-registration',
    entity: 'works',
    fields: MLC_BDF.fields,
    build(works) {
      const errors = [];
      const msgId = `MWN-${nowMs()}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
      const sender = window.RS?.tenant?.name || 'Pluralis Music';
      const senderIPI = window.RS?.tenant?.ipi || '00578913241';
      const lines = [];
      lines.push('<?xml version="1.0" encoding="UTF-8"?>');
      lines.push(`<MwnMessage MessageSchemaVersionId="ern/MWN/12">`);
      lines.push('  <MessageHeader>');
      lines.push(`    <MessageId>${msgId}</MessageId>`);
      lines.push(`    <MessageSender><PartyId>PA-${senderIPI}</PartyId><PartyName><FullName>${xmlEscape(sender)}</FullName></PartyName></MessageSender>`);
      lines.push(`    <MessageRecipient><PartyId>PA-MLC-001</PartyId><PartyName><FullName>The MLC</FullName></PartyName></MessageRecipient>`);
      lines.push(`    <MessageCreatedDateTime>${new Date().toISOString()}</MessageCreatedDateTime>`);
      lines.push(`    <MessageControlType>LiveMessage</MessageControlType>`);
      lines.push('  </MessageHeader>');
      lines.push('  <WorkList>');
      works.forEach((w, i) => {
        if (!w.title) { errors.push({ workId: w.workId, severity: 'error', msg: 'Missing title' }); return; }
        lines.push(`    <MusicalWork>`);
        lines.push(`      <MusicalWorkReference>WK${String(i + 1).padStart(6, '0')}</MusicalWorkReference>`);
        lines.push(`      <ProprietaryId Namespace="${xmlEscape(sender)}">${xmlEscape(w.workId || '')}</ProprietaryId>`);
        if (w.iswc) lines.push(`      <ISWC>${w.iswc}</ISWC>`);
        lines.push(`      <ReferenceTitle><TitleText>${xmlEscape(w.title)}</TitleText></ReferenceTitle>`);
        lines.push(`      <Duration>PT${Math.round(w.duration || 0)}S</Duration>`);
        (w.writers || []).forEach((wr) => {
          lines.push(`      <Contributor>`);
          lines.push(`        <ContributorPartyReference>P-${wr.ipi || '00000000000'}</ContributorPartyReference>`);
          lines.push(`        <Role>${wr.role === 'C' ? 'Composer' : wr.role === 'A' ? 'Lyricist' : 'ComposerLyricist'}</Role>`);
          lines.push(`        <RightShare><PerformingRightsShare>${(wr.prShare || 0).toFixed(2)}</PerformingRightsShare><MechanicalRightsShare>${(wr.mrShare || 0).toFixed(2)}</MechanicalRightsShare></RightShare>`);
          lines.push(`      </Contributor>`);
          (wr.publishers || []).forEach((p) => {
            lines.push(`      <RightsController>`);
            lines.push(`        <RightsControllerPartyReference>P-${p.ipi || '00000000000'}</RightsControllerPartyReference>`);
            lines.push(`        <RightShare><PerformingRightsShare>${(p.prShare || 0).toFixed(2)}</PerformingRightsShare><MechanicalRightsShare>${(p.mrShare || 0).toFixed(2)}</MechanicalRightsShare></RightShare>`);
            lines.push(`      </RightsController>`);
          });
        });
        lines.push(`    </MusicalWork>`);
      });
      lines.push('  </WorkList>');
      lines.push('</MwnMessage>');
      return {
        content: lines.join('\n'),
        name: `MWN_${msgId}.xml`,
        mime: 'application/xml',
        summary: { works: works.length, format: 'DDEX MWN/12 · XML' },
        errors,
      };
    },
    example: '<?xml version="1.0"?>\n<MwnMessage SchemaVersionId="ern/MWN/12">\n  <MessageHeader>...',
  };

  // ════════════════════════════════════════════════════════════════
  // ADAPTER 06 — DDEX ERN 4.3 (Electronic Release Notification)
  // ════════════════════════════════════════════════════════════════
  const DDEX_ERN = {
    id: 'ddex-ern',
    label: 'DDEX · ERN 4.3 (Release Notification)',
    version: 'ern/43',
    ext: 'xml',
    mime: 'application/xml',
    kind: 'release-delivery',
    entity: 'releases',
    fields: [
      { k: 'UPC',           t:'string', req:true,  syn:['upc','barcode','grid'], ex:'886446485041' },
      { k: 'Release Title', t:'string', req:true,  syn:['release title','album'], ex:'A Seat at the Table' },
      { k: 'Display Artist',t:'string', req:true,  syn:['artist','display artist'], ex:'Solange' },
      { k: 'Release Date',  t:'date',   req:true,  syn:['release date'], ex:'2017-04-03' },
      { k: 'Label',         t:'string', req:true,  syn:['label','imprint'], ex:'Saint Records' },
    ],
    build(releases) {
      const errors = [];
      const msgId = `ERN-${nowMs()}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
      const sender = window.RS?.tenant?.name || 'Pluralis Music';
      const senderIPI = window.RS?.tenant?.ipi || '00578913241';
      const lines = [];
      lines.push('<?xml version="1.0" encoding="UTF-8"?>');
      lines.push('<ern:NewReleaseMessage xmlns:ern="http://ddex.net/xml/ern/43" MessageSchemaVersionId="ern/43">');
      lines.push('  <MessageHeader>');
      lines.push(`    <MessageId>${msgId}</MessageId>`);
      lines.push(`    <MessageSender><PartyId>PA-${senderIPI}</PartyId><PartyName><FullName>${xmlEscape(sender)}</FullName></PartyName></MessageSender>`);
      lines.push(`    <MessageRecipient><PartyId>PA-DSP-001</PartyId><PartyName><FullName>DSP Recipient</FullName></PartyName></MessageRecipient>`);
      lines.push(`    <MessageCreatedDateTime>${new Date().toISOString()}</MessageCreatedDateTime>`);
      lines.push(`    <MessageControlType>LiveMessage</MessageControlType>`);
      lines.push('  </MessageHeader>');
      lines.push('  <ReleaseList>');
      releases.forEach((rel, i) => {
        if (!rel.upc) errors.push({ relId: rel.relId, severity: 'error', msg: 'Missing UPC' });
        lines.push(`    <Release>`);
        lines.push(`      <ReleaseReference>R${String(i + 1).padStart(4, '0')}</ReleaseReference>`);
        lines.push(`      <ReleaseId><GRid>A1-${rel.upc || ''}</GRid><ICPN>${rel.upc || ''}</ICPN></ReleaseId>`);
        lines.push(`      <ReferenceTitle><TitleText>${xmlEscape(rel.title)}</TitleText></ReferenceTitle>`);
        lines.push(`      <DisplayArtist><PartyName><FullName>${xmlEscape(rel.artist)}</FullName></PartyName><ArtistRole>MainArtist</ArtistRole></DisplayArtist>`);
        lines.push(`      <ReleaseLabelReference>${xmlEscape(rel.label)}</ReleaseLabelReference>`);
        lines.push(`      <OriginalReleaseDate>${rel.releaseDate || ''}</OriginalReleaseDate>`);
        lines.push(`    </Release>`);
      });
      lines.push('  </ReleaseList>');
      lines.push('</ern:NewReleaseMessage>');
      return {
        content: lines.join('\n'),
        name: `ERN43_${msgId}.xml`,
        mime: 'application/xml',
        summary: { releases: releases.length, format: 'DDEX ERN/4.3 · XML' },
        errors,
      };
    },
    example: '<ern:NewReleaseMessage SchemaVersionId="ern/43">\n  <MessageHeader>...',
  };

  // ════════════════════════════════════════════════════════════════
  // ADAPTER 07 — DDEX RDR-N (Recording Data & Rights Notification)
  // ════════════════════════════════════════════════════════════════
  const DDEX_RDRN = {
    id: 'ddex-rdrn',
    label: 'DDEX · RDR-N (Recording Rights)',
    version: 'rdr-n/11',
    ext: 'xml',
    mime: 'application/xml',
    kind: 'recording-registration',
    entity: 'recordings',
    fields: SX_ISRC.fields,
    build(recs) {
      const errors = [];
      const msgId = `RDR-${nowMs()}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
      const lines = [];
      lines.push('<?xml version="1.0" encoding="UTF-8"?>');
      lines.push('<RecordingRightsNotificationMessage MessageSchemaVersionId="rdr-n/11">');
      lines.push('  <MessageHeader>');
      lines.push(`    <MessageId>${msgId}</MessageId>`);
      lines.push(`    <MessageCreatedDateTime>${new Date().toISOString()}</MessageCreatedDateTime>`);
      lines.push('  </MessageHeader>');
      lines.push('  <SoundRecordingList>');
      recs.forEach((r, i) => {
        if (!r.isrc) errors.push({ recId: r.recId, severity: 'error', msg: 'Missing ISRC' });
        lines.push(`    <SoundRecording>`);
        lines.push(`      <SoundRecordingReference>SR${String(i + 1).padStart(4, '0')}</SoundRecordingReference>`);
        lines.push(`      <SoundRecordingId><ISRC>${r.isrc || ''}</ISRC></SoundRecordingId>`);
        lines.push(`      <ReferenceTitle><TitleText>${xmlEscape(r.title)}</TitleText></ReferenceTitle>`);
        lines.push(`      <DisplayArtist><PartyName><FullName>${xmlEscape(r.artist)}</FullName></PartyName></DisplayArtist>`);
        lines.push(`      <Duration>PT${Math.round(r.duration || 0)}S</Duration>`);
        lines.push(`      <PLine><Year>${r.pLineYear || ''}</Year><PLineText>${xmlEscape(r.label)}</PLineText></PLine>`);
        if (r.iswc) lines.push(`      <WorkId><ISWC>${r.iswc}</ISWC></WorkId>`);
        lines.push(`    </SoundRecording>`);
      });
      lines.push('  </SoundRecordingList>');
      lines.push('</RecordingRightsNotificationMessage>');
      return {
        content: lines.join('\n'),
        name: `RDRN_${msgId}.xml`,
        mime: 'application/xml',
        summary: { recordings: recs.length, format: 'DDEX RDR-N/11 · XML' },
        errors,
      };
    },
    example: '<RecordingRightsNotificationMessage SchemaVersionId="rdr-n/11">\n  ...',
  };

  // ════════════════════════════════════════════════════════════════
  // ADAPTER 08 — ASCAP / BMI direct repertoire upload
  // ────────────────────────────────────────────────────────────────
  // ASCAP: Title Search XLSX template; BMI: Repertoire XLSX template.
  // We emit CSV with the union column set (both societies accept CSV
  // as long as headers match).
  // ════════════════════════════════════════════════════════════════
  const PRO_DIRECT = {
    id: 'pro-direct',
    label: 'ASCAP/BMI · Direct repertoire',
    version: '2024.q3',
    ext: 'csv',
    mime: 'text/csv',
    kind: 'work-registration',
    entity: 'works',
    fields: [
      { k: 'Title',        t:'string', req:true,  syn:['title','work title'], ex:'Somos Los Que Faltan' },
      { k: 'ISWC',         t:'iswc',   req:false, syn:['iswc'], ex:'T9150768084' },
      { k: 'Pub IPI',      t:'ipi',    req:true,  syn:['publisher ipi','pub ipi'], ex:'00578913241' },
      { k: 'Pub Name',     t:'string', req:true,  syn:['publisher','publisher name'], ex:'Pluralis Music' },
      { k: 'Pub Share',    t:'pct',    req:true,  syn:['publisher share','pub share'], ex:'50' },
      { k: 'Writer Name',  t:'string', req:true,  syn:['writer','writer name'], ex:'Solange Knowles' },
      { k: 'Writer IPI',   t:'ipi',    req:true,  syn:['writer ipi'], ex:'00578913241' },
      { k: 'Writer Share', t:'pct',    req:true,  syn:['writer share'], ex:'50' },
      { k: 'Society',      t:'string', req:true,  syn:['society'], ex:'ASCAP' },
    ],
    build(works, opts = {}) {
      const society = (opts.society || 'ASCAP').toUpperCase();
      const errors = [];
      const cols = ['Title','ISWC','Pub IPI','Pub Name','Pub Share','Writer Name','Writer IPI','Writer Share','Society','Submitter Pub Number'];
      const submitter = opts.submitterPubNumber || (society === 'BMI' ? '12345' : '');
      if (society === 'BMI' && !submitter) errors.push({ severity: 'error', msg: 'BMI submission requires submitterPubNumber' });
      const lines = [cols.join(',')];
      works.forEach((w) => {
        if (!w.title) errors.push({ workId: w.workId, severity: 'error', msg: 'Missing Title' });
        (w.writers || []).forEach((wr) => {
          (wr.publishers && wr.publishers.length ? wr.publishers : [{ name: '(self-published)', ipi: '', prShare: 0 }]).forEach((p) => {
            lines.push([
              csvEscape(w.title),
              w.iswc || '',
              p.ipi || '',
              csvEscape(p.name),
              (p.prShare || 0).toFixed(2),
              csvEscape(wr.name),
              wr.ipi || '',
              (wr.prShare || 0).toFixed(2),
              society,
              submitter,
            ].join(','));
          });
        });
      });
      return {
        content: lines.join('\r\n') + '\r\n',
        name: `${society}_repertoire_${todayISO().replace(/-/g, '')}.csv`,
        mime: 'text/csv',
        summary: { works: works.length, society, format: 'CSV·UTF-8' },
        errors,
      };
    },
    example: 'Title,ISWC,Pub IPI,Pub Name,Pub Share,Writer Name,...\nSomos Los Que Faltan,T9150768084,00578913241,...',
  };

  // ════════════════════════════════════════════════════════════════
  // ADAPTER REGISTRY
  // ════════════════════════════════════════════════════════════════
  const ADAPTERS = {
    'mlc-bdf':       MLC_BDF,
    'hfa-songfile':  HFA_SONGFILE,
    'sx-isrc':       SX_ISRC,
    'xperi-tivo':    XPERI,
    'ddex-mwn':      DDEX_MWN,
    'ddex-ern':      DDEX_ERN,
    'ddex-rdrn':     DDEX_RDRN,
    'pro-direct':    PRO_DIRECT,
  };

  // ════════════════════════════════════════════════════════════════
  // CHANNELS — outbound delivery
  // ════════════════════════════════════════════════════════════════
  const CHANNELS = [
    { id: 'sftp',     label: 'SFTP',           note: 'Push to society/DSP SFTP endpoint with PGP signing' },
    { id: 'portal',   label: 'Portal upload',  note: 'Manual upload to society portal · file ready to download' },
    { id: 'api',      label: 'REST API',       note: 'Direct POST to society/DSP API with retry logic' },
    { id: 'email',    label: 'Email',          note: 'Encrypted email to society back-office' },
    { id: 'download', label: 'Download',       note: 'Download file locally for manual review' },
  ];

  // ════════════════════════════════════════════════════════════════
  // RUN HISTORY (in-memory, persists in localStorage best-effort)
  // ════════════════════════════════════════════════════════════════
  const HISTORY_KEY = 'astro_bulk_reg_history_v1';
  function loadHistory() {
    try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; }
  }
  function saveHistory(h) {
    try { localStorage.setItem(HISTORY_KEY, JSON.stringify(h.slice(0, 50))); } catch {}
  }
  function recordRun(run) {
    const h = loadHistory();
    h.unshift({ ...run, id: 'BR-' + nowMs(), at: new Date().toISOString() });
    saveHistory(h);
    return h;
  }

  // ════════════════════════════════════════════════════════════════
  // SELECTION — apply selectionModel to entities
  // ════════════════════════════════════════════════════════════════
  function selectEntities(entity, model, opts = {}) {
    let pool;
    if (entity === 'works') pool = getWorks();
    else if (entity === 'recordings') pool = getRecordings();
    else if (entity === 'releases') pool = getReleases();
    else pool = [];

    if (!model || model.kind === 'all') return pool;
    if (model.kind === 'ids' && Array.isArray(model.ids)) {
      const set = new Set(model.ids);
      return pool.filter((e) => set.has(e.workId || e.recId || e.relId));
    }
    if (model.kind === 'filter' && typeof model.fn === 'function') return pool.filter(model.fn);
    if (model.kind === 'recent') {
      // most recently added (RS data is already sorted; just slice)
      return pool.slice(0, model.n || 50);
    }
    if (model.kind === 'untransmitted') {
      // works/recordings with no prior registration to this format
      const fmt = opts.formatId;
      const hist = loadHistory();
      const transmittedIds = new Set();
      hist.forEach((r) => {
        if (r.formatId === fmt) (r.entityIds || []).forEach((id) => transmittedIds.add(id));
      });
      return pool.filter((e) => !transmittedIds.has(e.workId || e.recId || e.relId));
    }
    return pool;
  }

  // ════════════════════════════════════════════════════════════════
  // BUILD — orchestrator
  // ════════════════════════════════════════════════════════════════
  function build(formatId, selectionModel, opts = {}) {
    const adapter = ADAPTERS[formatId];
    if (!adapter) return { error: 'Unknown format: ' + formatId };
    const entities = selectEntities(adapter.entity, selectionModel, { formatId });
    const result = adapter.build(entities, opts);
    return {
      ...result,
      adapter,
      formatId,
      entityCount: entities.length,
      entityIds: entities.map((e) => e.workId || e.recId || e.relId),
    };
  }

  // ════════════════════════════════════════════════════════════════
  // EXPORTS
  // ════════════════════════════════════════════════════════════════
  window.BULK_REG_ADAPTERS = ADAPTERS;
  window.BULK_REG_ENGINE = {
    ADAPTERS,
    CHANNELS,
    selectEntities,
    build,
    getWorks, getRecordings, getReleases,
    loadHistory, recordRun,
    helpers: { ascii, csvEscape, xmlEscape, fmtDur, stripIswc, stripIsrc, stripIpi, isoCountry, today, todayISO },
  };
})();
