import { listen } from "rkgk/framework.js";
import { Viewport } from "rkgk/viewport.js";
import { Wall } from "rkgk/wall.js";

class CanvasRenderer extends HTMLElement {
    viewport = new Viewport();

    constructor() {
        super();
    }

    connectedCallback() {
        this.canvas = this.appendChild(document.createElement("canvas"));
        this.gl = this.canvas.getContext("webgl2");

        let resizeObserver = new ResizeObserver(() => this.#updateSize());
        resizeObserver.observe(this);

        this.#cursorReportingBehaviour();
        this.#panningBehaviour();
        this.#zoomingBehaviour();
        this.#interactionBehaviour();

        this.addEventListener("contextmenu", (event) => event.preventDefault());
    }

    initialize(wall, painter) {
        this.wall = wall;
        this.painter = painter;
        this.#initializeRenderer();
        requestAnimationFrame(() => this.#render());
    }

    // Rendering

    #updateSize() {
        this.canvas.width = this.clientWidth;
        this.canvas.height = this.clientHeight;
        // Rerender immediately after the canvas is resized, as its contents have now been invalidated.
        this.#render();
    }

    getWindowSize() {
        return {
            width: this.clientWidth,
            height: this.clientHeight,
        };
    }

    getVisibleRect() {
        return this.viewport.getVisibleRect(this.getWindowSize());
    }

    getVisibleChunkRect() {
        let visibleRect = this.viewport.getVisibleRect(this.getWindowSize());
        let left = Math.floor(visibleRect.x / this.wall.chunkSize);
        let top = Math.floor(visibleRect.y / this.wall.chunkSize);
        let right = Math.ceil((visibleRect.x + visibleRect.width) / this.wall.chunkSize);
        let bottom = Math.ceil((visibleRect.y + visibleRect.height) / this.wall.chunkSize);
        return { left, top, right, bottom };
    }

    // Renderer initialization

    #initializeRenderer() {
        console.groupCollapsed("initializeRenderer");

        console.info("vendor", this.gl.getParameter(this.gl.VENDOR));
        console.info("renderer", this.gl.getParameter(this.gl.RENDERER));

        this.gl.enable(this.gl.BLEND);
        this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);

        // Due to an ANGLE bug on Windows, we can only render around 64 rectangles in a batch.
        //
        // It seems that for DirectX it generates a horribly inefficient shader that the DirectX
        // compiler takes _ages_ to process (~1.5min on my machine for 512 elements.)
        // The compilation time seems to increase exponentially; 256 elements take around 8 seconds,
        // which is still unacceptable, and 128 elements take just over a second.
        //
        // We choose 64 because it causes an extremely short stutter, which I find acceptable.
        // We also realistically don't need anymore, because (at least at the time I'm writing this)
        // we store (8 * 8 = 64) chunks per texture atlas, so we can't batch more than that.
        const maxRects = 64;
        let renderChunksProgramId = this.#compileProgram(
            // Vertex
            `#version 300 es

            precision highp float;

            struct Rect {
                vec4 position;
                vec4 uv;
            };

            layout (std140) uniform ub_rects { Rect u_rects[${maxRects}]; };

            uniform mat4 u_projection;
            uniform mat4 u_view;

            layout (location = 0) in vec2 a_position;
            out vec2 vf_uv;

            void main() {
                Rect rect = u_rects[gl_InstanceID];
                vec2 localPosition = rect.position.xy + a_position * rect.position.zw;
                vec4 screenPosition = floor(u_view * vec4(localPosition, 0.0, 1.0));
                vec4 scenePosition = u_projection * screenPosition;

                vec2 uv = rect.uv.xy + a_position * rect.uv.zw;

                gl_Position = scenePosition;
                vf_uv = uv;
            }
            `,

            // Fragment
            `#version 300 es

            precision highp float;

            uniform sampler2D u_texture;

            in vec2 vf_uv;
            out vec4 f_color;

            void main() {
                f_color = texture(u_texture, vf_uv);
            }
            `,
        );

        this.renderChunksProgram = {
            id: renderChunksProgramId,

            u_projection: this.gl.getUniformLocation(renderChunksProgramId, "u_projection"),
            u_view: this.gl.getUniformLocation(renderChunksProgramId, "u_view"),
            u_texture: this.gl.getUniformLocation(renderChunksProgramId, "u_texture"),
            ub_rects: this.gl.getUniformBlockIndex(renderChunksProgramId, "ub_rects"),
        };

        console.debug("renderChunksProgram", this.renderChunksProgram);
        console.debug(
            "uniform buffer data size",
            this.gl.getActiveUniformBlockParameter(
                this.renderChunksProgram.id,
                this.renderChunksProgram.ub_rects,
                this.gl.UNIFORM_BLOCK_DATA_SIZE,
            ),
        );

        this.vaoRectMesh = this.gl.createVertexArray();
        this.vboRectMesh = this.gl.createBuffer();

        this.gl.bindVertexArray(this.vaoRectMesh);
        this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vboRectMesh);

        let rectMesh = new Float32Array([0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0]);
        this.gl.bufferData(this.gl.ARRAY_BUFFER, rectMesh, this.gl.STATIC_DRAW);

        this.gl.vertexAttribPointer(0, 2, this.gl.FLOAT, false, 2 * 4, 0);
        this.gl.enableVertexAttribArray(0);

        this.uboRectsData = new Float32Array(maxRects * 8);
        this.uboRectsNum = 0;

        this.uboRects = this.gl.createBuffer();
        this.gl.bindBuffer(this.gl.UNIFORM_BUFFER, this.uboRects);
        this.gl.bufferData(this.gl.UNIFORM_BUFFER, this.uboRectsData, this.gl.DYNAMIC_DRAW);

        this.gl.uniformBlockBinding(
            this.renderChunksProgram.id,
            this.renderChunksProgram.ub_rects,
            0,
        );
        this.gl.bindBufferBase(this.gl.UNIFORM_BUFFER, 0, this.uboRects);

        console.debug("initialized buffers", {
            vaoRectMesh: this.vaoRectMesh,
            vboRectMesh: this.vboRectMesh,
            uboRects: this.uboRects,
        });

        this.atlasAllocator = new AtlasAllocator(this.wall.chunkSize, 8);
        this.chunkAllocations = new Map();

        console.debug("initialized atlas allocator", this.atlasAllocator);

        this.chunksThisFrame = new Map();

        console.debug("GL error state", this.gl.getError());

        console.groupEnd();
    }

    #compileShader(kind, source) {
        let shader = this.gl.createShader(kind);

        this.gl.shaderSource(shader, source);
        this.gl.compileShader(shader);

        if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
            let error = new Error(`failed to compile shader: ${this.gl.getShaderInfoLog(shader)}`);
            this.gl.deleteShader(shader);
            throw error;
        } else {
            return shader;
        }
    }

    #compileProgram(vertexSource, fragmentSource) {
        let vertexShader = this.#compileShader(this.gl.VERTEX_SHADER, vertexSource);
        let fragmentShader = this.#compileShader(this.gl.FRAGMENT_SHADER, fragmentSource);

        let program = this.gl.createProgram();
        this.gl.attachShader(program, vertexShader);
        this.gl.attachShader(program, fragmentShader);
        this.gl.linkProgram(program);

        this.gl.deleteShader(vertexShader);
        this.gl.deleteShader(fragmentShader);

        if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
            let error = new Error(`failed to link program: ${this.gl.getProgramInfoLog(program)}`);
            this.gl.deleteProgram(program);
            throw error;
        } else {
            return program;
        }
    }

    // Renderer

    #render() {
        // NOTE: We should probably render on-demand only when it's needed.
        requestAnimationFrame(() => this.#render());
        this.#renderWall();
    }

    #renderWall() {
        if (this.wall == null) {
            console.debug("wall is not available, skipping rendering");
            return;
        }

        this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);

        this.gl.clearColor(1, 1, 1, 1);
        this.gl.clear(this.gl.COLOR_BUFFER_BIT);

        this.gl.useProgram(this.renderChunksProgram.id);

        let translationX = this.canvas.width / 2 - this.viewport.panX * this.viewport.zoom;
        let translationY = this.canvas.height / 2 - this.viewport.panY * this.viewport.zoom;
        let scale = this.viewport.zoom;

        this.gl.uniformMatrix4fv(
            this.renderChunksProgram.u_projection,
            false,
            // prettier-ignore
            [
                2.0 / this.canvas.width, 0.0,                       0.0, 0.0,
                0.0,                     2.0 / -this.canvas.height, 0.0, 0.0,
                0.0,                     0.0,                       1.0, 0.0,
                -1.0,                    1.0,                       0.0, 1.0
            ],
        );
        this.gl.uniformMatrix4fv(
            this.renderChunksProgram.u_view,
            false,
            // prettier-ignore
            [
                scale,        0.0,          0.0, 0.0,
                0.0,          scale,        0.0, 0.0,
                0.0,          0.0,          1.0, 0.0,
                translationX, translationY, 0.0, 1.0
            ],
        );

        this.#collectChunksThisFrame();

        for (let [i, chunks] of this.chunksThisFrame) {
            let atlas = this.atlasAllocator.atlases[i];
            this.gl.bindTexture(this.gl.TEXTURE_2D, atlas.id);

            this.#resetRectBuffer();
            for (let chunk of chunks) {
                let { i, allocation } = this.getChunkAllocation(chunk.x, chunk.y);
                let atlas = this.atlasAllocator.atlases[i];
                this.#pushRect(
                    chunk.x * this.wall.chunkSize,
                    chunk.y * this.wall.chunkSize,
                    this.wall.chunkSize,
                    this.wall.chunkSize,
                    (allocation.x * atlas.chunkSize) / atlas.textureSize,
                    (allocation.y * atlas.chunkSize) / atlas.textureSize,
                    atlas.chunkSize / atlas.textureSize,
                    atlas.chunkSize / atlas.textureSize,
                );
            }
            this.#drawRects();
        }
    }

    getChunkAllocation(chunkX, chunkY) {
        let key = Wall.chunkKey(chunkX, chunkY);
        if (this.chunkAllocations.has(key)) {
            return this.chunkAllocations.get(key);
        } else {
            let allocation = this.atlasAllocator.alloc(this.gl);
            this.chunkAllocations.set(key, allocation);
            return allocation;
        }
    }

    #collectChunksThisFrame() {
        // NOTE: Not optimal that we don't preserve the arrays anyhow; it would be better if we
        // preserved the allocations.
        this.chunksThisFrame.clear();

        let visibleRect = this.viewport.getVisibleRect(this.getWindowSize());
        let left = Math.floor(visibleRect.x / this.wall.chunkSize);
        let top = Math.floor(visibleRect.y / this.wall.chunkSize);
        let right = Math.ceil((visibleRect.x + visibleRect.width) / this.wall.chunkSize);
        let bottom = Math.ceil((visibleRect.y + visibleRect.height) / this.wall.chunkSize);
        for (let chunkY = top; chunkY < bottom; ++chunkY) {
            for (let chunkX = left; chunkX < right; ++chunkX) {
                let chunk = this.wall.getChunk(chunkX, chunkY);
                if (chunk != null) {
                    if (chunk.renderDirty) {
                        this.#updateChunkTexture(chunkX, chunkY);
                        chunk.renderDirty = false;
                    }

                    let allocation = this.getChunkAllocation(chunkX, chunkY);

                    let array = this.chunksThisFrame.get(allocation.i);
                    if (array == null) {
                        array = [];
                        this.chunksThisFrame.set(allocation.i, array);
                    }

                    array.push({ x: chunkX, y: chunkY });
                }
            }
        }
    }

    #resetRectBuffer() {
        this.uboRectsNum = 0;
    }

    #pushRect(x, y, width, height, u, v, uWidth, vHeight) {
        let lengthOfRect = 8;

        let i = this.uboRectsNum * lengthOfRect;
        this.uboRectsData[i + 0] = x;
        this.uboRectsData[i + 1] = y;
        this.uboRectsData[i + 2] = width;
        this.uboRectsData[i + 3] = height;
        this.uboRectsData[i + 4] = u;
        this.uboRectsData[i + 5] = v;
        this.uboRectsData[i + 6] = uWidth;
        this.uboRectsData[i + 7] = vHeight;
        this.uboRectsNum += 1;

        if (this.uboRectsNum == ((this.uboRectsData.length / lengthOfRect) | 0)) {
            this.#drawRects();
            this.#resetRectBuffer();
        }
    }

    #drawRects() {
        let rectBuffer = this.uboRectsData.subarray(0, this.uboRectsNum * 8);
        this.gl.bindBuffer(this.gl.UNIFORM_BUFFER, this.uboRects);
        this.gl.bufferSubData(this.gl.UNIFORM_BUFFER, 0, rectBuffer);

        this.gl.bindVertexArray(this.vaoRectMesh);
        this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vboRectMesh);
        this.gl.drawArraysInstanced(this.gl.TRIANGLES, 0, 6, this.uboRectsNum);
    }

    #updateChunkTexture(chunkX, chunkY) {
        let allocation = this.getChunkAllocation(chunkX, chunkY);
        let chunk = this.wall.getChunk(chunkX, chunkY);
        this.atlasAllocator.upload(this.gl, allocation, chunk.pixmap);
    }

    // Behaviours

    sendViewportUpdate() {
        this.dispatchEvent(new Event(".viewportUpdate"));
    }

    async #panningBehaviour() {
        while (true) {
            let mouseDown = await listen([this, "mousedown"]);

            let startingPanX = this.viewport.panX;
            let startingPanY = this.viewport.panY;

            if (mouseDown.button == 1 || mouseDown.button == 2) {
                mouseDown.preventDefault();
                while (true) {
                    let event = await listen([window, "mousemove"], [window, "mouseup"]);
                    if (event.type == "mousemove") {
                        let deltaX = mouseDown.clientX - event.clientX;
                        let deltaY = mouseDown.clientY - event.clientY;
                        this.viewport.panX = startingPanX + deltaX / this.viewport.zoom;
                        this.viewport.panY = startingPanY + deltaY / this.viewport.zoom;
                        this.sendViewportUpdate();
                    } else if (event.type == "mouseup" && event.button == mouseDown.button) {
                        this.dispatchEvent(new Event(".viewportUpdateEnd"));
                        break;
                    }
                }
            }
        }
    }

    async #zoomingBehaviour() {
        this.addEventListener(
            "wheel",
            (event) => {
                // TODO: Touchpad zoom
                this.viewport.zoomIn(event.deltaY > 0 ? -1 : 1);
                this.sendViewportUpdate();
                this.dispatchEvent(new Event(".viewportUpdateEnd"));
            },
            { bubbling: false },
        );
    }

    async #cursorReportingBehaviour() {
        while (true) {
            let event = await listen([this, "mousemove"]);
            let [x, y] = this.viewport.toViewportSpace(
                event.clientX - this.clientLeft,
                event.offsetY - this.clientTop,
                this.getWindowSize(),
            );
            this.dispatchEvent(Object.assign(new Event(".cursor"), { x, y }));
        }
    }

    async #interactionBehaviour() {
        while (true) {
            let mouseDown = await listen([this, "mousedown"]);
            if (mouseDown.button == 0) {
                let [mouseX, mouseY] = this.viewport.toViewportSpace(
                    mouseDown.clientX - this.clientLeft,
                    mouseDown.clientY - this.clientTop,
                    this.getWindowSize(),
                );
                notifyInteraction(this, "start", { mouseX, mouseY, num: 0 });
            }
        }
    }
}

customElements.define("rkgk-canvas-renderer", CanvasRenderer);

function notifyInteraction(canvasRenderer, kind, fields) {
    canvasRenderer.dispatchEvent(
        Object.assign(new InteractEvent(canvasRenderer), { interactionKind: kind, ...fields }),
    );
}

class InteractEvent extends Event {
    constructor(canvasRenderer) {
        super(".interact");

        this.canvasRenderer = canvasRenderer;
    }

    continueAsDotter() {
        (async () => {
            let event = await listen(
                [this.canvasRenderer, "mousemove"],
                [this.canvasRenderer, "mouseup"],
            );

            if (event.type == "mousemove") {
                let [mouseX, mouseY] = this.canvasRenderer.viewport.toViewportSpace(
                    event.clientX - this.canvasRenderer.clientLeft,
                    event.clientY - this.canvasRenderer.clientTop,
                    this.canvasRenderer.getWindowSize(),
                );

                notifyInteraction(this.canvasRenderer, "dotter", {
                    previousX: this.mouseX,
                    previousY: this.mouseY,
                    mouseX,
                    mouseY,
                    num: this.num + 1,
                });
            }

            if (event.type == "mouseup" && event.button == 0) {
                // Break the loop.
                return;
            }
        })();

        if (this.previousX != null && this.previousY != null) {
            return {
                fromX: this.previousX,
                fromY: this.previousY,
                toX: this.mouseX,
                toY: this.mouseY,
                num: this.num,
            };
        } else {
            return {
                fromX: this.mouseX,
                fromY: this.mouseY,
                toX: this.mouseX,
                toY: this.mouseY,
                num: this.num,
            };
        }
    }
}

class Atlas {
    static getInitBuffer(chunkSize, nChunks) {
        let imageSize = chunkSize * nChunks;
        return new Uint8Array(imageSize * imageSize * 4);
    }

    constructor(gl, chunkSize, nChunks, initBuffer) {
        this.id = gl.createTexture();
        this.chunkSize = chunkSize;
        this.nChunks = nChunks;
        this.textureSize = chunkSize * nChunks;

        this.free = Array(nChunks * nChunks);
        for (let y = 0; y < nChunks; ++y) {
            for (let x = 0; x < nChunks; ++x) {
                this.free[x + y * nChunks] = { x, y };
            }
        }

        gl.bindTexture(gl.TEXTURE_2D, this.id);
        gl.texImage2D(
            gl.TEXTURE_2D,
            0,
            gl.RGBA8,
            this.textureSize,
            this.textureSize,
            0,
            gl.RGBA,
            gl.UNSIGNED_BYTE,
            initBuffer,
        );
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    }

    alloc() {
        return this.free.pop();
    }

    upload(gl, { x, y }, pixmap) {
        gl.bindTexture(gl.TEXTURE_2D, this.id);
        gl.texSubImage2D(
            gl.TEXTURE_2D,
            0,
            x * this.chunkSize,
            y * this.chunkSize,
            this.chunkSize,
            this.chunkSize,
            gl.RGBA,
            gl.UNSIGNED_BYTE,
            pixmap.getArrayBuffer(),
        );
    }
}

class AtlasAllocator {
    atlases = [];

    constructor(chunkSize, nChunks) {
        this.chunkSize = chunkSize;
        this.nChunks = nChunks;
        this.initBuffer = Atlas.getInitBuffer(chunkSize, nChunks);
    }

    alloc(gl) {
        // Right now we do a dumb linear scan through all atlases, but in the future it would be
        // really nice to optimize this by storing information about which atlases have free slots
        // precisely.

        for (let i = 0; i < this.atlases.length; ++i) {
            let atlas = this.atlases[i];
            let allocation = atlas.alloc();
            if (allocation != null) {
                return { i, allocation };
            }
        }

        let i = this.atlases.length;
        let atlas = new Atlas(gl, this.chunkSize, this.nChunks, this.initBuffer);
        let allocation = atlas.alloc();
        this.atlases.push(atlas);
        return { i, allocation };
    }

    upload(gl, { i, allocation }, pixmap) {
        this.atlases[i].upload(gl, allocation, pixmap);
    }
}
