import * as THREE from 'three';
import { publicAssetUrl } from '../runtimeBase';
import { getLoaders } from './loaders';

/**
 * Multi-format model loader dispatch.
 *
 * GLB/GLTF go through the shared singleton loaders (loaders.ts — Draco + KTX2).
 * OBJ/FBX/Collada use three's example loaders, lazily imported as separate Vite
 * chunks so they never bloat the initial bundle. IFC uses web-ifc (single-thread
 * wasm under /web-ifc/, no COOP/COEP headers required).
 *
 * Every branch resolves to a plain THREE.Object3D ready to scene.add — the
 * caller (ThreeDViewer.onLoaded) does not need to know the source format.
 */
export type SupportedExt = 'glb' | 'gltf' | 'obj' | 'fbx' | 'dae' | 'ifc' | 'ply';

export const ACCEPT_EXT: SupportedExt[] = ['glb', 'gltf', 'obj', 'fbx', 'dae', 'ifc', 'ply'];

const EXT_RE = /\.(glb|gltf|obj|fbx|dae|ifc|ply)$/i;

/** Extract the supported extension from a file name / URL, or null. */
export function extOf(name: string): SupportedExt | null {
  const m = EXT_RE.exec(name);
  return m ? (m[1].toLowerCase() as SupportedExt) : null;
}

type ProgressCb = (loaded: number, total: number) => void;

// Lazy single instances for the example loaders — avoids re-import churn on
// repeat loads. NOT the Draco/KTX2 singletons (no shared-loader constraint here).
type OBJLoaderT = import('three/examples/jsm/loaders/OBJLoader.js').OBJLoader;
type FBXLoaderT = import('three/examples/jsm/loaders/FBXLoader.js').FBXLoader;
type ColladaLoaderT = import('three/examples/jsm/loaders/ColladaLoader.js').ColladaLoader;
type PLYLoaderT = import('three/examples/jsm/loaders/PLYLoader.js').PLYLoader;

let _obj: OBJLoaderT | null = null;
let _fbx: FBXLoaderT | null = null;
let _dae: ColladaLoaderT | null = null;
let _ply: PLYLoaderT | null = null;

async function getOBJ(): Promise<OBJLoaderT> {
  if (_obj) return _obj;
  const { OBJLoader } = await import('three/examples/jsm/loaders/OBJLoader.js');
  return (_obj = new OBJLoader());
}

async function getFBX(): Promise<FBXLoaderT> {
  if (_fbx) return _fbx;
  const { FBXLoader } = await import('three/examples/jsm/loaders/FBXLoader.js');
  return (_fbx = new FBXLoader());
}

async function getCollada(): Promise<ColladaLoaderT> {
  if (_dae) return _dae;
  const { ColladaLoader } = await import('three/examples/jsm/loaders/ColladaLoader.js');
  return (_dae = new ColladaLoader());
}

async function getPLY(): Promise<PLYLoaderT> {
  if (_ply) return _ply;
  const { PLYLoader } = await import('three/examples/jsm/loaders/PLYLoader.js');
  return (_ply = new PLYLoader());
}

function normalizePlyAttributes(geometry: THREE.BufferGeometry): void {
  for (const name of ['position', 'normal', 'uv', 'color']) {
    const attribute = geometry.getAttribute(name);
    if (!attribute || !(attribute.array instanceof Float64Array)) continue;
    geometry.setAttribute(
      name,
      new THREE.Float32BufferAttribute(
        new Float32Array(attribute.array),
        attribute.itemSize,
        attribute.normalized,
      ),
    );
  }
}

/**
 * Load any supported model URL and resolve to a scene-addable Object3D.
 * `nameHint` carries the real file name for ext detection when `url` is a
 * blob: URL (drag & drop) — blob URLs have no extension.
 */
export interface LoadOpts {
  /** Raw .mtl text for OBJ loads (drag & drop supplies it; blob: URLs can't
   *  resolve the sibling mtllib). Color-only MTLs apply without any texture fetch. */
  mtlText?: string;
  /** Image files dropped alongside an OBJ+MTL (textures the MTL's map_* lines
   *  reference). Their blob: URLs are mapped onto the requested texture names so
   *  textures resolve without a server. */
  texFiles?: File[];
  /** Parse the OBJ via the streaming parser (objStream.ts) instead of OBJLoader.
   *  Set for files too large for OBJLoader's single-string parse (>~300MB). */
  stream?: boolean;
}

export async function loadModel(
  url: string,
  renderer: THREE.WebGLRenderer,
  onProgress?: ProgressCb,
  nameHint?: string,
  opts?: LoadOpts,
): Promise<THREE.Object3D> {
  const ext = extOf(nameHint ?? url) ?? extOf(url);
  const onXhr = (xhr: ProgressEvent): void => {
    if (onProgress && xhr.total > 0) onProgress(xhr.loaded, xhr.total);
  };

  switch (ext) {
    case 'glb':
    case 'gltf': {
      const { gltf } = getLoaders(renderer);
      return new Promise<THREE.Object3D>((resolve, reject) => {
        gltf.load(url, (g) => resolve(g.scene), onXhr, (err) => reject(err));
      });
    }
    case 'obj': {
      if (opts?.stream) {
        const { loadObjStreaming } = await import('./objStream');
        return loadObjStreaming(url, opts.mtlText, onProgress);
      }
      const loader = await getOBJ();
      const texUrls: string[] = [];
      // Apply dropped .mtl (color-only) if present; otherwise clear any materials
      // left on the singleton loader from a previous load.
      if (opts?.mtlText) {
        const { MTLLoader } = await import('three/examples/jsm/loaders/MTLLoader.js');
        const manager = new THREE.LoadingManager();
        if (opts.texFiles && opts.texFiles.length) {
          // Map each dropped image's blob: URL onto the texture name the MTL
          // references (by basename), so map_Kd etc. resolve without a server.
          const byName = new Map<string, string>();
          for (const f of opts.texFiles) {
            const u = URL.createObjectURL(f);
            texUrls.push(u);
            byName.set(f.name.toLowerCase(), u);
          }
          manager.setURLModifier((u) => {
            const base = decodeURIComponent((u.split(/[\\/]/).pop() ?? '')).toLowerCase();
            return byName.get(base) ?? u;
          });
        }
        const mc = new MTLLoader(manager).parse(opts.mtlText, '');
        mc.preload();
        loader.setMaterials(mc);
      } else {
        (loader as unknown as { materials: unknown }).materials = null;
      }
      return new Promise<THREE.Object3D>((resolve, reject) => {
        loader.load(
          url,
          (group) => {
            group.traverse((node) => {
              const mat = (node as THREE.Mesh).material;
              if (!mat) return;
              (Array.isArray(mat) ? mat : [mat]).forEach((m) => {
                // CAD OBJ faces often have inconsistent winding; backface culling
                // tears flat surfaces. Render double-sided.
                m.side = THREE.DoubleSide;
                const map = (m as THREE.MeshPhongMaterial).map;
                if (map) map.colorSpace = THREE.SRGBColorSpace; // color textures are sRGB
              });
            });
            // Texture blob: URLs live with the object; revoked on dispose.
            if (texUrls.length) group.userData.__texUrls = texUrls;
            resolve(group);
          },
          onXhr,
          (err) => reject(err),
        );
      });
    }
    case 'fbx': {
      const loader = await getFBX();
      return new Promise<THREE.Object3D>((resolve, reject) => {
        loader.load(url, (group) => resolve(group), onXhr, (err) => reject(err));
      });
    }
    case 'dae': {
      const loader = await getCollada();
      return new Promise<THREE.Object3D>((resolve, reject) => {
        loader.load(
          url,
          (result) => {
            if (!result) {
              reject(new Error('Collada loader returned no scene'));
              return;
            }
            resolve(result.scene);
          },
          onXhr,
          (err) => reject(err),
        );
      });
    }
    case 'ply': {
      const loader = await getPLY();
      return new Promise<THREE.Object3D>((resolve, reject) => {
        loader.load(
          url,
          (geometry) => {
            normalizePlyAttributes(geometry);
            const hasColor = geometry.hasAttribute('color');
            if (geometry.index) {
              // Triangle mesh
              if (!geometry.hasAttribute('normal')) geometry.computeVertexNormals();
              const mat = new THREE.MeshStandardMaterial({
                color: 0xffffff,
                vertexColors: hasColor,
                metalness: 0.0,
                roughness: 1.0,
                side: THREE.DoubleSide,
              });
              resolve(new THREE.Mesh(geometry, mat));
            } else {
              // No faces → point cloud
              const mat = new THREE.PointsMaterial({
                color: 0xffffff,
                vertexColors: hasColor,
                size: 1,
                sizeAttenuation: false,
              });
              resolve(new THREE.Points(geometry, mat));
            }
          },
          onXhr,
          (err) => reject(err),
        );
      });
    }
    case 'ifc':
      return loadIfc(url, onProgress);
    default:
      throw new Error('Unsupported model format: ' + url);
  }
}

let _ifcApi: import('web-ifc').IfcAPI | null = null;

/** Parse an .ifc into a Group via web-ifc (single-thread wasm, no COOP/COEP). */
async function loadIfc(url: string, onProgress?: ProgressCb): Promise<THREE.Object3D> {
  try {
    const WebIFC = await import('web-ifc');
    if (!_ifcApi) {
      _ifcApi = new WebIFC.IfcAPI();
      _ifcApi.SetWasmPath(publicAssetUrl('web-ifc/'), true);
      await _ifcApi.Init(undefined, true);
    }
    const api = _ifcApi;

    const res = await fetch(url);
    if (!res.ok) throw new Error('fetch ' + res.status);
    const buf = await res.arrayBuffer();
    if (onProgress) onProgress(1, 1);
    const data = new Uint8Array(buf);

    const modelID = api.OpenModel(data, { COORDINATE_TO_ORIGIN: true });
    if (modelID < 0) throw new Error('OpenModel returned -1');

    const group = new THREE.Group();
    const matrix = new THREE.Matrix4();
    try {
      api.StreamAllMeshes(modelID, (mesh) => {
        const placedCount = mesh.geometries.size();
        for (let i = 0; i < placedCount; i++) {
          const placed = mesh.geometries.get(i);
          const geom = api.GetGeometry(modelID, placed.geometryExpressID);
          const verts = api.GetVertexArray(geom.GetVertexData(), geom.GetVertexDataSize());
          const indices = api.GetIndexArray(geom.GetIndexData(), geom.GetIndexDataSize());
          geom.delete();

          // Interleaved [posX,posY,posZ, normX,normY,normZ] — stride 6.
          const interleaved = new THREE.InterleavedBuffer(verts, 6);
          const bg = new THREE.BufferGeometry();
          bg.setAttribute('position', new THREE.InterleavedBufferAttribute(interleaved, 3, 0));
          bg.setAttribute('normal', new THREE.InterleavedBufferAttribute(interleaved, 3, 3));
          bg.setIndex(new THREE.BufferAttribute(indices, 1));

          const c = placed.color;
          const mat = new THREE.MeshStandardMaterial({
            color: new THREE.Color(c.x, c.y, c.z),
            metalness: 0.0,
            roughness: 1.0,
            side: THREE.DoubleSide,
          });
          if (c.w < 1) {
            mat.transparent = true;
            mat.opacity = c.w;
          }

          const three = new THREE.Mesh(bg, mat);
          matrix.fromArray(placed.flatTransformation);
          three.applyMatrix4(matrix);
          group.add(three);
        }
      });
    } finally {
      api.CloseModel(modelID);
    }
    return group;
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    throw new Error('IFC load failed: ' + msg);
  }
}
