<template>
    <Teleport to="#hy-popovers-container">
        <Transition name="hy-popover">
            <!-- tabindex necessary to be able to focus on this div => Use for click outside detection -->
            <div
                class="hy-popover"
                ref="popover"
                v-if="modelValue == true"
                :style="popoverStyles"
                :class="[variant, arrow.staticSide]"
                tabindex="-1"
                v-click-outside="onClickOutside"
                @pointerenter="onHoverEnter"
                @pointerleave="onHoverLeave"
            >
                <hy-section :class="{ headerVisible, footerVisible }">
                    <!-- Header -->
                    <div class="header" v-if="headerVisible" :class="{ centerHeadline: headerCentered }" ref="header">
                        <div class="headline" v-if="headline != null || description != null">
                            <h3 v-if="headline">{{ headline }}</h3>
                            <p v-if="description != null">{{ description }}</p>
                        </div>

                        <slot name="header" />
                    </div>

                    <!-- Content -->
                    <div class="content hy-hide-scrollbars hy-remove-leading-trailing-margins" ref="content" :style="contentStyles">
                        <slot />
                        <slot name="content" />
                    </div>

                    <!-- Footer -->
                    <div class="footer" ref="footer" v-if="footerVisible">
                        <slot name="footer" />
                    </div>
                </hy-section>

                <div :style="arrowStyles" class="arrow" ref="arrow"></div>
            </div>
        </Transition>
    </Teleport>
</template>

<script>
import { computePosition, autoUpdate, offset, flip, shift, arrow, size } from "@floating-ui/dom";
import vClickOutside from "click-outside-vue3";
import HySection from "./Section.vue";
import { ref, isRef, computed, watch, toValue } from "vue";
import querySelectorAsync from "../utils/querySelectorAsync.js";
import { useMediaQuery } from "@vueuse/core";

const DEBUG = false;

export default {
    name: "HyPopover",
    emits: ["update:modelValue"],
    props: {
        modelValue: {
            type: Boolean,
            required: true,
        },
        reference: {
            type: [HTMLElement, String, Object],
            default: null,
        },
        doOpenOnHover: {
            type: Boolean,
            default: false,
        },
        doConstrainHeight: {
            type: Boolean,
            default: false,
        },
        hoverDelay: {
            type: Number,
            default: 500,
        },
        hideOnClickOutside: {
            type: Boolean,
            default: true,
        },
        minWidth: {
            type: String,
            default: "0",
        },
        maxWidth: {
            type: String,
            default: "unset",
        },
        maxHeight: {
            type: String,
            default: "unset",
        },
        variant: {
            type: String,
            default: "match",
            validator(value) {
                return ["match", "contrast"].includes(value);
            },
        },
        zIndex: {
            type: Number,
            default: 10,
        },
        placement: {
            type: String,
            default: "bottom",
        },
        offsetMainAxis: {
            type: Number,
            default: 15,
        },
        offsetCrossAxis: {
            type: Number,
            default: 0,
        },
        headline: {
            type: String,
            default: null,
        },
        description: {
            type: String,
            default: null,
        },
    },
    data() {
        return {
            resolvedReference: null,
            cleanupAutoUpdate: null,
            cleanupHoverEventListeners: null,
            isHovering: false,
            isMobile: useMediaQuery("(max-width: 799px)"),

            // Popover style data
            x: 0,
            y: 0,
            arrow: {
                x: 0,
                y: 0,
                staticSide: "top",
            },
            availableHeight: window.innerHeight, // Just as a fallback, will be updated when popover middleware executes
            availableWidth: window.innerWidth, // Just as a fallback, will be updated when popover middleware executes
        };
    },
    watch: {
        reference: {
            handler() {
                if (DEBUG) console.log("Reference changed");

                this.beforeShow(); // Updates reference, recomputes position and sets up auto updates (if popover is open)
            },
        },
        modelValue: {
            handler() {
                if (DEBUG) console.log("ModelValue changed", this.modelValue);

                if (this.modelValue == true) this.beforeShow();
                else this.afterHide();
            },
        },
    },
    computed: {
        dimensions() {
            let headerHeight = this.$refs.header?.offsetHeight ?? 0; // Height of header element
            let footerHeight = this.$refs.footer?.offsetHeight ?? 0; // Height of footer element
            let sectionPadding = 2 * 15; // Section padding (top & bottom), which is not included in the header/footer height

            let calcAvailMaxHeight = `${Math.max(0, this.availableHeight - headerHeight - footerHeight - sectionPadding)}px`; // Available height for content element
            if (this.doConstrainHeight == false) calcAvailMaxHeight = "9999px"; // If height should not be constrained, use a very high value

            let userProvidedMaxWidth = this.maxWidth == "unset" || this.maxWidth == null ? "9999px" : this.maxWidth; // If no real value is provided, use a very high value
            let userProvidedMinWidth = this.minWidth == "0" || this.minWidth == null ? "0px" : this.minWidth; // If no real value is provided, use 0
            let userProvidedMaxHeight = this.maxHeight == "unset" || this.maxHeight == null ? "9999px" : this.maxHeight; // If no real value is provided, use a very high value

            return {
                popover: {
                    maxWidth: `min(${Math.max(0, this.availableWidth)}px, ${userProvidedMaxWidth})`, // User provided max width is constrained by available width
                    minWidth: `min(${Math.max(0, this.availableWidth)}px, ${userProvidedMinWidth})`, // User provided min width is constrained by available width
                },
                content: {
                    maxHeight: `min(${calcAvailMaxHeight}, ${userProvidedMaxHeight})`, // User provided max height is constrained by available height
                    minHeight: "min(max-content, 100px)", // Min height is 100px or the height of the container (if the container is naturally smaller than 100px)
                },
            };
        },
        popoverStyles() {
            return {
                zIndex: this.zIndex,
                left: `${this.x}px`,
                top: `${this.y}px`,
                maxWidth: this.dimensions.popover.maxWidth,
                minWidth: this.dimensions.popover.minWidth,
            };
        },
        contentStyles() {
            return {
                maxHeight: this.dimensions.content.maxHeight,
                minHeight: this.dimensions.content.minHeight,
            };
        },
        arrowStyles() {
            return {
                left: this.arrow.x ? `${this.arrow.x}px` : null,
                top: this.arrow.y ? `${this.arrow.y}px` : null,
                [this.arrow.staticSide]: "-4px",
            };
        },
        headerVisible() {
            return this.headline != null || this.description != null || this.$slots.header != undefined;
        },
        headerCentered() {
            return this.$slots.header == undefined;
        },
        footerVisible() {
            return this.$slots.footer != undefined;
        },
    },
    methods: {
        async beforeShow() {
            await this.updateResolvedReference();

            this.$nextTick(() => {
                this.computePosition();
                this.setupAutoUpdates();
            });
        },
        afterHide() {
            if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();
        },
        async resolveReference() {
            if (DEBUG) console.log("Resolving reference");

            if (this.reference == null) {
                return null;
            }

            if (typeof this.reference == "string") {
                return await querySelectorAsync(this.reference);
            }

            if (toValue(this.reference) instanceof HTMLElement) {
                return toValue(this.reference);
            }

            return null;
        },
        async updateResolvedReference() {
            // Cleanup previous hover event listeners for reference
            if (this.cleanupHoverEventListeners) this.cleanupHoverEventListeners();

            // Update reference
            this.resolvedReference = await this.resolveReference();

            if (DEBUG) console.log("Resolved reference", this.resolvedReference);

            // Setup hover event listeners for reference
            if (this.resolvedReference == null || this.doOpenOnHover == false) return;

            this.resolvedReference.addEventListener("pointerenter", this.onHoverEnter);
            this.resolvedReference.addEventListener("pointerleave", this.onHoverLeave);

            this.cleanupHoverEventListeners = () => {
                if (DEBUG) console.log("Cleaning up hover event listeners");

                this.resolvedReference.removeEventListener("pointerenter", this.onHoverEnter);
                this.resolvedReference.removeEventListener("pointerleave", this.onHoverLeave);
                this.cleanupHoverEventListeners = null;
            };
        },
        async computePosition() {
            if (this.resolvedReference == null) return;
            if (this.$refs.popover == null) return;

            if (DEBUG) console.log("Computing position");

            // Prepare constants
            let padding = {
                top: this.isMobile ? 80 : 100, // HyHeader component height
                bottom: this.isMobile ? 90 : 20, // HyNavigationTabbar component height
                left: 20,
                right: 20,
            };

            // Compite position
            let { x, y, placement, middlewareData } = await computePosition(this.resolvedReference, this.$refs.popover, {
                placement: this.placement,
                middleware: [
                    offset({ mainAxis: this.offsetMainAxis, crossAxis: this.offsetCrossAxis }),
                    shift({ padding }),
                    flip({ padding }),
                    size({
                        padding,
                        apply: ({ availableWidth, availableHeight }) => {
                            this.availableWidth = availableWidth;
                            this.availableHeight = availableHeight;
                        },
                    }),
                    arrow({ element: this.$refs.arrow, padding: 15 }),
                ],
            });

            // Set calculated position
            this.x = x;
            this.y = y;

            if (DEBUG) console.log("Computed position", x, y, placement);

            // Set arrow position
            let { x: arrowX, y: arrowY } = middlewareData.arrow;

            this.arrow.x = arrowX;
            this.arrow.y = arrowY;
            this.arrow.staticSide = {
                top: "bottom",
                right: "left",
                bottom: "top",
                left: "right",
            }[placement.split("-")[0]];
        },
        setupAutoUpdates() {
            if (this.resolvedReference == null) return;
            if (this.$refs.popover == null) return;

            // Cleanup previous auto update
            if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();

            if (DEBUG) console.log("Setting up auto updates");

            // Setup auto update
            const cleanupFn = autoUpdate(this.resolvedReference, this.$refs.popover, this.computePosition);

            // Provide cleanup function
            this.cleanupAutoUpdate = () => {
                if (DEBUG) console.log("Cleaning up auto update");

                cleanupFn();
                this.cleanupAutoUpdate = null;
            };
        },
        onClickOutside(event) {
            // For some reason this method is called every time a click occurs, but only emits if the click is outside
            if (this.hideOnClickOutside == false) return;

            // Reference is not part of the component itself, so the clickOutside directive triggers this method
            // => Abort if the reference is clicked
            // => Abort if a child of the reference is clicked
            if (this.resolvedReference == event.target) return;
            if (this.resolvedReference.contains(event.target)) return;

            if (DEBUG) console.log("Clicked outside");

            this.isHovering = false;
            this.$emit("update:modelValue", false);
        },
        onHoverEnter() {
            if (this.doOpenOnHover == false) return;

            if (DEBUG) console.log("On hover enter");

            this.isHovering = true;

            setTimeout(() => {
                if (this.isHovering == false) return;

                this.$emit("update:modelValue", true);
            }, this.hoverDelay);
        },
        onHoverLeave() {
            if (this.doOpenOnHover == false) return;

            if (DEBUG) console.log("On hover leave");

            this.isHovering = false;

            setTimeout(() => {
                if (this.isHovering == true) return;

                this.$emit("update:modelValue", false);
            }, this.hoverDelay);
        },
    },
    mounted() {
        this.beforeShow();
    },
    beforeUnmount() {
        // Cleanup
        if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();
        if (this.cleanupHoverEventListeners) this.cleanupHoverEventListeners();
    },
    directives: {
        clickOutside: vClickOutside.directive,
    },
    components: {
        HySection,
    },
};
</script>

<style lang="scss">
.hy-popover {
    position: absolute;
    border-radius: var(--hy-section-border-radius); // Box shadow fix

    // Different color variants
    &.match {
        &,
        & > .hy-section {
            background-color: var(--hy-popover-bg-color-match);
            color: var(--hy-font-color-contrast);

            & > .header,
            & > .footer {
                border-color: var(--hy-popover-border-color-match);
            }
        }
    }

    &.contrast {
        &,
        & > .hy-section {
            background-color: var(--hy-popover-bg-color-contrast);
            color: var(--hy-font-color-match);

            & > .header,
            & > .footer {
                border-color: var(--hy-popover-border-color-contrast);
            }
        }
    }

    & > .hy-section {
        box-shadow: var(--hy-popover-shadow);
        margin: 0;

        // Because the header border should go from edge to edge, the wrapper section may not have a left/right padding
        // But we want a padding at the top/bottom edge
        padding: var(--hy-element-padding) 0;

        & > .header {
            padding: var(--hy-element-padding);
            padding-top: 0; // Top padding is part of the section padding
            display: flex;
            gap: var(--hy-flex-gap);
            align-items: center;
            justify-content: flex-end;
            border-bottom: 1px solid transparent; // Color set by color variant

            & > * {
                margin: 0; // Remove margin from header slot content
            }

            &.centerHeadline > .headline * {
                text-align: center;
            }

            & > .headline {
                flex: 100;

                h3,
                p {
                    margin: 0;
                }

                p {
                    margin-top: 3px;
                    font-size: var(--hy-font-size-small);
                    color: var(--hy-font-color-gray);
                }
            }
        }

        & > .content {
            // Content has its own left/right padding because we want to have the header border to go from edge to edge
            padding: 0 var(--hy-element-padding);
            overflow-y: scroll;
        }

        &.headerVisible > .content {
            // If the content is visible, we want a small white space between header and content
            // But when scrolling, the content should touch the edge of the header
            // This is not possible when using a margin on the header
            padding-top: var(--hy-element-padding);
        }

        &.footerVisible > .content {
            // Same for the footer
            padding-bottom: var(--hy-element-padding);
        }

        & > .footer {
            padding: var(--hy-element-padding);
            padding-bottom: 0; // Bottom padding is part of the section padding
            border-top: 1px solid transparent; // Color set by color variant
            display: flex;
            gap: var(--hy-flex-gap);
            align-items: center;
            justify-content: space-between;

            & > * {
                margin: 0;
                flex: 1;
            }
        }
    }

    & > .arrow,
    & > .arrow::before {
        position: absolute;
        width: 12px;
        height: 12px;
        background: inherit;
    }

    & > .arrow {
        visibility: hidden;
    }

    & > .arrow::before {
        visibility: visible;
        content: "";
        transform: rotate(45deg);
    }

    &[data-popper-placement^="top"] > .arrow {
        bottom: -6px;
    }

    &[data-popper-placement^="bottom"] > .arrow {
        top: -6px;
    }

    &[data-popper-placement^="left"] > .arrow {
        right: -6px;
    }

    &[data-popper-placement^="right"] > .arrow {
        left: -6px;
    }
}

// Enter/Leave transition
.hy-popover-enter-active,
.hy-popover-leave-active {
    transition: var(--hy-tabbar-transition);
    transition-property: transform, opacity;

    &.top {
        transform-origin: top;
    }
    &.right {
        transform-origin: right;
    }
    &.bottom {
        transform-origin: bottom;
    }
    &.left {
        transform-origin: left;
    }
}

.hy-popover-enter-from,
.hy-popover-leave-to {
    opacity: 0;
    transform: perspective(100px) scale(0.98);
}
</style>
