import * as THREE from 'three';
import RAPIER from '@dimforge/rapier3d-compat';
import type { App, View } from '../core/app';
import { Rng } from '../core/rng';
import { audio } from '../core/audio';
import { TOOLS, TOOL_IDS, colliderParts, makeCutleryMesh, type ToolId } from '../sim/cutlery';
import { el, Panel } from '../ui/hud';
import { toolSilhouette } from '../ui/silhouette';

const W = 3.1;
const D = 2.2;
const H = 1.15;
const WALL = 0.12;
/** Above this you've got it clear of the drawer. */
const RIM_Y = H + 0.32;

interface Piece {
  id: ToolId;
  body: RAPIER.RigidBody;
  mesh: THREE.Group;
}

/**
 * The cutlery drawer.
 *
 * Rules that keep a dozen tangled steel objects stable: compound boxes rather
 * than trimeshes (a trimesh fork catching another trimesh fork is a solver
 * nightmare and slow with it), and grabbing pulls with a spring rather than a
 * fixed joint — so a snagged piece drags its neighbours instead of teleporting
 * through them, which is the entire feel we're after.
 */
export class DrawerView implements View {
  readonly root = new THREE.Group();
  private world!: RAPIER.World;
  private pieces: Piece[] = [];
  private eventQueue!: RAPIER.EventQueue;
  private rng: Rng;

  private grabbed: Piece | null = null;
  private grabLocal = new THREE.Vector3();
  private grabPlane = new THREE.Plane();
  private target = new THREE.Vector3();
  private ray = new THREE.Raycaster();
  private tmp = new THREE.Vector3();
  private tmpQ = new THREE.Quaternion();

  private accum = 0;

  private panel: Panel;
  private cardEl!: HTMLElement;
  private timerFill!: HTMLElement;
  private hintEl!: HTMLElement;

  /** Called with whatever you actually pulled out. */
  onPicked: ((tool: ToolId) => void) | null = null;
  /** How warm the toast still is, 0..1 — drawn as the timer. */
  warmth = 1;

  constructor(
    private app: App,
    seed = 7,
  ) {
    this.rng = new Rng(seed);
    this.buildScenery();
    this.panel = new Panel('drawer-panel');
    this.buildUi();
    this.panel.hide();
  }

  private buildUi(): void {
    const p = this.panel.root;
    const card = el('div', 'drawer-card', p);
    el('div', 'drawer-lbl', card, 'FIND THE');
    this.cardEl = el('div', 'drawer-sil', card);
    const timer = el('div', 'drawer-timer', card);
    const bar = el('div', 'timer-bar', timer);
    this.timerFill = el('div', 'timer-fill', bar);
    el('div', 'timer-lbl', timer, 'your toast is going cold');
    this.hintEl = el('div', 'drawer-hint', p, 'drag it out of the drawer');
  }

  private buildScenery(): void {
    const wood = new THREE.MeshStandardMaterial({ color: 0x6f4c2e, roughness: 0.8 });
    const inner = new THREE.MeshStandardMaterial({ color: 0x8a6740, roughness: 0.9 });
    const floor = new THREE.Mesh(new THREE.BoxGeometry(W, WALL, D), inner);
    floor.position.y = -WALL / 2;
    floor.receiveShadow = true;
    this.root.add(floor);
    const walls: [number, number, number, number, number, number][] = [
      [W, H, WALL, 0, H / 2, -D / 2],
      [W, H, WALL, 0, H / 2, D / 2],
      [WALL, H, D, -W / 2, H / 2, 0],
      [WALL, H, D, W / 2, H / 2, 0],
    ];
    for (const [w, h, d, x, y, z] of walls) {
      const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), wood);
      m.position.set(x, y, z);
      m.receiveShadow = true;
      m.castShadow = true;
      this.root.add(m);
    }
  }

  async init(): Promise<void> {
    await RAPIER.init();
    this.world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
    this.eventQueue = new RAPIER.EventQueue(true);

    const fixed = (hx: number, hy: number, hz: number, x: number, y: number, z: number) => {
      const rb = this.world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(x, y, z));
      this.world.createCollider(
        RAPIER.ColliderDesc.cuboid(hx, hy, hz).setFriction(0.7).setRestitution(0.04),
        rb,
      );
    };
    fixed(W / 2, WALL / 2, D / 2, 0, -WALL / 2, 0);
    fixed(W / 2, H, WALL / 2, 0, H, -D / 2 - WALL / 2);
    fixed(W / 2, H, WALL / 2, 0, H, D / 2 + WALL / 2);
    fixed(WALL / 2, H, D / 2, -W / 2 - WALL / 2, H, 0);
    fixed(WALL / 2, H, D / 2, W / 2 + WALL / 2, H, 0);
  }

  /** Fill the drawer and let it settle into a mess. */
  reset(want: ToolId, extras = 7): void {
    for (const p of this.pieces) {
      this.world.removeRigidBody(p.body);
      this.root.remove(p.mesh);
    }
    this.pieces = [];
    this.grabbed = null;

    // The wanted piece, plus a cast chosen to be confusably similar to it.
    const ids: ToolId[] = [want];
    const kin = TOOL_IDS.filter((t) => t !== want && TOOLS[t].kind === TOOLS[want].kind);
    for (const k of kin) ids.push(k);
    const rest = TOOL_IDS.filter((t) => !ids.includes(t));
    while (ids.length < extras + 1 && rest.length) {
      ids.push(rest.splice(Math.floor(this.rng.next() * rest.length), 1)[0]);
    }

    ids.forEach((id, i) => this.spawn(id, i));
    this.cardEl.innerHTML = toolSilhouette(TOOLS[want]);
    el('div', 'sil-name', this.cardEl, TOOLS[want].name);

    // Settle offline so the player opens the drawer on an existing mess rather
    // than watching the cutlery rain in.
    for (let i = 0; i < 300; i++) this.world.step();
    this.syncMeshes();
  }

  private spawn(id: ToolId, i: number): void {
    const tool = TOOLS[id];
    // Pass the quaternion itself, not .toArray(): Rapier wants {x,y,z,w}, and an
    // array reads back as undefined on every axis, which is NaN in the solver and
    // a wasm panic one step later.
    const q = new THREE.Quaternion().setFromEuler(
      new THREE.Euler(
        this.rng.range(-0.5, 0.5),
        this.rng.range(0, Math.PI * 2),
        this.rng.range(-2.6, 2.6),
      ),
    );
    const body = this.world.createRigidBody(
      RAPIER.RigidBodyDesc.dynamic()
        // Drop them down a narrow column in the middle so they land on top of
        // one another rather than politely finding their own patch of floor.
        .setTranslation(
          this.rng.range(-0.5, 0.5),
          0.7 + i * 0.5,
          this.rng.range(-0.35, 0.35),
        )
        .setRotation({ x: q.x, y: q.y, z: q.z, w: q.w })
        .setLinearDamping(0.35)
        .setAngularDamping(0.45),
    );
    for (const part of colliderParts(tool)) {
      this.world.createCollider(
        RAPIER.ColliderDesc.cuboid(...part.half)
          .setTranslation(...part.pos)
          // Steel. Heavy, slippery, and it rings.
          .setDensity(7.8)
          .setFriction(0.28)
          .setRestitution(0.12)
          .setActiveEvents(RAPIER.ActiveEvents.CONTACT_FORCE_EVENTS)
          .setContactForceEventThreshold(2.5),
        body,
      );
    }
    const mesh = makeCutleryMesh(tool);
    this.root.add(mesh);
    this.pieces.push({ id, body, mesh });
  }

  private syncMeshes(): void {
    for (const p of this.pieces) {
      const t = p.body.translation();
      const r = p.body.rotation();
      p.mesh.position.set(t.x, t.y, t.z);
      p.mesh.quaternion.set(r.x, r.y, r.z, r.w);
    }
  }

  enter(): void {
    this.panel.show();
    this.app.camera.position.set(0, 3.3, 2.35);
    this.app.camera.lookAt(0, 0.15, 0.05);
    this.hintEl.textContent = 'drag a piece up and out · ENTER takes what you\'re holding';
  }

  exit(): void {
    this.panel.hide();
    this.grabbed = null;
  }

  update(dt: number): void {
    const inp = this.app.input;
    this.app.camera.position.set(0, 3.3, 2.35);
    this.app.camera.lookAt(0, 0.15, 0.05);

    this.ray.setFromCamera(inp.ndc, this.app.camera);

    if (inp.down && !this.grabbed) this.tryGrab();
    if (!inp.down) this.grabbed = null;
    if (this.grabbed) this.dragGrabbed();

    // Fixed-step the solver so a frame hitch can't explode the tangle.
    this.accum += dt;
    let steps = 0;
    while (this.accum >= 1 / 60 && steps < 4) {
      this.world.step(this.eventQueue);
      this.accum -= 1 / 60;
      steps++;
      this.drainContacts();
    }
    this.syncMeshes();

    this.timerFill.style.width = `${Math.max(0, this.warmth * 100)}%`;
    this.timerFill.style.background = this.warmth > 0.5 ? '#e8a53a' : this.warmth > 0.25 ? '#d2703a' : '#c0392b';

    // Lifted clear of the rim? That's your pick.
    if (this.grabbed && this.grabbed.body.translation().y > RIM_Y) {
      const got = this.grabbed.id;
      this.grabbed = null;
      this.onPicked?.(got);
      return;
    }
    if (inp.justPressed('Enter') && this.grabbed) {
      const got = this.grabbed.id;
      this.grabbed = null;
      this.onPicked?.(got);
    }
  }

  private drainContacts(): void {
    this.eventQueue.drainContactForceEvents((e) => {
      const mag = e.totalForceMagnitude();
      if (mag > 3) audio.clatter(Math.min(1, mag / 260), 0.8 + this.rng.next() * 0.5);
    });
  }

  private tryGrab(): void {
    // Raycast the visual meshes: they're what the player can see, and the
    // colliders are a coarse stand-in for them.
    const hits = this.ray.intersectObjects(
      this.pieces.map((p) => p.mesh),
      true,
    );
    if (!hits.length) return;
    let obj: THREE.Object3D | null = hits[0].object;
    let piece: Piece | undefined;
    while (obj && !piece) {
      piece = this.pieces.find((p) => p.mesh === obj);
      obj = obj.parent;
    }
    if (!piece) return;
    this.grabbed = piece;
    piece.body.wakeUp();
    // Remember where on the piece we took hold, in its own frame.
    this.grabLocal.copy(hits[0].point);
    piece.mesh.worldToLocal(this.grabLocal);
    // Drag on a VERTICAL plane through the grab point, facing the camera
    // horizontally. A camera-facing plane sounds right but the camera looks down
    // into the drawer, so dragging to the top of the screen only lifts a little
    // and you can't physically get anything over the rim. Killing the Y of the
    // normal makes up-screen mean up.
    this.app.camera.getWorldDirection(this.tmp);
    this.tmp.y = 0;
    this.tmp.normalize();
    this.grabPlane.setFromNormalAndCoplanarPoint(this.tmp, hits[0].point);
    audio.clatter(0.25, 1.2);
  }

  private dragGrabbed(): void {
    const p = this.grabbed!;
    if (!this.ray.ray.intersectPlane(this.grabPlane, this.target)) return;

    // Where the grab point is right now, in world space.
    const t = p.body.translation();
    const r = p.body.rotation();
    this.tmp.copy(this.grabLocal).applyQuaternion(this.tmpQ.set(r.x, r.y, r.z, r.w));
    const px = t.x + this.tmp.x;
    const py = t.y + this.tmp.y;
    const pz = t.z + this.tmp.z;

    // A spring, not a joint: a snagged piece has to fight the ones on top of it
    // and lose, rather than tunnel through them.
    const v = p.body.linvel();
    const m = p.body.mass();
    const K = 165 * m;
    const C = 13 * m;
    const fx = (this.target.x - px) * K - v.x * C;
    const fy = (this.target.y - py) * K - v.y * C;
    const fz = (this.target.z - pz) * K - v.z * C;
    const cap = 90 * m;
    const mag = Math.hypot(fx, fy, fz);
    const s = mag > cap ? cap / mag : 1;
    p.body.wakeUp();
    p.body.addForceAtPoint(
      { x: fx * s, y: fy * s, z: fz * s },
      { x: px, y: py, z: pz },
      true,
    );
  }

  dispose(): void {
    this.panel.dispose();
  }
}
