// ============================================================================
// BULK-REGISTRATION EXPORT ENGINE
// ----------------------------------------------------------------------------
// Generators for industry bulk-registration formats. Each target defines:
//   • kind         'works' | 'recordings' | 'releases' (what scope it consumes)
//   • required     fields each row MUST have (drives validators)
//   • columns      [{ key, header, width?, get(row) }] (drives builders)
//   • format       'tsv' | 'csv' | 'xml' | 'fixed'
//   • filenameOf   (scope, today) => string
//   • build        (rows, opts) => string  (the generator)
//   • validate     (rows) => [{ row, errors:[…], warnings:[…] }]
//
// Targets (9):
//   - MLC Bulk Works Registration   (TSV, works)
//   - HFA Slingshot CSV             (CSV, works)
//   - SoundExchange ISRC Manifest   (CSV, recordings)
//   - Xperi/Gracenote Catalog Feed  (XML, releases)
//   - Music Reports (MRI)           (TSV, works)
//   - AMRA                          (TSV, works)
//   - CMRRA                         (CSV, works)
//   - CISAC v22 (registration)      (Fixed-width, works) — slim CWR-style
//   - DDEX MEAD                     (XML, releases)
//
// Every generator is pure — same input → same bytes. Caller owns the download.
// ============================================================================
(function bulkExportEngine() {
  const E = {};

  // ── helpers ─────────────────────────────────────────────────────────────
  const stripCtl = (s) => String(s || '').replace(/[\x00-\x1f\x7f]/g, '');
  const csv = (s) => {
    const v = stripCtl(s);
    if (/[",\n\r]/.test(v)) return '"' + v.replace(/"/g, '""') + '"';
    return v;
  };
  const tsv = (s) => stripCtl(s).replace(/\t/g, ' ').replace(/[\r\n]/g, ' ');
  const padR = (s, n) => stripCtl(s).slice(0, n).padEnd(n, ' ');
  const padL = (s, n) => String(s || '').slice(0, n).padStart(n, '0');
  const xmlEsc = (s) => stripCtl(s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
  const today = () => new Date().toISOString().slice(0, 10).replace(/-/g, '');
  const isoToday = () => new Date().toISOString();

  // Format-strict helpers (used by validators)
  const looksISWC = (v) => /^T\d{10}$/.test(String(v||'').replace(/[-\.\s]/g, ''));
  const looksISRC = (v) => /^[A-Z]{2}[A-Z0-9]{3}\d{7}$/.test(String(v||'').replace(/[-\s]/g, '').toUpperCase());
  const looksUPC  = (v) => /^\d{12,14}$/.test(String(v||'').replace(/[-\s]/g, ''));
  const looksIPI  = (v) => /^\d{9,11}$/.test(String(v||'').replace(/[-\s]/g, ''));
  const looksISNI = (v) => /^\d{15}[\dX]$/.test(String(v||'').replace(/[-\s]/g, '').toUpperCase());

  // ── Source rowization ───────────────────────────────────────────────────
  // Convert window.WORKS / RECORDINGS / RELEASES into normalized row objects
  // each generator consumes. We do this once so generators stay simple.
  function worksRows() {
    const W = window.WORKS || [];
    return W.map(w => ({
      id: w.id,
      title: w.title || '',
      altTitles: Array.isArray(w.altTitles) ? w.altTitles : [],
      iswc: w.iswc || '',
      duration: w.durationSec || w.duration || 0,
      writers: (w.writers || []).map(x => ({
        name: x.name || x.fullName || '',
        ipi: x.ipi || '',
        role: x.role || 'CA',
        share: Number(x.share || x.splitShares?.publishing || 0),
        perfShare: Number(x.perfShare || x.share || 0),
        mechShare: Number(x.mechShare || x.share || 0),
        syncShare: Number(x.syncShare || x.share || 0),
        pro: x.pro || x.society || '',
        controlled: !!x.controlled,
        publishers: (x.pubs || x.publishers || []).map(p => ({
          name: p.name || '', ipi: p.ipi || '', share: Number(p.share || 0), pro: p.pro || ''
        })),
      })),
      publishers: (w.publishers || []).map(p => ({
        name: p.name || '', ipi: p.ipi || '', share: Number(p.share || 0), pro: p.pro || '',
        territory: p.territory || 'World', role: p.role || 'OP'
      })),
      recordings: (w.recordings || []).map(r => ({
        isrc: r.isrc || '', title: r.title || w.title, artist: r.artist || '', durationSec: r.durationSec || 0
      })),
      catalog: w.catalog || '',
      lyricLanguage: w.lyricLanguage || 'EN',
      firstReleaseDate: w.firstReleaseDate || w.recordings?.[0]?.releaseDate || '',
      genre: w.genre || '',
    }));
  }

  function recordingsRows() {
    const R = window.RECORDINGS || [];
    return R.map(r => ({
      id: r.id,
      isrc: r.isrc || '',
      title: r.title || '',
      versionTitle: r.versionTitle || '',
      artist: r.artist || (r.artists || []).join(' / '),
      durationSec: r.durationSec || 0,
      releaseDate: r.releaseDate || '',
      label: r.label || '',
      releaseTitle: r.releaseTitle || '',
      upc: r.upc || '',
      pLine: r.pLine || '',
      cLine: r.cLine || '',
      grid: r.grid || '',
      workIswc: r.iswc || (r.work?.iswc) || '',
      workTitle: r.workTitle || (r.work?.title) || '',
      writers: (r.writers || []).map(x => ({ name: x.name || '', share: Number(x.share || 0) })),
      genre: r.genre || '',
    }));
  }

  function releasesRows() {
    const RG = window.RELEASE_GROUPS || [];
    const REL = window.RELEASES_X || [];
    const recs = window.RECORDINGS || [];
    const recsByRel = new Map();
    for (const rec of recs) {
      const k = rec.releaseId || rec.releaseTitle;
      if (!k) continue;
      if (!recsByRel.has(k)) recsByRel.set(k, []);
      recsByRel.get(k).push(rec);
    }
    return RG.map(g => {
      const editions = REL.filter(r => r.groupId === g.id || r.releaseGroupId === g.id);
      const tracks = (g.tracks || recsByRel.get(g.id) || recsByRel.get(g.title) || []);
      return {
        id: g.id,
        title: g.title || g.name || '',
        artist: g.artist || g.displayArtist || '',
        kind: g.kind || g.type || 'Album',
        upc: g.upc || (editions[0]?.upc) || '',
        grid: g.grid || (editions[0]?.grid) || '',
        catalog: g.catalog || (editions[0]?.catalog) || '',
        label: g.label || (editions[0]?.label) || '',
        releaseDate: g.releaseDate || (editions[0]?.releaseDate) || '',
        territory: g.territory || 'World',
        pLine: g.pLine || '',
        cLine: g.cLine || '',
        genre: g.genre || '',
        tracks: tracks.map((t, i) => ({
          seq: i + 1,
          isrc: t.isrc || '',
          title: t.title || '',
          artist: t.artist || g.artist || '',
          durationSec: t.durationSec || 0,
          iswc: t.iswc || ''
        }))
      };
    });
  }

  E.rowsFor = (kind) => {
    if (kind === 'works') return worksRows();
    if (kind === 'recordings') return recordingsRows();
    if (kind === 'releases') return releasesRows();
    return [];
  };

  // ── TARGETS ─────────────────────────────────────────────────────────────
  const TARGETS = {};

  // ─── MLC Bulk Works Registration (TSV) ──────────────────────────────────
  TARGETS.mlc = {
    id: 'mlc',
    name: 'MLC — Bulk Works Registration',
    short: 'MLC',
    kind: 'works',
    format: 'tsv',
    filenameOf: () => `mlc_bulk_works_${today()}.tsv`,
    description: 'TSV upload to The MLC Portal · Bulk Tools · Works. One row per work; writers/publishers stacked in delimited columns.',
    columns: [
      { key:'work_title',         header:'Work Title' },
      { key:'iswc',               header:'ISWC' },
      { key:'duration_sec',       header:'Duration (sec)' },
      { key:'alt_titles',         header:'Alt Titles' },
      { key:'lyric_language',     header:'Lyric Language' },
      { key:'first_release_date', header:'First Release Date' },
      { key:'writers',            header:'Writers (Name|IPI|Role|Share)' },
      { key:'publishers',         header:'Publishers (Name|IPI|Share|Role|Territory)' },
      { key:'isrcs',              header:'Linked ISRCs' },
      { key:'catalog_number',     header:'Member Catalog #' },
    ],
    rowMap: (w) => ({
      work_title: w.title,
      iswc: w.iswc,
      duration_sec: w.duration || '',
      alt_titles: w.altTitles.join(' || '),
      lyric_language: w.lyricLanguage,
      first_release_date: w.firstReleaseDate,
      writers: w.writers.map(x => `${x.name}|${x.ipi}|${x.role}|${x.share.toFixed(2)}`).join(' || '),
      publishers: w.publishers.map(p => `${p.name}|${p.ipi}|${p.share.toFixed(2)}|${p.role}|${p.territory}`).join(' || '),
      isrcs: w.recordings.map(r => r.isrc).filter(Boolean).join('; '),
      catalog_number: w.catalog,
    }),
    validateRow: (w) => {
      const errors = [], warnings = [];
      if (!w.title) errors.push('title required');
      if (!w.iswc) warnings.push('ISWC missing — MLC strongly recommends');
      else if (!looksISWC(w.iswc)) errors.push('ISWC malformed (T#########)');
      if (!w.writers.length) errors.push('at least one writer required');
      const totalWriterShare = w.writers.reduce((a, x) => a + (Number(x.share) || 0), 0);
      if (Math.abs(totalWriterShare - 100) > 0.5) errors.push(`writer shares sum to ${totalWriterShare.toFixed(2)} (must = 100)`);
      w.writers.forEach((x, i) => {
        if (!x.name) errors.push(`writer ${i + 1}: name required`);
        if (x.ipi && !looksIPI(x.ipi)) errors.push(`writer ${i + 1}: IPI malformed`);
      });
      if (!w.publishers.length) warnings.push('no publisher set — default sub-pub will be used');
      return { errors, warnings };
    }
  };

  // ─── HFA Slingshot (CSV) ────────────────────────────────────────────────
  TARGETS.hfa = {
    id: 'hfa',
    name: 'HFA — Slingshot Bulk Registration',
    short: 'HFA',
    kind: 'works',
    format: 'csv',
    filenameOf: () => `hfa_slingshot_${today()}.csv`,
    description: 'CSV for HFA Slingshot bulk registration. HFA Song Code generated post-receipt.',
    columns: [
      { key:'hfa_song_code',   header:'HFA Song Code' },
      { key:'work_title',      header:'Work Title' },
      { key:'iswc',            header:'ISWC' },
      { key:'duration_min',    header:'Duration (mm:ss)' },
      { key:'lyric_language',  header:'Lyric Language' },
      { key:'writer_name',     header:'Writer Name' },
      { key:'writer_ipi',      header:'Writer IPI' },
      { key:'writer_role',     header:'Writer Role' },
      { key:'writer_share',    header:'Writer Share %' },
      { key:'publisher_name',  header:'Publisher Name' },
      { key:'publisher_ipi',   header:'Publisher IPI' },
      { key:'publisher_share', header:'Publisher Share %' },
      { key:'pro',             header:'PRO' },
      { key:'territory',       header:'Territory' },
    ],
    // HFA wants one writer-row per work — we explode here so it's the generator's job
    explode: (w) => {
      const out = [];
      const dur = w.duration ? `${Math.floor(w.duration/60)}:${String(w.duration%60).padStart(2,'0')}` : '';
      for (const x of w.writers) {
        const pub = (x.publishers && x.publishers[0]) || w.publishers[0] || { name:'', ipi:'', share:0 };
        out.push({
          hfa_song_code: '',
          work_title: w.title,
          iswc: w.iswc,
          duration_min: dur,
          lyric_language: w.lyricLanguage,
          writer_name: x.name,
          writer_ipi: x.ipi,
          writer_role: x.role,
          writer_share: Number(x.share || 0).toFixed(2),
          publisher_name: pub.name,
          publisher_ipi: pub.ipi,
          publisher_share: Number(pub.share || 0).toFixed(2),
          pro: x.pro,
          territory: pub.territory || 'WORLD',
        });
      }
      return out;
    },
    validateRow: (w) => {
      const errors = [], warnings = [];
      if (!w.title) errors.push('title required');
      if (!w.writers.length) errors.push('writer required');
      if (!w.duration) warnings.push('duration missing — HFA prefers it');
      const totalWriterShare = w.writers.reduce((a, x) => a + (Number(x.share) || 0), 0);
      if (Math.abs(totalWriterShare - 100) > 0.5) errors.push(`writer shares = ${totalWriterShare.toFixed(2)}, must equal 100`);
      return { errors, warnings };
    }
  };

  // ─── SoundExchange ISRC Manifest (CSV) ──────────────────────────────────
  TARGETS.soundexchange = {
    id: 'soundexchange',
    name: 'SoundExchange — ISRC Manifest',
    short: 'SX',
    kind: 'recordings',
    format: 'csv',
    filenameOf: () => `soundexchange_isrc_${today()}.csv`,
    description: 'Recording-level manifest for SoundExchange member registration. One row per ISRC.',
    columns: [
      { key:'isrc',           header:'ISRC' },
      { key:'track_title',    header:'Track Title' },
      { key:'version',        header:'Version' },
      { key:'featured_artist',header:'Featured Artist' },
      { key:'duration_sec',   header:'Duration (sec)' },
      { key:'release_date',   header:'First Release Date' },
      { key:'release_title',  header:'Release Title' },
      { key:'label',          header:'Label' },
      { key:'p_line',         header:'P-Line' },
      { key:'upc',            header:'UPC' },
      { key:'genre',          header:'Genre' },
    ],
    rowMap: (r) => ({
      isrc: (r.isrc || '').toUpperCase(),
      track_title: r.title,
      version: r.versionTitle,
      featured_artist: r.artist,
      duration_sec: r.durationSec,
      release_date: r.releaseDate,
      release_title: r.releaseTitle,
      label: r.label,
      p_line: r.pLine,
      upc: r.upc,
      genre: r.genre,
    }),
    validateRow: (r) => {
      const errors = [], warnings = [];
      if (!r.isrc) errors.push('ISRC required');
      else if (!looksISRC(r.isrc)) errors.push('ISRC malformed (CC-XXX-YY-NNNNN)');
      if (!r.title) errors.push('track title required');
      if (!r.artist) errors.push('featured artist required');
      if (!r.durationSec) warnings.push('duration missing');
      if (!r.label) warnings.push('label missing');
      if (!r.pLine) warnings.push('P-Line missing');
      if (r.upc && !looksUPC(r.upc)) errors.push('UPC malformed');
      return { errors, warnings };
    }
  };

  // ─── Xperi (Gracenote) Catalog Feed (XML) ───────────────────────────────
  TARGETS.xperi = {
    id: 'xperi',
    name: 'Xperi (Gracenote) — Catalog Feed',
    short: 'Xperi',
    kind: 'releases',
    format: 'xml',
    filenameOf: () => `xperi_catalog_${today()}.xml`,
    description: 'Gracenote catalog-feed XML. Release-level, with track manifest and recognition fingerprint hooks.',
    validateRow: (rel) => {
      const errors = [], warnings = [];
      if (!rel.title) errors.push('release title required');
      if (!rel.artist) errors.push('release artist required');
      if (!rel.upc) errors.push('UPC required for Gracenote ingestion');
      else if (!looksUPC(rel.upc)) errors.push('UPC malformed');
      if (!rel.tracks || !rel.tracks.length) warnings.push('release has no tracks');
      rel.tracks?.forEach((t, i) => {
        if (!t.isrc) errors.push(`track ${i + 1}: ISRC required`);
        else if (!looksISRC(t.isrc)) errors.push(`track ${i + 1}: ISRC malformed`);
      });
      return { errors, warnings };
    }
  };

  // ─── Music Reports / MRI (TSV) ──────────────────────────────────────────
  TARGETS.mri = {
    id: 'mri',
    name: 'Music Reports (MRI) — Catalog Feed',
    short: 'MRI',
    kind: 'works',
    format: 'tsv',
    filenameOf: () => `mri_catalog_${today()}.tsv`,
    description: 'TSV catalog feed for Music Reports licensing.',
    columns: [
      { key:'work_title',   header:'Work Title' },
      { key:'iswc',         header:'ISWC' },
      { key:'duration',     header:'Duration' },
      { key:'writers',      header:'Writers' },
      { key:'publishers',   header:'Publishers' },
      { key:'territories',  header:'Territories' },
      { key:'isrcs',        header:'ISRCs' },
    ],
    rowMap: (w) => ({
      work_title: w.title,
      iswc: w.iswc,
      duration: w.duration ? `${Math.floor(w.duration/60)}:${String(w.duration%60).padStart(2,'0')}` : '',
      writers: w.writers.map(x => `${x.name}(${x.share.toFixed(2)})`).join('; '),
      publishers: w.publishers.map(p => `${p.name}(${p.share.toFixed(2)})`).join('; '),
      territories: w.publishers.map(p => p.territory).filter((v,i,a)=>a.indexOf(v)===i).join(','),
      isrcs: w.recordings.map(r => r.isrc).filter(Boolean).join(';'),
    }),
    validateRow: TARGETS.mlc.validateRow,
  };

  // ─── AMRA (TSV) ─────────────────────────────────────────────────────────
  TARGETS.amra = {
    id: 'amra',
    name: 'AMRA — Catalog Registration',
    short: 'AMRA',
    kind: 'works',
    format: 'tsv',
    filenameOf: () => `amra_catalog_${today()}.tsv`,
    description: 'AMRA performance + mechanical CMO. Adds territory exclusion for ASCAP/BMI overlap.',
    columns: [
      { key:'work_title',   header:'Work Title' },
      { key:'iswc',         header:'ISWC' },
      { key:'writers',      header:'Writers (with PRO)' },
      { key:'publishers',   header:'Publishers' },
      { key:'amra_share',   header:'AMRA Share %' },
      { key:'excluded',     header:'Excluded Territories' },
    ],
    rowMap: (w) => {
      const amraShare = w.publishers.reduce((a, p) => a + (p.share || 0), 0);
      return {
        work_title: w.title,
        iswc: w.iswc,
        writers: w.writers.map(x => `${x.name}(${x.pro || '?'} · ${x.share.toFixed(2)})`).join('; '),
        publishers: w.publishers.map(p => `${p.name}(${p.ipi})`).join('; '),
        amra_share: amraShare.toFixed(2),
        excluded: 'US,GB,CA',
      };
    },
    validateRow: TARGETS.mlc.validateRow,
  };

  // ─── CMRRA (CSV) ────────────────────────────────────────────────────────
  TARGETS.cmrra = {
    id: 'cmrra',
    name: 'CMRRA — Mechanical Registration (Canada)',
    short: 'CMRRA',
    kind: 'works',
    format: 'csv',
    filenameOf: () => `cmrra_works_${today()}.csv`,
    description: 'Canadian mechanical-rights agency. CA-territory specific.',
    columns: [
      { key:'cmrra_song_code', header:'CMRRA Song Code' },
      { key:'work_title',      header:'Work Title' },
      { key:'iswc',            header:'ISWC' },
      { key:'duration',        header:'Duration' },
      { key:'writer_name',     header:'Writer' },
      { key:'writer_share',    header:'Writer Share %' },
      { key:'publisher_name',  header:'Publisher' },
      { key:'publisher_share', header:'Pub Share %' },
      { key:'pro',             header:'PRO (SOCAN/SODRAC)' },
      { key:'sub_publisher',   header:'CA Sub-Publisher' },
    ],
    explode: (w) => {
      const out = [];
      for (const x of w.writers) {
        const pub = (x.publishers && x.publishers[0]) || w.publishers[0] || { name:'', share:0 };
        out.push({
          cmrra_song_code: '',
          work_title: w.title,
          iswc: w.iswc,
          duration: w.duration ? `${Math.floor(w.duration/60)}:${String(w.duration%60).padStart(2,'0')}` : '',
          writer_name: x.name,
          writer_share: Number(x.share || 0).toFixed(2),
          publisher_name: pub.name,
          publisher_share: Number(pub.share || 0).toFixed(2),
          pro: x.pro || 'SOCAN',
          sub_publisher: 'CMRRA',
        });
      }
      return out;
    },
    validateRow: TARGETS.mlc.validateRow,
  };

  // ─── CISAC v22 — Registration variant (Fixed-width) ─────────────────────
  TARGETS.cisac22 = {
    id: 'cisac22',
    name: 'CISAC v22 — Slim Registration',
    short: 'CISAC v22',
    kind: 'works',
    format: 'fixed',
    filenameOf: () => `cisac22_${today()}.txt`,
    description: 'Slimline registration variant of CISAC v22 (CWR-style fixed-width). For fast registration; use full CWR generator for distribution.',
    validateRow: TARGETS.mlc.validateRow,
  };

  // ─── DDEX MEAD — Metadata Enrichment Description (XML) ──────────────────
  TARGETS.mead = {
    id: 'mead',
    name: 'DDEX MEAD — Metadata Enrichment',
    short: 'MEAD',
    kind: 'releases',
    format: 'xml',
    filenameOf: () => `ddex_mead_${today()}.xml`,
    description: 'DDEX Metadata Enrichment Description for downstream DSP catalog enrichment.',
    validateRow: TARGETS.xperi.validateRow,
  };

  E.targets = TARGETS;
  E.targetIds = Object.keys(TARGETS);
  E.targetById = (id) => TARGETS[id] || null;

  // ── BUILDERS ────────────────────────────────────────────────────────────
  function buildTSV(target, rows) {
    const cols = target.columns;
    const lines = [cols.map(c => tsv(c.header)).join('\t')];
    const exploded = target.explode ? rows.flatMap(target.explode) : rows.map(target.rowMap);
    for (const r of exploded) {
      lines.push(cols.map(c => tsv(r[c.key] != null ? r[c.key] : '')).join('\t'));
    }
    return lines.join('\n');
  }

  function buildCSV(target, rows) {
    const cols = target.columns;
    const lines = [cols.map(c => csv(c.header)).join(',')];
    const exploded = target.explode ? rows.flatMap(target.explode) : rows.map(target.rowMap);
    for (const r of exploded) {
      lines.push(cols.map(c => csv(r[c.key] != null ? r[c.key] : '')).join(','));
    }
    return lines.join('\n');
  }

  function buildXperiXML(rows) {
    const head = `<?xml version="1.0" encoding="UTF-8"?>
<GnCatalogFeed xmlns="http://schemas.gracenote.com/catalog/v3" generated="${isoToday()}" rowCount="${rows.length}">`;
    const body = rows.map(rel => `
  <Release upc="${xmlEsc(rel.upc)}" grid="${xmlEsc(rel.grid)}">
    <Title>${xmlEsc(rel.title)}</Title>
    <Artist>${xmlEsc(rel.artist)}</Artist>
    <ReleaseType>${xmlEsc(rel.kind)}</ReleaseType>
    <Label>${xmlEsc(rel.label)}</Label>
    <ReleaseDate>${xmlEsc(rel.releaseDate)}</ReleaseDate>
    <Territory>${xmlEsc(rel.territory)}</Territory>
    <PLine>${xmlEsc(rel.pLine)}</PLine>
    <CLine>${xmlEsc(rel.cLine)}</CLine>
    <Genre>${xmlEsc(rel.genre)}</Genre>
    <TrackList count="${(rel.tracks||[]).length}">
${(rel.tracks||[]).map(t => `      <Track seq="${t.seq}" isrc="${xmlEsc((t.isrc||'').toUpperCase())}">
        <Title>${xmlEsc(t.title)}</Title>
        <Artist>${xmlEsc(t.artist)}</Artist>
        <Duration>${t.durationSec || 0}</Duration>
        <ISWC>${xmlEsc(t.iswc)}</ISWC>
      </Track>`).join('\n')}
    </TrackList>
  </Release>`).join('');
    return head + body + '\n</GnCatalogFeed>\n';
  }

  function buildMeadXML(rows) {
    const head = `<?xml version="1.0" encoding="UTF-8"?>
<MeadMessage xmlns="http://ddex.net/xml/mead/15" MessageSchemaVersionId="mead/15" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <MessageHeader>
    <MessageId>MEAD-${today()}-${Math.random().toString(36).slice(2, 8).toUpperCase()}</MessageId>
    <MessageCreatedDateTime>${isoToday()}</MessageCreatedDateTime>
    <MessageSender><PartyId>RKTSCI</PartyId><PartyName><FullName>Rocket Science</FullName></PartyName></MessageSender>
    <MessageRecipient><PartyId>DDEXMEAD</PartyId></MessageRecipient>
  </MessageHeader>
  <ReleaseList>`;
    const body = rows.map(rel => `
    <Release>
      <ReleaseId><ICPN>${xmlEsc(rel.upc)}</ICPN><GRid>${xmlEsc(rel.grid)}</GRid></ReleaseId>
      <ReleaseReference>R${rel.id}</ReleaseReference>
      <ReleaseType>${xmlEsc(rel.kind)}</ReleaseType>
      <DisplayTitleText>${xmlEsc(rel.title)}</DisplayTitleText>
      <DisplayArtistName>${xmlEsc(rel.artist)}</DisplayArtistName>
      <ReleaseLabelReference>${xmlEsc(rel.label)}</ReleaseLabelReference>
      <PLine><Year>${(rel.releaseDate||'').slice(0,4)}</Year><PLineText>${xmlEsc(rel.pLine)}</PLineText></PLine>
      <CLine><Year>${(rel.releaseDate||'').slice(0,4)}</Year><CLineText>${xmlEsc(rel.cLine)}</CLineText></CLine>
      <Genre><GenreText>${xmlEsc(rel.genre)}</GenreText></Genre>
      <ResourceGroup>
${(rel.tracks||[]).map((t,i) => `        <ResourceGroupContentItem sequenceNumber="${i+1}">
          <ResourceReference>R${rel.id}-${i+1}</ResourceReference>
          <ISRC>${xmlEsc((t.isrc||'').toUpperCase())}</ISRC>
          <Title>${xmlEsc(t.title)}</Title>
          <DisplayArtistName>${xmlEsc(t.artist)}</DisplayArtistName>
          <Duration>PT${Math.floor((t.durationSec||0)/60)}M${(t.durationSec||0)%60}S</Duration>
        </ResourceGroupContentItem>`).join('\n')}
      </ResourceGroup>
    </Release>`).join('');
    return head + body + '\n  </ReleaseList>\n</MeadMessage>\n';
  }

  function buildCisac22Fixed(rows) {
    // Slim CISAC v22-style fixed-width: header + per-work record + writers + publishers + trailer
    const lines = [];
    const dt = today();
    lines.push(padR('HDR', 3) + padR('RKTSCI', 12) + padR('Rocket Science', 30) + dt + padR('CISAC22-SLIM', 16));
    let workSeq = 0;
    for (const w of rows) {
      workSeq++;
      const wId = padL(workSeq, 8);
      lines.push(padR('NWR', 3) + wId + padR(w.title, 60) + padR(w.iswc, 11) + padL(w.duration || 0, 6) + padR(w.lyricLanguage, 2));
      let seq = 0;
      for (const x of w.writers) {
        seq++;
        lines.push(padR('SWR', 3) + wId + padL(seq, 3) + padR(x.name, 45) + padR(x.ipi, 11) + padR(x.role, 2) + padL(Math.round((x.share||0)*100), 5));
      }
      seq = 0;
      for (const p of w.publishers) {
        seq++;
        lines.push(padR('SPU', 3) + wId + padL(seq, 3) + padR(p.name, 45) + padR(p.ipi, 11) + padR(p.role, 2) + padL(Math.round((p.share||0)*100), 5));
      }
    }
    lines.push(padR('TRL', 3) + padL(workSeq, 8) + padL(lines.length, 8));
    return lines.join('\n');
  }

  E.build = function build(targetId, opts) {
    const t = TARGETS[targetId];
    if (!t) throw new Error('unknown target ' + targetId);
    const rows = (opts && opts.rows) || E.rowsFor(t.kind);
    if (t.format === 'tsv') return buildTSV(t, rows);
    if (t.format === 'csv') return buildCSV(t, rows);
    if (t.format === 'xml') {
      if (t.id === 'xperi') return buildXperiXML(rows);
      if (t.id === 'mead') return buildMeadXML(rows);
    }
    if (t.format === 'fixed') return buildCisac22Fixed(rows);
    throw new Error('no builder for format ' + t.format);
  };

  // ── VALIDATION ──────────────────────────────────────────────────────────
  E.validate = function validate(targetId, opts) {
    const t = TARGETS[targetId];
    if (!t) throw new Error('unknown target ' + targetId);
    const rows = (opts && opts.rows) || E.rowsFor(t.kind);

    // Per-row validation
    const rowReports = rows.map((r, idx) => {
      const v = t.validateRow ? t.validateRow(r) : { errors: [], warnings: [] };
      return { index: idx, id: r.id, label: r.title || r.workTitle || r.releaseTitle || `#${idx + 1}`, ...v };
    });

    // Cross-row: duplicate detection
    const dupes = {};
    const dupKey = (r) => (r.iswc || r.isrc || r.upc || r.id || '').toString().toUpperCase();
    rows.forEach((r, idx) => {
      const k = dupKey(r);
      if (!k) return;
      if (!dupes[k]) dupes[k] = [];
      dupes[k].push(idx);
    });
    Object.entries(dupes).forEach(([k, ix]) => {
      if (ix.length > 1) {
        ix.forEach(i => rowReports[i].errors.push(`duplicate identifier ${k} (also at row ${ix.filter(j => j !== i).map(j => j + 1).join(',')})`));
      }
    });

    // Stats
    const errCount = rowReports.reduce((a, r) => a + r.errors.length, 0);
    const warnCount = rowReports.reduce((a, r) => a + r.warnings.length, 0);
    const errorRows = rowReports.filter(r => r.errors.length).length;
    const cleanRows = rowReports.filter(r => !r.errors.length && !r.warnings.length).length;

    return {
      targetId,
      target: t.name,
      total: rows.length,
      errorRows,
      cleanRows,
      errCount,
      warnCount,
      rowReports,
    };
  };

  // ── DIFF VS LAST EXPORT ─────────────────────────────────────────────────
  // Persists last export hash per target in localStorage; diff returns
  // { added: [...ids], removed: [...ids], same: [...ids] }
  const LS_KEY = 'astro_bulkexport_lastIds_v1';

  E.diffVsLast = function diffVsLast(targetId, opts) {
    const t = TARGETS[targetId];
    if (!t) return { added: [], removed: [], same: [] };
    const rows = (opts && opts.rows) || E.rowsFor(t.kind);
    const currentIds = rows.map(r => r.id || r.iswc || r.isrc || r.upc).filter(Boolean);
    let last = [];
    try {
      const raw = localStorage.getItem(LS_KEY);
      if (raw) last = (JSON.parse(raw)[targetId] || []);
    } catch {}
    const lastSet = new Set(last);
    const curSet = new Set(currentIds);
    const added = currentIds.filter(id => !lastSet.has(id));
    const removed = last.filter(id => !curSet.has(id));
    const same = currentIds.filter(id => lastSet.has(id));
    return { added, removed, same, lastCount: last.length };
  };

  E.commitExport = function commitExport(targetId, opts) {
    const t = TARGETS[targetId];
    if (!t) return;
    const rows = (opts && opts.rows) || E.rowsFor(t.kind);
    const ids = rows.map(r => r.id || r.iswc || r.isrc || r.upc).filter(Boolean);
    let store = {};
    try {
      const raw = localStorage.getItem(LS_KEY);
      if (raw) store = JSON.parse(raw);
    } catch {}
    store[targetId] = ids;
    try { localStorage.setItem(LS_KEY, JSON.stringify(store)); } catch {}
  };

  // ── DOWNLOAD ────────────────────────────────────────────────────────────
  E.download = function download(targetId, opts) {
    const t = TARGETS[targetId];
    if (!t) throw new Error('unknown target ' + targetId);
    const content = E.build(targetId, opts);
    const filename = t.filenameOf();
    const mime = t.format === 'xml' ? 'application/xml'
              : t.format === 'csv' ? 'text/csv'
              : t.format === 'tsv' ? 'text/tab-separated-values'
              : 'text/plain';
    const blob = new Blob([content], { type: mime });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = filename;
    document.body.appendChild(a); a.click();
    setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
    E.commitExport(targetId, opts);
    return { filename, bytes: content.length };
  };

  window.BulkExport = E;
})();
