export type TilePattern = "straight" | "half-bond";

export type TileRoom =
  | {
      shape: "rectangle";
      widthMm: number;
      lengthMm: number;
    }
  | {
      shape: "l-shape";
      widthMm: number;
      lengthMm: number;
      notchWidthMm: number;
      notchLengthMm: number;
    };

export interface TilePlanInput {
  room: TileRoom;
  tileWidthMm: number;
  tileLengthMm: number;
  groutMm: number;
  pattern: TilePattern;
  wastePercent: number;
  tilesPerPack: number;
  rotateTile?: boolean;
}

export interface TilePlacement {
  xMm: number;
  yMm: number;
  widthMm: number;
  lengthMm: number;
  visibleAreaMm2: number;
  isCut: boolean;
  row: number;
  column: number;
}

export interface TilePlanResult {
  netAreaMm2: number;
  geometricTileEstimate: number;
  layoutTileCount: number;
  wasteAdjustedTileCount: number;
  packsRequired: number;
  purchaseTileCount: number;
  effectiveTileWidthMm: number;
  effectiveTileLengthMm: number;
  horizontalPitchMm: number;
  verticalPitchMm: number;
  halfBondOffsetMm: number;
  roomWidthMm: number;
  roomLengthMm: number;
  roomPath: string;
  placements: TilePlacement[];
  visualTruncated: boolean;
  assumptions: readonly string[];
}

interface Rectangle {
  x: number;
  y: number;
  width: number;
  length: number;
}

const MAX_DIMENSION_MM = 100_000;
const MAX_GROUT_MM = 1_000;
const MAX_VISUAL_TILES = 5_000;

export const TILE_PLAN_ASSUMPTIONS = [
  "The layout starts square at the room's top-left corner.",
  "A half-bond row moves by half of one tile-and-grout module, rounded to the nearest whole millimetre.",
  "Each visible cut is treated as using a separate tile. Offcuts are not optimised, and the plan does not promise that an offcut can be reused.",
  "The grout value is used between tiles. Perimeter movement joints, thresholds and substrate conditions are not included.",
  "The extra allowance is added to the drawn layout count. The separate face-area reference excludes grout and does not override the layout.",
  "For an L-shaped room, the rectangular notch is shown at the bottom-right; rotating the sketch does not change its area.",
] as const;

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

function requireIntegerMillimetres(
  value: number,
  label: string,
  minimum = 1,
  maximum = MAX_DIMENSION_MM,
): void {
  if (!Number.isInteger(value) || value < minimum || value > maximum) {
    throw new TilePlanError(
      `${label} must be a whole number of millimetres between ${minimum.toLocaleString()} and ${maximum.toLocaleString()}.`,
    );
  }
}

function requireFiniteRange(
  value: number,
  label: string,
  minimum: number,
  maximum: number,
): void {
  if (!Number.isFinite(value) || value < minimum || value > maximum) {
    throw new TilePlanError(`${label} must be between ${minimum} and ${maximum}.`);
  }
}

function overlapLength(
  firstStart: number,
  firstLength: number,
  secondStart: number,
  secondLength: number,
): number {
  return Math.max(
    0,
    Math.min(firstStart + firstLength, secondStart + secondLength) -
      Math.max(firstStart, secondStart),
  );
}

function overlapArea(first: Rectangle, second: Rectangle): number {
  return (
    overlapLength(first.x, first.width, second.x, second.width) *
    overlapLength(first.y, first.length, second.y, second.length)
  );
}

function getNotch(room: TileRoom): Rectangle | null {
  if (room.shape === "rectangle") {
    return null;
  }

  return {
    x: room.widthMm - room.notchWidthMm,
    y: room.lengthMm - room.notchLengthMm,
    width: room.notchWidthMm,
    length: room.notchLengthMm,
  };
}

function getVisibleArea(tile: Rectangle, room: TileRoom): number {
  const outerRoom: Rectangle = {
    x: 0,
    y: 0,
    width: room.widthMm,
    length: room.lengthMm,
  };
  const notch = getNotch(room);
  const outerOverlap = overlapArea(tile, outerRoom);

  return notch === null ? outerOverlap : outerOverlap - overlapArea(tile, notch);
}

function getRoomPath(room: TileRoom): string {
  if (room.shape === "rectangle") {
    return `M 0 0 H ${room.widthMm} V ${room.lengthMm} H 0 Z`;
  }

  const innerX = room.widthMm - room.notchWidthMm;
  const innerY = room.lengthMm - room.notchLengthMm;

  return `M 0 0 H ${room.widthMm} V ${innerY} H ${innerX} V ${room.lengthMm} H 0 Z`;
}

function validateInput(input: TilePlanInput): void {
  requireIntegerMillimetres(input.room.widthMm, "Room width");
  requireIntegerMillimetres(input.room.lengthMm, "Room length");
  requireIntegerMillimetres(input.tileWidthMm, "Tile width");
  requireIntegerMillimetres(input.tileLengthMm, "Tile length");
  requireIntegerMillimetres(input.groutMm, "Grout gap", 0, MAX_GROUT_MM);

  if (input.room.shape === "l-shape") {
    requireIntegerMillimetres(input.room.notchWidthMm, "Notch width");
    requireIntegerMillimetres(input.room.notchLengthMm, "Notch length");

    if (input.room.notchWidthMm >= input.room.widthMm) {
      throw new TilePlanError("Notch width must be smaller than the full room width.");
    }

    if (input.room.notchLengthMm >= input.room.lengthMm) {
      throw new TilePlanError("Notch length must be smaller than the full room length.");
    }
  }

  if (input.pattern !== "straight" && input.pattern !== "half-bond") {
    throw new TilePlanError("Choose either a straight or half-bond pattern.");
  }

  requireFiniteRange(input.wastePercent, "Allowance percentage", 0, 100);

  if (!Number.isInteger(input.tilesPerPack) || input.tilesPerPack < 1 || input.tilesPerPack > 10_000) {
    throw new TilePlanError("Tiles per pack must be a whole number between 1 and 10,000.");
  }
}

function getRowTileRange(
  startX: number,
  pitchMm: number,
  tileWidthMm: number,
  roomWidthMm: number,
): { firstColumn: number; lastColumn: number; count: number } {
  // Coordinates describe half-open rectangles. With whole millimetres, the first
  // intersecting x coordinate is 1 - tileWidth and the last is roomWidth - 1.
  const firstColumn = Math.max(
    0,
    Math.ceil((1 - tileWidthMm - startX) / pitchMm),
  );
  const lastColumn = Math.floor((roomWidthMm - 1 - startX) / pitchMm);

  return {
    firstColumn,
    lastColumn,
    count: Math.max(0, lastColumn - firstColumn + 1),
  };
}

/**
 * Produces a deterministic, conservative tile plan using whole millimetres.
 * The returned placements are a drawing aid, not an installation drawing.
 */
export function calculateTilePlan(input: TilePlanInput): TilePlanResult {
  validateInput(input);

  const room = input.room;
  const effectiveTileWidthMm = input.rotateTile
    ? input.tileLengthMm
    : input.tileWidthMm;
  const effectiveTileLengthMm = input.rotateTile
    ? input.tileWidthMm
    : input.tileLengthMm;
  const horizontalPitchMm = effectiveTileWidthMm + input.groutMm;
  const verticalPitchMm = effectiveTileLengthMm + input.groutMm;
  const halfBondOffsetMm = Math.round(horizontalPitchMm / 2);
  const notchAreaMm2 =
    room.shape === "l-shape" ? room.notchWidthMm * room.notchLengthMm : 0;
  const netAreaMm2 = room.widthMm * room.lengthMm - notchAreaMm2;
  const tileAreaMm2 = input.tileWidthMm * input.tileLengthMm;
  const geometricTileEstimate = Math.ceil(netAreaMm2 / tileAreaMm2);
  const rowCount = Math.ceil(room.lengthMm / verticalPitchMm);
  const shoulderY =
    room.shape === "l-shape"
      ? room.lengthMm - room.notchLengthMm
      : room.lengthMm;

  let layoutTileCount = 0;
  const rows: Array<{
    row: number;
    yMm: number;
    startX: number;
    rowWidthMm: number;
    firstColumn: number;
    lastColumn: number;
  }> = [];

  for (let row = 0; row < rowCount; row += 1) {
    const yMm = row * verticalPitchMm;
    // A tile row that starts above the L's shoulder still touches the full-width
    // top bar, even if the tile continues into the narrower lower leg.
    const rowWidthMm =
      room.shape === "l-shape" && yMm >= shoulderY
        ? room.widthMm - room.notchWidthMm
        : room.widthMm;
    const rowOffsetMm =
      input.pattern === "half-bond" && row % 2 === 1
        ? halfBondOffsetMm
        : 0;
    const startX = -rowOffsetMm;
    const range = getRowTileRange(
      startX,
      horizontalPitchMm,
      effectiveTileWidthMm,
      rowWidthMm,
    );

    layoutTileCount += range.count;
    rows.push({
      row,
      yMm,
      startX,
      rowWidthMm,
      firstColumn: range.firstColumn,
      lastColumn: range.lastColumn,
    });
  }

  const placements: TilePlacement[] = [];

  for (const row of rows) {
    if (placements.length >= MAX_VISUAL_TILES) {
      break;
    }

    for (
      let column = row.firstColumn;
      column <= row.lastColumn && placements.length < MAX_VISUAL_TILES;
      column += 1
    ) {
      const tile: Rectangle = {
        x: row.startX + column * horizontalPitchMm,
        y: row.yMm,
        width: effectiveTileWidthMm,
        length: effectiveTileLengthMm,
      };
      const visibleAreaMm2 = getVisibleArea(tile, room);

      if (visibleAreaMm2 <= 0) {
        continue;
      }

      placements.push({
        xMm: tile.x,
        yMm: tile.y,
        widthMm: tile.width,
        lengthMm: tile.length,
        visibleAreaMm2,
        isCut: visibleAreaMm2 < tile.width * tile.length,
        row: row.row,
        column,
      });
    }
  }

  // The simulated positions already account for grout spacing and every tile
  // that touches the room. A face-area division cannot do that because grout
  // occupies some of the room area, so it remains an explanatory reference
  // rather than an ordering floor.
  const baselineTileCount = layoutTileCount;
  const wasteBasisPoints = Math.round(input.wastePercent * 100);
  const wasteAdjustedTileCount = Math.ceil(
    (baselineTileCount * (10_000 + wasteBasisPoints)) / 10_000,
  );
  const packsRequired = Math.ceil(
    wasteAdjustedTileCount / input.tilesPerPack,
  );

  return {
    netAreaMm2,
    geometricTileEstimate,
    layoutTileCount,
    wasteAdjustedTileCount,
    packsRequired,
    purchaseTileCount: packsRequired * input.tilesPerPack,
    effectiveTileWidthMm,
    effectiveTileLengthMm,
    horizontalPitchMm,
    verticalPitchMm,
    halfBondOffsetMm,
    roomWidthMm: room.widthMm,
    roomLengthMm: room.lengthMm,
    roomPath: getRoomPath(room),
    placements,
    visualTruncated: layoutTileCount > placements.length,
    assumptions: TILE_PLAN_ASSUMPTIONS,
  };
}
