// Integer engines for the materials beyond tiles, flooring, paving and wallpaper: paint, decking,
// fencing, gravel and aggregates, and turf. Pure: they run in the quantities worker and in Node tests.
// Rule from AGENTS.md: lengths are whole millimetres, areas whole mm², volumes of paint whole
// millilitres and masses whole grams. Text is written elsewhere (quantities-core.ts).

export class MaterialsError extends RangeError {
  constructor(message: string) {
    super(message);
    this.name = "MaterialsError";
  }
}

function whole(value: number, label: string, min: number, max: number): void {
  if (!Number.isSafeInteger(value) || value < min || value > max) {
    throw new MaterialsError(`${label} must be from ${min.toLocaleString("en-GB")} to ${max.toLocaleString("en-GB")}.`);
  }
}

// Allowance in whole basis points (10% = 1,000), so percentages never bring float rounding into the counts.
function basisPoints(allowancePercent: number): number {
  if (!Number.isFinite(allowancePercent) || allowancePercent < 0 || allowancePercent > 100) {
    throw new MaterialsError("The allowance must be between 0 and 100%.");
  }
  return Math.round(allowancePercent * 100);
}

const withAllowance = (n: number, bp: number) => Math.ceil((n * (10_000 + bp)) / 10_000);

export const MAX_SIDE_MM = 100_000; // 100 m, as the tile planner
export const MAX_WALL_HEIGHT_MM = 20_000;

// ---------- paint tins ----------

export interface TinCount {
  volumeMl: number;
  quantity: number;
}

export interface TinPlan {
  neededMl: number;
  tins: TinCount[];       // largest tin first; sizes with no tins are left out
  totalMl: number;
  tinCount: number;
  leftoverMl: number;
  // True only for very large volumes with unusual tin sizes, where most of the paint is counted in the
  // largest tin first and the search covers the rest. Otherwise the mix is the one with the least paint
  // left over, then the fewest tins.
  reduced: boolean;
}

export const MAX_TIN_SIZES = 8;
export const TIN_ML_RANGE = [50, 100_000] as const;
// Largest search table, in steps of the tins' common size (about 2 MB of memory).
export const TIN_SEARCH_CELLS = 400_000;
// Mixes whose totals are within this many millilitres count as leaving the same paint over. US sizes
// rounded to whole millilitres do not add up exactly (4 × 946 ml quarts is 3,784 ml, a gallon 3,785 ml),
// and 1 ml must not buy three extra tins.
export const TIN_SAME_ML = 10;

const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));

// Whole tins for `neededMl`: the mix with the least paint left over, then the fewest tins. Totals within
// TIN_SAME_ML of each other count as the same.
//
// Search: a table over volumes in steps of the tins' greatest common size. To keep the table small for
// large volumes, some largest tins are counted first. That never changes the answer: in the best mix,
// fewer than L tins are smaller than the largest (L = largest size in steps), because any L of them hold
// a group whose total is a whole number of largest tins, and swapping that group for largest tins keeps
// the total and uses fewer tins. So the best mix has at least (needed - (L - 1) × second size) ÷ L largest
// tins. Only when that bound still leaves a table above TIN_SEARCH_CELLS (thousands of litres with tin
// sizes that share no common step) are more largest tins counted first, and `reduced` says so.
export function chooseTins(neededMl: number, sizesMl: readonly number[]): TinPlan {
  whole(neededMl, "The paint needed in millilitres", 0, 1_000_000_000);
  const sizes = [...new Set(sizesMl)].sort((a, b) => a - b);
  if (sizes.length === 0 || sizes.length > MAX_TIN_SIZES) throw new MaterialsError(`Give from 1 to ${MAX_TIN_SIZES} tin sizes.`);
  for (const s of sizes) whole(s, "Each tin size in millilitres", TIN_ML_RANGE[0], TIN_ML_RANGE[1]);
  if (neededMl === 0) return { neededMl, tins: [], totalMl: 0, tinCount: 0, leftoverMl: 0, reduced: false };

  const step = sizes.reduce(gcd);
  const units = sizes.map((s) => s / step);
  const largest = units.at(-1)!;
  const second = units.length > 1 ? units.at(-2)! : 0;
  const neededUnits = Math.ceil(neededMl / step); // every total is a whole number of steps

  const counts = new Map<number, number>();
  let reduced = false;
  let rest = neededUnits;
  if (units.length === 1) {
    counts.set(largest, Math.ceil(neededUnits / largest));
    rest = 0;
  } else {
    let first = Math.max(0, Math.floor((neededUnits - (largest - 1) * second) / largest));
    if (neededUnits - first * largest + largest > TIN_SEARCH_CELLS) {
      first = Math.ceil((neededUnits + largest - TIN_SEARCH_CELLS) / largest);
      reduced = true;
    }
    counts.set(largest, first);
    rest = Math.max(0, neededUnits - first * largest);
  }

  if (rest > 0) {
    // best[v]: fewest tins that make exactly v steps (-1: not possible); last[v]: the size added last.
    const same = Math.floor(TIN_SAME_ML / step);
    const cells = rest + largest + same;
    const best = new Int32Array(cells).fill(-1);
    const last = new Uint8Array(cells);
    best[0] = 0;
    for (let v = 1; v < cells; v += 1) {
      for (let i = units.length - 1; i >= 0; i -= 1) {
        const u = units[i]!;
        if (u > v || best[v - u]! < 0) continue;
        const n = best[v - u]! + 1;
        if (best[v]! < 0 || n < best[v]!) {
          best[v] = n;
          last[v] = i;
        }
      }
    }
    let v = rest;
    while (best[v]! < 0) v += 1; // a whole number of largest tins always fits within one largest tin
    // Totals within TIN_SAME_ML of the least: the fewest tins, then the least paint.
    const end = Math.min(cells - 1, v + same);
    for (let w = v + 1; w <= end; w += 1) if (best[w]! >= 0 && best[w]! < best[v]!) v = w;
    for (let at = v; at > 0; at -= units[last[at]!]!) {
      const u = units[last[at]!]!;
      counts.set(u, (counts.get(u) ?? 0) + 1);
    }
  }

  const tins = units
    .map((u, i) => ({ volumeMl: sizes[i]!, quantity: counts.get(u) ?? 0 }))
    .filter((t) => t.quantity > 0)
    .reverse();
  const totalMl = tins.reduce((sum, t) => sum + t.volumeMl * t.quantity, 0);
  const tinCount = tins.reduce((sum, t) => sum + t.quantity, 0);
  return { neededMl, tins, totalMl, tinCount, leftoverMl: totalMl - neededMl, reduced };
}

export interface PaintInput {
  wallWidthsMm: number[];
  wallHeightMm: number;
  doors: number;
  windows: number;
  doorAreaMm2: number;
  windowAreaMm2: number;
  coats: number;
  spreadRateMm2PerL: number; // area one litre covers for one coat
  tinSizesMl: number[];
}

export interface PaintPlan {
  wallAreaMm2: number;
  openingsMm2: number;
  paintAreaMm2: number;
  neededMl: number;
  tins: TinPlan;
}

export const MAX_WALLS = 4;
export const MAX_OPENINGS = 50;
export const MAX_COATS = 10;
export const SPREAD_RATE_RANGE = [100_000, 100_000_000] as const; // 0.1 to 100 m² per litre

export function planPaint(input: PaintInput): PaintPlan {
  if (input.wallWidthsMm.length < 1 || input.wallWidthsMm.length > MAX_WALLS) throw new MaterialsError(`Give from 1 to ${MAX_WALLS} walls.`);
  for (const w of input.wallWidthsMm) whole(w, "Each wall width in millimetres", 1, MAX_SIDE_MM);
  whole(input.wallHeightMm, "The wall height in millimetres", 1, MAX_WALL_HEIGHT_MM);
  whole(input.doors, "The number of doors", 0, MAX_OPENINGS);
  whole(input.windows, "The number of windows", 0, MAX_OPENINGS);
  whole(input.doorAreaMm2, "The door area in mm²", 0, 100_000_000);
  whole(input.windowAreaMm2, "The window area in mm²", 0, 100_000_000);
  whole(input.coats, "The number of coats", 1, MAX_COATS);
  whole(input.spreadRateMm2PerL, "The coverage per litre in mm²", SPREAD_RATE_RANGE[0], SPREAD_RATE_RANGE[1]);

  const wallAreaMm2 = input.wallWidthsMm.reduce((sum, w) => sum + w * input.wallHeightMm, 0);
  const openingsMm2 = input.doors * input.doorAreaMm2 + input.windows * input.windowAreaMm2;
  if (openingsMm2 >= wallAreaMm2) throw new MaterialsError("The doors and windows cover all of the wall. Check the sizes and counts.");
  const paintAreaMm2 = wallAreaMm2 - openingsMm2;
  // Litres = area × coats ÷ coverage per litre; in millilitres, rounded up. At most 8e9 mm² × 10 × 1,000,
  // well inside exact integer range.
  const neededMl = Math.ceil((paintAreaMm2 * input.coats * 1_000) / input.spreadRateMm2PerL);
  return { wallAreaMm2, openingsMm2, paintAreaMm2, neededMl, tins: chooseTins(neededMl, input.tinSizesMl) };
}

// ---------- decking ----------

export interface DeckInput {
  deckWidthMm: number;   // across the boards
  deckLengthMm: number;  // along the boards
  boardWidthMm: number;  // covered width of one board
  boardLengthMm: number;
  gapMm: number;
  allowancePercent: number;
  boardsPerPack: number | null; // null: sold one board at a time
  joistSpacingMm: number | null;
}

export interface DeckPlan {
  rows: number;
  lastRowWidthMm: number;     // the last row is cut down to this width
  fullBoardsPerRow: number;
  endPieceMm: number;         // length of the short piece at the end of each row, 0 when none
  piecesPerBoard: number;     // end pieces cut from one board, 0 when there are none
  endPieceBoards: number;
  boardsLaid: number;         // before the allowance
  boardsNeeded: number;       // after the allowance
  packs: number | null;
  boardsBought: number;
  leftoverMm: number;         // board length bought and not laid
  joists: { count: number; lengthMm: number } | null;
}

export function planDecking(input: DeckInput): DeckPlan {
  whole(input.deckWidthMm, "The deck width in millimetres", 1, MAX_SIDE_MM);
  whole(input.deckLengthMm, "The deck length in millimetres", 1, MAX_SIDE_MM);
  whole(input.boardWidthMm, "The board width in millimetres", 10, 1_000);
  whole(input.boardLengthMm, "The board length in millimetres", 100, 10_000);
  whole(input.gapMm, "The gap between boards in millimetres", 0, 50);
  if (input.boardsPerPack !== null) whole(input.boardsPerPack, "Boards per pack", 1, 10_000);
  if (input.joistSpacingMm !== null) whole(input.joistSpacingMm, "The joist spacing in millimetres", 100, 2_000);
  const bp = basisPoints(input.allowancePercent);

  const { deckWidthMm: width, deckLengthMm: length, boardWidthMm: board, boardLengthMm: boardLength, gapMm: gap } = input;
  // Fewest rows that cover the width, each row a board and a gap. The last row is cut to what is left
  // (always more than 0); when what is left is wider than a board, the last gap is a little wider.
  const rows = Math.ceil(width / (board + gap));
  const lastRowWidthMm = Math.min(board, width - (rows - 1) * (board + gap));

  const fullBoardsPerRow = Math.floor(length / boardLength);
  const endPieceMm = length - fullBoardsPerRow * boardLength;
  const piecesPerBoard = endPieceMm > 0 ? Math.floor(boardLength / endPieceMm) : 0;
  const endPieceBoards = endPieceMm > 0 ? Math.ceil(rows / piecesPerBoard) : 0;
  const boardsLaid = rows * fullBoardsPerRow + endPieceBoards;
  const boardsNeeded = withAllowance(boardsLaid, bp);
  const packs = input.boardsPerPack === null ? null : Math.ceil(boardsNeeded / input.boardsPerPack);
  const boardsBought = packs === null ? boardsNeeded : packs * input.boardsPerPack!;
  const joists = input.joistSpacingMm === null ? null : { count: Math.ceil(length / input.joistSpacingMm) + 1, lengthMm: width };
  return {
    rows,
    lastRowWidthMm,
    fullBoardsPerRow,
    endPieceMm,
    piecesPerBoard,
    endPieceBoards,
    boardsLaid,
    boardsNeeded,
    packs,
    boardsBought,
    leftoverMm: boardsBought * boardLength - rows * length,
    joists,
  };
}

// ---------- fencing ----------

export interface FenceInput {
  runLengthMm: number;   // outside of the first post to outside of the last
  panelWidthMm: number;
  postWidthMm: number;
}

export interface FencePlan {
  panels: number;
  posts: number;
  coveredMm: number;          // panels and posts as bought, before any cut
  lastPanelWidthMm: number;   // the last panel is cut down to this width (the full width when no cut)
  shortMm: number;            // > 0 only when the run is short of a whole bay by less than one post width
}

export function planFencing(input: FenceInput): FencePlan {
  whole(input.runLengthMm, "The run length in millimetres", 1, 1_000_000);
  whole(input.panelWidthMm, "The panel width in millimetres", 300, 5_000);
  whole(input.postWidthMm, "The post width in millimetres", 20, 300);
  const { runLengthMm: run, panelWidthMm: panel, postWidthMm: post } = input;
  if (run <= 2 * post) throw new MaterialsError("The run is shorter than two posts. Check the length.");
  // Fewest panels with a post at each end and between panels: panels × panel + (panels + 1) × post ≥ run.
  let panels = Math.max(1, Math.ceil((run - post) / (panel + post)));
  let lastPanelWidthMm = run - (panels - 1) * panel - (panels + 1) * post;
  let shortMm = 0;
  if (lastPanelWidthMm <= 0) {
    // One panel fewer leaves a gap smaller than one post: no panel fits it, so the posts are spaced out.
    panels -= 1;
    shortMm = run - panels * panel - (panels + 1) * post;
    lastPanelWidthMm = panel;
  }
  return { panels, posts: panels + 1, coveredMm: panels * panel + (panels + 1) * post, lastPanelWidthMm, shortMm };
}

// ---------- gravel and aggregates ----------

export interface GravelInput {
  areaWidthMm: number;
  areaLengthMm: number;
  depthMm: number;
  densityKgM3: number;
  bagG: number;
  allowancePercent: number;
}

export interface GravelPlan {
  areaMm2: number;
  volumeMm3: number;
  massG: number;          // before the allowance
  massNeededG: number;    // after the allowance
  bags: number;
  boughtG: number;
  leftoverG: number;
}

export const DENSITY_RANGE = [100, 5_000] as const; // kg per m³
export const BAG_G_RANGE = [100, 5_000_000] as const; // 100 g to 5 tonnes

export function planGravel(input: GravelInput): GravelPlan {
  whole(input.areaWidthMm, "The area width in millimetres", 1, MAX_SIDE_MM);
  whole(input.areaLengthMm, "The area length in millimetres", 1, MAX_SIDE_MM);
  whole(input.depthMm, "The depth in millimetres", 1, 1_000);
  whole(input.densityKgM3, "The density in kg per m³", DENSITY_RANGE[0], DENSITY_RANGE[1]);
  whole(input.bagG, "The bag size in grams", BAG_G_RANGE[0], BAG_G_RANGE[1]);
  const bp = basisPoints(input.allowancePercent);
  const areaMm2 = input.areaWidthMm * input.areaLengthMm;
  const volumeMm3 = areaMm2 * input.depthMm; // at most 1e13, exact
  // grams = mm³ × kg/m³ ÷ 1,000,000 (1 m³ = 1e9 mm³, 1 kg = 1,000 g). The product can pass 2^53, so BigInt.
  const product = BigInt(volumeMm3) * BigInt(input.densityKgM3);
  const massG = Number((product + 999_999n) / 1_000_000n);
  const massNeededG = withAllowance(massG, bp);
  const bags = Math.max(1, Math.ceil(massNeededG / input.bagG));
  const boughtG = bags * input.bagG;
  return { areaMm2, volumeMm3, massG, massNeededG, bags, boughtG, leftoverG: boughtG - massG };
}

// ---------- turf ----------

export interface TurfInput {
  lawnWidthMm: number;
  lawnLengthMm: number;
  rollAreaMm2: number;
  allowancePercent: number;
}

export interface TurfPlan {
  areaMm2: number;
  neededMm2: number;
  rolls: number;
  boughtMm2: number;
  leftoverMm2: number;
}

export function planTurf(input: TurfInput): TurfPlan {
  whole(input.lawnWidthMm, "The lawn width in millimetres", 1, MAX_SIDE_MM);
  whole(input.lawnLengthMm, "The lawn length in millimetres", 1, MAX_SIDE_MM);
  whole(input.rollAreaMm2, "The roll area in mm²", 10_000, 100_000_000);
  const bp = basisPoints(input.allowancePercent);
  const areaMm2 = input.lawnWidthMm * input.lawnLengthMm;
  const neededMm2 = withAllowance(areaMm2, bp);
  const rolls = Math.max(1, Math.ceil(neededMm2 / input.rollAreaMm2));
  const boughtMm2 = rolls * input.rollAreaMm2;
  return { areaMm2, neededMm2, rolls, boughtMm2, leftoverMm2: boughtMm2 - areaMm2 };
}
