import type {
  AsukaAssemblyAdjacencyRule,
  AsukaAssemblyAnchorDefinition,
  AsukaAssemblyBom,
  AsukaAssemblyBomLine,
  AsukaAssemblyBounds,
  AsukaAssemblyComponent,
  AsukaAssemblyComponentDefinition,
  AsukaAssemblyConnection,
  AsukaAssemblyExportKind,
  AsukaAssemblyExportOptions,
  AsukaAssemblyFloorMetrics,
  AsukaAssemblyFloorSurface,
  AsukaAssemblyFloorEdgeConstraint,
  AsukaAssemblyIssue,
  AsukaAssemblyMaterialPreset,
  AsukaAssemblyMaterialApplyOptions,
  AsukaAssemblyPlacementInput,
  AsukaAssemblyPlacementResult,
  AsukaAssemblyPlanDefinition,
  AsukaAssemblyPlanPoint,
  AsukaAssemblyQuote,
  AsukaAssemblyQuoteOptions,
  AsukaAssemblyRoomWallFootprint,
  AsukaAssemblySelector,
  AsukaAssemblySettings,
  AsukaAssemblySnapResult,
  AsukaAssemblyStackLink,
  AsukaAssemblyStackWidthAxis,
  AsukaAssemblyState,
  AsukaAssemblySupportMode,
  AsukaAssemblySurfaceMaterial,
  AsukaAssemblySurface,
  AsukaAssemblySurfaceTransformInput,
  AsukaAssemblySurfaceTransform,
  AsukaAssemblyTransform,
  AsukaAssemblyValidation,
  AsukaAssemblyVector3,
  AsukaAssemblyWallSurface,
  AsukaAssemblyWorldAnchor,
} from '../types/assembly3d';
import { normalizePriceInput } from './pricingValue';

const EPSILON = 0.000001;

export type AsukaDeepRequired<T> = {
  [Key in keyof T]-?: NonNullable<T[Key]> extends object
    ? AsukaDeepRequired<NonNullable<T[Key]>>
    : NonNullable<T[Key]>;
};

export type AsukaNormalizedAssemblySettings = AsukaDeepRequired<AsukaAssemblySettings>;

const clonePlain = <T,>(value: T): T => {
  if (value == null) return value;
  try { return JSON.parse(JSON.stringify(value)) as T; } catch { return value; }
};

const finite = (value: unknown, fallback = 0): number => {
  const number = Number(value);
  return Number.isFinite(number) ? number : fallback;
};

const positive = (value: unknown, fallback: number): number => {
  const number = finite(value, fallback);
  return number > 0 ? number : fallback;
};

const normalizeToken = (value: unknown): string => String(value ?? '').trim();

const normalizeTokens = (value: unknown): string[] => Array.from(new Set(
  (Array.isArray(value) ? value : [])
    .map(normalizeToken)
    .filter(Boolean),
));

const normalizeAssemblySupportMode = (value: unknown): AsukaAssemblySupportMode => (
  value === 'floor' || value === 'wall' || value === 'ceiling' ? value : 'free'
);

const normalizeAssemblyPlacementDefinition = (
  input: AsukaAssemblyComponentDefinition['placement'] | null | undefined,
): NonNullable<AsukaAssemblyComponentDefinition['placement']> => ({
  support: normalizeAssemblySupportMode(input?.support),
  supportOffset: finite(input?.supportOffset),
});

export const ASUKA_ASSEMBLY_DEFAULT_PLAN: AsukaAssemblyPlanDefinition = {
  points: [
    { x: -3, z: -2.5 },
    { x: 3, z: -2.5 },
    { x: 3, z: 2.5 },
    { x: -3, z: 2.5 },
  ],
  floorY: 0,
  height: 2.6,
  wallThickness: 0.08,
  closed: true,
  floorColor: '#f6f8fb',
  wallColor: '#e7ebf1',
  gridColor: '#cbd5e1',
  autoExpand: true,
  autoExpandPadding: 0.2,
};

export const ASUKA_ASSEMBLY_DEFAULT_SETTINGS: AsukaNormalizedAssemblySettings = {
  grid: {
    enabled: false,
    size: 0.1,
    subdivisions: 10,
    snapY: false,
  },
  measurement: {
    displayUnit: 'm',
    worldUnitInMeters: 1,
  },
  snap: {
    enabled: true,
    anchors: true,
    boundaries: true,
    grid: true,
    distance: 0.28,
    rotation: true,
    rotationStepDegrees: 10,
    autoConnect: true,
  },
  collision: {
    enabled: true,
    policy: 'prevent',
    margin: 0.005,
    includeClearances: true,
  },
  adjacency: {
    enabled: true,
    autoCorners: true,
    reflowOnResize: true,
    resizeOverflowPolicy: 'expand-plan',
  },
  visualization: {
    environment: false,
    enabled: false,
    anchors: false,
    collisions: false,
    clearances: false,
    connections: true,
    dimensions: true,
    selectedOnly: true,
  },
};

const normalizeVector = (
  value: Partial<AsukaAssemblyVector3> | null | undefined,
  fallback: AsukaAssemblyVector3 = { x: 0, y: 0, z: 0 },
): AsukaAssemblyVector3 => ({
  x: finite(value?.x, fallback.x),
  y: finite(value?.y, fallback.y),
  z: finite(value?.z, fallback.z),
});

const add = (left: AsukaAssemblyVector3, right: AsukaAssemblyVector3): AsukaAssemblyVector3 => ({
  x: left.x + right.x,
  y: left.y + right.y,
  z: left.z + right.z,
});

const subtract = (left: AsukaAssemblyVector3, right: AsukaAssemblyVector3): AsukaAssemblyVector3 => ({
  x: left.x - right.x,
  y: left.y - right.y,
  z: left.z - right.z,
});

const scale = (value: AsukaAssemblyVector3, amount: number): AsukaAssemblyVector3 => ({
  x: value.x * amount,
  y: value.y * amount,
  z: value.z * amount,
});

const length = (value: AsukaAssemblyVector3): number => Math.sqrt(
  value.x * value.x + value.y * value.y + value.z * value.z,
);

const distance = (left: AsukaAssemblyVector3, right: AsukaAssemblyVector3): number => length(subtract(left, right));

const normalize = (
  value: AsukaAssemblyVector3,
  fallback: AsukaAssemblyVector3 = { x: 0, y: 0, z: 1 },
): AsukaAssemblyVector3 => {
  const magnitude = length(value);
  return magnitude > EPSILON ? scale(value, 1 / magnitude) : { ...fallback };
};

const dot = (left: AsukaAssemblyVector3, right: AsukaAssemblyVector3): number =>
  left.x * right.x + left.y * right.y + left.z * right.z;

const cross = (left: AsukaAssemblyVector3, right: AsukaAssemblyVector3): AsukaAssemblyVector3 => ({
  x: left.y * right.z - left.z * right.y,
  y: left.z * right.x - left.x * right.z,
  z: left.x * right.y - left.y * right.x,
});

const rotateX = (value: AsukaAssemblyVector3, angle: number): AsukaAssemblyVector3 => {
  const cosine = Math.cos(angle);
  const sine = Math.sin(angle);
  return { x: value.x, y: value.y * cosine - value.z * sine, z: value.y * sine + value.z * cosine };
};

const rotateY = (value: AsukaAssemblyVector3, angle: number): AsukaAssemblyVector3 => {
  const cosine = Math.cos(angle);
  const sine = Math.sin(angle);
  return { x: value.x * cosine + value.z * sine, y: value.y, z: -value.x * sine + value.z * cosine };
};

const rotateZ = (value: AsukaAssemblyVector3, angle: number): AsukaAssemblyVector3 => {
  const cosine = Math.cos(angle);
  const sine = Math.sin(angle);
  return { x: value.x * cosine - value.y * sine, y: value.x * sine + value.y * cosine, z: value.z };
};

/** Rotate with the same Euler X/Y/Z convention used by the Babylon adapter. */
export const rotateAssemblyVector = (
  value: AsukaAssemblyVector3,
  rotationInput?: Partial<AsukaAssemblyVector3> | null,
): AsukaAssemblyVector3 => {
  const rotation = normalizeVector(rotationInput);
  return rotateZ(rotateY(rotateX(value, rotation.x), rotation.y), rotation.z);
};

const mergeTransform = (
  currentInput: AsukaAssemblyTransform | null | undefined,
  input: AsukaAssemblyPlacementInput = {},
): AsukaAssemblyTransform => {
  const currentPosition = normalizeVector(currentInput?.position);
  const currentRotation = normalizeVector(currentInput?.rotation);
  const currentScale = normalizeVector(currentInput?.scale, { x: 1, y: 1, z: 1 });
  const requestedPosition = {
    ...currentPosition,
    ...(input.position ?? {}),
  };
  const requestedRotation = {
    ...currentRotation,
    ...(input.rotation ?? {}),
  };
  const delta = normalizeVector(input.delta);
  return {
    position: add(normalizeVector(requestedPosition), delta),
    rotation: normalizeVector(requestedRotation),
    scale: currentScale,
  };
};

const normalizeSurfaceMaterial = (
  input?: AsukaAssemblySurfaceMaterial | null,
  fallbackColor?: string,
): AsukaAssemblySurfaceMaterial => ({
  color: normalizeToken(input?.color) || fallbackColor,
  textureUrl: normalizeToken(input?.textureUrl) || undefined,
  textureScale: {
    x: positive(input?.textureScale?.x, 1),
    y: positive(input?.textureScale?.y, 1),
  },
  textureSize: input?.textureSize
    ? {
        width: positive(input.textureSize.width, 1),
        height: positive(input.textureSize.height, 1),
      }
    : undefined,
  textureRotation: finite(input?.textureRotation),
  opacity: Math.max(0, Math.min(1, finite(input?.opacity, 1))),
});

export const normalizeAssemblyAngleDegrees = (value: unknown): number => {
  const angle = finite(value);
  return ((angle + 180) % 360 + 360) % 360 - 180;
};

export const getAssemblyDisplayUnitFactor = (
  displayUnitInput: unknown,
  worldUnitInMetersInput: unknown = 1,
): number => {
  const displayUnit = displayUnitInput === 'cm'
    ? 'cm'
    : displayUnitInput === 'mm'
      ? 'mm'
      : displayUnitInput === 'in'
        ? 'in'
        : 'm';
  const metersPerWorldUnit = positive(worldUnitInMetersInput, 1);
  const unitsPerMeter = displayUnit === 'cm'
    ? 100
    : displayUnit === 'mm'
      ? 1000
      : displayUnit === 'in'
        ? 1 / 0.0254
        : 1;
  return metersPerWorldUnit * unitsPerMeter;
};

export const assemblyWorldToDisplay = (
  value: unknown,
  settings?: AsukaAssemblySettings | null,
): number => finite(value) * getAssemblyDisplayUnitFactor(
  settings?.measurement?.displayUnit,
  settings?.measurement?.worldUnitInMeters,
);

export const assemblyDisplayToWorld = (
  value: unknown,
  settings?: AsukaAssemblySettings | null,
): number => finite(value) / getAssemblyDisplayUnitFactor(
  settings?.measurement?.displayUnit,
  settings?.measurement?.worldUnitInMeters,
);

export const getAssemblyPolygonSignedArea = (
  pointsInput: AsukaAssemblyPlanPoint[] | null | undefined,
): number => {
  const points = Array.isArray(pointsInput) ? pointsInput : [];
  if (points.length < 3) return 0;
  return points.reduce((area, point, index) => {
    const next = points[(index + 1) % points.length];
    return area + finite(point?.x) * finite(next?.z) - finite(next?.x) * finite(point?.z);
  }, 0) * 0.5;
};

const assemblySegmentsIntersect = (
  startA: AsukaAssemblyPlanPoint,
  endA: AsukaAssemblyPlanPoint,
  startB: AsukaAssemblyPlanPoint,
  endB: AsukaAssemblyPlanPoint,
): boolean => {
  const orientation = (
    start: AsukaAssemblyPlanPoint,
    end: AsukaAssemblyPlanPoint,
    point: AsukaAssemblyPlanPoint,
  ): number => (
    (finite(end.z) - finite(start.z)) * (finite(point.x) - finite(end.x))
    - (finite(end.x) - finite(start.x)) * (finite(point.z) - finite(end.z))
  );
  const onSegment = (
    start: AsukaAssemblyPlanPoint,
    point: AsukaAssemblyPlanPoint,
    end: AsukaAssemblyPlanPoint,
  ): boolean => (
    finite(point.x) <= Math.max(finite(start.x), finite(end.x)) + EPSILON
    && finite(point.x) >= Math.min(finite(start.x), finite(end.x)) - EPSILON
    && finite(point.z) <= Math.max(finite(start.z), finite(end.z)) + EPSILON
    && finite(point.z) >= Math.min(finite(start.z), finite(end.z)) - EPSILON
  );
  const orientationA = orientation(startA, endA, startB);
  const orientationB = orientation(startA, endA, endB);
  const orientationC = orientation(startB, endB, startA);
  const orientationD = orientation(startB, endB, endA);
  if (
    ((orientationA > EPSILON && orientationB < -EPSILON)
      || (orientationA < -EPSILON && orientationB > EPSILON))
    && ((orientationC > EPSILON && orientationD < -EPSILON)
      || (orientationC < -EPSILON && orientationD > EPSILON))
  ) return true;
  if (Math.abs(orientationA) <= EPSILON && onSegment(startA, startB, endA)) return true;
  if (Math.abs(orientationB) <= EPSILON && onSegment(startA, endB, endA)) return true;
  if (Math.abs(orientationC) <= EPSILON && onSegment(startB, startA, endB)) return true;
  return Math.abs(orientationD) <= EPSILON && onSegment(startB, endA, endB);
};

/** Return whether a floor outline can be triangulated without crossing edges. */
export const isAssemblyPolygonSimple = (
  pointsInput: AsukaAssemblyPlanPoint[] | null | undefined,
): boolean => {
  const points = Array.isArray(pointsInput) ? pointsInput : [];
  if (points.length < 3 || Math.abs(getAssemblyPolygonSignedArea(points)) <= EPSILON) return false;
  for (let firstIndex = 0; firstIndex < points.length; firstIndex += 1) {
    const firstNext = (firstIndex + 1) % points.length;
    const firstStart = points[firstIndex];
    const firstEnd = points[firstNext];
    if (Math.hypot(firstEnd.x - firstStart.x, firstEnd.z - firstStart.z) <= EPSILON) return false;
    for (let secondIndex = firstIndex + 1; secondIndex < points.length; secondIndex += 1) {
      const secondNext = (secondIndex + 1) % points.length;
      if (
        firstIndex === secondIndex
        || firstIndex === secondNext
        || firstNext === secondIndex
        || firstNext === secondNext
      ) continue;
      if (assemblySegmentsIntersect(
        firstStart,
        firstEnd,
        points[secondIndex],
        points[secondNext],
      )) return false;
    }
  }
  return true;
};

export const createAssemblyFloorPointIds = (
  floorId: string,
  points: AsukaAssemblyPlanPoint[],
  pointIdsInput?: string[] | null,
): string[] => {
  const pointIds = Array.isArray(pointIdsInput) ? pointIdsInput : [];
  const used = new Set<string>();
  return points.map((_point, index) => {
    const requested = normalizeToken(pointIds[index]);
    const base = requested || `${floorId}-point-${index + 1}`;
    let candidate = base;
    let suffix = 2;
    while (used.has(candidate)) {
      candidate = `${base}-${suffix}`;
      suffix += 1;
    }
    used.add(candidate);
    return candidate;
  });
};

export const getAssemblyFloorEdgeId = (
  floorInput: AsukaAssemblyFloorSurface,
  edgeIndexInput: number,
): string => {
  const points = Array.isArray(floorInput?.points) ? floorInput.points : [];
  if (!points.length) return '';
  const edgeIndex = Math.floor(finite(edgeIndexInput, -1));
  if (edgeIndex < 0 || edgeIndex >= points.length) return '';
  const pointIds = createAssemblyFloorPointIds(
    normalizeToken(floorInput.id) || 'floor',
    points,
    floorInput.pointIds,
  );
  return `${pointIds[edgeIndex]}:${pointIds[(edgeIndex + 1) % points.length]}`;
};

export const getAssemblyFloorMetrics = (
  floorInput: AsukaAssemblyFloorSurface | null | undefined,
): AsukaAssemblyFloorMetrics => {
  const points = Array.isArray(floorInput?.points) ? floorInput!.points : [];
  if (!floorInput || points.length < 2) {
    return { area: 0, perimeter: 0, edges: [] };
  }
  const pointIds = createAssemblyFloorPointIds(
    normalizeToken(floorInput.id) || 'floor',
    points,
    floorInput.pointIds,
  );
  const openEdgeIds = new Set(normalizeTokens(floorInput.metadata?.openWallEdgeIds));
  const openEdgeIndexes = new Set(
    (Array.isArray(floorInput.metadata?.openWallEdges)
      ? floorInput.metadata!.openWallEdges as unknown[]
      : [])
      .map((value) => Math.floor(finite(value, -1)))
      .filter((value) => value >= 0),
  );
  const edges = points.map((start, index) => {
    const nextIndex = (index + 1) % points.length;
    const end = points[nextIndex];
    const id = `${pointIds[index]}:${pointIds[nextIndex]}`;
    const constraint = floorInput.edgeConstraints?.[id] ?? {};
    return {
      index,
      id,
      startPointId: pointIds[index],
      endPointId: pointIds[nextIndex],
      start: clonePlain(start),
      end: clonePlain(end),
      length: Math.hypot(finite(end.x) - finite(start.x), finite(end.z) - finite(start.z)),
      angleDegrees: normalizeAssemblyAngleDegrees(
        Math.atan2(finite(end.z) - finite(start.z), finite(end.x) - finite(start.x))
        * 180
        / Math.PI,
      ),
      wallEnabled: !openEdgeIds.has(id) && !openEdgeIndexes.has(index),
      lockLength: constraint.lockLength === true,
      lockAngle: constraint.lockAngle === true,
    };
  });
  return {
    area: Math.abs(getAssemblyPolygonSignedArea(points)),
    perimeter: edges.reduce((total, edge) => total + edge.length, 0),
    edges,
  };
};

/**
 * Toggle one linked room wall while preserving stable edge identities.
 * The returned floor can be passed directly to the renderer update contract.
 */
export const setAssemblyRoomEdgeWallEnabled = (
  floorInput: AsukaAssemblyFloorSurface,
  edgeIndexInput: number,
  enabled: boolean,
): AsukaAssemblyFloorSurface => {
  const floor = clonePlain(floorInput);
  const edgeIndex = Math.floor(finite(edgeIndexInput, -1));
  const edgeId = getAssemblyFloorEdgeId(floor, edgeIndex);
  if (!edgeId) return floor;
  const openEdgeIds = new Set(normalizeTokens(floor.metadata?.openWallEdgeIds));
  if (enabled) openEdgeIds.delete(edgeId);
  else openEdgeIds.add(edgeId);
  const legacyOpenEdges = (
    Array.isArray(floor.metadata?.openWallEdges)
      ? floor.metadata!.openWallEdges as unknown[]
      : []
  )
    .map((value) => Math.floor(finite(value, -1)))
    .filter((value) => value >= 0 && value !== edgeIndex);
  return {
    ...floor,
    pointIds: createAssemblyFloorPointIds(floor.id, floor.points, floor.pointIds),
    metadata: {
      ...(floor.metadata ?? {}),
      openWallEdgeIds: Array.from(openEdgeIds),
      ...(legacyOpenEdges.length ? { openWallEdges: legacyOpenEdges } : { openWallEdges: [] }),
    },
  };
};

/** Resize one floor edge by moving its end corner along the current direction. */
export const setAssemblyFloorEdgeLength = (
  floorInput: AsukaAssemblyFloorSurface,
  edgeIndexInput: number,
  lengthInput: number,
): AsukaAssemblyFloorSurface => {
  const floor = clonePlain(floorInput);
  const edgeIndex = Math.floor(finite(edgeIndexInput, -1));
  const targetLength = finite(lengthInput, -1);
  if (
    edgeIndex < 0
    || edgeIndex >= floor.points.length
    || targetLength <= EPSILON
  ) return floor;
  const nextIndex = (edgeIndex + 1) % floor.points.length;
  const edgeId = getAssemblyFloorEdgeId(floor, edgeIndex);
  if (floor.edgeConstraints?.[edgeId]?.lockLength === true) return floor;
  const start = floor.points[edgeIndex];
  const end = floor.points[nextIndex];
  const dx = finite(end.x) - finite(start.x);
  const dz = finite(end.z) - finite(start.z);
  const currentLength = Math.hypot(dx, dz);
  if (currentLength <= EPSILON) return floor;
  const ratio = targetLength / currentLength;
  const points = floor.points.map((point) => clonePlain(point));
  points[nextIndex] = {
    x: finite(start.x) + dx * ratio,
    z: finite(start.z) + dz * ratio,
  };
  return {
    ...floor,
    points,
    pointIds: createAssemblyFloorPointIds(floor.id, points, floor.pointIds),
  };
};

/** Rotate one floor edge around its start corner while preserving its length. */
export const setAssemblyFloorEdgeAngle = (
  floorInput: AsukaAssemblyFloorSurface,
  edgeIndexInput: number,
  angleDegreesInput: number,
): AsukaAssemblyFloorSurface => {
  const floor = clonePlain(floorInput);
  const edgeIndex = Math.floor(finite(edgeIndexInput, -1));
  if (edgeIndex < 0 || edgeIndex >= floor.points.length) return floor;
  const edgeId = getAssemblyFloorEdgeId(floor, edgeIndex);
  if (floor.edgeConstraints?.[edgeId]?.lockAngle === true) return floor;
  const nextIndex = (edgeIndex + 1) % floor.points.length;
  const start = floor.points[edgeIndex];
  const end = floor.points[nextIndex];
  const edgeLength = Math.hypot(
    finite(end.x) - finite(start.x),
    finite(end.z) - finite(start.z),
  );
  if (edgeLength <= EPSILON) return floor;
  const angle = normalizeAssemblyAngleDegrees(angleDegreesInput) * Math.PI / 180;
  const points = floor.points.map((point) => clonePlain(point));
  points[nextIndex] = {
    x: finite(start.x) + Math.cos(angle) * edgeLength,
    z: finite(start.z) + Math.sin(angle) * edgeLength,
  };
  return {
    ...floor,
    points,
    pointIds: createAssemblyFloorPointIds(floor.id, points, floor.pointIds),
  };
};

export const setAssemblyFloorEdgeConstraint = (
  floorInput: AsukaAssemblyFloorSurface,
  edgeIndexInput: number,
  patch: AsukaAssemblyFloorEdgeConstraint,
): AsukaAssemblyFloorSurface => {
  const floor = clonePlain(floorInput);
  const edgeId = getAssemblyFloorEdgeId(floor, edgeIndexInput);
  if (!edgeId) return floor;
  const current = floor.edgeConstraints?.[edgeId] ?? {};
  return {
    ...floor,
    edgeConstraints: {
      ...(floor.edgeConstraints ?? {}),
      [edgeId]: {
        ...current,
        ...clonePlain(patch),
      },
    },
  };
};

export const getAssemblySurfaceTransform = (
  surfaceInput: AsukaAssemblySurface | null | undefined,
): AsukaAssemblySurfaceTransform | null => {
  if (!surfaceInput) return null;
  if (surfaceInput.kind === 'wall') {
    const start = surfaceInput.start;
    const end = surfaceInput.end;
    return {
      center: {
        x: (finite(start.x) + finite(end.x)) * 0.5,
        z: (finite(start.z) + finite(end.z)) * 0.5,
      },
      rotationDegrees: normalizeAssemblyAngleDegrees(
        Math.atan2(finite(end.z) - finite(start.z), finite(end.x) - finite(start.x))
        * 180
        / Math.PI,
      ),
    };
  }
  if (!surfaceInput.points.length) return null;
  const center = surfaceInput.points.reduce(
    (result, point) => ({
      x: result.x + finite(point.x) / surfaceInput.points.length,
      z: result.z + finite(point.z) / surfaceInput.points.length,
    }),
    { x: 0, z: 0 },
  );
  const first = surfaceInput.points[0];
  const second = surfaceInput.points[1] ?? first;
  return {
    center,
    rotationDegrees: normalizeAssemblyAngleDegrees(
      Math.atan2(finite(second.z) - finite(first.z), finite(second.x) - finite(first.x))
      * 180
      / Math.PI,
    ),
  };
};

/** Translate and/or rotate one complete floor or wall without changing its size. */
export const transformAssemblySurface = (
  surfaceInput: AsukaAssemblySurface,
  input: AsukaAssemblySurfaceTransformInput,
): AsukaAssemblySurface => {
  const surface = clonePlain(surfaceInput);
  const current = getAssemblySurfaceTransform(surface);
  if (!current) return surface;
  const targetCenter = {
    x: finite(input.center?.x, current.center.x),
    z: finite(input.center?.z, current.center.z),
  };
  const requestedRotation = input.rotationDegrees == null
    ? current.rotationDegrees
    : normalizeAssemblyAngleDegrees(input.rotationDegrees);
  const rotationLocked = surface.kind === 'wall'
    ? surface.lockAngle === true
    : Object.values(surface.edgeConstraints ?? {}).some((constraint) => constraint.lockAngle === true);
  const rotationDelta = rotationLocked
    ? 0
    : normalizeAssemblyAngleDegrees(requestedRotation - current.rotationDegrees) * Math.PI / 180;
  const pivot = input.pivot
    ? { x: finite(input.pivot.x), z: finite(input.pivot.z) }
    : current.center;
  const transformPoint = (point: AsukaAssemblyPlanPoint): AsukaAssemblyPlanPoint => {
    const offsetX = finite(point.x) - pivot.x;
    const offsetZ = finite(point.z) - pivot.z;
    const rotated = {
      x: pivot.x + offsetX * Math.cos(rotationDelta) - offsetZ * Math.sin(rotationDelta),
      z: pivot.z + offsetX * Math.sin(rotationDelta) + offsetZ * Math.cos(rotationDelta),
    };
    return {
      x: rotated.x + targetCenter.x - current.center.x,
      z: rotated.z + targetCenter.z - current.center.z,
    };
  };
  if (surface.kind === 'wall') {
    return {
      ...surface,
      start: transformPoint(surface.start),
      end: transformPoint(surface.end),
    };
  }
  return {
    ...surface,
    points: surface.points.map(transformPoint),
    pointIds: createAssemblyFloorPointIds(surface.id, surface.points, surface.pointIds),
  };
};

const normalizeFloorSurface = (
  input: Partial<AsukaAssemblyFloorSurface> | null | undefined,
  index: number,
  planInput?: AsukaAssemblyPlanDefinition | null,
): AsukaAssemblyFloorSurface | null => {
  const id = normalizeToken(input?.id) || `floor-${index + 1}`;
  const points = (Array.isArray(input?.points) ? input!.points : [])
    .map((point) => ({ x: finite(point?.x), z: finite(point?.z) }))
    .filter((point, pointIndex, all) =>
      pointIndex === 0
      || Math.abs(point.x - all[pointIndex - 1].x) > EPSILON
      || Math.abs(point.z - all[pointIndex - 1].z) > EPSILON);
  if (points.length < 3) return null;
  return {
    id,
    kind: 'floor',
    label: normalizeToken(input?.label) || undefined,
    points,
    pointIds: createAssemblyFloorPointIds(id, points, input?.pointIds),
    edgeConstraints: input?.edgeConstraints && typeof input.edgeConstraints === 'object'
      ? Object.fromEntries(
          Object.entries(input.edgeConstraints)
            .map(([edgeId, constraint]) => [
              normalizeToken(edgeId),
              {
                lockLength: constraint?.lockLength === true,
                lockAngle: constraint?.lockAngle === true,
              } satisfies AsukaAssemblyFloorEdgeConstraint,
            ])
            .filter(([edgeId]) => Boolean(edgeId)),
        )
      : undefined,
    y: finite(input?.y, finite(planInput?.floorY)),
    thickness: positive(input?.thickness, 0.012),
    material: normalizeSurfaceMaterial(input?.material, normalizeToken(planInput?.floorColor) || '#f6f8fb'),
    metadata: input?.metadata && typeof input.metadata === 'object' ? clonePlain(input.metadata) : undefined,
  };
};

const normalizeWallSurface = (
  input: Partial<AsukaAssemblyWallSurface> | null | undefined,
  index: number,
  planInput?: AsukaAssemblyPlanDefinition | null,
): AsukaAssemblyWallSurface | null => {
  const start = { x: finite(input?.start?.x), z: finite(input?.start?.z) };
  const end = { x: finite(input?.end?.x), z: finite(input?.end?.z) };
  const dx = end.x - start.x;
  const dz = end.z - start.z;
  if (Math.sqrt(dx * dx + dz * dz) <= EPSILON) return null;
  return {
    id: normalizeToken(input?.id) || `wall-${index + 1}`,
    kind: 'wall',
    label: normalizeToken(input?.label) || undefined,
    start,
    end,
    baseY: finite(input?.baseY, finite(planInput?.floorY)),
    height: positive(input?.height, positive(planInput?.height, 2.6)),
    thickness: positive(input?.thickness, positive(planInput?.wallThickness, 0.08)),
    lockLength: input?.lockLength === true,
    lockAngle: input?.lockAngle === true,
    lockHeight: input?.lockHeight === true,
    material: normalizeSurfaceMaterial(input?.material, normalizeToken(planInput?.wallColor) || '#e7ebf1'),
    interiorMaterial: normalizeSurfaceMaterial(
      input?.interiorMaterial ?? input?.material,
      normalizeToken(planInput?.wallColor) || '#e7ebf1',
    ),
    exteriorMaterial: normalizeSurfaceMaterial(
      input?.exteriorMaterial ?? input?.material,
      normalizeToken(planInput?.wallColor) || '#e7ebf1',
    ),
    metadata: input?.metadata && typeof input.metadata === 'object' ? clonePlain(input.metadata) : undefined,
  };
};

const intersectAssemblyInfiniteLines = (
  startA: AsukaAssemblyPlanPoint,
  endA: AsukaAssemblyPlanPoint,
  startB: AsukaAssemblyPlanPoint,
  endB: AsukaAssemblyPlanPoint,
): AsukaAssemblyPlanPoint | null => {
  const directionA = {
    x: finite(endA.x) - finite(startA.x),
    z: finite(endA.z) - finite(startA.z),
  };
  const directionB = {
    x: finite(endB.x) - finite(startB.x),
    z: finite(endB.z) - finite(startB.z),
  };
  const denominator = directionA.x * directionB.z - directionA.z * directionB.x;
  if (Math.abs(denominator) <= EPSILON) return null;
  const delta = {
    x: finite(startB.x) - finite(startA.x),
    z: finite(startB.z) - finite(startA.z),
  };
  const distanceAlongA = (
    delta.x * directionB.z
    - delta.z * directionB.x
  ) / denominator;
  return {
    x: finite(startA.x) + directionA.x * distanceAlongA,
    z: finite(startA.z) + directionA.z * distanceAlongA,
  };
};

const getAssemblyWallOffsetLine = (
  wall: AsukaAssemblyWallSurface,
  fallbackInteriorSide: 'left' | 'right',
): {
  start: AsukaAssemblyPlanPoint;
  end: AsukaAssemblyPlanPoint;
  thickness: number;
} => {
  const start = {
    x: finite(wall.start?.x),
    z: finite(wall.start?.z),
  };
  const end = {
    x: finite(wall.end?.x),
    z: finite(wall.end?.z),
  };
  const dx = end.x - start.x;
  const dz = end.z - start.z;
  const length = Math.max(EPSILON, Math.hypot(dx, dz));
  const interiorSide = wall.metadata?.interiorSide === 'right'
    ? 'right'
    : wall.metadata?.interiorSide === 'left'
      ? 'left'
      : fallbackInteriorSide;
  const interiorSign = interiorSide === 'left' ? 1 : -1;
  const thickness = positive(wall.thickness, 0.08);
  const exteriorNormal = {
    x: (-dz / length) * -interiorSign,
    z: (dx / length) * -interiorSign,
  };
  return {
    start: {
      x: start.x + exteriorNormal.x * thickness,
      z: start.z + exteriorNormal.z * thickness,
    },
    end: {
      x: end.x + exteriorNormal.x * thickness,
      z: end.z + exteriorNormal.z * thickness,
    },
    thickness,
  };
};

/**
 * Resolve a linked room wall as a four-point footprint. Adjacent exterior
 * offset lines are intersected so arbitrary polygon corners meet cleanly.
 */
export const getAssemblyRoomWallFootprint = (
  floorInput: AsukaAssemblyFloorSurface,
  wallInput: AsukaAssemblyWallSurface,
  wallsInput: AsukaAssemblyWallSurface[] | null | undefined,
): AsukaAssemblyRoomWallFootprint => {
  const floor = clonePlain(floorInput);
  const wall = clonePlain(wallInput);
  const walls = Array.isArray(wallsInput) ? wallsInput : [];
  const fallbackInteriorSide = getAssemblyPolygonSignedArea(floor.points) >= 0
    ? 'left'
    : 'right';
  const currentLine = getAssemblyWallOffsetLine(wall, fallbackInteriorSide);
  const edgeIndex = Math.floor(finite(wall.metadata?.roomEdgeIndex, -1));
  const roomId = normalizeToken(wall.metadata?.roomSurfaceId) || floor.id;
  const edgeCount = floor.points.length;
  const findLinkedEdge = (wantedIndex: number): AsukaAssemblyWallSurface | null => (
    walls.find((candidate) => (
      candidate.id !== wall.id
      && normalizeToken(candidate.metadata?.roomSurfaceId) === roomId
      && Math.floor(finite(candidate.metadata?.roomEdgeIndex, -1)) === wantedIndex
    )) ?? null
  );
  const resolveJoint = (
    endpoint: AsukaAssemblyPlanPoint,
    neighbour: AsukaAssemblyWallSurface | null,
  ): { point: AsukaAssemblyPlanPoint; mitered: boolean } => {
    if (!neighbour) return { point: clonePlain(endpoint), mitered: false };
    const neighbourLine = getAssemblyWallOffsetLine(neighbour, fallbackInteriorSide);
    const neighbourEndpoint = Math.hypot(
      neighbourLine.start.x - endpoint.x,
      neighbourLine.start.z - endpoint.z,
    ) <= Math.hypot(
      neighbourLine.end.x - endpoint.x,
      neighbourLine.end.z - endpoint.z,
    )
      ? neighbourLine.start
      : neighbourLine.end;
    const bevelPoint = {
      x: (endpoint.x + neighbourEndpoint.x) * 0.5,
      z: (endpoint.z + neighbourEndpoint.z) * 0.5,
    };
    const intersection = intersectAssemblyInfiniteLines(
      currentLine.start,
      currentLine.end,
      neighbourLine.start,
      neighbourLine.end,
    );
    if (!intersection) return { point: bevelPoint, mitered: true };
    const maxMiterDistance = Math.max(
      currentLine.thickness,
      neighbourLine.thickness,
    ) * 8;
    if (Math.hypot(
      intersection.x - endpoint.x,
      intersection.z - endpoint.z,
    ) > maxMiterDistance) {
      return { point: bevelPoint, mitered: true };
    }
    return { point: intersection, mitered: true };
  };
  const previousIndex = edgeCount > 0 ? (edgeIndex - 1 + edgeCount) % edgeCount : -1;
  const nextIndex = edgeCount > 0 ? (edgeIndex + 1) % edgeCount : -1;
  const startJoint = resolveJoint(
    currentLine.start,
    edgeIndex >= 0 ? findLinkedEdge(previousIndex) : null,
  );
  const endJoint = resolveJoint(
    currentLine.end,
    edgeIndex >= 0 ? findLinkedEdge(nextIndex) : null,
  );
  return {
    interiorStart: {
      x: finite(wall.start?.x),
      z: finite(wall.start?.z),
    },
    interiorEnd: {
      x: finite(wall.end?.x),
      z: finite(wall.end?.z),
    },
    exteriorStart: startJoint.point,
    exteriorEnd: endJoint.point,
    miteredStart: startJoint.mitered,
    miteredEnd: endJoint.mitered,
  };
};

export interface ReconcileAssemblyRoomWallsOptions {
  height?: number;
  thickness?: number;
  material?: AsukaAssemblySurfaceMaterial;
  interiorMaterial?: AsukaAssemblySurfaceMaterial;
  exteriorMaterial?: AsukaAssemblySurfaceMaterial;
  createId?: (edgeIndex: number) => string;
}

/**
 * Rebuild the walls owned by one room floor while preserving each edge's
 * identity and finishes. Unrelated standalone walls pass through untouched.
 */
export const reconcileAssemblyRoomWalls = (
  floorInput: AsukaAssemblyFloorSurface,
  wallsInput: AsukaAssemblyWallSurface[] | null | undefined,
  options: ReconcileAssemblyRoomWallsOptions = {},
): AsukaAssemblyWallSurface[] => {
  const floor = clonePlain(floorInput);
  const walls = Array.isArray(wallsInput) ? clonePlain(wallsInput) : [];
  if (!isAssemblyPolygonSimple(floor.points)) return walls;
  const pointIds = createAssemblyFloorPointIds(floor.id, floor.points, floor.pointIds);
  const linked = walls.filter((wall) => normalizeToken(wall.metadata?.roomSurfaceId) === floor.id);
  const unlinked = walls.filter((wall) => normalizeToken(wall.metadata?.roomSurfaceId) !== floor.id);
  const usedWallIds = new Set<string>();
  const openEdgeIds = new Set(normalizeTokens(floor.metadata?.openWallEdgeIds));
  const openEdgeIndexes = new Set(
    (Array.isArray(floor.metadata?.openWallEdges) ? floor.metadata!.openWallEdges as unknown[] : [])
      .map((value) => Math.floor(finite(value, -1)))
      .filter((value) => value >= 0),
  );
  const roomWallTemplates = (
    floor.metadata?.roomWallTemplates
    && typeof floor.metadata.roomWallTemplates === 'object'
    && !Array.isArray(floor.metadata.roomWallTemplates)
  )
    ? floor.metadata.roomWallTemplates as Record<string, Partial<AsukaAssemblyWallSurface>>
    : {};
  const interiorSide = getAssemblyPolygonSignedArea(floor.points) >= 0 ? 'left' : 'right';
  const roomWalls: AsukaAssemblyWallSurface[] = [];
  for (let edgeIndex = 0; edgeIndex < floor.points.length; edgeIndex += 1) {
    const nextIndex = (edgeIndex + 1) % floor.points.length;
    const startPointId = pointIds[edgeIndex];
    const endPointId = pointIds[nextIndex];
    const edgeId = `${startPointId}:${endPointId}`;
    if (openEdgeIds.has(edgeId) || openEdgeIndexes.has(edgeIndex)) continue;
    const existing = linked.find((wall) => {
      if (usedWallIds.has(wall.id)) return false;
      const metadata = wall.metadata ?? {};
      const existingEdgeId = normalizeToken(metadata.roomEdgeId);
      const existingStartPointId = normalizeToken(metadata.roomStartPointId);
      const existingEndPointId = normalizeToken(metadata.roomEndPointId);
      const hasStableIdentity = Boolean(
        existingEdgeId || (existingStartPointId && existingEndPointId),
      );
      return existingEdgeId === edgeId
        || (
          existingStartPointId === startPointId
          && existingEndPointId === endPointId
        )
        || (
          !hasStableIdentity
          && Math.floor(finite(metadata.roomEdgeIndex, -1)) === edgeIndex
        );
    }) ?? null;
    if (existing) usedWallIds.add(existing.id);
    const template = roomWallTemplates[edgeId] ?? null;
    const source = existing ?? template;
    const material = clonePlain(source?.material ?? options.material ?? {});
    const interiorMaterial = clonePlain(
      source?.interiorMaterial
      ?? source?.material
      ?? options.interiorMaterial
      ?? options.material
      ?? {},
    );
    const exteriorMaterial = clonePlain(
      source?.exteriorMaterial
      ?? source?.material
      ?? options.exteriorMaterial
      ?? options.material
      ?? {},
    );
    roomWalls.push({
      ...(source ?? {}),
      id: source?.id
        || normalizeToken(options.createId?.(edgeIndex))
        || `${floor.id}-wall-${edgeIndex + 1}`,
      kind: 'wall',
      label: source?.label || `Wall ${edgeIndex + 1}`,
      start: clonePlain(floor.points[edgeIndex]),
      end: clonePlain(floor.points[nextIndex]),
      baseY: finite(floor.y),
      height: positive(source?.height, positive(options.height, 2.6)),
      thickness: positive(source?.thickness, positive(options.thickness, 0.08)),
      material,
      interiorMaterial,
      exteriorMaterial,
      metadata: {
        ...(source?.metadata ?? {}),
        roomSurfaceId: floor.id,
        roomEdgeIndex: edgeIndex,
        roomEdgeId: edgeId,
        roomStartPointId: startPointId,
        roomEndPointId: endPointId,
        interiorSide,
        alignToFloorEdge: true,
      },
    });
  }
  return [...unlinked, ...roomWalls];
};

const normalizePlan = (input?: AsukaAssemblyPlanDefinition | null): AsukaAssemblyPlanDefinition => {
  const points = (Array.isArray(input?.points) ? input!.points : [])
    .map((point) => ({ x: finite(point?.x), z: finite(point?.z) }))
    .filter((point, index, all) => index === 0 || Math.abs(point.x - all[index - 1].x) > EPSILON || Math.abs(point.z - all[index - 1].z) > EPSILON);
  const floors = Array.isArray(input?.floors)
    ? input!.floors
      .map((surface, index) => normalizeFloorSurface(surface, index, input))
      .filter(Boolean) as AsukaAssemblyFloorSurface[]
    : undefined;
  const walls = Array.isArray(input?.walls)
    ? input!.walls
      .map((surface, index) => normalizeWallSurface(surface, index, input))
      .filter(Boolean) as AsukaAssemblyWallSurface[]
    : undefined;
  return {
    ...clonePlain(ASUKA_ASSEMBLY_DEFAULT_PLAN),
    ...(input && typeof input === 'object' ? clonePlain(input) : {}),
    points: points.length >= 3 ? points : clonePlain(ASUKA_ASSEMBLY_DEFAULT_PLAN.points),
    floorY: finite(input?.floorY, ASUKA_ASSEMBLY_DEFAULT_PLAN.floorY),
    height: positive(input?.height, ASUKA_ASSEMBLY_DEFAULT_PLAN.height!),
    wallThickness: positive(input?.wallThickness, ASUKA_ASSEMBLY_DEFAULT_PLAN.wallThickness!),
    closed: input?.closed !== false,
    autoExpand: input?.autoExpand !== false,
    autoExpandPadding: Math.max(0, finite(input?.autoExpandPadding, ASUKA_ASSEMBLY_DEFAULT_PLAN.autoExpandPadding!)),
    ...(floors !== undefined ? { floors } : {}),
    ...(walls !== undefined ? { walls } : {}),
  };
};

export const normalizeAssemblySettings = (input?: AsukaAssemblySettings | null): AsukaNormalizedAssemblySettings => ({
  grid: {
    ...ASUKA_ASSEMBLY_DEFAULT_SETTINGS.grid,
    ...(input?.grid ?? {}),
    enabled: input?.grid?.enabled ?? ASUKA_ASSEMBLY_DEFAULT_SETTINGS.grid.enabled,
    size: positive(input?.grid?.size, ASUKA_ASSEMBLY_DEFAULT_SETTINGS.grid.size!),
    subdivisions: Math.max(1, Math.floor(positive(input?.grid?.subdivisions, ASUKA_ASSEMBLY_DEFAULT_SETTINGS.grid.subdivisions!))),
    snapY: input?.grid?.snapY === true,
  },
  measurement: {
    displayUnit: input?.measurement?.displayUnit === 'cm'
      ? 'cm'
      : input?.measurement?.displayUnit === 'mm'
        ? 'mm'
        : input?.measurement?.displayUnit === 'in'
          ? 'in'
          : 'm',
    worldUnitInMeters: positive(
      input?.measurement?.worldUnitInMeters,
      ASUKA_ASSEMBLY_DEFAULT_SETTINGS.measurement.worldUnitInMeters,
    ),
  },
  snap: {
    ...ASUKA_ASSEMBLY_DEFAULT_SETTINGS.snap,
    ...(input?.snap ?? {}),
    enabled: input?.snap?.enabled !== false,
    anchors: input?.snap?.anchors !== false,
    boundaries: input?.snap?.boundaries !== false,
    grid: input?.snap?.grid !== false,
    distance: positive(input?.snap?.distance, ASUKA_ASSEMBLY_DEFAULT_SETTINGS.snap.distance!),
    rotation: input?.snap?.rotation !== false,
    rotationStepDegrees: Math.max(
      1,
      Math.min(
        180,
        positive(
          input?.snap?.rotationStepDegrees,
          ASUKA_ASSEMBLY_DEFAULT_SETTINGS.snap.rotationStepDegrees,
        ),
      ),
    ),
    autoConnect: input?.snap?.autoConnect !== false,
  },
  collision: {
    ...ASUKA_ASSEMBLY_DEFAULT_SETTINGS.collision,
    ...(input?.collision ?? {}),
    enabled: input?.collision?.enabled !== false,
    policy: ['prevent', 'warn', 'allow'].includes(String(input?.collision?.policy))
      ? input!.collision!.policy!
      : ASUKA_ASSEMBLY_DEFAULT_SETTINGS.collision.policy!,
    margin: Math.max(0, finite(input?.collision?.margin, ASUKA_ASSEMBLY_DEFAULT_SETTINGS.collision.margin!)),
    includeClearances: input?.collision?.includeClearances !== false,
  },
  adjacency: {
    ...ASUKA_ASSEMBLY_DEFAULT_SETTINGS.adjacency,
    ...(input?.adjacency ?? {}),
    enabled: input?.adjacency?.enabled !== false,
    autoCorners: input?.adjacency?.autoCorners !== false,
    reflowOnResize: input?.adjacency?.reflowOnResize !== false,
    resizeOverflowPolicy: input?.adjacency?.resizeOverflowPolicy === 'shrink-components'
      ? 'shrink-components'
      : 'expand-plan',
  },
  visualization: {
    ...ASUKA_ASSEMBLY_DEFAULT_SETTINGS.visualization,
    ...(input?.visualization ?? {}),
    environment: input?.visualization?.environment ?? ASUKA_ASSEMBLY_DEFAULT_SETTINGS.visualization.environment,
    enabled: input?.visualization?.enabled ?? ASUKA_ASSEMBLY_DEFAULT_SETTINGS.visualization.enabled,
    anchors: input?.visualization?.anchors ?? ASUKA_ASSEMBLY_DEFAULT_SETTINGS.visualization.anchors,
    collisions: input?.visualization?.collisions ?? ASUKA_ASSEMBLY_DEFAULT_SETTINGS.visualization.collisions,
    clearances: input?.visualization?.clearances ?? ASUKA_ASSEMBLY_DEFAULT_SETTINGS.visualization.clearances,
    connections: input?.visualization?.connections ?? ASUKA_ASSEMBLY_DEFAULT_SETTINGS.visualization.connections,
    dimensions: input?.visualization?.dimensions ?? ASUKA_ASSEMBLY_DEFAULT_SETTINGS.visualization.dimensions,
    selectedOnly: input?.visualization?.selectedOnly ?? ASUKA_ASSEMBLY_DEFAULT_SETTINGS.visualization.selectedOnly,
  },
});

const normalizeAssemblyMaterialPreset = (
  input: Partial<AsukaAssemblyMaterialPreset> | null | undefined,
  index: number,
): AsukaAssemblyMaterialPreset | null => {
  const id = normalizeToken(input?.id) || `material-${index + 1}`;
  const name = normalizeToken(input?.name);
  if (!name) return null;
  return {
    id,
    name,
    category: normalizeToken(input?.category) || undefined,
    thumbnailUrl: normalizeToken(input?.thumbnailUrl) || undefined,
    material: normalizeSurfaceMaterial(input?.material),
    builtIn: input?.builtIn === true,
    metadata: input?.metadata && typeof input.metadata === 'object'
      ? clonePlain(input.metadata)
      : undefined,
  };
};

const assemblyPatternDataUrl = (body: string, background: string): string => (
  `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
    `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">`
    + `<rect width="96" height="96" fill="${background}"/>${body}</svg>`,
  )}`
);

export const ASUKA_ASSEMBLY_BUILTIN_MATERIALS: AsukaAssemblyMaterialPreset[] = [
  {
    id: 'builtin-soft-white',
    name: 'Soft white',
    category: 'Solid',
    builtIn: true,
    material: { color: '#f4f5f7' },
  },
  {
    id: 'builtin-graphite',
    name: 'Graphite',
    category: 'Solid',
    builtIn: true,
    material: { color: '#4b5563' },
  },
  {
    id: 'builtin-light-oak',
    name: 'Light oak',
    category: 'Wood',
    builtIn: true,
    material: {
      color: '#d7b98e',
      textureUrl: assemblyPatternDataUrl(
        '<path d="M0 18c24-8 48 8 72 0s24-5 24-5M0 48c18 9 42-7 66 0s30 3 30 3M0 77c28-8 46 7 74 0s22-4 22-4" fill="none" stroke="#9b7650" stroke-width="2" opacity=".55"/>',
        '#d7b98e',
      ),
      textureSize: { width: 0.8, height: 0.8 },
    },
  },
  {
    id: 'builtin-concrete',
    name: 'Soft concrete',
    category: 'Mineral',
    builtIn: true,
    material: {
      color: '#c9cdd1',
      textureUrl: assemblyPatternDataUrl(
        '<g fill="#8f969d" opacity=".32"><circle cx="14" cy="19" r="2"/><circle cx="63" cy="12" r="1.5"/><circle cx="45" cy="55" r="2"/><circle cx="82" cy="74" r="1.7"/><circle cx="20" cy="84" r="1.4"/></g>',
        '#c9cdd1',
      ),
      textureSize: { width: 0.6, height: 0.6 },
    },
  },
  {
    id: 'builtin-square-tile',
    name: 'Square tile',
    category: 'Tile',
    builtIn: true,
    material: {
      color: '#eef1f4',
      textureUrl: assemblyPatternDataUrl(
        '<path d="M0 48h96M48 0v96" stroke="#aab2bb" stroke-width="3"/>',
        '#eef1f4',
      ),
      textureSize: { width: 0.6, height: 0.6 },
    },
  },
];

export const getAssemblyMaterialPresets = (
  stateInput?: Partial<AsukaAssemblyState> | null,
): AsukaAssemblyMaterialPreset[] => {
  const custom = (Array.isArray(stateInput?.materialLibrary) ? stateInput!.materialLibrary : [])
    .map((preset, index) => normalizeAssemblyMaterialPreset(preset, index))
    .filter(Boolean) as AsukaAssemblyMaterialPreset[];
  const customIds = new Set(custom.map((preset) => preset.id));
  return [
    ...ASUKA_ASSEMBLY_BUILTIN_MATERIALS.filter((preset) => !customIds.has(preset.id)),
    ...custom,
  ].map((preset) => clonePlain(preset));
};

export const saveAssemblyMaterialPreset = (
  stateInput: AsukaAssemblyState,
  presetInput: Partial<AsukaAssemblyMaterialPreset>,
): AsukaAssemblyState => {
  const state = normalizeAssemblyState(stateInput);
  const requestedId = normalizeToken(presetInput.id);
  const id = requestedId
    && !ASUKA_ASSEMBLY_BUILTIN_MATERIALS.some((entry) => entry.id === requestedId)
    ? requestedId
    : `material-${Date.now().toString(36)}`;
  const preset = normalizeAssemblyMaterialPreset(
    {
      ...presetInput,
      id,
      builtIn: false,
    },
    state.materialLibrary?.length ?? 0,
  );
  if (!preset) return state;
  return {
    ...state,
    materialLibrary: [
      ...(state.materialLibrary ?? []).filter((entry) => entry.id !== preset.id),
      preset,
    ],
  };
};

export const removeAssemblyMaterialPreset = (
  stateInput: AsukaAssemblyState,
  presetIdInput: string,
): AsukaAssemblyState => {
  const state = normalizeAssemblyState(stateInput);
  const presetId = normalizeToken(presetIdInput);
  if (!presetId || ASUKA_ASSEMBLY_BUILTIN_MATERIALS.some((entry) => entry.id === presetId)) {
    return state;
  }
  return {
    ...state,
    materialLibrary: (state.materialLibrary ?? []).filter((entry) => entry.id !== presetId),
  };
};

export const applyAssemblyMaterialPreset = (
  stateInput: AsukaAssemblyState,
  surfaceIdInput: string,
  presetIdInput: string,
  options: AsukaAssemblyMaterialApplyOptions = {},
): AsukaAssemblyState => {
  const state = normalizeAssemblyState(stateInput);
  const surfaceId = normalizeToken(surfaceIdInput);
  const presetId = normalizeToken(presetIdInput);
  const preset = getAssemblyMaterialPresets(state).find((entry) => entry.id === presetId);
  const plan = state.plan;
  if (!surfaceId || !preset || !plan) return state;
  const floors = clonePlain(plan.floors ?? []);
  const walls = clonePlain(plan.walls ?? []);
  const selectedFloor = floors.find((surface) => surface.id === surfaceId) ?? null;
  const selectedWall = walls.find((surface) => surface.id === surfaceId) ?? null;
  if (!selectedFloor && !selectedWall) return state;
  const scope = options.scope === 'room'
    ? 'room'
    : options.scope === 'surface'
      ? 'surface'
      : 'face';
  const side = options.side === 'exterior'
    ? 'exterior'
    : options.side === 'both'
      ? 'both'
      : 'interior';
  const roomId = selectedFloor?.id
    ?? normalizeToken(selectedWall?.metadata?.roomSurfaceId);
  if (scope === 'room' && !roomId) return state;
  const material = normalizeSurfaceMaterial(preset.material);
  const applyFloor = (surface: AsukaAssemblyFloorSurface): AsukaAssemblyFloorSurface => ({
    ...surface,
    material: clonePlain(material),
  });
  const applyWall = (surface: AsukaAssemblyWallSurface): AsukaAssemblyWallSurface => {
    if (scope !== 'face' || side === 'both') {
      return {
        ...surface,
        material: clonePlain(material),
        interiorMaterial: clonePlain(material),
        exteriorMaterial: clonePlain(material),
      };
    }
    return {
      ...surface,
      ...(side === 'interior'
        ? { interiorMaterial: clonePlain(material) }
        : { exteriorMaterial: clonePlain(material) }),
    };
  };
  const nextFloors = floors.map((surface) => {
    const matches = scope === 'room'
      ? surface.id === roomId
      : surface.id === surfaceId;
    return matches ? applyFloor(surface) : surface;
  });
  const nextWalls = walls.map((surface) => {
    const matches = scope === 'room'
      ? normalizeToken(surface.metadata?.roomSurfaceId) === roomId
      : surface.id === surfaceId;
    return matches ? applyWall(surface) : surface;
  });
  return normalizeAssemblyState({
    ...state,
    plan: {
      ...plan,
      floors: nextFloors,
      walls: nextWalls,
    },
  });
};

export const normalizeAssemblyState = (input?: Partial<AsukaAssemblyState> | null): AsukaAssemblyState => ({
  schemaVersion: 1,
  enabled: input?.enabled === true,
  plan: normalizePlan(input?.plan),
  settings: normalizeAssemblySettings(input?.settings),
  materialLibrary: (Array.isArray(input?.materialLibrary) ? input!.materialLibrary : [])
    .map((preset, index) => normalizeAssemblyMaterialPreset(preset, index))
    .filter(Boolean) as AsukaAssemblyMaterialPreset[],
  componentPlacements: Object.fromEntries(
    Object.entries(
      input?.componentPlacements
      && typeof input.componentPlacements === 'object'
      && !Array.isArray(input.componentPlacements)
        ? input.componentPlacements
        : {},
    )
      .map(([componentId, placement]) => [
        normalizeToken(componentId),
        normalizeAssemblyPlacementDefinition(placement),
      ] as const)
      .filter(([componentId]) => Boolean(componentId)),
  ),
  componentAutoFitScales: Object.fromEntries(
    Object.entries(
      input?.componentAutoFitScales
      && typeof input.componentAutoFitScales === 'object'
      && !Array.isArray(input.componentAutoFitScales)
        ? input.componentAutoFitScales
        : {},
    )
      .map(([componentId, scale]) => {
        const numericScale = finite(scale, 1);
        return [
          normalizeToken(componentId),
          numericScale > 0 ? Math.min(1, Math.max(0.01, numericScale)) : 1,
        ] as const;
      })
      .filter(([componentId, scale]) => Boolean(componentId) && scale < 0.999999),
  ),
  connections: (Array.isArray(input?.connections) ? input!.connections : [])
    .map((connection) => normalizeAssemblyConnection(connection))
    .filter(Boolean) as AsukaAssemblyConnection[],
  stackLinks: normalizeAssemblyStackLinks(
    Array.isArray(input?.stackLinks) ? input!.stackLinks : [],
  ),
  metadata: input?.metadata && typeof input.metadata === 'object' ? clonePlain(input.metadata) : undefined,
});

export const normalizeAssemblyComponentDefinition = (
  input?: Partial<AsukaAssemblyComponentDefinition> | null,
): AsukaAssemblyComponentDefinition | null => {
  if (!input || typeof input !== 'object') return null;
  const anchors = (Array.isArray(input.anchors) ? input.anchors : [])
    .map((anchor, index) => normalizeAssemblyAnchorDefinition(anchor, index))
    .filter(Boolean) as AsukaAssemblyAnchorDefinition[];
  return {
    schemaVersion: 1,
    enabled: input.enabled !== false,
    category: normalizeToken(input.category) || undefined,
    tags: normalizeTokens(input.tags),
    placement: normalizeAssemblyPlacementDefinition(input.placement),
    anchors,
    collision: input.collision && typeof input.collision === 'object'
      ? {
          ...clonePlain(input.collision),
          enabled: input.collision.enabled !== false,
          margin: Math.max(0, finite(input.collision.margin)),
          group: normalizeToken(input.collision.group) || undefined,
          collidesWith: normalizeTokens(input.collision.collidesWith),
          ...(input.collision.bounds?.size
            ? {
                bounds: {
                  center: normalizeVector(input.collision.bounds.center),
                  size: {
                    x: positive(input.collision.bounds.size.x, 0.001),
                    y: positive(input.collision.bounds.size.y, 0.001),
                    z: positive(input.collision.bounds.size.z, 0.001),
                  },
                },
              }
            : {}),
        }
      : { enabled: true, margin: 0 },
    clearances: (Array.isArray(input.clearances) ? input.clearances : [])
      .map((clearance, index) => ({
        ...clonePlain(clearance),
        id: normalizeToken(clearance?.id) || `clearance-${index + 1}`,
        label: normalizeToken(clearance?.label) || undefined,
        center: normalizeVector(clearance?.center),
        size: {
          x: positive(clearance?.size?.x, 0.001),
          y: positive(clearance?.size?.y, 0.001),
          z: positive(clearance?.size?.z, 0.001),
        },
        positionMode: clearance?.positionMode === 'scaled' ? 'scaled' : 'fixed',
        sizeMode: clearance?.sizeMode === 'scaled' ? 'scaled' : 'fixed',
        severity: clearance?.severity === 'warning' ? 'warning' : 'error',
        group: normalizeToken(clearance?.group) || undefined,
        collidesWith: normalizeTokens(clearance?.collidesWith),
        message: normalizeToken(clearance?.message) || undefined,
      })),
    adjacency: (Array.isArray(input.adjacency) ? input.adjacency : [])
      .map((rule, index) => normalizeAdjacencyRule(rule, index))
      .filter(Boolean) as AsukaAssemblyAdjacencyRule[],
    bom: (Array.isArray(input.bom) ? input.bom : [])
      .map((item, index) => {
        const label = normalizeToken(item?.label);
        if (!label) return null;
        return {
          ...clonePlain(item),
          id: normalizeToken(item?.id) || `bom-${index + 1}`,
          sku: normalizeToken(item?.sku) || undefined,
          label,
          quantity: positive(item?.quantity, 1),
          unit: normalizeToken(item?.unit) || 'piece',
          unitPrice: normalizePriceInput(item?.unitPrice) ?? 0,
          category: normalizeToken(item?.category) || undefined,
        };
      })
      .filter(Boolean) as NonNullable<AsukaAssemblyComponentDefinition['bom']>,
    metadata: input.metadata && typeof input.metadata === 'object' ? clonePlain(input.metadata) : undefined,
  };
};

const normalizeAssemblyAnchorDefinition = (
  input: Partial<AsukaAssemblyAnchorDefinition> | null | undefined,
  index: number,
): AsukaAssemblyAnchorDefinition | null => {
  if (!input || typeof input !== 'object') return null;
  const id = normalizeToken(input.id) || `anchor-${index + 1}`;
  const normal = normalize(normalizeVector(input.normal, { x: 0, y: 0, z: 1 }));
  let up = normalize(normalizeVector(input.up, { x: 0, y: 1, z: 0 }), { x: 0, y: 1, z: 0 });
  if (Math.abs(dot(normal, up)) > 0.995) up = normalize(cross(normal, { x: 1, y: 0, z: 0 }), { x: 0, y: 1, z: 0 });
  return {
    ...clonePlain(input),
    id,
    label: normalizeToken(input.label) || undefined,
    role: input.role === 'source' || input.role === 'target' ? input.role : 'both',
    position: normalizeVector(input.position),
    positionMode: input.positionMode === 'scaled' || input.positionMode === 'bounds'
      ? input.positionMode
      : 'fixed',
    normal,
    up,
    provides: normalizeTokens(input.provides),
    accepts: normalizeTokens(input.accepts),
    snapDistance: positive(input.snapDistance, ASUKA_ASSEMBLY_DEFAULT_SETTINGS.snap.distance!),
    maxConnections: Math.max(1, Math.floor(positive(input.maxConnections, 1))),
    alignRotation: input.alignRotation !== false,
  };
};

const normalizeAdjacencyRule = (
  input: Partial<AsukaAssemblyAdjacencyRule> | null | undefined,
  index: number,
): AsukaAssemblyAdjacencyRule | null => {
  if (!input || typeof input !== 'object') return null;
  const allowed = ['requires-connection', 'requires-neighbor', 'forbids-neighbor', 'min-gap', 'max-gap'];
  if (!allowed.includes(String(input.type))) return null;
  return {
    ...clonePlain(input),
    id: normalizeToken(input.id) || `adjacency-${index + 1}`,
    label: normalizeToken(input.label) || undefined,
    type: input.type!,
    target: normalizeSelector(input.target),
    minDistance: input.minDistance == null ? undefined : Math.max(0, finite(input.minDistance)),
    maxDistance: input.maxDistance == null ? undefined : Math.max(0, finite(input.maxDistance)),
    severity: input.severity === 'warning' ? 'warning' : 'error',
    message: normalizeToken(input.message) || undefined,
  };
};

const normalizeSelector = (input?: AsukaAssemblySelector | null): AsukaAssemblySelector | undefined => {
  if (!input || typeof input !== 'object') return undefined;
  const output = {
    componentIds: normalizeTokens(input.componentIds),
    catalogIds: normalizeTokens(input.catalogIds),
    categories: normalizeTokens(input.categories),
    tags: normalizeTokens(input.tags),
    connectorTypes: normalizeTokens(input.connectorTypes),
  };
  return Object.values(output).some((items) => items.length) ? output : undefined;
};

export const normalizeAssemblyConnection = (
  input?: Partial<AsukaAssemblyConnection> | null,
): AsukaAssemblyConnection | null => {
  if (!input || typeof input !== 'object') return null;
  const sourceComponentId = normalizeToken(input.source?.componentId);
  const sourceAnchorId = normalizeToken(input.source?.anchorId);
  const targetComponentId = normalizeToken(input.target?.componentId);
  const targetAnchorId = normalizeToken(input.target?.anchorId);
  if (!sourceComponentId || !sourceAnchorId || !targetComponentId || !targetAnchorId || sourceComponentId === targetComponentId) return null;
  const source = { componentId: sourceComponentId, anchorId: sourceAnchorId };
  const target = { componentId: targetComponentId, anchorId: targetAnchorId };
  const fallbackId = `connection:${sourceComponentId}:${sourceAnchorId}:${targetComponentId}:${targetAnchorId}`;
  return {
    id: normalizeToken(input.id) || fallbackId,
    source,
    target,
    connectorType: normalizeToken(input.connectorType) || undefined,
    createdAt: normalizeToken(input.createdAt) || undefined,
    metadata: input.metadata && typeof input.metadata === 'object' ? clonePlain(input.metadata) : undefined,
  };
};

export const normalizeAssemblyStackLink = (
  input?: Partial<AsukaAssemblyStackLink> | null,
): AsukaAssemblyStackLink | null => {
  if (!input || typeof input !== 'object') return null;
  const lowerComponentId = normalizeToken(input.lowerComponentId);
  const upperComponentId = normalizeToken(input.upperComponentId);
  if (!lowerComponentId || !upperComponentId || lowerComponentId === upperComponentId) return null;
  const widthAxis: AsukaAssemblyStackWidthAxis = input.widthAxis === 'x' || input.widthAxis === 'z'
    ? input.widthAxis
    : 'auto';
  const upperMaxWidth = finite(input.upperMaxWidth, 0);
  return {
    id: normalizeToken(input.id) || `stack:${lowerComponentId}:${upperComponentId}`,
    lowerComponentId,
    upperComponentId,
    widthAxis,
    gap: Math.max(0, finite(input.gap, 0)),
    syncWidth: input.syncWidth !== false,
    ...(upperMaxWidth > EPSILON ? { upperMaxWidth } : {}),
    createdAt: normalizeToken(input.createdAt) || undefined,
    metadata: input.metadata && typeof input.metadata === 'object' ? clonePlain(input.metadata) : undefined,
  };
};

/**
 * Keep vertical stacks deterministic: an instance may have at most one direct
 * support and one direct child, while chains remain valid and cycles are dropped.
 */
export const normalizeAssemblyStackLinks = (
  input?: Array<Partial<AsukaAssemblyStackLink> | null | undefined> | null,
): AsukaAssemblyStackLink[] => {
  const output: AsukaAssemblyStackLink[] = [];
  const ids = new Set<string>();
  const byLower = new Map<string, AsukaAssemblyStackLink>();
  const upperIds = new Set<string>();
  for (const candidate of Array.isArray(input) ? input : []) {
    const link = normalizeAssemblyStackLink(candidate);
    if (!link || ids.has(link.id) || byLower.has(link.lowerComponentId) || upperIds.has(link.upperComponentId)) {
      continue;
    }
    let cursor = link.upperComponentId;
    const visited = new Set<string>();
    let createsCycle = cursor === link.lowerComponentId;
    while (!createsCycle && !visited.has(cursor)) {
      visited.add(cursor);
      const next = byLower.get(cursor)?.upperComponentId;
      if (!next) break;
      cursor = next;
      createsCycle = cursor === link.lowerComponentId;
    }
    if (createsCycle) continue;
    output.push(link);
    ids.add(link.id);
    byLower.set(link.lowerComponentId, link);
    upperIds.add(link.upperComponentId);
  }
  return output;
};

export const getAssemblyStackWidthAxis = (
  lower: Pick<AsukaAssemblyComponent, 'bounds'>,
  upper: Pick<AsukaAssemblyComponent, 'bounds'>,
  requestedAxis: AsukaAssemblyStackWidthAxis = 'auto',
): 'x' | 'z' => {
  if (requestedAxis === 'x' || requestedAxis === 'z') return requestedAxis;
  const lowerX = Math.max(0, finite(lower.bounds?.size?.x));
  const lowerZ = Math.max(0, finite(lower.bounds?.size?.z));
  if (Math.abs(lowerX - lowerZ) > EPSILON) return lowerX >= lowerZ ? 'x' : 'z';
  return finite(upper.bounds?.size?.x) >= finite(upper.bounds?.size?.z) ? 'x' : 'z';
};

export const getAssemblyStackTargetWidth = (
  lower: Pick<AsukaAssemblyComponent, 'bounds'>,
  upper: Pick<AsukaAssemblyComponent, 'bounds'>,
  link: Pick<AsukaAssemblyStackLink, 'widthAxis' | 'upperMaxWidth' | 'syncWidth'>,
): { axis: 'x' | 'z'; width: number } => {
  const axis = getAssemblyStackWidthAxis(lower, upper, link.widthAxis);
  const currentUpperWidth = Math.max(EPSILON, finite(upper.bounds?.size?.[axis], EPSILON));
  const maximum = positive(link.upperMaxWidth, currentUpperWidth);
  return {
    axis,
    width: link.syncWidth === false
      ? Math.min(currentUpperWidth, maximum)
      : Math.max(EPSILON, Math.min(finite(lower.bounds?.size?.[axis], currentUpperWidth), maximum)),
  };
};

export const getAssemblyStackAlignmentDelta = (
  lower: Pick<AsukaAssemblyComponent, 'bounds'>,
  upper: Pick<AsukaAssemblyComponent, 'bounds'>,
  gapInput = 0,
): AsukaAssemblyVector3 => ({
  x: finite(lower.bounds?.center?.x) - finite(upper.bounds?.center?.x),
  y: finite(lower.bounds?.max?.y) + Math.max(0, finite(gapInput)) - finite(upper.bounds?.min?.y),
  z: finite(lower.bounds?.center?.z) - finite(upper.bounds?.center?.z),
});

const signedPolygonArea = (points: Array<{ x: number; z: number }>): number => {
  let area = 0;
  for (let index = 0; index < points.length; index += 1) {
    const current = points[index];
    const next = points[(index + 1) % points.length];
    area += current.x * next.z - next.x * current.z;
  }
  return area * 0.5;
};

const getPolygonCenter = (points: Array<{ x: number; z: number }>): { x: number; z: number } => {
  if (!points.length) return { x: 0, z: 0 };
  const signedArea = signedPolygonArea(points);
  if (Math.abs(signedArea) <= EPSILON) {
    return {
      x: points.reduce((total, point) => total + point.x, 0) / points.length,
      z: points.reduce((total, point) => total + point.z, 0) / points.length,
    };
  }
  let x = 0;
  let z = 0;
  for (let index = 0; index < points.length; index += 1) {
    const current = points[index];
    const next = points[(index + 1) % points.length];
    const crossValue = current.x * next.z - next.x * current.z;
    x += (current.x + next.x) * crossValue;
    z += (current.z + next.z) * crossValue;
  }
  const scaleValue = 1 / (6 * signedArea);
  return { x: x * scaleValue, z: z * scaleValue };
};

const getPlanSegments = (planInput?: AsukaAssemblyPlanDefinition | null) => {
  const plan = normalizePlan(planInput);
  const roomPoints = (plan.floors ?? []).flatMap((surface) => surface.points);
  const roomCenter = getPolygonCenter(roomPoints.length ? roomPoints : plan.points);
  if ((plan.walls?.length ?? 0) > 0) {
    return (plan.walls ?? []).flatMap((wall, index) => {
      const start = wall.start;
      const end = wall.end;
      const dx = end.x - start.x;
      const dz = end.z - start.z;
      const segmentLength = Math.sqrt(dx * dx + dz * dz);
      if (segmentLength <= EPSILON) return [];
      const tangent = { x: dx / segmentLength, y: 0, z: dz / segmentLength };
      const firstNormal = { x: -tangent.z, y: 0, z: tangent.x };
      const midpoint = { x: (start.x + end.x) * 0.5, z: (start.z + end.z) * 0.5 };
      const towardRoom = { x: roomCenter.x - midpoint.x, z: roomCenter.z - midpoint.z };
      const inward = firstNormal.x * towardRoom.x + firstNormal.z * towardRoom.z >= 0
        ? firstNormal
        : scale(firstNormal, -1);
      return [{ index, start, end, tangent, inward, length: segmentLength }];
    });
  }
  const points = plan.points;
  const count = plan.closed === false ? Math.max(0, points.length - 1) : points.length;
  const ccw = signedPolygonArea(points) >= 0;
  return Array.from({ length: count }, (_, index) => {
    const start = points[index];
    const end = points[(index + 1) % points.length];
    const dx = end.x - start.x;
    const dz = end.z - start.z;
    const segmentLength = Math.max(EPSILON, Math.sqrt(dx * dx + dz * dz));
    const tangent = { x: dx / segmentLength, y: 0, z: dz / segmentLength };
    const inward = ccw
      ? { x: -tangent.z, y: 0, z: tangent.x }
      : { x: tangent.z, y: 0, z: -tangent.x };
    return { index, start, end, tangent, inward, length: segmentLength };
  });
};

const closestPointOnSegment = (
  point: AsukaAssemblyVector3,
  start: { x: number; z: number },
  end: { x: number; z: number },
): { point: AsukaAssemblyVector3; t: number; distance: number } => {
  const dx = end.x - start.x;
  const dz = end.z - start.z;
  const lengthSquared = dx * dx + dz * dz;
  const t = lengthSquared > EPSILON
    ? Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.z - start.z) * dz) / lengthSquared))
    : 0;
  const output = { x: start.x + dx * t, y: point.y, z: start.z + dz * t };
  return { point: output, t, distance: distance(point, output) };
};

const pointInsidePolygon = (point: { x: number; z: number }, points: Array<{ x: number; z: number }>): boolean => {
  for (let index = 0; index < points.length; index += 1) {
    const start = points[index];
    const end = points[(index + 1) % points.length];
    const dx = end.x - start.x;
    const dz = end.z - start.z;
    const lengthSquared = dx * dx + dz * dz;
    if (lengthSquared <= EPSILON) continue;
    const t = ((point.x - start.x) * dx + (point.z - start.z) * dz) / lengthSquared;
    if (t < -EPSILON || t > 1 + EPSILON) continue;
    const projectedX = start.x + dx * t;
    const projectedZ = start.z + dz * t;
    if (Math.hypot(projectedX - point.x, projectedZ - point.z) <= EPSILON) return true;
  }
  let inside = false;
  for (let current = 0, previous = points.length - 1; current < points.length; previous = current, current += 1) {
    const a = points[current];
    const b = points[previous];
    const intersects = ((a.z > point.z) !== (b.z > point.z))
      && (point.x < ((b.x - a.x) * (point.z - a.z)) / ((b.z - a.z) || EPSILON) + a.x);
    if (intersects) inside = !inside;
  }
  return inside;
};

const assemblyBoundsIntersectsPolygon = (
  bounds: AsukaAssemblyBounds,
  points: AsukaAssemblyPlanPoint[],
): boolean => {
  if (points.length < 3) return true;
  const footprint = [
    { x: bounds.min.x, z: bounds.min.z },
    { x: bounds.max.x, z: bounds.min.z },
    { x: bounds.max.x, z: bounds.max.z },
    { x: bounds.min.x, z: bounds.max.z },
  ];
  if (footprint.some((point) => pointInsidePolygon(point, points))) return true;
  if (points.some((point) => (
    point.x >= bounds.min.x - EPSILON
    && point.x <= bounds.max.x + EPSILON
    && point.z >= bounds.min.z - EPSILON
    && point.z <= bounds.max.z + EPSILON
  ))) return true;
  return footprint.some((start, index) => {
    const end = footprint[(index + 1) % footprint.length];
    return points.some((polygonStart, polygonIndex) => assemblySegmentsIntersect(
      start,
      end,
      polygonStart,
      points[(polygonIndex + 1) % points.length],
    ));
  });
};

/**
 * Resolve the top altitude for a newly drawn floor from the lowest product
 * touching its footprint. With no footprint, every supplied product is used.
 */
export const getAssemblyFloorElevation = (
  boundsInput: Array<AsukaAssemblyBounds | null | undefined> | null | undefined,
  pointsInput?: AsukaAssemblyPlanPoint[] | null,
  fallbackInput: unknown = 0,
): number => {
  const fallback = finite(fallbackInput);
  const points = Array.isArray(pointsInput)
    ? pointsInput.map((point) => ({ x: finite(point?.x), z: finite(point?.z) }))
    : [];
  const candidates = (Array.isArray(boundsInput) ? boundsInput : []).filter(
    (bounds): bounds is AsukaAssemblyBounds => Boolean(
      bounds
      && Number.isFinite(Number(bounds.min?.x))
      && Number.isFinite(Number(bounds.min?.y))
      && Number.isFinite(Number(bounds.min?.z))
      && Number.isFinite(Number(bounds.max?.x))
      && Number.isFinite(Number(bounds.max?.z)),
    ),
  );
  const scoped = points.length >= 3
    ? candidates.filter((bounds) => assemblyBoundsIntersectsPolygon(bounds, points))
    : candidates;
  if (!scoped.length) return fallback;
  return Math.min(...scoped.map((bounds) => finite(bounds.min.y, fallback)));
};

const makeBounds = (min: AsukaAssemblyVector3, max: AsukaAssemblyVector3): AsukaAssemblyBounds => {
  const normalizedMin = {
    x: Math.min(min.x, max.x),
    y: Math.min(min.y, max.y),
    z: Math.min(min.z, max.z),
  };
  const normalizedMax = {
    x: Math.max(min.x, max.x),
    y: Math.max(min.y, max.y),
    z: Math.max(min.z, max.z),
  };
  return {
    min: normalizedMin,
    max: normalizedMax,
    center: scale(add(normalizedMin, normalizedMax), 0.5),
    size: subtract(normalizedMax, normalizedMin),
  };
};

const transformBounds = (
  bounds: AsukaAssemblyBounds,
  currentTransform: AsukaAssemblyTransform,
  nextTransform: AsukaAssemblyTransform,
): AsukaAssemblyBounds => {
  const currentPosition = normalizeVector(currentTransform.position);
  const nextPosition = normalizeVector(nextTransform.position);
  const currentRotation = normalizeVector(currentTransform.rotation);
  const nextRotation = normalizeVector(nextTransform.rotation);
  const rotationDelta = subtract(nextRotation, currentRotation);
  const positionDelta = subtract(nextPosition, currentPosition);
  const corners: AsukaAssemblyVector3[] = [];
  for (const x of [bounds.min.x, bounds.max.x]) {
    for (const y of [bounds.min.y, bounds.max.y]) {
      for (const z of [bounds.min.z, bounds.max.z]) {
        const local = subtract({ x, y, z }, bounds.center);
        corners.push(add(add(bounds.center, rotateAssemblyVector(local, rotationDelta)), positionDelta));
      }
    }
  }
  const min = {
    x: Math.min(...corners.map((corner) => corner.x)),
    y: Math.min(...corners.map((corner) => corner.y)),
    z: Math.min(...corners.map((corner) => corner.z)),
  };
  const max = {
    x: Math.max(...corners.map((corner) => corner.x)),
    y: Math.max(...corners.map((corner) => corner.y)),
    z: Math.max(...corners.map((corner) => corner.z)),
  };
  return makeBounds(min, max);
};

const boundsIntersect = (left: AsukaAssemblyBounds, right: AsukaAssemblyBounds, margin = 0): boolean =>
  left.max.x > right.min.x - margin + EPSILON
  && left.min.x < right.max.x + margin - EPSILON
  && left.max.y > right.min.y - margin + EPSILON
  && left.min.y < right.max.y + margin - EPSILON
  && left.max.z > right.min.z - margin + EPSILON
  && left.min.z < right.max.z + margin - EPSILON;

const distanceBetweenBounds = (left: AsukaAssemblyBounds, right: AsukaAssemblyBounds): number => {
  const dx = Math.max(0, right.min.x - left.max.x, left.min.x - right.max.x);
  const dy = Math.max(0, right.min.y - left.max.y, left.min.y - right.max.y);
  const dz = Math.max(0, right.min.z - left.max.z, left.min.z - right.max.z);
  return Math.sqrt(dx * dx + dy * dy + dz * dz);
};

const boundsHavePhysicalOverlap = (
  left: AsukaAssemblyBounds,
  right: AsukaAssemblyBounds,
): boolean => (
  Math.min(left.max.x, right.max.x) - Math.max(left.min.x, right.min.x) > EPSILON
  && Math.min(left.max.y, right.max.y) - Math.max(left.min.y, right.min.y) > EPSILON
  && Math.min(left.max.z, right.max.z) - Math.max(left.min.z, right.min.z) > EPSILON
);

const transformWorldAnchor = (
  anchor: AsukaAssemblyWorldAnchor,
  currentBounds: AsukaAssemblyBounds,
  currentTransform: AsukaAssemblyTransform,
  nextTransform: AsukaAssemblyTransform,
): AsukaAssemblyWorldAnchor => {
  const currentPosition = normalizeVector(currentTransform.position);
  const nextPosition = normalizeVector(nextTransform.position);
  const currentRotation = normalizeVector(currentTransform.rotation);
  const nextRotation = normalizeVector(nextTransform.rotation);
  const rotationDelta = subtract(nextRotation, currentRotation);
  const positionDelta = subtract(nextPosition, currentPosition);
  return {
    ...anchor,
    position: add(add(currentBounds.center, rotateAssemblyVector(subtract(anchor.position, currentBounds.center), rotationDelta)), positionDelta),
    normal: normalize(rotateAssemblyVector(anchor.normal, rotationDelta)),
    up: normalize(rotateAssemblyVector(anchor.up, rotationDelta), { x: 0, y: 1, z: 0 }),
  };
};

export const createAssemblyWorldAnchors = (
  componentId: string,
  definition: AsukaAssemblyComponentDefinition | null | undefined,
  transformInput: AsukaAssemblyTransform | null | undefined,
  pivotInput: AsukaAssemblyVector3,
  connections: AsukaAssemblyConnection[] = [],
  geometryScaleInput: AsukaAssemblyVector3 = { x: 1, y: 1, z: 1 },
  geometrySizeInput: AsukaAssemblyVector3 = { x: 1, y: 1, z: 1 },
): AsukaAssemblyWorldAnchor[] => {
  const transform = {
    position: normalizeVector(transformInput?.position),
    rotation: normalizeVector(transformInput?.rotation),
    scale: normalizeVector(transformInput?.scale, { x: 1, y: 1, z: 1 }),
  };
  const pivot = normalizeVector(pivotInput);
  const geometryScale = normalizeVector(geometryScaleInput, { x: 1, y: 1, z: 1 });
  const geometrySize = normalizeVector(geometrySizeInput, { x: 1, y: 1, z: 1 });
  const componentPosition = normalizeVector(transform.position);
  const basePivot = subtract(pivot, componentPosition);
  return (normalizeAssemblyComponentDefinition(definition)?.anchors ?? []).map((anchor) => {
    const resizeScale = anchor.positionMode === 'scaled'
      ? geometryScale
      : { x: 1, y: 1, z: 1 };
    const localPosition = anchor.positionMode === 'bounds'
      ? {
          x: anchor.position.x * geometrySize.x,
          y: anchor.position.y * geometrySize.y,
          z: anchor.position.z * geometrySize.z,
        }
      : anchor.position;
    const scaledPosition = {
      x: localPosition.x * transform.scale.x * resizeScale.x,
      y: localPosition.y * transform.scale.y * resizeScale.y,
      z: localPosition.z * transform.scale.z * resizeScale.z,
    };
    const position = add(add(basePivot, componentPosition), rotateAssemblyVector(scaledPosition, transform.rotation));
    const connectionCount = connections.filter((connection) =>
      (connection.source.componentId === componentId && connection.source.anchorId === anchor.id)
      || (connection.target.componentId === componentId && connection.target.anchorId === anchor.id)).length;
    return {
      id: anchor.id,
      componentId,
      label: anchor.label,
      role: anchor.role ?? 'both',
      position,
      normal: normalize(rotateAssemblyVector(anchor.normal ?? { x: 0, y: 0, z: 1 }, transform.rotation)),
      up: normalize(rotateAssemblyVector(anchor.up ?? { x: 0, y: 1, z: 0 }, transform.rotation), { x: 0, y: 1, z: 0 }),
      provides: normalizeTokens(anchor.provides),
      accepts: normalizeTokens(anchor.accepts),
      snapDistance: positive(anchor.snapDistance, ASUKA_ASSEMBLY_DEFAULT_SETTINGS.snap.distance!),
      maxConnections: Math.max(1, Math.floor(positive(anchor.maxConnections, 1))),
      connectionCount,
      alignRotation: anchor.alignRotation !== false,
      metadata: anchor.metadata && typeof anchor.metadata === 'object' ? clonePlain(anchor.metadata) : undefined,
    };
  });
};

const rolesCompatible = (left: AsukaAssemblyWorldAnchor, right: AsukaAssemblyWorldAnchor): boolean =>
  left.role === 'both' || right.role === 'both' || left.role !== right.role;

export const getCompatibleAssemblyConnectorType = (
  source: Pick<AsukaAssemblyWorldAnchor, 'role' | 'provides' | 'accepts' | 'connectionCount' | 'maxConnections'>,
  target: Pick<AsukaAssemblyWorldAnchor, 'role' | 'provides' | 'accepts' | 'connectionCount' | 'maxConnections'>,
): string | null => {
  if (!rolesCompatible(source as AsukaAssemblyWorldAnchor, target as AsukaAssemblyWorldAnchor)) return null;
  if (source.connectionCount >= source.maxConnections || target.connectionCount >= target.maxConnections) return null;
  const sourceProvides = normalizeTokens(source.provides);
  const sourceAccepts = normalizeTokens(source.accepts);
  const targetProvides = normalizeTokens(target.provides);
  const targetAccepts = normalizeTokens(target.accepts);
  const first = sourceProvides.find((token) => !targetAccepts.length || targetAccepts.includes(token));
  const second = targetProvides.find((token) => !sourceAccepts.length || sourceAccepts.includes(token));
  if (first && second) return first === second ? first : `${first}:${second}`;
  if (first) return first;
  if (second) return second;
  if (!sourceProvides.length && !sourceAccepts.length && !targetProvides.length && !targetAccepts.length) return 'generic';
  return null;
};

const getConnectionBetween = (
  connections: AsukaAssemblyConnection[],
  leftId: string,
  rightId: string,
): AsukaAssemblyConnection | null => connections.find((connection) =>
  (connection.source.componentId === leftId && connection.target.componentId === rightId)
  || (connection.source.componentId === rightId && connection.target.componentId === leftId)) ?? null;

const isCornerConnection = (
  connection: AsukaAssemblyConnection,
  components: AsukaAssemblyComponent[],
): boolean => {
  if (String(connection.metadata?.snapKind ?? '').trim() === 'corner') return true;
  const sourceComponent = components.find((component) => component.id === connection.source.componentId);
  const targetComponent = components.find((component) => component.id === connection.target.componentId);
  const source = sourceComponent?.anchors.find((anchor) => anchor.id === connection.source.anchorId);
  const target = targetComponent?.anchors.find((anchor) => anchor.id === connection.target.anchorId);
  if (!source || !target) return false;
  const sameHeight = Math.abs(source.position.y - target.position.y) <= 0.05;
  const facingScore = dot(normalize(source.normal), scale(normalize(target.normal), -1));
  return sameHeight && facingScore < 0.94;
};

const matchesSelector = (
  component: AsukaAssemblyComponent,
  selector?: AsukaAssemblySelector | null,
): boolean => {
  if (!selector) return true;
  const checks: boolean[] = [];
  const componentIds = normalizeTokens(selector.componentIds);
  const catalogIds = normalizeTokens(selector.catalogIds);
  const categories = normalizeTokens(selector.categories);
  const tags = normalizeTokens(selector.tags);
  if (componentIds.length) checks.push(componentIds.includes(component.id));
  if (catalogIds.length) checks.push(catalogIds.includes(normalizeToken(component.catalogId)));
  if (categories.length) checks.push(categories.includes(normalizeToken(component.category)));
  if (tags.length) checks.push(tags.some((tag) => component.tags.includes(tag)));
  return checks.length ? checks.every(Boolean) : true;
};

const issue = (
  code: string,
  severity: 'error' | 'warning',
  message: string,
  componentIds: string[],
  metadata?: Record<string, unknown>,
): AsukaAssemblyIssue => ({
  id: `${code}:${componentIds.join(':')}:${normalizeToken(metadata?.ruleId ?? metadata?.clearanceId)}`,
  code,
  severity,
  message,
  componentIds,
  metadata,
});

const getComponentCollisionBounds = (component: AsukaAssemblyComponent): AsukaAssemblyBounds => {
  const custom = component.definition?.collision?.bounds;
  if (!custom?.size) return component.bounds;
  const center = add(component.bounds.center, rotateAssemblyVector(normalizeVector(custom.center), component.transform.rotation));
  const half = scale(normalizeVector(custom.size), 0.5);
  return makeBounds(subtract(center, half), add(center, half));
};

const getClearanceBounds = (
  component: AsukaAssemblyComponent,
  clearance: NonNullable<AsukaAssemblyComponentDefinition['clearances']>[number],
): AsukaAssemblyBounds => {
  const geometryScale = normalizeVector(component.geometryScale, { x: 1, y: 1, z: 1 });
  const localCenter = normalizeVector(clearance.center);
  const scaledCenter = clearance.positionMode === 'scaled'
    ? {
        x: localCenter.x * geometryScale.x,
        y: localCenter.y * geometryScale.y,
        z: localCenter.z * geometryScale.z,
      }
    : localCenter;
  const center = add(component.bounds.center, rotateAssemblyVector(scaledCenter, component.transform.rotation));
  const configuredSize = normalizeVector(clearance.size, { x: 0.001, y: 0.001, z: 0.001 });
  const size = clearance.sizeMode === 'scaled'
    ? {
        x: configuredSize.x * geometryScale.x,
        y: configuredSize.y * geometryScale.y,
        z: configuredSize.z * geometryScale.z,
      }
    : configuredSize;
  const half = scale(size, 0.5);
  return makeBounds(subtract(center, half), add(center, half));
};

const shouldGroupsCollide = (
  sourceGroup: string | undefined,
  sourceWith: string[] | undefined,
  targetGroup: string | undefined,
): boolean => {
  const allowed = normalizeTokens(sourceWith);
  if (!allowed.length) return true;
  const group = normalizeToken(targetGroup) || 'default';
  return allowed.includes('*') || allowed.includes(group);
};

export const validateAssembly = (
  stateInput: AsukaAssemblyState,
  componentsInput: AsukaAssemblyComponent[],
): AsukaAssemblyValidation => {
  const state = normalizeAssemblyState(stateInput);
  const settings = normalizeAssemblySettings(state.settings);
  const components = Array.isArray(componentsInput) ? componentsInput : [];
  const issues: AsukaAssemblyIssue[] = [];
  const collisions: AsukaAssemblyValidation['collisions'] = [];

  const plan = normalizePlan(state.plan);
  const placementPolygons = (plan.floors ?? [])
    .map((surface) => surface.points)
    .filter((points) => points.length >= 3);
  if (!placementPolygons.length && plan.closed !== false && plan.points.length >= 3) {
    placementPolygons.push(plan.points);
  }
  if (placementPolygons.length) {
    for (const component of components) {
      const corners = [
        { x: component.bounds.min.x, z: component.bounds.min.z },
        { x: component.bounds.max.x, z: component.bounds.min.z },
        { x: component.bounds.max.x, z: component.bounds.max.z },
        { x: component.bounds.min.x, z: component.bounds.max.z },
      ];
      if (corners.some((corner) =>
        !placementPolygons.some((polygon) => pointInsidePolygon(corner, polygon)))) {
        issues.push(issue(
          'outside-plan',
          'error',
          `${component.label ?? component.id} is outside the configured plan.`,
          [component.id],
        ));
      }
    }
  }

  if (settings.collision.enabled) {
    for (let leftIndex = 0; leftIndex < components.length; leftIndex += 1) {
      const left = components[leftIndex];
      if (left.definition?.collision?.enabled === false) continue;
      for (let rightIndex = leftIndex + 1; rightIndex < components.length; rightIndex += 1) {
        const right = components[rightIndex];
        if (right.definition?.collision?.enabled === false) continue;
        if (!shouldGroupsCollide(left.definition?.collision?.group, left.definition?.collision?.collidesWith, right.definition?.collision?.group)) continue;
        if (!shouldGroupsCollide(right.definition?.collision?.group, right.definition?.collision?.collidesWith, left.definition?.collision?.group)) continue;
        const margin = Math.max(
          settings.collision.margin,
          finite(left.definition?.collision?.margin),
          finite(right.definition?.collision?.margin),
        );
        const leftCollisionBounds = getComponentCollisionBounds(left);
        const rightCollisionBounds = getComponentCollisionBounds(right);
        if (boundsIntersect(leftCollisionBounds, rightCollisionBounds, margin)) {
          const connected = getConnectionBetween(state.connections ?? [], left.id, right.id);
          const physicalOverlap = boundsHavePhysicalOverlap(leftCollisionBounds, rightCollisionBounds);
          const permittedContact = Boolean(connected) && !physicalOverlap;
          const permittedCornerJoin = Boolean(
            connected
            && physicalOverlap
            && settings.adjacency.autoCorners
            && isCornerConnection(connected, components),
          );
          if (!permittedContact && !permittedCornerJoin) {
            collisions.push({ componentId: left.id, otherComponentId: right.id });
            issues.push(issue(
              'collision',
              settings.collision.policy === 'allow' ? 'warning' : 'error',
              `${left.label ?? left.id} collides with ${right.label ?? right.id}.`,
              [left.id, right.id],
            ));
          }
        }
      }
    }
  }

  if (settings.collision.includeClearances) {
    for (const component of components) {
      for (const clearance of component.definition?.clearances ?? []) {
        const clearanceBounds = getClearanceBounds(component, clearance);
        for (const other of components) {
          if (other.id === component.id) continue;
          if (!shouldGroupsCollide(clearance.group, clearance.collidesWith, other.definition?.collision?.group)) continue;
          if (!boundsIntersect(clearanceBounds, getComponentCollisionBounds(other))) continue;
          collisions.push({ componentId: component.id, otherComponentId: other.id, clearanceId: clearance.id });
          issues.push(issue(
            'clearance',
            clearance.severity === 'warning' ? 'warning' : 'error',
            clearance.message || `${other.label ?? other.id} occupies the ${clearance.label ?? clearance.id} clearance of ${component.label ?? component.id}.`,
            [component.id, other.id],
            { clearanceId: clearance.id },
          ));
        }
      }
    }
  }

  if (settings.adjacency.enabled) {
    for (const component of components) {
      for (const rule of component.definition?.adjacency ?? []) {
        const candidates = components.filter((candidate) => candidate.id !== component.id && matchesSelector(candidate, rule.target));
        const connected = candidates.filter((candidate) => getConnectionBetween(state.connections ?? [], component.id, candidate.id));
        const distances = candidates.map((candidate) => ({ component: candidate, distance: distanceBetweenBounds(component.bounds, candidate.bounds) }));
        const severity = rule.severity === 'warning' ? 'warning' : 'error';
        if (rule.type === 'requires-connection' && !connected.length) {
          issues.push(issue('missing-connection', severity, rule.message || `${component.label ?? component.id} requires a compatible connection.`, [component.id], { ruleId: rule.id }));
        } else if (rule.type === 'requires-neighbor' && !distances.some((entry) => entry.distance <= finite(rule.maxDistance, 0.3))) {
          issues.push(issue('missing-neighbor', severity, rule.message || `${component.label ?? component.id} requires a nearby compatible component.`, [component.id], { ruleId: rule.id }));
        } else if (rule.type === 'forbids-neighbor') {
          const forbidden = distances.find((entry) => entry.distance <= finite(rule.maxDistance, 0.3));
          if (forbidden) issues.push(issue('forbidden-neighbor', severity, rule.message || `${component.label ?? component.id} cannot be placed next to ${forbidden.component.label ?? forbidden.component.id}.`, [component.id, forbidden.component.id], { ruleId: rule.id }));
        } else if (rule.type === 'min-gap') {
          const tooClose = distances.find((entry) => entry.distance < finite(rule.minDistance));
          if (tooClose) issues.push(issue('gap-too-small', severity, rule.message || `${component.label ?? component.id} requires more clearance from ${tooClose.component.label ?? tooClose.component.id}.`, [component.id, tooClose.component.id], { ruleId: rule.id }));
        } else if (rule.type === 'max-gap') {
          const nearEnough = distances.some((entry) => entry.distance <= finite(rule.maxDistance, 0.3));
          if (!nearEnough) issues.push(issue('gap-too-large', severity, rule.message || `${component.label ?? component.id} is too far from a compatible component.`, [component.id], { ruleId: rule.id }));
        }
      }
    }
  }

  const unique = Array.from(new Map(issues.map((entry) => [entry.id, entry])).values());
  return {
    valid: unique.every((entry) => entry.severity !== 'error'),
    issues: unique,
    collisions,
  };
};

const getComponentWithPlacement = (
  component: AsukaAssemblyComponent,
  transform: AsukaAssemblyTransform,
): AsukaAssemblyComponent => ({
  ...component,
  transform: clonePlain(transform),
  bounds: transformBounds(component.bounds, component.transform, transform),
  anchors: component.anchors.map((anchor) => transformWorldAnchor(anchor, component.bounds, component.transform, transform)),
});

const findBestAnchorSnap = (
  component: AsukaAssemblyComponent,
  transformInput: AsukaAssemblyTransform,
  components: AsukaAssemblyComponent[],
  state: AsukaAssemblyState,
  input: AsukaAssemblyPlacementInput,
): { transform: AsukaAssemblyTransform; snap: AsukaAssemblySnapResult; connectorType: string } | null => {
  const settings = normalizeAssemblySettings(state.settings);
  let candidate = getComponentWithPlacement(component, transformInput);
  const sourceAnchors = input.sourceAnchorId
    ? candidate.anchors.filter((anchor) => anchor.id === input.sourceAnchorId)
    : candidate.anchors;
  const targetComponents = input.targetComponentId
    ? components.filter((entry) => entry.id === input.targetComponentId)
    : components.filter((entry) => entry.id !== component.id);
  let best: { source: AsukaAssemblyWorldAnchor; target: AsukaAssemblyWorldAnchor; distance: number; connectorType: string } | null = null;
  for (const source of sourceAnchors) {
    for (const targetComponent of targetComponents) {
      const targets = input.targetAnchorId
        ? targetComponent.anchors.filter((anchor) => anchor.id === input.targetAnchorId)
        : targetComponent.anchors;
      for (const target of targets) {
        const connectorType = getCompatibleAssemblyConnectorType(source, target);
        if (!connectorType) continue;
        const snapDistance = Math.max(settings.snap.distance, source.snapDistance, target.snapDistance);
        const currentDistance = distance(source.position, target.position);
        if (currentDistance > snapDistance || (best && currentDistance >= best.distance)) continue;
        best = { source, target, distance: currentDistance, connectorType };
      }
    }
  }
  if (!best) return null;

  let transform = clonePlain(transformInput);
  const desired = scale(best.target.normal, -1);
  const sameHeight = Math.abs(best.source.position.y - best.target.position.y)
    <= Math.max(0.05, settings.grid.size);
  const alreadyFacingTarget = dot(normalize(best.source.normal), normalize(desired)) >= 0.94;
  const cornerJoin = settings.adjacency.autoCorners
    && sameHeight
    && (
      best.source.alignRotation === false
      || best.target.alignRotation === false
      || !alreadyFacingTarget
    );
  if (settings.snap.rotation && !cornerJoin) {
    const currentYaw = Math.atan2(best.source.normal.x, best.source.normal.z);
    const desiredYaw = Math.atan2(desired.x, desired.z);
    const rotation = normalizeVector(transform.rotation);
    transform = { ...transform, rotation: { ...rotation, y: rotation.y + desiredYaw - currentYaw } };
    candidate = getComponentWithPlacement(component, transform);
    const rotatedSource = candidate.anchors.find((anchor) => anchor.id === best!.source.id) ?? best.source;
    best = { ...best, source: rotatedSource, distance: distance(rotatedSource.position, best.target.position) };
  }
  const offset = subtract(best.target.position, best.source.position);
  transform = {
    ...transform,
    position: add(normalizeVector(transform.position), offset),
  };
  return {
    transform,
    connectorType: best.connectorType,
    snap: {
      kind: cornerJoin ? 'corner' : 'anchor',
      distance: best.distance,
      source: { componentId: component.id, anchorId: best.source.id },
      target: { componentId: best.target.componentId, anchorId: best.target.id },
      position: normalizeVector(transform.position),
      rotation: normalizeVector(transform.rotation),
    },
  };
};

const findBoundarySnap = (
  component: AsukaAssemblyComponent,
  transformInput: AsukaAssemblyTransform,
  state: AsukaAssemblyState,
): { transform: AsukaAssemblyTransform; snap: AsukaAssemblySnapResult } | null => {
  const settings = normalizeAssemblySettings(state.settings);
  const candidate = getComponentWithPlacement(component, transformInput);
  let best: ReturnType<typeof closestPointOnSegment> & ReturnType<typeof getPlanSegments>[number] | null = null;
  for (const segment of getPlanSegments(state.plan)) {
    const closest = closestPointOnSegment(candidate.bounds.center, segment.start, segment.end);
    if (closest.distance > settings.snap.distance || (best && closest.distance >= best.distance)) continue;
    best = { ...segment, ...closest };
  }
  if (!best) return null;
  const extent = Math.abs(best.inward.x) * candidate.bounds.size.x * 0.5
    + Math.abs(best.inward.y) * candidate.bounds.size.y * 0.5
    + Math.abs(best.inward.z) * candidate.bounds.size.z * 0.5;
  const desiredCenter = add(best.point, scale(best.inward, extent + normalizeAssemblySettings(state.settings).collision.margin));
  const centerOffset = subtract(desiredCenter, candidate.bounds.center);
  const rotation = normalizeVector(transformInput.rotation);
  const autoCorner = settings.adjacency.autoCorners && (best.t <= 0.04 || best.t >= 0.96);
  const transform: AsukaAssemblyTransform = {
    ...transformInput,
    position: add(normalizeVector(transformInput.position), centerOffset),
    rotation: settings.snap.rotation
      ? { ...rotation, y: Math.atan2(best.inward.x, best.inward.z) }
      : rotation,
  };
  return {
    transform,
    snap: {
      kind: autoCorner ? 'corner' : 'boundary',
      distance: best.distance,
      boundaryIndex: best.index,
      position: normalizeVector(transform.position),
      rotation: normalizeVector(transform.rotation),
    },
  };
};

const applyGridSnap = (
  transformInput: AsukaAssemblyTransform,
  settings: AsukaNormalizedAssemblySettings,
  axis: NonNullable<AsukaAssemblyPlacementInput['axis']> = 'free',
): { transform: AsukaAssemblyTransform; snap: AsukaAssemblySnapResult } => {
  const position = normalizeVector(transformInput.position);
  const gridSize = positive(settings.grid.size, 0.1);
  const snapped = {
    x: axis === 'vertical' ? position.x : Math.round(position.x / gridSize) * gridSize,
    y: axis === 'vertical' || (axis === 'free' && settings.grid.snapY)
      ? Math.round(position.y / gridSize) * gridSize
      : position.y,
    z: axis === 'vertical' ? position.z : Math.round(position.z / gridSize) * gridSize,
  };
  return {
    transform: { ...transformInput, position: snapped },
    snap: {
      kind: 'grid',
      distance: distance(position, snapped),
      position: snapped,
      rotation: normalizeVector(transformInput.rotation),
    },
  };
};

const makeConnectionFromSnap = (
  snap: AsukaAssemblySnapResult,
  connectorType?: string,
): AsukaAssemblyConnection | null => {
  if ((snap.kind !== 'anchor' && snap.kind !== 'corner') || !snap.source || !snap.target) return null;
  return normalizeAssemblyConnection({
    id: `connection:${snap.source.componentId}:${snap.source.anchorId}:${snap.target.componentId}:${snap.target.anchorId}`,
    source: snap.source,
    target: snap.target,
    connectorType,
    createdAt: new Date().toISOString(),
    metadata: {
      snapKind: snap.kind,
    },
  });
};

export const getAssemblyComponentSupport = (
  stateInput: AsukaAssemblyState,
  component: AsukaAssemblyComponent,
  requestedSupport?: AsukaAssemblySupportMode,
): AsukaAssemblySupportMode => normalizeAssemblySupportMode(
  requestedSupport
  ?? stateInput.componentPlacements?.[component.id]?.support
  ?? component.definition?.placement?.support,
);

const applyAssemblySupportSnap = (
  state: AsukaAssemblyState,
  component: AsukaAssemblyComponent,
  transformInput: AsukaAssemblyTransform,
  support: AsukaAssemblySupportMode,
): { transform: AsukaAssemblyTransform; snap: AsukaAssemblySnapResult | null } => {
  if (support === 'free') return { transform: transformInput, snap: null };
  const candidate = getComponentWithPlacement(component, transformInput);
  const plan = state.plan ?? ASUKA_ASSEMBLY_DEFAULT_PLAN;
  const placement = state.componentPlacements?.[component.id]
    ?? component.definition?.placement
    ?? {};
  const supportOffset = finite(placement.supportOffset);
  let position = normalizeVector(transformInput.position);

  if (support === 'floor' || support === 'ceiling') {
    const supportingFloor = (plan.floors ?? []).find((surface) =>
      pointInsidePolygon(
        { x: candidate.bounds.center.x, z: candidate.bounds.center.z },
        surface.points,
      ));
    const floorY = supportingFloor
      ? finite(supportingFloor.y, finite(plan.floorY))
      : finite(plan.floorY);
    const targetY = support === 'ceiling'
      ? floorY + positive(plan.height, 2.6) - supportOffset - candidate.bounds.max.y
      : floorY + supportOffset - candidate.bounds.min.y;
    position = {
      ...position,
      y: position.y + targetY,
    };
  } else {
    const planPoints = Array.isArray(plan.points) ? plan.points : [];
    const fallbackInteriorSide = getAssemblyPolygonSignedArea(planPoints) >= 0
      ? 'left'
      : 'right';
    const explicitWalls = Array.isArray(plan.walls) ? plan.walls : [];
    const walls: AsukaAssemblyWallSurface[] = explicitWalls.length
      ? explicitWalls
      : Array.from({
          length: plan.closed === false
            ? Math.max(0, planPoints.length - 1)
            : planPoints.length,
        }, (_unused, index) => ({
          id: `support-wall-${index + 1}`,
          kind: 'wall',
          start: clonePlain(planPoints[index]),
          end: clonePlain(planPoints[(index + 1) % planPoints.length]),
          thickness: positive(plan.wallThickness, 0.08),
          metadata: { interiorSide: fallbackInteriorSide },
        }));
    let best: {
      distance: number;
      point: AsukaAssemblyPlanPoint;
      normal: AsukaAssemblyPlanPoint;
      thickness: number;
      edgeAligned: boolean;
    } | null = null;
    for (const wall of walls) {
      const start = { x: finite(wall.start?.x), z: finite(wall.start?.z) };
      const end = { x: finite(wall.end?.x), z: finite(wall.end?.z) };
      const dx = end.x - start.x;
      const dz = end.z - start.z;
      const lengthSquared = dx * dx + dz * dz;
      if (lengthSquared <= EPSILON) continue;
      const ratio = Math.max(0, Math.min(1, (
        (candidate.bounds.center.x - start.x) * dx
        + (candidate.bounds.center.z - start.z) * dz
      ) / lengthSquared));
      const point = {
        x: start.x + dx * ratio,
        z: start.z + dz * ratio,
      };
      const length = Math.sqrt(lengthSquared);
      const interiorSide = wall.metadata?.interiorSide === 'right'
        ? 'right'
        : wall.metadata?.interiorSide === 'left'
          ? 'left'
          : fallbackInteriorSide;
      const interiorSign = interiorSide === 'left' ? 1 : -1;
      const normal = {
        x: (-dz / length) * interiorSign,
        z: (dx / length) * interiorSign,
      };
      const wallDistance = Math.hypot(
        candidate.bounds.center.x - point.x,
        candidate.bounds.center.z - point.z,
      );
      if (!best || wallDistance < best.distance) {
        best = {
          distance: wallDistance,
          point,
          normal,
          thickness: positive(wall.thickness, positive(plan.wallThickness, 0.08)),
          edgeAligned: wall.metadata?.alignToFloorEdge === true,
        };
      }
    }
    if (best) {
      const halfExtent = (
        Math.abs(best.normal.x) * candidate.bounds.size.x
        + Math.abs(best.normal.z) * candidate.bounds.size.z
      ) * 0.5;
      const structureInset = best.edgeAligned ? 0 : best.thickness * 0.5;
      const boundaryMargin = Math.max(0.002, finite(state.settings?.collision?.margin));
      const targetCenter = {
        x: best.point.x + best.normal.x * (
          halfExtent + structureInset + supportOffset + boundaryMargin
        ),
        z: best.point.z + best.normal.z * (
          halfExtent + structureInset + supportOffset + boundaryMargin
        ),
      };
      position = {
        ...position,
        x: position.x + targetCenter.x - candidate.bounds.center.x,
        z: position.z + targetCenter.z - candidate.bounds.center.z,
      };
    }
  }

  const distanceToSupport = distance(normalizeVector(transformInput.position), position);
  if (distanceToSupport <= EPSILON) return { transform: transformInput, snap: null };
  return {
    transform: {
      ...transformInput,
      position,
    },
    snap: {
      kind: 'support',
      distance: distanceToSupport,
      position,
      rotation: normalizeVector(transformInput.rotation),
    },
  };
};

export const resolveAssemblyPlacement = (
  stateInput: AsukaAssemblyState,
  componentsInput: AsukaAssemblyComponent[],
  componentIdInput: string,
  input: AsukaAssemblyPlacementInput = {},
): AsukaAssemblyPlacementResult => {
  const state = normalizeAssemblyState(stateInput);
  const settings = normalizeAssemblySettings(state.settings);
  const components = Array.isArray(componentsInput) ? componentsInput : [];
  const componentId = normalizeToken(componentIdInput);
  const component = components.find((entry) => entry.id === componentId) ?? null;
  if (!component) {
    const validation: AsukaAssemblyValidation = {
      valid: false,
      issues: [issue('component-not-found', 'error', `Assembly component ${componentId || '<empty>'} was not found.`, componentId ? [componentId] : [])],
      collisions: [],
    };
    return {
      componentId,
      accepted: false,
      changed: false,
      requested: {},
      applied: {},
      snapped: false,
      snap: null,
      validation,
    };
  }

  const requested = mergeTransform(component.transform, input);
  let applied = clonePlain(requested);
  let snap: AsukaAssemblySnapResult | null = null;
  let connectorType: string | undefined;
  const shouldSnap = input.snap !== false && settings.snap.enabled;

  if (shouldSnap && input.snapAnchors !== false && settings.snap.anchors) {
    const anchorSnap = findBestAnchorSnap(component, applied, components, state, input);
    if (anchorSnap) {
      applied = anchorSnap.transform;
      snap = anchorSnap.snap;
      connectorType = anchorSnap.connectorType;
    }
  }
  if (!snap && shouldSnap && input.snapBoundaries !== false && settings.snap.boundaries) {
    const boundarySnap = findBoundarySnap(component, applied, state);
    if (boundarySnap) {
      applied = boundarySnap.transform;
      snap = boundarySnap.snap;
    }
  }
  // Grid visibility is an editor preference. Snapping remains functional when
  // the grid is hidden so production placement rules never depend on a guide.
  if (!snap && shouldSnap && input.snapGrid !== false && settings.snap.grid) {
    const gridSnap = applyGridSnap(applied, settings, input.axis ?? 'free');
    applied = gridSnap.transform;
    snap = gridSnap.snap;
  }
  const support = getAssemblyComponentSupport(state, component, input.support);
  if (input.snapSupport !== false && support !== 'free') {
    const supportSnap = applyAssemblySupportSnap(state, component, applied, support);
    applied = supportSnap.transform;
    if (!snap && supportSnap.snap) snap = supportSnap.snap;
  }

  const movedComponent = getComponentWithPlacement(component, applied);
  const nextComponents = components.map((entry) => entry.id === component.id ? movedComponent : entry);
  const provisionalConnection = snap && settings.snap.autoConnect
    ? makeConnectionFromSnap(snap, connectorType)
    : null;
  const validationState = provisionalConnection
    ? {
        ...state,
        connections: [
          ...(state.connections ?? []).filter((entry) => entry.id !== provisionalConnection.id),
          provisionalConnection,
        ],
      }
    : state;
  const validation = validateAssembly(validationState, nextComponents);
  const blocking = validation.issues.some((entry) => {
    // A component must remain movable even when another component still needs
    // attention elsewhere in the room. Only issues caused by this placement
    // can reject the current gesture.
    if (entry.componentIds.length && !entry.componentIds.includes(component.id)) return false;
    if (entry.severity !== 'error') return false;
    if (entry.code === 'collision' || entry.code === 'clearance') {
      return settings.collision.policy === 'prevent';
    }
    return true;
  }) && input.allowInvalid !== true;
  const accepted = !blocking;
  const finalTransform = accepted ? applied : component.transform;
  const createdConnection = accepted ? provisionalConnection : null;
  const changed = JSON.stringify(finalTransform) !== JSON.stringify(component.transform);

  return {
    componentId,
    accepted,
    changed,
    requested,
    applied: clonePlain(finalTransform),
    snapped: Boolean(accepted && snap),
    snap: accepted ? snap : null,
    validation,
    createdConnection,
    metadata: {
      ...(input.metadata ?? {}),
      support,
    },
  };
};

/**
 * Finds a deterministic valid location inside a drawn floor or closed plan.
 * Existing valid placement is preserved; otherwise candidates are tested from
 * the centre outward until boundaries and collisions both accept the result.
 */
export const findAssemblyInteriorPlacement = (
  stateInput: AsukaAssemblyState,
  componentsInput: AsukaAssemblyComponent[],
  componentIdInput: string,
): AsukaAssemblyPlacementResult => {
  const state = normalizeAssemblyState(stateInput);
  const settings = normalizeAssemblySettings(state.settings);
  const components = Array.isArray(componentsInput) ? componentsInput : [];
  const componentId = normalizeToken(componentIdInput);
  const component = components.find((entry) => entry.id === componentId);
  if (!component) return resolveAssemblyPlacement(state, components, componentId, { snap: false });

  const current = resolveAssemblyPlacement(state, components, componentId, {
    position: clonePlain(component.transform.position),
    rotation: clonePlain(component.transform.rotation),
    snap: false,
    snapSupport: true,
    allowInvalid: false,
    axis: 'free',
  });
  if (current.accepted) return current;

  const floorPolygons = (state.plan?.floors ?? [])
    .filter((surface) => surface.points.length >= 3)
    .map((surface) => surface.points);
  const polygons = floorPolygons.length
    ? floorPolygons
    : (state.plan?.closed !== false && (state.plan?.points?.length ?? 0) >= 3
      ? [state.plan!.points]
      : []);
  if (!polygons.length) return current;

  const halfWidth = component.bounds.size.x * 0.5;
  const halfDepth = component.bounds.size.z * 0.5;
  const margin = Math.max(0.005, settings.collision.margin);
  const step = Math.max(
    settings.grid.size,
    Math.min(
      Math.max(component.bounds.size.x * 0.5, 0.05),
      Math.max(component.bounds.size.z * 0.5, 0.05),
    ),
  );
  const candidates: Array<{ x: number; z: number; priority: number }> = [];
  const seen = new Set<string>();
  const addCandidate = (x: number, z: number, priority: number): void => {
    const key = `${x.toFixed(5)}:${z.toFixed(5)}`;
    if (seen.has(key)) return;
    seen.add(key);
    candidates.push({ x, z, priority });
  };

  for (const [polygonIndex, polygon] of polygons.entries()) {
    const center = getPolygonCenter(polygon);
    const minX = Math.min(...polygon.map((point) => point.x)) + halfWidth + margin;
    const maxX = Math.max(...polygon.map((point) => point.x)) - halfWidth - margin;
    const minZ = Math.min(...polygon.map((point) => point.z)) + halfDepth + margin;
    const maxZ = Math.max(...polygon.map((point) => point.z)) - halfDepth - margin;
    if (minX > maxX || minZ > maxZ) continue;
    const clampedCenter = {
      x: Math.max(minX, Math.min(maxX, center.x)),
      z: Math.max(minZ, Math.min(maxZ, center.z)),
    };
    addCandidate(clampedCenter.x, clampedCenter.z, polygonIndex * 1_000_000);
    for (let x = minX; x <= maxX + EPSILON; x += step) {
      for (let z = minZ; z <= maxZ + EPSILON; z += step) {
        const distanceFromCenter = (x - center.x) ** 2 + (z - center.z) ** 2;
        addCandidate(x, z, polygonIndex * 1_000_000 + distanceFromCenter);
      }
    }
  }
  candidates.sort((left, right) => left.priority - right.priority);

  let lastResult = current;
  for (const candidate of candidates.slice(0, 4096)) {
    const position = normalizeVector(component.transform.position);
    const result = resolveAssemblyPlacement(state, components, componentId, {
      position: {
        x: position.x + candidate.x - component.bounds.center.x,
        y: position.y,
        z: position.z + candidate.z - component.bounds.center.z,
      },
      rotation: clonePlain(component.transform.rotation),
      snap: false,
      snapSupport: true,
      allowInvalid: false,
      axis: 'free',
      metadata: { source: 'assembly.interior-placement' },
    });
    if (result.accepted) return result;
    lastResult = result;
  }
  return lastResult;
};

export const validateAssemblyConnection = (
  stateInput: AsukaAssemblyState,
  componentsInput: AsukaAssemblyComponent[],
  connectionInput: Partial<AsukaAssemblyConnection>,
): { valid: boolean; connection: AsukaAssemblyConnection | null; issue?: AsukaAssemblyIssue } => {
  const state = normalizeAssemblyState(stateInput);
  const components = Array.isArray(componentsInput) ? componentsInput : [];
  const connection = normalizeAssemblyConnection(connectionInput);
  if (!connection) {
    return {
      valid: false,
      connection: null,
      issue: issue('incompatible-anchor', 'error', 'A connection requires two different component anchors.', []),
    };
  }
  const source = components.find((entry) => entry.id === connection.source.componentId)?.anchors.find((anchor) => anchor.id === connection.source.anchorId);
  const target = components.find((entry) => entry.id === connection.target.componentId)?.anchors.find((anchor) => anchor.id === connection.target.anchorId);
  if (!source || !target) {
    return {
      valid: false,
      connection,
      issue: issue('incompatible-anchor', 'error', 'One of the requested assembly anchors does not exist.', [connection.source.componentId, connection.target.componentId]),
    };
  }
  const connectorType = getCompatibleAssemblyConnectorType(source, target);
  if (!connectorType) {
    return {
      valid: false,
      connection,
      issue: issue('incompatible-anchor', 'error', 'The selected assembly anchors are not compatible or have reached their capacity.', [source.componentId, target.componentId]),
    };
  }
  const duplicate = (state.connections ?? []).find((existing) =>
    (existing.source.componentId === connection.source.componentId
      && existing.source.anchorId === connection.source.anchorId
      && existing.target.componentId === connection.target.componentId
      && existing.target.anchorId === connection.target.anchorId)
    || (existing.source.componentId === connection.target.componentId
      && existing.source.anchorId === connection.target.anchorId
      && existing.target.componentId === connection.source.componentId
      && existing.target.anchorId === connection.source.anchorId));
  if (duplicate) {
    return {
      valid: true,
      connection: {
        ...duplicate,
        connectorType: duplicate.connectorType ?? connectorType,
      },
    };
  }
  return { valid: true, connection: { ...connection, connectorType: connection.connectorType ?? connectorType } };
};

export const createAssemblyBom = (
  componentsInput: AsukaAssemblyComponent[],
  options: { currency?: string } = {},
): AsukaAssemblyBom => {
  const components = Array.isArray(componentsInput) ? componentsInput : [];
  const byKey = new Map<string, AsukaAssemblyBomLine>();
  for (const component of components) {
    const definedItems = component.definition?.bom?.length
      ? component.definition.bom
      : [{
          id: component.catalogId || component.id,
          sku: normalizeToken((component.metadata as any)?.sku) || undefined,
          label: component.label || component.catalogId || component.id,
          quantity: 1,
          unit: 'piece',
          unitPrice: normalizePriceInput((component.metadata as any)?.price ?? (component.metadata as any)?.basePrice) ?? 0,
          category: component.category,
        }];
    for (const item of definedItems) {
      const quantity = positive(item.quantity, 1);
      const unitPrice = normalizePriceInput(item.unitPrice) ?? 0;
      const unit = normalizeToken(item.unit) || 'piece';
      const key = normalizeToken(item.sku) || normalizeToken(item.id) || `${normalizeToken(item.label)}:${unit}:${unitPrice}`;
      const existing = byKey.get(key);
      if (existing) {
        existing.quantity += quantity;
        existing.total = existing.quantity * existing.unitPrice;
        if (!existing.componentIds.includes(component.id)) existing.componentIds.push(component.id);
      } else {
        byKey.set(key, {
          key,
          sku: normalizeToken(item.sku) || undefined,
          label: normalizeToken(item.label) || component.label || component.id,
          category: normalizeToken(item.category) || component.category,
          quantity,
          unit,
          unitPrice,
          total: quantity * unitPrice,
          componentIds: [component.id],
          metadata: item.metadata && typeof item.metadata === 'object' ? clonePlain(item.metadata) : undefined,
        });
      }
    }
  }
  const lines = Array.from(byKey.values()).sort((left, right) => left.label.localeCompare(right.label));
  return {
    currency: normalizeToken(options.currency) || undefined,
    lines,
    subtotal: lines.reduce((total, line) => total + line.total, 0),
    componentCount: components.length,
    generatedAt: new Date().toISOString(),
  };
};

export const createAssemblyQuote = (
  componentsInput: AsukaAssemblyComponent[],
  options: AsukaAssemblyQuoteOptions = {},
): AsukaAssemblyQuote => {
  const bom = createAssemblyBom(componentsInput, { currency: options.currency });
  const adjustments = (Array.isArray(options.adjustments) ? options.adjustments : [])
    .map((entry, index) => ({
      id: normalizeToken(entry?.id) || `adjustment-${index + 1}`,
      label: normalizeToken(entry?.label) || `Adjustment ${index + 1}`,
      amount: finite(entry?.amount),
    }));
  const adjustmentTotal = adjustments.reduce((total, entry) => total + entry.amount, 0);
  return {
    bom,
    adjustments,
    subtotal: bom.subtotal,
    total: bom.subtotal + adjustmentTotal,
    currency: normalizeToken(options.currency) || bom.currency,
    generatedAt: new Date().toISOString(),
    metadata: options.metadata && typeof options.metadata === 'object' ? clonePlain(options.metadata) : undefined,
  };
};

const escapeCsv = (value: unknown): string => {
  const text = String(value ?? '');
  return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
};

export const createAssemblyExportBlob = (
  kind: AsukaAssemblyExportKind,
  stateInput: AsukaAssemblyState,
  componentsInput: AsukaAssemblyComponent[],
  options: AsukaAssemblyExportOptions = {},
): Blob => {
  const state = normalizeAssemblyState(stateInput);
  const components = Array.isArray(componentsInput) ? componentsInput : [];
  const bom = createAssemblyBom(components, { currency: options.currency });
  const quote = createAssemblyQuote(components, options);
  if (kind === 'bom-csv') {
    const rows = [
      ['SKU', 'Label', 'Category', 'Quantity', 'Unit', 'Unit price', 'Total', 'Component ids'],
      ...bom.lines.map((line) => [
        line.sku ?? '',
        line.label,
        line.category ?? '',
        line.quantity,
        line.unit,
        line.unitPrice,
        line.total,
        line.componentIds.join('|'),
      ]),
    ];
    return new Blob([rows.map((row) => row.map(escapeCsv).join(',')).join('\r\n')], { type: 'text/csv;charset=utf-8' });
  }
  const payload = kind === 'bom-json'
    ? bom
    : kind === 'quote-json'
      ? quote
      : {
          schemaVersion: 1,
          assembly: state,
          components,
          validation: validateAssembly(state, components),
          bom,
          quote,
          generatedAt: new Date().toISOString(),
        };
  return new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
};

export const getAssemblyPlanBounds = (planInput?: AsukaAssemblyPlanDefinition | null): AsukaAssemblyBounds => {
  const plan = normalizePlan(planInput);
  const surfacePoints = [
    ...(plan.floors ?? []).flatMap((surface) => surface.points),
    ...(plan.walls ?? []).flatMap((surface) => [surface.start, surface.end]),
  ];
  const points = [...plan.points, ...surfacePoints];
  const minX = Math.min(...points.map((point) => point.x));
  const maxX = Math.max(...points.map((point) => point.x));
  const minZ = Math.min(...points.map((point) => point.z));
  const maxZ = Math.max(...points.map((point) => point.z));
  const floorY = finite(plan.floorY);
  const maxSurfaceY = Math.max(
    floorY + positive(plan.height, 2.6),
    ...(plan.walls ?? []).map((surface) =>
      finite(surface.baseY, floorY) + positive(surface.height, positive(plan.height, 2.6))),
  );
  return makeBounds(
    { x: minX, y: floorY, z: minZ },
    { x: maxX, y: maxSurfaceY, z: maxZ },
  );
};
