import { mat4 } from "gl-matrix";
import {
    ClampToEdgeWrapping,
    DataTexture,
    FloatType,
    LinearFilter,
    NearestFilter,
    RedFormat,
    UnsignedByteType
} from 'three';
import { assert } from "../../../../core/assert.js";
import { BvhClient } from "../../../../core/bvh2/bvh3/BvhClient.js";
import { IllegalStateException } from "../../../../core/fsm/exceptions/IllegalStateException.js";
import { SurfacePoint3 } from "../../../../core/geom/3d/SurfacePoint3.js";
import Vector2 from '../../../../core/geom/Vector2.js';
import { clamp } from "../../../../core/math/clamp.js";
import { GameAssetType } from "../../../asset/GameAssetType.js";
import { SplatMaterial } from "../../../graphics/material/SplatMaterial.js";
import { makeTerrainDepthMaterial } from "../../../graphics/material/TerrainDepthMaterial.js";
import { Sampler2D } from '../../../graphics/texture/sampler/Sampler2D.js';
import { WHITE_PIXEL_DATA_URL } from "../../../graphics/WHITE_PIXEL_DATA_URL.js";
import { TerrainOverlay } from '../overlay/TerrainOverlay.js';

import Clouds from '../TerrainClouds.js';
import { TerrainPreview } from "../TerrainPreview.js";

import TerrainTileManager from '../tiles/TerrainTileManager.js';
import { buildLightTexture } from "./BuildLightTexture.js";
import { GridTransformKind } from "./GridTransformKind.js";
import { TerrainLayers } from "./layers/TerrainLayers.js";
import { makeTerrainWorkerProxy } from "./makeTerrainWorkerProxy.js";
import { OffsetScaleTransform2D } from "./OffsetScaleTransform2D.js";
import { SplatMapping } from "./splat/SplatMapping.js";
import { TerrainFlags } from "./TerrainFlags.js";

let idCounter = 0;

/**
 * Terrain is split into equal sized square patches
 * Dimensions of single patch in width and height
 * NOTE: edges of the terrain will typically result in patches smaller than this, so this is upper bound
 * @type {number}
 */
const TILE_SIZE = 32;

class Terrain {
    /**
     * @readonly
     * @type {number}
     */
    id = idCounter++;

    /**
     * @private
     * @type {number|TerrainFlags}
     */
    flags = 0;

    /**
     *
     * @type {number}
     */
    gridScale = 1;

    /**
     * Transform from grid coordinate system to world coordinate system
     * @type {OffsetScaleTransform2D}
     */
    gridTransform = new OffsetScaleTransform2D();

    /**
     *
     * @type {number}
     */
    gridTransformKind = GridTransformKind.Direct;

    /**
     * Number of geometric subdivisions per single terrain tile
     * @example if value is set to 4, each tile will be split into 4x4 quads
     * @type {number}
     */
    resolution = 4;

    /**
     * @readonly
     * @type {Vector2}
     */
    size = new Vector2(0, 0);
    /**
     *
     * @type {TerrainPreview}
     */
    preview = new TerrainPreview();
    /**
     * @readonly
     * @type {SplatMapping}
     */
    splat = new SplatMapping();

    /**
     * @readonly
     * @type {TerrainLayers}
     */
    layers = new TerrainLayers();

    /**
     * @readonly
     * @type {WorkerProxy}
     */
    __buildWorker = makeTerrainWorkerProxy();

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

    constructor() {

        /**
         *
         * @type {Clouds}
         */
        this.clouds = new Clouds();
        this.clouds.enabled = true;

        /**
         * @deprecated
         * @type {number}
         */
        this.heightRange = 0;


        /**
         *
         * @type {Sampler2D}
         */
        this.samplerHeight = Sampler2D.float32(1, 1, 1);

        /**
         *
         * @type {DataTexture}
         */
        this.heightTexture = new DataTexture(this.samplerHeight.data, this.samplerHeight.width, this.samplerHeight, RedFormat, FloatType);

        this.heightTexture.wrapS = ClampToEdgeWrapping;
        this.heightTexture.wrapT = ClampToEdgeWrapping;

        this.heightTexture.generateMipmaps = false;

        this.heightTexture.minFilter = LinearFilter;
        this.heightTexture.magFilter = NearestFilter;

        this.heightTexture.flipY = false;

        this.heightTexture.internalFormat = 'R32F';


        /**
         * @type {ShaderMaterial}
         */
        this.material = new SplatMaterial({});

        /**
         * Custom depth material attached to each terrain tile mesh as
         * `mesh.customDepthMaterial`. Sinks recorded depth into the surface
         * so the terrain does not self-shadow at creases. See
         * {@link makeTerrainDepthMaterial} for the trade-off this makes.
         * The bias strength is exposed via {@link Terrain#shadowBias}.
         *
         * @readonly
         * @type {MeshDepthMaterial}
         */
        this.depthMaterial = makeTerrainDepthMaterial();

        /**
         * whether or not frustum culling is enabled
         * @type {boolean}
         */
        this.frustumCulled = true;

        /**
         * 4x4 transform matrix
         * @private
         * @type {Float32Array}
         */
        this.__transform_matrix = mat4.create();


        /**
         *
         * @type {TerrainOverlay}
         */
        this.overlay = new TerrainOverlay(this.size);

        /**
         *
         * @type {TerrainTileManager}
         * @private
         */
        this.__tiles = new TerrainTileManager({
            material: this.material,
            depthMaterial: this.depthMaterial,
            buildWorker: this.__buildWorker
        });

        this.__tiles.tileSize.set(TILE_SIZE, TILE_SIZE);

        this.__tiles.material.set(this.material);

        /**
         *
         * @type {AssetManager}
         * @private
         */
        this.__assetManager = null;

        /**
         *
         * @type {String}
         */
        this.lightMapURL = null;

        /**
         *
         * @type {Reference<Material>|null}
         * @private
         */
        this.__material_reference = null;

        this.initialize();
    }

    /**
     *
     * @param {number|TerrainFlags} flag
     * @returns {void}
     */
    setFlag(flag) {
        this.flags |= flag;
    }

    /**
     *
     * @param {number|TerrainFlags} flag
     * @returns {void}
     */
    clearFlag(flag) {
        this.flags &= ~flag;
    }

    /**
     *
     * @param {number|TerrainFlags} flag
     * @param {boolean} value
     */
    writeFlag(flag, value) {
        if (value) {
            this.setFlag(flag);
        } else {
            this.clearFlag(flag);
        }
    }

    /**
     *
     * @param {number|TerrainFlags} flag
     * @returns {boolean}
     */
    getFlag(flag) {
        return (this.flags & flag) === flag;
    }

    /**
     * Object-space distance the terrain is pushed along its negative
     * geometric normal when it renders into a shadow map. See
     * {@link makeTerrainDepthMaterial} for the mechanism and trade-off.
     *
     * Default ({@link makeTerrainDepthMaterial}) is conservative. Increase
     * if hairline self-shadow faults are still visible at creases; decrease
     * if other objects' contact shadows on the terrain look too shrunken.
     *
     * The value is in object space, so it scales with the terrain's
     * transform. With a default 1:1 terrain transform it is also world units.
     *
     * @returns {number}
     */
    get shadowBias() {
        return this.depthMaterial.userData.shadowBias.value;
    }

    set shadowBias(v) {
        this.depthMaterial.userData.shadowBias.value = v;
    }

    /**
     *
     * @param {GraphicsEngine} graphics
     */
    link(graphics) {

        if (this.getFlag(TerrainFlags.Linked)) {
            // already linked
            return;
        }

        this.startBuildService();

        // bind material
        const material_handle = graphics.getMaterialManager().obtain(this.material);

        this.__material_reference = material_handle;

        // set material
        const material = material_handle.getValue();
        this.clouds.addMaterial(material);

        this.__tiles.material.set(material);

        this.updateMaterial();


        material.uniforms.diffuseMaps.value = this.layers.texture;

        this.setFlag(TerrainFlags.Linked);
    }

    /**
     * disconnect the component, here all resources are released
     */
    unlink() {

        if (!this.getFlag(TerrainFlags.Linked)) {
            // not linked
            return;
        }

        this.stopBuildService();

        const material = this.__material_reference.getValue();

        this.clouds.removeMaterial(material);

        // release material handle
        this.__material_reference.release();

        this.__tiles.dispose();
        this.splat.dispose();
        this.layers.dispose();

        this.heightTexture.dispose();

        this.bvh.unlink();

        this.clearFlag(TerrainFlags.Linked);
    }

    dispose() {
        if (this.getFlag(TerrainFlags.Linked)) {
            throw new IllegalStateException('component is linked, must be unlinked first');
        }
    }

    initialize() {
        const overlay = this.overlay;

        const uniforms = this.material.uniforms;

        uniforms.diffuseGridOverlayMap.value = overlay.texture;
        uniforms.gridBorderWidth.value = overlay.borderWidth.getValue();

        const size = this.size;

        overlay.size.onChanged.add((x, y) => {
            uniforms.gridResolution.value.set(size.x, size.y);
        });

        overlay.borderWidth.onChanged.add((v) => {
            uniforms.gridBorderWidth.value = v;
        });

        overlay.tileImage.onChanged.add(this.updateTileImage, this);
    }

    async updateTileImage() {
        const tileImageURL = this.overlay.tileImage.getValue();

        const assetManager = this.__assetManager;

        if (assetManager === null) {
            return;
        }

        const asset = await assetManager.promise(tileImageURL, GameAssetType.Texture);

        if (tileImageURL !== this.overlay.tileImage.getValue()) {
            //url has changed, abort
            return;
        }

        /**
         *
         * @type {Texture}
         */
        const texture = asset.create();

        texture.minFilter = LinearFilter;
        texture.magFilter = LinearFilter;

        texture.generateMipmaps = false;

        this.material.uniforms.diffuseGridOverlaySprite.value = texture;
    }

    update(timeDelta) {
        this.clouds.update(timeDelta);
    }

    /**
     * @param {number} x
     * @param {number} y
     * @param {function} callback
     * @param {function} missCallback
     * @param {function} errorCallback
     */
    sampleHeight(x, y, callback, missCallback, errorCallback) {
        assert.isFunction(callback, 'callback');
        assert.isFunction(missCallback, 'missCallback');
        assert.isFunction(errorCallback, 'errorCallback');

        const hit = new SurfacePoint3();

        if (this.raycastVerticalFirstSync(hit, x, y)) {
            callback(hit.position.y);
        } else {
            missCallback();
        }
    }

    /**
     *
     * @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
    ) {
        return this.__tiles.raycastFirstSync(result, originX, originY, originZ, directionX, directionY, directionZ);
    }

    /**
     *
     * @param {SurfacePoint3} contact
     * @param {number} x
     * @param {number} y
     * @return {boolean}
     */
    raycastVerticalFirstSync(contact, x, y) {
        return this.__tiles.raycastVerticalFirstSync(contact, x, y);
    }

    /**
     *
     * @param {Array.<Vector3>} points3v points in world coordinate space
     * @param {function} callback
     * @param {function} errorCallback
     */
    projectPointsVertical(points3v, callback, errorCallback) {
        /**
         *
         * @type {Terrain}
         */
        const terrain = this;

        let mappedCount = 0;

        const l = points3v.length;

        const missedPoints = [];

        function finalizePoint() {
            if (++mappedCount >= l) {
                mendMissedPoints(points3v, missedPoints);
                callback(points3v);
            }
        }


        function mapPoint(index) {
            const p = points3v[index];

            function missedCallback() {
                //no mapping
                missedPoints.push(index);

                finalizePoint();
            }

            function hitCallback(y) {
                p.y = y;

                finalizePoint();
            }

            terrain.sampleHeight(p.x, p.z, hitCallback, missedCallback, errorCallback);
        }

        if (l === 0) {
            callback(points3v);
        } else {
            for (let i = 0; i < l; i++) {
                mapPoint(i);
            }
        }
    }

    /**
     *
     * @param {Array.<Vector2>} points2v
     * @param {Vector3[]} result
     * @param {function} callback
     * @param {function} errorCallback
     */
    mapGridPoints(points2v, result, callback, errorCallback) {
        const terrain = this;

        const pointCount = points2v.length;

        for (let i = 0; i < pointCount; i++) {
            const v2 = points2v[i];

            const v3 = result[i];

            terrain.mapPointGrid2World(v2.x, v2.y, v3);
        }

        this.projectPointsVertical(result, callback, errorCallback);
    }

    /**
     *
     * @param {number} x
     * @param {number} y
     * @param {Vector3} result
     */
    mapPointGrid2World(x, y, result) {
        assert.isNumber(x, 'x');
        assert.isNumber(y, 'y');

        assert.notNull(result,'result');
        assert.defined(result, 'result');
        assert.ok(result.isVector3, 'result is not Vector3');

        const transform = this.gridTransform;

        const _x = x * transform.scale_x + transform.offset_x;
        const _z = y * transform.scale_y + transform.offset_y;

        result.set(_x, result.y, _z);
    }

    /**
     *
     * @param {Vector3} v3
     * @param {Vector2} result
     */
    mapPointWorld2Grid(v3, result) {
        const transform = this.gridTransform;

        const x = (v3.x - transform.offset_x) / transform.scale_x;
        const y = (v3.z - transform.offset_y) / transform.scale_y;

        result.set(x, y);
    }

    /**
     *
     * @return {Promise}
     */
    promiseAllTiles() {
        /**
         *
         * @type {TerrainTileManager}
         */
        const tiles = this.__tiles;

        const promisedTiles = [];

        tiles.traverse(function (tile) {
            const position = tile.gridPosition;

            const x = position.x;
            const y = position.y;

            const tilePromise = tiles.obtain(x, y);

            promisedTiles.push(tilePromise);
        });

        //promise all tiles
        return Promise.all(promisedTiles);
    }

    updateMaterial() {
        let m = this.material;

        if (this.__material_reference !== null && this.__material_reference.getValue() !== null) {
            m = this.__material_reference.getValue();
        }

        const uniforms = m.uniforms;

        uniforms.diffuseMaps.value = this.layers.texture;

        uniforms.splatWeightMap.value = this.splat.weightTexture;

        // Update layer count as a compile-time define (AMD GLSL 300 ES requires constant loop bounds)
        const layerCount = this.layers.count();
        if (m.defines.SPLAT_LAYER_COUNT !== layerCount) {
            m.defines.SPLAT_LAYER_COUNT = layerCount;
            m.needsUpdate = true;
        }

        uniforms.materialScalesMap.value = this.layers.scalesTexture;

        uniforms.splatResolution.value.set(this.splat.size.x, this.splat.size.y);
        uniforms.gridResolution.value.set(this.size.x, this.size.y);

        uniforms.uGridTransform.value.set(
            this.gridTransform.scale_x,
            this.gridTransform.scale_y,
            this.gridTransform.offset_x,
            this.gridTransform.offset_y
        );

        this.layers.updateLayerScales(this.size.x * this.gridScale, this.size.y * this.gridScale);

        m.uniformsNeedUpdate = true;
    }

    /**
     *
     * @param {mat4|Float32Array|number[]} m4
     */
    set transform(m4) {
        mat4.copy(this.__transform_matrix, m4);

        this.__tiles.transform = m4;
    }

    /**
     *
     * @return {Float32Array}
     */
    get transform() {
        return this.__transform_matrix;
    }

    /**
     *
     * @param {TerrainLayer} layer
     */
    addLayer(layer) {
        const layers = this.layers;
        const index = layers.addLayer(layer);

        // expand splat
        this.splat.addWeightLayer();

        if (this.getFlag(TerrainFlags.Built)) {
            // terrain is built, need to do a few updates

            layers.buildTexture();

            layer.loadTextureData(this.__assetManager).then(() => {
                // verify that layer is still present
                if (layers.get(index) !== layer) {
                    return;
                }

                layers.writeLayerDataIntoTexture(index);
            });

            this.updateMaterial();
        }

        return index;
    }

    /**
     * Should be called after modifying heights to actualize changes
     * @returns {Promise<void>}
     */
    async updateHeights() {
        this.updateHeightTexture();
        await this.updateWorkerHeights();
    }

    updateHeightTexture() {
        const sampler = this.samplerHeight;
        const texture = this.heightTexture;

        const image = texture.image;

        if (image.data !== sampler.data) {
            // cleanup existing texture data
            texture.dispose();

            image.data = sampler.data;
        }

        image.width = sampler.width;
        image.height = sampler.height;

        texture.needsUpdate = true;
    }

    startBuildService() {
        this.__buildWorker.start();
        this.updateWorkerHeights();
    }

    async updateWorkerHeights() {
        const s = this.samplerHeight;
        const worker = this.__buildWorker;

        await worker.setHeightSampler(s.data, s.itemSize, s.width, s.height);

        return await worker.computeHeightRange()
            .then(({ min, max }) => {
                this.__tiles.setHeightRange(min, max);
            });
    }

    stopBuildService() {
        this.__buildWorker.stop();
    }

    buildGridTransform() {
        if (this.gridTransformKind === GridTransformKind.Legacy) {
            this.gridTransform.scale_x = (this.size.x / (this.size.x - 1)) * this.gridScale;
            this.gridTransform.scale_y = (this.size.y / (this.size.y - 1)) * this.gridScale;

            this.gridTransform.offset_x = 0;
            this.gridTransform.offset_y = 0;

        } else if (this.gridTransformKind === GridTransformKind.Direct) {
            this.gridTransform.scale_x = this.gridScale;
            this.gridTransform.scale_y = this.gridScale;

            this.gridTransform.offset_x = this.gridScale / 2;
            this.gridTransform.offset_y = this.gridScale / 2;
        }
    }

    buildNonVisual() {

        this.buildGridTransform();

        this.bvh.resize(0, 0, 0, 0, 0, 0);

        //
        this.overlay.size.copy(this.size);

        this.__tiles.totalSize.copy(this.size);
        this.__tiles.scale.set(this.gridScale, this.gridScale);
        this.__tiles.resolution.set(this.resolution);


        this.updateHeightTexture();

        this.updateWorkerHeights();

    }

    /**
     *
     * @param {AssetManager} assetManager
     */
    build(assetManager) {
        if (this.getFlag(TerrainFlags.Built)) {
            // already built
            console.warn('already built, if you want rebuild to occur, clear this flag first');
            return;
        }

        assert.defined(assetManager, 'assetManager');
        assert.notNull(assetManager, 'assetManager');
        assert.equal(assetManager.isAssetManager, true, '.isAssetManager !== true');

        this.__assetManager = assetManager;

        this.buildNonVisual();

        this.layers.buildTexture();

        this.layers.loadTextureData(assetManager);

        if (this.lightMapURL !== null) {
            this.material.aoMap = true;
            this.material.needsUpdate = true;

            assetManager.promise(this.lightMapURL, GameAssetType.Texture).then(asset => {
                this.material.uniforms.aoMap.value = asset.create();
                this.material.uniforms.aoMapIntensity.value = 0.7;
            });
        }

        /**
         *
         * @type {Promise<ShaderMaterial>}
         * @deprecated
         */
        this.pMaterial = Promise.resolve(this.material);

        /**
         *
         * @type {Promise<TerrainTileManager>}
         * @deprecated
         */
        this.pTiles = Promise.resolve(this.__tiles);

        this.__tiles.initialize();

        this.updateMaterial();

        this.updateTileImage();

        this.setFlag(TerrainFlags.Built);
    }

    /**
     * Note: For advanced users only. You can seriously mess up how things look and function with respect to terrain here
     * @return {TerrainTileManager}
     */
    get tiles() {
        return this.__tiles;
    }

    /**
     *
     * @returns {Texture|null}
     */
    get aoMap() {
        const ref = this.__material_reference;
        if (ref === null) {
            return null;
        }
        const mat = ref.getValue();

        if (mat === null) {
            return null;
        }

        return mat.uniforms.aoMap.value;
    }

    /**
     *
     * @param {number} quality resolution in texels per grid cell (4 = 4x4 light map texels per single grid cell)
     * @return {Promise<DataTexture>}
     */
    buildLightMap(quality = 4) {


        const size_x = this.size.x;
        const size_y = this.size.y;

        const light_texture_width = clamp(size_x, 16, 2048);
        const light_texture_height = clamp(size_y, 16, 2048);

        const textureData = new Uint8Array(light_texture_width * light_texture_height);
        const texture = new DataTexture(textureData, light_texture_width, light_texture_height, RedFormat, UnsignedByteType);

        texture.wrapT = ClampToEdgeWrapping;
        texture.wrapS = ClampToEdgeWrapping;

        texture.minFilter = NearestFilter;
        texture.magFilter = LinearFilter;

        texture.flipY = false;
        texture.anisotropy = 4;

        const promise = buildLightTexture({
            texture,
            heightSampler: this.samplerHeight,
            worldSize: new Vector2(size_x * this.gridScale, size_y * this.gridScale),
            resolution: new Vector2(size_x * quality, size_y * quality)
        });


        const result = promise.then(() => {

            const ref = this.__material_reference;

            let mat = this.material;

            if (ref !== null && ref.getValue() !== null) {
                mat = ref.getValue();
            }

            mat.aoMap = true;
            mat.needsUpdate = true;

            mat.uniforms.aoMap.value = texture;
            mat.uniforms.aoMapIntensity.value = 0.7;

            return texture;
        });

        // TODO ensure cleanup when terrain is unlinked

        return result;
    }

    /**
     *
     * @param opt
     * @param {Engine} engine
     */
    fromJSON({
                 resolution = 4,
                 preview,
                 size = 1,
                 scale = 1,
                 material,
                 heights,
                 layers,
                 splat,
                 overlayTileImage
             } = {}, engine) {
        // mark as needing rebuilding
        this.clearFlag(TerrainFlags.Built);

        if (preview !== undefined) {
            this.preview.fromJSON(preview);
        }

        this.resolution = resolution;

        this.size.fromJSON(size);

        this.gridScale = scale;
        //

        this.materialDesc = material;

        if (heights !== undefined) {
            this.samplerHeight.fromJSON(heights);
        } else {
            this.samplerHeight.data = new Float32Array(1);
            this.samplerHeight.width = 1;
            this.samplerHeight.height = 1;
            this.samplerHeight.itemSize = 1;
            this.samplerHeight.version++;
        }

        this.layers.fromJSON(layers);
        this.splat.fromJSON(splat);

        if (overlayTileImage !== undefined) {
            this.overlay.baseTileImage = overlayTileImage;
        } else {
            this.overlay.baseTileImage = WHITE_PIXEL_DATA_URL;
        }


        // debugSamplers(this);
        this.build(engine.assetManager);
    }

    toJSON() {

        const result = {
            size: this.size.toJSON(),
            scale: this.gridScale,
            resolution: this.resolution,
            material: this.materialDesc,
            preview: this.preview.toJSON(),
            heights: this.samplerHeight.toJSON(),
            layers: this.layers.toJSON(),
            splat: this.splat.toJSON(),
            overlayTileImage: this.overlay.baseTileImage
        };

        return result;
    }
}

Terrain.typeName = "Terrain";

/**
 *
 * @param {Vector3[]} points
 * @param {number[]} missIndices
 */
function mendMissedPoints(points, missIndices) {
    const numPoints = points.length;
    const lastPointIndex = numPoints - 1;

    const numMisses = missIndices.length;

    for (let i = 0; i < numMisses; i++) {
        const index = missIndices[i];

        let heightSum = 0;
        let sampleCount = 0;

        if (index > 0) {
            heightSum += points[index - 1].y;
            sampleCount++;
        }

        if (index < lastPointIndex) {
            heightSum += points[index + 1].y;
            sampleCount++;
        }

        if (sampleCount > 0) {
            points[index].y = heightSum / sampleCount;
        }
    }
}


export default Terrain;

