import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';

/** Shared key light direction — the slice shader rolls its own lighting, so it
 *  has to be told the same thing the real lights are doing. */
export const LIGHT_DIR = new THREE.Vector3(0.5, 0.9, 0.42).normalize();

export interface View {
  readonly root: THREE.Object3D;
  enter(): void;
  exit(): void;
  update(dt: number): void;
  resize?(w: number, h: number): void;
  dispose?(): void;
}

export class Input {
  /** Normalised device coords (-1..1). */
  readonly ndc = new THREE.Vector2();
  /** Pixel coords. */
  readonly px = new THREE.Vector2();
  /** Pixel delta since last frame. */
  readonly delta = new THREE.Vector2();
  down = false;
  rightDown = false;
  /** Accumulated wheel since last frame (normalised-ish). */
  wheel = 0;
  private prev = new THREE.Vector2();
  private first = true;
  private keys = new Set<string>();
  private pressed = new Set<string>();

  constructor(el: HTMLElement) {
    el.addEventListener('pointermove', (e) => {
      const r = el.getBoundingClientRect();
      this.px.set(e.clientX - r.left, e.clientY - r.top);
      this.ndc.set((this.px.x / r.width) * 2 - 1, -(this.px.y / r.height) * 2 + 1);
      if (this.first) {
        this.prev.copy(this.px);
        this.first = false;
      }
    });
    el.addEventListener('pointerdown', (e) => {
      if (e.button === 0) this.down = true;
      if (e.button === 2) this.rightDown = true;
    });
    window.addEventListener('pointerup', (e) => {
      if (e.button === 0) this.down = false;
      if (e.button === 2) this.rightDown = false;
    });
    el.addEventListener('contextmenu', (e) => e.preventDefault());
    el.addEventListener(
      'wheel',
      (e) => {
        e.preventDefault();
        this.wheel += Math.sign(e.deltaY) * Math.min(1, Math.abs(e.deltaY) / 50);
      },
      { passive: false },
    );
    window.addEventListener('keydown', (e) => {
      if (!this.keys.has(e.code)) this.pressed.add(e.code);
      this.keys.add(e.code);
    });
    window.addEventListener('keyup', (e) => this.keys.delete(e.code));
    window.addEventListener('blur', () => {
      this.keys.clear();
      this.down = false;
      this.rightDown = false;
    });
  }

  held(code: string): boolean {
    return this.keys.has(code);
  }
  justPressed(code: string): boolean {
    return this.pressed.has(code);
  }

  endFrame(): void {
    this.delta.subVectors(this.px, this.prev);
    this.prev.copy(this.px);
    this.wheel = 0;
    this.pressed.clear();
  }
}

export class App {
  readonly renderer: THREE.WebGLRenderer;
  readonly scene = new THREE.Scene();
  readonly camera: THREE.PerspectiveCamera;
  readonly input: Input;
  readonly timer = new THREE.Timer();
  /** Additive screen shake, applied after the camera is positioned. */
  shake = 0;

  private view: View | null = null;
  private tickers: ((dt: number) => void)[] = [];

  constructor(canvas: HTMLCanvasElement) {
    this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
    this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFShadowMap;
    this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
    this.renderer.toneMappingExposure = 1.05;
    this.camera = new THREE.PerspectiveCamera(42, 1, 0.05, 100);
    this.input = new Input(canvas);
    // Deliberately NOT timer.connect(document): it zeroes the delta whenever
    // document.hidden is true, which some embedded/preview browsers report
    // permanently — the sim then freezes with no error. Browsers already pause
    // rAF for hidden tabs, so the toast can't burn while you're away regardless,
    // and the dt clamp below covers the hitch on resume.
    this.scene.fog = new THREE.Fog(0x1b1512, 8, 22);
    window.addEventListener('resize', () => this.resize());
    this.resize();
  }

  onTick(fn: (dt: number) => void): void {
    this.tickers.push(fn);
  }

  setView(v: View | null): void {
    if (this.view === v) return;
    if (this.view) {
      this.view.exit();
      this.view.root.visible = false;
    }
    this.view = v;
    if (v) {
      v.root.visible = true;
      v.enter();
      const s = this.renderer.getSize(new THREE.Vector2());
      v.resize?.(s.x, s.y);
    }
  }

  get currentView(): View | null {
    return this.view;
  }

  resize(): void {
    // Guard the zero case: a collapsed or hidden container reports 0, and 0/0
    // makes the aspect NaN, which poisons the projection matrix for good. Every
    // raycast then silently returns nothing — no error, the game just stops
    // responding to the mouse.
    const w = Math.max(1, window.innerWidth);
    const h = Math.max(1, window.innerHeight);
    this.renderer.setSize(w, h, false);
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
    this.view?.resize?.(w, h);
  }

  /**
   * One simulation + render step. Normally driven by rAF; exposed so tests (and
   * headless browsers that never fire rAF) can advance the game deterministically.
   */
  step(dt: number): void {
    for (const t of this.tickers) t(dt);
    this.view?.update(dt);
    this.renderFrame(dt);
    this.input.endFrame();
  }

  private savedPos = new THREE.Vector3();
  private savedQuat = new THREE.Quaternion();

  private renderFrame(dt: number): void {
    // Shake is an offset applied for the render only — baking it into the
    // camera would let it accumulate and walk the view off the bench.
    if (this.shake > 0.0001) {
      const s = this.shake;
      this.savedPos.copy(this.camera.position);
      this.savedQuat.copy(this.camera.quaternion);
      this.camera.position.x += (Math.random() - 0.5) * s;
      this.camera.position.y += (Math.random() - 0.5) * s;
      this.camera.rotateZ((Math.random() - 0.5) * s * 0.35);
      this.renderer.render(this.scene, this.camera);
      this.camera.position.copy(this.savedPos);
      this.camera.quaternion.copy(this.savedQuat);
      this.shake *= Math.pow(0.0015, dt);
      if (this.shake < 0.0005) this.shake = 0;
    } else {
      this.renderer.render(this.scene, this.camera);
    }
  }

  start(): void {
    const loop = (ts?: number) => {
      requestAnimationFrame(loop);
      this.timer.update(ts);
      // Clamp so a single frame hitch can't teleport the sim forward.
      this.step(Math.min(this.timer.getDelta(), 1 / 20));
    };
    loop();
  }
}

/**
 * Cutlery is the point of half this game, and polished metal with nothing to
 * reflect renders black. An environment map is not optional here.
 */
export function makeEnvironment(app: App): void {
  const pmrem = new THREE.PMREMGenerator(app.renderer);
  app.scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
  app.scene.environmentIntensity = 0.4;
  pmrem.dispose();
}

/** Standard kitchen lighting rig, shared by every view. */
export function makeLights(scene: THREE.Scene): THREE.DirectionalLight {
  const key = new THREE.DirectionalLight(0xfff2dd, 1.5);
  key.position.copy(LIGHT_DIR).multiplyScalar(6);
  key.castShadow = true;
  key.shadow.mapSize.set(2048, 2048);
  key.shadow.camera.near = 0.5;
  key.shadow.camera.far = 18;
  const d = 3.2;
  key.shadow.camera.left = -d;
  key.shadow.camera.right = d;
  key.shadow.camera.top = d;
  key.shadow.camera.bottom = -d;
  key.shadow.bias = -0.0016;
  key.shadow.normalBias = 0.02;
  scene.add(key);
  scene.add(new THREE.HemisphereLight(0xbfc6d2, 0x4a3526, 0.22));
  const fill = new THREE.DirectionalLight(0xbdd6ff, 0.18);
  fill.position.set(-3, 2.2, -2.5);
  scene.add(fill);
  return key;
}
