import {
  AnimationMixer,
  Box3,
  Group,
  LoopOnce,
  LoopRepeat,
  Mesh,
  MeshLambertMaterial,
  NearestFilter,
  SkinnedMesh,
  Vector2,
  type AnimationAction,
  type AnimationClip,
} from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { applyPSXMaterial } from "../post/PSXMaterial.js";

/**
 * Runtime side of the Milestone 3 harness: loads a generated character and
 * drives its canonical clips.
 *
 * Clips are addressed by name. The harness emits Idle / Walk / Run / QuickTurn
 * for the player and Slumped for ambient bodies, so this controller works with
 * any character the pipeline produces.
 */

export type CharacterState = "idle" | "walk" | "run" | "quickTurn" | "slumped";

const CLIP_FOR_STATE: Record<CharacterState, string> = {
  idle: "Idle",
  walk: "Walk",
  run: "Run",
  quickTurn: "QuickTurn",
  slumped: "Slumped",
};

/** Seconds to cross-fade between locomotion states. */
const BLEND_DURATION = 0.18;

export interface CharacterModelOptions {
  /** Vertex snap grid, matched to the PSX pipeline's virtual framebuffer. */
  snapResolution?: Vector2;
  /**
   * Clip to start in. Ambient corpses use `"slumped"` so they don't stand in
   * the breathing Idle pose.
   */
  initialState?: CharacterState;
}

export class CharacterModel {
  readonly group = new Group();

  private readonly mixer: AnimationMixer;
  private readonly actions = new Map<string, AnimationAction>();
  private current: CharacterState = "idle";
  private locked = false;

  private constructor(
    scene: Group,
    clips: AnimationClip[],
    options: CharacterModelOptions,
  ) {
    this.group.add(scene);
    this.mixer = new AnimationMixer(scene);

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

      // Vertex-colour cast: COLOR_0 only. Textured cast: baseColor map from
      // the Grok UV pipeline (nearest-filtered in the GLB sampler).
      const loaded = mesh.material as MeshLambertMaterial;
      const loadedMap = !Array.isArray(loaded) && loaded.map ? loaded.map : null;

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

      // Generated characters are small in screen space and always near the
      // camera; skipping frustum culling avoids popping during camera cuts.
      if ((mesh as SkinnedMesh).isSkinnedMesh) mesh.frustumCulled = false;
    });

    for (const clip of clips) {
      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(options.initialState ?? "idle");
    // Apply the first frame immediately so a slumped body is posed before
    // the next rendered frame (otherwise they flash in T/A-pose for a tick).
    this.mixer.update(0);
  }

  static async load(url: string, options: CharacterModelOptions = {}): Promise<CharacterModel> {
    const loader = new GLTFLoader();
    const gltf = await loader.loadAsync(url);
    return new CharacterModel(gltf.scene as Group, gltf.animations, options);
  }

  /**
   * Pick a locomotion state from speed. The thresholds sit slightly below the
   * controller's walk and run speeds so the animation commits rather than
   * flickering between clips at the boundary.
   */
  setSpeed(metresPerSecond: number): void {
    if (this.locked || this.current === "slumped") return;
    if (metresPerSecond < 0.15) this.play("idle");
    else if (metresPerSecond < 2.6) this.play("walk");
    else this.play("run");
  }

  play(state: CharacterState): void {
    if (state === this.current && this.actions.get(CLIP_FOR_STATE[state])?.isRunning()) return;

    const next = this.actions.get(CLIP_FOR_STATE[state]);
    const previous = this.actions.get(CLIP_FOR_STATE[this.current]);
    if (!next) return;

    next.reset().play();
    if (state === "slumped") {
      next.setEffectiveWeight(1);
      // Hold the collapsed pose; no blend back to idle.
      this.locked = true;
    }
    if (previous && previous !== next) {
      previous.crossFadeTo(next, state === "slumped" ? 0 : BLEND_DURATION, false);
    }
    this.current = state;
  }

  /** Plays the quick-turn once and holds locomotion until it finishes. */
  playQuickTurn(durationSeconds: number): void {
    if (this.locked && this.current === "quickTurn") return;
    if (this.current === "slumped") return;
    this.play("quickTurn");
    this.locked = true;
    setTimeout(() => {
      this.locked = false;
      this.current = "idle";
    }, durationSeconds * 1000);
  }

  update(dt: number): void {
    this.mixer.update(dt);
  }

  setTransform(x: number, y: number, z: number, yaw: number): void {
    this.group.position.set(x, y, z);
    this.group.rotation.y = yaw;
  }

  /**
   * Snap the lowest skinned vertex to y=0 in world space after pose/animation
   * so ambient bodies don't float above the floor or sink through it.
   */
  groundToFloor(): void {
    this.group.updateWorldMatrix(true, true);
    const box = new Box3().setFromObject(this.group);
    if (!Number.isFinite(box.min.y)) return;
    this.group.position.y -= box.min.y;
  }

  dispose(): void {
    this.mixer.stopAllAction();
    this.group.traverse((object) => {
      const mesh = object as Mesh;
      if (!mesh.isMesh) return;
      mesh.geometry.dispose();
      const material = mesh.material;
      if (Array.isArray(material)) material.forEach((entry) => entry.dispose());
      else material.dispose();
    });
  }
}
