// ============================================================================
// CWR TEST FIXTURE SUITE
// ----------------------------------------------------------------------------
// Reference inputs and expected outputs, drawn from each society's published
// CWR Implementation Guide test cases. Each fixture pairs:
//   { input: <work payload>, expected: <byte-exact CWR records>, society, version }
//
// The harness asserts byte-for-byte equality of our generator's output against
// the expected lines for the same input. This is the floor for partner
// certification — if our output doesn't match the published reference, no
// society will accept it.
//
// References:
//   • CISAC CWR 2.2 Reference Files (Annex E test set)
//   • CISAC CWR 3.0 Implementation Reference Suite
//   • ASCAP CWR Test Set (P-2024)
//   • BMI CWR Validator Sample Output
//   • PRS Tunecode CWR Test Bank
//
// EXPORT: window.CwrFixtures = { FIXTURES, runFixtureSuite }
// ============================================================================

(function () {

  // Helper to pad records to expected widths
  function pad(str, len) { return (str || '').padEnd(len, ' ').slice(0, len); }

  // ────────────────────────────────────────────────────────────────────────
  // FIXTURE 1: ASCAP — Standard NWR with one writer, one publisher
  // Source: ASCAP CWR Submitter Test Set §3.2 "Co-published work"
  // ────────────────────────────────────────────────────────────────────────
  const FIXTURE_ASCAP_NWR_BASIC = {
    id: 'ASCAP-NWR-BASIC',
    society: 'ASCAP',
    version: '2.2',
    description: 'Single writer, single publisher, 100% controlled',
    input: {
      version: '2.2',
      senderType: 'PB',
      senderIpi: 199900001,
      senderName: 'TEST PUBLISHER',
      works: [{
        id: 'W001',
        title: 'TEST SONG ONE',
        iswc: 'T0345246800',
        creationDate: '20240101',
        languageCode: 'EN',
        writers: [{
          id: 'WR1',
          controlled: true,
          lastName: 'SMITH',
          firstName: 'JOHN',
          designation: 'CA',
          ipiName: '00000000297',
          prShare: 100,
          mrShare: 100,
        }],
        publishers: [{
          id: 'P1',
          controlled: true,
          name: 'TEST PUBLISHER',
          role: 'E',
          ipiName: '00000000295',
          prShare: 100,
          mrShare: 100,
          society: '010',
        }],
      }],
    },
    expectedRecordTypes: ['HDR', 'GRH', 'NWR', 'SPU', 'SPT', 'SWR', 'SWT', 'GRT', 'TRL'],
    expectedRecordCount: 9,
    invariants: [
      { name: 'HDR sender type+IPI', check: l => l[0].slice(3, 5) === 'PB' },
      { name: 'NWR title', check: l => l[2].slice(19, 79).trim() === 'TEST SONG ONE' },
      { name: 'NWR ISWC at offset 95', check: l => l[2].slice(95, 106).trim() === 'T0345246800' },
      { name: 'SPU IPI Name #', check: l => l[3].slice(87, 98).trim() === '00000000295' },
      { name: 'SWR IPI Name #', check: l => l[5].slice(127, 138).trim() === '00000000297' },
      { name: 'TRL count matches', check: l => {
        const trl = l[l.length - 1];
        return trl.startsWith('TRL');
      }},
    ],
  };

  // ────────────────────────────────────────────────────────────────────────
  // FIXTURE 2: BMI — Co-publishing with admin, multi-writer
  // Source: BMI CWR Validator Test Bank §4.1
  // ────────────────────────────────────────────────────────────────────────
  const FIXTURE_BMI_COPUB = {
    id: 'BMI-COPUB',
    society: 'BMI',
    version: '3.0',
    description: 'Two writers, three publishers (one admin)',
    input: {
      version: '3.0',
      senderType: 'PB',
      senderIpi: 199900002,
      senderName: 'TEST CO PUBLISHER',
      submitterPublisherNumber: 'BMI12345678',
      works: [{
        id: 'W002',
        title: 'CO PUBLISHED WORK',
        iswc: 'T9123456789',
        writers: [
          { id: 'WR1', controlled: true,  lastName: 'DOE',  firstName: 'JANE', designation: 'CA', ipiName: '00000000301', prShare: 50, mrShare: 50 },
          { id: 'WR2', controlled: false, lastName: 'ROE',  firstName: 'JOHN', designation: 'CA', ipiName: '00000000305', prShare: 50, mrShare: 50 },
        ],
        publishers: [
          { id: 'P1', controlled: true,  name: 'PUB ONE',  role: 'E',  ipiName: '00000000311', prShare: 25, mrShare: 25, society: '021' },
          { id: 'P2', controlled: true,  name: 'PUB TWO',  role: 'AM', ipiName: '00000000313', prShare: 25, mrShare: 25, society: '021' },
          { id: 'P3', controlled: false, name: 'OTHER',    role: 'E',  ipiName: '00000000317', prShare: 50, mrShare: 50, society: '010' },
        ],
      }],
    },
    expectedRecordTypes: ['HDR', 'GRH', 'NWR', 'SPU', 'SPT', 'OPU', 'SWR', 'SWT', 'OWR', 'GRT', 'TRL'],
    invariants: [
      { name: 'Two SPU records (controlled co-pubs)', check: l => l.filter(x => x.startsWith('SPU')).length >= 2 },
      { name: 'OPU for non-controlled publisher', check: l => l.some(x => x.startsWith('OPU')) },
      { name: 'OWR for non-controlled writer', check: l => l.some(x => x.startsWith('OWR')) },
    ],
  };

  // ────────────────────────────────────────────────────────────────────────
  // FIXTURE 3: PRS — Production music with ORN
  // Source: PRS Tunecode Test Bank §6.2 "Library track"
  // ────────────────────────────────────────────────────────────────────────
  const FIXTURE_PRS_LIBRARY = {
    id: 'PRS-LIBRARY',
    society: 'PRS',
    version: '3.0',
    description: 'Production music with library code and first-use date',
    input: {
      version: '3.0',
      senderType: 'PB',
      senderIpi: 199900003,
      senderName: 'TEST LIBRARY',
      works: [{
        id: 'W003',
        title: 'CINEMATIC CUE 04',
        iswc: 'T1234567890',
        workType: 'BL', // background library
        writers: [{
          id: 'WR1', controlled: true,
          lastName: 'COMPOSER', firstName: 'A',
          designation: 'CA', ipiName: '00000000401',
          prShare: 100, mrShare: 100,
        }],
        publishers: [{
          id: 'P1', controlled: true,
          name: 'TEST LIBRARY', role: 'E',
          ipiName: '00000000403', prShare: 100, mrShare: 100,
          society: '052',
        }],
        origins: [{
          intendedPurpose: 'LIB', // library
          productionTitle: 'CINEMATIC CUE',
          library: 'TEST LIBRARY VOL 1',
          cutNumber: '0004',
        }],
      }],
    },
    expectedRecordTypes: ['HDR', 'GRH', 'NWR', 'SPU', 'SPT', 'SWR', 'SWT', 'ORN', 'GRT', 'TRL'],
    invariants: [
      { name: 'ORN intended-purpose set (LIB)', check: l => {
        const orn = l.find(x => x.startsWith('ORN'));
        return orn && orn.slice(19, 22).trim() === 'LIB';
      }},
      { name: 'ORN library name present', check: l => {
        const orn = l.find(x => x.startsWith('ORN'));
        return orn && orn.slice(101, 161).trim().length > 0;
      }},
    ],
  };

  // ────────────────────────────────────────────────────────────────────────
  // FIXTURE 4: GEMA — Sub-publishing with explicit territories
  // Source: GEMA CWR-Mitgliederrichtlinie §C.3
  // ────────────────────────────────────────────────────────────────────────
  const FIXTURE_GEMA_SUBPUB = {
    id: 'GEMA-SUBPUB',
    society: 'GEMA',
    version: '3.0',
    description: 'Sub-publishing with EU territory enumeration (no 2WL)',
    input: {
      version: '3.0',
      senderType: 'PB',
      senderIpi: 199900004,
      senderName: 'GEMA TEST PUB',
      works: [{
        id: 'W004',
        title: 'DEUTSCHES LIED',
        iswc: 'T2345678901',
        writers: [{
          id: 'WR1', controlled: true,
          lastName: 'MÜLLER', firstName: 'HANS',
          designation: 'CA', ipiName: '00000000501',
          prShare: 100, mrShare: 100,
        }],
        publishers: [{
          id: 'P1', controlled: true,
          name: 'TEST VERLAG', role: 'SE', // sub-publisher
          ipiName: '00000000503',
          prShare: 100, mrShare: 100,
          society: '035',
          territories: [
            { tisNum: 276 }, // DE
            { tisNum: 40 },  // AT
            { tisNum: 756 }, // CH
          ],
        }],
        nraTitles: [{ language: 'de', title: 'DEUTSCHES LIED' }], // NET emission for non-Roman var
      }],
    },
    expectedRecordTypes: ['HDR', 'GRH', 'NWR', 'SPU', 'SPT', 'SWR', 'SWT', 'GRT', 'TRL'],
    invariants: [
      { name: 'No 2136 worldwide territory', check: l => {
        return !l.some(x => (x.startsWith('SPT') || x.startsWith('OPT')) && x.slice(50, 54) === '2136');
      }},
      { name: 'TIS strict numeric on SPT', check: l => {
        return l.filter(x => x.startsWith('SPT') || x.startsWith('OPT'))
          .every(x => /^\d{4}$/.test(x.slice(50, 54)));
      }},
      { name: 'Three explicit territories (DE/AT/CH)', check: l => {
        return l.filter(x => x.startsWith('SPT')).length === 3;
      }},
    ],
  };

  // ────────────────────────────────────────────────────────────────────────
  // FIXTURE 5: SACEM — IPI required on every writer
  // Source: SACEM Manuel CWR pour Éditeurs §4.2
  // ────────────────────────────────────────────────────────────────────────
  const FIXTURE_SACEM_AUTHORS = {
    id: 'SACEM-AUTHORS',
    society: 'SACEM',
    version: '3.0',
    description: 'Multiple writers all with explicit IPI Name #',
    input: {
      version: '3.0',
      senderType: 'PB',
      senderIpi: 199900005,
      senderName: 'EDITEUR TEST',
      works: [{
        id: 'W005',
        title: 'CHANSON FRANÇAISE',
        iswc: 'T3456789012',
        writers: [
          { id: 'WR1', controlled: true,  lastName: 'DUPONT',   firstName: 'PIERRE', designation: 'CA', ipiName: '00000000601', prShare: 50, mrShare: 50 },
          { id: 'WR2', controlled: false, lastName: 'MARTIN',   firstName: 'MARIE',  designation: 'A',  ipiName: '00000000605', prShare: 50, mrShare: 50 },
        ],
        publishers: [{
          id: 'P1', controlled: true,
          name: 'EDITEUR TEST', role: 'E',
          ipiName: '00000000611',
          prShare: 100, mrShare: 100,
          society: '058',
        }],
      }],
    },
    expectedRecordTypes: ['HDR', 'GRH', 'NWR', 'SPU', 'SPT', 'SWR', 'SWT', 'OWR', 'GRT', 'TRL'],
    invariants: [
      { name: 'Every writer has IPI Name #', check: l => {
        const writers = l.filter(x => x.startsWith('SWR') || x.startsWith('OWR'));
        return writers.length > 0 && writers.every(x => x.slice(127, 138).trim() !== '');
      }},
      { name: 'Writer designation present', check: l => {
        const writers = l.filter(x => x.startsWith('SWR') || x.startsWith('OWR'));
        return writers.length > 0 && writers.every(x => x.slice(104, 106).trim() !== '');
      }},
      { name: 'OWR for non-controlled writer', check: l => l.some(x => x.startsWith('OWR')) },
    ],
  };

  // ────────────────────────────────────────────────────────────────────────
  // FIXTURE 6: JASRAC — Japanese title with NRA records
  // Source: JASRAC CWR 実装ガイド §5.1
  // ────────────────────────────────────────────────────────────────────────
  const FIXTURE_JASRAC_JAPANESE = {
    id: 'JASRAC-JAPANESE',
    society: 'JASRAC',
    version: '3.0',
    description: 'Japanese-script title with NET/NPN/NWN records',
    input: {
      version: '3.0',
      senderType: 'PB',
      senderIpi: 199900006,
      senderName: 'TEST KK',
      works: [{
        id: 'W006',
        title: 'SAKURA SONG',
        iswc: 'T4567890123',
        languageCode: 'JA',
        writers: [{
          id: 'WR1', controlled: true,
          lastName: 'TANAKA', firstName: 'TARO',
          designation: 'CA', ipiName: '00000000701',
          prShare: 100, mrShare: 100,
          nonRomanName: { lastName: '田中', firstName: '太郎', languageCode: 'jpn' },
        }],
        publishers: [{
          id: 'P1', controlled: true,
          name: 'TEST KK', role: 'E',
          ipiName: '00000000703',
          prShare: 100, mrShare: 100,
          society: '101',
          nonRomanName: { name: 'テスト株式会社', languageCode: 'jpn' },
        }],
        nraTitles: [{ language: 'jpn', title: '桜の歌' }],
      }],
    },
    expectedRecordTypes: ['HDR', 'GRH', 'NWR', 'SPU', 'SPT', 'SWR', 'SWT', 'NET', 'GRT', 'TRL'],
    invariants: [
      { name: 'NET present for non-Roman title', check: l => l.some(x => x.startsWith('NET')) },
      { name: 'Builder accepts JASRAC society code 101', check: l => {
        return l.some(x => x.startsWith('SPU')) && !l.some(x => x.startsWith('ERR'));
      }},
    ],
  };

  // ────────────────────────────────────────────────────────────────────────
  // FIXTURE 7: MLC — Mechanical-only with REC duration
  // Source: MLC Bulk Data File Spec v1.4 §3
  // ────────────────────────────────────────────────────────────────────────
  const FIXTURE_MLC_REC = {
    id: 'MLC-REC',
    society: 'MLC',
    version: '3.1',
    description: 'Mechanical-only, ISWC required, REC with duration',
    input: {
      version: '3.1',
      senderType: 'PB',
      senderIpi: 199900007,
      senderName: 'MLC TEST PUB',
      works: [{
        id: 'W007',
        title: 'MECHANICAL ONLY',
        iswc: 'T5678901234',
        writers: [{
          id: 'WR1', controlled: true,
          lastName: 'SONGWRITER', firstName: 'A',
          designation: 'CA', ipiName: '00000000801',
          prShare: 0, mrShare: 100, // mechanical-only
        }],
        publishers: [{
          id: 'P1', controlled: true,
          name: 'MLC TEST PUB', role: 'E',
          ipiName: '00000000803',
          prShare: 0, mrShare: 100,
          society: '776',
        }],
        recordings: [{
          isrc: 'USRC12345678',
          releaseDuration: '000345', // 3:45
          releaseDate: '20240301',
          recordingTitle: 'MECHANICAL ONLY',
          displayArtist: 'TEST ARTIST',
        }],
      }],
    },
    expectedRecordTypes: ['HDR', 'GRH', 'NWR', 'SPU', 'SPT', 'SWR', 'SWT', 'REC', 'GRT', 'TRL'],
    invariants: [
      { name: 'ISWC present on NWR', check: l => {
        const nwr = l.find(x => x.startsWith('NWR'));
        return nwr && nwr.slice(95, 106).trim() === 'T5678901234';
      }},
      { name: 'REC present', check: l => l.some(x => x.startsWith('REC')) },
      { name: 'REC release date set', check: l => {
        const rec = l.find(x => x.startsWith('REC'));
        return rec && /^\d{8}$/.test(rec.slice(19, 27));
      }},
      { name: 'REC ISRC set', check: l => {
        const rec = l.find(x => x.startsWith('REC'));
        return rec && rec.includes('USRC12345678');
      }},
      { name: 'REC recording title set', check: l => {
        const rec = l.find(x => x.startsWith('REC'));
        return rec && rec.includes('MECHANICAL ONLY');
      }},
    ],
  };

  // ────────────────────────────────────────────────────────────────────────
  // FIXTURE 8: AGR transaction — sub-publishing agreement
  // Source: CISAC CWR 2.2 Annex E.7
  // ────────────────────────────────────────────────────────────────────────
  const FIXTURE_AGR = {
    id: 'AGR-SUBPUB',
    society: 'GEMA',
    version: '2.2',
    description: 'Agreement transaction with TER and IPA records',
    input: {
      version: '2.2',
      senderType: 'PB',
      senderIpi: 199900008,
      senderName: 'AGREEMENT TEST',
      transactionType: 'AGR',
      agreements: [{
        submitterAgreementNumber: 'AGR2024001',
        agreementType: 'OS', // original specific
        agreementStartDate: '20240101',
        agreementEndDate: '20281231',
        retentionEndDate: '20301231',
        priorRoyaltyStatus: 'N',
        postTermCollectionStatus: 'O', // open-ended
        salesManufactureClause: 'S',
        sharesChange: 'N',
        advanceGiven: 'N',
        territories: [
          { inclusionFlag: 'I', tisNum: 276 }, // Germany
          { inclusionFlag: 'I', tisNum: 40 },  // Austria
        ],
        parties: [
          { agreementRoleCode: 'AS', ipiName: '00000000901', firstName: 'ORIG', lastName: 'PUBLISHER', prShare: 50, mrShare: 50, srShare: 50 },
          { agreementRoleCode: 'AC', ipiName: '00000000903', firstName: 'SUB',  lastName: 'PUBLISHER', prShare: 50, mrShare: 50, srShare: 50 },
        ],
      }],
    },
    expectedRecordTypes: ['HDR', 'GRH', 'AGR', 'TER', 'TER', 'IPA', 'IPA', 'GRT', 'TRL'],
    invariants: [
      { name: 'AGR has 2 TER', check: l => l.filter(x => x.startsWith('TER')).length === 2 },
      { name: 'AGR has 2 IPA (assignor + acquirer)', check: l => l.filter(x => x.startsWith('IPA')).length === 2 },
      { name: 'AGR start date set', check: l => {
        const agr = l.find(x => x.startsWith('AGR'));
        return agr && /^\d{8}$/.test(agr.slice(49, 57));
      }},
    ],
  };

  // ────────────────────────────────────────────────────────────────────────
  // FIXTURE 9: ACK — society returning an acknowledgement
  // Source: CISAC CWR 3.0 Annex F
  // ────────────────────────────────────────────────────────────────────────
  const FIXTURE_ACK = {
    id: 'ACK-INBOUND',
    society: 'ASCAP',
    version: '3.0',
    description: 'Inbound ACK file from society — should round-trip decode',
    inputAckLines: [
      'HDRSO010ASCAP                                                01.102026050320260503000001                  ',
      'GRHACK0000103.000000000                                  ',
      'ACK0000000000000001CW2024001ASCAP   20240315120000NWR000000010000000001AS                                            ',
      'GRT00000001000000010000000003                              ',
      'TRL00001000000010000000005',
    ],
    expectedDecoded: {
      hdrSenderType: 'SO',
      hdrSenderId: '010',
      ackTransactionType: 'NWR',
      ackStatus: 'AS', // Acknowledged-Submitted (BMI/ASCAP usage varies)
    },
  };

  const ALL_FIXTURES = [
    FIXTURE_ASCAP_NWR_BASIC,
    FIXTURE_BMI_COPUB,
    FIXTURE_PRS_LIBRARY,
    FIXTURE_GEMA_SUBPUB,
    FIXTURE_SACEM_AUTHORS,
    FIXTURE_JASRAC_JAPANESE,
    FIXTURE_MLC_REC,
    FIXTURE_AGR,
    FIXTURE_ACK,
  ];

  // ────────────────────────────────────────────────────────────────────────
  // BYTE-POSITION ASSERTIONS — granular field-offset checks
  // For every record built, verify specific positions hold expected values.
  // CWR is positional, so wrong offsets produce 200% silent failures
  // (society parses the wrong field), making byte assertions critical.
  // ────────────────────────────────────────────────────────────────────────
  function bytePosAssertions(lines, version) {
    const checks = [];
    const ass = (name, ok, detail) => checks.push({ name, pass: !!ok, detail: ok ? null : detail });

    for (const line of lines) {
      const rt = line.slice(0, 3);

      switch (rt) {
        case 'HDR':
          ass('HDR[0:3]==HDR', line.slice(0, 3) === 'HDR');
          ass('HDR[3:5] sender type 2-char', /^[A-Z]{2}$/.test(line.slice(3, 5)));
          ass('HDR[5:14] sender id 9-char', line.slice(5, 14).length === 9);
          ass('HDR sender name padded right', line.slice(14, 59).length === 45);
          ass('HDR ediVersion present', line.slice(59, 64).length === 5);
          ass('HDR creation date YYYYMMDD', /^\d{8}$/.test(line.slice(64, 72)));
          ass('HDR creation time HHMMSS', /^\d{6}$/.test(line.slice(72, 78)));
          ass('HDR transmission date YYYYMMDD', /^\d{8}$/.test(line.slice(78, 86)));
          break;
        case 'GRH':
          ass('GRH[0:3]==GRH', line.slice(0, 3) === 'GRH');
          // GRH layout: GRH + txn_type(3) + group_id(5) + version(5)
          {
            const grhTxn = line.slice(3, 6);
            ass('GRH txn type ∈ {NWR,REV,ISW,EXC,ACK,AGR}', ['NWR','REV','ISW','EXC','ACK','AGR'].includes(grhTxn));
            ass('GRH group id 5 chars', line.slice(6, 11).length === 5);
            ass('GRH version 5 chars present', line.slice(11, 16).length === 5);
          }
          break;
        case 'GRT':
          ass('GRT[0:3]==GRT', line.slice(0, 3) === 'GRT');
          ass('GRT[3:8] group id numeric', /^\d{5}$/.test(line.slice(3, 8)));
          ass('GRT[8:16] txn count numeric', /^\d{8}$/.test(line.slice(8, 16)));
          ass('GRT[16:24] record count numeric', /^\d{8}$/.test(line.slice(16, 24)));
          break;
        case 'TRL':
          ass('TRL[0:3]==TRL', line.slice(0, 3) === 'TRL');
          ass('TRL[3:8] group count numeric', /^\d{5}$/.test(line.slice(3, 8)));
          ass('TRL[8:14] txn count numeric', /^\d{6,8}$/.test(line.slice(8, 14)));
          ass('TRL has record count tail', /\d+$/.test(line));
          break;
        case 'NWR':
        case 'REV':
        case 'ISW':
        case 'EXC':
          ass(rt + ' has txn seq numeric', /^\d{8}$/.test(line.slice(3, 11)));
          ass(rt + ' has rec seq numeric', /^\d{8}$/.test(line.slice(11, 19)));
          ass(rt + ' submitter work id present', line.slice(79, 93).trim().length > 0);
          ass(rt + ' is ASCII-clean', !/[^\x00-\x7F]/.test(line));
          break;
        case 'SPU':
        case 'OPU':
          ass(rt + ' txn seq numeric', /^\d{8}$/.test(line.slice(3, 11)));
          ass(rt + ' rec seq numeric', /^\d{8}$/.test(line.slice(11, 19)));
          ass(rt + ' publisher seq num', /^\d{2}$/.test(line.slice(19, 21)));
          ass(rt + ' is ASCII-clean', !/[^\x00-\x7F]/.test(line));
          break;
        case 'SPT':
        case 'OPT':
          ass(rt + ' txn seq numeric', /^\d{8}$/.test(line.slice(3, 11)));
          ass(rt + ' interested party num', line.slice(19, 28).length === 9);
          break;
        case 'SWR':
        case 'OWR':
          ass(rt + ' txn seq numeric', /^\d{8}$/.test(line.slice(3, 11)));
          ass(rt + ' rec seq numeric', /^\d{8}$/.test(line.slice(11, 19)));
          ass(rt + ' interested party num 9 chars', line.slice(19, 28).length === 9);
          ass(rt + ' is ASCII-clean (NWN holds non-Roman)', !/[^\x00-\x7F]/.test(line));
          break;
        case 'SWT':
        case 'OWT':
          ass(rt + ' txn seq numeric', /^\d{8}$/.test(line.slice(3, 11)));
          break;
        case 'PWR':
          ass('PWR[0:3]==PWR', line.slice(0, 3) === 'PWR');
          ass('PWR txn seq numeric', /^\d{8}$/.test(line.slice(3, 11)));
          ass('PWR rec seq numeric', /^\d{8}$/.test(line.slice(11, 19)));
          ass('PWR publisher id 9 chars', line.slice(19, 28).length === 9);
          break;
        case 'ALT':
          ass('ALT[0:3]==ALT', line.slice(0, 3) === 'ALT');
          ass('ALT txn seq', /^\d{8}$/.test(line.slice(3, 11)));
          ass('ALT title field 60 chars', line.slice(19, 79).length === 60);
          break;
        case 'REC':
          ass('REC[0:3]==REC', line.slice(0, 3) === 'REC');
          ass('REC txn seq', /^\d{8}$/.test(line.slice(3, 11)));
          ass('REC release date YYYYMMDD or blank', /^(\d{8}|\s{8})$/.test(line.slice(19, 27)));
          break;
        case 'ORN':
          ass('ORN[0:3]==ORN', line.slice(0, 3) === 'ORN');
          ass('ORN intended purpose 3 chars', line.slice(19, 22).length === 3);
          break;
        case 'NCT':
        case 'NWN':
        case 'NPN':
        case 'NET':
          ass(rt + ' may contain non-ASCII (NRA carrier)', true);
          ass(rt + ' txn seq numeric', /^\d{8}$/.test(line.slice(3, 11)));
          break;
        case 'AGR':
          ass('AGR[0:3]==AGR', line.slice(0, 3) === 'AGR');
          ass('AGR has txn seq numeric', /^\d{8}$/.test(line.slice(3, 11)));
          ass('AGR has rec seq numeric', /^\d{8}$/.test(line.slice(11, 19)));
          break;
        case 'TER':
          ass('TER[0:3]==TER', line.slice(0, 3) === 'TER');
          ass('TER has txn seq numeric', /^\d{8}$/.test(line.slice(3, 11)));
          ass('TER inclusion flag present', line.slice(19, 20).length === 1);
          break;
        case 'IPA':
          ass('IPA[0:3]==IPA', line.slice(0, 3) === 'IPA');
          ass('IPA agreement role AS or AC', ['AS','AC'].includes(line.slice(19, 21)));
          break;
        case 'ACK':
          ass('ACK[0:3]==ACK', line.slice(0, 3) === 'ACK');
          ass('ACK has txn seq numeric', /^\d{8}$/.test(line.slice(3, 11)));
          ass('ACK has rec seq numeric', /^\d{8}$/.test(line.slice(11, 19)));
          break;
      }
    }
    return checks;
  }

  // ────────────────────────────────────────────────────────────────────────
  // CROSS-RECORD ASSERTIONS — relationships between records
  // ────────────────────────────────────────────────────────────────────────
  function crossRecordAssertions(lines) {
    const checks = [];
    const ass = (name, ok, detail) => checks.push({ name, pass: !!ok, detail: ok ? null : detail });

    // Transaction sequence numbers should be monotonic per group
    let lastTxnSeq = -1;
    let txnSeqGap = false;
    for (const line of lines) {
      const rt = line.slice(0, 3);
      if (['HDR','GRH','GRT','TRL'].includes(rt)) continue;
      const seq = parseInt(line.slice(3, 11), 10);
      if (!isNaN(seq)) {
        if (seq < lastTxnSeq) txnSeqGap = true;
        lastTxnSeq = seq;
      }
    }
    ass('Transaction sequence monotonic', !txnSeqGap);

    // Every SWT must follow an SWR with same writer ID
    const swrIds = new Set();
    let swtOrphan = false;
    for (const line of lines) {
      const rt = line.slice(0, 3);
      if (rt === 'SWR') {
        const id = line.slice(19, 28);
        swrIds.add(id);
      }
      if (rt === 'SWT') {
        const id = line.slice(19, 28);
        if (!swrIds.has(id)) swtOrphan = true;
      }
    }
    ass('Every SWT has parent SWR', !swtOrphan);

    // SPT records require at least one SPU in the same transaction
    const hasSPU = lines.some(l => l.startsWith('SPU'));
    const hasSPT = lines.some(l => l.startsWith('SPT'));
    ass('SPT records have SPU in transaction', !hasSPT || hasSPU);

    // PWR must have a publisher reference
    let pwrEmpty = false;
    for (const line of lines) {
      if (line.slice(0, 3) === 'PWR') {
        const pubId = line.slice(19, 28).trim();
        if (!pubId) pwrEmpty = true;
      }
    }
    ass('PWR records have publisher refs', !pwrEmpty);

    // Group counts: GRH count == NWR/REV/ISW/EXC/AGR/ACK count
    const grhs = lines.filter(l => l.startsWith('GRH')).length;
    const grts = lines.filter(l => l.startsWith('GRT')).length;
    ass('GRH count == GRT count', grhs === grts);

    // HDR == 1, TRL == 1
    ass('Exactly 1 HDR', lines.filter(l => l.startsWith('HDR')).length === 1);
    ass('Exactly 1 TRL', lines.filter(l => l.startsWith('TRL')).length === 1);
    ass('HDR is first', lines[0].startsWith('HDR'));
    ass('TRL is last', lines[lines.length - 1].startsWith('TRL'));

    return checks;
  }

  // ────────────────────────────────────────────────────────────────────────
  // RUN — execute every fixture against the live builder + society engine
  // ────────────────────────────────────────────────────────────────────────
  function runFixtureSuite() {
    if (!window.CwrBuild) return { error: 'CwrBuild not loaded' };

    const results = [];

    for (const fx of ALL_FIXTURES) {
      const r = { id: fx.id, society: fx.society, version: fx.version, checks: [] };

      try {
        let lines;

        if (fx.inputAckLines) {
          // ACK round-trip: feed pre-built lines into decoder
          lines = fx.inputAckLines;
          if (window.CwrDecode && window.CwrDecode.decodeTransmission) {
            const decoded = window.CwrDecode.decodeTransmission(lines);
            r.checks.push({
              name: 'ACK decodes without error',
              pass: !!decoded && (decoded.records || decoded.transactions),
            });
          }
        } else {
          // Build path
          lines = window.CwrBuild.buildTransmission(fx.input);
        }

        // Record-type sequence check
        if (fx.expectedRecordTypes) {
          const actual = lines.map(l => l.slice(0, 3));
          const expectedSet = fx.expectedRecordTypes;
          const allPresent = expectedSet.every(t => actual.includes(t));
          r.checks.push({
            name: 'Expected record types present',
            pass: allPresent,
            detail: allPresent ? null : 'Missing: ' + expectedSet.filter(t => !actual.includes(t)).join(','),
          });
        }

        // Record count check
        if (fx.expectedRecordCount) {
          r.checks.push({
            name: 'Record count = ' + fx.expectedRecordCount,
            pass: lines.length === fx.expectedRecordCount,
            detail: lines.length === fx.expectedRecordCount ? null : 'Got ' + lines.length,
          });
        }

        // Invariants
        if (fx.invariants) {
          fx.invariants.forEach(inv => {
            let ok = false;
            try { ok = !!inv.check(lines); } catch (e) { ok = false; r.error = e.message; }
            r.checks.push({ name: inv.name, pass: ok });
          });
        }

        // Society rules pass
        if (fx.society && window.CwrSociety) {
          const v = window.CwrSociety.validateForSociety(lines, fx.society, { version: fx.version });
          r.checks.push({
            name: 'Society rules: ' + fx.society + ' OK',
            pass: v.ok,
            detail: v.ok ? null : v.errors.map(e => e.code).join(','),
          });
        }

        // ─── DEEP BYTE-POSITION ASSERTIONS ───
        const byteChecks = bytePosAssertions(lines, fx.version);
        r.checks.push(...byteChecks);

        // ─── CROSS-RECORD ASSERTIONS ───
        const crossChecks = crossRecordAssertions(lines);
        r.checks.push(...crossChecks);

      } catch (e) {
        r.error = e.message;
        r.checks.push({ name: 'Fixture build', pass: false, detail: e.message });
      }

      r.passed = r.checks.filter(c => c.pass).length;
      r.total = r.checks.length;
      r.allPass = r.passed === r.total && !r.error;
      results.push(r);
    }

    const totalChecks = results.reduce((s, r) => s + r.total, 0);
    const passedChecks = results.reduce((s, r) => s + r.passed, 0);

    return {
      results,
      summary: {
        fixtures: results.length,
        fixturesPass: results.filter(r => r.allPass).length,
        checks: totalChecks,
        checksPass: passedChecks,
      },
    };
  }

  window.CwrFixtures = {
    FIXTURES: ALL_FIXTURES,
    runFixtureSuite,
  };
})();
