/* ============================================================================
   CA Automator — browser calculation engines.

   These mirror server/engines.py line for line so every module computes the same
   figures with no connection. The server recomputes on sync and is authoritative
   for report output; if the two ever disagree, the app says so rather than
   silently preferring one.

   Rates come from the cached rate master, never from constants in this file.
   ========================================================================= */
(function (global) {
  'use strict';

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

  /** Rate lookup over the cached rate_master rows. */
  function rateSet(rates, domain, fy, appliesTo) {
    const out = {}, unverified = [];
    (rates || []).forEach(r => {
      if (r.domain !== domain) return;
      if (fy && r.fiscal_year_label && r.fiscal_year_label !== fy) return;
      if (appliesTo && r.applies_to && r.applies_to !== appliesTo && r.applies_to !== 'all') return;
      out[r.code] = r;
      if (r.verification_status === 'UNVERIFIED') unverified.push(domain + '.' + r.code);
    });
    return { rates: out, unverified };
  }
  const val = (rs, code, dflt) => (rs[code] && rs[code].value_num != null ? rs[code].value_num : dflt);

  /** Every '<prefix>_<n>_ceiling' row present, ascending by n — a slab ladder
      whose length comes from Rate Master, not a fixed count in code. */
  function dynamicSlabs(rs, prefix) {
    const re = new RegExp('^' + prefix + '_(\\d+)_ceiling$');
    return Object.keys(rs).map(code => { const m = code.match(re); return m ? [+m[1], rs[code]] : null; })
      .filter(Boolean).sort((a, b) => a[0] - b[0]).map(t => t[1]);
  }

  /** Every '<prefix><slug>' row present — a set of named alternatives (company
      rate categories, presumptive-tax areas) rather than a marginal ladder. */
  function dynamicCategories(rs, prefix) {
    return Object.keys(rs).filter(code => code.startsWith(prefix) && code !== prefix)
      .map(code => ({ code, slug: code.slice(prefix.length), label: rs[code].label, row: rs[code] }))
      .sort((a, b) => a.label < b.label ? -1 : a.label > b.label ? 1 : 0);
  }

  // ------------------------------------------------------- personal income tax
  function personalTax(rates, fy, status, taxableIncome, opts) {
    opts = opts || {};
    const { rates: R, unverified } = rateSet(rates, 'income_tax', fy, status);
    // SSF contributors: the 1% social security tax on slab 1 does not apply to
    // employees registered under the Contribution-Based Social Security Fund —
    // it would otherwise double up with their actual SSF contribution.
    const ssfRow = opts.ssf_contributor ? R.ssf_rebate_slab1 : null;
    // Senior citizens: slab 1's ceiling is widened; stacks with the SSF concession.
    const seniorExtra = opts.is_senior_citizen ? (val(R, 'senior_citizen_slab1_extra_pct', 0) || 0) : 0;
    const slabs = dynamicSlabs(R, 'slab').map((row, idx) => {
      let band = N(row.value_num);
      let rate = parseFloat(row.value_text || 0);
      if (idx === 0) {
        if (seniorExtra) band = band * (1 + seniorExtra);
        if (ssfRow) rate = N(ssfRow.value_num);
      }
      return [band, rate];
    });
    const topRate = R.slab_top ? parseFloat(R.slab_top.value_text || 0) : 0;

    let remaining = Math.max(0, N(taxableIncome)), tax = 0, floor = 0;
    const lines = [];
    slabs.forEach(([ceiling, rate], idx) => {
      const width = idx ? ceiling - floor : ceiling;
      if (width <= 0 || remaining <= 0) { if (width > 0) floor = ceiling; return; }
      const chunk = Math.min(remaining, width);
      lines.push({ band: fmtBand(floor, ceiling), rate, income: R2(chunk), tax: R2(chunk * rate) });
      tax += chunk * rate; remaining -= chunk; floor = ceiling;
    });
    if (remaining > 0) {
      lines.push({ band: 'Above ' + floor.toLocaleString(), rate: topRate, income: R2(remaining), tax: R2(remaining * topRate) });
      tax += remaining * topRate;
    }
    let rebate = 0;
    if (opts.is_resident_woman && status === 'individual') rebate = tax * (val(R, 'female_rebate', 0) || 0);
    return { slab_lines: lines, tax_before_rebate: R2(tax), rebate: R2(rebate), tax_payable: R2(tax - rebate), unverified };
  }
  const fmtBand = (a, b) => a.toLocaleString() + ' – ' + b.toLocaleString();

  // Mirrors server/engines.py's personal_income_tax() exactly.
  function personalIncomeTax(rates, fy, d) {
    const person = d.person || {};
    const incomes = d.incomes || {};
    const deductions = d.deductions || {};
    const status = person.status || 'individual';

    const { rates: R, unverified } = rateSet(rates, 'income_tax', fy, status);

    const employment = Number(incomes.employment_assessable) || 0;
    const business = Number(incomes.business_net_profit) || 0;
    const rentalGross = Number(incomes.rental_gross) || 0;
    const rentalExpenses = Math.min(Number(incomes.rental_expenses) || 0, rentalGross);
    const rentalNet = rentalGross - rentalExpenses;
    const other = Number(incomes.other_income) || 0;
    const grossIncome = employment + business + rentalNet + other;

    const retirementActual = Number(deductions.retirement_contribution) || 0;
    const cap = val(R, 'retirement_deduction_cap', 0) || 0;
    const pctLimb = (val(R, 'retirement_deduction_pct', 0) || 0) * grossIncome;
    const retirementDeduction = pctLimb ? Math.min(retirementActual, cap, pctLimb) : Math.min(retirementActual, cap);

    const lifeIns = Math.min(Number(deductions.life_insurance_premium) || 0, val(R, 'insurance_premium_cap', 0) || 0);
    const healthIns = Math.min(Number(deductions.health_insurance_premium) || 0, val(R, 'health_insurance_cap', 0) || 0);

    const adjusted = Math.max(0, grossIncome - retirementDeduction - lifeIns - healthIns);

    const donationActual = Number(deductions.donations) || 0;
    const donPct = R.donation_cap_pct ? val(R, 'donation_cap_pct', null) : null;
    const donCeiling = R.donation_cap_amount ? val(R, 'donation_cap_amount', null) : null;
    let donationAllowed = 0;
    if (donationActual) {
      const limbs = [donationActual];
      if (donPct != null) limbs.push(adjusted * donPct);
      if (donCeiling != null) limbs.push(donCeiling);
      donationAllowed = Math.max(0, Math.min(...limbs));
    }

    const taxableIncome = Math.max(0, adjusted - donationAllowed);

    const opts = { is_resident_woman: !!person.is_resident_woman, ssf_contributor: !!person.ssf_contributor,
      is_senior_citizen: !!person.is_senior_citizen };
    const slab = personalTax(rates, fy, status, taxableIncome, opts);

    return {
      person,
      incomes: { employment: R2(employment), business: R2(business), rental_gross: R2(rentalGross),
        rental_expenses: R2(rentalExpenses), rental_net: R2(rentalNet), other: R2(other), gross_income: R2(grossIncome) },
      deductions: { retirement: R2(retirementDeduction), life_insurance: R2(lifeIns), health_insurance: R2(healthIns),
        donation: R2(donationAllowed), donation_disallowed: R2(donationActual - donationAllowed),
        total: R2(retirementDeduction + lifeIns + healthIns + donationAllowed) },
      adjusted_income: R2(adjusted), taxable_income: R2(taxableIncome),
      slab_lines: slab.slab_lines, tax_before_rebate: slab.tax_before_rebate, rebate: slab.rebate,
      tax_payable: slab.tax_payable, unverified: Array.from(new Set(unverified.concat(slab.unverified))).sort(),
    };
  }

  // ---------------------------------------------------------------- salary TDS
  function salaryTds(rates, fy, e) {
    const pay = rateSet(rates, 'payroll', fy);
    const status = e.status || 'individual';
    const it = rateSet(rates, 'income_tax', fy, status);
    const P = pay.rates, I = it.rates;
    const taxOpts = { is_resident_woman: e.is_resident_woman, ssf_contributor: e.ssf_contributor,
                       is_senior_citizen: e.is_senior_citizen };

    const months = N(e.months) || 12;
    const basic = N(e.basic_monthly) * months;
    const allow = N(e.allowance_monthly) * months;
    const other = N(e.other_annual), bonus = N(e.bonus_annual);
    const overtime = N(e.overtime_annual), commission = N(e.commission_annual);
    const leaveEncashment = N(e.leave_encashment_annual);
    const cashPay = basic + allow + other + bonus + overtime + commission + leaveEncashment;

    const erPct = e.ssf_contributor ? (val(P, 'ssf_employer', 0) || 0) : (val(P, 'pf_employer', 0) || 0);
    const eePct = e.ssf_contributor ? (val(P, 'ssf_employee', 0) || 0) : (val(P, 'pf_employee', 0) || 0);
    const employerContrib = basic * erPct;
    const employeeContrib = e.employee_contrib_override != null && e.employee_contrib_override !== ''
      ? N(e.employee_contrib_override) : basic * eePct;

    // Perquisites
    const perq = {};
    if (e.vehicle_facility) {
      const vPct = val(P, 'vehicle_perquisite_monthly_pct', 0) || 0;
      perq.vehicle = R2(N(e.vehicle_cost) * vPct * months);
    }
    if (e.accommodation_facility) {
      const aPct = val(P, 'accommodation_perquisite_pct', 0) || 0;
      const baseForAccom = cashPay + employerContrib;
      perq.accommodation = R2(Math.max(N(e.accommodation_rent_paid), baseForAccom * aPct));
    }
    const loanAmt = N(e.employer_loan_amount);
    if (loanAmt) {
      const marketRate = val(P, 'employer_loan_market_rate', 0) || 0;
      perq.employer_loan = R2(loanAmt * Math.max(0, marketRate - N(e.employer_loan_rate_charged)));
    }
    const otherPerq = N(e.other_perquisites_annual);
    if (otherPerq) perq.other = R2(otherPerq);
    const perqTotal = Object.keys(perq).reduce((s, k) => s + perq[k], 0);

    // Foreign posting
    const foreignGross = e.is_foreign_posting ? N(e.foreign_allowance_annual) : 0;
    const foreignExemptPct = foreignGross ? (val(I, 'foreign_allowance_exempt_pct', 0) || 0) : 0;
    const foreignExempt = R2(foreignGross * foreignExemptPct);
    const foreignTaxable = R2(foreignGross - foreignExempt);

    const gross = cashPay + employerContrib + perqTotal + foreignTaxable;

    const cap = val(I, 'retirement_deduction_cap', 0) || 0;
    const pctLimb = (val(I, 'retirement_deduction_pct', 0) || 0) * gross;
    const totalRetirement = employerContrib + employeeContrib;
    const retirement = pctLimb ? Math.min(totalRetirement, cap, pctLimb) : Math.min(totalRetirement, cap);

    const ins = Math.min(N(e.insurance_premium), val(I, 'insurance_premium_cap', 0) || 0);
    const health = Math.min(N(e.health_premium), val(I, 'health_insurance_cap', 0) || 0);
    const category = (e.remote_area_category || '').trim().toLowerCase();
    let remoteCap = category ? val(I, 'remote_area_cap_' + category, null) : null;
    if (remoteCap == null) remoteCap = val(I, 'remote_area_cap', 0) || 0;
    const remote = Math.min(N(e.remote_area_allowance), remoteCap);

    const deductionTotal = retirement + ins + health + remote;
    const taxable = Math.max(0, gross - deductionTotal);

    const otherIncome = N(e.other_employer_income_annual);
    const combinedTaxable = taxable + otherIncome;
    const tCombined = personalTax(rates, fy, status, combinedTaxable, taxOpts);
    let tFloor = null, attributableTax;
    if (otherIncome > 0) {
      tFloor = personalTax(rates, fy, status, otherIncome, taxOpts);
      attributableTax = Math.max(0, tCombined.tax_payable - tFloor.tax_payable);
    } else {
      attributableTax = tCombined.tax_payable;
    }

    const medPct = val(I, 'medical_credit_pct', 0) || 0;
    const medCeiling = val(I, 'medical_credit_ceiling', 0) || 0;
    const medicalCredit = Math.min(N(e.approved_medical_expense_annual) * medPct, medCeiling);
    const annualTax = Math.max(0, attributableTax - medicalCredit);

    const retApplicable = !!(e.is_retirement_year && N(e.retirement_lumpsum_amount));
    let ret = { applicable: retApplicable, lumpsum: 0, exempt: 0, taxable: 0, rate: 0, tax: 0 };
    if (retApplicable) {
      const lumpsum = N(e.retirement_lumpsum_amount);
      const exemptThreshold = val(I, 'retirement_exempt_threshold', 0) || 0;
      const exemptPct = val(I, 'retirement_exempt_pct', 0) || 0;
      const exempt = Math.max(exemptThreshold, lumpsum * exemptPct);
      const rtTaxable = Math.max(0, lumpsum - exempt);
      const rtRate = val(I, 'retirement_lumpsum_rate', 0) || 0;
      ret = { applicable: true, lumpsum: R2(lumpsum), exempt: R2(Math.min(exempt, lumpsum)),
              taxable: R2(rtTaxable), rate: rtRate, tax: R2(rtTaxable * rtRate) };
    }

    const totalTaxPayable = annualTax + ret.tax;
    const already = N(e.tds_already_deducted);
    const remMonths = Math.max(1, N(e.remaining_months) || months);
    const balance = totalTaxPayable - already;

    return {
      name: e.name, designation: e.designation,
      basic: R2(basic), allowance: R2(allow), other: R2(other), bonus: R2(bonus),
      overtime: R2(overtime), commission: R2(commission), leave_encashment: R2(leaveEncashment),
      cash_pay: R2(cashPay),
      employer_contribution: R2(employerContrib), employee_contribution: R2(employeeContrib),
      perquisites: Object.assign({}, perq, { total: R2(perqTotal) }),
      foreign: { gross: R2(foreignGross), exempt: foreignExempt, taxable: foreignTaxable, exempt_pct: foreignExemptPct },
      gross_assessable: R2(gross),
      deductions: {
        retirement: R2(retirement), life_insurance: R2(ins), health_insurance: R2(health),
        remote_area: R2(remote), total: R2(deductionTotal)
      },
      taxable_income: R2(taxable),
      other_employer_income: R2(otherIncome),
      combined_taxable_income: R2(combinedTaxable),
      slab_lines: tCombined.slab_lines,
      tax_on_combined: tCombined.tax_payable,
      tax_on_other_employer: tFloor ? tFloor.tax_payable : 0,
      attributable_tax: R2(attributableTax),
      medical_credit: R2(medicalCredit),
      annual_tax: R2(annualTax), rebate: tCombined.rebate,
      retirement: ret,
      total_tax_payable: R2(totalTaxPayable),
      tds_already_deducted: R2(already), balance_to_deduct: R2(balance),
      monthly_tds: R2(balance / remMonths),
      unverified: Array.from(new Set(pay.unverified.concat(it.unverified, tCombined.unverified)))
    };
  }

  // -------------------------------------------------------------- depreciation
  // Nepali month 1 = Shrawan (FY start). Additions in the first six months absorb
  // at the full pool rate, the next three at two-thirds, the last three at a third.
  const TRANCHE_BY_MONTH = { 1:'q1',2:'q1',3:'q1',4:'q1',5:'q1',6:'q1',7:'q2',8:'q2',9:'q2',10:'q3',11:'q3',12:'q3' };

  function depreciation(rates, fy, pools, opts) {
    opts = opts || {};
    const { rates: R, unverified } = rateSet(rates, 'depreciation', fy);
    const repairCeiling = val(R, 'repair_ceiling', 0) || 0;
    const deMinimis = val(R, 'de_minimis', 0) || 0;
    const extraThird = val(R, 'special_extra_third', 0) || 0;
    const tranche = { q1: val(R,'tranche_q1',1)||1, q2: val(R,'tranche_q2',0.6667)||0.6667, q3: val(R,'tranche_q3',0.3333)||0.3333 };

    const results = [], totals = { opening:0, absorbed:0, disposals:0, depreciation:0, closing:0 };
    (pools || []).forEach(p => {
      const key = String(p.pool || 'd').toLowerCase();
      const rate = val(R, 'pool_' + key + '_rate', 0) || 0;
      const opening = N(p.opening_base);

      let absorbed = 0, unabsorbed = 0;
      const addLines = (p.additions || []).map(a => {
        const amt = N(a.amount);
        const t = TRANCHE_BY_MONTH[parseInt(a.bs_month, 10) || 1] || 'q1';
        const factor = tranche[t];
        absorbed += amt * factor; unabsorbed += amt * (1 - factor);
        return { desc: a.desc || '', amount: R2(amt), bs_month: a.bs_month, tranche: t, factor, absorbed: R2(amt * factor) };
      });

      const disposals = N(p.disposals);
      const repairs = N(p.repairs);
      const ceilingAmount = (opening + absorbed) * repairCeiling;
      const repairAllowed = Math.min(repairs, ceilingAmount);
      const repairCapitalised = Math.max(0, repairs - repairAllowed);

      const base = opening + absorbed + repairCapitalised - disposals;
      let dep = 0, gain = 0, closing = 0, note = '';

      if (base < 0) {
        gain = Math.abs(base);
        note = 'Negative base — gain on disposal is taxable and the pool closes.';
      } else if (base > 0 && base <= deMinimis) {
        dep = base;
        note = 'Base below the de-minimis threshold — written off in full.';
      } else {
        dep = base * rate;
        if (opts.special_industry && extraThird) { dep *= (1 + extraThird); note = 'Extra depreciation applied for special industry.'; }
        dep *= (N(p.business_use_pct) || 100) / 100;
        closing = base - dep;
      }

      results.push({
        pool: key.toUpperCase(), rate, opening_base: R2(opening), additions: addLines,
        absorbed_additions: R2(absorbed), unabsorbed_carried_forward: R2(unabsorbed),
        repairs_claimed: R2(repairAllowed), repairs_capitalised: R2(repairCapitalised),
        repair_ceiling_amount: R2(ceilingAmount), disposals: R2(disposals),
        depreciation_base: R2(Math.max(0, base)), depreciation: R2(dep),
        gain_on_disposal: R2(gain), closing_base: R2(closing), note
      });
      totals.opening += opening; totals.absorbed += absorbed; totals.disposals += disposals;
      totals.depreciation += dep; totals.closing += Math.max(0, closing);
    });
    Object.keys(totals).forEach(k => totals[k] = R2(totals[k]));
    return { pools: results, totals, unverified };
  }

  // ------------------------------------------------------- business income tax
  function fyStartYear(label) {
    const m = String(label || '').trim().match(/^(\d+)/);
    return m ? parseInt(m[1], 10) : null;
  }

  function businessTax(rates, fy, d) {
    const { rates: R, unverified } = rateSet(rates, 'income_tax', fy, 'company');
    const entity = d.entity || 'company';

    const presumptive = d.presumptive || {};
    if (presumptive.elected) {
      const area = (presumptive.area_category || 'metro').trim().toLowerCase();
      const turnover = N(presumptive.turnover);
      const brackets = dynamicSlabs(R, 'presumptive_' + area);
      let chosen = brackets.find(row => turnover <= N(row.value_num)) || null;
      if (!chosen && brackets.length) chosen = brackets[brackets.length - 1];
      const flatTax = (chosen && chosen.value_text) ? parseFloat(chosen.value_text) : 0;
      const tds = N(d.tds_credit), advance = N(d.advance_tax_paid);
      const net = flatTax - tds - advance;
      return {
        regime: 'presumptive',
        presumptive: {
          area_category: area, turnover: R2(turnover),
          bracket_ceiling: chosen ? R2(chosen.value_num) : null,
          bracket_label: chosen ? chosen.label : null,
          flat_tax: R2(flatTax), brackets_found: brackets.length,
          note: brackets.length ? '' : "No presumptive brackets configured for area '" + area +
            "' — add income_tax.presumptive_" + area + "_<n>_ceiling rows in Rate Master."
        },
        taxable_income: null, tax_liability: R2(flatTax),
        tds_credit: R2(tds), advance_tax_paid: R2(advance),
        net_payable: net >= 0 ? R2(net) : 0, refund_due: net < 0 ? R2(-net) : 0,
        unverified
      };
    }

    const pbt = N(d.profit_before_tax);
    const adds = (d.additions || []).map(a => ({ desc: a.desc || '', amount: R2(a.amount) }));
    const dedn = (d.deductions || []).map(x => ({ desc: x.desc || '', amount: R2(x.amount) }));
    const addTotal = adds.reduce((s, a) => s + a.amount, 0);
    const dedTotal = dedn.reduce((s, a) => s + a.amount, 0);
    const bookDep = N(d.book_depreciation), taxDep = N(d.tax_depreciation);
    const adjusted = pbt + addTotal + bookDep - dedTotal - taxDep;

    const donationActual = N(d.donations);
    const donPct = val(R, 'donation_cap_pct', null);
    const donCeiling = val(R, 'donation_cap_amount', null);
    let donationAllowed = 0;
    if (donationActual) {
      const limbs = [donationActual];
      if (donPct) limbs.push(Math.max(0, adjusted) * donPct);
      if (donCeiling) limbs.push(donCeiling);
      donationAllowed = Math.max(0, Math.min(...limbs));
    }
    const afterDonation = adjusted - donationAllowed;

    const category = (d.rate_category || 'general').trim().toLowerCase();
    let cfYears = val(R, 'loss_carry_forward_years_' + category, null);
    if (cfYears == null) cfYears = val(R, 'loss_carry_forward_years', 7) || 7;
    const thisStart = fyStartYear(fy);
    let remainingProfit = Math.max(0, afterDonation);
    let bfTotal = 0, usedTotal = 0, expiredTotal = 0;
    const losses = (d.brought_forward_losses || []).map(l => {
      const amt = N(l.amount), label = l.fy_label || '';
      const start = fyStartYear(label);
      const age = (thisStart != null && start != null) ? thisStart - start : null;
      const expired = age != null && age >= cfYears;
      bfTotal += amt;
      let use = 0;
      if (!expired) { use = Math.min(remainingProfit, amt); remainingProfit -= use; usedTotal += use; }
      else expiredTotal += amt;
      return { fy_label: label, amount: R2(amt), age_years: age, expired, utilised: R2(use) };
    });
    const taxable = Math.max(0, remainingProfit);

    let taxBeforeHoliday, rate = null, rateLabel, slabLines, unv = unverified, rateCategories = [];
    if (entity === 'company') {
      const categories = dynamicCategories(R, 'company_rate_');
      let chosenCat = categories.find(c => c.slug === category);
      if (!chosenCat && categories.length) chosenCat = categories.find(c => c.slug === 'general') || categories[0];
      rate = chosenCat ? N(chosenCat.row.value_num) : 0;
      rateLabel = chosenCat ? chosenCat.label : 'No company rate category configured in Rate Master';
      taxBeforeHoliday = taxable * rate;
      slabLines = [{ band: 'Flat', rate, income: R2(taxable), tax: R2(taxBeforeHoliday) }];
      rateCategories = categories.map(c => ({ slug: c.slug, label: c.label, rate: c.row.value_num }));
    } else {
      const pt = personalTax(rates, fy, entity, taxable, {});
      taxBeforeHoliday = pt.tax_payable; rateLabel = 'Personal slab rates'; slabLines = pt.slab_lines;
      unv = Array.from(new Set(unverified.concat(pt.unverified)));
    }

    const holidayPct = Math.max(0, Math.min(1, N(d.tax_holiday_pct)));
    const holidayRelief = taxBeforeHoliday * holidayPct;
    const tax = taxBeforeHoliday - holidayRelief;

    const tds = N(d.tds_credit), advance = N(d.advance_tax_paid);
    const net = tax - tds - advance;
    return {
      regime: 'standard',
      profit_before_tax: R2(pbt), additions: adds, add_total: R2(addTotal),
      deductions: dedn, deduction_total: R2(dedTotal),
      book_depreciation: R2(bookDep), tax_depreciation: R2(taxDep),
      adjusted_income: R2(adjusted),
      donations: { actual: R2(donationActual), allowed: R2(donationAllowed), disallowed: R2(donationActual - donationAllowed) },
      losses, brought_forward_total: R2(bfTotal), loss_utilised: R2(usedTotal),
      loss_expired: R2(expiredTotal), loss_carry_forward_years: cfYears,
      taxable_income: R2(taxable),
      rate_category: category, rate_categories: rateCategories,
      rate, rate_label: rateLabel, slab_lines: slabLines,
      tax_before_holiday: R2(taxBeforeHoliday), tax_holiday_pct: holidayPct, tax_holiday_relief: R2(holidayRelief),
      tax_liability: R2(tax), tds_credit: R2(tds), advance_tax_paid: R2(advance),
      net_payable: net >= 0 ? R2(net) : 0, refund_due: net < 0 ? R2(-net) : 0, unverified: unv
    };
  }

  // ----------------------------------------------------------- fees & interest
  function penalty(rates, fy, d) {
    const it = rateSet(rates, 'penalty', fy), vt = rateSet(rates, 'vat', fy);
    const R = it.rates, V = vt.rates, lines = [];
    const add = (category, head, basis, amount) => { if (amount) lines.push({ category, head, basis, amount: R2(amount) }); };

    // ── Sec 117 — filing and record-keeping fees ──
    if (d.estimate_return_filed === false) {
      add('income_tax', 'Sec 117(1) — estimated return (D-01) not filed', 'Fixed fee', val(R, 's117_estimate_fee', 0) || 0);
    }
    if (d.books_maintained === false) {
      add('income_tax', 'Sec 117 — books of account not maintained', 'Fixed fee', val(R, 's117_books_fee', 0) || 0);
    }
    const m = N(d.months_late_return);
    if (m > 0) {
      const pct = val(R, 's117_income_return_pct', 0) || 0;
      const perMonth = val(R, 's117_income_return_month', 0) || 0;
      const byIncome = N(d.assessable_income) * pct, byMonth = perMonth * m;
      add('income_tax', 'Sec 117(1)(c) — late income return (D-03)',
        'Higher of ' + (pct * 100).toFixed(2) + '% of assessable income (' + byIncome.toLocaleString() +
        ') and ' + perMonth.toLocaleString() + ' × ' + m + ' months (' + byMonth.toLocaleString() + ')',
        Math.max(byIncome, byMonth));
    }

    // ── Sec 118 — instalment (advance tax) interest: auto + manual ──
    const instRate = val(R, 's118_interest_rate', 0) || 0;
    const estimate = N(d.advance_tax_estimate);
    if (estimate) {
      [['Poush (1st instalment — 40% due)', 'instalment_1_pct', 'paid_by_poush', 6],
       ['Chaitra (2nd instalment — 70% due)', 'instalment_2_pct', 'paid_by_chaitra', 3]]
        .forEach(([label, pctCode, paidKey, monthsLeft]) => {
          const requiredPct = val(R, pctCode, 0) || 0;
          const required = estimate * requiredPct;
          const paid = N(d[paidKey]);
          const shortfall = Math.max(0, required - paid);
          const interest = shortfall * instRate * monthsLeft / 12;
          add('income_tax', 'Sec 118 — instalment interest, ' + label,
            'Required ' + (requiredPct * 100).toFixed(0) + '% of ' + estimate.toLocaleString() + ' (' + required.toLocaleString() +
            ') less paid (' + paid.toLocaleString() + ') = shortfall ' + shortfall.toLocaleString() +
            ' × ' + (instRate * 100).toFixed(0) + '% × ' + monthsLeft + '/12', interest);
        });
      // The 3rd instalment (100% by Ashad-end) has no months left to run interest
      // under Sec 118 — a shortfall still open there becomes an ordinary Sec 119
      // late-payment case (tax_unpaid below), not this section.
    }
    (d.instalment_shortfalls || []).forEach(s => {
      const amt = N(s.shortfall), mo = N(s.months);
      add('income_tax', 'Sec 118 — instalment interest, manual entry (' + (s.label || '') + ')',
        amt.toLocaleString() + ' × ' + (instRate * 100).toFixed(0) + '% × ' + mo + '/12', amt * instRate * mo / 12);
    });

    // ── Sec 119 — interest on tax assessed/due but paid late ──
    const unpaid = N(d.tax_unpaid), mu = N(d.months_unpaid);
    if (unpaid && mu) {
      const r = val(R, 's119_interest_rate', 0) || 0;
      add('income_tax', 'Sec 119 — interest on unpaid tax',
        unpaid.toLocaleString() + ' × ' + (r * 100).toFixed(0) + '% × ' + mu + '/12', unpaid * r * mu / 12);
    }

    // ── Sec 120 — false or misleading statement ──
    const understatement = N(d.understatement_amount);
    if (understatement) {
      const knowing = d.understatement_intent === 'knowing';
      const r = val(R, knowing ? 's120_false_statement_knowing' : 's120_false_statement', 0) || 0;
      add('income_tax', 'Sec 120 — false or misleading statement (' + (knowing ? 'knowing/reckless' : 'careless') + ')',
        understatement.toLocaleString() + ' × ' + (r * 100).toFixed(0) + '% of the understatement', understatement * r);
    }

    // ── TDS — withholding agent's own exposure ──
    const tdsShort = N(d.tds_shortfall), tdsMo = N(d.tds_months);
    if (tdsShort && tdsMo) {
      const r = val(R, 'tds_shortfall_interest_rate', 0) || 0;
      add('tds', "TDS not deducted / not deposited — withholding agent's interest",
        tdsShort.toLocaleString() + ' × ' + (r * 100).toFixed(0) + '% × ' + tdsMo + '/12', tdsShort * r * tdsMo / 12);
    }
    const tdsN = parseInt(d.tds_returns_late, 10) || 0;
    if (tdsN) {
      const fee = val(R, 'tds_return_late_fee', 0) || 0;
      add('tds', 'TDS return filed late', fee.toLocaleString() + ' × ' + tdsN + ' returns', fee * tdsN);
    }

    // ── VAT ──
    const n = parseInt(d.vat_returns_late, 10) || 0;
    if (n) {
      const fee = val(V, 'late_return_fee', 0) || 0;
      add('vat', 'VAT — late returns', fee.toLocaleString() + ' × ' + n + ' returns', fee * n);
    }
    const vu = N(d.vat_unpaid), vm = N(d.vat_months);
    if (vu && vm) {
      const r = val(V, 'late_payment_addl_fee', 0) || 0;
      add('vat', 'VAT — additional fee on unpaid tax',
        vu.toLocaleString() + ' × ' + (r * 100).toFixed(0) + '% × ' + vm + '/12', vu * r * vm / 12);
    }
    const vt2 = N(d.vat_unregistered_turnover), vrm = N(d.vat_unregistered_months);
    if (vt2 && vrm) {
      const r = val(V, 'unregistered_penalty_pct', 0) || 0;
      add('vat', 'VAT — trading above the registration threshold without registering',
        vt2.toLocaleString() + ' turnover while unregistered × ' + (r * 100).toFixed(2) +
        '% (over ' + vrm + ' months — in addition to the VAT itself now due on that turnover)', vt2 * r);
    }

    return { lines, total: R2(lines.reduce((s, l) => s + l.amount, 0)),
             unverified: Array.from(new Set(it.unverified.concat(vt.unverified))) };
  }

  // ------------------------------------------------------------- AD/BS dates
  // Real, exact AD <-> Bikram Sambat conversion — mirrors server/nepal_calendar.py
  // (backed there by the nepali-datetime package) so both sides agree exactly and
  // this still works fully offline. BS_CALENDAR is the same official month-length
  // reference table every mainstream Nepali-date library ships (year -> 12 month
  // lengths, which genuinely vary — never a fixed "~30 days a month" guess).
  // Names kept as bsApprox/fyLabelForAd for every existing caller; the math
  // underneath is exact, not approximate.
  const BS_MIN_YEAR = 1975, BS_MAX_YEAR = 2100;
  const BS_REF_AD = new Date(1918, 3, 13); // BS 1975-01-01, month is 0-indexed
  const BS_CALENDAR = {1975:[31,31,32,32,31,30,30,29,30,29,30,30],1976:[31,32,31,32,31,30,30,30,29,29,30,31],1977:[30,32,31,32,31,30,30,30,29,30,29,31],1978:[31,31,32,31,31,31,30,29,30,29,30,30],1979:[31,31,32,32,31,30,30,29,30,29,30,30],1980:[31,32,31,32,31,30,30,30,29,29,30,31],1981:[31,31,31,32,31,31,29,30,30,29,30,30],1982:[31,31,32,31,31,31,30,29,30,29,30,30],1983:[31,31,32,32,31,30,30,29,30,29,30,30],1984:[31,32,31,32,31,30,30,30,29,29,30,31],1985:[31,31,31,32,31,31,29,30,30,29,30,30],1986:[31,31,32,31,31,31,30,29,30,29,30,30],1987:[31,32,31,32,31,30,30,29,30,29,30,30],1988:[31,32,31,32,31,30,30,30,29,29,30,31],1989:[31,31,31,32,31,31,29,30,30,29,30,30],1990:[31,31,32,31,31,31,30,29,30,29,30,30],1991:[31,32,31,32,31,30,30,30,29,29,30,30],1992:[31,32,31,32,31,30,30,30,29,30,29,31],1993:[31,31,32,31,31,31,30,29,30,29,30,30],1994:[31,31,32,31,31,31,30,29,30,29,30,30],1995:[31,32,31,32,31,30,30,30,29,29,30,30],1996:[31,32,31,32,31,30,30,30,29,30,29,31],1997:[31,31,32,31,31,31,30,29,30,29,30,30],1998:[31,31,32,31,31,31,30,29,30,29,30,30],1999:[31,32,31,32,31,30,30,30,29,29,30,31],2000:[30,32,31,32,31,30,30,30,29,30,29,31],2001:[31,31,32,31,31,31,30,29,30,29,30,30],2002:[31,31,32,32,31,30,30,29,30,29,30,30],2003:[31,32,31,32,31,30,30,30,29,29,30,31],2004:[30,32,31,32,31,30,30,30,29,30,29,31],2005:[31,31,32,31,31,31,30,29,30,29,30,30],2006:[31,31,32,32,31,30,30,29,30,29,30,30],2007:[31,32,31,32,31,30,30,30,29,29,30,31],2008:[31,31,31,32,31,31,29,30,30,29,29,31],2009:[31,31,32,31,31,31,30,29,30,29,30,30],2010:[31,31,32,32,31,30,30,29,30,29,30,30],2011:[31,32,31,32,31,30,30,30,29,29,30,31],2012:[31,31,31,32,31,31,29,30,30,29,30,30],2013:[31,31,32,31,31,31,30,29,30,29,30,30],2014:[31,31,32,32,31,30,30,29,30,29,30,30],2015:[31,32,31,32,31,30,30,30,29,29,30,31],2016:[31,31,31,32,31,31,29,30,30,29,30,30],2017:[31,31,32,31,31,31,30,29,30,29,30,30],2018:[31,32,31,32,31,30,30,29,30,29,30,30],2019:[31,32,31,32,31,30,30,30,29,30,29,31],2020:[31,31,31,32,31,31,30,29,30,29,30,30],2021:[31,31,32,31,31,31,30,29,30,29,30,30],2022:[31,32,31,32,31,30,30,30,29,29,30,30],2023:[31,32,31,32,31,30,30,30,29,30,29,31],2024:[31,31,31,32,31,31,30,29,30,29,30,30],2025:[31,31,32,31,31,31,30,29,30,29,30,30],2026:[31,32,31,32,31,30,30,30,29,29,30,31],2027:[30,32,31,32,31,30,30,30,29,30,29,31],2028:[31,31,32,31,31,31,30,29,30,29,30,30],2029:[31,31,32,31,32,30,30,29,30,29,30,30],2030:[31,32,31,32,31,30,30,30,29,29,30,31],2031:[30,32,31,32,31,30,30,30,29,30,29,31],2032:[31,31,32,31,31,31,30,29,30,29,30,30],2033:[31,31,32,32,31,30,30,29,30,29,30,30],2034:[31,32,31,32,31,30,30,30,29,29,30,31],2035:[30,32,31,32,31,31,29,30,30,29,29,31],2036:[31,31,32,31,31,31,30,29,30,29,30,30],2037:[31,31,32,32,31,30,30,29,30,29,30,30],2038:[31,32,31,32,31,30,30,30,29,29,30,31],2039:[31,31,31,32,31,31,29,30,30,29,30,30],2040:[31,31,32,31,31,31,30,29,30,29,30,30],2041:[31,31,32,32,31,30,30,29,30,29,30,30],2042:[31,32,31,32,31,30,30,30,29,29,30,31],2043:[31,31,31,32,31,31,29,30,30,29,30,30],2044:[31,31,32,31,31,31,30,29,30,29,30,30],2045:[31,32,31,32,31,30,30,29,30,29,30,30],2046:[31,32,31,32,31,30,30,30,29,29,30,31],2047:[31,31,31,32,31,31,30,29,30,29,30,30],2048:[31,31,32,31,31,31,30,29,30,29,30,30],2049:[31,32,31,32,31,30,30,30,29,29,30,30],2050:[31,32,31,32,31,30,30,30,29,30,29,31],2051:[31,31,31,32,31,31,30,29,30,29,30,30],2052:[31,31,32,31,31,31,30,29,30,29,30,30],2053:[31,32,31,32,31,30,30,30,29,29,30,30],2054:[31,32,31,32,31,30,30,30,29,30,29,31],2055:[31,31,32,31,31,31,30,29,30,29,30,30],2056:[31,31,32,31,32,30,30,29,30,29,30,30],2057:[31,32,31,32,31,30,30,30,29,29,30,31],2058:[30,32,31,32,31,30,30,30,29,30,29,31],2059:[31,31,32,31,31,31,30,29,30,29,30,30],2060:[31,31,32,32,31,30,30,29,30,29,30,30],2061:[31,32,31,32,31,30,30,30,29,29,30,31],2062:[31,31,31,32,31,31,29,30,29,30,29,31],2063:[31,31,32,31,31,31,30,29,30,29,30,30],2064:[31,31,32,32,31,30,30,29,30,29,30,30],2065:[31,32,31,32,31,30,30,30,29,29,30,31],2066:[31,31,31,32,31,31,29,30,30,29,29,31],2067:[31,31,32,31,31,31,30,29,30,29,30,30],2068:[31,31,32,32,31,30,30,29,30,29,30,30],2069:[31,32,31,32,31,30,30,30,29,29,30,31],2070:[31,31,31,32,31,31,29,30,30,29,30,30],2071:[31,31,32,31,31,31,30,29,30,29,30,30],2072:[31,32,31,32,31,30,30,29,30,29,30,30],2073:[31,32,31,32,31,30,30,30,29,29,30,31],2074:[31,31,31,32,31,31,30,29,30,29,30,30],2075:[31,31,32,31,31,31,30,29,30,29,30,30],2076:[31,32,31,32,31,30,30,30,29,29,30,30],2077:[31,32,31,32,31,30,30,30,29,30,29,31],2078:[31,31,31,32,31,31,30,29,30,29,30,30],2079:[31,31,32,31,31,31,30,29,30,29,30,30],2080:[31,32,31,32,31,30,30,30,29,29,30,30],2081:[31,32,31,32,31,30,30,30,29,30,29,31],2082:[31,31,32,31,31,31,30,29,30,29,30,30],2083:[31,31,32,31,31,31,30,29,30,29,30,30],2084:[31,31,32,31,31,30,30,30,29,30,30,30],2085:[31,32,31,32,30,31,30,30,29,30,30,30],2086:[30,32,31,32,31,30,30,30,29,30,30,30],2087:[31,31,32,31,31,31,30,29,30,30,30,30],2088:[30,31,32,32,30,31,30,30,29,30,30,30],2089:[30,32,31,32,31,30,30,30,29,30,30,30],2090:[30,32,31,32,31,30,30,30,29,30,30,30],2091:[31,31,32,31,31,31,30,30,29,30,30,30],2092:[30,31,32,32,31,30,30,30,29,30,30,30],2093:[30,32,31,32,31,30,30,30,29,30,30,30],2094:[31,31,32,31,31,30,30,30,29,30,30,30],2095:[31,31,32,31,31,31,30,29,30,30,30,30],2096:[30,31,32,32,31,30,30,29,30,29,30,30],2097:[31,32,31,32,31,30,30,30,29,30,30,30],2098:[31,31,32,31,31,31,29,30,29,30,29,31],2099:[31,31,32,31,31,31,30,29,29,30,30,30],2100:[31,32,31,32,30,31,30,29,30,29,30,30]};

  function bsToOrdinal(y, m, d) {
    if (y < BS_MIN_YEAR || y > BS_MAX_YEAR) throw new Error('BS year out of range (1975-2100): ' + y);
    if (m < 1 || m > 12) throw new Error('BS month must be 1-12');
    const months = BS_CALENDAR[y];
    if (d < 1 || d > months[m - 1]) throw new Error('That BS date doesn\'t exist — ' + y + '-' + m + ' only has ' + months[m - 1] + ' days');
    let n = 0;
    for (let yy = BS_MIN_YEAR; yy < y; yy++) BS_CALENDAR[yy].forEach(x => n += x);
    for (let i = 0; i < m - 1; i++) n += months[i];
    return n + d;
  }
  function bsFromOrdinal(n) {
    let y = BS_MIN_YEAR;
    while (true) {
      const yearTotal = BS_CALENDAR[y].reduce((s, x) => s + x, 0);
      if (n <= yearTotal) break;
      n -= yearTotal; y++;
      if (y > BS_MAX_YEAR) throw new Error('AD date out of supported BS range');
    }
    const months = BS_CALENDAR[y];
    let m = 0;
    while (n > months[m]) { n -= months[m]; m++; }
    return { year: y, month: m + 1, day: n };
  }
  function bsApprox(adDateStr) {
    const d = new Date(adDateStr + 'T00:00:00');
    const days = Math.round((d - BS_REF_AD) / 86400000);
    const bs = bsFromOrdinal(days + 1);
    return { year: bs.year, month: bs.month, day: bs.day,
      label: bs.year + '-' + String(bs.month).padStart(2, '0') + '-' + String(bs.day).padStart(2, '0') };
  }
  function adFromBs(y, m, d) {
    const ordinal = bsToOrdinal(y, m, d);
    const ad = new Date(BS_REF_AD.getTime() + (ordinal - 1) * 86400000);
    return ad.getFullYear() + '-' + String(ad.getMonth() + 1).padStart(2, '0') + '-' + String(ad.getDate()).padStart(2, '0');
  }
  function fyLabelForAd(adDateStr, fiscalYears) {
    const d = new Date(adDateStr + 'T00:00:00');
    for (const fy of (fiscalYears || [])) {
      if (fy.start_ad && fy.end_ad) {
        const s = new Date(fy.start_ad + 'T00:00:00'), e = new Date(fy.end_ad + 'T00:00:00');
        if (d >= s && d <= e) return fy.label_bs;
      }
    }
    // No fiscal_year row covers this date: derive from the real BS date.
    // Shrawan (BS month 4) is the first month of a new Nepali fiscal year.
    try {
      const bs = bsApprox(adDateStr);
      const startYear = bs.month >= 4 ? bs.year : bs.year - 1;
      return startYear + '-' + String(startYear + 1).slice(-2);
    } catch (e) {
      const cutoff = new Date(d.getFullYear(), 6, 16);
      const bsStartYear = d >= cutoff ? d.getFullYear() + 57 : d.getFullYear() + 56;
      return bsStartYear + '-' + String(bsStartYear + 1).slice(-2);
    }
  }

  const FREQ_PERIODS_PER_YEAR = { monthly: 12, quarterly: 4, half_yearly: 2, annual: 1 };
  const FREQ_MONTHS = { monthly: 1, quarterly: 3, half_yearly: 6, annual: 12 };
  function addMonths(d, months) {
    const y = d.getFullYear() + Math.floor((d.getMonth() + months) / 12);
    const m = ((d.getMonth() + months) % 12 + 12) % 12;
    const lastDay = new Date(y, m + 1, 0).getDate();
    return new Date(y, m, Math.min(d.getDate(), lastDay));
  }
  function periodDates(startDateAd, frequency, n) {
    if (!startDateAd) return [];
    const start = new Date(startDateAd + 'T00:00:00');
    if (isNaN(start)) return [];
    const monthsPerPeriod = FREQ_MONTHS[frequency] || 1;
    const out = [];
    for (let i = 1; i <= n; i++) out.push(addMonths(start, i * monthsPerPeriod));
    return out;
  }
  function isoDate(d) { return d.toISOString().slice(0, 10); }
  function attachDates(rows, dates) {
    rows.forEach((row, i) => { row.date_ad = isoDate(dates[i]); row.date_bs_approx = bsApprox(isoDate(dates[i])).label; });
  }
  function groupJournalsByFy(journals, dates, fiscalYears) {
    if (!dates.length) return {};
    const byFy = {};
    const dateByPeriod = {}; dates.forEach((d, i) => { dateByPeriod[i + 1] = d; }); dateByPeriod[0] = dates[0];
    journals.forEach(entry => {
      const d = dateByPeriod[entry.period];
      if (!d) return;
      const label = fyLabelForAd(isoDate(d), fiscalYears);
      (byFy[label] = byFy[label] || []).push(entry);
    });
    return byFy;
  }

  // ---------------------------------------------------------------- loan / EMI
  function solveEir(netProceeds, cashflows) {
    if (netProceeds <= 0 || !cashflows.length || !cashflows.some(x => x)) return 0;
    const npv = rate => cashflows.reduce((s, cf, t) => s + cf / Math.pow(1 + rate, t + 1), 0);
    let lo = 0, hi = 1;
    if (npv(hi) > netProceeds) hi = 5;
    for (let i = 0; i < 100; i++) {
      const mid = (lo + hi) / 2;
      if (npv(mid) > netProceeds) lo = mid; else hi = mid;
    }
    return (lo + hi) / 2;
  }

  function classifyCurrent(rows, principalKey, closingKey, periodsPerYear) {
    periodsPerYear = periodsPerYear || 12;
    const n = rows.length;
    rows.forEach((row, i) => {
      const closing = row[closingKey];
      let upcoming = 0;
      for (let j = i + 1; j < Math.min(i + 1 + periodsPerYear, n); j++) upcoming += rows[j][principalKey];
      const current = Math.min(closing, Math.max(0, upcoming));
      row.current_portion = R2(current);
      row.non_current_portion = R2(Math.max(0, closing - current));
    });
  }

  function loanJournals(rows, eirRows, principal, netProceeds) {
    const j = [{ period: 0, narration: 'Loan drawdown' + (eirRows.length ? ' (net of processing fee, amortised cost)' : ''),
      debit: [{ account: 'Bank', amount: R2(eirRows.length ? netProceeds : principal) }],
      credit: [{ account: 'Loan payable', amount: R2(eirRows.length ? netProceeds : principal) }] }];
    if (eirRows.length) {
      eirRows.forEach(x => {
        j.push({ period: x.period, narration: `Period ${x.period} — effective interest`,
          debit: [{ account: 'Interest expense', amount: x.effective_interest }],
          credit: [{ account: 'Loan payable', amount: x.effective_interest }] });
        if (x.cash_flow) j.push({ period: x.period, narration: `Period ${x.period} — instalment paid`,
          debit: [{ account: 'Loan payable', amount: x.cash_flow }],
          credit: [{ account: 'Bank', amount: x.cash_flow }] });
      });
    } else {
      rows.forEach(x => {
        j.push({ period: x.period, narration: `Period ${x.period} — interest accrued`,
          debit: [{ account: 'Interest expense', amount: x.interest }],
          credit: [{ account: 'Loan payable', amount: x.interest }] });
        if (x.instalment) j.push({ period: x.period, narration: `Period ${x.period} — instalment paid`,
          debit: [{ account: 'Loan payable', amount: x.instalment }],
          credit: [{ account: 'Bank', amount: x.instalment }] });
      });
    }
    return j;
  }

  function pv(cashflows, rate) {
    return cashflows.reduce((s, cf, t) => s + cf / Math.pow(1 + rate, t + 1), 0);
  }

  function loanSchedule(d) {
    const p = N(d.principal), ar = N(d.annual_rate) / 100;
    const frequency = d.frequency || 'monthly';
    const ppy = FREQ_PERIODS_PER_YEAR[frequency] || 12;
    const n = parseInt(d.tenure_periods != null ? d.tenure_periods : d.tenure_months, 10) || 0;
    const mor = parseInt(d.moratorium_periods != null ? d.moratorium_periods : d.moratorium_months, 10) || 0;
    const fee = N(d.processing_fee);
    const mode = d.repayment || 'emi';
    const startDateAd = d.start_date_ad;
    if (p <= 0 || n <= 0) return { rows: [], eir_rows: [], journals: [], journals_by_fy: {}, summary: {}, unverified: [] };

    const mods = (d.modifications || []).slice().sort((a, b) => N(a.at_period) - N(b.at_period));

    const rows = []; let balance = p;
    const morR = ar / ppy;
    for (let i = 1; i <= mor; i++) {
      const interest = balance * morR;
      rows.push({ period: i, opening: R2(balance), instalment: 0, interest: R2(interest),
                  principal: 0, closing: R2(balance + interest), note: 'Moratorium — interest capitalised' });
      balance += interest;
    }

    let segments = [], cursor = mor, curRate = ar, totalN = n;
    mods.forEach(m => {
      const at = parseInt(m.at_period, 10) || 0;
      if (at <= cursor || at >= totalN) return;
      segments.push({ start: cursor + 1, end: at, rate: curRate });
      cursor = at;
      if (m.new_annual_rate !== undefined && m.new_annual_rate !== null && m.new_annual_rate !== '') curRate = N(m.new_annual_rate) / 100;
      if (m.new_tenure_periods) totalN = at + parseInt(m.new_tenure_periods, 10);
    });
    segments.push({ start: cursor + 1, end: totalN, rate: curRate });

    let emi = null;
    const modificationGainLoss = [];
    segments.forEach((seg, segIdx) => {
      if (segIdx > 0) {
        const extra = N(mods[segIdx - 1] && mods[segIdx - 1].extra_payment);
        if (extra) {
          balance = Math.max(0, balance - extra);
          rows[rows.length - 1].closing = R2(balance);
          rows[rows.length - 1].note = (rows[rows.length - 1].note ? rows[rows.length - 1].note + ' ' : '') +
            `Extra payment of ${extra.toLocaleString()} applied on modification.`;
        }
      }
      const segR = seg.rate / ppy;
      const payN = Math.max(1, segIdx === 0 ? (n - mor) : (seg.end - seg.start + 1));
      const segEmi = mode === 'emi' ? (segR ? balance * segR / (1 - Math.pow(1 + segR, -payN)) : balance / payN) : null;
      const segPrincipalFixed = balance / payN;
      if (emi === null) emi = segEmi;

      if (segIdx > 0) {
        const futureCf = []; let b = balance;
        const runN = seg.end - seg.start + 1;
        for (let k = 0; k < runN; k++) {
          const interestK = b * segR;
          let prinK, instK;
          if (mode === 'emi') { instK = segEmi; prinK = instK - interestK; }
          else { prinK = segPrincipalFixed; instK = prinK + interestK; }
          if (k === runN - 1) { prinK = b; instK = prinK + interestK; }
          futureCf.push(instK); b -= prinK;
        }
        const revisedPv = pv(futureCf, ar / ppy);
        modificationGainLoss.push({ at_period: seg.start - 1, carrying_amount: R2(balance),
          revised_pv_at_original_rate: R2(revisedPv), gain_or_loss: R2(revisedPv - balance) });
      }

      for (let i = seg.start; i <= seg.end; i++) {
        const interest = balance * segR;
        let prin, inst;
        if (mode === 'emi') { inst = segEmi; prin = inst - interest; }
        else { prin = segPrincipalFixed; inst = prin + interest; }
        if (i === totalN) { prin = balance; inst = prin + interest; }
        rows.push({ period: i, opening: R2(balance), instalment: R2(inst), interest: R2(interest),
                    principal: R2(prin), closing: R2(balance - prin), note: '' });
        balance -= prin;
      }
    });

    classifyCurrent(rows, 'principal', 'closing', ppy);

    const dates = periodDates(startDateAd, frequency, totalN);
    if (dates.length) attachDates(rows, dates);

    const totalInterest = rows.reduce((s, x) => s + x.interest, 0);
    const totalPaid = rows.reduce((s, x) => s + x.instalment, 0);

    let eirRows = [], effectiveRate = null;
    const netProceeds = p - fee;
    if (fee > 0 && netProceeds > 0 && !mods.length) {
      const cashflows = rows.map(x => x.instalment);
      effectiveRate = solveEir(netProceeds, cashflows);
      let eirBalance = netProceeds;
      rows.forEach((x, idx) => {
        const cf = cashflows[idx];
        const effInterest = eirBalance * effectiveRate;
        const principalReduction = cf - effInterest;
        const closing = eirBalance - principalReduction;
        eirRows.push({ period: x.period, opening: R2(eirBalance), cash_flow: R2(cf),
          effective_interest: R2(effInterest), principal_reduction: R2(principalReduction), closing: R2(closing) });
        eirBalance = closing;
      });
      classifyCurrent(eirRows, 'principal_reduction', 'closing', ppy);
    }

    const journals = loanJournals(rows, eirRows, p, netProceeds);
    modificationGainLoss.forEach(mgl => {
      if (mgl.gain_or_loss) {
        const isGain = mgl.gain_or_loss < 0;
        journals.push({ period: mgl.at_period,
          narration: `Period ${mgl.at_period} — loan modification (${isGain ? 'gain' : 'loss'} on non-substantial modification, NFRS 9)`,
          debit: [{ account: isGain ? 'Loan payable' : 'Modification loss', amount: Math.abs(mgl.gain_or_loss) }],
          credit: [{ account: isGain ? 'Modification gain' : 'Loan payable', amount: Math.abs(mgl.gain_or_loss) }] });
      }
    });
    const fiscalYears = dates.length ? (d.fiscal_years || []) : [];
    const journalsByFy = groupJournalsByFy(journals, dates, fiscalYears);

    return { rows, eir_rows: eirRows, journals, journals_by_fy: journalsByFy,
      modifications_applied: modificationGainLoss,
      summary: { principal: R2(p), emi: emi ? R2(emi) : null,
      total_interest: R2(totalInterest), total_paid: R2(totalPaid), processing_fee: R2(fee),
      net_proceeds: fee ? R2(netProceeds) : null,
      effective_annual_rate: effectiveRate != null ? R2(effectiveRate * 12) : null,
      eir_skipped_due_to_modification: !!(fee > 0 && netProceeds > 0 && mods.length),
      total_cost: R2(totalInterest + fee), periods: totalN, moratorium_periods: mor, frequency,
      start_date_ad: startDateAd || null,
      start_date_bs_approx: startDateAd ? bsApprox(startDateAd).label : null }, unverified: [] };
  }

  // -------------------------------------------------------------- lease NFRS16
  function leasePaymentAt(period, basePayment, rentSteps) {
    if (!rentSteps || !rentSteps.length) return basePayment;
    const applicable = rentSteps.filter(s => (parseInt(s.from_period, 10) || 1) <= period);
    if (!applicable.length) return basePayment;
    const step = applicable.reduce((a, b) => (parseInt(a.from_period, 10) || 1) >= (parseInt(b.from_period, 10) || 1) ? a : b);
    return step.payment != null && step.payment !== '' ? N(step.payment) : basePayment;
  }
  function leasePv(payments, r, advance) {
    if (!r) return payments.reduce((s, cf) => s + cf, 0);
    if (advance) return payments.reduce((s, cf, i) => s + cf / Math.pow(1 + r, i), 0);
    return payments.reduce((s, cf, i) => s + cf / Math.pow(1 + r, i + 1), 0);
  }
  function leaseJournals(rows, rou, pv0, idc, inc, rest, tdsRate, tdsBorneBy) {
    const debit = [{ account: 'Right-of-use asset', amount: R2(rou) }];
    if (inc) debit.push({ account: 'Cash — incentive received', amount: R2(inc) });
    const credit = [{ account: 'Lease liability', amount: R2(pv0) }];
    if (idc) credit.push({ account: 'Cash — initial direct costs', amount: R2(idc) });
    if (rest) credit.push({ account: 'Provision for restoration', amount: R2(rest) });
    const j = [{ period: 0, narration: 'Initial recognition of right-of-use asset and lease liability', debit, credit }];
    rows.forEach(x => {
      j.push({ period: x.period, narration: `Period ${x.period} — interest on lease liability`,
        debit: [{ account: 'Interest expense', amount: x.interest }],
        credit: [{ account: 'Lease liability', amount: x.interest }] });
      if (tdsRate && (tdsBorneBy === 'lessor' || tdsBorneBy === 'lessee')) {
        const tdsAmt = R2(x.payment * tdsRate);
        if (tdsBorneBy === 'lessor') {
          j.push({ period: x.period, narration: `Period ${x.period} — lease payment, TDS withheld (borne by lessor)`,
            debit: [{ account: 'Lease liability', amount: x.payment }],
            credit: [{ account: 'Cash — net to lessor', amount: R2(x.payment - tdsAmt) }, { account: 'TDS payable', amount: tdsAmt }] });
        } else {
          j.push({ period: x.period, narration: `Period ${x.period} — lease payment (lessor receives gross)`,
            debit: [{ account: 'Lease liability', amount: x.payment }],
            credit: [{ account: 'Cash — to lessor', amount: x.payment }] });
          j.push({ period: x.period, narration: `Period ${x.period} — TDS on rent borne by lessee, remitted to IRD`,
            debit: [{ account: 'TDS expense (borne by lessee)', amount: tdsAmt }],
            credit: [{ account: 'Cash — TDS remittance', amount: tdsAmt }] });
        }
      } else {
        j.push({ period: x.period, narration: `Period ${x.period} — lease payment`,
          debit: [{ account: 'Lease liability', amount: x.payment }],
          credit: [{ account: 'Cash', amount: x.payment }] });
      }
      j.push({ period: x.period, narration: `Period ${x.period} — right-of-use amortisation`,
        debit: [{ account: 'Depreciation expense — ROU asset', amount: x.rou_amortisation }],
        credit: [{ account: 'Accumulated depreciation — ROU asset', amount: x.rou_amortisation }] });
    });
    return j;
  }

  function leaseSchedule(d, rates, fy) {
    const basePmt = N(d.payment);
    const rentSteps = d.rent_steps || [];
    const n = parseInt(d.periods, 10) || 0;
    if ((basePmt <= 0 && !rentSteps.length) || n <= 0) return { rows: [], journals: [], journals_by_fy: {}, summary: {}, unverified: [] };

    const frequency = d.frequency || 'monthly';
    const ppy = FREQ_PERIODS_PER_YEAR[frequency] || 12;
    const startDateAd = d.start_date_ad;

    const tds = rateSet(rates || [], 'payroll', fy);
    const tdsRate = val(tds.rates, 'rent_tds_rate', 0) || 0;
    const tdsBorneBy = d.tds_borne_by || 'none';

    if (d.short_term_or_low_value) {
      const rows = [];
      for (let i = 1; i <= n; i++) rows.push({ period: i, expense: R2(leasePaymentAt(i, basePmt, rentSteps)) });
      const journals = rows.map(x => ({ period: x.period, narration: `Period ${x.period} — short-term/low-value lease payment`,
        debit: [{ account: 'Lease expense', amount: x.expense }], credit: [{ account: 'Cash', amount: x.expense }] }));
      const dates0 = periodDates(startDateAd, frequency, n);
      if (dates0.length) attachDates(rows, dates0);
      const fiscalYears0 = dates0.length ? (d.fiscal_years || []) : [];
      return { regime: 'exempt', rows, journals, journals_by_fy: groupJournalsByFy(journals, dates0, fiscalYears0),
        summary: { total_payments: R2(rows.reduce((s, x) => s + x.expense, 0)), periods: n, frequency,
          note: 'Short-term / low-value recognition exemption elected (NFRS 16) — expensed straight-line, not capitalised.' },
        unverified: [] };
    }

    const ar = N(d.annual_rate) / 100;
    const advance = d.timing === 'advance';
    const mods = (d.modifications || []).slice().sort((a, b) => N(a.at_period) - N(b.at_period));

    const payments = []; for (let i = 1; i <= n; i++) payments.push(leasePaymentAt(i, basePmt, rentSteps));
    const r0 = ar / ppy;
    const pv0 = leasePv(payments, r0, advance);

    const idc = N(d.initial_direct_costs), inc = N(d.incentives), rest = N(d.restoration_provision);
    const rou0 = pv0 + idc - inc + rest;
    let rouMonthly = rou0 / n;

    let segments = [], cursor = 0, curRate = ar, totalN = n;
    mods.forEach(m => {
      const at = parseInt(m.at_period, 10) || 0;
      if (at <= cursor || at >= totalN) return;
      segments.push({ start: cursor + 1, end: at, rate: curRate });
      cursor = at;
      if (m.new_annual_rate !== undefined && m.new_annual_rate !== null && m.new_annual_rate !== '') curRate = N(m.new_annual_rate) / 100;
      if (m.new_remaining_periods) totalN = at + parseInt(m.new_remaining_periods, 10);
    });
    segments.push({ start: cursor + 1, end: totalN, rate: curRate });

    const rows = []; let liability = pv0, carrying = rou0;
    const remeasurements = [];
    segments.forEach((seg, segIdx) => {
      const segR = seg.rate / ppy;
      let remainingPayments = null;
      if (segIdx > 0) {
        const m = mods[segIdx - 1];
        remainingPayments = [];
        for (let i = seg.start; i <= seg.end; i++) {
          remainingPayments.push(m.new_payment !== undefined && m.new_payment !== null && m.new_payment !== ''
            ? N(m.new_payment) : leasePaymentAt(i, basePmt, rentSteps));
        }
        const newLiability = leasePv(remainingPayments, segR, advance);
        const adjustment = newLiability - liability;
        let carryingAfter = carrying + adjustment;
        let plGain = 0;
        if (carryingAfter < 0) { plGain = -carryingAfter; carryingAfter = 0; }
        remeasurements.push({ at_period: seg.start - 1, old_liability: R2(liability), new_liability: R2(newLiability),
          rou_adjustment: R2(adjustment), rou_carrying_after: R2(carryingAfter), pl_gain: R2(plGain) });
        liability = newLiability;
        carrying = carryingAfter;
        const remainingN = seg.end - seg.start + 1;
        rouMonthly = remainingN ? carrying / remainingN : 0;
      }
      for (let k = 0, i = seg.start; i <= seg.end; i++, k++) {
        const pmt = segIdx > 0 ? remainingPayments[k] : payments[i - 1];
        let opening = liability, interest, principal, closing;
        if (advance) { principal = pmt; interest = (opening - pmt) * segR; closing = opening - pmt + interest; }
        else { interest = opening * segR; principal = pmt - interest; closing = opening - principal; }
        const amort = i < totalN ? rouMonthly : carrying;
        rows.push({ period: i, opening_liability: R2(opening), payment: R2(pmt), interest: R2(interest),
          principal: R2(principal), closing_liability: R2(Math.max(0, closing)),
          rou_amortisation: R2(amort), rou_carrying: R2(Math.max(0, carrying - amort)) });
        liability = closing; carrying -= amort;
      }
    });

    classifyCurrent(rows, 'principal', 'closing_liability', ppy);

    const dates = periodDates(startDateAd, frequency, totalN);
    if (dates.length) attachDates(rows, dates);

    const journals = leaseJournals(rows, rou0, pv0, idc, inc, rest, tdsRate, tdsBorneBy);
    remeasurements.forEach(rm => {
      if (rm.pl_gain) {
        journals.push({ period: rm.at_period,
          narration: `Period ${rm.at_period} — lease remeasurement, ROU floored at zero, excess to profit or loss (NFRS 16)`,
          debit: [{ account: 'Lease liability', amount: rm.pl_gain }],
          credit: [{ account: 'Gain on lease remeasurement', amount: rm.pl_gain }] });
      }
    });
    const fiscalYears = dates.length ? (d.fiscal_years || []) : [];
    const journalsByFy = groupJournalsByFy(journals, dates, fiscalYears);

    return { regime: 'capitalised', rows, journals, journals_by_fy: journalsByFy, remeasurements,
      summary: { initial_liability: R2(pv0), rou_asset: R2(rou0),
      total_payments: R2(rows.reduce((s, x) => s + x.payment, 0)),
      total_interest: R2(rows.reduce((s, x) => s + x.interest, 0)),
      monthly_amortisation: R2(rou0 / n), periods: totalN, frequency,
      tds_rate: (tdsBorneBy === 'lessor' || tdsBorneBy === 'lessee') ? tdsRate : null, tds_borne_by: tdsBorneBy,
      start_date_ad: startDateAd || null,
      start_date_bs_approx: startDateAd ? bsApprox(startDateAd).label : null },
      unverified: (tdsBorneBy === 'lessor' || tdsBorneBy === 'lessee') ? tds.unverified : [] };
  }

  // ------------------------------------------- financial statements (TB -> FS)
  // No statutory number is involved here — `unverified` is always []. HEADS/RULES
  // are the presentation taxonomy, kept as constants for the same reason
  // TRANCHE_BY_MONTH is: they don't vary by fiscal year.
  const FS_HEADS = [
    { id: 'share_capital', label: "Share capital", st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: "Shareholders' funds", nat: 'credit' },
    { id: 'reserves', label: 'Reserves and surplus', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: "Shareholders' funds", nat: 'credit' },
    { id: 'lt_borrow', label: 'Long-term borrowings', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: 'Non-current liabilities', nat: 'credit' },
    { id: 'dtl', label: 'Deferred tax liability (net)', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: 'Non-current liabilities', nat: 'credit' },
    { id: 'lt_prov', label: 'Long-term provisions', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: 'Non-current liabilities', nat: 'credit' },
    { id: 'other_ncl', label: 'Other non-current liabilities', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: 'Non-current liabilities', nat: 'credit' },
    { id: 'st_borrow', label: 'Short-term borrowings', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: 'Current liabilities', nat: 'credit' },
    { id: 'trade_pay', label: 'Trade payables', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: 'Current liabilities', nat: 'credit' },
    { id: 'other_cl', label: 'Other current liabilities', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: 'Current liabilities', nat: 'credit' },
    { id: 'tds_payable', label: 'TDS payable', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: 'Current liabilities', nat: 'credit' },
    { id: 'vat_payable', label: 'VAT payable', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: 'Current liabilities', nat: 'credit' },
    { id: 'tax_prov', label: 'Provision for taxation', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: 'Current liabilities', nat: 'credit' },
    { id: 'st_prov', label: 'Other short-term provisions', st: 'bs', sec: 'EQUITY AND LIABILITIES', grp: 'Current liabilities', nat: 'credit' },
    { id: 'ppe', label: 'Property, plant and equipment (gross)', st: 'bs', sec: 'ASSETS', grp: 'Non-current assets', nat: 'debit' },
    { id: 'acc_dep', label: 'Less: accumulated depreciation', st: 'bs', sec: 'ASSETS', grp: 'Non-current assets', nat: 'debit' },
    { id: 'cwip', label: 'Capital work in progress', st: 'bs', sec: 'ASSETS', grp: 'Non-current assets', nat: 'debit' },
    { id: 'intangible', label: 'Intangible assets', st: 'bs', sec: 'ASSETS', grp: 'Non-current assets', nat: 'debit' },
    { id: 'nc_inv', label: 'Non-current investments', st: 'bs', sec: 'ASSETS', grp: 'Non-current assets', nat: 'debit' },
    { id: 'dta', label: 'Deferred tax asset (net)', st: 'bs', sec: 'ASSETS', grp: 'Non-current assets', nat: 'debit' },
    { id: 'lt_adv', label: 'Long-term loans and advances', st: 'bs', sec: 'ASSETS', grp: 'Non-current assets', nat: 'debit' },
    { id: 'inventories', label: 'Inventories', st: 'bs', sec: 'ASSETS', grp: 'Current assets', nat: 'debit' },
    { id: 'trade_rec', label: 'Trade receivables', st: 'bs', sec: 'ASSETS', grp: 'Current assets', nat: 'debit' },
    { id: 'cash', label: 'Cash and cash equivalents', st: 'bs', sec: 'ASSETS', grp: 'Current assets', nat: 'debit' },
    { id: 'st_adv', label: 'Short-term loans and advances', st: 'bs', sec: 'ASSETS', grp: 'Current assets', nat: 'debit' },
    { id: 'other_ca', label: 'Other current assets', st: 'bs', sec: 'ASSETS', grp: 'Current assets', nat: 'debit' },
    { id: 'revenue', label: 'Revenue from operations', st: 'pl', sec: 'INCOME', grp: 'Income', nat: 'credit' },
    { id: 'other_income', label: 'Other income', st: 'pl', sec: 'INCOME', grp: 'Income', nat: 'credit' },
    { id: 'opening_stock', label: 'Opening inventory', st: 'pl', sec: 'EXPENSES', grp: 'Cost of materials', nat: 'debit' },
    { id: 'purchases', label: 'Purchases of stock-in-trade / raw materials', st: 'pl', sec: 'EXPENSES', grp: 'Cost of materials', nat: 'debit' },
    { id: 'closing_stock', label: 'Less: closing inventory', st: 'pl', sec: 'EXPENSES', grp: 'Cost of materials', nat: 'debit' },
    { id: 'direct_exp', label: 'Direct and manufacturing expenses', st: 'pl', sec: 'EXPENSES', grp: 'Direct costs', nat: 'debit' },
    { id: 'employee', label: 'Employee benefit expenses', st: 'pl', sec: 'EXPENSES', grp: 'Operating expenses', nat: 'debit' },
    { id: 'finance', label: 'Finance costs', st: 'pl', sec: 'EXPENSES', grp: 'Operating expenses', nat: 'debit' },
    { id: 'depreciation', label: 'Depreciation and amortisation', st: 'pl', sec: 'EXPENSES', grp: 'Operating expenses', nat: 'debit' },
    { id: 'other_exp', label: 'Other expenses', st: 'pl', sec: 'EXPENSES', grp: 'Operating expenses', nat: 'debit' },
    { id: 'current_tax', label: 'Current tax', st: 'pl', sec: 'TAX', grp: 'Tax expense', nat: 'debit' },
    { id: 'deferred_tax', label: 'Deferred tax charge / (credit)', st: 'pl', sec: 'TAX', grp: 'Tax expense', nat: 'debit' }
  ];
  const FS_HEAD = {}; FS_HEADS.forEach(h => { FS_HEAD[h.id] = h; });

  const FS_RULES = [
    [/capital work|c\.?w\.?i\.?p/, 'cwip'],
    [/accumulated deprec|acc\.? dep|provision for deprec/, 'acc_dep'],
    [/deferred tax liab/, 'dtl'],
    [/deferred tax asset/, 'dta'],
    [/deferred tax/, 'deferred_tax'],
    [/deprec|amorti[sz]/, 'depreciation'],
    [/interest on|bank charge|finance cost|interest expense|loan interest|processing fee/, 'finance'],
    [/share capital|equity share|paid.?up capital|proprietor|partner.?s capital|capital account/, 'share_capital'],
    [/retained earning|accumulated profit|profit and loss account|general reserve|reserve|surplus/, 'reserves'],
    [/opening stock|opening inventor/, 'opening_stock'],
    [/closing stock|closing inventor/, 'inventories'],
    [/bank overdraft|cash credit|o\/d account|working capital loan|short.?term borrow/, 'st_borrow'],
    [/secured loan|unsecured loan|term loan|debenture|long.?term borrow|vehicle loan|hire purchase|loan from bank|bank loan/, 'lt_borrow'],
    [/sundry creditor|trade payable|creditors for goods|accounts payable|payable to suppl/, 'trade_pay'],
    [/provision for tax|income tax provision|tax payable/, 'tax_prov'],
    [/gratuity|leave encash|provision for employee|retirement benefit/, 'lt_prov'],
    [/tds payable|tax deducted at source payable|withholding.*payable/, 'tds_payable'],
    [/vat payable|value added tax payable/, 'vat_payable'],
    [/outstanding|expenses payable|audit fee payable|advance from customer|unearned|duties and taxes/, 'other_cl'],
    [/land|building|plant|machinery|furniture|fixture|vehicle|computer|equipment|fixed asset|leasehold improve/, 'ppe'],
    [/goodwill|software|patent|trademark|intangible/, 'intangible'],
    [/investment/, 'nc_inv'],
    [/security deposit|deposit with|long.?term advance/, 'lt_adv'],
    [/stock|inventor|raw material stock|finished goods|work in progress/, 'inventories'],
    [/sundry debtor|trade receivable|accounts receivable|debtor|receivable from custom/, 'trade_rec'],
    [/cash in hand|petty cash|cash at bank|bank balance|bank account|bank a\/c|^cash$|^bank|fixed deposit|fd with/, 'cash'],
    [/advance to|prepaid|tds receivable|vat receivable|input vat|advance income tax|loans and advances|staff advance/, 'st_adv'],
    [/(?<!cost of )sales|revenue from|service income|job work|turnover|contract income/, 'revenue'],
    [/interest income|interest received|discount received|other income|rent received|commission received|profit on sale|misc.*income/, 'other_income'],
    [/purchase|material consum/, 'purchases'],
    [/wages|factory|carriage inward|freight inward|power and fuel|manufacturing|direct expen|labour|labor/, 'direct_exp'],
    [/salar|staff welfare|bonus|provident|employee benefit|remuneration|allowance to staff/, 'employee'],
    [/current tax|income tax expense|tax expense/, 'current_tax'],
    [/audit fee|professional fee|legal|consultanc|registration fee|renewal/, 'other_exp'],
    [/^rent|rent paid|office rent|electricity|water charge|repair|maintenance|insurance|telephone|mobile|internet|travel|conveyance|printing|stationer|advertis|marketing|donation|office expense|postage|courier|security service|cleaning|entertainment|subscription|bad debt|written off|loss on sale|misc/, 'other_exp']
  ];
  function autoHeadFor(name, group) {
    const s = ((name || '') + ' ' + (group || '')).toLowerCase();
    for (let i = 0; i < FS_RULES.length; i++) if (FS_RULES[i][0].test(s)) return FS_RULES[i][1];
    return '';
  }

  function finStatements(fy, data) {
    data = data || {};
    const rows = (data.rows || []).map(r => {
      const dr = R2(r.dr), cr = R2(r.cr);
      const head = r.head || autoHeadFor(r.name, r.group);
      // "Previous year" is one plain field (not a Dr/Cr pair like Opening) —
      // a preparer types a positive balance regardless of which side of the
      // ledger the account sits on. disp() below negates credit-natured
      // heads once already (dr-minus-cr -> a positive liability/equity
      // figure); align prev to that same convention here so it isn't
      // negated twice or zero times. Mirrors the same fix in engines.py.
      let prev = (r.prev === null || r.prev === undefined || r.prev === '') ? null : R2(r.prev);
      if (prev !== null) {
        const creditSide = FS_HEAD[head] ? FS_HEAD[head].nat === 'credit' : cr > dr;
        prev = creditSide ? -Math.abs(prev) : Math.abs(prev);
      }
      return { name: r.name || '', group: r.group || '', dr, cr, net: dr - cr, prev,
               opening_dr: R2(r.opening_dr), opening_cr: R2(r.opening_cr),
               head, flag: !!r.flag,
               source_module: r.source_module || null, source_workfile_id: r.source_workfile_id || null,
               source_version: r.source_version != null ? r.source_version : null,
               source_unverified: !!r.source_unverified, source_movement: r.source_movement || null };
    });
    const cs = data.closing_stock || {};
    const csCur = R2(cs.current), csPrev = R2(cs.previous);
    if (csCur || csPrev) {
      rows.push({ name: 'Closing stock (adjustment)', group: '', dr: csCur, cr: 0, net: csCur,
                  prev: csPrev || null, opening_dr: 0, opening_cr: 0, head: 'inventories', flag: true,
                  source_module: null, source_workfile_id: null, source_version: null,
                  source_unverified: false, source_movement: null });
      rows.push({ name: 'Closing stock credited to profit and loss', group: '', dr: 0, cr: csCur, net: -csCur,
                  prev: csPrev ? -csPrev : null, opening_dr: 0, opening_cr: 0, head: 'closing_stock', flag: true,
                  source_module: null, source_workfile_id: null, source_version: null,
                  source_unverified: false, source_movement: null });
    }

    const totalDr = R2(rows.reduce((s, r) => s + r.dr, 0));
    const totalCr = R2(rows.reduce((s, r) => s + r.cr, 0));
    const diff = R2(totalDr - totalCr);
    const totalOpeningDr = R2(rows.reduce((s, r) => s + r.opening_dr, 0));
    const totalOpeningCr = R2(rows.reduce((s, r) => s + r.opening_cr, 0));
    const openingDiff = R2(totalOpeningDr - totalOpeningCr);

    const by = {};
    rows.forEach(r => {
      if (!r.head) return;
      const b = by[r.head] = by[r.head] || { net: 0, prev: 0, has_prev: false, opening: 0, has_opening: false, items: [] };
      b.net += r.net;
      if (r.prev !== null) { b.prev += r.prev; b.has_prev = true; }
      const openingNet = r.opening_dr - r.opening_cr;
      if (r.opening_dr || r.opening_cr) { b.opening += openingNet; b.has_opening = true; }
      b.items.push(r);
    });
    const disp = (hid, key) => {
      const b = by[hid]; if (!b) return 0;
      const v = key === 'prev' ? b.prev : key === 'opening' ? b.opening : b.net;
      return FS_HEAD[hid].nat === 'credit' ? -v : v;
    };
    const incomeIds = ['revenue', 'other_income'];
    const expIds = ['opening_stock', 'purchases', 'closing_stock', 'direct_exp', 'employee', 'finance', 'depreciation', 'other_exp'];
    const taxIds = ['current_tax', 'deferred_tax'];
    const ssum = (ids, key) => ids.reduce((s, i) => s + disp(i, key), 0);

    const income = ssum(incomeIds), expense = ssum(expIds);
    const pbt = income - expense, tax = ssum(taxIds), pat = pbt - tax;
    const incomeP = ssum(incomeIds, 'prev'), expenseP = ssum(expIds, 'prev');
    const pbtP = incomeP - expenseP, taxP = ssum(taxIds, 'prev'), patP = pbtP - taxP;
    const anyPrev = Object.keys(by).some(k => by[k].has_prev);

    let noteNo = 0; const notes = [];
    function note(hid, extra) {
      const b = by[hid]; if (!b && !extra) return null;
      noteNo++;
      let movement = null;
      if (b) { const found = b.items.find(it => it.source_movement); if (found) movement = found.source_movement; }
      notes.push({ n: noteNo, id: hid, label: FS_HEAD[hid].label, items: b ? b.items : [], extra: extra || null, movement });
      return noteNo;
    }

    const bsRows = []; let lastSec = '', lastGrp = '';
    let assetsTot = 0, eqTot = 0, assetsTotP = 0, eqTotP = 0, assetsTotOpen = 0, eqTotOpen = 0;
    const anyOpening = Object.keys(by).some(k => by[k].has_opening);
    FS_HEADS.filter(h => h.st === 'bs').forEach(h => {
      const b = by[h.id];
      const present = !!b || h.id === 'reserves';
      if (!present) return;
      let v = disp(h.id, 'net'), p = disp(h.id, 'prev'), o = disp(h.id, 'opening');
      if (h.id === 'reserves') { v += pat; p += patP; }
      if (h.sec === 'ASSETS') { assetsTot += v; assetsTotP += p; assetsTotOpen += o; }
      else { eqTot += v; eqTotP += p; eqTotOpen += o; }
      const n = note(h.id, h.id === 'reserves' ? { pat: R2(pat), pat_p: R2(patP) } : null);
      if (h.sec !== lastSec) { bsRows.push({ t: 'sec', l: h.sec }); lastSec = h.sec; lastGrp = ''; }
      if (h.grp !== lastGrp) { bsRows.push({ t: 'sub', l: h.grp }); lastGrp = h.grp; }
      bsRows.push({ t: 'row', l: h.label, n, v: R2(v), p: (b && b.has_prev) ? R2(p) : null,
                    o: (b && b.has_opening) ? R2(o) : null,
                    flag: !!(b && b.items.some(it => it.flag)) });
      if (h.id === 'acc_dep' && by.ppe) bsRows.push({ t: 'row', l: 'Net block', v: R2(disp('ppe', 'net') + v), p: null, o: null, sub: true });
    });

    const plRows = [{ t: 'sec', l: 'INCOME' }];
    incomeIds.forEach(id => { const b = by[id]; if (!b) return;
      plRows.push({ t: 'row', l: FS_HEAD[id].label, n: note(id), v: R2(disp(id, 'net')), p: b.has_prev ? R2(disp(id, 'prev')) : null }); });
    plRows.push({ t: 'tot', l: 'Total income', v: R2(income), p: anyPrev ? R2(incomeP) : null });
    plRows.push({ t: 'sec', l: 'EXPENSES' });
    expIds.forEach(id => { const b = by[id]; if (!b) return;
      plRows.push({ t: 'row', l: FS_HEAD[id].label, n: note(id), v: R2(disp(id, 'net')), p: b.has_prev ? R2(disp(id, 'prev')) : null,
                    flag: b.items.some(it => it.flag) }); });
    plRows.push({ t: 'tot', l: 'Total expenses', v: R2(expense), p: anyPrev ? R2(expenseP) : null });
    plRows.push({ t: 'tot', l: 'Profit before tax', v: R2(pbt), p: anyPrev ? R2(pbtP) : null });
    taxIds.forEach(id => { const b = by[id]; if (!b) return;
      plRows.push({ t: 'row', l: FS_HEAD[id].label, n: note(id), v: R2(disp(id, 'net')), p: b.has_prev ? R2(disp(id, 'prev')) : null }); });
    plRows.push({ t: 'tot', l: 'Profit for the year', v: R2(pat), p: anyPrev ? R2(patP) : null });

    const tie = R2(assetsTot - eqTot);
    const unmapped = rows.filter(r => !r.head).map(r => ({ name: r.name, net: R2(r.net) }));

    return {
      entity: data.entity || {},
      accounting_policy: data.accounting_policy || '',
      disclosures: data.disclosures || {},
      balance_sheet: { rows: bsRows, total_equity_liabilities: R2(eqTot), total_assets: R2(assetsTot),
                       total_equity_liabilities_prev: anyPrev ? R2(eqTotP) : null, total_assets_prev: anyPrev ? R2(assetsTotP) : null,
                       total_equity_liabilities_opening: anyOpening ? R2(eqTotOpen) : null,
                       total_assets_opening: anyOpening ? R2(assetsTotOpen) : null,
                       any_opening: anyOpening,
                       tie, tied: Math.abs(tie) < 0.005 },
      profit_and_loss: { rows: plRows, profit_before_tax: R2(pbt), tax: R2(tax), profit_for_year: R2(pat) },
      notes, unmapped, any_prev: anyPrev,
      control: { total_dr: totalDr, total_cr: totalCr, diff, tied: Math.abs(diff) < 0.005 },
      opening_control: { total_opening_dr: totalOpeningDr, total_opening_cr: totalOpeningCr,
                        diff: openingDiff, tied: Math.abs(openingDiff) < 0.005 },
      unverified: []
    };
  }

  // --------------------------------------------------------- government grants
  // NAS 20 / IAS 20. Mirrors server engines.py's grant_accounting() field-for-
  // field (snake_case keys throughout, matching the Python port) so a report
  // built from either side renders identically. Same scope note as the server:
  // core four engines + rate-reset events only; opening restatement, clawback
  // events and deferred tax are not yet ported.
  function gD(s) {
    if (!s) return null;
    const p = String(s).split('-');
    if (p.length < 3) return null;
    const d = new Date(Date.UTC(+p[0], +p[1] - 1, +p[2]));
    return isNaN(d.getTime()) ? null : d;
  }
  const gIso = d => d ? d.toISOString().slice(0, 10) : '';
  function gAddM(d, m) {
    const x = new Date(d.getTime()); const day = x.getUTCDate();
    x.setUTCMonth(x.getUTCMonth() + m);
    if (x.getUTCDate() < day) x.setUTCDate(0);
    return x;
  }
  const gAddD = (d, k) => { const x = new Date(d.getTime()); x.setUTCDate(x.getUTCDate() + k); return x; };
  const gDays = (a, b) => Math.round((b - a) / 86400000);
  const monIdx = d => d.getUTCFullYear() * 12 + d.getUTCMonth();
  const monStart = i => new Date(Date.UTC(Math.floor(i / 12), i % 12, 1));
  const monEnd = i => new Date(Date.UTC(Math.floor(i / 12), i % 12 + 1, 0));

  function fyParts(fyStart) { const p = (fyStart || '07-16').split('-'); return { m: +p[0], d: +p[1] }; }
  function fyStartOf(y, fyStart) { const p = fyParts(fyStart); return new Date(Date.UTC(y, p.m - 1, p.d)); }
  function fyYear(d, fyStart) { if (!d) return null; const y = d.getUTCFullYear(); return (d >= fyStartOf(y, fyStart)) ? y : y - 1; }
  function fyRangeG(y, fyStart) { return { a: fyStartOf(y, fyStart), b: gAddD(fyStartOf(y + 1, fyStart), -1) }; }
  function fyLabelG(y, fyStart, fyLabelMode) {
    if (y === null || y === undefined) return '—';
    const p = fyParts(fyStart);
    if (p.m === 1 && p.d === 1) return String(y);
    if (fyLabelMode === 'bs') { const b = y + 57; return b + '/' + String((b + 1) % 100).padStart(2, '0'); }
    return y + '/' + String((y + 1) % 100).padStart(2, '0');
  }
  function fySpreadG(a, b, amt, out, fyStart) {
    out = out || {};
    if (!a || !b || b < a) { if (a) { const y0 = fyYear(a, fyStart); out[y0] = (out[y0] || 0) + amt; } return out; }
    const tot = gDays(a, b) + 1; let cursor = a, guard = 0;
    while (cursor <= b && guard++ < 400) {
      const y = fyYear(cursor, fyStart), fr = fyRangeG(y, fyStart);
      const e = (fr.b < b) ? fr.b : b;
      out[y] = (out[y] || 0) + amt * ((gDays(cursor, e) + 1) / tot);
      if (e >= b) break;
      cursor = gAddD(e, 1);
    }
    return out;
  }
  const addFyG = (m, y, v) => { m[y] = (m[y] || 0) + v; return m; };
  const sumAllG = m => Object.values(m).reduce((s, v) => s + v, 0);

  function depSeriesG(cost, resid, months, method, rate, startD, uopRows, fyStart) {
    const out = []; if (months <= 0) return out;
    const mi0 = monIdx(startD);
    if (method === 'wdv') {
      let wdv = cost; const yrs = Math.ceil(months / 12); let rem = months;
      for (let y = 0; y < yrs; y++) {
        const mThis = Math.min(12, rem); rem -= mThis;
        let annual = wdv * (rate / 100) * (mThis / 12);
        if (wdv - annual < resid) annual = Math.max(0, wdv - resid);
        if (y === yrs - 1) annual = Math.max(0, wdv - resid);
        for (let i = 0; i < mThis; i++) out.push({ mi: mi0 + y * 12 + i, dep: mThis ? annual / mThis : 0 });
        wdv -= annual;
      }
    } else if (method === 'uop') {
      let totU = 0; (uopRows || []).forEach(r => totU += N(r.units));
      if (totU <= 0) return out;
      uopRows.forEach(r => {
        const amt = (cost - resid) * N(r.units) / totU;
        const fr = fyRangeG(+r.fy, fyStart), m1 = monIdx(fr.a), m2 = monIdx(fr.b), cnt = m2 - m1 + 1;
        for (let i = 0; i < cnt; i++) out.push({ mi: m1 + i, dep: cnt ? amt / cnt : 0 });
      });
    } else {
      const base = cost - resid;
      for (let i = 0; i < months; i++) out.push({ mi: mi0 + i, dep: base / months });
    }
    return out;
  }
  const monthsOfG = g => (g.life_unit === 'months') ? Math.round(N(g.life)) : Math.round(N(g.life) * 12);

  function loanFlowsG(g, events, fyStart) {
    const P = N(g.principal), f = N(g.freq) || 12, Nt = Math.max(1, Math.round(N(g.tenor)));
    const gr = Math.max(0, Math.min(Nt - 1, Math.round(N(g.grace) || 0)));
    const ic = N(g.rate_c) / 100 / f, d0 = gD(g.disb_date) || gD(g.receipt_date) || new Date();
    const custom = {}; (g.custom || []).forEach(c => { custom[Math.round(N(c.p))] = N(c.amt); });
    const resets = (events || []).filter(e => e.grant_id === g.id && e.kind === 'rate')
      .map(e => ({ d: gD(e.date), r: N(e.amount) / 100 / f })).filter(e => e.d).sort((a, b) => a.d - b.d);

    const rows = []; let bal = P, lvl = null;
    const mode = g.mode || 'emi';
    for (let p = 1; p <= Nt; p++) {
      const ds = gAddM(d0, Math.round((p - 1) * 12 / f)), de = gAddM(d0, Math.round(p * 12 / f));
      let rt = ic;
      resets.forEach(x => { if (x.d <= ds) rt = x.r; });
      const int_ = bal * rt;
      let pay;
      if (p <= gr) { pay = g.grace_type === 'full' ? 0 : int_; }
      else if (mode === 'custom') {
        pay = custom[p] || 0;
        if (p === Nt && Math.abs(bal + int_ - pay) > 0.01) pay = bal + int_;
      } else if (mode === 'bullet') { pay = int_ + (p === Nt ? bal : 0); }
      else if (mode === 'zero') { pay = (p === Nt) ? bal + int_ : 0; }
      else if (mode === 'epi') {
        const k1 = Nt - gr;
        pay = (bal > 0 ? (P > 0 ? (g.grace_type === 'full' ? bal / (Nt - p + 1) : P / k1) : 0) : 0) + int_;
        if (p === Nt) pay = bal + int_;
      } else {
        if (lvl === null) { const k = Nt - gr, b = bal, i2 = rt; lvl = i2 ? b * i2 / (1 - Math.pow(1 + i2, -k)) : b / k; }
        pay = lvl;
        if (p === Nt) pay = bal + int_;
      }
      const pr = pay - int_, close = bal + int_ - pay;
      rows.push({ p, ds, de, open: bal, cint: int_, pay, cprin: pr, close, rate: rt * f * 100 });
      bal = close;
      if (Math.abs(bal) < 0.005) bal = 0;
    }
    return rows;
  }

  function engLoanG(g, events, fyStart) {
    const f = N(g.freq) || 12, im = N(g.rate_m) / 100 / f, P = N(g.principal);
    const rows = loanFlowsG(g, events, fyStart);
    let fv = 0; rows.forEach(r => { fv += r.pay / Math.pow(1 + im, r.p); });
    fv = R2(fv);
    const grant = R2(P - fv);
    const W = []; let open = fv, tot = 0;
    rows.forEach((r, i) => {
      let mint = open * im, close = open + mint - r.pay;
      if (i === rows.length - 1) { mint = r.pay - open; close = 0; }
      r.fv_open = open; r.mint = mint; r.fv_close = close;
      const w = Math.max(0, mint - r.cint); W.push(w); tot += w;
      open = close;
    });
    if (tot <= 0.0001) { rows.forEach((r, i) => W[i] = 1); tot = rows.length || 1; }
    let run = 0;
    rows.forEach((r, i) => {
      r.rel = grant * W[i] / tot; run += r.rel;
      r.rel_cum = run; r.di_open = grant - run + r.rel; r.di_close = grant - run;
    });
    const release = g.release;
    if (release === 'sl' && rows.length) {
      run = 0; rows.forEach(r => { r.rel = grant / rows.length; run += r.rel; r.rel_cum = run; r.di_open = grant - run + r.rel; r.di_close = grant - run; });
    }
    if (release === 'immediate') {
      run = 0; rows.forEach((r, i) => { r.rel = i === 0 ? grant : 0; run += r.rel; r.rel_cum = run; r.di_open = grant - run + r.rel; r.di_close = grant - run; });
    }

    const fy = { income: {}, interest: {}, cint: {}, cash: {} };
    const jr = [];
    const d0 = gD(g.disb_date) || gD(g.receipt_date);
    rows.forEach(r => {
      fySpreadG(r.ds, gAddD(r.de, -1), r.mint, fy.interest, fyStart);
      fySpreadG(r.ds, gAddD(r.de, -1), r.cint, fy.cint, fyStart);
      if (release === 'immediate') { if (r.p === 1 && d0) addFyG(fy.income, fyYear(d0, fyStart), r.rel); }
      else fySpreadG(r.ds, gAddD(r.de, -1), r.rel, fy.income, fyStart);
      addFyG(fy.cash, fyYear(r.de, fyStart), -r.pay);
    });

    let relByAsset = false;
    if (release === 'asset' && N(g.asset_cost) > 0) {
      const ds2 = depSeriesG(N(g.asset_cost), N(g.residual), monthsOfG(g), g.dep_method || 'slm', N(g.wdv_rate), gD(g.asset_date) || d0 || new Date(), g.uop, fyStart);
      const td = ds2.reduce((s, x) => s + x.dep, 0);
      if (td > 0) {
        relByAsset = true; fy.income = {};
        ds2.forEach(x => fySpreadG(monStart(x.mi), monEnd(x.mi), grant * x.dep / td, fy.income, fyStart));
        rows.forEach(r => { r.rel = null; r.di_close = null; });
      }
    }
    if (release === 'immediate') rows.forEach(r => { r.rel = 0; r.di_close = 0; });

    const name = g.name || '';
    jr.push({ date: gIso(d0), title: 'Receipt of concessional government loan', lines: [
      { ac: 'Bank / Cash', dr: R2(P), cr: 0 },
      { ac: 'Government loan — ' + name + ' (at fair value)', dr: 0, cr: fv },
      { ac: (release === 'immediate' ? 'Government grant income (P/L)' : 'Deferred government grant income'), dr: 0, cr: grant }
    ], narration: 'Loan received at a concessional rate against a market rate of ' + N(g.rate_m).toFixed(2) +
      '%; loan recognised at fair value ' + fv.toLocaleString() + ' and the ' + grant.toLocaleString() +
      ' benefit recognised as a government grant (NFRS 9 / NAS 20.10A).' });
    rows.forEach(r => {
      jr.push({ date: gIso(r.de), title: 'Loan servicing — period ' + r.p, lines: [
        { ac: 'Interest expense', dr: R2(r.mint), cr: 0 },
        { ac: 'Government loan — ' + name, dr: R2(r.pay - r.mint), cr: 0 },
        { ac: 'Bank / Cash', dr: 0, cr: R2(r.pay) }
      ], narration: 'Interest at the effective (market) rate and instalment of ' + r.pay.toLocaleString() + ' for period ' + r.p + '.' });
      if (!relByAsset && release !== 'immediate' && Math.abs(r.rel || 0) > 0.004) {
        jr.push({ date: gIso(r.de), title: 'Release of interest subsidy to profit or loss', lines: [
          { ac: 'Deferred government grant income', dr: R2(r.rel), cr: 0 },
          { ac: 'Government grant income (P/L)', dr: 0, cr: R2(r.rel) }
        ], narration: 'Grant released against the interest cost of the period (NAS/IAS 20.20).' });
      }
    });
    if (relByAsset) {
      Object.keys(fy.income).map(Number).sort((a, b) => a - b).forEach(y => {
        const amt = fy.income[y]; if (Math.abs(amt) < 0.004) return;
        const fr = fyRangeG(y, fyStart);
        jr.push({ date: gIso(fr.b), title: 'Release of loan subsidy — FY ' + y, lines: [
          { ac: 'Deferred government grant income', dr: R2(amt), cr: 0 },
          { ac: 'Government grant income (P/L)', dr: 0, cr: R2(amt) }
        ], narration: 'Subsidy on the concessional loan released on the same basis as the depreciation of the asset it funded.' });
      });
    }
    return { kind: 'loan', rows, fv, grant, principal: R2(P), fy, journals: jr, rel_by_asset: relByAsset,
      total_pay: R2(rows.reduce((s, r) => s + r.pay, 0)), total_cint: R2(rows.reduce((s, r) => s + r.cint, 0)),
      total_mint: R2(rows.reduce((s, r) => s + r.mint, 0)) };
  }

  function engAssetG(g, fyStart) {
    const G = N(g.amount), cost = N(g.asset_cost), res = N(g.residual), M = monthsOfG(g);
    const start = gD(g.asset_date) || gD(g.receipt_date) || new Date();
    const gs = gD(g.grant_start) || gD(g.receipt_date) || start;
    const ds = depSeriesG(cost, res, M, g.dep_method || 'slm', N(g.wdv_rate), start, g.uop, fyStart);
    const dsN = depSeriesG(Math.max(0, cost - G), res, M, g.dep_method || 'slm', N(g.wdv_rate), start, g.uop, fyStart);
    const totDep = ds.reduce((s, x) => s + x.dep, 0);
    const relStartMi = monIdx(gs);
    let denom = g.catchup === 'prospective' ? ds.filter(x => x.mi >= relStartMi).reduce((s, x) => s + x.dep, 0) : totDep;
    if (denom <= 0) denom = 1;

    const rows = []; let cumCatch = 0;
    ds.forEach((x, i) => {
      let rel;
      if (g.catchup === 'prospective') rel = x.mi >= relStartMi ? G * x.dep / denom : 0;
      else { rel = G * x.dep / denom; if (x.mi < relStartMi) { cumCatch += rel; rel = 0; } }
      rows.push({ mi: x.mi, dep: x.dep, rel, dep_net: dsN[i] ? dsN[i].dep : 0 });
    });
    if (g.catchup !== 'prospective' && cumCatch > 0) {
      for (let i = 0; i < rows.length; i++) if (rows[i].mi >= relStartMi) { rows[i].rel += cumCatch; rows[i].catchup = cumCatch; break; }
    }
    const fy = { income: {}, dep: {}, dep_net: {}, cash: {} };
    rows.forEach(x => {
      fySpreadG(monStart(x.mi), monEnd(x.mi), x.dep, fy.dep, fyStart);
      fySpreadG(monStart(x.mi), monEnd(x.mi), x.dep_net, fy.dep_net, fyStart);
      fySpreadG(monStart(x.mi), monEnd(x.mi), x.rel, fy.income, fyStart);
    });
    addFyG(fy.cash, fyYear(gD(g.receipt_date) || gs, fyStart), G);

    const name = g.name || '', recv = g.receipt_date || gIso(gs);
    const jr = [];
    if (g.type === 'nonmonetary') {
      if (g.nm_basis === 'nominal') {
        jr.push({ date: recv, title: 'Non-monetary government grant recorded at nominal amount', lines: [
          { ac: 'Property, plant and equipment — ' + name, dr: 1, cr: 0 }, { ac: 'Government grant income (P/L)', dr: 0, cr: 1 }],
          narration: 'Asset transferred by government recorded at a nominal amount (NAS/IAS 20.23). No grant income is recognised over time.' });
      } else {
        jr.push({ date: recv, title: 'Non-monetary government grant recorded at fair value', lines: [
          { ac: 'Property, plant and equipment — ' + name, dr: R2(cost), cr: 0 }, { ac: 'Deferred government grant income', dr: 0, cr: R2(G) }],
          narration: 'Asset received free of charge recorded at fair value ' + cost.toLocaleString() + ' with a corresponding grant (NAS/IAS 20.23).' });
      }
    } else if (g.present === 'netcost') {
      jr.push({ date: recv, title: 'Asset-related grant received — deducted from asset', lines: [
        { ac: 'Bank / Cash', dr: R2(G), cr: 0 }, { ac: 'Property, plant and equipment — ' + name, dr: 0, cr: R2(G) }],
        narration: "Grant deducted from the carrying amount of the asset (NAS/IAS 20.27). The benefit reaches profit or loss through a reduced depreciation charge." });
    } else {
      jr.push({ date: recv, title: 'Asset-related grant received', lines: [
        { ac: 'Bank / Cash', dr: R2(G), cr: 0 }, { ac: 'Deferred government grant income', dr: 0, cr: R2(G) }],
        narration: 'Grant received towards the cost of ' + name + ', deferred and released over the life of the asset (NAS/IAS 20.24, 20.26).' });
    }
    Object.keys(fy.dep).map(Number).sort((a, b) => a - b).forEach(y => {
      const fr = fyRangeG(y, fyStart);
      const dep = (g.present === 'netcost' ? fy.dep_net[y] : fy.dep[y]) || 0;
      const rel = fy.income[y] || 0;
      if (dep > 0.004) jr.push({ date: gIso(fr.b), title: 'Depreciation for FY ' + y, lines: [
        { ac: 'Depreciation expense', dr: R2(dep), cr: 0 }, { ac: 'Accumulated depreciation — ' + name, dr: 0, cr: R2(dep) }],
        narration: g.present === 'netcost' ? 'Depreciation on the asset net of the government grant.' : 'Depreciation on the gross cost of the asset.' });
      if (g.present !== 'netcost' && !(g.type === 'nonmonetary' && g.nm_basis === 'nominal') && rel > 0.004)
        jr.push({ date: gIso(fr.b), title: 'Release of deferred grant income — FY ' + y, lines: [
          { ac: 'Deferred government grant income', dr: R2(rel), cr: 0 }, { ac: 'Government grant income (P/L)', dr: 0, cr: R2(rel) }],
          narration: 'Deferred grant released to profit or loss on the same basis as depreciation.' });
    });
    return { kind: 'asset', rows, fy, journals: jr, grant: R2(G), cost: R2(cost), total_dep: R2(totDep),
      net_cost: R2(Math.max(0, cost - G)), catchup: R2(cumCatch) };
  }

  function engIncomeG(g, fyStart) {
    const G = N(g.amount), fy = { income: {}, cash: {} }, rows = [];
    const recv = gD(g.receipt_date) || gD(g.assurance) || new Date();
    if (g.type === 'pastcost' || g.inc_pattern === 'immediate') {
      addFyG(fy.income, fyYear(recv, fyStart), G);
      rows.push({ label: 'Recognised in full on ' + gIso(recv), amount: R2(G) });
    } else if (g.inc_pattern === 'sched') {
      let tot = 0; (g.inc_sched || []).forEach(r => tot += N(r.cost));
      (g.inc_sched || []).forEach(r => {
        const amt = tot ? G * N(r.cost) / tot : 0;
        addFyG(fy.income, +r.fy, amt);
        rows.push({ label: 'FY ' + r.fy + ' — eligible cost ' + N(r.cost).toLocaleString(), amount: R2(amt) });
      });
    } else {
      const a = gD(g.inc_start) || recv, b = gD(g.inc_end) || recv;
      fySpreadG(a, b, G, fy.income, fyStart);
      rows.push({ label: 'Spread evenly from ' + gIso(a) + ' to ' + gIso(b), amount: R2(G) });
    }
    addFyG(fy.cash, fyYear(recv, fyStart), G);

    const name = g.name || '';
    const upfront = g.type === 'pastcost' || g.inc_pattern === 'immediate';
    const netExp = g.inc_present === 'netexp';
    const jr = [{ date: gIso(recv), title: upfront ? 'Government grant received — recognised immediately' : 'Government grant received — deferred',
      lines: [{ ac: 'Bank / Cash', dr: R2(G), cr: 0 },
              { ac: upfront ? (netExp ? 'Related expense (grant credited against cost)' : 'Government grant income (P/L)') : 'Deferred government grant income', dr: 0, cr: R2(G) }],
      narration: upfront ? 'Grant receivable as compensation for expenses or losses already incurred, recognised in profit or loss in the period it becomes receivable (NAS/IAS 20.20).'
                          : 'Grant received towards costs of a specified period, deferred and recognised as those costs are incurred (NAS/IAS 20.12, 20.16).' }];
    if (!upfront) {
      Object.keys(fy.income).map(Number).sort((a, b) => a - b).forEach(y => {
        const amt = fy.income[y]; if (Math.abs(amt) < 0.004) return;
        const fr = fyRangeG(y, fyStart);
        jr.push({ date: gIso(fr.b), title: 'Recognition of grant income — FY ' + y, lines: [
          { ac: 'Deferred government grant income', dr: R2(amt), cr: 0 },
          { ac: netExp ? 'Related expense (grant credited against cost)' : 'Government grant income (P/L)', dr: 0, cr: R2(amt) }],
          narration: 'Grant matched with the costs it is intended to compensate.' });
      });
    }
    return { kind: 'income', rows, fy, journals: jr, grant: R2(G) };
  }

  function engForgivableG(g, events, fyStart) {
    const res = engLoanG(g, events, fyStart);
    const fd = gD(g.forgive_date), amt = N(g.forgive_amt);
    if (fd) {
      let bal = amt;
      if (!amt) {
        bal = res.fv;
        res.rows.forEach(r => { if (r.de <= fd) bal = r.fv_close; });
        const part = res.rows.filter(r => r.ds <= fd && r.de > fd)[0];
        if (part) {
          const t = gDays(part.ds, gAddD(part.de, -1)) + 1, e = gDays(part.ds, fd) + 1;
          bal += part.mint * Math.max(0, Math.min(1, e / t));
        }
      }
      ['income', 'interest', 'cint', 'cash'].forEach(key => {
        res.fy[key] = truncFyG(res.fy[key], fd, fyStart);
      });
      const released = sumAllG(res.fy.income), rest = res.grant - released;
      if (Math.abs(rest) > 0.004) {
        addFyG(res.fy.income, fyYear(fd, fyStart), rest);
        res.journals.push({ date: gIso(fd), title: 'Unamortised interest subsidy released on waiver', lines: [
          { ac: 'Deferred government grant income', dr: R2(rest), cr: 0 }, { ac: 'Government grant income (P/L)', dr: 0, cr: R2(rest) }],
          narration: 'The loan no longer exists, so the deferred credit relating to the concessional interest is released in full.' });
      }
      addFyG(res.fy.income, fyYear(fd, fyStart), bal);
      res.journals = res.journals.filter(j => { const d = gD(j.date); return !d || d <= fd; });
      res.journals.push({ date: gIso(fd), title: 'Forgiveness of government loan', lines: [
        { ac: 'Government loan — ' + (g.name || ''), dr: R2(bal), cr: 0 }, { ac: 'Government grant income (P/L)', dr: 0, cr: R2(bal) }],
        narration: 'Loan waived by the grantor, or reasonable assurance obtained that the terms for forgiveness will be met — treated as a government grant from that date (NAS/IAS 20.10).' });
      res.forgiven = R2(bal); res.forgive_date = gIso(fd);
      res.rows = res.rows.filter(r => r.de <= fd);
    }
    res.kind = 'forgivable';
    return res;
  }
  function truncFyG(map, d, fyStart) {
    const y = fyYear(d, fyStart), out = {};
    Object.keys(map).forEach(k => {
      if (+k < y) out[k] = map[k];
      else if (+k === y) { const fr = fyRangeG(y, fyStart), t = gDays(fr.a, fr.b) + 1, e = gDays(fr.a, d) + 1;
        out[k] = map[k] * Math.max(0, Math.min(1, e / t)); }
    });
    return out;
  }

  function runGrantG(g, events, fyStart) {
    let res;
    const gtype = g.type;
    if (gtype === 'loan') res = engLoanG(g, events, fyStart);
    else if (gtype === 'forgivable') res = engForgivableG(g, events, fyStart);
    else if (gtype === 'asset' || gtype === 'nonmonetary') res = engAssetG(g, fyStart);
    else res = engIncomeG(g, fyStart);

    const warn = [];
    const ad = gD(g.assurance);
    if (ad && Object.keys(res.fy.income).length) {
      const first = Math.min(...Object.keys(res.fy.income).map(Number));
      if (first < fyYear(ad, fyStart)) warn.push('Grant income is being recognised from FY ' + first +
        ', which is before reasonable assurance was obtained on ' + g.assurance + '. NAS/IAS 20.7 does not permit recognition before that date.');
    }

    let running = 0; const closing = {};
    Object.keys(res.fy.income).map(Number).sort((a, b) => a - b).forEach(y => {
      running += res.fy.income[y]; closing[y] = R2(res.grant - running);
    });

    (res.rows || []).forEach(r => { delete r.ds; delete r.de; });
    res.id = g.id; res.name = g.name || ''; res.warn = warn;
    res.deferred_income_closing_by_fy = closing;
    const fyOut = {};
    Object.keys(res.fy).forEach(k => { fyOut[k] = {}; Object.keys(res.fy[k]).forEach(y => { fyOut[k][y] = R2(res.fy[k][y]); }); });
    res.fy = fyOut;
    return res;
  }

  function grantAccounting(data) {
    data = data || {};
    const meta = data.meta || {};
    const fyStart = meta.fy_start || '07-16';
    const fyLabelMode = meta.fy_label || 'bs';
    const events = data.events || [];

    const grants = [];
    const allFy = new Set();
    (data.grants || []).forEach(g => {
      const res = runGrantG(g, events, fyStart);
      grants.push(res);
      Object.keys(res.deferred_income_closing_by_fy).forEach(y => allFy.add(+y));
      Object.keys(res.fy.income || {}).forEach(y => allFy.add(+y));
    });

    const consolidated = Array.from(allFy).sort((a, b) => a - b).map(y => {
      const row = { fy: y, fy_label: fyLabelG(y, fyStart, fyLabelMode), income: 0, dep: 0, interest: 0, deferred_income_closing: 0, cash: 0 };
      grants.forEach(res => {
        row.income += (res.fy.income || {})[y] || 0;
        row.dep += (res.fy.dep || {})[y] || (res.fy.dep_net || {})[y] || 0;
        row.interest += (res.fy.interest || {})[y] || 0;
        row.deferred_income_closing += res.deferred_income_closing_by_fy[y] || 0;
        row.cash += (res.fy.cash || {})[y] || 0;
      });
      ['income', 'dep', 'interest', 'deferred_income_closing', 'cash'].forEach(k => row[k] = R2(row[k]));
      return row;
    });

    return { grants, consolidated, unverified: [] };
  }

  // --------------------------------------------- projection / feasibility model
  // Mirrors server engines.py's projection() field-for-field. Industry defaults
  // aren't rate_master (not FY-versioned) so they don't come through `rates` —
  // the caller passes the cached industry_default rows via data.industries
  // (from state.boot.industry_defaults), and this looks one up by name.
  const POOL_RATES = { A: 0.05, B: 0.25, C: 0.20, D: 0.15 };
  const pn = (v, dflt) => (v === null || v === undefined || v === '') ? (dflt || 0) : Number(v);

  function pPmt(r, n, p) { return r === 0 ? p / n : p * r / (1 - Math.pow(1 + r, -n)); }

  function pLoanSchedule(P, r, T, m, ltype, horizon, balloonPct) {
    const H = Math.max(horizon, T || 0) + 1;
    const open_ = [], int_ = [], prin = [], close = [], cap = [];
    if (ltype === 'none' || !(P > 0)) {
      const z = Array(H).fill(0);
      return { open: z, int: z.slice(), prin: z.slice(), close: z.slice(), cap: z.slice() };
    }
    let bal = P;
    T = T || 1;
    const K = Math.max(1, Math.round(T) - Math.round(m || 0));
    for (let t = 1; t <= H; t++) {
      open_.push(bal);
      let it = bal * r, pr = 0, cp = 0;
      if (ltype === 'wconly') { pr = 0; }
      else if (ltype === 'bullet') { pr = (t === T) ? bal : 0; }
      else if (ltype === 'epi' || ltype === 'subsidised') { pr = t <= m ? 0 : Math.min(bal, P / K); }
      else if (ltype === 'emi') { const inst = pPmt(r, K, P); pr = t <= m ? 0 : Math.min(bal, Math.max(0, inst - it)); }
      else if (ltype === 'balloon') {
        const amort = P * (1 - pn(balloonPct, 0.30));
        pr = t <= m ? 0 : Math.min(bal, amort / K);
        if (t === T) pr = bal;
      } else if (ltype === 'stepup') {
        const w = []; let sw = 0; for (let k = 1; k <= K; k++) { w.push(k); sw += k; }
        pr = t <= m ? 0 : Math.min(bal, P * w[Math.min(t - m, K) - 1] / sw);
      } else if (ltype === 'moratint') {
        if (t <= m) { cp = it; it = 0; }
        else pr = Math.min(bal, bal / Math.max(1, (T - m) - (t - m - 1)));
      } else if (ltype === 'hp') {
        const mr = r / 12, n_ = Math.round(T) * 12, inst = pPmt(mr, n_, P);
        let b = bal, yi = 0, yp = 0;
        for (let k = 0; k < 12; k++) { if ((t - 1) * 12 + k >= n_) break; const im = b * mr; const pm = Math.min(b, inst - im); yi += im; yp += pm; b -= pm; }
        it = yi; pr = yp;
      }
      if (t > T && ltype !== 'wconly') { it = bal > 0 ? bal * r : 0; }
      bal = bal + cp - pr;
      if (bal < 0.5) bal = 0;
      int_.push(it); prin.push(pr); cap.push(cp); close.push(bal);
    }
    return { open: open_, int: int_, prin, close, cap };
  }

  function pIrr(cf) {
    let lo = -0.95, hi = 10;
    const f = x => cf.reduce((s, c, i) => s + c / Math.pow(1 + x, i), 0);
    if (f(lo) * f(hi) > 0) return null;
    for (let k = 0; k < 200; k++) { const mid = (lo + hi) / 2; (f(lo) * f(mid) <= 0) ? hi = mid : lo = mid; }
    return (lo + hi) / 2;
  }
  const pNpv = (r, cf) => cf.reduce((s, c, i) => s + c / Math.pow(1 + r, i), 0);
  function pPayback(cum, flows) {
    for (let i = 0; i < cum.length; i++) if (cum[i] >= 0) {
      if (i === 0) return 0;
      return (i - 1) + Math.abs(cum[i - 1]) / (flows[i] || 1);
    }
    return 99;
  }
  function pUtilArray(n, indUtil, override) {
    if (override && override.length === n) return override.slice();
    const out = [];
    for (let i = 0; i < n; i++) {
      if (i < indUtil.length) out.push(indUtil[i]);
      else { const last = out[out.length - 1]; out.push(Math.min(0.95, last + (0.95 - last) * 0.35)); }
    }
    return out;
  }
  function industryDefault(name, industries) {
    return (industries || []).find(x => x.name === name) || {
      name: 'Other / Custom', asset_turnover: 1.2, util_ramp: [.5, .65, .75, .82, .88],
      cost_pct: [.55, .08, .045, .04, .05, .055], repairs_pct: .02, wc_days: [40, 30, 25, 35],
      tax_rate: .25, hol_years: 0, hol_rate: .25, impl_months: 12, life_years: 9,
      tenure_years: 9, moratorium_years: 1, capex_split: [.07, .18, .43, .045, .03, .075, .17],
      uom: 'Units', revenue_mode: 'turnover', gearing_norm: .65
    };
  }

  function projection(fy, data) {
    const s = data || {};
    const g = (k, dflt) => pn(s[k], dflt);
    const ind = industryDefault(s.industry, s.industries);
    const A = (key, dflt) => (s[key] === null || s[key] === undefined || s[key] === '') ? dflt : Number(s[key]);

    const N = Math.round(g('years', 5));
    const loanType = s.loan_type || 'epi';
    const netRate = Math.max(0, g('rate') - (loanType === 'subsidised' ? g('subsidy') : 0));
    const T = g('loan') + g('equity') + g('grant_amt') + g('promoter_loan');
    const hOn = (s.client || 'greenfield') === 'greenfield' ? 0 : 1;

    const cx = ind.capex_split;
    const land = T * cx[0], bldg = T * cx[1], plant = T * cx[2], veh = T * cx[3], furn = T * cx[4];
    const preopBase = T * cx[5], mmGross = T * cx[6];
    const procFee = g('loan') * g('proc_fee');
    const preop = preopBase + procFee;
    const mm = mmGross - procFee;
    const fixedOutlay = land + bldg + plant + veh + furn;
    const grossBlock = fixedOutlay + hOn * g('h_nb');
    const deprBlock = bldg + plant + veh + furn;
    const life = A('life_years', ind.life_years) || 15;

    const util = pUtilArray(N, ind.util_ramp, s.util);
    const pesc = g('price_esc', .04), cesc = g('cost_esc', .05);
    const rmodeAsked = s.revenue_mode || ind.revenue_mode;
    let rmode = rmodeAsked;
    if (rmode === 'capacity' && !(g('capacity') > 0 && g('price') > 0)) rmode = 'turnover';

    const base100 = rmode === 'capacity' ? g('capacity') * g('price') : T * A('at', ind.asset_turnover);
    const projRev = Array(N).fill(0), volume = Array(N).fill(0);
    for (let t = 0; t < N; t++) {
      const pidx = Math.pow(1 + pesc, t);
      volume[t] = rmode === 'capacity' ? g('capacity') * util[t] : 0;
      projRev[t] = base100 * util[t] * pidx;
    }
    const hGrowth = g('h_growth', .07);
    const histRev = Array.from({ length: N }, (_, t) => hOn * g('h_rev') * Math.pow(1 + hGrowth, t + 1));
    const histOpex = Array.from({ length: N }, (_, t) => histRev[t] - hOn * g('h_ebitda') * Math.pow(1 + hGrowth, t + 1));
    const sales = projRev.map((v, t) => v + histRev[t]);
    const otherInc = Array.from({ length: N }, (_, t) => g('other_income') * Math.pow(1 + pesc, t));
    const income = sales.map((v, t) => v + otherInc[t]);

    const ef = t => Math.pow((1 + cesc) / (1 + pesc), t), cidx = t => Math.pow(1 + cesc, t);
    const rm = Array(N).fill(0), lab = Array(N).fill(0), pow_ = Array(N).fill(0), moh = Array(N).fill(0),
          rep = Array(N).fill(0), adm = Array(N).fill(0), sel = Array(N).fill(0);
    const costPct = ind.cost_pct;
    for (let t = 0; t < N; t++) {
      const r_ = projRev[t] * ef(t);
      rm[t] = r_ * A('rm', costPct[0]); lab[t] = r_ * A('labour', costPct[1]); pow_[t] = r_ * A('power', costPct[2]);
      moh[t] = r_ * A('mfgoh', costPct[3]); adm[t] = r_ * A('admin', costPct[4]); sel[t] = r_ * A('selling', costPct[5]);
      rep[t] = grossBlock * A('repairs', ind.repairs_pct) * cidx(t);
    }
    const cop = Array.from({ length: N }, (_, t) => rm[t] + lab[t] + pow_[t] + moh[t] + rep[t]);
    const opex = Array.from({ length: N }, (_, t) => cop[t] + adm[t] + sel[t] + histOpex[t]);
    const ebitda = income.map((v, t) => v - opex[t]);

    const tenure = A('tenure_years', ind.tenure_years) || 10;
    const mor = Math.min(A('moratorium_years', ind.moratorium_years) || 0, tenure - 1);
    const sch = pLoanSchedule(g('loan'), netRate, tenure, mor, loanType, N, g('balloon_pct', .30));
    const schCap = sch.cap;
    const tlOpen = Array.from({ length: N }, (_, t) => sch.open[t] || 0);
    const tlInt = Array.from({ length: N }, (_, t) => sch.int[t] || 0);
    const tlPrin = Array.from({ length: N }, (_, t) => sch.prin[t] || 0);
    const tlClose = Array.from({ length: N }, (_, t) => sch.close[t] || 0);
    const hLoanClose = Array.from({ length: N }, (_, t) => Math.max(0, hOn * g('h_loan') * (1 - (t + 1) / 5)));
    const hLoanPrin = Array.from({ length: N }, (_, t) => (t === 0 ? hOn * g('h_loan') : hLoanClose[t - 1]) - hLoanClose[t]);

    const maint = Array.from({ length: N }, () => grossBlock * g('maint_capex'));
    const bookDep = Array(N).fill(0), taxDep = Array(N).fill(0), amort = Array(N).fill(0), amortPre = Array(N).fill(0);
    const nbArr = Array(N).fill(0), preopClose = Array(N).fill(0);
    const pools = { A: { r: POOL_RATES.A, b: bldg }, B: { r: POOL_RATES.B, b: furn },
                    C: { r: POOL_RATES.C, b: veh }, D: { r: POOL_RATES.D, b: plant + hOn * g('h_nb') } };
    const upl = s.special_dep ? 4 / 3 : 1;
    const K = Math.min(5, Math.max(1, N));
    let accBook = 0, nb = grossBlock, preopBal = preop;
    for (let t = 0; t < N; t++) {
      const slm = Math.min(deprBlock / life, Math.max(0, deprBlock - accBook));
      const histD = hOn * g('h_nb') * Math.pow(0.85, t) * 0.15;
      bookDep[t] = slm + histD; accBook += slm;
      let td = 0;
      for (const k in pools) { const p = pools[k]; const d = p.b * Math.min(1, p.r * upl); td += d; p.b -= d; }
      taxDep[t] = td;
      preopBal += (schCap[t] || 0);
      amortPre[t] = t < K ? preopBal / Math.max(1, K - t) : preopBal;
      preopBal -= amortPre[t];
      amort[t] = amortPre[t];
      preopClose[t] = preopBal;
      nb = nb - bookDep[t] + maint[t]; nbArr[t] = nb;
    }

    const deb = Array(N).fill(0), rmst = Array(N).fill(0), wipst = Array(N).fill(0), fgst = Array(N).fill(0),
          spst = Array(N).fill(0), ocaArr = Array(N).fill(0), payArr = Array(N).fill(0), advArr = Array(N).fill(0),
          oclArr = Array(N).fill(0), cred = Array(N).fill(0), gap = Array(N).fill(0), wcl = Array(N).fill(0),
          caOps = Array(N).fill(0), dpArr = Array(N).fill(0), limArr = Array(N).fill(0), ccc = Array(N).fill(0),
          ntaDays = Array(N).fill(0);
    const mpbf = { margin: Array(N).fill(0), tandon1: Array(N).fill(0), tandon2: Array(N).fill(0), turnover: Array(N).fill(0), dp: Array(N).fill(0) };
    const hCAg = Array.from({ length: N }, (_, t) => hOn * g('h_ca') * Math.pow(1 + hGrowth, t + 1));
    const hCLg = Array.from({ length: N }, (_, t) => hOn * g('h_cl') * Math.pow(1 + hGrowth, t + 1));
    const days = ind.wc_days;
    const dWIP = A('d_wip', Math.round(A('d_fg', days[2]) * 0.4));
    const peak = Math.max(1, g('peak_factor', 1) || 1);
    const wcMethod = s.wc_method || 'margin';
    for (let t = 0; t < N; t++) {
      deb[t] = sales[t] * A('d_deb', days[0]) / 365;
      rmst[t] = rm[t] * A('d_rm', days[1]) / 365;
      wipst[t] = cop[t] * dWIP / 365;
      fgst[t] = cop[t] * A('d_fg', days[2]) / 365;
      spst[t] = (pow_[t] + rep[t]) * g('d_spares') / 365;
      ocaArr[t] = sales[t] * g('d_other_ca') / 365 + hCAg[t];
      payArr[t] = rm[t] * A('d_cred', days[3]) / 365;
      advArr[t] = sales[t] * g('d_adv') / 365;
      oclArr[t] = cop[t] * g('d_other_cl') / 365 + hCLg[t];
      caOps[t] = deb[t] + rmst[t] + wipst[t] + fgst[t] + spst[t] + ocaArr[t];
      cred[t] = payArr[t] + advArr[t] + oclArr[t];
      gap[t] = caOps[t] - cred[t];
      ntaDays[t] = sales[t] ? gap[t] / sales[t] * 365 : 0;
      ccc[t] = A('d_rm', days[1]) + dWIP + A('d_fg', days[2]) + A('d_deb', days[0]) - A('d_cred', days[3]);

      const pCA = caOps[t] * peak, pCL = cred[t] * peak, pGap = gap[t] * peak;
      mpbf.margin[t] = pGap * g('wc_margin', .75);
      mpbf.tandon1[t] = 0.75 * pGap;
      mpbf.tandon2[t] = Math.max(0, 0.75 * pCA - pCL);
      mpbf.turnover[t] = 0.20 * sales[t];
      mpbf.dp[t] = (rmst[t] + spst[t]) * (1 - g('m_rm', .25)) + wipst[t] * (1 - g('m_wip', .25))
                 + fgst[t] * (1 - g('m_fg', .25)) + deb[t] * (1 - g('m_deb', .25)) + ocaArr[t] * (1 - g('m_other', .40)) - cred[t];
      dpArr[t] = Math.max(0, mpbf.dp[t]);
      let lim = Math.max(0, (mpbf[wcMethod] || mpbf.margin)[t]);
      if (g('sanction_limit') > 0) lim = Math.min(lim, g('sanction_limit'));
      limArr[t] = lim;
      wcl[t] = Math.max(0, Math.min(lim, gap[t] - mm));
      if (loanType === 'wconly') wcl[t] = Math.max(0, Math.min(gap[t], g('loan')));
    }
    const gap0 = hOn * (g('h_ca') - g('h_cl'));

    const pbt = Array(N).fill(0), pat = Array(N).fill(0), tax = Array(N).fill(0), lossBF = Array(N).fill(0),
          taxable = Array(N).fill(0), rateY = Array(N).fill(0), ebit = Array(N).fill(0), fin = Array(N).fill(0),
          div = Array(N).fill(0), re = Array(N).fill(0), cash = Array(N).fill(0), od = Array(N).fill(0),
          netcash = Array(N).fill(0), chk = Array(N).fill(0), ta = Array(N).fill(0), tel = Array(N).fill(0),
          nw = Array(N).fill(0), ocf = Array(N).fill(0), icf = Array(N).fill(0), fcf = Array(N).fill(0),
          wcInt = Array(N).fill(0), odInt = Array(N).fill(0);
    let prevRE = 0, prevCash = Math.max(0, mm), prevOD = Math.max(0, -mm), prevGap = gap0, prevWCL = 0, prevLoss = 0;
    const wcRate = g('wc_rate', .12);
    for (let t = 0; t < N; t++) {
      wcInt[t] = (prevWCL + wcl[t]) / 2 * wcRate;
      odInt[t] = prevOD * (wcRate + g('od_spread', .01));
      ebit[t] = ebitda[t] - bookDep[t] - amort[t];
      fin[t] = tlInt[t] + wcInt[t] + odInt[t];
      pbt[t] = ebit[t] - fin[t];
      const adjT = pbt[t] + bookDep[t] - taxDep[t];
      lossBF[t] = prevLoss;
      taxable[t] = Math.max(0, adjT - lossBF[t]);
      rateY[t] = (t + 1) <= A('hol_years', ind.hol_years) ? A('hol_rate', ind.hol_rate) : A('tax_rate', ind.tax_rate);
      tax[t] = taxable[t] * rateY[t];
      const lossCF = Math.max(0, lossBF[t] - adjT);
      prevLoss = lossCF;
      pat[t] = pbt[t] - tax[t];
      div[t] = (pat[t] > 0 && prevRE + pat[t] > 0) ? pat[t] * g('payout') : 0;
      re[t] = prevRE + pat[t] - div[t];
      const dGap = gap[t] - prevGap, dWCL = wcl[t] - prevWCL;
      ocf[t] = pat[t] + bookDep[t] + amort[t] - dGap;
      icf[t] = -maint[t];
      fcf[t] = dWCL - tlPrin[t] - hLoanPrin[t] - div[t];
      netcash[t] = (prevCash - prevOD) + ocf[t] + icf[t] + fcf[t];
      cash[t] = Math.max(0, netcash[t]); od[t] = Math.max(0, -netcash[t]);
      nw[t] = g('equity') + g('grant_amt') + hOn * g('h_nw') + re[t];
      const hBal = hOn * ((g('h_nb') + g('h_ca')) - (g('h_nw') + g('h_loan') + g('h_cl')));
      tel[t] = nw[t] + tlClose[t] + hLoanClose[t] + g('promoter_loan') + wcl[t] + od[t] + cred[t] + hBal;
      ta[t] = nbArr[t] + preopClose[t] + caOps[t] + cash[t];
      chk[t] = ta[t] - tel[t];
      prevRE = re[t]; prevCash = cash[t]; prevOD = od[t]; prevGap = gap[t]; prevWCL = wcl[t];
    }

    const cads = Array(N).fill(0), dso = Array(N).fill(0);
    const dscr = Array(N).fill(null), cr = Array(N).fill(null), icr = Array(N).fill(null),
          roce = Array(N).fill(null), roe = Array(N).fill(null), bep = Array(N).fill(null), de = Array(N).fill(null);
    for (let t = 0; t < N; t++) {
      cads[t] = pat[t] + bookDep[t] + amort[t] + tlInt[t];
      dso[t] = tlInt[t] + tlPrin[t];
      dscr[t] = dso[t] > 0.5 ? cads[t] / dso[t] : null;
      cr[t] = (wcl[t] + od[t] + cred[t]) > 0 ? (caOps[t] + cash[t]) / (wcl[t] + od[t] + cred[t]) : null;
      icr[t] = fin[t] > 0.5 ? ebit[t] / fin[t] : null;
      roce[t] = (nw[t] + tlClose[t]) !== 0 ? ebit[t] / (nw[t] + tlClose[t]) : null;
      roe[t] = nw[t] > 0 ? pat[t] / nw[t] : null;
      de[t] = nw[t] > 0 ? (tlClose[t] + hLoanClose[t]) / nw[t] : null;
      const vc = rm[t] + pow_[t] + sel[t], contrib = sales[t] - vc;
      const fc = lab[t] + moh[t] + rep[t] + adm[t] + histOpex[t] + bookDep[t] + amort[t] + fin[t];
      bep[t] = contrib > 0 ? fc / contrib : null;
    }
    const dscrV = dscr.filter(x => x !== null && isFinite(x));
    const avgD = dscrV.length ? dscrV.reduce((a, b) => a + b, 0) / dscrV.length : null;
    const minD = dscrV.length ? Math.min(...dscrV) : null;

    const disc = g('disc_rate', .13);

    // Loan Life / Project Life Coverage Ratios — mirrors engines.py's projection().
    const llcrLife = tenure ? Math.min(tenure, N) : N;
    let pvOverLoanLife = 0, pvOverProjectLife = 0;
    for (let t = 0; t < N; t++) {
      const pv = cads[t] / Math.pow(1 + disc, t + 1);
      if (t < llcrLife) pvOverLoanLife += pv;
      pvOverProjectLife += pv;
    }
    const d0 = tlOpen.length ? tlOpen[0] : 0;
    const llcr = d0 ? pvOverLoanLife / d0 : null;
    const plcr = d0 ? pvOverProjectLife / d0 : null;

    const tvBasis = s.tv_basis || 'going';
    let tv = 0;
    if (tvBasis === 'going') {
      const tgr = g('tgrowth', .03);
      const last = ebit[N - 1] - tax[N - 1] + bookDep[N - 1] + amort[N - 1];
      tv = disc > tgr ? last * (1 + tgr) / (disc - tgr) : 0;
    } else if (tvBasis === 'salvage') {
      tv = g('salvage', .60) * grossBlock + gap[N - 1];
    }
    const fcff = [-T], ecf = [-(g('equity') + g('grant_amt'))];
    for (let t = 0; t < N; t++) {
      const dGap = gap[t] - (t ? gap[t - 1] : gap0);
      let f = ebit[t] - tax[t] + bookDep[t] + amort[t] - dGap - maint[t];
      let e = pat[t] + bookDep[t] + amort[t] - dGap + (wcl[t] - (t ? wcl[t - 1] : 0)) - tlPrin[t] - hLoanPrin[t];
      if (t === N - 1) { f += tv; e += tv - tlClose[t] - wcl[t] - hLoanClose[t]; }
      fcff.push(f); ecf.push(e);
    }
    const cum = [], dcum = []; let cc = 0, dc = 0;
    fcff.forEach((f, k) => { cc += f; cum.push(cc); dc += f / Math.pow(1 + disc, k); dcum.push(dc); });

    const nn = x => x === null ? null : R2(x);
    const R = {
      N, industry: ind.name, T: R2(T), gross_block: R2(grossBlock), life,
      util, rmode, rmode_asked: rmodeAsked,
      sales: sales.map(R2), income: income.map(R2), ebitda: ebitda.map(R2), opex: opex.map(R2), cop: cop.map(R2),
      cost_lines: { rm: rm.map(R2), labour: lab.map(R2), power: pow_.map(R2), mfgoh: moh.map(R2), repairs: rep.map(R2), admin: adm.map(R2), selling: sel.map(R2) },
      book_dep: bookDep.map(R2), tax_dep: taxDep.map(R2), amort: amort.map(R2),
      nb: nbArr.map(R2), preop_close: preopClose.map(R2),
      tl_open: tlOpen.map(R2), tl_int: tlInt.map(R2), tl_prin: tlPrin.map(R2), tl_close: tlClose.map(R2),
      tenure, moratorium: mor, rate: netRate,
      gap: gap.map(R2), wcl: wcl.map(R2), wc_int: wcInt.map(R2), od_int: odInt.map(R2), cred: cred.map(R2), ca_ops: caOps.map(R2),
      mpbf: Object.fromEntries(Object.entries(mpbf).map(([k, v]) => [k, v.map(R2)])), dp: dpArr.map(R2),
      limit: limArr.map(R2), ccc: ccc.map(R2), nta_days: ntaDays.map(R2),
      ebit: ebit.map(R2), fin: fin.map(R2), pbt: pbt.map(R2), tax: tax.map(R2), rate_y: rateY.slice(), maint: maint.map(R2),
      pat: pat.map(R2), div: div.map(R2), re: re.map(R2), nw: nw.map(R2), cash: cash.map(R2), od: od.map(R2),
      ta: ta.map(R2), tel: tel.map(R2), chk: chk.map(R2), ocf: ocf.map(R2), icf: icf.map(R2), fcf: fcf.map(R2),
      dscr: dscr.map(nn), cr: cr.map(nn), icr: icr.map(nn), roce: roce.map(nn), roe: roe.map(nn), de: de.map(nn), bep: bep.map(nn),
      avg_dscr: nn(avgD), min_dscr: nn(minD), llcr: nn(llcr), plcr: nn(plcr),
      fcff: fcff.map(R2), ecf: ecf.map(R2), tv: R2(tv),
      npv: R2(pNpv(disc, fcff)), irr: nn(pIrr(fcff)), eirr: nn(pIrr(ecf)),
      pi: T ? R2((pNpv(disc, fcff) + T) / T) : 0,
      payback: R2(pPayback(cum, fcff)),
      disc_payback: R2(pPayback(dcum, fcff.map((f, k) => f / Math.pow(1 + disc, k)))),
      max_od: R2(Math.max(...od, 0)), gearing: T ? R2(g('loan') / T) : 0,
      gearing_norm: ind.gearing_norm, margin_money: R2(mm), unverified: []
    };
    R.checks = projChecks(s, ind, R, T, tenure, mor, loanType);
    R.verdict = projVerdict(s, R, loanType);
    R.sensitivity = projSensitivity(s, R);
    return R;
  }

  function projChecks(s, ind, R, T, tenure, mor, loanType) {
    const g = (k, dflt) => pn(s[k], dflt);
    const c = [], bench = 1.25;
    const E = (t, d) => c.push({ s: 'err', t, d }), W = (t, d) => c.push({ s: 'warn', t, d }), O = t => c.push({ s: 'ok', t });

    Math.max(...R.chk.map(Math.abs)) > 1
      ? E('The balance sheet does not tie', 'Usually an unbalanced opening balance sheet for the existing business. Re-enter the audited figures.')
      : O('The balance sheet ties in every projected year');
    T <= 0 ? E('No project cost', 'Enter a loan amount, promoter\'s equity, or both.') : O('Means of finance equal the total project cost');

    if ((s.client || 'greenfield') !== 'greenfield') {
      const bal = (g('h_nb') + g('h_ca')) - (g('h_nw') + g('h_loan') + g('h_cl'));
      Math.abs(bal) > 0.01 * Math.max(1, g('h_nb') + g('h_ca'))
        ? W('The audited balance sheet leaves a large balancing figure', 'Re-enter net block, current assets, current liabilities, term loan and net worth so they agree.')
        : O('The opening balance sheet of the existing business ties');
      if (g('h_rev') <= 0) W('No revenue entered for the existing business', 'The basis of preparation says this is an existing business, but nothing has been carried in.');
    }

    if (loanType !== 'none') {
      R.gearing > ind.gearing_norm + 0.05
        ? W('Gearing is above the norm for this industry', 'Sectors financed by stock and receivables carry a working capital limit, not a term loan. Reduce the term loan or reclassify part of it.')
        : O('Gearing is within the norm for this industry');
      if (R.gearing > 0.75) W('Gearing above 75% of project cost', 'Few lenders will sanction this without additional security or a corporate guarantee.');
      (tenure <= mor) ? E('The moratorium is not shorter than the tenure', 'There is no repayment period left. Shorten the moratorium or extend the tenure.') : O('Tenure is longer than the moratorium');
      if (R.min_dscr !== null) {
        R.min_dscr < bench
          ? W('Debt service coverage falls below the benchmark', 'Lowest coverage is ' + R.min_dscr.toFixed(2) + ' against a benchmark of 1.25. Lengthen the tenure, extend the moratorium or reduce the loan.')
          : O('Debt service coverage meets the benchmark in every year');
        if (R.avg_dscr !== null && R.avg_dscr < bench) W('Average debt service coverage is below the benchmark', 'Average is ' + R.avg_dscr.toFixed(2) + '.');
        if (R.min_dscr < 1) W('Cash generation does not cover debt service in at least one year', 'The project cannot pay that year\'s instalment from its own earnings. This is a capital structure problem: lengthen the tenure, extend the moratorium, or reduce the loan.');
      }
      if (loanType === 'bullet' || loanType === 'balloon') W('A large principal falls due at maturity', 'Coverage looks comfortable until the final instalment. Set out how the balloon will be refinanced or repaid.');
      if (loanType === 'moratint') W('Interest is being capitalised during the moratorium', 'The amount ultimately repaid exceeds the amount drawn. Confirm the lender permits this.');
      if (loanType === 'subsidised' && g('subsidy') > 0) W('The projection relies on an interest subsidy', 'Coverage depends on the subsidy continuing for the full tenure. Test the position at the gross rate as well.');
    } else O('No borrowing — coverage ratios do not arise');

    R.max_od > 1 ? W('The project runs short of cash', 'It would need unplanned short-term borrowing of up to ' + R.max_od.toLocaleString() + '. Provide for it as a sanctioned limit or additional contribution.') : O('No unplanned short-term borrowing is required');
    R.npv < 0 ? W('Negative net present value', 'The project does not clear its cost of capital as modelled.') : O('The project returns more than its cost of capital');
    (R.irr !== null && R.irr < g('rate') && loanType !== 'none')
      ? W('The return is below the cost of debt', 'A project returning less than its loan rate cannot service that loan from its own returns.')
      : O('The return exceeds the cost of debt');
    Math.min(...R.pat) < 0 ? W('A loss is shown in at least one year', 'Normal during a ramp-up. Explain it rather than hiding it.') : O('No projected year shows a loss');
    const bepVals = R.bep.filter(x => x !== null);
    (bepVals.length && Math.max(...bepVals) > 1) ? W('Break-even is above achieved sales in at least one year', 'Fixed cost is not covered in that year.') : O('Break-even sits below achieved sales throughout');
    const cr3 = R.cr[Math.min(2, R.N - 1)];
    (cr3 !== null && cr3 < 1.2) ? W('The current ratio is thin', 'Increase the promoter\'s margin on working capital or shorten the collection cycle.') : O('Current ratio meets the usual benchmark');

    const ratios = ['rm', 'labour', 'power', 'mfgoh', 'admin', 'selling'].reduce((sum, k, i) =>
      sum + ((s[k] !== null && s[k] !== undefined && s[k] !== '') ? Number(s[k]) : ind.cost_pct[i]), 0);
    ratios >= 1 ? E('Operating cost ratios total 100% or more of sales', 'There is no margin left. Correct the overrides on the Industry page.') : O('Operating cost ratios leave a positive margin');
    if (R.rmode_asked === 'capacity' && R.rmode !== 'capacity') W('Capacity or selling price is missing', 'Revenue has fallen back to the asset turnover basis so the projection still works. Enter installed capacity and net selling price to model output properly.');

    if ((s.tv_basis || 'going') === 'going' && g('tgrowth', .03) >= g('disc_rate', .13)) E('Terminal growth is not below the discount rate', 'A perpetuity growing at or above the discount rate is infinite.');
    if (R.N < 3) W('A two year projection is short for a term loan', 'Most lenders expect the projection to cover at least the first repayment years.');
    if (R.N < tenure && loanType !== 'none') W('The projection is shorter than the loan tenure', 'Coverage in the later years of the loan is not shown. Consider extending the projection to ' + tenure + ' years.');
    if (s.special_dep) W('Additional one-third depreciation is being claimed', 'Confirm the entity qualifies as a special industry under Schedule 2.');
    if (R.gap.some((gp, t) => gp > R.limit[t] + R.margin_money + 1)) W('Net trading assets exceed the limit assessed under the chosen method', 'The gap is wider than the bank will fund plus the margin money provided. Either the cycle must shorten, or the promoter must bring in more.');
    if (R.dp.some((d, t) => d < R.wcl[t] - 1)) W('Drawing power falls short of the amount drawn', 'The limit may be sanctioned but the stock and book debt statement will not support drawings at that level in every year. Review the component margins.');
    if (R.ccc.some(cv => cv > 180)) W('The operating cycle exceeds six months', 'A cycle this long ties up a great deal of cash. Confirm the holding periods against the client\'s actual experience.');
    W('The tax rate has not been verified', 'Confirm the rate and any concession against the Finance Act of the relevant year. This warning never clears on its own.');
    if (!s.company) W('No entity name entered', 'The reports will be issued without a name on the cover.');
    return c;
  }

  function projVerdict(s, R, loanType) {
    const g = (k, dflt) => pn(s[k], dflt);
    const hard = R.checks.filter(x => x.s === 'err').length;
    const bench = 1.25, noDebt = loanType === 'none';
    const cov = noDebt || (R.min_dscr !== null && R.min_dscr >= bench);
    const bankable = cov && R.npv > 0 && (R.irr === null || R.irr > g('disc_rate', .13)) && R.max_od < 1 && !hard;
    const viable = !bankable && R.npv > 0 && (noDebt || (R.irr !== null && R.irr > g('rate'))) && !hard;
    if (bankable) return { k: 'ok', t: 'Financially viable and bankable as structured',
      s: 'On these assumptions the project services its debt in every projected year, returns more than its cost of capital and needs no unplanned borrowing.' };
    if (viable) {
      const reasons = [];
      if (!noDebt && R.min_dscr !== null && R.min_dscr < bench) reasons.push('debt service coverage falls to ' + R.min_dscr.toFixed(2) + ' against a benchmark of 1.25');
      if (R.max_od >= 1) reasons.push('the project draws unplanned short-term borrowing of up to ' + R.max_od.toLocaleString());
      if (R.gearing > R.gearing_norm + 0.05) reasons.push('gearing of ' + (R.gearing * 100).toFixed(0) + '% exceeds the ' + (R.gearing_norm * 100).toFixed(0) + '% normal for this sector');
      return { k: 'mid', t: 'Economically viable, but not bankable on the terms proposed',
        s: 'The business earns more than the money used to build it costs. It is the capital structure that does not work: ' + (reasons.join('; ') || 'coverage is tight') + '. This is capable of being resolved without changing anything about the project itself.' };
    }
    const reasons = [];
    if (R.npv <= 0) reasons.push('it does not recover its cost of capital');
    if (R.irr !== null && R.irr <= g('rate') && !noDebt) reasons.push('its return of ' + (R.irr * 100).toFixed(1) + '% is below the lending rate of ' + (g('rate') * 100).toFixed(1) + '%');
    if (!noDebt && R.min_dscr !== null && R.min_dscr < 1) reasons.push('cash generation does not cover debt service in at least one year');
    if (R.max_od >= 1) reasons.push('the project runs out of cash');
    if (hard) reasons.push('unresolved input errors remain');
    return { k: 'bad', t: 'Not viable as presently structured',
      s: 'The project cannot be recommended in its present form because ' + (reasons.join('; ') || 'the numbers do not support it') + '.' };
  }

  const PROJ_SCEN = [['Base case', 1, 1, 0], ['Selling price down 5%', .95, 1, 0], ['Selling price down 10%', .90, 1, 0],
    ['Operating cost up 10%', 1, 1.10, 0], ['Price down 10% and cost up 10%', .90, 1.10, 0],
    ['Volume down 20%', .80, .88, 0], ['Interest rate up 2 points', 1, 1, .02]];

  function projSensitivity(s, R) {
    const disc = pn(s.disc_rate, .13);
    return PROJ_SCEN.map(([name, sf, cf, dr]) => {
      const dscrList = []; let cumPat = 0; const flows = [-R.T];
      for (let t = 0; t < R.N; t++) {
        const rev = R.income[t] * sf, cost = R.opex[t] * cf;
        const eb = rev - cost, extraInt = (dr || 0) * (R.tl_open[t] || 0);
        const pbt = eb - R.book_dep[t] - R.amort[t] - R.fin[t] - extraInt;
        const tx = Math.max(0, pbt) * R.rate_y[t], pat = pbt - tx;
        cumPat += pat;
        const dso = R.tl_int[t] + R.tl_prin[t];
        const d = dso > 0.5 ? (pat + R.book_dep[t] + R.amort[t] + R.tl_int[t] + extraInt) / (dso + extraInt) : null;
        if (d !== null) dscrList.push(d);
        let f = eb - tx - (R.gap[t] - (t ? R.gap[t - 1] : 0)) - R.maint[t];
        if (t === R.N - 1) f += R.tv * sf;
        flows.push(f);
      }
      const avg = dscrList.length ? dscrList.reduce((a, b) => a + b, 0) / dscrList.length : null;
      const worst = dscrList.length ? Math.min(...dscrList) : null;
      const ir = pIrr(flows);
      return { scenario: name, avg_dscr: avg !== null ? R2(avg) : null, min_dscr: worst !== null ? R2(worst) : null,
               irr: ir !== null ? R2(ir) : null, cum_pat: R2(cumPat), npv: R2(pNpv(disc, flows)) };
    });
  }

  // ------------------------------------------------- due diligence workbench
  // Mirrors server engines.py's dd_workbench() field-for-field. Tax-exposure
  // rates come from the SAME income_tax/penalty/vat rate_master domains the
  // other modules use — not a duplicate 'dd' set — via the standard rateSet().
  const dn = v => { if (v === null || v === undefined || v === '') return 0; const n = parseFloat(String(v).replace(/[, ]/g, '')); return isFinite(n) ? n : 0; };
  const dSafeDiv = (a, b) => { b = dn(b); return b === 0 ? 0 : dn(a) / b; };
  function dFyList(latest, n) {
    const m = String(latest || '').match(/(\d{4})\s*[\/-]\s*(\d{2,4})/);
    const start = m ? parseInt(m[1], 10) : 2081;
    const out = [];
    for (let i = n - 1; i >= 0; i--) { const y = start - i; out.push(y + '/' + String((y + 1) % 100).padStart(2, '0')); }
    return out;
  }
  const dLvl = v => { const n = parseInt(String(v || '3'), 10); return Math.max(1, Math.min(5, isFinite(n) ? n : 3)); };
  const dRagOf = (l, i) => { const s = dLvl(l) * dLvl(i); return s >= 15 ? 'R' : (s >= 8 ? 'A' : 'G'); };

  function ddTaxRates(rates, fy) {
    const it = rateSet(rates, 'income_tax', fy, 'company').rates;
    const pen = rateSet(rates, 'penalty', fy).rates;
    const vat = rateSet(rates, 'vat', fy).rates;
    const un = rateSet(rates, 'income_tax', fy, 'company').unverified
      .concat(rateSet(rates, 'penalty', fy).unverified, rateSet(rates, 'vat', fy).unverified);
    return {
      p: {
        itrate: (val(it, 'company_rate_general', .25) || .25) * 100,
        intrate: (val(pen, 's119_interest_rate', .15) || .15) * 100,
        feepc: (val(pen, 's117_income_return_pct', .001) || .001) * 100,
        feemin: val(pen, 's117_income_return_month', 100) || 100,
        vatint: (val(vat, 'late_payment_addl_fee', .15) || .15) * 100,
        vatfee: val(vat, 'late_return_fee', 1000) || 1000
      },
      unverified: Array.from(new Set(un)).sort()
    };
  }

  function ditExp(r, p) {
    const disTax = dn(r.dis) * p.itrate / 100, shortfall = Math.max(0, dn(r.tself) - dn(r.tpaid));
    const principal = disTax + shortfall, interest = principal * p.intrate / 100 * dn(r.mo) / 12;
    let fee = 0;
    if (r.filed === 'Not filed' || r.filed === 'Filed late') fee = Math.max(dn(r.inc) * p.feepc / 100 * dn(r.mo) / 12, p.feemin * dn(r.mo));
    return { principal, interest, fee, total: principal + interest + fee };
  }
  function dtdsExp(r, p) {
    const short = dn(r.base) * Math.max(0, dn(r.req) - dn(r.applied)) / 100;
    const depFactor = r.dep === 'No' ? 1 : (r.dep === 'Partial' ? .5 : 0);
    const nondep = dn(r.base) * dn(r.applied) / 100 * depFactor;
    const principal = short + nondep, interest = principal * p.intrate / 100 * dn(r.mo) / 12;
    return { principal, interest, fee: 0, total: principal + interest };
  }
  function dvatExp(r, p) {
    const principal = dn(r.dis) + dn(r.short);
    const interest = principal * p.vatint / 100 * dn(r.mo) / 12, fee = dn(r.late) * p.vatfee;
    return { principal, interest, fee, total: principal + interest + fee };
  }
  function doneoffEffect(r) {
    if (r.inc !== 'Yes') return 0;
    const amt = dn(r.amt);
    return /income|reversal/i.test(r.nat || '') ? -amt : amt;
  }
  const DD_FIN_FIELDS = ['rev', 'otherinc', 'cogs', 'staff', 'rentx', 'admin', 'selling', 'otheropex', 'dep', 'amort',
    'fin', 'excep', 'tax', 'ppe', 'cwip', 'intang', 'invst', 'othernca', 'inv', 'tr', 'otherca', 'cash', 'eqcap',
    'resv', 'ltd', 'ltprov', 'dtl', 'std', 'curmat', 'tp', 'othercl', 'provtax', 'cfo', 'capex', 'cfi', 'cff',
    'emp', 'top5', 'rppct', 'cashsales'];
  function dFinOf(fin, y) {
    const row = (fin && fin[y]) || {};
    const out = {};
    DD_FIN_FIELDS.forEach(k => out[k] = dn(row[k]));
    return out;
  }

  function ddWorkbench(rates, fy, data) {
    const s = data || {};
    const eng = s.eng || {};
    const g = {};
    ['dr', 'conc', 'stream', 'norm', 'wcm', 'debt', 'dlike', 'rp', 'oneoff', 'cont', 'it', 'tds', 'vat', 'notice', 'acct', 'find', 'rec']
      .forEach(k => g[k] = (s.g && s.g[k]) || []);
    const ov = s.ov || {};
    const { p, unverified } = ddTaxRates(rates, fy);
    const fin = s.fin || {};

    const ys = dFyList(eng.latest_fy || '2081/82', parseInt(eng.nyears, 10) || 5);
    const y = {};
    ys.forEach(yr => {
      const d = dFinOf(fin, yr), o = {};
      o.rev = d.rev; o.oi = d.otherinc; o.cogs = d.cogs;
      o.opex = d.staff + d.rentx + d.admin + d.selling + d.otheropex;
      o.gp = o.rev - o.cogs; o.ebitda = o.rev + o.oi - o.cogs - o.opex;
      o.dep = d.dep + d.amort; o.ebit = o.ebitda - o.dep; o.fin = d.fin;
      o.pbt = o.ebit - o.fin + d.excep; o.tax = d.tax; o.pat = o.pbt - o.tax;
      o.ca = d.inv + d.tr + d.otherca + d.cash;
      o.cl = d.std + d.curmat + d.tp + d.othercl + d.provtax;
      o.nwc = d.inv + d.tr + d.otherca - d.tp - d.othercl;
      o.ta = d.ppe + d.cwip + d.intang + d.invst + d.othernca + o.ca;
      o.teq = d.eqcap + d.resv;
      o.tl = d.ltd + d.ltprov + d.dtl + o.cl;
      o.chk = o.ta - (o.teq + o.tl);
      o.debt = d.ltd + d.std + d.curmat; o.cash = d.cash; o.netdebt = o.debt - o.cash;
      o.dso = o.rev ? d.tr / o.rev * 365 : 0; o.dio = o.cogs ? d.inv / o.cogs * 365 : 0; o.dpo = o.cogs ? d.tp / o.cogs * 365 : 0;
      o.ccc = o.dso + o.dio - o.dpo;
      o.gpm = dSafeDiv(o.gp, o.rev) * 100; o.em = dSafeDiv(o.ebitda, o.rev) * 100; o.pm = dSafeDiv(o.pat, o.rev) * 100;
      o.cr = dSafeDiv(o.ca, o.cl); o.lev = dSafeDiv(o.netdebt, o.ebitda); o.icr = dSafeDiv(o.ebitda, o.fin);
      o.roce = dSafeDiv(o.ebit, o.teq + o.debt) * 100; o.cfo = d.cfo; o.conv = dSafeDiv(o.cfo, o.ebitda) * 100;
      o.capex = d.capex; o.accr = dSafeDiv(o.pat - o.cfo, o.ta) * 100; o.adj = 0; o.norm_adj = 0; o.oneoff_adj = 0;
      y[yr] = o;
    });
    (g.norm || []).forEach(r => { if (y[r.fy]) { const amt = dn(r.amt); y[r.fy].adj += amt; y[r.fy].norm_adj += amt; } });
    (g.oneoff || []).forEach(r => { if (y[r.fy]) { const amt = doneoffEffect(r); y[r.fy].adj += amt; y[r.fy].oneoff_adj += amt; } });
    ys.forEach(yr => { const o = y[yr]; o.nebitda = o.ebitda + o.adj; o.nem = dSafeDiv(o.nebitda, o.rev) * 100; o.nlev = dSafeDiv(o.netdebt, o.nebitda); });

    const latest = ys[ys.length - 1], first = ys[0];
    const L = y[latest], F = y[first];
    const c = { ys, y, latest, first, rev: L.rev, L };
    c.cagr = (F.rev > 0 && ys.length > 1) ? (Math.pow(dSafeDiv(L.rev, F.rev), 1 / (ys.length - 1)) - 1) * 100 : 0;
    c.rev_gr = ys.map((yr, i) => i ? dSafeDiv(y[ys[i]].rev - y[ys[i - 1]].rev, y[ys[i - 1]].rev) * 100 : 0);
    const finL = dFinOf(fin, latest), finF = dFinOf(fin, first);
    c.tr_gr = (F.rev > 0) ? (dSafeDiv(finL.tr, finF.tr) - 1) * 100 : 0;
    c.rev_gr_tot = (F.rev > 0) ? (dSafeDiv(L.rev, F.rev) - 1) * 100 : 0;
    c.dso_delta = L.dso - F.dso;

    c.tot_stream = (g.stream || []).reduce((a, r) => a + dn(r.amt), 0);
    const conc = (g.conc || []).slice().sort((a, b) => dn(b.amt) - dn(a.amt));
    c.conc = conc;
    const top5 = conc.slice(0, 5).reduce((a, r) => a + dn(r.amt), 0);
    c.top5 = top5;
    c.top5pct = dSafeDiv(top5, L.rev) * 100 || finL.top5;
    c.top1pct = dSafeDiv(dn((conc[0] || {}).amt), L.rev) * 100;
    c.rp_rev = (g.rp || []).filter(r => r.nat === 'Sales').reduce((a, r) => a + dn(r.amt), 0);
    c.rp_rev_pct = dSafeDiv(c.rp_rev, L.rev) * 100 || finL.rppct;
    c.rp_bal = (g.rp || []).reduce((a, r) => a + dn(r.bal), 0);
    c.rp_no_arm = (g.rp || []).filter(r => r.arm === 'No' || r.arm === 'Partly').length;

    c.norm_tot = ys.reduce((a, yr) => a + y[yr].adj, 0);
    c.adj_pct = dSafeDiv(Math.abs(L.adj), Math.abs(L.ebitda)) * 100;
    c.low_conf = (g.norm || []).filter(r => r.conf === 'Low').reduce((a, r) => a + Math.abs(dn(r.amt)), 0);

    const wm = (g.wcm || []).filter(r => r.m);
    const wcVals = wm.map(r => dn(r.inv) + dn(r.tr) + dn(r.oca) - dn(r.tp) - dn(r.ocl));
    c.wc_count = wm.length;
    c.wc_avg = wcVals.length ? wcVals.reduce((a, b) => a + b, 0) / wcVals.length : L.nwc;
    c.wc_high = wcVals.length ? Math.max(...wcVals) : L.nwc;
    c.wc_low = wcVals.length ? Math.min(...wcVals) : L.nwc;
    c.wc_dev = L.nwc - c.wc_avg;
    c.wc_swing = dSafeDiv(c.wc_high - c.wc_low, Math.abs(c.wc_avg) || 1) * 100;

    c.debt_gross = (g.debt || []).reduce((a, r) => a + dn(r.out), 0) || L.debt;
    c.dlike_d = (g.dlike || []).filter(r => r.cat === 'Debt-like').reduce((a, r) => a + dn(r.amt), 0);
    c.dlike_c = (g.dlike || []).filter(r => r.cat === 'Cash-like').reduce((a, r) => a + dn(r.amt), 0);
    c.off_bs = (g.dlike || []).filter(r => r.cat === 'Off balance sheet').reduce((a, r) => a + dn(r.amt), 0);
    c.net_debt_adj = c.debt_gross + c.dlike_d - L.cash - c.dlike_c;
    c.breach = (g.debt || []).filter(r => r.cov === 'Breached').length;
    c.coc = (g.debt || []).filter(r => /Yes/.test(r.coc || '')).length;
    c.pg = (g.debt || []).filter(r => r.pg === 'Yes').length;

    c.cont_gross = (g.cont || []).reduce((a, r) => a + dn(r.amt), 0);
    c.cont_w = (g.cont || []).reduce((a, r) => a + Math.max(0, dn(r.amt) - dn(r.prov)) * (r.prob === 'Probable' ? 1 : r.prob === 'Possible' ? .5 : .05), 0);

    const sum = (arr, f) => arr.reduce((a, r) => a + f(r), 0);
    c.tax_it = sum(g.it || [], r => ditExp(r, p).total);
    c.tax_tds = sum(g.tds || [], r => dtdsExp(r, p).total);
    c.tax_vat = sum(g.vat || [], r => dvatExp(r, p).total);
    c.tax_not = sum(g.notice || [], r => dn(r.dem) * dn(r.prob) / 100);
    c.tax_total = c.tax_it + c.tax_tds + c.tax_vat + c.tax_not;
    c.tax_prin = sum(g.it || [], r => ditExp(r, p).principal) + sum(g.tds || [], r => dtdsExp(r, p).principal) + sum(g.vat || [], r => dvatExp(r, p).principal);
    c.tax_int = sum(g.it || [], r => ditExp(r, p).interest) + sum(g.tds || [], r => dtdsExp(r, p).interest) + sum(g.vat || [], r => dvatExp(r, p).interest);
    c.tax_fee = sum(g.it || [], r => ditExp(r, p).fee) + sum(g.vat || [], r => dvatExp(r, p).fee);
    c.open_ass = (g.it || []).filter(r => /audit|101|115|Tribunal|Court/.test(r.stat || '')).length;
    c.not_filed = (g.it || []).filter(r => r.filed === 'Not filed').length + (g.vat || []).filter(r => r.filed === 'Not filed').length;

    c.acct_pl = (g.acct || []).reduce((a, r) => a + dn(r.pl), 0);
    c.acct_bs = (g.acct || []).reduce((a, r) => a + dn(r.bs), 0);
    c.acct_dev = (g.acct || []).filter(r => r.status === 'Deviation').length;
    c.acct_na = (g.acct || []).filter(r => r.status === 'Not assessed').length;

    const dr = (g.dr || []).filter(r => r.status !== 'Not applicable');
    c.dr_tot = dr.length;
    const drRecd = dr.filter(r => r.status === 'Received').length;
    const drPart = dr.filter(r => r.status === 'Partial').length;
    c.dr_pct = dSafeDiv(drRecd + drPart * 0.5, c.dr_tot) * 100;
    c.dr_crit_open = dr.filter(r => r.crit === 'Yes' && r.status !== 'Received').length;

    c.cashsales_latest = finL.cashsales;
    c.mat = eng.matmode === 'manual' ? dn(eng.mat) : Math.max(L.rev * 0.005, Math.abs(L.nebitda) * 0.05);
    c.opinions = ys.filter(yr => ((fin[yr] || {}).opinion || 'Unmodified') !== 'Unmodified');
    c.chk_fail = ys.filter(yr => Math.abs(y[yr].chk) > Math.max(1, L.rev * 0.0005));

    const findings = ddAllFindings(c, g, eng, ov);
    c.find = findings;
    c.rag = { R: findings.filter(f => f.rag === 'R').length, A: findings.filter(f => f.rag === 'A').length, G: findings.filter(f => f.rag === 'G').length };
    c.exposure = findings.reduce((a, f) => a + dn(f.qty), 0);
    c.overall = c.rag.R > 0 ? 'R' : (c.rag.A > 0 ? 'A' : 'G');

    c.deal = ddDealCalc(c, eng);
    c.unverified = unverified;
    c.registers = g;
    c.perspective = eng.perspective || 'buy_side';
    c.comm = s.comm || {};
    c.comm_score = ddCommercialScore(c.comm, c.top5pct);
    return c;
  }

  function ddCommercialScore(comm, top5pct) {
    function band(value, breakpoints) {
      for (const [threshold, score] of breakpoints) {
        if (value >= threshold) return score;
      }
      return breakpoints[breakpoints.length - 1][1];
    }
    const growth = comm.market_growth_pct;
    const growthScore = (growth === null || growth === undefined || growth === '') ? null :
      band(dn(growth), [[15, 100], [5, 70], [0, 40], [-999, 10]]);
    const concScore = (top5pct === null || top5pct === undefined || top5pct === '') ? null :
      band(100 - dn(top5pct), [[70, 100], [50, 60], [30, 30], [-999, 10]]);
    const rating = comm.position_rating;
    const positionScore = (rating === null || rating === undefined || rating === '') ? null :
      Math.max(0, Math.min(100, dn(rating) * 20));
    const parts = [growthScore, concScore, positionScore].filter(x => x !== null);
    const overall = parts.length ? parts.reduce((a, b) => a + b, 0) / parts.length : null;
    const rag = overall === null ? null : (overall >= 70 ? 'G' : (overall >= 40 ? 'A' : 'R'));
    return { growth_score: growthScore, concentration_score: concScore, position_score: positionScore,
      overall_score: overall === null ? null : Math.round(overall), rag };
  }

  function ddDealCalc(c, eng) {
    const L = c.L, mult = dn(eng.multiple);
    const ev = mult > 0 ? L.nebitda * mult : dn(eng.dealval);
    const d = { ev, mult, netdebt: c.net_debt_adj, wc_adj: c.wc_dev, tax: -c.tax_total, cont: -c.cont_w, acct: c.acct_bs };
    d.equity = ev - c.net_debt_adj + d.wc_adj + d.tax + d.cont + d.acct;
    d.escrow = c.tax_total * 0.5 + c.cont_w * 0.5;
    return d;
  }

  function ddAllFindings(c, g, eng, ov) {
    const out = [], L = c.L, ys = c.ys;
    const unit = (eng.cur || 'NPR') + ({ Actual: '', Thousands: " '000", Lakh: ' lakh', Million: ' mn', Crore: ' cr' }[eng.unit] || '');
    const add = (id, ws, title, detail, qty, lik, imp, prot) => out.push({ id, ws, title, detail, qty: qty || 0, lik, imp, prot: prot || 'Indemnity', status: 'Open', auto: true });
    const M = v => Math.abs(Math.round(dn(v))).toLocaleString() + ' ' + unit;
    const f0 = v => { const n = dn(v); return (n < 0 ? '(' : '') + Math.abs(Math.round(n)).toLocaleString() + (n < 0 ? ')' : ''); };
    const f1 = v => { const n = dn(v); return (n < 0 ? '(' : '') + Math.abs(n).toFixed(1) + (n < 0 ? ')' : ''); };
    const pc = (v, d) => (isFinite(v) ? v : 0).toFixed(d === undefined ? 1 : d) + '%';

    if (c.dr_tot && c.dr_pct < 50) add('DR01', 'Data room', 'Data room materially incomplete',
      'Only ' + pc(c.dr_pct) + ' of requested documents received. Conclusions on all workstreams are provisional and the scope limitation is pervasive.', 0, 4, 4, 'Condition precedent');
    else if (c.dr_tot && c.dr_pct < 80) add('DR02', 'Data room', 'Information gaps outstanding',
      pc(c.dr_pct) + ' of requested documents received; the outstanding items are listed in the information request appendix.', 0, 3, 3, 'Condition precedent');
    if (c.dr_crit_open > 0) add('DR03', 'Data room', 'Critical documents not provided',
      c.dr_crit_open + ' document set(s) marked critical remain outstanding. Findings in the affected workstreams cannot be concluded.', 0, 4, 4, 'Condition precedent');

    if (L.rev > 0) {
      if (c.top5pct >= 70) add('FD01', 'Financial DD', 'Severe customer concentration',
        'Top five customers represent ' + pc(c.top5pct) + ' of revenue. Loss of a single relationship would materially change the earnings base.', 0, 4, 5, 'Price adjustment');
      else if (c.top5pct >= 50) add('FD02', 'Financial DD', 'Customer concentration',
        'Top five customers represent ' + pc(c.top5pct) + ' of revenue; contractual protection and renewal history should be confirmed.', 0, 3, 4, 'Warranty');
      if (c.rp_rev_pct >= 10) add('FD03', 'Financial DD', 'Revenue dependent on related parties',
        pc(c.rp_rev_pct) + " of revenue is transacted with related parties. Sustainability post-completion and arm's-length pricing both require testing.", Math.round(c.rp_rev * 0.25), 4, 4, 'Price adjustment');
      if (c.rev_gr_tot - c.tr_gr < -20 && c.tr_gr > 0) add('FD04', 'Financial DD', 'Receivables growing faster than revenue',
        'Revenue moved ' + pc(c.rev_gr_tot) + ' across the review period while trade receivables moved ' + pc(c.tr_gr) + '. Indicates channel loading, cut-off or collectability issues.', 0, 4, 4, 'Price adjustment');
      if (c.dso_delta > 30) add('FD05', 'Financial DD', 'Collection cycle lengthening',
        'DSO increased by ' + f0(c.dso_delta) + ' days over the review period (now ' + f0(L.dso) + ' days). Review ageing, credit notes after year-end and ECL provision.', 0, 3, 3, 'Price adjustment');
      if (c.cashsales_latest >= 25) add('FD06', 'Financial DD', 'High proportion of cash sales',
        pc(c.cashsales_latest) + ' of revenue is collected in cash. Completeness of recorded revenue and VAT compliance both carry risk.', 0, 4, 4, 'Indemnity');
    }
    if (L.ebitda !== 0) {
      if (c.adj_pct >= 25) add('FD07', 'Financial DD', 'Reported EBITDA heavily normalised',
        'Normalisation adjustments equal ' + pc(c.adj_pct) + ' of reported EBITDA in ' + c.latest + '. A purchase price built on normalised EBITDA carries elevated evidence risk.', 0, 4, 4, 'Price adjustment');
      else if (c.adj_pct >= 10) add('FD08', 'Financial DD', 'Material normalisation adjustments',
        'Normalisation adjustments equal ' + pc(c.adj_pct) + ' of reported EBITDA in ' + c.latest + '.', 0, 3, 3, 'Price adjustment');
      if (c.low_conf > 0 && c.low_conf > c.mat) add('FD09', 'Financial DD', 'Normalisation adjustments with weak evidence',
        M(c.low_conf) + ' of adjustments are supported by low-quality evidence and may not withstand seller challenge.', Math.round(c.low_conf), 3, 4, 'Price adjustment');
      if (L.conv < 25 && L.cfo !== 0) add('FD10', 'Financial DD', 'Weak cash conversion',
        'Operating cash flow is ' + pc(L.conv) + ' of EBITDA in ' + c.latest + '. Earnings are not converting to cash.', 0, 4, 4, 'Price adjustment');
      else if (L.conv < 50 && L.cfo !== 0) add('FD11', 'Financial DD', 'Cash conversion below expectation',
        'Operating cash flow is ' + pc(L.conv) + ' of EBITDA in ' + c.latest + '; working capital absorption should be explained.', 0, 3, 3, 'Price adjustment');
      const ems = ys.map(yr => c.y[yr].em).filter(v => isFinite(v));
      if (ems.length > 1 && (Math.max(...ems) - Math.min(...ems)) > 8) add('FD12', 'Financial DD', 'Volatile EBITDA margin',
        'EBITDA margin ranges from ' + pc(Math.min(...ems)) + ' to ' + pc(Math.max(...ems)) + ' across the review period; the maintainable level is not self-evident.', 0, 3, 4, 'Price adjustment');
    }
    if (c.wc_count >= 6 && c.wc_swing > 40) add('FD13', 'Financial DD', 'Working capital highly seasonal',
      'Monthly net working capital swings ' + pc(c.wc_swing) + ' around the average. A year-end peg would misprice the business; use a 12-month average peg.', 0, 4, 4, 'Price adjustment');
    if (c.wc_count >= 6 && c.wc_dev < -Math.abs(c.wc_avg) * 0.15) add('FD14', 'Financial DD', 'Year-end working capital below normal level',
      'Closing NWC of ' + M(L.nwc) + ' is ' + M(Math.abs(c.wc_dev)) + ' below the 12-month average — consistent with year-end window dressing of receipts and payments.', Math.round(Math.abs(c.wc_dev)), 4, 4, 'Price adjustment');
    if (L.nebitda > 0 && L.nlev > 4.5) add('FD15', 'Financial DD', 'Leverage above prudent level',
      'Net debt / normalised EBITDA is ' + f1(L.nlev) + 'x. Refinancing and covenant capacity require attention before completion.', 0, 4, 4, 'Condition precedent');
    else if (L.nebitda > 0 && L.nlev > 3) add('FD16', 'Financial DD', 'Elevated leverage', 'Net debt / normalised EBITDA is ' + f1(L.nlev) + 'x.', 0, 3, 3, 'Condition precedent');
    if (L.fin > 0 && L.icr < 1.25) add('FD17', 'Financial DD', 'Interest cover below 1.25x',
      'EBITDA covers finance cost only ' + f1(L.icr) + ' times. Debt service depends on continued lender support.', 0, 4, 5, 'Deal breaker');
    if (c.breach > 0) add('FD18', 'Financial DD', 'Loan covenant breach',
      c.breach + ' facility(ies) are recorded as in breach of covenant. Lender waiver is required and acceleration risk exists.', 0, 4, 5, 'Condition precedent');
    if (c.coc > 0) add('FD19', 'Financial DD', 'Change-of-control consent required',
      c.coc + ' facility(ies) require lender consent on change of control. Without consent the debt may become repayable on completion.', 0, 4, 4, 'Condition precedent');
    if (c.pg > 0) add('FD20', 'Financial DD', 'Promoter guarantees on borrowings',
      c.pg + ' facility(ies) are secured by promoter personal guarantee. Release and replacement of security must be sequenced at completion.', 0, 3, 3, 'Condition precedent');
    if (c.dlike_d > 0) add('FD21', 'Financial DD', 'Debt-like items outside reported borrowings',
      M(c.dlike_d) + ' of debt-like items (unfunded employee benefits, statutory dues, declared dividends, capex creditors and similar) reduce equity value.', Math.round(c.dlike_d), 4, 4, 'Price adjustment');
    if (c.off_bs > 0) add('FD22', 'Financial DD', 'Off-balance-sheet obligations', M(c.off_bs) + ' of off-balance-sheet obligations identified.', Math.round(c.off_bs), 3, 4, 'Indemnity');
    if (c.rp_no_arm > 0) add('FD23', 'Financial DD', "Related-party transactions not at arm's length",
      c.rp_no_arm + " related-party transaction stream(s) are assessed as not at arm's length; both earnings quality and transfer-pricing exposure are affected.", 0, 4, 4, 'Price adjustment');
    if (c.rp_bal > c.mat) add('FD24', 'Financial DD', 'Significant related-party balances outstanding',
      M(c.rp_bal) + ' remains outstanding with related parties. Settlement or assignment before completion should be a condition.', Math.round(c.rp_bal), 3, 4, 'Condition precedent');
    if (c.cont_w > 0 && c.cont_w > c.mat) add('FD25', 'Financial DD', 'Contingent liabilities exceed materiality',
      'Probability-weighted contingent exposure of ' + M(c.cont_w) + ' against gross claims of ' + M(c.cont_gross) + '.', Math.round(c.cont_w), 3, 4, 'Escrow / holdback');

    if (c.tax_total > 0) {
      const big = c.tax_total > c.mat;
      add('TD01', 'Tax DD', 'Quantified tax exposure',
        'Aggregate exposure of ' + M(c.tax_total) + ' — principal ' + M(c.tax_prin) + ', interest ' + M(c.tax_int) + ', fees and penalties ' + M(c.tax_fee) + ', plus ' + M(c.tax_not) + ' probability-weighted on open assessments.',
        Math.round(c.tax_total), big ? 4 : 3, big ? 4 : 3, 'Indemnity');
    }
    if (c.not_filed > 0) add('TD02', 'Tax DD', 'Returns not filed',
      c.not_filed + ' income tax or VAT period(s) show returns not filed. Fees and interest accrue and the limitation period does not begin to run.', 0, 5, 4, 'Indemnity');
    if (c.open_ass > 0) add('TD03', 'Tax DD', 'Open assessments and proceedings',
      c.open_ass + ' year(s) are under audit, amended assessment, administrative review or before the Tribunal. These years are not closed to further demand.', 0, 4, 4, 'Escrow / holdback');
    if (c.tax_tds > c.mat * 0.5) add('TD04', 'Tax DD', 'TDS deduction and deposit exposure',
      'Short deduction and non-deposit exposure of ' + M(c.tax_tds) + ' including interest. Disallowance of the underlying expense may also arise.', Math.round(c.tax_tds), 4, 4, 'Indemnity');
    if (c.tax_vat > c.mat * 0.5) add('TD05', 'Tax DD', 'VAT exposure', 'Input credit at risk and output under-declaration total ' + M(c.tax_vat) + ' including additional fees.', Math.round(c.tax_vat), 4, 4, 'Indemnity');
    (g.notice || []).forEach((r, i) => { if (dn(r.dem) > c.mat && dn(r.prob) >= 50)
      add('TD1' + i, 'Tax DD', 'Open demand: ' + (r.subj || r.ref || 'assessment'),
        'Demand of ' + M(dn(r.dem)) + ' before ' + (r.auth || 'the tax authority') + ' with assessed loss probability of ' + pc(dn(r.prob), 0) + '. ' + (r.rem || ''),
        Math.round(dn(r.dem) * dn(r.prob) / 100), 4, 4, 'Escrow / holdback'); });

    if (c.opinions.length) add('AD01', 'Accounting DD', 'Modified audit opinion in the review period',
      'Audit opinion is other than unmodified for ' + c.opinions.join(', ') + '. The basis for modification carries directly into the reported numbers.', 0, 5, 4, 'Price adjustment');
    if (c.chk_fail.length) add('AD02', 'Accounting DD', 'Balance sheet does not balance as captured',
      'Assets do not equal equity plus liabilities for ' + c.chk_fail.join(', ') + '. Underlying records must be reconciled before any conclusion is drawn.', 0, 4, 4, 'Condition precedent');
    if (Math.abs(c.acct_pl) > c.mat) add('AD03', 'Accounting DD', 'Accounting deviations affect EBITDA', 'Identified NFRS deviations change EBITDA by ' + M(c.acct_pl) + '.', Math.round(Math.abs(c.acct_pl)), 4, 4, 'Price adjustment');
    if (Math.abs(c.acct_bs) > c.mat) add('AD04', 'Accounting DD', 'Accounting deviations affect net assets', 'Identified NFRS deviations change net assets by ' + M(c.acct_bs) + '.', Math.round(Math.abs(c.acct_bs)), 4, 4, 'Price adjustment');
    if (c.acct_na > 0 && c.dr_tot) add('AD05', 'Accounting DD', 'Accounting areas not yet assessed', c.acct_na + ' checklist area(s) remain unassessed pending information.', 0, 3, 3, 'Condition precedent');
    (g.acct || []).forEach((r, i) => { if (r.status === 'Deviation' && (r.rag === 'R' || r.rag === 'A'))
      add('AD1' + i, 'Accounting DD', (r.area || 'Accounting matter') + ' — ' + (r.std || ''),
        (r.find || 'Deviation identified.') + (dn(r.pl) ? ' EBITDA effect ' + M(dn(r.pl)) + '.' : '') + (dn(r.bs) ? ' Net asset effect ' + M(dn(r.bs)) + '.' : ''),
        Math.round(Math.abs(dn(r.pl)) || Math.abs(dn(r.bs))), r.rag === 'R' ? 4 : 3, r.rag === 'R' ? 4 : 3, 'Price adjustment'); });

    const auto = out.map(f => {
      const o = ov[f.id] || {};
      const lik = o.lik !== undefined ? o.lik : f.lik, imp = o.imp !== undefined ? o.imp : f.imp;
      return Object.assign({}, f, { lik, imp, rag: o.rag || dRagOf(lik, imp), prot: o.prot || f.prot,
        status: o.status || f.status, note: o.note || '', qty: o.qty !== undefined ? dn(o.qty) : f.qty });
    });
    const manual = (g.find || []).map((r, i) => ({
      id: 'M' + (i + 1), ws: r.ws, title: r.title || '(untitled finding)', detail: r.detail, qty: dn(r.qty),
      lik: dLvl(r.lik), imp: dLvl(r.imp), rag: (r.rag && r.rag !== 'N') ? r.rag : dRagOf(r.lik, r.imp),
      prot: r.prot, status: r.status, auto: false
    }));
    const all = auto.concat(manual);
    const ord = { R: 0, A: 1, G: 2, N: 3 };
    all.sort((a, b) => (ord[a.rag] - ord[b.rag]) || (dn(b.qty) - dn(a.qty)));
    all.forEach((f, i) => f.n = i + 1);
    return all;
  }

  // ------------------------------------------------------------------- FDI model
  // Mirrors server engines.py's fdi() field-for-field. Sector data comes from
  // data.sectors (the cached fdi_sector rows from state.boot.fdi_sectors),
  // matching how industry defaults reach the projection engine.
  const FDI_NEGATIVE_LIST = [
    'Primary agriculture — poultry, fisheries, bee-keeping, fruits, vegetables, oilseed, pulses, dairy and other primary agro-production',
    'Cottage and small industries (as classified under the industrial law)',
    'Personal service business — hair cutting, beauty parlour, tailoring, driving training and the like',
    'Arms and ammunition, gunpowder and explosives, and industries related to radioactive material',
    'Real estate business (construction industry is not covered by this restriction)',
    'Motion pictures produced in the national language or in a language of the nation / local level',
    'Security printing', 'Bank notes and coin business',
    'Retail business — except a retail chain already operating in two or more countries',
    'Internal courier service', 'Atomic energy',
    'Travel agency, trekking agency, tourist guiding, rural tourism including home stay',
    'Business of mass communication media — newspaper, radio, television and online news',
    'Management, accountancy, engineering and legal consultancy services beyond the permitted foreign equity ceiling',
    'Language training, music and computer training', 'Consultancy services beyond 51% foreign equity'
  ];
  const fn = v => { const x = parseFloat(String(v !== null && v !== undefined ? v : 0).replace(/,/g, '')); return isFinite(x) ? x : 0; };
  const fdiIsForeign = inv => inv.kind === 'FC' || inv.kind === 'FI';
  const fdiIsCompany = inv => inv.kind === 'FC' || inv.kind === 'NC';
  function fdiInvNpr(inv, fx) {
    const c = inv.currency || 'NPR';
    if (c === 'NPR') return fn(inv.amount);
    const rate = (fx.rates && fx.rates[c] != null) ? fn(fx.rates[c]) : fn(fx.rate);
    return fn(inv.amount) * rate;
  }
  function fdiTotals(investors, capital, fx) {
    const t = { total_inv: 0, foreign: 0, local: 0, by_inv: [], shares: 0 };
    (investors || []).forEach(inv => {
      const n = fdiInvNpr(inv, fx);
      t.total_inv += n;
      if (fdiIsForeign(inv)) t.foreign += n; else t.local += n;
    });
    const fv = fn(capital.face_value) || 100;
    (investors || []).forEach(inv => {
      const n = fdiInvNpr(inv, fx);
      const shares = Math.round(n / fv);
      t.by_inv.push({ inv, npr: R2(n), pct: R2(t.total_inv ? n / t.total_inv * 100 : 0), shares });
      t.shares += shares;
    });
    t.foreign_pct = t.total_inv ? t.foreign / t.total_inv * 100 : 0;
    t.local_pct = t.total_inv ? t.local / t.total_inv * 100 : 0;
    return t;
  }
  const fdiCostTotal = cost => (cost || []).reduce((s, c) => s + fn(c[1]), 0);
  function fdiCostSplit(cost) {
    const c = (cost || []).map(x => fn(x[1]));
    while (c.length < 9) c.push(0);
    const fixed = c[0] + c[1] + c[2] + c[3] + c[4] + c[5];
    return { land: c[0], bldg: c[1], plant: c[2] + c[3], veh: c[4], off: c[5],
             preop: c[6] + c[7], margin: c[8], fixed, total: fdiCostTotal(cost) };
  }
  function fdiFloanNpr(loan, fx) {
    const cur = loan.f_cur || 'USD';
    const rate = (fx.rates && fx.rates[cur] != null) ? fn(fx.rates[cur]) : fn(fx.rate);
    return fn(loan.f_amt) * rate;
  }
  function fdiLoanSchedule(amt, rate, tenure, morat, years) {
    amt = fn(amt); rate = fn(rate) / 100;
    tenure = Math.max(1, Math.round(fn(tenure))); morat = Math.max(0, Math.round(fn(morat)));
    const repayYears = Math.max(1, tenure - morat), princ = amt / repayYears;
    const rows = []; let open_ = amt;
    for (let y = 1; y <= years; y++) {
      const interest = open_ * rate;
      let p = (y > morat && y <= tenure) ? Math.min(princ, open_) : 0;
      if (y === tenure) p = open_;
      const close = open_ - p;
      rows.push({ y, open: open_, interest: amt ? interest : 0, principal: p, close });
      open_ = close;
    }
    return rows;
  }
  function fdiEffectiveTaxRate(y, proj) {
    const base = fn(proj.tax_rate);
    if (y <= fn(proj.tax_holiday)) return 0;
    return base * (1 - fn(proj.concession_pct) / 100);
  }
  function fdiIrr(cf) {
    const npvAt = r => { let s = 0; for (let i = 0; i < cf.length; i++) s += cf[i] / Math.pow(1 + r, i); return s; };
    let lo = -0.9, hi = 5, fl = npvAt(lo), fh = npvAt(hi);
    if (!isFinite(fl) || !isFinite(fh) || fl * fh > 0) return null;
    for (let k = 0; k < 200; k++) { const m = (lo + hi) / 2, fmv = npvAt(m); if (fl * fmv <= 0) { hi = m; fh = fmv; } else { lo = m; fl = fmv; } }
    return (lo + hi) / 2 * 100;
  }
  function fdiPayback(cf) {
    let c = 0;
    for (let i = 0; i < cf.length; i++) { const p = c; c += cf[i]; if (c >= 0 && i > 0) return (i - 1) + (cf[i] ? (-p / cf[i]) : 0); }
    return null;
  }
  function fdiAvg(a) { const v = a.filter(x => x != null && isFinite(x)); return v.length ? v.reduce((x, y) => x + y, 0) / v.length : null; }
  function fdiMinOf(a) { const v = a.filter(x => x != null && isFinite(x)); return v.length ? Math.min(...v) : null; }

  function fdiModel(data) {
    const proj = data.proj || {}, loan = data.loan || {}, fx = data.fx || {}, val = data.val || {}, params = data.params || {};
    const N = Math.max(2, Math.min(10, Math.round(fn(proj.years)) || 5));
    const cs = fdiCostSplit(data.cost);
    const totals = fdiTotals(data.investors, data.capital || {}, fx);
    const floan = fdiFloanNpr(loan, fx), lloan = fn(loan.l_amt);
    const src = totals.total_inv + floan + lloan;
    const fin = { equity: totals.total_inv, f_loan: floan, l_loan: lloan, debt: floan + lloan, sources: src,
                  cost: cs.total, gap: cs.total - src, de_pct: src ? (floan + lloan) / src * 100 : 0, eq_pct: src ? totals.total_inv / src * 100 : 0 };

    const fs = fdiLoanSchedule(floan, loan.f_rate, loan.f_tenure, loan.f_morat, N);
    const ls = fdiLoanSchedule(lloan, loan.l_rate, loan.l_tenure, loan.l_morat, N);
    const util = proj.util || [];
    const depOther = (cs.plant + cs.veh + cs.off) / Math.max(1, fn(proj.dep_life));
    const depB = cs.bldg / Math.max(1, fn(proj.dep_bldg));
    const amortRate = cs.preop / Math.max(1, fn(proj.preop_amort));

    const Y = [];
    const cash0 = fin.sources - cs.fixed - cs.preop;
    let accDep = 0, preopLeft = cs.preop, ret = 0, cashPrev = cash0, nwcPrev = 0, cfLoss = 0;
    for (let y = 1; y <= N; y++) {
      const u = fn(util[y - 1] != null ? util[y - 1] : 100) / 100;
      const esc = Math.pow(1 + fn(proj.growth) / 100, y - 1);
      const rev = fn(proj.rev_y1) * u * esc;
      const cogs = rev * fn(proj.cogs_pct) / 100;
      const admin = rev * fn(proj.admin_pct) / 100, sell = rev * fn(proj.sell_pct) / 100,
            oth = rev * fn(proj.other_pct) / 100, sal = rev * fn(proj.salary_pct) / 100;
      const roy = rev * fn(proj.royalty_pct) / 100, tech = rev * fn(proj.tech_fee_pct) / 100, mgmt = rev * fn(proj.mgmt_fee_pct) / 100;
      const opex = cogs + admin + sell + oth + sal, fees = roy + tech + mgmt;
      const ebitda = rev - opex - fees;
      const dB = (y <= fn(proj.dep_bldg)) ? depB : 0;
      const dO = (y <= fn(proj.dep_life)) ? depOther : 0;
      const am = (preopLeft > 0.005) ? Math.min(amortRate, preopLeft) : 0; preopLeft -= am;
      const dep = dB + dO + am;
      const ebit = ebitda - dep;
      const intF = fs[y - 1].interest, intL = ls[y - 1].interest, interest = intF + intL;
      const pbt = ebit - interest;
      const taxable = pbt + cfLoss;
      const rate = fdiEffectiveTaxRate(y, proj);
      let tax = 0;
      if (taxable > 0) { tax = taxable * rate / 100; cfLoss = 0; } else { cfLoss = taxable; }
      const pat = pbt - tax;
      const ar = rev * fn(proj.ar_days) / 365, inv = cogs * fn(proj.inv_days) / 365, ap = cogs * fn(proj.ap_days) / 365;
      const nwc = ar + inv - ap, dNwc = nwc - nwcPrev;
      ret = ret + pat;
      let div = 0;
      if (pat > 0 && ret > 0) div = Math.min(pat * fn(proj.payout) / 100, ret);
      ret -= div;
      const princ = fs[y - 1].principal + ls[y - 1].principal;
      const net = cashPrev + pat + dep - dNwc - princ - div;
      const od = (net < -0.005) ? -net : 0;
      const cash = (net > 0) ? net : 0;
      accDep += dB + dO;
      const netBlock = cs.fixed - accDep;
      const assets = netBlock + preopLeft + ar + inv + cash;
      const liab = fin.equity + ret + fs[y - 1].close + ls[y - 1].close + ap + od;
      Y.push({
        y, util: u * 100, rev, cogs, admin, sell, oth, sal, roy, tech, mgmt, fees, opex, ebitda, dep,
        ebit, int_f: intF, int_l: intL, interest, pbt, tax, tax_rate: rate, pat, div, ret, ar, inv, ap, nwc,
        d_nwc: dNwc, cash, od, net, princ, f_close: fs[y - 1].close, l_close: ls[y - 1].close,
        acc_dep: accDep, net_block: netBlock, preop_left: preopLeft, assets, liab, diff: assets - liab,
        dscr: (interest + princ) > 0 ? ((pat + dep + interest) / (interest + princ)) : null,
        ebitda_pct: rev ? ebitda / rev * 100 : 0, pat_pct: rev ? pat / rev * 100 : 0,
        current_ratio: (ar + inv + cash) / Math.max(1e-9, (ap + od)),
        roce: ebit / Math.max(1e-9, (netBlock + nwc)) * 100
      });
      cashPrev = net; nwcPrev = nwc;
    }

    const wacc = fn(val.wacc) / 100 || 0.16;
    const fcf = [-cs.total], eq = [-fin.equity];
    Y.forEach((r, i) => {
      const t = r.tax_rate / 100;
      let f = r.ebit * (1 - t) + r.dep - r.d_nwc;
      if (i === Y.length - 1) f += r.nwc + (cs.fixed - r.acc_dep);
      fcf.push(f);
      let d = r.div * (1 - fn(params.dividend_wht) / 100);
      if (i === Y.length - 1) d += Math.max(0, fin.equity + r.ret);
      eq.push(d);
    });
    let npv = 0; fcf.forEach((f, i) => { npv += f / Math.pow(1 + wacc, i); });
    return {
      years: N, Y, cs, fin, fs, ls, cash0,
      npv, irr: fdiIrr(fcf), eirr: fdiIrr(eq), fcf, eq, wacc: wacc * 100,
      avg_dscr: fdiAvg(Y.map(r => r.dscr)), min_dscr: fdiMinOf(Y.map(r => r.dscr)),
      payback: fdiPayback(fcf), max_diff: Math.max(...Y.map(r => Math.abs(r.diff)), 0)
    };
  }

  function fdiSectorRow(name, sectors) {
    return (sectors || []).find(s => s.name === name) ||
      (sectors || []).find(s => s.name === 'Other / to be specified') ||
      { name: 'Other / to be specified', cap_pct: 100, tax_rate: 25, special: 0, note: '' };
  }

  function fdiRouteCheck(data, totals, sector) {
    const params = data.params || {}, route = data.route || {}, client = data.client || {};
    const out = { flags: [], total_inv: totals.total_inv, foreign: totals.foreign, foreign_pct: totals.foreign_pct };
    const isIT = /information technology/i.test(client.sector || '');
    out.min_per_investor = (isIT && params.min_fdi_exempt_it) ? 0 : fn(params.min_fdi);
    const fx = data.fx || {};
    out.below_min = (data.investors || []).filter(inv => fdiIsForeign(inv) && fdiInvNpr(inv, fx) < out.min_per_investor - 0.5);
    const capOverride = route.cap_override;
    out.cap = (capOverride !== '' && capOverride !== null && capOverride !== undefined) ? fn(capOverride) : fn(sector.cap_pct);
    out.cap_breach = totals.foreign_pct > out.cap + 0.0001;
    const amt = totals.total_inv;
    if ((route.authority || 'auto') === 'auto') {
      out.authority = amt > fn(params.ibn_threshold) ? 'Investment Board Nepal (IBN)' : 'Department of Industry (DoI)';
    } else out.authority = route.authority;
    const negChecks = route.neg_checks || {};
    const negSelected = Object.keys(negChecks).filter(k => negChecks[k]);
    out.neg_hits = negSelected;
    out.auto_eligible = (amt <= fn(params.auto_cap)) && negSelected.length === 0 && !out.cap_breach && amt <= fn(params.ibn_threshold);
    if (route.auto_eligible === 'yes') out.auto_eligible = true;
    if (route.auto_eligible === 'no') out.auto_eligible = false;
    out.route = out.authority.indexOf('IBN') >= 0 ? 'Investment Board approval route'
      : (out.auto_eligible ? 'Automatic route (online approval)' : 'Department of Industry approval route');
    if (negSelected.length) out.flags.push({ t: 'bad', m: 'The activity touches ' + negSelected.length + ' item(s) on the restricted list. Foreign investment cannot be approved for a restricted activity — restructure the objects or drop the activity.' });
    if (out.below_min.length) out.flags.push({ t: 'bad', m: out.below_min.length + ' foreign investor(s) are below the minimum of NPR ' + out.min_per_investor.toLocaleString() + ' each. The minimum applies per foreign investor, not to the total.' });
    if (out.cap_breach) out.flags.push({ t: 'bad', m: 'Foreign holding of ' + totals.foreign_pct.toFixed(2) + '% exceeds the ceiling of ' + out.cap.toFixed(0) + '% indicated for this sector.' });
    if (totals.foreign <= 0) out.flags.push({ t: 'warn', m: 'No foreign investor has been entered, so there is no foreign investment to approve.' });
    if (amt > fn(params.ibn_threshold)) out.flags.push({ t: 'warn', m: 'Total investment exceeds the Investment Board threshold of NPR ' + fn(params.ibn_threshold).toLocaleString() + '. The file goes to IBN, not DoI, and the approval timeline is longer.' });
    else if (amt > fn(params.auto_cap)) out.flags.push({ t: 'info', m: 'Investment exceeds the automatic-route ceiling of NPR ' + fn(params.auto_cap).toLocaleString() + '. Expect the standard DoI approval route.' });
    if (!params.last_verified) out.flags.push({ t: 'warn', m: 'Thresholds and rates have not been marked as verified against the current Act, Regulation and gazette notice. Do that before the file leaves the office.' });
    return out;
  }

  function fdiTaxPack(data, model, totals) {
    const params = data.params || {};
    const fp = totals.foreign_pct / 100;
    const rows = model.Y.map(r => {
      const divF = r.div * fp, wht = divF * fn(params.dividend_wht) / 100;
      const intF = r.int_f, intWht = intF * fn(params.interest_wht) / 100;
      const fees = r.roy + r.tech + r.mgmt, feeWht = fees * fn(params.royalty_wht) / 100;
      const corpTax = r.tax, totalTax = corpTax + wht + intWht + feeWht;
      return { y: r.y, pbt: r.pbt, corp_tax: corpTax, pat: r.pat, div: r.div, div_f: divF, wht, net_div: divF - wht,
               int_f: intF, int_wht: intWht, net_int: intF - intWht, fees, fee_wht: feeWht, net_fee: fees - feeWht,
               total_out: (divF - wht) + (intF - intWht) + (fees - feeWht), total_tax: totalTax,
               eff: r.pbt > 0 ? totalTax / r.pbt * 100 : 0, principal_f: model.fs[r.y - 1].principal };
    });
    const last = model.Y[model.Y.length - 1] || { pbt: 0, tax: 0, pat: 0 };
    const proj = data.proj || {};
    const cr = fdiEffectiveTaxRate(model.years, proj);
    return {
      rows, total_net_out: rows.reduce((a, b) => a + b.total_out, 0), total_principal: rows.reduce((a, b) => a + b.principal_f, 0),
      total_tax: rows.reduce((a, b) => a + b.total_tax, 0), foreign_invested: totals.foreign, fp,
      compare: {
        subsidiary: { tax: last.pbt > 0 ? last.pbt * cr / 100 : 0, then: 'Dividend withholding at ' + params.dividend_wht + '% on distribution' },
        branch: { tax: last.pbt > 0 ? last.pbt * fn(proj.tax_rate) / 100 : 0, then: 'No separate dividend withholding on branch profit remittance, but concessions available to a special industry may not apply and permanent-establishment exposure is direct' },
        pbt: last.pbt
      }
    };
  }

  function fdiExitCalc(data, model, totals) {
    const val = data.val || {}, params = data.params || {};
    const ey = Math.max(1, Math.min(model.years, Math.round(fn(val.exit_year)) || model.years));
    const r = model.Y[ey - 1] || model.Y[model.Y.length - 1];
    if (!r) return null;
    const ev = r.ebitda * fn(val.exit_multiple);
    const netDebt = r.f_close + r.l_close - r.net;
    const eqv = ev - netDebt;
    const fp = totals.foreign_pct / 100;
    const foreignProceeds = eqv * fp;
    const costBase = fn(val.exit_cost_base) || totals.foreign;
    const gain = Math.max(0, foreignProceeds - costBase);
    const cgt = gain * fn(params.cgt_non_resident) / 100;
    const shares = totals.shares || 1;
    return { ey, ebitda: r.ebitda, ev, net_debt: netDebt, eqv, per_share: eqv / shares, fp: fp * 100,
             foreign_proceeds: foreignProceeds, cost_base: costBase, gain, cgt, net: foreignProceeds - cgt,
             moic: costBase ? foreignProceeds / costBase : 0, shares };
  }

  function fdiValuation(data, model, totals) {
    const val = data.val || {}, capital = data.capital || {};
    const shares = totals.shares || Math.round(totals.total_inv / (fn(capital.face_value) || 100)) || 1;
    const wacc = fn(val.wacc) / 100, g = fn(val.tg) / 100;
    let pv = 0; const fl = [];
    model.Y.forEach((r, i) => {
      const tr = r.tax_rate / 100;
      const f = r.ebit * (1 - tr) + r.dep - r.d_nwc;
      fl.push(f); pv += f / Math.pow(1 + wacc, i + 1);
    });
    const last = fl[fl.length - 1] || 0;
    const tv = (wacc > g) ? last * (1 + g) / (wacc - g) : 0;
    const pvTv = tv / Math.pow(1 + wacc, model.years);
    const ev = pv + pvTv;
    const ylast = model.Y[model.Y.length - 1] || {};
    const netDebt = fn(val.net_debt) || model.fin.debt;
    const dcfEq = ev - netDebt;
    let nav = fn(val.net_assets) || totals.total_inv;
    nav = nav + fn(val.surplus);
    const peEq = (ylast.pat || 0) * fn(val.pe);
    const evEq = (ylast.ebitda || 0) * fn(val.ev_ebitda) - netDebt;
    const w = { nav: fn(val.nav_weight), dcf: fn(val.dcf_weight), pe: fn(val.pe_weight), ev: fn(val.ebitda_weight) };
    const wsum = (w.nav + w.dcf + w.pe + w.ev) || 1;
    const weighted = (nav * w.nav + dcfEq * w.dcf + peEq * w.pe + evEq * w.ev) / wsum;
    return { shares, pv_fcf: pv, tv, pv_tv: pvTv, ev, net_debt: netDebt, dcf_eq: dcfEq, nav, pe_eq: peEq, ev_eq: evEq,
             weighted, w, per_share: { nav: nav / shares, dcf: dcfEq / shares, pe: peEq / shares, ev: evEq / shares, weighted: weighted / shares } };
  }

  function fdiValidate(data, totals, model, route, finance, ddItemCount) {
    const v = [];
    const client = data.client || {}, params = data.params || {}, proj = data.proj || {}, val = data.val || {},
          tt = data.tt || {}, dd = data.dd || {}, loan = data.loan || {};
    const add = (sev, area, msg) => v.push({ sev, area, msg });
    if (!client.name) add('err', 'Client', 'The project company has not been named.');
    if (!(data.investors || []).length) add('err', 'Investors', 'No investor has been added.');
    if (Math.abs(finance.gap) > 1) add(finance.gap > 0 ? 'err' : 'warn', 'Finance',
      (finance.gap > 0 ? 'Cost of project exceeds the means of finance by NPR ' : 'Means of finance exceed the cost of project by NPR ') +
      Math.abs(finance.gap).toLocaleString() + '. The two must tie before the project report is issued.');
    if (fdiCostTotal(data.cost) <= 0) add('err', 'Project cost', 'Cost of project has not been entered.');
    if (fn(proj.rev_y1) <= 0) add('err', 'Projections', 'Revenue at full capacity has not been entered, so the whole model is zero.');
    route.flags.forEach(x => add(x.t === 'bad' ? 'err' : (x.t === 'warn' ? 'warn' : 'info'), 'Route', x.m));
    const capital = data.capital || {};
    if (fn(capital.authorised) && totals.total_inv > fn(capital.authorised)) add('err', 'Capital', 'Issued capital of NPR ' + totals.total_inv.toLocaleString() + ' exceeds the authorised capital of NPR ' + fn(capital.authorised).toLocaleString() + '.');
    if (model.max_diff > 1) add('err', 'Model', 'The balance sheet does not tie — maximum difference NPR ' + model.max_diff.toLocaleString() + '. Report this; it is a defect in the model, not in the data.');
    if (model.min_dscr != null && model.min_dscr < 1.2 && finance.debt > 0) add('warn', 'Appraisal', 'Minimum DSCR is ' + model.min_dscr.toFixed(2) + '. Lenders in Nepal generally look for 1.20 or better; consider a longer tenure, a moratorium or more equity.');
    model.Y.forEach(r => { if (r.od > 1) add('warn', 'Cash flow', 'Year ' + r.y + ' shows a cash shortfall of NPR ' + r.od.toLocaleString() + ' plugged as an overdraft. Interest on the plug is not modelled.'); });
    if (model.irr == null) add('warn', 'Appraisal', 'Project IRR could not be computed — the cash flows do not change sign.');
    else if (model.irr < fn(val.wacc)) add('warn', 'Appraisal', 'Project IRR of ' + model.irr.toFixed(2) + '% is below the discount rate of ' + fn(val.wacc).toFixed(0) + '%. On these numbers the project does not create value.');
    if (finance.de_pct > fn(params.de_ratio)) add('warn', 'Gearing', 'Debt is ' + finance.de_pct.toFixed(2) + '% of the means of finance, above the working norm of ' + fn(params.de_ratio).toFixed(0) + '%. Expect the lender and the regulator to ask for more equity.');
    if (fn(loan.f_amt) > 0 && !params.last_verified) add('warn', 'NRB', 'A foreign loan is included. Confirm the current NRB position on foreign currency borrowing, the interest ceiling and the hedging requirement before committing.');
    (data.investors || []).forEach(inv => {
      const name = inv.name || '';
      if (!name) add('err', 'Investors', 'An investor row has no name.');
      if (fdiIsForeign(inv) && !inv.country) add('warn', 'Investors', 'Country not stated for ' + (name || 'an investor') + '.');
      if (fdiIsCompany(inv) && !inv.reg_no) add('warn', 'Investors', 'Registration number not stated for ' + (name || 'a company investor') + '.');
      if (!fdiIsCompany(inv) && fdiIsForeign(inv) && !inv.passport) add('warn', 'Investors', 'Passport number not stated for ' + (name || 'an individual investor') + '.');
      if (fdiIsForeign(inv) && !inv.source_of_funds) add('warn', 'Due diligence', 'Source of funds not documented for ' + (name || 'a foreign investor') + '.');
    });
    if (tt.has && fn(tt.royalty_pct) > fn(params.royalty_domestic_sales_cap)) add('warn', 'Technology transfer', 'Royalty of ' + fn(tt.royalty_pct).toFixed(0) + '% of sales exceeds the working ceiling of ' + fn(params.royalty_domestic_sales_cap).toFixed(0) + '% used in this file. Anything above the ceiling in the approved agreement is not remittable.');
    const ddDone = Object.keys(dd).filter(k => dd[k]).length;
    if (ddDone < ddItemCount) add('info', 'Due diligence', (ddItemCount - ddDone) + ' of ' + ddItemCount + ' due diligence points are still open.');
    if (!params.last_verified) add('warn', 'Parameters', 'Statutory thresholds, tax rates and ceilings are planning defaults. Mark them verified in Route & eligibility once checked against the current law.');
    return v;
  }

  // ----------------------------- capital structure & feasibility advisory
  // Mirrors server engines.py's _debt_capacity / _loan_restructuring /
  // _debt_equity_funding / _breakeven_analysis / capital_advisory field-for-field.
  // Pure corporate-finance formulae (annuity, contribution margin, WACC) — no
  // rate_master dependency, same as Valuation and the DD workbench.
  const pnum = (v, dflt) => (v === null || v === undefined || v === '') ? (dflt || 0) : dn(v);

  function annuityPayment(principal, periodicRate, nPeriods) {
    if (nPeriods <= 0) return 0;
    if (periodicRate === 0) return principal / nPeriods;
    return principal * periodicRate / (1 - Math.pow(1 + periodicRate, -nPeriods));
  }
  function annuityPrincipal(payment, periodicRate, nPeriods) {
    if (nPeriods <= 0 || payment <= 0) return 0;
    if (periodicRate === 0) return payment * nPeriods;
    return payment * (1 - Math.pow(1 + periodicRate, -nPeriods)) / periodicRate;
  }
  const annuityPv = annuityPrincipal;

  function debtCapacity(d) {
    d = d || {};
    const ebitda = pnum(d.ebitda), existingDebt = pnum(d.existing_debt), existingDebtService = pnum(d.existing_debt_service);
    const availableForDebtService = (d.cash_accruals === null || d.cash_accruals === undefined || d.cash_accruals === '') ? ebitda : pnum(d.cash_accruals);
    const targetDscr = pnum(d.target_dscr, 1.25) || 1.25;
    const interestRate = pnum(d.interest_rate) / 100;
    const tenureYears = parseInt(pnum(d.tenure_years, 5) || 5, 10);
    const maxLeverageMultiple = pnum(d.max_leverage_multiple, 3.0);

    const maxTotalDebtService = dSafeDiv(availableForDebtService, targetDscr);
    const maxNewDebtService = Math.max(0, maxTotalDebtService - existingDebtService);
    const dscrBasedCapacity = R2(Math.max(0, annuityPrincipal(maxNewDebtService, interestRate, tenureYears)));
    const leverageBasedCapacity = R2(Math.max(0, ebitda * maxLeverageMultiple - existingDebt));
    const recommended = Math.min(dscrBasedCapacity, leverageBasedCapacity);
    const binding = dscrBasedCapacity <= leverageBasedCapacity ? 'DSCR' : 'Leverage';
    const proposed = d.proposed_new_debt;
    const headroom = (proposed !== null && proposed !== undefined && proposed !== '') ? R2(recommended - pnum(proposed)) : null;

    const flex = [];
    [['EBITDA -20%', 0.80], ['EBITDA -10%', 0.90], ['Base case', 1.0], ['EBITDA +10%', 1.10]].forEach(([label, ef]) => {
      const e2 = ebitda * ef, afds2 = availableForDebtService * ef;
      const mtds2 = dSafeDiv(afds2, targetDscr), mnds2 = Math.max(0, mtds2 - existingDebtService);
      const dscrCap2 = Math.max(0, annuityPrincipal(mnds2, interestRate, tenureYears));
      const levCap2 = Math.max(0, e2 * maxLeverageMultiple - existingDebt);
      flex.push({ scenario: label, dscr_based_capacity: R2(dscrCap2), leverage_based_capacity: R2(levCap2),
                 recommended_max_new_debt: R2(Math.min(dscrCap2, levCap2)) });
    });

    return {
      ebitda: R2(ebitda), available_for_debt_service: R2(availableForDebtService), existing_debt: R2(existingDebt),
      existing_debt_service: R2(existingDebtService), target_dscr: targetDscr, interest_rate_pct: R2(interestRate * 100),
      tenure_years: tenureYears, max_leverage_multiple: maxLeverageMultiple, dscr_based_capacity: dscrBasedCapacity,
      leverage_based_capacity: leverageBasedCapacity, recommended_max_new_debt: R2(Math.max(0, recommended)),
      binding_constraint: binding,
      proposed_new_debt: (proposed !== null && proposed !== undefined && proposed !== '') ? R2(pnum(proposed)) : null,
      headroom, sensitivity: flex,
    };
  }

  function loanRestructuring(d) {
    d = d || {};
    const cur = d.current || {};
    const principal = pnum(cur.principal);
    const rate = pnum(cur.rate) / 100;
    const tenure = parseInt(pnum(cur.tenure_years) || 0, 10);
    const discountRate = pnum(d.discount_rate, pnum(cur.rate)) / 100;

    function evaluate(principal_, rate_, tenure_, moratorium_) {
      const n = Math.max(0, tenure_ - moratorium_);
      const opening = moratorium_ ? principal_ * Math.pow(1 + rate_, moratorium_) : principal_;
      const annualPayment = R2(annuityPayment(opening, rate_, n));
      const totalPaid = R2(annualPayment * n);
      const totalInterest = R2(totalPaid - principal_);
      const pv = R2(discountRate ? annuityPv(annualPayment, discountRate, n) : totalPaid);
      return { annual_payment: annualPayment, n_payments: n, total_paid: totalPaid, total_interest: totalInterest, pv_of_payments: pv };
    }

    const currentEval = Object.assign(evaluate(principal, rate, tenure, 0),
      { label: 'Current terms', rate_pct: R2(rate * 100), tenure_years: tenure, moratorium_years: 0 });

    const options = (d.options || []).map(opt => {
      const newRate = pnum(opt.new_rate, rate * 100) / 100;
      const newTenure = parseInt(pnum(opt.new_tenure_years, tenure) || tenure, 10);
      const mor = parseInt(pnum(opt.moratorium_years) || 0, 10);
      const ev = evaluate(principal, newRate, newTenure, mor);
      return Object.assign(ev, {
        label: opt.label || 'Restructuring option', rate_pct: R2(newRate * 100), tenure_years: newTenure, moratorium_years: mor,
        interest_saving_vs_current: R2(currentEval.total_interest - ev.total_interest),
        pv_saving_vs_current: R2(currentEval.pv_of_payments - ev.pv_of_payments),
        annual_payment_change: R2(ev.annual_payment - currentEval.annual_payment),
      });
    });

    let best = null;
    if (options.length) best = options.reduce((a, b) => a.pv_of_payments <= b.pv_of_payments ? a : b).label;

    return { principal: R2(principal), current: currentEval, options, discount_rate_pct: R2(discountRate * 100), lowest_pv_option: best };
  }

  function debtEquityFunding(d) {
    d = d || {};
    const projectCost = pnum(d.project_cost);
    const costOfDebt = pnum(d.cost_of_debt) / 100;
    const costOfEquity = pnum(d.cost_of_equity) / 100;
    const taxRate = pnum(d.tax_rate) / 100;
    const afterTaxCostOfDebt = costOfDebt * (1 - taxRate);

    const rows = (d.scenarios || []).map(sc => {
      const debtPct = pnum(sc.debt_pct), equityPct = 100 - debtPct;
      const debtAmt = projectCost * debtPct / 100, equityAmt = projectCost * equityPct / 100;
      const annualInterest = R2(debtAmt * costOfDebt);
      const taxShield = R2(annualInterest * taxRate);
      const wacc = R2((debtPct / 100) * afterTaxCostOfDebt * 100 + (equityPct / 100) * costOfEquity * 100);
      return { label: sc.label || (debtPct.toFixed(0) + ':' + equityPct.toFixed(0) + ' Debt:Equity'),
               debt_pct: debtPct, equity_pct: equityPct, debt_amount: R2(debtAmt), equity_amount: R2(equityAmt),
               annual_interest: annualInterest, annual_tax_shield: taxShield, wacc_pct: wacc };
    });
    const lowestWacc = rows.length ? rows.reduce((a, b) => a.wacc_pct <= b.wacc_pct ? a : b).label : null;
    return { project_cost: R2(projectCost), cost_of_debt_pct: R2(costOfDebt * 100), cost_of_equity_pct: R2(costOfEquity * 100),
            tax_rate_pct: R2(taxRate * 100), after_tax_cost_of_debt_pct: R2(afterTaxCostOfDebt * 100),
            scenarios: rows, lowest_wacc_scenario: lowestWacc };
  }

  function breakevenAnalysis(d) {
    d = d || {};
    const mode = d.mode || 'unit';
    const fixedCosts = pnum(d.fixed_costs);

    if (mode === 'revenue') {
      const currentRevenue = pnum(d.current_revenue);
      const variableCostPct = pnum(d.variable_cost_pct_of_revenue) / 100;
      const cmRatio = 1 - variableCostPct;
      const bepRevenue = cmRatio > 0 ? R2(dSafeDiv(fixedCosts, cmRatio)) : null;
      const mos = bepRevenue !== null ? R2(currentRevenue - bepRevenue) : null;
      const mosPct = (mos !== null && currentRevenue) ? R2(dSafeDiv(mos, currentRevenue) * 100) : null;
      const flex = [];
      [['Fixed costs -10%', 0.9], ['Base case', 1.0], ['Fixed costs +10%', 1.1], ['Fixed costs +20%', 1.2]].forEach(([label, fcf]) => {
        const fc2 = fixedCosts * fcf;
        flex.push({ scenario: label, bep_revenue: cmRatio > 0 ? R2(dSafeDiv(fc2, cmRatio)) : null });
      });
      return { mode, fixed_costs: R2(fixedCosts), contribution_margin_ratio_pct: R2(cmRatio * 100), bep_revenue: bepRevenue,
              current_revenue: R2(currentRevenue), margin_of_safety: mos, margin_of_safety_pct: mosPct, sensitivity: flex };
    }

    const price = pnum(d.selling_price_per_unit), vc = pnum(d.variable_cost_per_unit), currentVolume = pnum(d.current_volume);
    const cmPerUnit = price - vc;
    const cmRatio = R2(dSafeDiv(cmPerUnit, price) * 100);
    const bepUnits = cmPerUnit > 0 ? R2(dSafeDiv(fixedCosts, cmPerUnit)) : null;
    const bepRevenue = bepUnits !== null ? R2(bepUnits * price) : null;
    const mosUnits = bepUnits !== null ? R2(currentVolume - bepUnits) : null;
    const mosPct = (mosUnits !== null && currentVolume) ? R2(dSafeDiv(mosUnits, currentVolume) * 100) : null;
    const totalCm = currentVolume * cmPerUnit;
    const operatingLeverage = (totalCm - fixedCosts) > 0 ? R2(dSafeDiv(totalCm, totalCm - fixedCosts)) : null;

    const flex = [];
    [['Fixed costs +10%', 1.0, 1.0, 1.1], ['Selling price -5%', 0.95, 1.0, 1.0],
     ['Variable cost +5%', 1.0, 1.05, 1.0], ['Base case', 1.0, 1.0, 1.0]].forEach(([label, pf, vf, fcf]) => {
      const p2 = price * pf, vc2 = vc * vf, fc2 = fixedCosts * fcf, cm2 = p2 - vc2;
      const bep2 = cm2 > 0 ? R2(dSafeDiv(fc2, cm2)) : null;
      flex.push({ scenario: label, bep_units: bep2, bep_revenue: bep2 !== null ? R2(bep2 * p2) : null });
    });

    return { mode, fixed_costs: R2(fixedCosts), selling_price_per_unit: R2(price), variable_cost_per_unit: R2(vc),
            contribution_margin_per_unit: R2(cmPerUnit), contribution_margin_ratio_pct: cmRatio, bep_units: bepUnits,
            bep_revenue: bepRevenue, current_volume: R2(currentVolume), margin_of_safety_units: mosUnits,
            margin_of_safety_pct: mosPct, operating_leverage: operatingLeverage, sensitivity: flex };
  }

  function capitalAdvisory(fy, data) {
    const d = data || {};
    return {
      entity: d.entity || {},
      capacity: debtCapacity(d.capacity || {}),
      restructuring: loanRestructuring(d.restructure || {}),
      funding: debtEquityFunding(d.funding || {}),
      breakeven: breakevenAnalysis(d.breakeven || {}),
      signatory: d.signatory || {},
      unverified: [],
    };
  }

  const FDI_COMPLIANCE_CALENDAR = [
    ['Annual progress report to the Department of Industry / Investment Board Nepal', 'Annual'],
    ['Renewal of industry registration, where the registration carries a validity period', 'As applicable'],
    ['Nepal Rastra Bank reporting of inward remittance and foreign loan drawdown/repayment', 'As transactions occur'],
    ['Repatriation approval obtained for each dividend remittance before transfer', 'Per dividend declaration'],
    ['Repatriation approval obtained for capital or loan-principal remittance before transfer', 'Per repayment / exit'],
    ['Annual audited financial statements filed with the regulator', 'Annual'],
    ['Foreign investment statistics / survey return filed with Nepal Rastra Bank, where applicable', 'As required'],
    ['PAN, VAT and labour-law registrations confirmed current', 'Ongoing'],
  ];
  const FDI_LOAN_COMPLIANCE_CHECKLIST = [
    'Prior Nepal Rastra Bank approval obtained for the foreign loan',
    'Loan purpose matches the objects approved by the Department of Industry / Investment Board',
    'Loan drawdown and repayment routed only through the banking channel',
    'Loan registered with Nepal Rastra Bank and reported under FITTA reporting requirements',
    'Interest rate confirmed against the NRB ceiling current at drawdown',
    'Hedging position considered and documented, if required',
  ];

  function fdiLoanCompliance(data, finance) {
    const loan = data.loan || {}, params = data.params || {}, checklist = data.loan_compliance || {};
    const out = { has_loan: fn(loan.f_amt) > 0, items: [], flags: [] };
    if (!out.has_loan) return out;
    FDI_LOAN_COMPLIANCE_CHECKLIST.forEach((item, i) => { out.items.push({ item, done: !!checklist[String(i)] }); });
    const rate = fn(loan.f_rate);
    const ceiling = params.foreign_loan_rate_ceiling;
    if (ceiling !== null && ceiling !== undefined && ceiling !== '') {
      const ceilingV = fn(ceiling);
      out.rate_ceiling = ceilingV;
      out.rate_breach = rate > ceilingV + 0.0001;
      if (out.rate_breach) out.flags.push({ t: 'bad', m: 'Foreign loan interest rate of ' + rate.toFixed(2) + '% exceeds the confirmed ceiling of ' + ceilingV.toFixed(2) + '%.' });
    } else {
      out.flags.push({ t: 'warn', m: 'No foreign-loan interest-rate ceiling has been confirmed in the statutory parameters — confirm the current NRB position before relying on this rate.' });
    }
    const deRatio = fn(params.de_ratio);
    if (deRatio && (finance.de_pct || 0) > deRatio) out.flags.push({ t: 'warn', m: 'Gearing including this loan is ' + finance.de_pct.toFixed(2) + '% of means of finance, above the working norm of ' + deRatio.toFixed(0) + '%.' });
    const incomplete = out.items.filter(i => !i.done);
    if (incomplete.length) out.flags.push({ t: 'info', m: incomplete.length + ' of ' + out.items.length + ' foreign-loan compliance point(s) not yet confirmed.' });
    return out;
  }

  function fdiFxAnalysis(data, totals, exitCalc) {
    const fx = data.fx || {}, fxExit = data.fx_exit || {};
    const rows = [];
    (data.investors || []).forEach(inv => {
      if (!fdiIsForeign(inv)) return;
      const cur = inv.currency || 'NPR';
      if (cur === 'NPR') return;
      const entryRate = (fx.rates && fx.rates[cur] != null) ? fn(fx.rates[cur]) : fn(fx.rate);
      const exitRate = (fxExit[cur] != null && fxExit[cur] !== '') ? fn(fxExit[cur]) : entryRate;
      const amountFc = fn(inv.amount);
      const investedNpr = amountFc * entryRate;
      const shareOfForeign = totals.foreign ? investedNpr / totals.foreign : 0;
      const proceedsNpr = exitCalc ? exitCalc.net * shareOfForeign : 0;
      const proceedsFc = exitRate ? proceedsNpr / exitRate : 0;
      const fxMovePct = entryRate ? (exitRate - entryRate) / entryRate * 100 : 0;
      rows.push({ investor: inv.name || '', currency: cur, entry_rate: R2(entryRate), exit_rate: R2(exitRate),
        fx_move_pct: R2(fxMovePct), invested_fc: R2(amountFc), invested_npr: R2(investedNpr),
        proceeds_npr: R2(proceedsNpr), proceeds_fc: R2(proceedsFc), moic_fc: amountFc ? R2(proceedsFc / amountFc) : 0 });
    });
    return { rows, has_exit: exitCalc !== null && exitCalc !== undefined };
  }

  function fdiEngine(fy, data) {
    data = data || {};
    const sector = fdiSectorRow((data.client || {}).sector, data.sectors);
    const totals = fdiTotals(data.investors, data.capital || {}, data.fx || {});
    const model = fdiModel(data);
    const route = fdiRouteCheck(data, totals, sector);
    const fx = data.fx || {};
    const floan = fdiFloanNpr(data.loan || {}, fx);
    const lloan = fn((data.loan || {}).l_amt);
    const src = totals.total_inv + floan + lloan;
    const costTotal = fdiCostTotal(data.cost);
    const finance = { equity: totals.total_inv, f_loan: floan, l_loan: lloan, debt: floan + lloan, sources: src,
                       cost: costTotal, gap: costTotal - src, de_pct: src ? (floan + lloan) / src * 100 : 0, eq_pct: src ? totals.total_inv / src * 100 : 0 };
    const taxPack = fdiTaxPack(data, model, totals);
    const exitCalc = fdiExitCalc(data, model, totals);
    const valuation = fdiValuation(data, model, totals);
    const checks = fdiValidate(data, totals, model, route, finance, 26);
    const loanCompliance = fdiLoanCompliance(data, finance);
    const fxAnalysis = fdiFxAnalysis(data, totals, exitCalc);
    return { sector, totals, model, route, finance, tax_pack: taxPack, exit: exitCalc, valuation, checks,
             loan_compliance: loanCompliance, fx_analysis: fxAnalysis,
             negative_list: FDI_NEGATIVE_LIST, compliance_calendar: FDI_COMPLIANCE_CALENDAR,
             unverified: (data.params || {}).last_verified ? [] : ['fdi.params'],
             client: data.client || {}, investors: data.investors || [], capital: data.capital || {},
             cost: data.cost || [], jva: data.jva || {}, poa: data.poa || {}, tt: data.tt || {},
             doi: data.doi || {}, roadmap: data.roadmap || [], dd: data.dd || {},
             compliance: data.compliance || {}, loan_compliance_checklist: data.loan_compliance || {},
             params: data.params || {}, fx: data.fx || {}, fx_exit: data.fx_exit || {} };
  }

  // -------------------------------------------------------------------- facade
  // -------------------------------------------------------- inventory (NAS 2)
  function invLandedUnitCost(p) {
    const qty = N(p.qty);
    if (qty <= 0) return 0;
    const gross = qty * N(p.rate);
    return (gross + N(p.freight) + N(p.duty) + N(p.other_cost) - N(p.discount)) / qty;
  }

  function invRunLedger(events, method) {
    events = events.slice().sort((a, b) => {
      const da = a.date || '', db = b.date || '';
      if (da !== db) return da < db ? -1 : 1;
      return (a.kind === 'in' ? 0 : 1) - (b.kind === 'in' ? 0 : 1);
    });
    const ledger = [];
    if (method === 'fifo') {
      const layers = [];
      events.forEach(e => {
        if (e.kind === 'in') {
          layers.push([e.qty, e.unit_cost]);
          const bq = layers.reduce((s, l) => s + l[0], 0), bc = layers.reduce((s, l) => s + l[0] * l[1], 0);
          ledger.push(Object.assign({}, e, { total_cost: R2(e.qty * e.unit_cost), balance_qty: R2(bq), balance_cost: R2(bc) }));
        } else {
          let remaining = e.qty, costOut = 0;
          while (remaining > 1e-9 && layers.length) {
            const lot = layers[0], take = Math.min(remaining, lot[0]);
            costOut += take * lot[1]; lot[0] -= take; remaining -= take;
            if (lot[0] <= 1e-9) layers.shift();
          }
          const bq = layers.reduce((s, l) => s + l[0], 0), bc = layers.reduce((s, l) => s + l[0] * l[1], 0);
          ledger.push(Object.assign({}, e, { unit_cost: e.qty ? R2(costOut / e.qty) : 0, total_cost: R2(costOut),
            balance_qty: R2(bq), balance_cost: R2(bc), shortfall: remaining > 1e-9 ? R2(remaining) : 0 }));
        }
      });
    } else {
      let bq = 0, bc = 0;
      events.forEach(e => {
        if (e.kind === 'in') {
          bq += e.qty; bc += e.qty * e.unit_cost;
          ledger.push(Object.assign({}, e, { total_cost: R2(e.qty * e.unit_cost), balance_qty: R2(bq), balance_cost: R2(bc) }));
        } else {
          const avg = bq > 1e-9 ? bc / bq : 0;
          const take = Math.min(e.qty, bq), costOut = take * avg, shortfall = e.qty - take;
          bq -= take; bc -= costOut;
          if (bq <= 1e-9) { bq = 0; bc = 0; }
          ledger.push(Object.assign({}, e, { unit_cost: R2(avg), total_cost: R2(costOut), balance_qty: R2(bq), balance_cost: R2(bc),
            shortfall: shortfall > 1e-9 ? R2(shortfall) : 0 }));
        }
      });
    }
    return ledger;
  }

  function invRawMaterial(item) {
    const method = item.costing_method || 'weighted_avg';
    const opening = item.opening || {};
    const events = [];
    const oq = N(opening.qty);
    if (oq > 0) events.push({ date: opening.date || '', kind: 'in', type: 'opening', qty: oq, unit_cost: N(opening.rate) });
    (item.purchases || []).forEach(p => { const q = N(p.qty); if (q > 0) events.push({ date: p.date || '', kind: 'in', type: 'purchase', qty: q, unit_cost: invLandedUnitCost(p), desc: p.desc || '' }); });
    (item.issues || []).forEach(iss => { const q = N(iss.qty); if (q > 0) events.push({ date: iss.date || '', kind: 'out', type: 'issue', qty: q, desc: iss.desc || '' }); });
    const ledger = invRunLedger(events, method);

    let closingQty = ledger.length ? ledger[ledger.length - 1].balance_qty : oq;
    let closingCost = ledger.length ? ledger[ledger.length - 1].balance_cost : oq * N(opening.rate);
    let closingUnitCost = closingQty > 1e-9 ? closingCost / closingQty : 0;

    let writeDown = 0, valuationBasis = 'cost';
    const rc = item.replacement_cost_per_unit;
    if (item.fg_expected_below_cost && rc !== undefined && rc !== null && rc !== '' && N(rc) < closingUnitCost) {
      writeDown = (closingUnitCost - N(rc)) * closingQty;
      closingUnitCost = N(rc); closingCost = closingUnitCost * closingQty;
      valuationBasis = 'replacement cost (related finished goods expected to sell below cost)';
    }
    const issued = ledger.filter(e => e.type === 'issue');
    return { name: item.name || '', unit: item.unit || '', costing_method: method, ledger,
      closing_qty: R2(closingQty), closing_unit_cost: R2(closingUnitCost), write_down: R2(writeDown),
      valuation_basis: valuationBasis, closing_value: R2(closingCost),
      material_consumed_qty: R2(issued.reduce((s, e) => s + e.qty, 0)),
      material_consumed_cost: R2(issued.reduce((s, e) => s + (e.total_cost || 0), 0)),
      shortfall_qty: R2(issued.reduce((s, e) => s + (e.shortfall || 0), 0)) };
  }

  function invWip(wipData, materialCost) {
    const opening = wipData.opening || {};
    const openingQty = N(opening.qty), openingCost = N(opening.cost);
    const unitsIntroduced = N(wipData.units_introduced);

    const laborCost = N(wipData.direct_labor_cost), variableOh = N(wipData.variable_overhead);
    const normalCapacity = N(wipData.normal_capacity_units);
    const actualProduction = N(wipData.actual_production_units) || unitsIntroduced;
    const budgetedFixedOh = N(wipData.budgeted_fixed_overhead);

    let fixedOhRate = 0, fixedOhAbsorbed = budgetedFixedOh, unabsorbedFixedOh = 0;
    if (normalCapacity > 0 && budgetedFixedOh > 0) {
      fixedOhRate = budgetedFixedOh / normalCapacity;
      fixedOhAbsorbed = fixedOhRate * actualProduction;
      unabsorbedFixedOh = actualProduction < normalCapacity ? Math.max(0, budgetedFixedOh - fixedOhRate * Math.min(actualProduction, normalCapacity)) : 0;
    }
    const totalConversionCost = laborCost + variableOh + fixedOhAbsorbed;

    const normalLossUnits = N(wipData.normal_loss_units), abnormalLossUnits = N(wipData.abnormal_loss_units);
    const scrapRate = N(wipData.scrap_rate_per_unit);
    const normalScrapValue = normalLossUnits * scrapRate;

    const transferredUnits = N(wipData.units_completed_transferred);
    const closingWipUnits = N(wipData.closing_wip_units);
    const matPct = N(wipData.closing_material_pct != null ? wipData.closing_material_pct : 100) / 100;
    const convPct = N(wipData.closing_conversion_pct != null ? wipData.closing_conversion_pct : 50) / 100;

    const equivUnitsMaterial = transferredUnits + abnormalLossUnits + closingWipUnits * matPct;
    const equivUnitsConversion = transferredUnits + abnormalLossUnits + closingWipUnits * convPct;

    const materialPool = materialCost - normalScrapValue;
    const materialRate = equivUnitsMaterial > 0 ? materialPool / equivUnitsMaterial : 0;
    const conversionRate = equivUnitsConversion > 0 ? totalConversionCost / equivUnitsConversion : 0;
    const combinedRate = materialRate + conversionRate;

    const abnormalLossCost = abnormalLossUnits * combinedRate;
    const abnormalScrapRecovery = abnormalLossUnits * scrapRate;
    const abnormalLossNetExpense = Math.max(0, abnormalLossCost - abnormalScrapRecovery);

    const transferredCost = openingCost + transferredUnits * combinedRate;
    let closingWipValue = closingWipUnits * matPct * materialRate + closingWipUnits * convPct * conversionRate;

    let nrvWriteDown = 0;
    const sp = wipData.nrv_selling_price_per_unit;
    if (sp !== undefined && sp !== null && sp !== '' && closingWipUnits > 0) {
      const costToComplete = N(wipData.nrv_cost_to_complete_per_unit), sellingCost = N(wipData.nrv_selling_cost_per_unit);
      const nrvPerUnit = Math.max(0, N(sp) - costToComplete - sellingCost);
      const costPerUnit = closingWipValue / closingWipUnits;
      if (nrvPerUnit < costPerUnit) { nrvWriteDown = (costPerUnit - nrvPerUnit) * closingWipUnits; closingWipValue -= nrvWriteDown; }
    }

    const unitsToAccount = openingQty + unitsIntroduced;
    const unitsAccounted = transferredUnits + closingWipUnits + normalLossUnits + abnormalLossUnits;

    return { opening_qty: R2(openingQty), opening_cost: R2(openingCost), units_introduced: R2(unitsIntroduced),
      material_cost: R2(materialCost), direct_labor_cost: R2(laborCost), variable_overhead: R2(variableOh),
      fixed_overhead_rate: R2(fixedOhRate), fixed_overhead_absorbed: R2(fixedOhAbsorbed),
      unabsorbed_fixed_overhead: R2(unabsorbedFixedOh), total_conversion_cost: R2(totalConversionCost),
      normal_loss_units: R2(normalLossUnits), abnormal_loss_units: R2(abnormalLossUnits),
      scrap_rate_per_unit: R2(scrapRate), normal_scrap_value: R2(normalScrapValue),
      equivalent_units_material: R2(equivUnitsMaterial), equivalent_units_conversion: R2(equivUnitsConversion),
      material_rate_per_eu: R2(materialRate), conversion_rate_per_eu: R2(conversionRate), combined_rate_per_eu: R2(combinedRate),
      abnormal_loss_cost: R2(abnormalLossNetExpense), transferred_units: R2(transferredUnits), transferred_cost: R2(transferredCost),
      closing_wip_units: R2(closingWipUnits), closing_wip_value_before_nrv: R2(closingWipValue + nrvWriteDown),
      nrv_write_down: R2(nrvWriteDown), closing_wip_value: R2(Math.max(0, closingWipValue)),
      units_to_account: R2(unitsToAccount), units_accounted: R2(unitsAccounted),
      reconciles: Math.abs(unitsToAccount - unitsAccounted) < 0.01 };
  }

  function invFinishedGoods(fgData, transferredUnits, transferredCost) {
    const method = fgData.costing_method || 'weighted_avg';
    const opening = fgData.opening || {};
    const events = [];
    const oq = N(opening.qty);
    if (oq > 0) events.push({ date: opening.date || '', kind: 'in', type: 'opening', qty: oq, unit_cost: N(opening.rate) });
    if (transferredUnits > 0) events.push({ date: fgData.transfer_date || '', kind: 'in', type: 'production', qty: transferredUnits,
      unit_cost: transferredUnits ? transferredCost / transferredUnits : 0 });
    (fgData.sales || []).forEach(s => { const q = N(s.qty); if (q > 0) events.push({ date: s.date || '', kind: 'out', type: 'sale', qty: q, desc: s.desc || '' }); });
    const ledger = invRunLedger(events, method);

    let closingQty = ledger.length ? ledger[ledger.length - 1].balance_qty : oq;
    let closingCost = ledger.length ? ledger[ledger.length - 1].balance_cost : oq * N(opening.rate);
    let closingUnitCost = closingQty > 1e-9 ? closingCost / closingQty : 0;

    let writeDown = 0;
    const sp = fgData.selling_price_per_unit;
    if (sp !== undefined && sp !== null && sp !== '' && closingQty > 0) {
      const sellingCost = N(fgData.selling_cost_per_unit);
      const nrvPerUnit = Math.max(0, N(sp) - sellingCost);
      if (nrvPerUnit < closingUnitCost) { writeDown = (closingUnitCost - nrvPerUnit) * closingQty; closingUnitCost = nrvPerUnit; closingCost -= writeDown; }
    }
    const sold = ledger.filter(e => e.type === 'sale');
    return { costing_method: method, ledger, closing_qty: R2(closingQty), closing_unit_cost: R2(closingUnitCost),
      write_down: R2(writeDown), closing_value: R2(Math.max(0, closingCost)),
      cogs_qty: R2(sold.reduce((s, e) => s + e.qty, 0)), cogs_value: R2(sold.reduce((s, e) => s + (e.total_cost || 0), 0)),
      shortfall_qty: R2(sold.reduce((s, e) => s + (e.shortfall || 0), 0)) };
  }

  function invJournals(rmResults, wip, fg, rmMaterialCost) {
    const j = [];
    rmResults.forEach(rm => {
      rm.ledger.forEach(e => {
        if (e.type === 'purchase') {
          const tag = e.desc || (e.qty + ' units');
          j.push({ period: e.date || '', narration: `Purchase — ${rm.name} (${tag})`,
            debit: [{ account: `Raw material inventory — ${rm.name}`, amount: e.total_cost }],
            credit: [{ account: 'Accounts payable / cash', amount: e.total_cost }] });
        }
      });
      if (rm.write_down) j.push({ period: '', narration: `Write-down to replacement cost — ${rm.name}`,
        debit: [{ account: 'Inventory write-down expense', amount: rm.write_down }],
        credit: [{ account: `Raw material inventory — ${rm.name}`, amount: rm.write_down }] });
    });
    if (rmMaterialCost) j.push({ period: '', narration: 'Raw material issued to production',
      debit: [{ account: 'Work in progress', amount: R2(rmMaterialCost) }],
      credit: [{ account: 'Raw material inventory', amount: R2(rmMaterialCost) }] });
    if (wip.direct_labor_cost) j.push({ period: '', narration: 'Direct labour applied to production',
      debit: [{ account: 'Work in progress', amount: wip.direct_labor_cost }],
      credit: [{ account: 'Wages payable', amount: wip.direct_labor_cost }] });
    const ohApplied = R2(wip.variable_overhead + wip.fixed_overhead_absorbed);
    if (ohApplied) j.push({ period: '', narration: 'Production overhead applied',
      debit: [{ account: 'Work in progress', amount: ohApplied }],
      credit: [{ account: 'Production overhead applied', amount: ohApplied }] });
    if (wip.unabsorbed_fixed_overhead) j.push({ period: '', narration: 'Unabsorbed fixed overhead — expensed, not inventoried (NAS 2 §13)',
      debit: [{ account: 'Cost of production (unabsorbed overhead)', amount: wip.unabsorbed_fixed_overhead }],
      credit: [{ account: 'Production overhead applied', amount: wip.unabsorbed_fixed_overhead }] });
    if (wip.normal_scrap_value) j.push({ period: '', narration: 'Scrap recovered from normal loss',
      debit: [{ account: 'Scrap / stores', amount: wip.normal_scrap_value }],
      credit: [{ account: 'Work in progress', amount: wip.normal_scrap_value }] });
    if (wip.abnormal_loss_cost) j.push({ period: '', narration: 'Abnormal loss written off, net of scrap recovered',
      debit: [{ account: 'Abnormal loss (P&L)', amount: wip.abnormal_loss_cost }],
      credit: [{ account: 'Work in progress', amount: wip.abnormal_loss_cost }] });
    if (wip.nrv_write_down) j.push({ period: '', narration: 'WIP written down to net realisable value',
      debit: [{ account: 'Inventory write-down expense', amount: wip.nrv_write_down }],
      credit: [{ account: 'Work in progress', amount: wip.nrv_write_down }] });
    if (wip.transferred_cost) j.push({ period: '', narration: 'Completed production transferred to finished goods',
      debit: [{ account: 'Finished goods inventory', amount: wip.transferred_cost }],
      credit: [{ account: 'Work in progress', amount: wip.transferred_cost }] });
    (fg.ledger || []).forEach(e => {
      if (e.type === 'sale') {
        const tag = e.desc || (e.qty + ' units');
        j.push({ period: e.date || '', narration: `Cost of goods sold — ${tag}`,
          debit: [{ account: 'Cost of goods sold', amount: e.total_cost }],
          credit: [{ account: 'Finished goods inventory', amount: e.total_cost }] });
      }
    });
    if (fg.write_down) j.push({ period: '', narration: 'Finished goods written down to net realisable value',
      debit: [{ account: 'Inventory write-down expense', amount: fg.write_down }],
      credit: [{ account: 'Finished goods inventory', amount: fg.write_down }] });
    return j;
  }

  function inventoryValuation(fy, data) {
    data = data || {};
    const rmResults = (data.raw_materials || []).map(invRawMaterial);
    const totalMaterialConsumed = rmResults.reduce((s, r) => s + r.material_consumed_cost, 0);

    const wipData = data.wip || {};
    const materialOverride = wipData.material_cost_override;
    const materialCost = (materialOverride !== undefined && materialOverride !== null && materialOverride !== '') ? N(materialOverride) : totalMaterialConsumed;
    const wip = invWip(wipData, materialCost);

    const fg = invFinishedGoods(data.fg || {}, wip.transferred_units, wip.transferred_cost);
    const journals = invJournals(rmResults, wip, fg, materialCost);

    const closingInventoryValue = R2(rmResults.reduce((s, r) => s + r.closing_value, 0) + wip.closing_wip_value + fg.closing_value);
    const totalWriteDown = R2(rmResults.reduce((s, r) => s + r.write_down, 0) + (wip.nrv_write_down || 0) + (fg.write_down || 0));

    return { entity: data.entity || {}, raw_materials: rmResults, material_cost_used_in_wip: R2(materialCost),
      wip, fg, journals,
      summary: { closing_raw_materials: R2(rmResults.reduce((s, r) => s + r.closing_value, 0)),
        closing_wip: wip.closing_wip_value, closing_finished_goods: fg.closing_value,
        closing_inventory_value: closingInventoryValue, total_nrv_write_down: totalWriteDown,
        cogs: fg.cogs_value, abnormal_loss_expense: wip.abnormal_loss_cost,
        unabsorbed_overhead_expense: wip.unabsorbed_fixed_overhead, wip_reconciles: wip.reconciles },
      unverified: [] };
  }

  // -------------------------------------------------------------- valuation
  const VALUATION_METHOD_META = {
    dcf: { label: 'Discounted cash flow — FCFF (unlevered)', approach: 'income', subjects: ['entity', 'share', 'brand'] },
    fcfe: { label: 'Discounted cash flow — FCFE (levered)', approach: 'income', subjects: ['entity', 'share'] },
    income_capitalization: { label: 'Income capitalisation', approach: 'income', subjects: ['entity', 'share', 'land', 'machinery'] },
    market_comparable: { label: 'Comparable company analysis (trading multiples)', approach: 'market', subjects: ['entity', 'share', 'brand', 'land', 'machinery'] },
    precedent_transaction: { label: 'Precedent transaction analysis (deal multiples)', approach: 'market', subjects: ['entity', 'share'] },
    nav: { label: 'Net asset value', approach: 'asset', subjects: ['entity', 'share'] },
    liquidation: { label: 'Liquidation value', approach: 'asset', subjects: ['entity', 'share'] },
    relief_from_royalty: { label: 'Relief from royalty', approach: 'income', subjects: ['brand'] },
    excess_earnings: { label: 'Multi-period excess earnings (MEEM)', approach: 'income', subjects: ['brand'] },
    rcnld: { label: 'Replacement cost new less depreciation', approach: 'cost', subjects: ['machinery', 'land'] },
  };

  function valDcf(inputs) {
    const cfs = inputs.cashflows || [];
    const r = N(inputs.discount_rate_pct) / 100;
    const lines = []; let pvSum = 0;
    cfs.forEach((c, idx) => {
      const i = idx + 1, fcf = N(c.fcf);
      const disc = r ? 1 / Math.pow(1 + r, i) : 1;
      const pvv = fcf * disc; pvSum += pvv;
      lines.push({ year: c.year != null ? c.year : i, fcf: R2(fcf), discount_factor: Math.round(disc * 10000) / 10000, pv: R2(pvv) });
    });
    const n = cfs.length;
    const lastFcf = cfs.length ? N(cfs[cfs.length - 1].fcf) : 0;
    const method = inputs.terminal_method || 'gordon';
    let tv = 0;
    if (method === 'gordon') {
      const g = N(inputs.terminal_growth_pct) / 100;
      if (r > g) tv = lastFcf * (1 + g) / (r - g);
    } else {
      tv = N(inputs.exit_multiple) * N(inputs.exit_metric_value);
    }
    const pvTv = n ? tv / Math.pow(1 + r, n) : tv;
    const ev = pvSum + pvTv;
    const netDebt = N(inputs.net_debt), nonOp = N(inputs.non_operating_assets), minority = N(inputs.minority_interest);
    const equityValue = ev - netDebt + nonOp - minority;
    const shares = N(inputs.shares_outstanding);
    const perShare = shares ? equityValue / shares : null;
    return { lines, pv_of_cashflows: R2(pvSum), terminal_method: method, discount_rate_pct: R2(r * 100), terminal_value: R2(tv),
      pv_of_terminal_value: R2(pvTv), enterprise_value: R2(ev), net_debt: R2(netDebt),
      non_operating_assets: R2(nonOp), minority_interest: R2(minority), shares_outstanding: shares || null,
      value_per_share: perShare != null ? R2(perShare) : null, value: R2(equityValue) };
  }

  /** FCFE — levered cash flow, discounted at cost of equity, PV sums directly to equity value (no net-debt bridge). */
  function valFcfe(inputs) {
    const cfs = inputs.cashflows || [];
    const r = N(inputs.discount_rate_pct) / 100;
    const lines = []; let pvSum = 0;
    cfs.forEach((c, idx) => {
      const i = idx + 1, fcfe = N(c.fcf);
      const disc = r ? 1 / Math.pow(1 + r, i) : 1;
      const pvv = fcfe * disc; pvSum += pvv;
      lines.push({ year: c.year != null ? c.year : i, fcf: R2(fcfe), discount_factor: Math.round(disc * 10000) / 10000, pv: R2(pvv) });
    });
    const n = cfs.length;
    const lastFcfe = cfs.length ? N(cfs[cfs.length - 1].fcf) : 0;
    const method = inputs.terminal_method || 'gordon';
    let tv = 0;
    if (method === 'gordon') {
      const g = N(inputs.terminal_growth_pct) / 100;
      if (r > g) tv = lastFcfe * (1 + g) / (r - g);
    } else {
      tv = N(inputs.exit_multiple) * N(inputs.exit_metric_value);
    }
    const pvTv = n ? tv / Math.pow(1 + r, n) : tv;
    const nonOp = N(inputs.non_operating_assets), minority = N(inputs.minority_interest);
    const equityValue = pvSum + pvTv + nonOp - minority;
    const shares = N(inputs.shares_outstanding);
    const perShare = shares ? equityValue / shares : null;
    return { lines, pv_of_cashflows: R2(pvSum), terminal_method: method, discount_rate_pct: R2(r * 100),
      terminal_value: R2(tv), pv_of_terminal_value: R2(pvTv), non_operating_assets: R2(nonOp),
      minority_interest: R2(minority), shares_outstanding: shares || null,
      value_per_share: perShare != null ? R2(perShare) : null, value: R2(equityValue) };
  }

  /** 2-D discount-rate x terminal-growth (or cap-rate x growth) what-if grid — re-invokes calcFn with nudged inputs. */
  function valSensitivityGrid(calcFn, inputs, rateKey, rateDeltas, growthKey, growthDeltas) {
    const baseRate = N(inputs[rateKey]), baseGrowth = N(inputs[growthKey]);
    return rateDeltas.map(rd => {
      const cells = growthDeltas.map(gd => {
        const flexed = Object.assign({}, inputs); flexed[rateKey] = baseRate + rd; flexed[growthKey] = baseGrowth + gd;
        const out = calcFn(flexed);
        const cell = {}; cell[growthKey] = R2(baseGrowth + gd); cell.value = out.value;
        return cell;
      });
      const row = {}; row[rateKey] = R2(baseRate + rd); row.cells = cells;
      return row;
    });
  }

  function valDiscountRateBuilder(inputs) {
    const rf = N(inputs.risk_free_rate_pct), beta = inputs.beta != null && inputs.beta !== '' ? N(inputs.beta) : 1;
    const erp = N(inputs.equity_risk_premium_pct), sizePrem = N(inputs.size_premium_pct), specificPrem = N(inputs.specific_risk_premium_pct);
    const costOfEquity = rf + beta * erp + sizePrem + specificPrem;
    const costOfDebt = N(inputs.cost_of_debt_pct), taxRate = N(inputs.tax_rate_pct) / 100;
    const afterTaxCostOfDebt = costOfDebt * (1 - taxRate);
    const mvEquity = N(inputs.market_value_of_equity), mvDebt = N(inputs.market_value_of_debt);
    const total = mvEquity + mvDebt;
    const wEquity = total ? mvEquity / total : 1, wDebt = total ? mvDebt / total : 0;
    const wacc = wEquity * costOfEquity + wDebt * afterTaxCostOfDebt;
    return { risk_free_rate_pct: R2(rf), beta: R2(beta), equity_risk_premium_pct: R2(erp),
      size_premium_pct: R2(sizePrem), specific_risk_premium_pct: R2(specificPrem), cost_of_equity_pct: R2(costOfEquity),
      cost_of_debt_pct: R2(costOfDebt), tax_rate_pct: R2(taxRate * 100), after_tax_cost_of_debt_pct: R2(afterTaxCostOfDebt),
      market_value_of_equity: R2(mvEquity), market_value_of_debt: R2(mvDebt),
      weight_of_equity_pct: R2(wEquity * 100), weight_of_debt_pct: R2(wDebt * 100), wacc_pct: R2(wacc) };
  }

  function valReviewFlags(workingsOut, weightTotal, rangeLow, rangeHigh) {
    const flags = [];
    if (workingsOut.length === 1) flags.push({ severity: 'Medium', message: 'Only one method has been used. A single-method valuation is harder to defend — consider triangulating with at least one other approach (income, market or asset).' });
    if (workingsOut.length && !weightTotal) flags.push({ severity: 'High', message: 'No working carries a weight, so no value has been concluded — assign at least one weight in the Conclusion.' });
    if (rangeLow != null && rangeLow > 0 && rangeHigh != null && rangeHigh > rangeLow * 2) {
      flags.push({ severity: 'Medium', message: 'Values across workings range from ' + rangeLow.toLocaleString() + ' to ' + rangeHigh.toLocaleString() + ' — more than double — the conclusion should explain the spread.' });
    }
    workingsOut.forEach(w => {
      if (w.value !== null && w.value !== undefined && !w.fair_value_level) {
        flags.push({ severity: 'Low', message: "'" + w.label + "' has no NFRS 13 fair-value hierarchy level recorded." });
      }
      if ((w.method === 'dcf' || w.method === 'fcfe') && w.discount_rate_pct != null && (w.discount_rate_pct < 5 || w.discount_rate_pct > 30)) {
        flags.push({ severity: 'Medium', message: "'" + w.label + "': discount rate of " + w.discount_rate_pct.toFixed(1) + '% is outside the typical 5%-30% range used in practice — confirm the basis.' });
      }
      const pvTv = w.pv_of_terminal_value;
      if (pvTv != null && w.value) {
        const denom = w.value + (w.net_debt || 0) - (w.non_operating_assets || 0) + (w.minority_interest || 0);
        if (denom && Math.abs(pvTv) / Math.abs(denom) > 0.80) {
          flags.push({ severity: 'Medium', message: "'" + w.label + "': the terminal value makes up more than 80% of the total value — the conclusion depends heavily on the terminal assumption." });
        }
      }
    });
    return flags;
  }

  function valIncomeCapitalization(inputs) {
    const income = N(inputs.normalized_income);
    const capRate = N(inputs.cap_rate_pct) / 100, growth = N(inputs.growth_pct) / 100;
    const denom = capRate - growth;
    const grossValue = denom > 0 ? income / denom : 0;
    const netDebt = N(inputs.net_debt);
    const value = grossValue - netDebt;
    const shares = N(inputs.shares_outstanding);
    const perShare = shares ? value / shares : null;
    return { normalized_income: R2(income), cap_rate_pct: R2(capRate * 100), growth_pct: R2(growth * 100),
      gross_value: R2(grossValue), net_debt: R2(netDebt), shares_outstanding: shares || null,
      value_per_share: perShare != null ? R2(perShare) : null, value: R2(value) };
  }

  function valMarketComparable(inputs) {
    const comps = inputs.comparables || [];
    const compLines = [], adjustedValues = [];
    comps.forEach(c => {
      const base = N(c.value);
      const adjPct = (c.adjustments || []).reduce((s, a) => s + N(a.pct), 0) / 100;
      const adjusted = base * (1 + adjPct);
      compLines.push({ name: c.name || '', base_value: R2(base), adjustment_pct: R2(adjPct * 100), adjusted_value: R2(adjusted) });
      adjustedValues.push(adjusted);
    });
    const stat = inputs.selected_stat || 'median';
    let chosen;
    if (stat === 'custom') chosen = N(inputs.custom_value);
    else if (stat === 'mean' && adjustedValues.length) chosen = adjustedValues.reduce((s, v) => s + v, 0) / adjustedValues.length;
    else if (adjustedValues.length) {
      const s = adjustedValues.slice().sort((a, b) => a - b), mid = Math.floor(s.length / 2);
      chosen = s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
    } else chosen = 0;
    const subjectMetric = inputs.subject_metric != null && inputs.subject_metric !== '' ? N(inputs.subject_metric) : 1;
    const grossValue = chosen * subjectMetric;
    const deductions = (inputs.deductions || []).map(d => ({ desc: d.desc || '', amount: R2(d.amount) }));
    const dedTotal = deductions.reduce((s, d) => s + d.amount, 0);
    const premiums = (inputs.premiums || []).map(d => ({ desc: d.desc || '', amount: R2(d.amount) }));
    const premTotal = premiums.reduce((s, d) => s + d.amount, 0);
    const value = grossValue - dedTotal + premTotal;
    const shares = N(inputs.shares_outstanding);
    const perShare = shares ? value / shares : null;
    return { comparables: compLines, selected_stat: stat, chosen_multiple_or_rate: R2(chosen),
      subject_metric: subjectMetric, gross_value: R2(grossValue), deductions, deduction_total: R2(dedTotal),
      premiums, premium_total: R2(premTotal), shares_outstanding: shares || null,
      value_per_share: perShare != null ? R2(perShare) : null, value: R2(value) };
  }

  function valNav(inputs) {
    function lines(rows) {
      const out = []; let total = 0;
      (rows || []).forEach(x => {
        const book = N(x.book_value);
        const fv = x.fair_value != null && x.fair_value !== '' ? N(x.fair_value) : book;
        out.push({ desc: x.desc || '', book_value: R2(book), fair_value: R2(fv), adjustment: R2(fv - book) });
        total += fv;
      });
      return [out, total];
    }
    const [aLines, aTotal] = lines(inputs.assets);
    const [lLines, lTotal] = lines(inputs.liabilities);
    const value = aTotal - lTotal;
    const shares = N(inputs.shares_outstanding);
    const perShare = shares ? value / shares : null;
    return { assets: aLines, assets_total: R2(aTotal), liabilities: lLines, liabilities_total: R2(lTotal),
      shares_outstanding: shares || null, value_per_share: perShare != null ? R2(perShare) : null, value: R2(value) };
  }

  function valLiquidation(inputs) {
    const navValue = N(inputs.nav_value);
    const discount = N(inputs.forced_sale_discount_pct) / 100;
    const afterDiscount = navValue * (1 - discount);
    const costs = N(inputs.winding_up_costs), priority = N(inputs.priority_claims);
    const value = Math.max(0, afterDiscount - costs - priority);
    const shares = N(inputs.shares_outstanding);
    const perShare = shares ? value / shares : null;
    return { nav_value: R2(navValue), forced_sale_discount_pct: R2(discount * 100), value_after_discount: R2(afterDiscount),
      winding_up_costs: R2(costs), priority_claims: R2(priority), shares_outstanding: shares || null,
      value_per_share: perShare != null ? R2(perShare) : null, value: R2(value) };
  }

  function valReliefFromRoyalty(inputs) {
    const revs = inputs.revenue_projections || [];
    const royaltyRate = N(inputs.royalty_rate_pct) / 100, taxRate = N(inputs.tax_rate_pct) / 100;
    const r = N(inputs.discount_rate_pct) / 100;
    const lines = []; let pvSum = 0;
    revs.forEach((rr, idx) => {
      const i = idx + 1, revenue = N(rr.revenue);
      const royaltySaving = revenue * royaltyRate, afterTax = royaltySaving * (1 - taxRate);
      const disc = r ? 1 / Math.pow(1 + r, i) : 1;
      const pvv = afterTax * disc; pvSum += pvv;
      lines.push({ year: rr.year != null ? rr.year : i, revenue: R2(revenue), royalty_saving: R2(royaltySaving),
        after_tax_saving: R2(afterTax), pv: R2(pvv) });
    });
    const n = revs.length;
    const g = N(inputs.terminal_growth_pct) / 100;
    const lastSaving = lines.length ? lines[lines.length - 1].after_tax_saving : 0;
    const tv = r > g ? lastSaving * (1 + g) / (r - g) : 0;
    const pvTv = n ? tv / Math.pow(1 + r, n) : tv;
    let value = pvSum + pvTv;
    let tabApplied = false;
    if (inputs.tax_amortization_benefit) {
      const tabYears = parseInt(inputs.tab_years, 10) || 15;
      const pvAnnuity = r ? (1 - Math.pow(1 + r, -tabYears)) / r : tabYears;
      const denom = 1 - taxRate * pvAnnuity / tabYears;
      if (denom > 0) { value *= 1 / denom; tabApplied = true; }
    }
    return { lines, pv_of_royalty_savings: R2(pvSum), terminal_value: R2(tv), pv_of_terminal_value: R2(pvTv),
      tax_amortization_benefit_applied: tabApplied, value: R2(value) };
  }

  function valExcessEarnings(inputs) {
    const projs = inputs.projections || [];
    const charges = (inputs.contributory_asset_charges || []).map(c => ({ desc: c.desc || '', annual_charge: R2(c.annual_charge) }));
    const chargeTotal = charges.reduce((s, c) => s + c.annual_charge, 0);
    const taxRate = N(inputs.tax_rate_pct) / 100, r = N(inputs.discount_rate_pct) / 100;
    const lines = []; let pvSum = 0;
    projs.forEach((p, idx) => {
      const i = idx + 1, earnings = N(p.earnings);
      const afterTaxEarnings = earnings * (1 - taxRate);
      const excess = afterTaxEarnings - chargeTotal;
      const disc = r ? 1 / Math.pow(1 + r, i) : 1;
      const pvv = excess * disc; pvSum += pvv;
      lines.push({ year: p.year != null ? p.year : i, earnings: R2(earnings), after_tax_earnings: R2(afterTaxEarnings),
        contributory_charges: R2(chargeTotal), excess_earnings: R2(excess), pv: R2(pvv) });
    });
    const n = projs.length;
    const g = N(inputs.terminal_growth_pct) / 100;
    const last = lines.length ? lines[lines.length - 1].excess_earnings : 0;
    const tv = r > g ? last * (1 + g) / (r - g) : 0;
    const pvTv = n ? tv / Math.pow(1 + r, n) : tv;
    const value = pvSum + pvTv;
    return { lines, contributory_asset_charges: charges, pv_of_excess_earnings: R2(pvSum), terminal_value: R2(tv),
      pv_of_terminal_value: R2(pvTv), value: R2(value) };
  }

  function valRcnld(inputs) {
    const rcn = N(inputs.replacement_cost_new);
    const age = N(inputs.effective_age_years), remainingLife = N(inputs.remaining_useful_life_years);
    const totalLife = age + remainingLife;
    const override = inputs.physical_deterioration_pct_override;
    const physicalPct = (override != null && override !== '') ? N(override) / 100 : (totalLife > 0 ? age / totalLife : 0);
    const functionalPct = N(inputs.functional_obsolescence_pct) / 100, economicPct = N(inputs.economic_obsolescence_pct) / 100;
    const afterPhysical = rcn * (1 - physicalPct);
    const afterFunctional = afterPhysical * (1 - functionalPct);
    const value = Math.max(0, afterFunctional * (1 - economicPct));
    return { replacement_cost_new: R2(rcn), physical_deterioration_pct: R2(physicalPct * 100), value_after_physical: R2(afterPhysical),
      functional_obsolescence_pct: R2(functionalPct * 100), value_after_functional: R2(afterFunctional),
      economic_obsolescence_pct: R2(economicPct * 100), value: R2(value) };
  }

  const VALUATION_METHODS = {
    dcf: valDcf, fcfe: valFcfe, income_capitalization: valIncomeCapitalization, market_comparable: valMarketComparable,
    precedent_transaction: valMarketComparable, nav: valNav, liquidation: valLiquidation,
    relief_from_royalty: valReliefFromRoyalty, excess_earnings: valExcessEarnings, rcnld: valRcnld,
  };

  function valuation(fy, d) {
    const subject = d.subject || {};
    const workingsOut = [];
    (d.workings || []).forEach(w => {
      const calc = VALUATION_METHODS[w.method];
      if (!calc) return;
      const inputs = w.inputs || {};
      const result = calc(inputs);
      Object.assign(result, { id: w.id, method: w.method,
        label: w.label || (VALUATION_METHOD_META[w.method] && VALUATION_METHOD_META[w.method].label) || w.method,
        approach: VALUATION_METHOD_META[w.method] && VALUATION_METHOD_META[w.method].approach,
        fair_value_level: w.fair_value_level });
      if (w.method === 'dcf' || w.method === 'fcfe') {
        result.sensitivity_grid = valSensitivityGrid(calc, inputs, 'discount_rate_pct', [-2, -1, 0, 1, 2], 'terminal_growth_pct', [-1, 0, 1]);
      } else if (w.method === 'income_capitalization') {
        result.sensitivity_grid = valSensitivityGrid(calc, inputs, 'cap_rate_pct', [-2, -1, 0, 1, 2], 'growth_pct', [-1, 0, 1]);
      }
      workingsOut.push(result);
    });
    const weights = (d.conclusion || {}).weights || {};
    let weightedSum = 0, weightTotal = 0; const blend = [];
    workingsOut.forEach(w => {
      const wt = N(weights[w.id]);
      if (wt) { weightedSum += w.value * wt; weightTotal += wt; blend.push({ id: w.id, label: w.label, value: w.value, weight: wt }); }
    });
    const concludedValue = weightTotal ? weightedSum / weightTotal : null;
    const values = workingsOut.map(w => w.value);
    const rangeLow = values.length ? R2(Math.min(...values)) : null;
    const rangeHigh = values.length ? R2(Math.max(...values)) : null;
    const review = d.review || {};
    return { subject, workings: workingsOut,
      discount_rate_builder: d.discount_rate_builder ? valDiscountRateBuilder(d.discount_rate_builder) : null,
      conclusion: { blend, concluded_value: concludedValue != null ? R2(concludedValue) : null,
        range_low: rangeLow, range_high: rangeHigh, notes: (d.conclusion || {}).notes || '' },
      review: { flags: valReviewFlags(workingsOut, weightTotal, rangeLow, rangeHigh),
        reviewed_by: review.reviewed_by || '', review_date: review.review_date || '',
        key_assumptions_challenged: review.key_assumptions_challenged || '',
        independence_confirmed: !!review.independence_confirmed,
        review_conclusion: review.review_conclusion || '', review_status: review.review_status || 'pending' },
      unverified: [] };
  }

  // --------------------------------------------- fixed assets (accounts, NAS 16)
  // Mirrors server engines.py's fixed_assets() field-for-field. See that
  // module's docstring/comment for the full design note (user-defined asset
  // "types"/classes, SLM or WDV per class or per asset, day-exact AD pro-
  // ration, opening balances read from last year's closing figure).
  function faFyWindow(fy, fiscalYears) {
    const row = (fiscalYears || []).find(f => f.label_bs === fy);
    if (row && row.start_ad && row.end_ad) {
      const s = gD(row.start_ad), e = gD(row.end_ad);
      if (s && e) return [s, e];
    }
    const year = parseInt(String(fy || '').split('-')[0], 10) || new Date().getFullYear();
    return [new Date(Date.UTC(year, 6, 17)), new Date(Date.UTC(year + 1, 6, 16))];
  }

  function faOv(asset, cls, key, dflt) {
    let v = asset[key];
    if (v === null || v === undefined || v === '') v = cls[key];
    if (v === null || v === undefined || v === '') v = dflt;
    return v;
  }

  function faProrate(fyStart, fyEnd, acqDate, dispDate) {
    const start = (acqDate && acqDate > fyStart) ? acqDate : fyStart;
    const end = (dispDate && dispDate < fyEnd) ? dispDate : fyEnd;
    const totalDays = gDays(fyStart, fyEnd) + 1;
    if (end < start) return { factor: 0, start: null, end: null, totalDays };
    const usedDays = gDays(start, end) + 1;
    return { factor: usedDays / totalDays, start, end, totalDays };
  }

  function faAssetCharge(asset, cls, fyStart, fyEnd) {
    const method = String(faOv(asset, cls, 'method', 'wdv') || 'wdv').toLowerCase();
    const status = asset.status || (asset.acquisition_date ? 'addition' : 'existing');
    const acq = gD(asset.acquisition_date);
    const disp = gD(asset.disposal_date);
    const biz = (N(asset.business_use_pct) || 100) / 100;

    const pr = faProrate(fyStart, fyEnd, status === 'addition' ? acq : null, disp);
    const factor = pr.factor;

    const cost = N(asset.cost) + N(asset.additional_cost);
    const hasKnownCost = status === 'addition' || !!asset.cost;

    let openingNbv, base;
    if (status === 'addition') { openingNbv = 0; base = cost; }
    else { openingNbv = N(asset.opening_nbv); base = openingNbv; }

    let charge = 0, residualFloor = 0;
    if (factor > 0) {
      if (method === 'wdv') {
        const rate = N(faOv(asset, cls, 'rate_pct', 0)) / 100;
        charge = base * rate * factor;
      } else {
        let annual;
        if (status === 'addition') {
          const life = N(faOv(asset, cls, 'useful_life_years', 0));
          const residualPct = N(faOv(asset, cls, 'residual_pct', 0)) / 100;
          residualFloor = cost * residualPct;
          annual = life ? (cost - residualFloor) / life : 0;
        } else {
          const life = N(asset.opening_nbv_remaining_life_years);
          residualFloor = N(asset.opening_nbv_residual_value);
          annual = life ? (openingNbv - residualFloor) / life : 0;
        }
        charge = annual * factor;
      }
      charge = charge * biz;
      charge = Math.max(0, Math.min(charge, base - residualFloor));
    }

    const nbvBefore = base;
    const nbvAfter = nbvBefore - charge;

    let disposalGainLoss = null, disposalProceeds = null;
    if (disp && disp >= fyStart && disp <= fyEnd) {
      disposalProceeds = R2(N(asset.disposal_proceeds));
      disposalGainLoss = R2(disposalProceeds - nbvAfter);
    }

    return {
      code: asset.code || '', name: asset.name || '', status, method,
      acquisition_date: asset.acquisition_date || null, disposal_date: asset.disposal_date || null,
      cost: hasKnownCost ? R2(cost) : null,
      opening_nbv: R2(nbvBefore), depreciation: R2(charge), closing_nbv: R2(Math.max(0, nbvAfter)),
      days_in_use: (pr.start && pr.end) ? (gDays(pr.start, pr.end) + 1) : 0,
      proration_factor: Math.round(factor * 10000) / 10000,
      disposal_proceeds: disposalProceeds, gain_loss_on_disposal: disposalGainLoss,
      has_known_cost: hasKnownCost,
    };
  }

  function faClass(cls, fyStart, fyEnd) {
    const key = cls.key || 'class';
    const label = cls.label || key;
    const assetsOut = [];

    const blockNbv = N(cls.opening_block_nbv);
    if (blockNbv) {
      const blockAsset = {
        code: 'UNALLOC', name: 'Unallocated opening balance (not broken into individual assets)',
        status: 'existing', opening_nbv: blockNbv,
        opening_nbv_remaining_life_years: cls.opening_block_remaining_life_years,
        opening_nbv_residual_value: cls.opening_block_residual_value,
        disposal_date: cls.opening_block_disposal_date,
        disposal_proceeds: cls.opening_block_disposal_proceeds,
      };
      assetsOut.push(faAssetCharge(blockAsset, cls, fyStart, fyEnd));
    }

    (cls.assets || []).forEach(a => assetsOut.push(faAssetCharge(a, cls, fyStart, fyEnd)));

    const totals = { opening_nbv: 0, additions_cost: 0, depreciation: 0, closing_nbv: 0, disposal_proceeds: 0, gain_loss: 0 };
    assetsOut.forEach(r => {
      if (r.status === 'existing') totals.opening_nbv += r.opening_nbv;
      else if (r.cost) totals.additions_cost += r.cost;
      totals.depreciation += r.depreciation;
      totals.closing_nbv += r.closing_nbv;
      if (r.gain_loss_on_disposal !== null) {
        totals.disposal_proceeds += r.disposal_proceeds || 0;
        totals.gain_loss += r.gain_loss_on_disposal;
      }
    });

    const totalsR = {};
    Object.keys(totals).forEach(k => { totalsR[k] = R2(totals[k]); });

    return { key, label, method: cls.method || 'wdv',
      rate_pct: cls.rate_pct == null ? null : cls.rate_pct,
      useful_life_years: cls.useful_life_years == null ? null : cls.useful_life_years,
      residual_pct: cls.residual_pct == null ? null : cls.residual_pct,
      assets: assetsOut, totals: totalsR };
  }

  function faJournals(classesOut, fyLabel) {
    const journals = [];
    classesOut.forEach(c => {
      const label = c.label;
      c.assets.forEach(a => {
        if (a.status === 'addition' && a.cost) {
          journals.push({
            period: a.acquisition_date || fyLabel,
            narration: 'Addition — ' + label + ': ' + (a.name || a.code),
            debit: [{ account: label + ' (at cost)', amount: a.cost }],
            credit: [{ account: 'Bank / accounts payable', amount: a.cost }],
          });
        }
      });
      if (c.totals.depreciation) {
        journals.push({
          period: fyLabel, narration: 'Depreciation charge for the year — ' + label,
          debit: [{ account: 'Depreciation expense — ' + label, amount: c.totals.depreciation }],
          credit: [{ account: 'Accumulated depreciation — ' + label, amount: c.totals.depreciation }],
        });
      }
      c.assets.forEach(a => {
        if (a.gain_loss_on_disposal === null) return;
        const proceeds = a.disposal_proceeds || 0;
        const nbvAtDisposal = a.closing_nbv;
        const gl = a.gain_loss_on_disposal;
        let debit, credit;
        if (a.has_known_cost && a.cost) {
          const accumDep = R2(a.cost - nbvAtDisposal);
          debit = [{ account: 'Bank / receivable — disposal proceeds', amount: proceeds },
                   { account: 'Accumulated depreciation — ' + label, amount: accumDep }];
          credit = [{ account: label + ' (at cost)', amount: a.cost }];
        } else {
          debit = [{ account: 'Bank / receivable — disposal proceeds', amount: proceeds }];
          credit = [{ account: label + ' (net book value)', amount: nbvAtDisposal }];
        }
        if (gl > 0) credit.push({ account: 'Gain on disposal of asset', amount: gl });
        else if (gl < 0) debit.push({ account: 'Loss on disposal of asset', amount: -gl });
        journals.push({
          period: a.disposal_date || fyLabel,
          narration: 'Disposal — ' + label + ': ' + (a.name || a.code),
          debit, credit,
        });
      });
    });
    return journals;
  }

  function fixedAssets(fy, d) {
    const [fyStart, fyEnd] = faFyWindow(fy, d.fiscal_years);
    const classesOut = (d.classes || []).map(c => faClass(c, fyStart, fyEnd));
    const journals = faJournals(classesOut, fy);
    const sum = k => R2(classesOut.reduce((s, c) => s + (c.totals[k] || 0), 0));
    const summary = {
      opening_nbv: sum('opening_nbv'), additions_cost: sum('additions_cost'),
      depreciation: sum('depreciation'), closing_nbv: sum('closing_nbv'),
      disposal_proceeds: sum('disposal_proceeds'), gain_loss_on_disposal: sum('gain_loss'),
    };
    return { entity: d.entity || {}, fy_start: gIso(fyStart), fy_end: gIso(fyEnd),
      classes: classesOut, journals, summary, unverified: [] };
  }

  // ------------------------------------------------------ internal audit
  // Mirrors server/engines.py internal_audit() — the criterion bank comes
  // from d.audit_criteria_bank (the whole app_boot.audit_criteria array,
  // cached offline like everything else this module needs) rather than a
  // DB query, since there's no DB in the browser.
  function auditCriteriaFor(allCriteria, entityType) {
    const rows = (allCriteria || []).slice().sort((a, b) =>
      (a.sort_order - b.sort_order) || String(a.area).localeCompare(String(b.area)));
    const out = [];
    rows.forEach(r => {
      if (r.applies_to === 'all') { out.push(Object.assign({}, r, { applies_to: 'all' })); return; }
      if (entityType) {
        let names = [];
        try { names = JSON.parse(r.applies_to); } catch (e) { names = []; }
        if (names.includes(entityType)) out.push(Object.assign({}, r, { applies_to: names }));
      }
    });
    return out;
  }

  function internalAudit(fy, d) {
    const entity = d.entity || {};
    const entityType = entity.entity_type || '';
    const lookupType = (entityType === '' || entityType === 'Other') ? null : entityType;
    const criteria = auditCriteriaFor(d.audit_criteria_bank, lookupType);
    const responses = d.criteria_responses || {};

    const byArea = {};
    let total = 0, tested = 0, exceptions = 0, notApplicable = 0;
    criteria.forEach(c => {
      total++;
      const resp = responses[String(c.id)] || {};
      const status = resp.status || 'not_tested';
      if (status !== 'not_tested') tested++;
      if (status === 'exception') exceptions++;
      if (status === 'not_applicable') notApplicable++;
      const row = Object.assign({}, c, { status, evidence: resp.evidence || '', remark: resp.remark || '' });
      ['procedure', 'sample_basis', 'wp_ref', 'performed_by', 'result'].forEach(k => { row[k] = resp[k] || ''; });
      (byArea[c.area] = byArea[c.area] || []).push(row);
    });

    const findings = d.findings || [];
    const findingsByRisk = { high: 0, medium: 0, low: 0 };
    let openFindings = 0;
    findings.forEach(f => {
      const r = (f.risk_rating || 'low').toLowerCase();
      if (findingsByRisk[r] !== undefined) findingsByRisk[r]++;
      if ((f.status || 'open') === 'open') openFindings++;
    });

    const testable = total - notApplicable;
    const completionPct = testable ? R2(100 * tested / testable) : 0;

    // Risk-Based Internal Audit Generator — likelihood x impact, 1-5 scale.
    const riskUniverse = (d.risk_universe || []).map(row => {
      const likelihood = Number(row.likelihood) || 0, impact = Number(row.impact) || 0;
      const score = likelihood * impact;
      const priority = score >= 15 ? 'High' : (score >= 8 ? 'Medium' : 'Low');
      return { process: row.process || '', likelihood, impact, score, priority, notes: row.notes || '' };
    }).sort((a, b) => b.score - a.score);

    // Process Risk & Control Matrix / RACM Generator + Control Gap Analyzer.
    const GAP_STATES = ['Partially Effective', 'Ineffective'];
    const racm = (d.racm || []).map(row => {
      const design = row.design_effectiveness || 'Not Assessed', operating = row.operating_effectiveness || 'Not Assessed';
      const hasGap = GAP_STATES.indexOf(design) !== -1 || GAP_STATES.indexOf(operating) !== -1;
      return Object.assign({}, row, { design_effectiveness: design, operating_effectiveness: operating, has_gap: hasGap });
    });
    const controlGaps = racm.filter(r => r.has_gap);

    return {
      entity, planning: d.planning || {},
      areas: Object.keys(byArea).map(area => ({ area, criteria: byArea[area] })),
      risk_universe: riskUniverse, racm, control_gaps: controlGaps,
      fraud_risk: d.fraud_risk || {}, walkthroughs: d.walkthroughs || [], follow_ups: d.follow_ups || [],
      findings,
      summary: {
        total_criteria: total, tested_criteria: tested, not_applicable_criteria: notApplicable,
        completion_pct: completionPct, exceptions_count: exceptions,
        findings_total: findings.length, findings_open: openFindings, findings_by_risk: findingsByRisk,
        control_gaps_count: controlGaps.length,
      },
      conclusion: d.conclusion || {}, signatory: d.signatory || {}, unverified: [],
    };
  }

  // -------------------------------------------------- statutory audit (Phase 2)
  // Mirrors server/engines.py's _sa_* helpers and statutory_audit() exactly —
  // wraps internalAudit() as its base, adds materiality/sample-size/risk on top.
  const SA_RISK_MATRIX = {
    'high,high': 'high', 'high,medium': 'high', 'high,low': 'medium',
    'medium,high': 'high', 'medium,medium': 'medium', 'medium,low': 'low',
    'low,high': 'medium', 'low,medium': 'low', 'low,low': 'low',
  };
  const SA_DETECTION_FOR_COMBINED = { high: 'low', medium: 'medium', low: 'high' };
  const SA_RISK_FACTOR = { high: 3, medium: 2, low: 1 };

  function saMateriality(m) {
    m = m || {};
    const benchmarkValue = Number(m.benchmark_value) || 0;
    const materialityPct = (Number(m.materiality_pct) || 0) / 100;
    const overall = R2(benchmarkValue * materialityPct);
    const pmPct = (m.pm_pct != null ? Number(m.pm_pct) : 75) / 100;
    const performanceMateriality = R2(overall * pmPct);
    const ctPct = (m.ct_pct != null ? Number(m.ct_pct) : 5) / 100;
    const clearlyTrivial = R2(performanceMateriality * ctPct);
    return {
      benchmark: m.benchmark || '', benchmark_value: benchmarkValue,
      materiality_pct: R2(materialityPct * 100), overall_materiality: overall,
      pm_pct: R2(pmPct * 100), performance_materiality: performanceMateriality,
      ct_pct: R2(ctPct * 100), clearly_trivial: clearlyTrivial,
    };
  }

  function saSampleSize(s, performanceMateriality) {
    s = s || {};
    const populationValue = Number(s.population_value) || 0;
    const populationCount = Number(s.population_count) || 0;
    const tolerable = (s.tolerable_misstatement !== undefined && s.tolerable_misstatement !== null && s.tolerable_misstatement !== '')
      ? Number(s.tolerable_misstatement) : performanceMateriality;
    const expected = Number(s.expected_misstatement) || 0;
    const riskLevel = (s.risk_level || 'medium').toLowerCase();
    const riskFactor = SA_RISK_FACTOR[riskLevel] || 2;
    const netTolerable = tolerable ? Math.max(tolerable - expected, tolerable * 0.1) : 0;
    const samplingInterval = (netTolerable && riskFactor) ? R2(netTolerable / riskFactor) : 0;
    let sampleSizeValue = (samplingInterval && populationValue) ? Math.floor(populationValue / samplingInterval) + 1 : 0;
    const sampleSize = populationCount ? Math.min(sampleSizeValue, Math.floor(populationCount)) : sampleSizeValue;
    return {
      population_value: populationValue, population_count: populationCount,
      tolerable_misstatement: R2(tolerable), expected_misstatement: R2(expected),
      risk_level: riskLevel, risk_factor: riskFactor,
      sampling_interval: samplingInterval, sample_size: sampleSize,
      note: 'Judgmental monetary-unit-style estimate for planning purposes, not a substitute for a ' +
            'statistical sampling standard or dedicated sampling software.',
    };
  }

  function saRiskAssessment(raData, areas) {
    raData = raData || {};
    return areas.map(a => {
      const entry = raData[a.area] || {};
      const ir = (entry.inherent || 'medium').toLowerCase();
      const cr = (entry.control || 'medium').toLowerCase();
      const combined = SA_RISK_MATRIX[ir + ',' + cr] || 'medium';
      return { area: a.area, inherent: ir, control: cr, combined_risk: combined,
        detection_risk_required: SA_DETECTION_FOR_COMBINED[combined], rationale: entry.rationale || '' };
    });
  }

  function statutoryAudit(fy, d) {
    const base = internalAudit(fy, d);
    const materiality = saMateriality(d.materiality);
    base.materiality = materiality;
    base.sample_size = saSampleSize(d.sample_size, materiality.performance_materiality);
    base.risk_assessment = saRiskAssessment(d.risk_assessment, base.areas);
    return base;
  }

  // -------------------------------------------------------- TDS / withholding
  // Mirrors server/engines.py's tds_deduction() exactly.
  function tdsDeduction(rates, fy, d) {
    const tds = rateSet(rates, 'tds', fy);
    const payroll = rateSet(rates, 'payroll', fy);
    let categories = dynamicCategories(tds.rates, 'tds_rate_');
    const rentRow = payroll.rates.rent_tds_rate;
    if (rentRow) categories = [{ code: 'rent_tds_rate', slug: 'rent', label: rentRow.label, row: rentRow }].concat(categories);
    const catBySlug = {};
    categories.forEach(c => { catBySlug[c.slug] = c; });

    const lines = [];
    const byCategory = {};
    let totalGross = 0, totalTds = 0, totalNet = 0;
    (d.entries || []).forEach(e => {
      const slug = e.category || '';
      const cat = catBySlug[slug];
      let rate;
      if (e.rate_override !== undefined && e.rate_override !== null && e.rate_override !== '') rate = Number(e.rate_override) / 100;
      else if (cat && cat.row.value_num != null) rate = Number(cat.row.value_num);
      else rate = 0;
      const gross = Number(e.gross_amount) || 0;
      const tdsAmt = R2(gross * rate);
      const net = R2(gross - tdsAmt);
      const label = cat ? cat.label : (slug || 'Uncategorised');
      lines.push({
        payee_name: e.payee_name || '', payee_pan: e.payee_pan || '', payment_date: e.payment_date || '',
        category: slug, category_label: label, description: e.description || '', gross_amount: R2(gross),
        rate: R2(rate * 100), tds_amount: tdsAmt, net_amount: net, deposited: !!e.deposited,
        deposit_date: e.deposit_date || '', deposit_ref: e.deposit_ref || '',
      });
      totalGross += gross; totalTds += tdsAmt; totalNet += net;
      const agg = byCategory[slug] || (byCategory[slug] = { label, gross: 0, tds: 0 });
      agg.gross += gross; agg.tds += tdsAmt;
    });

    const depositedTds = R2(lines.filter(l => l.deposited).reduce((s, l) => s + l.tds_amount, 0));
    const byPayee = {};
    lines.forEach(l => {
      const key = l.payee_name + '' + l.payee_pan;
      (byPayee[key] = byPayee[key] || { payee_name: l.payee_name, payee_pan: l.payee_pan, lines: [] }).lines.push(l);
    });

    return {
      entries: lines,
      categories: categories.map(c => ({ slug: c.slug, label: c.label, rate: R2((c.row.value_num || 0) * 100) })),
      by_payee: Object.keys(byPayee).map(k => {
        const g = byPayee[k];
        return { payee_name: g.payee_name, payee_pan: g.payee_pan, lines: g.lines,
          gross: R2(g.lines.reduce((s, x) => s + x.gross_amount, 0)), tds: R2(g.lines.reduce((s, x) => s + x.tds_amount, 0)) };
      }),
      summary: {
        total_gross: R2(totalGross), total_tds: R2(totalTds), total_net: R2(totalNet),
        deposited_tds: depositedTds, pending_tds: R2(totalTds - depositedTds),
        by_category: Object.keys(byCategory).map(k => ({ category: k, label: byCategory[k].label,
          gross: R2(byCategory[k].gross), tds: R2(byCategory[k].tds) })),
      },
      signatory: d.signatory || {},
      unverified: Array.from(new Set(tds.unverified.concat(payroll.unverified))).sort(),
    };
  }

  const OPINION_TYPES = { unqualified: 'Unqualified opinion', qualified: 'Qualified opinion',
    adverse: 'Adverse opinion', disclaimer: 'Disclaimer of opinion' };

  function auditOpinionReport(d) {
    let opinionType = d.opinion_type || 'unqualified';
    if (!OPINION_TYPES[opinionType]) opinionType = 'unqualified';
    return {
      entity: d.entity || {}, opinion_type: opinionType, opinion_type_label: OPINION_TYPES[opinionType],
      basis_paragraph: d.basis_paragraph || '', opinion_paragraph: d.opinion_paragraph || '',
      has_eom: !!d.eom_paragraph, eom_paragraph: d.eom_paragraph || '',
      has_other_matter: !!d.other_matter_paragraph, other_matter_paragraph: d.other_matter_paragraph || '',
      has_kam: !!(d.kam_paragraphs && d.kam_paragraphs.length), kam_paragraphs: d.kam_paragraphs || [],
      responsibilities_management: d.responsibilities_management || '', responsibilities_auditor: d.responsibilities_auditor || '',
      signatory: d.signatory || {}, unverified: [],
    };
  }

  // Blocked-input categories under VAT Act 2052 Rule 41(1) — statutory text,
  // not a fiscal-year figure, so this stays a code constant, same as
  // fin_statements' HEADS list.
  const VAT_BLOCKED_CATEGORIES = [
    "Passenger vehicle purchase, lease or hire (unless dealing in or renting such vehicles is the registrant's own business)",
    'Spare parts, repair and fuel for a blocked passenger vehicle',
    'Entertainment expenses',
    'Goods or services acquired for a residential or other non-business use',
    'Goods lost, stolen, destroyed, or given away as a gift or free sample',
    'Any other expenditure specifically barred from input credit under Rule 41',
  ];

  function vatReturn(rates, fy, d) {
    const vat = rateSet(rates, 'vat', fy);
    const rate = N((vat.rates.standard_rate || {}).value_num);

    const sales = [];
    let taxableSales = 0, zeroRatedSales = 0, exemptSales = 0;
    (d.sales || []).forEach(s => {
      const t = N(s.taxable_amount), z = N(s.zero_rated_amount), e = N(s.exempt_amount);
      sales.push({ date: s.date || '', party: s.party || '', desc: s.desc || '',
        taxable_amount: R2(t), zero_rated_amount: R2(z), exempt_amount: R2(e), vat_amount: R2(t * rate) });
      taxableSales += t; zeroRatedSales += z; exemptSales += e;
    });

    const purchases = [];
    let creditablePurchases = 0, blockedPurchases = 0, capitalPurchases = 0;
    (d.purchases || []).forEach(p => {
      const t = N(p.taxable_amount);
      const blocked = !!p.blocked, capital = !!p.capital_goods;
      purchases.push({ date: p.date || '', party: p.party || '', desc: p.desc || '',
        taxable_amount: R2(t), blocked, capital_goods: capital, vat_amount: R2(t * rate) });
      if (blocked) blockedPurchases += t;
      else { creditablePurchases += t; if (capital) capitalPurchases += t; }
    });

    const creditNotesIssued = N(d.credit_notes_issued), debitNotesIssued = N(d.debit_notes_issued);
    const creditNotesReceived = N(d.credit_notes_received), debitNotesReceived = N(d.debit_notes_received);
    const openingCredit = N(d.opening_credit);

    const netTaxableSales = taxableSales - creditNotesIssued + debitNotesIssued;
    const outputVat = R2(netTaxableSales * rate);

    const netCreditablePurchases = creditablePurchases - creditNotesReceived + debitNotesReceived;
    const inputVatGross = R2(netCreditablePurchases * rate);

    const mixedSupply = !!d.mixed_supply;
    const totalSupply = taxableSales + zeroRatedSales + exemptSales;
    const ratio = (mixedSupply && totalSupply > 0) ? (taxableSales + zeroRatedSales) / totalSupply : 1.0;
    const eligibleInputVat = R2(inputVatGross * ratio);
    const restrictedInputVat = R2(inputVatGross - eligibleInputVat);

    const totalCreditAvailable = R2(eligibleInputVat + openingCredit);
    let payable, creditCarriedForward;
    if (outputVat >= totalCreditAvailable) { payable = R2(outputVat - totalCreditAvailable); creditCarriedForward = 0; }
    else { payable = 0; creditCarriedForward = R2(totalCreditAvailable - outputVat); }

    return {
      entity: d.entity || {},
      rate: R2(rate * 100),
      sales, purchases,
      totals: {
        taxable_sales: R2(taxableSales), zero_rated_sales: R2(zeroRatedSales), exempt_sales: R2(exemptSales),
        credit_notes_issued: R2(creditNotesIssued), debit_notes_issued: R2(debitNotesIssued),
        net_taxable_sales: R2(netTaxableSales), output_vat: outputVat,
        creditable_purchases: R2(creditablePurchases), blocked_purchases: R2(blockedPurchases),
        capital_purchases: R2(capitalPurchases),
        credit_notes_received: R2(creditNotesReceived), debit_notes_received: R2(debitNotesReceived),
        net_creditable_purchases: R2(netCreditablePurchases), input_vat_gross: inputVatGross,
        mixed_supply: mixedSupply, apportionment_ratio: R2(ratio * 100),
        eligible_input_vat: eligibleInputVat, restricted_input_vat: restrictedInputVat,
        opening_credit: R2(openingCredit), total_credit_available: totalCreditAvailable,
        payable, credit_carried_forward: creditCarriedForward,
      },
      blocked_categories: VAT_BLOCKED_CATEGORIES,
      signatory: d.signatory || {},
      unverified: vat.unverified,
    };
  }

  // -------------------------------------------------------------- UAE-specific
  // uaeCorporateTax needs no new mechanic — it's personalTax()'s own
  // marginal-slab computation against a 'company'-scoped income_tax seed
  // (0% to AED 375,000, 9% above). Mirrors the uae_corporate_tax lambda in
  // server/engines.py's ENGINES dict.
  function uaeCorporateTax(rates, fy, d) {
    return personalTax(rates, fy, 'company', d.taxable_income, d.opts || {});
  }

  // Mirrors server/uae_engines.py's uae_penalties() — Cabinet Decision
  // 49/2021 (late-payment mechanics as revised by 108/2021) fixed-fee /
  // capped-percentage administrative penalties. See that function's own
  // docstring for the data shape and the late-payment formula.
  function uaePenalties(rates, fy, d) {
    const { rates: R, unverified } = rateSet(rates, 'penalty', fy);
    const lines = [];
    const add = (head, basis, amount) => { if (amount) lines.push({ head, basis, amount: R2(amount) }); };

    if (d.late_registration) add('Late VAT registration', 'Flat fee', val(R, 'late_registration_fee', 0) || 0);
    if (d.late_deregistration) add('Late VAT deregistration', 'Flat fee', val(R, 'late_deregistration_fee', 0) || 0);
    if (d.late_return_filed) {
      const feeCode = d.late_return_is_repeat ? 'late_return_fee_repeat' : 'late_return_fee_first';
      const head = 'Late return filing' + (d.late_return_is_repeat ? ' (repeat within 24 months)' : ' (first occurrence)');
      add(head, 'Flat fee', val(R, feeCode, 0) || 0);
    }
    if (d.records_not_kept) {
      const feeCode = d.records_repeat_violation ? 'record_keeping_fee_repeat' : 'record_keeping_fee_first';
      const head = 'Failure to keep required records' + (d.records_repeat_violation ? ' (repeat)' : ' (first occurrence)');
      add(head, 'Flat fee', val(R, feeCode, 0) || 0);
    }

    const unpaidTax = N(d.unpaid_tax);
    const monthsLate = Math.max(0, Math.trunc(N(d.months_since_due_date)));
    if (unpaidTax > 0 && monthsLate > 0) {
      const immediatePct = val(R, 'late_payment_immediate_pct', 0) || 0;
      const monthlyPct = val(R, 'late_payment_monthly_pct', 0) || 0;
      const capPct = val(R, 'late_payment_cap_pct', 0) || 0;
      const rawPct = immediatePct + monthlyPct * monthsLate;
      const appliedPct = capPct ? Math.min(rawPct, capPct) : rawPct;
      const amount = unpaidTax * appliedPct;
      add('Late payment of tax',
        unpaidTax.toLocaleString() + ' unpaid x ' + (appliedPct * 100).toFixed(0) + '% ('
        + (immediatePct * 100).toFixed(0) + '% immediate + ' + (monthlyPct * 100).toFixed(0) + '%/month x '
        + monthsLate + ' month(s)' + ((capPct && rawPct > capPct) ? ', capped at ' + (capPct * 100).toFixed(0) + '%' : '') + ')',
        amount);
    }

    return { lines, total: R2(lines.reduce((s, l) => s + l.amount, 0)), unverified };
  }

  const ENGINES = {
    salary_tds: (rates, fy, d) => ({ employees: (d.employees || []).map(e => salaryTds(rates, fy, e)) }),
    depreciation: (rates, fy, d) => depreciation(rates, fy, d.pools || [], d.opts),
    biz_tax: (rates, fy, d) => businessTax(rates, fy, d),
    penalty: (rates, fy, d) => penalty(rates, fy, d),
    emi: (rates, fy, d) => loanSchedule(d),
    lease: (rates, fy, d) => leaseSchedule(d, rates, fy),
    fin_statements: (rates, fy, d) => finStatements(fy, d),
    grant: (rates, fy, d) => grantAccounting(d),
    projection: (rates, fy, d) => projection(fy, d),
    dd: (rates, fy, d) => ddWorkbench(rates, fy, d),
    capital_advisory: (rates, fy, d) => capitalAdvisory(fy, d),
    fdi: (rates, fy, d) => fdiEngine(fy, d),
    valuation: (rates, fy, d) => valuation(fy, d),
    inventory: (rates, fy, d) => inventoryValuation(fy, d),
    fixed_assets: (rates, fy, d) => fixedAssets(fy, d),
    internal_audit: (rates, fy, d) => internalAudit(fy, d),
    stat_audit: (rates, fy, d) => statutoryAudit(fy, d),
    audit_report: (rates, fy, d) => auditOpinionReport(d),
    tds: (rates, fy, d) => tdsDeduction(rates, fy, d),
    personal_tax: (rates, fy, d) => personalIncomeTax(rates, fy, d),
    vat: (rates, fy, d) => vatReturn(rates, fy, d),
    uae_corporate_tax: (rates, fy, d) => uaeCorporateTax(rates, fy, d),
    uae_penalties: (rates, fy, d) => uaePenalties(rates, fy, d)
  };

  global.CAEngines = {
    compute(moduleKey, rates, fy, data) {
      const fn = ENGINES[moduleKey];
      if (!fn) throw new Error('No engine for module ' + moduleKey);
      return fn(rates || [], fy, data || {});
    },
    unverifiedOf(moduleKey, result) {
      if (moduleKey === 'salary_tds') {
        const s = new Set();
        (result.employees || []).forEach(e => (e.unverified || []).forEach(u => s.add(u)));
        return Array.from(s);
      }
      return result.unverified || [];
    },
    FS_HEADS: () => FS_HEADS,
    VALUATION_METHOD_META,
    OPINION_TYPES,
    suggestFsHead: autoHeadFor,
    bsApprox, fyLabelForAd, adFromBs,
    R2
  };
})(window);
