import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
// (C) 2020-2026 GoodData Corporation
import { Component, createRef, } from "react";
import cx from "classnames";
import { bindAll, debounce, isEqual, pick } from "lodash-es";
import isReactEqual from "react-fast-compare";
import { Portal } from "react-portal";
import { v4 as uuid } from "uuid";
import { ENUM_KEY_CODE } from "../typings/utilities.js";
import { elementRegion, isFixedPosition } from "../utils/domUtilities.js";
import { GOODSTRAP_DRAG_EVENT } from "../utils/drag.js";
import { DEFAULT_ALIGN_POINTS, getOptimalAlignment, getOverlayStyles } from "../utils/overlay.js";
import { GOODSTRAP_SCROLLED_EVENT } from "../utils/scroll.js";
import { OverlayContext } from "./OverlayContext.js";
const events = [
    {
        name: "click",
        handler: "closeOnOutsideClick",
        target: typeof document === "undefined" ? null : document,
    },
    {
        name: "mousedown",
        handler: "onDocumentMouseDown",
        target: typeof document === "undefined" ? null : document,
        requiredProp: "closeOnOutsideClick",
    },
    { name: GOODSTRAP_SCROLLED_EVENT, handler: "closeOnParentScroll" },
    { name: GOODSTRAP_DRAG_EVENT, handler: "closeOnMouseDrag" },
    { name: "keydown", handler: "closeOnEscape" },
];
const eventProps = events.map((event) => event.handler);
const ALIGN_TIMEOUT_MS = 10;
const INIT_STATE_ALIGN = -500;
export const POSITION_SAME_AS_TARGET = "sameAsTarget";
const OVERLAY_CONTENT_CLASS = "gd-overlay-content";
const VISIBLE_PART_TOLERANCE = 0.01;
function exceedsThreshold(firstNumber, secondNumber) {
    return ((firstNumber === undefined && secondNumber !== undefined) ||
        (firstNumber !== undefined && secondNumber === undefined) ||
        Math.abs((firstNumber ?? 0) - (secondNumber ?? 0)) > 2);
}
function alignExceedsThreshold(firstAlignment, secondAlignment) {
    return (exceedsThreshold(firstAlignment.top, secondAlignment.top) ||
        exceedsThreshold(firstAlignment.left, secondAlignment.left) ||
        exceedsThreshold(firstAlignment.right, secondAlignment.right) ||
        exceedsThreshold(firstAlignment.width, secondAlignment.width) ||
        exceedsThreshold(firstAlignment.height, secondAlignment.height));
}
/**
 * Stops React synthetic event propagation but re-dispatches native events
 * to document and documentElement so that:
 * - Other Overlays can detect outside clicks and close themselves
 * - Libraries listening on document / document.documentElement (e.g. for drag/scroll
 *   release tracking) still receive the events
 */
const stopPropagation = (e) => {
    e.stopPropagation();
    const evt = new MouseEvent(e.nativeEvent.type, e.nativeEvent);
    Object.defineProperty(evt, "target", { value: e.nativeEvent.target, enumerable: true });
    document.dispatchEvent(evt);
    document.documentElement.dispatchEvent(evt);
};
/**
 * @internal
 */
export class Overlay extends Component {
    static defaultProps = {
        alignPoints: DEFAULT_ALIGN_POINTS,
        alignTo: "body",
        children: false,
        className: "",
        containerClassName: "",
        positionType: "absolute",
        resizeObserverThreshold: 1,
        ignoreClicksOn: [],
        ignoreClicksOnByClass: [],
        ensureVisibility: false,
        shouldCloseOnClick: () => true,
        onClick: stopPropagation,
        onMouseOver: stopPropagation,
        onMouseUp: stopPropagation,
        onAlign: () => { },
        onClose: () => { },
    };
    id;
    overlayRef = createRef();
    containerRef = createRef();
    resizeHandler = debounce(() => {
        this.align();
    }, 100);
    portalNode = null;
    isComponentMounted;
    clickedInside;
    alignmentTimeoutId;
    static contextType = OverlayContext;
    observer;
    constructor(props) {
        super(props);
        this.state = {
            alignment: {
                align: props.alignPoints?.[0]?.align ?? "cc cc",
                left: INIT_STATE_ALIGN,
                top: INIT_STATE_ALIGN,
                right: 0,
                width: 0,
                height: 0,
            },
            visiblePart: 0,
            observedHeight: 0,
        };
        this.observer = this.createResizeObserver();
        this.isComponentMounted = false;
        this.clickedInside = false;
        this.alignmentTimeoutId = 0;
        this.id = props.id ?? uuid();
        bindAll(this, events.map((event) => event.handler));
        this.createPortalNode();
    }
    UNSAFE_componentWillMount() {
        // reserve the zIndex via the context as soon as possible so that Overlays in the children get higher zIndex
        this.context?.addOverlay(this.id);
    }
    componentDidMount() {
        this.isComponentMounted = true;
        afterOverlayOpened();
        window.addEventListener("resize", this.resizeHandler);
        if (this.overlayRef.current) {
            this.observer?.observe(this.overlayRef.current);
        }
        this.addListeners(this.props);
        this.alignWithTimeout();
    }
    UNSAFE_componentWillReceiveProps(nextProps) {
        if (!isEqual(pick(this.props, eventProps), pick(nextProps, eventProps))) {
            this.removeListeners(this.props);
            this.addListeners(nextProps);
        }
    }
    shouldComponentUpdate(nextProps, nextState) {
        const propsChanged = !isReactEqual(this.props, nextProps);
        const positionChanged = !isEqual(this.state.alignment, nextState.alignment);
        const visibilityChanged = Math.abs(this.state.visiblePart - nextState.visiblePart) > VISIBLE_PART_TOLERANCE;
        return propsChanged || positionChanged || visibilityChanged;
    }
    componentDidUpdate() {
        this.alignWithTimeout();
    }
    componentWillUnmount() {
        this.isComponentMounted = false;
        this.clearAlignmentTimeout();
        window.removeEventListener("resize", this.resizeHandler);
        this.observer?.disconnect();
        this.removeListeners(this.props);
        this.removePortalNodeAfterAllTreeUnmount();
        this.context?.removeOverlay(this.id);
        afterOverlayClosed();
    }
    render() {
        // Need stop propagation of events from Portal thats new behavior of react 16
        // https://github.com/facebook/react/issues/11387
        return (_jsx("span", { "data-testid": "portal-scroll-anchor", className: "s-portal-scroll-anchor", ref: this.containerRef, style: { display: "none" }, children: _jsx(Portal, { node: this.portalNode, children: _jsxs("div", { id: this.id, className: cx(this.props.containerClassName, OVERLAY_CONTENT_CLASS), onClick: this.props.onClick, onMouseOver: this.props.onMouseOver, onMouseUp: this.props.onMouseUp, children: [this.renderMask(), _jsx("div", { ref: this.overlayRef, style: this.getOverlayStyles(), className: this.getOverlayClasses(), children: this.props.children })
                    ] }) }) }));
    }
    align = () => {
        const { alignPoints, alignTo, positionType } = this.props;
        const overlay = this.overlayRef.current;
        if (!alignPoints || !overlay || !alignTo) {
            return;
        }
        const isSameAsTarget = this.isSameAsTargetPosition(positionType ?? "absolute");
        const optimalAlign = getOptimalAlignment({
            targetRegion: elementRegion(alignTo, isSameAsTarget),
            selfRegion: elementRegion(overlay),
            alignPoints,
            ignoreScrollOffsets: isSameAsTarget,
        });
        // process also if visiblePart is 1 to get dimensions of the overlay
        const shouldApplyConstraints = this.props.ensureVisibility && (optimalAlign.visiblePart ?? 0) > 0;
        const constrainedAlignment = shouldApplyConstraints
            ? this.calculateConstrainedAlignment(optimalAlign.alignment, overlay, positionType === "fixed")
            : optimalAlign.alignment;
        const visiblePartChanged = this.hasVisiblePartChanged(optimalAlign.visiblePart ?? 0);
        const alignmentChanged = alignExceedsThreshold(this.state.alignment, constrainedAlignment);
        if (alignmentChanged || visiblePartChanged) {
            this.setState((prevState) => ({
                alignment: alignmentChanged ? constrainedAlignment : prevState.alignment,
                visiblePart: visiblePartChanged ? (optimalAlign.visiblePart ?? 0) : prevState.visiblePart,
            }), () => {
                const alignmentToReport = alignmentChanged
                    ? constrainedAlignment
                    : optimalAlign.alignment;
                this.props.onAlign?.(alignmentToReport);
            });
        }
        else {
            this.props.onAlign?.(optimalAlign.alignment);
        }
    };
    calculateConstrainedAlignment = (alignment, overlay, ignoreScrollOffsets) => {
        const overlayRegion = elementRegion(overlay);
        const { width: originalWidth, height: originalHeight } = overlayRegion;
        const viewportWidth = window.visualViewport?.width || window.innerWidth;
        const viewportHeight = window.visualViewport?.height || window.innerHeight;
        const scrollTop = ignoreScrollOffsets
            ? 0
            : window.visualViewport?.pageTop || window.scrollY || document.documentElement.scrollTop;
        const scrollLeft = ignoreScrollOffsets
            ? 0
            : window.visualViewport?.pageLeft || window.scrollX || document.documentElement.scrollLeft;
        // Calculate the minimum allowed dimensions (50% of original)
        const minWidth = originalWidth * 0.5;
        const minHeight = originalHeight * 0.5;
        const constrainedAlignment = { ...alignment };
        if (constrainedAlignment.left < 0 + scrollLeft) {
            constrainedAlignment.left = 0 + scrollLeft;
        }
        if (constrainedAlignment.top < 0 + scrollTop) {
            constrainedAlignment.top = 0 + scrollTop;
        }
        // Step 2: Calculate if the overlay extends beyond viewport
        const rightEdge = constrainedAlignment.left + originalWidth;
        const bottomEdge = constrainedAlignment.top + originalHeight;
        const exceedsRight = rightEdge > scrollLeft + viewportWidth;
        const exceedsBottom = bottomEdge > scrollTop + viewportHeight;
        if (exceedsRight) {
            const availableWidth = scrollLeft + viewportWidth - constrainedAlignment.left;
            const newWidth = Math.max(availableWidth, minWidth);
            constrainedAlignment.width = newWidth;
            constrainedAlignment.right = undefined;
        }
        else {
            constrainedAlignment.width = originalWidth;
        }
        if (exceedsBottom) {
            const availableHeight = scrollTop + viewportHeight - constrainedAlignment.top;
            const newHeight = Math.max(availableHeight, minHeight);
            constrainedAlignment.height = newHeight;
        }
        else {
            constrainedAlignment.height = originalHeight;
        }
        return constrainedAlignment;
    };
    clearAlignmentTimeout = () => {
        if (this.alignmentTimeoutId) {
            window.clearTimeout(this.alignmentTimeoutId);
        }
    };
    alignWithTimeout = () => {
        this.clearAlignmentTimeout();
        this.alignmentTimeoutId = window.setTimeout(() => {
            this.align();
        }, ALIGN_TIMEOUT_MS);
    };
    onMaskClick = (e) => {
        if (!this.props.closeOnOutsideClick) {
            e.stopPropagation();
        }
    };
    getZIndex() {
        return this.context ? this.context.getZIndex(this.id) : this.props.zIndex;
    }
    getOverlayStyles = () => {
        const { alignTo, positionType } = this.props;
        const { alignment } = this.state;
        const position = this.isSameAsTargetPosition(positionType)
            ? isFixedPosition(alignTo ?? "body")
                ? "fixed"
                : "absolute"
            : positionType;
        // Base styles always applied
        return {
            position,
            left: alignment.left,
            top: alignment.top,
            zIndex: this.getZIndex(),
            visibility: this.isAligned() ? undefined : "hidden",
            ...(this.props.ensureVisibility &&
                alignment.width && {
                width: alignment.width,
            }),
            ...(this.props.ensureVisibility &&
                alignment.height && {
                height: alignment.height,
            }),
            width: this.props.width,
            maxWidth: this.props.maxWidth,
        };
    };
    getOverlayClasses = () => {
        return cx(this.props.className, this.getAlignClasses(), this.getVisibilityClass(), {
            "overlay-wrapper": true,
        });
    };
    /**
     * Add CSS classes to overlay wrapper, so they can be used
     * for position of arrows and stuff
     */
    getAlignClasses = () => {
        if (!this.isAligned()) {
            return "";
        }
        const align = this.state.alignment.align.split(" ");
        return `target-${align[0]} self-${align[1]}`;
    };
    getVisibilityClass = () => {
        if (!this.isAligned()) {
            return "";
        }
        return this.props.ensureVisibility && this.state.visiblePart < 1 ? "truncated" : "";
    };
    createPortalNode() {
        this.portalNode = document.createElement("div");
        document.body.appendChild(this.portalNode);
    }
    removePortalNodeAfterAllTreeUnmount() {
        setTimeout(() => {
            if (this.portalNode && document.body.contains(this.portalNode)) {
                document.body.removeChild(this.portalNode);
            }
            this.portalNode = null;
        });
    }
    isSameAsTargetPosition = (positionType) => {
        return positionType === POSITION_SAME_AS_TARGET;
    };
    isEventOnParent = (event) => {
        const node = this.containerRef.current;
        const eventNode = event.detail?.node || event.target;
        return eventNode.contains(node);
    };
    shouldCloseOnClick = (e) => {
        if (!this.isComponentMounted) {
            return false;
        }
        if (!this.isAligned()) {
            return false;
        }
        // an ignored node has been clicked, always keep the overlay open
        if (this.hasClickedOnIgnoredNode(e)) {
            return false;
        }
        // non-ignored node clicked, give shouldCloseOnClick the chance
        // to override closing the dialog
        return this.props.shouldCloseOnClick?.(e) ?? true;
    };
    hasClickedOnIgnoredNode = (e) => {
        if (e.defaultPrevented) {
            // Ignore event if it should be prevented (e.q. by click in child component)
            return true;
        }
        const clickedElement = e.target;
        if (this.isElementInChildOverlay(clickedElement) || this.clickedInside) {
            return true;
        }
        const ignoredRefElements = [this.overlayRef.current, ...(this.props.ignoreClicksOn ?? [])];
        const clickInsideIgnoredRefElement = ignoredRefElements.some((ref) => {
            return ref?.contains(clickedElement);
        });
        if (clickInsideIgnoredRefElement) {
            return true;
        }
        return (this.props.ignoreClicksOnByClass ?? []).some((selector) => clickedElement.closest(selector));
    };
    isAligned = () => {
        return this.state.alignment.left !== INIT_STATE_ALIGN && this.state.alignment.top != INIT_STATE_ALIGN;
    };
    // makes assumption that overlays later in the DOM are child overlays
    isElementInChildOverlay = (element) => {
        const overlays = Array.from(document.querySelectorAll(".overlay-wrapper"));
        const thisOverlayIndex = overlays.findIndex((overlay) => overlay === this.overlayRef.current);
        return overlays.slice(thisOverlayIndex + 1).some((overlay) => overlay.contains(element));
    };
    onDocumentMouseDown(e) {
        this.clickedInside = this.overlayRef.current.contains(e.target);
        if (this.clickedInside) {
            e.stopPropagation();
        }
    }
    closeOnParentScroll(e) {
        if (!this.isComponentMounted) {
            return;
        }
        if (this.isEventOnParent(e)) {
            this.props.onClose?.();
        }
    }
    closeOnMouseDrag = () => {
        if (!this.isComponentMounted) {
            return;
        }
        this.props.onClose?.();
    };
    closeOnOutsideClick(e) {
        if (this.shouldCloseOnClick(e)) {
            this.props.onClose?.();
        }
    }
    closeOnEscape(e) {
        if (this.isComponentMounted &&
            this.props.closeOnEscape &&
            e.keyCode === ENUM_KEY_CODE.KEY_CODE_ESCAPE) {
            this.props.onClose?.();
        }
    }
    updateListeners = (method, props) => {
        events.forEach((event) => {
            if (props[event.handler] || props[event.requiredProp]) {
                const handler = this[event.handler];
                if (handler) {
                    (event.target || window)[`${method}EventListener`](event.name, handler);
                }
            }
        });
    };
    addListeners = (props) => {
        this.updateListeners("add", props);
    };
    removeListeners = (props) => {
        this.updateListeners("remove", props);
    };
    renderMask = () => {
        const styles = {
            zIndex: this.context ? this.context.getZIndex(this.id) : undefined,
        };
        return this.props.isModal ? (_jsx("div", { className: "modalityPlugin-mask modalityPlugin-mask-visible", onClick: this.onMaskClick, style: styles })) : (false);
    };
    createResizeObserver() {
        // With 100% or more threshold, don't use ResizeObserver
        if ((this.props.resizeObserverThreshold ?? 1) >= 1) {
            return undefined;
        }
        return new ResizeObserver((entries) => {
            const newHeightCandidate = entries[0].contentRect.height;
            const heightDiffersByThreshold = Math.abs(this.state.observedHeight - newHeightCandidate) / this.state.observedHeight >
                (this.props.resizeObserverThreshold ?? 1);
            if (heightDiffersByThreshold) {
                this.resizeHandler();
                this.setState({ observedHeight: newHeightCandidate });
            }
        });
    }
    hasVisiblePartChanged(nextVisiblePart) {
        if (!isFinite(nextVisiblePart)) {
            return false;
        }
        return Math.abs(this.state.visiblePart - nextVisiblePart) > VISIBLE_PART_TOLERANCE;
    }
}
const overlayState = {
    opened: 0,
    body: {},
    html: {},
};
function afterOverlayOpened() {
    if (overlayState.opened === 0) {
        const styles = getOverlayStyles();
        overlayState.html = applyStyles(document.documentElement, styles.html);
        overlayState.body = applyStyles(document.body, styles.body);
    }
    overlayState.opened++;
}
function afterOverlayClosed() {
    overlayState.opened--;
    if (overlayState.opened === 0) {
        applyStyles(document.documentElement, overlayState.html);
        applyStyles(document.body, overlayState.body);
    }
}
function applyStyles(el, newStyles) {
    return Object.keys(newStyles).reduce((prev, key) => {
        const style = el.style;
        const oldValue = style[key];
        style[key] = newStyles[key];
        return { ...prev, [key]: oldValue };
    }, {});
}
