import { mat4, vec3 } from "gl-matrix";


import { MeshPhongMaterial } from 'three';
import { assert } from "../../../../core/assert.js";
import { BVH } from "../../../../core/bvh2/bvh3/BVH.js";
import { bvh_query_leaves_ray } from "../../../../core/bvh2/bvh3/query/bvh_query_leaves_ray.js";
import { isArrayEqualStrict } from "../../../../core/collection/array/isArrayEqualStrict.js";
import { Color } from "../../../../core/color/Color.js";
import Signal from '../../../../core/events/signal/Signal.js';
import { aabb2_overlap_exists } from "../../../../core/geom/2d/aabb/aabb2_overlap_exists.js";
import { SurfacePoint3 } from "../../../../core/geom/3d/SurfacePoint3.js";
import Vector2 from '../../../../core/geom/Vector2.js';
import { NumericInterval } from "../../../../core/math/interval/NumericInterval.js";
import { randomFloatBetween } from "../../../../core/math/random/randomFloatBetween.js";
import ObservedInteger from "../../../../core/model/ObservedInteger.js";
import ObservedValue from '../../../../core/model/ObservedValue.js';

import CheckersTexture from '../../../graphics/texture/CheckersTexture.js';
import TerrainTile from './TerrainTile.js';

/**
 *
 * @type {number[]}
 */
const scratch_array = [];
const scratch_contact = new SurfacePoint3();

class TerrainTileManager {
    /**
     *
     * @type {TerrainTile[]}
     */
    tiles = [];

    on = {
        tileBuilt: new Signal(),
        tileDestroyed: new Signal()
    };

    /**
     *
     * @type {Vector2}
     */
    tileSize = new Vector2(10, 10);

    /**
     *
     * @type {Vector2}
     */
    totalSize = new Vector2(1, 1);
    /**
     * Number of subdivisions per single grid cell
     * @type {ObservedInteger}
     */
    resolution = new ObservedInteger(4);

    /**
     * 2D Scale of the terrain
     * @type {Vector2}
     */
    scale = new Vector2(1, 1);


    /**
     *
     * @type {Float32Array}
     * @private
     */
    __transform = new Float32Array(16);

    /**
     * @readonly
     * @type {BVH}
     */
    bvh = new BVH();

    /**
     *
     * @type {NumericInterval}
     */
    heightRange = new NumericInterval(0, 0);

    /**
     * Debug parameter, makes all tiles have random colored material for easy visual distinction
     * @type {boolean}
     */
    debugTileMaterialRandom = false;

    /**
     *
     * @param {Vector2} [tileSize]
     * @param {Material} [material]
     * @param {Material} [depthMaterial] applied to each tile mesh as
     *   `customDepthMaterial` so the shadow caster pass picks it up.
     *   Omit (null) to let three.js fall back to its default depth material.
     * @param {WorkerProxy} buildWorker
     */
    constructor(
        {
            material,
            depthMaterial = null,
            buildWorker
        }
    ) {


        if (material === undefined) {
            const defaultMaterialTexture = CheckersTexture.create(this.totalSize.clone()._sub(1, 1).multiplyScalar(0.5));
            material = new MeshPhongMaterial({ map: defaultMaterialTexture });
        }

        this.material = new ObservedValue(material);

        /**
         * Optional custom depth material for the shadow caster pass. When
         * non-null it is assigned to each tile mesh's `customDepthMaterial`
         * field. See {@link TerrainTileManager#assignTileMaterial}.
         *
         * @type {Material|null}
         */
        this.depthMaterial = depthMaterial;


        /**
         *
         * @type {WorkerProxy}
         */
        this.buildWorker = buildWorker;

        this.material.onChanged.add(() => {
            this.traverse(this.assignTileMaterial, this);
        });

    }

    set transform(m4) {
        if (isArrayEqualStrict(m4, this.__transform)) {
            // no change
            return;
        }

        mat4.copy(this.__transform, m4);

        this.traverse(t => {
            t.transform = m4;
        });
    }

    get transform() {
        return this.__transform;
    }

    /**
     *
     * @param {number} min_height
     * @param {number} max_height
     */
    setHeightRange(min_height, max_height) {
        assert.isNumber(min_height, 'min_height');
        assert.notNaN(min_height, 'min_height');

        assert.isNumber(max_height, 'max_height');
        assert.notNaN(max_height, 'max_height');

        this.heightRange.set(min_height, max_height);

        const tiles = this.tiles;

        const n = tiles.length;

        for (let i = 0; i < n; i++) {
            const terrainTile = tiles[i];

            if (
                !terrainTile.isBuilt
                && !terrainTile.isBuildInProgress
            ) {

                const bb = terrainTile.external_bvh;

                bb.bounds[1] = min_height;
                bb.bounds[4] = max_height;

                bb.write_bounds();

            }

        }
    }

    initialize() {
        this.destroyTiles();

        this.initializeTiles();
    }

    /**
     *
     * @param {TerrainTile} tile
     */
    assignTileMaterial(tile) {
        let material = this.material.getValue();

        if (this.debugTileMaterialRandom) {
            const color = new Color();
            color.setHSV(Math.random(), randomFloatBetween(Math.random, 0.4, 1), 1);
            material = new MeshPhongMaterial({ color: color.toUint() });
        }

        tile.material = material;
        if (tile.mesh !== null) {
            tile.mesh.material = material;

            // route shadow-caster rendering through the terrain-specific
            // depth material so the recorded depth is inset into the surface
            // (see TerrainDepthMaterial). Leaving as undefined when none was
            // supplied falls back to three.js's default depth material.
            if (this.depthMaterial !== null) {
                tile.mesh.customDepthMaterial = this.depthMaterial;
            }
        }
    }

    /**
     *
     * @param {function(tile:TerrainTile)} callback
     * @param {*} [thisArg]
     */
    traverse(callback, thisArg) {
        const tiles = this.tiles;
        let tile;
        let i = 0;
        const il = tiles.length;
        for (; i < il; i++) {
            tile = tiles[i];
            callback.call(thisArg, tile);
        }
    }

    destroyTiles() {

        //destroy all existing tiles
        const tiles = this.tiles;
        const tile_count = tiles.length;

        console.warn(`#destroyTiles tile_count=${tile_count}`);

        for (let i = 0; i < tile_count; i++) {
            const tile = tiles[i];

            tile.external_bvh.unlink();
            tile.dispose();

        }

        tiles.splice(0, tile_count);

        //clear out BVH
        this.bvh.release_all();

    }

    /**
     * Rebuild all tiles
     */
    rebuild() {
        this.destroyTiles();

        this.initializeTiles();
    }

    /**
     * Rebuild tiles that overlap rectangular region of the overall terrain defined by normalized coordinates (UV space)
     * @param {number} u0
     * @param {number} v0
     * @param {number} u1
     * @param {number} v1
     */
    rebuildTilesByUV(
        u0, v0,
        u1, v1
    ) {

        const size = this.totalSize;

        const tx0 = u0 * size.x;
        const tx1 = u1 * size.x;

        const ty0 = v0 * size.y;
        const ty1 = v1 * size.y;

        const dirty_tiles = this.getRawTilesOverlappingRectangle(tx0 - 1, ty0 - 1, tx1 + 1, ty1 + 1);

        const dirty_count = dirty_tiles.length;

        for (let i = 0; i < dirty_count; i++) {
            const tile = dirty_tiles[i];
            tile.isBuilt = false;
        }
    }


    /**
     *
     * @param {number} x0
     * @param {number} y0
     * @param {number} x1
     * @param {number} y1
     * @returns {TerrainTile[]}
     */
    getRawTilesOverlappingRectangle(x0, y0, x1, y1) {
        /**
         *
         * @type {TerrainTile[]}
         */
        const result = [];

        const terrainTiles = this.tiles;

        const n = terrainTiles.length;

        for (let i = 0; i < n; i++) {
            const tile = terrainTiles[i];

            const tx0 = tile.position.x;
            const ty0 = tile.position.y;

            const tx1 = tx0 + tile.size.x;
            const ty1 = ty0 + tile.size.y;
            if (
                aabb2_overlap_exists(
                    x0, y0, x1, y1,
                    tx0, ty0, tx1, ty1
                )
            ) {
                result.push(tile);
            }
        }

        return result;
    }

    initializeTiles() {
        const total_size = this.totalSize;

        const gridSize = total_size.clone();

        const time_size = this.tileSize;

        gridSize.divide(time_size);
        gridSize.ceil();

        const tiles = this.tiles;

        if (tiles.length > 0) {
            throw new Error(`There are already ${tiles.length} initialized tiles, those must be destroyed before initialization can happen`);
        }

        //populate tiles
        const tile_resolution_y = gridSize.y;
        const tile_resolution_x = gridSize.x;

        for (let y = 0; y < tile_resolution_y; y++) {

            const tY = y < tile_resolution_y - 1 ? time_size.y : (total_size.y - time_size.y * y);

            for (let x = 0; x < tile_resolution_x; x++) {

                const tX = x < tile_resolution_x - 1 ? time_size.x : (total_size.x - time_size.x * x);

                const tile = new TerrainTile();
                const index = y * tile_resolution_x + x;
                tiles[index] = tile;

                this.assignTileMaterial(tile);

                tile.gridPosition.set(x, y);

                tile.size.set(tX, tY);
                tile.position.set(time_size.x * x, time_size.y * y);
                tile.scale.copy(this.scale);
                tile.resolution.copy(this.resolution);

                tile.setInitialHeightBounds(this.heightRange.min, this.heightRange.max);
                tile.computeBoundingBox();

                tile.external_bvh.link(this.bvh, index);
            }
        }

    }

    /**
     *
     * @param {number} x Tile X coordinate
     * @param {number} y Tile Y coordinate
     * @returns {number}
     */
    computeTileIndex(x, y) {
        assert.isNonNegativeInteger(x, 'x');
        assert.isNonNegativeInteger(y, 'y');

        const w = Math.ceil(this.totalSize.x / this.tileSize.x);

        assert.ok(x < w, `x(=${x}) must be less than than width(=${w})`);
        assert.ok(y < Math.ceil(this.totalSize.y / this.tileSize.y), `y(=${y}) must be less than than height(=${Math.ceil(this.totalSize.y / this.tileSize.y)})`);

        return y * w + x;
    }

    /**
     *
     * @param {number} x
     * @param {number} y
     * @returns {TerrainTile|undefined}
     */
    getRaw(x, y) {
        if (
            x < 0
            || x >= Math.ceil(this.totalSize.x / this.tileSize.x)
            || y < 0
        ) {
            return undefined;
        }

        const tileIndex = this.computeTileIndex(x, y);

        assert.ok(tileIndex >= 0, `tileIndex(=${tileIndex}) must be >= 0`);
        assert.ok(tileIndex <= this.tiles.length, `tileIndex(=${tileIndex}) must be <= tileCount(=${this.tiles.length})`);

        return this.tiles[tileIndex];
    }

    /**
     *
     * @param {number} x Grid X coordinate
     * @param {number} y Grid Y coordinate
     * @returns {TerrainTile}
     */
    getRawTileByPosition(x, y) {
        assert.isNumber(x, 'x');
        assert.notNaN(x, 'x');

        assert.isNumber(y, 'y');
        assert.notNaN(y, 'y');

        const tileX = Math.floor(x / this.tileSize.x);
        const tileY = Math.floor(y / this.tileSize.y);

        return this.getRaw(tileX, tileY);
    }

    /**
     * Given world coordinates in top-down plane, where X runs along X axis and Y runs along Z axis, returns terrain tile that overlaps that 2d region
     * @param {number} x
     * @param {number} y
     * @return {TerrainTile|undefined}
     */
    getTileByWorldPosition2D(x, y) {
        const v3 = vec3.fromValues(x, 0, y);

        const world_inverse = mat4.create();

        mat4.invert(world_inverse, this.transform);


        vec3.transformMat4(v3, v3, world_inverse);

        return this.getRawTileByPosition(v3[0], v3[1]);
    }

    /**
     * Builds and returns the tile from world coordinates
     * @param {number} x
     * @param {number} y
     * @returns {Promise<TerrainTile>}
     */
    obtainTileAtWorldPosition2D(x, y) {
        assert.isNumber(x, 'x')
        assert.notNaN(x, 'x')

        assert.isNumber(y, 'y')
        assert.notNaN(y, 'y')

        const tile = this.getTileByWorldPosition2D(x, y);

        return this.obtain(tile.gridPosition.x, tile.gridPosition.y);
    }

    /**
     *
     * @param {number} x coordinate
     * @param {number} y coordinate
     * @returns {Promise<TerrainTile>}
     * @throws if no tile exists at given coordinates
     */
    obtain(x, y) {
        const tile = this.getRaw(x, y);

        if (tile === undefined) {
            throw new Error(`No tile found at x=${x},y=${y}`);
        }

        tile.referenceCount++;

        if (tile.isBuilt) {
            return Promise.resolve(tile);

        } else {

            if (!tile.isBuildInProgress) {
                return new Promise((resolve, reject) => this.build(x, y, resolve, reject));
            } else {
                return new Promise((resolve, reject) => {
                    tile.onBuilt.addOne(resolve);
                    tile.onDestroyed.addOne(reject);
                });
            }

        }

    }

    /**
     *
     * @param {TerrainTile} tile
     */
    release(tile) {
        if (tile.referenceCount <= 0) {
            console.warn('Tile already has no references');
        }

        tile.referenceCount--;

        if (tile.referenceCount <= 0) {
            //potential garbage
            tile.dispose();
        }

    }

    dispose() {
        const tiles = this.tiles;

        const n = tiles.length;

        for (let i = 0; i < n; i++) {
            const t = tiles[i];
            t.dispose();
        }
    }

    /**
     * Fix normals along the seams of the tile
     *
     * @param {number} x
     * @param {number} y
     * @param {TerrainTile} tile
     */
    stitchTile(x, y, tile) {

        const gridSize = this.totalSize.clone();
        gridSize.divide(this.tileSize);
        gridSize.floor();

        const self = this;
        //normal stitching
        let top, bottom, left, right, topLeft, topRight, bottomLeft, bottomRight;

        if (y > 0) {
            top = self.getRaw(x, y - 1);
            if (x > 0) {
                topLeft = self.getRaw(x - 1, y - 1);
            }
            if (x < gridSize.x - 1) {
                topRight = self.getRaw(x + 1, y - 1);
            }
        }
        if (y < gridSize.y - 1) {
            bottom = self.getRaw(x, y + 1);
            if (x > 0) {
                bottomLeft = self.getRaw(x - 1, y + 1);
            }
            if (x < gridSize.x - 1) {
                bottomRight = self.getRaw(x + 1, y + 1);
            }
        }
        if (x > 0) {
            left = self.getRaw(x - 1, y);
        }
        if (x < gridSize.x - 1) {
            right = self.getRaw(x + 1, y);
        }

        tile.stitchNormals(top, bottom, left, right, topLeft, topRight, bottomLeft, bottomRight);
    }

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

        const hit_count = bvh_query_leaves_ray(
            this.bvh,
            this.bvh.root,
            scratch_array, 0,
            originX, originY, originZ,
            directionX, directionY, directionZ
        );

        let closest_hit_distance = Number.POSITIVE_INFINITY;
        let hit_found = false;

        for (let i = 0; i < hit_count; i++) {
            const node_id = scratch_array[i];

            const tile_index = this.bvh.node_get_user_data(node_id);

            const tile = this.tiles[tile_index];

            if (!tile.isBuilt) {
                continue;
            }

            const tile_hit_found = tile.raycastFirstSync(
                scratch_contact,
                originX, originY, originZ,
                directionX, directionY, directionZ
            );

            if (!tile_hit_found) {
                continue;
            }

            hit_found = true;

            const distance_sqr = scratch_contact.position._distanceSqrTo(originX, originY, originZ);

            if (distance_sqr < closest_hit_distance) {
                closest_hit_distance = distance_sqr;
                result.copy(scratch_contact);
            }
        }

        return hit_found;
    }

    /**
     * TODO untested
     * @param {SurfacePoint3} contact
     * @param {number} x
     * @param {number} y
     * @return {boolean}
     */
    raycastVerticalFirstSync(contact, x, y) {
        // console.log('+ raycastVerticalFirstSync');
        const r = this.raycastFirstSync(contact, x, -10000, y, 0, 1, 0);
        // console.log('- raycastVerticalFirstSync');
        return r;
    }

    /**
     *
     * @param {number} x
     * @param {number} y
     * @param {function(tile:TerrainTile)} resolve
     * @param {function(reason:*)} reject
     */
    build(x, y, resolve, reject) {

        assert.isFunction(resolve, 'resolve');
        assert.isFunction(reject, 'reject');

        const tile_index = this.computeTileIndex(x, y);

        const tile = this.tiles[tile_index];

        if (tile.isBuildInProgress) {
            throw new Error('Tile is already in process of being built');
        }

        const start_version = tile.version;

        tile.isBuilt = false;
        tile.isBuildInProgress = true;

        const tile_resolution = tile.resolution.getValue();

        this.buildWorker.buildTile(
            tile.position.toJSON(),
            tile.size.toJSON(),
            tile.scale.toJSON(),
            this.totalSize.toJSON(),
            tile_resolution
        ).then((tileData) => {

            //check that the tile under index is still the same tile
            if (this.tiles[tile_index] !== tile) {
                //the original tile was destroyed
                reject('Original tile was destroyed during build process');
                return;
            }

            if (tile.version !== start_version) {
                reject(`Tile version changed, likely due to concurrent build request. Expected version ${start_version}, actual version ${tile.version}`);
                return;
            }

            if (!tile.isBuildInProgress) {
                if (tile.isBuilt) {
                    // tile already built
                    resolve(tile);
                    return;
                } else {

                    reject('Build request has been cancelled');
                    return;
                }
            }

            // const processName = 'building tile x = ' + x + ", y = " + y;
            // console.time(processName);

            tile.build(tileData);

            assert.equal(tile.resolution.getValue(), tile_resolution, 'tile resolution has changed');

            this.stitchTile(x, y, tile);

            //refit the bvh
            tile.external_bvh.write_bounds();

            tile.isBuilt = true;
            tile.isBuildInProgress = false;

            //invoke callbacks
            tile.onBuilt.send1(tile);

            // console.timeEnd(processName);

            this.on.tileBuilt.send1(tile);

            resolve(tile);
        }, (reason) => {

            tile.isBuilt = false;
            tile.isBuildInProgress = false;

            reject(reason);

        });
    }
}

export default TerrainTileManager;

