// Lot cost allocation: pure functions, no WooCommerce and no database. Money is an integer number of cents.
// A lot is bought for one price (plus shipping and other costs) and holds several pieces: this file says how
// much of that total each piece carries. The result always adds up to the lot total, to the cent.

import { __ } from "@wordpress/i18n";

export type AllocationMethod = "equal" | "price" | "manual";

export const METHODS: AllocationMethod[] = ["equal", "price", "manual"];

// Translated when the page starts: the translations are loaded before the script runs.
export const METHOD_LABEL: Record<AllocationMethod, string> = {
  equal: __("Equal share", "lotcost"),
  price: __("In proportion to the selling price", "lotcost"),
  manual: __("By hand", "lotcost"),
};

export interface LotAmounts {
  itemsCents: number;
  shippingCents: number;
  otherCents: number;
}

export const lotTotalCents = (amounts: LotAmounts): number => amounts.itemsCents + amounts.shippingCents + amounts.otherCents;

export interface AllocationPiece {
  // Whatever identifies the piece (the variant id): the result comes back in the same order.
  key: string;
  // How many identical units the lot holds for this piece (1 for a one-of-a-kind piece).
  units: number;
  // The selling price of one unit today: the weight of the "price" method.
  priceCents: number | null;
  // The cost of one unit fixed by the merchant: that unit is taken out of the lot before the rest is shared
  // (equal and price methods).
  pinnedCents: number | null;
  // The cost of one unit typed by the merchant (the manual method).
  manualCents: number | null;
}

export type PieceProblem = "no_price" | "no_cost" | "pinned_too_much";
export type LotProblem = "no_pieces" | "pinned_exceed_total";

export interface PieceShare {
  key: string;
  units: number;
  // The cost of one unit, to the cent. Null when the piece has no cost (see `problem`).
  unitCostCents: number | null;
  // What the piece carries in the lot, before the cost of one unit is rounded.
  totalCents: number;
  pinned: boolean;
  problem: PieceProblem | null;
}

export interface Allocation {
  totalCents: number;
  // The part of the total carried by the pieces.
  allocatedCents: number;
  // The part not carried by any piece: always 0 when the lot is shared automatically and has pieces to share
  // it with; the amount still to give out in manual mode (negative when too much was given out).
  leftCents: number;
  // How much the rounding of the cost of one unit moves the total of a piece that holds several units
  // (a lot of 3 identical shirts for 1.00 cannot give 3 x 0.333...). 0 when every piece is a single unit.
  roundingCents: number;
  pieces: PieceShare[];
  problem: LotProblem | null;
}

const wholeUnits = (units: number): number => (Number.isFinite(units) && units >= 1 ? Math.floor(units) : 1);
const money = (cents: number | null): number | null => (cents !== null && Number.isFinite(cents) && cents >= 0 ? Math.round(cents) : null);

function share(piece: AllocationPiece, unitCostCents: number | null, totalCents: number, pinned: boolean, problem: PieceProblem | null): PieceShare {
  return { key: piece.key, units: wholeUnits(piece.units), unitCostCents, totalCents, pinned, problem };
}

export function allocate(amounts: LotAmounts, method: AllocationMethod, pieces: AllocationPiece[]): Allocation {
  const totalCents = lotTotalCents(amounts);
  if (pieces.length === 0) return { totalCents, allocatedCents: 0, leftCents: totalCents, roundingCents: 0, pieces: [], problem: "no_pieces" };
  return method === "manual" ? allocateByHand(totalCents, pieces) : allocateAutomatically(totalCents, method, pieces);
}

function allocateByHand(totalCents: number, pieces: AllocationPiece[]): Allocation {
  const shares = pieces.map((piece) => {
    const unit = money(piece.manualCents);
    const units = wholeUnits(piece.units);
    return unit === null ? share(piece, null, 0, false, "no_cost") : share(piece, unit, unit * units, false, null);
  });
  const allocatedCents = shares.reduce((sum, s) => sum + s.totalCents, 0);
  return { totalCents, allocatedCents, leftCents: totalCents - allocatedCents, roundingCents: 0, pieces: shares, problem: null };
}

function allocateAutomatically(totalCents: number, method: "equal" | "price", pieces: AllocationPiece[]): Allocation {
  const entries = pieces.map((piece) => {
    const units = wholeUnits(piece.units);
    const pinned = money(piece.pinnedCents);
    return { piece, units, pinned };
  });

  // 1. What the merchant fixed is taken out first.
  const pinnedTotal = entries.reduce((sum, e) => sum + (e.pinned === null ? 0 : e.pinned * e.units), 0);
  const rest = totalCents - pinnedTotal;
  const exceeds = rest < 0;

  // 2. The weight of every other piece. A piece with no weight (no price in the "price" method) gets nothing.
  const weights = entries.map((e) => {
    if (e.pinned !== null) return 0n;
    if (method === "equal") return BigInt(e.units);
    const price = money(e.piece.priceCents);
    return price !== null && price > 0 ? BigInt(price) * BigInt(e.units) : 0n;
  });
  const weightSum = exceeds ? 0n : weights.reduce((sum, w) => sum + w, 0n);

  // 3. Share the rest by weight. Every piece gets the rounded-down amount, then the cents that remain go one by one to
  // the pieces that lost the most (the earlier piece first on a tie), so the shares add up to the rest exactly.
  const totals = entries.map(() => 0);
  if (weightSum > 0n) {
    const rest_ = BigInt(rest);
    const rows = weights.map((w, index) => ({ index, w, quotient: (rest_ * w) / weightSum, remainder: (rest_ * w) % weightSum }));
    let left = rest_ - rows.reduce((sum, r) => sum + r.quotient, 0n);
    for (const row of [...rows].filter((r) => r.w > 0n).sort((a, b) => (a.remainder === b.remainder ? a.index - b.index : a.remainder > b.remainder ? -1 : 1))) {
      if (left <= 0n) break;
      row.quotient += 1n;
      left -= 1n;
    }
    for (const row of rows) totals[row.index] = Number(row.quotient);
  }

  let allocatedCents = 0;
  let roundingCents = 0;
  const shares = entries.map((e, i) => {
    if (e.pinned !== null) {
      const total = e.pinned * e.units;
      allocatedCents += total;
      return share(e.piece, e.pinned, total, true, null);
    }
    if (exceeds) return share(e.piece, null, 0, false, "pinned_too_much");
    if (weights[i] === 0n) return share(e.piece, null, 0, false, method === "price" ? "no_price" : "no_cost");
    const total = totals[i] ?? 0;
    const unit = Math.round(total / e.units);
    allocatedCents += total;
    roundingCents += unit * e.units - total;
    return share(e.piece, unit, total, false, null);
  });

  return { totalCents, allocatedCents, leftCents: totalCents - allocatedCents, roundingCents, pieces: shares, problem: exceeds ? "pinned_exceed_total" : null };
}
