// web/src/items/quantityMath.ts — the only arithmetic Inventory Task 5's
// screens do themselves. Everything else on those screens is a number the
// server already worked out; these four functions exist because a build
// preview's "short by", a BOM row's extended cost and an adjustment's
// "what it will become" have to move as the user types, before any round trip.
//
// Two things are being pinned here:
//   1. NO FLOATS, EVER (CLAUDE.md §2). 0.1 + 0.2 and 1.005 × 100 are the
//      classic float failures; both are asserted exact.
//   2. The rounding matches what the DATABASE will store. record_movement()
//      computes round(round(qty,4) × round(unit_cost,6), 4) in plpgsql, and
//      Postgres's numeric round() is half-AWAY-FROM-ZERO, not banker's. A
//      preview that rounded differently would show a number the build then
//      contradicted.
//   3. Draining an item to exactly zero assigns the WHOLE remaining value
//      rather than qty × average — 0018's decision 5, stated in the spec as
//      "Reports and previews must not re-derive the last issue's cost as
//      qty × average", because a fraction of a cent would otherwise be
//      stranded in the Inventory account with no quantity left to carry it out.

import { describe, it, expect } from "vitest";
import {
  addQuantity,
  compareQuantity,
  exactUnitCost,
  extendedCost,
  hasSubCentDigits,
  isZeroQuantity,
  issueValuePreview,
  subtractQuantity,
} from "../../web/src/items/quantityMath.js";

describe("addQuantity / subtractQuantity", () => {
  it("adds and subtracts exactly, where floats would not", () => {
    expect(addQuantity("0.1000", "0.2000")).toBe("0.3000");
    expect(subtractQuantity("0.3000", "0.1000")).toBe("0.2000");
  });

  it("keeps the numeric(19,4) shape the API returns", () => {
    expect(addQuantity("12", "3.5")).toBe("15.5000");
    expect(subtractQuantity("100.0000", "100.0000")).toBe("0.0000");
  });

  it("goes negative when it should — a build short by 8 is short by 8", () => {
    expect(subtractQuantity("20.0000", "12.0000")).toBe("8.0000");
    expect(subtractQuantity("12.0000", "20.0000")).toBe("-8.0000");
  });

  it("never signs a zero", () => {
    expect(subtractQuantity("5.0000", "5.0000")).toBe("0.0000");
  });

  it("survives quantities far beyond Number's exact integer range", () => {
    expect(addQuantity("9007199254740993", "1")).toBe("9007199254740994.0000");
  });

  it("returns null mid-keystroke rather than a wrong intermediate", () => {
    expect(addQuantity("12.", "1")).toBeNull();
    expect(subtractQuantity("", "1")).toBeNull();
    expect(extendedCost("abc", "1")).toBeNull();
  });
});

describe("isZeroQuantity / compareQuantity", () => {
  it("recognises zero in every shape the API can return it", () => {
    expect(isZeroQuantity("0")).toBe(true);
    expect(isZeroQuantity("0.0000")).toBe(true);
    expect(isZeroQuantity("-0.0000")).toBe(true);
    expect(isZeroQuantity("0.0001")).toBe(false);
  });

  it("orders quantities without parsing them", () => {
    expect(compareQuantity("12.0000", "12.0000")).toBe(0);
    expect(compareQuantity("12.0001", "12.0000")).toBe(1);
    expect(compareQuantity("11.9999", "12.0000")).toBe(-1);
  });
});

describe("extendedCost", () => {
  it("multiplies a 4dp quantity by a 6dp unit cost", () => {
    expect(extendedCost("3", "7.500000")).toBe("22.5000");
    expect(extendedCost("2.5", "4")).toBe("10.0000");
  });

  it("keeps sub-cent costs honest — a washer really does cost $0.0042", () => {
    expect(extendedCost("1000", "0.004200")).toBe("4.2000");
    expect(extendedCost("1", "0.004200")).toBe("0.0042");
  });

  it("rounds HALF AWAY FROM ZERO, matching Postgres's numeric round()", () => {
    // 0.00005 lands exactly on the boundary at 4dp: away from zero, not to even.
    expect(extendedCost("1", "0.000050")).toBe("0.0001");
    expect(extendedCost("1", "0.000150")).toBe("0.0002");
    // ...and the classic float trap: 1.005 * 100 is 100.49999999999999 in
    // binary floating point, which would round DOWN. It must not.
    expect(extendedCost("100", "1.005000")).toBe("100.5000");
  });

  it("carries the sign through a negative quantity", () => {
    expect(extendedCost("-3", "7.500000")).toBe("-22.5000");
  });
});

describe("issueValuePreview", () => {
  it("is quantity x average cost for an ordinary issue", () => {
    // 5 of an item averaging 7.5 -> 37.50, with 20 left on the shelf.
    expect(issueValuePreview("5", "20.0000", "7.500000", "150.0000")).toBe("37.5000");
  });

  it("assigns the WHOLE remaining value when the item drains to zero", () => {
    // 3 on hand carrying 10.0001 in total. qty x average would round to
    // 10.0000 and strand a hundredth of a cent in the Inventory account with
    // no quantity left to carry it out (0018 decision 5).
    const average = "3.333367"; // 10.0001 / 3, to the column's 6dp
    expect(extendedCost("3", average)).toBe("10.0001");
    expect(issueValuePreview("3", "3.0000", average, "10.0001")).toBe("10.0001");
  });

  it("takes the remaining value even when the average would round elsewhere", () => {
    // 7 on hand worth 100.0000 -> average 14.285714; 7 x that is 99.999998,
    // which rounds to 100.0000 here but need not in general. The drain case
    // must never depend on that coincidence.
    expect(issueValuePreview("7", "7.0000", "14.285714", "100.0000")).toBe("100.0000");
    // 3 on hand worth 10.0000 -> average 3.333333; 3 x that is 9.999999,
    // which rounds to 10.0000 — but 6 on hand worth 20.0000 drained in one go
    // must still be exactly 20.0000 whatever the average says.
    expect(issueValuePreview("6", "6.0000", "3.333333", "20.0000")).toBe("20.0000");
  });

  it("returns null while the quantity is incomplete", () => {
    expect(issueValuePreview("", "20.0000", "7.500000", "150.0000")).toBeNull();
  });
});

// Task 5 review F4. A build of ten at an exact unit cost of 37.1008 shows
// "$371.01 of parts" beside "$37.10 each", and 10 x $37.10 is $371.00 — the
// screen looks a penny out on the one page where the user is consenting to the
// numbers. Nothing is wrong; cents simply cannot hold the unit cost. These two
// are what let the screen say so instead of leaving it to be found.
describe("hasSubCentDigits / exactUnitCost", () => {
  it("spots a unit cost that cents cannot hold", () => {
    expect(hasSubCentDigits("37.100800")).toBe(true);
    expect(hasSubCentDigits("0.004200")).toBe(true);
    // ...and stays quiet when the rounded figure IS the whole number.
    expect(hasSubCentDigits("37.100000")).toBe(false);
    expect(hasSubCentDigits("12.500000")).toBe(false);
    expect(hasSubCentDigits("0.000000")).toBe(false);
  });

  it("ignores the sign, which has nothing to do with precision", () => {
    expect(hasSubCentDigits("-37.100800")).toBe(true);
    expect(hasSubCentDigits("-37.100000")).toBe(false);
  });

  it("shows the exact figure with its trailing zeros trimmed", () => {
    expect(exactUnitCost("37.100800")).toBe("37.1008");
    expect(exactUnitCost("0.004200")).toBe("0.0042");
    expect(exactUnitCost("12.000000")).toBe("12");
  });

  it("leaves a whole number alone rather than eating its zeros", () => {
    // The guard that BomEditor/OpeningQuantitiesForm's own trimming was missing
    // (review F6): without it, "100" comes back "1".
    expect(exactUnitCost("100")).toBe("100");
  });

  it("says no rather than throwing on something that isn't a number", () => {
    expect(hasSubCentDigits("")).toBe(false);
    expect(hasSubCentDigits("not a cost")).toBe(false);
  });
});
