import { Camera, Matrix4, PerspectiveCamera, Vector3 } from "three";

import { isDevEnvironment } from "../../engine/debug/debug.js";
import { Gizmos } from "../../engine/engine_gizmos.js";
import { serializable } from "../../engine/engine_serialization_decorator.js";
import { getTempVector } from "../../engine/engine_three_utils.js";
import { registerType } from "../../engine/engine_typestore.js";
import { getParam } from "../../engine/engine_utils.js";
import { RGBAColor } from "../../engine/js-extensions/RGBAColor.js";
import { Behaviour } from "../Component.js";


const debugParam = getParam("debugviewbox");
const disabledGizmoColor = new RGBAColor(.5, .5, .5, .5);

export type ViewBoxMode = "continuous" | "once";

/**
 * This component can be used to automatically fit a certain box area into the camera view - no matter your screen size or aspect ratio.  
 * This is useful for example to frame a character or object in the center of the screen and ensure it is always fully visible. You can also animate or scale the viewbox to create zoom or framing effects.
 * 
 * - [Example on needle.run](https://viewbox-demo-z23hmxbz2gkayo-z1nyzm6.needle.run/)
 * - [Scrollytelling Demo using animated Viewbox](https://scrollytelling-bike-z23hmxb2gnu5a.needle.run/)   
 * - [Example on Stackblitz](https://stackblitz.com/edit/needle-engine-view-box-example)
 * 
 * @example Add a Viewbox component to an object in your scene
 * ```ts
  const viewBox = new Object3D();
  viewBox.scale.set(0, 0, 0);
  viewBox.addComponent(ViewBox, { debug: true });
  scene.add(viewBox);
 * ```
  
 * @summary Automatically fits a box area into the camera view
 * @category Camera
 * @group Components
 * @component
 */
@registerType
export class ViewBox extends Behaviour {

    /**
     * All active ViewBox instances. The last one in the array is the currently active one.
     */
    static readonly instances: ViewBox[] = [];

    /**
     * The reference field of view is used to calculate the box size. This should usually be the same as your camera's fov.
     * @default -1 (meaning it will use the camera fov on the first frame)
     */
    @serializable()
    referenceFieldOfView: number = -1;

    /**
     * The mode determines if the viewbox should be applied once or continuously while it is the active viewbox.  
     * Options:
     * - "once": The viewbox will be applied once when it becomes the active viewbox. This is useful if you want to fit the view once and then allow the user to zoom or pan freely.
     * - "continuous": The viewbox will be applied continuously while it is the active viewbox. This is useful if you animate or scale the viewbox over time.
    */
    @serializable()
    get mode() { return this._mode; }
    set mode(v: ViewBoxMode) {
        if (v === this._mode) return;
        this._mode = v;
        if (v === "once") this._applyCount = 0;
        if (debugParam || this.debug) console.debug("[ViewBox] Set mode:", v);
    }
    private _mode: ViewBoxMode = "continuous";

    /**
     * Enable debug logs and rendering for this component instance
     */
    @serializable()
    debug: boolean = false;

    /** @internal */
    onEnable(): void {
        if (debugParam || this.debug || isDevEnvironment()) console.debug("[ViewBox] Using camera fov:", this.referenceFieldOfView);
        // register instance
        ViewBox.instances.push(this);
        this._applyCount = 0;
        this.removeUpdateCallback();
        this.context.pre_render_callbacks.push(this.internalUpdate);
    }

    /** @internal */
    onDisable(): void {
        if (debugParam || this.debug) console.debug("[ViewBox] Disabled");
        // unregister instance
        const idx = ViewBox.instances.indexOf(this);
        if (idx !== -1) ViewBox.instances.splice(idx, 1);
        this._projectedBoxElement?.remove();
        this.removeUpdateCallback();
    }

    private removeUpdateCallback() {
        // remove prerender callback
        const cbIdx = this.context.pre_render_callbacks.indexOf(this.internalUpdate);
        if (cbIdx !== -1) this.context.pre_render_callbacks.splice(cbIdx, 1);
    }

    private static readonly _tempProjectionMatrix: Matrix4 = new Matrix4();
    private static readonly _tempProjectionMatrixInverse: Matrix4 = new Matrix4();
    private _applyCount = 0;

    private internalUpdate = () => {
        if (this.context.isInXR) return;
        if (this.destroyed || !this.activeAndEnabled) return;
        const isActive = ViewBox.instances[ViewBox.instances.length - 1] === this;
        if (!isActive) {
            if (debugParam || this.debug) {
                Gizmos.DrawWireBox(this.gameObject.worldPosition, this.gameObject.worldScale, disabledGizmoColor);
            }
            return;
        }
        if (debugParam || this.debug) Gizmos.DrawWireBox(this.gameObject.worldPosition, this.gameObject.worldScale, 0xdddd00, 0, true, this.gameObject.worldQuaternion);

        // calculate box size to fit the camera frustrum size at the current position (just scale)
        const camera = this.context.mainCamera;
        if (!camera) return;
        if (!(camera instanceof PerspectiveCamera)) {
            // TODO: support orthographic camera
            return;
        }

        if (this.referenceFieldOfView === undefined || this.referenceFieldOfView === -1) {
            this.referenceFieldOfView = camera.fov;
            console.debug("[ViewBox] No referenceFieldOfView set, using camera fov:", this.referenceFieldOfView);
        }

        if (this.referenceFieldOfView === undefined || this.referenceFieldOfView <= 0) {
            if (debugParam || this.debug) console.warn("[ViewBox] No valid referenceFieldOfView set, cannot adjust box size:", this.referenceFieldOfView);
            return;
        }

        if (this._applyCount >= 1 && this.mode === "once") {
            return;
        }
        this._applyCount++;

        const domWidth = this.context.domWidth;
        const domHeight = this.context.domHeight;

        let rectWidth = domWidth;
        let rectHeight = domHeight;
        let diffWidth = 1;
        let diffHeight = 1;
        // use focus rect if available
        const focusRectSize = this.context.focusRectSize;
        if (focusRectSize) {
            rectWidth = focusRectSize.width;
            rectHeight = focusRectSize.height;
            diffWidth = domWidth / rectWidth;
            diffHeight = domHeight / rectHeight;
        }


        // Copy the projection matrix and restore values so we can reset it later
        ViewBox._tempProjectionMatrix.copy(camera.projectionMatrix);
        ViewBox._tempProjectionMatrixInverse.copy(camera.projectionMatrixInverse);
        const view = camera.view;
        const cameraZoom = camera.zoom;
        const aspect = camera.aspect;
        const fov = camera.fov;
        // Set values to default so we can calculate the box size correctly
        camera.view = null;
        camera.zoom = 1;
        camera.fov = this.referenceFieldOfView;
        camera.updateProjectionMatrix();


        const boxPosition = this.gameObject.worldPosition;
        const boxScale = this.gameObject.worldScale;

        const cameraPosition = camera.worldPosition;
        const distance = cameraPosition.distanceTo(boxPosition);


        // #region camera fixes
        // If the camera is inside the box, move it out
        const boxSizeMax = Math.max(boxScale.x, boxScale.y, boxScale.z);
        const direction = getTempVector(cameraPosition).sub(boxPosition);
        if (distance < boxSizeMax) {
            // move camera out of bounds
            if (this.debug || debugParam) console.warn("[ViewBox] Moving camera out of bounds", distance, "<", boxSizeMax);
            const positionDirection = getTempVector(direction);
            positionDirection.y *= .00000001; // stay on horizontal plane mostly
            positionDirection.normalize();
            const lengthToMove = (boxSizeMax - distance);
            const newPosition = cameraPosition.add(positionDirection.multiplyScalar(lengthToMove));
            camera.worldPosition = newPosition.lerp(cameraPosition, 1 - this.context.time.deltaTime);
        }

        // Ensure the camera looks at the ViewBox
        // TOOD: smooth lookat over multiple frames if we have multiple viewboxes
        // const dot = direction.normalize().dot(camera.worldForward);
        // if (dot < .9) {
        //     console.log(dot);
        //     const targetRotation = direction;
        //     const rotation = getTempQuaternion();
        //     rotation.setFromUnitVectors(camera.worldForward.multiplyScalar(-1), targetRotation);
        //     camera.worldQuaternion = rotation;
        //     camera.updateMatrixWorld();
        // }
        const boxPositionInCameraSpace = getTempVector(boxPosition);
        camera.worldToLocal(boxPositionInCameraSpace);
        camera.lookAt(boxPosition);
        camera.updateMatrixWorld();


        // #region calculate fit
        const vFOV = this.referenceFieldOfView * Math.PI / 180; // convert vertical fov to radians
        const height = 2 * Math.tan(vFOV / 2) * distance; // visible height
        const width = height * camera.aspect; // visible width

        const projectedBox = this.projectBoxIntoCamera(camera, 1);
        // return
        const boxWidth = (projectedBox.maxX - projectedBox.minX);
        const boxHeight = (projectedBox.maxY - projectedBox.minY);

        const scale = this.fit(
            boxWidth * camera.aspect,
            boxHeight,
            width / diffWidth,
            height / diffHeight
        );
        const zoom = scale / (height * .5);
        // console.log({ scale, width, height, boxWidth: boxWidth * camera.aspect, boxHeight, diffWidth, diffHeight, aspect: camera.aspect, distance })
        // this.context.focusRectSettings.zoom = 1.39;
        // if (!this.context.focusRect) this.context.setCameraFocusRect(this.context.domElement);
        // return
        const vec = getTempVector(boxPosition);
        vec.project(camera);
        this.context.focusRectSettings.offsetX = vec.x;
        this.context.focusRectSettings.offsetY = vec.y;
        this.context.focusRectSettings.zoom = zoom;
        // if we don't have a focus rect yet, set it to the dom element
        if (!this.context.focusRect) this.context.setCameraFocusRect(this.context.domElement);

        // Reset values
        camera.view = view;
        camera.zoom = cameraZoom;
        camera.aspect = aspect;
        camera.fov = fov;
        camera.projectionMatrix.copy(ViewBox._tempProjectionMatrix);
        camera.projectionMatrixInverse.copy(ViewBox._tempProjectionMatrixInverse);


        // BACKLOG: some code for box scale of an object (different component)
        // this.gameObject.worldScale = getTempVector(width, height, worldscale.z);
        // this.gameObject.scale.multiplyScalar(.98)
        // const minscale = Math.min(width, height);
        // console.log(width, height);
        // this.gameObject.worldScale = getTempVector(scale, scale, scale);
    }


    /**
     * Cover fit
     */
    private fit(width1: number, height1: number, width2: number, height2: number) {
        const scaleX = width2 / width1;
        const scaleY = height2 / height1;
        return Math.min(scaleX, scaleY);
    }



    private projectBoxIntoCamera(camera: Camera, _factor: number) {
        const factor = .5 * _factor;

        const corners = [
            getTempVector(-factor, -factor, -factor),
            getTempVector(factor, -factor, -factor),
            getTempVector(-factor, factor, -factor),
            getTempVector(factor, factor, -factor),
            getTempVector(-factor, -factor, factor),
            getTempVector(factor, -factor, factor),
            getTempVector(-factor, factor, factor),
            getTempVector(factor, factor, factor),
        ];
        let minX = Number.POSITIVE_INFINITY;
        let maxX = Number.NEGATIVE_INFINITY;
        let minY = Number.POSITIVE_INFINITY;
        let maxY = Number.NEGATIVE_INFINITY;
        for (let i = 0; i < corners.length; i++) {
            const c = corners[i];
            c.applyMatrix4(this.gameObject.matrixWorld);
            c.project(camera);
            if (c.x < minX) minX = c.x;
            if (c.x > maxX) maxX = c.x;
            if (c.y < minY) minY = c.y;
            if (c.y > maxY) maxY = c.y;
        }

        if (debugParam) {
            if (!this._projectedBoxElement) {
                this._projectedBoxElement = document.createElement("div");
            }
            if (this._projectedBoxElement.parentElement !== this.context.domElement)
                this.context.domElement.appendChild(this._projectedBoxElement);
            this._projectedBoxElement.style.position = "fixed";
            // dotted but with larger gaps
            this._projectedBoxElement.style.outline = "2px dashed rgba(255,0,0,.5)";
            this._projectedBoxElement.style.left = ((minX * .5 + .5) * this.context.domWidth) + "px";
            this._projectedBoxElement.style.top = ((-maxY * .5 + .5) * this.context.domHeight) + "px";
            this._projectedBoxElement.style.width = ((maxX - minX) * .5 * this.context.domWidth) + "px";
            this._projectedBoxElement.style.height = ((maxY - minY) * .5 * this.context.domHeight) + "px";
            this._projectedBoxElement.style.pointerEvents = "none";
            this._projectedBoxElement.style.zIndex = "1000";
        }


        return { minX, maxX, minY, maxY };

    }
    private _projectedBoxElement: HTMLElement | null = null;




}