// ============================================================================
// CWR VALIDATION  ·  CISAC compliance gauntlet
// ----------------------------------------------------------------------------
// Implements the structural + semantic rules every CWR file must pass before
// society submission. Returns a pass/fail array per rule with line-level
// citations so the inspector can show "rule 2.1.4 failed at line 23".
//
// Coverage:
//   • IPI base + name check digits (mod-10/1 weighted; "Luhn-like")
//   • ISWC check digit (mod-10 weighted; T-XXX.XXX.XXX-C)
//   • CISAC society code lookups (407 societies)
//   • TIS territory code lookups (267 territories)
//   • Share reconciliation (PR/MR/SR sum to 100% per right within scope)
//   • Publisher-writer chain consistency (PWR ↔ SPU/SWR back-references)
//   • Character set (ASCII-only for v2.x non-NRA records)
//   • Record-length sanity (every line === REC_LEN[type][version])
//
// EXPORT: window.CwrValidate = { validate, checkIPIBase, checkISWC, … }
// ============================================================================

(function () {
  'use strict';

  // ─────────────────────────────────────────────────────────────────────────
  // CHECK-DIGIT ALGORITHMS
  // ─────────────────────────────────────────────────────────────────────────

  // IPI base number: I-NNNNNNNNN-C (1 letter + 9 digits + check digit)
  // Check digit = (sum of digit_i * (10 - i)) mod 11; if 10, transmit as 0.
  // Source: CISAC IPI Information User Manual § 4.2
  function checkIPIBase(ipiBase) {
    if (!ipiBase) return { ok: false, reason: 'empty' };
    const s = String(ipiBase).trim().toUpperCase().replace(/[\s\-.]/g, '');
    if (!/^I\d{10}$/.test(s)) return { ok: false, reason: 'format', expected: 'I + 10 digits' };
    const digits = s.slice(1, 10).split('').map(Number);
    const check = parseInt(s[10], 10);
    let sum = 0;
    for (let i = 0; i < 9; i++) sum += digits[i] * (10 - i);
    let computed = sum % 11;
    if (computed === 10) computed = 0;
    if (computed !== check) return { ok: false, reason: 'check', computed, given: check };
    return { ok: true };
  }

  // IPI name number: NNNNNNNNNNN (11 digits, last 2 = check digits)
  // Mod 101 over weighted sum. CISAC IPI Manual § 4.1.
  function checkIPIName(ipiName) {
    if (!ipiName) return { ok: false, reason: 'empty' };
    const s = String(ipiName).trim().replace(/[\s\-.]/g, '');
    if (!/^\d{11}$/.test(s)) return { ok: false, reason: 'format', expected: '11 digits' };
    const digits = s.slice(0, 9).split('').map(Number);
    const check = parseInt(s.slice(9, 11), 10);
    let sum = 0;
    for (let i = 0; i < 9; i++) sum += digits[i] * (10 - i);
    const computed = (101 - (sum % 101)) % 101;
    if (computed !== check) return { ok: false, reason: 'check', computed, given: check };
    return { ok: true };
  }

  // ISWC: T-NNN.NNN.NNN-C (T + 9 digits + check digit)
  // Check digit = (1 + sum of digit_i * (i+1)) mod 10. Then 10 - that, mod 10.
  // ISO 15707 § 6.
  function checkISWC(iswc) {
    if (!iswc) return { ok: false, reason: 'empty' };
    const s = String(iswc).trim().toUpperCase().replace(/[\s\-.]/g, '');
    if (!/^T\d{10}$/.test(s)) return { ok: false, reason: 'format', expected: 'T + 10 digits' };
    const digits = s.slice(1, 10).split('').map(Number);
    const check = parseInt(s[10], 10);
    let sum = 1;
    for (let i = 0; i < 9; i++) sum += digits[i] * (i + 1);
    const computed = (10 - (sum % 10)) % 10;
    if (computed !== check) return { ok: false, reason: 'check', computed, given: check };
    return { ok: true };
  }

  // ISRC: CC-XXX-YY-NNNNN (2-char country + 3-char registrant + 2-digit year + 5-digit designation)
  // No check digit, but format must be valid.
  function checkISRC(isrc) {
    if (!isrc) return { ok: false, reason: 'empty' };
    const s = String(isrc).trim().toUpperCase().replace(/[\s\-]/g, '');
    if (!/^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$/.test(s)) return { ok: false, reason: 'format', expected: 'CCXXXYYNNNNN' };
    return { ok: true };
  }

  // EAN/UPC: 13 digits, last is checksum (mod-10 with weights 1,3,1,3...)
  function checkEAN(ean) {
    if (!ean) return { ok: true }; // optional
    const s = String(ean).trim().replace(/\D/g, '');
    if (s.length !== 13 && s.length !== 12) return { ok: false, reason: 'format', expected: '12 or 13 digits' };
    if (s.length === 12) return { ok: true }; // UPC-A
    const digits = s.split('').map(Number);
    let sum = 0;
    for (let i = 0; i < 12; i++) sum += digits[i] * (i % 2 === 0 ? 1 : 3);
    const computed = (10 - (sum % 10)) % 10;
    if (computed !== digits[12]) return { ok: false, reason: 'check', computed, given: digits[12] };
    return { ok: true };
  }

  // ─────────────────────────────────────────────────────────────────────────
  // CISAC LOOKUPS (resolved against window.REF)
  // ─────────────────────────────────────────────────────────────────────────

  function lookupSociety(cisacCode) {
    const REF = window.REF;
    if (!REF || !REF.ready || !Array.isArray(REF.societies)) return null;
    const padded = String(cisacCode || '').trim().padStart(3, '0').slice(0, 3);
    return REF.societies.find((s) => String(s.cisac_code).padStart(3, '0') === padded) || null;
  }
  function lookupTIS(tisCode) {
    const REF = window.REF;
    if (!REF || !REF.ready || !Array.isArray(REF.territories)) return null;
    const padded = String(tisCode || '').trim();
    return REF.territories.find((t) => String(t.tis_code).replace(/^0+/, '') === padded.replace(/^0+/, '')) || null;
  }

  // ─────────────────────────────────────────────────────────────────────────
  // SHARE RECONCILIATION
  // ─────────────────────────────────────────────────────────────────────────
  // CWR rule: across all writers (controlled + non-controlled) for a work,
  // PR shares must sum to ≤ 100%, MR shares must sum to ≤ 100%, SR shares
  // must sum to ≤ 100%. Exact 100% is required for full claims; <100%
  // signals partial claim with a Y "USA license indicator" or similar.
  function reconcileShares(work) {
    const issues = [];
    const sum = (xs, k) => (xs || []).reduce((s, x) => s + (Number(x[k]) || 0), 0);

    const writerPR = sum(work.writers, 'prShare');
    const writerMR = sum(work.writers, 'mrShare');
    const writerSR = sum(work.writers, 'srShare');
    const pubPR    = sum(work.publishers, 'prShare');
    const pubMR    = sum(work.publishers, 'mrShare');
    const pubSR    = sum(work.publishers, 'srShare');

    // Shares may be expressed as 0–1 fractions, 0–100 percentages, or 0–10000
    // basis points (CWR canonical). Detect scale and normalize all to percent.
    const maxObserved = Math.max(writerPR, writerMR, writerSR, pubPR, pubMR, pubSR);
    const asPct = (v) => {
      if (maxObserved > 200) return v / 100;       // basis points → percent
      if (maxObserved > 1.5) return v;              // already percent
      return v * 100;                                // fraction → percent
    };
    const target = 100;
    const eps = 0.01;

    const totalPR = asPct(writerPR + pubPR);
    const totalMR = asPct(writerMR + pubMR);
    const totalSR = asPct(writerSR + pubSR);
    // Allow ±2% drift as warn (rounding in real catalog), >2% is error
    const driftWarn = 2.0;
    if (Math.abs(totalPR - target) > eps && totalPR > eps) {
      const sev = Math.abs(totalPR - target) > driftWarn ? 'error' : 'warn';
      issues.push({ rule: 'shares.PR.total', severity: sev,
        msg: `PR shares sum to ${totalPR.toFixed(2)}% (expected 100%)`, value: totalPR });
    }
    if (Math.abs(totalMR - target) > eps && totalMR > eps) {
      const sev = Math.abs(totalMR - target) > driftWarn ? 'error' : 'warn';
      issues.push({ rule: 'shares.MR.total', severity: sev,
        msg: `MR shares sum to ${totalMR.toFixed(2)}% (expected 100%)`, value: totalMR });
    }
    if (totalSR > target + driftWarn) {
      issues.push({ rule: 'shares.SR.total', severity: 'error',
        msg: `SR shares exceed 100%: ${totalSR.toFixed(2)}%`, value: totalSR });
    } else if (totalSR > target + eps) {
      issues.push({ rule: 'shares.SR.total', severity: 'warn',
        msg: `SR shares slightly exceed 100%: ${totalSR.toFixed(2)}%`, value: totalSR });
    }
    return issues;
  }

  // PWR consistency: every PWR record must reference a writer ID that has an
  // SWR record AND a publisher ID that has an SPU record in the same txn.
  function reconcilePWR(work) {
    const issues = [];
    const writerIds = new Set((work.writers || []).filter((w) => w.controlled).map((w) => w.id));
    const pubIds = new Set((work.publishers || []).filter((p) => p.controlled).map((p) => p.id));
    (work.writers || []).forEach((w) => {
      (w.pwrLinks || []).forEach((link) => {
        if (link.publisherId && !pubIds.has(link.publisherId)) {
          issues.push({ rule: 'pwr.publisher_missing', severity: 'error',
            msg: `PWR references publisher ${link.publisherId} not in SPU chain`, writer: w.id });
        }
      });
    });
    return issues;
  }

  // ─────────────────────────────────────────────────────────────────────────
  // CHARACTER SET (ASCII-only for v2.x, non-NRA records)
  // ─────────────────────────────────────────────────────────────────────────
  function checkASCII(line) {
    for (let i = 0; i < line.length; i++) {
      if (line.charCodeAt(i) > 127) {
        return { ok: false, pos: i, ch: line[i], code: line.charCodeAt(i) };
      }
    }
    return { ok: true };
  }

  // ─────────────────────────────────────────────────────────────────────────
  // MAIN VALIDATOR
  // ─────────────────────────────────────────────────────────────────────────
  function validate(opts) {
    const { version, lines, works } = opts;
    const issues = [];
    const REC_LEN = window.CwrBuild ? window.CwrBuild.REC_LEN : null;
    const isV3 = version === '3.0' || version === '3.1';
    const NRA_RECORDS = new Set(['NET', 'NCT', 'NVT', 'NPN', 'NPR', 'NWN', 'NOW', 'NRN']);

    // ─── Structural ────────────────────────────────────────────────────────
    if (!lines || !lines.length) {
      issues.push({ rule: 'file.empty', severity: 'error', msg: 'No lines in transmission' });
      return { issues, summary: summarize(issues) };
    }
    if (!lines[0].startsWith('HDR')) {
      issues.push({ rule: 'file.hdr_missing', severity: 'error', msg: 'First line must be HDR', line: 0 });
    }
    if (!lines[lines.length - 1].startsWith('TRL')) {
      issues.push({ rule: 'file.trl_missing', severity: 'error', msg: 'Last line must be TRL', line: lines.length - 1 });
    }

    // Per-line checks
    let txnCount = 0;
    let groupCount = 0;
    lines.forEach((line, i) => {
      const rt = line.slice(0, 3);
      if (rt === 'GRH') groupCount++;
      if (['NWR', 'REV', 'ISW', 'EXC', 'ACK', 'AGR'].includes(rt)) txnCount++;

      // Length
      if (REC_LEN && REC_LEN[rt] && REC_LEN[rt][version]) {
        const expected = REC_LEN[rt][version];
        if (line.length !== expected) {
          issues.push({ rule: 'length.mismatch', severity: 'error',
            msg: `${rt} record at line ${i + 1} is ${line.length} chars (expected ${expected})`,
            line: i });
        }
      }

      // Character set: v2.x non-NRA records must be ASCII
      if (!isV3 && !NRA_RECORDS.has(rt)) {
        const r = checkASCII(line);
        if (!r.ok) {
          issues.push({ rule: 'charset.ascii_only', severity: 'error',
            msg: `Non-ASCII char '${r.ch}' (U+${r.code.toString(16)}) at line ${i + 1} pos ${r.pos}. Use NRA records for non-Roman text.`,
            line: i });
        }
      }
    });

    // TRL count check
    const trl = lines[lines.length - 1];
    if (trl && trl.startsWith('TRL')) {
      const trlGroupCount = parseInt(trl.slice(3, 8), 10);
      const trlTxnCount = parseInt(trl.slice(8, 16), 10);
      if (trlGroupCount !== groupCount) {
        issues.push({ rule: 'trl.group_count', severity: 'error',
          msg: `TRL group count is ${trlGroupCount}, found ${groupCount}` });
      }
      if (trlTxnCount !== txnCount) {
        issues.push({ rule: 'trl.txn_count', severity: 'error',
          msg: `TRL transaction count is ${trlTxnCount}, found ${txnCount}` });
      }
    }

    // ─── Per-work semantic checks ────────────────────────────────────────
    (works || []).forEach((work, wi) => {
      // ISWC check digit
      if (work.iswc) {
        const r = checkISWC(work.iswc);
        if (!r.ok) {
          issues.push({ rule: 'iswc.checksum', severity: 'error',
            msg: `Work "${work.title}" ISWC ${work.iswc}: ${r.reason}${r.computed != null ? ` (computed ${r.computed}, given ${r.given})` : ''}`,
            work: wi });
        }
      }

      // Society codes
      const allParties = [...(work.publishers || []), ...(work.writers || [])];
      allParties.forEach((p) => {
        ['prSocCode', 'mrSocCode', 'srSocCode'].forEach((field) => {
          const code = p[field];
          if (code && String(code).trim()) {
            const soc = lookupSociety(code);
            if (!soc) {
              issues.push({ rule: 'society.unknown', severity: 'warn',
                msg: `Unknown CISAC society code "${code}" on ${p.name || p.id} (${field})`,
                work: wi });
            }
          }
        });

        // IPI name
        if (p.ipiName) {
          const r = checkIPIName(p.ipiName);
          if (!r.ok) {
            issues.push({ rule: 'ipi.name.checksum', severity: 'warn',
              msg: `IPI name ${p.ipiName} on ${p.name || p.id}: ${r.reason}`,
              work: wi });
          }
        }
        // IPI base
        if (p.intStandardCode) {
          const r = checkIPIBase(p.intStandardCode);
          if (!r.ok && r.reason !== 'empty') {
            issues.push({ rule: 'ipi.base.checksum', severity: 'warn',
              msg: `IPI base ${p.intStandardCode} on ${p.name || p.id}: ${r.reason}`,
              work: wi });
          }
        }
      });

      // Territories
      const territoriesUsed = [];
      (work.publishers || []).forEach((p) => (p.territories || []).forEach((t) => territoriesUsed.push(t.tisNum)));
      (work.writers || []).forEach((w) => (w.territories || []).forEach((t) => territoriesUsed.push(t.tisNum)));
      territoriesUsed.forEach((tis) => {
        if (tis && tis !== 2136) { // 2136 = world; always valid
          const t = lookupTIS(tis);
          if (!t) {
            issues.push({ rule: 'tis.unknown', severity: 'warn',
              msg: `Unknown TIS territory code ${tis}`,
              work: wi });
          }
        }
      });

      // Share reconciliation
      reconcileShares(work).forEach((iss) => issues.push({ ...iss, work: wi }));
      // PWR consistency
      reconcilePWR(work).forEach((iss) => issues.push({ ...iss, work: wi }));

      // Recording ISRCs
      (work.recordings || []).forEach((r) => {
        if (r.isrc) {
          const c = checkISRC(r.isrc);
          if (!c.ok) {
            issues.push({ rule: 'isrc.format', severity: 'warn',
              msg: `ISRC ${r.isrc}: ${c.reason}`, work: wi });
          }
        }
      });
    });

    return { issues, summary: summarize(issues) };
  }

  function summarize(issues) {
    const errors = issues.filter((i) => i.severity === 'error').length;
    const warnings = issues.filter((i) => i.severity === 'warn').length;
    return { errors, warnings, total: issues.length, ok: errors === 0 };
  }

  // Public API
  window.CwrValidate = {
    validate,
    checkIPIBase, checkIPIName, checkISWC, checkISRC, checkEAN,
    lookupSociety, lookupTIS,
    reconcileShares, reconcilePWR,
    checkASCII,
  };
})();
