import { assert } from "../../../core/assert.js";
import Signal from "../../../core/events/signal/Signal.js";
import Vector2 from "../../../core/geom/Vector2.js";
import Vector3 from "../../../core/geom/Vector3.js";
import { sign } from "../../../core/math/sign.js";
import { current_time_in_seconds } from "../../../core/time/current_time_in_seconds.js";
import { MouseEvents } from "./events/MouseEvents.js";
import { PointerEvents } from "./events/PointerEvents.js";
import { InputDeviceSwitch } from "./InputDeviceSwitch.js";
import { LocationalInteractionMetadata } from "./LocationalInteractionMetadata.js";
import { suppressContextMenu } from "./mouse/suppressContextMenu.js";


/**
 *
 * @param {Signal} up
 * @param {Signal} down
 * @param {Signal} move
 * @param {number} [maxDistance] in pixels
 * @param {number} [maxDelay] Maximum delay between down and up events in seconds
 * @param {Signal} signal
 */
function observeTap({
                        up,
                        down,
                        move = new Signal(),
                        maxDistance = 10,
                        maxDelay = 1,
                        signal
                    }) {

    /**
     *
     * @type {Map<number, LocationalInteractionMetadata>}
     */
    const active = new Map();

    /**
     *
     * @param {number} id
     */
    function reset(id) {
        assert.isNonNegativeInteger(id, 'id');

        const deleted = active.delete(id);
        if (deleted) {
            up.remove(handleUp);
            move.remove(handleMove);
        }
    }

    /**
     *
     * @param {Vector2} position
     * @param {PointerEvent} event
     */
    function handleUp(position, event) {
        const id = event.pointerId;

        const meta = active.get(id);

        if (meta === undefined) {
            // this should not happen
            console.warn(`Unregistered up event handler`);
            return;
        }

        reset(id);

        const time_now = current_time_in_seconds();

        const delay = time_now - meta.timestamp;

        if (delay > maxDelay) {
            // too much time has passed, swallow event
            return;
        }

        signal.send2(position, event);
    }

    /**
     *
     * @param {Vector2} position
     * @param {PointerEvent} event
     */
    function handleMove(position, event) {
        const id = event.pointerId;

        const meta = active.get(id);

        if (meta === undefined) {
            // this should not happen
            console.warn(`Unregistered move event handler`);

            reset(id);

            return;
        }

        if (meta.position.distanceTo(position) > maxDistance) {
            //we moved too far, abort tap
            reset(id);
        }

    }

    /**
     *
     * @param {Vector2} position
     * @param {PointerEvent} event
     */
    function handleDown(position, event) {
        const id = event.pointerId;

        // make sure to cancel previous pending resolution
        reset(id);

        active.set(id, LocationalInteractionMetadata.from(position));

        up.addOne(handleUp);

        //track move
        move.add(handleMove);
    }

    down.add(handleDown);
}

/**
 *
 * @param {Signal} up
 * @param {Signal} down
 * @param {Signal} move
 * @param {Signal} dragStart
 * @param {Signal} dragEnd
 * @param {Signal} drag
 */
function observeDrag(up, down, move, dragStart, dragEnd, drag) {
    const origin = new Vector2();

    /**
     *
     * @param {Vector2} position
     */
    function noDrag(position) {
        up.remove(noDrag);
        move.remove(handleDragStart);
    }

    /**
     *
     * @param {Vector2} position
     */
    function handleDragEnd(position) {
        up.remove(handleDragEnd);
        move.remove(handleDrag);

        dragEnd.send1(position);
    }

    /**
     *
     * @param {Vector2} position
     * @param {PointerEvent} event
     */
    function handleDragStart(position, event) {

        move.remove(handleDragStart);
        move.add(handleDrag);

        up.remove(noDrag);
        up.add(handleDragEnd);

        lastDragPosition.copy(origin);
        dragStart.send2(origin, event);
        handleDrag(position, event);
    }

    const lastDragPosition = new Vector2();

    /**
     *
     * @param {Vector2} position
     * @param {PointerEvent} event
     */
    function handleDrag(position, event) {
        drag.send4(position, origin, lastDragPosition, event);
        lastDragPosition.copy(position);
    }

    /**
     *
     * @param {Vector2} position
     */
    function handleDown(position) {
        origin.copy(position);
        up.add(noDrag);
        move.add(handleDragStart);
    }

    down.add(handleDown);
}


/**
 *
 * @param {Vector2} result
 * @param {MouseEvent|Touch} event
 * @param {Element} source
 */
export function readPositionFromMouseEvent(result, event, source = event.target) {
    let x = event.clientX;
    let y = event.clientY;

    if (typeof source.getBoundingClientRect === "function") {
        // there are cases when source element something like "document" object, which doesn't expose bounds API, so we're guarding against that
        const bounds = source.getBoundingClientRect();

        y -= bounds.top;
        x -= bounds.left;
    }

    result.set(x, y);
}

/**
 * Abstracts Mouse and Touch interfaces as single "pointer" device.
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class PointerDevice {

    /**
     * Current live position of the pointer
     * @readonly
     * @type {Vector2}
     */
    position = new Vector2();

    #globalUp = new Signal();

    /**
     * @readonly
     */
    on = {
        down: new Signal(),
        up: new Signal(),
        move: new Signal(),
        /**
         * @type {Signal<Vector2, (MouseEvent|TouchEvent)>}
         */
        tap: new Signal(),
        drag: new Signal(),
        dragStart: new Signal(),
        dragEnd: new Signal(),
        wheel: new Signal(),
        pinch: new Signal(),
        pinchStart: new Signal(),
        pinchEnd: new Signal(),
    };

    /**
     *
     * @type {Element|null}
     * @private
     */
    #target = null;

    /**
     *
     * @type {Element|null}
     */
    #domElement = null;

    /**
     * @private
     * @type {boolean}
     */
    isRunning = false;

    /**
     * The {@link MouseEvent.buttons} is a 32bit field, which means we can encode up to 32 buttons
     * @readonly
     * @type {InputDeviceSwitch[]}
     */
    buttons = new Array(32);

    /**
     *
     * @returns {InputDeviceSwitch}
     */
    get mouseButtonLeft() {
        return this.buttons[0];
    }

    /**
     *
     * @returns {InputDeviceSwitch}
     */
    get mouseButtonRight() {
        return this.buttons[2];
    }

    /**
     *
     * @returns {InputDeviceSwitch}
     */
    get mouseButtonMiddle() {
        return this.buttons[1];
    }

    /**
     *
     * @param {EventTarget} domElement html element
     * @constructor
     */
    constructor(domElement) {
        assert.defined(domElement, "domElement");

        // initialize buttons
        for (let i = 0; i < this.buttons.length; i++) {
            this.buttons[i] = new InputDeviceSwitch();
        }

        /**
         *
         * @type {EventTarget}
         */
        this.#domElement = domElement;


        //constructed events
        observeTap({ up: this.on.up, down: this.on.down, move: this.on.move, maxDistance: 10, signal: this.on.tap });
        observeDrag(this.#globalUp, this.on.down, this.on.move, this.on.dragStart, this.on.dragEnd, this.on.drag);

    }


    /**
     *
     * @param {PointerEvent} event
     */
    #eventHandlerPointerDown = (event) => {
        this.readPointerPositionFromEvent(this.position, event);

        // update button state and dispatch specific signal
        const button_index = event.button;

        const button = this.buttons[button_index];

        button?.press();

        this.on.down.send2(this.position, event);

    }

    /**
     *
     * @param {PointerEvent} event
     */
    #eventHandlerPointerUp = (event) => {
        this.readPointerPositionFromEvent(this.position, event);
        this.on.up.send2(this.position, event);
    }

    /**
     *
     * @param {PointerEvent} event
     */
    #eventHandlerGlobalPointerUp = (event) => {
        this.readPointerPositionFromEvent(this.position, event);
        this.#globalUp.send2(this.position, event);

        // update button state and dispatch specific signal
        const button_index = event.button;

        const button = this.buttons[button_index];

        button?.release();
    }


    /**
     *
     * @param {WheelEvent} event
     */
    #eventHandlerWheel = (event) => {
        event.preventDefault();

        //deltas have inconsistent values across browsers, so we will normalize them

        const x = sign(event.deltaX);
        const y = sign(event.deltaY);
        const z = sign(event.deltaZ);

        const delta = new Vector3(x, y, z);

        this.readPointerPositionFromEvent(this.position, event);

        this.on.wheel.send3(delta, this.position, event);
    }

    /**
     *
     * @param {PointerEvent} event
     */
    #eventHandlerPointerMove = (event) => {
        event.preventDefault();

        this.#target = event.target;

        this.readPointerPositionFromEvent(this.position, event);

        this.on.move.send3(this.position, event, new Vector2(event.movementX, event.movementY));
    }

    /**
     *
     * @return {Element}
     */
    getTargetElement() {
        return this.#target;
    }

    /**
     *
     * @param {Element} el
     */
    set domElement(el) {
        assert.defined(el, 'el');
        assert.notNull(el, 'el');

        if (this.#domElement === el) {
            // no change
            return;
        }

        let was_running = this.isRunning;

        if (was_running) {
            // disconnect from previous target
            this.stop();
        }

        this.#domElement = el;

        if (was_running) {
            // restart to maintain original state
            this.start();
        }
    }

    get domElement() {
        return this.#domElement;
    }

    /**
     *
     * @param {Vector2} result
     * @param {MouseEvent|Touch} event
     */
    readPointerPositionFromEvent(result, event) {
        readPositionFromMouseEvent(result, event, this.domElement);
    }

    start() {
        if (this.isRunning) {
            //already running
            return;
        }

        this.isRunning = true;

        // console.warn("PointerDevice.start");

        const domElement = this.#domElement;

        assert.notEqual(domElement, null, "domElement is null");
        assert.notEqual(domElement, undefined, "domElement is undefined");

        domElement.addEventListener(PointerEvents.Move, this.#eventHandlerPointerMove);
        domElement.addEventListener(PointerEvents.Up, this.#eventHandlerPointerUp);
        domElement.addEventListener(PointerEvents.Down, this.#eventHandlerPointerDown);

        window.addEventListener(PointerEvents.Up, this.#eventHandlerGlobalPointerUp);

        /*
        In some cases wheel event gets registered as "passive" by default. This interferes with "preventDefault()"
        see https://www.chromestatus.com/features/6662647093133312
         */
        domElement.addEventListener(MouseEvents.Wheel, this.#eventHandlerWheel, { passive: false });


        domElement.addEventListener("contextmenu", suppressContextMenu);
    }

    stop() {
        if (!this.isRunning) {
            //not running
            return;
        }

        this.isRunning = false;

        // console.warn("PointerDevice.stop");

        const domElement = this.domElement;

        domElement.removeEventListener(PointerEvents.Move, this.#eventHandlerPointerMove);
        domElement.removeEventListener(PointerEvents.Up, this.#eventHandlerPointerUp);
        domElement.removeEventListener(PointerEvents.Down, this.#eventHandlerPointerDown);

        window.removeEventListener(PointerEvents.Up, this.#eventHandlerGlobalPointerUp);

        domElement.removeEventListener(MouseEvents.Wheel, this.#eventHandlerWheel);


        domElement.removeEventListener("contextmenu", suppressContextMenu);
    }
}
