import {
    CSSProperties,
    MouseEvent as ReactMouseEvent,
    ReactElement,
    RefObject,
    useCallback,
    useEffect,
    useLayoutEffect,
    useRef,
    useState,
} from "react"
import {
    add,
    angle,
    distance,
    middle,
    middlePos,
    minus,
    mul,
    norm,
    NULL_POS,
    Pos,
    posWithinBase,
    ratioWithinBase,
    relativeTo,
} from "../../geo/Pos"

import "../../style/bendable_arrows.css"
import Draggable from "react-draggable"
import { Segment } from "../../model/tactic/Action.ts"

export interface BendableArrowProps {
    area: RefObject<HTMLElement>
    startPos: Pos | string
    segments: Segment[]
    onSegmentsChanges: (edges: Segment[]) => void
    forceStraight: boolean
    wavy: boolean
    readOnly: boolean

    startRadius?: number
    endRadius?: number

    onDeleteRequested?: () => void

    style?: ArrowStyle
}

export interface ArrowStyle {
    width?: number
    dashArray?: string
    color: string
    head?: () => ReactElement
    tail?: () => ReactElement
}

const ArrowStyleDefaults: ArrowStyle = {
    width: 3,
    color: "black",
}

/**
 * Given a circle shaped by a central position, and a radius, return
 * a position that is constrained on its perimeter, pointing to the direction
 * between the circle's center and the reference position.
 * @param center circle's center.
 * @param reference a reference point used to create the angle where the returned position
 * will point to on the circle's perimeter
 * @param radius circle's radius.
 */
function constraintInCircle(center: Pos, reference: Pos, radius: number): Pos {
    const theta = angle(center, reference)

    return {
        x: center.x - Math.sin(theta) * radius,
        y: center.y - Math.cos(theta) * radius,
    }
}

/**
 * An arrow that follows a bézier curve built from given segments that can be edited, added or removed by the user
 * The arrow only works with relative positions within a given area.
 * All position handled by the arrow must be positions where x and y are a percentage within the area's surface
 * (0.5, 0.5) is a position at the middle of the area
 * (1, 0.75) means that the position is at 100percent to the right of given area, and 75 percent to the bottom
 * @param area
 * @param startPos
 * @param segments
 * @param onSegmentsChanges
 * @param wavy
 * @param readOnly
 * @param forceStraight
 * @param style
 * @param startRadius
 * @param endRadius
 * @param onDeleteRequested
 * @constructor
 */
export default function BendableArrow({
    area,
    startPos,

    segments,
    onSegmentsChanges,

    forceStraight,
    wavy,
    readOnly,

    style,
    startRadius = 0,
    endRadius = 0,
    onDeleteRequested,
}: BendableArrowProps) {
    const containerRef = useRef<HTMLDivElement>(null)
    const svgRef = useRef<SVGSVGElement>(null)
    const pathRef = useRef<SVGPathElement>(null)

    const styleWidth = style?.width ?? ArrowStyleDefaults.width

    const computeInternalSegments = useCallback(
        (segments: Segment[]) => {
            return segments.map((segment, idx) => {
                if (idx == 0) {
                    return {
                        start: startPos,
                        controlPoint: segment.controlPoint ?? null,
                        end: segment.next,
                    }
                }
                const start = segments[idx - 1].next
                return {
                    start,
                    controlPoint: segment.controlPoint ?? null,
                    end: segment.next,
                }
            })
        },
        [startPos],
    )

    // Cache the segments so that when the user is changing the segments (it moves an ArrowPoint),
    // it does not unwind to this arrow's component parent until validated.
    // The changes are validated (meaning that onSegmentsChanges is called) when the
    // user releases an ArrowPoint.
    const [internalSegments, setInternalSegments] = useState<FullSegment[]>(
        () => computeInternalSegments(segments),
    )
    // If the (original) segments changes, overwrite the current ones.
    useLayoutEffect(() => {
        setInternalSegments(computeInternalSegments(segments))
    }, [computeInternalSegments, segments])

    const [isSelected, setIsSelected] = useState(false)

    const headRef = useRef<HTMLDivElement>(null)
    const tailRef = useRef<HTMLDivElement>(null)

    /**
     * Computes and return the segments edition points
     * @param parentBase
     */
    function computePoints(parentBase: DOMRect) {
        return segments.flatMap(({ next, controlPoint }, i) => {
            const prev = i == 0 ? startPos : segments[i - 1].next

            const prevRelative = getPosWithinBase(prev, parentBase)
            const nextRelative = getPosWithinBase(next, parentBase)

            const cpPos =
                controlPoint ||
                ratioWithinBase(
                    add(middle(prevRelative, nextRelative), parentBase),
                    parentBase,
                )

            const setControlPointPos = (newPos: Pos | null) => {
                const segment = segments[i]
                const newSegments = segments.toSpliced(i, 1, {
                    ...segment,
                    controlPoint: newPos ?? undefined,
                })
                onSegmentsChanges(newSegments)
            }

            return [
                // curve control point
                <ArrowPoint
                    key={i}
                    className={"arrow-point-control"}
                    posRatio={cpPos}
                    parentBase={parentBase}
                    onPosValidated={setControlPointPos}
                    onRemove={() => setControlPointPos(null)}
                    onMoves={(controlPoint) => {
                        setInternalSegments((is) => {
                            return is.toSpliced(i, 1, {
                                ...is[i],
                                controlPoint,
                            })
                        })
                    }}
                />,
                //next pos point (only if this is not the last segment)
                i != segments.length - 1 && (
                    <ArrowPoint
                        key={i + "-2"}
                        className={"arrow-point-next"}
                        posRatio={getRatioWithinBase(next, parentBase)}
                        parentBase={parentBase}
                        onPosValidated={(next) => {
                            const currentSegment = segments[i]
                            const newSegments = segments.toSpliced(i, 1, {
                                ...currentSegment,
                                next,
                            })
                            onSegmentsChanges(newSegments)
                        }}
                        onRemove={() => {
                            onSegmentsChanges(
                                segments.toSpliced(Math.max(i - 1, 0), 1),
                            )
                        }}
                        onMoves={(end) => {
                            setInternalSegments((is) => {
                                return is.toSpliced(
                                    i,
                                    2,
                                    {
                                        ...is[i],
                                        end,
                                    },
                                    {
                                        ...is[i + 1],
                                        start: end,
                                    },
                                )
                            })
                        }}
                    />
                ),
            ]
        })
    }

    /**
     * Updates the states based on given parameters, which causes the arrow to re-render.
     */
    const update = useCallback(() => {
        const parentBase = area.current!.getBoundingClientRect()

        const segment = internalSegments[0] ?? null
        if (segment == null) throw new Error("segments might not be empty.")

        const lastSegment = internalSegments[internalSegments.length - 1]

        const startRelative = getPosWithinBase(startPos, parentBase)
        const endRelative = getPosWithinBase(lastSegment.end, parentBase)

        const startNext =
            segment.controlPoint && !forceStraight
                ? posWithinBase(segment.controlPoint, parentBase)
                : getPosWithinBase(segment.end, parentBase)

        const endPrevious = forceStraight
            ? startRelative
            : lastSegment.controlPoint
              ? posWithinBase(lastSegment.controlPoint, parentBase)
              : getPosWithinBase(lastSegment.start, parentBase)

        const tailPos = constraintInCircle(
            startRelative,
            startNext,
            startRadius!,
        )
        const headPos = constraintInCircle(endRelative, endPrevious, endRadius!)

        const left = Math.min(tailPos.x, headPos.x)
        const top = Math.min(tailPos.y, headPos.y)

        Object.assign(tailRef.current!.style, {
            left: tailPos.x + "px",
            top: tailPos.y + "px",
            transformOrigin: "top center",
            transform: `translateX(-50%) rotate(${
                -angle(tailPos, startNext) * (180 / Math.PI)
            }deg)`,
        } as CSSProperties)

        Object.assign(headRef.current!.style, {
            left: headPos.x + "px",
            top: headPos.y + "px",
            transformOrigin: "top center",
            transform: `translateX(-50%) rotate(${
                -angle(headPos, endPrevious) * (180 / Math.PI)
            }deg)`,
        } as CSSProperties)

        const svgStyle: CSSProperties = {
            left: left + "px",
            top: top + "px",
        }

        const segmentsRelatives = (
            forceStraight
                ? [
                      {
                          start: startPos,
                          controlPoint: undefined,
                          end: lastSegment.end,
                      },
                  ]
                : internalSegments
        ).map(({ start, controlPoint, end }) => {
            const svgPosRelativeToBase = { x: left, y: top }

            const nextRelative = relativeTo(
                getPosWithinBase(end, parentBase),
                svgPosRelativeToBase,
            )
            const startRelative = relativeTo(
                getPosWithinBase(start, parentBase),
                svgPosRelativeToBase,
            )
            const controlPointRelative =
                controlPoint && !forceStraight
                    ? relativeTo(
                          posWithinBase(controlPoint, parentBase),
                          svgPosRelativeToBase,
                      )
                    : middle(startRelative, nextRelative)

            return {
                start: startRelative,
                end: nextRelative,
                cp: controlPointRelative,
            }
        })

        const computedSegments = segmentsRelatives
            .map(({ start, cp, end: e }, idx) => {
                let end = e
                if (idx == segmentsRelatives.length - 1) {
                    //if it is the last element
                    end = constraintInCircle(end, cp, endRadius!)
                }

                const previousSegment =
                    idx != 0 ? segmentsRelatives[idx - 1] : undefined

                const previousSegmentCpAndCurrentPosVector = minus(
                    start,
                    previousSegment?.cp ?? middle(start, end),
                )

                const smoothCp = previousSegment
                    ? add(start, previousSegmentCpAndCurrentPosVector)
                    : cp

                if (forceStraight) {
                    return `L${end.x} ${end.y}`
                }

                if (wavy) {
                    return wavyBezier(start, smoothCp, cp, end, 10, 10)
                }

                return `C${smoothCp.x} ${smoothCp.y}, ${cp.x} ${cp.y}, ${end.x} ${end.y}`
            })
            .join(" ")

        const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments
        pathRef.current!.setAttribute("d", d)
        Object.assign(svgRef.current!.style, svgStyle)
    }, [
        area,
        internalSegments,
        startPos,
        forceStraight,
        startRadius,
        endRadius,
        wavy,
    ])

    // Will update the arrow when the props change
    useEffect(update, [update])

    useEffect(() => {
        const observer = new MutationObserver(update)
        const config = { attributes: true }

        if (typeof startPos == "string") {
            observer.observe(document.getElementById(startPos)!, config)
        }

        for (const segment of segments) {
            if (typeof segment.next == "string") {
                observer.observe(document.getElementById(segment.next)!, config)
            }
        }

        return () => observer.disconnect()
    }, [startPos, segments, update])

    useEffect(() => {
        const observer = new ResizeObserver(update)

        observer.observe(area.current!, {})

        return () => observer.disconnect()
    })

    // Adds a selection handler
    // Also force an update when the window is resized
    useEffect(() => {
        const selectionHandler = (e: MouseEvent) => {
            if (!(e.target instanceof Node)) return

            // The arrow is selected if the mouse clicks on an element that belongs to the current arrow
            const isSelected = containerRef.current!.contains(e.target)
            setIsSelected(isSelected)
        }

        document.addEventListener("mousedown", selectionHandler)
        window.addEventListener("resize", update)

        return () => {
            document.removeEventListener("mousedown", selectionHandler)
            window.removeEventListener("resize", update)
        }
    }, [update, containerRef])

    const addSegment = useCallback(
        (e: ReactMouseEvent) => {
            if (forceStraight) return
            const parentBase = area.current!.getBoundingClientRect()

            const clickAbsolutePos: Pos = { x: e.pageX, y: e.pageY }
            const clickPosBaseRatio = ratioWithinBase(
                clickAbsolutePos,
                parentBase,
            )

            let segmentInsertionIndex = -1
            let segmentInsertionIsOnRightOfCP = false
            for (let i = 0; i < segments.length; i++) {
                const segment = segments[i]
                const beforeSegment = i != 0 ? segments[i - 1] : undefined
                const beforeSegmentPos = getRatioWithinBase(
                    i > 1 ? segments[i - 2].next : startPos,
                    parentBase,
                )

                const currentPos = getRatioWithinBase(
                    beforeSegment ? beforeSegment.next : startPos,
                    parentBase,
                )
                const nextPos = getRatioWithinBase(segment.next, parentBase)
                const segmentCp = segment.controlPoint
                    ? segment.controlPoint
                    : middle(currentPos, nextPos)

                const smoothCp = beforeSegment
                    ? add(
                          currentPos,
                          minus(
                              currentPos,
                              beforeSegment.controlPoint ??
                                  middle(beforeSegmentPos, currentPos),
                          ),
                      )
                    : segmentCp

                const result = searchOnSegment(
                    currentPos,
                    smoothCp,
                    segmentCp,
                    nextPos,
                    clickPosBaseRatio,
                    0.05,
                )
                if (result == PointSegmentSearchResult.NOT_FOUND) continue

                segmentInsertionIndex = i
                segmentInsertionIsOnRightOfCP =
                    result == PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT
                break
            }

            if (segmentInsertionIndex == -1) return

            const splicedSegment: Segment = segments[segmentInsertionIndex]

            onSegmentsChanges(
                segments.toSpliced(
                    segmentInsertionIndex,
                    1,
                    {
                        next: clickPosBaseRatio,
                        controlPoint: segmentInsertionIsOnRightOfCP
                            ? splicedSegment.controlPoint
                            : undefined,
                    },
                    {
                        next: splicedSegment.next,
                        controlPoint: segmentInsertionIsOnRightOfCP
                            ? undefined
                            : splicedSegment.controlPoint,
                    },
                ),
            )
        },
        [area, forceStraight, onSegmentsChanges, segments, startPos],
    )

    return (
        <div
            ref={containerRef}
            style={{ position: "absolute", top: 0, left: 0 }}>
            <svg
                ref={svgRef}
                style={{
                    overflow: "visible",
                    position: "absolute",
                    pointerEvents: "none",
                }}>
                <path
                    className="arrow-path"
                    ref={pathRef}
                    stroke={style?.color ?? ArrowStyleDefaults.color}
                    strokeWidth={styleWidth}
                    strokeDasharray={
                        style?.dashArray ?? ArrowStyleDefaults.dashArray
                    }
                    fill="none"
                    tabIndex={0}
                    onDoubleClick={readOnly ? undefined : addSegment}
                    onKeyUp={(e) => {
                        if (onDeleteRequested && e.key == "Delete")
                            onDeleteRequested()
                    }}
                />
            </svg>

            <div
                className={"arrow-head"}
                style={{ position: "absolute", transformOrigin: "center" }}
                ref={headRef}>
                {style?.head?.call(style)}
            </div>

            <div
                className={"arrow-tail"}
                style={{ position: "absolute", transformOrigin: "center" }}
                ref={tailRef}>
                {style?.tail?.call(style)}
            </div>

            {!forceStraight &&
                isSelected &&
                !readOnly &&
                computePoints(area.current!.getBoundingClientRect())}
        </div>
    )
}

function getPosWithinBase(target: Pos | string, area: DOMRect): Pos {
    if (typeof target != "string") {
        return posWithinBase(target, area)
    }

    const targetPos = document.getElementById(target)?.getBoundingClientRect()
    return targetPos ? relativeTo(middlePos(targetPos), area) : NULL_POS
}

function getRatioWithinBase(target: Pos | string, area: DOMRect): Pos {
    if (typeof target != "string") {
        return target
    }

    const targetPos = document.getElementById(target)?.getBoundingClientRect()
    return targetPos ? ratioWithinBase(middlePos(targetPos), area) : NULL_POS
}

interface ControlPointProps {
    className: string
    posRatio: Pos
    parentBase: DOMRect
    onMoves: (currentPos: Pos) => void
    onPosValidated: (newPos: Pos) => void
    onRemove: () => void
    radius?: number
}

enum PointSegmentSearchResult {
    LEFT_TO_CONTROL_POINT,
    RIGHT_TO_CONTROL_POINT,
    NOT_FOUND,
}

interface FullSegment {
    start: Pos | string
    controlPoint: Pos | null
    end: Pos | string
}

/**
 * returns a path delimiter that follows a given cubic béziers curve, but with additional waves on it, of the given
 * density and amplitude.
 * @param start
 * @param cp1
 * @param cp2
 * @param end
 * @param wavesPer100px
 * @param amplitude
 */
function wavyBezier(
    start: Pos,
    cp1: Pos,
    cp2: Pos,
    end: Pos,
    wavesPer100px: number,
    amplitude: number,
): string {
    function getVerticalDerivativeProjectionAmplification(t: number): Pos {
        const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t)
        const velocityLength = norm(velocity)
        //rotate the velocity by 90 deg
        const projection = { x: velocity.y, y: -velocity.x }

        return {
            x: (projection.x / velocityLength) * amplitude,
            y: (projection.y / velocityLength) * amplitude,
        }
    }

    let result: string = ""

    const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end)

    // we need two phases in order to complete a wave
    const waveLength = (dist / 100) * wavesPer100px * 2
    const step = 1 / waveLength

    // 0 : middle to up
    // 1 : up to middle
    // 2 : middle to down
    // 3 : down to middle
    let phase = 0

    for (let t = step; t <= 1; ) {
        const pos = cubicBeziers(start, cp1, cp2, end, t)
        const amplification = getVerticalDerivativeProjectionAmplification(t)

        let nextPos
        if (phase == 1 || phase == 3) {
            nextPos = pos
        } else {
            if (phase == 0) {
                nextPos = add(pos, amplification)
            } else {
                nextPos = minus(pos, amplification)
            }
        }

        const controlPointBase: Pos = cubicBeziers(
            start,
            cp1,
            cp2,
            end,
            t - step / 2,
        )

        const controlPoint: Pos =
            phase == 0 || phase == 1
                ? add(controlPointBase, amplification)
                : minus(controlPointBase, amplification)

        result += `Q${controlPoint.x} ${controlPoint.y} ${nextPos.x} ${nextPos.y}`
        phase = (phase + 1) % 4
        t += step
        if (t < 1 && t > 1 - step) t = 1
    }

    return result
}

function cubicBeziersDerivative(
    start: Pos,
    cp1: Pos,
    cp2: Pos,
    end: Pos,
    t: number,
): Pos {
    return add(
        add(
            mul(minus(cp1, start), 3 * (1 - t) ** 2),
            mul(minus(cp2, cp1), 6 * (1 - t) * t),
        ),
        mul(minus(end, cp2), 3 * t ** 2),
    )
}

function cubicBeziers(
    start: Pos,
    cp1: Pos,
    cp2: Pos,
    end: Pos,
    t: number,
): Pos {
    return add(
        add(
            add(mul(start, (1 - t) ** 3), mul(cp1, 3 * t * (1 - t) ** 2)),
            mul(cp2, 3 * t ** 2 * (1 - t)),
        ),
        mul(end, t ** 3),
    )
}

/**
 * Given a quadratic bézier curve (start position, end position and a middle control point position)
 * search if the given `point` lies on the curve, within a minimum acceptance distance.
 * @param start
 * @param cp1
 * @param cp2
 * @param end
 * @param point
 * @param minDistance
 */
function searchOnSegment(
    start: Pos,
    cp1: Pos,
    cp2: Pos,
    end: Pos,
    point: Pos,
    minDistance: number,
): PointSegmentSearchResult {
    const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end)
    const step = 1 / (dist / minDistance)

    function getDistanceAt(t: number): number {
        return distance(cubicBeziers(start, cp1, cp2, end, t), point)
    }

    for (let t = 0; t < 1; t += step) {
        const distance = getDistanceAt(t)

        if (distance <= minDistance)
            return t >= 0.5
                ? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT
                : PointSegmentSearchResult.LEFT_TO_CONTROL_POINT
    }

    return PointSegmentSearchResult.NOT_FOUND
}

/**
 * An arrow point, that can be moved.
 * @param className
 * @param posRatio
 * @param parentBase
 * @param onMoves
 * @param onPosValidated
 * @param onRemove
 * @param radius
 * @constructor
 */
function ArrowPoint({
    className,
    posRatio,
    parentBase,
    onMoves,
    onPosValidated,
    onRemove,
    radius = 7,
}: ControlPointProps) {
    const ref = useRef<HTMLDivElement>(null)

    const pos = posWithinBase(posRatio, parentBase)

    return (
        <Draggable
            nodeRef={ref}
            onStop={() => {
                const pointPos = middlePos(ref.current!.getBoundingClientRect())
                onPosValidated(ratioWithinBase(pointPos, parentBase))
            }}
            onDrag={() => {
                const pointPos = middlePos(ref.current!.getBoundingClientRect())
                onMoves(ratioWithinBase(pointPos, parentBase))
            }}
            position={{ x: pos.x - radius, y: pos.y - radius }}>
            <div
                ref={ref}
                className={`arrow-point ${className}`}
                style={{
                    position: "absolute",
                    width: radius * 2,
                    height: radius * 2,
                }}
                onKeyUp={(e) => {
                    if (e.key == "Delete") {
                        onRemove()
                    }
                }}
                tabIndex={0}
            />
        </Draggable>
    )
}
