// @vitest-environment node
import { describe, it, expect } from "vitest";
import {
  computePnL, computeMargins, reconcileWithRetainedEarnings,
  toMinor, normalBalanceOf, PnlValidationError,
  type PnlAccount, type Posting,
} from "./pnl";

// ── Fixtures ──────────────────────────────────────────────────────────────────

const accounts: PnlAccount[] = [
  { id: "rev",   name: "Sales Revenue",     type: "income",  classification: "REVENUE" },
  { id: "ret",   name: "Sales Returns",     type: "income",  classification: "CONTRA_REVENUE" },
  { id: "cogs",  name: "Cost of Goods Sold",type: "expense", classification: "COGS" },
  { id: "opex",  name: "Salaries",          type: "expense", classification: "OPERATING_EXPENSE" },
  { id: "oinc",  name: "Interest Income",   type: "income",  classification: "OTHER_INCOME" },
  { id: "oexp",  name: "Interest Expense",  type: "expense", classification: "OTHER_EXPENSE" },
  { id: "tax",   name: "Income Tax",        type: "expense", classification: "TAX_EXPENSE" },
  { id: "cash",  name: "Cash",              type: "asset",   classification: "REVENUE" }, // mis-tagged
];

const PERIOD = { periodStart: "2026-01-01", periodEnd: "2026-12-31" };

// Revenue is earned with credits; expenses are incurred with debits.
const credit = (accountId: string, dollars: number, date = "2026-06-01"): Posting =>
  ({ accountId, debitMinor: 0, creditMinor: toMinor(dollars), date });
const debit = (accountId: string, dollars: number, date = "2026-06-01"): Posting =>
  ({ accountId, debitMinor: toMinor(dollars), creditMinor: 0, date });

// ── §9 tests ──────────────────────────────────────────────────────────────────

describe("normalBalanceOf", () => {
  it("assigns natural sides per §3", () => {
    expect(normalBalanceOf("REVENUE")).toBe("CREDIT");
    expect(normalBalanceOf("OTHER_INCOME")).toBe("CREDIT");
    expect(normalBalanceOf("CONTRA_COGS")).toBe("CREDIT");
    expect(normalBalanceOf("CONTRA_REVENUE")).toBe("DEBIT");
    expect(normalBalanceOf("COGS")).toBe("DEBIT");
    expect(normalBalanceOf("OPERATING_EXPENSE")).toBe("DEBIT");
    expect(normalBalanceOf("OTHER_EXPENSE")).toBe("DEBIT");
    expect(normalBalanceOf("TAX_EXPENSE")).toBe("DEBIT");
  });
});

describe("empty period", () => {
  it("returns all zeros and Net Income 0", () => {
    const r = computePnL([], accounts, PERIOD);
    expect(r.subtotals.netIncome).toBe(0);
    expect(r.subtotals.netRevenue).toBe(0);
    expect(r.subtotals.grossProfit).toBe(0);
    expect(r.subtotals.operatingIncome).toBe(0);
    expect(r.totalRevenue).toBe(0);
    expect(r.totalExpenses).toBe(0);
    expect(r.lines).toHaveLength(0);
  });
});

describe("revenue and expense only", () => {
  it("Net Income = Revenue - Expense (single-step)", () => {
    const r = computePnL([credit("rev", 1000), debit("opex", 400)], accounts, PERIOD);
    expect(r.subtotals.netRevenue).toBe(toMinor(1000));
    expect(r.subtotals.operatingExpenses).toBe(toMinor(400));
    expect(r.subtotals.netIncome).toBe(toMinor(600));
    expect(r.totalRevenue - r.totalExpenses).toBe(r.subtotals.netIncome);
  });
});

describe("full multi-step", () => {
  it("the subtotal chain equals the direct computation", () => {
    const r = computePnL([
      credit("rev", 10000),
      debit("ret", 500),        // contra-revenue
      debit("cogs", 3000),
      debit("opex", 2500),
      credit("oinc", 200),      // other income
      debit("oexp", 150),       // other expense
      debit("tax", 800),
    ], accounts, PERIOD);

    const s = r.subtotals;
    expect(s.netRevenue).toBe(toMinor(9500));         // 10000 - 500
    expect(s.grossProfit).toBe(toMinor(6500));        // 9500 - 3000
    expect(s.operatingIncome).toBe(toMinor(4000));    // 6500 - 2500
    expect(s.otherNet).toBe(toMinor(50));             // 200 - 150
    expect(s.preTaxIncome).toBe(toMinor(4050));       // 4000 + 50
    expect(s.netIncome).toBe(toMinor(3250));          // 4050 - 800

    // §7.2 chain == direct
    const chain = s.grossProfit - s.operatingExpenses + s.otherNet - s.incomeTaxExpense;
    const directly = r.totalRevenue - r.totalExpenses;
    expect(chain).toBe(directly);
    expect(chain).toBe(s.netIncome);
  });
});

describe("contra-revenue", () => {
  it("reduces Net Revenue and is never added", () => {
    const base = computePnL([credit("rev", 1000)], accounts, PERIOD);
    const withReturns = computePnL([credit("rev", 1000), debit("ret", 250)], accounts, PERIOD);
    expect(withReturns.subtotals.netRevenue).toBe(base.subtotals.netRevenue - toMinor(250));
    expect(withReturns.subtotals.netRevenue).toBe(toMinor(750));
  });

  it("flags a contra account that would increase its parent (§7.3)", () => {
    // A net-credit on a contra-revenue account → would increase revenue.
    expect(() => computePnL([credit("ret", 100)], accounts, PERIOD)).toThrow(PnlValidationError);
  });
});

describe("type purity (§7.1)", () => {
  it("raises when a balance-sheet account contributes", () => {
    expect(() => computePnL([credit("cash", 500)], accounts, PERIOD))
      .toThrow(/balance-sheet type|must not appear/i);
  });
});

describe("period boundaries (§7.4)", () => {
  it("includes postings exactly on both endpoints and excludes one day outside each", () => {
    const r = computePnL([
      credit("rev", 100, "2026-01-01"),  // on start — included
      credit("rev", 100, "2026-12-31"),  // on end — included
      credit("rev", 999, "2025-12-31"),  // day before start — excluded
      credit("rev", 999, "2027-01-01"),  // day after end — excluded
    ], accounts, PERIOD);
    expect(r.subtotals.netRevenue).toBe(toMinor(200));
  });
});

describe("reconciliation with the balance sheet (§6)", () => {
  it("holds when RE movement + dividends equals net income", () => {
    const r = computePnL([credit("rev", 5000), debit("opex", 1200)], accounts, PERIOD);
    const ni = r.subtotals.netIncome; // 380000 minor
    // RE went from 1,000,000 to 1,330,000 and 50,000 of dividends were declared.
    const rec = reconcileWithRetainedEarnings(ni, toMinor(10000), toMinor(13300), toMinor(500));
    expect(rec.ok).toBe(true);
    expect(rec.differenceMinor).toBe(0);
    expect(rec.expectedMinor).toBe(ni);
  });

  it("reports a non-zero difference when they disagree", () => {
    const rec = reconcileWithRetainedEarnings(toMinor(380000 / 100), toMinor(10000), toMinor(13000), 0);
    expect(rec.ok).toBe(false);
    expect(rec.differenceMinor).not.toBe(0);
  });
});

describe("margins (§5)", () => {
  it("computes ratios from the chain and returns null when net revenue is 0", () => {
    const r = computePnL([credit("rev", 1000), debit("cogs", 400), debit("opex", 100)], accounts, PERIOD);
    const m = computeMargins(r);
    expect(m.grossMargin).toBeCloseTo(0.6, 10);      // 600/1000
    expect(m.operatingMargin).toBeCloseTo(0.5, 10);  // 500/1000
    expect(m.netMargin).toBeCloseTo(0.5, 10);

    const empty = computeMargins(computePnL([], accounts, PERIOD));
    expect(empty.grossMargin).toBeNull();
    expect(empty.operatingMargin).toBeNull();
    expect(empty.netMargin).toBeNull();
  });
});

describe("negative net revenue (§7.5)", () => {
  it("is a warning, not an error", () => {
    const r = computePnL([credit("rev", 100), debit("ret", 300)], accounts, PERIOD);
    expect(r.subtotals.netRevenue).toBe(toMinor(-200));
    expect(r.issues.some((i) => i.code === "NEGATIVE_NET_REVENUE" && i.severity === "warning")).toBe(true);
  });
});

describe("cents exactness (§9)", () => {
  it("keeps integer minor units through the chain without float drift", () => {
    // 3 postings of $0.10 each = $0.30 exactly; floats would give 0.30000000000000004
    const r = computePnL([credit("rev", 0.1), credit("rev", 0.1), credit("rev", 0.1)], accounts, PERIOD);
    expect(r.subtotals.netRevenue).toBe(30);
    expect(Number.isInteger(r.subtotals.netIncome)).toBe(true);
  });
});
