import type { FunctionalComponent } from 'preact';

import { getRuntimeRenderEngine } from '../../../core/renderEngine';
import { register3DModelFileRuntimeUrl } from '../../../core/modelAsset3d';
import { beginAsuka3DPerfSpan, logAsuka3DPerf } from '../../../core/perf3dDebug';
import { SectionCard } from '../../core';
import { FormGrid, ModelField, SelectField } from '../../forms';
import type { AsukaUploadModuleConfig } from '../../../types/options';
import { ElementAssemblyFields } from './ElementAssemblyFields';

const inferModelFormat = (value: string, fallback: string = 'glb'): 'glb' | 'gltf' => {
  const v = String(value ?? '').trim().toLowerCase();
  if (v.startsWith('data:model/gltf+json') || /\.gltf(?:[?#].*)?$/.test(v)) return 'gltf';
  if (v.startsWith('data:model/gltf-binary') || /\.glb(?:[?#].*)?$/.test(v)) return 'glb';
  return String(fallback ?? 'glb').toLowerCase() === 'gltf' ? 'gltf' : 'glb';
};

const readProduct3D = (form: any): any => {
  const value = (form?.values as any)?.product3d;
  const pending = (form as any)?.__asukaProduct3DPending;
  const base = value && typeof value === 'object' ? value : {};
  return pending && typeof pending === 'object' ? { ...base, ...pending } : base;
};

const splitCsv = (value: any): string[] => String(Array.isArray(value) ? value.join(',') : (value ?? ''))
  .split(',')
  .map((item) => item.trim())
  .filter(Boolean);

const slugifyPartId = (value: any, fallback: string): string => {
  const raw = String(value ?? '').trim() || fallback;
  const slug = raw
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/[^a-zA-Z0-9_-]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .toLowerCase();
  return slug || fallback;
};

const genId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 7)}`;
const makePartId = (count: number) => `part-${count + 1}-${Math.random().toString(36).slice(2, 7)}`;

const PRODUCT3D_DEBUG_PREFIX = '[AsukaDesigner:Product3D]';

const isProduct3DDebugEnabled = (): boolean => {
  try {
    const g = globalThis as any;
    if (g?.__ASUKA_3D_PRODUCT_DEBUG__ === true) return true;
    if (g?.__ASUKA_3D_PRODUCT_DEBUG__ === false || g?.__ASUKA_DISABLE_3D_PRODUCT_DEBUG__ === true) return false;
    const search = typeof window !== 'undefined' ? String(window.location?.search ?? '') : '';
    if (/(?:^|[?&])asuka3ddebug=1(?:&|$)/i.test(search)) return true;
    if (/(?:^|[?&])asuka3ddebug=0(?:&|$)/i.test(search)) return false;
    const stored = typeof window !== 'undefined' ? window.localStorage?.getItem('asuka:3d-product-debug') : null;
    if (stored === '1' || stored === 'true' || stored === 'on') return true;
    if (stored === '0' || stored === 'false' || stored === 'off') return false;
  } catch {}
  return false;
};

const summarizeDebugValue = (value: any, depth = 0): any => {
  if (value == null) return value;
  if (typeof value === 'string') {
    const text = value;
    if (/^data:/i.test(text)) {
      const mime = text.match(/^data:([^;,]+)/i)?.[1] ?? 'unknown';
      return { kind: 'data-url', mime, length: text.length, prefix: text.slice(0, 96) };
    }
    if (/^blob:/i.test(text)) return { kind: 'blob-url', length: text.length, prefix: text.slice(0, 120) };
    if (text.length > 260) return `${text.slice(0, 160)}… (${text.length} chars)`;
    return text;
  }
  if (typeof value !== 'object') return value;
  try {
    if (typeof File !== 'undefined' && value instanceof File) return { kind: 'file', name: value.name, type: value.type, size: value.size, lastModified: value.lastModified };
    if (typeof Blob !== 'undefined' && value instanceof Blob) return { kind: 'blob', type: value.type, size: value.size };
  } catch {}
  if (Array.isArray(value)) {
    const max = depth > 1 ? 12 : 30;
    const out = value.slice(0, max).map((item) => summarizeDebugValue(item, depth + 1));
    if (value.length > max) out.push(`… ${value.length - max} more`);
    return out;
  }
  if (depth > 3) return '[Object]';
  const out: Record<string, any> = {};
  for (const key of Object.keys(value).slice(0, 40)) out[key] = summarizeDebugValue(value[key], depth + 1);
  const remaining = Object.keys(value).length - Object.keys(out).length;
  if (remaining > 0) out.__remainingKeys = remaining;
  return out;
};

const debugProduct3D = (phase: string, payload?: Record<string, any>, level: 'info' | 'warn' | 'error' = 'info') => {
  if (!isProduct3DDebugEnabled()) return;
  try {
    const entry = { at: new Date().toISOString(), phase, payload: summarizeDebugValue(payload ?? {}) };
    const g = globalThis as any;
    const logs = Array.isArray(g.__ASUKA_3D_PRODUCT_LOGS__) ? g.__ASUKA_3D_PRODUCT_LOGS__ : [];
    logs.push(entry);
    g.__ASUKA_3D_PRODUCT_LOGS__ = logs.slice(-160);
    const logger = level === 'error' ? console.error : (level === 'warn' ? console.warn : console.info);
    logger(`${PRODUCT3D_DEBUG_PREFIX} ${phase}`, entry.payload);
  } catch {}
};

const normalizeParts = (value: any): any[] => {
  const list = Array.isArray(value) ? value : [];
  return list.map((part, index) => {
    const id = String(part?.id ?? makePartId(index)).trim() || makePartId(index);
    const meshNames = splitCsv(part?.meshNames ?? part?.meshName ?? part?.targetMeshIds ?? part?.targetMeshId);
    const normalized = {
      ...(part && typeof part === 'object' ? part : {}),
      id,
      label: String(part?.label ?? part?.name ?? id).trim() || id,
      meshNames,
      materialNames: splitCsv(part?.materialNames ?? part?.materialName),
      materialTarget: String(part?.materialTarget ?? id).trim() || id,
      editable: part?.editable !== false,
      printable: part?.printable === true,
      visible: part?.visible !== false,
      locked: part?.locked === true,
      parentPartId: String(part?.parentPartId ?? '').trim() || undefined,
    };
    try { delete (normalized as any).variations; } catch {}
    return normalized;
  });
};


const GLB_MAGIC = 0x46546c67;
const GLB_JSON_CHUNK = 0x4e4f534a;
const GLB_BIN_CHUNK = 0x004e4942;
const GLTF_COMPONENT_BYTE_SIZE: Record<number, number> = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
const GLTF_COMPONENT_COUNT: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
const MAX_GEOMETRY_COMPONENT_PARTS = 14;
const MIN_GEOMETRY_COMPONENT_RATIO = 0.008;
const MIN_GEOMETRY_COMPONENT_VERTICES = 96;
const MIN_GEOMETRY_COMPONENT_TRIANGLES = 32;

type GltfAssetData = { json: any | null; binary: ArrayBuffer | null };
type GltfAccessorValues = Array<number | number[]>;
type GltfConnectedComponent = {
  sourceIndex: number;
  rank: number;
  vertexCount: number;
  triangleCount: number;
  vertexIndices: number[];
  triangleIndices: number[];
  bboxMin: [number, number, number];
  bboxMax: [number, number, number];
};

const decodeTextChunk = (buffer: ArrayBuffer): string => {
  try { return new TextDecoder('utf-8').decode(new Uint8Array(buffer)).replace(/\0+$/g, '').trim(); } catch { return ''; }
};

const dataUriToArrayBuffer = (uri: string): ArrayBuffer | null => {
  const raw = String(uri ?? '').trim();
  const match = raw.match(/^data:.*?;base64,(.*)$/i);
  if (!match) return null;
  try {
    const binary = atob(match[1]);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
    return bytes.buffer;
  } catch {
    return null;
  }
};

const readGltfAssetFromArrayBuffer = (buffer: ArrayBuffer, filename: string): GltfAssetData => {
  const lowerName = String(filename ?? '').toLowerCase();
  if (buffer.byteLength >= 20 && new DataView(buffer).getUint32(0, true) === GLB_MAGIC) {
    const view = new DataView(buffer);
    const totalLength = Math.min(view.getUint32(8, true) || buffer.byteLength, buffer.byteLength);
    let offset = 12;
    let json: any | null = null;
    let binary: ArrayBuffer | null = null;
    while (offset + 8 <= totalLength) {
      const chunkLength = view.getUint32(offset, true);
      const chunkType = view.getUint32(offset + 4, true);
      const chunkStart = offset + 8;
      const chunkEnd = Math.min(chunkStart + chunkLength, totalLength);
      if (chunkType === GLB_JSON_CHUNK) {
        const text = decodeTextChunk(buffer.slice(chunkStart, chunkEnd));
        try { json = JSON.parse(text); } catch { json = null; }
      } else if (chunkType === GLB_BIN_CHUNK) {
        binary = buffer.slice(chunkStart, chunkEnd);
      }
      offset = chunkEnd;
    }
    return { json, binary };
  }

  if (/\.gltf(?:[?#].*)?$/.test(lowerName) || !/\.glb(?:[?#].*)?$/.test(lowerName)) {
    const text = decodeTextChunk(buffer);
    try {
      const json = JSON.parse(text);
      const firstBufferUri = String(Array.isArray(json?.buffers) ? json.buffers[0]?.uri ?? '' : '').trim();
      return { json, binary: dataUriToArrayBuffer(firstBufferUri) };
    } catch {
      return { json: null, binary: null };
    }
  }
  return { json: null, binary: null };
};

const getAccessorCount = (gltf: any, accessorIndex: any): number | undefined => {
  const index = Number(accessorIndex);
  if (!Number.isFinite(index) || index < 0) return undefined;
  const count = Number(gltf?.accessors?.[index]?.count);
  return Number.isFinite(count) && count > 0 ? count : undefined;
};

const readAccessorValues = (asset: GltfAssetData, accessorIndex: any): GltfAccessorValues => {
  const gltf = asset.json;
  const index = Number(accessorIndex);
  if (!gltf || !Number.isFinite(index) || index < 0) return [];
  const accessor = Array.isArray(gltf?.accessors) ? gltf.accessors[index] : null;
  if (!accessor || typeof accessor !== 'object') return [];
  const bufferView = Array.isArray(gltf?.bufferViews) ? gltf.bufferViews[Number(accessor.bufferView)] : null;
  if (!bufferView || typeof bufferView !== 'object') return [];
  const bufferIndex = Number(bufferView.buffer ?? 0);
  const bufferInfo = Array.isArray(gltf?.buffers) ? gltf.buffers[bufferIndex] : null;
  const binary = bufferIndex === 0 ? asset.binary : dataUriToArrayBuffer(String(bufferInfo?.uri ?? ''));
  if (!binary) return [];

  const componentType = Number(accessor.componentType);
  const componentSize = GLTF_COMPONENT_BYTE_SIZE[componentType] ?? 0;
  const componentCount = GLTF_COMPONENT_COUNT[String(accessor.type ?? '')] ?? 0;
  const count = Number(accessor.count);
  if (!componentSize || !componentCount || !Number.isFinite(count) || count <= 0) return [];

  const itemSize = componentSize * componentCount;
  const stride = Math.max(Number(bufferView.byteStride ?? itemSize) || itemSize, itemSize);
  const start = Math.max(0, Number(bufferView.byteOffset ?? 0) + Number(accessor.byteOffset ?? 0));
  const view = new DataView(binary);
  const normalized = accessor.normalized === true;
  const values: GltfAccessorValues = [];

  const readComponent = (offset: number): number => {
    if (offset < 0 || offset + componentSize > view.byteLength) return 0;
    switch (componentType) {
      case 5120: {
        const value = view.getInt8(offset);
        return normalized ? Math.max(value / 127, -1) : value;
      }
      case 5121: {
        const value = view.getUint8(offset);
        return normalized ? value / 255 : value;
      }
      case 5122: {
        const value = view.getInt16(offset, true);
        return normalized ? Math.max(value / 32767, -1) : value;
      }
      case 5123: {
        const value = view.getUint16(offset, true);
        return normalized ? value / 65535 : value;
      }
      case 5125: return view.getUint32(offset, true);
      case 5126: return view.getFloat32(offset, true);
      default: return 0;
    }
  };

  for (let i = 0; i < count; i += 1) {
    const itemOffset = start + i * stride;
    const item: number[] = [];
    for (let c = 0; c < componentCount; c += 1) item.push(readComponent(itemOffset + c * componentSize));
    values.push(componentCount === 1 ? item[0] : item);
  }
  return values;
};

const readPrimitivePositions = (asset: GltfAssetData, primitive: any): Array<[number, number, number]> => readAccessorValues(asset, primitive?.attributes?.POSITION)
  .map((value: any) => Array.isArray(value) ? [Number(value[0]) || 0, Number(value[1]) || 0, Number(value[2]) || 0] as [number, number, number] : null)
  .filter(Boolean) as Array<[number, number, number]>;

const readPrimitiveIndices = (asset: GltfAssetData, primitive: any, vertexCount: number): number[] => {
  const explicit = readAccessorValues(asset, primitive?.indices).map((value: any) => Number(value)).filter((value) => Number.isFinite(value) && value >= 0);
  if (explicit.length) return explicit;
  return Array.from({ length: Math.max(0, vertexCount) }, (_, index) => index);
};

const summarizeGltfMesh = (assetOrGltf: GltfAssetData | any, meshIndex: number, displayName: string, nativeId: string): any | null => {
  const gltf = assetOrGltf?.json ?? assetOrGltf;
  const mesh = Array.isArray(gltf?.meshes) ? gltf.meshes[meshIndex] : null;
  if (!mesh || typeof mesh !== 'object') return null;

  const primitives = Array.isArray(mesh.primitives) ? mesh.primitives : [];
  const vertices = primitives.reduce((sum: number, primitive: any) => {
    const count = getAccessorCount(gltf, primitive?.attributes?.POSITION);
    return Number.isFinite(Number(count)) ? sum + Number(count) : sum;
  }, 0);
  const firstMaterialIndex = primitives.find((primitive: any) => Number.isFinite(Number(primitive?.material)))?.material;
  const materialIndex = Number(firstMaterialIndex);
  const material = Number.isFinite(materialIndex) && Array.isArray(gltf?.materials) ? gltf.materials[materialIndex] : null;
  const materialName = String(material?.name ?? (Number.isFinite(materialIndex) ? `material-${materialIndex}` : '')).trim();
  const meshName = String(mesh?.name ?? '').trim();
  const name = String(displayName || meshName || `mesh-${meshIndex + 1}`).trim();

  return {
    id: name,
    label: name,
    name,
    materialId: materialName || undefined,
    materialName: materialName || undefined,
    vertices: vertices || undefined,
    visible: true,
    metadata: {
      engine: 'gltf-file-inspector',
      nativeId,
      nativeName: name,
      meshIndex,
      meshName: meshName || undefined,
      primitiveCount: primitives.length,
      detectedFrom: 'model-file-upload',
      detectionKind: 'mesh',
    },
  };
};

const summarizeGltfPrimitivePart = (asset: GltfAssetData, meshIndex: number, primitiveIndex: number, displayName: string): any | null => {
  const gltf = asset.json;
  const mesh = Array.isArray(gltf?.meshes) ? gltf.meshes[meshIndex] : null;
  const primitive = Array.isArray(mesh?.primitives) ? mesh.primitives[primitiveIndex] : null;
  if (!gltf || !mesh || !primitive) return null;
  const materialIndex = Number(primitive?.material);
  const material = Number.isFinite(materialIndex) && Array.isArray(gltf?.materials) ? gltf.materials[materialIndex] : null;
  const materialName = String(material?.name ?? (Number.isFinite(materialIndex) ? `material-${materialIndex}` : '')).trim();
  const meshName = String(mesh?.name ?? `mesh-${meshIndex + 1}`).trim() || `mesh-${meshIndex + 1}`;
  const suffix = materialName || `primitive-${primitiveIndex + 1}`;
  const id = `${meshName}__${slugifyPartId(suffix, `primitive-${primitiveIndex + 1}`)}`;
  return {
    id,
    label: `${displayName} / ${materialName || `Primitive ${primitiveIndex + 1}`}`,
    name: id,
    materialId: materialName || undefined,
    materialName: materialName || undefined,
    vertices: getAccessorCount(gltf, primitive?.attributes?.POSITION),
    visible: true,
    metadata: {
      engine: 'gltf-file-inspector',
      nativeId: id,
      nativeName: id,
      meshIndex,
      meshName,
      primitiveIndex,
      primitiveCount: 1,
      materialIndex: Number.isFinite(materialIndex) ? materialIndex : undefined,
      detectedFrom: 'model-file-upload',
      detectionKind: 'material-primitive',
      sourceMeshName: meshName,
    },
  };
};

const findConnectedComponents = (positions: Array<[number, number, number]>, indices: number[]): GltfConnectedComponent[] => {
  const vertexCount = positions.length;
  if (vertexCount <= 0 || indices.length < 3) return [];
  const parent = Array.from({ length: vertexCount }, (_, index) => index);
  const rank = Array.from({ length: vertexCount }, () => 0);
  const find = (input: number): number => {
    let value = input;
    while (parent[value] !== value) {
      parent[value] = parent[parent[value]];
      value = parent[value];
    }
    return value;
  };
  const union = (aInput: number, bInput: number) => {
    const a = Number(aInput);
    const b = Number(bInput);
    if (!Number.isFinite(a) || !Number.isFinite(b) || a < 0 || b < 0 || a >= vertexCount || b >= vertexCount) return;
    let rootA = find(a);
    let rootB = find(b);
    if (rootA === rootB) return;
    if (rank[rootA] < rank[rootB]) [rootA, rootB] = [rootB, rootA];
    parent[rootB] = rootA;
    if (rank[rootA] === rank[rootB]) rank[rootA] += 1;
  };

  for (let i = 0; i + 2 < indices.length; i += 3) {
    const a = indices[i];
    const b = indices[i + 1];
    const c = indices[i + 2];
    union(a, b);
    union(b, c);
    union(c, a);
  }

  // Many exported GLB files duplicate seam vertices. Welding exact positions keeps
  // separate objects apart but prevents one visual part from becoming dozens of tiny islands.
  const byPosition = new Map<string, number>();
  for (let i = 0; i < positions.length; i += 1) {
    const p = positions[i];
    const key = `${Math.round(p[0] * 100000)}:${Math.round(p[1] * 100000)}:${Math.round(p[2] * 100000)}`;
    const previous = byPosition.get(key);
    if (previous == null) byPosition.set(key, i);
    else union(i, previous);
  }

  const byRoot = new Map<number, { vertices: Set<number>; triangles: number[] }>();
  for (let i = 0; i < positions.length; i += 1) {
    const root = find(i);
    const current = byRoot.get(root) ?? { vertices: new Set<number>(), triangles: [] };
    current.vertices.add(i);
    byRoot.set(root, current);
  }
  for (let tri = 0; tri + 2 < indices.length; tri += 3) {
    const root = find(indices[tri]);
    const current = byRoot.get(root);
    if (current) current.triangles.push(tri / 3);
  }

  const components = Array.from(byRoot.values()).map((item, sourceIndex) => {
    const vertexIndices = Array.from(item.vertices.values());
    const bboxMin: [number, number, number] = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
    const bboxMax: [number, number, number] = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
    for (const vertexIndex of vertexIndices) {
      const p = positions[vertexIndex];
      bboxMin[0] = Math.min(bboxMin[0], p[0]);
      bboxMin[1] = Math.min(bboxMin[1], p[1]);
      bboxMin[2] = Math.min(bboxMin[2], p[2]);
      bboxMax[0] = Math.max(bboxMax[0], p[0]);
      bboxMax[1] = Math.max(bboxMax[1], p[1]);
      bboxMax[2] = Math.max(bboxMax[2], p[2]);
    }
    return {
      sourceIndex,
      rank: 0,
      vertexCount: vertexIndices.length,
      triangleCount: item.triangles.length,
      vertexIndices,
      triangleIndices: item.triangles,
      bboxMin,
      bboxMax,
    } as GltfConnectedComponent;
  }).sort((a, b) => (b.triangleCount - a.triangleCount) || (b.vertexCount - a.vertexCount));

  return components.map((component, rankIndex) => ({ ...component, rank: rankIndex }));
};

const summarizeGltfGeometryComponents = (asset: GltfAssetData, meshIndex: number, primitiveIndex: number, displayName: string): any[] => {
  const gltf = asset.json;
  const mesh = Array.isArray(gltf?.meshes) ? gltf.meshes[meshIndex] : null;
  const primitive = Array.isArray(mesh?.primitives) ? mesh.primitives[primitiveIndex] : null;
  if (!gltf || !mesh || !primitive || !asset.binary) return [];
  const positions = readPrimitivePositions(asset, primitive);
  const indices = readPrimitiveIndices(asset, primitive, positions.length);
  const components = findConnectedComponents(positions, indices);
  if (components.length <= 1) return [];

  const meshName = String(mesh?.name ?? `mesh-${meshIndex + 1}`).trim() || `mesh-${meshIndex + 1}`;
  const materialIndex = Number(primitive?.material);
  const material = Number.isFinite(materialIndex) && Array.isArray(gltf?.materials) ? gltf.materials[materialIndex] : null;
  const materialName = String(material?.name ?? (Number.isFinite(materialIndex) ? `material-${materialIndex}` : '')).trim();
  const totalVertices = positions.length;
  const minVertices = Math.max(MIN_GEOMETRY_COMPONENT_VERTICES, Math.round(totalVertices * MIN_GEOMETRY_COMPONENT_RATIO));
  const selected = components
    .filter((component) => component.vertexCount >= minVertices && component.triangleCount >= MIN_GEOMETRY_COMPONENT_TRIANGLES)
    .slice(0, MAX_GEOMETRY_COMPONENT_PARTS);
  if (selected.length <= 1) return [];

  return selected.map((component) => {
    const id = `${meshName}__component_${component.rank + 1}`;
    return {
      id,
      label: `${displayName} / Part ${component.rank + 1}`,
      name: id,
      materialId: materialName || undefined,
      materialName: materialName || undefined,
      vertices: component.vertexCount,
      visible: true,
      metadata: {
        engine: 'gltf-file-inspector',
        nativeId: id,
        nativeName: id,
        meshIndex,
        meshName: id,
        sourceMeshName: meshName,
        sourcePrimitiveIndex: primitiveIndex,
        primitiveIndex,
        componentIndex: component.sourceIndex,
        componentRank: component.rank,
        componentVertices: component.vertexCount,
        componentTriangles: component.triangleCount,
        componentBboxMin: component.bboxMin,
        componentBboxMax: component.bboxMax,
        primitiveCount: 1,
        detectedFrom: 'model-file-upload',
        detectionKind: 'geometry-component',
      },
    };
  });
};

const extractMeshSummariesFromGltfAsset = (asset: GltfAssetData): any[] => {
  const gltf = asset.json;
  const meshes = Array.isArray(gltf?.meshes) ? gltf.meshes : [];
  if (!meshes.length) return [];

  const summaries: any[] = [];
  const seen = new Set<string>();
  const addSummary = (summary: any | null) => {
    if (!summary) return;
    const baseId = String(summary.id ?? summary.name ?? `mesh-${summaries.length + 1}`).trim() || `mesh-${summaries.length + 1}`;
    let id = baseId;
    let suffix = 2;
    while (seen.has(id)) id = `${baseId}-${suffix++}`;
    seen.add(id);
    summaries.push({ ...summary, id, name: String(summary.name ?? id), label: String(summary.label ?? summary.name ?? id) });
  };

  const nodes = Array.isArray(gltf?.nodes) ? gltf.nodes : [];
  nodes.forEach((node: any, nodeIndex: number) => {
    const meshIndex = Number(node?.mesh);
    if (!Number.isFinite(meshIndex) || meshIndex < 0 || meshIndex >= meshes.length) return;
    const meshName = String(meshes[meshIndex]?.name ?? '').trim();
    const nodeName = String(node?.name ?? '').trim();
    const displayName = nodeName || meshName || `mesh-${meshIndex + 1}`;
    addSummary(summarizeGltfMesh(asset, meshIndex, displayName, `node-${nodeIndex}`));
  });

  if (!summaries.length) {
    meshes.forEach((mesh: any, meshIndex: number) => {
      addSummary(summarizeGltfMesh(asset, meshIndex, String(mesh?.name ?? '').trim() || `mesh-${meshIndex + 1}`, `mesh-${meshIndex}`));
    });
  }

  if (summaries.length > 1) return summaries;

  const meshIndex = Number(summaries[0]?.metadata?.meshIndex ?? 0);
  const mesh = meshes[meshIndex];
  const primitives = Array.isArray(mesh?.primitives) ? mesh.primitives : [];
  const displayName = String(summaries[0]?.label ?? summaries[0]?.name ?? mesh?.name ?? `mesh-${meshIndex + 1}`);

  if (primitives.length > 1) {
    primitives.forEach((primitive: any, primitiveIndex: number) => addSummary(summarizeGltfPrimitivePart(asset, meshIndex, primitiveIndex, displayName)));
    const materialPartIds = summaries.slice(1).filter((summary: any) => summary?.metadata?.detectionKind === 'material-primitive');
    if (materialPartIds.length > 1) return materialPartIds;
  }

  const geometryParts = summarizeGltfGeometryComponents(asset, meshIndex, 0, displayName);
  if (geometryParts.length > 1) return geometryParts;

  return summaries;
};

export const inspectModelFileParts = async (file: File | null | undefined): Promise<any[]> => {
  if (!file) {
    debugProduct3D('inspect-file:skip-empty');
    return [];
  }
  debugProduct3D('inspect-file:start', { file });
  const buffer = await file.arrayBuffer();
  debugProduct3D('inspect-file:array-buffer', { name: file.name, byteLength: buffer.byteLength });
  const asset = readGltfAssetFromArrayBuffer(buffer, file.name);
  debugProduct3D('inspect-file:gltf-asset', {
    hasJson: Boolean(asset.json),
    hasBinary: Boolean(asset.binary),
    meshCount: Array.isArray(asset.json?.meshes) ? asset.json.meshes.length : 0,
    materialCount: Array.isArray(asset.json?.materials) ? asset.json.materials.length : 0,
    nodeCount: Array.isArray(asset.json?.nodes) ? asset.json.nodes.length : 0,
  });
  const summaries = extractMeshSummariesFromGltfAsset(asset);
  debugProduct3D('inspect-file:result', { count: summaries.length, summaries });
  return summaries;
};

const readModelFileAsDataUrl = async (file: File | null | undefined): Promise<string> => {
  if (!file) return '';
  return new Promise((resolve) => {
    try {
      const reader = new FileReader();
      reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
      reader.onerror = () => resolve('');
      reader.readAsDataURL(file);
    } catch {
      resolve('');
    }
  });
};


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

const readElementVariations = (form: any): any[] => Array.isArray((form?.values as any)?.variations)
  ? (form.values as any).variations
  : [];

const isPartVariationGroup = (variation: any): boolean => Boolean(
  variation?.metadata?.asukaProduct3DPartGroup === true
  || variation?.product3d?.partGroup === true,
);

const isAutoGeneratedNativeDefaultVariation = (variation: any): boolean => Boolean(
  variation?.metadata?.asukaProduct3DAutoDefaultVariation === true,
);

const stripAutoGeneratedNativeDefaults = (variations: any[]): any[] => (
  Array.isArray(variations) ? variations.filter((variation) => !isAutoGeneratedNativeDefaultVariation(variation)) : []
);

const materialFromVariationForTarget = (variation: any, target: string): any => {
  const product3d = variation?.product3d && typeof variation.product3d === 'object' ? variation.product3d : {};
  const materials = product3d?.materials && typeof product3d.materials === 'object' ? product3d.materials : {};
  const sourceTarget = String(product3d?.materialTarget ?? '').trim();
  const sourceMaterial =
    (target && materials[target] && typeof materials[target] === 'object' ? materials[target] : null)
    ?? (sourceTarget && materials[sourceTarget] && typeof materials[sourceTarget] === 'object' ? materials[sourceTarget] : null)
    ?? Object.values(materials).find((item: any) => item && typeof item === 'object')
    ?? {};
  if ((sourceMaterial as any)?.useNativeMaterial === true) {
    return {
      ...(sourceMaterial as any),
      label: String((sourceMaterial as any)?.label ?? variation?.name ?? target),
      useNativeMaterial: true,
    };
  }
  return {
    ...(sourceMaterial as any),
    label: String((sourceMaterial as any)?.label ?? variation?.name ?? target),
    color: String((sourceMaterial as any)?.color ?? '#d7dde8'),
    textureUrl: String((sourceMaterial as any)?.textureUrl ?? ''),
    roughness: Number.isFinite(Number((sourceMaterial as any)?.roughness)) ? Number((sourceMaterial as any)?.roughness) : 0.55,
    metallic: Number.isFinite(Number((sourceMaterial as any)?.metallic)) ? Number((sourceMaterial as any)?.metallic) : 0.05,
  };
};

const createNativeDefaultVariationForPart = (part: any): any => {
  const partId = String(part?.id ?? '').trim() || 'product';
  const target = String(part?.materialTarget ?? partId).trim() || partId;
  const targetMeshIds = Array.isArray(part?.meshNames) ? part.meshNames : splitCsv(part?.meshNames);
  return {
    id: 'default',
    name: 'Default',
    active: true,
    price: 0,
    image: '',
    icon: '',
    product3d: {
      partId,
      materialTarget: target,
      materials: {
        [target]: {
          label: 'Default',
          useNativeMaterial: true,
          targetMeshIds,
          metadata: {
            asukaProduct3DPartId: partId,
            asukaProduct3DPartLabel: String(part?.label ?? partId),
            targetMeshIds,
          },
        },
      },
    },
    metadata: {
      asukaProduct3DPartId: partId,
      asukaProduct3DPartLabel: String(part?.label ?? partId),
      asukaProduct3DAutoDefaultVariation: true,
    },
    variations: [],
  };
};

const scopeVariationTreeToSinglePart = (variation: any, part: any): any => {
  const source = clonePlain(variation) as any;
  const partId = String(part?.id ?? '').trim() || 'product';
  const target = String(part?.materialTarget ?? partId).trim() || partId;
  const targetMeshIds = Array.isArray(part?.meshNames) ? part.meshNames : splitCsv(part?.meshNames);
  const children = Array.isArray(source?.variations)
    ? source.variations.map((child: any) => scopeVariationTreeToSinglePart(child, part))
    : [];
  const existingProduct3D = source?.product3d && typeof source.product3d === 'object' ? source.product3d : {};
  const rawMaterials = existingProduct3D?.materials && typeof existingProduct3D.materials === 'object' ? existingProduct3D.materials : {};
  const rawTarget = String(existingProduct3D?.materialTarget ?? '').trim();
  const sourceMaterial =
    (rawMaterials[target] && typeof rawMaterials[target] === 'object' ? rawMaterials[target] : null)
    ?? (rawTarget && rawMaterials[rawTarget] && typeof rawMaterials[rawTarget] === 'object' ? rawMaterials[rawTarget] : null)
    ?? (rawMaterials.product && typeof rawMaterials.product === 'object' ? rawMaterials.product : null)
    ?? Object.values(rawMaterials).find((item: any) => item && typeof item === 'object')
    ?? null;
  const shouldOwnMaterial = children.length === 0 || sourceMaterial !== null;
  const nextProduct3D: any = {
    ...existingProduct3D,
    partId,
    materialTarget: target,
  };
  try { delete nextProduct3D.partGroup; } catch {}
  if (shouldOwnMaterial) {
    const material = sourceMaterial
      ? materialFromVariationForTarget(source, target)
      : { label: String(source?.name ?? source?.label ?? target), color: '#d7dde8', textureUrl: '', roughness: 0.55, metallic: 0.05 };
    nextProduct3D.materials = {
      [target]: {
        ...material,
        targetMeshIds,
        metadata: {
          ...((material?.metadata && typeof material.metadata === 'object') ? material.metadata : {}),
          asukaProduct3DPartId: partId,
          asukaProduct3DPartLabel: String(part?.label ?? partId),
          targetMeshIds,
        },
      },
    };
  } else {
    try { delete nextProduct3D.materials; } catch {}
  }
  return {
    ...source,
    product3d: nextProduct3D,
    metadata: {
      ...((source?.metadata && typeof source.metadata === 'object') ? source.metadata : {}),
      asukaProduct3DPartId: partId,
      asukaProduct3DPartLabel: String(part?.label ?? partId),
    },
    variations: children,
  };
};

const ensureOneActiveVariation = (items: any[]): any[] => {
  let activeSeen = false;
  const next = (Array.isArray(items) ? items : []).map((item, _index) => {
    const active = item?.active === true && !activeSeen;
    if (active) activeSeen = true;
    return { ...(item ?? {}), active };
  });
  if (!activeSeen && next[0]) next[0] = { ...next[0], active: true };
  return next;
};

const cloneVariationForPart = (variation: any, part: any, index: number): any => {
  const target = String(part?.materialTarget ?? part?.id ?? part?.meshNames?.[0] ?? 'product').trim() || 'product';
  const partSlug = String(part?.id ?? target).replace(/[^a-zA-Z0-9_-]+/g, '-').toLowerCase();
  const idBase = String(variation?.id ?? `var-${index + 1}`).replace(/-part-[a-z0-9_-]+$/i, '');
  const material = materialFromVariationForTarget(variation, target);
  const targetMeshIds = Array.isArray(part?.meshNames) ? part.meshNames : splitCsv(part?.meshNames);
  const cloned = clonePlain(variation) as any;
  // Part variations must stay scoped to their detected 3D part. Reusing the full
  // source variation product3d payload here can carry a global material or a
  // model override into every part child; then editing one child recolors the
  // whole GLB. Keep the normal variation fields, but rebuild product3d as a
  // part-only material patch.
  return {
    ...cloned,
    id: `${idBase}-part-${partSlug}`,
    product3d: {
      partId: String(part?.id ?? ''),
      materialTarget: target,
      materials: {
        [target]: {
          ...material,
          targetMeshIds,
          metadata: {
            ...((material?.metadata && typeof material.metadata === 'object') ? material.metadata : {}),
            asukaProduct3DPartId: String(part?.id ?? ''),
            asukaProduct3DPartLabel: String(part?.label ?? part?.id ?? ''),
            targetMeshIds,
          },
        },
      },
    },
    metadata: {
      ...((variation?.metadata && typeof variation.metadata === 'object') ? clonePlain(variation.metadata) : {}),
      asukaProduct3DPartId: String(part?.id ?? ''),
    },
    variations: Array.isArray(variation?.variations)
      ? variation.variations.map((child: any, childIndex: number) => cloneVariationForPart(child, part, childIndex))
      : [],
  };
};

const normalizePartVariationGroupChildren = (children: any[], part: any): any[] => ensureOneActiveVariation(
  (Array.isArray(children) ? children : [])
    .map((variation, index) => cloneVariationForPart(variation, part, index)),
);

const createPartVariationGroup = (part: any, templateVariations: any[], existingGroup?: any): any => {
  const partId = String(part?.id ?? '').trim() || genId('part');
  const target = String(part?.materialTarget ?? partId).trim() || partId;
  const retainedExistingChildren = stripAutoGeneratedNativeDefaults(existingGroup?.variations);
  const existingChildren = retainedExistingChildren.length ? retainedExistingChildren : null;
  return {
    ...(existingGroup ? clonePlain(existingGroup) : {}),
    id: String(existingGroup?.id ?? `part-group-${partId}`).trim() || `part-group-${partId}`,
    name: String(existingGroup?.name ?? part?.label ?? partId).trim() || partId,
    active: false,
    price: existingGroup?.price ?? 0,
    image: typeof existingGroup?.image === 'string' ? existingGroup.image : '',
    icon: typeof existingGroup?.icon === 'string' ? existingGroup.icon : '',
    product3d: {
      ...((existingGroup?.product3d && typeof existingGroup.product3d === 'object') ? clonePlain(existingGroup.product3d) : {}),
      partGroup: true,
      partId,
      materialTarget: target,
    },
    metadata: {
      ...((existingGroup?.metadata && typeof existingGroup.metadata === 'object') ? clonePlain(existingGroup.metadata) : {}),
      asukaProduct3DPartGroup: true,
      asukaProduct3DPartId: partId,
      asukaProduct3DPartLabel: String(part?.label ?? partId),
    },
    variations: normalizePartVariationGroupChildren(existingChildren ?? templateVariations, part),
  };
};

export const syncDetectedPartsWithVariationTree = (form: any, detectedParts: any[]) => {
  const parts = normalizeParts(detectedParts);
  if (parts.length <= 1) return;

  const currentVariations = readElementVariations(form);
  const existingGroups = currentVariations.filter(isPartVariationGroup);
  const templateVariations = currentVariations.filter((variation) => !isPartVariationGroup(variation));
  const firstExistingGroupChildren = existingGroups
    .map((group) => stripAutoGeneratedNativeDefaults(group?.variations))
    .find((children) => children.length) ?? [];
  const safeTemplates = templateVariations.length
    ? templateVariations
    : (firstExistingGroupChildren.length ? firstExistingGroupChildren : []);

  const groups = parts.map((part) => {
    const partId = String(part?.id ?? '').trim();
    const existing = existingGroups.find((group) => String(group?.product3d?.partId ?? group?.metadata?.asukaProduct3DPartId ?? '').trim() === partId);
    return createPartVariationGroup(part, safeTemplates, existing);
  });

  form.setValue('variations', groups);
};

export const syncSinglePartWithVariationTree = (form: any, detectedPart: any) => {
  const part = normalizeParts([detectedPart])[0];
  if (!part) return;
  const currentVariations = readElementVariations(form);
  const flatVariations = currentVariations.filter((variation: any) => !isPartVariationGroup(variation));
  const firstPartChoices = currentVariations.find((variation: any) => isPartVariationGroup(variation) && Array.isArray(variation?.variations) && variation.variations.length)?.variations ?? [];
  const sourceVariations = flatVariations.length ? flatVariations : firstPartChoices;
  const nextVariations = sourceVariations.length
    ? sourceVariations.map((variation: any) => scopeVariationTreeToSinglePart(variation, part))
    : [createNativeDefaultVariationForPart(part)];
  form.setValue('variations', ensureOneActiveVariation(nextVariations));
};

const patchProduct3D = (form: any, patch: Record<string, any>) => {
  const current = readProduct3D(form);
  const next = { ...current, ...patch };
  // Product/support material values are intentionally owned by variations and 3D parts.
  // Product-level print areas are kept here because 3D texture zones are
  // stable product metadata, not freehand Fabric drawing areas.
  try { delete (next as any).materialTarget; } catch {}
  try { delete (next as any).materials; } catch {}
  // Several model upload side effects can run in the same tick: first the local
  // GLB data URL is patched, then mesh/part detection patches parts. Preact state
  // updates are async, so reading only form.values here can lose the model URL and
  // make submit load the gray placeholder. Keep a transient draft on the current
  // form object so consecutive patches merge with the latest in-flight product3d.
  debugProduct3D('form:patch-product3d', {
    patchKeys: Object.keys(patch ?? {}),
    patch,
    previous: current,
    next,
  });
  try { (form as any).__asukaProduct3DPending = next; } catch {}
  form.setValue('product3d', next);
};

export type Product3DElementFieldsProps = {
  t: (key: string, fallback?: string) => string;
  form: any;
  upload?: AsukaUploadModuleConfig;
};

export const Product3DElementFields: FunctionalComponent<Product3DElementFieldsProps> = ({ t, form, upload }) => {
  const type = String((form.values as any).type ?? '').trim();
  const isProduct3D = type === 'product-3d';
  const product3d = readProduct3D(form);
  const parts = normalizeParts(product3d.parts);

  if (!isProduct3D) return null;

  const setParts = (items: any[]) => patchProduct3D(form, { parts: normalizeParts(items) });
  const buildPartsFromMeshSummaries = (meshSummaries: any[], currentParts: any[] = parts): any[] => {
    const realMeshes = (Array.isArray(meshSummaries) ? meshSummaries : []).filter((mesh: any) => {
      if (!mesh || mesh?.metadata?.isProductAlias === true) return false;
      if (Number.isFinite(Number(mesh?.vertices)) && Number(mesh.vertices) <= 0) return false;
      return true;
    });
    if (!realMeshes.length) return [];

    const existingByMesh = new Map<string, any>();
    for (const part of normalizeParts(currentParts)) {
      for (const meshName of splitCsv(part.meshNames)) existingByMesh.set(meshName, part);
    }

    const detectedMeshNames = new Set<string>();
    const detected = realMeshes.map((mesh: any, index: number) => {
      const nativeName = String(mesh?.metadata?.nativeName ?? mesh?.name ?? mesh?.id ?? '').trim();
      const nativeId = String(mesh?.metadata?.nativeId ?? mesh?.id ?? nativeName).trim();
      const meshKey = nativeName || nativeId || `mesh-${index + 1}`;
      detectedMeshNames.add(meshKey);
      if (nativeId) detectedMeshNames.add(nativeId);
      const existing = existingByMesh.get(meshKey) ?? existingByMesh.get(nativeId) ?? null;
      const id = String(existing?.id ?? slugifyPartId(meshKey, `part-${index + 1}`));
      const materialName = String(mesh?.materialName ?? mesh?.materialId ?? '').trim();
      return {
        ...(existing ?? {}),
        id,
        label: String(existing?.label ?? mesh?.label ?? meshKey).trim() || meshKey,
        meshNames: Array.from(new Set([...(splitCsv(existing?.meshNames)), meshKey].filter(Boolean))),
        materialNames: Array.from(new Set([...(splitCsv(existing?.materialNames)), materialName].filter(Boolean))),
        materialTarget: String(existing?.materialTarget ?? id).trim() || id,
        editable: existing?.editable !== false,
        printable: existing?.printable === true,
        visible: existing?.visible !== false,
        locked: existing?.locked === true,
        metadata: {
          ...((mesh?.metadata && typeof mesh.metadata === 'object') ? mesh.metadata : {}),
          ...((existing?.metadata && typeof existing.metadata === 'object') ? existing.metadata : {}),
          detectedFrom: String(mesh?.metadata?.detectedFrom ?? 'model-file'),
          nativeId,
          nativeName,
          vertices: mesh?.vertices,
        },
      };
    });
    const remainingManualParts = normalizeParts(currentParts).filter((part) => {
      const meshNames = splitCsv(part.meshNames);
      return meshNames.length === 0 || meshNames.every((meshName) => !detectedMeshNames.has(meshName));
    });
    return [...detected, ...remainingManualParts];
  };

  const syncPartsFromMeshSummaries = (meshSummaries: any[]) => {
    debugProduct3D('parts:sync-start', { summaryCount: Array.isArray(meshSummaries) ? meshSummaries.length : 0, meshSummaries });
    const nextParts = buildPartsFromMeshSummaries(meshSummaries);
    debugProduct3D('parts:built', { partCount: nextParts.length, nextParts });
    if (nextParts.length > 1) {
      // Preserve the established multi-part model exactly: one detected part group
      // per logical component, with no automatically invented child variation.
      setParts(nextParts);
      syncDetectedPartsWithVariationTree(form, nextParts);
      debugProduct3D('parts:multi-variations-synced', { variationCount: readElementVariations(form).length, parts: nextParts });
      return;
    }
    if (nextParts.length === 1) {
      // A single-mesh GLB still needs one logical target for picking and material
      // application, but its variation tree remains flat. Do not wrap the user's
      // variations in a redundant part-group/sub-variation level.
      setParts(nextParts);
      syncSinglePartWithVariationTree(form, nextParts[0]);
      debugProduct3D('parts:single-flat-variations-synced', { variationCount: readElementVariations(form).length, part: nextParts[0] });
    }
  };

  const autoDetectPartsFromModel = async (model: { url: string; format: 'glb' | 'gltf' }) => {
    const endSpan = beginAsuka3DPerfSpan('form-model', 'detect-from-model', { model });
    const url = String(model?.url ?? '').trim();
    if (!url) {
      debugProduct3D('model-detect:skip-empty-url', { model });
      endSpan({ ok: false, reason: 'empty-url' }, { level: 'warn' });
      return;
    }
    try {
      debugProduct3D('model-detect:start', { model });
      const engine = getRuntimeRenderEngine('3d') as any;
      const meshes = engine?.models
        ? await engine.models.inspectParts({ model })
        : [];
      debugProduct3D('model-detect:result', { hasEngine: Boolean(engine), count: Array.isArray(meshes) ? meshes.length : 0, meshes });
      const syncStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
      syncPartsFromMeshSummaries(meshes);
      endSpan({ ok: true, meshSummaryCount: Array.isArray(meshes) ? meshes.length : 0, syncMs: (typeof performance !== 'undefined' ? performance.now() : Date.now()) - syncStart });
    } catch (error) {
      debugProduct3D('model-detect:error', { error }, 'error');
      endSpan({ ok: false, error }, { level: 'error' });
    }
  };

  const autoDetectPartsFromUploadedFiles = async (files: File[]) => {
    const file = files?.[0];
    const endSpan = beginAsuka3DPerfSpan('form-model', 'upload-detect', { file });
    if (!file) {
      debugProduct3D('upload-detect:skip-empty-files', { files });
      endSpan({ ok: false, reason: 'empty-files' }, { level: 'warn' });
      return;
    }
    try {
      const nextFormat = inferModelFormat(file.name, String(product3d.model?.format ?? 'glb'));
      debugProduct3D('upload-detect:start', { file, nextFormat, currentProduct3D: readProduct3D(form) });
      const readStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
      const localUrl = await readModelFileAsDataUrl(file);
      const readDataUrlMs = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - readStart;
      logAsuka3DPerf('form-model', 'file-read-data-url', { file, readDataUrlMs, dataUrlLength: String(localUrl ?? '').length }, { durationMs: readDataUrlMs });
      // Patch the model field immediately from the selected file, so submitting
      // right after choosing a GLB/glTF still loads the actual model instead of
      // falling back to the placeholder while the configured upload pipeline is
      // still resolving. If a remote/custom upload handler is configured, its URL
      // will still arrive through onValueChange and replace this temporary value.
      debugProduct3D('upload-detect:data-url-read', { hasLocalUrl: Boolean(localUrl), localUrl });
      if (localUrl) {
        register3DModelFileRuntimeUrl(localUrl, file);
        const currentProduct = readProduct3D(form);
        const nextModel = { ...(currentProduct.model ?? {}), url: localUrl, format: nextFormat };
        patchProduct3D(form, { model: nextModel });
        try {
          const runtimeEngine = getRuntimeRenderEngine('3d') as any;
          const preloadEnd = beginAsuka3DPerfSpan('form-model', 'preload-request', { file, model: nextModel, hasEngine: Boolean(runtimeEngine) });
          void Promise.resolve(runtimeEngine?.models?.preload({ model: nextModel }))
            .then((ok) => preloadEnd({ ok: ok === true }))
            .catch((error) => preloadEnd({ ok: false, error }, { level: 'error' }));
        } catch (error) {
          logAsuka3DPerf('form-model', 'preload-request-error', { file, error }, { level: 'error' });
        }
      }
      const inspectStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
      const meshes = await inspectModelFileParts(file);
      const inspectMs = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - inspectStart;
      const syncStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
      if (Array.isArray(meshes) && meshes.length) syncPartsFromMeshSummaries(meshes);
      else debugProduct3D('upload-detect:no-meshes-detected', { file });
      const syncMs = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - syncStart;
      endSpan({ ok: true, readDataUrlMs, dataUrlLength: String(localUrl ?? '').length, inspectMs, syncMs, meshSummaryCount: Array.isArray(meshes) ? meshes.length : 0 });
    } catch (error) {
      debugProduct3D('upload-detect:error', { error }, 'error');
      endSpan({ ok: false, error }, { level: 'error' });
    }
  };

  return (
    <div className="ask-flex ask-flex-col ask-gap-4">
      <SectionCard
        title={t('elements.product3d.modelTitle', '3D model')}
        description={t('elements.product3d.modelHelper', 'The active 3D view creates a Product 3D element automatically. Add the default model here; each variation can override the model and owns its colors/textures.')}
      >
        <FormGrid columns={2} className="max-lg:ask-grid-cols-1">
          <ModelField
            label={t('elements.product3d.modelFile', 'Model file')}
            hint={t('elements.product3d.modelFileHint', 'Paste a GLB/glTF URL, or upload/select a model file.')}
            placeholder="/models/tshirt.glb"
            accept=".glb,.gltf,model/gltf-binary,model/gltf+json,application/octet-stream,application/json"
            value={String(product3d.model?.url ?? '')}
            upload={upload}
            uploadSource="element-editor:product-3d-model"
            uploadDefaultStrategy="base64"
            showPreview={false}
            onFilesSelected={(files) => {
              debugProduct3D('field:on-files-selected', { files });
              void autoDetectPartsFromUploadedFiles(files);
            }}
            onValueChange={(value) => {
              const nextFormat = inferModelFormat(value, String(product3d.model?.format ?? 'glb'));
              debugProduct3D('field:on-value-change', { value, nextFormat, currentProduct3D: readProduct3D(form) });
              patchProduct3D(form, { model: value ? { ...(readProduct3D(form).model ?? {}), url: value, format: nextFormat } : undefined });
              const rawValue = String(value ?? '').trim();
              // Local file uploads are already inspected through onFilesSelected().
              // Re-importing the same large data/blob URL here doubles the GLB parse
              // work and makes the Product 3D editor increasingly laggy.
              if (rawValue && !/^(data:|blob:)/i.test(rawValue)) {
                void autoDetectPartsFromModel({ url: rawValue, format: nextFormat });
              }
            }}
          />
          <SelectField
            label={t('elements.product3d.modelFormat', 'Model format')}
            value={String(product3d.model?.format ?? 'glb')}
            options={[{ value: 'glb', label: 'GLB' }, { value: 'gltf', label: 'glTF' }]}
            onValueChange={(value) => patchProduct3D(form, { model: { ...(product3d.model ?? {}), format: value } })}
          />
        </FormGrid>
      </SectionCard>

      {/* Model parts and 3D print areas are intentionally not rendered here.
          Multi-mesh GLB/glTF files are detected automatically from the model file
          and their part variation groups are managed in the normal Variations tree. */}
      <ElementAssemblyFields t={t} form={form} />
    </div>
  );
};
