// ============================================================================
// CWR FILE BUILDER  ·  pure functions, no React
// ----------------------------------------------------------------------------
// Builds CWR-format transmissions for versions 2.1, 2.1r7, 2.2, 3.0, 3.1
// from work-summary objects pulled out of the Pluralis catalog.
//
// CWR is a fixed-width, line-oriented format (CISAC). Each record is a single
// line; the first three characters identify the record type. Fields are
// padded with spaces (text, left-aligned) or zeros (numbers, right-aligned).
//
// Every record-builder here returns a single string of length L (the spec
// length for that record/version). We deliberately model EVERY record type
// the registrations team has ever asked about so the generator can show
// the complete grammar — even rare ones like NPN/NPR/NWN/INT.
//
// EXPORT: window.CwrBuild = { buildTransmission, RECORD_LENGTHS, … }
// ============================================================================

(function () {
  'use strict';

  // ─── Field padders ────────────────────────────────────────────────────────
  // CWR is unforgiving about lengths. These helpers return EXACTLY n chars.
  function padR(s, n) { s = (s == null ? '' : String(s)); s = asciiFold(s); return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length); }
  function padL(s, n) { s = (s == null ? '' : String(s)); s = asciiFold(s); return s.length >= n ? s.slice(0, n) : ' '.repeat(n - s.length) + s; }
  function padNum(v, n) { v = (v == null || v === '') ? 0 : v; const s = String(Math.abs(parseInt(v, 10) || 0)); return s.length >= n ? s.slice(-n) : '0'.repeat(n - s.length) + s; }
  // Coerce a built record line to EXACTLY n chars: pad with spaces if short,
  // truncate if long. This is what every record builder must return — the
  // record-length slice() pattern is wrong because it doesn't pad up.
  function fitTo(s, n) { s = String(s == null ? '' : s); return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length); }

  // ASCII-fold: for v2.x, all non-NRA records MUST be ASCII. We Unicode-NFD
  // decompose accented chars and strip combining marks, then drop anything
  // remaining that's still non-ASCII. The originals travel in NCT/NWN/NPN.
  // CWR 3.0+ supports UTF-8 in main records, so the builder can opt-out via ctx.
  function asciiFold(s) {
    if (!s) return s;
    if (typeof s !== 'string') return s;
    // Fast path: pure ASCII
    if (!/[^\x00-\x7F]/.test(s)) return s;
    return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^\x00-\x7F]/g, '?');
  }
  function padIPI(ipi, n) {
    // IPI name numbers are 11 digits; IPI base numbers are 13 chars (I + 9 digits + check).
    if (!ipi) return ' '.repeat(n);
    const stripped = String(ipi).replace(/[^\dIA-Z]/g, '');
    return padL(stripped, n).slice(0, n);
  }
  function padISWC(iswc, n) {
    // ISWC stored as T-XXX.XXX.XXX-X — CWR wants T followed by 10 digits then check digit
    if (!iswc) return ' '.repeat(n);
    const compact = String(iswc).replace(/[^\dT]/g, '');
    return padR(compact, n).slice(0, n);
  }
  function padDate(d) {
    // YYYYMMDD
    if (!d) return ' '.repeat(8);
    if (d instanceof Date) {
      const y = d.getUTCFullYear(); const m = (d.getUTCMonth() + 1); const day = d.getUTCDate();
      return String(y) + String(m).padStart(2, '0') + String(day).padStart(2, '0');
    }
    return padR(String(d).replace(/[^\d]/g, ''), 8).slice(0, 8);
  }
  function padTime(t) {
    if (!t) return '000000';
    if (t instanceof Date) {
      const h = t.getUTCHours(); const m = t.getUTCMinutes(); const s = t.getUTCSeconds();
      return String(h).padStart(2, '0') + String(m).padStart(2, '0') + String(s).padStart(2, '0');
    }
    return padR(String(t).replace(/[^\d]/g, ''), 6).slice(0, 6);
  }

  // ─── Record lengths per version ───────────────────────────────────────────
  // (Values per CISAC CWR spec. Some records grow across versions.)
  const REC_LEN = {
    HDR: { '2.1': 86, '2.1r7': 86, '2.2': 86, '3.0': 88, '3.1': 88 },
    GRH: { '2.1': 41, '2.1r7': 41, '2.2': 41, '3.0': 28, '3.1': 28 },
    GRT: { '2.1': 47, '2.1r7': 47, '2.2': 47, '3.0': 47, '3.1': 47 },
    TRL: { '2.1': 23, '2.1r7': 23, '2.2': 23, '3.0': 23, '3.1': 23 },
    NWR: { '2.1': 233, '2.1r7': 233, '2.2': 233, '3.0': 235, '3.1': 235 },
    SPU: { '2.1': 213, '2.1r7': 213, '2.2': 213, '3.0': 215, '3.1': 215 },
    SPT: { '2.1':  77, '2.1r7':  77, '2.2':  77, '3.0':  79, '3.1':  79 },
    SWR: { '2.1': 167, '2.1r7': 167, '2.2': 167, '3.0': 169, '3.1': 169 },
    SWT: { '2.1':  60, '2.1r7':  60, '2.2':  60, '3.0':  62, '3.1':  62 },
    PWR: { '2.1':  91, '2.1r7':  91, '2.2':  91, '3.0':  93, '3.1':  93 },
    ALT: { '2.1':  84, '2.1r7':  84, '2.2':  84, '3.0':  86, '3.1':  86 },
    EWT: { '2.1': 144, '2.1r7': 144, '2.2': 144, '3.0': 146, '3.1': 146 },
    VER: { '2.1': 146, '2.1r7': 146, '2.2': 146, '3.0': 148, '3.1': 148 },
    // PER: 3+8+8+45+30+11+13 = 118 (v2.2). v3.x adds perfLanguage(2) + perfDialect(3) = 123. v3.1 keeps same.
    PER: { '2.1': 105, '2.1r7': 105, '2.2': 118, '3.0': 123, '3.1': 123 },
    REC: { '2.1': 159, '2.1r7': 159, '2.2': 640, '3.0': 642, '3.1': 642 },
    // ORN: full layout below in builder. Computed exactly from field set.
    // 2.1: 3+8+8+3+60+15+4+1+25+12+4+3+15 = 161 (no library, no episode)
    // 2.2: 3+8+8+3+60+15+4+60+1+25+12+60+20+4+3+15 = 301 (library + episode added)
    // 3.x: 2.2 layout (no further additions in 3.x for ORN)
    ORN: { '2.1': 161, '2.1r7': 161, '2.2': 301, '3.0': 301, '3.1': 301 },
    INS: { '2.1':  84, '2.1r7':  84, '2.2':  84, '3.0':  86, '3.1':  86 },
    IND: { '2.1':  46, '2.1r7':  46, '2.2':  46, '3.0':  48, '3.1':  48 },
    COM: { '2.1': 160, '2.1r7': 160, '2.2': 160, '3.0': 162, '3.1': 162 },
    MSG: { '2.1': 161, '2.1r7': 161, '2.2': 161, '3.0': 163, '3.1': 163 },
    NET: { '2.1': 384, '2.1r7': 384, '2.2': 384, '3.0': 386, '3.1': 386 },
    NCT: { '2.1': 384, '2.1r7': 384, '2.2': 384, '3.0': 386, '3.1': 386 },
    NVT: { '2.1': 384, '2.1r7': 384, '2.2': 384, '3.0': 386, '3.1': 386 },
    NPN: { '2.1': 480, '2.1r7': 480, '2.2': 480, '3.0': 482, '3.1': 482 },
    NPR: { '2.1': 538, '2.1r7': 538, '2.2': 538, '3.0': 540, '3.1': 540 },
    NWN: { '2.1': 345, '2.1r7': 345, '2.2': 345, '3.0': 347, '3.1': 347 },
    NOW: { '2.1': 322, '2.1r7': 322, '2.2': 322, '3.0': 324, '3.1': 324 },
    ARI: { '2.1': 103, '2.1r7': 103, '2.2': 103, '3.0': 105, '3.1': 105 },
    // XRF: 3+8+8+3+14+1+1 = 38 (v3.0+ only)
    XRF: { '2.1':   0, '2.1r7':   0, '2.2':   0, '3.0':  38, '3.1':  38 },
    OPU: { '2.1': 213, '2.1r7': 213, '2.2': 213, '3.0': 215, '3.1': 215 },
    OPT: { '2.1':  77, '2.1r7':  77, '2.2':  77, '3.0':  79, '3.1':  79 },
    OWR: { '2.1': 167, '2.1r7': 167, '2.2': 167, '3.0': 169, '3.1': 169 },
    OWT: { '2.1':  60, '2.1r7':  60, '2.2':  60, '3.0':  62, '3.1':  62 },
    REV: { '2.1': 233, '2.1r7': 233, '2.2': 233, '3.0': 235, '3.1': 235 },
    ISW: { '2.1': 233, '2.1r7': 233, '2.2': 233, '3.0': 235, '3.1': 235 },
    EXC: { '2.1': 233, '2.1r7': 233, '2.2': 233, '3.0': 235, '3.1': 235 },
    ACK: { '2.1': 158, '2.1r7': 158, '2.2': 158, '3.0': 160, '3.1': 160 },
    AGR: { '2.1':  92, '2.1r7':  92, '2.2':  92, '3.0':  94, '3.1':  94 },
    TER: { '2.1':  20, '2.1r7':  20, '2.2':  20, '3.0':  22, '3.1':  22 },
    IPA: { '2.1': 178, '2.1r7': 178, '2.2': 178, '3.0': 180, '3.1': 180 },
    NPA: { '2.1': 480, '2.1r7': 480, '2.2': 480, '3.0': 482, '3.1': 482 },
    NRN: { '2.1':   0, '2.1r7':   0, '2.2':   0, '3.0': 235, '3.1': 235 }, // v3 only
  };

  // ─── Header / Trailer ─────────────────────────────────────────────────────
  function HDR(ctx) {
    // 2.1: rec(3) + senderType(2) + senderId(9) + senderName(45) + ediVer(5) + dateCreated(8) + timeCreated(6) + dateTransmission(8)
    const senderTypeMap = { PB: 'PB', SO: 'SO', AA: 'AA', WR: 'WR' };
    const senderType = senderTypeMap[ctx.senderType] || 'PB';
    let s = '';
    s += padR('HDR', 3);
    s += padR(senderType, 2);
    s += padNum(ctx.senderIpi || 199900001, 9);
    s += padR(ctx.senderName || 'PLURALIS MUSIC', 45);
    s += padR('01.10', 5); // EDI standard version
    s += padDate(ctx.created);
    s += padTime(ctx.created);
    s += padDate(ctx.created);
    if (ctx.version === '3.0' || ctx.version === '3.1') {
      // v3 adds a 2-char minor version field
      s += padR(ctx.version === '3.1' ? '31' : '30', 2);
    }
    return fitTo(s, REC_LEN.HDR[ctx.version]);
  }

  function GRH(ctx, txnType, groupId) {
    let s = '';
    s += padR('GRH', 3);
    s += padR(txnType, 3);            // NWR / REV / ISW / ACK / AGR
    s += padNum(groupId, 5);
    s += padR(ctx.version === '2.1' || ctx.version === '2.1r7' ? '02.10' : ctx.version === '2.2' ? '02.20' : ctx.version === '3.0' ? '03.00' : '03.10', 5);
    if (ctx.version === '2.1' || ctx.version === '2.1r7' || ctx.version === '2.2') {
      s += padNum(ctx.batchRequest || 0, 10);
      s += padR(ctx.submissionDistribution || '   ', 2); // ISO submission flag in some specs; spaces otherwise
      s += ' '.repeat(13);
    }
    return fitTo(s, REC_LEN.GRH[ctx.version]);
  }

  function GRT(groupId, txnCount, recordCount, currency, totalAmt) {
    let s = '';
    s += padR('GRT', 3);
    s += padNum(groupId, 5);
    s += padNum(txnCount, 8);
    s += padNum(recordCount, 8);
    s += padR(currency || '   ', 3);
    s += padNum(totalAmt || 0, 10);
    s += '          '; // 10 trailing
    return fitTo(s, 47);
  }

  function TRL(groupCount, txnCount, recordCount) {
    let s = '';
    s += padR('TRL', 3);
    s += padNum(groupCount, 5);
    s += padNum(txnCount, 8);
    s += padNum(recordCount, 7);
    return fitTo(s, 23);
  }

  // ─── Work registration head ───────────────────────────────────────────────
  function NWRish(kind, ctx, work, txnSeq, recSeq) {
    // NWR / REV / ISW / EXC share the same structure
    let s = '';
    s += padR(kind, 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(work.title || 'UNTITLED', 60);
    s += padR(work.lang || 'EN', 2);
    s += padR(work.submitterWorkId || work.id || '', 14);
    s += padISWC(work.iswc, 11);
    s += padDate(work.copyrightDate);
    s += padR(work.copyrightNumber || '', 12);
    s += padR(work.musicalWorkDistribution || 'POP', 3); // CWR work category
    s += padNum(work.duration || 0, 6); // HHMMSS
    s += padR(work.recorded ? 'Y' : 'U', 1);
    s += padR(work.textMusicRel || 'MTX', 3);
    s += padR(work.composite || '   ', 3);
    s += padR(work.versionType || 'ORI', 3);
    s += padR(work.excerptType || '   ', 3);
    s += padR(work.musicArrangement || '   ', 3);
    s += padR(work.lyricAdaptation || '   ', 3);
    s += padR(work.contactName || '', 30);
    s += padR(work.contactId || '', 10);
    s += padR(work.workType || 'PO', 2);
    s += padR(work.grandRights ? 'Y' : 'N', 1);
    s += padNum(work.compositeComponentCount || 0, 3);
    s += padDate(work.publicationDatePrintedEdition);
    s += padR(work.exceptionalClause || ' ', 1);
    s += padR(work.opusNumber || '', 25);
    s += padR(work.catalogueNumber || '', 25);
    s += padR(work.priorityFlag || ' ', 1);
    if (ctx.version === '3.0' || ctx.version === '3.1') {
      s += '  '; // 2 trailing chars in v3 (reserved for category extension)
    }
    return fitTo(s, REC_LEN[kind][ctx.version]);
  }

  // ─── Publisher records ────────────────────────────────────────────────────
  function SPU(ctx, pub, txnSeq, recSeq, sequenceN) {
    return PUBish('SPU', ctx, pub, txnSeq, recSeq, sequenceN);
  }
  function OPU(ctx, pub, txnSeq, recSeq, sequenceN) {
    return PUBish('OPU', ctx, pub, txnSeq, recSeq, sequenceN);
  }
  function PUBish(kind, ctx, pub, txnSeq, recSeq, n) {
    let s = '';
    s += padR(kind, 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padNum(n || 1, 2);
    s += padR(pub.id || '', 9);
    s += padR(pub.name || '', 45);
    s += padR(pub.unknown ? 'Y' : ' ', 1);
    s += padR(pub.role || 'E', 2);
    s += padR(pub.taxId || '', 9);
    s += padIPI(pub.ipiName, 11);
    s += padR(pub.agreement || '', 14);
    s += padR(pub.prSocCode || '   ', 3);
    s += padNum(Math.round((pub.prShare || 0) * 100), 5);
    s += padR(pub.mrSocCode || '   ', 3);
    s += padNum(Math.round((pub.mrShare || 0) * 100), 5);
    s += padR(pub.srSocCode || '   ', 3);
    s += padNum(Math.round((pub.srShare || 0) * 100), 5);
    s += padR(pub.specialAgreement || ' ', 1);
    s += padR(pub.firstRecording ? 'Y' : ' ', 1);
    s += padR(pub.usaLicenseInd || ' ', 1);
    s += padNum(pub.prAffiliation || 0, 3);
    s += padR(pub.intStandardCode || '', 13); // IPI base
    s += padR(pub.agreementType || '   ', 2);
    s += padR(pub.salesManufactureClause || ' ', 1);
    s += padNum(pub.shareInOtherTerritories || 0, 5);
    s += padNum(pub.agreementNum || 0, 14);
    s += padR(pub.numberOfShares || '', 9);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN[kind][ctx.version]);
  }

  function SPT(ctx, terr, txnSeq, recSeq, n) {
    return TERRish('SPT', ctx, terr, txnSeq, recSeq, n);
  }
  function OPT(ctx, terr, txnSeq, recSeq, n) {
    return TERRish('OPT', ctx, terr, txnSeq, recSeq, n);
  }
  function TERRish(kind, ctx, terr, txnSeq, recSeq, n) {
    let s = '';
    s += padR(kind, 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(terr.ipNum || '', 9);
    s += '      '; // constants
    s += padNum(Math.round((terr.prShare || 0) * 100), 5);
    s += padNum(Math.round((terr.mrShare || 0) * 100), 5);
    s += padNum(Math.round((terr.srShare || 0) * 100), 5);
    s += padR(terr.inclusionFlag || 'I', 1);
    s += padNum(terr.tisNum || 2136, 4); // TIS code (2136 = world)
    s += padR(terr.sharesChange || ' ', 1);
    s += padNum(n || 1, 3);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN[kind][ctx.version]);
  }

  // ─── Writer records ───────────────────────────────────────────────────────
  function SWR(ctx, w, txnSeq, recSeq) { return WRish('SWR', ctx, w, txnSeq, recSeq); }
  function OWR(ctx, w, txnSeq, recSeq) { return WRish('OWR', ctx, w, txnSeq, recSeq); }
  function WRish(kind, ctx, w, txnSeq, recSeq) {
    let s = '';
    s += padR(kind, 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(w.id || '', 9);
    s += padR(w.lastName || w.name || '', 45);
    s += padR(w.firstName || '', 30);
    s += padR(w.unknown ? 'Y' : ' ', 1);
    s += padR(w.designation || 'CA', 2);
    s += padR(w.taxId || '', 9);
    s += padR(w.personalNumber || '', 12);
    s += padIPI(w.ipiName, 11);
    s += padR(w.prSocCode || '   ', 3);
    s += padNum(Math.round((w.prShare || 0) * 100), 5);
    s += padR(w.mrSocCode || '   ', 3);
    s += padNum(Math.round((w.mrShare || 0) * 100), 5);
    s += padR(w.srSocCode || '   ', 3);
    s += padNum(Math.round((w.srShare || 0) * 100), 5);
    s += padR(w.reversionary || ' ', 1);
    s += padR(w.firstRecording ? 'Y' : ' ', 1);
    s += padR(w.workForHire || ' ', 1);
    s += padR(w.intStandardCode || '', 13);
    s += padR(w.usaLicenseInd || ' ', 1);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN[kind][ctx.version]);
  }

  function SWT(ctx, t, txnSeq, recSeq) { return WTish('SWT', ctx, t, txnSeq, recSeq); }
  function OWT(ctx, t, txnSeq, recSeq) { return WTish('OWT', ctx, t, txnSeq, recSeq); }
  function WTish(kind, ctx, t, txnSeq, recSeq) {
    let s = '';
    s += padR(kind, 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(t.ipNum || '', 9);
    s += padNum(Math.round((t.prShare || 0) * 100), 5);
    s += padNum(Math.round((t.mrShare || 0) * 100), 5);
    s += padNum(Math.round((t.srShare || 0) * 100), 5);
    s += padR(t.inclusionFlag || 'I', 1);
    s += padNum(t.tisNum || 2136, 4);
    s += padR(t.sharesChange || ' ', 1);
    s += padNum(t.sequence || 1, 3);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN[kind][ctx.version]);
  }

  function PWR(ctx, link, txnSeq, recSeq) {
    let s = '';
    s += padR('PWR', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(link.publisherId || '', 9);
    s += padR(link.publisherName || '', 45);
    s += padR(link.submitterAgreementNum || '', 14);
    s += padR(link.societyAgreementNum || '', 14);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.PWR[ctx.version]);
  }

  // ─── Misc title / EWT / VER / PER / REC / ORN / INS / IND / COM / MSG ────
  function ALT(ctx, alt, txnSeq, recSeq) {
    let s = '';
    s += padR('ALT', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(alt.title || '', 60);
    s += padR(alt.titleType || 'AT', 2);
    s += padR(alt.lang || 'EN', 2);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.ALT[ctx.version]);
  }

  function EWT(ctx, ent, txnSeq, recSeq) {
    let s = '';
    s += padR('EWT', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(ent.entireWorkTitle || '', 60);
    s += padISWC(ent.entireIswc, 11);
    s += padR(ent.lang || 'EN', 2);
    s += padR(ent.writer1Last || '', 45);
    s += padR(ent.writer1First || '', 30);
    s += padR(ent.source || '', 60);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.EWT[ctx.version]);
  }

  function VER(ctx, ver, txnSeq, recSeq) {
    let s = '';
    s += padR('VER', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(ver.originalTitle || '', 60);
    s += padISWC(ver.originalIswc, 11);
    s += padR(ver.lang || 'EN', 2);
    s += padR(ver.writer1Last || '', 45);
    s += padR(ver.writer1First || '', 30);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.VER[ctx.version]);
  }

  // PER — Performer
  // CWR 2.1/2.1r7: Last(45) + First(30) + IPIName(11)             → 105
  // CWR 2.2:      + IPIBase(13)                                   → 118
  // CWR 3.0/3.1:  + Performance Language(2) + Performance Dialect(3) → 123
  // Performance Language = ISO 639-1 (2 alpha) e.g. "EN", "ES", "FR"
  // Performance Dialect  = optional regional dialect 3-char code, e.g. "ENG"
  function PER(ctx, p, txnSeq, recSeq) {
    let s = '';
    s += padR('PER', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(p.lastName || '', 45);
    s += padR(p.firstName || '', 30);
    s += padIPI(p.ipiName, 11);
    if (ctx.version === '2.2' || ctx.version === '3.0' || ctx.version === '3.1') {
      s += padR(p.ipiBase || '', 13);
    }
    if (ctx.version === '3.0' || ctx.version === '3.1') {
      // ISO 639-1 2-char language code
      const lang = (p.performanceLanguage || '').toUpperCase().slice(0, 2);
      s += padR(lang, 2);
      // 3-char dialect (optional)
      s += padR(p.performanceDialect || '', 3);
    }
    return fitTo(s, REC_LEN.PER[ctx.version]);
  }

  function REC(ctx, r, txnSeq, recSeq) {
    let s = '';
    s += padR('REC', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padDate(r.releaseDate);
    s += padR('  ', 60); // constant blanks (legacy)
    s += padNum(r.releaseDuration || 0, 6);
    s += padR('   ', 5);
    s += padR(r.albumTitle || '', 60);
    s += padR(r.albumLabel || '', 60);
    s += padR(r.releaseCatalogNum || '', 18);
    s += padR(r.eanUpc || '', 13);
    s += padR(r.isrc || '', 12);
    s += padR(r.recFormat || ' ', 1);
    s += padR(r.recordingTechnique || ' ', 1);
    s += padR(r.mediaType || '   ', 3);
    if (ctx.version === '2.2' || ctx.version === '3.0' || ctx.version === '3.1') {
      s += padR(r.recordingTitle || '', 60);
      s += padR(r.versionTitle || '', 60);
      s += padR(r.displayArtist || '', 60);
      s += padR(r.recordLabel || '', 60);
      s += padR(r.isrcValidity || ' ', 1);
      s += padR(r.submitterRecordingId || '', 14);
    }
    return fitTo(s, REC_LEN.REC[ctx.version]);
  }

  // ORN — Origin (cue sheet, library, AV)
  // intendedPurpose validated against CISAC code list:
  //   COM=Commercial  FIL=Film  LIB=Library  MUL=Multimedia  RAD=Radio
  //   TEL=Television  THR=Theater  VID=Video
  // CWR 2.1: production title + CD info, no library/episode
  // CWR 2.2+: adds library, episode title/num, V-ISAN composite, AV society code
  const ORN_VALID_PURPOSES = ['COM','FIL','LIB','MUL','RAD','TEL','THR','VID'];

  function ORN(ctx, o, txnSeq, recSeq) {
    let s = '';
    s += padR('ORN', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    // Intended Purpose — validate against code list, fall back to COM
    const purposeRaw = (o.intendedPurpose || '').toUpperCase();
    const purpose = ORN_VALID_PURPOSES.includes(purposeRaw) ? purposeRaw : 'COM';
    s += purpose;
    s += padR(o.productionTitle || '', 60);
    s += padR(o.cdIdentifier || '', 15);
    s += padR(o.cutNumber || '', 4);
    if (ctx.version === '2.2' || ctx.version === '3.0' || ctx.version === '3.1') {
      s += padR(o.library || '', 60);
    }
    // BLTVR — Background/Logo/Theme/Visual/Rolled-up — single char
    const bltvrRaw = (o.bltvr || ' ').toUpperCase();
    const bltvr = ['B','L','T','V','R',' '].includes(bltvrRaw) ? bltvrRaw : ' ';
    s += bltvr;
    // V-ISAN composite 25 chars
    s += padR(o.visan || '', 25);
    s += padR(o.productionNum || '', 12);
    if (ctx.version === '2.2' || ctx.version === '3.0' || ctx.version === '3.1') {
      s += padR(o.episodeTitle || '', 60);
      s += padR(o.episodeNum || '', 20);
    }
    // Production Year — must be 4 numeric or blank
    const pyRaw = String(o.productionYear || '').replace(/\D/g, '');
    s += pyRaw.length === 4 ? pyRaw : '    ';
    s += padR(o.aviSocietyCode || '   ', 3);
    s += padR(o.audioVisualNumber || '', 15);
    return fitTo(s, REC_LEN.ORN[ctx.version]);
  }

  function INS(ctx, i, txnSeq, recSeq) {
    let s = '';
    s += padR('INS', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(i.numberOfVoices || '   ', 3);
    s += padR(i.standardInstr || '   ', 3);
    s += padR(i.instrumentationDesc || '', 50);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.INS[ctx.version]);
  }

  function IND(ctx, i, txnSeq, recSeq) {
    let s = '';
    s += padR('IND', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(i.instrumentCode || '   ', 3);
    s += padNum(i.numberOfPlayers || 1, 3);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.IND[ctx.version]);
  }

  function COM(ctx, c, txnSeq, recSeq) {
    let s = '';
    s += padR('COM', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(c.title || '', 60);
    s += padISWC(c.iswc, 11);
    s += padR(c.submitterWorkNum || '', 14);
    s += padNum(c.duration || 0, 6);
    s += padR(c.writer1Last || '', 45);
    s += padR(c.writer1First || '', 30);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.COM[ctx.version]);
  }

  function MSG(ctx, m, txnSeq, recSeq) {
    let s = '';
    s += padR('MSG', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(m.messageType || 'F', 1);
    s += padR(m.recordType || '   ', 3);
    s += padR(m.messageLevel || 'F', 1);
    s += padR(m.validation || '   ', 3);
    s += padR(m.messageText || '', 150);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.MSG[ctx.version]);
  }

  // Non-Roman alphabet (NRA) records — title / writer / publisher etc.
  // ─── AGREEMENT TRANSACTIONS ───────────────────────────────────────────────
  // CWR also carries direct agreements via AGR/TER/IPA/NPA. They use HDR/GRH/
  // GRT/TRL framing but a different transaction body than NWR.

  function AGR(ctx, a, txnSeq, recSeq) {
    let s = '';
    s += padR('AGR', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(a.agreementNum || '', 14);
    s += padR(a.intStandardCode || '', 14);
    s += padR(a.agreementType || 'OS', 2); // OS / OG / PS / PG
    s += padDate(a.agreementStartDate);
    s += padDate(a.agreementEndDate);
    s += padDate(a.retentionEndDate);
    s += padR(a.priorRoyaltyStatus || 'N', 1); // N / D / A
    s += padDate(a.priorRoyaltyStartDate);
    s += padR(a.postTermCollectionStatus || 'N', 1); // N / D / O
    s += padDate(a.postTermCollectionEndDate);
    s += padDate(a.dateOfSignatureOfAgreement);
    s += padNum(a.numberOfWorks || 0, 5);
    s += padR(a.salesManufactureClause || ' ', 1);
    s += padR(a.sharesChange || 'N', 1);
    s += padR(a.advanceGiven || 'N', 1);
    s += padR(a.societyAssignedAgreementNumber || '', 14);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.AGR[ctx.version]);
  }

  function TER(ctx, t, txnSeq, recSeq) {
    let s = '';
    s += padR('TER', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(t.inclusionFlag || 'I', 1); // I / E
    s += padNum(t.tisNum || 2136, 4);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.TER[ctx.version]);
  }

  function IPA(ctx, p, txnSeq, recSeq) {
    let s = '';
    s += padR('IPA', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(p.agreementRoleCode || 'AS', 2); // AS = assignor, AC = acquirer
    s += padIPI(p.ipiName, 11);
    s += padR(p.intStandardCode || '', 13);
    s += padR(p.ipNum || '', 9);
    s += padR(p.lastName || p.name || '', 45);
    s += padR(p.firstName || '', 30);
    s += padR(p.prSocCode || '   ', 3);
    s += padNum(Math.round((p.prShare || 0) * 100), 5);
    s += padR(p.mrSocCode || '   ', 3);
    s += padNum(Math.round((p.mrShare || 0) * 100), 5);
    s += padR(p.srSocCode || '   ', 3);
    s += padNum(Math.round((p.srShare || 0) * 100), 5);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.IPA[ctx.version]);
  }

  // NPA — Non-Roman Alphabet Agreement Party Name. v3.1 tightening: ipName must
  // use script-coded form (Unicode block hint via lang); empty firstName only
  // for sole-trader publishers (role MUST be 'E' or 'PA' in linked IPA).
  function NPA(ctx, n, txnSeq, recSeq) {
    let s = '';
    s += padR('NPA', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(n.ipNum || '', 9);
    s += padR(n.ipName || '', 160);
    s += padR(n.ipFirstName || '', 160);
    s += padR(n.lang || 'EN', 2);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.NPA[ctx.version]);
  }

  function NRN(ctx, n, txnSeq, recSeq) {
    if (ctx.version !== '3.0' && ctx.version !== '3.1') return null;
    let s = '';
    s += padR('NRN', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(n.recordingTitle || '', 60);
    s += padR(n.versionTitle || '', 60);
    s += padR(n.displayArtist || '', 60);
    s += padR(n.recordLabel || '', 60);
    s += padR(n.isrc || '', 12);
    s += padR(n.lang || 'EN', 2);
    s += '  ';
    return fitTo(s, REC_LEN.NRN[ctx.version]);
  }

  // Build a complete AGR transaction (AGR + TER list + IPA list + optional NPA list)
  function buildAgreementTransaction(ctx, agr, txnSeq) {
    const lines = [];
    let recSeq = 0;
    lines.push(AGR(ctx, agr, txnSeq, recSeq++));
    (agr.territories || [{ inclusionFlag: 'I', tisNum: 2136 }]).forEach((t) => {
      lines.push(TER(ctx, t, txnSeq, recSeq++));
    });
    (agr.parties || []).forEach((p) => {
      lines.push(IPA(ctx, p, txnSeq, recSeq++));
      if (p.nonRomanName) lines.push(NPA(ctx, p.nonRomanName, txnSeq, recSeq++));
    });
    return lines;
  }

  // ─── NRA records (non-Roman alphabet) ────────────────────────────────────
  // NRA records carry the original UTF-8/non-Roman content — they MUST NOT
  // be ASCII-folded. We use padRRaw (no fold) here.
  function NRA(kind, ctx, n, txnSeq, recSeq) {
    let s = '';
    s += padR(kind, 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    // Use raw-pad for the unicode payload
    const text = n.text || n.title || n.name || ((n.name || '') + (n.firstName ? ' ' + n.firstName : ''));
    const rawPad = (str, len) => {
      str = String(str || '');
      return str.length >= len ? str.slice(0, len) : str + ' '.repeat(len - str.length);
    };
    s += rawPad(text, 380);
    s += padR(n.lang || n.language || 'EN', 2);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return s.length >= REC_LEN[kind][ctx.version] ? s.slice(0, REC_LEN[kind][ctx.version]) : s + ' '.repeat(REC_LEN[kind][ctx.version] - s.length);
  }

  function ARI(ctx, a, txnSeq, recSeq) {
    let s = '';
    s += padR('ARI', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padR(a.societyNum || '   ', 3);
    s += padR(a.workNum || '', 14);
    s += padR(a.typeOfRight || 'PER', 3);
    s += padR(a.subjectCode || 'GEN', 3);
    s += padR(a.note || '', 80);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.ARI[ctx.version]);
  }

  // XRF — Cross Reference (v3.0+ only)
  // 3+8+8+3+14+1+1 = 38
  // organisationCode: 3-numeric CISAC society code (e.g. "010" = ASCAP, "021" = BMI)
  //                   OR special code "099" for global standards (ISWC/ISRC)
  // identifierType: 1 char from {W, R, P, V, T, X, S, A, I, M, U}
  //   W=Work code, R=Recording (ISRC), P=Product (UPC/EAN), V=Video (V-ISAN),
  //   T=Title, X=Other-society-work-code, S=Series, A=Album,
  //   I=ISWC, M=MWN (Musical Work Number), U=Unknown
  // validityIndicator: Y=valid, N=invalid, U=unknown
  const XRF_VALID_TYPES = ['W','R','P','V','T','X','S','A','I','M','U'];
  const XRF_VALID_VALIDITY = ['Y','N','U'];

  function XRF(ctx, x, txnSeq, recSeq) {
    if (ctx.version === '2.1' || ctx.version === '2.1r7' || ctx.version === '2.2') return null;
    let s = '';
    s += padR('XRF', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    // Organisation code: must be 3-numeric CISAC code, or "099" for ISO standards
    let orgCode = String(x.organisationCode || '099').replace(/\D/g, '');
    orgCode = orgCode.padStart(3, '0').slice(0, 3);
    s += orgCode;
    s += padR(x.identifier || '', 14);
    // Validate identifier type — fall back to 'W' if invalid
    const idType = XRF_VALID_TYPES.includes(x.identifierType) ? x.identifierType : 'W';
    s += idType;
    // Validate validity indicator
    const val = XRF_VALID_VALIDITY.includes(x.validityIndicator) ? x.validityIndicator : 'Y';
    s += val;
    return fitTo(s, REC_LEN.XRF[ctx.version]);
  }

  function ACK(ctx, a, txnSeq, recSeq) {
    let s = '';
    s += padR('ACK', 3);
    s += padNum(txnSeq, 8);
    s += padNum(recSeq, 8);
    s += padDate(a.creationDate);
    s += padTime(a.creationTime);
    s += padR(a.originalGroupId || '', 5);
    s += padR(a.originalTransactionSeq || '', 8);
    s += padR(a.originalTransactionType || 'NWR', 3);
    s += padR(a.creationTitle || '', 60);
    s += padR(a.submitterCreationNum || '', 20);
    s += padR(a.recipientCreationNum || '', 20);
    s += padDate(a.processingDate);
    s += padR(a.transactionStatus || 'AS', 2);
    if (ctx.version === '3.0' || ctx.version === '3.1') s += '  ';
    return fitTo(s, REC_LEN.ACK[ctx.version]);
  }

  // ─── Build a single work transaction ──────────────────────────────────────
  function buildWorkTransaction(ctx, work, txnSeq) {
    const lines = [];
    let recSeq = 0;
    const push = (line) => { if (line) lines.push(line); };

    // Header
    push(NWRish(ctx.transactionType, ctx, work, txnSeq, recSeq++));

    // SPU/SPT for each controlled publisher chain (plus OPU/OPT for non-controlled)
    (work.publishers || []).forEach((p, i) => {
      if (p.controlled) {
        push(SPU(ctx, p, txnSeq, recSeq++, i + 1));
        (p.territories || [{ tisNum: 2136, prShare: p.prShare, mrShare: p.mrShare }]).forEach((t, ti) => {
          push(SPT(ctx, { ...t, ipNum: p.id }, txnSeq, recSeq++, ti + 1));
        });
      } else {
        push(OPU(ctx, p, txnSeq, recSeq++, i + 1));
        (p.territories || [{ tisNum: 2136 }]).forEach((t, ti) => {
          push(OPT(ctx, { ...t, ipNum: p.id }, txnSeq, recSeq++, ti + 1));
        });
      }
    });

    // SWR/SWT (controlled writers) and OWR/OWT (other writers)
    (work.writers || []).forEach((w) => {
      if (w.controlled) {
        push(SWR(ctx, w, txnSeq, recSeq++));
        (w.territories || [{ tisNum: 2136, prShare: w.prShare, mrShare: w.mrShare }]).forEach((t, ti) => {
          push(SWT(ctx, { ...t, ipNum: w.id, sequence: ti + 1 }, txnSeq, recSeq++));
        });
        // PWR linking writer to controlling publisher
        (w.pwrLinks || []).forEach((link) => {
          push(PWR(ctx, link, txnSeq, recSeq++));
        });
      } else {
        push(OWR(ctx, w, txnSeq, recSeq++));
      }
    });

    // ALT — alternate titles
    (work.alternateTitles || []).forEach((alt) => push(ALT(ctx, alt, txnSeq, recSeq++)));

    // Non-Roman titles (NAT) only fire if present
    (work.nraTitles || []).forEach((n) => push(NRA('NET', ctx, n, txnSeq, recSeq++)));

    // ─── Auto-emit NCT/NWN for non-Roman characters in title and writer names ──
    // CWR 2.x ASCII-only requires non-Roman content travel as NCT/NWN records.
    // Detect any non-ASCII char and emit the corresponding non-Roman record.
    const hasNonAscii = (s) => s && /[^\x00-\x7F]/.test(s);
    if (hasNonAscii(work.title)) {
      push(NRA('NCT', ctx, { language: work.lang || 'EN', title: work.title }, txnSeq, recSeq++));
    }
    (work.writers || []).forEach((w) => {
      const hasNR = hasNonAscii(w.lastName) || hasNonAscii(w.firstName);
      if (hasNR) {
        push(NRA('NWN', ctx, {
          language: w.language || 'EN',
          interestedPartyNumber: w.id,
          name: w.lastName || '',
          firstName: w.firstName || '',
        }, txnSeq, recSeq++));
      }
    });
    (work.publishers || []).forEach((p) => {
      if (hasNonAscii(p.name)) {
        push(NRA('NPN', ctx, {
          language: p.language || 'EN',
          publisherSequenceNumber: p.sequenceN || 1,
          interestedPartyNumber: p.id,
          name: p.name,
        }, txnSeq, recSeq++));
      }
    });

    // EWT — only if work is an excerpt of something
    if (work.entireWork) push(EWT(ctx, work.entireWork, txnSeq, recSeq++));

    // VER — only if version
    if (work.originalWork) push(VER(ctx, work.originalWork, txnSeq, recSeq++));

    // PER — performers
    (work.performers || []).forEach((p) => push(PER(ctx, p, txnSeq, recSeq++)));

    // REC — recordings
    (work.recordings || []).forEach((r) => push(REC(ctx, r, txnSeq, recSeq++)));

    // ORN — origin (cue, library, AV)
    (work.origins || []).forEach((o) => push(ORN(ctx, o, txnSeq, recSeq++)));

    // INS — instrumentation summary, IND — instrumentation detail
    (work.instrumentations || []).forEach((i) => push(INS(ctx, i, txnSeq, recSeq++)));
    (work.instrumentDetails || []).forEach((i) => push(IND(ctx, i, txnSeq, recSeq++)));

    // COM — components (for composite works)
    (work.components || []).forEach((c) => push(COM(ctx, c, txnSeq, recSeq++)));

    // ARI — additional related info (society-only fields)
    (work.relatedInfo || []).forEach((a) => push(ARI(ctx, a, txnSeq, recSeq++)));

    // XRF — cross-reference identifiers (v3.0+)
    if (ctx.version === '3.0' || ctx.version === '3.1') {
      (work.xrefs || []).forEach((x) => push(XRF(ctx, x, txnSeq, recSeq++)));
    }

    // MSG — only present in ACK transmissions, but include if user opts in
    (work.messages || []).forEach((m) => push(MSG(ctx, m, txnSeq, recSeq++)));

    return lines;
  }

  // ─── Build the whole transmission ─────────────────────────────────────────
  function buildTransmission(opts) {
    const ctx = {
      version: opts.version || '2.1',
      senderType: opts.senderType || 'PB',
      senderIpi: opts.senderIpi || 199900001,
      senderName: opts.senderName || 'PLURALIS MUSIC',
      transactionType: opts.transactionType || 'NWR',
      created: opts.created || new Date(),
      batchRequest: opts.batchRequest || 1,
    };
    const works = opts.works || [];
    const agreements = opts.agreements || [];
    const isAgrBatch = ctx.transactionType === 'AGR';
    const lines = [];
    lines.push(HDR(ctx));

    // One group per transaction type — typical for catalog batches
    const groupId = 1;
    lines.push(GRH(ctx, ctx.transactionType, groupId));
    let txnRecCount = 0;
    let txnCount = 0;
    if (isAgrBatch) {
      agreements.forEach((a, i) => {
        const txnLines = buildAgreementTransaction(ctx, a, i);
        txnCount++;
        txnRecCount += txnLines.length;
        txnLines.forEach((l) => lines.push(l));
      });
    } else {
      works.forEach((w, i) => {
        const txnLines = buildWorkTransaction(ctx, w, i);
        txnCount++;
        txnRecCount += txnLines.length;
        txnLines.forEach((l) => lines.push(l));
      });
    }
    // GRT counts the GRH + all txn lines + GRT itself? CISAC says: record count
    // includes GRH + txns + GRT. We count GRH + txns + GRT.
    const grtRecordCount = txnRecCount + 2;
    lines.push(GRT(groupId, txnCount, grtRecordCount));

    // TRL = group count, total transactions, total records (HDR + GRH + txns + GRT + TRL)
    const totalRecords = lines.length + 1;
    lines.push(TRL(1, txnCount, totalRecords));
    return lines;
  }

  // ─── Filename per CISAC convention ────────────────────────────────────────
  // CW{YY}{nnnn}{sender}_{recipient}.V{XX}
  function buildFilename(opts) {
    const d = opts.created || new Date();
    const yy = String(d.getUTCFullYear()).slice(-2);
    const seq = String(opts.sequence || 1).padStart(4, '0');
    const sender = (opts.senderCode || 'PLU').toUpperCase().slice(0, 3);
    const recipient = (opts.recipientCode || 'ASCAP').toUpperCase().slice(0, 5);
    let v = '21';
    if (opts.version === '2.1r7') v = '21';
    else if (opts.version === '2.2') v = '22';
    else if (opts.version === '3.0') v = '30';
    else if (opts.version === '3.1') v = '31';
    return `CW${yy}${seq}${sender}_${recipient}.V${v}`;
  }

  // ─── Public API ───────────────────────────────────────────────────────────
  window.CwrBuild = {
    buildTransmission,
    buildFilename,
    buildWorkTransaction,
    buildAgreementTransaction,
    REC_LEN,
    HDR, GRH, GRT, TRL,
    NWRish, SPU, SPT, OPU, OPT, SWR, SWT, OWR, OWT, PWR,
    ALT, EWT, VER, PER, REC, ORN, INS, IND, COM, MSG, ARI, XRF, ACK, NRA,
    AGR, TER, IPA, NPA, NRN,
    fitTo, padR, padL, padNum,
  };
})();
