import type { Effect, EffectComposer } from "postprocessing";

import { isDevEnvironment, showBalloonMessage } from "../../engine/debug/index.js";
import { Context } from "../../engine/engine_context.js";
import type { EditorModification, IEditorModification as IEditorModificationReceiver } from "../../engine/engine_editor-sync.js";
import { serializeable } from "../../engine/engine_serialization_decorator.js";
import { DeviceUtilities, getParam } from "../../engine/engine_utils.js";
import { Behaviour } from "../Component.js";
import { EffectWrapper } from "./Effects/EffectWrapper.js";
import { PostProcessingEffect } from "./PostProcessingEffect.js";
import { PostProcessingHandler } from "./PostProcessingHandler.js";
import { IPostProcessingManager, setPostprocessingManagerType } from "./utils.js";
import { VolumeParameter } from "./VolumeParameter.js";
import { VolumeProfile } from "./VolumeProfile.js";

const debug = getParam("debugpost");


/** [Volume](https://engine.needle.tools/docs/api/Volume) The Volume/PostprocessingManager component is responsible for managing post processing effects.  
 * Add this component to any object in your scene to enable post processing effects.  
 * 
 * @example Add bloom
 * ```ts
 * const volume = new Volume();
 * volume.addEffect(new BloomEffect({
 *   intensity: 3,
 *   luminanceThreshold: .2
 * }));
 * gameObject.addComponent(volume);
 * ```
 * 
 * @example Remove bloom
 * ```ts
 * volume.removeEffect(bloom);
 * ```
 * 
 * @example Add pixelation
 * ```ts
 * const pixelation = new PixelationEffect();
 * pixelation.granularity.value = 10;
 * volume.addEffect(pixelation);
 * ```
 * 
 * @summary Manage Post-Processing Effects
 * @category Rendering
 * @category Effects
 * @group Components
 */
export class Volume extends Behaviour implements IEditorModificationReceiver, IPostProcessingManager {

    get isPostProcessingManager() {
        return true;
    }

    /** Currently active postprocessing effects */
    get effects() {
        return this._activeEffects;
    }

    get dirty() { return this._isDirty; }
    set dirty(value: boolean) { this._isDirty = value; }

    @serializeable(VolumeProfile)
    sharedProfile?: VolumeProfile;

    /**
     * Set multisampling to "auto" to automatically adjust the multisampling level based on performance.   
     * Set to a number to manually set the multisampling level.
     * @default "auto"
     * @min 0
     * @max renderer.capabilities.maxSamples
     */
    @serializeable()
    multisampling: "auto" | number = "auto";

    /**
     * Add a post processing effect to the stack and schedules the effect stack to be re-created.  
     */
    addEffect<T extends PostProcessingEffect | Effect>(effect: T & { order?: number }): T {
        let entry = effect as PostProcessingEffect;
        if (!(entry instanceof PostProcessingEffect)) {
            entry = new EffectWrapper(entry);
            if (typeof effect.order === "number") entry.order = effect.order;
        }
        if (entry.gameObject === undefined) this.gameObject.addComponent(entry);
        if (this._effects.includes(entry)) return effect;
        this._effects.push(entry);
        this._isDirty = true;
        return effect;
    }
    /**
     * Remove a post processing effect from the stack and schedules the effect stack to be re-created.
     */
    removeEffect<T extends PostProcessingEffect | Effect>(effect: T): T {

        let index = -1;
        if (!(effect instanceof PostProcessingEffect)) {
            index = this._effects.findIndex(e => e instanceof EffectWrapper && e.effect === effect);
        }
        else {
            index = this._effects.indexOf(effect);
        }

        if (index !== -1) {
            this._effects.splice(index, 1);
            this._isDirty = true;
            return effect;
        }
        else if (effect instanceof PostProcessingEffect) {
            // if the effect is part of the shared profile remove it from there
            const si = this.sharedProfile?.components?.indexOf(effect);
            if (si !== undefined && si !== -1) {
                this._isDirty = true;
                this.sharedProfile?.components?.splice(si, 1);
            }
        }

        return effect;
    }

    private _postprocessing?: PostProcessingHandler;
    private readonly _activeEffects: PostProcessingEffect[] = [];
    private readonly _effects: PostProcessingEffect[] = [];

    /**
     * When dirty the post processing effects will be re-applied
     */
    markDirty(): void {
        this._isDirty = true;
    }

    /** @internal */
    awake() {
        if (debug) {
            console.log("PostprocessingManager Awake", this);
            console.log("Press P to toggle post processing");
            window.addEventListener("keydown", (e) => {
                if (e.key === "p") {
                    this.enabled = !this.enabled;
                    showBalloonMessage("Toggle PostProcessing " + this.name + ": Enabled=" + this.enabled);
                    this.markDirty();
                }
            });
        }

        // ensure the profile is initialized
        this.sharedProfile?.__init(this);
    }

    private _componentEnabledTime: number = -1;
    private _multisampleAutoChangeTime: number = 0;
    private _multisampleAutoDecreaseTime: number = 0;

    /** @internal */
    onEnable(): void {
        this._componentEnabledTime = this.context.time.realtimeSinceStartup;
        this._isDirty = true;
    }

    /** @internal */
    onDisable() {
        this._postprocessing?.unapply();
        this._isDirty = false;
    }

    /** @internal */
    onBeforeRender(): void {
        if (!this.context.isInXR) {

            // TODO: not sure when this was/could be the case? Maybe when using the default three composer?
            // if (this.context.composer && (this.context.composer instanceof EffectComposer) === false) {
            //     if (debug) console.warn("PostProcessing: The current composer is not an EffectComposer - this is not supported");
            //     return;
            // }

            // Wait for the first frame to be rendered before creating because then we know we have a camera (issue 135)
            if (this.context.mainCamera) {
                if (this._isDirty) {
                    this.apply();
                }
            }

            if (this.context.composer && this._postprocessing && this._postprocessing.composer === this.context.composer) {
                if (this.context.renderer.getContext().isContextLost()) {
                    this.context.renderer.forceContextRestore();
                }
                if (this.context.composer.getRenderer() !== this.context.renderer)
                    this.context.composer.setRenderer(this.context.renderer);

                this.context.composer.setMainScene(this.context.scene);

                if (this.multisampling === "auto") {

                    // If the postprocessing handler is using depth+normals (e.g. with SMAA) we ALWAYS disable multisampling to avoid ugly edges
                    if (this._postprocessing && (this._postprocessing.hasSmaaEffect)) {
                        if (this._postprocessing.multisampling !== 0) {
                            this._postprocessing.multisampling = 0;
                            if (debug || isDevEnvironment()) {
                                console.log(`[PostProcessing] multisampling is disabled because it's set to 'auto' on your PostprocessingManager/Volume component that also has an SMAA effect.\n\nIf you need multisampling consider changing 'auto' to a fixed value (e.g. 4).`);
                            }
                        }
                    }
                    else {
                        const timeSinceLastChange = this.context.time.realtimeSinceStartup - this._multisampleAutoChangeTime;

                        if (this.context.time.realtimeSinceStartup - this._componentEnabledTime > 2
                            && timeSinceLastChange > .5
                        ) {
                            const prev = this._postprocessing.multisampling;

                            if (this._postprocessing.multisampling > 0 && this.context.time.smoothedFps <= 50) {
                                this._multisampleAutoChangeTime = this.context.time.realtimeSinceStartup;
                                this._multisampleAutoDecreaseTime = this.context.time.realtimeSinceStartup;
                                let newMultiSample = this._postprocessing.multisampling * .5;
                                newMultiSample = Math.floor(newMultiSample);
                                if (newMultiSample != this._postprocessing.multisampling) {
                                    this._postprocessing.multisampling = newMultiSample;
                                }
                                if (debug) console.debug(`[PostProcessing] Reduced multisampling from ${prev} to ${this._postprocessing.multisampling}`);
                            }
                            // if performance is good for a while try increasing multisampling again
                            else if (timeSinceLastChange > 1
                                && this.context.time.smoothedFps >= 59
                                && this._postprocessing.multisampling < this.context.renderer.capabilities.maxSamples
                                && this.context.time.realtimeSinceStartup - this._multisampleAutoDecreaseTime > 10
                            ) {
                                this._multisampleAutoChangeTime = this.context.time.realtimeSinceStartup;
                                let newMultiSample = this._postprocessing.multisampling <= 0 ? 1 : this._postprocessing.multisampling * 2;
                                newMultiSample = Math.floor(newMultiSample);
                                if (newMultiSample !== this._postprocessing.multisampling) {
                                    this._postprocessing.multisampling = newMultiSample;
                                }
                                if (debug) console.debug(`[PostProcessing] Increased multisampling from ${prev} to ${this._postprocessing.multisampling}`);
                            }
                        }
                    }
                }
                else {
                    const newMultiSample = Math.max(0, Math.min(this.multisampling, this.context.renderer.capabilities.maxSamples));
                    if (newMultiSample !== this._postprocessing.multisampling)
                        this._postprocessing.multisampling = newMultiSample;
                }

                // only set the main camera if any pass has a different camera
                // trying to avoid doing this regularly since it involves doing potentially unnecessary work
                // https://github.com/pmndrs/postprocessing/blob/3d3df0576b6d49aec9e763262d5a1ff7429fd91a/src/core/EffectComposer.js#L406
                if (this.context.mainCamera) {
                    const passes = this.context.composer.passes;
                    for (const pass of passes) {
                        if (pass.mainCamera && pass.mainCamera !== this.context.mainCamera) {
                            this.context.composer.setMainCamera(this.context.mainCamera);
                            break;
                        }
                    }
                }
            }
        }
    }

    /** @internal */
    onDestroy(): void {
        this._postprocessing?.dispose();
    }

    private _lastApplyTime?: number;
    private _rapidApplyCount = 0;
    private _isDirty: boolean = false;

    private apply() {
        if (debug) console.log(`Apply PostProcessing "${this.name || "unnamed"}"`);

        if (isDevEnvironment()) {
            if (this._lastApplyTime !== undefined && Date.now() - this._lastApplyTime < 100) {
                this._rapidApplyCount++;
                if (this._rapidApplyCount === 5)
                    console.warn("Detected rapid post processing modifications - this might be a bug", this);
            }
            this._lastApplyTime = Date.now();
        }

        this._isDirty = false;

        this._activeEffects.length = 0;
        // get from profile
        if (this.sharedProfile?.components) {
            const comps = this.sharedProfile.components;
            for (const effect of comps) {
                if (effect.active && effect.enabled && !this._activeEffects.includes(effect))
                    this._activeEffects.push(effect);
            }
        }
        // add effects registered via code
        for (const effect of this._effects) {
            if (effect.active && effect.enabled && !this._activeEffects.includes(effect))
                this._activeEffects.push(effect);
        }

        if (this._activeEffects.length > 0) {
            if (!this._postprocessing)
                this._postprocessing = new PostProcessingHandler(this.context);

            this._postprocessing.apply(this._activeEffects)
                ?.then(() => {
                    if (!this.activeAndEnabled) return;

                    this._applyPostQueue();

                    if (this._postprocessing) {
                        if (this.multisampling === "auto") {
                            this._postprocessing.multisampling = DeviceUtilities.isMobileDevice()
                                ? 2
                                : 4;
                        }
                        else {
                            this._postprocessing.multisampling = Math.max(0, Math.min(this.multisampling, this.context.renderer.capabilities.maxSamples));
                        }
                        if (debug) console.debug(`[PostProcessing] Set multisampling to ${this._postprocessing.multisampling} (Is Mobile: ${DeviceUtilities.isMobileDevice()})`);
                    }
                    else if (debug) {
                        console.warn(`[PostProcessing] No composer found`);
                    }
                })
        }
        else {
            this._postprocessing?.unapply(false);
        }

    }

    private _applyPostQueue() {
        if (this._modificationQueue) {
            for (const entry of this._modificationQueue.values()) this.onEditorModification(entry);
            this._modificationQueue.clear();
        }
    }

    /** called from needle editor sync package if its active */
    onEditorModification(modification: EditorModification): void | boolean | undefined {

        if (modification.propertyName.startsWith("postprocessing.")) {

            if (!this._postprocessing) {
                if (!this._modificationQueue) this._modificationQueue = new Map<string, EditorModification>();
                this._modificationQueue.set(modification.propertyName, modification);
                return true;
            }

            if (!this._activeEffects?.length) return;
            const path = modification.propertyName.split(".");
            if (path.length === 3 || path.length === 4) {
                const componentName = path[1];
                const propertyName = path[2];
                for (const comp of this._activeEffects) {
                    if (comp.typeName?.toLowerCase() === componentName.toLowerCase()) {

                        if (propertyName === "active") {
                            comp.active = modification.value;
                            this.scheduleRecreate();
                            return;
                        }

                        // cache the volume parameters
                        if (!effectVolumeProperties.has(componentName)) {
                            const volumeParameterKeys = new Array<string>();
                            effectVolumeProperties.set(componentName, volumeParameterKeys);
                            const keys = Object.keys(comp);
                            for (const key of keys) {
                                const prop = comp[key];
                                if (prop instanceof VolumeParameter) {
                                    volumeParameterKeys.push(key);
                                }
                            }
                        }

                        if (effectVolumeProperties.has(componentName)) {
                            const paramName = propertyName.toLowerCase();
                            const volumeParameterKeys = effectVolumeProperties.get(componentName)!;
                            for (const key of volumeParameterKeys) {
                                if (key.toLowerCase() === paramName) {
                                    const prop = comp[key] as VolumeParameter;
                                    if (prop instanceof VolumeParameter) {
                                        const isActiveStateChange = path.length === 4 && path[3] === "active";
                                        if (isActiveStateChange) {
                                            prop.overrideState = modification.value;
                                            this.scheduleRecreate();
                                        }
                                        else if (prop && prop.value !== undefined) {
                                            prop.value = modification.value;
                                        }
                                    }
                                    return;
                                }
                            }
                        }

                        console.warn("Unknown modification", propertyName);
                        return;
                    }
                }
            }
            return true;
        }

        return false;
    }

    private _modificationQueue?: Map<string, EditorModification>;

    private _recreateId: number = -1;
    private scheduleRecreate() {
        // When the editor modifications come in with changed active effects we want/need to re-create the effects
        // We defer it slightly because multiple active changes could be made and we dont want to recreate the full effect stack multiple times
        const id = ++this._recreateId;
        setTimeout(() => {
            if (id !== this._recreateId) return;
            this.onDisable();
            this.onEnable();
        }, 200);
    }

}

/** cached VolumeParameter keys per object */
const effectVolumeProperties: Map<string, string[]> = new Map<string, string[]>();


setPostprocessingManagerType(Volume);

export { Volume as PostProcessingManager };