import { Shader } from "./Shader";
import { mat4 } from "gl-matrix";

import { Cube } from "./cube";
import vertexSource from "./shaders/vertices.glsl";
import fragmentSource from "./shaders/fragments.glsl";

const ROTATION_SPEED = 0.0;
const BACKGROUND_COLOR = [0.0588235294118, 0.0588235294118, 0.0588235294118];

class RendererError extends Error {}

class Renderer {
    private canvas: HTMLCanvasElement;
    private overlay: HTMLSpanElement;

    private analyser: AnalyserNode;
    private analyserData: Uint8Array;

    private lastFrameTime: number;
    private dTime: number;
    private nextAnimationFrame?: number;

    private rotation: number;

    private buffers: {
        indices?: WebGLBuffer;
        positions?: WebGLBuffer;
        normals?: WebGLBuffer;
        fft?: WebGLBuffer;
        velocitiesRead?: WebGLBuffer;
        velocitiesWrite?: WebGLBuffer;
    };

    constructor(
        analyser: AnalyserNode,
        canvas: HTMLCanvasElement,
        overlay: HTMLSpanElement
    ) {
        this.canvas = canvas;
        this.overlay = overlay;
        this.analyser = analyser;
        this.analyserData = new Uint8Array(analyser.frequencyBinCount);

        this.lastFrameTime = 0;
        this.dTime = 0;
        this.rotation = 0;
        this.buffers = {};
    }

    resizeAndDraw(
        gl: WebGL2RenderingContext,
        shader: Shader,
        observerData: ResizeObserverEntry | null
    ) {
        if (this.canvas.parentElement === null) {
            throw new Error("renderer has been removed from dom");
        }

        if (this.nextAnimationFrame) {
            cancelAnimationFrame(this.nextAnimationFrame);
        }

        // Note: For this to work, it's *incredibly important* for the
        // canvas to be overflowable by its parent, and its parent to
        // have `overflow: hidden` set. If using a flexbox, this means
        // that the canvas has to be `position: absolute`.
        let width: number;
        let height: number;

        if (observerData !== null && observerData.devicePixelContentBoxSize) {
            width = observerData.devicePixelContentBoxSize[0].inlineSize;
            height = observerData.devicePixelContentBoxSize[0].blockSize;
        } else {
            // Fallback; the above API is even newer than
            // ResizeObserver, and by setting the observerData to null
            // we can manually resize at least once without going
            // through the API.
            if (this.canvas.parentElement === null) {
                throw new Error("canvas parent disappeared");
            }
            // Note: This *requires* `box-sizing: border-box`
            ({ width, height } =
                this.canvas.parentElement.getBoundingClientRect());
        }

        // Preserve the aspect ratio
        const aspect_ratio = (this.analyser.frequencyBinCount * 2) / 512;
        const target_height = width / aspect_ratio;
        if (target_height > height) {
            this.canvas.width = aspect_ratio * height;
            this.canvas.height = height;
        } else {
            this.canvas.width = width;
            this.canvas.height = width / aspect_ratio;
        }

        gl.viewport(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
        this.updateProjection(gl, shader);

        // ResizeObserver will call when we should draw, so do our own
        // time calculation and draw the scene.
        this.updateTime(performance.now());
        this.drawScene(gl, shader);
    }

    updateTime(time: number) {
        this.dTime = time - this.lastFrameTime;
        this.lastFrameTime = time;
    }

    initializeScene() {
        if (this.canvas.parentElement === null) {
            throw new Error("canvas was not added to page");
        }

        const gl = this.canvas.getContext("webgl2");
        if (gl === null) {
            throw new RendererError("WebGL (2) is unsupported on this browser");
        }

        const shader = Shader.builder(gl)
            .addShader(vertexSource, gl.VERTEX_SHADER)
            .addShader(fragmentSource, gl.FRAGMENT_SHADER)
            .addAttribute("aVertexPosition")
            .addAttribute("aVertexNormal")
            .addAttribute("aHeight")
            .addUniforms("uProjectionMatrix")
            .addUniforms("uModelViewMatrix")
            .addUniforms("uNormalMatrix")
            .build();

        this.initGL(gl, shader);
        this.updateProjection(gl, shader);
        this.initBuffers(gl);

        try {
            const observer = new ResizeObserver((elements) => {
                // We only observe one element
                const element = elements[0];
                this.resizeAndDraw(gl, shader, element);
            });
            observer.observe(this.canvas.parentElement);
        } catch (error) {
            // If the browser does not support ResizeObserver, we
            // simply don't resize. Resizing is hard enough, just use
            // a modern browser.
            if (error instanceof ReferenceError) {
                console.warn(
                    "Browser does not support `ResizeObserver`. Canvas resizing will be disabled."
                );
            } else throw error;
        }
        this.resizeAndDraw(gl, shader, null);
    }

    updateProjection(gl: WebGLRenderingContext, shader: Shader) {
        const projectionMatrix = mat4.create();
        mat4.perspective(
            projectionMatrix,
            (45 * Math.PI) / 180,
            gl.canvas.clientWidth / gl.canvas.clientHeight,
            0.1,
            100.0
        );
        gl.uniformMatrix4fv(
            shader.getUniform("uProjectionMatrix"),
            false,
            projectionMatrix
        );
    }

    initBuffers(gl: WebGLRenderingContext) {
        // Scale down the unit cube before we use it
        Cube.vertices = Cube.vertices.map(
            (num: number) => num / this.analyser.frequencyBinCount
        );

        // Position buffer
        const positionBuffer = gl.createBuffer();

        if (positionBuffer === null) {
            throw new Error("could not initialize position buffer");
        }

        gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, Cube.vertices, gl.STATIC_DRAW);
        this.buffers.positions = positionBuffer;

        // Index buffer
        const indexBuffer = gl.createBuffer();

        if (indexBuffer === null) {
            throw new Error("could not initialize index buffer");
        }

        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, Cube.indices, gl.STATIC_DRAW);
        this.buffers.indices = indexBuffer;

        // Surface normal buffer
        const normalBuffer = gl.createBuffer();

        if (normalBuffer === null) {
            throw new Error("could not initialize normal buffer");
        }

        gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, Cube.normals, gl.STATIC_DRAW);
        this.buffers.normals = normalBuffer;

        // fft data buffer
        const fftBuffer = gl.createBuffer();

        if (fftBuffer === null) {
            throw new Error("could not initialize fft buffer");
        }

        // No need to initialize this buffer here since we will be
        // updating it as soon as we start rendering anyway.
        this.buffers.fft = fftBuffer;
    }

    initGL(gl: WebGLRenderingContext, shader: Shader) {
        gl.useProgram(shader.program);
        gl.clearColor(
            BACKGROUND_COLOR[0],
            BACKGROUND_COLOR[1],
            BACKGROUND_COLOR[2],
            1.0
        );
        gl.clearDepth(1.0);
        gl.enable(gl.DEPTH_TEST);
        gl.depthFunc(gl.LESS);
    }

    updateMatrices(gl: WebGLRenderingContext, shader: Shader) {
        this.rotation += (this.dTime / 1000.0) * ROTATION_SPEED;

        const modelViewMatrix = mat4.create();
        mat4.translate(modelViewMatrix, modelViewMatrix, [0.0, 0.0, -0.8]);
        mat4.rotateX(modelViewMatrix, modelViewMatrix, Math.PI / 32);
        mat4.rotateY(modelViewMatrix, modelViewMatrix, this.rotation);
        mat4.translate(modelViewMatrix, modelViewMatrix, [-1.0, 0.0, 0.0]);
        gl.uniformMatrix4fv(
            shader.getUniform("uModelViewMatrix"),
            false,
            modelViewMatrix
        );

        const normalMatrix = mat4.create();
        mat4.invert(normalMatrix, modelViewMatrix);
        mat4.transpose(normalMatrix, normalMatrix);
        gl.uniformMatrix4fv(
            shader.getUniform("uNormalMatrix"),
            false,
            normalMatrix
        );
    }

    updateBuffers(gl: WebGL2RenderingContext, shader: Shader) {
        if (
            this.buffers.indices === undefined ||
            this.buffers.positions === undefined ||
            this.buffers.normals === undefined ||
            this.buffers.fft === undefined
        ) {
            throw new Error("failed to create buffers before rendering");
        }

        // Update cube buffers
        gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.positions);
        gl.vertexAttribPointer(
            shader.getAttribute("aVertexPosition"),
            3,
            gl.FLOAT,
            false,
            0,
            0
        );
        gl.enableVertexAttribArray(shader.getAttribute("aVertexPosition"));

        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.buffers.indices);

        gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.normals);
        gl.vertexAttribPointer(
            shader.getAttribute("aVertexNormal"),
            3,
            gl.FLOAT,
            false,
            0,
            0
        );
        gl.enableVertexAttribArray(shader.getAttribute("aVertexNormal"));

        // Update fft
        this.analyser.getByteFrequencyData(this.analyserData);

        gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.fft);
        gl.bufferData(gl.ARRAY_BUFFER, this.analyserData, gl.STREAM_DRAW);
        gl.vertexAttribPointer(
            shader.getAttribute("aHeight"),
            1,
            gl.UNSIGNED_BYTE,
            false,
            0,
            0
        );
        gl.vertexAttribDivisor(shader.getAttribute("aHeight"), 1);
        gl.enableVertexAttribArray(shader.getAttribute("aHeight"));
    }

    drawScene(gl: WebGL2RenderingContext, shader: Shader) {
        this.updateMatrices(gl, shader);
        this.updateBuffers(gl, shader);

        let cpuTime = 0;
        if (process.env.NODE_ENV === "development") {
            cpuTime = Math.round(performance.now() - this.lastFrameTime);
        }

        gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
        gl.drawElementsInstanced(
            gl.TRIANGLES,
            36,
            gl.UNSIGNED_SHORT,
            0,
            this.analyser.frequencyBinCount
        );

        if (process.env.NODE_ENV === "development") {
            const gpuTime = Math.round(performance.now() - this.lastFrameTime);
            const dTime = Math.round(this.dTime);

            this.overlay.innerText = `${dTime}ms (${cpuTime}ms / ${gpuTime}ms)`;
        }

        this.nextAnimationFrame = requestAnimationFrame((time) => {
            this.updateTime(time);

            this.drawScene(gl, shader);
        });
    }
}

export { Renderer, RendererError };
