/* eslint-disable @typescript-eslint/no-non-null-assertion */
// #region Imports
// #endregion
// #region Constants
/**
 * Additional events to listen for during drag.
 *
 * @internal
 */
const ADDITIONAL_EVENTS = [
    'pointermove',
    'lostpointercapture',
    'contextmenu'
];
// #endregion
// #region Helper Functions
/**
 * Gets the default layer (document body).
 *
 * @returns The default layer element.
 *
 * @internal
 */
function getDefaultLayer() {
    return document.body;
}
/**
 * Creates a default drag ghost element.
 *
 * @param rect - The bounding rect of the original element.
 * @returns The ghost element.
 *
 * @internal
 */
function createDefaultDragGhost(rect) {
    const element = document.createElement('div');
    Object.assign(element.style, {
        position: 'fixed',
        left: `${rect.x}px`,
        top: `${rect.y}px`,
        width: `${rect.width}px`,
        height: `${rect.height}px`,
        zIndex: '1000',
        background: 'gold',
        pointerEvents: 'none'
    });
    return element;
}
/**
 * Finds an element matching a predicate in the event's composed path.
 *
 * @param predicate - The predicate function.
 * @param event - The event.
 * @returns The matching element or undefined.
 *
 * @internal
 */
function findElementFromEventPath(predicate, event) {
    const path = event.composedPath();
    for (const item of path) {
        if (item instanceof Element && predicate(item)) {
            return item;
        }
    }
    return undefined;
}
/**
 * Gets the root node for element queries.
 *
 * @param element - The element.
 * @returns The root node.
 *
 * @internal
 */
function getRoot(element) {
    return element.getRootNode();
}
/**
 * Checks if the element's direction is LTR.
 *
 * @param element - The element.
 * @returns True if LTR.
 *
 * @internal
 */
function isLTR(element) {
    return getComputedStyle(element).direction !== 'rtl';
}
/**
 * Rounds a value by device pixel ratio for crisp rendering.
 *
 * @param value - The value to round.
 * @returns The rounded value.
 *
 * @internal
 */
function roundByDPR(value) {
    const dpr = window.devicePixelRatio || 1;
    return Math.round(value * dpr) / dpr;
}
// #endregion
/**
 * A drag and drop controller implementing the IgniteUI pattern.
 *
 * This controller manages pointer events to enable drag and drop functionality
 * on Lit elements. It supports both immediate and deferred drag modes.
 *
 * @example
 * ```typescript
 * class MyElement extends LitElement {
 *     private readonly _dragController = addDragController(this, {
 *         skip: (event) => this._shouldSkipDrag(event),
 *         matchTarget: (element) => element.tagName === 'MY-DROP-TARGET',
 *         ghost: () => this._createDragGhost(),
 *         start: (params) => this._handleDragStart(params),
 *         over: (params) => this._handleDragOver(params),
 *         end: (params) => this._handleDragEnd(params),
 *         cancel: (state) => this._handleDragCancel(state),
 *     });
 * }
 * ```
 *
 * @public
 */
export class DragController {
    // #region Fields
    _host;
    _options = {
        enabled: true,
        mode: 'deferred',
        snapToCursor: false,
        layer: getDefaultLayer
    };
    _abortController = null;
    _state;
    _matchedElement = null;
    _pointerId = -1;
    _hasPointerCapture = false;
    _ghost = null;
    // #endregion
    // #region Ctor
    /**
     * Constructs a new instance of the `DragController` class.
     *
     * @param host - The host element.
     * @param options - The configuration options.
     *
     * @public
     */
    constructor(host, options) {
        this._host = host;
        this._host.addController(this);
        this.set(options);
    }
    // #endregion
    // #region Properties
    /**
     * Whether the drag controller is enabled.
     *
     * @public
     * @readonly
     */
    get enabled() {
        return Boolean(this._options.enabled);
    }
    /**
     * Whether `snapToCursor` is enabled for the controller.
     *
     * @private
     * @readonly
     */
    get _hasSnapping() {
        return Boolean(this._options.snapToCursor);
    }
    /**
     * Whether the current drag mode is deferred.
     *
     * @private
     * @readonly
     */
    get _isDeferred() {
        return this._options.mode === 'deferred';
    }
    /**
     * The source element which will capture pointer events and initiate drag mode.
     * By default that will be the host element itself, unless `trigger` is passed in.
     *
     * @private
     * @readonly
     */
    get _element() {
        return this._options.trigger?.() ?? this._host;
    }
    /**
     * The element being dragged.
     * When in deferred mode this returns a reference to the drag ghost element,
     * otherwise it is the host element.
     *
     * @private
     * @readonly
     */
    get _dragItem() {
        return this._isDeferred ? this._ghost : this._host;
    }
    /**
     * The DOM element that will "host" the ghost drag element when the controller
     * is set to deferred. In immediate mode, this property is ignored.
     *
     * @private
     * @readonly
     */
    get _layer() {
        if (!this._isDeferred) {
            return this._host;
        }
        return this._options.layer?.() ?? this._host;
    }
    /**
     * The current state parameters for callbacks.
     *
     * @private
     * @readonly
     */
    get _stateParameters() {
        return {
            ...this._state,
            ghost: this._ghost,
            element: this._matchedElement
        };
    }
    // #endregion
    // #region Methods
    /**
     * Updates the drag controller configuration.
     *
     * @param options - The new configuration options.
     *
     * @public
     */
    set(options) {
        Object.assign(this._options, options);
    }
    /**
     * Stops any drag operation and cleans up state, event listeners, and elements.
     *
     * @public
     */
    dispose() {
        this._matchedElement = null;
        this._removeGhost();
        this._setDragState(false);
    }
    /**
     * @internal
     */
    hostConnected() {
        this._abortController = new AbortController();
        const { signal } = this._abortController;
        this._host.addEventListener('dragstart', this, { signal });
        this._host.addEventListener('touchstart', this, {
            passive: false,
            signal
        });
        this._host.addEventListener('pointerdown', this, { signal });
    }
    /**
     * @internal
     */
    hostDisconnected() {
        this._abortController?.abort();
        this._abortController = null;
        this._setDragCancelListener(false);
        this._removeGhost();
    }
    /**
     * Handles events dispatched to the controller.
     *
     * @param event - The event.
     *
     * @internal
     */
    handleEvent(event) {
        if (!this.enabled) {
            return;
        }
        switch (event.type) {
            case 'touchstart':
            case 'dragstart':
            case 'contextmenu':
                event.preventDefault();
                break;
            case 'keydown':
                this._handleCancel(event);
                break;
            case 'pointerdown':
                this._handlePointerDown(event);
                break;
            case 'pointermove':
                this._handlePointerMove(event);
                break;
            case 'lostpointercapture':
                this._handlePointerEnd(event);
                break;
        }
    }
    /**
     * Handles pointer down events.
     *
     * @param event - The pointer event.
     *
     * @private
     */
    _handlePointerDown(event) {
        if (this._shouldSkip(event)) {
            return;
        }
        this._setInitialState(event);
        this._createDragGhost();
        this._updatePosition(event);
        const parameters = {
            event,
            state: this._stateParameters
        };
        if (this._options.start?.call(this._host, parameters) === false) {
            this.dispose();
            return;
        }
        this._assignPosition(this._dragItem);
        this._setDragState();
    }
    /**
     * Handles pointer move events.
     *
     * @param event - The pointer event.
     *
     * @private
     */
    _handlePointerMove(event) {
        if (!this._hasPointerCapture) {
            return;
        }
        this._updatePosition(event);
        this._updatePointerState(event);
        this._updateMatcher(event);
        const parameters = {
            event,
            state: this._stateParameters
        };
        this._options.move?.call(this._host, parameters);
        this._assignPosition(this._dragItem);
    }
    /**
     * Handles pointer end events.
     *
     * @param event - The pointer event.
     *
     * @private
     */
    _handlePointerEnd(event) {
        this._options.end?.call(this._host, {
            event,
            state: this._stateParameters
        });
        this.dispose();
    }
    /**
     * Handles cancel events (Escape key).
     *
     * @param event - The keyboard event.
     *
     * @private
     */
    _handleCancel(event) {
        const key = event.key.toLowerCase();
        if (this._hasPointerCapture && key === 'escape') {
            this._options.cancel?.call(this._host, this._stateParameters);
            this.dispose();
        }
    }
    /**
     * Sets up the cancel key listener.
     *
     * @param enabled - Whether to enable the listener.
     *
     * @private
     */
    _setDragCancelListener(enabled = true) {
        if (enabled) {
            globalThis.addEventListener('keydown', this);
        }
        else {
            globalThis.removeEventListener('keydown', this);
        }
    }
    /**
     * Sets the initial drag state.
     *
     * @param event - The pointer event.
     *
     * @private
     */
    _setInitialState(event) {
        const rect = this._host.getBoundingClientRect();
        this._pointerId = event.pointerId;
        this._state = {
            initial: rect,
            current: structuredClone(rect),
            position: {
                x: rect.x,
                y: rect.y
            },
            offset: {
                x: rect.x - event.clientX,
                y: rect.y - event.clientY
            },
            pointerState: {
                previous: {
                    x: event.clientX,
                    y: event.clientY
                },
                current: {
                    x: event.clientX,
                    y: event.clientY
                },
                direction: 'end'
            }
        };
    }
    /**
     * Sets the drag state (captures pointer, applies styles).
     *
     * @param enabled - Whether drag is active.
     *
     * @private
     */
    _setDragState(enabled = true) {
        this._hasPointerCapture = enabled;
        const cssValue = enabled ? 'none' : '';
        Object.assign(this._element.style, {
            touchAction: cssValue,
            userSelect: cssValue
        });
        if (enabled) {
            this._element.setPointerCapture(this._pointerId);
        }
        else {
            this._element.releasePointerCapture(this._pointerId);
        }
        this._setDragCancelListener(enabled);
        for (const type of ADDITIONAL_EVENTS) {
            if (enabled) {
                this._host.addEventListener(type, this);
            }
            else {
                this._host.removeEventListener(type, this);
            }
        }
    }
    /**
     * Updates the matched element based on current pointer position.
     *
     * @param event - The pointer event.
     *
     * @private
     */
    _updateMatcher(event) {
        if (!this._options.matchTarget) {
            return;
        }
        const matchFn = this._options.matchTarget;
        const match = getRoot(this._host)
            .elementsFromPoint(event.clientX, event.clientY)
            .find((element) => matchFn.call(this._host, element));
        if (match && !this._matchedElement) {
            this._matchedElement = match;
            this._options.enter?.call(this._host, {
                event,
                state: this._stateParameters
            });
            return;
        }
        if (!match && this._matchedElement) {
            this._options.leave?.call(this._host, {
                event,
                state: this._stateParameters
            });
            this._matchedElement = null;
            return;
        }
        if (match && match === this._matchedElement) {
            this._options.over?.call(this._host, {
                event,
                state: this._stateParameters
            });
        }
    }
    /**
     * Updates the position state based on the current pointer position.
     *
     * @param event - The pointer event.
     *
     * @private
     */
    _updatePosition(event) {
        const offset = this._state.offset;
        const layerRect = this._isDeferred
            ? this._layer.getBoundingClientRect()
            : this._state.initial;
        const posX = this._hasSnapping
            ? event.clientX - layerRect.x
            : event.clientX - layerRect.x + offset.x;
        const posY = this._hasSnapping
            ? event.clientY - layerRect.y
            : event.clientY - layerRect.y + offset.y;
        this._state.position = {
            x: posX,
            y: posY
        };
    }
    /**
     * Updates the pointer state for direction tracking.
     *
     * @param event - The pointer event.
     *
     * @private
     */
    _updatePointerState(event) {
        const state = this._state.pointerState;
        state.previous = { ...state.current };
        state.current = {
            x: event.clientX,
            y: event.clientY
        };
        const dx = state.current.x - state.previous.x;
        const dy = state.current.y - state.previous.y;
        if (Math.abs(dx) >= Math.abs(dy)) {
            const swapHorizontal = isLTR(this._host) ? dx >= 0 : dx <= 0;
            state.direction = swapHorizontal ? 'end' : 'start';
        }
        else {
            state.direction = dy >= 0 ? 'bottom' : 'top';
        }
    }
    /**
     * Assigns the position to the element via transform.
     *
     * @param element - The element to position.
     *
     * @private
     */
    _assignPosition(element) {
        const x = roundByDPR(this._state.position.x);
        const y = roundByDPR(this._state.position.y);
        element.style.transform = `translate3d(${x}px,${y}px,0)`;
    }
    /**
     * Creates the drag ghost element.
     *
     * @private
     */
    _createDragGhost() {
        if (!this._isDeferred) {
            return;
        }
        this._ghost =
            this._options.ghost?.call(this._host) ??
                createDefaultDragGhost(this._host.getBoundingClientRect());
        this._layer.append(this._ghost);
    }
    /**
     * Removes the drag ghost element.
     *
     * @private
     */
    _removeGhost() {
        this._ghost?.remove();
        this._ghost = null;
    }
    /**
     * Checks whether the drag should be skipped.
     *
     * @param event - The pointer event.
     * @returns True if the drag should be skipped.
     *
     * @private
     */
    _shouldSkip(event) {
        return (Boolean(event.button) ||
            Boolean(this._options.skip?.call(this._host, event)) ||
            !findElementFromEventPath((e) => e === this._element, event));
    }
}
/**
 * Adds a drag and drop controller to the given host.
 *
 * @param host - The host element.
 * @param options - The configuration options.
 * @returns The drag controller instance.
 *
 * @public
 */
export function addDragController(host, options) {
    return new DragController(host, options);
}
//# sourceMappingURL=DragController.js.map