export type WallpaperMatch = "straight" | "half-drop";

export type WallpaperDisplayUnit = "metric" | "imperial";

export type WallpaperMeasurementValue = number | "";

export interface WallpaperMeasurementDraft {
  raw: string;
  millimetres: WallpaperMeasurementValue;
}

export const WALLPAPER_LIMITS = {
  maxWalls: 4,
  maxWallWidthMm: 100_000,
  maxWallHeightMm: 20_000,
  maxRollWidthMm: 10_000,
  maxRollLengthMm: 1_000_000,
  maxTrimAllowanceMm: 5_000,
  maxPatternRepeatMm: 10_000,
  maxOpeningsPerWall: 24,
  maxDropsPerWall: 250,
  maxTotalDrops: 500,
} as const;

export type OpeningKind = "door" | "window" | "other";

export interface WallpaperOpening {
  kind?: OpeningKind;
  label?: string;
  /** Horizontal distance from the wall's left edge. */
  leftMm: number;
  /** Vertical distance from the ceiling. */
  topMm: number;
  widthMm: number;
  heightMm: number;
}

export interface WallpaperWall {
  label?: string;
  widthMm: number;
  heightMm: number;
  openings?: readonly WallpaperOpening[];
}

export interface WallpaperPlanInput {
  walls: readonly WallpaperWall[];
  rollWidthMm: number;
  rollLengthMm: number;
  /** Combined cutting allowance added to each drop, not an allowance per edge. */
  trimAllowanceMm: number;
  patternRepeatMm: number;
  match: WallpaperMatch;
}

export interface WallpaperDrop {
  index: number;
  startMm: number;
  endMm: number;
  widthMm: number;
  skippedForOpening: boolean;
}

export interface WallpaperWallPlan {
  label: string;
  widthMm: number;
  heightMm: number;
  grossDrops: number;
  openingDropsSaved: number;
  requiredDrops: number;
  drops: readonly WallpaperDrop[];
}

export interface WallpaperPlan {
  tallestWallMm: number;
  baseDropLengthMm: number;
  /** The stated pattern repeat used for the drop length (0 for a free match). */
  effectiveRepeatMm: number;
  adjustedDropLengthMm: number;
  dropsPerRoll: number;
  grossRequiredDrops: number;
  openingDropsSaved: number;
  requiredDrops: number;
  rollsWithoutOpeningReduction: number | null;
  rolls: number | null;
  wastePerRollMm: number | null;
  openingSummary: string;
  assumptions: readonly string[];
  wallPlans: readonly WallpaperWallPlan[];
}

function requireInteger(
  value: number,
  name: string,
  options: { allowZero?: boolean; maximum?: number } = {},
): void {
  const minimum = options.allowZero ? 0 : 1;

  if (!Number.isSafeInteger(value) || value < minimum) {
    throw new RangeError(`${name} must be a safe integer of at least ${minimum} mm.`);
  }
  if (options.maximum !== undefined && value > options.maximum) {
    throw new RangeError(
      `${name} must be no more than ${options.maximum.toLocaleString("en-GB")} mm.`,
    );
  }
}

/** Keeps the user's raw draft intact while exposing integer mm for live calculation. */
export function parseWallpaperMeasurement(
  raw: string,
  unit: WallpaperDisplayUnit,
): WallpaperMeasurementDraft {
  const parsed = Number(raw);
  if (raw.trim() === "" || !Number.isFinite(parsed)) {
    return { raw, millimetres: "" };
  }

  return {
    raw,
    millimetres: Math.round(parsed * (unit === "metric" ? 1 : 25.4)),
  };
}

/** Formats only at initial display, blur, or an explicit unit switch. */
export function formatWallpaperMeasurement(
  valueMm: WallpaperMeasurementValue,
  unit: WallpaperDisplayUnit,
): string {
  if (valueMm === "") return "";
  if (unit === "metric") return String(valueMm);
  return String(Math.round((valueMm / 25.4) * 100) / 100);
}

function roundUpToMultiple(value: number, multiple: number): number {
  if (multiple === 0) return value;

  const rounded = Math.ceil(value / multiple) * multiple;
  if (!Number.isSafeInteger(rounded)) {
    throw new RangeError("The calculated drop length is outside the supported range.");
  }

  return rounded;
}

function mergeIntervals(
  intervals: ReadonlyArray<readonly [number, number]>,
): Array<[number, number]> {
  if (intervals.length === 0) return [];

  const ordered = [...intervals].sort((left, right) => left[0] - right[0]);
  const merged: Array<[number, number]> = [];

  for (const [start, end] of ordered) {
    const previous = merged.at(-1);
    if (!previous || start > previous[1]) {
      merged.push([start, end]);
    } else if (end > previous[1]) {
      previous[1] = end;
    }
  }

  return merged;
}

function calculateWallPlan(
  wall: WallpaperWall,
  wallIndex: number,
  rollWidthMm: number,
  grossDrops: number,
): WallpaperWallPlan {
  const openings = wall.openings ?? [];
  const fullHeightIntervals: Array<[number, number]> = [];

  openings.forEach((opening) => {
    // Only a ceiling-to-floor opening can remove every piece of one drop.
    // Partial-height doors and windows still need wallpaper above or below.
    if (opening.topMm !== 0 || opening.heightMm < wall.heightMm) return;

    const start = Math.min(opening.leftMm, wall.widthMm);
    const end = Math.min(opening.leftMm + opening.widthMm, wall.widthMm);
    if (end > start) fullHeightIntervals.push([start, end]);
  });

  const coveredIntervals = mergeIntervals(fullHeightIntervals);
  const drops: WallpaperDrop[] = [];

  for (let index = 0; index < grossDrops; index += 1) {
    const startMm = index * rollWidthMm;
    const endMm = Math.min(startMm + rollWidthMm, wall.widthMm);
    const widthMm = endMm - startMm;
    const isFullWidth = widthMm === rollWidthMm;
    const isFullyCovered = coveredIntervals.some(
      ([start, end]) => start <= startMm && end >= endMm,
    );

    drops.push({
      index: index + 1,
      startMm,
      endMm,
      widthMm,
      skippedForOpening: isFullWidth && isFullyCovered,
    });
  }

  const openingDropsSaved = drops.reduce(
    (total, drop) => total + (drop.skippedForOpening ? 1 : 0),
    0,
  );

  return {
    label: wall.label?.trim() || `Wall ${wallIndex + 1}`,
    widthMm: wall.widthMm,
    heightMm: wall.heightMm,
    grossDrops,
    openingDropsSaved,
    requiredDrops: grossDrops - openingDropsSaved,
    drops,
  };
}

function rollsForDrops(requiredDrops: number, dropsPerRoll: number): number | null {
  if (requiredDrops === 0) return 0;
  if (dropsPerRoll === 0) return null;
  return Math.ceil(requiredDrops / dropsPerRoll);
}

/**
 * Produces a deliberately conservative wallpaper estimate using integer-mm
 * arithmetic. All walls use the tallest adjusted drop so a cut drop can be
 * used on any entered wall.
 */
export function calculateWallpaperPlan(input: WallpaperPlanInput): WallpaperPlan {
  if (input.walls.length < 1 || input.walls.length > WALLPAPER_LIMITS.maxWalls) {
    throw new RangeError("Enter between one and four walls.");
  }
  if (input.match !== "straight" && input.match !== "half-drop") {
    throw new RangeError("Match must be straight or half-drop.");
  }

  requireInteger(input.rollWidthMm, "Roll width", {
    maximum: WALLPAPER_LIMITS.maxRollWidthMm,
  });
  requireInteger(input.rollLengthMm, "Roll length", {
    maximum: WALLPAPER_LIMITS.maxRollLengthMm,
  });
  requireInteger(input.trimAllowanceMm, "Trim allowance", {
    allowZero: true,
    maximum: WALLPAPER_LIMITS.maxTrimAllowanceMm,
  });
  requireInteger(input.patternRepeatMm, "Pattern repeat", {
    allowZero: true,
    maximum: WALLPAPER_LIMITS.maxPatternRepeatMm,
  });

  // Preflight every bound and the complete drop count before allocating any
  // drop arrays. The same cap also bounds the number of SVG groups rendered.
  const grossDropsByWall: number[] = [];
  let preflightTotalDrops = 0;

  input.walls.forEach((wall, wallIndex) => {
    const wallName = `Wall ${wallIndex + 1}`;
    requireInteger(wall.widthMm, `${wallName} width`, {
      maximum: WALLPAPER_LIMITS.maxWallWidthMm,
    });
    requireInteger(wall.heightMm, `${wallName} height`, {
      maximum: WALLPAPER_LIMITS.maxWallHeightMm,
    });

    const openings = wall.openings ?? [];
    if (openings.length > WALLPAPER_LIMITS.maxOpeningsPerWall) {
      throw new RangeError(
        `${wallName} has ${openings.length} openings; the safe limit is ${WALLPAPER_LIMITS.maxOpeningsPerWall}. Split the wall into separate plans.`,
      );
    }

    openings.forEach((opening, openingIndex) => {
      const prefix = `${wallName}, opening ${openingIndex + 1}`;
      requireInteger(opening.leftMm, `${prefix} left position`, {
        allowZero: true,
        maximum: WALLPAPER_LIMITS.maxWallWidthMm,
      });
      requireInteger(opening.topMm, `${prefix} top position`, {
        allowZero: true,
        maximum: WALLPAPER_LIMITS.maxWallHeightMm,
      });
      requireInteger(opening.widthMm, `${prefix} width`, {
        maximum: WALLPAPER_LIMITS.maxWallWidthMm,
      });
      requireInteger(opening.heightMm, `${prefix} height`, {
        maximum: WALLPAPER_LIMITS.maxWallHeightMm,
      });
    });

    const grossDrops = Math.ceil(wall.widthMm / input.rollWidthMm);
    if (grossDrops > WALLPAPER_LIMITS.maxDropsPerWall) {
      throw new RangeError(
        `${wallName} needs ${grossDrops.toLocaleString("en-GB")} drops; the safe per-wall limit is ${WALLPAPER_LIMITS.maxDropsPerWall}. Split the job or check the wall and roll widths.`,
      );
    }

    grossDropsByWall.push(grossDrops);
    preflightTotalDrops += grossDrops;
  });

  if (preflightTotalDrops > WALLPAPER_LIMITS.maxTotalDrops) {
    throw new RangeError(
      `This job needs ${preflightTotalDrops.toLocaleString("en-GB")} gross drops; the safe total is ${WALLPAPER_LIMITS.maxTotalDrops}. Split it into separate plans.`,
    );
  }

  const wallPlans = input.walls.map((wall, index) =>
    calculateWallPlan(wall, index, input.rollWidthMm, grossDropsByWall[index]),
  );
  const tallestWallMm = wallPlans.reduce(
    (maximum, wall) => Math.max(maximum, wall.heightMm),
    0,
  );
  const baseDropLengthMm = tallestWallMm + input.trimAllowanceMm;
  if (!Number.isSafeInteger(baseDropLengthMm)) {
    throw new RangeError("Wall height and trim allowance are too large.");
  }

  // Straight match: round each drop up to the next whole repeat, so every drop
  // starts at the same point in the pattern.
  // Half-drop match: add one full repeat to every drop. This is the rule the
  // manufacturers publish (Cole & Son, Harlequin, Graham & Brown). Neighbouring
  // drops start half a repeat apart, so exact cutting needs at most one extra
  // repeat per drop. Rounding up to twice the repeat (the earlier rule) could
  // fit one drop too many in a roll, so it sometimes gave too few rolls.
  // lib/wallpaper-exact.test.ts checks both rules against exact cutting.
  const effectiveRepeatMm = input.patternRepeatMm;
  const adjustedDropLengthMm =
    input.match === "half-drop"
      ? baseDropLengthMm + input.patternRepeatMm
      : roundUpToMultiple(baseDropLengthMm, input.patternRepeatMm);
  if (!Number.isSafeInteger(adjustedDropLengthMm)) {
    throw new RangeError("The calculated drop length is outside the supported range.");
  }
  const dropsPerRoll = Math.floor(input.rollLengthMm / adjustedDropLengthMm);
  const grossRequiredDrops = wallPlans.reduce(
    (total, wall) => total + wall.grossDrops,
    0,
  );
  const openingDropsSaved = wallPlans.reduce(
    (total, wall) => total + wall.openingDropsSaved,
    0,
  );
  const requiredDrops = grossRequiredDrops - openingDropsSaved;
  const rollsWithoutOpeningReduction = rollsForDrops(grossRequiredDrops, dropsPerRoll);
  const rolls = rollsForDrops(requiredDrops, dropsPerRoll);
  const openingCount = input.walls.reduce(
    (total, wall) => total + (wall.openings?.length ?? 0),
    0,
  );

  let openingSummary: string;
  if (openingCount === 0) {
    openingSummary = "No openings have been deducted from the estimate.";
  } else if (openingDropsSaved === 0) {
    openingSummary =
      "The openings do not remove a complete, aligned full-width drop, so they do not reduce the roll count.";
  } else if (requiredDrops === 0) {
    openingSummary = `${openingDropsSaved} complete full-width ${
      openingDropsSaved === 1 ? "drop was" : "drops were"
    } avoided, so no drops or rolls remain in this estimate.`;
  } else if (rolls === null || rollsWithoutOpeningReduction === null) {
    openingSummary = `${openingDropsSaved} complete full-width ${
      openingDropsSaved === 1 ? "drop was" : "drops were"
    } avoided, but the selected roll is too short for the adjusted drop.`;
  } else if (rolls < rollsWithoutOpeningReduction) {
    openingSummary = `${openingDropsSaved} complete full-width ${
      openingDropsSaved === 1 ? "drop was" : "drops were"
    } avoided, reducing the estimate from ${rollsWithoutOpeningReduction} to ${rolls} rolls.`;
  } else {
    openingSummary = `${openingDropsSaved} complete full-width ${
      openingDropsSaved === 1 ? "drop was" : "drops were"
    } avoided, but the remaining drops still require ${rolls} rolls.`;
  }

  const assumptions = [
    "Every wall is cut to the tallest entered wall height so drops remain interchangeable.",
    "Trim allowance is the combined extra length added once to every drop.",
    input.patternRepeatMm === 0
      ? "No pattern repeat has been allowed for."
      : input.match === "half-drop"
        ? "Half-drop match: each drop is the wall height plus the trim allowance plus one full pattern repeat, as wallpaper makers advise. Neighbouring drops start half a repeat apart, and the extra repeat covers that step."
        : "Straight matching rounds each drop up to the next complete stated repeat.",
    "The drop grid begins at each wall's left edge. Only a ceiling-to-floor opening covering a complete aligned roll-width column saves a drop.",
    "Partial-height doors and windows still need material above or below, so their area is not subtracted.",
    "No spare roll is added for damage, dye-lot matching, unusual set-out or installer preference.",
  ] as const;

  return {
    tallestWallMm,
    baseDropLengthMm,
    effectiveRepeatMm,
    adjustedDropLengthMm,
    dropsPerRoll,
    grossRequiredDrops,
    openingDropsSaved,
    requiredDrops,
    rollsWithoutOpeningReduction,
    rolls,
    wastePerRollMm:
      dropsPerRoll === 0
        ? null
        : input.rollLengthMm - dropsPerRoll * adjustedDropLengthMm,
    openingSummary,
    assumptions,
    wallPlans,
  };
}
