import { BackSide, CustomBlending, DoubleSide, FrontSide, Group, Material, Matrix4, MaxEquation, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshStandardMaterial, MinEquation, Object3D, OrthographicCamera, PlaneGeometry, RenderItem, ShaderMaterial, Vector3, Vector3Like, WebGLRenderTarget } from "three";
import { HorizontalBlurShader } from 'three/examples/jsm/shaders/HorizontalBlurShader.js';
import { VerticalBlurShader } from 'three/examples/jsm/shaders/VerticalBlurShader.js';

import { setAutoFitEnabled } from "../engine/engine_camera.js";
import { addComponent } from "../engine/engine_components.js";
import { Context } from "../engine/engine_context.js";
import { Gizmos } from "../engine/engine_gizmos.js";
import { onStart } from "../engine/engine_lifecycle_api.js";
import { serializable } from "../engine/engine_serialization_decorator.js";
import { getBoundingBox, getVisibleInCustomShadowRendering } from "../engine/engine_three_utils.js";
import { HideFlags, IGameObject, Vec3 } from "../engine/engine_types.js";
import { getParam } from "../engine/engine_utils.js"
import { setCustomVisibility } from "../engine/js-extensions/Layers.js";
import { Behaviour, GameObject } from "./Component.js";
import type { Light } from "./Light.js";
import type { Renderer } from "./Renderer.js";
import type { ShadowCatcher } from "./ShadowCatcher.js";

const debug = getParam("debugcontactshadows");

onStart(ctx => {
    const val = ctx.domElement.getAttribute("contactshadows") || ctx.domElement.getAttribute("contact-shadows");
    if (val != undefined && val != "0" && val != "false") {
        console.debug("Auto-creating ContactShadows because of `contactshadows` attribute");
        const shadows = ContactShadows.auto(ctx);
        const intensity = parseFloat(val);
        if (!isNaN(intensity)) {
            shadows.opacity = intensity;
            shadows.darkness = intensity;
        }
    }
});


type FitParameters = {
    object?: Object3D | Object3D[];
    positionOffset?: Partial<Vector3Like>;
    scaleFactor?: Partial<Vector3Like>;
}

// Adapted from https://github.com/mrdoob/three.js/blob/master/examples/webgl_shadow_contact.html.

// Improved with
// - ground occluder
// - backface shadowing (slightly less than front faces)
// - node can simply be scaled in Y to adjust max. ground height

/**
 * [ContactShadows](https://engine.needle.tools/docs/api/ContactShadows) renders proximity-based soft shadows on flat surfaces.
 * Ideal for products or objects that need visual grounding without real-time shadows.  
 * Produces soft, blurred shadows that hug the ground, giving a sense of contact and depth.     
 * 
 * ![](https://cloud.needle.tools/-/media/87bPTNXHcsbV-An-oSEvHQ.gif)
 *
 * **Setup options:**  
 * 1. `ContactShadows.auto(context)` - Auto-create and fit to scene  
 * 2. Add component manually to control position and scale  
 * 3. HTML attribute: `<needle-engine contactshadows="0.7">`  
 *
 * **Properties:**  
 * - `opacity` / `darkness` - Shadow intensity  
 * - `blur` - Softness of shadow edges
 * - Object scale defines shadow area size
 *
 * **Debug:** Use `?debugcontactshadows` URL parameter.  
 *
 *
 * @example Auto-create contact shadows
 * ```ts
 * const shadows = ContactShadows.auto(this.context);
 * shadows.opacity = 0.5;
 * shadows.darkness = 0.8;
 * ```
 *
 * @summary Display contact shadows on the ground
 * @category Rendering
 * @group Components
 * @see {@link ShadowCatcher} for real-time shadows from lights (more accurate, higher performance cost)
 * @see {@link Light} for real-time shadow casting
 * @see {@link Renderer} for material/rendering control
 * @link https://engine.needle.tools/samples/contact-shadows for a demo of contact shadows
 */
export class ContactShadows extends Behaviour {

    private static readonly _instances: Map<Context, ContactShadows> = new Map();
    /**
     * Create contact shadows for the scene. Automatically fits the shadows to the scene.  
     * The instance of contact shadows will be created only once.  
     * @param context The context to create the contact shadows in.
     * @returns The instance of the contact shadows.
     */
    static auto(context?: Context, params?: FitParameters): ContactShadows {
        if (!context) context = Context.Current;
        if (!context) {
            throw new Error("No context provided and no current context set.");
        }
        let instance = this._instances.get(context);
        if (!instance || instance.destroyed) {
            const obj = new Object3D();
            obj.name = "ContactShadows";
            instance = addComponent(obj, ContactShadows, {
                autoFit: false,
                occludeBelowGround: false
            });
            this._instances.set(context, instance);
        }
        context.scene.add(instance.gameObject);
        instance.fitShadows(params);
        return instance;
    }

    /**
     * When enabled the contact shadows component will be created to fit the whole scene.
     * @default false
     */
    @serializable()
    autoFit: boolean = false;
    /**
     * Darkness of the shadows. 
     * @default 0.5
     */
    @serializable()
    darkness: number = 0.5;
    /**
     * Opacity of the shadows. 
     * @default 0.5
     */
    @serializable()
    opacity: number = 0.5;
    /**
     * Blur of the shadows. 
     * @default 4.0
     */
    @serializable()
    blur: number = 4.0;
    /**
     * When enabled objects will not be visible below the shadow plane
     * @default false
     */
    @serializable()
    occludeBelowGround: boolean = false;
    /**
     * When enabled the backfaces of objects will cast shadows as well.
     * @default true
     */
    @serializable()
    backfaceShadows: boolean = true;

    /**
     * The minimum size of the shadows box
     * @default undefined
     */
    minSize?: Partial<Vec3>;

    /**
     * When enabled the shadows will not be updated automatically. Use `needsUpdate()` to update the shadows manually.
     * This is useful when you want to update the shadows only when the scene changes.
     * @default false
     */
    manualUpdate: boolean = false;
    /**
     * Call this method to update the shadows manually. The update will be done in the next frame.
     */
    set needsUpdate(val: boolean) {
        this._needsUpdate = val;
    }
    get needsUpdate(): boolean {
        return this._needsUpdate;
    }
    private _needsUpdate: boolean = false;

    /** All shadow objects are parented to this object. 
     * The gameObject itself should not be transformed because we want the ContactShadows object e.g. also have a GroundProjectedEnv component
     * in which case ContactShadows scale would affect the projection
     **/
    private readonly shadowsRoot: IGameObject = new Object3D() as IGameObject;
    private shadowCamera?: OrthographicCamera;
    private readonly shadowGroup: Group = new Group();

    private renderTarget?: WebGLRenderTarget;
    private renderTargetBlur?: WebGLRenderTarget;

    private plane?: Mesh;
    private occluderMesh?: Mesh;
    private blurPlane?: Mesh;

    private planeMaterial?: MeshBasicMaterial;
    private depthMaterial?: MeshDepthMaterial;
    private horizontalBlurMaterial?: ShaderMaterial;
    private verticalBlurMaterial?: ShaderMaterial;

    private textureSize = 512;

    /**
     * Call to fit the shadows to the scene.
     */
    fitShadows(params: FitParameters = {}) {
        if (debug) console.warn("Fitting shadows to scene");
        setAutoFitEnabled(this.shadowsRoot, false);
        const objectToFit = params.object || this.context.scene;
        const box = getBoundingBox(objectToFit, [this.shadowsRoot]);
        // expand box in all directions (except below ground)
        // 0.75 expands by 75% in each direction
        // The "32" is pretty much heuristically determined – adjusting the value until we don't get a visible border anymore.
        const expandFactor = Math.max(1, this.blur / 32);
        const sizeX = box.max.x - box.min.x;
        const sizeZ = box.max.z - box.min.z;
        box.expandByVector(new Vector3(expandFactor * sizeX, 0, expandFactor * sizeZ));
        if (debug) Gizmos.DrawWireBox3(box, 0xffff00, 60);
        if (this.gameObject.parent) {
            // transform box from world space into parent space
            box.applyMatrix4((this.gameObject.parent as GameObject).matrixWorld.clone().invert());
        }
        const min = box.min;
        const offset = Math.max(0.00001, (box.max.y - min.y) * .002);
        box.max.y += offset;
        // This is for cases where GroundProjection with autoFit is used
        // Since contact shadows can currently not ignore certain objects from rendering
        // we need to make sure the GroundProjection is not exactly on the same level as ContactShadows
        // We can't move GroundProjection down because of immersive-ar mesh/plane tracking where occlusion would otherwise hide GroundProjection
        this.shadowsRoot.position.set((min.x + box.max.x) / 2, min.y - offset, (min.z + box.max.z) / 2);
        this.shadowsRoot.scale.set(box.max.x - min.x, box.max.y - min.y, box.max.z - min.z);
        if (params.positionOffset) {
            if (params.positionOffset.x !== undefined) this.shadowsRoot.position.x += params.positionOffset.x;
            if (params.positionOffset.y !== undefined) this.shadowsRoot.position.y += params.positionOffset.y;
            if (params.positionOffset.z !== undefined) this.shadowsRoot.position.z += params.positionOffset.z;
        }
        if (params.scaleFactor) {
            if (params.scaleFactor.x !== undefined) this.shadowsRoot.scale.x *= params.scaleFactor.x;
            if (params.scaleFactor.y !== undefined) this.shadowsRoot.scale.y *= params.scaleFactor.y;
            if (params.scaleFactor.z !== undefined) this.shadowsRoot.scale.z *= params.scaleFactor.z;
        }
        this.applyMinSize();
        this.shadowsRoot.matrixWorldNeedsUpdate = true;
        if (debug) console.log("Fitted shadows to scene", this.shadowsRoot.scale.clone());
    }


    /** @internal */
    awake() {
        ContactShadows._instances.set(this.context, this);
        this.shadowsRoot.hideFlags = HideFlags.DontExport;

        // ignore self for autofitting
        setAutoFitEnabled(this.shadowsRoot, false);
    }


    /** @internal */
    start(): void {
        if (debug) console.log("Create ContactShadows on " + this.gameObject.name, this)

        this.gameObject.add(this.shadowsRoot);
        this.shadowsRoot.add(this.shadowGroup);

        // the render target that will show the shadows in the plane texture
        this.renderTarget = new WebGLRenderTarget(this.textureSize, this.textureSize);
        this.renderTarget.texture.generateMipmaps = false;

        // the render target that we will use to blur the first render target
        this.renderTargetBlur = new WebGLRenderTarget(this.textureSize, this.textureSize);
        this.renderTargetBlur.texture.generateMipmaps = false;

        // make a plane and make it face up
        const planeGeometry = new PlaneGeometry(1, 1).rotateX(Math.PI / 2);

        if (this.gameObject instanceof Mesh) {
            console.warn("ContactShadows can not be added to a Mesh. Please add it to a Group or an empty Object");
            // this.enabled = false;
            setCustomVisibility(this.gameObject, false);
            // this.plane = this.gameObject as any as Mesh;
            // // Make sure we clone the material once because it might be used on another object as well
            // const mat = this.plane.material = (this.plane.material as MeshBasicMaterial).clone();
            // mat.map = this.renderTarget.texture;
            // mat.opacity = this.opacity;
            // mat.transparent = true;
            // mat.depthWrite = false;
            // mat.needsUpdate = true;
            // When someone makes a custom mesh, they can set these values right on the material.
            // mat.opacity = this.state.plane.opacity;
            // mat.transparent = true;
            // mat.depthWrite = false;
        }

        const planeMaterial = this.planeMaterial = new MeshBasicMaterial({
            map: this.renderTarget.texture,
            opacity: this.opacity,
            color: 0x000000,
            transparent: true,
            depthWrite: false,
            side: FrontSide,
        });
        this.plane = new Mesh(planeGeometry, planeMaterial);
        this.plane.scale.y = - 1;
        this.plane.layers.set(2);
        this.shadowsRoot.add(this.plane);

        if (this.plane) this.plane.renderOrder = 1;

        this.occluderMesh = new Mesh(this.plane.geometry, new MeshBasicMaterial({
            depthWrite: true,
            stencilWrite: true,
            colorWrite: false,
            side: BackSide,
        }))
            // .rotateX(Math.PI)
            .translateY(-0.0001);
        this.occluderMesh.renderOrder = -100;
        this.occluderMesh.layers.set(2);
        this.shadowsRoot.add(this.occluderMesh);

        // the plane onto which to blur the texture
        this.blurPlane = new Mesh(planeGeometry);
        this.blurPlane.visible = false;
        this.shadowGroup.add(this.blurPlane);

        // max. ground distance is controlled via object scale
        const near = 0;
        const far = 1.0;
        this.shadowCamera = new OrthographicCamera(-1 / 2, 1 / 2, 1 / 2, -1 / 2, near, far);
        this.shadowCamera.layers.enableAll();
        this.shadowCamera.rotation.x = Math.PI / 2; // get the camera to look up
        // Disable automatic matrix world updates because Camera.updateMatrixWorld
        // resets scale to 1 which would break the shadow camera transform
        this.shadowCamera.matrixWorldAutoUpdate = false;
        this.shadowGroup.add(this.shadowCamera);
        // Update the local matrix after setting position/rotation
        this.shadowCamera.updateMatrix();

        // like MeshDepthMaterial, but goes from black to transparent
        this.depthMaterial = new MeshDepthMaterial();
        this.depthMaterial.userData.darkness = { value: this.darkness };
        // this will properly overlap calculated shadows
        this.depthMaterial.blending = CustomBlending;
        this.depthMaterial.blendEquation = MaxEquation;

        // this.depthMaterial.blendEquation = MinEquation;
        this.depthMaterial.onBeforeCompile = shader => {
            if (!this.depthMaterial) return;
            shader.uniforms.darkness = this.depthMaterial.userData.darkness;
            shader.fragmentShader = /* glsl */`
                uniform float darkness;
                ${shader.fragmentShader.replace(
                'gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );',
                // we're scaling the shadow value down a bit when it's a backface (looks better)
                'gl_FragColor = vec4( vec3( 1.0 ), ( 1.0 - fragCoordZ ) * darkness * opacity * (gl_FrontFacing ? 1.0 : 0.66) );'
            )}
            `;
        };

        this.depthMaterial.depthTest = false;
        this.depthMaterial.depthWrite = false;

        this.horizontalBlurMaterial = new ShaderMaterial(HorizontalBlurShader);
        this.horizontalBlurMaterial.depthTest = false;

        this.verticalBlurMaterial = new ShaderMaterial(VerticalBlurShader);
        this.verticalBlurMaterial.depthTest = false;

        this.shadowGroup.visible = false;

        if (this.autoFit) this.fitShadows();
        else this.applyMinSize();
    }

    onEnable(): void {
        this._needsUpdate = true;
    }

    /** @internal */
    onDestroy(): void {
        const instance = ContactShadows._instances.get(this.context);
        if (instance === this) {
            ContactShadows._instances.delete(this.context);
        }

        // dispose the render targets
        this.renderTarget?.dispose();
        this.renderTargetBlur?.dispose();

        // dispose the materials
        this.depthMaterial?.dispose();
        this.horizontalBlurMaterial?.dispose();
        this.verticalBlurMaterial?.dispose();

        // dispose the geometries
        this.blurPlane?.geometry.dispose();
        this.plane?.geometry.dispose();
        this.occluderMesh?.geometry.dispose();
    }

    /** @internal */
    onBeforeRender(_frame: XRFrame | null): void {

        if (this.manualUpdate) {
            if (!this._needsUpdate) return;
        }
        this._needsUpdate = false;

        if (!this.renderTarget || !this.renderTargetBlur ||
            !this.depthMaterial || !this.shadowCamera ||
            !this.blurPlane || !this.shadowGroup || !this.plane ||
            !this.horizontalBlurMaterial || !this.verticalBlurMaterial || !this.planeMaterial) {
            if (debug) console.error("ContactShadows: not initialized yet");
            return;
        }

        // Update properties that might have been changed
        this.depthMaterial.userData.darkness.value = this.darkness;
        this.planeMaterial.opacity = this.opacity;


        const scene = this.context.scene;
        const renderer = this.context.renderer;
        const initialRenderTarget = renderer.getRenderTarget();

        // Idea: shear the shadowCamera matrix to add some light direction to the ground shadows
        /*
        const mat = this.shadowCamera.projectionMatrix.clone();
        this.shadowCamera.projectionMatrix.multiply(new Matrix4().makeShear(0, 0, 0, 0, 0, 0));
        */

        this.shadowGroup.visible = true;
        if (this.occluderMesh) this.occluderMesh.visible = false;
        const planeWasVisible = this.plane.visible;
        this.plane.visible = false;

        if (this.gameObject instanceof Mesh) {
            // this.gameObject.visible = false;
            setCustomVisibility(this.gameObject, false);
        }

        // remove the background
        const initialBackground = scene.background;
        scene.background = null;

        // force the depthMaterial to everything
        scene.overrideMaterial = this.depthMaterial;
        if (this.backfaceShadows)
            this.depthMaterial.side = DoubleSide;
        else {
            this.depthMaterial.side = FrontSide;
        }

        // set renderer clear alpha
        const initialClearAlpha = renderer.getClearAlpha();
        renderer.setClearAlpha(0);

        const prevXRState = renderer.xr.enabled;
        renderer.xr.enabled = false;

        const prevSceneMatrixAutoUpdate = this.context.scene.matrixWorldAutoUpdate;
        this.context.scene.matrixWorldAutoUpdate = false;

        const list = renderer.renderLists.get(scene, 0);
        const prevTransparent = list.transparent;
        empty_buffer.length = 0;
        list.transparent = empty_buffer;

        // we need to hide objects that don't render color or that are wireframes
        objects_hidden.length = 0;
        for (const entry of list.opaque) {
            if (!entry.object.visible) continue;
            const mat = entry.material as MeshStandardMaterial;
            // Ignore objects that don't render color
            let hide = entry.material.colorWrite == false || mat.wireframe === true || getVisibleInCustomShadowRendering(entry.object) === false;
            // Ignore line materials (e.g. GridHelper)
            if (!hide && (entry.material["isLineMaterial"]))
                hide = true;
            // Ignore point materials
            if (!hide && (entry.material["isPointsMaterial"]))
                hide = true;
            if (hide) {
                objects_hidden.push(entry.object);
                entry.object["needle:visible"] = entry.object.visible;
                entry.object.visible = false;
            }
        }

        // Manually update shadow camera's matrix world without calling Camera.updateMatrixWorld
        // (which would reset scale to 1 for glTF conformance)
        if (this.shadowCamera.parent) {
            this.shadowCamera.matrixWorld.multiplyMatrices(this.shadowCamera.parent.matrixWorld, this.shadowCamera.matrix);
        } else {
            this.shadowCamera.matrixWorld.copy(this.shadowCamera.matrix);
        }
        this.shadowCamera.matrixWorldInverse.copy(this.shadowCamera.matrixWorld).invert();

        // render to the render target to get the depths
        renderer.setRenderTarget(this.renderTarget);
        renderer.clear();
        renderer.render(scene, this.shadowCamera);
        list.transparent = prevTransparent;

        // reset previously hidden objects
        for (const object of objects_hidden) {
            if (object["needle:visible"] != undefined) {
                object.visible = object["needle:visible"];
            }
        }

        // for the shearing idea
        // this.shadowCamera.projectionMatrix.copy(mat);

        // and reset the override material
        scene.overrideMaterial = null;

        const blurAmount = Math.max(this.blur, 0.05);

        // two-pass blur to reduce the artifacts
        this.blurShadow(blurAmount * 2);
        this.blurShadow(blurAmount * 0.5);

        this.shadowGroup.visible = false;
        if (this.occluderMesh) this.occluderMesh.visible = this.occludeBelowGround;
        this.plane.visible = planeWasVisible;

        // reset and render the normal scene
        renderer.setRenderTarget(initialRenderTarget);
        renderer.setClearAlpha(initialClearAlpha);
        scene.background = initialBackground;
        renderer.xr.enabled = prevXRState;
        this.context.scene.matrixWorldAutoUpdate = prevSceneMatrixAutoUpdate;
    }

    // renderTarget --> blurPlane (horizontalBlur) --> renderTargetBlur --> blurPlane (verticalBlur) --> renderTarget
    private blurShadow(amount: number) {
        if (!this.blurPlane || !this.shadowCamera ||
            !this.renderTarget || !this.renderTargetBlur ||
            !this.horizontalBlurMaterial || !this.verticalBlurMaterial)
            return;

        this.blurPlane.visible = true;

        // Correct for contact shadow plane aspect ratio.
        // since we have a separable blur, we can just adjust the blur amount for X and Z individually
        const ws = this.shadowsRoot.worldScale;
        const avg = (ws.x + ws.z) / 2;
        const aspectX = ws.z / avg;
        const aspectZ = ws.x / avg;

        // blur horizontally and draw in the renderTargetBlur
        this.blurPlane.material = this.horizontalBlurMaterial;
        (this.blurPlane.material as ShaderMaterial).uniforms.tDiffuse.value = this.renderTarget.texture;
        this.horizontalBlurMaterial.uniforms.h.value = amount * 1 / this.textureSize * aspectX;

        const renderer = this.context.renderer;

        const currentRt = renderer.getRenderTarget();
        renderer.setRenderTarget(this.renderTargetBlur);
        renderer.render(this.blurPlane, this.shadowCamera);

        // blur vertically and draw in the main renderTarget
        this.blurPlane.material = this.verticalBlurMaterial;
        (this.blurPlane.material as ShaderMaterial).uniforms.tDiffuse.value = this.renderTargetBlur.texture;
        this.verticalBlurMaterial.uniforms.v.value = amount * 1 / this.textureSize * aspectZ;

        renderer.setRenderTarget(this.renderTarget);
        renderer.render(this.blurPlane, this.shadowCamera);

        this.blurPlane.visible = false;

        renderer.setRenderTarget(currentRt);
    }

    private applyMinSize() {
        if (this.minSize) {
            this.shadowsRoot.scale.set(
                Math.max(this.minSize.x || 0, this.shadowsRoot.scale.x),
                Math.max(this.minSize.y || 0, this.shadowsRoot.scale.y),
                Math.max(this.minSize.z || 0, this.shadowsRoot.scale.z)
            );
        }
    }
}

const empty_buffer = [];
const objects_hidden = new Array<Object3D>();

