/* ═══════════════════════════════════════════════════════════════
   Generic 3D Product Configurator Engine.
   Renders whatever GLB/GLTF is referenced by config.json and builds its
   entire UI (colors, materials, decals, scene options, pricing) purely
   from that config. Swapping products means swapping the model file and
   config.json only — nothing in this file names a specific product,
   mesh, or texture, so any model exported from Blender can drive it.
═══════════════════════════════════════════════════════════════ */
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader }    from "three/addons/loaders/GLTFLoader.js";
import { DRACOLoader }   from "three/addons/loaders/DRACOLoader.js";

const PANEL_W = 390;

/* ── DOM refs ──────────────────────────────────────────────────── */
const canvas         = document.getElementById("canvas");
const loader         = document.getElementById("loader");
const loaderSpin     = document.getElementById("loader-spinner");
const loaderText     = document.getElementById("loader-text");
const loaderError    = document.getElementById("loader-error");
const modelStatus    = document.getElementById("model-status");
const dropOverlay    = document.getElementById("drop-overlay");
const toast          = document.getElementById("toast");
const toastMsg       = document.getElementById("toast-msg");
const btnResetCam    = document.getElementById("btn-reset-cam");
const btnExport      = document.getElementById("btn-export");
const tabNav         = document.getElementById("tab-nav");
const panelBody      = document.getElementById("panel-body");
const priceTotalEl   = document.getElementById("price-total");
const priceBreakdown = document.getElementById("price-breakdown");
const btnResetCfg    = document.getElementById("btn-reset-cfg");
const btnAddToCart   = document.getElementById("btn-add-to-cart");
const cfgHashEl      = document.getElementById("cfg-hash");

/* Fallback tab icon used when a tab in config.json doesn't supply its
   own "icon" SVG markup — keeps the UI from ever showing a blank box. */
const DEFAULT_TAB_ICON = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.6"/></svg>`;

/* Fallback thumbnail icon for the generic material-grid control, used
   whenever a Materials Library entry has no thumbnail image yet. */
const STK_DEFAULT_ICON = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/></svg>`;
const STK_NONE_ICON = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/><line x1="6.5" y1="17.5" x2="17.5" y2="6.5" stroke="currentColor" stroke-width="1.6"/></svg>`;

/* ── Product engine state — populated entirely from config.json.
   app.js never hardcodes a product's mesh names, colors, or presets;
   swapping products means swapping the GLB + config.json only. ──── */
let PRODUCT_DATA = null;   // parsed config.json
let CFG          = {};     // live user selections, keyed by cfgPath
let meshRefs     = {};     // raw GLB node name -> [THREE.Mesh]
let TARGET_REFS  = {};     // logical meshTargets key -> [THREE.Mesh]
let TARGET_NODE_SPANS  = {}; // logical meshTargets key -> [ [spanA, spanB] per node ] — real-world width/height of the geometry itself, independent of any material
let MATERIALS_CATALOG  = []; // flat list of Materials Library products fetched from the host (WordPress, etc.)
let MATERIALS_BY_ID    = {};
let materialsCatalogPromise = null;
let MATERIAL_GRID_CREATED = {}; // logical meshTargets key -> [ [material per slot] per node ] this control itself created, so switching products disposes exactly those and never the mesh's original GLB-baked material
let MATERIAL_GRID_FILTER  = {}; // control id -> active category filter (or falsy for "All")
let ORIGINAL_NODE_MATERIALS = {}; // logical meshTargets key -> [ original material(s) per node ], captured once on first override so the "None" card can restore the mesh's own GLB-baked material

function viewportW() {
  return window.innerWidth <= 767 ? window.innerWidth : window.innerWidth - PANEL_W;
}

/* Detected once at boot — used to scale render quality (pixel ratio,
   shadow resolution) down on phones/tablets so first paint and frame
   rate stay smooth on weaker GPUs and slower mobile networks, without
   touching anything product-specific. ─────────────────────────────── */
const isMobileDevice = window.innerWidth <= 767 || /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);

/* ── Renderer / scene / camera — neutral defaults that work for any
   GLB regardless of its real-world scale or units. ──────────────── */
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobileDevice ? 1.5 : 2));
renderer.setSize(viewportW(), window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.NeutralToneMapping;
renderer.toneMappingExposure = 1.0;

const scene = new THREE.Scene();
scene.background = new THREE.Color("#1a1a2e");

const camera = new THREE.PerspectiveCamera(45, viewportW() / window.innerHeight, 0.1, 100);
camera.position.set(2, 2, 5);
camera.lookAt(0, 0, 0);

const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 0.5;
controls.maxDistance = 20;
controls.target.set(0, 0, 0);
controls.update();

/* ── Studio environment map — built from a DataTexture in memory so
   there is no file download and no load delay on mobile. PMREMGenerator
   converts it to a prefiltered mipmap chain that PBR materials use for
   image-based lighting (reflections, ambient). scene.background stays
   as the user's chosen solid colour; only scene.environment is set. ── */
(function () {
  const W = 64, H = 32;
  const data = new Uint8Array(W * H * 4);
  for (let y = 0; y < H; y++) {
    const t = y / (H - 1);                        // 0 = sky top, 1 = ground bottom
    const r = Math.round(255 + (20  - 255) * t);  // warm white  → near-black
    const g = Math.round(250 + (20  - 250) * t);
    const b = Math.round(240 + (24  - 240) * t);
    for (let x = 0; x < W; x++) {
      const i = (y * W + x) * 4;
      data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 255;
    }
  }
  const tex = new THREE.DataTexture(data, W, H);
  tex.mapping    = THREE.EquirectangularReflectionMapping;
  tex.colorSpace = THREE.SRGBColorSpace;
  tex.needsUpdate = true;
  const pmrem = new THREE.PMREMGenerator(renderer);
  pmrem.compileEquirectangularShader();
  scene.environment = pmrem.fromEquirectangular(tex).texture;
  tex.dispose();
  pmrem.dispose();
})();

/* ── Lights ────────────────────────────────────────────────────── */
const ambientLight = new THREE.AmbientLight(0xffffff, 1.2);
scene.add(ambientLight);

const mainLight = new THREE.DirectionalLight(0xffffff, 1.2);
mainLight.position.set(5, 10, 7);
mainLight.castShadow = true;
// Lower shadow-map resolution on phones — halves GPU/memory cost for
// shadow rendering with no visible difference on a small screen.
const shadowRes = isMobileDevice ? 1024 : 2048;
mainLight.shadow.mapSize.set(shadowRes, shadowRes);
scene.add(mainLight);

const fillLight = new THREE.DirectionalLight(0xffffff, 0.4);
fillLight.position.set(-5, 2, -3);
scene.add(fillLight);

const rimLight = new THREE.DirectionalLight(0xffffff, 0.2);
rimLight.position.set(0, -2, -5);
scene.add(rimLight);

/* ── Ground — positioned/scaled relative to whatever model loads, since
   a model's real-world scale isn't known until it's actually loaded. ── */
const groundGeo = new THREE.PlaneGeometry(20, 20);
const groundMat = new THREE.MeshStandardMaterial({
  color: 0x222244, roughness: 1.0, metalness: 0,
  transparent: true, opacity: 0.3, side: THREE.DoubleSide
});
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

function handleViewportResize() {
  camera.aspect = viewportW() / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(viewportW(), window.innerHeight);
}
window.addEventListener("resize", handleViewportResize);
// Mobile browsers (notably iOS Safari) resize the visual viewport when the
// address bar / keyboard show or hide without always firing window "resize" —
// listening here too keeps the canvas correctly sized on mobile.
if (window.visualViewport) {
  window.visualViewport.addEventListener("resize", handleViewportResize);
}

/* ── Mobile bottom-sheet panel ─────────────────────────────────────
   On mobile the config panel is a draggable bottom sheet (see the
   ≤767px rules in style.css). The handle markup/CSS exist purely as
   styling hooks — this wires up the actual drag-to-resize + tap-to-
   toggle behaviour so customers can see more of the model or more of
   the controls as needed, and still always reach the price/cart bar. ── */
const panelEl     = document.getElementById("panel");
const panelHandle = document.getElementById("panel-handle");
const SHEET_HEIGHT_FRAC = { collapsed: 0.16, default: 0.52, expanded: 0.86 };
let sheetState = "default";

function applySheetHeight(px) {
  if (panelEl) panelEl.style.height = `${px}px`;
}
function snapSheetTo(state) {
  sheetState = state;
  applySheetHeight(Math.round(window.innerHeight * SHEET_HEIGHT_FRAC[state]));
}

if (panelHandle && panelEl) {
  let dragging = false, moved = false, startY = 0, startHeight = 0;

  const onPointerDown = e => {
    dragging = true; moved = false;
    startY = e.clientY;
    startHeight = panelEl.getBoundingClientRect().height;
    panelHandle.setPointerCapture?.(e.pointerId);
  };
  const onPointerMove = e => {
    if (!dragging) return;
    const dy = startY - e.clientY;
    if (Math.abs(dy) > 4) moved = true;
    const next = Math.min(window.innerHeight * 0.92, Math.max(window.innerHeight * 0.10, startHeight + dy));
    applySheetHeight(next);
  };
  const onPointerUp = e => {
    if (!dragging) return;
    dragging = false;
    panelHandle.releasePointerCapture?.(e.pointerId);
    if (!moved) {
      // Plain tap on the handle — toggle between collapsed and default
      // so a quick tap is enough to see more of the product.
      snapSheetTo(sheetState === "collapsed" ? "default" : "collapsed");
      return;
    }
    // Dragged — snap to whichever of the three heights is closest.
    const currentFrac = panelEl.getBoundingClientRect().height / window.innerHeight;
    const closest = Object.entries(SHEET_HEIGHT_FRAC)
      .sort((a, b) => Math.abs(a[1] - currentFrac) - Math.abs(b[1] - currentFrac))[0][0];
    snapSheetTo(closest);
  };

  panelHandle.addEventListener("pointerdown", onPointerDown);
  panelHandle.addEventListener("pointermove", onPointerMove);
  panelHandle.addEventListener("pointerup", onPointerUp);
  panelHandle.addEventListener("pointercancel", onPointerUp);
}
// Returning to desktop width: let CSS (100vh) govern the panel again
// instead of leaving a leftover inline mobile height applied.
window.addEventListener("resize", () => {
  if (window.innerWidth > 767 && panelEl) panelEl.style.height = "";
});

let autoRotateEnabled = false;
let currentModel = null;

(function tick() {
  requestAnimationFrame(tick);
  if (autoRotateEnabled && currentModel) currentModel.rotation.y += 0.0035;
  controls.update();
  renderer.render(scene, camera);
})();

/* ── Loaders ──────────────────────────────────────────────────── */
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath("assets/js/three/examples/jsm/libs/draco/");
dracoLoader.setDecoderConfig({ type: 'js' }); // JS-only decoder — .wasm excluded from WP.org dist.

const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);

/* ── Normalizes colorSpace/filtering on every texture map a loaded
   model has, regardless of how it was exported.
   Only the colour channels (map, emissiveMap) are sRGB — all other
   maps carry linear data and must stay in NoColorSpace or Three.js
   will double-gamma them and produce incorrect PBR output. ───────── */
const TEXTURE_MAP_KEYS   = ["map", "emissiveMap", "normalMap", "roughnessMap", "metalnessMap", "aoMap", "alphaMap"];
const TEXTURE_COLOR_MAPS = new Set(["map", "emissiveMap"]);
function fixTextures(model) {
  model.traverse(node => {
    if (!node.isMesh) return;
    node.castShadow = node.receiveShadow = true;
    const mats = Array.isArray(node.material) ? node.material : [node.material];
    mats.forEach(mat => {
      if (!mat) return;
      if (mat.alphaMap) {
        mat.transparent = true;
        mat.depthWrite = false;
        mat.side = THREE.DoubleSide;
      }
      TEXTURE_MAP_KEYS.forEach(key => {
        const tex = mat[key];
        if (tex && tex.isTexture) {
          tex.colorSpace = TEXTURE_COLOR_MAPS.has(key) ? THREE.SRGBColorSpace : THREE.NoColorSpace;
          tex.minFilter = THREE.LinearMipmapLinearFilter;
          tex.magFilter = THREE.LinearFilter;
          tex.generateMipmaps = true;
          tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
          tex.needsUpdate = true;
        }
      });
      mat.needsUpdate = true;
    });
  });
}

/* ═══════════════════════════════════════════════════════════════
   CFG HELPERS — deep-read / deep-write the live state object
═══════════════════════════════════════════════════════════════ */
function getCFGValue(path) {
  if (!path) return undefined;
  return path.split(".").reduce((o, k) => o?.[k], CFG);
}
function setCFGValue(path, value) {
  if (!path) return;
  const parts = path.split(".");
  let obj = CFG;
  for (let i = 0; i < parts.length - 1; i++) {
    if (obj[parts[i]] === undefined) obj[parts[i]] = {};
    obj = obj[parts[i]];
  }
  obj[parts[parts.length - 1]] = value;
}
function deepMerge(target, source) {
  for (const k of Object.keys(source)) {
    if (source[k] && typeof source[k] === "object" && !Array.isArray(source[k])) {
      if (!target[k] || typeof target[k] !== "object") target[k] = {};
      deepMerge(target[k], source[k]);
    } else {
      target[k] = source[k];
    }
  }
  return target;
}

function cfgStorageKey() {
  return `noor-configurator-cfg-${PRODUCT_DATA?.id || "default"}`;
}
function saveCFG() {
  try { localStorage.setItem(cfgStorageKey(), JSON.stringify(CFG)); } catch (e) { /* ignore quota errors */ }
}
function buildInitialCFG() {
  const cfg = deepMerge({}, PRODUCT_DATA.defaults || {});
  try {
    const saved = localStorage.getItem(cfgStorageKey());
    if (saved) deepMerge(cfg, JSON.parse(saved));
  } catch (e) { /* ignore corrupt storage */ }
  return cfg;
}

/* ═══════════════════════════════════════════════════════════════
   MESH TARGET RESOLUTION — maps config.json's logical target keys
   (whatever names a product's config defines, e.g. "body", "strap")
   onto the actual named nodes in whatever GLB is currently loaded.
═══════════════════════════════════════════════════════════════ */
function resolveMeshRefs(model) {
  meshRefs = {};
  model.traverse(node => {
    if (!node.isMesh) return;
    if (Array.isArray(node.material)) node.material = node.material.map(m => m.clone());
    else if (node.material) node.material = node.material.clone();
    if (!meshRefs[node.name]) meshRefs[node.name] = [];
    meshRefs[node.name].push(node);
  });
}
function resolveTargetRefs() {
  TARGET_REFS = {};
  const targets = PRODUCT_DATA?.meshTargets || {};
  Object.keys(targets).forEach(key => {
    const nodeName = targets[key];
    TARGET_REFS[key] = meshRefs[nodeName] || [];
    if (TARGET_REFS[key].length === 0) {
      console.warn(`meshTargets.${key} ("${nodeName}") was not found in the loaded model.`);
    }
  });
  computeTargetNodeSpans();
}

/* ═══════════════════════════════════════════════════════════════
   REAL-WORLD SCALE — caches each meshTargets entry's own world-space
   bounding box once at load, independent of any material. The two
   largest dimensions are treated as the surface's real width/height,
   regardless of whether that surface is a floor, a wall, a ceiling, or
   anything else — nothing here assumes one axis. The Materials Library
   (material-grid) divides this by a selected product's own physical
   size to compute a correct repeat on demand — see repeatForDimensions
   below — so no repeat value is ever stored in config.json. Relies on
   the GLB itself being authored at true real-world scale with a clean
   UV unwrap. ───────────────────────────────────────────────────────── */
function computeTargetNodeSpans() {
  TARGET_NODE_SPANS = {};
  const box  = new THREE.Box3();
  const size = new THREE.Vector3();
  Object.keys(TARGET_REFS).forEach(key => {
    TARGET_NODE_SPANS[key] = TARGET_REFS[key].map(node => {
      box.setFromObject(node);
      box.getSize(size);
      const [spanA, spanB] = [size.x, size.y, size.z].sort((a, b) => b - a);
      return [spanA, spanB];
    });
  });
}
/* repeat = mesh's real-world span ÷ a product's own physical size
   (mm, converted to meters). Returns null (→ stretch to fit, no
   tiling) if either the mesh span or the product's dimensions aren't
   available. Called on demand per material-grid selection, so two
   different products on the same mesh each get their own correct
   repeat. ──────────────────────────────────────────────────────── */
function repeatForDimensions(targetKey, nodeIndex, dims) {
  const span = TARGET_NODE_SPANS[targetKey]?.[nodeIndex];
  const widthMm  = Number(dims?.width_mm);
  const heightMm = Number(dims?.height_mm);
  if (!span || !widthMm || !heightMm) return null;
  return { x: span[0] / (widthMm / 1000), y: span[1] / (heightMm / 1000) };
}

/* ═══════════════════════════════════════════════════════════════
   GENERIC APPLY FUNCTIONS — the only two primitives this stage of
   the engine supports. Both operate purely off meshTargets/config,
   never a hardcoded mesh or product name.
═══════════════════════════════════════════════════════════════ */
function forEachTargetMaterial(targetKey, fn) {
  (TARGET_REFS[targetKey] || []).forEach(node => {
    const mats = Array.isArray(node.material) ? node.material : [node.material];
    mats.forEach(m => { if (m) fn(m); });
  });
}
function applyColorToTarget(targetKey, hex) {
  if (!hex) return;
  forEachTargetMaterial(targetKey, mat => {
    if (mat.color) mat.color.set(hex);
    mat.needsUpdate = true;
  });
}
function applyMaterialPreset(targetKey, presetKey) {
  const preset = PRODUCT_DATA?.materialPresets?.[presetKey];
  if (!preset) return;
  forEachTargetMaterial(targetKey, mat => {
    if (preset.color !== undefined && mat.color) mat.color.set(preset.color);
    if (preset.roughness !== undefined) mat.roughness = preset.roughness;
    if (preset.metalness !== undefined) mat.metalness = preset.metalness;
    mat.needsUpdate = true;
  });
}

const imageLoader = new THREE.ImageLoader();

/* ═══════════════════════════════════════════════════════════════
   MATERIALS LIBRARY — a digital product catalog (tile, wallpaper, or
   any future real-world surface material), fetched once from the host
   as flat JSON and rendered generically off meshTargets, exactly like
   every other control here: nothing in this section knows whether a
   given product is a ceramic tile or a wallpaper roll.
═══════════════════════════════════════════════════════════════ */
function loadMaterialsCatalog() {
  if (materialsCatalogPromise) return materialsCatalogPromise;
  const url = window.configuratorAjax?.materialsUrl;
  materialsCatalogPromise = (url ? fetch(url).then(r => r.json()) : Promise.resolve([]))
    .then(list => {
      MATERIALS_CATALOG = Array.isArray(list) ? list : [];
      MATERIALS_BY_ID = {};
      MATERIALS_CATALOG.forEach(m => { if (m?.id) MATERIALS_BY_ID[m.id] = m; });
      return MATERIALS_CATALOG;
    })
    .catch(() => { MATERIALS_CATALOG = []; MATERIALS_BY_ID = {}; return MATERIALS_CATALOG; });
  return materialsCatalogPromise;
}
function getMaterialById(id) { return MATERIALS_BY_ID[id] || null; }
function materialsForTarget(targetKey) {
  return MATERIALS_CATALOG.filter(m => (m.target || "floor") === targetKey);
}

/* Full-resolution product images are only fetched once a card is
   actually selected — the material-grid UI itself only ever needs
   material.thumbnail (a plain <img>, no THREE texture). Cached by URL
   as a decoded HTMLImageElement (not a THREE.Texture) so re-selecting
   a product already used elsewhere never re-downloads.

   Deliberately NOT "textureLoader.load(url).clone()": TextureLoader
   returns a Texture before its image has finished downloading, and
   cloning it immediately races that load — the clone's needsUpdate
   gets consumed by the renderer before real pixel data exists, and
   nothing re-triggers it once the image actually arrives, leaving a
   permanently blank/broken texture on that material. Loading the raw
   image once, awaiting it, and only THEN constructing each node's own
   Texture (with needsUpdate set once real data already exists)
   removes that race entirely. ─────────────────────────────────────── */
const materialImageCache = {};
function loadMaterialImage(url) {
  if (!materialImageCache[url]) {
    materialImageCache[url] = new Promise((resolve, reject) => {
      imageLoader.load(url, resolve, undefined, reject);
    });
  }
  return materialImageCache[url];
}
async function applyMaterialGrid(ctrl, materialId) {
  const targetKey = ctrl.target;
  if (!targetKey || !materialId) return;
  const material = getMaterialById(materialId);
  if (!material || !material.image) return;

  let image;
  try {
    image = await loadMaterialImage(material.image);
  } catch (err) {
    console.error(`[Tilvora] failed to load material image "${material.image}":`, err);
    return;
  }

  if (!MATERIAL_GRID_CREATED[targetKey]) MATERIAL_GRID_CREATED[targetKey] = [];
  if (!ORIGINAL_NODE_MATERIALS[targetKey]) ORIGINAL_NODE_MATERIALS[targetKey] = [];

  (TARGET_REFS[targetKey] || []).forEach((node, i) => {
    // Capture the mesh's own GLB-baked material exactly once, before the
    // very first override — this is what the "None" card restores later.
    // Never overwritten by subsequent product switches.
    if (ORIGINAL_NODE_MATERIALS[targetKey][i] === undefined) {
      ORIGINAL_NODE_MATERIALS[targetKey][i] = node.material;
    }
    // "fit" is reserved for a future non-repeating product type (spec:
    // no implementation required yet) — it simply skips tiling for now.
    const repeat = (material.mappingMode === "fit") ? null : repeatForDimensions(targetKey, i, material.dimensions);
    const oldMats = Array.isArray(node.material) ? node.material : [node.material];
    const newMats = oldMats.map(() => {
      const tex = new THREE.Texture(image);
      tex.colorSpace = THREE.SRGBColorSpace;
      tex.flipY = false;
      tex.wrapS = THREE.RepeatWrapping;
      tex.wrapT = THREE.RepeatWrapping;
      tex.repeat.set(repeat ? repeat.x : 1, repeat ? repeat.y : 1);
      tex.needsUpdate = true; // safe here — image is already fully loaded
      // Unlit deliberately: this is a product catalog, not a mood-lit
      // showroom render — a customer needs to see the tile/wallpaper's
      // true printed color, unaffected by whichever Room Lighting
      // preset or brightness slider happens to be active in the Scene
      // tab. MeshBasicMaterial ignores all scene lighting entirely.
      return new THREE.MeshBasicMaterial({ map: tex, side: THREE.FrontSide });
    });
    node.material = Array.isArray(node.material) ? newMats : newMats[0];

    // Dispose only what a PREVIOUS material-grid selection created on
    // this exact node — never the mesh's original GLB-baked material,
    // which nothing here owns the lifecycle of.
    const previouslyCreated = MATERIAL_GRID_CREATED[targetKey][i];
    if (previouslyCreated) previouslyCreated.forEach(m => { m.map?.dispose(); m.dispose(); });
    MATERIAL_GRID_CREATED[targetKey][i] = newMats;
  });
}
/* "None" card — undoes whatever material-grid applied, restoring each node's
   own GLB-baked material (captured in ORIGINAL_NODE_MATERIALS on first use).
   A no-op if this control never overrode anything yet. */
function clearMaterialGrid(ctrl) {
  const targetKey = ctrl.target;
  if (!targetKey) return;

  (TARGET_REFS[targetKey] || []).forEach((node, i) => {
    const original = ORIGINAL_NODE_MATERIALS[targetKey]?.[i];
    if (original !== undefined) node.material = original;

    const created = MATERIAL_GRID_CREATED[targetKey]?.[i];
    if (created) {
      created.forEach(m => { m.map?.dispose(); m.dispose(); });
      MATERIAL_GRID_CREATED[targetKey][i] = null;
    }
  });
}

/* ═══════════════════════════════════════════════════════════════
   SCENE EFFECTS — generic viewer-chrome primitives (environment,
   ground, shadows, auto-rotate, light intensity). These apply to the
   universal viewer (scene/camera/lights/ground), never to a specific
   product mesh, so every product config can opt into them the same
   way via a control's "effect" key — no per-product code in app.js.
═══════════════════════════════════════════════════════════════ */
function applyEnvironment(key) {
  const env = PRODUCT_DATA?.environments?.[key];
  if (!env) return;
  scene.background = new THREE.Color(env.color);
  scene.fog = env.fog ? new THREE.FogExp2(env.color, env.fog) : null;
}
const SCENE_EFFECTS = {
  environment:         applyEnvironment,
  ground:              val => { ground.visible = !!val; },
  shadows:             val => { renderer.shadowMap.enabled = !!val; mainLight.castShadow = !!val; },
  autoRotate:          val => { autoRotateEnabled = !!val; },
  ambientLight:        val => { ambientLight.intensity = val; },
  keyLight:            val => { mainLight.intensity = val; },
  exposure:            val => { renderer.toneMappingExposure = val; },
  environmentIntensity:val => { scene.environmentIntensity = val; },
};

/* ── Generic rendering loop — driven entirely by PRODUCT_DATA.uiTabs ── */
function applyAll() {
  if (!PRODUCT_DATA) return;
  for (const tab of PRODUCT_DATA.uiTabs || []) {
    for (const ctrl of tab.controls || []) {
      const val = getCFGValue(ctrl.cfgPath);
      if (val === undefined) continue;
      switch (ctrl.type) {
        case "color-swatches": {
          if (!ctrl.target) break;
          // Apply colour + optional per-swatch roughness/metalness (PBR).
          const swatchOpt = findOption(ctrl, val);
          forEachTargetMaterial(ctrl.target, mat => {
            if (mat.color) mat.color.set(val);
            if (swatchOpt?.roughness !== undefined) mat.roughness = swatchOpt.roughness;
            if (swatchOpt?.metalness !== undefined) mat.metalness = swatchOpt.metalness;
            mat.needsUpdate = true;
          });
          break;
        }
        case "material-preset":
          if (ctrl.target) applyMaterialPreset(ctrl.target, val);
          break;
        case "finish":
          applyFinish(ctrl, val);
          break;
        case "material-grid":
          if (val) applyMaterialGrid(ctrl, val); else clearMaterialGrid(ctrl);
          break;
        case "env-grid":
          applyEnvironment(val);
          break;
        case "switch":
        case "slider":
          SCENE_EFFECTS[ctrl.effect]?.(val);
          break;
      }
    }
  }
  updatePrice();
  updateCfgHash();
}

/* ═══════════════════════════════════════════════════════════════
   PRICING — sums basePrice + any priceModifier on the option/swatch
   matching the current CFG value for every control that defines one.
═══════════════════════════════════════════════════════════════ */
function calcPrice() {
  let total = PRODUCT_DATA?.basePrice || 0;
  const lines = [];
  for (const tab of PRODUCT_DATA?.uiTabs || []) {
    for (const ctrl of tab.controls || []) {
      // text-input: control-level priceModifier applies when the field is non-empty.
      if (ctrl.type === "text-input") {
        if (ctrl.priceModifier) {
          const val = getCFGValue(ctrl.cfgPath);
          if (val && String(val).trim()) {
            total += ctrl.priceModifier;
            lines.push({ label: ctrl.label, amount: ctrl.priceModifier });
          }
        }
        continue;
      }
      // material-grid: price lives on the fetched catalog entry, not ctrl.options.
      if (ctrl.type === "material-grid") {
        const val = getCFGValue(ctrl.cfgPath);
        const material = val ? getMaterialById(val) : null;
        if (material?.price) {
          total += material.price;
          lines.push({ label: ctrl.label, amount: material.price });
        }
        continue;
      }
      const list = ctrl.swatches || ctrl.options;
      if (!list) continue;
      const val = getCFGValue(ctrl.cfgPath);
      const match = list.find(o => o.value === val);
      if (match?.priceModifier) {
        total += match.priceModifier;
        lines.push({ label: ctrl.label, amount: match.priceModifier });
      }
    }
  }
  return { total, lines };
}
function updatePrice() {
  const { total, lines } = calcPrice();
  const currency = PRODUCT_DATA?.currency || "";
  if (priceTotalEl) priceTotalEl.textContent = `${currency}${total.toFixed(0)}`;
  if (priceBreakdown) {
    priceBreakdown.innerHTML = lines
      .map(l => `<div class="noor-pb-row noor-pb-base"><span class="noor-pb-label">${l.label}</span><span class="noor-pb-amt">${currency}${l.amount}</span></div>`)
      .join("");
  }
}

/* ═══════════════════════════════════════════════════════════════
   UI BUILDER — generic for the two supported control types.
   Adding a new control type later means adding one case here, not
   touching any product-specific code.
═══════════════════════════════════════════════════════════════ */
function findOption(ctrl, value) {
  return (ctrl.swatches || ctrl.options || []).find(o => o.value === value);
}
function valuePill(ctrl, color, label) {
  return `
    <div class="noor-val-pill" data-pill="${ctrl.id}">
      <span class="noor-val-dot" style="background:${color || "#888"}"></span>
      <span class="noor-val-label">${label || ""}</span>
    </div>
  `;
}
function updateValuePill(ctrl, root, color, label) {
  const pill = root.querySelector(`[data-pill="${ctrl.id}"]`);
  if (!pill) return;
  const dot = pill.querySelector(".noor-val-dot");
  const lbl = pill.querySelector(".noor-val-label");
  if (dot) dot.style.background = color || "#888";
  if (lbl) lbl.textContent = label || "";
}

function buildColorSwatches(ctrl) {
  const current = getCFGValue(ctrl.cfgPath);
  const currentOpt = findOption(ctrl, current);
  const swatches = ctrl.swatches.map(s => `
    <button class="noor-swatch${s.value === current ? " active" : ""}" style="--c:${s.value}" data-value="${s.value}" title="${s.label}"></button>
  `).join("");
  return `
    <div class="noor-section" data-ctrl="${ctrl.id}">
      <div class="noor-sec-header">
        <div class="noor-sec-title"><span class="noor-sec-title-dot"></span>${ctrl.label}</div>
        ${valuePill(ctrl, current, currentOpt?.label)}
      </div>
      <div class="noor-swatches">${swatches}</div>
    </div>
  `;
}
function wireColorSwatches(ctrl, root) {
  const section = root.querySelector(`[data-ctrl="${ctrl.id}"]`);
  section?.querySelectorAll(".noor-swatch").forEach(btn => {
    btn.addEventListener("click", () => {
      section.querySelectorAll(".noor-swatch").forEach(b => b.classList.remove("active"));
      btn.classList.add("active");
      setCFGValue(ctrl.cfgPath, btn.dataset.value);
      applyColorToTarget(ctrl.target, btn.dataset.value);
      updateValuePill(ctrl, section, btn.dataset.value, findOption(ctrl, btn.dataset.value)?.label);
      updatePrice();
      updateCfgHash();
      saveCFG();
    });
  });
}

function buildMaterialPreset(ctrl) {
  const current = getCFGValue(ctrl.cfgPath);
  const currentOpt = findOption(ctrl, current);
  const currentColor = PRODUCT_DATA.materialPresets?.[current]?.color;
  const cards = ctrl.options.map(o => {
    const preset = PRODUCT_DATA.materialPresets?.[o.value];
    const swatchColor = preset?.color || "#888";
    return `
      <button class="noor-finish-card${o.value === current ? " active" : ""}" data-v="${o.value}">
        <span class="noor-finish-ball" style="background:${swatchColor}"></span>
        <span class="noor-finish-name">${o.label}</span>
      </button>
    `;
  }).join("");
  return `
    <div class="noor-section" data-ctrl="${ctrl.id}">
      <div class="noor-sec-header">
        <div class="noor-sec-title"><span class="noor-sec-title-dot"></span>${ctrl.label}</div>
        ${valuePill(ctrl, currentColor, currentOpt?.label)}
      </div>
      <div class="noor-finish-cards">${cards}</div>
    </div>
  `;
}
function wireMaterialPreset(ctrl, root) {
  const section = root.querySelector(`[data-ctrl="${ctrl.id}"]`);
  section?.querySelectorAll(".noor-finish-card").forEach(btn => {
    btn.addEventListener("click", () => {
      section.querySelectorAll(".noor-finish-card").forEach(b => b.classList.remove("active"));
      btn.classList.add("active");
      setCFGValue(ctrl.cfgPath, btn.dataset.v);
      applyMaterialPreset(ctrl.target, btn.dataset.v);
      const opt = findOption(ctrl, btn.dataset.v);
      updateValuePill(ctrl, section, PRODUCT_DATA.materialPresets?.[btn.dataset.v]?.color, opt?.label);
      updatePrice();
      updateCfgHash();
      saveCFG();
    });
  });
}

function buildEnvGrid(ctrl) {
  const current = getCFGValue(ctrl.cfgPath);
  const cards = ctrl.options.map(o => `
    <button class="noor-env-card noor-${o.cssClass}${o.value === current ? " active" : ""}" data-v="${o.value}">
      <span class="noor-env-preview"></span>
      <span class="noor-env-name">${o.label}</span>
    </button>
  `).join("");
  return `
    <div class="noor-section" data-ctrl="${ctrl.id}">
      <div class="noor-sec-header">
        <div class="noor-sec-title"><span class="noor-sec-title-dot"></span>${ctrl.label}</div>
      </div>
      <div class="noor-env-grid">${cards}</div>
    </div>
  `;
}
function wireEnvGrid(ctrl, root) {
  const section = root.querySelector(`[data-ctrl="${ctrl.id}"]`);
  section?.querySelectorAll(".noor-env-card").forEach(btn => {
    btn.addEventListener("click", () => {
      section.querySelectorAll(".noor-env-card").forEach(b => b.classList.remove("active"));
      btn.classList.add("active");
      setCFGValue(ctrl.cfgPath, btn.dataset.v);
      applyEnvironment(btn.dataset.v);
      updateCfgHash();
      saveCFG();
    });
  });
}

function buildSwitch(ctrl) {
  const current = !!getCFGValue(ctrl.cfgPath);
  return `
    <div class="noor-section" data-ctrl="${ctrl.id}">
      <div class="noor-sec-header">
        <div class="noor-sec-title"><span class="noor-sec-title-dot"></span>${ctrl.label}</div>
        <label class="noor-switch">
          <input type="checkbox" ${current ? "checked" : ""} />
          <span class="noor-switch-track"></span>
        </label>
      </div>
      ${ctrl.subLabel ? `<div class="noor-slider-lbl">${ctrl.subLabel}</div>` : ""}
    </div>
  `;
}
function wireSwitch(ctrl, root) {
  const section = root.querySelector(`[data-ctrl="${ctrl.id}"]`);
  const input = section?.querySelector('input[type="checkbox"]');
  input?.addEventListener("change", () => {
    setCFGValue(ctrl.cfgPath, input.checked);
    SCENE_EFFECTS[ctrl.effect]?.(input.checked);
    updateCfgHash();
    saveCFG();
  });
}

function buildSlider(ctrl) {
  const scale = ctrl.scale ?? 1;
  const current = getCFGValue(ctrl.cfgPath) ?? 0;
  const raw = current / scale;
  const displayVal = Math.round(raw);
  return `
    <div class="noor-section" data-ctrl="${ctrl.id}">
      <div class="noor-sec-header">
        <div class="noor-sec-title"><span class="noor-sec-title-dot"></span>${ctrl.label}</div>
      </div>
      <div class="noor-slider-row-full">
        <input type="range" min="${ctrl.min}" max="${ctrl.max}" value="${raw}" step="1" />
        <span class="noor-slider-val">${displayVal}${ctrl.displayUnit || ""}</span>
      </div>
    </div>
  `;
}
function wireSlider(ctrl, root) {
  const section = root.querySelector(`[data-ctrl="${ctrl.id}"]`);
  const input = section?.querySelector('input[type="range"]');
  const valEl = section?.querySelector(".noor-slider-val");
  const scale = ctrl.scale ?? 1;
  input?.addEventListener("input", () => {
    const raw = Number(input.value);
    const actual = raw * scale;
    if (valEl) valEl.textContent = `${Math.round(raw)}${ctrl.displayUnit || ""}`;
    setCFGValue(ctrl.cfgPath, actual);
    SCENE_EFFECTS[ctrl.effect]?.(actual);
    updateCfgHash();
    saveCFG();
  });
}

/* ── Material grid — a manufacturer's product catalog (Materials
   Library), rendered as simple thumbnail + name cards sourced from
   MATERIALS_CATALOG and filtered to whatever this control's own
   "target" is. Each card only ever loads its small thumbnail; the
   full-resolution image is fetched by applyMaterialGrid only once a
   card is actually selected. ──────────────────────────────────────── */
function buildMaterialGrid(ctrl) {
  const current = getCFGValue(ctrl.cfgPath);
  const allProducts = materialsForTarget(ctrl.target);
  const currency = PRODUCT_DATA?.currency || "";

  if (!allProducts.length) {
    return `
      <div class="noor-section" data-ctrl="${ctrl.id}">
        <div class="noor-sec-header">
          <div class="noor-sec-title"><span class="noor-sec-title-dot"></span>${ctrl.label}</div>
        </div>
        <div class="noor-mat-empty">No products published yet.</div>
      </div>`;
  }

  // Category sub-tabs — lets a customer narrow a long catalog down
  // instead of scrolling every product. Purely a client-side filter
  // over the already-fetched catalog; categories are whatever text the
  // Materials Library admin typed in, never a fixed list.
  const categories = [...new Set(allProducts.map(p => p.category).filter(Boolean))];
  const activeCategory = MATERIAL_GRID_FILTER[ctrl.id] || "";
  const products = activeCategory ? allProducts.filter(p => p.category === activeCategory) : allProducts;

  const categoryTabs = categories.length ? `
    <div class="noor-mat-cats">
      <button class="noor-mat-cat${!activeCategory ? " active" : ""}" data-cat="">All</button>
      ${categories.map(c => `<button class="noor-mat-cat${c === activeCategory ? " active" : ""}" data-cat="${c}">${c}</button>`).join("")}
    </div>` : "";

  const currentProduct = current ? getMaterialById(current) : null;
  // "None" — always present regardless of category filter, so a customer can
  // clear this control's selection (restoring the mesh's own GLB material)
  // no matter which category tab they're currently viewing.
  const noneCard = `
      <button class="noor-stk noor-stk-none${!current ? " active" : ""}" data-v="">
        <span class="noor-stk-thumb">${STK_NONE_ICON}</span>
        <span class="noor-stk-name">None</span>
      </button>
    `;
  const cards = noneCard + (products.length ? products.map(p => `
      <button class="noor-stk${p.id === current ? " active" : ""}" data-v="${p.id}">
        <span class="noor-stk-thumb">${p.thumbnail ? `<img class="noor-stk-thumb-img" src="${p.thumbnail}" alt="">` : STK_DEFAULT_ICON}</span>
        <span class="noor-stk-name">${p.name || ""}</span>
        ${p.price ? `<span class="noor-mat-price">${currency}${p.price}${p.unit ? "/" + p.unit : ""}</span>` : ""}
      </button>
    `).join("") : `<div class="noor-mat-empty">No products in this category.</div>`);

  return `
    <div class="noor-section" data-ctrl="${ctrl.id}">
      <div class="noor-sec-header">
        <div class="noor-sec-title"><span class="noor-sec-title-dot"></span>${ctrl.label}</div>
        ${valuePill(ctrl, undefined, currentProduct ? currentProduct.name : "None")}
      </div>
      ${categoryTabs}
      <div class="noor-stickers-grid">${cards}</div>
    </div>`;
}
function wireMaterialGrid(ctrl, root) {
  function wireSection(section) {
    section.querySelectorAll(".noor-mat-cat").forEach(btn => {
      btn.addEventListener("click", () => {
        MATERIAL_GRID_FILTER[ctrl.id] = btn.dataset.cat || "";
        const wrapper = document.createElement("div");
        wrapper.innerHTML = buildMaterialGrid(ctrl);
        const fresh = wrapper.firstElementChild;
        section.replaceWith(fresh);
        wireSection(fresh);
      });
    });
    section.querySelectorAll(".noor-stk").forEach(btn => {
      btn.addEventListener("click", () => {
        const raw   = btn.dataset.v;
        const value = raw || null; // "" (None card) → null, matches getCFGValue's "no selection"
        setCFGValue(ctrl.cfgPath, value);
        section.querySelectorAll(".noor-stk").forEach(b => b.classList.toggle("active", (b.dataset.v || null) === value));
        if (value) applyMaterialGrid(ctrl, value); else clearMaterialGrid(ctrl);
        updateValuePill(ctrl, section, undefined, value ? getMaterialById(value)?.name : "None");
        updatePrice();
        updateCfgHash();
        saveCFG();
      });
    });
  }
  const section = root.querySelector(`[data-ctrl="${ctrl.id}"]`);
  if (!section) return;
  wireSection(section);
}

/* ── Finish — inline PBR presets (roughness + metalness per option).
   No separate materialPresets dictionary required. Each option carries
   its own roughness/metalness directly in config.json:
     { "label":"Gloss", "value":"gloss", "roughness":0.05, "metalness":0 }
   An optional "color" key lets it also tint the material. ─────────── */
function applyFinish(ctrl, val) {
  const opt = findOption(ctrl, val);
  if (!opt || !ctrl.target) return;
  forEachTargetMaterial(ctrl.target, mat => {
    if (opt.roughness !== undefined) mat.roughness = opt.roughness;
    if (opt.metalness !== undefined) mat.metalness = opt.metalness;
    if (opt.color !== undefined && mat.color) mat.color.set(opt.color);
    mat.needsUpdate = true;
  });
}
function buildFinish(ctrl) {
  const current = getCFGValue(ctrl.cfgPath);
  const currentOpt = findOption(ctrl, current);
  const currency = PRODUCT_DATA?.currency || "";
  const cards = (ctrl.options || []).map(o => {
    const bg = o.color
      ? `background:${o.color}`
      : o.metalness > 0.5
        ? `background:linear-gradient(135deg,#ccc 40%,#555 100%)`
        : o.roughness < 0.2
          ? `background:linear-gradient(135deg,#fff 0%,#aaa 100%)`
          : `background:#888`;
    return `
      <button class="noor-finish-card${o.value === current ? " active" : ""}" data-v="${o.value}" title="${o.label}">
        <span class="noor-finish-ball" style="${bg}"></span>
        <span class="noor-finish-name">${o.label}${o.priceModifier ? `<em>+${currency}${o.priceModifier}</em>` : ""}</span>
      </button>`;
  }).join("");
  return `
    <div class="noor-section" data-ctrl="${ctrl.id}">
      <div class="noor-sec-header">
        <div class="noor-sec-title"><span class="noor-sec-title-dot"></span>${ctrl.label}</div>
        ${valuePill(ctrl, currentOpt?.color || null, currentOpt?.label)}
      </div>
      <div class="noor-finish-cards">${cards}</div>
    </div>`;
}
function wireFinish(ctrl, root) {
  const section = root.querySelector(`[data-ctrl="${ctrl.id}"]`);
  section?.querySelectorAll(".noor-finish-card").forEach(btn => {
    btn.addEventListener("click", () => {
      section.querySelectorAll(".noor-finish-card").forEach(b => b.classList.remove("active"));
      btn.classList.add("active");
      const val = btn.dataset.v;
      setCFGValue(ctrl.cfgPath, val);
      applyFinish(ctrl, val);
      const opt = findOption(ctrl, val);
      updateValuePill(ctrl, section, opt?.color || null, opt?.label);
      updatePrice();
      updateCfgHash();
      saveCFG();
    });
  });
}

/* ── Text input — single-line text for personalisation / engraving.
   Contributes to order fields and, when non-empty, adds its own
   priceModifier. No mesh changes — the host integration (WooCommerce,
   Shopify, etc.) reads the value from order.fields and handles it. ── */
function buildTextInput(ctrl) {
  const current = getCFGValue(ctrl.cfgPath) || "";
  const max = ctrl.maxLength || 50;
  const currency = PRODUCT_DATA?.currency || "";
  return `
    <div class="noor-section" data-ctrl="${ctrl.id}">
      <div class="noor-sec-header">
        <div class="noor-sec-title"><span class="noor-sec-title-dot"></span>${ctrl.label}</div>
      </div>
      ${ctrl.description ? `<div class="noor-text-input-desc">${ctrl.description}</div>` : ""}
      <div class="noor-text-input-row">
        <input class="noor-text-input" type="text"
          placeholder="${ctrl.placeholder || ""}"
          maxlength="${max}"
          value="${current.replace(/"/g, "&quot;")}" />
        <span class="noor-text-input-count">${current.length}/${max}</span>
      </div>
      ${ctrl.priceModifier ? `<span class="noor-text-input-price">+${currency}${ctrl.priceModifier} when filled</span>` : ""}
    </div>`;
}
function wireTextInput(ctrl, root) {
  const section = root.querySelector(`[data-ctrl="${ctrl.id}"]`);
  const input   = section?.querySelector(".noor-text-input");
  const counter = section?.querySelector(".noor-text-input-count");
  const max = ctrl.maxLength || 50;
  input?.addEventListener("input", () => {
    const val = input.value.slice(0, max);
    input.value = val;
    if (counter) counter.textContent = `${val.length}/${max}`;
    setCFGValue(ctrl.cfgPath, val);
    updatePrice();
    updateCfgHash();
    saveCFG();
  });
}

const CONTROL_BUILDERS = {
  "color-swatches":  buildColorSwatches,
  "material-preset": buildMaterialPreset,
  "finish":          buildFinish,
  "material-grid":   buildMaterialGrid,
  "env-grid":        buildEnvGrid,
  "switch":          buildSwitch,
  "slider":          buildSlider,
  "text-input":      buildTextInput,
};
const CONTROL_WIRERS = {
  "color-swatches":  wireColorSwatches,
  "material-preset": wireMaterialPreset,
  "finish":          wireFinish,
  "material-grid":   wireMaterialGrid,
  "env-grid":        wireEnvGrid,
  "switch":          wireSwitch,
  "slider":          wireSlider,
  "text-input":      wireTextInput,
};

function buildUI() {
  if (!tabNav || !panelBody || !PRODUCT_DATA) return;
  tabNav.innerHTML = "";
  panelBody.innerHTML = "";

  (PRODUCT_DATA.uiTabs || []).forEach((tab, i) => {
    const navBtn = document.createElement("button");
    navBtn.className = "noor-tab" + (i === 0 ? " active" : "");
    navBtn.dataset.tab = tab.id;
    navBtn.innerHTML = `<span class="noor-tab-icon">${tab.icon || DEFAULT_TAB_ICON}</span>${tab.label}`;
    navBtn.addEventListener("click", () => {
      tabNav.querySelectorAll(".noor-tab").forEach(b => b.classList.remove("active"));
      panelBody.querySelectorAll(".noor-tab-panel").forEach(p => p.classList.remove("active"));
      navBtn.classList.add("active");
      panelBody.querySelector(`[data-tab="${tab.id}"]`)?.classList.add("active");
    });
    tabNav.appendChild(navBtn);

    const panel = document.createElement("div");
    panel.className = "noor-tab-panel" + (i === 0 ? " active" : "");
    panel.dataset.tab = tab.id;
    panel.innerHTML = (tab.controls || [])
      .map(ctrl => ctrl.hidden ? "" : (CONTROL_BUILDERS[ctrl.type]?.(ctrl) || ""))
      .join("");
    panelBody.appendChild(panel);

    (tab.controls || []).forEach(ctrl => {
      if (!ctrl.hidden) CONTROL_WIRERS[ctrl.type]?.(ctrl, panel);
    });
  });

  updatePrice();
  updateCfgHash();
}

/* ── Config hash — a tiny visual fingerprint of the current CFG state,
   purely cosmetic, shown as "CFG-#——————" in the footer. ──────────── */
function updateCfgHash() {
  if (!cfgHashEl) return;
  const str = JSON.stringify(CFG);
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    hash = (hash * 31 + str.charCodeAt(i)) >>> 0;
  }
  cfgHashEl.textContent = `CFG-#${hash.toString(16).toUpperCase().padStart(8, "0").slice(0, 8)}`;
}

/* ═══════════════════════════════════════════════════════════════
   CONFIG CODE — a compact, URL-safe encoding of the live CFG object.
   Lets a configuration be saved/shared/resumed via a link (decoded
   automatically from location.hash on boot, see below) and gives any
   host integration (a WooCommerce plugin, a Shopify app, anything
   else) a single opaque string to store against an order and
   reconstruct the exact configuration later. ─────────────────────── */
function encodeConfigCode(cfg) {
  try {
    const json = JSON.stringify(cfg);
    const b64 = btoa(unescape(encodeURIComponent(json)));
    return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  } catch (e) {
    return "";
  }
}
function decodeConfigCode(code) {
  if (!code) return null;
  try {
    let b64 = code.replace(/-/g, "+").replace(/_/g, "/");
    while (b64.length % 4) b64 += "=";
    return JSON.parse(decodeURIComponent(escape(atob(b64))));
  } catch (e) {
    return null;
  }
}

/* ═══════════════════════════════════════════════════════════════
   ORDER FIELDS — a generic, human-readable summary of the current
   configuration, built purely from PRODUCT_DATA.uiTabs. A brand new
   product (new config.json + new GLB, zero app.js edits) automatically
   gets correct order fields — no per-product field-mapping code is
   ever needed in app.js, nor in any host integration (WooCommerce
   plugin, Shopify app, custom site, etc.).
   A control opts in/out of the order summary via "includeInOrder" in
   config.json; if it doesn't specify one, customization-style controls
   (colors, materials, decals) are included by default and viewer-
   chrome controls (scene background, auto-rotate, light sliders) are
   excluded by default, since those aren't things a customer ordered. */
const ORDER_FIELD_DEFAULT_INCLUDE = {
  "color-swatches":  true,
  "material-preset": true,
  "finish":          true,
  "material-grid":   true,
  "text-input":      true,
  "env-grid":        false,
  "switch":          false,
  "slider":          false
};
function buildOrderFields() {
  const fields = [];
  for (const tab of PRODUCT_DATA?.uiTabs || []) {
    for (const ctrl of tab.controls || []) {
      // Hidden controls default to excluded from order unless explicitly opted in.
      const include = ctrl.hidden
        ? (ctrl.includeInOrder ?? false)
        : (ctrl.includeInOrder ?? ORDER_FIELD_DEFAULT_INCLUDE[ctrl.type] ?? false);
      if (!include) continue;
      const value = getCFGValue(ctrl.cfgPath);
      if (value === undefined || value === null || value === "") continue;

      let display = value;
      let thumbnail;
      if (ctrl.type === "text-input") {
        display = String(value).trim();
        if (!display) continue; // skip empty personalization fields
      } else if (ctrl.type === "material-grid") {
        const material = getMaterialById(value);
        if (!material) continue;
        display = material.name;
        thumbnail = material.thumbnail || undefined;
      } else {
        const opt = findOption(ctrl, value);
        if (opt?.label) display = opt.label;
        else if (typeof value === "boolean") display = value ? "Yes" : "No";
      }
      fields.push({ id: ctrl.id, label: ctrl.label, value: display, thumbnail });
    }
  }
  return fields;
}

/* ═══════════════════════════════════════════════════════════════
   PUBLIC INTEGRATION API — window.Configurator.
   This is the entire contract a host page or platform plugin needs
   against this engine; it never has to know about meshTargets,
   control types, or any other internal detail, and nothing here
   assumes a particular embedding method (plain <script> tag, an
   asset bundled into a WooCommerce plugin, a Shopify theme asset,
   etc. all work the same way). ─────────────────────────────────── */
const addToCartCallbacks = [];
function buildOrder() {
  const { total, lines } = calcPrice();
  return {
    productId:   PRODUCT_DATA?.id || null,
    productName: PRODUCT_DATA?.productName || null,
    config:      JSON.parse(JSON.stringify(CFG)),
    fields:      buildOrderFields(),
    price:       { total, currency: PRODUCT_DATA?.currency || "", breakdown: lines },
    configCode:  encodeConfigCode(CFG),
    timestamp:   new Date().toISOString()
  };
}
function dispatchAddToCart() {
  const order = buildOrder();
  addToCartCallbacks.forEach(fn => {
    try { fn(order); } catch (e) { console.error("Configurator.onAddToCart callback error:", e); }
  });
  // Also fire a plain DOM event, for integrations that prefer
  // addEventListener over a registered callback (e.g. a WooCommerce
  // theme script that doesn't control load order relative to this file).
  window.dispatchEvent(new CustomEvent("configurator:addtocart", { detail: order }));
  return order;
}
window.Configurator = {
  /** Register a function to run whenever the customer adds the
   *  current configuration to cart. Called with the full order object. */
  onAddToCart(fn) { if (typeof fn === "function") addToCartCallbacks.push(fn); },
  /** Current raw selections, keyed by cfgPath (e.g. { body: { color: "#fff" } }). */
  getConfig() { return JSON.parse(JSON.stringify(CFG)); },
  /** Current calculated price: { total, currency, breakdown }. */
  getPrice() { const { total, lines } = calcPrice(); return { total, currency: PRODUCT_DATA?.currency || "", breakdown: lines }; },
  /** A full order snapshot — same shape passed to onAddToCart callbacks. */
  getOrder() { return buildOrder(); },
  /** Basic product metadata from config.json. */
  getProduct() { return PRODUCT_DATA ? { id: PRODUCT_DATA.id, name: PRODUCT_DATA.productName, currency: PRODUCT_DATA.currency } : null; },
  /** Apply a partial configuration (merged onto the current CFG) and re-render. */
  setConfig(partial) { deepMerge(CFG, partial || {}); buildUI(); applyAll(); saveCFG(); },
  /** Decode and apply a configCode produced by getOrder()/onAddToCart — used to resume a saved/shared configuration. */
  restoreFromCode(code) {
    const decoded = decodeConfigCode(code);
    if (!decoded) return false;
    CFG = deepMerge({}, PRODUCT_DATA?.defaults || {});
    deepMerge(CFG, decoded);
    buildUI();
    applyAll();
    saveCFG();
    return true;
  }
};

btnResetCfg?.addEventListener("click", () => {
  CFG = deepMerge({}, PRODUCT_DATA?.defaults || {});
  saveCFG();
  buildUI();
  applyAll();
  showToast("Reset to defaults");
});

let cartAddedTimer = null;
btnAddToCart?.addEventListener("click", () => {
  btnAddToCart.classList.add("added");
  clearTimeout(cartAddedTimer);
  cartAddedTimer = setTimeout(() => btnAddToCart.classList.remove("added"), 1800);
  dispatchAddToCart();
  showToast("Added to cart");
});

/* ── Status / toast helpers ──────────────────────────────────────── */
function setModelStatus(state, text) {
  if (!modelStatus) return;
  modelStatus.classList.remove("loading", "loaded");
  if (state) modelStatus.classList.add(state);
  const span = modelStatus.querySelector("span");
  if (span) span.textContent = text;
}

let toastTimer = null;
function showToast(msg) {
  if (!toast || !toastMsg) return;
  toastMsg.textContent = msg;
  toast.classList.add("show");
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => toast.classList.remove("show"), 2600);
}

function hideLoader() {
  if (loader) loader.classList.add("out");
}

function showLoaderError(title, lines) {
  if (loaderSpin) loaderSpin.style.display = "none";
  if (loaderText) loaderText.style.display = "none";
  if (!loaderError) return;
  loaderError.style.display = "flex";
  const titleEl = loaderError.querySelector(".noor-loader-err-title");
  if (titleEl) titleEl.textContent = title;
}

/* ── Camera auto-fit — frame the model without rescaling its geometry,
   so every mesh keeps its original baked GLB transform intact.
   Reads PRODUCT_DATA.camera for per-product overrides so each config
   can define its own ideal default view angle without touching app.js:
     "camera": { "distanceMultiplier": 2.0, "angleX": 0.25, "angleY": 0.15 } ── */
function frameModel(model) {
  const box = new THREE.Box3().setFromObject(model);
  const size = box.getSize(new THREE.Vector3());
  const center = box.getCenter(new THREE.Vector3());
  const maxDim = Math.max(size.x, size.y, size.z) || 1;

  const camCfg  = PRODUCT_DATA?.camera || {};
  const distMult = camCfg.distanceMultiplier ?? 1.8;
  const angleX   = camCfg.angleX ?? 0.6;
  const angleY   = camCfg.angleY ?? 0.4;
  const distance = maxDim * distMult;

  camera.position.set(center.x + distance * angleX, center.y + distance * angleY, center.z + distance);
  controls.target.copy(center);
  controls.update();

  ground.position.y = box.min.y - maxDim * 0.02;
  ground.scale.setScalar(maxDim * 1.5);
}

/* ── Load model ──────────────────────────────────────────────────── */
function loadModel(url, name) {
  setModelStatus("loading", `Loading ${name || "model"}…`);
  if (loaderText) loaderText.textContent = "Loading 3D model…";
  if (loader) loader.classList.remove("out");

  gltfLoader.load(
    url,
    gltf => {
      if (currentModel) {
        scene.remove(currentModel);
        currentModel.traverse(node => {
          if (!node.isMesh) return;
          const mats = Array.isArray(node.material) ? node.material : [node.material];
          mats.forEach(m => m && m.dispose());
        });
        currentModel = null;
      }

      fixTextures(gltf.scene);
      currentModel = gltf.scene;
      scene.add(currentModel);
      frameModel(currentModel);

      resolveMeshRefs(currentModel);
      resolveTargetRefs();
      applyAll();

      setModelStatus("loaded", name || url.split("/").pop());
      showToast(`Loaded: ${name || url.split("/").pop()}`);
      hideLoader();
    },
    xhr => {
      // Engine booted successfully — cancel the boot-failure timeout so a
      // large GLB that takes >60 s to download never triggers the error screen.
      if (window._batforgeBootTimeout) {
        clearTimeout(window._batforgeBootTimeout);
        window._batforgeBootTimeout = null;
      }
      if (xhr.total && loaderText) {
        const pct = Math.round((xhr.loaded / xhr.total) * 100);
        loaderText.textContent = `Loading 3D model… ${pct}%`;
      }
    },
    err => {
      console.error("GLB load failed:", err);
      setModelStatus("", "Load failed");
      showToast("Model failed to load — check console");
      showLoaderError("Failed to load model", []);
    }
  );
}

/* ── Boot — fetch config.json, then load whatever GLB it points at.
   This is the only place app.js knows a product's name; everything
   else (meshes, colors, presets, pricing) comes from the fetched
   config. The config file itself can be overridden with a ?config=
   query param, so one deployment of this engine can serve unlimited
   products/pages — e.g. ?config=table.json vs ?config=chair.json —
   without any code change, just different config.json files. ───── */
async function boot() {
  if (location.protocol === "file:") return; // index.html shows its own guidance for this case
  try {
    const configUrl = new URL(location.href).searchParams.get("config") || "assets/config.json";
    const res = await fetch(configUrl);
    PRODUCT_DATA = await res.json();
  } catch (err) {
    console.error("Failed to load config.json:", err);
    showToast("Failed to load product config — check console");
    return;
  }
  CFG = buildInitialCFG();

  // Apply optional "camera" config section — fov, orbit limits.
  // Overrides the hard-coded defaults above so each product can ship
  // its own ideal viewing angle without touching app.js.
  const camCfg = PRODUCT_DATA.camera || {};
  if (camCfg.fov)                { camera.fov = camCfg.fov; camera.updateProjectionMatrix(); }
  if (camCfg.minPolarAngle   != null) controls.minPolarAngle   = camCfg.minPolarAngle;
  if (camCfg.maxPolarAngle   != null) controls.maxPolarAngle   = camCfg.maxPolarAngle;
  if (camCfg.minAzimuthAngle != null) controls.minAzimuthAngle = camCfg.minAzimuthAngle;
  if (camCfg.maxAzimuthAngle != null) controls.maxAzimuthAngle = camCfg.maxAzimuthAngle;
  if (camCfg.minDistance     != null) controls.minDistance     = camCfg.minDistance;
  if (camCfg.maxDistance     != null) controls.maxDistance     = camCfg.maxDistance;

  // Apply optional "scene" config section — initial exposure,
  // environment intensity, ground, shadows, autoRotate.
  const sceneCfg = PRODUCT_DATA.scene || {};
  if (sceneCfg.exposure            != null) renderer.toneMappingExposure = sceneCfg.exposure;
  if (sceneCfg.environmentIntensity!= null) scene.environmentIntensity  = sceneCfg.environmentIntensity;
  if (sceneCfg.autoRotate          != null) autoRotateEnabled = !!sceneCfg.autoRotate;
  if (sceneCfg.shadows             != null) { renderer.shadowMap.enabled = !!sceneCfg.shadows; mainLight.castShadow = !!sceneCfg.shadows; }
  if (sceneCfg.ground              != null) ground.visible = !!sceneCfg.ground;

  // Apply optional "viewer" section — silent, non-interactive overrides
  // for any SCENE_EFFECTS key plus background and fog. Nothing here renders
  // UI; it is purely for the developer/product owner to lock in a specific
  // look without the customer being able to change it. Applied last so it
  // wins over the "scene" section and any env-grid defaults.
  const viewerCfg = PRODUCT_DATA.viewer || {};
  if (viewerCfg.environment != null) applyEnvironment(viewerCfg.environment);
  if (viewerCfg.background  != null) scene.background = new THREE.Color(viewerCfg.background);
  if (viewerCfg.fog         != null) scene.fog = viewerCfg.fog > 0 ? new THREE.FogExp2(scene.background?.getHexString ? `#${scene.background.getHexString()}` : "#000", viewerCfg.fog) : null;
  const viewerEffectKeys = ["ground","shadows","autoRotate","exposure","environmentIntensity","ambientLight","keyLight"];
  viewerEffectKeys.forEach(k => { if (viewerCfg[k] != null) SCENE_EFFECTS[k]?.(viewerCfg[k]); });

  // A configCode in the URL hash overrides localStorage/defaults.
  const hashCode = location.hash ? location.hash.slice(1) : "";
  if (hashCode) {
    const decoded = decodeConfigCode(hashCode);
    if (decoded) deepMerge(CFG, decoded);
  }
  // Only fetch the Materials Library when this product's config actually
  // has a material-grid control — a plain tile/finish-only product never
  // pays for a request it doesn't need.
  const usesMaterialGrid = (PRODUCT_DATA.uiTabs || []).some(tab => (tab.controls || []).some(c => c.type === "material-grid"));
  if (usesMaterialGrid) await loadMaterialsCatalog();

  buildUI();
  loadModel(PRODUCT_DATA.modelPath, PRODUCT_DATA.modelPath.split("/").pop());
}
boot();

/* ── Drag & drop a .glb anywhere on the canvas ──────────────────── */
["dragenter", "dragover"].forEach(evt =>
  document.addEventListener(evt, e => {
    e.preventDefault();
    if (dropOverlay) dropOverlay.classList.add("active");
  })
);
document.addEventListener("dragleave", e => {
  if (e.target === document || e.target === document.body) {
    if (dropOverlay) dropOverlay.classList.remove("active");
  }
});
document.addEventListener("drop", e => {
  e.preventDefault();
  if (dropOverlay) dropOverlay.classList.remove("active");
  const file = e.dataTransfer?.files?.[0];
  if (!file) return;
  if (!file.name.toLowerCase().endsWith(".glb") && !file.name.toLowerCase().endsWith(".gltf")) {
    showToast("Please drop a .glb or .gltf file");
    return;
  }
  const url = URL.createObjectURL(file);
  loadModel(url, file.name);
});

/* ── Top bar buttons ─────────────────────────────────────────────── */
btnResetCam?.addEventListener("click", () => {
  if (currentModel) frameModel(currentModel);
  showToast("View reset");
});

btnExport?.addEventListener("click", () => {
  renderer.render(scene, camera);
  const link = document.createElement("a");
  link.download = `${PRODUCT_DATA?.id || "model"}-render.png`;
  link.href = canvas.toDataURL("image/png");
  link.click();
  showToast("Exported PNG");
});
