import type {
  Asuka3DDecoration,
  Asuka3DPrintArea,
  Asuka3DProduct,
  Asuka3DSceneState,
} from './renderEngine';
import { normalizeAssemblyState } from './assembly3d';

const SCENE_KEYS = new Set([
  'products',
  'selectedProductId',
  'selectedPartId',
  'camera',
  'cameraLimits',
  'materials',
  'decorations',
  'printAreas',
  'activePrintAreaId',
  'selectedObjectId',
  'selectedMaterialId',
  'assembly',
]);

const PRODUCT_KEYS = new Set([
  'id',
  'catalogId',
  'name',
  'model',
  'parts',
  'variationId',
  'variationName',
  'variationPath',
  'variationPathLabel',
  'partVariationIds',
  'price',
  'basePrice',
  'variationPrice',
  'partVariationPrices',
  'parametric',
  'transform',
  'meshIds',
  'metadata',
]);

const MODEL_KEYS = new Set(['url', 'format', 'metadata']);

const REMOVED_SCENE_KEYS = new Set([
  'product',
  'productElement',
  'productElementId',
  'productElementName',
  'productElementInstanceId',
  'productElementInstances',
  'model',
  'modelUrl',
  'modelFormat',
  'activeVariationId',
  'activeVariationPath',
  'viewId',
]);

const REMOVED_PRODUCT_KEYS = new Set([
  'product',
  'element',
  'elementId',
  'elementName',
  'instanceId',
  'productId',
  'productElement',
  'productElementId',
  'productElementName',
  'productElementInstanceId',
  'modelUrl',
  'modelFormat',
]);

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

const assertObject = (value: unknown, label: string): Record<string, unknown> => {
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
    throw new TypeError(`${label} must be an object.`);
  }
  return value as Record<string, unknown>;
};

const assertNoRemovedKeys = (value: Record<string, unknown>, keys: Set<string>, label: string): void => {
  const removed = Object.keys(value).filter((key) => keys.has(key));
  if (removed.length) {
    throw new TypeError(`${label} uses removed 3D fields: ${removed.join(', ')}.`);
  }
};

const assertOnlyKeys = (value: Record<string, unknown>, keys: Set<string>, label: string): void => {
  const unknown = Object.keys(value).filter((key) => !keys.has(key));
  if (unknown.length) throw new TypeError(`${label} uses unsupported 3D fields: ${unknown.join(', ')}.`);
};

export function assertCanonical3DProduct(value: unknown, index = 0): asserts value is Asuka3DProduct {
  const product = assertObject(value, `products[${index}]`);
  assertNoRemovedKeys(product, REMOVED_PRODUCT_KEYS, `products[${index}]`);
  assertOnlyKeys(product, PRODUCT_KEYS, `products[${index}]`);

  const id = String(product.id ?? '').trim();
  if (!id) throw new TypeError(`products[${index}].id is required.`);

  if (product.model != null) {
    const model = assertObject(product.model, `products[${index}].model`);
    assertOnlyKeys(model, MODEL_KEYS, `products[${index}].model`);
    const url = String(model.url ?? '').trim();
    if (!url) throw new TypeError(`products[${index}].model.url is required when model is present.`);
  }

  if (product.parts != null && !Array.isArray(product.parts)) {
    throw new TypeError(`products[${index}].parts must be an array.`);
  }

  for (const key of ['price', 'basePrice', 'variationPrice'] as const) {
    if (product[key] != null && (typeof product[key] !== 'number' || !Number.isFinite(product[key]))) {
      throw new TypeError(`products[${index}].${key} must be a finite number.`);
    }
  }

  if (product.partVariationPrices != null) {
    const prices = assertObject(product.partVariationPrices, `products[${index}].partVariationPrices`);
    for (const [key, price] of Object.entries(prices)) {
      if (!String(key).trim() || typeof price !== 'number' || !Number.isFinite(price)) {
        throw new TypeError(`products[${index}].partVariationPrices.${key || '<empty>'} must be a finite number.`);
      }
    }
  }

  if (product.parametric != null) {
    const parametric = assertObject(product.parametric, `products[${index}].parametric`);
    assertOnlyKeys(parametric, new Set(['values']), `products[${index}].parametric`);
    const values = assertObject(parametric.values ?? {}, `products[${index}].parametric.values`);
    for (const [key, entry] of Object.entries(values)) {
      if (!String(key ?? '').trim()) throw new TypeError(`products[${index}].parametric.values contains an empty key.`);
      if (!(entry == null || typeof entry === 'string' || typeof entry === 'boolean' || (typeof entry === 'number' && Number.isFinite(entry)))) {
        throw new TypeError(`products[${index}].parametric.values.${key} must be a string, number, boolean or null.`);
      }
    }
  }
}

/**
 * Validate the one supported 3D scene contract.
 *
 * This intentionally performs no migration and accepts no removed aliases.
 */
export function assertCanonical3DSceneState(value: unknown): asserts value is Partial<Asuka3DSceneState> {
  if (value == null) return;
  const scene = assertObject(value, '3D scene');
  assertNoRemovedKeys(scene, REMOVED_SCENE_KEYS, '3D scene');
  assertOnlyKeys(scene, SCENE_KEYS, '3D scene');

  if (scene.products != null && !Array.isArray(scene.products)) {
    throw new TypeError('3D scene.products must be an array.');
  }
  const productIds = new Set<string>();
  for (const [index, product] of (Array.isArray(scene.products) ? scene.products : []).entries()) {
    assertCanonical3DProduct(product, index);
    const id = String((product as Asuka3DProduct).id).trim();
    if (productIds.has(id)) throw new TypeError(`3D scene.products contains duplicate id: ${id}.`);
    productIds.add(id);
  }

  if (scene.decorations != null && !Array.isArray(scene.decorations)) {
    throw new TypeError('3D scene.decorations must be an array.');
  }
  if (scene.printAreas != null && !Array.isArray(scene.printAreas)) {
    throw new TypeError('3D scene.printAreas must be an array.');
  }
  if (scene.assembly != null) {
    const assembly = assertObject(scene.assembly, '3D scene.assembly');
    if (assembly.schemaVersion !== 1) {
      throw new TypeError('3D scene.assembly.schemaVersion must be 1.');
    }
    if (assembly.connections != null && !Array.isArray(assembly.connections)) {
      throw new TypeError('3D scene.assembly.connections must be an array.');
    }
    if (assembly.stackLinks != null && !Array.isArray(assembly.stackLinks)) {
      throw new TypeError('3D scene.assembly.stackLinks must be an array.');
    }
    if (assembly.plan != null) {
      const plan = assertObject(assembly.plan, '3D scene.assembly.plan');
      if (plan.points != null && !Array.isArray(plan.points)) {
        throw new TypeError('3D scene.assembly.plan.points must be an array.');
      }
    }
  }
  for (const [index, areaValue] of (Array.isArray(scene.printAreas) ? scene.printAreas : []).entries()) {
    const area = assertObject(areaValue, `printAreas[${index}]`);
    if (!String(area.id ?? '').trim()) throw new TypeError(`printAreas[${index}].id is required.`);
    if (area.mode !== 'texture' && area.mode !== 'surface') {
      throw new TypeError(`printAreas[${index}].mode must be "texture" or "surface".`);
    }
    const expectedFrame = area.mode === 'surface'
      ? { space: 'surface-tangent', unit: 'world-unit' }
      : { space: 'texture', unit: 'texture-px' };
    if (area.space !== expectedFrame.space || area.unit !== expectedFrame.unit) {
      throw new TypeError(`printAreas[${index}] must use ${expectedFrame.space}/${expectedFrame.unit} coordinates.`);
    }
  }
  for (const [index, decorationValue] of (Array.isArray(scene.decorations) ? scene.decorations : []).entries()) {
    const decoration = assertObject(decorationValue, `decorations[${index}]`);
    if (!String(decoration.id ?? '').trim()) throw new TypeError(`decorations[${index}].id is required.`);
    if (decoration.placement != null) {
      const placement = assertObject(decoration.placement, `decorations[${index}].placement`);
      if (placement.mode != null && !['surface', 'texture', 'plane'].includes(String(placement.mode))) {
        throw new TypeError(`decorations[${index}].placement.mode is invalid.`);
      }
    }
  }
}


const getProductId = (value: unknown): string => String((value as any)?.id ?? '').trim();
const getPartId = (value: unknown): string => String((value as any)?.id ?? '').trim();

export const get3DProductSelectionId = (productId: unknown): string | null => {
  const id = String(productId ?? '').trim();
  return id ? `product:${id}` : null;
};

export const get3DProductIdFromSelectionId = (selectionId: unknown): string | null => {
  const id = String(selectionId ?? '').trim();
  if (!id.startsWith('product:')) return null;
  const productId = id.slice('product:'.length).trim();
  return productId || null;
};

const getSceneObjectIds = (scene: Pick<Asuka3DSceneState, 'products' | 'decorations' | 'printAreas'>): Set<string> => {
  const ids = new Set<string>();
  for (const product of scene.products ?? []) {
    const id = get3DProductSelectionId(getProductId(product));
    if (id) ids.add(id);
  }
  for (const decoration of scene.decorations ?? []) {
    const id = String(decoration?.id ?? '').trim();
    if (id) ids.add(id);
  }
  for (const area of scene.printAreas ?? []) {
    for (const object of area?.objects ?? []) {
      const id = String(object?.id ?? '').trim();
      if (id) ids.add(id);
    }
  }
  return ids;
};

const getProductPartIds = (product: Asuka3DProduct | null | undefined): Set<string> => new Set(
  (product?.parts ?? []).map(getPartId).filter(Boolean),
);

/**
 * Select one canonical 3D product and keep the related part/object selection coherent.
 * This is intentionally strict and performs no alias lookup.
 */
export function select3DProduct(
  scene: Asuka3DSceneState,
  productIdInput: string | null | undefined,
  options: { selectObject?: boolean } = {},
): Asuka3DSceneState {
  const productId = String(productIdInput ?? '').trim();
  const product = productId
    ? (scene.products ?? []).find((candidate) => getProductId(candidate) === productId) ?? null
    : null;
  if (!product) {
    return {
      ...scene,
      selectedProductId: null,
      selectedPartId: null,
      selectedObjectId: null,
    };
  }

  const partIds = getProductPartIds(product);
  const selectedPartId = String(scene.selectedPartId ?? '').trim();
  return {
    ...scene,
    selectedProductId: productId,
    selectedPartId: selectedPartId && (!partIds.size || partIds.has(selectedPartId)) ? selectedPartId : null,
    selectedObjectId: options.selectObject === false
      ? null
      : get3DProductSelectionId(productId),
    selectedMaterialId: null,
  };
}

/** Select a part only when it belongs to the active canonical product. */
export function select3DPart(scene: Asuka3DSceneState, partIdInput: string | null | undefined): Asuka3DSceneState {
  const productId = String(scene.selectedProductId ?? '').trim();
  const product = productId
    ? (scene.products ?? []).find((candidate) => getProductId(candidate) === productId) ?? null
    : null;
  const partId = String(partIdInput ?? '').trim();
  const partIds = getProductPartIds(product);
  const selectedPartId = product && partId && (!partIds.size || partIds.has(partId)) ? partId : null;
  return { ...scene, selectedPartId };
}

/**
 * Select a canonical scene object. Product selections also become the active product.
 * Decorations and texture objects do not discard the active product context.
 */
export function select3DObject(scene: Asuka3DSceneState, objectIdInput: string | null | undefined): Asuka3DSceneState {
  const objectId = String(objectIdInput ?? '').trim();
  if (!objectId) return { ...scene, selectedObjectId: null };
  const productId = get3DProductIdFromSelectionId(objectId);
  if (productId) return select3DProduct(scene, productId, { selectObject: true });
  if (!getSceneObjectIds(scene).has(objectId)) return { ...scene, selectedObjectId: null };
  return { ...scene, selectedObjectId: objectId, selectedMaterialId: null };
}

/** Remove editor-only selections before a scene is persisted or normally mounted. */
export function clear3DTransientSelection(scene: Asuka3DSceneState): Asuka3DSceneState {
  return {
    ...scene,
    selectedObjectId: null,
    selectedMaterialId: null,
  };
}

/** Build a complete canonical scene from canonical partial input only. */
export function create3DSceneState(input: Partial<Asuka3DSceneState> | null | undefined = {}): Asuka3DSceneState {
  assertCanonical3DSceneState(input);
  const products = clone(Array.isArray(input?.products) ? input.products : []);
  const productIds = new Set(products.map(getProductId).filter(Boolean));
  const objectSelectedProductId = get3DProductIdFromSelectionId(input?.selectedObjectId);
  const requestedProductId = objectSelectedProductId ?? String(input?.selectedProductId ?? '').trim();
  const selectedProductId = requestedProductId && productIds.has(requestedProductId)
    ? requestedProductId
    : null;
  const selectedProduct = selectedProductId
    ? products.find((product) => getProductId(product) === selectedProductId) ?? null
    : null;
  const requestedPartId = String(input?.selectedPartId ?? '').trim();
  const persistedPartIds = new Set((selectedProduct?.parts ?? [])
    .map((part) => String(part?.id ?? '').trim())
    .filter(Boolean));
  const selectedPartId = selectedProductId && requestedPartId
    && (!persistedPartIds.size || persistedPartIds.has(requestedPartId))
    ? requestedPartId
    : null;

  const decorations = clone(Array.isArray(input?.decorations) ? input.decorations : []) as Asuka3DDecoration[];
  const printAreas = clone(Array.isArray(input?.printAreas) ? input.printAreas : []) as Asuka3DPrintArea[];
  const printAreaIds = new Set(printAreas.map((area) => String(area?.id ?? '').trim()).filter(Boolean));
  const requestedPrintAreaId = String(input?.activePrintAreaId ?? '').trim();
  const activePrintAreaId = requestedPrintAreaId && printAreaIds.has(requestedPrintAreaId)
    ? requestedPrintAreaId
    : null;

  const objectIds = getSceneObjectIds({ products, decorations, printAreas });
  const requestedObjectId = String(input?.selectedObjectId ?? '').trim();
  const selectedObjectId = requestedObjectId && objectIds.has(requestedObjectId)
    ? requestedObjectId
    : null;

  const materials = input?.materials ? clone(input.materials) : {};
  const requestedMaterialId = String(input?.selectedMaterialId ?? '').trim();
  const selectedMaterialId = requestedMaterialId && Object.prototype.hasOwnProperty.call(materials, requestedMaterialId)
    ? requestedMaterialId
    : null;

  return {
    products,
    selectedProductId,
    selectedPartId,
    camera: input?.camera ? clone(input.camera) : undefined,
    cameraLimits: input?.cameraLimits ? clone(input.cameraLimits) : undefined,
    materials,
    decorations,
    printAreas,
    activePrintAreaId,
    selectedObjectId,
    selectedMaterialId,
    ...(input?.assembly ? { assembly: normalizeAssemblyState(input.assembly) } : {}),
  };
}
