import { ExtendProps } from '@/utils/type'
import './sheet.scss'
import { Scrim } from '@/utils/Scrim'
import clsx from 'clsx'
import assign from 'lodash-es/assign'
import {
    forwardRef,
    useImperativeHandle,
    useLayoutEffect,
    useRef,
    useState,
} from 'react'
import { Portal } from '@/utils/Portal'

export type BottomSheetHandle = ReturnType<typeof drag>

/**
 * This component use ref to control, so it's ref is not a element
 * but an object that holds controller functions
 * @specs https://m3.material.io/components/bottom-sheets/specs
 */
export const BottomSheet = forwardRef<
    BottomSheetHandle,
    ExtendProps<{
        children?: React.ReactNode
        hideDragHandle?: boolean
        onChange?: (visiable: boolean) => void
        onScrimClick?(): void
        teleportTo?: Element | DocumentFragment
    }>
>(function BottomSheet(
    {
        children,
        hideDragHandle,
        onChange,
        onScrimClick,
        className,
        style,
        teleportTo,
        ...props
    },
    ref
) {
    const sheetRef = useRef<HTMLDivElement>(null)
    const handleRef = useRef<HTMLDivElement>(null)

    const dragHandlerRef = useRef<ReturnType<typeof drag> | null>(null)
    const [visiable, setVisiable] = useState(false) // = isOpen

    // useLayoutEffect() rather than useEffect()
    // this make sure ref.current exists in useImperativeHandle()
    useLayoutEffect(() => {
        const sheet = sheetRef.current!
        const handle = hideDragHandle ? sheet : handleRef.current!
        dragHandlerRef.current = drag(handle, sheet, {
            onShow() {
                setVisiable(true)
                onChange?.(true)
            },
            onHide() {
                setVisiable(false)
                onChange?.(false)
            },
        })
        return dragHandlerRef.current.cleanup
    }, [onChange, hideDragHandle])

    useImperativeHandle(ref, () => dragHandlerRef.current!)

    return (
        <Portal container={teleportTo}>
            <Scrim open={visiable} onClick={() => onScrimClick?.()} />
            <div className="sd-bottom_sheet-scrim">
                <div
                    {...props}
                    className={clsx('sd-bottom_sheet', className)}
                    ref={sheetRef}
                    style={assign({ transform: 'translateY(100%)' }, style)}
                >
                    {!hideDragHandle && (
                        <div
                            className="sd-bottom_sheet-drag_handle"
                            ref={handleRef}
                        />
                    )}
                    {children}
                </div>
            </div>
        </Portal>
    )
})

export function drag(
    dragHandle: HTMLDivElement,
    sheet: HTMLDivElement,
    options?: {
        onShow?(): void
        onHide?(): void
    }
) {
    /**
     * do not capture pointer when the dragHandle is the entire sheet (when `hideDragHandle` set to true),
     * this is because the children of the sheet may want to capture the pointer,
     * for example, the <Ripple> element
     */
    const isCapturePointer = dragHandle !== sheet
    let isDragging = false
    let translateY = 0 // previous translateY in px
    let initY = 0
    let pointerDownTime: number

    const hide = () => {
        const { height } = sheet.getBoundingClientRect() // the sheet height

        const animation = sheet.animate(
            [{ transform: `translateY(${height}px)` }],
            {
                duration: 200,
                easing: 'ease-out',
            }
        )
        animation.onfinish = animation.oncancel = () => {
            sheet.style.transform = `translateY(${height}px)`
        }
        translateY = height

        options?.onHide?.()
    }

    const show = () => {
        const animation = sheet.animate([{ transform: `translateY(0)` }], {
            duration: 200,
            easing: 'ease-out',
        })
        animation.onfinish = animation.oncancel = () => {
            sheet.style.transform = `translateY(0)`
        }
        translateY = 0

        options?.onShow?.()
    }

    const onPointerDown = (e: PointerEvent) => {
        if (isCapturePointer) dragHandle.setPointerCapture(e.pointerId)
        isDragging = true
        initY = e.clientY
        pointerDownTime = Date.now()
    }

    const onPointerMove = (e: PointerEvent) => {
        if (!isDragging) return
        const currY = e.clientY
        const distanceY = currY - initY
        const nextTranslateY = translateY + distanceY
        if (nextTranslateY <= 0) {
            // stop moving because the sheet is out of range
            translateY = 0
            sheet.style.transform = 'translateY(0)'
        } else {
            sheet.style.transform = `translateY(${nextTranslateY}px)`
        }
    }

    const onPointerUp = (e: PointerEvent) => {
        if (isCapturePointer) dragHandle.releasePointerCapture(e.pointerId)
        isDragging = false
        const currY = e.clientY
        const distanceY = currY - initY
        const nextTranslateY = translateY + distanceY // a positive number
        const pointerMoveDuration = Date.now() - pointerDownTime
        const { height } = sheet.getBoundingClientRect() // the sheet height

        if (pointerMoveDuration <= 200 /** ms */) {
            // if fast drag to bottom, hide it
            if (distanceY >= 32) {
                hide()
            }
            // if fast drag to top, show it entirely
            else if (distanceY <= -32) {
                show()
            }
        } else {
            // not fast drag
            if (nextTranslateY / height > 0.5) {
                hide()
            } else {
                show()
            }
        }
    }

    dragHandle.addEventListener('pointerdown', onPointerDown)
    dragHandle.addEventListener('pointermove', onPointerMove)
    dragHandle.addEventListener('pointerup', onPointerUp)
    dragHandle.addEventListener('pointercancel', onPointerUp)

    return {
        cleanup: () => {
            dragHandle.removeEventListener('pointerdown', onPointerDown)
            dragHandle.removeEventListener('pointermove', onPointerMove)
            dragHandle.removeEventListener('pointerup', onPointerUp)
            dragHandle.removeEventListener('pointercancel', onPointerUp)
        },
        show,
        hide,
        visiable() {
            return translateY > 0
        },
    }
}
