import { describe, it, expect } from "vitest";
import {
  totalNet,
  monthTotals,
  netWorth,
  netWorthSeries,
  observedMonthlySurplus,
  projectedSpend,
  monthsToGoal,
  goalProbability,
} from "./finance";
import { relativeDay } from "./format";

const iso = (daysAgo: number) => new Date(Date.now() - daysAgo * 86_400_000).toISOString();

const txs = [
  { amount: 5000, type: "income", occurred_at: iso(20) },
  { amount: 1200, type: "expense", occurred_at: iso(15) },
  { amount: 300, type: "expense", occurred_at: iso(5) },
];

describe("net worth integrity", () => {
  it("nets income against expenses", () => {
    expect(totalNet(txs)).toBe(3500);
  });

  it("adds the baseline to all transactions", () => {
    expect(netWorth(10_000, txs)).toBe(13_500);
  });

  it("series ends exactly at the headline net worth", () => {
    const s = netWorthSeries(10_000, txs);
    expect(s).toHaveLength(3);
    expect(s[s.length - 1].value).toBe(netWorth(10_000, txs));
  });

  it("series is chronological regardless of input order", () => {
    const s = netWorthSeries(0, [...txs].reverse());
    expect(s.map((p) => p.t)).toEqual([...s.map((p) => p.t)].sort((a, b) => a - b));
  });

  it("handles empty and malformed data", () => {
    expect(netWorth(0, [])).toBe(0);
    expect(netWorthSeries(500, [])).toEqual([]);
    expect(totalNet([{ amount: "abc" as any, type: "expense", occurred_at: iso(1) }])).toBe(0);
  });
});

describe("month totals", () => {
  it("only counts the current calendar month", () => {
    const now = new Date();
    const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1, 12).toISOString();
    const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 15, 12).toISOString();
    const r = monthTotals([
      { amount: 100, type: "income", occurred_at: thisMonth },
      { amount: 40, type: "expense", occurred_at: thisMonth },
      { amount: 999, type: "expense", occurred_at: lastMonth },
    ]);
    expect(r).toEqual({ earned: 100, spent: 40, net: 60 });
  });
});

describe("surplus and pacing", () => {
  it("returns 0 with no transactions in the window", () => {
    expect(observedMonthlySurplus([])).toBe(0);
    expect(observedMonthlySurplus([{ amount: 100, type: "income", occurred_at: iso(400) }])).toBe(0);
  });

  it("never reports a negative surplus", () => {
    expect(observedMonthlySurplus([{ amount: 900, type: "expense", occurred_at: iso(10) }])).toBe(0);
  });

  it("projects month-end spend from spend-to-date", () => {
    const mid = new Date(2026, 0, 15); // 15 of 31 days
    expect(Math.round(projectedSpend(1500, mid))).toBe(3100);
  });

  it("projects zero spend as zero", () => {
    expect(projectedSpend(0, new Date(2026, 0, 15))).toBe(0);
  });
});

describe("goal projections", () => {
  it("computes months needed", () => {
    expect(monthsToGoal(1000, 250)).toBe(4);
    expect(monthsToGoal(1001, 250)).toBe(5);
    expect(monthsToGoal(0, 250)).toBe(0);
    expect(monthsToGoal(1000, 0)).toBe(Infinity);
  });

  it("scores odds sensibly", () => {
    const now = Date.now();
    const inAYear = new Date(now + 365 * 86_400_000);
    expect(goalProbability(1000, 100, null)).toBeNull();
    expect(goalProbability(0, 100, inAYear)).toBe(100);
    expect(goalProbability(1000, 0, inAYear)).toBe(0);
    expect(goalProbability(1000, 100, new Date(now - 86_400_000))).toBe(0);
    expect(goalProbability(1200, 500, inAYear, now)).toBeGreaterThan(90);
    expect(goalProbability(120_000, 100, inAYear, now)).toBeLessThan(10);
  });
});

describe("relativeDay", () => {
  it("labels calendar days, not 24h windows", () => {
    const now = new Date();
    expect(relativeDay(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 1))).toBe("Today");
    expect(relativeDay(new Date(now.getTime() - 86_400_000))).toBe("Yesterday");
  });

  it("does not call future dates Today", () => {
    expect(relativeDay(new Date(Date.now() + 86_400_000))).toBe("Tomorrow");
    expect(relativeDay(new Date(Date.now() + 3 * 86_400_000))).toBe("in 3d");
  });

  it("handles invalid input", () => {
    expect(relativeDay("not-a-date")).toBe("—");
  });
});
