import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { getLoaders } from './loaders';
import { loadModel, extOf } from './modelLoader';
import { recenterObjFile } from './objRecenter';
import { createHydrationGate } from './hydration';
import { showProgress, hideProgress, setProgress } from '../ui/progress';
import { createFpsMeter } from '../ui/fps';
import { createAdaptiveQuality } from './adaptiveQuality';

// Formats authored Y-up; the viewer world is Z-up (structure convention), so
// these are rotated +90° about X on load. OBJ/IFC are Z-up native (no rotate).
const Y_UP_EXTS = new Set(['glb', 'gltf', 'fbx', 'dae']);
function isYUp(name: string): boolean {
  const e = extOf(name);
  return e !== null && Y_UP_EXTS.has(e);
}

export class ThreeDViewer {
  private readonly renderer: THREE.WebGLRenderer;
  private readonly scene = new THREE.Scene();
  private readonly perspCamera: THREE.PerspectiveCamera;
  private readonly orthoCamera: THREE.OrthographicCamera;
  private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
  private projection: 'persp' | 'ortho' = 'persp';
  private readonly fov = 50;
  private readonly controls: OrbitControls;
  private readonly gate;
  private readonly onError;
  private readonly fps = createFpsMeter();
  private readonly adaptive;
  private disposed = false;
  private raf = 0;
  private current: THREE.Object3D | null = null;
  private outline: THREE.Group | null = null;
  private outlineOn = false;

  constructor(
    private readonly container: HTMLElement,
    private readonly progress: HTMLElement,
    preview: HTMLImageElement,
    onError?: (msg: string) => void,
  ) {
    this.onError = onError ?? ((msg: string) => console.error('[hmwebviewer]', msg));
    this.renderer = new THREE.WebGLRenderer({ antialias: true });
    this.container.appendChild(this.renderer.domElement);
    // Adaptive quality owns renderer.setPixelRatio: tier 0 applies a DPR cap of
    // min(devicePixelRatio, 2) immediately (uncapped DPR on 3x devices is the
    // #1 cause of <60fps). It then steps the pixel ratio down/up with hysteresis.
    this.adaptive = createAdaptiveQuality(this.renderer);
    this.container.appendChild(this.fps.el);

    this.perspCamera = new THREE.PerspectiveCamera(this.fov, 1, 0.1, 1000);
    this.orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
    for (const c of [this.perspCamera, this.orthoCamera]) {
      c.up.set(0, 0, 1); // Z-up world (right-handed, structure convention)
      c.position.set(2, -3, 2);
    }
    this.camera = this.perspCamera;

    this.controls = new OrbitControls(this.camera, this.renderer.domElement);
    this.controls.enableDamping = true;
    // Pan in the ground (XY) plane, not the screen plane — keeps elevation
    // constant when panning along a road/rail alignment between turntable spins.
    this.controls.screenSpacePanning = false;

    this.scene.background = new THREE.Color(0xcfcfcf);
    const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 1.2);
    hemi.position.set(0, 0, 1); // sky toward +Z
    this.scene.add(hemi);
    const dir = new THREE.DirectionalLight(0xffffff, 1.0);
    dir.position.set(2, -3, 5); // from above (+Z) and front
    this.scene.add(dir);

    // Warm the shared singleton loaders so KTX2 detectSupport(renderer) runs at
    // init; loadModel() reuses the same singletons for GLB/GLTF.
    getLoaders(this.renderer);
    this.gate = createHydrationGate(preview);

    window.addEventListener('resize', this.onResize);
    this.onResize();
    this.animate();
    this.gate.markWebGLReady();
  }

  /** Path A — server asset (SSR placeholder already shown by host page). */
  loadServerAsset(url: string): void {
    showProgress(this.progress);
    loadModel(url, this.renderer, (loaded, total) => {
      setProgress(this.progress, (loaded / total) * 100);
    }).then(
      (obj) => this.onLoaded(obj, isYUp(url)),
      (err) => {
        hideProgress(this.progress);
        this.onError('Failed to load server asset.');
        console.error('[hmwebviewer] server asset load failed', err);
      },
    );
  }

  /**
   * Path B — local file (Drag & Drop). Blob URL revoked on both paths.
   * `sidecars` carries companion files dropped alongside (e.g. a .mtl for OBJ);
   * the blob: URL can't resolve them, so their text is read and passed inline.
   */
  loadLocalFile(file: File, sidecars: File[] = []): void {
    showProgress(this.progress);
    const mtlFile = sidecars.find((f) => /\.mtl$/i.test(f.name));
    const texFiles = sidecars.filter((f) => /\.(png|jpe?g|bmp|gif|webp|tga)$/i.test(f.name));
    let url: string | null = null;
    const isObj = extOf(file.name) === 'obj';
    // OBJLoader builds one giant string; past ~V8 max string length (~1GB of
    // text) it fails. Route large OBJ through the streaming parser instead.
    const huge = isObj && file.size > 300 * 1024 * 1024;
    const run = async (): Promise<THREE.Object3D> => {
      const mtlText = mtlFile ? await mtlFile.text() : undefined;
      // Small/medium OBJ: recenter in float64 BEFORE OBJLoader quantizes to
      // float32 (objRecenter.ts). Huge OBJ recenters inside the streaming parser.
      const source = (isObj && !huge)
        ? await recenterObjFile(file, (l, t) => setProgress(this.progress, (l / t) * 100))
        : file;
      url = URL.createObjectURL(source);
      return loadModel(
        url,
        this.renderer,
        (loaded, total) => setProgress(this.progress, (loaded / total) * 100),
        file.name,
        { mtlText, texFiles, stream: huge },
      );
    };
    run()
      .then(
        (obj) => this.onLoaded(obj, isYUp(file.name)),
        (err) => {
          hideProgress(this.progress);
          this.onError('Failed to load file (corrupt or unsupported?).');
          console.error('[hmwebviewer] local file load failed', err);
        },
      )
      .finally(() => { if (url) URL.revokeObjectURL(url); });
  }

  private onLoaded(obj: THREE.Object3D, yUp = false): void {
    if (this.current) {
      this.scene.remove(this.current);
      this.disposeObject(this.current);
    }
    this.current = obj;
    if (yUp) obj.rotateX(Math.PI / 2); // Y-up source → Z-up world
    this.scene.add(this.current);
    this.frameObject(this.current);
    this.applyOutlineState();
    hideProgress(this.progress);
    this.gate.markModelLoaded();
    // observable hook for perf smoke tests (tools/perf-smoke.mjs)
    window.dispatchEvent(new CustomEvent('hmw:ready', { detail: performance.now() }));
  }

  private frameObject(obj: THREE.Object3D): void {
    const box = new THREE.Box3().setFromObject(obj);
    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 fovRad = (this.fov * Math.PI) / 180;
    const dist = (maxDim / 2) / Math.tan(fovRad / 2) * 2;
    const near = Math.max(maxDim / 1000, 0.01); // adaptive: fixed far=1000 clips large models
    const far = (dist + maxDim) * 4;
    const aspect = this.aspect;
    const dir = new THREE.Vector3(1, -1, 0.8).normalize(); // iso view, Z up
    const pos = center.clone().addScaledVector(dir, dist);

    this.perspCamera.position.copy(pos);
    this.perspCamera.aspect = aspect;
    this.perspCamera.near = near;
    this.perspCamera.far = far;
    this.perspCamera.lookAt(center);
    this.perspCamera.updateProjectionMatrix();

    // Ortho frustum half-height matches the perspective apparent size at `dist`,
    // so toggling projection doesn't jump the model scale.
    const halfH = dist * Math.tan(fovRad / 2);
    this.orthoCamera.position.copy(pos);
    this.orthoCamera.top = halfH;
    this.orthoCamera.bottom = -halfH;
    this.orthoCamera.left = -halfH * aspect;
    this.orthoCamera.right = halfH * aspect;
    this.orthoCamera.zoom = 1;
    this.orthoCamera.near = near;
    this.orthoCamera.far = far;
    this.orthoCamera.lookAt(center);
    this.orthoCamera.updateProjectionMatrix();

    this.controls.target.copy(center);
    this.controls.update();
  }

  /** Re-frame the current model to fit the view (Zoom Fit button). */
  fitView(): void {
    if (this.current) this.frameObject(this.current);
  }

  private applyOutlineState(): void {
    this.clearOutline();
    if (this.outlineOn && this.current) this.buildOutline(this.current);
  }

  /**
   * Build a feature-edge outline (EdgesGeometry, 30° threshold) — the object's
   * silhouette + sharp creases as black lines, far sparser than a wireframe.
   * Built lazily; one-time cost can be seconds on very large meshes.
   */
  private buildOutline(obj: THREE.Object3D): void {
    obj.updateMatrixWorld(true);
    const group = new THREE.Group();
    const mat = new THREE.LineBasicMaterial({ color: 0x1a1a1a });
    obj.traverse((node) => {
      const mesh = node as THREE.Mesh;
      if (!mesh.isMesh || !mesh.geometry) return;
      const seg = new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry, 30), mat);
      mesh.matrixWorld.decompose(seg.position, seg.quaternion, seg.scale);
      group.add(seg);
    });
    this.outline = group;
    this.scene.add(group);
  }

  private clearOutline(): void {
    if (!this.outline) return;
    this.scene.remove(this.outline);
    this.outline.traverse((node) => {
      const seg = node as THREE.LineSegments;
      if (seg.geometry) seg.geometry.dispose();
    });
    const m = (this.outline.children[0] as THREE.LineSegments | undefined)?.material;
    if (m && !Array.isArray(m)) m.dispose();
    this.outline = null;
  }

  /** Toggle the object outline overlay (테두리 button). */
  toggleOutline(): boolean {
    this.outlineOn = !this.outlineOn;
    this.applyOutlineState();
    return this.outlineOn;
  }

  /** Switch between perspective and orthographic projection, preserving the view. */
  setProjection(mode: 'persp' | 'ortho'): void {
    if (mode === this.projection) return;
    const target = this.controls.target;
    const from = this.camera;
    const fovRad = (this.fov * Math.PI) / 180;
    const offset = from.position.clone().sub(target);
    const dist = offset.length() || 1;
    const aspect = this.aspect;

    if (mode === 'ortho') {
      const halfH = dist * Math.tan(fovRad / 2);
      this.orthoCamera.position.copy(from.position);
      this.orthoCamera.up.copy(from.up);
      this.orthoCamera.top = halfH;
      this.orthoCamera.bottom = -halfH;
      this.orthoCamera.left = -halfH * aspect;
      this.orthoCamera.right = halfH * aspect;
      this.orthoCamera.zoom = 1;
      this.orthoCamera.near = from.near;
      this.orthoCamera.far = from.far;
      this.orthoCamera.lookAt(target);
      this.orthoCamera.updateProjectionMatrix();
      this.camera = this.orthoCamera;
    } else {
      // Place the perspective camera so apparent size matches the ortho view.
      const orthoHalfH = this.orthoCamera.top / this.orthoCamera.zoom;
      const d = orthoHalfH / Math.tan(fovRad / 2);
      this.perspCamera.position.copy(target).add(offset.setLength(d));
      this.perspCamera.up.copy(from.up);
      this.perspCamera.aspect = aspect;
      this.perspCamera.near = from.near;
      this.perspCamera.far = from.far;
      this.perspCamera.lookAt(target);
      this.perspCamera.updateProjectionMatrix();
      this.camera = this.perspCamera;
    }
    this.projection = mode;
    this.controls.object = this.camera;
    this.controls.update();
  }

  private disposeObject(obj: THREE.Object3D): void {
    const texUrls = obj.userData?.__texUrls as string[] | undefined;
    if (texUrls) texUrls.forEach((u) => URL.revokeObjectURL(u));
    obj.traverse((node) => {
      const mesh = node as THREE.Mesh;
      if (mesh.geometry) mesh.geometry.dispose();
      const mat = mesh.material;
      if (Array.isArray(mat)) mat.forEach((m) => this.disposeMaterial(m));
      else if (mat) this.disposeMaterial(mat);
    });
  }

  private disposeMaterial(mat: THREE.Material): void {
    for (const v of Object.values(mat)) {
      if (v instanceof THREE.Texture) v.dispose();
    }
    mat.dispose();
  }

  private get aspect(): number {
    const w = this.container.clientWidth || window.innerWidth;
    const h = this.container.clientHeight || window.innerHeight;
    return w / h;
  }

  private onResize = (): void => {
    const w = this.container.clientWidth || window.innerWidth;
    const h = this.container.clientHeight || window.innerHeight;
    this.renderer.setSize(w, h, false);
    const aspect = w / h;
    this.perspCamera.aspect = aspect;
    this.perspCamera.updateProjectionMatrix();
    const halfH = this.orthoCamera.top || 1;
    this.orthoCamera.left = -halfH * aspect;
    this.orthoCamera.right = halfH * aspect;
    this.orthoCamera.updateProjectionMatrix();
  };

  private animate = (now: number = performance.now()): void => {
    if (this.disposed) return;
    this.raf = requestAnimationFrame(this.animate);
    this.fps.sample(now);
    this.adaptive.update(this.fps.fps());
    this.controls.update();
    this.renderer.render(this.scene, this.camera);
  };

  dispose(): void {
    if (this.disposed) return;
    this.disposed = true;
    cancelAnimationFrame(this.raf);
    this.raf = 0;
    window.removeEventListener('resize', this.onResize);
    this.controls.dispose();
    this.clearOutline();
    if (this.current) this.disposeObject(this.current);
    this.current = null;
    this.fps.el.remove();
    this.renderer.dispose();
    this.renderer.forceContextLoss();
    this.renderer.domElement.remove();
    this.scene.clear();
  }

  /** Orbit the camera to azimuth (radians) around the model — used by tools/prerender.mjs. */
  rotateTo(azimuth: number): void {
    if (!this.current) return;
    const offset = new THREE.Vector3().subVectors(this.camera.position, this.controls.target);
    const radius = Math.max(offset.length(), 1e-3);
    const phi = Math.acos(THREE.MathUtils.clamp(offset.y / radius, -1, 1));
    const target = this.controls.target;
    this.camera.position.set(
      target.x + radius * Math.sin(phi) * Math.sin(azimuth),
      target.y + radius * Math.cos(phi),
      target.z + radius * Math.sin(phi) * Math.cos(azimuth),
    );
    this.camera.lookAt(target);
    this.controls.update();
    this.renderer.render(this.scene, this.camera);
  }
}
