import * as THREE from 'three';

/** 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);
    // Pauses the clock while the tab is hidden, so you don't come back to charcoal.
    this.timer.connect(document);
    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 {
    const w = window.innerWidth;
    const h = window.innerHeight;
    this.renderer.setSize(w, h, false);
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
    this.view?.resize?.(w, h);
  }

  start(): void {
    const loop = (ts?: number) => {
      requestAnimationFrame(loop);
      this.timer.update(ts);
      // Clamp so a single frame hitch can't teleport the sim forward.
      const dt = Math.min(this.timer.getDelta(), 1 / 20);
      for (const t of this.tickers) t(dt);
      this.view?.update(dt);
      if (this.shake > 0.0001) {
        const s = this.shake;
        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.shake *= Math.pow(0.0015, dt);
        if (this.shake < 0.0005) this.shake = 0;
      }
      this.renderer.render(this.scene, this.camera);
      this.input.endFrame();
    };
    loop();
  }
}

/** Standard kitchen lighting rig, shared by every view. */
export function makeLights(scene: THREE.Scene): THREE.DirectionalLight {
  const key = new THREE.DirectionalLight(0xfff2dd, 2.6);
  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(0x9fb4d6, 0x3a2a20, 0.75));
  const fill = new THREE.DirectionalLight(0xbdd6ff, 0.45);
  fill.position.set(-3, 2.2, -2.5);
  scene.add(fill);
  return key;
}
