// Pack and roll counts for <layout-foundry-quantities>. Pure: runs in the quantities worker and in Node tests.
// It uses the site's existing planner engines (lib/tile.ts and lib/wallpaper.ts, re-exported by packages/engine)
// for tiles, flooring, paving and wallpaper, and ./materials.ts for paint, decking, fencing, gravel and turf.
// All maths is in whole millimetres (and whole millilitres and grams); text is formatted at the end for the
// shop's units, in the shopper's language (`text`: English by default; src/i18n/quantities/core-en.ts).

import { calculateTilePlan, TILE_PLAN_ASSUMPTIONS } from "../../../lib/tile";
import { calculateWallpaperPlan } from "../../../lib/wallpaper";
import type { QuantitiesConfig, QuantityProduct } from "./config";
import { planDecking, planFencing, planGravel, planPaint, planTurf, type TinCount } from "./materials";
import { ENGLISH_CORE, type CoreText } from "./quantities-text";

export interface SurfaceInputs {
  roomWidthMm: number;
  roomLengthMm: number;
  allowancePercent: number;
}

export interface WallInputs {
  wallHeightMm: number;
  wallWidthsMm: number[];
}

// Paint: walls as for wallpaper, with doors and windows taken off.
export interface PaintInputs extends WallInputs {
  doors: number;
  windows: number;
  coats: number;
}

export interface FenceInputs {
  runLengthMm: number;
  gravelBoards: boolean;
}

export interface GravelInputs extends SurfaceInputs {
  depthMm: number;
}

export type QuantityInputs = SurfaceInputs | WallInputs | PaintInputs | FenceInputs | GravelInputs;

export interface TileDiagram {
  kind: "tiles";
  widthMm: number;
  lengthMm: number;
  tiles: { x: number; y: number; w: number; l: number; cut: boolean }[];
  truncated: boolean;
}

export interface WallpaperDiagram {
  kind: "wallpaper";
  walls: { widthMm: number; heightMm: number; drops: { startMm: number; endMm: number }[] }[];
}

export interface FenceDiagram {
  kind: "fence";
  runMm: number;
  heightMm: number;                  // drawing height only; the text gives every size
  panels: { x: number; w: number; cut: boolean }[];
  posts: { x: number; w: number }[];
  truncated: boolean;
}

export type QuantityUnit = "pack" | "roll" | "tin" | "board" | "panel" | "bag";

export interface QuantityResult {
  product: QuantityProduct;
  method: "layout" | "coverage" | "wallpaper" | "paint" | "boards" | "panels" | "weight";
  unit: QuantityUnit;
  quantity: number;                  // whole packs, rolls, tins, boards, panels or bags
  areaMm2: number;                   // floor or wall area entered
  allowancePercent: number;
  piecesPerPack: number | null;
  piecesNeeded: number | null;       // pieces after the allowance, before rounding to packs
  piecesBought: number | null;       // quantity × pieces per pack
  coverageBoughtMm2: number | null;  // area the packs cover (coverage method)
  summary: string;                   // one sentence, the main answer
  details: string[];                 // text version of the result, including waste and rounding
  assumptions: string[];
  diagram: TileDiagram | WallpaperDiagram | FenceDiagram | null;
  tins?: TinCount[];                 // paint only: tins of each size, largest first
}

// Largest number of tiles drawn in the diagram. The text result always covers the whole area.
export const DIAGRAM_MAX_TILES = 1_500;
// Largest number of fence panels drawn.
export const DIAGRAM_MAX_PANELS = 200;

// Doors and windows taken off painted walls: a 30 × 78 in door (762 × 1,981 mm) and a 120 × 100 cm window
// in metric shops; a 30 × 80 in door and a 36 × 48 in window in imperial shops. Shown in the assumptions.
export const OPENINGS_MM: Record<"cm" | "in", { door: [number, number]; window: [number, number] }> = {
  cm: { door: [762, 1_981], window: [1_200, 1_000] },
  in: { door: [762, 2_032], window: [914, 1_219] },
};

type PieceProduct = "tiles" | "flooring" | "paving";

// The piece each product counts: tiles, planks or slabs.
const NOUNS = { tiles: "n.tile", flooring: "n.plank", paving: "n.slab" } as const;

function surface(config: QuantitiesConfig, inputs: SurfaceInputs, x: CoreText): QuantityResult {
  const product = config.product as PieceProduct;
  const pieces = (n: number) => x.t(NOUNS[product], { count: n });
  const packs = (n: number) => x.t("n.pack", { count: n });
  const u = config.units;
  const areaMm2 = inputs.roomWidthMm * inputs.roomLengthMm;
  const allowance = inputs.allowancePercent;
  if (!Number.isFinite(allowance) || allowance < 0 || allowance > 100) throw new RangeError(x.t("error.allowance"));
  const areaText = x.t(`area.${product}`, { width: x.room(inputs.roomWidthMm, u), length: x.room(inputs.roomLengthMm, u), area: x.area(areaMm2, u) });

  if (config.pieceSizeMm && config.piecesPerPack) {
    const [w, l] = config.pieceSizeMm;
    const plan = calculateTilePlan({
      room: { shape: "rectangle", widthMm: inputs.roomWidthMm, lengthMm: inputs.roomLengthMm },
      tileWidthMm: w,
      tileLengthMm: l,
      groutMm: config.jointMm,
      pattern: config.pattern,
      wastePercent: allowance,
      tilesPerPack: config.piecesPerPack,
    });
    const cut = plan.placements.filter((p) => p.isCut).length;
    const spare = plan.purchaseTileCount - plan.layoutTileCount;
    return {
      product,
      method: "layout",
      unit: "pack",
      quantity: plan.packsRequired,
      areaMm2,
      allowancePercent: allowance,
      piecesPerPack: config.piecesPerPack,
      piecesNeeded: plan.wasteAdjustedTileCount,
      piecesBought: plan.purchaseTileCount,
      coverageBoughtMm2: null,
      summary: x.t("surface.summaryLayout", { packs: packs(plan.packsRequired), pieces: pieces(plan.purchaseTileCount), area: areaText }),
      details: [
        x.t("surface.layout", {
          pieces: pieces(plan.layoutTileCount),
          width: x.length(w, u),
          length: x.length(l, u),
          joint: x.number(config.jointMm),
          pattern: x.t(config.pattern === "half-bond" ? "pattern.halfBond" : "pattern.straight"),
          cut: x.t(`cut.${product}`, { count: cut }),
        }),
        x.t("surface.allowance", { allowance: x.number(allowance), pieces: pieces(plan.wasteAdjustedTileCount) }),
        x.t("surface.rounding", {
          perPack: pieces(config.piecesPerPack),
          needed: pieces(plan.wasteAdjustedTileCount),
          packs: packs(plan.packsRequired),
          bought: pieces(plan.purchaseTileCount),
        }),
        x.t("surface.waste", { spare: pieces(spare) }),
      ],
      assumptions: TILE_PLAN_ASSUMPTIONS.map((a) => x.shared(a)),
      diagram: {
        kind: "tiles",
        widthMm: plan.roomWidthMm,
        lengthMm: plan.roomLengthMm,
        tiles: plan.placements.slice(0, DIAGRAM_MAX_TILES).map((p) => ({ x: p.xMm, y: p.yMm, w: p.widthMm, l: p.lengthMm, cut: p.isCut })),
        truncated: plan.visualTruncated || plan.placements.length > DIAGRAM_MAX_TILES,
      },
    };
  }

  const coverage = config.coverageMm2;
  if (!coverage) throw new RangeError(x.t("error.noCoverage"));
  // packs = ceil(area × (100 + allowance)% ÷ coverage), in whole basis points so no float rounding creeps in.
  const basisPoints = Math.round(allowance * 100);
  const neededMm2 = Math.ceil((areaMm2 * (10_000 + basisPoints)) / 10_000);
  const packCount = Math.max(1, Math.ceil(neededMm2 / coverage));
  const bought = packCount * coverage;
  return {
    product,
    method: "coverage",
    unit: "pack",
    quantity: packCount,
    areaMm2,
    allowancePercent: allowance,
    piecesPerPack: config.piecesPerPack,
    piecesNeeded: null,
    piecesBought: config.piecesPerPack ? packCount * config.piecesPerPack : null,
    coverageBoughtMm2: bought,
    summary: x.t("surface.summaryCoverage", { packs: packs(packCount), covered: x.area(bought, u), area: areaText }),
    details: [
      x.t("coverage.allowance", { allowance: x.number(allowance), from: x.area(areaMm2, u), to: x.area(neededMm2, u) }),
      x.t("coverage.rounding", { coverage: x.area(coverage, u), needed: x.area(neededMm2, u), packs: packs(packCount) }),
      x.t("coverage.waste", { waste: x.area(bought - areaMm2, u), areaName: x.t(`areaName.${product}`) }),
    ],
    assumptions: [x.t("coverage.a1"), x.t("coverage.a2"), x.t("coverage.a3")],
    diagram: null,
  };
}

function wallpaper(config: QuantitiesConfig, inputs: WallInputs, x: CoreText): QuantityResult {
  const u = config.units;
  if (!config.rollWidthMm || !config.rollLengthMm) throw new RangeError(x.t("error.noRollSize"));
  const plan = calculateWallpaperPlan({
    walls: inputs.wallWidthsMm.map((widthMm) => ({ widthMm, heightMm: inputs.wallHeightMm })),
    rollWidthMm: config.rollWidthMm,
    rollLengthMm: config.rollLengthMm,
    trimAllowanceMm: config.trimMm,
    patternRepeatMm: config.patternRepeatMm,
    match: config.match,
  });
  if (plan.rolls === null) {
    throw new RangeError(x.t("wallpaper.dropTooLong", { drop: x.length(plan.adjustedDropLengthMm, u), roll: x.room(config.rollLengthMm, u) }));
  }
  const rolls = (n: number) => x.t("n.roll", { count: n });
  const drops = (n: number) => x.t("n.drop", { count: n });
  const dropText = { drop: x.length(plan.adjustedDropLengthMm, u), trim: x.length(config.trimMm, u), drops: drops(plan.requiredDrops) };
  const totalWidth = inputs.wallWidthsMm.reduce((a, b) => a + b, 0);
  const areaMm2 = totalWidth * inputs.wallHeightMm;
  const wasteMm = (plan.wastePerRollMm ?? 0) * plan.rolls;
  return {
    product: "wallpaper",
    method: "wallpaper",
    unit: "roll",
    quantity: plan.rolls,
    areaMm2,
    allowancePercent: 0,
    piecesPerPack: null,
    piecesNeeded: plan.requiredDrops,
    piecesBought: null,
    coverageBoughtMm2: null,
    summary: x.t("wallpaper.summary", {
      rolls: rolls(plan.rolls),
      walls: x.t("n.wall", { count: inputs.wallWidthsMm.length }),
      width: x.room(totalWidth, u),
      height: x.room(inputs.wallHeightMm, u),
    }),
    details: [
      plan.effectiveRepeatMm
        ? config.match === "half-drop"
          ? x.t("wallpaper.dropsHalfDrop", { ...dropText, repeat: x.length(plan.effectiveRepeatMm, u) })
          : x.t("wallpaper.dropsStraight", dropText)
        : x.t("wallpaper.drops", dropText),
      x.t("wallpaper.rounding", {
        roll: x.room(config.rollLengthMm, u),
        perRoll: x.t("n.fullDrop", { count: plan.dropsPerRoll }),
        drops: drops(plan.requiredDrops),
        rolls: rolls(plan.rolls),
      }),
      x.t("wallpaper.waste", { perRoll: x.length(plan.wastePerRollMm ?? 0, u), total: x.room(wasteMm, u) }),
    ],
    assumptions: plan.assumptions.map((a) => x.shared(a)),
    diagram: {
      kind: "wallpaper",
      walls: plan.wallPlans.map((w) => ({ widthMm: w.widthMm, heightMm: w.heightMm, drops: w.drops.map((d) => ({ startMm: d.startMm, endMm: d.endMm })) })),
    },
  };
}

// ---------- paint ----------

function paint(config: QuantitiesConfig, inputs: PaintInputs, x: CoreText): QuantityResult {
  const u = config.units;
  if (!config.spreadRateMm2PerL || config.tinSizesMl.length === 0) throw new RangeError(x.t("error.noPaint"));
  const openings = OPENINGS_MM[u];
  const plan = planPaint({
    wallWidthsMm: inputs.wallWidthsMm,
    wallHeightMm: inputs.wallHeightMm,
    doors: inputs.doors,
    windows: inputs.windows,
    doorAreaMm2: openings.door[0] * openings.door[1],
    windowAreaMm2: openings.window[0] * openings.window[1],
    coats: inputs.coats,
    spreadRateMm2PerL: config.spreadRateMm2PerL,
    tinSizesMl: config.tinSizesMl,
  });
  const { tins } = plan;
  const vol = (ml: number) => x.volume(ml, u);
  const tinCount = (n: number) => x.t("n.tin", { count: n });
  const tinList = x.list(tins.tins.map((t) => x.t("paint.tins", { tins: tinCount(t.quantity), volume: vol(t.volumeMl) })));
  const coats = x.t("n.coat", { count: inputs.coats });
  const totalWidth = inputs.wallWidthsMm.reduce((a, b) => a + b, 0);
  const perUnit = x.t(u === "in" ? "paint.gallon" : "paint.litre");
  const spread = x.area(u === "in" ? Math.round(config.spreadRateMm2PerL * 3.785411784) : config.spreadRateMm2PerL, u);
  const openingParts = [
    ...(inputs.doors ? [x.t("n.door", { count: inputs.doors })] : []),
    ...(inputs.windows ? [x.t("n.window", { count: inputs.windows })] : []),
  ];
  const high = x.room(inputs.wallHeightMm, u);
  const walls =
    inputs.wallWidthsMm.length === 1
      ? x.t("paint.wallsOne", { width: x.room(totalWidth, u), height: high, area: x.area(plan.wallAreaMm2, u) })
      : x.t("paint.walls", { walls: x.t("n.wall", { count: inputs.wallWidthsMm.length }), width: x.room(totalWidth, u), height: high, area: x.area(plan.wallAreaMm2, u) });
  const sizes = x.list(config.tinSizesMl.map(vol));
  const paintArea = x.area(plan.paintAreaMm2, u);
  return {
    product: "paint",
    method: "paint",
    unit: "tin",
    quantity: tins.tinCount,
    areaMm2: plan.paintAreaMm2,
    allowancePercent: 0,
    piecesPerPack: null,
    piecesNeeded: null,
    piecesBought: null,
    coverageBoughtMm2: null,
    summary: x.t("paint.summary", { tins: tinList, total: vol(tins.totalMl), coats, area: paintArea }),
    details: [
      openingParts.length
        ? x.t("paint.openings", { count: inputs.doors + inputs.windows, walls, openings: x.list(openingParts), area: x.area(plan.openingsMm2, u), left: paintArea })
        : walls,
      x.t("paint.paint", { count: inputs.coats, coats, area: paintArea, spread, unit: perUnit, volume: vol(plan.neededMl) }),
      config.tinSizesMl.length === 1
        ? x.t("paint.roundingOne", { size: vol(config.tinSizesMl[0]!), needed: vol(plan.neededMl), tins: tinCount(tins.tinCount), total: vol(tins.totalMl) })
        : x.t("paint.roundingMix", { sizes, needed: vol(plan.neededMl), total: vol(tins.totalMl) }),
      x.t("paint.leftover", { volume: vol(tins.leftoverMl) }),
    ],
    assumptions: [
      x.t("paint.a1", { unit: perUnit }),
      x.t("paint.a2", {
        doorWidth: x.length(openings.door[0], u),
        doorHeight: x.length(openings.door[1], u),
        windowWidth: x.length(openings.window[0], u),
        windowHeight: x.length(openings.window[1], u),
      }),
      x.t("paint.a3"),
      ...(config.tinSizesMl.length > 1 ? [x.t("paint.a4", { volume: x.volume(10, "cm") })] : []),
      ...(tins.reduced ? [x.t("paint.a5")] : []),
    ],
    diagram: null,
    tins: tins.tins,
  };
}

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

function decking(config: QuantitiesConfig, inputs: SurfaceInputs, x: CoreText): QuantityResult {
  const u = config.units;
  if (!config.pieceSizeMm) throw new RangeError(x.t("error.noBoard"));
  const [boardWidth, boardLength] = config.pieceSizeMm;
  const gap = config.jointMm;
  const plan = planDecking({
    deckWidthMm: inputs.roomWidthMm,
    deckLengthMm: inputs.roomLengthMm,
    boardWidthMm: boardWidth,
    boardLengthMm: boardLength,
    gapMm: gap,
    allowancePercent: inputs.allowancePercent,
    boardsPerPack: config.piecesPerPack,
    joistSpacingMm: config.joistSpacingMm,
  });
  const width = x.room(inputs.roomWidthMm, u);
  const length = x.room(inputs.roomLengthMm, u);
  const areaMm2 = inputs.roomWidthMm * inputs.roomLengthMm;
  const boards = (n: number) => x.t("n.board", { count: n });
  const rows = (n: number) => x.t("n.row", { count: n });
  const piece = x.length(plan.endPieceMm, u);
  const board = x.room(boardLength, u);
  let rowsLine: string;
  if (plan.endPieceMm === 0) {
    rowsLine = x.t("deck.rowsFull", { length, full: x.t("n.fullBoard", { count: plan.fullBoardsPerRow }), board });
  } else if (plan.fullBoardsPerRow === 0) {
    rowsLine = x.t("deck.rowsCut", { length, board, perBoard: rows(plan.piecesPerBoard), rows: rows(plan.rows), boards: boards(plan.endPieceBoards) });
  } else {
    rowsLine = x.t("deck.rowsMixed", {
      length,
      full: x.t("n.fullBoard", { count: plan.fullBoardsPerRow }),
      board,
      piece,
      perBoard: x.t("n.piece", { count: plan.piecesPerBoard }),
      ends: x.t("n.endPiece", { count: plan.rows }),
      boards: boards(plan.endPieceBoards),
    });
  }
  const layout = x.t("deck.layout", { rows: rows(plan.rows), board: x.length(boardWidth, u), gap: x.number(gap), width });
  const details = [
    plan.lastRowWidthMm < boardWidth ? `${layout} ${x.t("deck.lastRow", { width: x.length(plan.lastRowWidthMm, u) })}` : layout,
    rowsLine,
    inputs.allowancePercent
      ? x.t("deck.allowance", { allowance: x.number(inputs.allowancePercent), laid: boards(plan.boardsLaid), needed: boards(plan.boardsNeeded) })
      : x.t("allowance.none"),
    ...(plan.packs !== null
      ? [x.t("deck.rounding", { perPack: boards(config.piecesPerPack!), needed: boards(plan.boardsNeeded), packs: x.t("n.pack", { count: plan.packs }), bought: boards(plan.boardsBought) })]
      : []),
    x.t(plan.packs !== null ? "deck.wastePacks" : "deck.waste", { leftover: x.room(plan.leftoverMm, u) }),
    ...(plan.joists
      ? [
          x.t("deck.joists", {
            spacing: x.length(config.joistSpacingMm!, u),
            length,
            joists: x.t("n.joist", { count: plan.joists.count }),
            width,
            total: x.room(plan.joists.count * plan.joists.lengthMm, u),
          }),
        ]
      : []),
  ];

  // Diagram: rows across the width, boards along the length, with the end piece on alternate sides.
  const tiles: TileDiagram["tiles"] = [];
  for (let row = 0; row < plan.rows && tiles.length < DIAGRAM_MAX_TILES; row += 1) {
    const x = row * (boardWidth + gap);
    const w = Math.min(boardWidth, inputs.roomWidthMm - x);
    const pieces = [...Array<number>(plan.fullBoardsPerRow).fill(boardLength), ...(plan.endPieceMm ? [plan.endPieceMm] : [])];
    if (row % 2 === 1) pieces.reverse();
    let y = 0;
    for (const l of pieces) {
      if (tiles.length >= DIAGRAM_MAX_TILES) break;
      tiles.push({ x, y, w, l, cut: w < boardWidth || l < boardLength });
      y += l;
    }
  }
  const drawn = plan.rows * (plan.fullBoardsPerRow + (plan.endPieceMm ? 1 : 0));

  return {
    product: "decking",
    method: "boards",
    unit: plan.packs === null ? "board" : "pack",
    quantity: plan.packs ?? plan.boardsBought,
    areaMm2,
    allowancePercent: inputs.allowancePercent,
    piecesPerPack: config.piecesPerPack,
    piecesNeeded: plan.boardsNeeded,
    piecesBought: plan.boardsBought,
    coverageBoughtMm2: null,
    summary:
      plan.packs === null
        ? x.t("deck.summary", { boards: boards(plan.boardsBought), width, length, area: x.area(areaMm2, u) })
        : x.t("deck.summaryPacks", { packs: x.t("n.pack", { count: plan.packs }), boards: boards(plan.boardsBought), width, length, area: x.area(areaMm2, u) }),
    details,
    assumptions: [
      x.t("deck.a1"),
      x.t("deck.a2", { width: x.length(boardWidth, u), length: board, gap: x.number(gap) }),
      x.t("deck.a3"),
      ...(plan.joists ? [x.t("deck.a4")] : []),
    ],
    diagram: { kind: "tiles", widthMm: inputs.roomWidthMm, lengthMm: inputs.roomLengthMm, tiles, truncated: drawn > tiles.length },
  };
}

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

function fencing(config: QuantitiesConfig, inputs: FenceInputs, tx: CoreText): QuantityResult {
  const u = config.units;
  if (!config.panelWidthMm) throw new RangeError(tx.t("error.noPanel"));
  const panel = config.panelWidthMm;
  const post = config.postWidthMm;
  const plan = planFencing({ runLengthMm: inputs.runLengthMm, panelWidthMm: panel, postWidthMm: post });
  const run = tx.room(inputs.runLengthMm, u);
  const panelWords = tx.t("n.panel", { count: plan.panels });
  const postWords = tx.t("n.post", { count: plan.posts });
  const parts = [panelWords, postWords, ...(inputs.gravelBoards ? [tx.t("n.gravelBoard", { count: plan.panels })] : [])];
  let cutLine: string;
  if (plan.shortMm > 0) {
    cutLine = tx.t("fence.spacing", { short: tx.length(plan.shortMm, u) });
  } else if (plan.lastPanelWidthMm < panel) {
    cutLine = tx.t("fence.cut", { panel: tx.length(panel, u), last: tx.length(plan.lastPanelWidthMm, u) });
  } else {
    cutLine = tx.t("fence.noCut");
  }

  const drawnPanels = Math.min(plan.panels, DIAGRAM_MAX_PANELS);
  const panels: FenceDiagram["panels"] = [];
  const posts: FenceDiagram["posts"] = [];
  let x = 0;
  for (let i = 0; i < drawnPanels; i += 1) {
    posts.push({ x, w: post });
    x += post;
    const last = i === plan.panels - 1;
    const w = last ? plan.lastPanelWidthMm : panel;
    panels.push({ x, w, cut: last && plan.lastPanelWidthMm < panel });
    x += w;
  }
  if (drawnPanels === plan.panels) x += plan.shortMm; // the posts spaced out to close a gap under one post
  posts.push({ x, w: post });

  return {
    product: "fencing",
    method: "panels",
    unit: "panel",
    quantity: plan.panels,
    areaMm2: 0,
    allowancePercent: 0,
    piecesPerPack: null,
    piecesNeeded: plan.panels,
    piecesBought: plan.panels,
    coverageBoughtMm2: null,
    summary: tx.t("fence.summary", { parts: tx.list(parts), run }),
    details: [
      tx.t("fence.layout", { panels: panelWords, panel: tx.length(panel, u), posts: postWords, post: tx.length(post, u), covered: tx.room(plan.coveredMm, u) }),
      cutLine,
      ...(inputs.gravelBoards ? [tx.t("fence.gravelBoards", { count: plan.panels })] : []),
    ],
    assumptions: [tx.t("fence.a1"), tx.t("fence.a2", { panel: tx.length(panel, u), post: tx.length(post, u) }), tx.t("fence.a3")],
    diagram: { kind: "fence", runMm: x + post, heightMm: Math.min(1_800, panel), panels, posts, truncated: drawnPanels < plan.panels },
  };
}

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

function gravel(config: QuantitiesConfig, inputs: GravelInputs, x: CoreText): QuantityResult {
  const u = config.units;
  if (!config.densityKgM3 || !config.bagG) throw new RangeError(x.t("error.noDensity"));
  const plan = planGravel({
    areaWidthMm: inputs.roomWidthMm,
    areaLengthMm: inputs.roomLengthMm,
    depthMm: inputs.depthMm,
    densityKgM3: config.densityKgM3,
    bagG: config.bagG,
    allowancePercent: inputs.allowancePercent,
  });
  const mass = (g: number) => x.mass(g, u);
  const depth = x.length(inputs.depthMm, u);
  const density =
    u === "in"
      ? x.t("gravel.densityImperial", { value: x.number(Number((config.densityKgM3 * 0.0624279606).toFixed(1))) })
      : x.t("gravel.densityMetric", { value: x.number(Number((config.densityKgM3 / 1_000).toFixed(3))) });
  const bags = (n: number) => x.t("n.bag", { count: n });
  return {
    product: "gravel",
    method: "weight",
    unit: "bag",
    quantity: plan.bags,
    areaMm2: plan.areaMm2,
    allowancePercent: inputs.allowancePercent,
    piecesPerPack: null,
    piecesNeeded: null,
    piecesBought: null,
    coverageBoughtMm2: null,
    summary: x.t("gravel.summary", { bags: bags(plan.bags), mass: mass(plan.boughtG), width: x.room(inputs.roomWidthMm, u), length: x.room(inputs.roomLengthMm, u), depth }),
    details: [
      x.t("gravel.volume", { area: x.area(plan.areaMm2, u), depth, volume: x.cubic(plan.volumeMm3, u) }),
      x.t("gravel.weight", { density, mass: mass(plan.massG) }),
      inputs.allowancePercent ? x.t("gravel.allowance", { allowance: x.number(inputs.allowancePercent), mass: mass(plan.massNeededG) }) : x.t("allowance.none"),
      x.t("gravel.rounding", { bag: mass(config.bagG), needed: mass(plan.massNeededG), bags: bags(plan.bags), bought: mass(plan.boughtG) }),
      x.t("gravel.leftover", { mass: mass(plan.leftoverG) }),
    ],
    assumptions: [x.t("gravel.a1"), x.t("gravel.a2", { density }), x.t("gravel.a3")],
    diagram: null,
  };
}

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

function turf(config: QuantitiesConfig, inputs: SurfaceInputs, x: CoreText): QuantityResult {
  const u = config.units;
  const sized = config.rollWidthMm && config.rollLengthMm;
  const rollAreaMm2 = sized ? config.rollWidthMm! * config.rollLengthMm! : config.coverageMm2;
  if (!rollAreaMm2) throw new RangeError(x.t("error.noRollSize"));
  const plan = planTurf({ lawnWidthMm: inputs.roomWidthMm, lawnLengthMm: inputs.roomLengthMm, rollAreaMm2, allowancePercent: inputs.allowancePercent });
  const area = (mm2: number) => x.area(mm2, u);
  const rolls = x.t("n.roll", { count: plan.rolls });
  return {
    product: "turf",
    method: "coverage",
    unit: "roll",
    quantity: plan.rolls,
    areaMm2: plan.areaMm2,
    allowancePercent: inputs.allowancePercent,
    piecesPerPack: null,
    piecesNeeded: null,
    piecesBought: null,
    coverageBoughtMm2: plan.boughtMm2,
    summary: x.t("turf.summary", { rolls, bought: area(plan.boughtMm2), width: x.room(inputs.roomWidthMm, u), length: x.room(inputs.roomLengthMm, u), area: area(plan.areaMm2) }),
    details: [
      inputs.allowancePercent
        ? x.t("turf.allowance", { allowance: x.number(inputs.allowancePercent), from: area(plan.areaMm2), to: area(plan.neededMm2) })
        : x.t("allowance.none"),
      sized
        ? x.t("turf.roundingSized", { area: area(rollAreaMm2), width: x.length(config.rollWidthMm!, u), length: x.room(config.rollLengthMm!, u), needed: area(plan.neededMm2), rolls })
        : x.t("turf.rounding", { area: area(rollAreaMm2), needed: area(plan.neededMm2), rolls }),
      x.t("turf.waste", { waste: area(plan.leftoverMm2) }),
    ],
    assumptions: [x.t("turf.a1"), x.t("turf.a2"), x.t("turf.a3")],
    diagram: null,
  };
}

/**
 * The quantity to buy, with its words in the shopper's language (`text`; English by default). An error
 * from the planner engines (lib/tile.ts, lib/wallpaper.ts, src/materials.ts) is in English; the worker
 * passes it through `text.shared()`.
 */
export function computeQuantity(config: QuantitiesConfig, inputs: QuantityInputs, text: CoreText = ENGLISH_CORE): QuantityResult {
  const x = text;
  switch (config.product) {
    case "wallpaper":
      if (!("wallHeightMm" in inputs)) throw new RangeError(x.t("error.enterWalls"));
      return wallpaper(config, inputs, x);
    case "paint":
      if (!("wallHeightMm" in inputs) || !("coats" in inputs)) throw new RangeError(x.t("error.enterPaint"));
      return paint(config, inputs, x);
    case "fencing":
      if (!("runLengthMm" in inputs)) throw new RangeError(x.t("error.enterRun"));
      return fencing(config, inputs, x);
    case "gravel":
      if (!("depthMm" in inputs)) throw new RangeError(x.t("error.enterDepth"));
      return gravel(config, inputs, x);
    default:
      if (!("roomWidthMm" in inputs)) throw new RangeError(x.t("error.enterSize"));
      if (config.product === "decking") return decking(config, inputs, x);
      if (config.product === "turf") return turf(config, inputs, x);
      return surface(config, inputs, x);
  }
}
