import { mat4 } from "gl-matrix";
import {
    Box3 as ThreeBox3,
    BufferAttribute as ThreeBufferAttribute,
    BufferGeometry as ThreeBufferGeometry,
    MeshBasicMaterial,
    Sphere as ThreeSphere,
    Vector3 as ThreeVector3
} from 'three';
import { BinaryUint32BVH } from "../../../../core/bvh2/binary/2/BinaryUint32BVH.js";
import { BvhClient } from "../../../../core/bvh2/bvh3/BvhClient.js";
import { array_copy } from "../../../../core/collection/array/array_copy.js";
import Signal from "../../../../core/events/signal/Signal.js";
import { AABB3 } from "../../../../core/geom/3d/aabb/AABB3.js";
import { ray3_array_apply_matrix4 } from "../../../../core/geom/3d/ray/ray3_array_apply_matrix4.js";
import { ray3_array_compose } from "../../../../core/geom/3d/ray/ray3_array_compose.js";
import { SurfacePoint3 } from "../../../../core/geom/3d/SurfacePoint3.js";
import Vector2 from '../../../../core/geom/Vector2.js';
import Vector3 from '../../../../core/geom/Vector3.js';
import { NumericInterval } from "../../../../core/math/interval/NumericInterval.js";
import ObservedInteger from "../../../../core/model/ObservedInteger.js";
import { bvh32_geometry_raycast } from "../../../graphics/geometry/buffered/query/bvh32_geometry_raycast.js";

import ThreeFactory from '../../../graphics/three/ThreeFactory.js';


const EMPTY_GEOMETRY = new ThreeBufferGeometry();
const DEFAULT_MATERIAL = new MeshBasicMaterial();

const ray_tmp = [];
const m4_tmp = [];

/**
 * terrain tile is a part of a 2d array
 */
class TerrainTile {
    gridPosition = new Vector2();
    scale = new Vector2(1, 1);
    size = new Vector2(1, 1);
    position = new Vector2();
    resolution = new ObservedInteger(1);

    /**
     *
     * @type {Material}
     */
    material = null;
    mesh = ThreeFactory.createMesh(EMPTY_GEOMETRY, DEFAULT_MATERIAL);


    /**
     *
     * @type {THREE.BufferGeometry}
     */
    geometry = null;

    /**
     *
     * @type {boolean}
     */
    enableBVH = true;

    /**
     *
     * @type {BvhClient}
     */
    external_bvh = new BvhClient();

    /**
     *
     * @type {BinaryUint32BVH}
     */
    bvh = null;

    /**
     *
     * @type {boolean}
     */
    isBuilt = false;
    /**
     *
     * @type {boolean}
     */
    isBuildInProgress = false;
    referenceCount = 0;

    /**
     *
     * @type {Signal<TerrainTile>}
     */
    onBuilt = new Signal();
    onDestroyed = new Signal();

    /**
     * Encodes whether stitching has been performed on per-neighbour basis
     * @private
     * @type {{bottomLeft: boolean, top: boolean, left: boolean, bottom: boolean, bottomRight: boolean, topLeft: boolean, topRight: boolean, right: boolean}}
     */
    stitching = {
        top: false,
        bottom: false,
        left: false,
        right: false,

        topLeft: false,
        topRight: false,

        bottomLeft: false,
        bottomRight: false
    };

    /**
     * Initial estimate of height bounds for this tile
     * Untransformed by transform matrix
     * @type {NumericInterval}
     * @private
     */
    __initial_height_range = new NumericInterval(Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY);

    /**
     * Used to track number of times tile was built
     * @type {number}
     */
    version = 0;

    constructor() {

        this.mesh.name = "TerrainTile";

        /**
         * Terrain mesh is static, it never changes its transform. Updates are wasteful.
         * @type {boolean}
         */
        this.mesh.matrixWorldNeedsUpdate = false;
    }

    /**
     *
     * @return {number[]}
     */
    get transform() {
        return this.mesh.matrixWorld.elements;
    }

    /**
     *
     * @param {number[]|Float32Array|mat4} m4
     */
    set transform(m4) {
        array_copy(m4, 0, this.mesh.matrixWorld.elements, 0, 16);
        this.computeBoundingBox();
    }

    /**
     *
     * @param {SurfacePoint3} result
     * @param {number} originX
     * @param {number} originY
     * @param {number} originZ
     * @param {number} directionX
     * @param {number} directionY
     * @param {number} directionZ
     * @return {boolean}
     */
    raycastFirstSync(
        result,
        originX, originY, originZ,
        directionX, directionY, directionZ
    ) {

        const m4 = this.transform;

        mat4.invert(m4_tmp, m4);

        ray3_array_compose(
            ray_tmp,
            originX, originY, originZ,
            directionX, directionY, directionZ
        );

        ray3_array_apply_matrix4(ray_tmp, 0, ray_tmp, 0, m4_tmp);

        const _originX = ray_tmp[0];
        const _originY = ray_tmp[1];
        const _originZ = ray_tmp[2];

        const _directionX = ray_tmp[3];
        const _directionY = ray_tmp[4];
        const _directionZ = ray_tmp[5];

        const geometry = this.geometry;

        const geometryIndices = geometry.getIndex().array;
        const attribute_position = geometry.getAttribute('position');
        const position_array = attribute_position.array;

        let hit_found = bvh32_geometry_raycast(
            result, this.bvh, position_array, 0, 3,
            attribute_position.normalized,
            geometryIndices,
            _originX, _originY, _originZ,
            _directionX, _directionY, _directionZ,
            Infinity,
        );

        if (hit_found) {
            result.applyMatrix4(m4);

            // make sure to pull normal to magnitude of 1
            result.normal.normalize();
        }

        return hit_found;
    }

    getVertexNormal(index, result) {
        const normals = this.geometry.attributes.normal.array;
        const index3 = index * 3;

        result.set(normals[index3], normals[index3 + 1], normals[index3 + 2]);
    }

    setVertexNormal(index, value) {
        const normals = this.geometry.attributes.normal.array;
        const index3 = index * 3;

        normals[index3] = value.x;
        normals[index3 + 1] = value.y;
        normals[index3 + 2] = value.z;
    }

    /**
     * Stitch vertex normals along the edges of the tile set
     * @param {TerrainTile|undefined} top
     * @param {TerrainTile|undefined} bottom
     * @param {TerrainTile|undefined} left
     * @param {TerrainTile|undefined} right
     * @param {TerrainTile|undefined} topLeft
     * @param {TerrainTile|undefined} topRight
     * @param {TerrainTile|undefined} bottomLeft
     * @param {TerrainTile|undefined} bottomRight
     */
    stitchNormals(top, bottom, left, right, topLeft, topRight, bottomLeft, bottomRight) {


        const v0 = new Vector3(),
            v1 = new Vector3(),
            v2 = new Vector3(),
            v3 = new Vector3();

        const tile = this;

        function tileIsBuilt(tile) {
            return tile !== undefined && tile.isBuilt;
        }

        function updateNormals(tile) {
            tile.geometry.attributes.normal.needsUpdate = true;
        }

        /**
         *
         * @param {TerrainTile} top
         * @param {TerrainTile} bottom
         */
        function stitchVertical(top, bottom) {
            //can only stitch if both sides are built
            if (tileIsBuilt(top) && tileIsBuilt(bottom)) {
                let i;

                const thisResolution = bottom.resolution.getValue();

                const otherResolution = top.resolution.getValue();
                const otherOffset = top.size.x * otherResolution * (top.size.y * otherResolution - 1);

                const stitchCount = bottom.size.x * thisResolution - 1;

                //see if we can copy one from the other
                if (top.stitching.bottom) {
                    for (i = 1; i < stitchCount; i++) {
                        top.getVertexNormal(otherOffset + i, v0);
                        bottom.setVertexNormal(i, v0);
                    }
                    updateNormals(bottom);
                    bottom.stitching.top = true;
                } else if (bottom.stitching.top) {
                    for (i = 1; i < stitchCount; i++) {
                        bottom.getVertexNormal(i, v0);
                        top.setVertexNormal(otherOffset + i, v0);
                    }
                    updateNormals(top);
                    top.stitching.bottom = true;
                } else {
                    //neither is stitched
                    for (i = 1; i < stitchCount; i++) {
                        bottom.getVertexNormal(i, v0);
                        top.getVertexNormal(otherOffset + i, v1);

                        v0.add(v1);
                        v0.normalize();

                        bottom.setVertexNormal(i, v0);
                        top.setVertexNormal(otherOffset + i, v0);
                    }
                    updateNormals(top);
                    updateNormals(bottom);
                    top.stitching.bottom = true;
                    bottom.stitching.top = true;
                }

            }
        }

        /**
         *
         * @param {TerrainTile} left
         * @param {TerrainTile} right
         */
        function stitchHorizontal(left, right) {
            if (tileIsBuilt(left) && tileIsBuilt(right)) {
                let i;
                const thisResolution = right.resolution.getValue();

                const otherResolution = left.resolution.getValue();

                const stitchCount = right.size.y * thisResolution - 1;

                const otherOffset = left.size.x * otherResolution - 1;
                const otherMultiplier = left.size.x * otherResolution;
                const thisMultiplier = right.size.x * thisResolution;

                let index0, index1;
                if (left.stitching.right) {

                    for (i = 0; i < stitchCount; i++) {
                        index0 = i * thisMultiplier;
                        index1 = otherOffset + i * otherMultiplier;

                        left.getVertexNormal(index1, v0);
                        right.setVertexNormal(index0, v0);
                    }
                    updateNormals(right);
                    right.stitching.left = true;
                } else if (right.stitching.left) {
                    for (i = 0; i < stitchCount; i++) {
                        index0 = i * thisMultiplier;
                        index1 = otherOffset + i * otherMultiplier;

                        right.getVertexNormal(index0, v0);
                        left.setVertexNormal(index1, v0);
                    }
                    updateNormals(left);
                    left.stitching.right = true;
                } else {
                    //neither is stitched
                    for (i = 0; i < stitchCount; i++) {
                        index0 = i * thisMultiplier;
                        index1 = otherOffset + i * otherMultiplier;

                        right.getVertexNormal(index0, v0);
                        left.getVertexNormal(index1, v1);

                        v0.add(v1);
                        v0.normalize();

                        right.setVertexNormal(index0, v0);
                        left.setVertexNormal(index1, v0);
                    }
                    updateNormals(left);
                    updateNormals(right);
                    left.stitching.right = true;
                    right.stitching.left = true;
                }
            }
        }

        /**
         *
         * @param {TerrainTile} topLeft
         * @param {TerrainTile} topRight
         * @param {TerrainTile} bottomLeft
         * @param {TerrainTile} bottomRight
         */
        function stitchOneCorner(topLeft, topRight, bottomLeft, bottomRight) {
            function topLeftCornerIndex() {
                return (topLeft.size.x * topLeft.resolution.getValue()) * (topLeft.size.y * topLeft.resolution.getValue()) - 1;
            }

            function topRightCornerIndex() {
                return topRight.size.x * topRight.resolution.getValue() * (topRight.size.y * topRight.resolution.getValue() - 1);
            }

            function bottomLeftCornerIndex() {
                return bottomLeft.size.x * bottomLeft.resolution.getValue() - 1;
            }


            if (tileIsBuilt(topLeft) && tileIsBuilt(topRight) && tileIsBuilt(bottomLeft) && tileIsBuilt(bottomRight)) {

                const tlCornerIndex = topLeftCornerIndex();
                const cornerIndex = 0;
                const tCornerIndex = topRightCornerIndex();
                const lCornerIndex = bottomLeftCornerIndex();

                topLeft.getVertexNormal(tlCornerIndex, v0);
                bottomRight.getVertexNormal(cornerIndex, v1);
                topRight.getVertexNormal(tCornerIndex, v2);
                bottomLeft.getVertexNormal(lCornerIndex, v3);

                v0.add(v1);
                v0.add(v2);
                v0.add(v3);
                v0.normalize();

                topLeft.setVertexNormal(tlCornerIndex, v0);
                bottomRight.setVertexNormal(cornerIndex, v0);
                topRight.setVertexNormal(tCornerIndex, v0);
                bottomLeft.setVertexNormal(lCornerIndex, v0);

                updateNormals(topLeft);
                updateNormals(topRight);
                updateNormals(bottomLeft);
                updateNormals(bottomRight);
                topLeft.stitching.bottomRight = true;
                topRight.stitching.bottomLeft = true;
                bottomLeft.stitching.topRight = true;
                bottomRight.stitching.topLeft = true;
            }
        }

        function stitchCorners() {
            //top-left
            stitchOneCorner(topLeft, top, left, tile);
            //top-right
            stitchOneCorner(top, topRight, tile, right);
            //bottom-left
            stitchOneCorner(left, tile, bottomLeft, bottom);
            //bottom-right
            stitchOneCorner(tile, right, bottom, bottomRight);
        }

        function stitchSides() {
            //top
            stitchVertical(top, tile);
            //bottom
            stitchVertical(tile, bottom);
            //left
            stitchHorizontal(left, tile);
            //right
            stitchHorizontal(tile, right);
        }

        stitchCorners();
        stitchSides();
    }

    computeBoundingBox() {
        /**
         * @type {ThreeBox3}
         */
        let bb;

        const geometry = this.geometry;

        if (geometry === null) {
            // no geometry present yet

            const position = this.position;
            const scale = this.scale;
            const size = this.size;

            const initial_height_range = this.__initial_height_range;

            const min = new ThreeVector3(position.x * scale.x, initial_height_range.min, position.y * scale.y);
            const max = new ThreeVector3(min.x + size.x * scale.x, initial_height_range.max, min.z + size.y * scale.y);

            bb = new ThreeBox3(
                min,
                max
            );

        } else {

            //check for bvh
            const bvh = this.bvh;

            if (bvh !== null) {
                const float32 = bvh.float32;

                const x0 = float32[0];
                const y0 = float32[1];
                const z0 = float32[2];

                const x1 = float32[3];
                const y1 = float32[4];
                const z1 = float32[5];

                geometry.boundingBox = new ThreeBox3(new ThreeVector3(x0, y0, z0), new ThreeVector3(x1, y1, z1));

                const dX = x1 - x0;
                const dY = y1 - y0;
                const dZ = z1 - z0;

                const radius = Math.sqrt(dX * dX + dY * dY + dZ * dZ) / 2;

                const center = new ThreeVector3(x0 + dX / 2, y0 + dY / 2, z0 + dZ / 2);

                geometry.boundingSphere = new ThreeSphere(center, radius);
            }

            //pull bounding box from geometry

            bb = geometry.boundingBox;
            if (bb === null) {
                geometry.computeBoundingBox();
                bb = geometry.boundingBox;
            }

        }

        const x0 = bb.min.x;
        const y0 = bb.min.y;
        const z0 = bb.min.z;

        const x1 = bb.max.x;
        const y1 = bb.max.y;
        const z1 = bb.max.z;

        const geometry_bb = new AABB3(
            x0, y0, z0,
            x1, y1, z1
        );

        geometry_bb.applyMatrix4(this.transform);

        this.external_bvh.resize(
            geometry_bb.x0, geometry_bb.y0, geometry_bb.z0,
            geometry_bb.x1, geometry_bb.y1, geometry_bb.z1
        );
    }

    /**
     *
     * @param {number} min_height
     * @param {number} max_height
     */
    setInitialHeightBounds(min_height, max_height) {
        this.__initial_height_range.set(min_height, max_height);
    }

    dispose() {
        if (!this.isBuilt) {
            return;
        }

        if (this.geometry !== null) {
            this.geometry.dispose();
            this.geometry = null;
        }

        this.isBuilt = false;
        this.onDestroyed.send1(this);
    }

    /**
     *
     * @param {{geometry, bvh?:{leaf_count:number, data:ArrayBuffer}}} tileData
     * @returns {Mesh}
     */
    build(tileData) {
        this.isBuilt = true;
        // console.groupCollapsed('Building tile');
        // console.time('total');
        const tileDataGeometry = tileData.geometry;

        const g = new ThreeBufferGeometry();

        g.setIndex(new ThreeBufferAttribute(tileDataGeometry.indices, 1));

        g.setAttribute('position', new ThreeBufferAttribute(tileDataGeometry.vertices, 3));
        g.setAttribute('normal', new ThreeBufferAttribute(tileDataGeometry.normals, 3));
        g.setAttribute('uv', new ThreeBufferAttribute(tileDataGeometry.uvs, 2));
        //second UV set is needed for lightmap, this is already present in TerrainShader
        // g.addAttribute('uv2', new THREE.BufferAttribute(tileDataGeometry.uvs, 2));

        this.geometry = g;

        if (this.enableBVH) {
            // console.time('bvh');
            // this.generateBufferedGeometryBVH();
            const bvh = this.bvh = new BinaryUint32BVH();
            const serialized_bvh = tileData.bvh;

            bvh.setLeafCount(serialized_bvh.leaf_count);
            bvh.data = serialized_bvh.data;

            // console.timeEnd('bvh');
        }


        const mesh = this.mesh;

        mesh.geometry = g;

        mesh.receiveShadow = true;
        mesh.castShadow = true;

        //set bounding box
        // console.time('bb');
        this.computeBoundingBox();
        // console.timeEnd('bb');

        // console.timeEnd('total');
        // console.groupEnd();

        this.version++;

        return mesh;
    }
}

export default TerrainTile;
