package compose.widgets

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.unit.*
import compose.input.onKeyShortcut
import compose.utils.requestInitialFocus
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue


/**
 * Manages dragging rows to reorder them.
 *
 * This is a utility meant to be part of a widget, e.g. [SimpleGrid].
 */
@Stable
class DragRowsToReorderHelper {


    /**
     * The bounds of [dragToReorderContainer].
     */
    private var containerCoords: LayoutCoordinates? = null


    /**
     * The bounds of each row in the grid.
     */
    private val rowBounds = mutableMapOf<Int, Rect>()


    /**
     * The index of the row currently being dragged; -1 when not dragging.
     */
    private var draggedRowIndex: Int by mutableIntStateOf(-1)


    /**
     * The composable representing the dragged row; `null` when not dragging.
     */
    private var draggedContent: (@Composable () -> Unit)? by mutableStateOf(null)


    /**
     * The top-left position of the dragged content; `null` when not dragging.
     */
    private var draggedContentPosition: Offset? by mutableStateOf(null)


    /**
     * The size of the dragged content; `null` when not dragging and before the content
     */
    private var draggedContentSize: IntSize? by mutableStateOf(null)


    /**
     * The rows that can be targeted during the current dragging, sorted by the row index; `null` when not dragging.
     */
    private var sortedTargetRows: List<TargetRow>? = null


    /**
     * The function to call to notify the cancellation of dragging.
     */
    private var onDragCancelled: (() -> Unit)? = null


    /**
     * The color of the drag marker.
     */
    private var dragMarkerColor: Color? by mutableStateOf(null)


    @OptIn(ExperimentalFoundationApi::class)
    private val bringIntoViewRequester = BringIntoViewRequester()


    /**
     * A modifier added to the row to allow the drag manager to measure its bounds.
     */
    fun rowModifier(rowIndex: Int): Modifier = Modifier.composed {
        DisposableEffect(rowIndex) {
            onDispose {
                rowBounds.remove(rowIndex)
            }
        }

        onPlaced {
            val containerCoords = containerCoords ?: return@onPlaced
            rowBounds[rowIndex] = containerCoords.localBoundingBoxOf(it, clipBounds = false)
        }
    }


    /**
     * The index of the row above which the "target" marker should be drawn.
     * The value is between 0 and one past the index of the last row.
     */
    private val dragTargetMarkerRowIndex: Int? by derivedStateOf(structuralEqualityPolicy()) {
        val draggedContentY = draggedContentPosition?.y ?: return@derivedStateOf null
        val draggedContentHeight = draggedContentSize?.height ?: return@derivedStateOf null
        val draggedContentMiddle = draggedContentY + draggedContentHeight / 2.0f
        val targetRows = sortedTargetRows
        if (targetRows.isNullOrEmpty())
            return@derivedStateOf null

        // Find the index of the first row whose middle is greater than the middle of the dragged content
        val indexInSortedRowBounds = targetRows.binarySearch {
            if (draggedContentMiddle <= it.bounds.center.y)
                1
            else
                -1
        }
        // indexInSortedRowBounds is always negative because the comparison above never returns 0
        val insertIndexInSortedRowBounds = -indexInSortedRowBounds - 1

        // Now find which of the two nearest rows is actually closer.
        // This is needed because there can be rows into which dragging is not allowed between prevRow and nextRow.
        val prevRow = targetRows.getOrNull(insertIndexInSortedRowBounds - 1)
        val nextRow = targetRows.getOrNull(insertIndexInSortedRowBounds)
        when {
            // prevRow is null when dragging to above the first target row
            prevRow == null -> nextRow!!.index

            // nextRow is null when dragging to below the last row
            nextRow == null -> targetRows.last().index

            // Finally, choose the nearest one.
            else -> {
                val distanceToPrevRow = (draggedContentMiddle - prevRow.bounds.bottom).absoluteValue
                val distanceToNextRow = (draggedContentMiddle - nextRow.bounds.top).absoluteValue
                if (distanceToPrevRow < distanceToNextRow)
                    prevRow.index
                else
                    nextRow.index
            }
        }
    }


    /**
     * Returns whether a drag is in progress.
     */
    private fun isDragging() = draggedRowIndex >= 0


    /**
     * Cancels dragging, if any is in progress.
     */
    fun cancelDragging() {
        if (!isDragging())
            return

        onDragCancelled?.invoke()
        onDragStopped()
    }


    /**
     * A modifier to attach to the container of the draggable elements.
     */
    @OptIn(ExperimentalFoundationApi::class)
    fun Modifier.dragToReorderContainer() = composed {
        dragMarkerColor = MaterialTheme.colors.onSurface

        this
            .drawWithContent {
                drawContent()
                drawDragTargetMarker()
            }
            .bringIntoViewRequester(bringIntoViewRequester)
            .onPlaced {
                containerCoords = it
            }
    }


    /**
     * A modifier to attach to a row in order to allow dragging it to reorder rows.
     */
    @OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
    fun Modifier.dragToReorder(

        /**
         * The representation of the dragged content during dragging.
         */
        draggableContent: @Composable () -> Unit,

        /**
         * Returns whether the dragged row can be inserted at the given row index. The target marker will be drawn
         * before the rows for which this function returns `true`.
         *
         * Note that it may be given indices between 0 and one past the last row index.
         */
        canMoveToRow: (Int) -> Boolean,

        /**
         * Called when the dragging gesture completes successfully.
         *
         * Note that dropRowIndex will be the index of the row above which the drag target marker is drawn, and could
         * be one past the last row (if [canMoveToRow] allowed it).
         */
        onDrop: (draggedRowIndex: Int, dropRowIndex: Int) -> Unit,

        /**
         * Called when the dragging is cancelled by the user.
         */
        onDragCancelled: (() -> Unit)? = null,

    ) = composed {
        val coroutineScope = rememberCoroutineScope()
        val draggableState = rememberDraggableState { dragDelta ->
            draggedContentPosition = draggedContentPosition?.let {
                it + Offset(x = 0f, y = dragDelta)
            }
            val offset = draggedContentPosition ?: return@rememberDraggableState
            val size = draggedContentSize ?: return@rememberDraggableState
            coroutineScope.launch {
                bringIntoViewRequester.bringIntoView(Rect(offset, size.toSize()))
            }
        }
        var relativeDraggedPointY: Float by remember { mutableFloatStateOf(0f) }
        var bounds: Rect? by remember { mutableStateOf(null) }
        this
            .onPlaced {
                val containerCoords = containerCoords ?: return@onPlaced
                bounds = containerCoords.localBoundingBoxOf(it, clipBounds = false)
            }
            .draggable(
                state = draggableState,
                orientation = Orientation.Vertical,
                onDragStarted = { startedPosition ->
                    this@DragRowsToReorderHelper.onDragCancelled = onDragCancelled
                    bounds?.let {
                        relativeDraggedPointY = startedPosition.y - it.top
                        onDragStarted(draggableContent, canMoveToRow, it)
                    }
                },
                onDragStopped = {
                    val draggedRowIndex = draggedRowIndex
                    val dragTargetMarkerRowIndex = dragTargetMarkerRowIndex
                    if ((draggedRowIndex >= 0) && (dragTargetMarkerRowIndex != null)) {
                        onDrop(draggedRowIndex, dragTargetMarkerRowIndex)
                        onDragStopped()
                    }
                    relativeDraggedPointY = 0f
                }
            )
            // Manually detect the synthetic move events while dragging as a workaround for
            // https://issuetracker.google.com/issues/343917640
            .onPointerEvent(eventType = PointerEventType.Move, pass = PointerEventPass.Final) { event ->
                if (isDragging() && !event.changes.all { it.isConsumed }) {
                    val position = event.changes.first { !it.isConsumed }.position
                    draggableState.dispatchRawDelta(
                        (position.y - relativeDraggedPointY) - draggedContentPosition!!.y
                    )
                }
            }
    }


    /**
     * Invoked when the dragging gesture starts.
     */
    private fun onDragStarted(
        draggableContent: @Composable () -> Unit,
        canMoveToRow: (Int) -> Boolean,
        draggedRowBounds: Rect
    ) {
        val lastRow = rowBounds.maxBy { it.key }
        val targetRows = rowBounds
            .mapNotNull { (rowIndex, bounds) ->
                if (canMoveToRow(rowIndex))
                    TargetRow(rowIndex, bounds)
                else
                    null
            }
            .sortedBy { it.index }
            .let {
                if (canMoveToRow(lastRow.key + 1)) {
                    val (lastRowIndex, lastRowBounds) = lastRow
                    it + TargetRow(
                        index = lastRowIndex + 1,
                        bounds = Rect(topLeft = lastRowBounds.bottomLeft, bottomRight = lastRowBounds.bottomRight)
                    )
                }
                else
                    it
            }
        if (targetRows.isEmpty())
            return

        val middleY = draggedRowBounds.center.y
        val indexInSortedRowBounds = targetRows.binarySearch { (_, bounds) ->
            when {
                middleY < bounds.top -> 1
                middleY > bounds.bottom -> -1
                else -> 0
            }
        }

        // Started "dragging" outside of any row bounds
        if (indexInSortedRowBounds < 0)
            return

        draggedRowIndex = targetRows.getOrNull(indexInSortedRowBounds)?.index ?: return
        draggedContent = draggableContent
        draggedContentPosition = draggedRowBounds.topLeft
        this.sortedTargetRows = targetRows
    }


    /**
     * Invoked when the dragging gesture ends.
     */
    private fun onDragStopped() {
        draggedRowIndex = -1
        draggedContent = null
        draggedContentPosition = null
        draggedContentSize = null
        sortedTargetRows = null
        onDragCancelled = null
    }


    /**
     * Displays the draggable content in the grid.
     */
    @Composable
    fun DraggedContent() {
        draggedContent?.let { draggedContent ->
            Box(
                modifier = Modifier
                    .onPlaced {
                        draggedContentSize = it.size
                    }
                    .absoluteOffset {
                        draggedContentPosition?.round() ?: IntOffset.Zero
                    }
                    .requestInitialFocus()
                    .focusable(true)
                    .onKeyShortcut(Key.Escape, onPreview = true) {
                        cancelDragging()
                    }
                    .onPreviewKeyEvent { true }  // Consume all events while dragging
        ) {
                draggedContent()
            }
        }
    }


    /**
     * Draws the drag target marker.
     */
    private fun DrawScope.drawDragTargetMarker() {
        val color = dragMarkerColor ?: return
        val targetRowIndex = dragTargetMarkerRowIndex ?: return
        val strokeWidth = 3f
        val targetRowBounds = rowBounds[targetRowIndex]

        val y = if (targetRowBounds == null)
            rowBounds[targetRowIndex-1]!!.bottom - strokeWidth/2  // dragging to one past the last row
        else
            targetRowBounds.top + (if (targetRowIndex == 0) strokeWidth/2 else 0f)

        drawLine(
            color = color,
            strokeWidth = strokeWidth,
            start = Offset(x = 0f, y = y),
            end = Offset(x = size.width, y = y)
        )
    }


}


/**
 * A row that can be targeted during a drag gesture.
 */
private data class TargetRow(
    val index: Int,
    val bounds: Rect
)


/**
 * Adjusts the touch slop to a sensible value, which is needed because the default touch slop is smaller than one pixel.
 * It's recommended to wrap any content to which you attach [DragRowsToReorderHelper.dragToReorder] in this.
 */
@Composable
fun AdjustViewConfigurationForDragToReorder(content: @Composable () -> Unit) {
    CompositionLocalProvider(
        value = LocalViewConfiguration provides ViewConfigurationForDragToReorder(),
        content = content
    )
}


/**
 * Returns a [ViewConfiguration] that sets the touch slop to sensible value for dragging to reorder.
 */
@Composable
fun ViewConfigurationForDragToReorder(): ViewConfiguration {
    val density = LocalDensity.current
    val viewConfig = LocalViewConfiguration.current
    return remember(density, viewConfig) {
        object : ViewConfiguration by viewConfig {
            override val touchSlop: Float = with(density) {
                // Such a large value is needed because what this provides is the touch slop for touches, which
                // ViewConfiguration.pointerSlop (in DragGestureDetector) then multiplies by a very small value.
                500.dp.toPx()
            }
        }
    }
}
