import { math } from '../../core/math/math.js';
import { Vec3 } from '../../core/math/vec3.js';
import { Mat4 } from '../../core/math/mat4.js';
import { Ray } from '../../core/shape/ray.js';
import { EventHandler } from '../../core/event-handler.js';
import { SORTMODE_NONE, PROJECTION_PERSPECTIVE } from '../../scene/constants.js';
import { Entity } from '../../framework/entity.js';
import { Layer } from '../../scene/layer.js';

/**
 * @import { AppBase } from '../../framework/app-base.js'
 * @import { CameraComponent } from '../../framework/components/camera/component.js';
 * @import { GraphNode } from '../../scene/graph-node.js'
 * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js'
 * @import { MeshInstance } from '../../scene/mesh-instance.js'
 * @import { EventHandle } from '../../core/event-handle.js'
 * @import { Shape } from './shape/shape.js'
 * @import { GizmoSpace } from './constants.js'
 */ // temporary variables
const v = new Vec3();
const position = new Vec3();
const angles = new Vec3();
const dir = new Vec3();
const m1 = new Mat4();
const m2 = new Mat4();
const ray = new Ray();
// constants
const MIN_SCALE = 1e-4;
const PERS_SCALE_RATIO = 0.3;
const ORTHO_SCALE_RATIO = 0.32;
const UPDATE_EPSILON = 1e-6;
const DIST_EPSILON = 1e-4;
/**
 * The base class for all gizmos.
 *
 * @category Gizmo
 */ class Gizmo extends EventHandler {
    static{
        /**
     * Fired when the pointer is down on the gizmo.
     *
     * @event
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.on('pointer:down', (x, y, meshInstance) => {
     *     console.log(`Pointer was down on ${meshInstance.node.name} at ${x}, ${y}`);
     * });
     */ this.EVENT_POINTERDOWN = 'pointer:down';
    }
    static{
        /**
     * Fired when the pointer is moving over the gizmo.
     *
     * @event
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.on('pointer:move', (x, y, meshInstance) => {
     *     console.log(`Pointer was moving on ${meshInstance.node.name} at ${x}, ${y}`);
     * });
     */ this.EVENT_POINTERMOVE = 'pointer:move';
    }
    static{
        /**
     * Fired when the pointer is up off the gizmo.
     *
     * @event
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.on('pointer:up', (x, y, meshInstance) => {
     *     console.log(`Pointer was up on ${meshInstance.node.name} at ${x}, ${y}`);
     * })
     */ this.EVENT_POINTERUP = 'pointer:up';
    }
    static{
        /**
     * Fired when the gizmo's position is updated.
     *
     * @event
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.on('position:update', (position) => {
     *     console.log(`The gizmo's position was updated to ${position}`);
     * })
     */ this.EVENT_POSITIONUPDATE = 'position:update';
    }
    static{
        /**
     * Fired when the gizmo's rotation is updated.
     *
     * @event
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.on('rotation:update', (rotation) => {
     *     console.log(`The gizmo's rotation was updated to ${rotation}`);
     * });
     */ this.EVENT_ROTATIONUPDATE = 'rotation:update';
    }
    static{
        /**
     * Fired when the gizmo's scale is updated.
     *
     * @event
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.on('scale:update', (scale) => {
     *     console.log(`The gizmo's scale was updated to ${scale}`);
     * });
     */ this.EVENT_SCALEUPDATE = 'scale:update';
    }
    static{
        /**
     * Fired when graph nodes are attached.
     *
     * @event
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.on('nodes:attach', () => {
     *     console.log('Graph nodes attached');
     * });
     */ this.EVENT_NODESATTACH = 'nodes:attach';
    }
    static{
        /**
     * Fired when graph nodes are detached.
     *
     * @event
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.on('nodes:detach', () => {
     *     console.log('Graph nodes detached');
     * });
     */ this.EVENT_NODESDETACH = 'nodes:detach';
    }
    static{
        /**
     * Fired when when the gizmo render has updated.
     *
     * @event
     * @example
     * const gizmo = new pc.TransformGizmo(camera, layer);
     * gizmo.on('render:update', () => {
     *     console.log('Gizmo render has been updated');
     * });
     */ this.EVENT_RENDERUPDATE = 'render:update';
    }
    /**
     * Creates a new gizmo layer and adds it to the scene.
     *
     * @param {AppBase} app - The app.
     * @param {string} [layerName] - The layer name. Defaults to 'Gizmo'.
     * @param {number} [layerIndex] - The layer index. Defaults to the end of the layer list.
     * @returns {Layer} The new layer.
     */ static createLayer(app, layerName = 'Gizmo', layerIndex = app.scene.layers.layerList.length) {
        const layer = new Layer({
            name: layerName,
            clearDepthBuffer: true,
            opaqueSortMode: SORTMODE_NONE,
            transparentSortMode: SORTMODE_NONE
        });
        app.scene.layers.insert(layer, layerIndex);
        return layer;
    }
    /**
     * Creates a new Gizmo object.
     *
     * @param {CameraComponent} camera - The camera component.
     * @param {Layer} layer - The render layer. This can be provided by the user or will be created
     * and added to the scene and camera if not provided. Successive gizmos will share the same layer
     * and will be removed from the camera and scene when the last gizmo is destroyed.
     * @param {string} [name] - The name of the gizmo. Defaults to 'gizmo'.
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     */ constructor(camera, layer, name = 'gizmo'){
        super(), /**
     * Internal version of the gizmo size. Defaults to 1.
     *
     * @type {number}
     * @private
     */ this._size = 1, /**
     * Internal version of the gizmo scale. Defaults to 1.
     *
     * @type {number}
     * @protected
     */ this._scale = 1, /**
     * Internal version of coordinate space. Defaults to 'world'.
     *
     * @type {GizmoSpace}
     * @protected
     */ this._coordSpace = 'world', /**
     * Internal list of app event handles for the gizmo.
     *
     * @type {EventHandle[]}
     * @protected
     */ this._handles = [], /**
     * The graph nodes attached to the gizmo.
     *
     * @type {GraphNode[]}
     */ this.nodes = [], /**
     * The intersection shapes for the gizmo.
     *
     * @type {Shape[]}
     */ this.intersectShapes = [];
        this._layer = layer;
        this._camera = camera;
        this._camera.layers = this._camera.layers.concat(this._layer.id);
        this._app = this._camera.system.app;
        this._device = this._app.graphicsDevice;
        this.root = new Entity(name);
        this._app.root.addChild(this.root);
        this.root.enabled = false;
        this._updateScale();
        this._onPointerDown = this._onPointerDown.bind(this);
        this._onPointerMove = this._onPointerMove.bind(this);
        this._onPointerUp = this._onPointerUp.bind(this);
        this._device.canvas.addEventListener('pointerdown', this._onPointerDown);
        this._device.canvas.addEventListener('pointermove', this._onPointerMove);
        this._device.canvas.addEventListener('pointerup', this._onPointerUp);
        this._handles.push(this._app.on('prerender', ()=>this.prerender()));
        this._handles.push(this._app.on('update', ()=>this.update()));
        this._handles.push(this._app.on('destroy', ()=>this.destroy()));
    }
    /**
     * Sets the gizmo enabled state.
     *
     * @type {boolean}
     */ set enabled(state) {
        const cameraDist = this.root.getLocalPosition().distance(this.camera.entity.getPosition());
        const enabled = state ? this.nodes.length > 0 && cameraDist > DIST_EPSILON : false;
        if (enabled !== this.root.enabled) {
            this.root.enabled = enabled;
            this.fire(Gizmo.EVENT_RENDERUPDATE);
        }
    }
    /**
     * Gets the gizmo enabled state.
     *
     * @type {boolean}
     */ get enabled() {
        return this.root.enabled;
    }
    /**
     * Sets the gizmo render layer.
     *
     * @param {Layer} layer - The layer to render the gizmo.
     */ set layer(layer) {
        if (this._layer === layer) {
            return;
        }
        this._camera.layers = this._camera.layers.filter((id)=>id !== this._layer.id);
        this._layer = layer;
        this._camera.layers = this._camera.layers.concat(this._layer.id);
        this.enabled = true;
    }
    /**
     * Gets the gizmo render layer.
     *
     * @type {Layer}
     */ get layer() {
        return this._layer;
    }
    /**
     * Sets the camera component to view the gizmo.
     *
     * @type {CameraComponent} camera - The camera component.
     */ set camera(camera) {
        if (this._camera === camera) {
            return;
        }
        this._camera.layers = this._camera.layers.filter((id)=>id !== this._layer.id);
        this._camera = camera;
        this._camera.layers = this._camera.layers.concat(this._layer.id);
        this.enabled = true;
    }
    /**
     * Gets the camera component to view the gizmo.
     *
     * @type {CameraComponent} The camera component.
     */ get camera() {
        return this._camera;
    }
    /**
     * Sets the gizmo coordinate space. Defaults to 'world'
     *
     * @type {GizmoSpace}
     */ set coordSpace(value) {
        this._coordSpace = value ?? this._coordSpace;
        this._updateRotation();
    }
    /**
     * Gets the gizmo coordinate space.
     *
     * @type {GizmoSpace}
     */ get coordSpace() {
        return this._coordSpace;
    }
    /**
     * Sets the gizmo size. Defaults to 1.
     *
     * @type {number}
     */ set size(value) {
        this._size = value;
        this._updateScale();
    }
    /**
     * Gets the gizmo size.
     *
     * @type {number}
     */ get size() {
        return this._size;
    }
    /**
     * @type {Vec3}
     * @protected
     */ get facingDir() {
        if (this._camera.projection === PROJECTION_PERSPECTIVE) {
            const gizmoPos = this.root.getLocalPosition();
            const cameraPos = this._camera.entity.getPosition();
            return dir.sub2(cameraPos, gizmoPos).normalize();
        }
        return dir.copy(this._camera.entity.forward).mulScalar(-1);
    }
    /**
     * @type {Vec3}
     * @protected
     */ get cameraDir() {
        const cameraPos = this._camera.entity.getPosition();
        const gizmoPos = this.root.getLocalPosition();
        return dir.sub2(cameraPos, gizmoPos).normalize();
    }
    /**
     * @param {PointerEvent} e - The pointer event.
     * @private
     */ _onPointerDown(e) {
        if (!this.enabled || document.pointerLockElement) {
            return;
        }
        const selection = this._getSelection(e.offsetX, e.offsetY);
        if (selection[0]) {
            e.preventDefault();
            e.stopPropagation();
        }
        // capture the pointer during drag
        const { canvas } = this._device;
        canvas.setPointerCapture(e.pointerId);
        this.fire(Gizmo.EVENT_POINTERDOWN, e.offsetX, e.offsetY, selection[0]);
    }
    /**
     * @param {PointerEvent} e - The pointer event.
     * @private
     */ _onPointerMove(e) {
        if (!this.enabled || document.pointerLockElement) {
            return;
        }
        const selection = this._getSelection(e.offsetX, e.offsetY);
        if (selection[0]) {
            e.preventDefault();
            e.stopPropagation();
        }
        this.fire(Gizmo.EVENT_POINTERMOVE, e.offsetX, e.offsetY, selection[0]);
    }
    /**
     * @param {PointerEvent} e - The pointer event.
     * @private
     */ _onPointerUp(e) {
        if (!this.enabled || document.pointerLockElement) {
            return;
        }
        const selection = this._getSelection(e.offsetX, e.offsetY);
        if (selection[0]) {
            e.preventDefault();
            e.stopPropagation();
        }
        const { canvas } = this._device;
        canvas.releasePointerCapture(e.pointerId);
        this.fire(Gizmo.EVENT_POINTERUP, e.offsetX, e.offsetY, selection[0]);
    }
    /**
     * @protected
     */ _updatePosition() {
        position.set(0, 0, 0);
        for(let i = 0; i < this.nodes.length; i++){
            const node = this.nodes[i];
            position.add(node.getPosition());
        }
        position.mulScalar(1.0 / (this.nodes.length || 1));
        if (position.distance(this.root.getLocalPosition()) < UPDATE_EPSILON) {
            return;
        }
        this.root.setLocalPosition(position);
        this.fire(Gizmo.EVENT_POSITIONUPDATE, position);
        this.fire(Gizmo.EVENT_RENDERUPDATE);
    }
    /**
     * @protected
     */ _updateRotation() {
        angles.set(0, 0, 0);
        if (this._coordSpace === 'local' && this.nodes.length !== 0) {
            angles.copy(this.nodes[this.nodes.length - 1].getEulerAngles());
        }
        if (angles.distance(this.root.getLocalEulerAngles()) < UPDATE_EPSILON) {
            return;
        }
        this.root.setLocalEulerAngles(angles);
        this.fire(Gizmo.EVENT_ROTATIONUPDATE, angles);
        this.fire(Gizmo.EVENT_RENDERUPDATE);
    }
    /**
     * @protected
     */ _updateScale() {
        if (this._camera.projection === PROJECTION_PERSPECTIVE) {
            const gizmoPos = this.root.getLocalPosition();
            const cameraPos = this._camera.entity.getPosition();
            const dist = gizmoPos.distance(cameraPos);
            this._scale = Math.tan(0.5 * this._camera.fov * math.DEG_TO_RAD) * dist * PERS_SCALE_RATIO;
        } else {
            this._scale = this._camera.orthoHeight * ORTHO_SCALE_RATIO;
        }
        this._scale = Math.max(this._scale * this._size, MIN_SCALE);
        if (Math.abs(this._scale - this.root.getLocalScale().x) < UPDATE_EPSILON) {
            return;
        }
        this.root.setLocalScale(this._scale, this._scale, this._scale);
        this.fire(Gizmo.EVENT_SCALEUPDATE, this._scale);
        this.fire(Gizmo.EVENT_RENDERUPDATE);
    }
    /**
     * @param {number} x - The x coordinate.
     * @param {number} y - The y coordinate.
     * @returns {MeshInstance[]} - The mesh instances.
     * @private
     */ _getSelection(x, y) {
        const start = this._camera.screenToWorld(x, y, 0);
        const end = this._camera.screenToWorld(x, y, this._camera.farClip - this._camera.nearClip);
        const dir = v.copy(end).sub(start).normalize();
        const selection = [];
        for(let i = 0; i < this.intersectShapes.length; i++){
            const shape = this.intersectShapes[i];
            if (shape.disabled || !shape.entity.enabled) {
                continue;
            }
            const parentTM = shape.entity.getWorldTransform();
            for(let j = 0; j < shape.triData.length; j++){
                const { tris, transform, priority } = shape.triData[j];
                // combine node world transform with transform of tri relative to parent
                const triWTM = m1.copy(parentTM).mul(transform);
                const invTriWTM = m2.copy(triWTM).invert();
                invTriWTM.transformPoint(start, ray.origin);
                invTriWTM.transformVector(dir, ray.direction);
                ray.direction.normalize();
                for(let k = 0; k < tris.length; k++){
                    if (tris[k].intersectsRay(ray, v)) {
                        selection.push({
                            dist: triWTM.transformPoint(v).sub(start).length(),
                            meshInstances: shape.meshInstances,
                            priority: priority
                        });
                    }
                }
            }
        }
        if (selection.length) {
            selection.sort((s0, s1)=>{
                if (s0.priority !== 0 && s1.priority !== 0) {
                    return s1.priority - s0.priority;
                }
                return s0.dist - s1.dist;
            });
            return selection[0].meshInstances;
        }
        return [];
    }
    /**
     * Attach an array of graph nodes to the gizmo.
     *
     * @param {GraphNode[] | GraphNode} [nodes] - The graph nodes. Defaults to [].
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.attach([boxA, boxB]);
     */ attach(nodes = []) {
        if (Array.isArray(nodes)) {
            if (nodes.length === 0) {
                return;
            }
            this.nodes = nodes;
        } else {
            this.nodes = [
                nodes
            ];
        }
        this._updatePosition();
        this._updateRotation();
        this._updateScale();
        this.fire(Gizmo.EVENT_NODESATTACH);
        this.enabled = true;
    }
    /**
     * Detaches all graph nodes from the gizmo.
     *
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.attach([boxA, boxB]);
     * gizmo.detach();
     */ detach() {
        this.enabled = false;
        this.fire(Gizmo.EVENT_NODESDETACH);
        this.nodes = [];
    }
    /**
     * Pre-render method. This is called before the gizmo is rendered.
     *
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.attach([boxA, boxB]);
     * gizmo.prerender();
     */ prerender() {}
    /**
     * Updates the gizmo position, rotation, and scale.
     *
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.attach([boxA, boxB]);
     * gizmo.update();
     */ update() {
        this._updatePosition();
        this._updateRotation();
        this._updateScale();
    }
    /**
     * Detaches all graph nodes and destroys the gizmo instance.
     *
     * @example
     * const gizmo = new pc.Gizmo(camera, layer);
     * gizmo.attach([boxA, boxB]);
     * gizmo.destroy();
     */ destroy() {
        this.detach();
        this._device.canvas.removeEventListener('pointerdown', this._onPointerDown);
        this._device.canvas.removeEventListener('pointermove', this._onPointerMove);
        this._device.canvas.removeEventListener('pointerup', this._onPointerUp);
        this._handles.forEach((handle)=>handle.off());
        this.root.destroy();
    }
}

export { Gizmo };
