package compose.utils

import androidx.compose.animation.*
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.*
import androidx.compose.ui.node.*
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.rememberPopupPositionProviderAtPosition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch


/**
 * Provides the host for tooltips.
 *
 * Use [Modifier.tooltip] inside [content] to associate tooltips with elements.
 */
@Composable
fun TooltipHost(
    properties: TooltipHostProperties = TooltipHostProperties(),
    content: @Composable () -> Unit
) {
    val tooltipState: MutableState<TooltipState> = remember {
        mutableStateOf(TooltipState.Hidden)
    }
    var positionInRoot: Offset by remember { mutableStateOf(Offset.Zero) }
    val coroutineScope = rememberCoroutineScope()
    val tooltipHost = remember {
        TooltipHostImpl(tooltipState, properties, coroutineScope)
    }.also {
        it.properties = properties
    }

    CompositionLocalProvider(LocalTooltipHost provides tooltipHost) {
        // Wrap everything in a box to make sure we always emit a single measurable
        Box(
            Modifier.onPlaced { positionInRoot = it.positionInRoot() }
        ) {
            content()

            val popupVisibleState = remember {
                MutableTransitionState(initialState = false)
            }
            var lingeringTooltipState: TooltipState by remember {
                mutableStateOf(TooltipState.Hidden)
            }

            val tooltipStateValue = tooltipState.value
            if (tooltipStateValue is TooltipState.Visible) {  // Tooltip starts fading in
                lingeringTooltipState = tooltipStateValue
                popupVisibleState.targetState = true
            }
            else {  // Tooltip starts fading out
                popupVisibleState.targetState = false
            }

            if (popupVisibleState.isIdle && !popupVisibleState.currentState) {
                // Tooltip completed fading out
                lingeringTooltipState = TooltipState.Hidden
            }

            (lingeringTooltipState as? TooltipState.Visible)?.let {
                Popup(
                    popupPositionProvider = it.node.tooltipPlacement.positionProvider(
                        tooltipAreaBounds = it.node.boundsInRoot ?: Rect.Zero,
                        cursorPosition = it.pointerPosition - positionInRoot
                    ),
                    onDismissRequest = {
                        tooltipState.value = TooltipState.Hidden
                    },
                ) {
                    AnimatedVisibility(
                        modifier = it.node.modifier
                            .then(tooltipHost.tooltipModifier),
                        visibleState = popupVisibleState,
                        enter = properties.showTransition,
                        exit = properties.hideTransition,
                    ) {
                        properties.decorator.Decoration(it.node.content)
                    }
                }
            }
        }
    }
}


/**
 * Associates a custom tooltip composable with the element.
 */
fun Modifier.tooltip(
    delayMillis: Int = TooltipDefaults.ShowDelayMillis,
    placement: EasyTooltipPlacement = TooltipDefaults.Placement,
    modifier: Modifier = Modifier,
    content: (@Composable () -> Unit)?
) = this.then(
    if (content != null) {
        TooltipGuestElement(
            content = content,
            delayMillis = delayMillis,
            tooltipPlacement = placement,
            modifier = modifier
        )
    }
    else
        Modifier
)


/**
 * Associates a plain text tooltip with the element.
 *
 * The text is computed only when the tooltip is actually shown.
 */
fun Modifier.tooltip(
    text: @Composable (() -> String)?,
    delayMillis: Int = TooltipDefaults.ShowDelayMillis,
    placement: EasyTooltipPlacement = TooltipDefaults.Placement,
    modifier: Modifier = Modifier,
) = tooltip(
    content = if (text == null) null else { -> Text(text()) },
    delayMillis = delayMillis,
    placement = placement,
    modifier = modifier
)



/**
 * Associates a plain text tooltip with the element.
 */
fun Modifier.tooltip(
    text: String?,
    delayMillis: Int = TooltipDefaults.ShowDelayMillis,
    placement: EasyTooltipPlacement = TooltipDefaults.Placement,
    modifier: Modifier = Modifier,
) = tooltip(
    content = if (text == null) null else { -> Text(text) },
    delayMillis = delayMillis,
    placement = placement,
    modifier = modifier
)


/**
 * Associates an [AnnotatedString] tooltip with the element.
 *
 * The string is computed only when the tooltip is actually shown.
 */
fun Modifier.tooltip(
    annotatedText: @Composable (() -> AnnotatedString)?,
    inlineContent: Map<String, InlineTextContent> = mapOf(),
    delayMillis: Int = TooltipDefaults.ShowDelayMillis,
    placement: EasyTooltipPlacement = TooltipDefaults.Placement,
    modifier: Modifier = Modifier,
) = tooltip(
    content = if (annotatedText == null) null else { -> Text(annotatedText(), inlineContent = inlineContent) },
    delayMillis = delayMillis,
    placement = placement,
    modifier = modifier
)


/**
 * Associates an [AnnotatedString] tooltip with the element.
 */
fun Modifier.tooltip(
    annotatedText: AnnotatedString?,
    inlineContent: Map<String, InlineTextContent> = mapOf(),
    delayMillis: Int = TooltipDefaults.ShowDelayMillis,
    placement: EasyTooltipPlacement = TooltipDefaults.Placement,
    modifier: Modifier = Modifier,
) = tooltip(
    content = if (annotatedText == null) null else { -> Text(annotatedText, inlineContent = inlineContent) },
    delayMillis = delayMillis,
    placement = placement,
    modifier = modifier
)


/**
 * The configuration of the tooltip host.
 *
 * @param showTransition The enter transition for the tooltip.
 * @param hideTransition The exit transition for the tooltip.
 * @param decorator Decorates the tooltip content composable, possibly adding things like a border, shadow etc.
 * @param hideDelayMillis When a tooltip is already visible, and the pointer moves into another node, the tooltip for
 * the new node will be shown immediately, without the usual "show delay". However, if at least [hideDelayMillis]
 * milliseconds pass from the time the pointer exited a node, the standard show delay will be used again.
 */
class TooltipHostProperties(
    val showTransition: EnterTransition = fadeIn(),
    val hideTransition: ExitTransition = fadeOut(),
    val decorator: TooltipDecorator = TooltipDefaults.Decoration,
    val hideDelayMillis: Int = TooltipDefaults.HideDelayMillis
)


/**
 * Decorates the tooltip content, configuring the tooltips' look.
 */
fun interface TooltipDecorator {

    @Composable
    fun Decoration(innerTooltip: @Composable () -> Unit)

}


/**
 * The composition local providing the [TooltipHost].
 */
private val LocalTooltipHost: ProvidableCompositionLocal<TooltipHostImpl> =
    staticCompositionLocalOf { error("No TooltipHost provided") }


/**
 * The interface for specifying the position of the tooltip by
 * providing a [PopupPositionProvider].
 */
interface EasyTooltipPlacement {


    /**
     * Returns the [PopupPositionProvider] that will determine the actual position
     * of the tooltip.
     *
     * @param tooltipAreaBounds The bounds, in the root composable, of the element
     * with which the tooltip is associated.
     * @param cursorPosition The position of the cursor, in the root composable,
     * when the tooltip is shown.
     */
    @Composable
    fun positionProvider(
        tooltipAreaBounds: Rect,
        cursorPosition: Offset
    ): PopupPositionProvider


    /**
     * Places the tooltip relative to the position of the cursor.
     *
     * @param offset The offset of the anchor point relative to the position of the cursor.
     * Note that it's not recommended to use 0 for the offset on either axis, to prevent the
     * tooltip from appearing under the cursor.
     * @param alignment The alignment of the tooltip relative to the anchor point.
     * @param windowMargin The margin inside the window to avoid placing the tooltip at.
     */
    class Cursor(
        private val offset: DpOffset = DefaultCursorOffset,
        private val alignment: Alignment = Alignment.BottomEnd,
        private val windowMargin: Dp = 4.dp
    ) : EasyTooltipPlacement {

        @OptIn(ExperimentalComposeUiApi::class)
        @Composable
        override fun positionProvider(tooltipAreaBounds: Rect, cursorPosition: Offset) =
            rememberPopupPositionProviderAtPosition(
                positionPx = cursorPosition,
                offset = offset,
                alignment = alignment,
                windowMargin = windowMargin
            )

    }

    /**
     * Places the tooltip relative to the bounds of the element with which it is associated.
     *
     * @param anchor The anchor point relative to the current component bounds.
     * @param alignment The alignment of the popup relative to the [anchor] point.
     * @param offset Extra offset to add to the position of the popup.
     */
    class Element(
        private val anchor: Alignment = Alignment.BottomCenter,
        private val alignment: Alignment = Alignment.BottomCenter,
        private val offset: DpOffset = DpOffset.Zero
    ) : EasyTooltipPlacement {

        @Composable
        override fun positionProvider(tooltipAreaBounds: Rect, cursorPosition: Offset) =
            rememberPopupPositionProviderAtRect(
                rectPx = tooltipAreaBounds.roundToIntRect(),
                anchor = anchor,
                alignment = alignment,
                offset = offset
            )

    }


    companion object {


        /**
         * The default offset for [Cursor].
         */
        internal val DefaultCursorOffset = DpOffset(1.dp, 1.dp)


        /**
         * Positions the tooltip below the element, centered horizontally.
         */
        fun ElementBottomCenter(offset: DpOffset = DpOffset.Zero) = Element(
            anchor = Alignment.BottomCenter,
            alignment = Alignment.BottomCenter,
            offset = offset
        )


        /**
         * Positions the tooltip below the element, centered horizontally.
         */
        val ElementBottomCenter = ElementBottomCenter()


        /**
         * Positions the tooltip above the element, centered horizontally.
         */
        fun ElementTopCenter(offset: DpOffset = DpOffset.Zero) = Element(
            anchor = Alignment.TopCenter,
            alignment = Alignment.TopCenter,
            offset = offset
        )


        /**
         * Positions the tooltip above the element, centered horizontally.
         */
        val ElementTopCenter = ElementTopCenter()


        /**
         * Positions the tooltip at the end of the element, centered vertically.
         */
        fun ElementCenterEnd(offset: DpOffset = DpOffset.Zero) = Element(
            anchor = Alignment.CenterEnd,
            alignment = Alignment.CenterEnd,
            offset = offset
        )


        /**
         * Positions the tooltip at the end of the element, centered vertically.
         */
        @Suppress("unused")
        val ElementCenterEnd = ElementCenterEnd()


        /**
         * Positions the tooltip at the start of the element, centered vertically.
         */
        fun ElementCenterStart(offset: DpOffset = DpOffset.Zero) = Element(
            anchor = Alignment.CenterStart,
            alignment = Alignment.CenterStart,
            offset = offset
        )


        /**
         * Positions the tooltip at the start of the element, centered vertically.
         */
        val ElementCenterStart = ElementCenterStart()


    }


}


/**
 * Remembers and returns a [PopupPositionProvider] that positions the popup relative to [rectPx].
 */
@Composable
private fun rememberPopupPositionProviderAtRect(
    rectPx: IntRect,
    anchor: Alignment = Alignment.BottomCenter,
    alignment: Alignment = Alignment.BottomCenter,
    offset: DpOffset = DpOffset.Zero
): PopupPositionProvider {
    val offsetPx = with(LocalDensity.current) {
        IntOffset(offset.x.roundToPx(), offset.y.roundToPx())
    }
    return remember(rectPx, anchor, alignment, offsetPx) {
        object : PopupPositionProvider {
            override fun calculatePosition(
                anchorBounds: IntRect,
                windowSize: IntSize,
                layoutDirection: LayoutDirection,
                popupContentSize: IntSize
            ): IntOffset {
                val anchorPoint = anchor.align(IntSize.Zero, rectPx.size, layoutDirection)
                val tooltipArea = IntRect(
                    IntOffset(
                        rectPx.left + anchorPoint.x - popupContentSize.width,
                        rectPx.top + anchorPoint.y - popupContentSize.height,
                    ),
                    IntSize(
                        popupContentSize.width * 2,
                        popupContentSize.height * 2
                    )
                )
                val position = alignment.align(popupContentSize, tooltipArea.size, layoutDirection)
                return tooltipArea.topLeft + position + offsetPx
            }
        }
    }
}


/**
 * Bundles the default values of tooltip configuration.
 */
object TooltipDefaults {


    /**
     * The default delay between the triggering event (e.g. pointer-enter) and showing the tooltip.
     */
    const val ShowDelayMillis = 500


    /**
     * The default delay between the triggering event (e.g. pointer-exit) and hiding the tooltip.
     */
    const val HideDelayMillis = 200


    /**
     * The default tooltip placement.
     */
    val Placement = EasyTooltipPlacement.Cursor(
        offset = DpOffset(1.dp, 16.dp)
    )


    /**
     * The default tooltip decoration.
     */
    val Decoration = TooltipDecorator { content ->
        Box(
            modifier = Modifier
                .background(color = Color.Gray, shape = RoundedCornerShape(2.dp))
                .padding(horizontal = 8.dp)
                .height(24.dp),
            contentAlignment = Alignment.Center,
        ) {
            content()
        }
    }


}


/**
 * Encapsulates the state of the tooltip.
 */
@Immutable
private sealed interface TooltipState {


    /**
     * The tooltip is hidden.
     */
    data object Hidden: TooltipState


    /**
     * The tooltip is visible.
     */
    data class Visible(
        val node: TooltipGuestNode,
        val pointerPosition: Offset
    ): TooltipState


}


/**
 * Implements the main logic of showing/hiding the tooltip, depending on the events
 * it receives from [TooltipGuestNode]s.
 */
private class TooltipHostImpl(
    private val tooltipState: MutableState<TooltipState>,
    var properties: TooltipHostProperties,
    private val coroutineScope: CoroutineScope
) {


    /**
     * The current node the pointer is hovering on.
     */
    private var hoveredNode: TooltipGuestNode? = null


    /**
     * The current position of the pointer.
     */
    private var pointerPosition: Offset = Offset.Unspecified


    /**
     * The job delaying showing/hiding the tooltip.
     */
    private var delayJob: Job? = null


    /**
     * The layout coordinates of the tooltip popup.
     */
    private var tooltipLayoutCoords: LayoutCoordinates? = null


    @OptIn(ExperimentalComposeUiApi::class)
    val tooltipModifier = Modifier
        .onGloballyPositioned { coords ->
            tooltipLayoutCoords = coords
        }
        .onPointerEvent(PointerEventType.Exit) {
            onExitFromTooltip(it.changes.first().position)
        }


    /**
     * Shows the tooltip for the given node.
     */
    private fun show(node: TooltipGuestNode) {
        tooltipState.value = TooltipState.Visible(
            node = node,
            pointerPosition = pointerPosition
        )
    }


    /**
     * Either shows the tooltip for the given node immediately, or starts the job
     * that will show it after a delay.
     */
    private fun startShowing(node: TooltipGuestNode) {
        delayJob?.cancel()

        if (tooltipState.value is TooltipState.Visible) {
            // When we're already showing a tooltip,
            show(node)
        }
        else {
            delayJob = coroutineScope.launch {
                delay(node.delayMillis.toLong())
                show(node)
            }
        }
    }


    /**
     * Hides the tooltip, optionally delaying it by [TooltipHostProperties.hideDelayMillis].
     */
    private fun hide(immediately: Boolean) {
        delayJob?.cancel()

        if (immediately) {
            tooltipState.value = TooltipState.Hidden
        }
        else {
            delayJob = coroutineScope.launch {
                delay(properties.hideDelayMillis.toLong())
                tooltipState.value = TooltipState.Hidden
            }
        }
    }


    /**
     * Returns whether the given coordinates are inside the tooltip.
     */
    private fun Offset.isInTooltip() = tooltipLayoutCoords?.let {
        it.isAttached && it.boundsInRoot().contains(this)
    } ?: false


    /**
     * Invoked when a [TooltipGuestNode] receives a pointer event.
     */
    fun onGuestPointerEvent(
        node: TooltipGuestNode,
        pointerEvent: PointerEvent,
        pass: PointerEventPass
    ) {
        if (pass == PointerEventPass.Main) {
            val nodePositionInRoot = node.boundsInRoot?.topLeft ?: return
            pointerPosition = pointerEvent.changes.first().position + nodePositionInRoot
            when (pointerEvent.type) {
                PointerEventType.Enter -> {
                    if (!pointerEvent.buttons.areAnyPressed) {
                        startShowing(node)
                        hoveredNode = node
                    }
                }
                PointerEventType.Exit -> {
                    if ((node == hoveredNode) && !pointerPosition.isInTooltip()) {
                        hide(immediately = false)
                        hoveredNode = null
                    }
                }
                PointerEventType.Release -> {
                    // Checking hoveredNode == node makes sure we don't show the tooltip together with another popup,
                    // such as a context menu. When a context menu is shown, the guest receives an exit event before
                    // the release event, which clears hoveredNode
                    if (!pointerEvent.buttons.areAnyPressed && (hoveredNode == node)) {
                        startShowing(node)
                    }
                }
            }
        }
        else if (pass == PointerEventPass.Initial) {
            if (pointerEvent.changes.any { it.changedToDown() }) {
                hide(immediately = true)
            }
        }
    }


    /**
     * Invoked when the pointer exits the tooltip.
     */
    private fun onExitFromTooltip(position: Offset) {
        val tooltipCoords = tooltipLayoutCoords
        if ((tooltipCoords == null) || !tooltipCoords.isAttached)
            return

        val pointerPositionInRoot = position + tooltipCoords.positionInRoot()
        val exitedIntoHoveredNode = hoveredNode?.boundsInRoot?.contains(pointerPositionInRoot) == true
        if (!exitedIntoHoveredNode) {
            hide(immediately = false)
            hoveredNode = null
        }
    }


    /**
     * Invoked when a [TooltipGuestNode] is detached.
     */
    fun onGuestDetached(node: TooltipGuestNode) {
        if (node == hoveredNode) {
            hide(immediately = true)
            hoveredNode = null
        }
    }


}


/**
 * The [ModifierNodeElement] for a node that has a tooltip associated with it.
 */
private class TooltipGuestElement(
    private val content: @Composable () -> Unit,
    private val delayMillis: Int,
    private val tooltipPlacement: EasyTooltipPlacement,
    private val modifier: Modifier,
) : ModifierNodeElement<TooltipGuestNode>() {


    override fun create(): TooltipGuestNode {
        return TooltipGuestNode(content, delayMillis, tooltipPlacement, modifier)
    }


    override fun update(node: TooltipGuestNode) {
        node.update(content, delayMillis, tooltipPlacement, modifier)
    }


    override fun InspectorInfo.inspectableProperties() {
        name = "tooltipGuest"
        properties["delayMillis"] = delayMillis
        properties["tooltipPlacement"] = tooltipPlacement
        properties["modifier"] = modifier
    }


    override fun equals(other: Any?): Boolean {
        if (this === other)
            return true

        if (other !is TooltipGuestElement)
            return false

        return (content == other.content) &&
                (delayMillis == other.delayMillis) &&
                (tooltipPlacement == other.tooltipPlacement)
    }


    override fun hashCode(): Int {
        var result = content.hashCode()
        result = 31 * result + delayMillis
        result = 31 * result + tooltipPlacement.hashCode()
        return result
    }


}


/**
 * The [Modifier.Node] that has a tooltip associated with it.
 */
private class TooltipGuestNode(
    content: @Composable () -> Unit,
    var delayMillis: Int,
    tooltipPlacement: EasyTooltipPlacement,
    modifier: Modifier,
): Modifier.Node(), GlobalPositionAwareModifierNode, CompositionLocalConsumerModifierNode, PointerInputModifierNode {


    /**
     * The content to display in the tooltip.
     */
    var content: @Composable () -> Unit by mutableStateOf(content)
        private set


    /**
     * The tooltip placement.
     */
    var tooltipPlacement: EasyTooltipPlacement by mutableStateOf(tooltipPlacement)
        private set


    /**
     * The modifier to apply to the tooltip.
     */
    var modifier: Modifier by mutableStateOf(modifier)
        private set


    /**
     * The bounds of this node in the root composable.
     */
    var boundsInRoot: Rect? by mutableStateOf(null)
        private set


    /**
     * The tooltip host that will display the tooltip for this node.
     */
    private val tooltipHost: TooltipHostImpl
        get() = currentValueOf(LocalTooltipHost)


    override fun onAttach() {

    }


    override fun onDetach() {
        tooltipHost.onGuestDetached(this)
        boundsInRoot = null
    }


    fun update(
        content: @Composable () -> Unit,
        delayMillis: Int,
        tooltipPlacement: EasyTooltipPlacement,
        modifier: Modifier,
    ) {
        this.content = content
        this.delayMillis = delayMillis
        this.tooltipPlacement = tooltipPlacement
        this.modifier = modifier
    }


    override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
        boundsInRoot = coordinates.boundsInRoot()
    }


    override fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) {
        tooltipHost.onGuestPointerEvent(this, pointerEvent, pass)
    }


    override fun onCancelPointerInput() { }


}