// The inventory valuation report's tie line and the item profitability
// report's margin — Inventory Task 5.
//
// The NON-ZERO tie branch cannot be reached through the running app, and that
// is the point: 0022's check_entry_stock_backed() refuses an entry that moves
// money into an inventory account without moving the stock behind it, and
// 0018's per-entry check refuses the reverse. So the only way to see that copy
// is a database whose own invariant has already been broken — exactly when a
// user most needs a sentence rather than a bare number (CLAUDE.md §9). It is
// pinned here because live verification structurally cannot pin it.
//
// marginPercent is the one derived figure the profitability report computes
// itself, and it is derived FROM MONEY, so §2's no-floats rule applies to it
// as much as to the amounts it divides.

import { describe, it, expect } from "vitest";
import {
  tieExplanation,
  tieLine,
  tieVerdict,
  groupByAccount,
  type ValuationAccount,
  type ValuationData,
} from "../../web/src/reports/inventoryValuationData.js";
import { marginPercent } from "../../web/src/reports/itemProfitabilityMath.js";
import { trimStoredDecimal } from "../../web/src/items/itemTypes.js";

function account(over: Partial<ValuationAccount> = {}): ValuationAccount {
  return {
    accountId: "1", code: "1300", name: "Inventory",
    ledgerValue: "41207.1800", movementValue: "41207.1800", difference: "0.0000",
    ...over,
  };
}

describe("tieLine", () => {
  it("prints both derivations and the gap, in the spec's own shape", () => {
    expect(tieLine(account())).toBe(
      "Inventory account: $41,207.18 · Items on hand: $41,207.18 · Difference: $0.00",
    );
  });
});

describe("tieExplanation", () => {
  it("says nothing when the account ties — the zero speaks for itself", () => {
    expect(tieExplanation(account())).toBeNull();
    expect(tieExplanation(account({ difference: "-0.0000" }))).toBeNull();
  });

  it("explains a ledger that holds MORE than the stock behind it", () => {
    const msg = tieExplanation(account({
      ledgerValue: "1250.0000", movementValue: "1000.0000", difference: "250.0000",
    }));
    expect(msg).toContain("$250.00 more than the stock recorded against it is worth");
    expect(msg).toContain("own more stock than the shelf does");
    // Never a bare number with no meaning attached (§9).
    expect(msg!.length).toBeGreaterThan(120);
  });

  it("explains stock recorded beyond what the ledger holds", () => {
    const msg = tieExplanation(account({
      ledgerValue: "1000.0000", movementValue: "1250.0000", difference: "-250.0000",
    }));
    expect(msg).toContain("$250.00 more stock recorded here");
    expect(msg).toContain("own less stock than the shelf does");
  });
});

describe("tieVerdict", () => {
  const base: ValuationData = {
    asOf: "2026-08-04", items: [], accounts: [account()],
    totalValue: "41207.1800", outOfTie: "0.0000", ties: true,
  };

  it("says agreeing is meaningful, not automatic, when everything ties", () => {
    expect(tieVerdict(base)).toContain("worked out separately");
  });

  it("names the total gap, unsigned, when something is out", () => {
    const out = tieVerdict({ ...base, ties: false, outOfTie: "250.0000" });
    expect(out).toContain("$250.00");
    expect(out).not.toContain("-$");
  });
});

describe("groupByAccount", () => {
  const items = [
    { itemId: "1", sku: null, name: "Bolt", qtyOnHand: "10.0000", averageCost: "1.000000",
      value: "10.0000", inventoryAccountId: "1", inventoryAccountCode: "1300",
      inventoryAccountName: "Inventory" },
    { itemId: "2", sku: null, name: "Orphan", qtyOnHand: "5.0000", averageCost: "1.000000",
      value: "5.0000", inventoryAccountId: null, inventoryAccountCode: null,
      inventoryAccountName: null },
  ];

  it("groups items under their account, in code order, orphans last", () => {
    const groups = groupByAccount({
      asOf: null, items, accounts: [account({ accountId: "1", code: "1300" })],
      totalValue: "15.0000", outOfTie: "0.0000", ties: true,
    });
    expect(groups).toHaveLength(2);
    expect(groups[0]!.account!.code).toBe("1300");
    expect(groups[0]!.items.map((i) => i.name)).toEqual(["Bolt"]);
    // An item with no inventory account is SHOWN, not silently dropped — it is
    // the one row on this report worth seeing.
    expect(groups[1]!.account).toBeNull();
    expect(groups[1]!.items.map((i) => i.name)).toEqual(["Orphan"]);
  });
});

describe("marginPercent", () => {
  it("computes a margin to one decimal place", () => {
    expect(marginPercent("725.7984", "800.0000")).toBe("90.7%");
    expect(marginPercent("50.0000", "200.0000")).toBe("25.0%");
  });

  it("rounds half away from zero rather than to even", () => {
    // 12.5 / 100 -> 12.5%, and the .05 boundary at one decimal.
    expect(marginPercent("1.2500", "10.0000")).toBe("12.5%");
    expect(marginPercent("0.1250", "1.0000")).toBe("12.5%");
  });

  it("reports a loss as a negative margin", () => {
    expect(marginPercent("-40.0000", "100.0000")).toBe("-40.0%");
  });

  it("returns null where a percentage has no meaning", () => {
    expect(marginPercent("0.0000", "0.0000")).toBeNull();
    expect(marginPercent("10.0000", "not a number")).toBeNull();
  });

  it("survives revenue beyond Number's exact integer range", () => {
    // 9007199254740993.0000 is Number.MAX_SAFE_INTEGER + 2 — a float would
    // have already lost the last digit before the division began.
    expect(marginPercent("9007199254740993.0000", "9007199254740993.0000")).toBe("100.0%");
  });
});

// Task 5 review F6. Two screens had each grown their own copy of this, and the
// copy in OpeningQuantitiesForm had lost the `includes(".")` guard — so the
// strip was unconditional and a whole number would have been eaten. Latent for
// values straight off the API (always four decimal places), not latent for
// anything a form had already trimmed once. One shared helper, one test.
describe("trimStoredDecimal", () => {
  it("trims a stored numeric(19,4) to what a person would type", () => {
    expect(trimStoredDecimal("12.5000")).toBe("12.5");
    expect(trimStoredDecimal("1.0000")).toBe("1");
    expect(trimStoredDecimal("0.0042")).toBe("0.0042");
  });

  it("leaves a whole number with no decimal point completely alone", () => {
    // The bug: an unguarded /0+$/ strip turns "100" into "1".
    expect(trimStoredDecimal("100")).toBe("100");
    expect(trimStoredDecimal("1000")).toBe("1000");
    expect(trimStoredDecimal("0")).toBe("0");
  });

  it("never returns an empty string", () => {
    expect(trimStoredDecimal("0.0000")).toBe("0");
  });
});
