import React from "react";
import * as three from "three";

type VisualizerProps = {
  audioContext: AudioContext;
  audioSource: AudioNode;
};

class CanvasDrawer {
  private analyser: AnalyserNode;
  private canvas: HTMLCanvasElement;

  private analyserData: Float32Array;

  private boxes: Array<three.Mesh>;
  private camera: three.Camera;
  private renderer: three.Renderer;
  private scene: three.Scene;

  private angle: number;

  private animationFrame: number;
  private lastTime: number;

  constructor(analyser: AnalyserNode, canvas: HTMLCanvasElement) {
    this.analyser = analyser;
    this.canvas = canvas;

    // Set up analyser data storage
    this.analyserData = new Float32Array(analyser.frequencyBinCount);

    // Initialize the scene
    this.scene = new three.Scene();

    // Make a bunch of boxes to represent the bars
    this.boxes = Array(analyser.frequencyBinCount);
    let width = 2 / analyser.frequencyBinCount;
    for (let freq = 0; freq < analyser.frequencyBinCount; freq++) {
      let geometry = new three.BoxGeometry(1, 1, 1);
      let material = new three.MeshLambertMaterial({
        color: new three.Color(0x99d1ce),
      });
      let cube = new three.Mesh(geometry, material);

      cube.scale.set(width, 1e-6, width);
      cube.position.set(-1 + freq * width, 0, 0);

      this.scene.add(cube);
      this.boxes[freq] = cube;
    }

    // Add lights for shadowing
    let ambientLight = new three.AmbientLight(0xffffff, 0.4);
    this.scene.add(ambientLight);

    let directionalLight = new three.DirectionalLight(0xffffff, 1);
    directionalLight.position.set(-1, 0.3, -1);
    directionalLight.castShadow = true;
    this.scene.add(directionalLight);

    // Add a camera
    this.angle = 3;
    this.camera = new three.PerspectiveCamera(
      70,
      canvas.width / canvas.height,
      0.01,
      10
    );
    this.camera.lookAt(0, 0, 0);
    this.scene.add(this.camera);
    this.rotateCamera(1);

    // Add a renderer
    this.renderer = new three.WebGLRenderer({
      antialias: true,
      canvas: canvas,
      powerPreference: "low-power",
    });

    this.renderer.setClearColor(new three.Color(0x0f0f0f));
    this.renderer.setSize(canvas.width, canvas.height);

    // Set up canvas resizing
    window.addEventListener("resize", this.resize.bind(this));

    // Run the first, set the first animation frame time and start requesting
    // animation frames
    this.resize();
    this.lastTime = 0;
    this.animationFrame = requestAnimationFrame(this.render.bind(this));
  }

  render(time: number) {
    // Set our animation frame to 0, so that if we stop, we don't try to cancel a past animation frame
    this.animationFrame = 0;
    // Update elapsed time
    let elapsed = time - this.lastTime;
    this.lastTime = time;

    let camera = this.camera;
    let renderer = this.renderer;
    let scene = this.scene;

    this.scaleBoxes();
    this.rotateCamera(elapsed);

    renderer.render(scene, camera);
    this.animationFrame = requestAnimationFrame(this.render.bind(this));
  }

  scaleBoxes() {
    let analyser = this.analyser;

    analyser.getFloatFrequencyData(this.analyserData);

    for (let freq = 0; freq < analyser.frequencyBinCount; freq++) {
      let height = analyser.maxDecibels / this.analyserData[freq];

      if (height > 0.3) {
        height -= 0.3;
      } else {
        height = 1e-6;
      }

      this.boxes[freq].scale.y = height;
    }
  }

  rotateCamera(elapsed: number) {
    if (this.angle >= Math.PI * 2) {
      this.angle = 0;
    } else {
      this.angle += 0.1 * (elapsed / 1000);
    }

    let camera = this.camera;
    let angle = this.angle;

    camera.position.x = 1.01 * Math.sin(angle);
    camera.position.z = 1.01 * Math.cos(angle);

    /* camera.position.y = (1 - Math.abs(angle - 0.5) / 0.5); */
    camera.lookAt(0, 0, 0);
  }

  resize() {
    let canvas = this.canvas;
    if (canvas.parentElement === null) {
      throw Error("Could not access canvas parent for size calculation");
    }

    // Compute the height of all our siblings
    let combinedHeight = 0;
    for (let i = 0; i < canvas.parentElement.children.length; i++) {
      const child = canvas.parentElement.children[i];

      if (child != canvas) {
        combinedHeight += child.clientHeight;
      }
    }

    // The remaining space we want to fill
    let remainingHeight = canvas.parentElement.clientHeight - combinedHeight;
    canvas.height = remainingHeight;
    canvas.width = canvas.parentElement.clientWidth;

    this.camera.aspect = canvas.width / remainingHeight;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(canvas.width, remainingHeight);
  }

  stop() {
    if (this.animationFrame != 0) {
      cancelAnimationFrame(this.animationFrame);
    }
  }
}

class Visualizer extends React.Component<VisualizerProps, {}> {
  private analyser: AnalyserNode;
  private canvas: React.RefObject<HTMLCanvasElement>;
  private drawer: CanvasDrawer;

  constructor(props: VisualizerProps) {
    super(props);
    this.canvas = React.createRef();
  }

  render() {
    return (
      <canvas
        id="visualizer"
        ref={this.canvas}
        style={{ width: "100%", height: "100%" }}
      ></canvas>
    );
  }

  componentDidMount() {
    if (this.canvas.current === null) {
      throw Error("Failed to create canvas; aborting");
    }

    this.analyser = this.props.audioContext.createAnalyser();
    this.analyser.fftSize = 2048;
    this.analyser.smoothingTimeConstant = 0.8;
    this.props.audioSource.connect(this.analyser);

    this.drawer = new CanvasDrawer(this.analyser, this.canvas.current);
  }

  componentWillUnmount() {
    this.drawer.stop();
    this.props.audioSource.disconnect(this.analyser);
  }
}

export default Visualizer;