// Cost engine: pure functions, no WordPress and no database. Money is always an integer number of
// cents; quantities and percentages are plain numbers. Everything is summed in floating point and
// rounded exactly once, at the end of a recipe, so a result never drifts by a cent per line.

export interface Material {
  id: string;
  name: string;
  unit: string;
  // What one purchase costs and how much it contains (e.g. 1 500 cents for 1 000 g).
  packPriceCents: number;
  packQuantity: number;
  // Extra quantity to buy to cover offcuts and spoilage, on top of the quantity used:
  // 10 means a recipe line of 100 g costs 110 g.
  wastePercent: number;
}

export interface RecipeLine {
  materialId: string;
  quantity: number;
}

export interface Recipe {
  productId: string;
  lines: RecipeLine[];
  labourMinutes: number;
  packagingCents: number;
  otherCents: number;
}

export interface CostSettings {
  hourlyRateCents: number;
}

export interface CostBreakdown {
  materialsCents: number;
  labourCents: number;
  packagingCents: number;
  otherCents: number;
  totalCents: number;
  // Lines pointing to a material that does not exist (deleted). They count for 0: the cost is
  // then incomplete and must not be written to the store without a warning.
  missingMaterialIds: string[];
}

export type MaterialIndex = ReadonlyMap<string, Material>;

export function indexMaterials(materials: Material[]): MaterialIndex {
  return new Map(materials.map((m) => [m.id, m]));
}

const positive = (n: number): number => (Number.isFinite(n) && n > 0 ? n : 0);

// Cost of one unit of the material, in cents, before waste. Not rounded (a gram of wax can cost
// 0.8 cents). A pack with no quantity gives 0 instead of Infinity.
export function unitCostCents(material: Material): number {
  const quantity = positive(material.packQuantity);
  return quantity === 0 ? 0 : positive(material.packPriceCents) / quantity;
}

// `coefficient` scales the quantity of materials and the labour for one variant (a large size
// uses 1.5x the wax and time). Packaging and other costs are fixed amounts per item.
export function computeCost(
  recipe: Recipe,
  materials: MaterialIndex,
  settings: CostSettings,
  coefficient = 1,
): CostBreakdown {
  const factor = positive(coefficient);
  let materialsExact = 0;
  const missing: string[] = [];

  for (const line of recipe.lines) {
    const material = materials.get(line.materialId);
    if (!material) {
      if (!missing.includes(line.materialId)) missing.push(line.materialId);
      continue;
    }
    const withWaste = positive(line.quantity) * factor * (1 + positive(material.wastePercent) / 100);
    materialsExact += withWaste * unitCostCents(material);
  }

  const labourExact = (positive(recipe.labourMinutes) * factor * positive(settings.hourlyRateCents)) / 60;
  const packagingCents = Math.round(positive(recipe.packagingCents));
  const otherCents = Math.round(positive(recipe.otherCents));
  const materialsCents = Math.round(materialsExact);
  const labourCents = Math.round(labourExact);
  // The total is rounded from the exact sum, not added up from the rounded parts, so the
  // parts shown to the merchant can differ from the total by one cent at most.
  const totalCents = Math.round(materialsExact + labourExact) + packagingCents + otherCents;

  return { materialsCents, labourCents, packagingCents, otherCents, totalCents, missingMaterialIds: missing };
}

// Margin as a share of the selling price, in percent. Null when there is no price to compare to.
export function marginPercent(priceCents: number, costCents: number): number | null {
  if (!Number.isFinite(priceCents) || priceCents <= 0) return null;
  return (100 * (priceCents - costCents)) / priceCents;
}

export type PriceRounding = "none" | "whole" | "end99";

export const MAX_TARGET_MARGIN = 95;

// Smallest price that keeps the target margin: cost / (1 - margin). The rounding never goes below
// that price, so a rounded price still reaches the target. "whole" rounds up to a whole unit
// (12.34 -> 13.00); "end99" rounds up to the next price ending in .99 (12.34 -> 12.99, and 12.00 -> 12.99
// because 11.99 would fall under the target).
export function suggestPrice(costCents: number, targetMarginPercent: number, rounding: PriceRounding = "none"): number {
  const cost = positive(costCents);
  const margin = Math.min(Math.max(Number.isFinite(targetMarginPercent) ? targetMarginPercent : 0, 0), MAX_TARGET_MARGIN);
  const exact = cost / (1 - margin / 100);
  if (rounding === "none") return Math.ceil(exact - 1e-9);
  if (rounding === "whole") return Math.ceil(exact / 100 - 1e-9) * 100;
  let candidate = Math.ceil(exact / 100 - 1e-9) * 100 - 1;
  if (candidate < exact - 1e-9) candidate += 100;
  return candidate;
}

export interface CostChange {
  productId: string;
  beforeCents: number;
  afterCents: number;
  deltaCents: number;
}

// Cascade preview: what happens to every recipe if one material changes (its pack price, pack size or
// waste). Only the recipes that use the material are listed, biggest change first. Nothing is written.
export function previewMaterialChange(
  recipes: Recipe[],
  materials: Material[],
  settings: CostSettings,
  materialId: string,
  patch: Partial<Pick<Material, "packPriceCents" | "packQuantity" | "wastePercent">>,
): CostChange[] {
  const before = indexMaterials(materials);
  const after = indexMaterials(materials.map((m) => (m.id === materialId ? { ...m, ...patch } : m)));
  const changes: CostChange[] = [];
  for (const recipe of recipes) {
    if (!recipe.lines.some((line) => line.materialId === materialId)) continue;
    const beforeCents = computeCost(recipe, before, settings).totalCents;
    const afterCents = computeCost(recipe, after, settings).totalCents;
    changes.push({ productId: recipe.productId, beforeCents, afterCents, deltaCents: afterCents - beforeCents });
  }
  return changes.sort((a, b) => Math.abs(b.deltaCents) - Math.abs(a.deltaCents) || a.productId.localeCompare(b.productId));
}

export function previewMaterialPriceChange(
  recipes: Recipe[],
  materials: Material[],
  settings: CostSettings,
  materialId: string,
  newPackPriceCents: number,
): CostChange[] {
  return previewMaterialChange(recipes, materials, settings, materialId, { packPriceCents: newPackPriceCents });
}
