import {
  resolveBeltLoad,
  type BeltId,
  type ResolvedBeltLoad,
} from "../../app/lib/belts.ts";
import type { BlueprintBlockSettings } from "../../app/lib/blueprint-contract.ts";
import type {
  CalculatorInput,
  CalculationResult,
  Locale,
  LogisticsFlow,
  LogisticsResult,
  LogisticsSettings,
  Machine,
  ModuleSelection,
  ProductionChainNode,
  ProductionChainResult,
  QualityId,
  TilePlacement,
} from "../../app/lib/types.ts";
import { calculateBeaconDesignWithGap } from "../../app/lib/beacons.ts";
import { planPowerGrid, substationWires } from "./power-grid.ts";
import { placeFlowLabels } from "./flow-labels.ts";

const FACTORIO_VERSION =
  2 * 2 ** 48 + 1 * 2 ** 32 + 17 * 2 ** 16;
const MAX_MACHINES_PER_BLOCK = 12;
const EPSILON = 1e-9;
type Direction = "north" | "east" | "south" | "west";

const DIRECTION_NUMBER: Record<Direction, number> = {
  north: 0,
  east: 4,
  south: 8,
  west: 12,
};


const UNDERGROUND_BELT: Record<BeltId, string> = {
  "transport-belt": "underground-belt",
  "fast-transport-belt": "fast-underground-belt",
  "express-transport-belt": "express-underground-belt",
  "turbo-transport-belt": "turbo-underground-belt",
};

const SPLITTER: Record<BeltId, string> = {
  "transport-belt": "splitter",
  "fast-transport-belt": "fast-splitter",
  "express-transport-belt": "express-splitter",
  "turbo-transport-belt": "turbo-splitter",
};

const UNDERGROUND_BELT_DISTANCE: Record<BeltId, number> = {
  "transport-belt": 5,
  "fast-transport-belt": 7,
  "express-transport-belt": 9,
  "turbo-transport-belt": 11,
};

export interface BlueprintNodeView {
  key: string;
  depth: number;
  input: CalculatorInput;
  result: CalculationResult;
  logistics: LogisticsResult;
  logisticsSettings: LogisticsSettings;
}

export interface BlueprintWarning {
  code:
    | "belt-upgrade"
    | "belt-parallel"
    | "inserter-input-space"
    | "inserter-output-space"
    | "mixed-input-belt"
    | "mixed-output-belt"
    | "fluid-port-map"
    | "module-quality"
    | "route-obstacle"
    | "preview-block"
    | "beacon-coverage"
    | "substation-coverage"
    | "roboport-coverage"
    | "chain-connections";
  message: string;
}

export interface BlueprintEntity {
  entity_number: number;
  name: string;
  position: { x: number; y: number };
  direction?: number;
  quality?: QualityId;
  recipe?: string;
  mirror?: boolean;
  recipe_quality?: QualityId;
  items?: Record<string, number>;
  type?: "input" | "output";
}

interface InternalEntity extends BlueprintEntity {
  left: number;
  top: number;
  width: number;
  height: number;
}

export interface BlueprintFluidRoute {
  id: string;
  direction: "input" | "output";
  axis: "horizontal" | "vertical";
  /** Tile y for a horizontal manifold; tile x for a vertical manifold. */
  lane: number;
  ports: Array<{ x: number; y: number }>;
}

export interface BlueprintPlan {
  layout: {
    requestedPerMachine: number;
    machineCount: number;
    counts: number[];
    average: number;
    min: number;
    max: number;
    uniqueBeacons: number;
    exact: boolean;
    extrapolated: boolean;
    corridor: number;
    placements: TilePlacement[];
    bounds: { minX: number; maxX: number; minY: number; maxY: number };
  };
  blueprint: { blueprint: Record<string, unknown> };
  blueprintJson: string;
  warnings: BlueprintWarning[];
  machineCountInBlock: number;
  totalMachineCount: number;
  repeatCount: number;
  entityCount: number;
  grid: { x: number; y: number };
  requestedBeltOverloaded: boolean;
  beltLoads: Array<{
    id: string;
    direction: "input" | "output";
    rate: number;
    load: ResolvedBeltLoad;
  }>;
  fluidRoutes: BlueprintFluidRoute[];
  roboportCoverageComplete: boolean;
  beaconCoverageComplete: boolean;
  substationCoverageComplete: boolean;
  chainProcesses?: Array<{ productId: string; recipeId: string; machineId: string; machines: number; sources: number }>;
}

function warningText(
  locale: Locale,
  german: string,
  english: string,
): string {
  return locale === "de" ? german : english;
}

function visitChain(node: ProductionChainNode, output: BlueprintNodeView[]) {
  output.push({
    key: node.key,
    depth: node.depth,
    input: node.calculatorInput,
    result: node.result,
    logistics: node.logistics,
    logisticsSettings: node.logisticsSettings,
  });
  for (const child of node.inputs) {
    if (child.kind === "recipe" && !child.cycle) visitChain(child, output);
  }
}

export function blueprintNodes(
  input: CalculatorInput,
  result: CalculationResult,
  logistics: LogisticsResult,
  logisticsSettings: LogisticsSettings,
  chain?: ProductionChainResult,
): BlueprintNodeView[] {
  if (!chain)
    return [
      { key: "root", depth: 0, input, result, logistics, logisticsSettings },
    ];
  const nodes: BlueprintNodeView[] = [];
  visitChain(chain.root, nodes);
  return nodes.filter(
    (node, index) => nodes.findIndex((candidate) => candidate.key === node.key) === index,
  );
}

function moduleItemId(module: ModuleSelection): string {
  return `${module.family}-module${module.tier === 1 ? "" : `-${module.tier}`}`;
}

function moduleItems(
  modules: Array<ModuleSelection | null>,
): Record<string, number> | undefined {
  const items: Record<string, number> = {};
  for (const selectedModule of modules) {
    if (!selectedModule) continue;
    const id = moduleItemId(selectedModule);
    items[id] = (items[id] ?? 0) + 1;
  }
  return Object.keys(items).length ? items : undefined;
}

function overlaps(
  first: Pick<InternalEntity, "left" | "top" | "width" | "height">,
  second: Pick<InternalEntity, "left" | "top" | "width" | "height">,
): boolean {
  return (
    first.left < second.left + second.width - EPSILON &&
    first.left + first.width > second.left + EPSILON &&
    first.top < second.top + second.height - EPSILON &&
    first.top + first.height > second.top + EPSILON
  );
}

function placementBounds(placements: TilePlacement[]) {
  if (!placements.length) return { minX: 0, maxX: 1, minY: 0, maxY: 1 };
  return placements.reduce(
    (bounds, placement) => ({
      minX: Math.min(bounds.minX, placement.x),
      maxX: Math.max(bounds.maxX, placement.x + placement.width),
      minY: Math.min(bounds.minY, placement.y),
      maxY: Math.max(bounds.maxY, placement.y + placement.height),
    }),
    {
      minX: Infinity,
      maxX: -Infinity,
      minY: Infinity,
      maxY: -Infinity,
    },
  );
}

function qualityField(quality: QualityId): QualityId | undefined {
  return quality === "normal" ? undefined : quality;
}

function portOffsets(
  machine: Machine,
  direction: "input" | "output",
  count: number,
): number[] {
  if (machine.id === "oil-refinery") {
    return (direction === "input" ? [1, 3] : [0, 2, 4]).slice(0, count);
  }
  if (machine.id === "chemical-plant") return [0, 2].slice(0, count);
  if (count <= 1) return [Math.floor(machine.width / 2)];
  return Array.from({ length: count }, (_, index) =>
    Math.round((index * (machine.width - 1)) / (count - 1)),
  );
}

function fluidPortSide(
  machine: Machine,
  direction: "input" | "output",
): "north" | "south" {
  // Factorio's north-facing refinery is the deliberate exception: crude oil
  // and water enter from the south while all three products leave north.
  if (machine.id === "oil-refinery") {
    return direction === "input" ? "south" : "north";
  }
  return direction === "input" ? "north" : "south";
}

function machinePreview(
  result: CalculationResult,
  blockCount: number,
): { placements: TilePlacement[]; indices: number[] } {
  const machines = result.beaconLayout.placements
    .filter((placement) => placement.kind === "machine")
    .slice(0, blockCount)
    .map((placement) => ({
      ...placement,
      affects: placement.affects ? [...placement.affects] : undefined,
    }));
  const indices = machines.map(
    (placement, index) => placement.machineIndex ?? index,
  );
  const selected = new Set(indices);
  const beacons = result.beaconLayout.placements
    .filter(
      (placement) =>
        placement.kind === "beacon" &&
        (!placement.affects?.length ||
          placement.affects.some((index) => selected.has(index))),
    )
    .map((placement) => ({
      ...placement,
      affects: placement.affects ? [...placement.affects] : undefined,
    }));
  return { placements: [...machines, ...beacons], indices };
}

export function generateBlueprintPlan(
  node: BlueprintNodeView,
  settings: BlueprintBlockSettings,
  locale: Locale,
  machineLimit = MAX_MACHINES_PER_BLOCK,
): BlueprintPlan {
  const { input, result, logistics } = node;
  const snapToGrid = settings.snapToGrid !== false;
  const warnings: BlueprintWarning[] = [];
  const totalMachineCount = Math.max(1, result.machinesToBuild);
  const availablePreviewMachines = result.beaconLayout.placements.filter(
    (placement) => placement.kind === "machine",
  ).length;
  const machineCountInBlock = Math.max(
    1,
    Math.min(
      totalMachineCount,
      machineLimit,
      availablePreviewMachines || totalMachineCount,
    ),
  );
  const repeatCount = Math.max(
    1,
    Math.ceil(totalMachineCount / machineCountInBlock - EPSILON),
  );
  if (repeatCount > 1) {
    warnings.push({
      code: "preview-block",
      message: warningText(
        locale,
        `Der Blueprint enthält ${machineCountInBlock} Maschinen pro kachelbarem Block. Für ${totalMachineCount} Maschinen werden insgesamt ${repeatCount} Blöcke benötigt.`,
        `The blueprint contains ${machineCountInBlock} machines per tileable block. ${repeatCount} blocks are required for ${totalMachineCount} machines.`,
      ),
    });
  }

  const hasFluidFlows = logistics.flows.some((flow) => flow.type === "fluid");
  let blueprintBeaconLayout = result.beaconLayout;
  if (hasFluidFlows) {
    // Start with the tightly packed row from the reference. Increase the gap
    // only when the requested Beacon coverage requires it.
    for (const gap of [0, 2, 3, 4, 6, 8]) {
      const candidate = calculateBeaconDesignWithGap(input.machine, machineCountInBlock, result.beaconLayout.requestedPerMachine, gap);
      if (gap === 0 || candidate.min > blueprintBeaconLayout.min) blueprintBeaconLayout = candidate;
      if (candidate.min >= candidate.requestedPerMachine) { blueprintBeaconLayout = candidate; break; }
    }
  }
  const preview = machinePreview(
    { ...result, beaconLayout: blueprintBeaconLayout },
    machineCountInBlock,
  );
  if (!preview.placements.some((placement) => placement.kind === "machine")) {
    preview.placements.push({
      kind: "machine",
      x: 0,
      y: 0,
      width: input.machine.width,
      height: input.machine.height,
      machineIndex: 0,
    });
    preview.indices.push(0);
  }
  const baseBounds = placementBounds(preview.placements);
  const machinePlacements = preview.placements.filter(
    (placement) => placement.kind === "machine",
  );
  const entities: InternalEntity[] = [];
  const placements: TilePlacement[] = [];
  const occupied: InternalEntity[] = [];
  let entityNumber = 0;

  function addEntity({
    name,
    left,
    top,
    width = 1,
    height = 1,
    direction,
    quality,
    recipe,
    mirror,
    items,
    type,
    kind = "entity",
    tone,
    label,
    allowOverlap = false,
  }: {
    name: string;
    left: number;
    top: number;
    width?: number;
    height?: number;
    direction?: Direction;
    quality?: QualityId;
    recipe?: string;
    mirror?: boolean;
    items?: Record<string, number>;
    type?: "input" | "output";
    kind?: TilePlacement["kind"];
    tone?: TilePlacement["tone"];
    label?: string;
    allowOverlap?: boolean;
  }): InternalEntity | undefined {
    const geometry = { left, top, width, height };
    if (!allowOverlap && occupied.some((entity) => overlaps(entity, geometry))) {
      return undefined;
    }
    const entity: InternalEntity = {
      entity_number: ++entityNumber,
      name,
      position: { x: left + width / 2, y: top + height / 2 },
      ...(direction ? { direction: DIRECTION_NUMBER[direction] } : {}),
      ...(qualityField(quality ?? "normal")
        ? { quality: qualityField(quality ?? "normal") }
        : {}),
      ...(recipe ? { recipe, recipe_quality: input.inputQuality } : {}),
      ...(mirror ? { mirror } : {}),
      ...(items ? { items } : {}),
      ...(type ? { type } : {}),
      left,
      top,
      width,
      height,
    };
    entities.push(entity);
    occupied.push(entity);
    placements.push({
      kind,
      x: left,
      y: top,
      width,
      height,
      entityId: name,
      label,
      tone,
      direction,
      beltEnd: type,
      mirror,
      ...(kind === "machine"
        ? { machineIndex: placements.filter((entry) => entry.kind === "machine").length,
          productId: input.product.id, productType: input.product.type }
        : {}),
    });
    return entity;
  }

  function isOccupied(left: number, top: number, width = 1, height = 1) {
    return occupied.some((entity) =>
      overlaps(entity, { left, top, width, height }),
    );
  }

  let powerLayout: ReturnType<typeof planPowerGrid> | undefined;
  let roboportCoverageComplete = !settings.roboportCoverage;
  let substationCoverageComplete = !settings.substationPriority;

  const machineModuleItems = moduleItems(input.machineModules);
  const beaconModuleItems = moduleItems(input.beaconModules);
  if (
    [...input.machineModules, ...input.beaconModules].some(
      (module) => module && module.quality !== "normal",
    )
  ) {
    warnings.push({
      code: "module-quality",
      message: warningText(
        locale,
        "Modulanzahl und Modultyp werden exportiert. Die Qualitätsstufe angeforderter Module muss in dieser ersten Version nach dem Import kontrolliert werden.",
        "Module type and count are exported. Verify the requested module quality after importing this first version.",
      ),
    });
  }

  const expectedBeaconCount = preview.placements.filter(
    (placement) => placement.kind === "beacon",
  ).length;
  let placedBeaconCount = 0;
  for (const placement of preview.placements) {
    if (placement.kind === "machine") {
      addEntity({
        name: input.machine.id,
        left: placement.x,
        top: placement.y,
        width: placement.width,
        height: placement.height,
        // North keeps the exported entity orientation identical to the port
        // map used below. In particular, refineries have inputs south and
        // outputs north in this orientation.
        direction: "north",
        mirror: input.machine.id === "oil-refinery" && (placement.machineIndex ?? 0) % 2 === 1,
        quality: input.machineQuality,
        recipe:
          input.machine.id === "passive-spoilage" ? undefined : input.recipe.id,
        items: machineModuleItems,
        kind: "machine",
      });
    } else if (placement.kind === "beacon") {
      const beacon = addEntity({
        name: "beacon",
        left: placement.x,
        top: placement.y,
        width: placement.width,
        height: placement.height,
        quality: input.beaconQuality,
        items: beaconModuleItems,
        kind: "beacon",
      });
      if (beacon) placedBeaconCount += 1;
    }
  }
  const beaconCoverageComplete =
    placedBeaconCount === expectedBeaconCount &&
    blueprintBeaconLayout.counts
      .slice(0, machineCountInBlock)
      .every(
        (count) => count >= blueprintBeaconLayout.requestedPerMachine,
      );
  if (!beaconCoverageComplete) {
    warnings.push({
      code: "beacon-coverage",
      message: warningText(
        locale,
        "Die angeforderte Beacon-Abdeckung passt nicht vollständig kollisionsfrei in diesen Block. Bitte Anzahl oder Layout vor dem Import prüfen.",
        "The requested Beacon coverage does not fit this block without collisions. Verify the count or layout before importing.",
      ),
    });
  }

  const blockShare = machineCountInBlock / totalMachineCount;
  const itemFlows = logistics.flows.filter((flow) => flow.type === "item");
  const fluidFlows = logistics.flows.filter((flow) => flow.type === "fluid");
  const beltLoads = itemFlows.map((flow) => {
    const rate = flow.totalPerSecond * blockShare;
    return {
      id: flow.id,
      direction: flow.direction,
      rate,
      load: resolveBeltLoad(
        rate,
        settings.beltId,
        settings.beltStackSize,
        settings.autoUpgradeBelt,
      ),
    };
  });
  const requestedBeltOverloaded = beltLoads.some((entry) => entry.load.overloaded);
  for (const entry of beltLoads.filter((candidate) => candidate.load.overloaded)) {
    warnings.push({
      code: "belt-upgrade",
      message: warningText(
        locale,
        `${entry.id}: ${entry.rate.toFixed(2)} Items/s überlasten den gewählten ${entry.load.requestedBeltId}. Die Vorschau nutzt ${entry.load.effectiveBeltId}${entry.load.beltsRequired > 1 ? ` auf ${entry.load.beltsRequired} parallelen Bändern` : ""}.`,
        `${entry.id}: ${entry.rate.toFixed(2)} items/s overload the selected ${entry.load.requestedBeltId}. The preview uses ${entry.load.effectiveBeltId}${entry.load.beltsRequired > 1 ? ` on ${entry.load.beltsRequired} parallel belts` : ""}.`,
      ),
    });
  }
  if (beltLoads.some((entry) => entry.load.beltsRequired > 1)) {
    warnings.push({
      code: "belt-parallel",
      message: warningText(
        locale,
        "Mindestens ein Strom benötigt mehrere parallele Bänder. Diese bleiben getrennt, damit kein einzelnes Band überladen wird.",
        "At least one stream requires parallel belts. They remain separate so no single belt is overloaded.",
      ),
    });
  }

  const fluidInputs = fluidFlows.filter((flow) => flow.direction === "input");
  const fluidOutputs = fluidFlows.filter((flow) => flow.direction === "output");
  const fluidRoutes: BlueprintFluidRoute[] = [];
  const northLaneStart = baseBounds.minY - 2;
  const southLaneStart = baseBounds.maxY + 1;

  function addHorizontalPipeLane(flow: LogisticsFlow, y: number, taps: number[]) {
    const start = Math.floor(baseBounds.minX) - 2;
    const end = Math.ceil(baseBounds.maxX) + 1;
    const junctions = new Set([start, end, ...taps]);
    const pipe = (x: number, underground?: Direction) => addEntity({
      name: underground ? "pipe-to-ground" : "pipe",
      left: x, top: y, direction: underground, tone: "fluid", label: flow.id,
    });
    // Surface only at junctions. Horizontal tunnels cross the perpendicular
    // machine branches without joining them, like the supplied oil manifold.
    for (let x = start; x <= end;) {
      if (junctions.has(x)) {
        pipe(x);
        x++;
        continue;
      }
      const nextJunction = Math.min(...[...junctions].filter((tap) => tap > x));
      const last = Math.min(nextJunction - 1, x + 11);
      if (last - x >= 2 && !isOccupied(x, y) && !isOccupied(last, y)) {
        pipe(x, "west");
        pipe(last, "east");
        x = last + 1;
      } else {
        pipe(x);
        x++;
      }
    }
  }

  function connectFluid(
    flow: LogisticsFlow,
    flowIndex: number,
    direction: "input" | "output",
    laneY: number,
  ) {
    const recipeFluids = (direction === "input" ? input.recipe.ingredients : input.recipe.products).filter((part) => part.type === "fluid");
    const recipeIndex = recipeFluids.findIndex((part) => part.name === flow.id);
    const portIndex = recipeIndex >= 0 ? (recipeFluids[recipeIndex].fluidboxIndex ?? recipeIndex + 1) - 1 : flowIndex;
    const offsets = portOffsets(
      input.machine,
      direction,
      Math.max(portIndex + 1, direction === "input" ? fluidInputs.length : fluidOutputs.length),
    );
    const side = fluidPortSide(input.machine, direction);
    const ports: Array<{ x: number; y: number }> = [];
    for (const machine of machinePlacements) {
      const offset = offsets[portIndex] ?? offsets.at(-1) ?? 0;
      const mirrored = input.machine.id === "oil-refinery" && (machine.machineIndex ?? 0) % 2 === 1;
      const portX = machine.x + (mirrored ? machine.width - 1 - offset : offset);
      const nearY = side === "north" ? machine.y - 1 : machine.y + machine.height;
      const farY = side === "north" ? laneY + 1 : laneY - 1;
      const span = Math.abs(farY - nearY);
      if (span > 11) {
        warnings.push({
          code: "route-obstacle",
          message: warningText(
            locale,
            `${flow.id}: Der Untergrundrohr-Abstand ist für eine direkte Verbindung zu groß; dieser Anschluss muss im Spiel geprüft werden.`,
            `${flow.id}: the underground-pipe distance is too large for one direct connection; verify this port in game.`,
          ),
        });
        continue;
      }
      if (span === 0) {
        const connector = addEntity({ name: "pipe", left: portX, top: nearY, tone: "fluid", label: flow.id });
        if (connector) ports.push({ x: portX, y: nearY });
        continue;
      }
      const machineConnector = addEntity({
        name: "pipe-to-ground",
        left: portX,
        top: nearY,
        direction: side === "north" ? "south" : "north",
        tone: "fluid",
        label: flow.id,
      });
      const laneConnector = addEntity({
        name: "pipe-to-ground",
        left: portX,
        top: farY,
        direction: side === "north" ? "north" : "south",
        tone: "fluid",
        label: flow.id,
      });
      if (machineConnector && laneConnector) {
        ports.push({ x: portX, y: nearY });
      } else {
        warnings.push({
          code: "route-obstacle",
          message: warningText(
            locale,
            `${flow.id}: Ein direkter Maschinenanschluss kollidiert mit einer anderen Entität; diesen Anschluss im Spiel prüfen.`,
            `${flow.id}: a direct machine connection collides with another entity; verify this port in game.`,
          ),
        });
      }
    }
    addHorizontalPipeLane(flow, laneY, ports.map((port) => port.x));
    placements.push({
      kind: "link",
      x: Math.floor(baseBounds.minX) - 5,
      y: laneY,
      width: 3,
      height: 1,
      entityId: flow.id,
      productId: flow.id,
      productType: "fluid",
      flowDirection: direction,
      tone: "fluid",
    });
    fluidRoutes.push({ id: flow.id, direction, axis: "horizontal", lane: laneY, ports });
  }

  let northLaneIndex = 0;
  let southLaneIndex = 0;
  for (const [direction, flows] of [
    ["input", fluidInputs],
    ["output", fluidOutputs],
  ] as const) {
    const side = fluidPortSide(input.machine, direction);
    flows.forEach((flow, index) => {
      const laneY =
        side === "north"
          ? northLaneStart - northLaneIndex++ * 2
          : southLaneStart + southLaneIndex++ * 2;
      connectFluid(flow, index, direction, laneY);
    });
  }

  if (
    input.recipe.id === "advanced-oil-processing" &&
    (fluidInputs.length !== 2 || fluidOutputs.length !== 3)
  ) {
    warnings.push({
      code: "fluid-port-map",
      message: warningText(
        locale,
        "Advanced Oil Processing wurde nicht als 2-Eingang/3-Ausgang erkannt; Blueprint nicht ungeprüft importieren.",
        "Advanced oil processing was not recognized as 2-input/3-output; do not import the blueprint without checking it.",
      ),
    });
  } else if (
    fluidFlows.length > 0 &&
    !["oil-refinery", "chemical-plant"].includes(input.machine.id)
  ) {
    warnings.push({
      code: "fluid-port-map",
      message: warningText(
        locale,
        "Die getrennten Fluidleitungen sind kollisionsfrei geplant; der konkrete Anschlussplan dieser Spezialmaschine ist in Version 1 noch als Import-Prüfpunkt markiert.",
        "The separate fluid lines are collision-free; this specialised machine's exact port map remains an import-check item in version 1.",
      ),
    });
  }

  const beltStart = Math.min(...machinePlacements.map((p) => p.x));
  const beltEnd = Math.max(...machinePlacements.map((p) => p.x + p.width)) - 1;

  function addBeltLine(
    y: number,
    beltId: BeltId,
    direction: "east" | "west",
    splitterX?: number,
  ) {
    const start = beltStart;
    const end = beltEnd;
    let x = start;
    while (x <= end) {
      if (x === splitterX) { x++; continue; }
      if (!isOccupied(x, y)) {
        const obstacleBegins = x < end && isOccupied(x + 1, y);
        if (obstacleBegins) {
          let after = x + 1;
          while (after <= end && isOccupied(after, y)) after += 1;
          if (
            after <= end &&
            after - x <= UNDERGROUND_BELT_DISTANCE[beltId]
          ) {
            addEntity({
              name: UNDERGROUND_BELT[beltId],
              left: x,
              top: y,
              direction,
              type: direction === "east" ? "input" : "output",
            });
            addEntity({
              name: UNDERGROUND_BELT[beltId],
              left: after,
              top: y,
              direction,
              type: direction === "east" ? "output" : "input",
            });
            x = after + 1;
            continue;
          }
        }
        addEntity({
          name: beltId,
          left: x,
          top: y,
          direction,
        });
      }
      x += 1;
    }
  }

  const inputItemFlows = itemFlows.filter((flow) => flow.direction === "input");
  const outputItemFlows = itemFlows.filter((flow) => flow.direction === "output");
  const inputLoads = beltLoads.filter((entry) => entry.direction === "input");
  const outputLoads = beltLoads.filter((entry) => entry.direction === "output");
  const inputBelt = inputLoads.reduce(
    (selected, entry) =>
      entry.load.capacityPerSecond > selected.load.capacityPerSecond
        ? entry
        : selected,
    inputLoads[0],
  );
  const outputBelt = outputLoads.reduce(
    (selected, entry) =>
      entry.load.capacityPerSecond > selected.load.capacityPerSecond
        ? entry
        : selected,
    outputLoads[0],
  );
  const inputBeltY = Math.min(...machinePlacements.map((entry) => entry.y)) - 2;
  const outputBeltY = Math.max(
    ...machinePlacements.map((entry) => entry.y + entry.height + 1),
  );
  for (const [flows, y] of [[inputItemFlows, inputBeltY], [outputItemFlows, outputBeltY]] as const) {
    flows.forEach((flow, index) => placements.push({
      kind: "link", entityId: flow.id, productId: flow.id, productType: "item",
      flowDirection: flow.direction, x: beltStart - 3 - index * 3,
      y, width: 3, height: 1,
    }));
  }
  if (inputBelt) {
    const lanes = Math.max(1, ...inputLoads.map((entry) => entry.load.beltsRequired));
    const splitLanes = new Set<number>();
    for (let lane = 0; lane + 1 < lanes; lane += 2) {
      if (addEntity({ name: SPLITTER[inputBelt.load.effectiveBeltId], left: beltStart,
        top: inputBeltY - lane - 1, width: 1, height: 2, direction: "east" })) {
        splitLanes.add(lane); splitLanes.add(lane + 1);
      }
    }
    for (let lane = 0; lane < lanes; lane += 1) {
      addBeltLine(inputBeltY - lane, inputBelt.load.effectiveBeltId, "east", splitLanes.has(lane) ? beltStart : undefined);
    }
  }
  if (outputBelt) {
    const lanes = Math.max(1, ...outputLoads.map((entry) => entry.load.beltsRequired));
    const splitLanes = new Set<number>();
    for (let lane = 0; lane + 1 < lanes; lane += 2) {
      if (addEntity({ name: SPLITTER[outputBelt.load.effectiveBeltId], left: beltEnd,
        top: outputBeltY + lane, width: 1, height: 2, direction: "west" })) {
        splitLanes.add(lane); splitLanes.add(lane + 1);
      }
    }
    for (let lane = 0; lane < lanes; lane += 1) {
      addBeltLine(outputBeltY + lane, outputBelt.load.effectiveBeltId, "west", splitLanes.has(lane) ? beltEnd : undefined);
    }
  }

  if (inputItemFlows.length > 2) {
    warnings.push({
      code: "mixed-input-belt",
      message: warningText(
        locale,
        `${inputItemFlows.length} feste Eingangsarten teilen sich in diesem ersten Blockentwurf einen Bandkorridor. Maximal zwei Arten sind ohne zusätzliche Filter-/Direkteinspeisung sauber lane-getrennt.`,
        `${inputItemFlows.length} solid input types share one belt corridor in this first block design. At most two types are cleanly lane-separated without extra filtering or direct insertion.`,
      ),
    });
  }
  if (outputItemFlows.length > 2) {
    warnings.push({
      code: "mixed-output-belt",
      message: warningText(
        locale,
        `${outputItemFlows.length} feste Outputarten benötigen zusätzliche Sortierung; die Hauptausgabe bleibt mittig, Nebenprodukte müssen nach dem Import geprüft werden.`,
        `${outputItemFlows.length} solid output types need additional sorting; the main output remains centred and by-products must be checked after import.`,
      ),
    });
  }

  function perimeterSpots(
    machine: TilePlacement,
    side: "input" | "output",
  ): Array<{ x: number; y: number; direction: Direction }> {
    const y = side === "input" ? machine.y - 1 : machine.y + machine.height;
    const beltY = side === "input" ? y - 1 : y + 1;
    // A north-facing vanilla inserter picks up north and drops south.
    // Both rows therefore face north: input belt -> machine -> output belt.
    // Never invent side inserters without a real belt at their pickup/drop.
    return Array.from({ length: machine.width }, (_, offset) => ({
      x: machine.x + offset, y, direction: "north" as const,
    })).filter((spot) => entities.some((entity) =>
      (entity.name.endsWith("transport-belt") || entity.name.endsWith("underground-belt")) &&
      entity.left === spot.x && entity.top === beltY));
  }

  function requestedInserters(flows: LogisticsFlow[]) {
    return flows.reduce(
      (total, flow) => total + (flow.insertersPerMachine ?? 0),
      0,
    );
  }

  const requiredInputInserters = requestedInserters(inputItemFlows);
  const requiredOutputInserters = requestedInserters(outputItemFlows);
  let minimumInputSpots = Infinity;
  let minimumOutputSpots = Infinity;
  for (const machine of machinePlacements) {
    const inputSpots = perimeterSpots(machine, "input").filter(
      (spot) => !isOccupied(spot.x, spot.y),
    );
    minimumInputSpots = Math.min(minimumInputSpots, inputSpots.length);
    let insertedInputs = 0;
    for (const flow of inputItemFlows) {
      const count = flow.insertersPerMachine ?? 0;
      for (let index = 0; index < count; index += 1) {
        const spot = inputSpots.shift();
        if (!spot) break;
        if (
          addEntity({
            name: node.logisticsSettings.inserterId,
            left: spot.x,
            top: spot.y,
            direction: spot.direction,
            quality: node.logisticsSettings.inserterQuality,
          })
        ) {
          insertedInputs += 1;
        }
      }
    }

    const outputSpots = perimeterSpots(machine, "output").filter(
      (spot) => !isOccupied(spot.x, spot.y),
    );
    minimumOutputSpots = Math.min(minimumOutputSpots, outputSpots.length);
    let insertedOutputs = 0;
    for (const flow of outputItemFlows) {
      const count = flow.insertersPerMachine ?? 0;
      for (let index = 0; index < count; index += 1) {
        const spot = outputSpots.shift();
        if (!spot) break;
        if (
          addEntity({
            name: node.logisticsSettings.inserterId,
            left: spot.x,
            top: spot.y,
            direction: spot.direction,
            quality: node.logisticsSettings.inserterQuality,
          })
        ) {
          insertedOutputs += 1;
        }
      }
    }
    if (insertedInputs < requiredInputInserters) minimumInputSpots = insertedInputs;
    if (insertedOutputs < requiredOutputInserters) minimumOutputSpots = insertedOutputs;
  }

  if (requiredInputInserters > minimumInputSpots) {
    warnings.push({
      code: "inserter-input-space",
      message: warningText(
        locale,
        `Eingangspriorität: ${requiredInputInserters} Greifarme je Maschine wären nötig, aber nur ${minimumInputSpots} freie Anschlussfelder bleiben. Schnelleren Greifarm wählen oder Durchsatz senken.`,
        `Input priority: ${requiredInputInserters} inserters per machine are required, but only ${minimumInputSpots} free connection tiles remain. Choose a faster inserter or reduce throughput.`,
      ),
    });
  }
  if (requiredOutputInserters > minimumOutputSpots) {
    warnings.push({
      code: "inserter-output-space",
      message: warningText(
        locale,
        `Output-Grenze: ${requiredOutputInserters} Greifarme je Maschine wären nötig, aber nach Fluid- und Eingangsanschlüssen bleiben nur ${minimumOutputSpots} freie Felder. Schnelleren Greifarm wählen oder Durchsatz senken.`,
        `Output limit: ${requiredOutputInserters} inserters per machine are required, but only ${minimumOutputSpots} free tiles remain after fluid and input connections. Choose a faster inserter or reduce throughput.`,
      ),
    });
  }

  if (settings.orientation === "vertical") {
    // Rotate the complete connected production geometry clockwise before
    // choosing its Substation frame. Alignment remains in preview/world axes.
    const clockwise: Record<Direction, Direction> = { north: "east", east: "south", south: "west", west: "north" };
    for (const entity of entities) {
      const { left, top, width, height, position } = entity;
      entity.left = -top - height;
      entity.top = left;
      entity.width = height;
      entity.height = width;
      entity.position = { x: -position.y, y: position.x };
      if (entity.direction !== undefined) entity.direction = (entity.direction + 4) % 16;
    }
    for (const placement of placements) {
      const { x, y, width, height } = placement;
      if (placement.kind === "link") {
        // Rotate the annotation's center, keeping the icon and In/Out upright.
        placement.x = -y - (height + width) / 2;
        placement.y = x + (width - height) / 2;
      } else {
        placement.x = -y - height;
        placement.y = x;
        placement.width = height;
        placement.height = width;
      }
      if (placement.direction) placement.direction = clockwise[placement.direction];
    }
    for (const route of fluidRoutes) {
      route.axis = "vertical";
      route.lane = -route.lane - 1;
      route.ports = route.ports.map(({ x, y }) => ({ x: -y - 1, y: x }));
    }
  }

  if (settings.substationPriority) {
    powerLayout = planPowerGrid(placements, settings);
    // A dense long machine row may occupy every phase of a full-reach
    // lattice. Split the repeatable block instead of moving poles closer or
    // silently leaving holes in the power grid.
    if (!powerLayout.complete && machineCountInBlock > 1) {
      return generateBlueprintPlan(node, settings, locale, machineCountInBlock - 1);
    }
    if (!powerLayout.complete) throw new Error(warningText(locale,
      "Die gewählte Produktion passt nicht zwischen die festen Substation- und Roboport-Positionen. Bitte Beacon-Anzahl oder Substation-Qualität anpassen.",
      "The selected production does not fit between the fixed Substation and Roboport positions. Adjust the Beacon count or Substation quality."));
    substationCoverageComplete = powerLayout.complete;
    roboportCoverageComplete = powerLayout.roboportCoverageComplete;
    if (!roboportCoverageComplete) warnings.push({
      code: "roboport-coverage",
      message: warningText(locale,
        "Die Roboport-Reihe deckt nicht den gesamten Block ab. Zusätzliche Roboports sind nötig.",
        "The Roboport row does not cover the entire block. Additional Roboports are needed."),
    });
  }

  const rawBounds = placementBounds(placements.filter((p) => p.kind !== "link"));
  const shiftX = powerLayout ? powerLayout.translation.x : 1 - Math.floor(rawBounds.minX);
  const shiftY = powerLayout ? powerLayout.translation.y : 1 - Math.floor(rawBounds.minY);
  for (const placement of placements) {
    placement.x += shiftX;
    placement.y += shiftY;
  }
  for (const entity of entities) {
    entity.left += shiftX;
    entity.top += shiftY;
    entity.position.x += shiftX;
    entity.position.y += shiftY;
  }
  for (const route of fluidRoutes) {
    route.lane += route.axis === "vertical" ? shiftX : shiftY;
    for (const port of route.ports) {
      port.x += shiftX;
      port.y += shiftY;
    }
  }
  // The entire production block moves together. Substations and Roboports
  // already have their final grid positions and are never translated to fit it.
  for (const placement of powerLayout?.placements ?? []) {
    addEntity({
      name: placement.entityId!, left: placement.x, top: placement.y,
      width: placement.width, height: placement.height,
      quality: settings.substationQuality, tone: "power",
    });
  }
  const physicalBounds = placementBounds(placements.filter((p) => p.kind !== "link"));
  const grid = powerLayout?.grid ?? {
    x: Math.max(1, Math.ceil(physicalBounds.maxX - physicalBounds.minX + 2)),
    y: Math.max(1, Math.ceil(physicalBounds.maxY - physicalBounds.minY + 2)),
  };
  placeFlowLabels(placements);
  const bounds = placementBounds(placements);

  const publicEntities: BlueprintEntity[] = entities.map((entity) => ({
    entity_number: entity.entity_number,
    name: entity.name,
    position: entity.position,
    ...(entity.direction !== undefined ? { direction: entity.direction } : {}),
    ...(entity.quality ? { quality: entity.quality } : {}),
    ...(entity.recipe ? { recipe: entity.recipe, recipe_quality: entity.recipe_quality } : {}),
    ...(entity.mirror ? { mirror: true } : {}),
    ...(entity.items ? { items: entity.items } : {}),
    ...(entity.type ? { type: entity.type } : {}),
  }));
  const blueprint = {
    blueprint: {
      item: "blueprint",
      label: `FactorioCalculatorApp · ${input.product.name}`,
      description: `${machineCountInBlock} × ${input.machine.name}; ${repeatCount} blocks for ${totalMachineCount} machines. ${settings.orientation === "vertical" ? "Vertical" : "Horizontal"} belt and pipe lanes. Input routing has priority over output routing.`,
      icons: [
        {
          signal: { type: input.product.type, name: input.product.id },
          index: 1,
        },
      ],
      entities: publicEntities,
      ...(settings.substationPriority ? { wires: substationWires(publicEntities) } : {}),
      ...(snapToGrid
        ? {
            "snap-to-grid": grid,
            "position-relative-to-grid": { x: 0, y: 0 },
            "absolute-snapping": false,
          }
        : {}),
      version: FACTORIO_VERSION,
    },
  };
  const counts = preview.indices.map(
    (index) =>
      blueprintBeaconLayout.counts[index] ??
      Math.round(blueprintBeaconLayout.average),
  );
  const min = counts.length ? Math.min(...counts) : 0;
  const max = counts.length ? Math.max(...counts) : 0;
  const average = counts.length
    ? counts.reduce((sum, count) => sum + count, 0) / counts.length
    : 0;

  return {
    layout: {
      requestedPerMachine: blueprintBeaconLayout.requestedPerMachine,
      machineCount: totalMachineCount,
      counts,
      average,
      min,
      max,
      uniqueBeacons: placements.filter((placement) => placement.kind === "beacon").length,
      exact: repeatCount === 1 && blueprintBeaconLayout.exact,
      extrapolated: repeatCount > 1,
      corridor: blueprintBeaconLayout.corridor,
      placements,
      bounds,
    },
    blueprint,
    blueprintJson: JSON.stringify(blueprint),
    warnings,
    machineCountInBlock,
    totalMachineCount,
    repeatCount,
    entityCount: publicEntities.length,
    grid,
    requestedBeltOverloaded,
    beltLoads,
    fluidRoutes,
    roboportCoverageComplete,
    beaconCoverageComplete,
    substationCoverageComplete,
  };
}

export async function encodeBlueprintString(blueprintJson: string) {
  if (typeof CompressionStream === "undefined") throw new Error("Blueprint compression is unavailable. Please use a current browser.");
  const stream = new Blob([blueprintJson])
    .stream()
    .pipeThrough(new CompressionStream("deflate"));
  const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
  let binary = "";
  const chunkSize = 0x8000;
  for (let index = 0; index < bytes.length; index += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
  }
  return `0${btoa(binary)}`;
}
