// ============================================================================
// CWR SOCIETY RULES ENGINE
// ----------------------------------------------------------------------------
// Per-society deviation table. The base CWR generator emits spec-compliant
// records; this engine applies society-specific quirks as a post-build pass
// before transmission. Each society has a `profile` describing what it needs
// beyond the CISAC base spec.
//
// References:
//   • ASCAP Submitter Reporting Requirements (CWR User Manual)
//   • BMI Publisher Affiliate Reporting (CWR Implementation Guide)
//   • PRS Member Resources / CWR Handbook
//   • GEMA CWR-Mitgliederrichtlinie
//   • SACEM Manuel CWR pour Éditeurs
//   • JASRAC CWR 実装ガイド
//   • SIAE / SOCAN / APRA AMCOS / STIM / AKM / SESAC / GMR / MLC / HFA / SAYCE
//
// Design: society profiles are PURE DATA. They describe:
//   - required fields the base spec marks optional
//   - rejected territory codes (e.g. GEMA refuses 2WL for sub-pub)
//   - charset constraints
//   - record-type mandates (e.g. JASRAC requires NRA for Japanese titles)
//   - validation rules
//
// EXPORT: window.CwrSociety = { applySocietyRules, validateForSociety,
//                               SOCIETY_PROFILES, SOCIETY_CODES }
// ============================================================================

(function () {
  // ────────────────────────────────────────────────────────────────────────
  // SOCIETY PROFILES
  //
  // Each entry encodes the deviations from CISAC base CWR for that society.
  // Keys are the 3-letter shortcode used in our UI; `cisac` is the 3-digit
  // CISAC numeric code used in CWR record fields.
  // ────────────────────────────────────────────────────────────────────────

  const SOCIETY_PROFILES = {
    // ═══════════════════════════════════════════════════════════ ASCAP (US)
    ASCAP: {
      cisac: '010',
      name: 'ASCAP',
      country: 'US',
      versions: ['2.1', '2.1r7', '2.2', '3.0', '3.1'],
      preferred: '2.2',
      requiresSubmitterId: true,
      submitterIdField: 'IPI',
      rules: {
        // PWR records must carry an agreement number when the publisher chain
        // has more than one link. Spec marks it conditional; ASCAP enforces.
        pwrAgreementRequiredOnChain: true,
        // SPU "Publisher Sequence #" must be unique across writers' chains
        spuSeqUniqueAcrossWriters: true,
        // ASCAP rejects controlled SPU without IPI Name #
        spuRequireIpiName: true,
        // Reject works without at least one ALT for foreign-language audiences
        altRequiredIfNonRoman: true,
        // CWR 2.2 only — ASCAP rejects v2.1r7 since 2024-Q3
        minVersion: '2.2',
        // Refuses non-ASCII in any v2.x field (no fallback to spaces)
        strictAscii: true,
      },
      tisAccept: 'all',
      maxFileSize: 250 * 1024 * 1024, // 250 MB per filing
    },

    // ═══════════════════════════════════════════════════════════════ BMI (US)
    BMI: {
      cisac: '021',
      name: 'BMI',
      country: 'US',
      versions: ['2.1', '2.1r7', '2.2', '3.0', '3.1'],
      preferred: '3.0',
      requiresSubmitterId: true,
      submitterIdField: 'BMI_PUBLISHER_NUMBER',
      rules: {
        // BMI requires publisher's BMI publisher number in SPU field 12
        // even though spec marks it optional
        spuBmiNumberRequired: true,
        // BMI writes ACK files in v3.0 format regardless of submission version
        ackVersion: '3.0',
        // BMI requires explicit OWR for every uncontrolled writer share
        owrRequiredForAllShares: true,
        // PWR must have publisher sequence numbers contiguous starting at 1
        pwrSeqContiguous: true,
        // Reject works without ISWC if work-creation date >180 days
        iswcRequiredIfMature: 180,
      },
      tisAccept: 'all',
      maxFileSize: 500 * 1024 * 1024,
    },

    // ═════════════════════════════════════════════════════════════ SESAC (US)
    SESAC: {
      cisac: '071',
      name: 'SESAC',
      country: 'US',
      versions: ['2.1', '2.2', '3.0'],
      preferred: '2.2',
      requiresSubmitterId: true,
      submitterIdField: 'SESAC_AFFILIATE',
      rules: {
        // SESAC accepts only NWR/REV (not ISW)
        rejectIsw: true,
        spuRequireIpiName: true,
        // SESAC ack code "AS" means accepted-with-changes; treat distinctly
        ackAsMeansAcceptWithChanges: true,
      },
      tisAccept: 'all',
    },

    // ═════════════════════════════════════════════════════════════ GMR (US)
    GMR: {
      cisac: '034', // Global Music Rights — not a traditional CISAC member
      name: 'GMR',
      country: 'US',
      versions: ['2.2', '3.0'],
      preferred: '3.0',
      requiresSubmitterId: true,
      submitterIdField: 'GMR_AFFILIATE',
      rules: {
        // GMR uses CWR 3.0 JSON sibling exclusively for production
        requireJsonSibling: true,
        spuRequireIpiName: true,
      },
      tisAccept: 'all',
    },

    // ═════════════════════════════════════════════════════════════ PRS (UK)
    PRS: {
      cisac: '052',
      name: 'PRS for Music',
      country: 'GB',
      versions: ['2.1', '2.1r7', '2.2', '3.0', '3.1'],
      preferred: '3.0',
      requiresSubmitterId: true,
      submitterIdField: 'IPI',
      rules: {
        // ORN first-use date must be ≥ work creation date and ≤ today
        ornFirstUseStrict: true,
        // PRS rejects ORN library code 'GS' (general stock) in production
        ornRejectGs: true,
        // CWR 3.x preferred; downgrade to 2.2 only with prior agreement
        warnOnLegacyVersion: '2.2',
        // PRS expects PER for every featured performer on production music
        perRequiredOnLibrary: true,
        // Strict: NPA must include language code
        npaLanguageRequired: true,
      },
      tisAccept: 'all',
    },

    // ═════════════════════════════════════════════════════════════ GEMA (DE)
    GEMA: {
      cisac: '035',
      name: 'GEMA',
      country: 'DE',
      versions: ['2.1', '2.2', '3.0', '3.1'],
      preferred: '3.0',
      requiresSubmitterId: true,
      submitterIdField: 'IPI',
      rules: {
        // GEMA rejects worldwide territory codes for sub-publishing
        // Must enumerate territories explicitly per agreement
        rejectWorldwideForSubpub: true,
        // SPT/OPT must use ISO TIS codes, never legacy 2-char
        tisStrictNumeric: true,
        // German titles require NCT (non-Roman/Latin extended characters)
        nctRequiredForExtendedLatin: true,
        // Mechanical share required (not just performance)
        mrShareRequired: true,
        // GEMA validates IPI base mod-10/1 strictly
        ipiBaseStrict: true,
      },
      tisAccept: 'numeric-only',
    },

    // ═════════════════════════════════════════════════════════════ SACEM (FR)
    SACEM: {
      cisac: '058',
      name: 'SACEM',
      country: 'FR',
      versions: ['2.1', '2.2', '3.0', '3.1'],
      preferred: '3.0',
      requiresSubmitterId: true,
      submitterIdField: 'IPI',
      rules: {
        // SACEM requires CAE/IPI on EVERY writer including non-controlled
        ipiRequiredOnAllWriters: true,
        // Writer designation 'CA' (composer/author) must be explicit, not blank
        writerDesignationRequired: true,
        // French titles in extended Latin must be sent with NCT alongside
        nctRequiredForExtendedLatin: true,
        // SACEM rejects PWR without publisher_sequence_n matching SPU
        pwrPubSeqMustMatchSpu: true,
        // SACEM accepts only SACEM-domiciled or SDRM-domiciled mechanical
        mrSocietyRestricted: ['058', '038'],
      },
      tisAccept: 'all',
    },

    // ═════════════════════════════════════════════════════════════ SIAE (IT)
    SIAE: {
      cisac: '055',
      name: 'SIAE',
      country: 'IT',
      versions: ['2.1', '2.2', '3.0'],
      preferred: '2.2',
      requiresSubmitterId: true,
      submitterIdField: 'IPI',
      rules: {
        spuRequireIpiName: true,
        // SIAE ORN library code mandatory for production music
        ornLibraryRequired: true,
        nctRequiredForExtendedLatin: true,
      },
      tisAccept: 'all',
    },

    // ═══════════════════════════════════════════════════════════ JASRAC (JP)
    JASRAC: {
      cisac: '101',
      name: 'JASRAC',
      country: 'JP',
      versions: ['2.1', '2.2', '3.0', '3.1'],
      preferred: '3.0',
      requiresSubmitterId: true,
      submitterIdField: 'IPI',
      rules: {
        // Mandatory NRA records for any work with Japanese-script title/names
        nraRequiredForJapanese: true,
        // JASRAC requires katakana/hiragana fallback in NWN
        nwnKanaFallback: true,
        // Mechanical share required
        mrShareRequired: true,
        // Must use UTF-8 in NRA, not Shift-JIS
        nraEncoding: 'utf-8',
        // Title language code mandatory
        titleLanguageRequired: true,
      },
      tisAccept: 'all',
    },

    // ════════════════════════════════════════════════════════════ SOCAN (CA)
    SOCAN: {
      cisac: '040',
      name: 'SOCAN',
      country: 'CA',
      versions: ['2.1', '2.2', '3.0', '3.1'],
      preferred: '3.0',
      requiresSubmitterId: true,
      submitterIdField: 'IPI',
      rules: {
        // SOCAN requires English OR French title; if French, NCT for accents
        bilingualTitlePreferred: true,
        nctRequiredForExtendedLatin: true,
        spuRequireIpiName: true,
      },
      tisAccept: 'all',
    },

    // ═══════════════════════════════════════════════════════════ APRA AMCOS
    APRA: {
      cisac: '014', // APRA
      name: 'APRA AMCOS',
      country: 'AU',
      versions: ['2.1', '2.2', '3.0', '3.1'],
      preferred: '3.0',
      requiresSubmitterId: true,
      submitterIdField: 'IPI',
      rules: {
        // APRA AMCOS accepts both PR (APRA) and MR (AMCOS) shares in one filing
        prMrCombined: true,
        spuRequireIpiName: true,
      },
      tisAccept: 'all',
    },

    // ════════════════════════════════════════════════════════════ STIM (SE)
    STIM: {
      cisac: '079',
      name: 'STIM',
      country: 'SE',
      versions: ['2.2', '3.0'],
      preferred: '3.0',
      requiresSubmitterId: true,
      submitterIdField: 'IPI',
      rules: {
        nctRequiredForExtendedLatin: true,
        // STIM uses NCB for Nordic mechanical
        mrSocietyRestricted: ['079', '048'], // STIM, NCB
      },
      tisAccept: 'all',
    },

    // ════════════════════════════════════════════════════════════ AKM (AT)
    AKM: {
      cisac: '005',
      name: 'AKM',
      country: 'AT',
      versions: ['2.1', '2.2', '3.0'],
      preferred: '2.2',
      requiresSubmitterId: true,
      submitterIdField: 'IPI',
      rules: {
        // AKM follows GEMA's strictness on territory enumeration
        rejectWorldwideForSubpub: true,
        nctRequiredForExtendedLatin: true,
        ipiBaseStrict: true,
      },
      tisAccept: 'numeric-only',
    },

    // ════════════════════════════════════════════════════════════ MLC (US)
    MLC: {
      cisac: '776', // MLC operating identifier
      name: 'The MLC',
      country: 'US',
      versions: ['3.0', '3.1'],
      preferred: '3.1',
      requiresSubmitterId: true,
      submitterIdField: 'MLC_MEMBER_ID',
      rules: {
        // MLC is mechanical-only; PR shares ignored
        prSharesIgnored: true,
        // Minimum CWR 3.0
        minVersion: '3.0',
        // ISWC required for every work
        iswcRequired: true,
        // Recording metadata (REC) required
        recRequired: true,
        // The MLC requires explicit "duration" on every recording
        recDurationRequired: true,
      },
      tisAccept: ['840'], // US only
    },

    // ════════════════════════════════════════════════════════════ HFA (US)
    HFA: {
      cisac: '073',
      name: 'HFA / Rumblefish',
      country: 'US',
      versions: ['2.1', '2.2', '3.0'],
      preferred: '2.2',
      requiresSubmitterId: true,
      submitterIdField: 'HFA_PUBLISHER_NUMBER',
      rules: {
        prSharesIgnored: true,
        iswcRequired: true,
        recRequired: true,
      },
      tisAccept: ['840'],
    },

    // ════════════════════════════════════════════════════════════ SAYCE (EC)
    SAYCE: {
      cisac: '065',
      name: 'SAYCE',
      country: 'EC',
      versions: ['2.1', '2.2'],
      preferred: '2.2',
      requiresSubmitterId: true,
      submitterIdField: 'IPI',
      rules: {
        // Spanish-language societies typically need NCT for ñ etc
        nctRequiredForExtendedLatin: true,
        spuRequireIpiName: true,
      },
      tisAccept: 'all',
    },
  };

  // CISAC code → society shortcode
  const SOCIETY_CODES = Object.fromEntries(
    Object.entries(SOCIETY_PROFILES).map(([k, v]) => [v.cisac, k])
  );

  // ────────────────────────────────────────────────────────────────────────
  // RULE APPLICATION
  // Each rule may transform records, mark them invalid, or inject new ones.
  // ────────────────────────────────────────────────────────────────────────

  function applySocietyRules(transmission, societyCode, opts = {}) {
    const profile = SOCIETY_PROFILES[societyCode];
    if (!profile) {
      return { transmission, mutations: [], errors: [{ code: 'UNKNOWN_SOCIETY', msg: 'No profile for ' + societyCode }] };
    }

    const mutations = [];
    const errors = [];
    const warnings = [];
    const r = profile.rules;

    // Version gate
    if (r.minVersion && opts.version) {
      if (compareVersions(opts.version, r.minVersion) < 0) {
        errors.push({
          code: 'VERSION_TOO_OLD',
          society: societyCode,
          msg: `${profile.name} requires CWR ${r.minVersion}+; got ${opts.version}`,
        });
      }
    }
    if (r.warnOnLegacyVersion && opts.version) {
      if (compareVersions(opts.version, r.warnOnLegacyVersion) < 0) {
        warnings.push({
          code: 'LEGACY_VERSION',
          society: societyCode,
          msg: `${profile.name} prefers ${profile.preferred}; using legacy ${opts.version}`,
        });
      }
    }

    // Walk records
    const lines = Array.isArray(transmission) ? transmission : transmission.lines || [];
    const out = [];
    let groupVersion = opts.version || '2.2';

    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];
      const recType = line.slice(0, 3);
      let mutated = line;

      // Rule: ASCAP — minimum version (already errored above)
      // Rule: BMI — submitter publisher number on SPU
      if (recType === 'SPU' && r.spuBmiNumberRequired && opts.submitterPublisherNumber) {
        // Field 12 = "Submitter agreement number" at offset varies by version
        // We append/overlay the publisher's BMI number into reserved field
        mutated = setBmiPublisherNumber(mutated, opts.submitterPublisherNumber, groupVersion);
        mutations.push({ rule: 'spuBmiNumberRequired', recType, line: i });
      }

      // Rule: SACEM/SESAC/SIAE/etc — IPI name on controlled SPU
      if (recType === 'SPU' && r.spuRequireIpiName) {
        const ipi = extractField(mutated, 'SPU', 'ipiName', groupVersion);
        if (!ipi || ipi.trim() === '') {
          errors.push({ code: 'SPU_IPI_REQUIRED', society: societyCode, line: i, msg: 'SPU missing IPI Name #' });
        }
      }

      // Rule: SACEM — IPI on EVERY writer
      if (recType === 'SWR' || recType === 'OWR') {
        if (r.ipiRequiredOnAllWriters) {
          const ipi = extractField(mutated, recType, 'ipiName', groupVersion);
          if (!ipi || ipi.trim() === '') {
            errors.push({ code: 'WRITER_IPI_REQUIRED', society: societyCode, line: i, msg: `${recType} missing IPI Name #` });
          }
        }
        if (r.writerDesignationRequired) {
          const des = extractField(mutated, recType, 'designation', groupVersion);
          if (!des || des.trim() === '') {
            errors.push({ code: 'WRITER_DESIGNATION_REQUIRED', society: societyCode, line: i, msg: `${recType} missing designation` });
          }
        }
      }

      // Rule: GEMA/AKM — reject 2WL/2136 worldwide for sub-publishing
      if ((recType === 'SPT' || recType === 'OPT') && r.rejectWorldwideForSubpub) {
        const tis = extractField(mutated, recType, 'tis', groupVersion);
        if (tis === '2136' || tis === '2WL' || tis === '2WL ') {
          errors.push({
            code: 'WORLDWIDE_NOT_ALLOWED',
            society: societyCode, line: i,
            msg: `${profile.name} rejects worldwide territory for sub-publishing — enumerate territories`,
          });
        }
      }

      // Rule: GEMA/AKM — TIS strict numeric
      if ((recType === 'SPT' || recType === 'OPT') && r.tisStrictNumeric) {
        const tis = extractField(mutated, recType, 'tis', groupVersion);
        if (tis && !/^\d{4}$/.test(tis.trim())) {
          errors.push({ code: 'TIS_NUMERIC_REQUIRED', society: societyCode, line: i, msg: 'TIS must be 4-digit numeric' });
        }
      }

      // Rule: PRS — ORN first-use strict (warning since v2.x ORN does not have a dedicated first-use date column;
      // PRS infers from production year + cue sheet)
      if (recType === 'ORN' && r.ornFirstUseStrict) {
        const fu = extractField(mutated, 'ORN', 'firstUseDate', groupVersion);
        if (!fu || !/^\d{8}$/.test(fu)) {
          warnings.push({ code: 'ORN_FIRST_USE_RECOMMENDED', society: societyCode, line: i, msg: 'PRS recommends explicit first-use date' });
        }
      }
      if (recType === 'ORN' && r.ornRejectGs) {
        const lib = extractField(mutated, 'ORN', 'libraryCode', groupVersion);
        if (lib && lib.trim() === 'GS') {
          errors.push({ code: 'ORN_GS_REJECTED', society: societyCode, line: i, msg: 'PRS rejects library code GS in production' });
        }
      }

      // Rule: MLC — REC duration required
      if (recType === 'REC' && r.recDurationRequired) {
        const dur = extractField(mutated, 'REC', 'duration', groupVersion);
        if (!dur || dur.replace(/0/g, '').length === 0) {
          errors.push({ code: 'REC_DURATION_REQUIRED', society: societyCode, line: i, msg: 'MLC requires duration on every REC' });
        }
      }

      // Rule: charset enforcement
      if (r.strictAscii && /[^\x00-\x7F]/.test(mutated)) {
        // For ASCAP strict ASCII, error rather than transliterate
        if (groupVersion.startsWith('2')) {
          errors.push({ code: 'NON_ASCII_IN_V2', society: societyCode, line: i, msg: 'Non-ASCII character in CWR 2.x' });
        }
      }

      out.push(mutated);
    }

    // Cross-record rules

    // Rule: BMI — PWR sequence numbers contiguous from 1
    if (r.pwrSeqContiguous) {
      const pwrSeqs = out
        .filter(l => l.startsWith('PWR'))
        .map((l, idx) => extractField(l, 'PWR', 'pubSeq', groupVersion));
      // simplified: just check first is 00000001
      if (pwrSeqs.length && pwrSeqs[0] !== '00000001') {
        warnings.push({ code: 'PWR_SEQ_NOT_CONTIGUOUS', society: societyCode, msg: 'PWR sequence should start at 00000001' });
      }
    }

    // Rule: MLC — ISWC required
    if (r.iswcRequired) {
      const nwrLines = out.filter(l => l.startsWith('NWR') || l.startsWith('REV'));
      nwrLines.forEach((l, idx) => {
        const iswc = extractField(l, 'NWR', 'iswc', groupVersion);
        if (!iswc || iswc.trim() === '' || iswc.startsWith('0000000000')) {
          errors.push({ code: 'ISWC_REQUIRED', society: societyCode, msg: 'Work missing ISWC' });
        }
      });
    }

    // Rule: JASRAC — NRA mandatory for Japanese titles
    if (r.nraRequiredForJapanese) {
      const nwrLines = out.filter(l => l.startsWith('NWR') || l.startsWith('REV'));
      nwrLines.forEach((l) => {
        const title = extractField(l, 'NWR', 'title', groupVersion);
        if (title && /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]/.test(title)) {
          // verify there's a corresponding NET in the transmission
          const hasNet = out.some(x => x.startsWith('NET'));
          if (!hasNet) {
            errors.push({ code: 'NRA_REQUIRED', society: societyCode, msg: 'JASRAC: Japanese title requires NET/NCT/NVT' });
          }
        }
      });
    }

    // Rule: NCT for extended Latin
    if (r.nctRequiredForExtendedLatin) {
      const nwrLines = out.filter(l => l.startsWith('NWR') || l.startsWith('REV'));
      nwrLines.forEach((l) => {
        const title = extractField(l, 'NWR', 'title', groupVersion);
        if (title && /[\u00C0-\u024F]/.test(title)) {
          const hasNct = out.some(x => x.startsWith('NCT') || x.startsWith('NET'));
          if (!hasNct) {
            warnings.push({ code: 'NCT_RECOMMENDED', society: societyCode, msg: 'Extended Latin title — NCT recommended' });
          }
        }
      });
    }

    return {
      transmission: out,
      mutations,
      errors,
      warnings,
      profile,
    };
  }

  // ────────────────────────────────────────────────────────────────────────
  // VALIDATE A TRANSMISSION FOR A SPECIFIC SOCIETY
  // Returns { ok: bool, errors, warnings, profile }
  // ────────────────────────────────────────────────────────────────────────
  function validateForSociety(transmission, societyCode, opts = {}) {
    const result = applySocietyRules(transmission, societyCode, opts);
    return {
      ok: result.errors.length === 0,
      errors: result.errors,
      warnings: result.warnings,
      mutations: result.mutations,
      profile: result.profile,
    };
  }

  // ────────────────────────────────────────────────────────────────────────
  // Field extraction — looks up offset from CwrBuild.RECORD_LENGTHS
  // ────────────────────────────────────────────────────────────────────────
  // Field-offset map — offsets and widths for the fields we inspect.
  // We don't replicate the full RECORD_LENGTHS map; just enough for rules.
  const FIELD_MAP = {
    SPU: {
      ipiName:    { '2.1': [87, 11], '2.1r7': [87, 11], '2.2': [87, 11], '3.0': [87, 11], '3.1': [87, 11] },
      pubSeq:     { '2.1': [19, 2],  '2.1r7': [19, 2],  '2.2': [19, 2],  '3.0': [19, 2],  '3.1': [19, 2] },
    },
    OPU: {
      ipiName:    { '2.1': [87, 11], '2.1r7': [87, 11], '2.2': [87, 11], '3.0': [87, 11], '3.1': [87, 11] },
    },
    SWR: {
      ipiName:    { '2.1': [127, 11], '2.1r7': [127, 11], '2.2': [127, 11], '3.0': [127, 11], '3.1': [127, 11] },
      designation:{ '2.1': [104, 2],  '2.1r7': [104, 2],  '2.2': [104, 2],  '3.0': [104, 2],  '3.1': [104, 2] },
    },
    OWR: {
      ipiName:    { '2.1': [127, 11], '2.1r7': [127, 11], '2.2': [127, 11], '3.0': [127, 11], '3.1': [127, 11] },
      designation:{ '2.1': [104, 2],  '2.1r7': [104, 2],  '2.2': [104, 2],  '3.0': [104, 2],  '3.1': [104, 2] },
    },
    SPT: {
      tis:        { '2.1': [50, 4],  '2.1r7': [50, 4],  '2.2': [50, 4],  '3.0': [50, 4],  '3.1': [50, 4] },
    },
    OPT: {
      tis:        { '2.1': [50, 4],  '2.1r7': [50, 4],  '2.2': [50, 4],  '3.0': [50, 4],  '3.1': [50, 4] },
    },
    NWR: {
      title:      { '2.1': [19, 60], '2.1r7': [19, 60], '2.2': [19, 60], '3.0': [19, 60], '3.1': [19, 60] },
      iswc:       { '2.1': [95, 11], '2.1r7': [95, 11], '2.2': [95, 11], '3.0': [95, 11], '3.1': [95, 11] },
    },
    REV: {
      title:      { '2.1': [19, 60], '2.1r7': [19, 60], '2.2': [19, 60], '3.0': [19, 60], '3.1': [19, 60] },
      iswc:       { '2.1': [95, 11], '2.1r7': [95, 11], '2.2': [95, 11], '3.0': [95, 11], '3.1': [95, 11] },
    },
    ORN: {
      intendedPurpose: { '2.1': [19, 3], '2.1r7': [19, 3], '2.2': [19, 3], '3.0': [19, 3], '3.1': [19, 3] },
      library:    { '2.1': [101, 60], '2.1r7': [101, 60], '2.2': [101, 60], '3.0': [101, 60], '3.1': [101, 60] },
      // firstUseDate not present in current builder ORN; mark as 0-width
      firstUseDate: { '2.1': [0, 0], '2.1r7': [0, 0], '2.2': [0, 0], '3.0': [0, 0], '3.1': [0, 0] },
      libraryCode: { '2.1': [0, 0], '2.1r7': [0, 0], '2.2': [0, 0], '3.0': [0, 0], '3.1': [0, 0] },
    },
    REC: {
      duration:   { '2.1': [85, 6],  '2.1r7': [85, 6],  '2.2': [85, 6],  '3.0': [85, 6],  '3.1': [85, 6] },
    },
    PWR: {
      pubSeq:     { '2.1': [83, 2],  '2.1r7': [83, 2],  '2.2': [83, 2],  '3.0': [83, 2],  '3.1': [83, 2] },
    },
  };

  function extractField(line, recType, fieldName, version) {
    const recMap = FIELD_MAP[recType];
    if (!recMap) return null;
    const fld = recMap[fieldName];
    if (!fld) return null;
    const v = (version || '2.2').slice(0, 3);
    const off = fld[v] || fld['2.2'];
    if (!off) return null;
    return line.slice(off[0], off[0] + off[1]);
  }

  function setBmiPublisherNumber(line, num, version) {
    // BMI uses SPU "Submitter Agreement Number" field (offset 121, width 14 in v2.x)
    const padded = String(num).padEnd(14, ' ').slice(0, 14);
    const off = version.startsWith('3') ? 123 : 121;
    return line.slice(0, off) + padded + line.slice(off + 14);
  }

  function compareVersions(a, b) {
    const pa = a.split(/[.r]/).map(Number);
    const pb = b.split(/[.r]/).map(Number);
    for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
      const da = pa[i] || 0, db = pb[i] || 0;
      if (da < db) return -1;
      if (da > db) return 1;
    }
    return 0;
  }

  // ────────────────────────────────────────────────────────────────────────
  // BATCH: validate one transmission against multiple societies at once
  // ────────────────────────────────────────────────────────────────────────
  function validateForSocieties(transmission, societyCodes, opts = {}) {
    const results = {};
    for (const code of societyCodes) {
      results[code] = validateForSociety(transmission, code, opts);
    }
    return results;
  }

  // ────────────────────────────────────────────────────────────────────────
  // EXPORT
  // ────────────────────────────────────────────────────────────────────────
  window.CwrSociety = {
    SOCIETY_PROFILES,
    SOCIETY_CODES,
    applySocietyRules,
    validateForSociety,
    validateForSocieties,
    compareVersions,
  };
})();
