import { ShaderMaterial, Texture } from "three";
import { GroundedSkybox as GroundProjection } from 'three/examples/jsm/objects/GroundedSkybox.js';

import { Gizmos } from "../engine/engine_gizmos.js";
import { serializable } from "../engine/engine_serialization_decorator.js";
import { getBoundingBox, getTempVector, getWorldScale, Graphics, setVisibleInCustomShadowRendering, setWorldPosition } from "../engine/engine_three_utils.js";
import { delayForFrames, getParam, Watch as Watch } from "../engine/engine_utils.js";
// Type-only imports for TSDoc @see links
import type { Camera } from "./Camera.js";
import { Behaviour } from "./Component.js";
import type { ContactShadows } from "./ContactShadows.js";

const debug = getParam("debuggroundprojection");

/**
 * The [GroundProjectedEnv](https://engine.needle.tools/docs/api/GroundProjectedEnv) projects the environment map onto a virtual ground plane.
 * Creates a realistic floor from 360° panoramas/HDRIs by deforming the skybox
 * into a hemisphere with a beveled floor.  
 *  
 * 
 * [![](https://cloud.needle.tools/-/media/8LDMd4TnGxVIj1XOfxIUIA.gif)](https://engine.needle.tools/samples/ground-projection)
 *
 * **Key properties:**  
 * - `radius` - Size of the projection sphere (keep camera inside)
 * - `height` - How high the original photo was taken (affects floor magnification)
 * - `autoFit` - Automatically center and position at ground level
 * - `arBlending` - Blend with real-world in AR (0=hidden, 1=visible)
 *
 * **Debug:** Use `?debuggroundprojection` URL parameter.  
 *
 * @example Apply ground projection
 * ```ts
 * const ground = myObject.getComponent(GroundProjectedEnv);
 * ground.radius = 100;
 * ground.height = 2;
 * ground.apply();
 * ```
 *
 * @summary Projects the environment map onto the ground
 * @category Rendering
 * @group Components
 * @see {@link Camera} for environment/skybox settings
 * @see {@link ContactShadows} for ground shadows
 * @link https://engine.needle.tools/samples/ground-projection for a demo of ground projection
 */
export class GroundProjectedEnv extends Behaviour {

    /**
     * If true the projection will be created on awake and onEnable
     * @default false
     */
    @serializable()
    applyOnAwake: boolean = false;

    /**
     * When enabled the position of the projected environment will be adjusted to be centered in the scene (and ground level).
     * @default true
     */
    @serializable()
    autoFit: boolean = true;

    /**
     * Radius of the projection sphere. Set it large enough so the camera stays inside (make sure the far plane is also large enough)
     * @default 50
     */
    @serializable()
    set radius(val: number) {
        this._radius = val;
        if (this._projection) this.updateProjection();
    }
    get radius(): number { return this._radius; }
    private _radius: number = 50;

    /**
     * How far the camera that took the photo was above the ground. A larger value will magnify the downward part of the image.
     * @default 3
     */
    @serializable()
    set height(val: number) {
        this._height = val;
        if (this._projection) this.updateProjection();
    }
    get height(): number { return this._height; }
    private _height: number = 3;

    /**
     * Blending factor for the AR projection being blended with the scene background.    
     * 0 = not visible in AR - 1 = blended with real world background.    
     * Values between 0 and 1 control the smoothness of the blend while lower values result in smoother blending.
     * @default 0
     */
    @serializable()
    set arBlending(val: number) {
        this._arblending = val;
        this._needsTextureUpdate = true;
    }
    get arBlending(): number { return this._arblending; }
    private _arblending = 0;

    private _lastBackground?: Texture;
    private _lastRadius?: number;
    private _lastHeight?: number;
    private _projection?: GroundProjection;
    private _watcher?: Watch;


    /** @internal */
    awake() {
        if (this.applyOnAwake)
            this.updateAndCreate();
    }
    /** @internal */
    onEnable() {
        // TODO: if we do this in the first frame we can not disable it again. Something buggy with the watch?!
        if (this.context.time.frameCount > 0) {
            if (this.applyOnAwake)
                this.updateAndCreate();
        }
        if (!this._watcher) {
            this._watcher = new Watch(this.context.scene, "background");
            this._watcher.subscribeWrite(_ => {
                if (debug) console.log("Background changed", this.context.scene.background);
                this._needsTextureUpdate = true;
            });
        }
    }
    /** @internal */
    onDisable() {
        this._watcher?.revoke();
        this._projection?.removeFromParent();
    }
    /** @internal */
    onEnterXR(): void {
        if (!this.activeAndEnabled) return;
        this._needsTextureUpdate = true;
        this.updateProjection();
    }
    /** @internal */
    async onLeaveXR() {
        if (!this.activeAndEnabled) return;
        await delayForFrames(1);
        this.updateProjection();
    }
    /** @internal */
    onBeforeRender(): void {
        if (this._projection && this.scene.backgroundRotation) {
            this._projection.rotation.copy(this.scene.backgroundRotation);
        }

        const blurrinessChanged = this.context.scene.backgroundBlurriness !== undefined && this._lastBlurriness != this.context.scene.backgroundBlurriness && this.context.scene.backgroundBlurriness > 0.001;
        if (blurrinessChanged) {
            this.updateProjection();
        }
        else if (this._needsTextureUpdate && this.context.scene.background instanceof Texture) {
            this.updateBlurriness(this.context.scene.background, this.context.scene.backgroundBlurriness);
        }
    }

    private updateAndCreate() {
        this.updateProjection();
        this._watcher?.apply();
    }


    private _needsTextureUpdate = false;

    /**
     * Updates the ground projection. This is called automatically when the environment or settings change.
     */
    updateProjection() {
        if (!this.context.scene.background) {
            this._projection?.removeFromParent();
            return;
        }
        const backgroundTexture = this.context.scene.background;
        if (!(backgroundTexture instanceof Texture)) {
            this._projection?.removeFromParent();
            return;
        }

        if (this.context.xr?.isPassThrough || this.context.xr?.isAR) {
            if (this.arBlending === 0) {
                this._projection?.removeFromParent();
                return;
            }
        }
        // If this is called from a setter during initialization
        if (!this.gameObject || this.destroyed) {
            return;
        }

        let needsNewAutoFit = true;
        // offset here must be zero (and not .01) because the plane occlusion (when mesh tracking is active) is otherwise not correct
        const offset = 0;

        const hasChanged = backgroundTexture !== this._lastBackground || this._height !== this._lastHeight || this._radius !== this._lastRadius;

        if (!this._projection || hasChanged) {
            if (debug) console.log("Create/Update Ground Projection", backgroundTexture.name);

            this._projection?.removeFromParent();

            try {
                this._projection = new GroundProjection(backgroundTexture, this._height, this._radius, 64);
            }
            catch (e) {
                console.error("Error creating three GroundProjection", e);
                return;
            }
            this._projection.position.y = this._height - offset;
            this._projection.name = "GroundProjection";
            setVisibleInCustomShadowRendering(this._projection, false);
        }
        else {
            needsNewAutoFit = false;
        }

        if (!this._projection.parent)
            this.gameObject.add(this._projection);

        if (this.autoFit && needsNewAutoFit) {
            // TODO: should also update the radius (?)
            this._projection.updateWorldMatrix(true, true);
            const box = getBoundingBox(this.context.scene.children, [this._projection]);

            const floor_y = box.min.y;
            if (floor_y < Infinity) {
                const wp = getTempVector();
                wp.x = box.min.x + (box.max.x - box.min.x) * .5;
                const scale = getWorldScale(this.gameObject).x;
                wp.y = floor_y + (this._height * scale) - offset;
                wp.z = box.min.z + (box.max.z - box.min.z) * .5;
                setWorldPosition(this._projection, wp);
            }

            if (debug) Gizmos.DrawWireBox3(box, 0x00ff00, 5);
        }

        /* TODO realtime adjustments aren't possible anymore with GroundedSkybox (mesh generation)
        this.env.scale.setScalar(this._scale);
        this.env.radius = this._radius;
        this.env.height = this._height;
        */

        if (this.context.scene.backgroundBlurriness > 0.001 && this._needsTextureUpdate) {
            this.updateBlurriness(backgroundTexture, this.context.scene.backgroundBlurriness);
        }

        this._lastBackground = backgroundTexture;
        this._lastHeight = this._height;
        this._lastRadius = this._radius;
        this._needsTextureUpdate = false;
    }

    private _blurrynessShader: ShaderMaterial | null = null;
    private _lastBlurriness: number = -1;

    private updateBlurriness(texture: Texture, blurriness: number) {
        if (!this._projection) {
            return;
        }
        else if (!texture) {
            return;
        }

        this._needsTextureUpdate = false;
        if (debug) console.log("Update Blurriness", blurriness);
        this._blurrynessShader ??= new ShaderMaterial({
            name: "GroundProjectionBlurriness",
            uniforms: {
                map: { value: texture },
                blurriness: { value: blurriness },
                blending: { value: 0 },
                alphaFactor: { value: 1 }
            },
            vertexShader: blurVertexShader,
            fragmentShader: blurFragmentShader
        });
        this._blurrynessShader.depthWrite = false;
        this._blurrynessShader.uniforms.map.value = texture;
        this._blurrynessShader.uniforms.blurriness.value = blurriness;
        this._lastBlurriness = blurriness;
        texture.needsUpdate = true;

        const wasTransparent = this._projection.material.transparent;
        this._projection.material.transparent = (this.context.xr?.isAR === true && this.arBlending > 0.000001) ?? false;
        if (this._projection.material.transparent) {
            this._blurrynessShader.uniforms.blending.value = this.arBlending;
        }
        else { this._blurrynessShader.uniforms.blending.value = 0; }

        if (this.context.isInPassThrough) {
            // Make the ground slightly transparent in passthrough mode
            this._blurrynessShader.uniforms.alphaFactor.value = 0.95;
        }
        else {
            this._blurrynessShader.uniforms.alphaFactor.value = 1;
        }

        // Make sure the material is updated if the transparency changed
        if (wasTransparent !== this._projection.material.transparent) {
            this._projection.material.needsUpdate = true;
        }

        // Update the texture
        this._projection.material.map = Graphics.copyTexture(texture, this._blurrynessShader);
        this._projection.material.depthTest = true;
        this._projection.material.depthWrite = false;
    }

}


const blurVertexShader = `
  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

// Fragment Shader
const blurFragmentShader = `
  uniform sampler2D map;
  uniform float blurriness;
  uniform float alphaFactor;
  uniform float blending;
  varying vec2 vUv;

  const float PI = 3.14159265359;

  // Gaussian function
  float gaussian(float x, float sigma) {
    return exp(-(x * x) / (2.0 * sigma * sigma)) / (sqrt(2.0 * PI) * sigma);
  }

  // Custom smoothstep function for desired falloff
  float customSmoothstep(float edge0, float edge1, float x) {
    float t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
    return t * t * (3.0 - 2.0 * t);
  }

  void main() {
    vec2 center = vec2(0.0, 0.0);
    vec2 pos = vUv;
    pos.x = 0.0; // Only consider vertical distance
    float distance = length(pos - center);
    
    // Calculate blur amount based on custom falloff
    float blurAmount = customSmoothstep(0.5, 1.0, distance * 2.0);
    blurAmount = clamp(blurAmount, 0.0, 1.0); // Ensure blur amount is within valid range

    // Gaussian blur
    vec2 pixelSize = 1.0 / vec2(textureSize(map, 0));
    vec4 color = vec4(0.0);
    float totalWeight = 0.0;
    int blurSize = int(60.0 * min(1.0, blurriness) * blurAmount); // Adjust blur size based on distance and blurriness
    float lodLevel = log2(float(blurSize)) * 0.5; // Compute LOD level

    for (int x = -blurSize; x <= blurSize; x++) {
        for (int y = -blurSize; y <= blurSize; y++) {
            vec2 offset = vec2(float(x), float(y)) * pixelSize * blurAmount;
            float weight = gaussian(length(vec2(float(x), float(y))), 1000.0 * blurAmount); // Use a fixed sigma value
            color += textureLod(map, vUv + offset, lodLevel) * weight;
            totalWeight += weight;
        }
    }

    color = totalWeight > 0.0 ? color / totalWeight : texture2D(map, vUv);

    gl_FragColor = color;

    float brightness = dot(gl_FragColor.rgb, vec3(0.299, 0.587, 0.114));
    float stepFactor = blending - brightness * .1;
    gl_FragColor.a = pow(1.0 - blending * customSmoothstep(0.35 * stepFactor, 0.45 * stepFactor, distance), 5.);
    gl_FragColor.a *= alphaFactor;
    // gl_FragColor.rgb = vec3(1.0);

    // #include <tonemapping_fragment>
    // #include <colorspace_fragment>
    
    // Uncomment to visualize blur amount
    // gl_FragColor = vec4(blurAmount, 0.0, 0.0, 1.0);
  }
`;