/* CA Automator — Forensic Audit & Investigation engines (JS mirror of
   server/forensic_engines.py). Kept numerically identical to the Python
   engine — every change here must be made there too, and vice versa. */
(function (global) {
  'use strict';

  const R2 = x => Math.round((Number(x) || 0) * 100) / 100;

  function parseDate(s) {
    if (!s) return null;
    const m1 = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.trim());
    if (m1) return new Date(Number(m1[1]), Number(m1[2]) - 1, Number(m1[3]));
    const m2 = /^(\d{2})-(\d{2})-(\d{4})$/.exec(s.trim());
    if (m2) return new Date(Number(m2[3]), Number(m2[2]) - 1, Number(m2[1]));
    return null;
  }

  // ------------------------------------------------------------- Benford's Law
  const BENFORD_EXPECTED = {};
  for (let d = 1; d <= 9; d++) BENFORD_EXPECTED[d] = Math.log10(1 + 1 / d);

  function leadingDigit(x) {
    x = Math.abs(Number(x) || 0);
    if (x === 0) return null;
    while (x < 1) x *= 10;
    while (x >= 10) x /= 10;
    return Math.floor(x);
  }

  function benfordAnalysis(amounts) {
    const counts = {}; for (let d = 1; d <= 9; d++) counts[d] = 0;
    amounts.forEach(a => { const d = leadingDigit(a); if (d) counts[d]++; });
    const n = Object.values(counts).reduce((s, x) => s + x, 0);
    if (n < 30) {
      return { count: n, expected: null, actual: null, counts, mad: null, conformity: null,
        note: "Fewer than 30 usable values — Benford's Law is not meaningful on a sample this small." };
    }
    const actual = {}; for (let d = 1; d <= 9; d++) actual[d] = counts[d] / n;
    let mad = 0; for (let d = 1; d <= 9; d++) mad += Math.abs(actual[d] - BENFORD_EXPECTED[d]);
    mad /= 9;
    let conformity;
    if (mad < 0.006) conformity = 'Close conformity';
    else if (mad < 0.012) conformity = 'Acceptable conformity';
    else if (mad < 0.015) conformity = 'Marginally acceptable';
    else conformity = 'Nonconformity';
    const expected = {}, actualPct = {};
    for (let d = 1; d <= 9; d++) { expected[d] = R2(BENFORD_EXPECTED[d] * 100); actualPct[d] = R2(actual[d] * 100); }
    return { count: n, expected, actual: actualPct, counts, mad: Math.round(mad * 100000) / 100000, conformity,
      note: "A screening signal, not proof — genuinely skewed populations can fail Benford's Law innocently." };
  }

  // -------------------------------------------------------------- duplicates
  function detectDuplicates(rows, keyFields) {
    const groups = new Map();
    rows.forEach((r, i) => {
      const keyParts = keyFields.map(f => { const v = r[f]; return typeof v === 'string' ? v.trim().toLowerCase() : v; });
      if (keyParts.some(v => v === null || v === undefined || v === '')) return;
      const key = JSON.stringify(keyParts);
      if (!groups.has(key)) groups.set(key, { keyParts, idxs: [] });
      groups.get(key).idxs.push(i);
    });
    const out = [];
    groups.forEach(({ keyParts, idxs }) => {
      if (idxs.length > 1) {
        const key = {}; keyFields.forEach((f, j) => { key[f] = keyParts[j]; });
        out.push({ key, row_indices: idxs, count: idxs.length });
      }
    });
    return out;
  }

  // ----------------------------------------------------------- round numbers
  function detectRoundNumbers(rows, roundTo, minAmount) {
    roundTo = roundTo || 1000; minAmount = minAmount || 10000;
    const out = [];
    rows.forEach((r, i) => {
      const amt = r.amount;
      if (amt && Math.abs(amt) >= minAmount && Math.abs(amt) % roundTo === 0) out.push(i);
    });
    return out;
  }

  // ----------------------------------------------------------- related party
  function detectRelatedParty(rows, relatedNames) {
    const names = new Set((relatedNames || []).filter(n => n && n.trim()).map(n => n.trim().toLowerCase()));
    if (!names.size) return [];
    const out = [];
    rows.forEach((r, i) => { if (names.has((r.party || '').trim().toLowerCase())) out.push(i); });
    return out;
  }

  // ------------------------------------------------------ journal entry score
  function scoreJournalEntry(row) {
    const factors = [];
    const add = (name, hit, points, note) => { if (hit) factors.push({ factor: name, points, note }); };
    const d = parseDate(row.date);
    add('Weekend posting', !!(d && (d.getDay() === 0 || d.getDay() === 6)), 2, 'Posted on a Saturday or Sunday — unusual for routine entries.');
    const amt = row.amount || 0;
    add('Round amount', !!(amt && Math.abs(amt) % 1000 === 0 && Math.abs(amt) >= 10000), 2, 'A suspiciously round amount for a journal entry.');
    add('Missing narration', !(row.description || '').trim(), 2, 'No description recorded for this entry.');
    add('Missing reference', !(row.reference_no || '').trim(), 1, 'No reference/voucher number recorded.');
    const total = factors.reduce((s, f) => s + f.points, 0);
    const level = total < 2 ? 'Low' : (total < 5 ? 'Medium' : 'High');
    return { factors, score: total, risk_level: level };
  }

  // ------------------------------------------------------------------ payroll
  function analyzePayroll(rows) {
    const findings = [];
    const byBank = new Map(), byPan = new Map();
    rows.forEach((r, i) => {
      const extra = r.extra || {};
      const bank = (extra.bank_account || '').trim(), pan = (extra.pan_no || '').trim();
      if (bank) { if (!byBank.has(bank)) byBank.set(bank, []); byBank.get(bank).push(i); }
      if (pan) { if (!byPan.has(pan)) byPan.set(pan, []); byPan.get(pan).push(i); }
    });
    byBank.forEach((idxs, bank) => { if (idxs.length > 1) findings.push({ type: 'duplicate_bank_account', row_indices: idxs,
      detail: idxs.length + ' employees share bank account ' + bank + ' — possible ghost employee.' }); });
    byPan.forEach((idxs, pan) => { if (idxs.length > 1) findings.push({ type: 'duplicate_pan', row_indices: idxs,
      detail: idxs.length + ' employees share PAN ' + pan + ' — possible ghost employee.' }); });
    rows.forEach((r, i) => {
      const extra = r.extra || {};
      const missing = ['pan_no', 'bank_account', 'address'].filter(f => !(extra[f] || '').trim());
      if (missing.length) findings.push({ type: 'missing_employee_data', row_indices: [i],
        detail: 'Missing ' + missing.join(', ') + ' — incomplete master data is a ghost-employee risk indicator.' });
    });
    detectRoundNumbers(rows, 1000, 5000).forEach(i => findings.push({ type: 'round_number_pay', row_indices: [i], detail: 'Round-number gross pay figure.' }));
    return findings;
  }

  // ------------------------------------------------------------------- vendor
  function analyzeVendors(rows, employeeRows) {
    const findings = [];
    const byBank = new Map();
    rows.forEach((r, i) => { const bank = ((r.extra || {}).bank_account || '').trim(); if (bank) { if (!byBank.has(bank)) byBank.set(bank, []); byBank.get(bank).push(i); } });
    byBank.forEach((idxs, bank) => { if (idxs.length > 1) findings.push({ type: 'duplicate_vendor_bank_account', row_indices: idxs,
      detail: idxs.length + ' vendors share bank account ' + bank + '.' }); });
    if (employeeRows && employeeRows.length) {
      const empBanks = new Set(employeeRows.map(r => ((r.extra || {}).bank_account || '').trim()).filter(Boolean));
      const empAddresses = new Set(employeeRows.map(r => ((r.extra || {}).address || '').trim().toLowerCase()).filter(Boolean));
      rows.forEach((r, i) => {
        const extra = r.extra || {};
        const b = (extra.bank_account || '').trim(), addr = (extra.address || '').trim().toLowerCase();
        if (b && empBanks.has(b)) findings.push({ type: 'vendor_matches_employee_bank', row_indices: [i],
          detail: 'Vendor bank account matches an employee bank account — possible fictitious vendor.' });
        if (addr && empAddresses.has(addr)) findings.push({ type: 'vendor_matches_employee_address', row_indices: [i],
          detail: 'Vendor address matches an employee address — possible fictitious vendor.' });
      });
    }
    return findings;
  }

  // ------------------------------------------------------------------ expense
  function analyzeExpenses(rows, approvalThreshold) {
    const findings = [];
    if (approvalThreshold) {
      rows.forEach((r, i) => {
        const amt = r.amount || 0, gap = approvalThreshold - amt;
        if (gap > 0 && gap <= approvalThreshold * 0.05) findings.push({ type: 'just_under_threshold', row_indices: [i],
          detail: 'Amount ' + amt.toLocaleString() + ' is just under the ' + approvalThreshold.toLocaleString() + ' approval threshold.' });
      });
    }
    detectRoundNumbers(rows, 500, 2000).forEach(i => findings.push({ type: 'round_number_expense', row_indices: [i], detail: 'Round-number expense claim.' }));
    rows.forEach((r, i) => { const d = parseDate(r.date); if (d && (d.getDay() === 0 || d.getDay() === 6)) findings.push({ type: 'weekend_expense', row_indices: [i], detail: 'Expense dated on a weekend.' }); });
    detectDuplicates(rows, ['party', 'amount', 'date']).forEach(dup => findings.push({ type: 'duplicate_expense_claim', row_indices: dup.row_indices,
      detail: 'Same claimant, amount and date submitted more than once.' }));
    return findings;
  }

  // ------------------------------------------------------------------ revenue
  function analyzeRevenue(rows, periodEndDate) {
    const findings = [];
    const pe = periodEndDate ? parseDate(periodEndDate) : null;
    if (pe) {
      rows.forEach((r, i) => {
        const d = parseDate(r.date), amt = r.amount || 0;
        if (d) { const days = Math.round((pe - d) / 86400000); if (days >= 0 && days <= 3 && amt > 0)
          findings.push({ type: 'period_end_spike', row_indices: [i], detail: 'Revenue recognised in the last 3 days of the period — check cut-off and channel-stuffing risk.' }); }
      });
    }
    detectRoundNumbers(rows, 100000, 500000).forEach(i => findings.push({ type: 'round_number_revenue', row_indices: [i], detail: 'Unusually round revenue figure.' }));
    return findings;
  }

  // --------------------------------------------------------------------- cash
  function analyzeCash(rows) {
    const findings = [];
    detectRoundNumbers(rows, 1000, 5000).forEach(i => {
      const r = rows[i], desc = (r.description || '').toLowerCase();
      if ((r.amount || 0) < 0 || desc.indexOf('withdraw') !== -1 || desc.indexOf('cash') !== -1)
        findings.push({ type: 'round_cash_movement', row_indices: [i], detail: 'Round-number cash movement.' });
    });
    return findings;
  }

  // ---------------------------------------------------------- Beneish M-Score
  function beneishMScore(curr, prior) {
    const sd = (a, b) => (a !== null && a !== undefined && b) ? a / b : null;
    const dsri = sd(sd(curr.receivables, curr.sales), sd(prior.receivables, prior.sales));
    const gmC = sd((curr.sales || 0) - (curr.cogs || 0), curr.sales);
    const gmP = sd((prior.sales || 0) - (prior.cogs || 0), prior.sales);
    const gmi = sd(gmP, gmC);
    const aqiC = curr.total_assets ? 1 - sd((curr.current_assets || 0) + (curr.ppe || 0), curr.total_assets) : null;
    const aqiP = prior.total_assets ? 1 - sd((prior.current_assets || 0) + (prior.ppe || 0), prior.total_assets) : null;
    const aqi = sd(aqiC, aqiP);
    const sgi = sd(curr.sales, prior.sales);
    const depiP = sd(prior.depreciation, (prior.depreciation || 0) + (prior.ppe || 0));
    const depiC = sd(curr.depreciation, (curr.depreciation || 0) + (curr.ppe || 0));
    const depi = sd(depiP, depiC);
    const sgai = sd(sd(curr.sga, curr.sales), sd(prior.sga, prior.sales));
    const lvgiC = sd((curr.current_liabilities || 0) + (curr.total_debt || 0), curr.total_assets);
    const lvgiP = sd((prior.current_liabilities || 0) + (prior.total_debt || 0), prior.total_assets);
    const lvgi = sd(lvgiC, lvgiP);
    const tata = sd((curr.net_income || 0) - (curr.cfo || 0), curr.total_assets);

    const components = { dsri, gmi, aqi, sgi, depi, sgai, lvgi, tata };
    if (Object.values(components).some(v => v === null || v === undefined)) {
      return { m_score: null, components, note: 'Some ratios could not be computed — check that every required figure was supplied for both periods.' };
    }
    const m = -4.84 + 0.920 * dsri + 0.528 * gmi + 0.404 * aqi + 0.892 * sgi + 0.115 * depi - 0.172 * sgai + 4.679 * tata - 0.327 * lvgi;
    const interpretation = m > -1.78 ? 'Possible earnings manipulation indicated by this screen' : 'No strong indication from this screen';
    const rounded = {}; Object.keys(components).forEach(k => { rounded[k] = Math.round(components[k] * 1000) / 1000; });
    return { m_score: Math.round(m * 1000) / 1000, components: rounded, threshold: -1.78, interpretation,
      note: "Beneish (1999) M-Score — a statistical screen used in forensic accounting, not a conclusion of fraud. " +
            "Source: Beneish, M.D. (1999), 'The Detection of Earnings Manipulation', Financial Analysts Journal." };
  }

  // --------------------------------------------------------------- dispatcher
  const DETECTORS_BY_DATASET_TYPE = {
    journal_entries: ['benford', 'round_number', 'unusual_je', 'related_party'],
    payments: ['benford', 'duplicate_payment', 'round_number', 'related_party'],
    invoices: ['benford', 'duplicate_invoice', 'round_number', 'related_party'],
    payroll: ['payroll_fraud'],
    vendors: ['vendor_fraud'],
    expenses: ['expense_fraud'],
  };

  function runAllDetectors(datasetType, rows, params, relatedRows) {
    params = params || {};
    const out = [];
    const amounts = rows.map(r => r.amount).filter(Boolean);
    const applicable = DETECTORS_BY_DATASET_TYPE[datasetType] || [];

    if (applicable.indexOf('benford') !== -1) {
      const b = benfordAnalysis(amounts);
      if (b.conformity === 'Marginally acceptable' || b.conformity === 'Nonconformity') {
        out.push({ detector: 'benford', severity: b.conformity === 'Marginally acceptable' ? 'Medium' : 'High',
          title: "Benford's Law — " + b.conformity, description: b.note, row_indices: [], evidence: b });
      }
    }
    if (applicable.indexOf('duplicate_payment') !== -1) {
      detectDuplicates(rows, ['party', 'amount']).forEach(dup => out.push({ detector: 'duplicate_payment', severity: 'High',
        title: 'Possible duplicate payment — ' + dup.count + ' occurrences', row_indices: dup.row_indices,
        description: 'Same party and amount appear ' + dup.count + ' times.', evidence: dup }));
    }
    if (applicable.indexOf('duplicate_invoice') !== -1) {
      detectDuplicates(rows, ['party', 'reference_no']).forEach(dup => out.push({ detector: 'duplicate_invoice', severity: 'High',
        title: 'Possible duplicate invoice — ' + dup.count + ' occurrences', row_indices: dup.row_indices,
        description: 'Same party and reference number appear ' + dup.count + ' times.', evidence: dup }));
    }
    if (applicable.indexOf('round_number') !== -1) {
      detectRoundNumbers(rows, params.round_to || 1000, params.min_amount || 10000).forEach(i => out.push({ detector: 'round_number',
        severity: 'Low', title: 'Round-number transaction', row_indices: [i], description: 'Amount is an unusually round figure.', evidence: {} }));
    }
    if (applicable.indexOf('related_party') !== -1 && params.related_party_names) {
      detectRelatedParty(rows, params.related_party_names).forEach(i => out.push({ detector: 'related_party', severity: 'Medium',
        title: 'Related-party transaction', row_indices: [i], description: 'Counterparty matches the related-party list supplied.', evidence: {} }));
    }
    if (applicable.indexOf('unusual_je') !== -1) {
      rows.forEach((r, i) => { const s = scoreJournalEntry(r); if (s.risk_level !== 'Low') out.push({ detector: 'unusual_je',
        severity: s.risk_level, title: 'Unusual journal entry', row_indices: [i], description: s.factors.map(f => f.note).join('; '), evidence: s }); });
    }
    if (applicable.indexOf('payroll_fraud') !== -1) {
      analyzePayroll(rows).forEach(f => { const isGhost = f.type === 'duplicate_bank_account' || f.type === 'duplicate_pan';
        out.push({ detector: isGhost ? 'ghost_employee' : 'payroll_fraud', severity: isGhost ? 'High' : 'Medium',
          title: f.type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), row_indices: f.row_indices, description: f.detail, evidence: {} }); });
    }
    if (applicable.indexOf('vendor_fraud') !== -1) {
      analyzeVendors(rows, relatedRows).forEach(f => out.push({ detector: 'vendor_fraud', severity: 'High',
        title: f.type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), row_indices: f.row_indices, description: f.detail, evidence: {} }));
    }
    if (applicable.indexOf('expense_fraud') !== -1) {
      analyzeExpenses(rows, params.approval_threshold).forEach(f => { const sev = f.type === 'duplicate_expense_claim' ? 'High' : (f.type === 'just_under_threshold' ? 'Medium' : 'Low');
        out.push({ detector: 'expense_fraud', severity: sev, title: f.type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), row_indices: f.row_indices, description: f.detail, evidence: {} }); });
    }
    return out;
  }

  global.ForensicEngines = {
    R2, benfordAnalysis, detectDuplicates, detectRoundNumbers, detectRelatedParty, scoreJournalEntry,
    analyzePayroll, analyzeVendors, analyzeExpenses, analyzeRevenue, analyzeCash, beneishMScore, runAllDetectors,
  };
})(window);
