import {
  AmbientLight,
  AnimationMixer,
  Color,
  DirectionalLight,
  Group,
  HemisphereLight,
  LoopOnce,
  LoopRepeat,
  Mesh,
  MeshBasicMaterial,
  MeshLambertMaterial,
  NearestFilter,
  OrthographicCamera,
  PerspectiveCamera,
  PlaneGeometry,
  RGBAFormat,
  Scene,
  SkinnedMesh,
  SRGBColorSpace,
  WebGLRenderTarget,
  WebGLRenderer,
  type AnimationAction,
  type AnimationClip,
  type Material,
} from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { buildEnvironment, ENV_PALETTE } from "./environment";

/**
 * Three.js viewport for the character creator.
 * Outdoor PSX-style set, fog, sun lighting, and a low-res nearest upscale.
 */
export class CharacterPreview {
  private readonly renderer: WebGLRenderer;
  private readonly scene = new Scene();
  private readonly camera: PerspectiveCamera;
  private readonly controls: OrbitControls;
  private readonly loader = new GLTFLoader();
  private readonly root = new Group();
  private readonly clock = { last: performance.now() };

  /** Offscreen low-res buffer → nearest-neighbor blit (GTE-ish look). */
  private readonly rt: WebGLRenderTarget;
  private readonly blitScene = new Scene();
  private readonly blitCam = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
  private readonly blitMat: MeshBasicMaterial;
  private viewW = 1;
  private viewH = 1;

  private mixer: AnimationMixer | null = null;
  private actions = new Map<string, AnimationAction>();
  private character: Group | null = null;
  private env: Group | null = null;
  private raf = 0;
  private autoRotate = true;
  private disposed = false;
  private loadGen = 0;

  constructor(private readonly canvas: HTMLCanvasElement) {
    this.renderer = new WebGLRenderer({
      canvas,
      antialias: false,
      alpha: false,
      powerPreference: "high-performance",
    });
    this.renderer.setClearColor(new Color(ENV_PALETTE.fog), 1);
    this.renderer.setPixelRatio(1);
    this.renderer.outputColorSpace = SRGBColorSpace;

    this.camera = new PerspectiveCamera(38, 1, 0.08, 28);
    this.camera.position.set(1.55, 1.25, 2.65);

    this.controls = new OrbitControls(this.camera, canvas);
    this.controls.target.set(0, 0.85, 0);
    this.controls.enableDamping = true;
    this.controls.dampingFactor = 0.08;
    this.controls.minDistance = 1.0;
    this.controls.maxDistance = 7;
    this.controls.maxPolarAngle = Math.PI * 0.48;
    this.controls.minPolarAngle = 0.15;

    this.scene.add(this.root);
    this.env = buildEnvironment(this.scene);
    this.setupLighting();

    // Low-res render target; size updated on resize.
    this.rt = new WebGLRenderTarget(320, 240, {
      format: RGBAFormat,
      minFilter: NearestFilter,
      magFilter: NearestFilter,
      depthBuffer: true,
      stencilBuffer: false,
    });
    this.rt.texture.generateMipmaps = false;

    this.blitMat = new MeshBasicMaterial({
      map: this.rt.texture,
      depthTest: false,
      depthWrite: false,
    });
    const blitQuad = new Mesh(new PlaneGeometry(2, 2), this.blitMat);
    this.blitScene.add(blitQuad);

    this.onResize();
    window.addEventListener("resize", this.onResize);
    this.tick();
  }

  private setupLighting(): void {
    // Cool overcast sky / warm dirt bounce — PSX adventure outdoor.
    const hemi = new HemisphereLight(0xb0b8a0, 0x3a3220, 0.55);
    hemi.position.set(0, 4, 0);

    // Late-afternoon key (sun).
    const sun = new DirectionalLight(0xffe2b0, 1.35);
    sun.position.set(4.5, 7.5, 3.2);

    // Cool sky fill from the opposite side.
    const fill = new DirectionalLight(0x8898a8, 0.4);
    fill.position.set(-3.5, 2.5, -2.5);

    // Soft ambient so flats don't crush to black.
    const ambient = new AmbientLight(0x4a5040, 0.38);

    // Weak rim so silhouettes read against fog.
    const rim = new DirectionalLight(0xc8d0c0, 0.25);
    rim.position.set(-1.5, 3.0, -4.0);

    this.scene.add(hemi, sun, fill, ambient, rim);
  }

  setAutoRotate(enabled: boolean): void {
    this.autoRotate = enabled;
    this.controls.autoRotate = enabled;
    this.controls.autoRotateSpeed = 1.15;
  }

  async setGlb(glb: Uint8Array, clipName = "Idle"): Promise<void> {
    const gen = ++this.loadGen;
    const gltf = await this.parseGlb(glb);
    if (this.disposed || gen !== this.loadGen) return;

    if (this.character) {
      this.root.remove(this.character);
      this.disposeObject(this.character);
      this.character = null;
    }
    this.mixer = null;
    this.actions.clear();

    const scene = gltf.scene as Group;
    scene.traverse((object) => {
      const mesh = object as Mesh;
      if (!mesh.isMesh) return;

      // Keep Grok albedo map when present; otherwise vertex colours.
      const prior = mesh.material as Material & { map?: import("three").Texture | null };
      const loadedMap = !Array.isArray(prior) && prior.map ? prior.map : null;

      mesh.material = new MeshLambertMaterial({
        vertexColors: !loadedMap,
        flatShading: true,
        fog: true,
        ...(loadedMap ? { map: loadedMap } : {}),
      });
      if (loadedMap) {
        loadedMap.magFilter = NearestFilter;
        loadedMap.minFilter = NearestFilter;
        loadedMap.needsUpdate = true;
      }

      if ((mesh as SkinnedMesh).isSkinnedMesh) {
        mesh.frustumCulled = false;
      }
    });

    this.root.add(scene);
    this.character = scene;

    this.mixer = new AnimationMixer(scene);
    for (const clip of gltf.animations) {
      const action = this.mixer.clipAction(clip);
      const once = clip.name === "QuickTurn" || clip.name === "Slumped";
      action.setLoop(once ? LoopOnce : LoopRepeat, Infinity);
      action.clampWhenFinished = once;
      this.actions.set(clip.name, action);
    }

    this.play(clipName);
    this.mixer.update(0);
  }

  play(clipName: string): void {
    if (!this.mixer) return;
    const next = this.actions.get(clipName);
    if (!next) return;

    for (const [name, action] of this.actions) {
      if (name === clipName) continue;
      if (action.isRunning()) action.fadeOut(0.15);
    }

    next.reset().fadeIn(0.15).play();
  }

  dispose(): void {
    this.disposed = true;
    cancelAnimationFrame(this.raf);
    window.removeEventListener("resize", this.onResize);
    this.controls.dispose();
    if (this.character) this.disposeObject(this.character);
    if (this.env) this.disposeObject(this.env);
    this.rt.dispose();
    this.blitMat.dispose();
    this.renderer.dispose();
  }

  private parseGlb(glb: Uint8Array): Promise<{ scene: Group; animations: AnimationClip[] }> {
    const buffer = glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength) as ArrayBuffer;
    return new Promise((resolve, reject) => {
      this.loader.parse(buffer, "", (gltf) => resolve(gltf as { scene: Group; animations: AnimationClip[] }), reject);
    });
  }

  private disposeObject(root: Group): void {
    root.traverse((object) => {
      const mesh = object as Mesh;
      if (mesh.isMesh) {
        mesh.geometry?.dispose();
        const material = mesh.material;
        if (Array.isArray(material)) material.forEach((m) => m.dispose());
        else material?.dispose();
      }
    });
  }

  private readonly onResize = (): void => {
    const parent = this.canvas.parentElement;
    if (!parent) return;
    const width = parent.clientWidth;
    const height = parent.clientHeight;
    if (width === 0 || height === 0) return;

    this.viewW = width;
    this.viewH = height;

    // Internal PSX-ish resolution (~320px wide), keep aspect.
    const internalW = 320;
    const internalH = Math.max(180, Math.round(internalW * (height / width)));
    this.rt.setSize(internalW, internalH);

    this.camera.aspect = width / height;
    this.camera.updateProjectionMatrix();

    // Full canvas for the upscale blit.
    this.renderer.setSize(width, height, false);
  };

  private readonly tick = (): void => {
    if (this.disposed) return;
    this.raf = requestAnimationFrame(this.tick);

    const now = performance.now();
    const dt = Math.min(0.05, (now - this.clock.last) / 1000);
    this.clock.last = now;

    this.mixer?.update(dt);
    this.controls.autoRotate = this.autoRotate;
    this.controls.update();

    // Pass 1: scene → low-res RT
    this.renderer.setRenderTarget(this.rt);
    this.renderer.setClearColor(new Color(ENV_PALETTE.fog), 1);
    this.renderer.clear();
    this.renderer.render(this.scene, this.camera);

    // Pass 2: nearest upscale to canvas (chunky pixels).
    this.renderer.setRenderTarget(null);
    this.renderer.clear();
    this.renderer.render(this.blitScene, this.blitCam);
  };
}
