"""
CA Automator — calculation engines.

Design rule: no engine contains a statutory number. Every rate, slab, ceiling and
percentage is fetched from rate_master and returned alongside the result, together
with the list of codes that are still UNVERIFIED. The UI and the generated reports
print that list, so a computation can never quietly rest on an unchecked rate.

The same arithmetic is mirrored in the browser (app.js inside index.html) so the
modules work offline; server-side is authoritative on sync and for report output.
"""
import math
import re
from datetime import date, timedelta

import country_service
import db
import fiscal_year_engine
import india_engines
import uae_engines
import nepal_calendar
from db import rate_dict

R2 = lambda x: round(float(x or 0), 2)


def _bs_label(ad_date):
    """Bikram Sambat display label for ad_date — only meaningful for a
    Nepal-jurisdiction firm. A non-Nepal firm gets None rather than a BS
    date that has nothing to do with its own calendar; every caller here
    already treats a missing *_bs_approx field as optional."""
    if country_service.current_country_code() != "NP":
        return None
    return nepal_calendar.bs_approx(ad_date)["label"]


# --------------------------------------------------------------------- helpers

def _val(rates, code, default=None):
    r = rates.get(code)
    if not r:
        return default
    return r["value_num"] if r["value_num"] is not None else default


def _dynamic_slabs(rates, prefix):
    """
    Discover every '<prefix>_<n>_ceiling' row present in `rates`, in ascending
    order of n. Adding, removing or re-numbering a slab in Rate Master — a new
    top bracket in a Finance Act, a slab dropped — changes the computation with
    no code change, instead of the slab count being fixed in the engine.
    """
    pat = re.compile(r'^' + re.escape(prefix) + r'_(\d+)_ceiling$')
    found = []
    for code, row in rates.items():
        m = pat.match(code)
        if m:
            found.append((int(m.group(1)), row))
    found.sort(key=lambda t: t[0])
    return [row for _, row in found]


def _dynamic_categories(rates, prefix):
    """
    Discover every '<prefix><slug>' row present in `rates` — a set of named
    alternatives (company rate categories, presumptive-tax area classes) rather
    than a marginal slab ladder. Returns [{code, slug, label, rate_row}], sorted
    by label, so the UI's picker and the engine's lookup both come from
    whatever rows exist in Rate Master rather than a fixed enum in code.
    """
    out = []
    for code, row in rates.items():
        if code.startswith(prefix) and code != prefix:
            out.append({"code": code, "slug": code[len(prefix):], "label": row["label"], "row": row})
    out.sort(key=lambda c: c["label"])
    return out


# ------------------------------------------------------- 1. PERSONAL INCOME TAX

def personal_tax(fy, status, taxable_income, opts=None):
    """
    status: 'individual' | 'couple'
    opts: {'is_resident_woman': bool, 'ssf_contributor': bool, 'is_senior_citizen': bool}
    Slabs are read as (ceiling, rate) pairs from rate_master, in code order.

    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. Slab 1's
    rate is overridden from income_tax.ssf_rebate_slab1 when that row exists
    for this status (seeded for 'individual' only; add a 'couple' row in the
    rate master once the firm has a citation for the couple-filing case).

    Senior citizens: slab 1's ceiling is widened by income_tax.senior_citizen_
    slab1_extra_pct. This stacks with the SSF concession above — a senior
    citizen who also contributes to SSF gets both a wider and a cheaper slab 1.
    """
    opts = opts or {}
    rates, unverified = rate_dict("income_tax", fy, status)
    ssf_row = rates.get("ssf_rebate_slab1") if opts.get("ssf_contributor") else None
    senior_extra = (_val(rates, "senior_citizen_slab1_extra_pct", 0) or 0) if opts.get("is_senior_citizen") else 0
    slabs = []
    for idx, row in enumerate(_dynamic_slabs(rates, "slab")):
        band = row["value_num"]
        rate = float(row["value_text"] or 0)
        if idx == 0:
            if senior_extra:
                band = band * (1 + senior_extra)
            if ssf_row is not None:
                rate = float(ssf_row["value_num"] or 0)
        slabs.append((band, rate))
    top = rates.get("slab_top")
    top_rate = float(top["value_text"] or 0) if top else 0.0

    remaining = max(0.0, float(taxable_income or 0))
    lines, tax, floor_amt = [], 0.0, 0.0
    for idx, (ceiling, rate) in enumerate(slabs):
        width = ceiling - floor_amt if idx else ceiling
        if width <= 0:
            continue
        chunk = min(remaining, width)
        if chunk <= 0:
            break
        amount = chunk * rate
        lines.append({"band": f"{R2(floor_amt)} – {R2(ceiling)}", "rate": rate,
                      "income": R2(chunk), "tax": R2(amount)})
        tax += amount
        remaining -= chunk
        floor_amt = ceiling
    if remaining > 0:
        amount = remaining * top_rate
        lines.append({"band": f"Above {R2(floor_amt)}", "rate": top_rate,
                      "income": R2(remaining), "tax": R2(amount)})
        tax += amount

    rebate = 0.0
    if opts.get("is_resident_woman") and status == "individual":
        pct = _val(rates, "female_rebate", 0) or 0
        rebate = tax * pct
    return {
        "slab_lines": lines,
        "tax_before_rebate": R2(tax),
        "rebate": R2(rebate),
        "tax_payable": R2(tax - rebate),
        "unverified": unverified,
    }


def personal_income_tax(fy, data):
    """
    Whole-person tax calculator for an individual (or filing couple) with
    income from more than one head — not necessarily an employee, so this
    doesn't duplicate salary_tds's per-employer computation. Aggregates
    income from employment (a plain figure — for the full pay-slip-level
    computation use the Salary & TDS module and bring the resulting
    assessable income here), business (same — use Business Income Tax for
    the detailed Schedule computation, bring the net profit figure here),
    house/land rental, and other income; applies the same three deduction
    caps salary_tds uses (retirement, life insurance, health insurance) and
    the same donation-cap logic business_tax uses, so a person's deduction
    treatment is identical regardless of which module touched it last; then
    hands the resulting taxable income to personal_tax() for the slab walk.

    Rental income is entered as gross receipts less the deductible repair/
    maintenance expense actually incurred — deliberately not a flat
    percentage assumption, since misapplying one would be worse than asking
    the user for the real figure.

    data: {
      person: {status: 'individual'|'couple', is_resident_woman, ssf_contributor, is_senior_citizen},
      incomes: {employment_assessable, business_net_profit, rental_gross, rental_expenses, other_income},
      deductions: {retirement_contribution, life_insurance_premium, health_insurance_premium, donations},
    }
    """
    person = data.get("person") or {}
    incomes = data.get("incomes") or {}
    deductions = data.get("deductions") or {}
    status = person.get("status") or "individual"

    rates, unverified = rate_dict("income_tax", fy, status)

    employment = float(incomes.get("employment_assessable", 0) or 0)
    business = float(incomes.get("business_net_profit", 0) or 0)
    rental_gross = float(incomes.get("rental_gross", 0) or 0)
    rental_expenses = min(float(incomes.get("rental_expenses", 0) or 0), rental_gross)
    rental_net = rental_gross - rental_expenses
    other = float(incomes.get("other_income", 0) or 0)
    gross_income = employment + business + rental_net + other

    retirement_actual = float(deductions.get("retirement_contribution", 0) or 0)
    cap = _val(rates, "retirement_deduction_cap", 0) or 0
    pct_limb = (_val(rates, "retirement_deduction_pct", 0) or 0) * gross_income
    retirement_deduction = min(retirement_actual, cap, pct_limb) if pct_limb else min(retirement_actual, cap)

    life_ins = min(float(deductions.get("life_insurance_premium", 0) or 0), _val(rates, "insurance_premium_cap", 0) or 0)
    health_ins = min(float(deductions.get("health_insurance_premium", 0) or 0), _val(rates, "health_insurance_cap", 0) or 0)

    adjusted = max(0.0, gross_income - retirement_deduction - life_ins - health_ins)

    donation_actual = float(deductions.get("donations", 0) or 0)
    don_pct = _val(rates, "donation_cap_pct", None)
    don_ceiling = _val(rates, "donation_cap_amount", None)
    donation_allowed = 0.0
    if donation_actual:
        limbs = [donation_actual]
        if don_pct is not None:
            limbs.append(adjusted * don_pct)
        if don_ceiling is not None:
            limbs.append(don_ceiling)
        donation_allowed = max(0.0, min(limbs))

    taxable_income = max(0.0, adjusted - donation_allowed)

    opts = {"is_resident_woman": bool(person.get("is_resident_woman")),
            "ssf_contributor": bool(person.get("ssf_contributor")),
            "is_senior_citizen": bool(person.get("is_senior_citizen"))}
    slab_result = personal_tax(fy, status, taxable_income, opts)

    return {
        "person": person,
        "incomes": {"employment": R2(employment), "business": R2(business),
                   "rental_gross": R2(rental_gross), "rental_expenses": R2(rental_expenses),
                   "rental_net": R2(rental_net), "other": R2(other), "gross_income": R2(gross_income)},
        "deductions": {"retirement": R2(retirement_deduction), "life_insurance": R2(life_ins),
                      "health_insurance": R2(health_ins), "donation": R2(donation_allowed),
                      "donation_disallowed": R2(donation_actual - donation_allowed),
                      "total": R2(retirement_deduction + life_ins + health_ins + donation_allowed)},
        "adjusted_income": R2(adjusted),
        "taxable_income": R2(taxable_income),
        "slab_lines": slab_result["slab_lines"],
        "tax_before_rebate": slab_result["tax_before_rebate"],
        "rebate": slab_result["rebate"],
        "tax_payable": slab_result["tax_payable"],
        "unverified": sorted(set(unverified + slab_result["unverified"])),
    }


# ------------------------------------------------------------- 2. SALARY / TDS

def salary_tds(fy, employee):
    """
    employee: {
      name, designation, status ('individual'|'couple'), months (default 12),
      # cash pay
      basic_monthly, allowance_monthly, other_annual, bonus_annual,
      overtime_annual, commission_annual, leave_encashment_annual,
      # retirement fund
      ssf_contributor (bool), employee_contrib_override,
      # perquisites — employer-provided benefits that Sch 1 brings into assessable income
      vehicle_facility (bool), vehicle_cost,
      accommodation_facility (bool), accommodation_rent_paid,
      employer_loan_amount, employer_loan_rate_charged,
      other_perquisites_annual,
      # foreign posting
      is_foreign_posting (bool), foreign_allowance_annual,
      # separation in this fiscal year — Sec 65 concessional lump-sum tax, kept
      # apart from the slab-stacked salary computation below
      is_retirement_year (bool), retirement_lumpsum_amount,
      # a second concurrent/prior employer this fiscal year, for correct marginal
      # slab stacking — enter that employer's own post-deduction taxable income
      other_employer_income_annual,
      # deductions and credits
      insurance_premium, health_premium, remote_area_category ('A'..'E' or ''),
      remote_area_allowance, approved_medical_expense_annual,
      is_resident_woman (bool), is_senior_citizen (bool),
      tds_already_deducted, remaining_months
    }
    """
    pay_rates, pay_unver = rate_dict("payroll", fy)
    status = employee.get("status", "individual")
    it_rates, it_unver = rate_dict("income_tax", fy, status)
    tax_opts = {"is_resident_woman": employee.get("is_resident_woman"),
                "ssf_contributor": employee.get("ssf_contributor"),
                "is_senior_citizen": employee.get("is_senior_citizen")}

    months = float(employee.get("months", 12) or 12)
    basic = float(employee.get("basic_monthly", 0) or 0) * months
    allow = float(employee.get("allowance_monthly", 0) or 0) * months
    other = float(employee.get("other_annual", 0) or 0)
    bonus = float(employee.get("bonus_annual", 0) or 0)
    overtime = float(employee.get("overtime_annual", 0) or 0)
    commission = float(employee.get("commission_annual", 0) or 0)
    leave_encashment = float(employee.get("leave_encashment_annual", 0) or 0)
    cash_pay = basic + allow + other + bonus + overtime + commission + leave_encashment

    # Employer/employee retirement-fund contributions form part of assessable
    # income (the employer's share) and, together, unlock the Sec 63 deduction below.
    if employee.get("ssf_contributor"):
        er_pct = _val(pay_rates, "ssf_employer", 0) or 0
        ee_pct = _val(pay_rates, "ssf_employee", 0) or 0
    else:
        er_pct = _val(pay_rates, "pf_employer", 0) or 0
        ee_pct = _val(pay_rates, "pf_employee", 0) or 0
    employer_contrib = basic * er_pct
    employee_contrib = float(employee.get("employee_contrib_override") or basic * ee_pct)

    # Perquisites — employer-provided benefits, valued and added to assessable income
    perq = {}
    if employee.get("vehicle_facility"):
        v_pct = _val(pay_rates, "vehicle_perquisite_monthly_pct", 0) or 0
        perq["vehicle"] = R2(float(employee.get("vehicle_cost", 0) or 0) * v_pct * months)
    if employee.get("accommodation_facility"):
        a_pct = _val(pay_rates, "accommodation_perquisite_pct", 0) or 0
        base_for_accom = cash_pay + employer_contrib
        perq["accommodation"] = R2(max(float(employee.get("accommodation_rent_paid", 0) or 0), base_for_accom * a_pct))
    loan_amt = float(employee.get("employer_loan_amount", 0) or 0)
    if loan_amt:
        market_rate = _val(pay_rates, "employer_loan_market_rate", 0) or 0
        charged_rate = float(employee.get("employer_loan_rate_charged", 0) or 0)
        perq["employer_loan"] = R2(loan_amt * max(0.0, market_rate - charged_rate))
    other_perq = float(employee.get("other_perquisites_annual", 0) or 0)
    if other_perq:
        perq["other"] = R2(other_perq)
    perq_total = sum(perq.values())

    # Foreign posting — the exempt portion of a foreign allowance never enters gross
    foreign_gross = float(employee.get("foreign_allowance_annual", 0) or 0) if employee.get("is_foreign_posting") else 0.0
    foreign_exempt_pct = (_val(it_rates, "foreign_allowance_exempt_pct", 0) or 0) if foreign_gross else 0.0
    foreign_exempt = R2(foreign_gross * foreign_exempt_pct)
    foreign_taxable = R2(foreign_gross - foreign_exempt)

    gross = cash_pay + employer_contrib + perq_total + foreign_taxable

    # Retirement contribution deduction (Sec 63): lower of the actual contribution,
    # the fixed cap, and the % of assessable-income limb.
    cap = _val(it_rates, "retirement_deduction_cap", 0) or 0
    pct_limb = (_val(it_rates, "retirement_deduction_pct", 0) or 0) * gross
    total_retirement = employer_contrib + employee_contrib
    retirement_deduction = min(total_retirement, cap, pct_limb) if pct_limb else min(total_retirement, cap)

    ins = min(float(employee.get("insurance_premium", 0) or 0), _val(it_rates, "insurance_premium_cap", 0) or 0)
    health = min(float(employee.get("health_premium", 0) or 0), _val(it_rates, "health_insurance_cap", 0) or 0)

    category = (employee.get("remote_area_category") or "").strip().lower()
    remote_cap = (_val(it_rates, f"remote_area_cap_{category}", None) if category else None)
    if remote_cap is None:
        remote_cap = _val(it_rates, "remote_area_cap", 0) or 0
    remote = min(float(employee.get("remote_area_allowance", 0) or 0), remote_cap)

    deduction_total = retirement_deduction + ins + health + remote
    taxable = max(0.0, gross - deduction_total)

    # Multiple employers this fiscal year: the marginal method — combined income
    # sets the bracket, and this employer withholds only the slice attributable
    # to what it pays. This avoids re-applying person-level deduction caps twice.
    other_income = float(employee.get("other_employer_income_annual", 0) or 0)
    combined_taxable = taxable + other_income
    tax_combined = personal_tax(fy, status, combined_taxable, tax_opts)
    if other_income > 0:
        tax_floor = personal_tax(fy, status, other_income, tax_opts)
        attributable_tax = max(0.0, tax_combined["tax_payable"] - tax_floor["tax_payable"])
    else:
        tax_floor = None
        attributable_tax = tax_combined["tax_payable"]

    # Medical tax credit (Sec 51) — a credit against tax payable, not a deduction
    med_pct = _val(it_rates, "medical_credit_pct", 0) or 0
    med_ceiling = _val(it_rates, "medical_credit_ceiling", 0) or 0
    medical_credit = min(float(employee.get("approved_medical_expense_annual", 0) or 0) * med_pct, med_ceiling)

    annual_tax = max(0.0, attributable_tax - medical_credit)

    # Retirement lump sum (Sec 65) — a separate, final flat tax on separation,
    # never stacked into the slab computation above.
    retirement = {"applicable": bool(employee.get("is_retirement_year") and employee.get("retirement_lumpsum_amount"))}
    if retirement["applicable"]:
        lumpsum = float(employee.get("retirement_lumpsum_amount", 0) or 0)
        exempt_threshold = _val(it_rates, "retirement_exempt_threshold", 0) or 0
        exempt_pct = _val(it_rates, "retirement_exempt_pct", 0) or 0
        exempt = max(exempt_threshold, lumpsum * exempt_pct)
        rt_taxable = max(0.0, lumpsum - exempt)
        rt_rate = _val(it_rates, "retirement_lumpsum_rate", 0) or 0
        retirement.update({
            "lumpsum": R2(lumpsum), "exempt": R2(min(exempt, lumpsum)),
            "taxable": R2(rt_taxable), "rate": rt_rate, "tax": R2(rt_taxable * rt_rate),
        })
    else:
        retirement.update({"lumpsum": 0.0, "exempt": 0.0, "taxable": 0.0, "rate": 0.0, "tax": 0.0})

    total_tax_payable = annual_tax + retirement["tax"]
    already = float(employee.get("tds_already_deducted", 0) or 0)
    remaining_months = max(1.0, float(employee.get("remaining_months", months)))
    balance = total_tax_payable - already

    return {
        "name": employee.get("name"),
        "designation": employee.get("designation"),
        "basic": R2(basic), "allowance": R2(allow), "other": R2(other), "bonus": R2(bonus),
        "overtime": R2(overtime), "commission": R2(commission), "leave_encashment": R2(leave_encashment),
        "cash_pay": R2(cash_pay),
        "employer_contribution": R2(employer_contrib),
        "employee_contribution": R2(employee_contrib),
        "perquisites": dict(perq, total=R2(perq_total)),
        "foreign": {"gross": R2(foreign_gross), "exempt": foreign_exempt, "taxable": foreign_taxable,
                    "exempt_pct": foreign_exempt_pct},
        "gross_assessable": R2(gross),
        "deductions": {
            "retirement": R2(retirement_deduction), "life_insurance": R2(ins),
            "health_insurance": R2(health), "remote_area": R2(remote),
            "total": R2(deduction_total),
        },
        "taxable_income": R2(taxable),
        "other_employer_income": R2(other_income),
        "combined_taxable_income": R2(combined_taxable),
        "slab_lines": tax_combined["slab_lines"],
        "tax_on_combined": tax_combined["tax_payable"],
        "tax_on_other_employer": tax_floor["tax_payable"] if tax_floor else 0.0,
        "attributable_tax": R2(attributable_tax),
        "medical_credit": R2(medical_credit),
        "annual_tax": R2(annual_tax),
        "rebate": tax_combined["rebate"],
        "retirement": retirement,
        "total_tax_payable": R2(total_tax_payable),
        "tds_already_deducted": R2(already),
        "balance_to_deduct": R2(balance),
        "monthly_tds": R2(balance / remaining_months),
        "unverified": sorted(set(pay_unver + it_unver + tax_combined["unverified"])),
    }


# ------------------------------------------------------- 3. POOL DEPRECIATION

TRANCHE_BY_MONTH = {  # Nepali month number (1 = Shrawan, FY start) -> absorption tranche
    # Sch 2 pooled-asset absorption: an addition put to use in the first six
    # months of the income year (Shrawan-Poush) absorbs at the full pool rate;
    # the next three months (Magh-Chaitra) at two-thirds; the last three
    # (Baishakh-Ashad) at one-third.
    1: "q1", 2: "q1", 3: "q1", 4: "q1", 5: "q1", 6: "q1",
    7: "q2", 8: "q2", 9: "q2",
    10: "q3", 11: "q3", 12: "q3",
}


def depreciation(fy, pools, opts=None):
    """
    pools: [{pool: 'a'..'e', opening_base, additions:[{desc, amount, bs_month}],
             disposals: <amount>, repairs: <amount>, business_use_pct}]
    opts: {'special_industry': bool}
    """
    opts = opts or {}
    rates, unverified = rate_dict("depreciation", fy)
    repair_ceiling = _val(rates, "repair_ceiling", 0) or 0
    de_minimis = _val(rates, "de_minimis", 0) or 0
    extra_third = _val(rates, "special_extra_third", 0) or 0
    tranche = {
        "q1": _val(rates, "tranche_q1", 1.0) or 1.0,
        "q2": _val(rates, "tranche_q2", 0.6667) or 0.6667,
        "q3": _val(rates, "tranche_q3", 0.3333) or 0.3333,
    }

    results, totals = [], {"opening": 0, "absorbed": 0, "disposals": 0, "depreciation": 0, "closing": 0}
    for p in pools:
        key = str(p.get("pool", "d")).lower()
        rate = _val(rates, f"pool_{key}_rate", 0) or 0
        opening = float(p.get("opening_base", 0) or 0)

        absorbed, unabsorbed, add_lines = 0.0, 0.0, []
        for a in p.get("additions", []) or []:
            amt = float(a.get("amount", 0) or 0)
            t = TRANCHE_BY_MONTH.get(int(a.get("bs_month", 1) or 1), "q1")
            factor = tranche[t]
            absorbed += amt * factor
            unabsorbed += amt * (1 - factor)
            add_lines.append({"desc": a.get("desc", ""), "amount": R2(amt), "bs_month": a.get("bs_month"),
                              "tranche": t, "factor": factor, "absorbed": R2(amt * factor)})

        disposals = float(p.get("disposals", 0) or 0)

        # Section 16 repair ceiling on the opening depreciation base
        repairs = float(p.get("repairs", 0) or 0)
        ceiling_amount = (opening + absorbed) * repair_ceiling
        repair_allowed = min(repairs, ceiling_amount)
        repair_capitalised = max(0.0, repairs - repair_allowed)

        base = opening + absorbed + repair_capitalised - disposals

        note = ""
        if base < 0:
            dep, gain, closing = 0.0, abs(base), 0.0
            note = "Negative base — gain on disposal taxable; pool closed."
        elif 0 < base <= de_minimis:
            dep, gain, closing = base, 0.0, 0.0
            note = f"Base below de-minimis ({de_minimis:,.0f}) — written off in full."
        else:
            dep = base * rate
            if opts.get("special_industry") and extra_third:
                dep = dep * (1 + extra_third)
                note = "Extra depreciation applied for special industry."
            biz = float(p.get("business_use_pct", 100) or 100) / 100.0
            dep = dep * biz
            gain, closing = 0.0, base - dep

        results.append({
            "pool": key.upper(), "rate": rate, "opening_base": R2(opening),
            "additions": add_lines, "absorbed_additions": R2(absorbed),
            "unabsorbed_carried_forward": R2(unabsorbed),
            "repairs_claimed": R2(repair_allowed), "repairs_capitalised": R2(repair_capitalised),
            "repair_ceiling_amount": R2(ceiling_amount),
            "disposals": R2(disposals), "depreciation_base": R2(max(0.0, base)),
            "depreciation": R2(dep), "gain_on_disposal": R2(gain),
            "closing_base": R2(closing), "note": note,
        })
        totals["opening"] += opening; totals["absorbed"] += absorbed
        totals["disposals"] += disposals; totals["depreciation"] += dep
        totals["closing"] += max(0.0, closing)

    return {"pools": results, "totals": {k: R2(v) for k, v in totals.items()},
            "unverified": unverified}


# ------------------------------------------------- 4. BUSINESS INCOME TAX (D-03)

def _fy_start_year(label):
    """'2082-83' -> 2082. Used only to age brought-forward losses against a
    carry-forward window; returns None for anything that doesn't parse."""
    try:
        return int(str(label).strip().split("-")[0])
    except (ValueError, IndexError, AttributeError):
        return None


def business_tax(fy, data):
    """
    data: {
      profit_before_tax, entity ('company'|'individual'|'couple'),
      rate_category — a slug matching an income_tax.company_rate_<slug> row in
        Rate Master (company entities only). Whatever categories are seeded
        there are the options; adding a sector rate or a Finance Act change to
        the categories is a Rate Master edit, not a code change.
      tax_holiday_pct — this year's determined holiday relief (0-1), applied to
        the computed tax before credits. This is the firm's own reading of the
        specific approval letter; the engine does not derive a holiday schedule.
      additions:[{desc,amount}], deductions:[{desc,amount}],
      book_depreciation, tax_depreciation,
      donations — capped at the lower of the actual amount, a % of adjusted
        income and a fixed ceiling (whichever of income_tax.donation_cap_pct /
        donation_cap_amount are seeded),
      brought_forward_losses: [{fy_label, amount}] — aged against
        income_tax.loss_carry_forward_years_<rate_category>, falling back to
        loss_carry_forward_years, so an expired loss is excluded automatically,
      tds_credit, advance_tax_paid,
      presumptive: {elected, turnover, area_category} — Sec 4 turnover-based
        flat tax, an alternative regime that replaces the computation below
        entirely when elected. Brackets come from whatever
        income_tax.presumptive_<area_category>_<n>_ceiling rows are seeded.
    }
    """
    rates, unverified = rate_dict("income_tax", fy, "company")
    entity = data.get("entity", "company")

    presumptive = data.get("presumptive") or {}
    if presumptive.get("elected"):
        area = (presumptive.get("area_category") or "metro").strip().lower()
        turnover = float(presumptive.get("turnover", 0) or 0)
        brackets = _dynamic_slabs(rates, f"presumptive_{area}")
        chosen = None
        for row in brackets:
            if turnover <= (row["value_num"] or 0):
                chosen = row
                break
        if chosen is None and brackets:
            chosen = brackets[-1]
        flat_tax = float(chosen["value_text"]) if chosen and chosen.get("value_text") else 0.0
        tds = float(data.get("tds_credit", 0) or 0)
        advance = float(data.get("advance_tax_paid", 0) or 0)
        net = flat_tax - tds - advance
        return {
            "regime": "presumptive",
            "presumptive": {
                "area_category": area, "turnover": R2(turnover),
                "bracket_ceiling": R2(chosen["value_num"]) if chosen else None,
                "bracket_label": chosen["label"] if chosen else None,
                "flat_tax": R2(flat_tax), "brackets_found": len(brackets),
                "note": "" if brackets else
                        f"No presumptive brackets configured for area '{area}' — add "
                        f"income_tax.presumptive_{area}_<n>_ceiling rows in Rate Master.",
            },
            "taxable_income": None, "tax_liability": R2(flat_tax),
            "tds_credit": R2(tds), "advance_tax_paid": R2(advance),
            "net_payable": R2(net) if net >= 0 else 0.0,
            "refund_due": R2(-net) if net < 0 else 0.0,
            "unverified": unverified,
        }

    pbt = float(data.get("profit_before_tax", 0) or 0)

    adds = [{"desc": a.get("desc", ""), "amount": R2(a.get("amount", 0))} for a in data.get("additions", []) or []]
    dedn = [{"desc": d.get("desc", ""), "amount": R2(d.get("amount", 0))} for d in data.get("deductions", []) or []]
    add_total = sum(a["amount"] for a in adds)
    ded_total = sum(d["amount"] for d in dedn)

    book_dep = float(data.get("book_depreciation", 0) or 0)
    tax_dep = float(data.get("tax_depreciation", 0) or 0)

    adjusted = pbt + add_total + book_dep - ded_total - tax_dep

    # Donation cap (Sec 12) — lower of the actual gift, a % of adjusted income
    # and a fixed ceiling; either limb is skipped if not seeded.
    donation_actual = float(data.get("donations", 0) or 0)
    don_pct = _val(rates, "donation_cap_pct", None)
    don_ceiling = _val(rates, "donation_cap_amount", None)
    donation_allowed = 0.0
    if donation_actual:
        limbs = [donation_actual]
        if don_pct:
            limbs.append(max(0.0, adjusted) * don_pct)
        if don_ceiling:
            limbs.append(don_ceiling)
        donation_allowed = max(0.0, min(limbs))
    after_donation = adjusted - donation_allowed

    # Loss carry-forward, aged year by year against the window for this rate
    # category — an expired loss is reported but excluded from what's usable.
    category = (data.get("rate_category") or "general").strip().lower()
    cf_years = _val(rates, f"loss_carry_forward_years_{category}", None)
    if cf_years is None:
        cf_years = _val(rates, "loss_carry_forward_years", 7) or 7
    this_start = _fy_start_year(fy)
    remaining_profit = max(0.0, after_donation)
    losses, bf_total, used_total, expired_total = [], 0.0, 0.0, 0.0
    for l in data.get("brought_forward_losses", []) or []:
        amt = float(l.get("amount", 0) or 0)
        label = l.get("fy_label", "")
        start = _fy_start_year(label)
        age = (this_start - start) if (this_start is not None and start is not None) else None
        expired = bool(age is not None and age >= cf_years)
        bf_total += amt
        use = 0.0
        if not expired:
            use = min(remaining_profit, amt)
            remaining_profit -= use
            used_total += use
        else:
            expired_total += amt
        losses.append({"fy_label": label, "amount": R2(amt), "age_years": age,
                       "expired": expired, "utilised": R2(use)})
    taxable = max(0.0, remaining_profit)

    if entity == "company":
        categories = _dynamic_categories(rates, "company_rate_")
        chosen_cat = next((c for c in categories if c["slug"] == category), None)
        if chosen_cat is None and categories:
            chosen_cat = next((c for c in categories if c["slug"] == "general"), categories[0])
        rate = float(chosen_cat["row"]["value_num"]) if chosen_cat else 0.0
        rate_label = chosen_cat["label"] if chosen_cat else "No company rate category configured in Rate Master"
        tax_before_holiday = taxable * rate
        slab_lines = [{"band": "Flat", "rate": rate, "income": R2(taxable), "tax": R2(tax_before_holiday)}]
        rate_categories = [{"slug": c["slug"], "label": c["label"], "rate": c["row"]["value_num"]} for c in categories]
    else:
        pt = personal_tax(fy, entity, taxable)
        tax_before_holiday, rate, rate_label = pt["tax_payable"], None, "Personal slab rates"
        slab_lines = pt["slab_lines"]
        unverified = sorted(set(unverified + pt["unverified"]))
        rate_categories = []

    holiday_pct = max(0.0, min(1.0, float(data.get("tax_holiday_pct", 0) or 0)))
    holiday_relief = tax_before_holiday * holiday_pct
    tax = tax_before_holiday - holiday_relief

    tds = float(data.get("tds_credit", 0) or 0)
    advance = float(data.get("advance_tax_paid", 0) or 0)
    net = tax - tds - advance

    return {
        "regime": "standard",
        "profit_before_tax": R2(pbt), "additions": adds, "add_total": R2(add_total),
        "deductions": dedn, "deduction_total": R2(ded_total),
        "book_depreciation": R2(book_dep), "tax_depreciation": R2(tax_dep),
        "adjusted_income": R2(adjusted),
        "donations": {"actual": R2(donation_actual), "allowed": R2(donation_allowed),
                      "disallowed": R2(donation_actual - donation_allowed)},
        "losses": losses, "brought_forward_total": R2(bf_total), "loss_utilised": R2(used_total),
        "loss_expired": R2(expired_total), "loss_carry_forward_years": cf_years,
        "taxable_income": R2(taxable),
        "rate_category": category, "rate_categories": rate_categories,
        "rate": rate, "rate_label": rate_label, "slab_lines": slab_lines,
        "tax_before_holiday": R2(tax_before_holiday),
        "tax_holiday_pct": holiday_pct, "tax_holiday_relief": R2(holiday_relief),
        "tax_liability": R2(tax),
        "tds_credit": R2(tds), "advance_tax_paid": R2(advance),
        "net_payable": R2(net) if net >= 0 else 0.0,
        "refund_due": R2(-net) if net < 0 else 0.0,
        "unverified": unverified,
    }


# ------------------------------------------------------- 5. PENALTY / INTEREST

def penalty(fy, data):
    """
    A scenario matrix, not a single formula — each head of charge has its own
    trigger condition and is only raised when the facts for it are present, the
    way an assessment would actually be built up. data: {
      # Sec 117 — filing / documentation fees
      estimate_return_filed, books_maintained (bools, default True — i.e. compliant),
      assessable_income, months_late_return,

      # Sec 118 — instalment (advance tax) interest. Two ways to get lines, additive:
      #  (a) auto: give advance_tax_estimate + what was actually paid by each date and
      #      the three statutory cumulative thresholds (40/70/100%) do the rest;
      #  (b) manual: instalment_shortfalls, for anything the simple 3-date model
      #      doesn't fit (revised estimates, part-year incorporation, etc).
      advance_tax_estimate, paid_by_poush, paid_by_chaitra, paid_by_ashad,
      instalment_shortfalls: [{label, shortfall, months}],

      # Sec 119 — interest on tax assessed/due but not paid by the due date
      tax_unpaid, months_unpaid,

      # Sec 120 — fee for a false or misleading statement found on reassessment
      understatement_amount, understatement_intent ('careless' | 'knowing'),

      # TDS — withholding agent's own exposure for not deducting/depositing,
      # and the return-filing fee, kept apart from the interest on the shortfall
      tds_shortfall, tds_months, tds_returns_late,

      # VAT
      vat_returns_late, vat_unpaid, vat_months,
      vat_unregistered_turnover, vat_unregistered_months,
    }
    """
    rates, unverified = rate_dict("penalty", fy)
    vat_rates, vat_unver = rate_dict("vat", fy)
    lines = []

    def add(category, head, basis, amount):
        if amount:
            lines.append({"category": category, "head": head, "basis": basis, "amount": R2(amount)})

    # ── Sec 117 — filing and record-keeping fees ──────────────────────────
    if not data.get("estimate_return_filed", True):
        add("income_tax", "Sec 117(1) — estimated return (D-01) not filed", "Fixed fee",
            _val(rates, "s117_estimate_fee", 0))

    if not data.get("books_maintained", True):
        add("income_tax", "Sec 117 — books of account not maintained", "Fixed fee",
            _val(rates, "s117_books_fee", 0))

    m = float(data.get("months_late_return", 0) or 0)
    if m > 0:
        pct = _val(rates, "s117_income_return_pct", 0) or 0
        per_month = _val(rates, "s117_income_return_month", 0) or 0
        by_income = float(data.get("assessable_income", 0) or 0) * pct
        by_month = per_month * m
        add("income_tax", "Sec 117(1)(c) — late income return (D-03)",
            f"Higher of {pct*100:.2f}% of assessable income ({by_income:,.2f}) "
            f"and {per_month:,.0f} x {m:.0f} months ({by_month:,.2f})",
            max(by_income, by_month))

    # ── Sec 118 — instalment (advance tax) interest ───────────────────────
    inst_rate = _val(rates, "s118_interest_rate", 0) or 0
    estimate = float(data.get("advance_tax_estimate", 0) or 0)
    if estimate:
        thresholds = [
            ("Poush (1st instalment — 40% due)", "instalment_1_pct", "paid_by_poush", 6),
            ("Chaitra (2nd instalment — 70% due)", "instalment_2_pct", "paid_by_chaitra", 3),
        ]
        for label, pct_code, paid_key, months_left in thresholds:
            required_pct = _val(rates, pct_code, 0) or 0
            required = estimate * required_pct
            paid = float(data.get(paid_key, 0) or 0)
            shortfall = max(0.0, required - paid)
            interest = shortfall * inst_rate * months_left / 12.0
            add("income_tax", f"Sec 118 — instalment interest, {label}",
                f"Required {required_pct*100:.0f}% of {estimate:,.2f} ({required:,.2f}) less paid "
                f"({paid:,.2f}) = shortfall {shortfall:,.2f} x {inst_rate*100:.0f}% x {months_left}/12",
                interest)
        # The 3rd instalment (100% by Ashad-end, year end) has no months left to run
        # interest under Sec 118 — a shortfall still open at that point becomes an
        # ordinary late-payment case under Sec 119 (tax_unpaid below), not this section.

    for s in data.get("instalment_shortfalls", []) or []:
        amt = float(s.get("shortfall", 0) or 0)
        mo = float(s.get("months", 0) or 0)
        add("income_tax", f"Sec 118 — instalment interest, manual entry ({s.get('label','')})",
            f"{amt:,.2f} x {inst_rate*100:.0f}% x {mo:.0f}/12", amt * inst_rate * mo / 12.0)

    # ── Sec 119 — interest on tax assessed/due but paid late ──────────────
    unpaid = float(data.get("tax_unpaid", 0) or 0)
    mu = float(data.get("months_unpaid", 0) or 0)
    if unpaid and mu:
        r = _val(rates, "s119_interest_rate", 0) or 0
        add("income_tax", "Sec 119 — interest on unpaid tax",
            f"{unpaid:,.2f} x {r*100:.0f}% x {mu:.0f}/12", unpaid * r * mu / 12.0)

    # ── Sec 120 — false or misleading statement ───────────────────────────
    understatement = float(data.get("understatement_amount", 0) or 0)
    if understatement:
        knowing = data.get("understatement_intent") == "knowing"
        code = "s120_false_statement_knowing" if knowing else "s120_false_statement"
        r = _val(rates, code, 0) or 0
        add("income_tax", "Sec 120 — false or misleading statement (" +
            ("knowing/reckless" if knowing else "careless") + ")",
            f"{understatement:,.2f} x {r*100:.0f}% of the understatement", understatement * r)

    # ── TDS — withholding agent's own exposure ────────────────────────────
    tds_short = float(data.get("tds_shortfall", 0) or 0)
    tds_mo = float(data.get("tds_months", 0) or 0)
    if tds_short and tds_mo:
        r = _val(rates, "tds_shortfall_interest_rate", 0) or 0
        add("tds", "TDS not deducted / not deposited — withholding agent's interest",
            f"{tds_short:,.2f} x {r*100:.0f}% x {tds_mo:.0f}/12", tds_short * r * tds_mo / 12.0)

    tds_n = int(data.get("tds_returns_late", 0) or 0)
    if tds_n:
        fee = _val(rates, "tds_return_late_fee", 0) or 0
        add("tds", "TDS return filed late", f"{fee:,.0f} x {tds_n} returns", fee * tds_n)

    # ── VAT ────────────────────────────────────────────────────────────────
    n = int(data.get("vat_returns_late", 0) or 0)
    if n:
        fee = _val(vat_rates, "late_return_fee", 0) or 0
        add("vat", "VAT — late returns", f"{fee:,.0f} x {n} returns", fee * n)

    vu = float(data.get("vat_unpaid", 0) or 0)
    vm = float(data.get("vat_months", 0) or 0)
    if vu and vm:
        r = _val(vat_rates, "late_payment_addl_fee", 0) or 0
        add("vat", "VAT — additional fee on unpaid tax",
            f"{vu:,.2f} x {r*100:.0f}% x {vm:.0f}/12", vu * r * vm / 12.0)

    vt = float(data.get("vat_unregistered_turnover", 0) or 0)
    vrm = float(data.get("vat_unregistered_months", 0) or 0)
    if vt and vrm:
        r = _val(vat_rates, "unregistered_penalty_pct", 0) or 0
        add("vat", "VAT — trading above the registration threshold without registering",
            f"{vt:,.2f} turnover while unregistered x {r*100:.2f}% "
            f"(over {vrm:.0f} months — in addition to the VAT itself now due on that turnover)",
            vt * r)

    return {"lines": lines, "total": R2(sum(l["amount"] for l in lines)),
            "unverified": sorted(set(unverified + vat_unver))}


# ------------------------------------------------------------ 6. LOAN / EMI
# Shared by the loan and lease engines: a payment frequency other than
# monthly, a real calendar start date (so a facility taken years ago, or one
# spanning several fiscal years, has actual dates instead of bare period
# numbers), and grouping the journal register by the fiscal year each entry
# falls in.

FREQ_PERIODS_PER_YEAR = {"monthly": 12, "quarterly": 4, "half_yearly": 2, "annual": 1}
FREQ_MONTHS = {"monthly": 1, "quarterly": 3, "half_yearly": 6, "annual": 12}


def _add_months(d, months):
    y = d.year + (d.month - 1 + months) // 12
    m = (d.month - 1 + months) % 12 + 1
    next_month = date(y + 1, 1, 1) if m == 12 else date(y, m + 1, 1)
    last_day = (next_month - timedelta(days=1)).day
    return date(y, m, min(d.day, last_day))


def _period_dates(start_date_ad, frequency, n):
    """Period i's date (1-indexed) = start_date + i periods of this frequency.
    Returns [] if no start date was given — schedules work fine without dates,
    they just can't be grouped by fiscal year or show a calendar column."""
    if not start_date_ad:
        return []
    try:
        start = date.fromisoformat(str(start_date_ad)[:10])
    except ValueError:
        return []
    months_per_period = FREQ_MONTHS.get(frequency, 1)
    return [_add_months(start, i * months_per_period) for i in range(1, n + 1)]


def _attach_dates(rows, dates):
    for row, d in zip(rows, dates):
        row["date_ad"] = d.isoformat()
        row["date_bs_approx"] = _bs_label(d)


def _fiscal_years_for_grouping(dates):
    """The rows _group_journals_by_fy needs to resolve a date to a period
    label — fiscal_year (Bikram Sambat) for a Nepal firm, financial_period
    (AD-only) for anyone else. Was previously always fiscal_year regardless
    of country, which meant a non-Nepal firm's dates fell straight through
    to nepal_calendar's own Bikram-Sambat-derived fallback — a real gap,
    not a hypothetical one, since nothing populates fiscal_year for a
    non-Nepal firm at all. See fiscal_year_engine.py."""
    if not dates:
        return []
    if country_service.current_country_code() == "NP":
        return db.query("SELECT label_bs, start_ad, end_ad FROM fiscal_year")
    return fiscal_year_engine.periods_for_country(country_service.current_country_code())


def _group_journals_by_fy(journals, dates, fiscal_years):
    """{fy_label: [journal entries]} — entry for period 0 (inception) goes
    under the FY of the start date itself; every other entry under the FY of
    its own period's date. Empty if there are no dates to group by."""
    if not dates:
        return {}
    by_fy = {}
    date_by_period = {i + 1: d for i, d in enumerate(dates)}
    date_by_period[0] = dates[0]
    is_nepal = country_service.current_country_code() == "NP"
    for entry in journals:
        d = date_by_period.get(entry["period"])
        if d is None:
            continue
        if is_nepal:
            label = nepal_calendar.fy_label_for_ad(d, fiscal_years)
        else:
            label = fiscal_year_engine.period_label_for_ad(country_service.current_country_code(), d, fiscal_years)
        by_fy.setdefault(label, []).append(entry)
    return by_fy


def _solve_eir(net_proceeds, cashflows):
    """
    The monthly rate i such that the present value of `cashflows` (one per
    period, 0 for a period nothing is paid) discounted at i equals
    `net_proceeds`, by bisection — no external numerics dependency, and this
    only ever needs to run once per computation. Falls back to 0 for a
    degenerate input (no fee, no proceeds) rather than raising.
    """
    if net_proceeds <= 0 or not cashflows or not any(cashflows):
        return 0.0

    def npv(rate):
        return sum(cf / ((1 + rate) ** (t + 1)) for t, cf in enumerate(cashflows))

    lo, hi = 0.0, 1.0
    if npv(hi) > net_proceeds:
        hi = 5.0  # a very large discount (an outsized fee) — widen the bracket
    for _ in range(100):
        mid = (lo + hi) / 2
        if npv(mid) > net_proceeds:
            lo = mid
        else:
            hi = mid
    return (lo + hi) / 2


def _classify_current(rows, principal_key, closing_key, periods_per_year=12):
    """
    NAS 1's 12-month test: the current portion of a period's closing balance
    is however much of it is scheduled to be repaid in the next 12 months —
    `periods_per_year` periods at this frequency — capped at the balance
    itself; the rest is non-current. Mutates each row.
    """
    n = len(rows)
    for i, row in enumerate(rows):
        closing = row[closing_key]
        upcoming = sum(rows[j][principal_key] for j in range(i + 1, min(i + 1 + periods_per_year, n)))
        current = min(closing, max(0.0, upcoming))
        row["current_portion"] = R2(current)
        row["non_current_portion"] = R2(max(0.0, closing - current))


def _loan_journals(rows, eir_rows, principal, net_proceeds):
    j = [{"period": 0, "narration": "Loan drawdown" + (" (net of processing fee, amortised cost)" if eir_rows else ""),
          "debit": [{"account": "Bank", "amount": R2(net_proceeds if eir_rows else principal)}],
          "credit": [{"account": "Loan payable", "amount": R2(net_proceeds if eir_rows else principal)}]}]
    if eir_rows:
        for x in eir_rows:
            j.append({"period": x["period"], "narration": f"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.append({"period": x["period"], "narration": f"Period {x['period']} — instalment paid",
                          "debit": [{"account": "Loan payable", "amount": x["cash_flow"]}],
                          "credit": [{"account": "Bank", "amount": x["cash_flow"]}]})
    else:
        for x in rows:
            j.append({"period": x["period"], "narration": f"Period {x['period']} — interest accrued",
                      "debit": [{"account": "Interest expense", "amount": x["interest"]}],
                      "credit": [{"account": "Loan payable", "amount": x["interest"]}]})
            if x["instalment"]:
                j.append({"period": x["period"], "narration": f"Period {x['period']} — instalment paid",
                          "debit": [{"account": "Loan payable", "amount": x["instalment"]}],
                          "credit": [{"account": "Bank", "amount": x["instalment"]}]})
    return j


def _pv(cashflows, rate):
    return sum(cf / ((1 + rate) ** (t + 1)) for t, cf in enumerate(cashflows))


def loan_schedule(data):
    """
    data: {principal, annual_rate, tenure_periods, frequency
           ('monthly'|'quarterly'|'half_yearly'|'annual'), moratorium_periods,
           processing_fee, repayment ('emi'|'equal_principal'), start_date_ad,
           modifications: [{at_period, new_annual_rate, new_tenure_periods,
                            extra_payment}]}
    Not statutory, so no rate_master dependency. tenure_months/moratorium_months
    are still read as a fallback for a monthly-frequency loan built before this.

    Two views come back. `rows` is the contractual cash-flow schedule — the
    nominal rate(s) on the outstanding balance, what's actually paid, broken
    into segments at each modification. When a processing fee applies,
    `eir_rows` is the amortised-cost view NFRS 9 actually requires: the fee is
    netted against the initial carrying amount and spread through profit or
    loss at the effective rate (solved against the ORIGINAL, unmodified
    contractual cash flows), not expensed upfront. A modification's gain or
    loss — the difference between the revised cash flows discounted at the
    original nominal rate and the balance actually outstanding at that point
    — is booked immediately, the non-substantial-modification treatment under
    NFRS 9; the amortised-cost view does not itself reflect a modification, so
    a fee and a modification together need a manual review, flagged in the
    summary. `journals` is built from whichever view is the accounting one —
    EIR if it exists, contractual otherwise — grouped by fiscal year in
    `journals_by_fy` when a start date is given, and every closing balance
    carries the NAS 1 current/non-current split.
    """
    p = float(data.get("principal", 0) or 0)
    ar = float(data.get("annual_rate", 0) or 0) / 100.0
    frequency = data.get("frequency") or "monthly"
    ppy = FREQ_PERIODS_PER_YEAR.get(frequency, 12)
    n = int(data.get("tenure_periods", data.get("tenure_months", 0)) or 0)
    mor = int(data.get("moratorium_periods", data.get("moratorium_months", 0)) or 0)
    fee = float(data.get("processing_fee", 0) or 0)
    mode = data.get("repayment", "emi")
    start_date_ad = data.get("start_date_ad")

    if p <= 0 or n <= 0:
        return {"rows": [], "eir_rows": [], "journals": [], "journals_by_fy": {}, "summary": {}, "unverified": []}

    mods = sorted((data.get("modifications") or []), key=lambda m: int(m.get("at_period", 0) or 0))

    rows, balance = [], p
    mor_r = ar / ppy
    for i in range(1, mor + 1):
        interest = balance * mor_r
        rows.append({"period": i, "opening": R2(balance), "instalment": 0.0,
                     "interest": R2(interest), "principal": 0.0, "closing": R2(balance + interest),
                     "note": "Moratorium — interest capitalised"})
        balance += interest

    # Break the post-moratorium run into segments at each in-range modification.
    # A segment's own contractual EMI/principal is only ever recomputed to
    # fully amortise its OWN remaining span when it starts after a
    # modification (seg_idx > 0) — the opening (unmodified) segment keeps
    # amortising over the loan's original remaining term, same as if no
    # modification existed yet, otherwise its instalment would silently jump
    # to whatever pays the balance off by the next modification instead.
    segments, cursor, cur_rate, total_n = [], mor, ar, n
    for m in mods:
        at = int(m.get("at_period", 0) or 0)
        if at <= cursor or at >= total_n:
            continue
        segments.append({"start": cursor + 1, "end": at, "rate": cur_rate})
        cursor = at
        if m.get("new_annual_rate") not in (None, ""):
            cur_rate = float(m["new_annual_rate"]) / 100.0
        if m.get("new_tenure_periods"):
            total_n = at + int(m["new_tenure_periods"])
    segments.append({"start": cursor + 1, "end": total_n, "rate": cur_rate})

    emi = None
    modification_gain_loss = []
    for seg_idx, seg in enumerate(segments):
        if seg_idx > 0:
            extra = float((mods[seg_idx - 1].get("extra_payment") if seg_idx - 1 < len(mods) else 0) or 0)
            if extra:
                balance = max(0.0, balance - extra)
                rows[-1]["closing"] = R2(balance)
                rows[-1]["note"] = (rows[-1]["note"] + " " if rows[-1]["note"] else "") + \
                    f"Extra payment of {extra:,.2f} applied on modification."
        seg_r = seg["rate"] / ppy
        pay_n = max(1, (n - mor) if seg_idx == 0 else (seg["end"] - seg["start"] + 1))
        seg_emi = (balance * seg_r / (1 - (1 + seg_r) ** (-pay_n)) if seg_r else balance / pay_n) if mode == "emi" else None
        seg_principal_fixed = balance / pay_n
        if emi is None:
            emi = seg_emi

        if seg_idx > 0:
            future_cf = []
            b, r_iter = balance, seg_r
            for k in range(pay_n):
                interest_k = b * r_iter
                if mode == "emi":
                    inst_k = seg_emi
                    prin_k = inst_k - interest_k
                else:
                    prin_k = seg_principal_fixed
                    inst_k = prin_k + interest_k
                if k == pay_n - 1:
                    prin_k = b; inst_k = prin_k + interest_k
                future_cf.append(inst_k)
                b -= prin_k
            revised_pv = _pv(future_cf, ar / ppy)  # discounted at the ORIGINAL nominal rate, period basis
            modification_gain_loss.append({"at_period": seg["start"] - 1, "carrying_amount": R2(balance),
                                           "revised_pv_at_original_rate": R2(revised_pv),
                                           "gain_or_loss": R2(revised_pv - balance)})

        for i in range(seg["start"], seg["end"] + 1):
            interest = balance * seg_r
            if mode == "emi":
                inst = seg_emi
                prin = inst - interest
            else:
                prin = seg_principal_fixed
                inst = prin + interest
            if i == total_n:
                prin = balance
                inst = prin + interest
            rows.append({"period": i, "opening": R2(balance), "instalment": R2(inst),
                         "interest": R2(interest), "principal": R2(prin),
                         "closing": R2(balance - prin), "note": ""})
            balance -= prin

    _classify_current(rows, "principal", "closing", ppy)

    dates = _period_dates(start_date_ad, frequency, total_n)
    if dates:
        _attach_dates(rows, dates)

    total_interest = sum(x["interest"] for x in rows)
    total_paid = sum(x["instalment"] for x in rows)

    eir_rows, effective_rate = [], None
    net_proceeds = p - fee
    if fee > 0 and net_proceeds > 0 and not mods:
        cashflows = [x["instalment"] for x in rows]
        effective_rate = _solve_eir(net_proceeds, cashflows)
        eir_balance = net_proceeds
        for x, cf in zip(rows, cashflows):
            eff_interest = eir_balance * effective_rate
            principal_reduction = cf - eff_interest
            closing = eir_balance - principal_reduction
            eir_rows.append({"period": x["period"], "opening": R2(eir_balance), "cash_flow": R2(cf),
                             "effective_interest": R2(eff_interest),
                             "principal_reduction": R2(principal_reduction), "closing": R2(closing)})
            eir_balance = closing
        _classify_current(eir_rows, "principal_reduction", "closing", ppy)

    journals = _loan_journals(rows, eir_rows, p, net_proceeds)
    for mgl in modification_gain_loss:
        if mgl["gain_or_loss"]:
            is_gain = mgl["gain_or_loss"] < 0
            journals.append({"period": mgl["at_period"],
                             "narration": f"Period {mgl['at_period']} — loan modification "
                                          f"({'gain' if is_gain else 'loss'} on non-substantial modification, NFRS 9)",
                             "debit": [{"account": "Loan payable" if is_gain else "Modification loss", "amount": abs(mgl["gain_or_loss"])}],
                             "credit": [{"account": "Modification gain" if is_gain else "Loan payable", "amount": abs(mgl["gain_or_loss"])}]})
    fiscal_years = _fiscal_years_for_grouping(dates)
    journals_by_fy = _group_journals_by_fy(journals, dates, fiscal_years)

    return {
        "rows": rows,
        "eir_rows": eir_rows,
        "journals": journals,
        "journals_by_fy": journals_by_fy,
        "modifications_applied": modification_gain_loss,
        "summary": {
            "principal": R2(p), "emi": R2(emi) if emi else None,
            "total_interest": R2(total_interest), "total_paid": R2(total_paid),
            "processing_fee": R2(fee), "net_proceeds": R2(net_proceeds) if fee else None,
            "effective_annual_rate": R2(effective_rate * 12) if effective_rate is not None else None,
            "eir_skipped_due_to_modification": bool(fee > 0 and net_proceeds > 0 and mods),
            "total_cost": R2(total_interest + fee),
            "periods": total_n, "moratorium_periods": mor, "frequency": frequency,
            "start_date_ad": start_date_ad,
            "start_date_bs_approx": _bs_label(start_date_ad) if start_date_ad else None,
        },
        "unverified": [],
    }


# --------------------------------------------------------- 7. LEASE (NFRS 16)

def _lease_payment_at(period, base_payment, rent_steps):
    """The payment for a given period under an (optional) escalation
    schedule — the latest step whose from_period has been reached, or the
    base payment if none has. rent_steps: [{from_period, payment}], any
    pattern at all (annual, one-off, recurring every two years...) is just a
    list of (period, new amount) pairs, not a named escalation formula."""
    if not rent_steps:
        return base_payment
    applicable = [s for s in rent_steps if int(s.get("from_period", 1) or 1) <= period]
    if not applicable:
        return base_payment
    step = max(applicable, key=lambda s: int(s.get("from_period", 1) or 1))
    return float(step.get("payment", base_payment) or base_payment)


def _lease_pv(payments, r, advance):
    if not r:
        return sum(payments)
    if advance:
        return sum(cf / ((1 + r) ** i) for i, cf in enumerate(payments))
    return sum(cf / ((1 + r) ** (i + 1)) for i, cf in enumerate(payments))


def _lease_journals(rows, rou, pv, idc, inc, rest, tds_rate, tds_borne_by):
    j = [{"period": 0, "narration": "Initial recognition of right-of-use asset and lease liability",
          "debit": [{"account": "Right-of-use asset", "amount": R2(rou)}] +
                   ([{"account": "Cash — incentive received", "amount": R2(inc)}] if inc else []),
          "credit": [{"account": "Lease liability", "amount": R2(pv)}] +
                    ([{"account": "Cash — initial direct costs", "amount": R2(idc)}] if idc else []) +
                    ([{"account": "Provision for restoration", "amount": R2(rest)}] if rest else [])}]
    for x in rows:
        j.append({"period": x["period"], "narration": f"Period {x['period']} — interest on lease liability",
                  "debit": [{"account": "Interest expense", "amount": x["interest"]}],
                  "credit": [{"account": "Lease liability", "amount": x["interest"]}]})
        if tds_rate and tds_borne_by in ("lessor", "lessee"):
            tds_amt = R2(x["payment"] * tds_rate)
            if tds_borne_by == "lessor":
                # Withheld from what the lessor is paid; the lessee's own cash outflow is unchanged.
                j.append({"period": x["period"], "narration": f"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"] - tds_amt)},
                                    {"account": "TDS payable", "amount": tds_amt}]})
            else:
                # Lessor receives the full contractual payment; the lessee separately bears the TDS as an extra outflow.
                j.append({"period": x["period"], "narration": f"Period {x['period']} — lease payment (lessor receives gross)",
                          "debit": [{"account": "Lease liability", "amount": x["payment"]}],
                          "credit": [{"account": "Cash — to lessor", "amount": x["payment"]}]})
                j.append({"period": x["period"], "narration": f"Period {x['period']} — TDS on rent borne by lessee, remitted to IRD",
                          "debit": [{"account": "TDS expense (borne by lessee)", "amount": tds_amt}],
                          "credit": [{"account": "Cash — TDS remittance", "amount": tds_amt}]})
        else:
            j.append({"period": x["period"], "narration": f"Period {x['period']} — lease payment",
                      "debit": [{"account": "Lease liability", "amount": x["payment"]}],
                      "credit": [{"account": "Cash", "amount": x["payment"]}]})
        j.append({"period": x["period"], "narration": f"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


def lease_schedule(data, fy=None):
    """
    data: {payment, rent_steps: [{from_period, payment}], periods, frequency
           ('monthly'|'quarterly'|'half_yearly'|'annual'), annual_rate,
           timing ('advance'|'arrear'), initial_direct_costs, incentives,
           restoration_provision, short_term_or_low_value, start_date_ad,
           tds_borne_by ('lessor'|'lessee'|'none'),
           modifications: [{at_period, new_payment, new_annual_rate,
                            new_remaining_periods}]}
    Not statutory except the TDS rate on rent, which comes from rate_master
    like everything else.

    NFRS 16's recognition exemption: a short-term lease (12 months or less, no
    purchase option) or a lease of a low-value underlying asset is not
    capitalised at all — it's expensed straight-line. `rent_steps` lets the
    payment escalate on any pattern (year-on-year, one increase then flat for
    two years, anything) rather than a single named formula — inception PV
    sums each period's own payment, not a flat annuity. A modification
    remeasures the liability at the new discount rate against the revised
    remaining payments and adjusts the ROU asset by the difference (a gain
    only reaches profit or loss if the ROU would otherwise go negative) —
    the NFRS 16 treatment, not a profit-or-loss gain/loss the way a loan
    modification is.
    """
    base_pmt = float(data.get("payment", 0) or 0)
    rent_steps = data.get("rent_steps") or []
    n = int(data.get("periods", 0) or 0)
    if (base_pmt <= 0 and not rent_steps) or n <= 0:
        return {"rows": [], "journals": [], "journals_by_fy": {}, "summary": {}, "unverified": []}

    frequency = data.get("frequency") or "monthly"
    ppy = FREQ_PERIODS_PER_YEAR.get(frequency, 12)
    start_date_ad = data.get("start_date_ad")

    tds_rates, tds_unverified = rate_dict("payroll", fy)
    tds_rate = _val(tds_rates, "rent_tds_rate", 0) or 0
    tds_borne_by = data.get("tds_borne_by") or "none"

    if data.get("short_term_or_low_value"):
        rows = [{"period": i, "expense": R2(_lease_payment_at(i, base_pmt, rent_steps))} for i in range(1, n + 1)]
        journals = [{"period": x["period"], "narration": f"Period {x['period']} — short-term/low-value lease payment",
                     "debit": [{"account": "Lease expense", "amount": x["expense"]}],
                     "credit": [{"account": "Cash", "amount": x["expense"]}]} for x in rows]
        dates = _period_dates(start_date_ad, frequency, n)
        if dates:
            _attach_dates(rows, dates)
        fiscal_years = _fiscal_years_for_grouping(dates)
        return {
            "regime": "exempt", "rows": rows, "journals": journals,
            "journals_by_fy": _group_journals_by_fy(journals, dates, fiscal_years),
            "summary": {"total_payments": R2(sum(x["expense"] for x in rows)), "periods": n, "frequency": frequency,
                        "note": "Short-term / low-value recognition exemption elected (NFRS 16) — "
                                "expensed straight-line, not capitalised."},
            "unverified": [],
        }

    ar = float(data.get("annual_rate", 0) or 0) / 100.0
    advance = data.get("timing", "arrear") == "advance"
    mods = sorted((data.get("modifications") or []), key=lambda m: int(m.get("at_period", 0) or 0))

    payments = [_lease_payment_at(i, base_pmt, rent_steps) for i in range(1, n + 1)]
    r = ar / ppy
    pv = _lease_pv(payments, r, advance)

    idc = float(data.get("initial_direct_costs", 0) or 0)
    inc = float(data.get("incentives", 0) or 0)
    rest = float(data.get("restoration_provision", 0) or 0)
    rou = pv + idc - inc + rest
    rou_monthly = rou / n

    segments, cursor, cur_rate, total_n = [], 0, ar, n
    for m in mods:
        at = int(m.get("at_period", 0) or 0)
        if at <= cursor or at >= total_n:
            continue
        segments.append({"start": cursor + 1, "end": at, "rate": cur_rate})
        cursor = at
        if m.get("new_annual_rate") not in (None, ""):
            cur_rate = float(m["new_annual_rate"]) / 100.0
        if m.get("new_remaining_periods"):
            total_n = at + int(m["new_remaining_periods"])
    segments.append({"start": cursor + 1, "end": total_n, "rate": cur_rate})

    rows, liability, carrying = [], pv, rou
    remeasurements = []
    for seg_idx, seg in enumerate(segments):
        seg_r = seg["rate"] / ppy
        if seg_idx > 0:
            m = mods[seg_idx - 1]
            remaining_payments = []
            for k, i in enumerate(range(seg["start"], seg["end"] + 1)):
                if m.get("new_payment") not in (None, ""):
                    remaining_payments.append(float(m["new_payment"]))
                else:
                    remaining_payments.append(_lease_payment_at(i, base_pmt, rent_steps))
            new_liability = _lease_pv(remaining_payments, seg_r, advance)
            adjustment = new_liability - liability
            carrying_before = carrying
            carrying = carrying_before + adjustment
            pl_gain = 0.0
            if carrying < 0:
                pl_gain = -carrying
                carrying = 0.0
            remeasurements.append({"at_period": seg["start"] - 1, "old_liability": R2(liability),
                                   "new_liability": R2(new_liability), "rou_adjustment": R2(adjustment),
                                   "rou_carrying_after": R2(carrying), "pl_gain": R2(pl_gain)})
            liability = new_liability
            remaining_n = seg["end"] - seg["start"] + 1
            rou_monthly = carrying / remaining_n if remaining_n else 0.0
        for k, i in enumerate(range(seg["start"], seg["end"] + 1)):
            pmt = remaining_payments[k] if seg_idx > 0 else payments[i - 1]
            if advance:
                opening = liability
                principal = pmt
                interest = (opening - pmt) * seg_r
                closing = opening - pmt + interest
            else:
                opening = liability
                interest = opening * seg_r
                principal = pmt - interest
                closing = opening - principal
            amort = rou_monthly if i < total_n else carrying
            rows.append({"period": i, "opening_liability": R2(opening), "payment": R2(pmt),
                         "interest": R2(interest), "principal": R2(principal),
                         "closing_liability": R2(max(0.0, closing)),
                         "rou_amortisation": R2(amort), "rou_carrying": R2(max(0.0, carrying - amort))})
            liability = closing
            carrying -= amort

    _classify_current(rows, "principal", "closing_liability", ppy)

    dates = _period_dates(start_date_ad, frequency, total_n)
    if dates:
        _attach_dates(rows, dates)

    journals = _lease_journals(rows, rou, pv, idc, inc, rest, tds_rate, tds_borne_by)
    for rm in remeasurements:
        if rm["pl_gain"]:
            journals.append({"period": rm["at_period"],
                             "narration": f"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"]}]})
    fiscal_years = _fiscal_years_for_grouping(dates)
    journals_by_fy = _group_journals_by_fy(journals, dates, fiscal_years)

    return {
        "regime": "capitalised", "rows": rows, "journals": journals, "journals_by_fy": journals_by_fy,
        "remeasurements": remeasurements,
        "summary": {"initial_liability": R2(pv), "rou_asset": R2(rou),
                    "total_payments": R2(sum(x["payment"] for x in rows)),
                    "total_interest": R2(sum(x["interest"] for x in rows)),
                    "monthly_amortisation": R2(rou / n), "periods": total_n, "frequency": frequency,
                    "tds_rate": tds_rate if tds_borne_by in ("lessor", "lessee") else None,
                    "tds_borne_by": tds_borne_by,
                    "start_date_ad": start_date_ad,
                    "start_date_bs_approx": _bs_label(start_date_ad) if start_date_ad else None},
        "unverified": tds_unverified if tds_borne_by in ("lessor", "lessee") else [],
    }


# ------------------------------------------------- 8. FINANCIAL STATEMENTS (TB → FS)
#
# Pure classification/aggregation — no statutory rate is involved, so this engine
# never touches rate_master. HEADS is the financial-statement taxonomy (line items,
# section, sub-group, natural balance side); RULES is the ordered keyword list used
# to suggest a head for a ledger line. Both are structural/presentation constants,
# not Finance-Act numbers, so — like TRANCHE_BY_MONTH above — they live in code.

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"},
]
HEAD = {h["id"]: h for h in HEADS}

RULES = [
    (r"capital work|c\.?w\.?i\.?p", "cwip"),
    (r"accumulated deprec|acc\.? dep|provision for deprec", "acc_dep"),
    (r"deferred tax liab", "dtl"),
    (r"deferred tax asset", "dta"),
    (r"deferred tax", "deferred_tax"),
    (r"deprec|amorti[sz]", "depreciation"),
    (r"interest on|bank charge|finance cost|interest expense|loan interest|processing fee", "finance"),
    (r"share capital|equity share|paid.?up capital|proprietor|partner.?s capital|capital account", "share_capital"),
    (r"retained earning|accumulated profit|profit and loss account|general reserve|reserve|surplus", "reserves"),
    (r"opening stock|opening inventor", "opening_stock"),
    (r"closing stock|closing inventor", "inventories"),
    (r"bank overdraft|cash credit|o/d account|working capital loan|short.?term borrow", "st_borrow"),
    (r"secured loan|unsecured loan|term loan|debenture|long.?term borrow|vehicle loan|hire purchase|loan from bank|bank loan", "lt_borrow"),
    (r"sundry creditor|trade payable|creditors for goods|accounts payable|payable to suppl", "trade_pay"),
    (r"provision for tax|income tax provision|tax payable", "tax_prov"),
    (r"gratuity|leave encash|provision for employee|retirement benefit", "lt_prov"),
    (r"tds payable|tax deducted at source payable|withholding.*payable", "tds_payable"),
    (r"vat payable|value added tax payable", "vat_payable"),
    (r"outstanding|expenses payable|audit fee payable|advance from customer|unearned|duties and taxes", "other_cl"),
    (r"land|building|plant|machinery|furniture|fixture|vehicle|computer|equipment|fixed asset|leasehold improve", "ppe"),
    (r"goodwill|software|patent|trademark|intangible", "intangible"),
    (r"investment", "nc_inv"),
    (r"security deposit|deposit with|long.?term advance", "lt_adv"),
    (r"stock|inventor|raw material stock|finished goods|work in progress", "inventories"),
    (r"sundry debtor|trade receivable|accounts receivable|debtor|receivable from custom", "trade_rec"),
    (r"cash in hand|petty cash|cash at bank|bank balance|bank account|bank a/c|^cash$|^bank|fixed deposit|fd with", "cash"),
    (r"advance to|prepaid|tds receivable|vat receivable|input vat|advance income tax|loans and advances|staff advance", "st_adv"),
    (r"(?<!cost of )sales|revenue from|service income|job work|turnover|contract income", "revenue"),
    (r"interest income|interest received|discount received|other income|rent received|commission received|profit on sale|misc.*income", "other_income"),
    (r"purchase|material consum", "purchases"),
    (r"wages|factory|carriage inward|freight inward|power and fuel|manufacturing|direct expen|labour|labor", "direct_exp"),
    (r"salar|staff welfare|bonus|provident|employee benefit|remuneration|allowance to staff", "employee"),
    (r"current tax|income tax expense|tax expense", "current_tax"),
    (r"audit fee|professional fee|legal|consultanc|registration fee|renewal", "other_exp"),
    (r"^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"),
]
_COMPILED_RULES = [(re.compile(p, re.I), head) for p, head in RULES]


def auto_head(name, group):
    s = f"{name or ''} {group or ''}".lower()
    for rx, head in _COMPILED_RULES:
        if rx.search(s):
            return head
    return ""


def fin_statements(fy, data):
    """
    data: {entity: {...}, rows: [{name, group, dr, cr, prev, opening_dr, opening_cr, head,
           source_module, source_workfile_id, source_version, source_unverified, source_movement}],
           closing_stock: {current, previous}, accounting_policy, disclosures}
    No statutory number is involved — `unverified` is always empty. opening_dr/opening_cr and the
    source_* fields are optional pass-through: a row pulled in from another module's workfile
    (see the "Pull from other modules" panel) carries provenance so the app can flag it stale or
    still-unreviewed, and — for PPE/inventory — the source's own movement schedule, richer than
    anything derivable from a bare opening/closing trial-balance figure.
    """
    data = data or {}
    entity = data.get("entity") or {}
    rows = []
    for r in data.get("rows") or []:
        dr, cr = R2(r.get("dr")), R2(r.get("cr"))
        head = r.get("head") or auto_head(r.get("name", ""), r.get("group", ""))
        prev = r.get("prev")
        if prev not in (None, ""):
            prev = R2(prev)
            # "Previous year" is one plain field, not a Dr/Cr pair like
            # Opening — a preparer naturally types a positive balance
            # regardless of which side of the ledger the account sits on
            # (e.g. "trade payables were 400,000 last year"). disp() below
            # negates credit-natured heads once already (to turn dr-minus-cr
            # into a positive liability/equity figure); align prev to that
            # same dr-minus-cr convention here so it isn't negated twice (or
            # zero times) and end up with the wrong sign on the comparative
            # column. Falls back to this year's own dr/cr side when the head
            # isn't classified yet.
            credit_side = HEAD[head]["nat"] == "credit" if head in HEAD else cr > dr
            prev = -abs(prev) if credit_side else abs(prev)
        else:
            prev = None
        rows.append({
            "name": r.get("name", ""), "group": r.get("group", ""), "dr": dr, "cr": cr,
            "net": dr - cr, "prev": prev,
            "opening_dr": R2(r.get("opening_dr")), "opening_cr": R2(r.get("opening_cr")),
            "head": head,
            "flag": bool(r.get("flag")),
            "source_module": r.get("source_module"), "source_workfile_id": r.get("source_workfile_id"),
            "source_version": r.get("source_version"), "source_unverified": bool(r.get("source_unverified")),
            "source_movement": r.get("source_movement"),
        })

    cs = data.get("closing_stock") or {}
    cs_cur, cs_prev = R2(cs.get("current")), R2(cs.get("previous"))
    if cs_cur or cs_prev:
        rows.append({"name": "Closing stock (adjustment)", "group": "", "dr": cs_cur, "cr": 0,
                     "net": cs_cur, "prev": cs_prev or None, "opening_dr": 0, "opening_cr": 0,
                     "head": "inventories", "flag": True, "source_module": None, "source_workfile_id": None,
                     "source_version": None, "source_unverified": False, "source_movement": None})
        rows.append({"name": "Closing stock credited to profit and loss", "group": "", "dr": 0, "cr": cs_cur,
                     "net": -cs_cur, "prev": (-cs_prev if cs_prev else None), "opening_dr": 0, "opening_cr": 0,
                     "head": "closing_stock", "flag": True, "source_module": None, "source_workfile_id": None,
                     "source_version": None, "source_unverified": False, "source_movement": None})

    total_dr = R2(sum(r["dr"] for r in rows))
    total_cr = R2(sum(r["cr"] for r in rows))
    diff = R2(total_dr - total_cr)
    total_opening_dr = R2(sum(r["opening_dr"] for r in rows))
    total_opening_cr = R2(sum(r["opening_cr"] for r in rows))
    opening_diff = R2(total_opening_dr - total_opening_cr)

    by = {}
    for r in rows:
        if not r["head"]:
            continue
        b = by.setdefault(r["head"], {"net": 0.0, "prev": 0.0, "has_prev": False,
                                       "opening": 0.0, "has_opening": False, "items": []})
        b["net"] += r["net"]
        if r["prev"] is not None:
            b["prev"] += r["prev"]
            b["has_prev"] = True
        opening_net = r["opening_dr"] - r["opening_cr"]
        if r["opening_dr"] or r["opening_cr"]:
            b["opening"] += opening_net
            b["has_opening"] = True
        b["items"].append(r)

    def disp(hid, key="net"):
        b = by.get(hid)
        if not b:
            return 0.0
        v = b[key]
        return -v if HEAD[hid]["nat"] == "credit" else v

    income_ids = ["revenue", "other_income"]
    exp_ids = ["opening_stock", "purchases", "closing_stock", "direct_exp", "employee", "finance", "depreciation", "other_exp"]
    tax_ids = ["current_tax", "deferred_tax"]

    def ssum(ids, key="net"):
        return sum(disp(i, key) for i in ids)

    income, expense = ssum(income_ids), ssum(exp_ids)
    pbt = income - expense
    tax = ssum(tax_ids)
    pat = pbt - tax
    income_p, expense_p = ssum(income_ids, "prev"), ssum(exp_ids, "prev")
    pbt_p = income_p - expense_p
    tax_p = ssum(tax_ids, "prev")
    pat_p = pbt_p - tax_p
    any_prev = any(b["has_prev"] for b in by.values())

    notes, note_no_box = [], [0]

    def note(hid, extra=None):
        b = by.get(hid)
        if not b and not extra:
            return None
        note_no_box[0] += 1
        movement = None
        if b:
            for it in b["items"]:
                if it.get("source_movement"):
                    movement = it["source_movement"]
                    break
        notes.append({"n": note_no_box[0], "id": hid, "label": HEAD[hid]["label"],
                      "items": b["items"] if b else [], "extra": extra, "movement": movement})
        return note_no_box[0]

    bs_rows, last_sec, last_grp = [], "", ""
    assets_tot = eq_tot = assets_tot_p = eq_tot_p = 0.0
    assets_tot_open = eq_tot_open = 0.0
    any_opening = any(b.get("has_opening") for b in by.values())
    for hid in [h["id"] for h in HEADS if h["st"] == "bs"]:
        h = HEAD[hid]
        b = by.get(hid)
        present = bool(b) or hid == "reserves"
        if not present:
            continue
        v, p, o = disp(hid), disp(hid, "prev"), disp(hid, "opening")
        if hid == "reserves":
            v += pat
            p += pat_p
        if h["sec"] == "ASSETS":
            assets_tot += v
            assets_tot_p += p
            assets_tot_open += o
        else:
            eq_tot += v
            eq_tot_p += p
            eq_tot_open += o
        n = note(hid, {"pat": R2(pat), "pat_p": R2(pat_p)} if hid == "reserves" else None)
        if h["sec"] != last_sec:
            bs_rows.append({"t": "sec", "l": h["sec"]}); last_sec = h["sec"]; last_grp = ""
        if h["grp"] != last_grp:
            bs_rows.append({"t": "sub", "l": h["grp"]}); last_grp = h["grp"]
        bs_rows.append({"t": "row", "l": h["label"], "n": n, "v": R2(v),
                        "p": R2(p) if (b and b["has_prev"]) else None,
                        "o": R2(o) if (b and b["has_opening"]) else None,
                        "flag": bool(b and any(it["flag"] for it in b["items"]))})
        if hid == "acc_dep" and by.get("ppe"):
            bs_rows.append({"t": "row", "l": "Net block", "v": R2(disp("ppe") + v), "p": None, "o": None, "sub": True})

    pl_rows = [{"t": "sec", "l": "INCOME"}]
    for hid in income_ids:
        b = by.get(hid)
        if not b:
            continue
        pl_rows.append({"t": "row", "l": HEAD[hid]["label"], "n": note(hid), "v": R2(disp(hid)),
                        "p": R2(disp(hid, "prev")) if b["has_prev"] else None})
    pl_rows.append({"t": "tot", "l": "Total income", "v": R2(income), "p": R2(income_p) if any_prev else None})
    pl_rows.append({"t": "sec", "l": "EXPENSES"})
    for hid in exp_ids:
        b = by.get(hid)
        if not b:
            continue
        pl_rows.append({"t": "row", "l": HEAD[hid]["label"], "n": note(hid), "v": R2(disp(hid)),
                        "p": R2(disp(hid, "prev")) if b["has_prev"] else None,
                        "flag": any(it["flag"] for it in b["items"])})
    pl_rows.append({"t": "tot", "l": "Total expenses", "v": R2(expense), "p": R2(expense_p) if any_prev else None})
    pl_rows.append({"t": "tot", "l": "Profit before tax", "v": R2(pbt), "p": R2(pbt_p) if any_prev else None})
    for hid in tax_ids:
        b = by.get(hid)
        if not b:
            continue
        pl_rows.append({"t": "row", "l": HEAD[hid]["label"], "n": note(hid), "v": R2(disp(hid)),
                        "p": R2(disp(hid, "prev")) if b["has_prev"] else None})
    pl_rows.append({"t": "tot", "l": "Profit for the year", "v": R2(pat), "p": R2(pat_p) if any_prev else None})

    tie = R2(assets_tot - eq_tot)
    unmapped = [{"name": r["name"], "net": R2(r["net"])} for r in rows if not r["head"]]

    return {
        "entity": entity,
        "accounting_policy": data.get("accounting_policy") or "",
        "disclosures": data.get("disclosures") or {},
        "balance_sheet": {"rows": bs_rows,
                          "total_equity_liabilities": R2(eq_tot), "total_assets": R2(assets_tot),
                          "total_equity_liabilities_prev": R2(eq_tot_p) if any_prev else None,
                          "total_assets_prev": R2(assets_tot_p) if any_prev else None,
                          "total_equity_liabilities_opening": R2(eq_tot_open) if any_opening else None,
                          "total_assets_opening": R2(assets_tot_open) if any_opening else None,
                          "any_opening": any_opening,
                          "tie": tie, "tied": abs(tie) < 0.005},
        "profit_and_loss": {"rows": pl_rows, "profit_before_tax": R2(pbt), "tax": R2(tax), "profit_for_year": R2(pat)},
        "notes": notes,
        "unmapped": unmapped,
        "any_prev": any_prev,
        "control": {"total_dr": total_dr, "total_cr": total_cr, "diff": diff, "tied": abs(diff) < 0.005},
        "opening_control": {"total_opening_dr": total_opening_dr, "total_opening_cr": total_opening_cr,
                            "diff": opening_diff, "tied": abs(opening_diff) < 0.005},
        "unverified": [],
    }


# --------------------------------------------------------- 9. GOVERNMENT GRANTS
#
# NAS 20 / IAS 20 grant accounting: recognition, measurement, deferred-income
# scheduling and journals for six grant types. Day-exact across fiscal-year
# boundaries so a grant received years ago reconstructs correctly.
#
# Scope note: this first pass ports the four core engines (loan, asset, income,
# forgivable) plus mid-loan rate-reset events. It does not yet port the source
# tool's opening prior-period restatement, repay/reverse clawback events or the
# deferred-tax mini-module — those are flagged as follow-up work, not silently
# dropped. None of the numbers below are statutory rates (they're per-grant terms
# the user enters), so `unverified` is always empty.

def _gd(s):
    if not s:
        return None
    try:
        y, m, d = str(s).split("-")[:3]
        return date(int(y), int(m), int(d))
    except Exception:
        return None


def _giso(d):
    return d.isoformat() if d else ""


def _gadd_months(d, m):
    total = d.year * 12 + (d.month - 1) + m
    y, mo = divmod(total, 12)
    day = d.day
    while True:
        try:
            return date(y, mo + 1, day)
        except ValueError:
            day -= 1


def _gadd_days(d, k):
    return d + timedelta(days=k)


def _gdays(a, b):
    return (b - a).days


def _mon_idx(d):
    return d.year * 12 + (d.month - 1)


def _mon_start(mi):
    y, m = divmod(mi, 12)
    return date(y, m + 1, 1)


def _mon_end(mi):
    return _mon_start(mi + 1) - timedelta(days=1)


def _fy_parts(fy_start):
    m, d = (fy_start or "07-16").split("-")
    return int(m), int(d)


def _fy_start_of(y, fy_start):
    m, d = _fy_parts(fy_start)
    return date(y, m, d)


def _fy_year(d, fy_start):
    if not d:
        return None
    return d.year if d >= _fy_start_of(d.year, fy_start) else d.year - 1


def _fy_range(y, fy_start):
    return _fy_start_of(y, fy_start), _fy_start_of(y + 1, fy_start) - timedelta(days=1)


def _fy_spread(a, b, amt, out, fy_start):
    if not a or not b or b < a:
        if a:
            y0 = _fy_year(a, fy_start)
            out[y0] = out.get(y0, 0.0) + amt
        return out
    tot = _gdays(a, b) + 1
    cursor, guard = a, 0
    while cursor <= b and guard < 400:
        guard += 1
        y = _fy_year(cursor, fy_start)
        _, fr_b = _fy_range(y, fy_start)
        e = fr_b if fr_b < b else b
        out[y] = out.get(y, 0.0) + amt * ((_gdays(cursor, e) + 1) / tot)
        if e >= b:
            break
        cursor = e + timedelta(days=1)
    return out


def _fy_label(y, fy_start, fy_label_mode):
    if y is None:
        return "—"
    m, d = _fy_parts(fy_start)
    if m == 1 and d == 1:
        return str(y)
    if fy_label_mode == "bs":
        b = y + 57
        return f"{b}/{(b + 1) % 100:02d}"
    return f"{y}/{(y + 1) % 100:02d}"


def _add_fy(m, y, v):
    m[y] = m.get(y, 0.0) + v
    return m


def _sum_all(m):
    return sum(m.values())


def _dep_series(cost, resid, months, method, rate, start_d, uop_rows, fy_start):
    out = []
    if months <= 0:
        return out
    mi0 = _mon_idx(start_d)
    if method == "wdv":
        wdv = cost
        yrs = math.ceil(months / 12)
        rem = months
        for y in range(yrs):
            m_this = min(12, rem)
            rem -= m_this
            annual = wdv * (rate / 100) * (m_this / 12)
            if wdv - annual < resid:
                annual = max(0.0, wdv - resid)
            if y == yrs - 1:
                annual = max(0.0, wdv - resid)
            for i in range(m_this):
                out.append({"mi": mi0 + y * 12 + i, "dep": annual / m_this if m_this else 0.0})
            wdv -= annual
    elif method == "uop":
        tot_u = sum(float(r.get("units") or 0) for r in (uop_rows or []))
        if tot_u <= 0:
            return out
        for r in uop_rows:
            amt = (cost - resid) * float(r.get("units") or 0) / tot_u
            fr_a, fr_b = _fy_range(int(r.get("fy")), fy_start)
            m1, m2 = _mon_idx(fr_a), _mon_idx(fr_b)
            cnt = m2 - m1 + 1
            for i in range(cnt):
                out.append({"mi": m1 + i, "dep": amt / cnt if cnt else 0.0})
    else:
        base = cost - resid
        for i in range(months):
            out.append({"mi": mi0 + i, "dep": base / months})
    return out


def _months_of(g):
    return round(float(g.get("life") or 0)) if g.get("life_unit") == "months" else round(float(g.get("life") or 0) * 12)


def _loan_flows(g, events, fy_start):
    P = float(g.get("principal") or 0)
    f = float(g.get("freq") or 12) or 12
    N = max(1, round(float(g.get("tenor") or 0)))
    gr = max(0, min(N - 1, round(float(g.get("grace") or 0))))
    ic = float(g.get("rate_c") or 0) / 100 / f
    d0 = _gd(g.get("disb_date")) or _gd(g.get("receipt_date")) or date.today()
    custom = {round(float(c.get("p") or 0)): float(c.get("amt") or 0) for c in (g.get("custom") or [])}

    resets = sorted(
        ({"d": _gd(e.get("date")), "r": float(e.get("amount") or 0) / 100 / f}
         for e in (events or []) if e.get("grant_id") == g.get("id") and e.get("kind") == "rate"),
        key=lambda x: x["d"] or date.min)
    resets = [r for r in resets if r["d"]]

    rows, bal, lvl = [], P, None
    mode = g.get("mode") or "emi"
    for p in range(1, N + 1):
        ds = _gadd_months(d0, round((p - 1) * 12 / f))
        de = _gadd_months(d0, round(p * 12 / f))
        rt = ic
        for x in resets:
            if x["d"] <= ds:
                rt = x["r"]
        int_ = bal * rt
        if p <= gr:
            pay = 0.0 if g.get("grace_type") == "full" else int_
        elif mode == "custom":
            pay = custom.get(p, 0.0)
            if p == N and abs(bal + int_ - pay) > 0.01:
                pay = bal + int_
        elif mode == "bullet":
            pay = int_ + (bal if p == N else 0.0)
        elif mode == "zero":
            pay = (bal + int_) if p == N else 0.0
        elif mode == "epi":
            k1 = N - gr
            pay = (bal / (N - p + 1) if g.get("grace_type") == "full" else P / k1) if (bal > 0 and P > 0) else 0.0
            pay += int_
            if p == N:
                pay = bal + int_
        else:  # emi
            if lvl is None:
                k, b, i2 = N - gr, bal, rt
                lvl = b * i2 / (1 - (1 + i2) ** (-k)) if i2 else b / k
            pay = lvl
            if p == N:
                pay = bal + int_
        pr = pay - int_
        close = bal + int_ - pay
        rows.append({"p": p, "ds": ds, "de": de, "open": bal, "cint": int_, "pay": pay,
                     "cprin": pr, "close": close, "rate": rt * f * 100})
        bal = close
        if abs(bal) < 0.005:
            bal = 0.0
    return rows


def _eng_loan(g, events, fy_start):
    f = float(g.get("freq") or 12) or 12
    im = float(g.get("rate_m") or 0) / 100 / f
    P = float(g.get("principal") or 0)
    rows = _loan_flows(g, events, fy_start)

    fv = R2(sum(r["pay"] / ((1 + im) ** r["p"]) for r in rows))
    grant = R2(P - fv)

    W, open_, tot = [], fv, 0.0
    for i, r in enumerate(rows):
        mint = open_ * im
        close = open_ + mint - r["pay"]
        if i == len(rows) - 1:
            mint = r["pay"] - open_
            close = 0.0
        r["fv_open"], r["mint"], r["fv_close"] = open_, mint, close
        w = max(0.0, mint - r["cint"])
        W.append(w); tot += w
        open_ = close
    if tot <= 0.0001:
        W = [1.0] * len(rows); tot = len(rows) or 1

    run = 0.0
    for i, r in enumerate(rows):
        r["rel"] = grant * W[i] / tot
        run += r["rel"]
        r["rel_cum"], r["di_open"], r["di_close"] = run, grant - run + r["rel"], grant - run

    release = g.get("release")
    if release == "sl" and rows:
        run = 0.0
        for r in rows:
            r["rel"] = grant / len(rows)
            run += r["rel"]
            r["rel_cum"], r["di_open"], r["di_close"] = run, grant - run + r["rel"], grant - run
    if release == "immediate":
        run = 0.0
        for i, r in enumerate(rows):
            r["rel"] = grant if i == 0 else 0.0
            run += r["rel"]
            r["rel_cum"], r["di_open"], r["di_close"] = run, grant - run + r["rel"], grant - run

    fy = {"income": {}, "interest": {}, "cint": {}, "cash": {}}
    jr = []
    d0 = _gd(g.get("disb_date")) or _gd(g.get("receipt_date"))
    for r in rows:
        _fy_spread(r["ds"], r["de"] - timedelta(days=1), r["mint"], fy["interest"], fy_start)
        _fy_spread(r["ds"], r["de"] - timedelta(days=1), r["cint"], fy["cint"], fy_start)
        if release == "immediate":
            if r["p"] == 1 and d0:
                _add_fy(fy["income"], _fy_year(d0, fy_start), r["rel"])
        else:
            _fy_spread(r["ds"], r["de"] - timedelta(days=1), r["rel"], fy["income"], fy_start)
        _add_fy(fy["cash"], _fy_year(r["de"], fy_start), -r["pay"])

    rel_by_asset = False
    if release == "asset" and float(g.get("asset_cost") or 0) > 0:
        ds2 = _dep_series(float(g.get("asset_cost") or 0), float(g.get("residual") or 0), _months_of(g),
                          g.get("dep_method") or "slm", float(g.get("wdv_rate") or 0),
                          _gd(g.get("asset_date")) or d0 or date.today(), g.get("uop"), fy_start)
        td = sum(x["dep"] for x in ds2)
        if td > 0:
            rel_by_asset = True
            fy["income"] = {}
            for x in ds2:
                _fy_spread(_mon_start(x["mi"]), _mon_end(x["mi"]), grant * x["dep"] / td, fy["income"], fy_start)
            for r in rows:
                r["rel"], r["di_close"] = None, None
    if release == "immediate":
        for r in rows:
            r["rel"], r["di_close"] = 0.0, 0.0

    name = g.get("name", "")
    jr.append({"date": _giso(d0), "title": "Receipt of concessional government loan", "lines": [
        {"ac": "Bank / Cash", "dr": R2(P), "cr": 0},
        {"ac": f"Government loan — {name} (at fair value)", "dr": 0, "cr": fv},
        {"ac": ("Government grant income (P/L)" if release == "immediate" else "Deferred government grant income"),
         "dr": 0, "cr": grant},
    ], "narration": f"Loan received at a concessional rate against a market rate of {float(g.get('rate_m') or 0):.2f}%; "
                    f"loan recognised at fair value {fv:,.2f} and the {grant:,.2f} benefit recognised as a "
                    f"government grant (NFRS 9 / NAS 20.10A)."})
    for r in rows:
        jr.append({"date": _giso(r["de"]), "title": f"Loan servicing — period {r['p']}", "lines": [
            {"ac": "Interest expense", "dr": R2(r["mint"]), "cr": 0},
            {"ac": f"Government loan — {name}", "dr": R2(r["pay"] - r["mint"]), "cr": 0},
            {"ac": "Bank / Cash", "dr": 0, "cr": R2(r["pay"])},
        ], "narration": f"Interest at the effective (market) rate and instalment of {r['pay']:,.2f} for period {r['p']}."})
        if not rel_by_asset and release != "immediate" and abs(r.get("rel") or 0) > 0.004:
            jr.append({"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 rel_by_asset:
        for y in sorted(fy["income"]):
            amt = fy["income"][y]
            if abs(amt) < 0.004:
                continue
            _, fr_b = _fy_range(y, fy_start)
            jr.append({"date": _giso(fr_b), "title": f"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": rows, "fv": fv, "grant": grant, "principal": R2(P), "fy": fy,
            "journals": jr, "rel_by_asset": rel_by_asset,
            "total_pay": R2(sum(r["pay"] for r in rows)), "total_cint": R2(sum(r["cint"] for r in rows)),
            "total_mint": R2(sum(r["mint"] for r in rows))}


def _eng_asset(g, fy_start):
    G = float(g.get("amount") or 0)
    cost = float(g.get("asset_cost") or 0)
    res = float(g.get("residual") or 0)
    M = _months_of(g)
    start = _gd(g.get("asset_date")) or _gd(g.get("receipt_date")) or date.today()
    gs = _gd(g.get("grant_start")) or _gd(g.get("receipt_date")) or start
    ds = _dep_series(cost, res, M, g.get("dep_method") or "slm", float(g.get("wdv_rate") or 0), start, g.get("uop"), fy_start)
    ds_n = _dep_series(max(0.0, cost - G), res, M, g.get("dep_method") or "slm", float(g.get("wdv_rate") or 0), start, g.get("uop"), fy_start)
    tot_dep = sum(x["dep"] for x in ds)
    rel_start_mi = _mon_idx(gs)

    denom = sum(x["dep"] for x in ds if x["mi"] >= rel_start_mi) if g.get("catchup") == "prospective" else tot_dep
    if denom <= 0:
        denom = 1.0

    rows, cum_catch = [], 0.0
    for i, x in enumerate(ds):
        if g.get("catchup") == "prospective":
            rel = G * x["dep"] / denom if x["mi"] >= rel_start_mi else 0.0
        else:
            rel = G * x["dep"] / denom
            if x["mi"] < rel_start_mi:
                cum_catch += rel
                rel = 0.0
        rows.append({"mi": x["mi"], "dep": x["dep"], "rel": rel, "dep_net": ds_n[i]["dep"] if i < len(ds_n) else 0.0})
    if g.get("catchup") != "prospective" and cum_catch > 0:
        for r in rows:
            if r["mi"] >= rel_start_mi:
                r["rel"] += cum_catch
                r["catchup"] = cum_catch
                break

    fy = {"income": {}, "dep": {}, "dep_net": {}, "cash": {}}
    for x in rows:
        _fy_spread(_mon_start(x["mi"]), _mon_end(x["mi"]), x["dep"], fy["dep"], fy_start)
        _fy_spread(_mon_start(x["mi"]), _mon_end(x["mi"]), x["dep_net"], fy["dep_net"], fy_start)
        _fy_spread(_mon_start(x["mi"]), _mon_end(x["mi"]), x["rel"], fy["income"], fy_start)
    _add_fy(fy["cash"], _fy_year(_gd(g.get("receipt_date")) or gs, fy_start), G)

    name = g.get("name", "")
    recv = g.get("receipt_date") or _giso(gs)
    jr = []
    if g.get("type") == "nonmonetary":
        if g.get("nm_basis") == "nominal":
            jr.append({"date": recv, "title": "Non-monetary government grant recorded at nominal amount", "lines": [
                {"ac": f"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.append({"date": recv, "title": "Non-monetary government grant recorded at fair value", "lines": [
                {"ac": f"Property, plant and equipment — {name}", "dr": R2(cost), "cr": 0},
                {"ac": "Deferred government grant income", "dr": 0, "cr": R2(G)}],
                "narration": f"Asset received free of charge recorded at fair value {cost:,.2f} with a "
                             f"corresponding grant (NAS/IAS 20.23)."})
    elif g.get("present") == "netcost":
        jr.append({"date": recv, "title": "Asset-related grant received — deducted from asset", "lines": [
            {"ac": "Bank / Cash", "dr": R2(G), "cr": 0},
            {"ac": f"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.append({"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": f"Grant received towards the cost of {name}, deferred and released over the life "
                         f"of the asset (NAS/IAS 20.24, 20.26)."})

    for y in sorted(fy["dep"]):
        _, fr_b = _fy_range(y, fy_start)
        dep = (fy["dep_net"].get(y) if g.get("present") == "netcost" else fy["dep"].get(y)) or 0.0
        rel = fy["income"].get(y) or 0.0
        if dep > 0.004:
            jr.append({"date": _giso(fr_b), "title": f"Depreciation for FY {y}", "lines": [
                {"ac": "Depreciation expense", "dr": R2(dep), "cr": 0},
                {"ac": f"Accumulated depreciation — {name}", "dr": 0, "cr": R2(dep)}],
                "narration": ("Depreciation on the asset net of the government grant." if g.get("present") == "netcost"
                             else "Depreciation on the gross cost of the asset.")})
        if g.get("present") != "netcost" and not (g.get("type") == "nonmonetary" and g.get("nm_basis") == "nominal") and rel > 0.004:
            jr.append({"date": _giso(fr_b), "title": f"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": rows, "fy": fy, "journals": jr, "grant": R2(G), "cost": R2(cost),
            "total_dep": R2(tot_dep), "net_cost": R2(max(0.0, cost - G)), "catchup": R2(cum_catch)}


def _eng_income(g, fy_start):
    G = float(g.get("amount") or 0)
    fy = {"income": {}, "cash": {}}
    recv = _gd(g.get("receipt_date")) or _gd(g.get("assurance")) or date.today()
    rows = []
    if g.get("type") == "pastcost" or g.get("inc_pattern") == "immediate":
        _add_fy(fy["income"], _fy_year(recv, fy_start), G)
        rows.append({"label": f"Recognised in full on {_giso(recv)}", "amount": R2(G)})
    elif g.get("inc_pattern") == "sched":
        tot = sum(float(r.get("cost") or 0) for r in (g.get("inc_sched") or []))
        for r in (g.get("inc_sched") or []):
            amt = G * float(r.get("cost") or 0) / tot if tot else 0.0
            _add_fy(fy["income"], int(r.get("fy")), amt)
            rows.append({"label": f"FY {r.get('fy')} — eligible cost {float(r.get('cost') or 0):,.2f}", "amount": R2(amt)})
    else:
        a = _gd(g.get("inc_start")) or recv
        b = _gd(g.get("inc_end")) or recv
        _fy_spread(a, b, G, fy["income"], fy_start)
        rows.append({"label": f"Spread evenly from {_giso(a)} to {_giso(b)}", "amount": R2(G)})
    _add_fy(fy["cash"], _fy_year(recv, fy_start), G)

    name = g.get("name", "")
    upfront = g.get("type") == "pastcost" or g.get("inc_pattern") == "immediate"
    net_exp = g.get("inc_present") == "netexp"
    jr = [{"date": _giso(recv),
          "title": "Government grant received — recognised immediately" if upfront else "Government grant received — deferred",
          "lines": [{"ac": "Bank / Cash", "dr": R2(G), "cr": 0},
                    {"ac": (("Related expense (grant credited against cost)" if net_exp else "Government grant income (P/L)")
                            if upfront else "Deferred government grant income"), "dr": 0, "cr": R2(G)}],
          "narration": ("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)." if upfront else
                       "Grant received towards costs of a specified period, deferred and recognised as those "
                       "costs are incurred (NAS/IAS 20.12, 20.16).")}]
    if not upfront:
        for y in sorted(fy["income"]):
            amt = fy["income"][y]
            if abs(amt) < 0.004:
                continue
            _, fr_b = _fy_range(y, fy_start)
            jr.append({"date": _giso(fr_b), "title": f"Recognition of grant income — FY {y}", "lines": [
                {"ac": "Deferred government grant income", "dr": R2(amt), "cr": 0},
                {"ac": ("Related expense (grant credited against cost)" if net_exp else "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": rows, "fy": fy, "journals": jr, "grant": R2(G)}


def _eng_forgivable(g, events, fy_start):
    res = _eng_loan(g, events, fy_start)
    fd = _gd(g.get("forgive_date"))
    amt = float(g.get("forgive_amt") or 0)
    if fd:
        bal = amt
        if not amt:
            bal = res["fv"]
            for r in res["rows"]:
                if r["de"] <= fd:
                    bal = r["fv_close"]
            part = next((r for r in res["rows"] if r["ds"] <= fd < r["de"]), None)
            if part:
                t = _gdays(part["ds"], part["de"] - timedelta(days=1)) + 1
                e = _gdays(part["ds"], fd) + 1
                bal += part["mint"] * max(0.0, min(1.0, e / t))
        for key in ("income", "interest", "cint", "cash"):
            trunc = {}
            for y, v in res["fy"][key].items():
                if y < _fy_year(fd, fy_start):
                    trunc[y] = v
                elif y == _fy_year(fd, fy_start):
                    fr_a, fr_b = _fy_range(y, fy_start)
                    t = _gdays(fr_a, fr_b) + 1
                    e = _gdays(fr_a, fd) + 1
                    trunc[y] = v * max(0.0, min(1.0, e / t))
            res["fy"][key] = trunc
        released = _sum_all(res["fy"]["income"])
        rest = res["grant"] - released
        if abs(rest) > 0.004:
            _add_fy(res["fy"]["income"], _fy_year(fd, fy_start), rest)
            res["journals"].append({"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."})
        _add_fy(res["fy"]["income"], _fy_year(fd, fy_start), bal)
        res["journals"] = [j for j in res["journals"] if not _gd(j["date"]) or _gd(j["date"]) <= fd]
        res["journals"].append({"date": _giso(fd), "title": "Forgiveness of government loan",
            "lines": [{"ac": f"Government loan — {g.get('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"], res["forgive_date"] = R2(bal), _giso(fd)
        res["rows"] = [r for r in res["rows"] if r["de"] <= fd]
    res["kind"] = "forgivable"
    return res


def _run_grant(g, events, fy_start):
    gtype = g.get("type")
    if gtype == "loan":
        res = _eng_loan(g, events, fy_start)
    elif gtype == "forgivable":
        res = _eng_forgivable(g, events, fy_start)
    elif gtype in ("asset", "nonmonetary"):
        res = _eng_asset(g, fy_start)
    else:
        res = _eng_income(g, fy_start)

    warn = []
    ad = _gd(g.get("assurance"))
    if ad and res["fy"]["income"]:
        first = min(res["fy"]["income"])
        if first < _fy_year(ad, fy_start):
            warn.append(f"Grant income is being recognised from FY {first}, which is before reasonable assurance "
                        f"was obtained on {g.get('assurance')}. NAS/IAS 20.7 does not permit recognition before that date.")

    running, closing = 0.0, {}
    for y in sorted(res["fy"]["income"]):
        running += res["fy"]["income"][y]
        closing[y] = R2(res["grant"] - running)

    for r in res.get("rows", []):
        r.pop("ds", None); r.pop("de", None)
    res["id"] = g.get("id")
    res["name"] = g.get("name", "")
    res["warn"] = warn
    res["deferred_income_closing_by_fy"] = closing
    res["fy"] = {k: {str(y): R2(v) for y, v in vv.items()} for k, vv in res["fy"].items()}
    return res


def grant_accounting(data):
    """
    data: {meta: {fy_start, fy_label}, grants: [...], events: [...]}
    No statutory rate involved in this first pass (deferred tax not yet ported) —
    `unverified` is always empty.
    """
    data = data or {}
    meta = data.get("meta") or {}
    fy_start = meta.get("fy_start") or "07-16"
    fy_label_mode = meta.get("fy_label") or "bs"
    events = data.get("events") or []

    grants = []
    all_fy = set()
    for g in data.get("grants") or []:
        res = _run_grant(g, events, fy_start)
        grants.append(res)
        all_fy.update(int(y) for y in res["deferred_income_closing_by_fy"])
        all_fy.update(int(y) for y in res["fy"].get("income", {}))

    consolidated = []
    for y in sorted(all_fy):
        row = {"fy": y, "fy_label": _fy_label(y, fy_start, fy_label_mode), "income": 0.0,
               "dep": 0.0, "interest": 0.0, "deferred_income_closing": 0.0, "cash": 0.0}
        for res in grants:
            row["income"] += res["fy"].get("income", {}).get(str(y), 0.0)
            row["dep"] += res["fy"].get("dep", {}).get(str(y), res["fy"].get("dep_net", {}).get(str(y), 0.0))
            row["interest"] += res["fy"].get("interest", {}).get(str(y), 0.0)
            row["deferred_income_closing"] += res["deferred_income_closing_by_fy"].get(y, 0.0)
            row["cash"] += res["fy"].get("cash", {}).get(str(y), 0.0)
        consolidated.append({k: (R2(v) if isinstance(v, float) else v) for k, v in row.items()})

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


# ---------------------------------------------------- 10. PROJECTION / FEASIBILITY
#
# Term-loan appraisal / feasibility / bank working-capital assessment. Statement
# generation (P&L/BS/CF), loan schedule, net trading assets and bank-limit
# methods, ratios, DCF appraisal, validation checks and a verdict — one pass,
# self-consistent by construction (the balance sheet is rebuilt from the same
# flows, not built independently, so `chk` near zero is the real tie check).
#
# `industry_default` (seeded, not rate_master — see schema.sql) supplies every
# planning default; a blank override falls back to it. Genuinely statutory
# pieces reachable from here (WDV pool rates, tax rate/holiday) are NOT yet
# routed through rate_master in this pass — the industry table's tax_rate/
# hol_rate/pool rates are carried as this module's own (unverified-by-nature)
# planning figures, matching the source tool's own explicit disclaimer that
# these must be confirmed against the Finance Act before issue. Flagged as
# follow-up, not silently treated as verified.

_POOL_RATES = {"A": 0.05, "B": 0.25, "C": 0.20, "D": 0.15}


def _pnum(v, dflt=0.0):
    if v is None or v == "":
        return dflt
    try:
        return float(v)
    except (TypeError, ValueError):
        return dflt


def _pmt(r, n, p):
    return p / n if r == 0 else p * r / (1 - (1 + r) ** (-n))


def _proj_loan_schedule(P, r, T, m, ltype, horizon, balloon_pct):
    H = int(max(horizon, T or 0)) + 1
    open_, int_, prin, close, cap = [], [], [], [], []
    if ltype == "none" or not (P > 0):
        z = [0.0] * H
        return {"open": z, "int": list(z), "prin": list(z), "close": list(z), "cap": list(z)}
    bal = P
    T = T or 1
    K = max(1, int(T) - int(m or 0))
    for t in range(1, H + 1):
        open_.append(bal)
        it, pr, cp = bal * r, 0.0, 0.0
        if ltype == "wconly":
            pr = 0.0
        elif ltype == "bullet":
            pr = bal if t == T else 0.0
        elif ltype in ("epi", "subsidised"):
            pr = 0.0 if t <= m else min(bal, P / K)
        elif ltype == "emi":
            inst = _pmt(r, K, P)
            pr = 0.0 if t <= m else min(bal, max(0.0, inst - it))
        elif ltype == "balloon":
            amort = P * (1 - _pnum(balloon_pct, 0.30))
            pr = 0.0 if t <= m else min(bal, amort / K)
            if t == T:
                pr = bal
        elif ltype == "stepup":
            w = list(range(1, K + 1))
            sw = sum(w)
            pr = 0.0 if t <= m else min(bal, P * w[min(t - m, K) - 1] / sw)
        elif ltype == "moratint":
            if t <= m:
                cp, it = it, 0.0
            else:
                pr = min(bal, bal / max(1, (T - m) - (t - m - 1)))
        elif ltype == "hp":
            mr, n_ = r / 12, int(T) * 12
            inst = _pmt(mr, n_, P)
            b, yi, yp = bal, 0.0, 0.0
            for k in range(12):
                if (t - 1) * 12 + k >= n_:
                    break
                im = b * mr
                pm = min(b, inst - im)
                yi += im; yp += pm; b -= pm
            it, pr = yi, yp
        if t > T and ltype != "wconly":
            it = bal * r if bal > 0 else 0.0
        bal = bal + cp - pr
        if bal < 0.5:
            bal = 0.0
        int_.append(it); prin.append(pr); cap.append(cp); close.append(bal)
    return {"open": open_, "int": int_, "prin": prin, "close": close, "cap": cap}


def _proj_irr(cf):
    lo, hi = -0.95, 10.0
    f = lambda x: sum(c / ((1 + x) ** i) for i, c in enumerate(cf))
    if f(lo) * f(hi) > 0:
        return None
    for _ in range(200):
        mid = (lo + hi) / 2
        if f(lo) * f(mid) <= 0:
            hi = mid
        else:
            lo = mid
    return (lo + hi) / 2


def _proj_npv(r, cf):
    return sum(c / ((1 + r) ** i) for i, c in enumerate(cf))


def _proj_payback(cum, flows):
    for i, c in enumerate(cum):
        if c >= 0:
            if i == 0:
                return 0.0
            return (i - 1) + abs(cum[i - 1]) / (flows[i] or 1)
    return 99.0


def _util_array(n, ind_util, override):
    if override and len(override) == n:
        return list(override)
    out = []
    for i in range(n):
        if i < len(ind_util):
            out.append(ind_util[i])
        else:
            last = out[-1]
            out.append(min(0.95, last + (0.95 - last) * 0.35))
    return out


def projection(fy, data):
    s = data or {}
    g = lambda k, dflt=0.0: _pnum(s.get(k), dflt)

    ind = db.industry_default(s.get("industry") or "") or {
        "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,
    }

    def A(key, dflt):
        v = s.get(key)
        return dflt if (v is None or v == "") else float(v)

    N = int(g("years", 5))
    loan_type = s.get("loan_type") or "epi"
    net_rate = max(0.0, g("rate") - (g("subsidy") if loan_type == "subsidised" else 0.0))
    T = g("loan") + g("equity") + g("grant_amt") + g("promoter_loan")
    h_on = 0 if (s.get("client") or "greenfield") == "greenfield" else 1

    cx = ind["capex_split"]
    land, bldg, plant, veh, furn = T * cx[0], T * cx[1], T * cx[2], T * cx[3], T * cx[4]
    preop_base, mm_gross = T * cx[5], T * cx[6]
    proc_fee = g("loan") * g("proc_fee")
    preop = preop_base + proc_fee
    mm = mm_gross - proc_fee
    fixed_outlay = land + bldg + plant + veh + furn
    gross_block = fixed_outlay + (h_on * g("h_nb"))
    depr_block = bldg + plant + veh + furn
    life = A("life_years", ind["life_years"]) or 15

    util = _util_array(N, ind["util_ramp"], s.get("util"))
    pesc, cesc = g("price_esc", .04), g("cost_esc", .05)
    rmode_asked = s.get("revenue_mode") or ind["revenue_mode"]
    rmode = rmode_asked
    if rmode == "capacity" and not (g("capacity") > 0 and g("price") > 0):
        rmode = "turnover"

    base100 = (g("capacity") * g("price")) if rmode == "capacity" else T * A("at", ind["asset_turnover"])
    proj_rev, volume = [0.0] * N, [0.0] * N
    for t in range(N):
        pidx = (1 + pesc) ** t
        volume[t] = g("capacity") * util[t] if rmode == "capacity" else 0.0
        proj_rev[t] = base100 * util[t] * pidx

    h_growth = g("h_growth", .07)
    hist_rev = [h_on * g("h_rev") * (1 + h_growth) ** (t + 1) for t in range(N)]
    hist_opex = [hist_rev[t] - h_on * g("h_ebitda") * (1 + h_growth) ** (t + 1) for t in range(N)]
    sales = [proj_rev[t] + hist_rev[t] for t in range(N)]
    other_inc = [g("other_income") * (1 + pesc) ** t for t in range(N)]
    income = [sales[t] + other_inc[t] for t in range(N)]

    ef = lambda t: ((1 + cesc) / (1 + pesc)) ** t
    cidx = lambda t: (1 + cesc) ** t
    rm, lab, pow_, moh, rep, adm, sel = ([0.0] * N for _ in range(7))
    cost_pct = ind["cost_pct"]
    for t in range(N):
        r_ = proj_rev[t] * ef(t)
        rm[t] = r_ * A("rm", cost_pct[0]); lab[t] = r_ * A("labour", cost_pct[1])
        pow_[t] = r_ * A("power", cost_pct[2]); moh[t] = r_ * A("mfgoh", cost_pct[3])
        adm[t] = r_ * A("admin", cost_pct[4]); sel[t] = r_ * A("selling", cost_pct[5])
        rep[t] = gross_block * A("repairs", ind["repairs_pct"]) * cidx(t)
    cop = [rm[t] + lab[t] + pow_[t] + moh[t] + rep[t] for t in range(N)]
    opex = [cop[t] + adm[t] + sel[t] + hist_opex[t] for t in range(N)]
    ebitda = [income[t] - opex[t] for t in range(N)]

    tenure = A("tenure_years", ind["tenure_years"]) or 10
    mor = min(A("moratorium_years", ind["moratorium_years"]) or 0, tenure - 1)
    sch = _proj_loan_schedule(g("loan"), net_rate, tenure, mor, loan_type, N, g("balloon_pct", .30))
    sch_cap = sch["cap"]
    tl_open = [sch["open"][t] if t < len(sch["open"]) else 0.0 for t in range(N)]
    tl_int = [sch["int"][t] if t < len(sch["int"]) else 0.0 for t in range(N)]
    tl_prin = [sch["prin"][t] if t < len(sch["prin"]) else 0.0 for t in range(N)]
    tl_close = [sch["close"][t] if t < len(sch["close"]) else 0.0 for t in range(N)]
    h_loan_close = [max(0.0, h_on * g("h_loan") * (1 - (t + 1) / 5)) for t in range(N)]
    h_loan_prin = [(h_on * g("h_loan") if t == 0 else h_loan_close[t - 1]) - h_loan_close[t] for t in range(N)]

    maint = [gross_block * g("maint_capex") for _ in range(N)]
    book_dep, tax_dep, amort, amort_pre = [0.0] * N, [0.0] * N, [0.0] * N, [0.0] * N
    nb_arr, accum, preop_close, intan_close = [0.0] * N, [0.0] * N, [0.0] * N, [0.0] * N
    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 + h_on * g("h_nb")}}
    upl = (4 / 3) if bool(s.get("special_dep")) else 1
    K = min(5, max(1, N))
    acc_book, nb, preop_bal = 0.0, gross_block, preop
    for t in range(N):
        slm = min(depr_block / life, max(0.0, depr_block - acc_book))
        hist_d = h_on * g("h_nb") * (0.85 ** t) * 0.15
        book_dep[t] = slm + hist_d
        acc_book += slm
        td = 0.0
        for k in pools:
            p = pools[k]
            d = p["b"] * min(1.0, p["r"] * upl)
            td += d; p["b"] -= d
        tax_dep[t] = td
        preop_bal += (sch_cap[t] if t < len(sch_cap) else 0.0)
        amort_pre[t] = preop_bal / max(1, K - t) if t < K else preop_bal
        preop_bal -= amort_pre[t]
        amort[t] = amort_pre[t]
        preop_close[t] = preop_bal
        nb = nb - book_dep[t] + maint[t]
        nb_arr[t] = nb
        accum[t] = gross_block + sum(maint[:t + 1]) - nb

    deb, rmst, wipst, fgst, spst, ocaArr = ([0.0] * N for _ in range(6))
    payArr, advArr, oclArr, cred, gap, wcl = ([0.0] * N for _ in range(6))
    caOps, dpArr, limArr, ccc, ntaDays = ([0.0] * N for _ in range(5))
    mpbf = {"margin": [0.0] * N, "tandon1": [0.0] * N, "tandon2": [0.0] * N, "turnover": [0.0] * N, "dp": [0.0] * N}
    hCAg = [h_on * g("h_ca") * (1 + h_growth) ** (t + 1) for t in range(N)]
    hCLg = [h_on * g("h_cl") * (1 + h_growth) ** (t + 1) for t in range(N)]
    days = ind["wc_days"]
    d_wip = A("d_wip", round(A("d_fg", days[2]) * 0.4))
    peak = max(1.0, g("peak_factor", 1.0) or 1.0)
    wc_method = s.get("wc_method") or "margin"
    for t in range(N):
        deb[t] = sales[t] * A("d_deb", days[0]) / 365
        rmst[t] = rm[t] * A("d_rm", days[1]) / 365
        wipst[t] = cop[t] * d_wip / 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] = (gap[t] / sales[t] * 365) if sales[t] else 0.0
        ccc[t] = A("d_rm", days[1]) + d_wip + A("d_fg", days[2]) + A("d_deb", days[0]) - A("d_cred", days[3])

        pCA, pCL, pGap = caOps[t] * peak, cred[t] * peak, gap[t] * peak
        mpbf["margin"][t] = pGap * g("wc_margin", .75)
        mpbf["tandon1"][t] = 0.75 * pGap
        mpbf["tandon2"][t] = max(0.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] = max(0.0, mpbf["dp"][t])
        lim = max(0.0, mpbf.get(wc_method, mpbf["margin"])[t])
        if g("sanction_limit") > 0:
            lim = min(lim, g("sanction_limit"))
        limArr[t] = lim
        wcl[t] = max(0.0, min(lim, gap[t] - mm))
        if loan_type == "wconly":
            wcl[t] = max(0.0, min(gap[t], g("loan")))
    gap0 = h_on * (g("h_ca") - g("h_cl"))
    wc_int, od_int = [0.0] * N, [0.0] * N

    (pbt, pat, tax, adj, lossBF, lossCF, taxable, rateY, ebit, fin, div, re,
     cash, od, netcash, chk, ta, tel, nw, ocf, icf, fcf) = ([0.0] * N for _ in range(22))
    prev_re, prev_cash, prev_od = 0.0, max(0.0, mm), max(0.0, -mm)
    prev_gap, prev_wcl, prev_loss = gap0, 0.0, 0.0
    wc_rate = g("wc_rate", .12)
    for t in range(N):
        wc_int[t] = (prev_wcl + wcl[t]) / 2 * wc_rate
        od_int[t] = prev_od * (wc_rate + g("od_spread", .01))
        ebit[t] = ebitda[t] - book_dep[t] - amort[t]
        fin[t] = tl_int[t] + wc_int[t] + od_int[t]
        pbt[t] = ebit[t] - fin[t]
        adj[t] = pbt[t] + book_dep[t] - tax_dep[t]
        lossBF[t] = prev_loss
        taxable[t] = max(0.0, adj[t] - lossBF[t])
        rateY[t] = A("hol_rate", ind["hol_rate"]) if (t + 1) <= A("hol_years", ind["hol_years"]) else A("tax_rate", ind["tax_rate"])
        tax[t] = taxable[t] * rateY[t]
        lossCF[t] = max(0.0, lossBF[t] - adj[t])
        prev_loss = lossCF[t]
        pat[t] = pbt[t] - tax[t]
        div[t] = pat[t] * g("payout") if (pat[t] > 0 and prev_re + pat[t] > 0) else 0.0
        re[t] = prev_re + pat[t] - div[t]
        dGap, dWCL = gap[t] - prev_gap, wcl[t] - prev_wcl
        ocf[t] = pat[t] + book_dep[t] + amort[t] - dGap
        icf[t] = -maint[t]
        fcf[t] = dWCL - tl_prin[t] - h_loan_prin[t] - div[t]
        netcash[t] = (prev_cash - prev_od) + ocf[t] + icf[t] + fcf[t]
        cash[t] = max(0.0, netcash[t]); od[t] = max(0.0, -netcash[t])
        nw[t] = g("equity") + g("grant_amt") + h_on * g("h_nw") + re[t]
        h_bal = h_on * ((g("h_nb") + g("h_ca")) - (g("h_nw") + g("h_loan") + g("h_cl")))
        tel[t] = nw[t] + tl_close[t] + h_loan_close[t] + g("promoter_loan") + wcl[t] + od[t] + cred[t] + h_bal
        ta[t] = nb_arr[t] + preop_close[t] + intan_close[t] + caOps[t] + cash[t]
        chk[t] = ta[t] - tel[t]
        prev_re, prev_cash, prev_od = re[t], cash[t], od[t]
        prev_gap, prev_wcl = gap[t], wcl[t]

    cads, dso, dscr, cr, icr, roce, roe, bep, de = ([None] * N for _ in range(9))
    for t in range(N):
        cads[t] = pat[t] + book_dep[t] + amort[t] + tl_int[t]
        dso[t] = tl_int[t] + tl_prin[t]
        dscr[t] = cads[t] / dso[t] if dso[t] > 0.5 else None
        cr[t] = (caOps[t] + cash[t]) / (wcl[t] + od[t] + cred[t]) if (wcl[t] + od[t] + cred[t]) > 0 else None
        icr[t] = ebit[t] / fin[t] if fin[t] > 0.5 else None
        roce[t] = ebit[t] / (nw[t] + tl_close[t]) if (nw[t] + tl_close[t]) != 0 else None
        roe[t] = pat[t] / nw[t] if nw[t] > 0 else None
        de[t] = (tl_close[t] + h_loan_close[t]) / nw[t] if nw[t] > 0 else None
        vc = rm[t] + pow_[t] + sel[t]
        contrib = sales[t] - vc
        fc = lab[t] + moh[t] + rep[t] + adm[t] + hist_opex[t] + book_dep[t] + amort[t] + fin[t]
        bep[t] = fc / contrib if contrib > 0 else None
    dscr_v = [x for x in dscr if x is not None]
    avg_d = (sum(dscr_v) / len(dscr_v)) if dscr_v else None
    min_d = min(dscr_v) if dscr_v else None

    disc = g("disc_rate", .13)

    # Loan Life / Project Life Coverage Ratios — the project-finance-style
    # lender covenants DSCR alone doesn't capture: PV of cash available for
    # debt service (cads, already computed above) over the loan tenure (LLCR)
    # or the full projection life (PLCR), against the initial drawn balance.
    llcr_life = min(tenure, N) if tenure else N
    pv_over_loan_life = sum(cads[t] / ((1 + disc) ** (t + 1)) for t in range(llcr_life))
    pv_over_project_life = sum(cads[t] / ((1 + disc) ** (t + 1)) for t in range(N))
    d0 = tl_open[0] if tl_open else 0.0
    llcr = (pv_over_loan_life / d0) if d0 else None
    plcr = (pv_over_project_life / d0) if d0 else None

    tv_basis = s.get("tv_basis") or "going"
    tv = 0.0
    if tv_basis == "going":
        tgr = g("tgrowth", .03)
        last = ebit[N - 1] - tax[N - 1] + book_dep[N - 1] + amort[N - 1]
        tv = last * (1 + tgr) / (disc - tgr) if disc > tgr else 0.0
    elif tv_basis == "salvage":
        tv = g("salvage", .60) * gross_block + gap[N - 1]
    fcff, ecf = [-T], [-(g("equity") + g("grant_amt"))]
    for t in range(N):
        dGap = gap[t] - (gap[t - 1] if t else gap0)
        f = ebit[t] - tax[t] + book_dep[t] + amort[t] - dGap - maint[t]
        e = pat[t] + book_dep[t] + amort[t] - dGap + (wcl[t] - (wcl[t - 1] if t else 0.0)) - tl_prin[t] - h_loan_prin[t]
        if t == N - 1:
            f += tv
            e += tv - tl_close[t] - wcl[t] - h_loan_close[t]
        fcff.append(f); ecf.append(e)
    cum, dcum, cc, dc = [], [], 0.0, 0.0
    for k, f in enumerate(fcff):
        cc += f; cum.append(cc)
        dc += f / ((1 + disc) ** k); dcum.append(dc)

    R = {
        "N": N, "industry": ind["name"], "T": R2(T), "gross_block": R2(gross_block), "life": life,
        "util": util, "rmode": rmode, "rmode_asked": rmode_asked,
        "sales": [R2(x) for x in sales], "income": [R2(x) for x in income], "ebitda": [R2(x) for x in ebitda],
        "opex": [R2(x) for x in opex], "cop": [R2(x) for x in cop],
        "cost_lines": {"rm": [R2(x) for x in rm], "labour": [R2(x) for x in lab], "power": [R2(x) for x in pow_],
                      "mfgoh": [R2(x) for x in moh], "repairs": [R2(x) for x in rep], "admin": [R2(x) for x in adm],
                      "selling": [R2(x) for x in sel]},
        "book_dep": [R2(x) for x in book_dep], "tax_dep": [R2(x) for x in tax_dep], "amort": [R2(x) for x in amort],
        "nb": [R2(x) for x in nb_arr], "preop_close": [R2(x) for x in preop_close],
        "tl_open": [R2(x) for x in tl_open], "tl_int": [R2(x) for x in tl_int], "tl_prin": [R2(x) for x in tl_prin],
        "tl_close": [R2(x) for x in tl_close], "tenure": tenure, "moratorium": mor, "rate": net_rate,
        "gap": [R2(x) for x in gap], "wcl": [R2(x) for x in wcl], "wc_int": [R2(x) for x in wc_int],
        "od_int": [R2(x) for x in od_int], "cred": [R2(x) for x in cred], "ca_ops": [R2(x) for x in caOps],
        "mpbf": {k: [R2(x) for x in v] for k, v in mpbf.items()}, "dp": [R2(x) for x in dpArr],
        "limit": [R2(x) for x in limArr], "ccc": [R2(x) for x in ccc], "nta_days": [R2(x) for x in ntaDays],
        "ebit": [R2(x) for x in ebit], "fin": [R2(x) for x in fin], "pbt": [R2(x) for x in pbt],
        "tax": [R2(x) for x in tax], "rate_y": list(rateY), "maint": [R2(x) for x in maint],
        "pat": [R2(x) for x in pat], "div": [R2(x) for x in div],
        "re": [R2(x) for x in re], "nw": [R2(x) for x in nw], "cash": [R2(x) for x in cash],
        "od": [R2(x) for x in od], "ta": [R2(x) for x in ta], "tel": [R2(x) for x in tel],
        "chk": [R2(x) for x in chk], "ocf": [R2(x) for x in ocf], "icf": [R2(x) for x in icf],
        "fcf": [R2(x) for x in fcf],
        "dscr": [R2(x) if x is not None else None for x in dscr],
        "cr": [R2(x) if x is not None else None for x in cr], "icr": [R2(x) if x is not None else None for x in icr],
        "roce": [R2(x) if x is not None else None for x in roce], "roe": [R2(x) if x is not None else None for x in roe],
        "de": [R2(x) if x is not None else None for x in de], "bep": [R2(x) if x is not None else None for x in bep],
        "avg_dscr": R2(avg_d) if avg_d is not None else None, "min_dscr": R2(min_d) if min_d is not None else None,
        "llcr": R2(llcr) if llcr is not None else None, "plcr": R2(plcr) if plcr is not None else None,
        "fcff": [R2(x) for x in fcff], "ecf": [R2(x) for x in ecf], "tv": R2(tv),
        "npv": R2(_proj_npv(disc, fcff)), "irr": _proj_irr(fcff), "eirr": _proj_irr(ecf),
        "pi": R2((_proj_npv(disc, fcff) + T) / T) if T else 0.0,
        "payback": R2(_proj_payback(cum, fcff)),
        "disc_payback": R2(_proj_payback(dcum, [f / ((1 + disc) ** k) for k, f in enumerate(fcff)])),
        "max_od": R2(max(od + [0])), "gearing": R2(g("loan") / T) if T else 0.0,
        "gearing_norm": ind["gearing_norm"], "margin_money": R2(mm), "unverified": [],
    }
    if R["irr"] is not None:
        R["irr"] = R2(R["irr"])
    if R["eirr"] is not None:
        R["eirr"] = R2(R["eirr"])
    R["checks"] = _proj_checks(s, ind, R, T, tenure, mor, loan_type)
    R["verdict"] = _proj_verdict(s, R, loan_type)
    R["sensitivity"] = _proj_sensitivity(s, R)
    return R


def _proj_checks(s, ind, R, T, tenure, mor, loan_type):
    g = lambda k, dflt=0.0: _pnum(s.get(k), dflt)
    c, bench = [], 1.25
    E = lambda t, d: c.append({"s": "err", "t": t, "d": d})
    W = lambda t, d: c.append({"s": "warn", "t": t, "d": d})
    O = lambda t: c.append({"s": "ok", "t": t})

    if max((abs(x) for x in R["chk"]), default=0) > 1:
        E("The balance sheet does not tie", "Usually an unbalanced opening balance sheet for the existing business. Re-enter the audited figures.")
    else:
        O("The balance sheet ties in every projected year")

    if T <= 0:
        E("No project cost", "Enter a loan amount, promoter's equity, or both.")
    else:
        O("Means of finance equal the total project cost")

    if (s.get("client") or "greenfield") != "greenfield":
        bal = (g("h_nb") + g("h_ca")) - (g("h_nw") + g("h_loan") + g("h_cl"))
        if abs(bal) > 0.01 * max(1.0, 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.")
        else:
            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 loan_type != "none":
        if 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.")
        else:
            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.")
        if tenure <= mor:
            E("The moratorium is not shorter than the tenure", "There is no repayment period left. Shorten the moratorium or extend the tenure.")
        else:
            O("Tenure is longer than the moratorium")
        if R["min_dscr"] is not None:
            if R["min_dscr"] < bench:
                W("Debt service coverage falls below the benchmark", f"Lowest coverage is {R['min_dscr']:.2f} against a benchmark of 1.25. Lengthen the tenure, extend the moratorium or reduce the loan.")
            else:
                O("Debt service coverage meets the benchmark in every year")
            if R["avg_dscr"] is not None and R["avg_dscr"] < bench:
                W("Average debt service coverage is below the benchmark", f"Average is {R['avg_dscr']:.2f}.")
            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 loan_type in ("bullet", "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 loan_type == "moratint":
            W("Interest is being capitalised during the moratorium", "The amount ultimately repaid exceeds the amount drawn. Confirm the lender permits this.")
        if loan_type == "subsidised" and 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")

    if R["max_od"] > 1:
        W("The project runs short of cash", f"It would need unplanned short-term borrowing of up to {R['max_od']:,.0f}. Provide for it as a sanctioned limit or additional contribution.")
    else:
        O("No unplanned short-term borrowing is required")
    if R["npv"] < 0:
        W("Negative net present value", "The project does not clear its cost of capital as modelled.")
    else:
        O("The project returns more than its cost of capital")
    if R["irr"] is not None and R["irr"] < g("rate") and loan_type != "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.")
    else:
        O("The return exceeds the cost of debt")
    if 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.")
    else:
        O("No projected year shows a loss")
    bep_vals = [x for x in R["bep"] if x is not None]
    if bep_vals and max(bep_vals) > 1:
        W("Break-even is above achieved sales in at least one year", "Fixed cost is not covered in that year.")
    else:
        O("Break-even sits below achieved sales throughout")
    cr3 = R["cr"][min(2, R["N"] - 1)]
    if cr3 is not None and cr3 < 1.2:
        W("The current ratio is thin", "Increase the promoter's margin on working capital or shorten the collection cycle.")
    else:
        O("Current ratio meets the usual benchmark")

    ratios = sum(_pnum(s.get(k)) if s.get(k) not in (None, "") else d for k, d in
                 [("rm", ind["cost_pct"][0]), ("labour", ind["cost_pct"][1]), ("power", ind["cost_pct"][2]),
                  ("mfgoh", ind["cost_pct"][3]), ("admin", ind["cost_pct"][4]), ("selling", ind["cost_pct"][5])])
    if ratios >= 1:
        E("Operating cost ratios total 100% or more of sales", "There is no margin left. Correct the overrides on the Industry page.")
    else:
        O("Operating cost ratios leave a positive margin")
    if R["rmode_asked"] == "capacity" and 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.get("tv_basis") or "going") == "going" and 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 and loan_type != "none":
        W("The projection is shorter than the loan tenure", f"Coverage in the later years of the loan is not shown. Consider extending the projection to {tenure:g} years.")
    if s.get("special_dep"):
        W("Additional one-third depreciation is being claimed", "Confirm the entity qualifies as a special industry under Schedule 2.")
    if any(gp > R["limit"][t] + R["margin_money"] + 1 for t, gp in enumerate(R["gap"])):
        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 any(dp < R["wcl"][t] - 1 for t, dp in enumerate(R["dp"])):
        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 any(x > 180 for x in R["ccc"]):
        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 not s.get("company"):
        W("No entity name entered", "The reports will be issued without a name on the cover.")
    return c


def _proj_verdict(s, R, loan_type):
    g = lambda k, dflt=0.0: _pnum(s.get(k), dflt)
    hard = len([x for x in R["checks"] if x["s"] == "err"])
    bench, no_debt = 1.25, loan_type == "none"
    cov = no_debt or (R["min_dscr"] is not None and R["min_dscr"] >= bench)
    bankable = cov and R["npv"] > 0 and (R["irr"] is None or R["irr"] > g("disc_rate", .13)) and R["max_od"] < 1 and not hard
    viable = (not bankable) and R["npv"] > 0 and (no_debt or (R["irr"] is not None and R["irr"] > g("rate"))) and not 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:
        reasons = []
        if not no_debt and R["min_dscr"] is not None and R["min_dscr"] < bench:
            reasons.append(f"debt service coverage falls to {R['min_dscr']:.2f} against a benchmark of 1.25")
        if R["max_od"] >= 1:
            reasons.append(f"the project draws unplanned short-term borrowing of up to {R['max_od']:,.0f}")
        if R["gearing"] > R["gearing_norm"] + 0.05:
            reasons.append(f"gearing of {R['gearing']*100:.0f}% exceeds the {R['gearing_norm']*100:.0f}% 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: " +
                     ("; ".join(reasons) or "coverage is tight") + ". This is capable of being resolved without changing anything about the project itself."}
    reasons = []
    if R["npv"] <= 0:
        reasons.append("it does not recover its cost of capital")
    if R["irr"] is not None and R["irr"] <= g("rate") and not no_debt:
        reasons.append(f"its return of {R['irr']*100:.1f}% is below the lending rate of {g('rate')*100:.1f}%")
    if not no_debt and R["min_dscr"] is not None and R["min_dscr"] < 1:
        reasons.append("cash generation does not cover debt service in at least one year")
    if R["max_od"] >= 1:
        reasons.append("the project runs out of cash")
    if hard:
        reasons.append("unresolved input errors remain")
    return {"k": "bad", "t": "Not viable as presently structured",
            "s": "The project cannot be recommended in its present form because " + ("; ".join(reasons) or "the numbers do not support it") + "."}


_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)]


def _proj_sensitivity(s, R):
    disc = _pnum(s.get("disc_rate"), .13)
    rows = []
    for name, sf, cf, dr in _SCEN:
        dscr_list, cum_pat, flows = [], 0.0, [-R["T"]]
        for t in range(R["N"]):
            rev, cost = R["income"][t] * sf, R["opex"][t] * cf
            eb = rev - cost
            extra_int = dr * (R["tl_open"][t] or 0)
            pbt = eb - R["book_dep"][t] - R["amort"][t] - R["fin"][t] - extra_int
            tx = max(0.0, pbt) * R["rate_y"][t]
            pat = pbt - tx
            cum_pat += pat
            dso = R["tl_int"][t] + R["tl_prin"][t]
            d = (pat + R["book_dep"][t] + R["amort"][t] + R["tl_int"][t] + extra_int) / (dso + extra_int) if dso > 0.5 else None
            if d is not None:
                dscr_list.append(d)
            f = eb - tx - (R["gap"][t] - (R["gap"][t - 1] if t else 0)) - R["maint"][t]
            if t == R["N"] - 1:
                f += R["tv"] * sf
            flows.append(f)
        avg = (sum(dscr_list) / len(dscr_list)) if dscr_list else None
        worst = min(dscr_list) if dscr_list else None
        ir = _proj_irr(flows)
        rows.append({"scenario": name, "avg_dscr": R2(avg) if avg is not None else None,
                    "min_dscr": R2(worst) if worst is not None else None,
                    "irr": R2(ir) if ir is not None else None, "cum_pat": R2(cum_pat),
                    "npv": R2(_proj_npv(disc, flows))})
    return rows


# ------------------------------------------------- 11. DUE DILIGENCE WORKBENCH
#
# Financial / Tax / Accounting due diligence: per-year derived metrics from a
# 5-year (2-6) financial input, 17 repeating registers, a rule-based findings
# engine (data-room completeness, revenue quality, earnings quality, working
# capital seasonality, debt, contingent liabilities, tax exposure, accounting
# deviations), RAG risk scoring and an EV-to-equity deal-impact bridge.
#
# Unlike the source tool's own TAXP block, the exposure-calculation rates
# (income tax rate, interest on arrears, return-filing fee, VAT late fee) are
# NOT duplicated here — they are the exact same figures already seeded under
# the `income_tax`, `penalty` and `vat` rate_master domains for the other
# modules, so this engine reads those instead of carrying a second copy that
# could drift out of sync.
#
# Scope note: ports the full per-year metrics, all ~25 auto-finding rules,
# manual-finding merge with overrides, RAG scoring and the deal-impact bridge.
# Does not yet port the source's SCAN ledger-paste classifier (a data-entry
# convenience, not a computation) or ratio-visualisation SVGs (rendering, not
# logic) — flagged as follow-up, not silently dropped.

_DD_LIK = ["1 Rare", "2 Unlikely", "3 Possible", "4 Likely", "5 Almost certain"]
_DD_IMP = ["1 Minimal", "2 Minor", "3 Moderate", "4 Major", "5 Critical"]


def _dn(v):
    if v is None or v == "":
        return 0.0
    try:
        return float(str(v).replace(",", "").replace(" ", ""))
    except ValueError:
        return 0.0


def _dsafe_div(a, b):
    b = _dn(b)
    return 0.0 if b == 0 else _dn(a) / b


def _dfy_list(latest, n):
    m = re.search(r"(\d{4})\s*[/-]\s*(\d{2,4})", str(latest or ""))
    start = int(m.group(1)) if m else 2081
    return [f"{start-i}/{(start-i+1)%100:02d}" for i in range(n - 1, -1, -1)]


def _dd_round(x):
    """Matches JS Math.round() (always rounds .5 toward +Infinity), not Python's
    banker's-rounding round(), so the two engines agree on borderline halves."""
    return math.floor(x + 0.5)


def _dlvl(v):
    try:
        n = int(str(v or "3"))
    except (TypeError, ValueError):
        n = 3
    return max(1, min(5, n))


def _drag_of(lik, imp):
    score = _dlvl(lik) * _dlvl(imp)
    return "R" if score >= 15 else ("A" if score >= 8 else "G")


def _dd_tax_rates(fy):
    it_rates, u1 = rate_dict("income_tax", fy, "company")
    pen_rates, u2 = rate_dict("penalty", fy)
    vat_rates, u3 = rate_dict("vat", fy)
    val = lambda rates, code, dflt: (rates[code]["value_num"] if code in rates and rates[code]["value_num"] is not None else dflt)
    p = {
        "itrate": (val(it_rates, "company_rate_general", 0.25) or 0.25) * 100,
        "intrate": (val(pen_rates, "s119_interest_rate", 0.15) or 0.15) * 100,
        "feepc": (val(pen_rates, "s117_income_return_pct", 0.001) or 0.001) * 100,
        "feemin": val(pen_rates, "s117_income_return_month", 100) or 100,
        "vatint": (val(vat_rates, "late_payment_addl_fee", 0.15) or 0.15) * 100,
        "vatfee": val(vat_rates, "late_return_fee", 1000) or 1000,
    }
    return p, sorted(set(u1 + u2 + u3))


def _dit_exp(r, p):
    dis_tax = _dn(r.get("dis")) * p["itrate"] / 100
    shortfall = max(0.0, _dn(r.get("tself")) - _dn(r.get("tpaid")))
    principal = dis_tax + shortfall
    interest = principal * p["intrate"] / 100 * _dn(r.get("mo")) / 12
    fee = 0.0
    if r.get("filed") in ("Not filed", "Filed late"):
        fee = max(_dn(r.get("inc")) * p["feepc"] / 100 * _dn(r.get("mo")) / 12, p["feemin"] * _dn(r.get("mo")))
    return {"principal": principal, "interest": interest, "fee": fee, "total": principal + interest + fee}


def _dtds_exp(r, p):
    short = _dn(r.get("base")) * max(0.0, _dn(r.get("req")) - _dn(r.get("applied"))) / 100
    dep_factor = 1 if r.get("dep") == "No" else (0.5 if r.get("dep") == "Partial" else 0)
    nondep = _dn(r.get("base")) * _dn(r.get("applied")) / 100 * dep_factor
    principal = short + nondep
    interest = principal * p["intrate"] / 100 * _dn(r.get("mo")) / 12
    return {"principal": principal, "interest": interest, "fee": 0.0, "total": principal + interest}


def _dvat_exp(r, p):
    principal = _dn(r.get("dis")) + _dn(r.get("short"))
    interest = principal * p["vatint"] / 100 * _dn(r.get("mo")) / 12
    fee = _dn(r.get("late")) * p["vatfee"]
    return {"principal": principal, "interest": interest, "fee": fee, "total": principal + interest + fee}


def _doneoff_effect(r):
    if r.get("inc") != "Yes":
        return 0.0
    amt = _dn(r.get("amt"))
    return -amt if re.search(r"income|reversal", str(r.get("nat") or ""), re.I) else amt


_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"]


def _dfin_of(fin, y):
    row = fin.get(y) or {}
    return {k: _dn(row.get(k)) for k in _DD_FIN_FIELDS}


def dd_workbench(fy, data):
    s = data or {}
    eng = s.get("eng") or {}
    g = {k: (s.get("g") or {}).get(k) or [] for k in
         ["dr", "conc", "stream", "norm", "wcm", "debt", "dlike", "rp", "oneoff", "cont",
          "it", "tds", "vat", "notice", "acct", "find", "rec"]}
    ov = s.get("ov") or {}
    p, unverified = _dd_tax_rates(fy)
    fin = s.get("fin") or {}

    ys = _dfy_list(eng.get("latest_fy") or "2081/82", int(eng.get("nyears") or 5) or 5)
    y = {}
    for yr in ys:
        d = _dfin_of(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"] = (d["tr"] / o["rev"] * 365) if o["rev"] else 0.0
        o["dio"] = (d["inv"] / o["cogs"] * 365) if o["cogs"] else 0.0
        o["dpo"] = (d["tp"] / o["cogs"] * 365) if o["cogs"] else 0.0
        o["ccc"] = o["dso"] + o["dio"] - o["dpo"]
        o["gpm"] = _dsafe_div(o["gp"], o["rev"]) * 100
        o["em"] = _dsafe_div(o["ebitda"], o["rev"]) * 100
        o["pm"] = _dsafe_div(o["pat"], o["rev"]) * 100
        o["cr"] = _dsafe_div(o["ca"], o["cl"])
        o["lev"] = _dsafe_div(o["netdebt"], o["ebitda"])
        o["icr"] = _dsafe_div(o["ebitda"], o["fin"])
        o["roce"] = _dsafe_div(o["ebit"], o["teq"] + o["debt"]) * 100
        o["cfo"] = d["cfo"]; o["conv"] = _dsafe_div(o["cfo"], o["ebitda"]) * 100
        o["capex"] = d["capex"]
        o["accr"] = _dsafe_div(o["pat"] - o["cfo"], o["ta"]) * 100
        o["adj"] = 0.0
        o["norm_adj"] = 0.0
        o["oneoff_adj"] = 0.0
        y[yr] = o

    # Quality-of-earnings bridge — kept as two separate running subtotals
    # (normalisation vs one-off) as well as the combined "adj" that already
    # feeds nebitda below, so the report can show reported EBITDA -> +/-
    # normalisation -> +/- one-offs -> normalised EBITDA as a clean waterfall
    # instead of only the net figure.
    for r in g["norm"]:
        if r.get("fy") in y:
            amt = _dn(r.get("amt"))
            y[r["fy"]]["adj"] += amt
            y[r["fy"]]["norm_adj"] += amt
    for r in g["oneoff"]:
        if r.get("fy") in y:
            amt = _doneoff_effect(r)
            y[r["fy"]]["adj"] += amt
            y[r["fy"]]["oneoff_adj"] += amt
    for yr in ys:
        o = y[yr]
        o["nebitda"] = o["ebitda"] + o["adj"]
        o["nem"] = _dsafe_div(o["nebitda"], o["rev"]) * 100
        o["nlev"] = _dsafe_div(o["netdebt"], o["nebitda"])

    latest, first = ys[-1], ys[0]
    L, F = y[latest], y[first]
    c = {"ys": ys, "y": y, "latest": latest, "first": first, "rev": L["rev"], "L": L}
    c["cagr"] = ((L["rev"] / F["rev"]) ** (1 / (len(ys) - 1)) - 1) * 100 if (F["rev"] > 0 and len(ys) > 1) else 0.0
    c["rev_gr"] = [0.0] + [_dsafe_div(y[ys[i]]["rev"] - y[ys[i - 1]]["rev"], y[ys[i - 1]]["rev"]) * 100 for i in range(1, len(ys))]
    fin_l, fin_f = _dfin_of(fin, latest), _dfin_of(fin, first)
    c["tr_gr"] = (_dsafe_div(fin_l["tr"], fin_f["tr"]) - 1) * 100 if F["rev"] > 0 else 0.0
    c["rev_gr_tot"] = (_dsafe_div(L["rev"], F["rev"]) - 1) * 100 if F["rev"] > 0 else 0.0
    c["dso_delta"] = L["dso"] - F["dso"]

    c["tot_stream"] = sum(_dn(r.get("amt")) for r in g["stream"])
    conc = sorted(g["conc"], key=lambda r: -_dn(r.get("amt")))
    c["conc"] = conc
    top5 = sum(_dn(r.get("amt")) for r in conc[:5])
    c["top5"] = top5
    c["top5pct"] = (_dsafe_div(top5, L["rev"]) * 100) or fin_l["top5"]
    c["top1pct"] = _dsafe_div(_dn((conc[0] if conc else {}).get("amt")), L["rev"]) * 100
    c["rp_rev"] = sum(_dn(r.get("amt")) for r in g["rp"] if r.get("nat") == "Sales")
    c["rp_rev_pct"] = (_dsafe_div(c["rp_rev"], L["rev"]) * 100) or fin_l["rppct"]
    c["rp_bal"] = sum(_dn(r.get("bal")) for r in g["rp"])
    c["rp_no_arm"] = len([r for r in g["rp"] if r.get("arm") in ("No", "Partly")])

    c["norm_tot"] = sum(y[yr]["adj"] for yr in ys)
    c["adj_pct"] = _dsafe_div(abs(L["adj"]), abs(L["ebitda"])) * 100
    c["low_conf"] = sum(abs(_dn(r.get("amt"))) for r in g["norm"] if r.get("conf") == "Low")

    wm = [r for r in g["wcm"] if r.get("m")]
    wc_vals = [_dn(r.get("inv")) + _dn(r.get("tr")) + _dn(r.get("oca")) - _dn(r.get("tp")) - _dn(r.get("ocl")) for r in wm]
    c["wc_count"] = len(wm)
    c["wc_avg"] = (sum(wc_vals) / len(wc_vals)) if wc_vals else L["nwc"]
    c["wc_high"] = max(wc_vals) if wc_vals else L["nwc"]
    c["wc_low"] = min(wc_vals) if wc_vals else L["nwc"]
    c["wc_dev"] = L["nwc"] - c["wc_avg"]
    c["wc_swing"] = _dsafe_div(c["wc_high"] - c["wc_low"], abs(c["wc_avg"]) or 1) * 100

    c["debt_gross"] = sum(_dn(r.get("out")) for r in g["debt"]) or L["debt"]
    c["dlike_d"] = sum(_dn(r.get("amt")) for r in g["dlike"] if r.get("cat") == "Debt-like")
    c["dlike_c"] = sum(_dn(r.get("amt")) for r in g["dlike"] if r.get("cat") == "Cash-like")
    c["off_bs"] = sum(_dn(r.get("amt")) for r in g["dlike"] if r.get("cat") == "Off balance sheet")
    c["net_debt_adj"] = c["debt_gross"] + c["dlike_d"] - L["cash"] - c["dlike_c"]
    c["breach"] = len([r for r in g["debt"] if r.get("cov") == "Breached"])
    c["coc"] = len([r for r in g["debt"] if re.search("Yes", str(r.get("coc") or ""))])
    c["pg"] = len([r for r in g["debt"] if r.get("pg") == "Yes"])

    c["cont_gross"] = sum(_dn(r.get("amt")) for r in g["cont"])
    c["cont_w"] = sum(max(0.0, _dn(r.get("amt")) - _dn(r.get("prov"))) *
                      (1 if r.get("prob") == "Probable" else (0.5 if r.get("prob") == "Possible" else 0.05))
                      for r in g["cont"])

    c["tax_it"] = sum(_dit_exp(r, p)["total"] for r in g["it"])
    c["tax_tds"] = sum(_dtds_exp(r, p)["total"] for r in g["tds"])
    c["tax_vat"] = sum(_dvat_exp(r, p)["total"] for r in g["vat"])
    c["tax_not"] = sum(_dn(r.get("dem")) * _dn(r.get("prob")) / 100 for r in g["notice"])
    c["tax_total"] = c["tax_it"] + c["tax_tds"] + c["tax_vat"] + c["tax_not"]
    c["tax_prin"] = (sum(_dit_exp(r, p)["principal"] for r in g["it"]) + sum(_dtds_exp(r, p)["principal"] for r in g["tds"]) +
                     sum(_dvat_exp(r, p)["principal"] for r in g["vat"]))
    c["tax_int"] = (sum(_dit_exp(r, p)["interest"] for r in g["it"]) + sum(_dtds_exp(r, p)["interest"] for r in g["tds"]) +
                    sum(_dvat_exp(r, p)["interest"] for r in g["vat"]))
    c["tax_fee"] = sum(_dit_exp(r, p)["fee"] for r in g["it"]) + sum(_dvat_exp(r, p)["fee"] for r in g["vat"])
    c["open_ass"] = len([r for r in g["it"] if re.search("audit|101|115|Tribunal|Court", str(r.get("stat") or ""))])
    c["not_filed"] = (len([r for r in g["it"] if r.get("filed") == "Not filed"]) +
                      len([r for r in g["vat"] if r.get("filed") == "Not filed"]))

    c["acct_pl"] = sum(_dn(r.get("pl")) for r in g["acct"])
    c["acct_bs"] = sum(_dn(r.get("bs")) for r in g["acct"])
    c["acct_dev"] = len([r for r in g["acct"] if r.get("status") == "Deviation"])
    c["acct_na"] = len([r for r in g["acct"] if r.get("status") == "Not assessed"])

    dr = [r for r in g["dr"] if r.get("status") != "Not applicable"]
    c["dr_tot"] = len(dr)
    dr_recd = len([r for r in dr if r.get("status") == "Received"])
    dr_part = len([r for r in dr if r.get("status") == "Partial"])
    c["dr_pct"] = _dsafe_div(dr_recd + dr_part * 0.5, c["dr_tot"]) * 100
    c["dr_crit_open"] = len([r for r in dr if r.get("crit") == "Yes" and r.get("status") != "Received"])

    c["cashsales_latest"] = fin_l["cashsales"]
    c["mat"] = _dn(eng.get("mat")) if eng.get("matmode") == "manual" else max(L["rev"] * 0.005, abs(L["nebitda"]) * 0.05)
    c["opinions"] = [yr for yr in ys if (fin.get(yr, {}).get("opinion") or "Unmodified") != "Unmodified"]
    c["chk_fail"] = [yr for yr in ys if abs(y[yr]["chk"]) > max(1.0, L["rev"] * 0.0005)]

    findings = _dd_all_findings(c, g, eng, ov)
    c["find"] = findings
    c["rag"] = {"R": len([f for f in findings if f["rag"] == "R"]), "A": len([f for f in findings if f["rag"] == "A"]),
                "G": len([f for f in findings if f["rag"] == "G"])}
    c["exposure"] = sum(_dn(f.get("qty")) for f in findings)
    c["overall"] = "R" if c["rag"]["R"] > 0 else ("A" if c["rag"]["A"] > 0 else "G")

    c["deal"] = _dd_deal_calc(c, eng)
    c["unverified"] = unverified
    c["registers"] = g
    # Buy-side (acquirer-commissioned) and vendor/sell-side (target-commissioned,
    # pre-emptive) due diligence run the same registers and analysis — only the
    # engagement framing and report title differ, so this is a flag on the
    # existing workbench rather than a second module.
    c["perspective"] = eng.get("perspective") or "buy_side"
    # Commercial due diligence — market/competitive qualitative canvas, plus a
    # composite score. market_growth_pct and position_rating are the
    # preparer's own inputs; customer concentration reuses top5pct/top1pct
    # already computed above from the financial DD register rather than
    # asking for it twice.
    c["comm"] = s.get("comm") or {}
    c["comm_score"] = _dd_commercial_score(c["comm"], c["top5pct"])
    return c


def _dd_commercial_score(comm, top5pct):
    """Bands market growth, customer concentration and a 1-5 competitive-
    position rating into a 0-100 composite — a starting-point assessment for
    the preparer to review and override, not a substitute for judgement."""
    def band(value, breakpoints):
        # breakpoints: [(threshold, score), ...] in descending threshold order;
        # the first breakpoint value is <= crosses into that score.
        for threshold, score in breakpoints:
            if value >= threshold:
                return score
        return breakpoints[-1][1]

    growth = comm.get("market_growth_pct")
    growth_score = None if growth in (None, "") else band(_dn(growth), [(15, 100), (5, 70), (0, 40), (-999, 10)])

    conc_score = None if top5pct in (None, "") else band(100 - _dn(top5pct), [(70, 100), (50, 60), (30, 30), (-999, 10)])

    rating = comm.get("position_rating")
    position_score = None if rating in (None, "") else max(0.0, min(100.0, _dn(rating) * 20))

    parts = [x for x in (growth_score, conc_score, position_score) if x is not None]
    overall = (sum(parts) / len(parts)) if parts else None
    rag = None if overall is None else ("G" if overall >= 70 else ("A" if overall >= 40 else "R"))
    return {"growth_score": growth_score, "concentration_score": conc_score, "position_score": position_score,
           "overall_score": _dd_round(overall) if overall is not None else None, "rag": rag}


def _dd_deal_calc(c, eng):
    L = c["L"]
    mult = _dn(eng.get("multiple"))
    ev = L["nebitda"] * mult if mult > 0 else _dn(eng.get("dealval"))
    d = {"ev": ev, "mult": 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


def _dd_all_findings(c, g, eng, ov):
    out = []
    L, ys = c["L"], c["ys"]
    unit = (eng.get("cur") or "NPR") + {"Actual": "", "Thousands": " '000", "Lakh": " lakh", "Million": " mn", "Crore": " cr"}.get(eng.get("unit"), "")

    def add(fid, ws, title, detail, qty, lik, imp, prot="Indemnity"):
        out.append({"id": fid, "ws": ws, "title": title, "detail": detail, "qty": qty or 0,
                    "lik": lik, "imp": imp, "prot": prot, "status": "Open", "auto": True})

    def M(v):
        return f"{abs(_dd_round(_dn(v))):,.0f} {unit}"

    def f0(v):
        n = _dn(v)
        return f"({abs(_dd_round(n)):,.0f})" if n < 0 else f"{abs(_dd_round(n)):,.0f}"

    def f1(v):
        n = _dn(v)
        return f"({abs(n):,.1f})" if n < 0 else f"{abs(n):,.1f}"

    def pc(v, d=1):
        v = v if isinstance(v, (int, float)) else 0
        return f"{v:.{d}f}%"

    if c["dr_tot"] and c["dr_pct"] < 50:
        add("DR01", "Data room", "Data room materially incomplete",
            f"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")
    elif c["dr_tot"] and c["dr_pct"] < 80:
        add("DR02", "Data room", "Information gaps outstanding",
            f"{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",
            f"{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",
                f"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")
        elif c["top5pct"] >= 50:
            add("FD02", "Financial DD", "Customer concentration",
                f"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",
                f"{pc(c['rp_rev_pct'])} of revenue is transacted with related parties. Sustainability post-completion and arm's-length pricing both require testing.",
                _dd_round(c["rp_rev"] * 0.25), 4, 4, "Price adjustment")
        if c["rev_gr_tot"] - c["tr_gr"] < -20 and c["tr_gr"] > 0:
            add("FD04", "Financial DD", "Receivables growing faster than revenue",
                f"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",
                f"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",
                f"{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",
                f"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")
        elif c["adj_pct"] >= 10:
            add("FD08", "Financial DD", "Material normalisation adjustments",
                f"Normalisation adjustments equal {pc(c['adj_pct'])} of reported EBITDA in {c['latest']}.", 0, 3, 3, "Price adjustment")
        if c["low_conf"] > 0 and c["low_conf"] > c["mat"]:
            add("FD09", "Financial DD", "Normalisation adjustments with weak evidence",
                f"{M(c['low_conf'])} of adjustments are supported by low-quality evidence and may not withstand seller challenge.", _dd_round(c["low_conf"]), 3, 4, "Price adjustment")
        if L["conv"] < 25 and L["cfo"] != 0:
            add("FD10", "Financial DD", "Weak cash conversion",
                f"Operating cash flow is {pc(L['conv'])} of EBITDA in {c['latest']}. Earnings are not converting to cash.", 0, 4, 4, "Price adjustment")
        elif L["conv"] < 50 and L["cfo"] != 0:
            add("FD11", "Financial DD", "Cash conversion below expectation",
                f"Operating cash flow is {pc(L['conv'])} of EBITDA in {c['latest']}; working capital absorption should be explained.", 0, 3, 3, "Price adjustment")
        ems = [c["y"][yr]["em"] for yr in ys if math.isfinite(c["y"][yr]["em"])]
        if len(ems) > 1 and (max(ems) - min(ems)) > 8:
            add("FD12", "Financial DD", "Volatile EBITDA margin",
                f"EBITDA margin ranges from {pc(min(ems))} to {pc(max(ems))} across the review period; the maintainable level is not self-evident.", 0, 3, 4, "Price adjustment")

    if c["wc_count"] >= 6 and c["wc_swing"] > 40:
        add("FD13", "Financial DD", "Working capital highly seasonal",
            f"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 and c["wc_dev"] < -abs(c["wc_avg"]) * 0.15:
        add("FD14", "Financial DD", "Year-end working capital below normal level",
            f"Closing NWC of {M(L['nwc'])} is {M(abs(c['wc_dev']))} below the 12-month average — consistent with year-end window dressing of receipts and payments.", _dd_round(abs(c["wc_dev"])), 4, 4, "Price adjustment")
    if L["nebitda"] > 0 and L["nlev"] > 4.5:
        add("FD15", "Financial DD", "Leverage above prudent level",
            f"Net debt / normalised EBITDA is {f1(L['nlev'])}x. Refinancing and covenant capacity require attention before completion.", 0, 4, 4, "Condition precedent")
    elif L["nebitda"] > 0 and L["nlev"] > 3:
        add("FD16", "Financial DD", "Elevated leverage", f"Net debt / normalised EBITDA is {f1(L['nlev'])}x.", 0, 3, 3, "Condition precedent")
    if L["fin"] > 0 and L["icr"] < 1.25:
        add("FD17", "Financial DD", "Interest cover below 1.25x",
            f"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",
            f"{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",
            f"{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",
            f"{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",
            f"{M(c['dlike_d'])} of debt-like items (unfunded employee benefits, statutory dues, declared dividends, capex creditors and similar) reduce equity value.", _dd_round(c["dlike_d"]), 4, 4, "Price adjustment")
    if c["off_bs"] > 0:
        add("FD22", "Financial DD", "Off-balance-sheet obligations", f"{M(c['off_bs'])} of off-balance-sheet obligations identified.", _dd_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",
            f"{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",
            f"{M(c['rp_bal'])} remains outstanding with related parties. Settlement or assignment before completion should be a condition.", _dd_round(c["rp_bal"]), 3, 4, "Condition precedent")
    if c["cont_w"] > 0 and c["cont_w"] > c["mat"]:
        add("FD25", "Financial DD", "Contingent liabilities exceed materiality",
            f"Probability-weighted contingent exposure of {M(c['cont_w'])} against gross claims of {M(c['cont_gross'])}.", _dd_round(c["cont_w"]), 3, 4, "Escrow / holdback")

    if c["tax_total"] > 0:
        big = c["tax_total"] > c["mat"]
        add("TD01", "Tax DD", "Quantified tax exposure",
            f"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.",
            _dd_round(c["tax_total"]), 4 if big else 3, 4 if big else 3, "Indemnity")
    if c["not_filed"] > 0:
        add("TD02", "Tax DD", "Returns not filed",
            f"{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",
            f"{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",
            f"Short deduction and non-deposit exposure of {M(c['tax_tds'])} including interest. Disallowance of the underlying expense may also arise.", _dd_round(c["tax_tds"]), 4, 4, "Indemnity")
    if c["tax_vat"] > c["mat"] * 0.5:
        add("TD05", "Tax DD", "VAT exposure", f"Input credit at risk and output under-declaration total {M(c['tax_vat'])} including additional fees.", _dd_round(c["tax_vat"]), 4, 4, "Indemnity")
    for i, r in enumerate(g["notice"]):
        if _dn(r.get("dem")) > c["mat"] and _dn(r.get("prob")) >= 50:
            add(f"TD1{i}", "Tax DD", "Open demand: " + (r.get("subj") or r.get("ref") or "assessment"),
                f"Demand of {M(_dn(r.get('dem')))} before {r.get('auth') or 'the tax authority'} with assessed loss probability of {pc(_dn(r.get('prob')), 0)}. {r.get('rem') or ''}",
                _dd_round(_dn(r.get("dem")) * _dn(r.get("prob")) / 100), 4, 4, "Escrow / holdback")

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

    auto = []
    for f in out:
        o = ov.get(f["id"]) or {}
        lik = o["lik"] if "lik" in o else f["lik"]
        imp = o["imp"] if "imp" in o else f["imp"]
        auto.append(dict(f, lik=lik, imp=imp, rag=o.get("rag") or _drag_of(lik, imp),
                         prot=o.get("prot") or f["prot"], status=o.get("status") or f["status"],
                         note=o.get("note") or "", qty=(_dn(o["qty"]) if "qty" in o else f["qty"])))

    manual = []
    for i, r in enumerate(g["find"]):
        lik, imp = _dlvl(r.get("lik")), _dlvl(r.get("imp"))
        manual.append({"id": f"M{i+1}", "ws": r.get("ws"), "title": r.get("title") or "(untitled finding)",
                       "detail": r.get("detail"), "qty": _dn(r.get("qty")), "lik": lik, "imp": imp,
                       "rag": r.get("rag") if r.get("rag") and r.get("rag") != "N" else _drag_of(lik, imp),
                       "prot": r.get("prot"), "status": r.get("status"), "auto": False})

    all_f = auto + manual
    order = {"R": 0, "A": 1, "G": 2, "N": 3}
    all_f.sort(key=lambda f: (order.get(f["rag"], 3), -_dn(f["qty"])))
    for i, f in enumerate(all_f):
        f["n"] = i + 1
    return all_f


# --------------------------------------------------------------- 12. FDI MODEL
#
# Phase 4a scope: the financial model, route/eligibility rule engine, tax and
# repatriation pack, exit calculation, valuation and validation — the
# calculation core of the FDI Automator. Document assembly (JVA/POA/Technology
# Transfer Agreement/Investor Profiles/Advisory Report) and the workflow
# trackers (DoI 15-step procedure, roadmap, compliance calendar) are follow-up
# phases, not ported here — flagged, not silently dropped.
#
# Sector caps/tax rates/special-industry flag come from `fdi_sector` (a
# reference table, like industry_default — see schema.sql). The negative-list
# item text and DTAA treaty rate table are presentation-only reference content
# (never read by the calculations below, only displayed), so — like HEADS/
# RULES in fin_statements — they stay as code constants rather than DB rows.

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",
]


def _fn(v):
    try:
        return float(str(v if v is not None else 0).replace(",", ""))
    except (TypeError, ValueError):
        return 0.0


def _fdi_is_foreign(inv):
    return inv.get("kind") in ("FC", "FI")


def _fdi_is_company(inv):
    return inv.get("kind") in ("FC", "NC")


def _fdi_inv_npr(inv, fx):
    c = inv.get("currency") or "NPR"
    if c == "NPR":
        return _fn(inv.get("amount"))
    rate = fx.get("rates", {}).get(c)
    rate = _fn(rate) if rate is not None else _fn(fx.get("rate"))
    return _fn(inv.get("amount")) * rate


def _fdi_totals(investors, capital, fx):
    t = {"total_inv": 0.0, "foreign": 0.0, "local": 0.0, "by_inv": [], "shares": 0}
    for inv in investors:
        n = _fdi_inv_npr(inv, fx)
        t["total_inv"] += n
        if _fdi_is_foreign(inv):
            t["foreign"] += n
        else:
            t["local"] += n
    fv = _fn(capital.get("face_value")) or 100
    for inv in investors:
        n = _fdi_inv_npr(inv, fx)
        shares = round(n / fv)
        t["by_inv"].append({"inv": inv, "npr": R2(n), "pct": R2((n / t["total_inv"] * 100) if t["total_inv"] else 0), "shares": shares})
        t["shares"] += shares
    t["foreign_pct"] = (t["foreign"] / t["total_inv"] * 100) if t["total_inv"] else 0.0
    t["local_pct"] = (t["local"] / t["total_inv"] * 100) if t["total_inv"] else 0.0
    return t


def _fdi_cost_total(cost):
    return sum(_fn(c[1]) for c in (cost or []))


def _fdi_cost_split(cost):
    c = [_fn(x[1]) for x in (cost or [])] + [0.0] * 9
    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": fixed, "total": _fdi_cost_total(cost)}


def _fdi_floan_npr(loan, fx):
    cur = loan.get("f_cur") or "USD"
    rate = fx.get("rates", {}).get(cur)
    rate = _fn(rate) if rate is not None else _fn(fx.get("rate"))
    return _fn(loan.get("f_amt")) * rate


def _fdi_loan_schedule(amt, rate, tenure, morat, years):
    amt = _fn(amt)
    rate = _fn(rate) / 100
    tenure = max(1, round(_fn(tenure)))
    morat = max(0, round(_fn(morat)))
    repay_years = max(1, tenure - morat)
    princ = amt / repay_years
    rows, open_ = [], amt
    for y in range(1, years + 1):
        interest = open_ * rate
        p = min(princ, open_) if (morat < y <= tenure) else 0.0
        if y == tenure:
            p = open_
        close = open_ - p
        rows.append({"y": y, "open": open_, "interest": interest if amt else 0.0, "principal": p, "close": close})
        open_ = close
    return rows


def _fdi_effective_tax_rate(y, proj):
    base = _fn(proj.get("tax_rate"))
    if y <= _fn(proj.get("tax_holiday")):
        return 0.0
    return base * (1 - _fn(proj.get("concession_pct")) / 100)


def _fdi_irr(cf):
    def npv_at(r):
        return sum(cf[i] / ((1 + r) ** i) for i in range(len(cf)))
    lo, hi = -0.9, 5.0
    fl, fh = npv_at(lo), npv_at(hi)
    if not math.isfinite(fl) or not math.isfinite(fh) or fl * fh > 0:
        return None
    for _ in range(200):
        m = (lo + hi) / 2
        fm = npv_at(m)
        if fl * fm <= 0:
            hi, fh = m, fm
        else:
            lo, fl = m, fm
    return (lo + hi) / 2 * 100


def _fdi_payback(cf):
    c = 0.0
    for i, x in enumerate(cf):
        p = c
        c += x
        if c >= 0 and i > 0:
            return (i - 1) + (-p / x if x else 0.0)
    return None


def _fdi_avg(vals):
    v = [x for x in vals if x is not None and math.isfinite(x)]
    return (sum(v) / len(v)) if v else None


def _fdi_min(vals):
    v = [x for x in vals if x is not None and math.isfinite(x)]
    return min(v) if v else None


def _fdi_model(data):
    proj = data.get("proj") or {}
    loan = data.get("loan") or {}
    fx = data.get("fx") or {}
    val = data.get("val") or {}
    params = data.get("params") or {}
    N = max(2, min(10, round(_fn(proj.get("years"))) or 5))
    cs = _fdi_cost_split(data.get("cost"))
    totals = _fdi_totals(data.get("investors") or [], data.get("capital") or {}, fx)
    floan = _fdi_floan_npr(loan, fx)
    lloan = _fn(loan.get("l_amt"))
    src = totals["total_inv"] + floan + lloan
    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": ((floan + lloan) / src * 100) if src else 0.0, "eq_pct": (totals["total_inv"] / src * 100) if src else 0.0}

    fs = _fdi_loan_schedule(floan, loan.get("f_rate"), loan.get("f_tenure"), loan.get("f_morat"), N)
    ls = _fdi_loan_schedule(lloan, loan.get("l_rate"), loan.get("l_tenure"), loan.get("l_morat"), N)
    util = proj.get("util") or []
    dep_other = (cs["plant"] + cs["veh"] + cs["off"]) / max(1.0, _fn(proj.get("dep_life")))
    dep_b = cs["bldg"] / max(1.0, _fn(proj.get("dep_bldg")))
    amort_rate = cs["preop"] / max(1.0, _fn(proj.get("preop_amort")))

    Y = []
    cash0 = fin["sources"] - cs["fixed"] - cs["preop"]
    acc_dep, preop_left, ret = 0.0, cs["preop"], 0.0
    cash_prev, nwc_prev, cf_loss = cash0, 0.0, 0.0
    for y in range(1, N + 1):
        u = _fn(util[y - 1] if y - 1 < len(util) else 100) / 100
        esc = (1 + _fn(proj.get("growth")) / 100) ** (y - 1)
        rev = _fn(proj.get("rev_y1")) * u * esc
        cogs = rev * _fn(proj.get("cogs_pct")) / 100
        admin = rev * _fn(proj.get("admin_pct")) / 100
        sell = rev * _fn(proj.get("sell_pct")) / 100
        oth = rev * _fn(proj.get("other_pct")) / 100
        sal = rev * _fn(proj.get("salary_pct")) / 100
        roy = rev * _fn(proj.get("royalty_pct")) / 100
        tech = rev * _fn(proj.get("tech_fee_pct")) / 100
        mgmt = rev * _fn(proj.get("mgmt_fee_pct")) / 100
        opex = cogs + admin + sell + oth + sal
        fees = roy + tech + mgmt
        ebitda = rev - opex - fees
        d_b = dep_b if y <= _fn(proj.get("dep_bldg")) else 0.0
        d_o = dep_other if y <= _fn(proj.get("dep_life")) else 0.0
        am = min(amort_rate, preop_left) if preop_left > 0.005 else 0.0
        preop_left -= am
        dep = d_b + d_o + am
        ebit = ebitda - dep
        int_f, int_l = fs[y - 1]["interest"], ls[y - 1]["interest"]
        interest = int_f + int_l
        pbt = ebit - interest
        taxable = pbt + cf_loss
        rate = _fdi_effective_tax_rate(y, proj)
        tax = 0.0
        if taxable > 0:
            tax = taxable * rate / 100
            cf_loss = 0.0
        else:
            cf_loss = taxable
        pat = pbt - tax
        ar = rev * _fn(proj.get("ar_days")) / 365
        inv = cogs * _fn(proj.get("inv_days")) / 365
        ap = cogs * _fn(proj.get("ap_days")) / 365
        nwc = ar + inv - ap
        d_nwc = nwc - nwc_prev
        ret += pat
        div = min(pat * _fn(proj.get("payout")) / 100, ret) if (pat > 0 and ret > 0) else 0.0
        ret -= div
        princ = fs[y - 1]["principal"] + ls[y - 1]["principal"]
        net = cash_prev + pat + dep - d_nwc - princ - div
        od = -net if net < -0.005 else 0.0
        cash = net if net > 0 else 0.0
        acc_dep += d_b + d_o
        net_block = cs["fixed"] - acc_dep
        assets = net_block + preop_left + ar + inv + cash
        liab = fin["equity"] + ret + fs[y - 1]["close"] + ls[y - 1]["close"] + ap + od
        dscr = ((pat + dep + interest) / (interest + princ)) if (interest + princ) > 0 else None
        Y.append({
            "y": y, "util": u * 100, "rev": rev, "cogs": cogs, "admin": admin, "sell": sell, "oth": oth, "sal": sal,
            "roy": roy, "tech": tech, "mgmt": mgmt, "fees": fees, "opex": opex, "ebitda": ebitda, "dep": dep,
            "ebit": ebit, "int_f": int_f, "int_l": int_l, "interest": interest, "pbt": pbt, "tax": tax,
            "tax_rate": rate, "pat": pat, "div": div, "ret": ret, "ar": ar, "inv": inv, "ap": ap, "nwc": nwc,
            "d_nwc": d_nwc, "cash": cash, "od": od, "net": net, "princ": princ,
            "f_close": fs[y - 1]["close"], "l_close": ls[y - 1]["close"],
            "acc_dep": acc_dep, "net_block": net_block, "preop_left": preop_left, "assets": assets, "liab": liab,
            "diff": assets - liab, "dscr": dscr, "ebitda_pct": (ebitda / rev * 100) if rev else 0.0,
            "pat_pct": (pat / rev * 100) if rev else 0.0,
            "current_ratio": (ar + inv + cash) / max(1e-9, ap + od),
            "roce": (ebit / max(1e-9, net_block + nwc) * 100),
        })
        cash_prev, nwc_prev = net, nwc

    wacc = _fn(val.get("wacc")) / 100 or 0.16
    fcf, eq = [-cs["total"]], [-fin["equity"]]
    for i, r in enumerate(Y):
        t = r["tax_rate"] / 100
        f = r["ebit"] * (1 - t) + r["dep"] - r["d_nwc"]
        if i == len(Y) - 1:
            f += r["nwc"] + (cs["fixed"] - r["acc_dep"])
        fcf.append(f)
        d = r["div"] * (1 - _fn(params.get("dividend_wht")) / 100)
        if i == len(Y) - 1:
            d += max(0.0, fin["equity"] + r["ret"])
        eq.append(d)
    npv = sum(f / ((1 + wacc) ** i) for i, f in enumerate(fcf))
    return {
        "years": N, "Y": Y, "cs": cs, "fin": fin, "fs": fs, "ls": ls, "cash0": cash0,
        "npv": npv, "irr": _fdi_irr(fcf), "eirr": _fdi_irr(eq), "fcf": fcf, "eq": eq, "wacc": wacc * 100,
        "avg_dscr": _fdi_avg([r["dscr"] for r in Y]), "min_dscr": _fdi_min([r["dscr"] for r in Y]),
        "payback": _fdi_payback(fcf), "max_diff": max([abs(r["diff"]) for r in Y] + [0.0]),
    }


def _fdi_sector_row(name):
    return db.fdi_sector(name or "") or db.fdi_sector("Other / to be specified") or {
        "name": "Other / to be specified", "cap_pct": 100, "tax_rate": 25, "special": 0, "note": ""}


def _fdi_route_check(data, totals, sector):
    params = data.get("params") or {}
    route = data.get("route") or {}
    client = data.get("client") or {}
    out = {"flags": [], "total_inv": totals["total_inv"], "foreign": totals["foreign"], "foreign_pct": totals["foreign_pct"]}
    is_it = bool(re.search("information technology", str(client.get("sector") or ""), re.I))
    out["min_per_investor"] = 0.0 if (is_it and params.get("min_fdi_exempt_it")) else _fn(params.get("min_fdi"))
    fx = data.get("fx") or {}
    out["below_min"] = [inv for inv in (data.get("investors") or [])
                        if _fdi_is_foreign(inv) and _fdi_inv_npr(inv, fx) < out["min_per_investor"] - 0.5]
    cap_override = route.get("cap_override")
    out["cap"] = _fn(cap_override) if cap_override not in (None, "") else _fn(sector["cap_pct"])
    out["cap_breach"] = totals["foreign_pct"] > out["cap"] + 0.0001
    amt = totals["total_inv"]
    if (route.get("authority") or "auto") == "auto":
        out["authority"] = "Investment Board Nepal (IBN)" if amt > _fn(params.get("ibn_threshold")) else "Department of Industry (DoI)"
    else:
        out["authority"] = route.get("authority")
    neg_checks = route.get("neg_checks") or {}
    neg_selected = [k for k, v in neg_checks.items() if v]
    out["neg_hits"] = neg_selected
    out["auto_eligible"] = (amt <= _fn(params.get("auto_cap")) and not neg_selected and
                            not out["cap_breach"] and amt <= _fn(params.get("ibn_threshold")))
    if route.get("auto_eligible") == "yes":
        out["auto_eligible"] = True
    if route.get("auto_eligible") == "no":
        out["auto_eligible"] = False
    if "IBN" in out["authority"]:
        out["route"] = "Investment Board approval route"
    elif out["auto_eligible"]:
        out["route"] = "Automatic route (online approval)"
    else:
        out["route"] = "Department of Industry approval route"
    if neg_selected:
        out["flags"].append({"t": "bad", "m": f"The activity touches {len(neg_selected)} 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"]:
        out["flags"].append({"t": "bad", "m": f"{len(out['below_min'])} foreign investor(s) are below the minimum of NPR {out['min_per_investor']:,.0f} each. The minimum applies per foreign investor, not to the total."})
    if out["cap_breach"]:
        out["flags"].append({"t": "bad", "m": f"Foreign holding of {totals['foreign_pct']:.2f}% exceeds the ceiling of {out['cap']:.0f}% indicated for this sector."})
    if totals["foreign"] <= 0:
        out["flags"].append({"t": "warn", "m": "No foreign investor has been entered, so there is no foreign investment to approve."})
    if amt > _fn(params.get("ibn_threshold")):
        out["flags"].append({"t": "warn", "m": f"Total investment exceeds the Investment Board threshold of NPR {_fn(params.get('ibn_threshold')):,.0f}. The file goes to IBN, not DoI, and the approval timeline is longer."})
    elif amt > _fn(params.get("auto_cap")):
        out["flags"].append({"t": "info", "m": f"Investment exceeds the automatic-route ceiling of NPR {_fn(params.get('auto_cap')):,.0f}. Expect the standard DoI approval route."})
    if not params.get("last_verified"):
        out["flags"].append({"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


def _fdi_tax_pack(data, model, totals):
    params = data.get("params") or {}
    fp = totals["foreign_pct"] / 100
    rows = []
    for r in model["Y"]:
        div_f = r["div"] * fp
        wht = div_f * _fn(params.get("dividend_wht")) / 100
        int_f = r["int_f"]
        int_wht = int_f * _fn(params.get("interest_wht")) / 100
        fees = r["roy"] + r["tech"] + r["mgmt"]
        fee_wht = fees * _fn(params.get("royalty_wht")) / 100
        corp_tax = r["tax"]
        total_tax = corp_tax + wht + int_wht + fee_wht
        rows.append({"y": r["y"], "pbt": r["pbt"], "corp_tax": corp_tax, "pat": r["pat"], "div": r["div"],
                    "div_f": div_f, "wht": wht, "net_div": div_f - wht, "int_f": int_f, "int_wht": int_wht,
                    "net_int": int_f - int_wht, "fees": fees, "fee_wht": fee_wht, "net_fee": fees - fee_wht,
                    "total_out": (div_f - wht) + (int_f - int_wht) + (fees - fee_wht), "total_tax": total_tax,
                    "eff": (total_tax / r["pbt"] * 100) if r["pbt"] > 0 else 0.0,
                    "principal_f": model["fs"][r["y"] - 1]["principal"]})
    last = model["Y"][-1] if model["Y"] else {"pbt": 0, "tax": 0, "pat": 0}
    proj = data.get("proj") or {}
    cr = _fdi_effective_tax_rate(model["years"], proj)
    return {
        "rows": rows, "total_net_out": sum(x["total_out"] for x in rows),
        "total_principal": sum(x["principal_f"] for x in rows), "total_tax": sum(x["total_tax"] for x in rows),
        "foreign_invested": totals["foreign"], "fp": fp,
        "compare": {
            "subsidiary": {"tax": last["pbt"] * cr / 100 if last["pbt"] > 0 else 0.0,
                           "then": f"Dividend withholding at {params.get('dividend_wht')}% on distribution"},
            "branch": {"tax": last["pbt"] * _fn(proj.get("tax_rate")) / 100 if last["pbt"] > 0 else 0.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"],
        },
    }


def _fdi_exit_calc(data, model, totals):
    val = data.get("val") or {}
    params = data.get("params") or {}
    ey = max(1, min(model["years"], round(_fn(val.get("exit_year"))) or model["years"]))
    r = model["Y"][ey - 1] if ey - 1 < len(model["Y"]) else (model["Y"][-1] if model["Y"] else None)
    if not r:
        return None
    ev = r["ebitda"] * _fn(val.get("exit_multiple"))
    net_debt = r["f_close"] + r["l_close"] - r["net"]
    eqv = ev - net_debt
    fp = totals["foreign_pct"] / 100
    foreign_proceeds = eqv * fp
    cost_base = _fn(val.get("exit_cost_base")) or totals["foreign"]
    gain = max(0.0, foreign_proceeds - cost_base)
    cgt = gain * _fn(params.get("cgt_non_resident")) / 100
    shares = totals["shares"] or 1
    return {"ey": ey, "ebitda": r["ebitda"], "ev": ev, "net_debt": net_debt, "eqv": eqv, "per_share": eqv / shares,
            "fp": fp * 100, "foreign_proceeds": foreign_proceeds, "cost_base": cost_base, "gain": gain, "cgt": cgt,
            "net": foreign_proceeds - cgt, "moic": (foreign_proceeds / cost_base) if cost_base else 0.0, "shares": shares}


def _fdi_valuation(data, model, totals):
    val = data.get("val") or {}
    capital = data.get("capital") or {}
    shares = totals["shares"] or round(totals["total_inv"] / (_fn(capital.get("face_value")) or 100)) or 1
    wacc = _fn(val.get("wacc")) / 100
    g = _fn(val.get("tg")) / 100
    pv, fl = 0.0, []
    for i, r in enumerate(model["Y"]):
        tr = r["tax_rate"] / 100
        f = r["ebit"] * (1 - tr) + r["dep"] - r["d_nwc"]
        fl.append(f)
        pv += f / ((1 + wacc) ** (i + 1))
    last = fl[-1] if fl else 0.0
    tv = (last * (1 + g) / (wacc - g)) if wacc > g else 0.0
    pv_tv = tv / ((1 + wacc) ** model["years"])
    ev = pv + pv_tv
    ylast = model["Y"][-1] if model["Y"] else {}
    net_debt = _fn(val.get("net_debt")) or model["fin"]["debt"]
    dcf_eq = ev - net_debt
    nav = (_fn(val.get("net_assets")) or totals["total_inv"]) + _fn(val.get("surplus"))
    pe_eq = (ylast.get("pat", 0) or 0) * _fn(val.get("pe"))
    ev_eq = (ylast.get("ebitda", 0) or 0) * _fn(val.get("ev_ebitda")) - net_debt
    w = {"nav": _fn(val.get("nav_weight")), "dcf": _fn(val.get("dcf_weight")),
         "pe": _fn(val.get("pe_weight")), "ev": _fn(val.get("ebitda_weight"))}
    wsum = (w["nav"] + w["dcf"] + w["pe"] + w["ev"]) or 1
    weighted = (nav * w["nav"] + dcf_eq * w["dcf"] + pe_eq * w["pe"] + ev_eq * w["ev"]) / wsum
    return {"shares": shares, "pv_fcf": pv, "tv": tv, "pv_tv": pv_tv, "ev": ev, "net_debt": net_debt,
            "dcf_eq": dcf_eq, "nav": nav, "pe_eq": pe_eq, "ev_eq": ev_eq, "weighted": weighted, "w": w,
            "per_share": {"nav": nav / shares, "dcf": dcf_eq / shares, "pe": pe_eq / shares,
                          "ev": ev_eq / shares, "weighted": weighted / shares}}


def _fdi_validate(data, totals, model, route, finance, dd_item_count=26):
    v = []
    client = data.get("client") or {}
    params = data.get("params") or {}
    proj = data.get("proj") or {}
    val = data.get("val") or {}
    tt = data.get("tt") or {}
    dd = data.get("dd") or {}
    loan = data.get("loan") or {}

    def add(sev, area, msg):
        v.append({"sev": sev, "area": area, "msg": msg})

    if not client.get("name"):
        add("err", "Client", "The project company has not been named.")
    if not data.get("investors"):
        add("err", "Investors", "No investor has been added.")
    if abs(finance["gap"]) > 1:
        add("err" if finance["gap"] > 0 else "warn", "Finance",
            (f"Cost of project exceeds the means of finance by NPR " if finance["gap"] > 0 else
             "Means of finance exceed the cost of project by NPR ") + f"{abs(finance['gap']):,.0f}. The two must tie before the project report is issued.")
    if _fdi_cost_total(data.get("cost")) <= 0:
        add("err", "Project cost", "Cost of project has not been entered.")
    if _fn(proj.get("rev_y1")) <= 0:
        add("err", "Projections", "Revenue at full capacity has not been entered, so the whole model is zero.")
    for x in route["flags"]:
        add("err" if x["t"] == "bad" else ("warn" if x["t"] == "warn" else "info"), "Route", x["m"])
    capital = data.get("capital") or {}
    if _fn(capital.get("authorised")) and totals["total_inv"] > _fn(capital.get("authorised")):
        add("err", "Capital", f"Issued capital of NPR {totals['total_inv']:,.0f} exceeds the authorised capital of NPR {_fn(capital.get('authorised')):,.0f}.")
    if model["max_diff"] > 1:
        add("err", "Model", f"The balance sheet does not tie — maximum difference NPR {model['max_diff']:,.0f}. Report this; it is a defect in the model, not in the data.")
    if model["min_dscr"] is not None and model["min_dscr"] < 1.2 and finance["debt"] > 0:
        add("warn", "Appraisal", f"Minimum DSCR is {model['min_dscr']:.2f}. Lenders in Nepal generally look for 1.20 or better; consider a longer tenure, a moratorium or more equity.")
    for r in model["Y"]:
        if r["od"] > 1:
            add("warn", "Cash flow", f"Year {r['y']} shows a cash shortfall of NPR {r['od']:,.0f} plugged as an overdraft. Interest on the plug is not modelled.")
    if model["irr"] is None:
        add("warn", "Appraisal", "Project IRR could not be computed — the cash flows do not change sign.")
    elif model["irr"] < _fn(val.get("wacc")):
        add("warn", "Appraisal", f"Project IRR of {model['irr']:.2f}% is below the discount rate of {_fn(val.get('wacc')):.0f}%. On these numbers the project does not create value.")
    if finance["de_pct"] > _fn(params.get("de_ratio")):
        add("warn", "Gearing", f"Debt is {finance['de_pct']:.2f}% of the means of finance, above the working norm of {_fn(params.get('de_ratio')):.0f}%. Expect the lender and the regulator to ask for more equity.")
    if _fn(loan.get("f_amt")) > 0 and not params.get("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.")
    fx = data.get("fx") or {}
    for inv in (data.get("investors") or []):
        name = inv.get("name") or ""
        if not name:
            add("err", "Investors", "An investor row has no name.")
        if _fdi_is_foreign(inv) and not inv.get("country"):
            add("warn", "Investors", f"Country not stated for {name or 'an investor'}.")
        if _fdi_is_company(inv) and not inv.get("reg_no"):
            add("warn", "Investors", f"Registration number not stated for {name or 'a company investor'}.")
        if not _fdi_is_company(inv) and _fdi_is_foreign(inv) and not inv.get("passport"):
            add("warn", "Investors", f"Passport number not stated for {name or 'an individual investor'}.")
        if _fdi_is_foreign(inv) and not inv.get("source_of_funds"):
            add("warn", "Due diligence", f"Source of funds not documented for {name or 'a foreign investor'}.")
    if tt.get("has") and _fn(tt.get("royalty_pct")) > _fn(params.get("royalty_domestic_sales_cap")):
        add("warn", "Technology transfer", f"Royalty of {_fn(tt.get('royalty_pct')):.0f}% of sales exceeds the working ceiling of {_fn(params.get('royalty_domestic_sales_cap')):.0f}% used in this file. Anything above the ceiling in the approved agreement is not remittable.")
    dd_done = len([k for k, on in dd.items() if on])
    if dd_done < dd_item_count:
        add("info", "Due diligence", f"{dd_item_count - dd_done} of {dd_item_count} due diligence points are still open.")
    if not params.get("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


# ------------------------------- 11b. CAPITAL STRUCTURE & FEASIBILITY ADVISORY
#
# Debt capacity, loan restructuring comparison, debt-vs-equity funding mix and
# break-even are all standard corporate-finance/CA advisory methodology —
# textbook formulae (annuity, contribution margin, WACC), not a statutory
# figure, so (like Valuation and the DD workbench) there is deliberately no
# rate_master dependency; every input is the preparer's own assumption.
#
# Loan Restructuring here is a side-by-side COMPARISON of a handful of
# proposed terms against the current facility (which option is cheapest,
# by how much) — for the full contractual/EIR amortisation schedule of
# whichever option is actually adopted, that is what the Loan & EMI module's
# own `modifications` mechanism already produces.
#
# Each of the four analyses below also returns a small "what-if" table
# (a scenario/sensitivity flex on the one or two assumptions that matter
# most to that analysis) rather than a separate generic engine — the
# Project Finance / Feasibility module (`projection`) already has its own,
# richer 7-scenario sensitivity table for a full project cash-flow model;
# this is the equivalent lightweight check for each standalone calculator.

def _annuity_payment(principal, periodic_rate, n_periods):
    """Level instalment that fully amortises `principal` over `n_periods` at `periodic_rate` per period."""
    if n_periods <= 0:
        return 0.0
    if periodic_rate == 0:
        return principal / n_periods
    return principal * periodic_rate / (1 - (1 + periodic_rate) ** -n_periods)


def _annuity_principal(payment, periodic_rate, n_periods):
    """Inverse of _annuity_payment — the principal a given level instalment can service."""
    if n_periods <= 0 or payment <= 0:
        return 0.0
    if periodic_rate == 0:
        return payment * n_periods
    return payment * (1 - (1 + periodic_rate) ** -n_periods) / periodic_rate


def _annuity_pv(payment, periodic_rate, n_periods):
    return _annuity_principal(payment, periodic_rate, n_periods)


def _debt_capacity(d):
    ebitda = _pnum(d.get("ebitda"))
    existing_debt = _pnum(d.get("existing_debt"))
    existing_debt_service = _pnum(d.get("existing_debt_service"))
    cash_accruals = d.get("cash_accruals")
    available_for_debt_service = _pnum(cash_accruals) if cash_accruals not in (None, "") else ebitda
    target_dscr = _pnum(d.get("target_dscr"), 1.25) or 1.25
    interest_rate = _pnum(d.get("interest_rate")) / 100.0
    tenure_years = int(_pnum(d.get("tenure_years"), 5) or 5)
    max_leverage_multiple = _pnum(d.get("max_leverage_multiple"), 3.0)

    max_total_debt_service = _dsafe_div(available_for_debt_service, target_dscr)
    max_new_debt_service = max(0.0, max_total_debt_service - existing_debt_service)
    dscr_based_capacity = R2(max(0.0, _annuity_principal(max_new_debt_service, interest_rate, tenure_years)))

    max_total_debt_leverage = ebitda * max_leverage_multiple
    leverage_based_capacity = R2(max(0.0, max_total_debt_leverage - existing_debt))

    recommended = min(dscr_based_capacity, leverage_based_capacity)
    binding = "DSCR" if dscr_based_capacity <= leverage_based_capacity else "Leverage"

    proposed = d.get("proposed_new_debt")
    headroom = R2(recommended - _pnum(proposed)) if proposed not in (None, "") else None

    flex = []
    for label, ebitda_factor in (("EBITDA -20%", 0.80), ("EBITDA -10%", 0.90), ("Base case", 1.0),
                                 ("EBITDA +10%", 1.10)):
        e2 = ebitda * ebitda_factor
        afds2 = available_for_debt_service * ebitda_factor
        mtds2 = _dsafe_div(afds2, target_dscr)
        mnds2 = max(0.0, mtds2 - existing_debt_service)
        dscr_cap2 = max(0.0, _annuity_principal(mnds2, interest_rate, tenure_years))
        lev_cap2 = max(0.0, e2 * max_leverage_multiple - existing_debt)
        flex.append({"scenario": label, "dscr_based_capacity": R2(dscr_cap2), "leverage_based_capacity": R2(lev_cap2),
                    "recommended_max_new_debt": R2(min(dscr_cap2, lev_cap2))})

    return {
        "ebitda": R2(ebitda), "available_for_debt_service": R2(available_for_debt_service),
        "existing_debt": R2(existing_debt), "existing_debt_service": R2(existing_debt_service),
        "target_dscr": target_dscr, "interest_rate_pct": R2(interest_rate * 100), "tenure_years": tenure_years,
        "max_leverage_multiple": max_leverage_multiple,
        "dscr_based_capacity": dscr_based_capacity, "leverage_based_capacity": leverage_based_capacity,
        "recommended_max_new_debt": R2(max(0.0, recommended)), "binding_constraint": binding,
        "proposed_new_debt": R2(_pnum(proposed)) if proposed not in (None, "") else None, "headroom": headroom,
        "sensitivity": flex,
    }


def _loan_restructuring(d):
    cur = d.get("current") or {}
    principal = _pnum(cur.get("principal"))
    rate = _pnum(cur.get("rate")) / 100.0
    tenure = int(_pnum(cur.get("tenure_years")) or 0)
    discount_rate = _pnum(d.get("discount_rate"), _pnum(cur.get("rate"))) / 100.0

    def evaluate(principal_, rate_, tenure_, moratorium_):
        n = max(0, tenure_ - moratorium_)
        opening = principal_ * (1 + rate_) ** moratorium_ if moratorium_ else principal_
        annual_payment = R2(_annuity_payment(opening, rate_, n))
        total_paid = R2(annual_payment * n)
        # principal_ (not opening) is the base — this way moratorium interest,
        # capitalised into `opening` before amortisation starts, is still
        # counted as interest rather than disappearing into "principal".
        total_interest = R2(total_paid - principal_)
        pv = R2(_annuity_pv(annual_payment, discount_rate, n) if discount_rate else total_paid)
        return {"annual_payment": annual_payment, "n_payments": n, "total_paid": total_paid,
               "total_interest": total_interest, "pv_of_payments": pv}

    current_eval = dict(evaluate(principal, rate, tenure, 0),
                       label="Current terms", rate_pct=R2(rate * 100), tenure_years=tenure, moratorium_years=0)

    options = []
    for opt in (d.get("options") or []):
        new_rate = _pnum(opt.get("new_rate"), rate * 100) / 100.0
        new_tenure = int(_pnum(opt.get("new_tenure_years"), tenure) or tenure)
        mor = int(_pnum(opt.get("moratorium_years")) or 0)
        ev = evaluate(principal, new_rate, new_tenure, mor)
        options.append(dict(ev, label=opt.get("label") or "Restructuring option",
                            rate_pct=R2(new_rate * 100), tenure_years=new_tenure, moratorium_years=mor,
                            interest_saving_vs_current=R2(current_eval["total_interest"] - ev["total_interest"]),
                            pv_saving_vs_current=R2(current_eval["pv_of_payments"] - ev["pv_of_payments"]),
                            annual_payment_change=R2(ev["annual_payment"] - current_eval["annual_payment"])))

    best = None
    if options:
        best = min(options, key=lambda o: o["pv_of_payments"])["label"]

    return {"principal": R2(principal), "current": current_eval, "options": options,
           "discount_rate_pct": R2(discount_rate * 100), "lowest_pv_option": best}


def _debt_equity_funding(d):
    project_cost = _pnum(d.get("project_cost"))
    cost_of_debt = _pnum(d.get("cost_of_debt")) / 100.0
    cost_of_equity = _pnum(d.get("cost_of_equity")) / 100.0
    tax_rate = _pnum(d.get("tax_rate")) / 100.0
    after_tax_cost_of_debt = cost_of_debt * (1 - tax_rate)

    scenarios = d.get("scenarios") or []
    rows = []
    for sc in scenarios:
        debt_pct = _pnum(sc.get("debt_pct"))
        equity_pct = 100.0 - debt_pct
        debt_amt = project_cost * debt_pct / 100.0
        equity_amt = project_cost * equity_pct / 100.0
        annual_interest = R2(debt_amt * cost_of_debt)
        tax_shield = R2(annual_interest * tax_rate)
        wacc = R2((debt_pct / 100.0) * after_tax_cost_of_debt * 100 + (equity_pct / 100.0) * cost_of_equity * 100)
        rows.append({"label": sc.get("label") or f"{debt_pct:.0f}:{equity_pct:.0f} Debt:Equity",
                     "debt_pct": debt_pct, "equity_pct": equity_pct, "debt_amount": R2(debt_amt),
                     "equity_amount": R2(equity_amt), "annual_interest": annual_interest,
                     "annual_tax_shield": tax_shield, "wacc_pct": wacc})

    lowest_wacc = min(rows, key=lambda r: r["wacc_pct"])["label"] if rows else None
    return {"project_cost": R2(project_cost), "cost_of_debt_pct": R2(cost_of_debt * 100),
           "cost_of_equity_pct": R2(cost_of_equity * 100), "tax_rate_pct": R2(tax_rate * 100),
           "after_tax_cost_of_debt_pct": R2(after_tax_cost_of_debt * 100),
           "scenarios": rows, "lowest_wacc_scenario": lowest_wacc}


def _breakeven_analysis(d):
    mode = d.get("mode") or "unit"
    fixed_costs = _pnum(d.get("fixed_costs"))

    if mode == "revenue":
        current_revenue = _pnum(d.get("current_revenue"))
        variable_cost_pct = _pnum(d.get("variable_cost_pct_of_revenue")) / 100.0
        cm_ratio = 1 - variable_cost_pct
        bep_revenue = R2(_dsafe_div(fixed_costs, cm_ratio)) if cm_ratio > 0 else None
        mos = R2(current_revenue - bep_revenue) if bep_revenue is not None else None
        mos_pct = R2(_dsafe_div(mos, current_revenue) * 100) if mos is not None and current_revenue else None
        result = {"mode": mode, "fixed_costs": R2(fixed_costs), "contribution_margin_ratio_pct": R2(cm_ratio * 100),
                  "bep_revenue": bep_revenue, "current_revenue": R2(current_revenue),
                  "margin_of_safety": mos, "margin_of_safety_pct": mos_pct}
        flex = []
        for label, fc_factor in (("Fixed costs -10%", 0.9), ("Base case", 1.0), ("Fixed costs +10%", 1.1), ("Fixed costs +20%", 1.2)):
            fc2 = fixed_costs * fc_factor
            bep2 = R2(_dsafe_div(fc2, cm_ratio)) if cm_ratio > 0 else None
            flex.append({"scenario": label, "bep_revenue": bep2})
        result["sensitivity"] = flex
        return result

    price = _pnum(d.get("selling_price_per_unit"))
    vc = _pnum(d.get("variable_cost_per_unit"))
    current_volume = _pnum(d.get("current_volume"))
    cm_per_unit = price - vc
    cm_ratio = R2(_dsafe_div(cm_per_unit, price) * 100)
    bep_units = R2(_dsafe_div(fixed_costs, cm_per_unit)) if cm_per_unit > 0 else None
    bep_revenue = R2(bep_units * price) if bep_units is not None else None
    mos_units = R2(current_volume - bep_units) if bep_units is not None else None
    mos_pct = R2(_dsafe_div(mos_units, current_volume) * 100) if mos_units is not None and current_volume else None
    total_cm = current_volume * cm_per_unit
    operating_leverage = (R2(_dsafe_div(total_cm, total_cm - fixed_costs))
                          if (total_cm - fixed_costs) > 0 else None)

    flex = []
    for label, price_factor, vc_factor, fc_factor in (
        ("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)):
        p2, vc2, fc2 = price * price_factor, vc * vc_factor, fixed_costs * fc_factor
        cm2 = p2 - vc2
        bep2 = R2(_dsafe_div(fc2, cm2)) if cm2 > 0 else None
        flex.append({"scenario": label, "bep_units": bep2, "bep_revenue": R2(bep2 * p2) if bep2 is not None else None})

    return {"mode": mode, "fixed_costs": R2(fixed_costs), "selling_price_per_unit": R2(price),
           "variable_cost_per_unit": R2(vc), "contribution_margin_per_unit": R2(cm_per_unit),
           "contribution_margin_ratio_pct": cm_ratio, "bep_units": bep_units, "bep_revenue": bep_revenue,
           "current_volume": R2(current_volume), "margin_of_safety_units": mos_units,
           "margin_of_safety_pct": mos_pct, "operating_leverage": operating_leverage, "sensitivity": flex}


def capital_advisory(fy, data):
    d = data or {}
    return {
        "entity": d.get("entity") or {},
        "capacity": _debt_capacity(d.get("capacity") or {}),
        "restructuring": _loan_restructuring(d.get("restructure") or {}),
        "funding": _debt_equity_funding(d.get("funding") or {}),
        "breakeven": _breakeven_analysis(d.get("breakeven") or {}),
        "signatory": d.get("signatory") or {},
        "unverified": [],
    }


# Post-approval recurring compliance calendar and the foreign-loan compliance
# checklist — the two "follow-up phase" trackers flagged (but not built) when
# the FDI model was first ported; the DoI 15-step procedure and the DD/roadmap
# trackers above already exist. Presentation-only reference content, like
# FDI_NEGATIVE_LIST — the checked/date state lives in the workfile, not here.
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"],
]

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",
]


def _fdi_loan_compliance(data, finance):
    """Foreign Loan Compliance Checker — a rule check on the confirmed interest-rate
    ceiling (blank/UNVERIFIED by default, exactly like every other params.* threshold
    in this model — never a hardcoded NRB rate) plus a procedural checklist, since most
    of what makes a foreign loan compliant is documentary, not a number to compare."""
    loan = data.get("loan") or {}
    params = data.get("params") or {}
    checklist = data.get("loan_compliance") or {}
    out = {"has_loan": _fn(loan.get("f_amt")) > 0, "items": [], "flags": []}
    if not out["has_loan"]:
        return out
    for i, item in enumerate(FDI_LOAN_COMPLIANCE_CHECKLIST):
        out["items"].append({"item": item, "done": bool(checklist.get(str(i)))})
    rate = _fn(loan.get("f_rate"))
    ceiling = params.get("foreign_loan_rate_ceiling")
    if ceiling not in (None, ""):
        ceiling_v = _fn(ceiling)
        out["rate_ceiling"] = ceiling_v
        out["rate_breach"] = rate > ceiling_v + 0.0001
        if out["rate_breach"]:
            out["flags"].append({"t": "bad", "m": f"Foreign loan interest rate of {rate:.2f}% exceeds the confirmed ceiling of {ceiling_v:.2f}%."})
    else:
        out["flags"].append({"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."})
    de_ratio = _fn(params.get("de_ratio"))
    if de_ratio and finance.get("de_pct", 0) > de_ratio:
        out["flags"].append({"t": "warn", "m": f"Gearing including this loan is {finance['de_pct']:.2f}% of means of finance, above the working norm of {de_ratio:.0f}%."})
    incomplete = [i for i in out["items"] if not i["done"]]
    if incomplete:
        out["flags"].append({"t": "info", "m": f"{len(incomplete)} of {len(out['items'])} foreign-loan compliance point(s) not yet confirmed."})
    return out


def _fdi_fx_analysis(data, totals, exit_calc):
    """Foreign Currency Investment Analyzer — isolates the FX-movement component of
    an investor's return by re-expressing exit proceeds in the investor's own
    invested currency at a separately-entered exit rate (fx_exit), rather than the
    entry rate the rest of the model uses. Exit proceeds are apportioned across
    foreign investors pro-rata to their share of total foreign investment."""
    fx = data.get("fx") or {}
    fx_exit = data.get("fx_exit") or {}
    rows = []
    for inv in (data.get("investors") or []):
        if not _fdi_is_foreign(inv):
            continue
        cur = inv.get("currency") or "NPR"
        if cur == "NPR":
            continue
        entry_rate = _fn(fx.get("rates", {}).get(cur)) or _fn(fx.get("rate"))
        exit_rate = _fn(fx_exit.get(cur)) or entry_rate
        amount_fc = _fn(inv.get("amount"))
        invested_npr = amount_fc * entry_rate
        share_of_foreign = (invested_npr / totals["foreign"]) if totals["foreign"] else 0.0
        proceeds_npr = (exit_calc["net"] * share_of_foreign) if exit_calc else 0.0
        proceeds_fc = (proceeds_npr / exit_rate) if exit_rate else 0.0
        fx_move_pct = ((exit_rate - entry_rate) / entry_rate * 100) if entry_rate else 0.0
        rows.append({
            "investor": inv.get("name", ""), "currency": cur, "entry_rate": R2(entry_rate), "exit_rate": R2(exit_rate),
            "fx_move_pct": R2(fx_move_pct), "invested_fc": R2(amount_fc), "invested_npr": R2(invested_npr),
            "proceeds_npr": R2(proceeds_npr), "proceeds_fc": R2(proceeds_fc),
            "moic_fc": R2(proceeds_fc / amount_fc) if amount_fc else 0.0,
        })
    return {"rows": rows, "has_exit": exit_calc is not None}


def fdi(fy, data):
    data = data or {}
    sector = _fdi_sector_row((data.get("client") or {}).get("sector"))
    totals = _fdi_totals(data.get("investors") or [], data.get("capital") or {}, data.get("fx") or {})
    model = _fdi_model(data)
    route = _fdi_route_check(data, totals, sector)
    fx = data.get("fx") or {}
    floan = _fdi_floan_npr(data.get("loan") or {}, fx)
    lloan = _fn((data.get("loan") or {}).get("l_amt"))
    src = totals["total_inv"] + floan + lloan
    cost_total = _fdi_cost_total(data.get("cost"))
    finance = {"equity": totals["total_inv"], "f_loan": floan, "l_loan": lloan, "debt": floan + lloan,
               "sources": src, "cost": cost_total, "gap": cost_total - src,
               "de_pct": ((floan + lloan) / src * 100) if src else 0.0, "eq_pct": (totals["total_inv"] / src * 100) if src else 0.0}
    tax_pack = _fdi_tax_pack(data, model, totals)
    exit_calc = _fdi_exit_calc(data, model, totals)
    valuation = _fdi_valuation(data, model, totals)
    checks = _fdi_validate(data, totals, model, route, finance)
    loan_compliance = _fdi_loan_compliance(data, finance)
    fx_analysis = _fdi_fx_analysis(data, totals, exit_calc)

    return {
        "sector": sector, "totals": totals, "model": model, "route": route, "finance": finance,
        "tax_pack": tax_pack, "exit": exit_calc, "valuation": valuation, "checks": checks,
        "loan_compliance": loan_compliance, "fx_analysis": fx_analysis,
        "negative_list": FDI_NEGATIVE_LIST, "compliance_calendar": FDI_COMPLIANCE_CALENDAR,
        "unverified": [] if (data.get("params") or {}).get("last_verified") else ["fdi.params"],
        "client": data.get("client") or {}, "investors": data.get("investors") or [],
        "capital": data.get("capital") or {}, "cost": data.get("cost") or [],
        "jva": data.get("jva") or {}, "poa": data.get("poa") or {}, "tt": data.get("tt") or {},
        "doi": data.get("doi") or {}, "roadmap": data.get("roadmap") or [], "dd": data.get("dd") or {},
        "compliance": data.get("compliance") or {}, "loan_compliance_checklist": data.get("loan_compliance") or {},
        "params": data.get("params") or {}, "fx": data.get("fx") or {}, "fx_exit": data.get("fx_exit") or {},
    }


# --------------------------------------------------------- 10b. INVENTORY (NAS 2)
#
# Raw materials -> WIP -> finished goods, each costed and then measured at the
# lower of cost and net realisable value — NAS 2's actual rule, not "value at
# cost" the way a simpler model would. Not statutory beyond that principle
# itself, so no rate_master dependency; every cost and rate here is the
# firm's own data.

def _inv_landed_unit_cost(p):
    qty = float(p.get("qty", 0) or 0)
    if qty <= 0:
        return 0.0
    gross = qty * float(p.get("rate", 0) or 0)
    freight = float(p.get("freight", 0) or 0)
    duty = float(p.get("duty", 0) or 0)
    other = float(p.get("other_cost", 0) or 0)
    discount = float(p.get("discount", 0) or 0)
    return (gross + freight + duty + other - discount) / qty


def _inv_run_ledger(events, method):
    """
    events: [{date, kind: 'in'|'out', qty, unit_cost (for 'in'), desc}].
    FIFO keeps a queue of [qty, unit_cost] layers, consuming the oldest first;
    weighted average keeps one running (qty, cost) balance, recomputed on
    every receipt. An 'out' larger than what's on hand is met as far as
    possible and the balance is flagged as a shortfall rather than going
    negative — a data problem to fix, not a valid physical position.
    """
    events = sorted(events, key=lambda e: (e.get("date") or "", 0 if e["kind"] == "in" else 1))
    ledger = []
    if method == "fifo":
        layers = []
        for e in events:
            if e["kind"] == "in":
                layers.append([e["qty"], e["unit_cost"]])
                bq = sum(l[0] for l in layers)
                bc = sum(l[0] * l[1] for l in layers)
                ledger.append(dict(e, total_cost=R2(e["qty"] * e["unit_cost"]), balance_qty=R2(bq), balance_cost=R2(bc)))
            else:
                remaining, cost_out = e["qty"], 0.0
                while remaining > 1e-9 and layers:
                    lot_qty, lot_cost = layers[0]
                    take = min(remaining, lot_qty)
                    cost_out += take * lot_cost
                    layers[0][0] -= take
                    remaining -= take
                    if layers[0][0] <= 1e-9:
                        layers.pop(0)
                bq = sum(l[0] for l in layers)
                bc = sum(l[0] * l[1] for l in layers)
                ledger.append(dict(e, unit_cost=R2(cost_out / e["qty"]) if e["qty"] else 0.0, total_cost=R2(cost_out),
                                   balance_qty=R2(bq), balance_cost=R2(bc),
                                   shortfall=R2(remaining) if remaining > 1e-9 else 0.0))
    else:
        bq, bc = 0.0, 0.0
        for e in events:
            if e["kind"] == "in":
                bq += e["qty"]; bc += e["qty"] * e["unit_cost"]
                ledger.append(dict(e, total_cost=R2(e["qty"] * e["unit_cost"]), balance_qty=R2(bq), balance_cost=R2(bc)))
            else:
                avg = (bc / bq) if bq > 1e-9 else 0.0
                take = min(e["qty"], bq)
                cost_out = take * avg
                shortfall = e["qty"] - take
                bq -= take; bc -= cost_out
                if bq <= 1e-9:
                    bq, bc = 0.0, 0.0
                ledger.append(dict(e, unit_cost=R2(avg), total_cost=R2(cost_out), balance_qty=R2(bq), balance_cost=R2(bc),
                                   shortfall=R2(shortfall) if shortfall > 1e-9 else 0.0))
    return ledger


def _inv_raw_material(item):
    method = item.get("costing_method", "weighted_avg")
    opening = item.get("opening") or {}
    events = []
    oq = float(opening.get("qty", 0) or 0)
    if oq > 0:
        events.append({"date": opening.get("date", ""), "kind": "in", "type": "opening", "qty": oq,
                       "unit_cost": float(opening.get("rate", 0) or 0)})
    for p in item.get("purchases", []) or []:
        q = float(p.get("qty", 0) or 0)
        if q > 0:
            events.append({"date": p.get("date", ""), "kind": "in", "type": "purchase", "qty": q,
                           "unit_cost": _inv_landed_unit_cost(p), "desc": p.get("desc", "")})
    for iss in item.get("issues", []) or []:
        q = float(iss.get("qty", 0) or 0)
        if q > 0:
            events.append({"date": iss.get("date", ""), "kind": "out", "type": "issue", "qty": q,
                           "desc": iss.get("desc", "")})
    ledger = _inv_run_ledger(events, method)

    closing_qty = ledger[-1]["balance_qty"] if ledger else oq
    closing_cost = ledger[-1]["balance_cost"] if ledger else oq * float(opening.get("rate", 0) or 0)
    closing_unit_cost = (closing_cost / closing_qty) if closing_qty > 1e-9 else 0.0

    # NAS 2: raw materials held for use in production are not written down
    # below cost merely because replacement cost has fallen — only when the
    # finished goods they'll produce are themselves expected to sell below
    # cost, in which case replacement cost is the best evidence of NRV.
    write_down, valuation_basis = 0.0, "cost"
    replacement_cost = item.get("replacement_cost_per_unit")
    if item.get("fg_expected_below_cost") and replacement_cost not in (None, "") and float(replacement_cost) < closing_unit_cost:
        rc = float(replacement_cost)
        write_down = (closing_unit_cost - rc) * closing_qty
        closing_unit_cost = rc
        closing_cost = closing_unit_cost * closing_qty
        valuation_basis = "replacement cost (related finished goods expected to sell below cost)"

    issued = [e for e in ledger if e["type"] == "issue"]
    return {
        "name": item.get("name", ""), "unit": item.get("unit", ""), "costing_method": method, "ledger": ledger,
        "closing_qty": R2(closing_qty), "closing_unit_cost": R2(closing_unit_cost),
        "write_down": R2(write_down), "valuation_basis": valuation_basis, "closing_value": R2(closing_cost),
        "material_consumed_qty": R2(sum(e["qty"] for e in issued)),
        "material_consumed_cost": R2(sum(e.get("total_cost", 0) for e in issued)),
        "shortfall_qty": R2(sum(e.get("shortfall", 0) for e in issued)),
    }


def _inv_wip(wip_data, material_cost):
    """
    Weighted-average process costing with separate material and conversion
    equivalent units — materials are usually introduced up front, conversion
    accrues through the process, so a half-finished unit is rarely half the
    material cost and half the conversion cost, and treating it as one blended
    percentage misstates both the transfer to FG and the closing WIP value.
    Fixed overhead is absorbed at a rate set from normal capacity (NAS 2 §13):
    under-utilisation does not inflate the rate — the shortfall is expensed,
    not carried into inventory.
    """
    opening = wip_data.get("opening") or {}
    opening_qty = float(opening.get("qty", 0) or 0)
    opening_cost = float(opening.get("cost", 0) or 0)
    units_introduced = float(wip_data.get("units_introduced", 0) or 0)

    labor_cost = float(wip_data.get("direct_labor_cost", 0) or 0)
    variable_oh = float(wip_data.get("variable_overhead", 0) or 0)
    normal_capacity = float(wip_data.get("normal_capacity_units", 0) or 0)
    actual_production = float(wip_data.get("actual_production_units", 0) or 0) or units_introduced
    budgeted_fixed_oh = float(wip_data.get("budgeted_fixed_overhead", 0) or 0)

    if normal_capacity > 0 and budgeted_fixed_oh > 0:
        fixed_oh_rate = budgeted_fixed_oh / normal_capacity
        fixed_oh_absorbed = fixed_oh_rate * actual_production
        unabsorbed_fixed_oh = max(0.0, budgeted_fixed_oh - fixed_oh_rate * min(actual_production, normal_capacity)) \
            if actual_production < normal_capacity else 0.0
    else:
        fixed_oh_rate, fixed_oh_absorbed, unabsorbed_fixed_oh = 0.0, budgeted_fixed_oh, 0.0

    total_conversion_cost = labor_cost + variable_oh + fixed_oh_absorbed

    normal_loss_units = float(wip_data.get("normal_loss_units", 0) or 0)
    abnormal_loss_units = float(wip_data.get("abnormal_loss_units", 0) or 0)
    scrap_rate = float(wip_data.get("scrap_rate_per_unit", 0) or 0)
    normal_scrap_value = normal_loss_units * scrap_rate

    transferred_units = float(wip_data.get("units_completed_transferred", 0) or 0)
    closing_wip_units = float(wip_data.get("closing_wip_units", 0) or 0)
    mat_pct = float(wip_data.get("closing_material_pct", 100) or 100) / 100
    conv_pct = float(wip_data.get("closing_conversion_pct", 50) or 50) / 100

    equiv_units_material = transferred_units + abnormal_loss_units + closing_wip_units * mat_pct
    equiv_units_conversion = transferred_units + abnormal_loss_units + closing_wip_units * conv_pct

    material_pool = material_cost - normal_scrap_value  # normal-loss scrap credited against material cost
    material_rate = material_pool / equiv_units_material if equiv_units_material > 0 else 0.0
    conversion_rate = total_conversion_cost / equiv_units_conversion if equiv_units_conversion > 0 else 0.0
    combined_rate = material_rate + conversion_rate

    abnormal_loss_cost = abnormal_loss_units * combined_rate
    abnormal_scrap_recovery = abnormal_loss_units * scrap_rate
    abnormal_loss_net_expense = max(0.0, abnormal_loss_cost - abnormal_scrap_recovery)

    transferred_cost = opening_cost + transferred_units * combined_rate
    closing_wip_value = closing_wip_units * mat_pct * material_rate + closing_wip_units * conv_pct * conversion_rate

    # WIP's own lower-of-cost-NRV: what the eventual finished unit will fetch,
    # less whatever it still costs to finish and sell.
    nrv_write_down = 0.0
    sp = wip_data.get("nrv_selling_price_per_unit")
    if sp not in (None, "") and closing_wip_units > 0:
        cost_to_complete = float(wip_data.get("nrv_cost_to_complete_per_unit", 0) or 0)
        selling_cost = float(wip_data.get("nrv_selling_cost_per_unit", 0) or 0)
        nrv_per_unit = max(0.0, float(sp) - cost_to_complete - selling_cost)
        cost_per_unit = closing_wip_value / closing_wip_units
        if nrv_per_unit < cost_per_unit:
            nrv_write_down = (cost_per_unit - nrv_per_unit) * closing_wip_units
            closing_wip_value -= nrv_write_down

    units_to_account = opening_qty + units_introduced
    units_accounted = transferred_units + closing_wip_units + normal_loss_units + abnormal_loss_units

    return {
        "opening_qty": R2(opening_qty), "opening_cost": R2(opening_cost), "units_introduced": R2(units_introduced),
        "material_cost": R2(material_cost), "direct_labor_cost": R2(labor_cost), "variable_overhead": R2(variable_oh),
        "fixed_overhead_rate": R2(fixed_oh_rate), "fixed_overhead_absorbed": R2(fixed_oh_absorbed),
        "unabsorbed_fixed_overhead": R2(unabsorbed_fixed_oh), "total_conversion_cost": R2(total_conversion_cost),
        "normal_loss_units": R2(normal_loss_units), "abnormal_loss_units": R2(abnormal_loss_units),
        "scrap_rate_per_unit": R2(scrap_rate), "normal_scrap_value": R2(normal_scrap_value),
        "equivalent_units_material": R2(equiv_units_material), "equivalent_units_conversion": R2(equiv_units_conversion),
        "material_rate_per_eu": R2(material_rate), "conversion_rate_per_eu": R2(conversion_rate),
        "combined_rate_per_eu": R2(combined_rate), "abnormal_loss_cost": R2(abnormal_loss_net_expense),
        "transferred_units": R2(transferred_units), "transferred_cost": R2(transferred_cost),
        "closing_wip_units": R2(closing_wip_units), "closing_wip_value_before_nrv": R2(closing_wip_value + nrv_write_down),
        "nrv_write_down": R2(nrv_write_down), "closing_wip_value": R2(max(0.0, closing_wip_value)),
        "units_to_account": R2(units_to_account), "units_accounted": R2(units_accounted),
        "reconciles": abs(units_to_account - units_accounted) < 0.01,
    }


def _inv_finished_goods(fg_data, transferred_units, transferred_cost):
    method = fg_data.get("costing_method", "weighted_avg")
    opening = fg_data.get("opening") or {}
    events = []
    oq = float(opening.get("qty", 0) or 0)
    if oq > 0:
        events.append({"date": opening.get("date", ""), "kind": "in", "type": "opening", "qty": oq,
                       "unit_cost": float(opening.get("rate", 0) or 0)})
    if transferred_units > 0:
        events.append({"date": fg_data.get("transfer_date", ""), "kind": "in", "type": "production",
                       "qty": transferred_units, "unit_cost": (transferred_cost / transferred_units) if transferred_units else 0.0})
    for s in fg_data.get("sales", []) or []:
        q = float(s.get("qty", 0) or 0)
        if q > 0:
            events.append({"date": s.get("date", ""), "kind": "out", "type": "sale", "qty": q, "desc": s.get("desc", "")})
    ledger = _inv_run_ledger(events, method)

    closing_qty = ledger[-1]["balance_qty"] if ledger else oq
    closing_cost = ledger[-1]["balance_cost"] if ledger else oq * float(opening.get("rate", 0) or 0)
    closing_unit_cost = (closing_cost / closing_qty) if closing_qty > 1e-9 else 0.0

    write_down = 0.0
    sp = fg_data.get("selling_price_per_unit")
    if sp not in (None, "") and closing_qty > 0:
        selling_cost = float(fg_data.get("selling_cost_per_unit", 0) or 0)
        nrv_per_unit = max(0.0, float(sp) - selling_cost)
        if nrv_per_unit < closing_unit_cost:
            write_down = (closing_unit_cost - nrv_per_unit) * closing_qty
            closing_unit_cost = nrv_per_unit
            closing_cost -= write_down

    sold = [e for e in ledger if e["type"] == "sale"]
    return {
        "costing_method": method, "ledger": ledger, "closing_qty": R2(closing_qty),
        "closing_unit_cost": R2(closing_unit_cost), "write_down": R2(write_down), "closing_value": R2(max(0.0, closing_cost)),
        "cogs_qty": R2(sum(e["qty"] for e in sold)), "cogs_value": R2(sum(e.get("total_cost", 0) for e in sold)),
        "shortfall_qty": R2(sum(e.get("shortfall", 0) for e in sold)),
    }


def _inv_journals(rm_results, wip, fg, rm_material_cost):
    j = []
    for rm in rm_results:
        for e in rm["ledger"]:
            if e["type"] == "purchase":
                tag = e.get("desc") or f"{e['qty']:g} units"
                j.append({"period": e.get("date", ""), "narration": f"Purchase — {rm['name']} ({tag})",
                          "debit": [{"account": f"Raw material inventory — {rm['name']}", "amount": e["total_cost"]}],
                          "credit": [{"account": "Accounts payable / cash", "amount": e["total_cost"]}]})
        if rm["write_down"]:
            j.append({"period": "", "narration": f"Write-down to replacement cost — {rm['name']}",
                      "debit": [{"account": "Inventory write-down expense", "amount": rm["write_down"]}],
                      "credit": [{"account": f"Raw material inventory — {rm['name']}", "amount": rm["write_down"]}]})
    if rm_material_cost:
        j.append({"period": "", "narration": "Raw material issued to production",
                  "debit": [{"account": "Work in progress", "amount": R2(rm_material_cost)}],
                  "credit": [{"account": "Raw material inventory", "amount": R2(rm_material_cost)}]})
    if wip.get("direct_labor_cost"):
        j.append({"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"]}]})
    oh_applied = R2(wip.get("variable_overhead", 0) + wip.get("fixed_overhead_absorbed", 0))
    if oh_applied:
        j.append({"period": "", "narration": "Production overhead applied",
                  "debit": [{"account": "Work in progress", "amount": oh_applied}],
                  "credit": [{"account": "Production overhead applied", "amount": oh_applied}]})
    if wip.get("unabsorbed_fixed_overhead"):
        j.append({"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.get("normal_scrap_value"):
        j.append({"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.get("abnormal_loss_cost"):
        j.append({"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.get("nrv_write_down"):
        j.append({"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.get("transferred_cost"):
        j.append({"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"]}]})
    for e in fg.get("ledger", []):
        if e["type"] == "sale":
            tag = e.get("desc") or f"{e['qty']:g} units"
            j.append({"period": e.get("date", ""), "narration": f"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.get("write_down"):
        j.append({"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


def inventory_valuation(fy, data):
    """
    data: {
      entity: {name, period_label},
      raw_materials: [{name, unit, costing_method, opening:{date,qty,rate},
                       purchases:[{date,qty,rate,freight,duty,other_cost,discount,desc}],
                       issues:[{date,qty,desc}], replacement_cost_per_unit, fg_expected_below_cost}],
      wip: {opening:{qty,cost}, units_introduced, material_cost_override,
            direct_labor_cost, variable_overhead, normal_capacity_units,
            actual_production_units, budgeted_fixed_overhead,
            normal_loss_units, abnormal_loss_units, scrap_rate_per_unit,
            units_completed_transferred, closing_wip_units,
            closing_material_pct, closing_conversion_pct,
            nrv_selling_price_per_unit, nrv_cost_to_complete_per_unit, nrv_selling_cost_per_unit},
      fg: {costing_method, opening:{date,qty,rate}, transfer_date,
           sales:[{date,qty,desc}], selling_price_per_unit, selling_cost_per_unit},
    }
    Not statutory, so no rate_master dependency — every cost, rate and
    percentage here is the firm's own data. Any number of raw materials.
    """
    data = data or {}
    rm_results = [_inv_raw_material(item) for item in (data.get("raw_materials") or [])]
    total_material_consumed = sum(r["material_consumed_cost"] for r in rm_results)

    wip_data = data.get("wip") or {}
    material_override = wip_data.get("material_cost_override")
    material_cost = float(material_override) if material_override not in (None, "") else total_material_consumed
    wip = _inv_wip(wip_data, material_cost)

    fg = _inv_finished_goods(data.get("fg") or {}, wip["transferred_units"], wip["transferred_cost"])

    journals = _inv_journals(rm_results, wip, fg, material_cost)

    closing_inventory_value = R2(sum(r["closing_value"] for r in rm_results) + wip["closing_wip_value"] + fg["closing_value"])
    total_write_down = R2(sum(r["write_down"] for r in rm_results) + wip.get("nrv_write_down", 0) + fg.get("write_down", 0))

    return {
        "entity": data.get("entity") or {}, "raw_materials": rm_results, "material_cost_used_in_wip": R2(material_cost),
        "wip": wip, "fg": fg, "journals": journals,
        "summary": {
            "closing_raw_materials": R2(sum(r["closing_value"] for r in rm_results)),
            "closing_wip": wip["closing_wip_value"], "closing_finished_goods": fg["closing_value"],
            "closing_inventory_value": closing_inventory_value, "total_nrv_write_down": total_write_down,
            "cogs": fg["cogs_value"], "abnormal_loss_expense": wip["abnormal_loss_cost"],
            "unabsorbed_overhead_expense": wip["unabsorbed_fixed_overhead"],
            "wip_reconciles": wip["reconciles"],
        },
        "unverified": [],
    }


# ------------------------------------------------------------- 11. VALUATION
#
# One calculator per valuation APPROACH, not per subject — an EBITDA multiple
# and a per-aana land rate are the same market-comparable arithmetic with
# different labels, and income capitalisation values a stable earnings stream
# whether that stream is a company's normalised PAT or a rented plot's net
# rental income. A working file picks a subject (entity/share/brand/land/
# machinery) and, independently, a method; the UI only offers the methods
# that make sense for that subject, but the engine itself doesn't care — it
# just runs whichever method's inputs it's given. Several workings can be
# run side by side and blended into one concluded value by weight, the way a
# real valuation triangulates income, market and asset approaches rather
# than trusting a single method. Not statutory — no rate_master dependency;
# every rate and multiple here is the valuer's own professional input.

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"]},
}


def _val_dcf(inputs):
    cfs = inputs.get("cashflows") or []
    r = float(inputs.get("discount_rate_pct", 0) or 0) / 100
    lines, pv_sum = [], 0.0
    for i, c in enumerate(cfs, start=1):
        fcf = float(c.get("fcf", 0) or 0)
        disc = 1 / ((1 + r) ** i) if r else 1.0
        pv = fcf * disc
        pv_sum += pv
        lines.append({"year": c.get("year", i), "fcf": R2(fcf), "discount_factor": round(disc, 4), "pv": R2(pv)})
    n = len(cfs)
    last_fcf = float(cfs[-1].get("fcf", 0) or 0) if cfs else 0.0

    method = inputs.get("terminal_method", "gordon")
    tv = 0.0
    if method == "gordon":
        g = float(inputs.get("terminal_growth_pct", 0) or 0) / 100
        if r > g:
            tv = last_fcf * (1 + g) / (r - g)
    else:
        mult = float(inputs.get("exit_multiple", 0) or 0)
        metric = float(inputs.get("exit_metric_value", 0) or 0)
        tv = mult * metric
    pv_tv = tv / ((1 + r) ** n) if n else tv

    ev = pv_sum + pv_tv
    net_debt = float(inputs.get("net_debt", 0) or 0)
    non_op = float(inputs.get("non_operating_assets", 0) or 0)
    minority = float(inputs.get("minority_interest", 0) or 0)
    equity_value = ev - net_debt + non_op - minority
    shares = float(inputs.get("shares_outstanding", 0) or 0)
    per_share = equity_value / shares if shares else None

    return {
        "lines": lines, "pv_of_cashflows": R2(pv_sum), "terminal_method": method, "discount_rate_pct": R2(r * 100),
        "terminal_value": R2(tv), "pv_of_terminal_value": R2(pv_tv), "enterprise_value": R2(ev),
        "net_debt": R2(net_debt), "non_operating_assets": R2(non_op), "minority_interest": R2(minority),
        "shares_outstanding": shares or None, "value_per_share": R2(per_share) if per_share is not None else None,
        "value": R2(equity_value),
    }


def _val_income_capitalization(inputs):
    income = float(inputs.get("normalized_income", 0) or 0)
    cap_rate = float(inputs.get("cap_rate_pct", 0) or 0) / 100
    growth = float(inputs.get("growth_pct", 0) or 0) / 100
    denom = cap_rate - growth
    gross_value = income / denom if denom > 0 else 0.0
    net_debt = float(inputs.get("net_debt", 0) or 0)
    value = gross_value - net_debt
    shares = float(inputs.get("shares_outstanding", 0) or 0)
    per_share = value / shares if shares else None
    return {
        "normalized_income": R2(income), "cap_rate_pct": R2(cap_rate * 100), "growth_pct": R2(growth * 100),
        "gross_value": R2(gross_value), "net_debt": R2(net_debt),
        "shares_outstanding": shares or None, "value_per_share": R2(per_share) if per_share is not None else None,
        "value": R2(value),
    }


def _val_market_comparable(inputs):
    comps = inputs.get("comparables") or []
    comp_lines, adjusted_values = [], []
    for c in comps:
        base = float(c.get("value", 0) or 0)
        adj_pct = sum(float(a.get("pct", 0) or 0) for a in (c.get("adjustments") or [])) / 100
        adjusted = base * (1 + adj_pct)
        comp_lines.append({"name": c.get("name", ""), "base_value": R2(base),
                           "adjustment_pct": R2(adj_pct * 100), "adjusted_value": R2(adjusted)})
        adjusted_values.append(adjusted)

    stat = inputs.get("selected_stat", "median")
    if stat == "custom":
        chosen = float(inputs.get("custom_value", 0) or 0)
    elif stat == "mean" and adjusted_values:
        chosen = sum(adjusted_values) / len(adjusted_values)
    elif adjusted_values:
        s = sorted(adjusted_values)
        mid = len(s) // 2
        chosen = s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2
    else:
        chosen = 0.0

    subject_metric = float(inputs.get("subject_metric", 1) or 1)
    gross_value = chosen * subject_metric
    deductions = [{"desc": d.get("desc", ""), "amount": R2(d.get("amount", 0))} for d in (inputs.get("deductions") or [])]
    ded_total = sum(d["amount"] for d in deductions)
    premiums = [{"desc": d.get("desc", ""), "amount": R2(d.get("amount", 0))} for d in (inputs.get("premiums") or [])]
    prem_total = sum(d["amount"] for d in premiums)
    value = gross_value - ded_total + prem_total
    shares = float(inputs.get("shares_outstanding", 0) or 0)
    per_share = value / shares if shares else None

    return {
        "comparables": comp_lines, "selected_stat": stat, "chosen_multiple_or_rate": R2(chosen),
        "subject_metric": subject_metric, "gross_value": R2(gross_value),
        "deductions": deductions, "deduction_total": R2(ded_total),
        "premiums": premiums, "premium_total": R2(prem_total),
        "shares_outstanding": shares or None, "value_per_share": R2(per_share) if per_share is not None else None,
        "value": R2(value),
    }


def _val_nav(inputs):
    def _lines(rows):
        out, total = [], 0.0
        for x in rows or []:
            book = float(x.get("book_value", 0) or 0)
            fv_raw = x.get("fair_value")
            fv = float(fv_raw) if fv_raw not in (None, "") else book
            out.append({"desc": x.get("desc", ""), "book_value": R2(book), "fair_value": R2(fv), "adjustment": R2(fv - book)})
            total += fv
        return out, total

    a_lines, a_total = _lines(inputs.get("assets"))
    l_lines, l_total = _lines(inputs.get("liabilities"))
    value = a_total - l_total
    shares = float(inputs.get("shares_outstanding", 0) or 0)
    per_share = value / shares if shares else None
    return {
        "assets": a_lines, "assets_total": R2(a_total), "liabilities": l_lines, "liabilities_total": R2(l_total),
        "shares_outstanding": shares or None, "value_per_share": R2(per_share) if per_share is not None else None,
        "value": R2(value),
    }


def _val_liquidation(inputs):
    nav_value = float(inputs.get("nav_value", 0) or 0)
    discount = float(inputs.get("forced_sale_discount_pct", 0) or 0) / 100
    after_discount = nav_value * (1 - discount)
    costs = float(inputs.get("winding_up_costs", 0) or 0)
    priority = float(inputs.get("priority_claims", 0) or 0)
    value = max(0.0, after_discount - costs - priority)
    shares = float(inputs.get("shares_outstanding", 0) or 0)
    per_share = value / shares if shares else None
    return {
        "nav_value": R2(nav_value), "forced_sale_discount_pct": R2(discount * 100),
        "value_after_discount": R2(after_discount), "winding_up_costs": R2(costs), "priority_claims": R2(priority),
        "shares_outstanding": shares or None, "value_per_share": R2(per_share) if per_share is not None else None,
        "value": R2(value),
    }


def _val_relief_from_royalty(inputs):
    """
    Value of a brand/trademark as the present value of the royalty a licensee
    would otherwise have had to pay to use it. Tax amortisation benefit (TAB),
    when elected, grosses the value up by the standard 1/(1 - t*PVA(n,r)/n)
    factor for the buyer's ability to amortise the intangible for tax over
    tab_years — confirm both the election and the amortisation period apply
    under the relevant tax regime before relying on it.
    """
    revs = inputs.get("revenue_projections") or []
    royalty_rate = float(inputs.get("royalty_rate_pct", 0) or 0) / 100
    tax_rate = float(inputs.get("tax_rate_pct", 0) or 0) / 100
    r = float(inputs.get("discount_rate_pct", 0) or 0) / 100

    lines, pv_sum = [], 0.0
    for i, rr in enumerate(revs, start=1):
        revenue = float(rr.get("revenue", 0) or 0)
        royalty_saving = revenue * royalty_rate
        after_tax = royalty_saving * (1 - tax_rate)
        disc = 1 / ((1 + r) ** i) if r else 1.0
        pv = after_tax * disc
        pv_sum += pv
        lines.append({"year": rr.get("year", i), "revenue": R2(revenue), "royalty_saving": R2(royalty_saving),
                      "after_tax_saving": R2(after_tax), "pv": R2(pv)})

    n = len(revs)
    g = float(inputs.get("terminal_growth_pct", 0) or 0) / 100
    last_saving = lines[-1]["after_tax_saving"] if lines else 0.0
    tv = last_saving * (1 + g) / (r - g) if r > g else 0.0
    pv_tv = tv / ((1 + r) ** n) if n else tv
    value = pv_sum + pv_tv

    tab_applied = False
    if inputs.get("tax_amortization_benefit"):
        tab_years = int(inputs.get("tab_years", 15) or 15)
        pv_annuity = (1 - (1 + r) ** -tab_years) / r if r else float(tab_years)
        denom = 1 - tax_rate * pv_annuity / tab_years
        if denom > 0:
            value *= 1 / denom
            tab_applied = True

    return {
        "lines": lines, "pv_of_royalty_savings": R2(pv_sum), "terminal_value": R2(tv),
        "pv_of_terminal_value": R2(pv_tv), "tax_amortization_benefit_applied": tab_applied,
        "value": R2(value),
    }


def _val_excess_earnings(inputs):
    projs = inputs.get("projections") or []
    charges = [{"desc": c.get("desc", ""), "annual_charge": R2(c.get("annual_charge", 0))}
               for c in (inputs.get("contributory_asset_charges") or [])]
    charge_total = sum(c["annual_charge"] for c in charges)
    tax_rate = float(inputs.get("tax_rate_pct", 0) or 0) / 100
    r = float(inputs.get("discount_rate_pct", 0) or 0) / 100

    lines, pv_sum = [], 0.0
    for i, p in enumerate(projs, start=1):
        earnings = float(p.get("earnings", 0) or 0)
        after_tax_earnings = earnings * (1 - tax_rate)
        excess = after_tax_earnings - charge_total
        disc = 1 / ((1 + r) ** i) if r else 1.0
        pv = excess * disc
        pv_sum += pv
        lines.append({"year": p.get("year", i), "earnings": R2(earnings), "after_tax_earnings": R2(after_tax_earnings),
                      "contributory_charges": R2(charge_total), "excess_earnings": R2(excess), "pv": R2(pv)})

    n = len(projs)
    g = float(inputs.get("terminal_growth_pct", 0) or 0) / 100
    last = lines[-1]["excess_earnings"] if lines else 0.0
    tv = last * (1 + g) / (r - g) if r > g else 0.0
    pv_tv = tv / ((1 + r) ** n) if n else tv
    value = pv_sum + pv_tv

    return {
        "lines": lines, "contributory_asset_charges": charges, "pv_of_excess_earnings": R2(pv_sum),
        "terminal_value": R2(tv), "pv_of_terminal_value": R2(pv_tv), "value": R2(value),
    }


def _val_rcnld(inputs):
    rcn = float(inputs.get("replacement_cost_new", 0) or 0)
    age = float(inputs.get("effective_age_years", 0) or 0)
    remaining_life = float(inputs.get("remaining_useful_life_years", 0) or 0)
    total_life = age + remaining_life
    override = inputs.get("physical_deterioration_pct_override")
    physical_pct = float(override) / 100 if override not in (None, "") else (age / total_life if total_life > 0 else 0.0)
    functional_pct = float(inputs.get("functional_obsolescence_pct", 0) or 0) / 100
    economic_pct = float(inputs.get("economic_obsolescence_pct", 0) or 0) / 100

    after_physical = rcn * (1 - physical_pct)
    after_functional = after_physical * (1 - functional_pct)
    value = max(0.0, after_functional * (1 - economic_pct))

    return {
        "replacement_cost_new": R2(rcn), "physical_deterioration_pct": R2(physical_pct * 100),
        "value_after_physical": R2(after_physical), "functional_obsolescence_pct": R2(functional_pct * 100),
        "value_after_functional": R2(after_functional), "economic_obsolescence_pct": R2(economic_pct * 100),
        "value": R2(value),
    }


def _val_fcfe(inputs):
    """
    Free cash flow to equity — levered cash flow (already net of interest and
    debt principal movements), discounted at the cost of equity, not WACC.
    Unlike _val_dcf (FCFF: unlevered, discounted at WACC to an enterprise
    value that is then bridged down by net debt), the PV of FCFE sums
    directly to an equity value — net debt is NOT subtracted again here,
    since the financing cash flows are already inside FCFE. Non-operating
    assets and minority interest can still apply, since neither is part of
    the operating FCFE stream.
    """
    cfs = inputs.get("cashflows") or []
    r = float(inputs.get("discount_rate_pct", 0) or 0) / 100
    lines, pv_sum = [], 0.0
    for i, c in enumerate(cfs, start=1):
        fcfe = float(c.get("fcf", 0) or 0)
        disc = 1 / ((1 + r) ** i) if r else 1.0
        pv = fcfe * disc
        pv_sum += pv
        lines.append({"year": c.get("year", i), "fcf": R2(fcfe), "discount_factor": round(disc, 4), "pv": R2(pv)})
    n = len(cfs)
    last_fcfe = float(cfs[-1].get("fcf", 0) or 0) if cfs else 0.0

    method = inputs.get("terminal_method", "gordon")
    tv = 0.0
    if method == "gordon":
        g = float(inputs.get("terminal_growth_pct", 0) or 0) / 100
        if r > g:
            tv = last_fcfe * (1 + g) / (r - g)
    else:
        mult = float(inputs.get("exit_multiple", 0) or 0)
        metric = float(inputs.get("exit_metric_value", 0) or 0)
        tv = mult * metric
    pv_tv = tv / ((1 + r) ** n) if n else tv

    non_op = float(inputs.get("non_operating_assets", 0) or 0)
    minority = float(inputs.get("minority_interest", 0) or 0)
    equity_value = pv_sum + pv_tv + non_op - minority
    shares = float(inputs.get("shares_outstanding", 0) or 0)
    per_share = equity_value / shares if shares else None

    return {
        "lines": lines, "pv_of_cashflows": R2(pv_sum), "terminal_method": method, "discount_rate_pct": R2(r * 100),
        "terminal_value": R2(tv), "pv_of_terminal_value": R2(pv_tv),
        "non_operating_assets": R2(non_op), "minority_interest": R2(minority),
        "shares_outstanding": shares or None, "value_per_share": R2(per_share) if per_share is not None else None,
        "value": R2(equity_value),
    }


def _val_sensitivity_grid(calc_fn, inputs, rate_key, rate_deltas, growth_key, growth_deltas):
    """
    A 2-D what-if grid for an income-approach working — the classic banker's
    discount-rate x terminal-growth (or cap-rate x growth) sensitivity table.
    Re-invokes the SAME method function with nudged inputs rather than
    re-deriving the NPV math, so it can never drift from the base calculation.
    """
    base_rate = float(inputs.get(rate_key, 0) or 0)
    base_growth = float(inputs.get(growth_key, 0) or 0)
    grid = []
    for rd in rate_deltas:
        row_rate = R2(base_rate + rd)
        cells = []
        for gd in growth_deltas:
            row_growth = R2(base_growth + gd)
            flexed = dict(inputs); flexed[rate_key] = base_rate + rd; flexed[growth_key] = base_growth + gd
            out = calc_fn(flexed)
            cells.append({growth_key: row_growth, "value": out.get("value")})
        grid.append({rate_key: row_rate, "cells": cells})
    return grid


def _val_discount_rate_builder(inputs):
    """
    CAPM/build-up cost of equity and a resulting WACC — a rate the preparer
    reads off and types into a DCF/FCFE/income-capitalisation working's own
    discount_rate_pct field. Deliberately not itself a 'working' (it produces
    a rate, not a value) so it doesn't distort the workings/conclusion blend.
    """
    rf = float(inputs.get("risk_free_rate_pct", 0) or 0)
    beta = float(inputs.get("beta", 1) or 1)
    erp = float(inputs.get("equity_risk_premium_pct", 0) or 0)
    size_prem = float(inputs.get("size_premium_pct", 0) or 0)
    specific_prem = float(inputs.get("specific_risk_premium_pct", 0) or 0)
    cost_of_equity = rf + beta * erp + size_prem + specific_prem

    cost_of_debt = float(inputs.get("cost_of_debt_pct", 0) or 0)
    tax_rate = float(inputs.get("tax_rate_pct", 0) or 0) / 100
    after_tax_cost_of_debt = cost_of_debt * (1 - tax_rate)

    mv_equity = float(inputs.get("market_value_of_equity", 0) or 0)
    mv_debt = float(inputs.get("market_value_of_debt", 0) or 0)
    total = mv_equity + mv_debt
    w_equity = mv_equity / total if total else 1.0
    w_debt = mv_debt / total if total else 0.0
    wacc = w_equity * cost_of_equity + w_debt * after_tax_cost_of_debt

    return {
        "risk_free_rate_pct": R2(rf), "beta": R2(beta), "equity_risk_premium_pct": R2(erp),
        "size_premium_pct": R2(size_prem), "specific_risk_premium_pct": R2(specific_prem),
        "cost_of_equity_pct": R2(cost_of_equity),
        "cost_of_debt_pct": R2(cost_of_debt), "tax_rate_pct": R2(tax_rate * 100),
        "after_tax_cost_of_debt_pct": R2(after_tax_cost_of_debt),
        "market_value_of_equity": R2(mv_equity), "market_value_of_debt": R2(mv_debt),
        "weight_of_equity_pct": R2(w_equity * 100), "weight_of_debt_pct": R2(w_debt * 100),
        "wacc_pct": R2(wacc),
    }


def _val_review_flags(workings_out, weight_total, range_low, range_high):
    """Rule-based prompts for the Valuation Review / Challenge checklist — never a pass/fail verdict, just what a reviewer would ask about."""
    flags = []
    if len(workings_out) == 1:
        flags.append({"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 workings_out and not weight_total:
        flags.append({"severity": "High", "message": "No working carries a weight, so no value has been concluded — assign at least one weight in the Conclusion."})
    if range_low is not None and range_low > 0 and range_high is not None and range_high > range_low * 2:
        flags.append({"severity": "Medium", "message": f"Values across workings range from {range_low:,.0f} to {range_high:,.0f} — more than double — the conclusion should explain the spread."})
    for w in workings_out:
        if w["value"] is not None and not w.get("fair_value_level"):
            flags.append({"severity": "Low", "message": f"'{w['label']}' has no NFRS 13 fair-value hierarchy level recorded."})
        if w["method"] in ("dcf", "fcfe"):
            r_pct = w.get("discount_rate_pct")
            if r_pct is not None and (r_pct < 5 or r_pct > 30):
                flags.append({"severity": "Medium", "message": f"'{w['label']}': discount rate of {r_pct:.1f}% is outside the typical 5%-30% range used in practice — confirm the basis."})
        pv_tv = w.get("pv_of_terminal_value")
        if pv_tv is not None and w.get("value"):
            denom = w["value"] + (w.get("net_debt") or 0) - (w.get("non_operating_assets") or 0) + (w.get("minority_interest") or 0)
            if denom and abs(pv_tv) / abs(denom) > 0.80:
                flags.append({"severity": "Medium", "message": f"'{w['label']}': the terminal value makes up more than 80% of the total value — the conclusion depends heavily on the terminal assumption."})
    return flags


VALUATION_METHODS = {
    "dcf": _val_dcf, "fcfe": _val_fcfe, "income_capitalization": _val_income_capitalization,
    "market_comparable": _val_market_comparable, "precedent_transaction": _val_market_comparable,
    "nav": _val_nav, "liquidation": _val_liquidation,
    "relief_from_royalty": _val_relief_from_royalty, "excess_earnings": _val_excess_earnings,
    "rcnld": _val_rcnld,
}


def valuation(fy, data):
    """
    data: {
      subject: {name, subject_type ('entity'|'share'|'brand'|'land'|'machinery'),
               valuation_date, purpose, currency},
      workings: [{id, method, label, fair_value_level (1|2|3), inputs: {...}}],
      conclusion: {weights: {working_id: weight}, notes},
    }
    Each working is computed by its own method (see VALUATION_METHODS); the
    conclusion blends whichever workings carry a weight into one concluded
    value — weights need not sum to 100, they're normalised. Not statutory,
    so `unverified` is always empty; the fair-value-hierarchy level (NFRS 13)
    is recorded per working as entered, not derived.
    """
    subject = data.get("subject") or {}
    workings_out = []
    for w in data.get("workings") or []:
        method = w.get("method")
        calc = VALUATION_METHODS.get(method)
        if not calc:
            continue
        inputs = w.get("inputs") or {}
        result = calc(inputs)
        result.update({
            "id": w.get("id"), "method": method,
            "label": w.get("label") or VALUATION_METHOD_META.get(method, {}).get("label", method),
            "approach": VALUATION_METHOD_META.get(method, {}).get("approach"),
            "fair_value_level": w.get("fair_value_level"),
        })
        if method in ("dcf", "fcfe"):
            result["sensitivity_grid"] = _val_sensitivity_grid(
                calc, inputs, "discount_rate_pct", (-2, -1, 0, 1, 2), "terminal_growth_pct", (-1, 0, 1))
        elif method == "income_capitalization":
            result["sensitivity_grid"] = _val_sensitivity_grid(
                calc, inputs, "cap_rate_pct", (-2, -1, 0, 1, 2), "growth_pct", (-1, 0, 1))
        workings_out.append(result)

    weights = (data.get("conclusion") or {}).get("weights") or {}
    weighted_sum, weight_total, blend = 0.0, 0.0, []
    for w in workings_out:
        wt = float(weights.get(w["id"], 0) or 0)
        if wt:
            weighted_sum += w["value"] * wt
            weight_total += wt
            blend.append({"id": w["id"], "label": w["label"], "value": w["value"], "weight": wt})
    concluded_value = (weighted_sum / weight_total) if weight_total else None
    values = [w["value"] for w in workings_out]
    range_low = R2(min(values)) if values else None
    range_high = R2(max(values)) if values else None

    discount_rate_builder = data.get("discount_rate_builder")
    return {
        "subject": subject, "workings": workings_out,
        "discount_rate_builder": _val_discount_rate_builder(discount_rate_builder) if discount_rate_builder else None,
        "conclusion": {
            "blend": blend, "concluded_value": R2(concluded_value) if concluded_value is not None else None,
            "range_low": range_low, "range_high": range_high,
            "notes": (data.get("conclusion") or {}).get("notes", ""),
        },
        "review": {
            "flags": _val_review_flags(workings_out, weight_total, range_low, range_high),
            "reviewed_by": (data.get("review") or {}).get("reviewed_by", ""),
            "review_date": (data.get("review") or {}).get("review_date", ""),
            "key_assumptions_challenged": (data.get("review") or {}).get("key_assumptions_challenged", ""),
            "independence_confirmed": bool((data.get("review") or {}).get("independence_confirmed")),
            "review_conclusion": (data.get("review") or {}).get("review_conclusion", ""),
            "review_status": (data.get("review") or {}).get("review_status", "pending"),
        },
        "unverified": [],
    }


# --------------------------------------------- 13. FIXED ASSETS (ACCOUNTS, NAS 16)
#
# Book depreciation, distinct from the Schedule 2 tax pools in section 3 above.
# Asset "types" (classes) are entirely user-defined, each with its own method
# (SLM or WDV — overridable per individual asset), day-exact AD-date
# pro-ration for additions and disposals during the year (not the tax
# module's Nepali-month tranches), an asset-by-asset register for firms that
# want the full audit trail, and a class-level "unallocated block" for a
# legacy balance nobody has broken down into individual assets yet. Both
# paths read their opening carrying amount from last year's closing figure —
# "opening_nbv" on an asset, or "opening_block_nbv" on a class — exactly the
# way the tax module already takes opening_base per pool, just generalised
# to a real calendar and a choice of method. Not statutory: no rate_master
# dependency, every rate/life/method here is the firm's own accounting
# policy, not a Finance Act number.
#
# A note on scope: an asset marked status='addition' must have been acquired
# within the fiscal year being computed — if it was bought in an earlier
# year, it belongs under status='existing' with its own opening_nbv, not
# under 'addition' with a back-dated acquisition_date, or it will be charged
# a full year's depreciation on gross cost instead of the correct
# already-run-down carrying amount.

def _fa_fy_window(fy):
    row = db.query("SELECT start_ad, end_ad FROM fiscal_year WHERE label_bs = ?", (fy,), one=True)
    if row and row.get("start_ad") and row.get("end_ad"):
        try:
            return date.fromisoformat(row["start_ad"]), date.fromisoformat(row["end_ad"])
        except ValueError:
            pass
    year = _fy_start_year(fy) or date.today().year
    return date(year, 7, 17), date(year + 1, 7, 16)


def _fa_parse_date(s):
    if not s:
        return None
    try:
        return date.fromisoformat(str(s)[:10])
    except ValueError:
        return None


def _fa_ov(asset, cls, key, default=0):
    """Asset-level override if one was actually given (including a deliberate 0),
    else the class default, else the hard-coded fallback."""
    v = asset.get(key)
    if v is None or v == "":
        v = cls.get(key)
    if v is None or v == "":
        v = default
    return v


def _fa_prorate(fy_start, fy_end, acq_date, disposal_date):
    start = acq_date if (acq_date and acq_date > fy_start) else fy_start
    end = disposal_date if (disposal_date and disposal_date < fy_end) else fy_end
    total_days = (fy_end - fy_start).days + 1
    if end < start:
        return 0.0, None, None, total_days
    used_days = (end - start).days + 1
    return used_days / total_days, start, end, total_days


def _fa_asset_charge(asset, cls, fy_start, fy_end):
    method = str(_fa_ov(asset, cls, "method", "wdv") or "wdv").lower()
    status = asset.get("status") or ("addition" if asset.get("acquisition_date") else "existing")
    acq = _fa_parse_date(asset.get("acquisition_date"))
    disp = _fa_parse_date(asset.get("disposal_date"))
    biz = float(asset.get("business_use_pct", 100) or 100) / 100.0

    factor, used_start, used_end, total_days = _fa_prorate(
        fy_start, fy_end, acq if status == "addition" else None, disp)

    cost = float(asset.get("cost", 0) or 0) + float(asset.get("additional_cost", 0) or 0)
    has_known_cost = status == "addition" or bool(asset.get("cost"))

    if status == "addition":
        opening_nbv = 0.0
        base = cost
    else:
        opening_nbv = float(asset.get("opening_nbv", 0) or 0)
        base = opening_nbv

    charge, residual_floor = 0.0, 0.0
    if factor > 0:
        if method == "wdv":
            rate = float(_fa_ov(asset, cls, "rate_pct", 0) or 0) / 100.0
            charge = base * rate * factor
        else:  # slm
            if status == "addition":
                life = float(_fa_ov(asset, cls, "useful_life_years", 0) or 0)
                residual_pct = float(_fa_ov(asset, cls, "residual_pct", 0) or 0) / 100.0
                residual_floor = cost * residual_pct
                annual = (cost - residual_floor) / life if life else 0.0
            else:
                life = float(asset.get("opening_nbv_remaining_life_years", 0) or 0)
                residual_floor = float(asset.get("opening_nbv_residual_value", 0) or 0)
                annual = (opening_nbv - residual_floor) / life if life else 0.0
            charge = annual * factor
        charge = charge * biz
        charge = max(0.0, min(charge, base - residual_floor))

    nbv_before = base
    nbv_after = nbv_before - charge

    disposal_gain_loss, disposal_proceeds = None, None
    if disp and fy_start <= disp <= fy_end:
        disposal_proceeds = R2(asset.get("disposal_proceeds", 0) or 0)
        disposal_gain_loss = R2(disposal_proceeds - nbv_after)

    return {
        "code": asset.get("code", ""), "name": asset.get("name", ""), "status": status, "method": method,
        "acquisition_date": asset.get("acquisition_date"), "disposal_date": asset.get("disposal_date"),
        "cost": R2(cost) if has_known_cost else None,
        "opening_nbv": R2(nbv_before), "depreciation": R2(charge), "closing_nbv": R2(max(0.0, nbv_after)),
        "days_in_use": ((used_end - used_start).days + 1) if (used_start and used_end) else 0,
        "proration_factor": round(factor, 4),
        "disposal_proceeds": disposal_proceeds, "gain_loss_on_disposal": disposal_gain_loss,
        "has_known_cost": has_known_cost,
    }


def _fa_class(cls, fy_start, fy_end):
    key = cls.get("key") or "class"
    label = cls.get("label") or key
    assets_out = []

    block_nbv = float(cls.get("opening_block_nbv", 0) or 0)
    if block_nbv:
        block_asset = {
            "code": "UNALLOC", "name": "Unallocated opening balance (not broken into individual assets)",
            "status": "existing", "opening_nbv": block_nbv,
            "opening_nbv_remaining_life_years": cls.get("opening_block_remaining_life_years", 0),
            "opening_nbv_residual_value": cls.get("opening_block_residual_value", 0),
            "disposal_date": cls.get("opening_block_disposal_date"),
            "disposal_proceeds": cls.get("opening_block_disposal_proceeds"),
        }
        assets_out.append(_fa_asset_charge(block_asset, cls, fy_start, fy_end))

    for a in cls.get("assets", []) or []:
        assets_out.append(_fa_asset_charge(a, cls, fy_start, fy_end))

    totals = {"opening_nbv": 0.0, "additions_cost": 0.0, "depreciation": 0.0, "closing_nbv": 0.0,
              "disposal_proceeds": 0.0, "gain_loss": 0.0}
    for r in assets_out:
        if r["status"] == "existing":
            totals["opening_nbv"] += r["opening_nbv"]
        elif r["cost"]:
            totals["additions_cost"] += r["cost"]
        totals["depreciation"] += r["depreciation"]
        totals["closing_nbv"] += r["closing_nbv"]
        if r["gain_loss_on_disposal"] is not None:
            totals["disposal_proceeds"] += r["disposal_proceeds"] or 0.0
            totals["gain_loss"] += r["gain_loss_on_disposal"]

    return {"key": key, "label": label, "method": cls.get("method", "wdv"),
            "rate_pct": cls.get("rate_pct"), "useful_life_years": cls.get("useful_life_years"),
            "residual_pct": cls.get("residual_pct"),
            "assets": assets_out, "totals": {k: R2(v) for k, v in totals.items()}}


def _fa_journals(classes_out, fy_label):
    journals = []
    for c in classes_out:
        label = c["label"]
        for a in c["assets"]:
            if a["status"] == "addition" and a["cost"]:
                journals.append({
                    "period": a.get("acquisition_date") or fy_label,
                    "narration": f"Addition — {label}: {a['name'] or a['code']}",
                    "debit": [{"account": f"{label} (at cost)", "amount": a["cost"]}],
                    "credit": [{"account": "Bank / accounts payable", "amount": a["cost"]}],
                })
        if c["totals"]["depreciation"]:
            journals.append({
                "period": fy_label, "narration": f"Depreciation charge for the year — {label}",
                "debit": [{"account": f"Depreciation expense — {label}", "amount": c["totals"]["depreciation"]}],
                "credit": [{"account": f"Accumulated depreciation — {label}", "amount": c["totals"]["depreciation"]}],
            })
        for a in c["assets"]:
            if a["gain_loss_on_disposal"] is None:
                continue
            proceeds = a["disposal_proceeds"] or 0.0
            nbv_at_disposal = a["closing_nbv"]
            gl = a["gain_loss_on_disposal"]
            if a["has_known_cost"] and a["cost"]:
                accum_dep = R2(a["cost"] - nbv_at_disposal)
                debit = [{"account": "Bank / receivable — disposal proceeds", "amount": proceeds},
                        {"account": f"Accumulated depreciation — {label}", "amount": accum_dep}]
                credit = [{"account": f"{label} (at cost)", "amount": a["cost"]}]
            else:
                debit = [{"account": "Bank / receivable — disposal proceeds", "amount": proceeds}]
                credit = [{"account": f"{label} (net book value)", "amount": nbv_at_disposal}]
            if gl > 0:
                credit.append({"account": "Gain on disposal of asset", "amount": gl})
            elif gl < 0:
                debit.append({"account": "Loss on disposal of asset", "amount": -gl})
            journals.append({
                "period": a.get("disposal_date") or fy_label,
                "narration": f"Disposal — {label}: {a['name'] or a['code']}",
                "debit": debit, "credit": credit,
            })
    return journals


def fixed_assets(fy, data):
    """data: {entity:{name,period_label}, classes:[{key,label,method,rate_pct,
    useful_life_years,residual_pct, opening_block_nbv, opening_block_remaining_life_years,
    opening_block_residual_value, assets:[{code,name,status('existing'|'addition'),
    acquisition_date,cost,additional_cost,opening_nbv,opening_nbv_remaining_life_years,
    opening_nbv_residual_value,method,rate_pct,useful_life_years,residual_pct,
    business_use_pct,disposal_date,disposal_proceeds}]}]}"""
    fy_start, fy_end = _fa_fy_window(fy)
    classes_out = [_fa_class(c, fy_start, fy_end) for c in data.get("classes", []) or []]
    journals = _fa_journals(classes_out, fy)

    summary = {
        "opening_nbv": R2(sum(c["totals"]["opening_nbv"] for c in classes_out)),
        "additions_cost": R2(sum(c["totals"]["additions_cost"] for c in classes_out)),
        "depreciation": R2(sum(c["totals"]["depreciation"] for c in classes_out)),
        "closing_nbv": R2(sum(c["totals"]["closing_nbv"] for c in classes_out)),
        "disposal_proceeds": R2(sum(c["totals"]["disposal_proceeds"] for c in classes_out)),
        "gain_loss_on_disposal": R2(sum(c["totals"]["gain_loss"] for c in classes_out)),
    }

    return {
        "entity": data.get("entity") or {}, "fy_start": fy_start.isoformat(), "fy_end": fy_end.isoformat(),
        "classes": classes_out, "journals": journals, "summary": summary, "unverified": [],
    }


# --------------------------------------------------------- 14. INTERNAL AUDIT
#
# Structure, not calculation — this is a checklist-and-findings workfile, not
# a computation module, so there's no rate_master dependency and no numbers
# to get wrong. What it DOES need to get right is the shape: one criterion
# bank (server-side, extensible — see db.audit_criteria/audit_entity_types),
# filtered by the workfile's own entity type, walked to a status per
# criterion, rolled up into a findings register and a completion/risk
# summary. This same shape (criterion bank + findings register + risk
# roll-up) is the intended foundation for the statutory-audit
# programs-by-area phase later — that phase adds materiality, an IR/CR/DR
# risk model, and an opinion report on top, it does not replace this.

def internal_audit(fy, data):
    entity = data.get("entity") or {}
    entity_type = entity.get("entity_type") or ""
    lookup_type = None if entity_type in ("", "Other") else entity_type
    criteria = db.audit_criteria(lookup_type)
    responses = data.get("criteria_responses") or {}

    by_area = {}
    total = tested = exceptions = not_applicable = 0
    for c in criteria:
        total += 1
        resp = responses.get(str(c["id"])) or {}
        status = resp.get("status") or "not_tested"
        if status != "not_tested":
            tested += 1
        if status == "exception":
            exceptions += 1
        if status == "not_applicable":
            not_applicable += 1
        row = dict(c)
        row["status"] = status
        row["evidence"] = resp.get("evidence") or ""
        row["remark"] = resp.get("remark") or ""
        # Audit-program fields — unused by Internal Audit's own checklist view,
        # but passed through so statutory_audit() (which reuses this function
        # as its base) can render the same criterion as a full program line
        # without duplicating the filtering/status logic above.
        for k in ("procedure", "sample_basis", "wp_ref", "performed_by", "result"):
            row[k] = resp.get(k) or ""
        by_area.setdefault(c["area"], []).append(row)

    findings = data.get("findings") or []
    findings_by_risk = {"high": 0, "medium": 0, "low": 0}
    open_findings = 0
    for f in findings:
        r = (f.get("risk_rating") or "low").lower()
        if r in findings_by_risk:
            findings_by_risk[r] += 1
        if (f.get("status") or "open") == "open":
            open_findings += 1

    testable = total - not_applicable
    completion_pct = R2(100 * tested / testable) if testable else 0.0

    # Risk-Based Internal Audit Generator — likelihood x impact on a plain
    # 1-5 scale (25-point matrix), sorted so the highest-risk processes lead
    # the audit plan. A planning aid, not a statutory calculation.
    risk_universe = []
    for row in data.get("risk_universe") or []:
        likelihood = int(row.get("likelihood") or 0)
        impact = int(row.get("impact") or 0)
        score = likelihood * impact
        priority = "High" if score >= 15 else ("Medium" if score >= 8 else "Low")
        risk_universe.append({"process": row.get("process", ""), "likelihood": likelihood, "impact": impact,
                              "score": score, "priority": priority, "notes": row.get("notes", "")})
    risk_universe.sort(key=lambda r: r["score"], reverse=True)

    # Process Risk & Control Matrix / RACM Generator — one row per
    # risk-control pair; Control Gap Analyzer is simply the subset where
    # design or operating effectiveness isn't "Effective".
    GAP_STATES = ("Partially Effective", "Ineffective")
    racm = []
    for row in data.get("racm") or []:
        design = row.get("design_effectiveness") or "Not Assessed"
        operating = row.get("operating_effectiveness") or "Not Assessed"
        has_gap = design in GAP_STATES or operating in GAP_STATES
        racm.append(dict(row, design_effectiveness=design, operating_effectiveness=operating, has_gap=has_gap))
    control_gaps = [r for r in racm if r["has_gap"]]

    return {
        "entity": entity,
        "planning": data.get("planning") or {},
        "areas": [{"area": area, "criteria": rows} for area, rows in by_area.items()],
        "risk_universe": risk_universe,
        "racm": racm,
        "control_gaps": control_gaps,
        "fraud_risk": data.get("fraud_risk") or {},
        "walkthroughs": data.get("walkthroughs") or [],
        "follow_ups": data.get("follow_ups") or [],
        "findings": findings,
        "summary": {
            "total_criteria": total, "tested_criteria": tested, "not_applicable_criteria": not_applicable,
            "completion_pct": completion_pct, "exceptions_count": exceptions,
            "findings_total": len(findings), "findings_open": open_findings,
            "findings_by_risk": findings_by_risk, "control_gaps_count": len(control_gaps),
        },
        "conclusion": data.get("conclusion") or {},
        "signatory": data.get("signatory") or {},
        "unverified": [],
    }


# --------------------------------------------- 14b. STATUTORY AUDIT (Phase 2)
#
# Everything here is professional-judgment methodology, not a Finance-Act
# figure — materiality benchmarks/percentages, sample-size risk factors, and
# the risk matrix are all the auditor's own judgment calls, so (like
# Valuation and the loan/lease mechanics) there is deliberately no
# rate_master dependency. This module wraps internal_audit() as its base
# rather than re-deriving the checklist/findings logic — same criterion
# bank, same findings shape, same signatory block; this only adds the
# materiality, sample-size and risk-assessment layers on top, exactly as
# planned in docs/audit-module-roadmap.md.

_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",
}
_SA_DETECTION_FOR_COMBINED = {"high": "low", "medium": "medium", "low": "high"}
_SA_RISK_FACTOR = {"high": 3, "medium": 2, "low": 1}


def _sa_materiality(m):
    benchmark_value = float(m.get("benchmark_value") or 0)
    materiality_pct = float(m.get("materiality_pct") or 0) / 100
    overall = R2(benchmark_value * materiality_pct)
    pm_pct = float(m.get("pm_pct") if m.get("pm_pct") is not None else 75) / 100
    performance_materiality = R2(overall * pm_pct)
    ct_pct = float(m.get("ct_pct") if m.get("ct_pct") is not None else 5) / 100
    clearly_trivial = R2(performance_materiality * ct_pct)
    return {
        "benchmark": m.get("benchmark") or "", "benchmark_value": benchmark_value,
        "materiality_pct": R2(materiality_pct * 100), "overall_materiality": overall,
        "pm_pct": R2(pm_pct * 100), "performance_materiality": performance_materiality,
        "ct_pct": R2(ct_pct * 100), "clearly_trivial": clearly_trivial,
    }


def _sa_sample_size(s, performance_materiality):
    population_value = float(s.get("population_value") or 0)
    population_count = float(s.get("population_count") or 0)
    tolerable = float(s["tolerable_misstatement"]) if s.get("tolerable_misstatement") not in (None, "") \
        else performance_materiality
    expected = float(s.get("expected_misstatement") or 0)
    risk_level = (s.get("risk_level") or "medium").lower()
    risk_factor = _SA_RISK_FACTOR.get(risk_level, 2)
    net_tolerable = max(tolerable - expected, tolerable * 0.1) if tolerable else 0
    sampling_interval = R2(net_tolerable / risk_factor) if net_tolerable and risk_factor else 0.0
    sample_size_value = int(population_value / sampling_interval) + 1 if sampling_interval and population_value else 0
    sample_size_count = min(sample_size_value, int(population_count)) if population_count else sample_size_value
    return {
        "population_value": population_value, "population_count": population_count,
        "tolerable_misstatement": R2(tolerable), "expected_misstatement": R2(expected),
        "risk_level": risk_level, "risk_factor": risk_factor,
        "sampling_interval": sampling_interval, "sample_size": sample_size_count,
        "note": "Judgmental monetary-unit-style estimate for planning purposes, not a substitute for a "
                "statistical sampling standard or dedicated sampling software.",
    }


def _sa_risk_assessment(ra_data, areas):
    out = []
    for a in areas:
        area = a["area"]
        entry = ra_data.get(area) or {}
        ir = (entry.get("inherent") or "medium").lower()
        cr = (entry.get("control") or "medium").lower()
        combined = _SA_RISK_MATRIX.get((ir, cr), "medium")
        out.append({
            "area": area, "inherent": ir, "control": cr, "combined_risk": combined,
            "detection_risk_required": _SA_DETECTION_FOR_COMBINED[combined],
            "rationale": entry.get("rationale") or "",
        })
    return out


def statutory_audit(fy, data):
    base = internal_audit(fy, data)
    materiality = _sa_materiality(data.get("materiality") or {})
    base["materiality"] = materiality
    base["sample_size"] = _sa_sample_size(data.get("sample_size") or {}, materiality["performance_materiality"])
    base["risk_assessment"] = _sa_risk_assessment(data.get("risk_assessment") or {}, base["areas"])
    return base


# ------------------------------------------------- 15. AUDITOR'S OPINION REPORT
#
# A standalone report generator, deliberately not gated behind a full
# statutory-audit engagement workflow — a firm needs to produce a signed
# opinion letter whether or not this application's own audit-programs
# workspace was used to get there. "All permutation combination" per the
# brief: any of the four NSA 700/705 opinion types, each with optional
# Emphasis of Matter and Other Matter paragraphs, assembled in the correct
# NSA-mandated order. Paragraph WORDING for anything engagement-specific
# (the actual EOM/basis-for-qualification text) is never fabricated here —
# it's the user's own input, this only assembles and orders the structure.

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


def audit_opinion_report(data):
    opinion_type = data.get("opinion_type") or "unqualified"
    if opinion_type not in OPINION_TYPES:
        opinion_type = "unqualified"
    return {
        "entity": data.get("entity") or {},
        "opinion_type": opinion_type,
        "opinion_type_label": OPINION_TYPES[opinion_type],
        "basis_paragraph": data.get("basis_paragraph") or "",
        "opinion_paragraph": data.get("opinion_paragraph") or "",
        "has_eom": bool(data.get("eom_paragraph")),
        "eom_paragraph": data.get("eom_paragraph") or "",
        "has_other_matter": bool(data.get("other_matter_paragraph")),
        "other_matter_paragraph": data.get("other_matter_paragraph") or "",
        "has_kam": bool(data.get("kam_paragraphs")),
        "kam_paragraphs": data.get("kam_paragraphs") or [],
        "responsibilities_management": data.get("responsibilities_management") or "",
        "responsibilities_auditor": data.get("responsibilities_auditor") or "",
        "signatory": data.get("signatory") or {},
        "unverified": [],
    }


# --------------------------------------------------- 16. TDS / WITHHOLDING
#
# The deductOR side — a client acting as a withholding agent on payments it
# makes (rent, contract, professional fees, commission, interest, dividend,
# non-resident, meeting fee, freight, vehicle rent, royalty, business
# promotion). Salary TDS already has its own module; house/land rent reuses
# payroll.rent_tds_rate rather than a second copy of that number. Categories
# are discovered dynamically from rate_master (domain=tds, prefix
# tds_rate_), the same mechanism as company-rate categories elsewhere — a
# new withholding category is a Rate Master row, not a rebuild.

def tds_deduction(fy, data):
    tds_rates, tds_unverified = db.rate_dict("tds", fy)
    payroll_rates, payroll_unverified = db.rate_dict("payroll", fy)
    categories = _dynamic_categories(tds_rates, "tds_rate_")
    rent_row = payroll_rates.get("rent_tds_rate")
    if rent_row:
        categories = [{"code": "rent_tds_rate", "slug": "rent", "label": rent_row["label"], "row": rent_row}] + categories
    cat_by_slug = {c["slug"]: c for c in categories}

    lines = []
    by_category = {}
    total_gross = total_tds = total_net = 0.0
    for e in data.get("entries", []):
        slug = e.get("category") or ""
        cat = cat_by_slug.get(slug)
        if e.get("rate_override") not in (None, ""):
            rate = float(e["rate_override"]) / 100
        elif cat and cat["row"]["value_num"] is not None:
            rate = float(cat["row"]["value_num"])
        else:
            rate = 0.0
        gross = float(e.get("gross_amount") or 0)
        tds = R2(gross * rate)
        net = R2(gross - tds)
        label = cat["label"] if cat else (slug or "Uncategorised")
        lines.append({
            "payee_name": e.get("payee_name", ""), "payee_pan": e.get("payee_pan", ""),
            "payment_date": e.get("payment_date", ""), "category": slug, "category_label": label,
            "description": e.get("description", ""), "gross_amount": R2(gross), "rate": R2(rate * 100),
            "tds_amount": tds, "net_amount": net, "deposited": bool(e.get("deposited")),
            "deposit_date": e.get("deposit_date", ""), "deposit_ref": e.get("deposit_ref", ""),
        })
        total_gross += gross
        total_tds += tds
        total_net += net
        agg = by_category.setdefault(slug, {"label": label, "gross": 0.0, "tds": 0.0})
        agg["gross"] += gross
        agg["tds"] += tds

    deposited_tds = R2(sum(l["tds_amount"] for l in lines if l["deposited"]))
    by_payee = {}
    for l in lines:
        key = (l["payee_name"], l["payee_pan"])
        by_payee.setdefault(key, []).append(l)

    return {
        "entries": lines,
        "categories": [{"slug": c["slug"], "label": c["label"],
                        "rate": R2(float(c["row"]["value_num"] or 0) * 100)} for c in categories],
        "by_payee": [{"payee_name": k[0], "payee_pan": k[1], "lines": v,
                     "gross": R2(sum(x["gross_amount"] for x in v)), "tds": R2(sum(x["tds_amount"] for x in v))}
                    for k, v in by_payee.items()],
        "summary": {
            "total_gross": R2(total_gross), "total_tds": R2(total_tds), "total_net": R2(total_net),
            "deposited_tds": deposited_tds, "pending_tds": R2(total_tds - deposited_tds),
            "by_category": [{"category": k, "label": v["label"], "gross": R2(v["gross"]), "tds": R2(v["tds"])}
                            for k, v in by_category.items()],
        },
        "signatory": data.get("signatory") or {},
        "unverified": sorted(set(tds_unverified + payroll_unverified)),
    }


# Blocked-input categories under VAT Act 2052 Rule 41(1) — statutory text, not
# a fiscal-year figure, so this stays a code constant (like fin_statements'
# HEADS list) rather than a rate_master row.
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",
]


def vat_return(fy, data):
    vat_rates, unverified = db.rate_dict("vat", fy)
    rate = float((vat_rates.get("standard_rate") or {}).get("value_num") or 0)

    sales = []
    taxable_sales = zero_rated_sales = exempt_sales = 0.0
    for s in data.get("sales", []):
        t = float(s.get("taxable_amount") or 0)
        z = float(s.get("zero_rated_amount") or 0)
        e = float(s.get("exempt_amount") or 0)
        sales.append({
            "date": s.get("date", ""), "party": s.get("party", ""), "desc": s.get("desc", ""),
            "taxable_amount": R2(t), "zero_rated_amount": R2(z), "exempt_amount": R2(e),
            "vat_amount": R2(t * rate),
        })
        taxable_sales += t
        zero_rated_sales += z
        exempt_sales += e

    purchases = []
    creditable_purchases = blocked_purchases = capital_purchases = 0.0
    for p in data.get("purchases", []):
        t = float(p.get("taxable_amount") or 0)
        blocked = bool(p.get("blocked"))
        capital = bool(p.get("capital_goods"))
        purchases.append({
            "date": p.get("date", ""), "party": p.get("party", ""), "desc": p.get("desc", ""),
            "taxable_amount": R2(t), "blocked": blocked, "capital_goods": capital, "vat_amount": R2(t * rate),
        })
        if blocked:
            blocked_purchases += t
        else:
            creditable_purchases += t
            if capital:
                capital_purchases += t

    credit_notes_issued = float(data.get("credit_notes_issued") or 0)
    debit_notes_issued = float(data.get("debit_notes_issued") or 0)
    credit_notes_received = float(data.get("credit_notes_received") or 0)
    debit_notes_received = float(data.get("debit_notes_received") or 0)
    opening_credit = float(data.get("opening_credit") or 0)

    net_taxable_sales = taxable_sales - credit_notes_issued + debit_notes_issued
    output_vat = R2(net_taxable_sales * rate)

    net_creditable_purchases = creditable_purchases - credit_notes_received + debit_notes_received
    input_vat_gross = R2(net_creditable_purchases * rate)

    # Mixed taxable/exempt supply: when input VAT can't be traced separately to
    # taxable vs exempt output, the credit is restricted pro-rata to the share
    # of turnover that's taxable. Zero-rated (export) sales count on the
    # taxable side of the ratio — they're full-rated supplies at a 0% rate,
    # not exempt, and remain fully creditable.
    mixed_supply = bool(data.get("mixed_supply"))
    total_supply = taxable_sales + zero_rated_sales + exempt_sales
    ratio = ((taxable_sales + zero_rated_sales) / total_supply) if (mixed_supply and total_supply > 0) else 1.0
    eligible_input_vat = R2(input_vat_gross * ratio)
    restricted_input_vat = R2(input_vat_gross - eligible_input_vat)

    total_credit_available = R2(eligible_input_vat + opening_credit)
    if output_vat >= total_credit_available:
        payable = R2(output_vat - total_credit_available)
        credit_carried_forward = 0.0
    else:
        payable = 0.0
        credit_carried_forward = R2(total_credit_available - output_vat)

    return {
        "entity": data.get("entity") or {},
        "rate": R2(rate * 100),
        "sales": sales,
        "purchases": purchases,
        "totals": {
            "taxable_sales": R2(taxable_sales), "zero_rated_sales": R2(zero_rated_sales),
            "exempt_sales": R2(exempt_sales), "credit_notes_issued": R2(credit_notes_issued),
            "debit_notes_issued": R2(debit_notes_issued), "net_taxable_sales": R2(net_taxable_sales),
            "output_vat": output_vat,
            "creditable_purchases": R2(creditable_purchases), "blocked_purchases": R2(blocked_purchases),
            "capital_purchases": R2(capital_purchases),
            "credit_notes_received": R2(credit_notes_received), "debit_notes_received": R2(debit_notes_received),
            "net_creditable_purchases": R2(net_creditable_purchases), "input_vat_gross": input_vat_gross,
            "mixed_supply": mixed_supply, "apportionment_ratio": R2(ratio * 100),
            "eligible_input_vat": eligible_input_vat, "restricted_input_vat": restricted_input_vat,
            "opening_credit": R2(opening_credit), "total_credit_available": total_credit_available,
            "payable": payable, "credit_carried_forward": credit_carried_forward,
        },
        "blocked_categories": VAT_BLOCKED_CATEGORIES,
        "signatory": data.get("signatory") or {},
        "unverified": unverified,
    }


ENGINES = {
    "salary_tds": lambda fy, d: {"employees": [salary_tds(fy, e) for e in d.get("employees", [])]},
    "depreciation": lambda fy, d: depreciation(fy, d.get("pools", []), d.get("opts")),
    "biz_tax": lambda fy, d: business_tax(fy, d),
    "penalty": lambda fy, d: penalty(fy, d),
    "emi": lambda fy, d: loan_schedule(d),
    "lease": lambda fy, d: lease_schedule(d, fy),
    "fin_statements": lambda fy, d: fin_statements(fy, d),
    "grant": lambda fy, d: grant_accounting(d),
    "projection": lambda fy, d: projection(fy, d),
    "dd": lambda fy, d: dd_workbench(fy, d),
    "capital_advisory": lambda fy, d: capital_advisory(fy, d),
    "fdi": lambda fy, d: fdi(fy, d),
    "valuation": lambda fy, d: valuation(fy, d),
    "inventory": lambda fy, d: inventory_valuation(fy, d),
    "fixed_assets": lambda fy, d: fixed_assets(fy, d),
    "internal_audit": lambda fy, d: internal_audit(fy, d),
    "stat_audit": lambda fy, d: statutory_audit(fy, d),
    "audit_report": lambda fy, d: audit_opinion_report(d),
    "tds": lambda fy, d: tds_deduction(fy, d),
    "personal_tax": lambda fy, d: personal_income_tax(fy, d),
    "vat": lambda fy, d: vat_return(fy, d),
    # India-specific — see india_engines.py. Not Nepal rate_master data run
    # through the existing engines above; genuinely different mechanics
    # (Sec 32 block-of-assets WDV, multi-slab GST, Sec 234A/B/C/F).
    "india_depreciation": lambda fy, d: india_engines.india_depreciation(fy, d.get("blocks", [])),
    "india_gst": lambda fy, d: india_engines.india_gst_return(fy, d),
    "india_interest_penalty": lambda fy, d: india_engines.india_interest_penalty(fy, d),
    # UAE-specific. uae_corporate_tax needs no dedicated function — it's
    # personal_tax()'s own marginal-slab mechanic against a 'company'-scoped
    # income_tax seed (0% to AED 375,000, 9% above); uae_penalties is the
    # one UAE mechanic (Cabinet Decision 49/2021 fixed/capped-% penalties)
    # that doesn't fit any existing engine — see uae_engines.py.
    "uae_corporate_tax": lambda fy, d: personal_tax(fy, "company", d.get("taxable_income", 0), d.get("opts")),
    "uae_penalties": lambda fy, d: uae_engines.uae_penalties(fy, d),
}


def compute(module_key, fy, data):
    fn = ENGINES.get(module_key)
    if not fn:
        raise ValueError(f"No engine registered for module '{module_key}'")
    return fn(fy, data or {})
