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

import { isDevEnvironment } from "../../engine/debug/debug.js";
// Type-only imports for TSDoc @see links
import type { Context } from "../../engine/engine_context.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 type { Camera as CameraComponent } from "../Camera.js";
import { Behaviour } from "../Component.js";
import type { DragControls } from "../DragControls.js";
import type { OrbitControls } from "../OrbitControls.js";
import type { SceneSwitcher } from "../SceneSwitcher.js";


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

/**
 * Defines how the {@link ViewBox} component applies camera framing adjustments.
 *
 * - `"continuous"`: Camera framing is continuously updated while the ViewBox is active. Use for animated or dynamic ViewBoxes.
 * - `"once"`: Camera framing is applied once when the ViewBox becomes active, then updates stop. Use for initial framing with subsequent user control.
 */
export type ViewBoxMode = "continuous" | "once";

/**
 * [ViewBox](https://engine.needle.tools/docs/api/ViewBox) automatically fits a defined box area into the camera view regardless of screen size or aspect ratio.  
 * This component is useful for framing characters, objects, or scenes in the center of the view while ensuring they remain fully visible.  
 * You can animate or scale the viewbox to create dynamic zoom effects, cinematic transitions, or responsive framing.  
 * 
 * [![](https://cloud.needle.tools/-/media/Thy6svVftsIC6Z_wIxUJMA.gif)](https://engine.needle.tools/samples/bike-scrollytelling-responsive-3d)
 *
 * The ViewBox component works by adjusting the camera's focus rect settings (offset and zoom) to ensure that the box defined by the
 * GameObject's position, rotation, and scale fits perfectly within the visible viewport. It supports different modes for one-time
 * fitting or continuous adjustment, making it versatile for both static compositions and animated sequences.
 *
 * **Key Features:**
 * - Automatically adjusts camera framing to fit the box area
 * - Works with any screen size and aspect ratio
 * - Supports one-time fitting or continuous updates
 * - Can be animated for dynamic zoom and framing effects
 * - Multiple ViewBoxes can be active, with the most recently enabled taking priority
 * - Handles camera positioning to ensure the box is visible (moves camera if inside the box)
 *
 * **Common Use Cases:**
 * - Character framing in cutscenes or dialogue
 * - Product showcases with guaranteed visibility
 * - Scrollytelling experiences with animated camera movements
 * - Responsive layouts that adapt to different screen sizes
 * - UI-driven camera transitions
 *
 * - [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 Basic setup - Add a ViewBox component to frame an object
 * ```ts
 * const viewBox = new Object3D();
 * viewBox.position.set(0, 1, 0); // Position the viewbox center
 * viewBox.scale.set(2, 2, 2);    // Define the box size
 * viewBox.addComponent(ViewBox, { debug: true });
 * scene.add(viewBox);
 * ```
 *
 * @example Animated ViewBox for zoom effects
 * ```ts
 * const viewBox = new Object3D();
 * viewBox.addComponent(ViewBox, { mode: "continuous" });
 * scene.add(viewBox);
 *
 * // Animate the viewbox scale over time
 * function update() {
 *   const scale = 1 + Math.sin(Date.now() * 0.001) * 0.5;
 *   viewBox.scale.setScalar(scale);
 * }
 * ```
 *
 * @example One-time fitting with user control afterwards
 * ```ts
 * const viewBox = new Object3D();
 * viewBox.addComponent(ViewBox, {
 *   mode: "once", // Fit once, then allow free camera control
 *   referenceFieldOfView: 60
 * });
 * scene.add(viewBox);
 * ```
 *
 * @see {@link CameraComponent} - The camera component that ViewBox controls
 * @see {@link OrbitControls} - Camera controls that work alongside ViewBox
 * @see {@link DragControls} - Alternative camera controls compatible with ViewBox
 * @see {@link SceneSwitcher} - Can be combined with ViewBox for scene transitions
 * @see {@link Context.setCameraFocusRect} - The underlying focus rect API used by ViewBox
 * @see {@link Context.focusRectSettings} - Manual control of focus rect settings
 * @see {@link ViewBoxMode} - The mode type for controlling ViewBox behavior
 *
 * @summary Automatically fits a box area into the camera view
 * @category Camera and Controls
 * @group Components
 * @component
 */
@registerType
export class ViewBox extends Behaviour {

    /**
     * Array of all active ViewBox instances in the scene.
     * When multiple ViewBoxes are enabled, the last one in the array (most recently enabled) takes priority and controls the camera.
     * Other ViewBoxes remain registered but inactive, displayed with a dimmed gizmo color when debug visualization is enabled.
     */
    static readonly instances: ViewBox[] = [];

    /**
     * The reference field of view (in degrees) used to calculate how the box should fit within the camera view.
     * This determines the baseline camera FOV for fitting calculations.
     *
     * **Behavior:**
     * - If set to `-1` (default), the component will automatically use the camera's FOV on the first frame
     * - Should typically match your camera's FOV for predictable framing
     * - Can be set to a different value to create specific framing effects
     *
     * **Example:**
     * If your camera has an FOV of 60° and you set `referenceFieldOfView` to 60, the ViewBox will fit objects
     * as they would appear with that field of view. Setting it to a wider FOV (e.g., 90) makes objects appear
     * smaller, while a narrower FOV (e.g., 30) makes them appear larger.
     *
     * @see {@link CameraComponent} for the camera component and its FOV property
     * @default -1 (automatically uses the camera's FOV on the first frame)
     */
    @serializable()
    referenceFieldOfView: number = -1;

    /**
     * Controls how the ViewBox applies camera adjustments.
     *
     * **Modes:**
     * - `"once"`: Applies the framing adjustment once when the ViewBox becomes active, then stops updating.
     *   This is ideal when you want to frame the view initially but allow users to freely zoom, pan, or orbit afterwards.
     *   Perfect for interactive scenes where you want a good starting view but full user control.
     *
     * - `"continuous"`: Continuously updates the camera framing while this ViewBox is active.
     *   Use this when animating or scaling the ViewBox over time, or when you need the framing to constantly adjust.
     *   Great for cutscenes, scrollytelling, or any scenario with animated ViewBoxes.
     *
     * **Example Use Cases:**
     * - Set to `"once"` for: Initial scene framing, product showcases where users explore freely after initial framing
     * - Set to `"continuous"` for: Animated zoom effects, scrollytelling sequences, dynamic camera movements tied to ViewBox transforms
     *
     * @see {@link ViewBoxMode} for the type definition
     * @default "continuous"
    */
    @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";

    /**
     * Enables debug visualization and logging for this ViewBox instance.
     *
     * **When enabled, you will see:**
     * - A yellow wireframe box showing the active ViewBox bounds in 3D space
     * - Gray wireframe boxes for inactive ViewBox instances
     * - A red dashed outline on screen showing the projected box in 2D (when using `?debugviewbox` URL parameter)
     * - Console logs for mode changes, FOV settings, and camera adjustments
     *
     * **Tip:** You can also enable debug visualization globally for all ViewBoxes by adding `?debugviewbox` to your URL.
     *
     * @see {@link Gizmos} for the gizmo rendering system used for debug visualization
     * @default false
     */
    @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;




}