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

import { State } from "../store";

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.PerspectiveCamera;
    private renderer: three.WebGLRenderer;
    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 = new Array(analyser.frequencyBinCount) as Array<three.Mesh>;
        const width = 2 / analyser.frequencyBinCount;
        for (let freq = 0; freq < analyser.frequencyBinCount; freq++) {
            const geometry = new three.BoxGeometry(1, 1, 1);
            const material = new three.MeshLambertMaterial({
                color: new three.Color(0x99d1ce),
            });
            const 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
        const ambientLight = new three.AmbientLight(0xffffff, 0.4);
        this.scene.add(ambientLight);

        const 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, false);

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

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

    scaleBoxes() {
        const 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);
        }

        const camera = this.camera;
        const 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);
    }

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

    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
        const elapsed = time - this.lastTime;
        this.lastTime = time;

        const camera = this.camera;
        const renderer = this.renderer;
        const scene = this.scene;

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

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

    resize = () => {
        const canvas = this.canvas;

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

        // This is stupid, but by setting the canvas proportions to 0
        // for a split second the browser can actually figure out the
        // height of the parentElement.
        //
        // If the canvas is allowed to keep its height, it will
        // prevent the parentElement from being able to shrink because
        // its contents are filling it completely.
        //
        // I've seen others use `overflow: hidden` to achieve this
        // (and in fact seen it render in my browser on jsfiddle), but
        // that doesn't work here somehow.
        canvas.height = 0;
        canvas.width = 0;

        const height = canvas.parentElement.clientHeight;
        const width = canvas.parentElement.clientWidth;

        this.camera.aspect = width / height;
        this.camera.updateProjectionMatrix();
        this.renderer.setSize(width, height, false);
    };
}

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

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

    render(): React.ReactNode {
        return (
            <canvas
                ref={this.canvas}
                style={{
                    display: "block",
                }}
            ></canvas>
        );
    }

    componentDidMount(): void {
        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(): void {
        if (!this.drawer || !this.analyser) {
            return;
        }

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

export default Visualizer;
