/**
 * 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 * as TDx from "../node_modules/@3dconnexion/3dconnexionjs/build/3dconnexion.module.js";
import { glMatrix, mat4, vec3, quat } from "../node_modules/gl-matrix/esm/index.js";

/**
 * NavigationModel2d
 *
 * Adapter between the viewport and the 3DconnexionJS driver.
 * Responsible for:
 *  - creating and exposing driver callbacks (connect, onConnect, on3dmouseCreated, etc.)
 *  - translating driver requests into viewport/camera queries (view extents, matrices, pointer)
 *  - driving the animation/render loop used while motion is active
 *
 * This class is passed to the _3Dconnexion client and implements the methods the
 * driver expects. Most methods are side-effecting (they call into the driver
 * or mutate the internal animating flag / request animation frames).
 *
 * Usage:
 *   const nav = new NavigationModel2d();
 *   nav.connect(viewport);
 *
 * @class
 */
class NavigationModel2d {
    #viewport = null;
    #spaceMouse;
    #coordinateSystem = [1, 0, 0, 0, 0, -1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1];
    #constructionPlane = [0, 0, -1, 0];
    #camera = null;
    #zoom_factor = 1;

    /**
     * Construct a NavigationModel2d and create the underlying 3Dconnexion client.
     */
    constructor() {
        glMatrix.setMatrixArrayType(Array);
        this.#camera = new Camera2d(this.#coordinateSystem);
        this.#spaceMouse = new TDx._3Dconnexion(this);
    }

    /**
     * Connects the navigation model to a viewport and a title and starts the driver connection.
     * @param {INavigation2d} viewport - The derived viewport instance that implements the INavigation2d interface.
     * @returns {boolean} True when connection succeeded (as returned by the driver).
     */
    connect(viewport) {
        this.#viewport = viewport;
        return this.#spaceMouse.connect() !== 0;
    }

    /**
     * Called by the driver when a connection is established.
     * Creates a focusable 3dmouse controller bound to the viewport canvas.
     * @sideEffects Calls driver API: create3dmouse(...)
     */
    onConnect() {
        // Create the 3D mouse controller and pass in a focusable element. Passing in a focusable element
        // will let the spacemouse focus follow the keyboard focus. If null is passed in then
        // this.#spaceMouse.focus() and this.#spaceMouse.blur() need to be explicitly invoked.
        this.#spaceMouse.create3dmouse(this.#viewport.focusable, this.#viewport.title);
    }

    /**
     * Called when the 3dmouse controller has been created.
     * Here the code sets the frame timing source and invokes the viewport's
     * on3dmouseCreated() allowing it to export commands for the 3dconnexion UI.
     * @sideEffects Calls driver API to update controller timimg source.
     */
    on3dmouseCreated() {
        this.#spaceMouse.update3dcontroller({
            frame: { timingSource: this.#viewport.timingSource },
        });

        this.#viewport.on3dmouseCreated();
    }

    /**
     * Driver disconnect callback.
     * @param {string} reason - Disconnect reason supplied by the driver.
     */
    onDisconnect(reason) {
        this.#viewport.onDisconnect(reason);
    }

    /**
     * Current zoom multiplier (read).
     * @returns {number}
     */
    get deltaZoom() {
        return this.#zoom_factor;
    }

    /**
     * Current zoom multiplier (write).
     * @param {number} factor
     */
    set deltaZoom(factor) {
        this.#zoom_factor = factor;
    }

    /**
     * Axis deltas computed from the last camera pose update.
     * @returns {number[]} Array of 6 axis values [dx, dy, dz, 0, 0, dRoll]
     */
    get axes() {
        return this.#camera.axes;
    }

    /**
     * Camera affine matrix (pose) exposed to the driver.
     * @returns {number[]} 4x4 column-major affine matrix.
     */
    get pose() {
        return this.#camera.pose;
    }

    /**
     * Timestamp of the last camera update (milliseconds, performance.now scale).
     * @returns {number}
     */
    get timestamp() {
        return this.#camera.timestamp;
    }

    // Below are the accessors used by the space mouse _3Dconnexion instance.

    /**
     * Indicates whether perspective projection is used.
     * @returns {boolean} false (this navigation model uses an orthographic projection).
     */
    getPerspective() {
        return false;
    }

    /**
     * Indicates whether the view supports rotation.
     * @returns {boolean} true when rotation is allowed.
     * @sideEffects returning false will disable all rotations.
     */
    getViewRotatable() {
        return true;
    }

    /**
     * Returns the sample's coordinate system matrix expected by the driver.
     * @returns {number[]} 4x4 column-major coordinate system matrix.
     */
    getCoordinateSystem() {
        return this.#coordinateSystem;
    }

    /**
     * Returns the construction plane used by the driver (optional).
     * @returns {number[]} Plane equation [a,b,c,d].
     * @sideEffects Even with getViewRotatable returning true, the driver
     * will only allow PI/2 rotations in the contruction plane when the normal
     * and camera lookat axis are coincidental.
     */
    getConstructionPlane() {
        // return this.#constructionPlane;
        return null;
    }

    getLookAt() {
        let lookAt = vec3.create();
        return this.#camera.getLookAt(lookAt);
    }

    /**
     * Returns model extents in world/image coordinates: [minX,minY,minZ,maxX,maxY,maxZ].
     * @returns {number[]} Extents for the model (image) space.
     */
    getModelExtents() {
        const bounds = this.#viewport.getImageBounds();
        // Due to a bug in navlib we need to give the model some thickness
        return [bounds.left, bounds.top, -0.00001, bounds.right, bounds.bottom, 0];
    }

    /**
     * Returns view extents used by the driver to compute zoom and pan speed:
     * [minX,minY,minZ,maxX,maxY,maxZ].
     * @returns {number[]}
     */
    getViewExtents() {
        const scale = this.#viewport.getScale();
        const half_width = this.#viewport.width / scale / 2;
        const half_height = this.#viewport.height / scale / 2;
        return [-half_width, -half_height, 0, half_width, half_height, 0];
    }

    /**
     * Queries the viewport for the current camera affine matrix.
     * Caller expects an array length 16.
     * @returns {number[]} The camera matrix (array length 16).
     */
    getViewMatrix() {
        const transform = this.#viewport.getTransform();
        this.#camera.setRotation(Math.atan2(transform.b, transform.a));

        let center = new DOMPoint(this.#viewport.width / 2, this.#viewport.height / 2);
        center = transform.invertSelf().transformPoint(center);
        this.#camera.setPosition(center.x, center.y);

        return this.#camera.pose;
    }

    /**
     * Sets the current view/camera from a driver-supplied matrix.
     * @param {number[]} data - Camera affine matrix to apply. (array length 16.)
     * @sideEffects Updates viewport camera state.
     */
    setViewMatrix(data) {
        this.#camera.pose = data;
    }

    /**
     * Sets the view extents. The driver may call this to update a new extents
     * (for example during zoom). This implementation updates the viewport zoom.
     * @param {number[]} extents - [minX,minY,minZ,maxX,maxY,maxZ]
     */
    setViewExtents(extents) {
        const scale = this.#viewport.getScale();
        this.#zoom_factor = this.#viewport.height / scale / 2 / extents[4];
    }

    /**
     * Returns the pointer position in world (image) coordinates.
     * Delegates to the viewport implementation.
     * @returns {number[]} A 3-element array [x, y, 0] with the pointer position in world coordinates.
     * @remarks The method is called by the navigation layer to get the
     * pointer position for AutoPivot and QuickZoom functions.
     */
    getPointerPosition() {
        const point = this.#viewport.getPointerPosition();
        return [point.x, point.y, point.z];
    }

    /**
     * Returns whether selection is empty.
     * @returns {boolean} Always true.
     * @remarks
     * The driver calls this method to determine if a selection exists.
     * The side effect of a false return value is that the driver will
     * query the selection extents and allow the user to rotate around
     * the selection center and zoom to the selection.
     */
    getSelectionEmpty() {
        return true;
    }

    /**
     * Sets the active command by its command ID.
     * @param {number} id - Command ID.
     * @sideEffects Executes the command in the viewport.
     * @remarks The driver calls this method to notify the application
     * when a button mapped to an action is pressed..
     * The application should execute the command identified by the ID.
     * The sample implementation delegates to the viewport's
     * executeCommand method.
     */
    setActiveCommand (id) {
        this.#viewport.executeCommand(id);
    }

    /**
     * Handles settings change event.
     * @param {number} change - Change number.
     * @sideEffects No-op.
     * @remarks The driver calls this method to notify the application
     * that the user changed settings in the 3Dconnexion properties dialog.
     */
    setSettingsChanged(change) {
        return;
    }

    /**
     * Called by the driver at the beginning and end of its transaction sequence.
     * When transaction === 0 this is the end of a frame; request a redraw if not animating.
     * @param {number} transaction - transaction state from driver.
     * @sideEffects Requests animation frame if needed.
     */
    setTransaction(transaction) {
        if (transaction === 0) {
            this.#viewport.draw();
        } else {
            this.#camera.timestamp = performance.now;
            this.#zoom_factor = 1;
        }
    }

    /**
     * Starts the internal animation loop (schedules render).
     * @sideEffects Sets animating flag and requests animation frames.
     * @remarks The driver calls this method to notify the application of the beginning
     * of a navigation sequence. In this sample the application supplies the frame timing.
     */
    onStartMotion() {
        this.#viewport.onStartMotion();
    }

    /**
     * Stops animation loop.
     * @sideEffects Clears animating flag.
     */
    onStopMotion() {
        this.#viewport.onStopMotion();
    }

    /**
     * Update the 3D controller properties on the server.
     *
     * This method forwards the supplied value object to the underlying
     * _3Dconnexion client's `update3dcontroller` method which performs a
     * WAMP RPC and returns a Promise that resolves when the update completes.
     *
     * @param {Object} value - Object describing properties to update on the 3D controller
     *                         (for example `{ frame: { time: 1234 } }` or `{ images: [...] }`).
     * @returns {Promise<any>} Promise resolving to the update result from the server.
     */
    update3dcontroller(value) {
        return this.#spaceMouse.update3dcontroller(value);
    }

    /**
     * Read a property from the 3D controller on the server.
     *
     * This method forwards the request to the underlying _3Dconnexion client's
     * `read3dcontroller` method which executes a WAMP RPC. The optional `onRead`
     * callback will be invoked with the RPC result when provided. The method
     * returns the Promise from the client so callers can await the value.
     *
     * @param {string} str - Property name to read (for example `"view.affine"` or `"pointer.position"`).
     * @param {function(any):void} [onRead] - Optional callback invoked with the result when the RPC completes.
     * @returns {Promise<any>} Promise resolving to the read result.
     */
    read3dcontroller(str, onRead) {
        return this.#spaceMouse.read3dcontroller(str, onRead);
    }
}

/**
 * The Camera2d class encapsulates a 2D camera's state and math.
 * It stores position and rotation and converts between that state and the 4x4
 * affine (look-at) matrices used by the spacemouse 3DconnexionJS code.
 *
 * @class Camera2d
 */
class Camera2d {
    #debug = false;
    #angle = 0;
    #position = vec3.create();

    #axes = [0, 0, 0, 0, 0, 0];

    #timestamp = performance.now();

    #yAxis = vec3.fromValues(0, 1, 0);
    #zAxis = vec3.fromValues(0, 0, 1);

    // This is used to transform the coordinate system back to that used in gl-matrix
    // to calculate the rotation angle.
    #q1Inv = quat.create();

    /**
     * Create a new Camera2d using the supplied coordinate system matrix.
     * @param {number[]} coordinateSystem - 4x4 column-major matrix describing sample coordinate system.
     */
    constructor(coordinateSystem) {
        // precompute the inverse of the coordinate system rotation
        let q1 = [];
        mat4.getRotation(q1, coordinateSystem);
        quat.invert(this.#q1Inv, q1);

        // transform the camera axes to the coordinate system
        vec3.transformMat4(this.#zAxis, this.#zAxis, coordinateSystem);
        vec3.transformMat4(this.#yAxis, this.#yAxis, coordinateSystem);
    }

    /**
     * Returns the last computed axes deltas.
     * @returns {number[]} Array of 6 axis delta values.
     */
    get axes() {
        return this.#axes;
    }

    /**
     * Computes the camera-to-world affine transform (look-at matrix).
     * @returns {mat4} a 4x4 matrix (length 16) populated in-place..
     * @remarks
     * Up vector is dynamically rotated about Z by current angle.
     * Caller should reuse the same matrix to avoid GC pressure.
     */
    get pose() {
        // position of the camera / eye.
        const eye = this.#position;

        // what the center of the camera is looking at.
        let target = vec3.create();
        vec3.subtract(target, eye, this.#zAxis);

        // the rotation around the z-axis as a quaternion.
        let rotation = [];
        quat.setAxisAngle(rotation, this.#zAxis, this.#angle);
        let up = [];

        // this.#yAxis is the camera up vector in world coordinates.
        // rotate it by the camera rotation angle around the z-axis.
        vec3.transformQuat(up, this.#yAxis, rotation);

        let affine = mat4.create();

        // create the 3d affine matrix.
        mat4.targetTo(affine, eye, target, up);

        return affine;
    }

    /**
     * Sets camera 2d translation and rotation from a 4x4 affine matrix.
     * @param {mat4} affine - Input matrix.
     * @sideEffects Updates internal camera position, rotation and axis deltas.
     */
    set pose(affine) {
        // Get the absolute translation
        let positionVector = vec3.create();
        mat4.getTranslation(positionVector, affine);
        if (this.#debug) {
            console.log(`new pos : ${positionVector}`);
            console.log(`old pos : ${this.#position}`);
        }
        let delta_pos = vec3.create();
        vec3.subtract(delta_pos, positionVector, this.#position);
        vec3.copy(this.#position, positionVector);
        if (this.#debug) {
            console.log(`delta_pos : ${delta_pos}`);
            console.log(`savedpos : ${this.#position}`);
        }

        this.#axes[0] = delta_pos[0];
        this.#axes[1] = delta_pos[1];
        this.#axes[2] = delta_pos[2];

        // get the absolute rotation
        let rotation = quat.create();
        mat4.getRotation(rotation, affine);

        // remove the rotation due to our coordinate system
        // see the coordinateSystem matrix above.
        quat.multiply(rotation, rotation, this.#q1Inv);
        let axis = vec3.create();
        let angle = quat.getAxisAngle(axis, rotation);
        angle *= vec3.dot(axis, this.#zAxis);

        let delta_roll = angle - this.#angle;
        this.#angle = angle;

        this.#axes[5] = delta_roll;
    }

    /**
     * Timestamp of the last camera update.
     * @returns {number}
     */
    get timestamp() {
        return this.#timestamp;
    }

    /**
     * Set the camera timestamp and reset axis deltas.
     * @param {number} time - Timestamp value (performance.now scale).
     */
    set timestamp(time) {
        this.#timestamp = time;
        this.#axes.fill(0);
    }

    /**
     * Returns the current camera rotation angle (radians).
     * @returns {number}
     */
    getRotation() {
        return this.angle;
    }

    /**
     * Sets the camera rotation angle.
     * @param {number} angle - Rotation in radians.
     */
    setRotation(angle) {
        this.angle = angle;
    }

    /**
     * Writes the camera translation / position to the provided vec3.
     * @param {vec3} position - Output receives [x, y, z].
     * @returns {vec3} The same vec3 for chaining.
     */
    getPosition(position) {
        position = this.#position;
        return position;
    }

    /**
     * Sets the camera position.
     * @param {number} x - X coordinate.
     * @param {number} y - Y coordinate.
     */
    setPosition(x, y) {
        this.#position[0] = x;
        this.#position[1] = y;
    }

    /**
     * Writes the position the camera is looking at to the provided vec3.
     * @param {vec3} lookAt - Output receives [x, y, z].
     * @returns {vec3} The same vec3 for chaining.
     */
    getLookAt(lookAt) {
        lookAt = this.#position;
        lookAt[2] = 0;
        return lookAt;
    }
}

export { NavigationModel2d };
