/**
 * Copyright (c) 2025 3Dconnexion. All rights reserved.
 * License:
 *   This file in licensed under the terms of the '3Dconnexion
 *   Software Development Kit' license agreement found in the
 *   'LicenseAgreementSDK.txt' file.
 *   All rights not expressly granted by 3Dconnexion are reserved.
 *
 */

// @ts-check
import { Viewport } from "./viewport.js";
import { NavigationModel2d } from "./navigation2d.js";
import * as TDx from "../node_modules/@3dconnexion/3dconnexionjs/build/3dconnexion.module.js";

/**
 * Extend the original viewport and implement the navigation interface used by the
 * 3Dconnexion driver. `ViewportEx` reuses `Viewport` rendering and exposes
 * the small adapter interface required by `INavigation2d`.
 *
 * @extends Viewport
 * @implements {INavigation2d}
 * @class
 */
class ViewportEx extends Viewport {
    #debug = false;

    /** @type {number} The timeing source for the Spacemouse data. */
    #timingSource = 1;

    /** @type {DOMPoint} Last known pointer position in canvas coordinates. */
    #pointer = new DOMPoint(0, 0);

    /** @type {?NavigationModel2d} The navgation controller instance. */
    #spaceMouse = null;

    /** @type {boolean} True while the navigation-driven animation loop is active. */
    #isAnimating = false;

    /** @type {?HTMLElement} The html element used to determine spacemouse focus. */
    #focusable = null;
    /**
     * Create a `ViewportEx`.
     *
     * Note: constructor accepts the DOM element id for the canvas and wires a
     * mousemove handler so the pointer can be sampled outside of DOM events.
     *
     * @param {string} id - The id of the canvas element to bind.
     * @throws {Error} If an element with the provided `id` cannot be found.
     */
    constructor(id = "") {
        super({ canvasId: id });

        let element = document.getElementById(id);
        if (!element) {
            throw new Error(`Element with id ${id} not found`);
        }

        // Add mouse event handler so that we can get the pointer position
        // outside of a mouse event.
        element.addEventListener("mousemove", (e) => {
            this.#onMouseMove(e);
        });

        // here we use the canvas as the element used to determine focus (the default).
        this.#focusable = element;

        // Create the spacemouse instance.
        this.#spaceMouse = new NavigationModel2d();

        // Connect the spacemouse to the viewport.
        this.#spaceMouse.connect(this);
    }

    /**
     * Returns the current scale factor extracted from the canvas transform.
     *
     * @returns {number} Uniform scale factor (> 0).
     */
    getScale() {
        const transform = super.getTransform();
        const scale = Math.hypot(transform.a, transform.b);
        return scale;
    }

    /**
     * Returns the current rotation angle (radians) extracted from the canvas transform.
     *
     * @returns {number} Rotation in radians.
     */
    getRotation() {
        const transform = super.getTransform();
        return Math.atan2(transform.b, transform.a);
    }

    /**
     * Returns the current translation vector [x, y] from the canvas transform.
     *
     * @returns {number[]} Translation [x, y] in canvas coordinates.
     */
    getTranslation() {
        const transform = super.getTransform();
        let pos = [transform.e, transform.f];
        return pos;
    }

    /**
     * Element used by the spacemouse driver to determine focus. By default this
     * is the canvas element passed to the constructor.
     * Note: if no focusable is returned then the focus() and blur() methods in the
     * 3DconnexionJS instance need to be manually invoked to control spacemouse focus.
     *
     * @returns {?HTMLElement} Focusable element or null.
     */
    get focusable() {
        return this.#focusable;
    }

    /**
     * Title exposed to the 3Dconnexion driver. This is used in the controller UI
     * and to determine the 3Dconnexion configuration file.
     *
     * @returns {string} Human readable title for this viewport.
     */
    get title() {
        return "Spacemouse 2D";
    }

    /**
     * Get the timing source for the spacemouse events.
     * @returns {number} 0 = spacemouse, 1 = application.
     */
    get timingSource() {
        return this.#timingSource;
    }

    /**
     * Request a draw frame. If navigation is not animating this method schedules
     * a render; otherwise it returns and the render loop will render in the
     * requestAnimationFrame callback..
     *
     * Overridable hook: subclasses can replace this behavior if necessary.
     *
     * @returns {void}
     */
    draw() {
        if (!this.#isAnimating) {
            // Request an animation frame.
            requestAnimationFrame((time) => {
                this.#render(time);
            });
        }
    }

    /**
     * Returns the last known pointer position transformed into world (image)
     * coordinates using the current canvas transform.
     *
     * @returns {DOMPoint} Pointer in world coordinates.
     */
    getPointerPosition() {
        return super.getTransform().invertSelf().transformPoint(this.#pointer);
    }

    /**
     * Handler invoked when the 3Dconnexion controller has been created.
     * Prepares action images, command tree and registers them with the driver.
     *
     * @returns {void}
     */
    on3dmouseCreated() {
        const actionImages = new TDx._3Dconnexion.ImageCache();

        actionImages.onload = () => {
            this.#spaceMouse.update3dcontroller({
                images: actionImages.images,
            });
        };

        // An actionset can also be considered to be a buttonbank, a menubar, or a set of toolbars
        // Define a unique string for the action set to be able to specify the active action set
        // Because we only have one action set use the 'Default' action set id to not display the label
        let buttonBank = new TDx._3Dconnexion.ActionSet("Default", "Custom action set");

        getApplicationCommands(buttonBank, actionImages);

        let actionTree = new TDx._3Dconnexion.ActionTree();
        actionTree.push(buttonBank);

        // Expose the commands to 3Dxware and specify the active buttonbank / action set
        this.#spaceMouse.update3dcontroller({
            commands: { activeSet: "Default", tree: actionTree },
        });
    }

    /**
     * Called when the controller or connection disconnects.
     *
     * @param {string} reason - Human-readable reason supplied by the driver.
     * @returns {void}
     */
    onDisconnect(reason) {
        console.log("3Dconnexion spacemouse disconnected " + reason);
    }

    /**
     * Begin navigation-driven motion. Called by the navigation model to start
     * the animation/render loop.
     *
     * @returns {void}
     */
    onStartMotion() {
        if (!this.#isAnimating) {
            this.#isAnimating = true;

            // Request an animation frame for the next incoming transaction data.
            requestAnimationFrame((time) => {
                this.#render(time);
            });
        }
    }

    /**
     * End navigation-driven motion. Called by the navigation model to stop
     * the animation/render loop.
     *
     * @returns {void}
     */
    onStopMotion() {
        this.#isAnimating = false;
    }

    /**
     * Execute the command identified by `commandId`.
     * Called by the navigation model when a button mapped to an action is pressed.
     *
     * @param {string} commandId - The command identifier.
     * @returns {void}
     * @sideEffects May modify the viewport state.
     * @see getApplicationCommands
     * @see NavigationModel2d
     * */
    executeCommand(commandId) {
        if (this.#debug) {
            console.log(`Execute command: ${commandId}`);
        }
        if (commandId === "ID_ZOOMEXTENTS") {
            this.zoomExtents();
        }
    }

    /**
     * Main render loop: if animating, request driver updates and schedule the
     * next frame; always calls the base `draw()` to render the current scene.
     *
     * @param {number} time - Frame timestamp from `requestAnimationFrame`.
     * @returns {void}
     * @sideEffects Schedules next frame, updates controller, draws viewport.
     */
    #render(time) {
        const axes = this.#spaceMouse.axes;

        if (this.#debug) {
            console.log(
                `delta X: ${axes[0]}, Y: ${axes[1]}, deltaT: ${time - this.#spaceMouse.timestamp}`
            );
        }

        super.pan(-axes[0], -axes[1]);
        const scale = this.#spaceMouse.deltaZoom;

        let viewCenter = new DOMPoint(super.width / 2, super.height / 2);
        viewCenter = super.getTransform().invertSelf().transformPoint(viewCenter);
        super.roll(axes[5], viewCenter.x, viewCenter.y);

        super.scale(scale, viewCenter.x, viewCenter.y);

        // Render the current scene.
        super.draw();

        if (this.#isAnimating) {
            // Initiate a new frame transaction by updating the controller with the frame time.
            if (this.#timingSource !== 0) {
                this.#spaceMouse
                    .update3dcontroller({
                        frame: { time: time },
                    })
                    .catch(() => {
                        this.#isAnimating = false;
                        this.#spaceMouse.update3dcontroller({
                            motion: this.#isAnimating,
                        });
                    });
            }

            // Request an animation frame for the next incoming transaction data.
            requestAnimationFrame((time) => {
                this.#render(time);
            });
        }
    }

    /**
     * Mouse move handler — updates the stored pointer position in canvas
     * coordinates so `getPointerPosition()` can be called outside of an event.
     *
     * @param {MouseEvent} event - The mousemove event.
     * @returns {void}
     */
    #onMouseMove(event) {
        this.#pointer.x = event.offsetX;
        this.#pointer.y = event.offsetY;
    }
}

/**
 * Populate the provided action button bank and image cache with the
 * application-specific commands and images to be exposed to the 3Dconnexion properties UI.
 *
 * @param {TDx._3Dconnexion.ActionSet} buttonBank - ButtonBank / ActionSet to populate.
 * @param {TDx._3Dconnexion.ImageCache} images - ImageCache to populate with images referenced by actions.
 * @returns {void}
 */
function getApplicationCommands(buttonBank, images) {
    // Add a couple of categories / menus / tabs to the buttonbank/menubar/toolbar
    // Use the categories to group actions so that the user can find them easily
    let fileNode = buttonBank.push(new TDx._3Dconnexion.Category("CAT_ID_FILE", "File"));
    let editNode = buttonBank.push(new TDx._3Dconnexion.Category("CAT_ID_EDIT", "Edit"));
    let viewNode = buttonBank.push(new TDx._3Dconnexion.Category("CAT_ID_VIEW", "View"));

    // Add menu items / actions
    fileNode.push(new TDx._3Dconnexion.Action("ID_OPEN", "Open", "Open file"));
    fileNode.push(new TDx._3Dconnexion.Action("ID_CLOSE", "Close", "Close file"));
    fileNode.push(new TDx._3Dconnexion.Action("ID_EXIT", "Exit", "Exit program"));

    // Add menu items / actions
    editNode.push(new TDx._3Dconnexion.Action("ID_UNDO", "Undo", "Shortcut is Ctrl + Z"));
    editNode.push(new TDx._3Dconnexion.Action("ID_REDO", "Redo", "Shortcut is Ctrl + Y"));
    editNode.push(new TDx._3Dconnexion.Action("ID_CUT", "Cut", "Shortcut is Ctrl + X"));
    editNode.push(new TDx._3Dconnexion.Action("ID_COPY", "Copy", "Shortcut is Ctrl + C"));
    editNode.push(new TDx._3Dconnexion.Action("ID_PASTE", "Paste", "Shortcut is Ctrl + V"));

    viewNode.push(
        new TDx._3Dconnexion.Action(
            "ID_ZOOMEXTENTS",
            "Zoom Extents",
            "Fit the image to the canvas."
        )
    );

    // Now add the images to the cache and associate it with the menu item by using the same id
    // as the menu item / action. These images will be shown in the 3Dconnexion properties
    // editor and in the UI elements which display the active button configuration of the 3dmouse
    images.push(TDx._3Dconnexion.ImageItem.fromURL("images/open.png", "ID_OPEN"));
    images.push(TDx._3Dconnexion.ImageItem.fromURL("images/close.png", "ID_CLOSE"));
    images.push(TDx._3Dconnexion.ImageItem.fromURL("images/exit.png", "ID_EXIT"));
    images.push(TDx._3Dconnexion.ImageItem.fromURL("images/Macro_Cut.png", "ID_CUT"));
    images.push(TDx._3Dconnexion.ImageItem.fromURL("images/Macro_Copy.png", "ID_COPY"));
    images.push(TDx._3Dconnexion.ImageItem.fromURL("images/Macro_Paste.png", "ID_PASTE"));
    images.push(TDx._3Dconnexion.ImageItem.fromURL("images/Macro_Undo.png", "ID_UNDO"));
    images.push(TDx._3Dconnexion.ImageItem.fromURL("images/Macro_Redo.png", "ID_REDO"));
    images.push(TDx._3Dconnexion.ImageItem.fromURL("images/e9a6.png", "ID_ZOOMEXTENTS"));
}

export { ViewportEx };
