import {
    CSSProperties,
    ReactNode,
    RefObject,
    SetStateAction,
    useCallback,
    useEffect,
    useMemo,
    useRef,
    useState,
} from "react"
import "../style/editor.css"
import TitleInput from "../components/TitleInput"

import { BallPiece } from "../components/editor/BallPiece"

import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"

import {
    CourtType,
    StepContent,
    StepInfoNode,
    TacticComponent,
} from "../model/tactic/Tactic"

import SavingState, {
    SaveState,
    SaveStates,
} from "../components/editor/SavingState"

import { BALL_TYPE } from "../model/tactic/CourtObjects"
import { CourtAction } from "../components/editor/CourtAction"
import {
    ActionPreview,
    BasketCourt,
    Court,
} from "../components/editor/BasketCourt"
import { overlaps } from "../geo/Box"

import {
    computeTerminalState,
    drainTerminalStateOnChildContent,
    dropBallOnComponent,
    getComponentCollided,
    getRackPlayers,
    mapToParentContent,
    moveComponent,
    placeBallAt,
    placeObjectAt,
    placePlayerAt,
    removeBall,
    selectContent,
    updateComponent,
} from "../domains/TacticContentDomains.ts"

import {
    BallState,
    Player,
    PlayerInfo,
    PlayerLike,
    PlayerTeam,
} from "../model/tactic/Player"

import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems"
import {
    CourtPlayer,
    EditableCourtPlayer,
} from "../components/editor/CourtPlayer.tsx"
import {
    createAction,
    getActionKind,
    isActionValid,
    removeAction,
    spreadNewStateFromOriginStateChange,
} from "../domains/ActionsDomains.ts"
import ArrowAction from "../components/actions/ArrowAction"
import { middlePos, Pos, ratioWithinBase } from "../geo/Pos"
import { Action, ActionKind } from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction"
import {
    ComputedRelativePositions,
    computeRelativePositions,
    getOrigin,
    getPhantomInfo,
    removePlayer,
} from "../domains/PlayerDomains.ts"
import { CourtBall } from "../components/editor/CourtBall"
import StepsTree from "../components/editor/StepsTree"
import {
    addStepNode,
    getParent,
    getStepNode,
    removeStepNode,
} from "../domains/StepsDomain.ts"
import SplitLayout from "../components/SplitLayout.tsx"
import {
    MutableTacticService,
    ServiceError,
} from "../service/MutableTacticService.ts"
import { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts"
import { APITacticService } from "../service/APITacticService.ts"
import { useNavigate, useParams } from "react-router-dom"
import { ContentVersions } from "../editor/ContentVersions.ts"
import { useAppFetcher } from "../App.tsx"

const ERROR_STYLE: CSSProperties = {
    borderColor: "red",
}

type ComputedStepContent = {
    content: StepContent
    relativePositions: ComputedRelativePositions
}

export interface EditorProps {
    guestMode: boolean
}

interface EditorService {
    addStep(
        parent: StepInfoNode,
        content: StepContent,
    ): Promise<StepInfoNode | ServiceError>

    removeStep(step: number): Promise<void | ServiceError>

    selectStep(step: number): Promise<void | ServiceError>

    setContent(content: SetStateAction<StepContent>): void

    setName(name: string): Promise<SaveState>

    openVisualizer(): Promise<void>
}

export default function Editor({ guestMode }: EditorProps) {
    const { tacticId: idStr } = useParams()
    const fetcher = useAppFetcher()

    const navigate = useNavigate()
    if (guestMode || !idStr) {
        return (
            <EditorPageWrapper
                service={LocalStorageTacticService.init()}
                openVisualizer={() => navigate("/tactic/view-guest")}
            />
        )
    }

    return (
        <EditorPageWrapper
            service={new APITacticService(fetcher, parseInt(idStr))}
            openVisualizer={() => navigate(`/tactic/${idStr}/view`)}
        />
    )
}

interface EditorPageWrapperProps {
    service: MutableTacticService
    openVisualizer(): void
}

function EditorPageWrapper({
    service,
    openVisualizer,
}: EditorPageWrapperProps) {
    const [panicMessage, setPanicMessage] = useState<string>()
    const [stepId, setStepId] = useState<number>()
    const [tacticName, setTacticName] = useState<string>()
    const [courtType, setCourtType] = useState<CourtType>()
    const [stepsTree, setStepsTree] = useState<StepInfoNode>()
    const [parentContent, setParentContent] = useState<StepContent | null>(null)

    const courtRef = useRef<HTMLDivElement>(null)

    const stepsVersions = useMemo<Map<number, ContentVersions>>(
        () => new Map(),
        [],
    )

    const saveContent = useCallback(
        async (content: StepContent) => {
            const result = await service.saveContent(stepId!, content)

            if (typeof result === "string") return SaveStates.Err

            await updateStepContents(
                stepId!,
                stepsTree!,
                async (id) => {
                    const content = await service.getContent(id)
                    if (typeof content === "string")
                        throw new Error(
                            "Error when retrieving children content",
                        )

                    const courtBounds =
                        courtRef.current!.getBoundingClientRect()
                    const relativePositions = computeRelativePositions(
                        courtBounds,
                        content,
                    )

                    if (id === stepId) {
                        let versions = stepsVersions.get(stepId!)
                        if (versions == undefined) {
                            versions = new ContentVersions()
                            stepsVersions.set(stepId!, versions)
                        }
                        versions.insertAndCut(content)
                    } else {
                        stepsVersions.delete(id)
                    }

                    return {
                        content,
                        relativePositions,
                    }
                },
                async (id, content) => {
                    const result = await service.saveContent(id, content)
                    if (typeof result === "string")
                        throw new Error("Error when updating children content")
                },
            )
            return SaveStates.Ok
        },
        [stepsVersions, service, stepId, stepsTree],
    )

    const [stepContent, setStepContent, saveState] =
        useContentState<StepContent>(
            { components: [] },
            SaveStates.Ok,
            useMemo(() => debounceAsync(saveContent, 250), [saveContent]),
        )

    const isNotInit = !tacticName || !stepId || !stepsTree || !courtType

    useEffect(() => {
        const handleGlobalControls = (e: KeyboardEvent) => {
            if (!e.ctrlKey) return
            if (e.key == "z" || e.key == "y") {
                let versions = stepsVersions.get(stepId!)
                if (versions == undefined) {
                    versions = new ContentVersions()
                    stepsVersions.set(stepId!, versions)
                }

                const newContent =
                    e.key == "z" ? versions.previous() : versions.next()
                if (newContent) {
                    setStepContent(newContent, false)
                }
            }
        }

        document.addEventListener("keydown", handleGlobalControls)

        return () =>
            document.removeEventListener("keydown", handleGlobalControls)
    }, [stepsVersions, setStepContent, stepId])

    useEffect(() => {
        async function init() {
            const contextResult = await service.getContext()
            if (typeof contextResult === "string") {
                setPanicMessage(
                    "There has been an error retrieving the editor initial context : " +
                        contextResult,
                )
                return
            }
            const stepId = contextResult.stepsTree.id
            setStepsTree(contextResult.stepsTree)
            setStepId(stepId)
            setCourtType(contextResult.courtType)
            setTacticName(contextResult.name)

            const contentResult = await service.getContent(stepId)

            if (typeof contentResult === "string") {
                setPanicMessage(
                    "There has been an error retrieving the tactic's root step content : " +
                        contentResult,
                )
                return
            }

            const versions = new ContentVersions()
            stepsVersions.set(stepId, versions)
            versions.insertAndCut(contentResult)

            setStepContent(contentResult, false)
        }

        if (isNotInit) init()
    }, [isNotInit, service, setStepContent, stepsVersions])

    const editorService: EditorService = useMemo(() => {
        let internalStepsTree = stepsTree
        return {
            async addStep(
                parent: StepInfoNode,
                content: StepContent,
            ): Promise<StepInfoNode | ServiceError> {
                const result = await service.addStep(parent, content)
                if (typeof result !== "string") {
                    internalStepsTree = addStepNode(
                        internalStepsTree!,
                        parent,
                        result,
                    )
                    setStepsTree(internalStepsTree)
                }
                return result
            },
            async removeStep(step: number): Promise<void | ServiceError> {
                const result = await service.removeStep(step)
                if (typeof result !== "string") {
                    internalStepsTree = removeStepNode(internalStepsTree!, step)
                    setStepsTree(internalStepsTree)
                }
                stepsVersions.delete(step)
                return result
            },

            setContent(content: StepContent) {
                setStepContent(content, true)
            },

            async setName(name: string): Promise<SaveState> {
                const result = await service.setName(name)
                if (typeof result === "string") return SaveStates.Err
                setTacticName(name)
                return SaveStates.Ok
            },

            async selectStep(step: number): Promise<void | ServiceError> {
                const result = await service.getContent(step)
                if (typeof result === "string") return result
                const stepParent = getParent(internalStepsTree!, step)?.id
                if (stepParent) {
                    const parentResult = await service.getContent(stepParent)
                    if (typeof parentResult === "string") return parentResult
                    setParentContent(mapToParentContent(parentResult))
                } else {
                    setParentContent(null)
                }
                setStepId(step)
                setStepContent(result, false)
            },

            async openVisualizer(): Promise<void> {
                openVisualizer()
            },
        }
    }, [stepsTree, service, stepsVersions, setStepContent, openVisualizer])

    if (panicMessage) {
        return <p>{panicMessage}</p>
    }

    if (isNotInit) {
        return <p>Retrieving editor context. Please wait...</p>
    }

    return (
        <EditorPage
            name={tacticName}
            courtType={courtType}
            stepId={stepId}
            stepsTree={stepsTree}
            contentSaveState={saveState}
            parentContent={parentContent}
            content={stepContent}
            service={editorService}
            courtRef={courtRef}
        />
    )
}

export interface EditorViewProps {
    stepsTree: StepInfoNode
    name: string
    courtType: CourtType
    contentSaveState: SaveState
    stepId: number

    parentContent: StepContent | null
    content: StepContent

    courtRef: RefObject<HTMLDivElement>

    service: EditorService
}

function EditorPage({
    name,
    courtType,
    parentContent,
    content,
    stepId,
    contentSaveState,
    stepsTree,
    courtRef,
    service,
}: EditorViewProps) {
    const [titleStyle, setTitleStyle] = useState<CSSProperties>({})

    const allies = getRackPlayers(PlayerTeam.Allies, content.components)
    const opponents = getRackPlayers(PlayerTeam.Opponents, content.components)

    const [objects, setObjects] = useState<RackedCourtObject[]>(() =>
        isBallOnCourt(content) ? [] : [{ key: "ball" }],
    )

    const [previewAction, setPreviewAction] = useState<ActionPreview | null>(
        null,
    )

    const [isStepsTreeVisible, setStepsTreeVisible] = useState(true)

    const courtBounds = useCallback(
        () => courtRef.current!.getBoundingClientRect(),
        [courtRef],
    )

    const [editorContentCurtainWidth, setEditorContentCurtainWidth] =
        useState(80)

    const relativePositions = useMemo(() => {
        const courtBounds = courtRef.current?.getBoundingClientRect()
        return courtBounds
            ? computeRelativePositions(courtBounds, content)
            : new Map()
    }, [content, courtRef])

    const setComponents = (action: SetStateAction<TacticComponent[]>) => {
        service.setContent((c) => ({
            ...c,
            components:
                typeof action == "function" ? action(c.components) : action,
        }))
    }

    useEffect(() => {
        setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }])
    }, [setObjects, content])

    const insertRackedPlayer = (player: Player) => {
        if (player.ballState == BallState.HOLDS_BY_PASS) {
            setObjects([{ key: "ball" }])
        }
    }

    const doRemovePlayer = useCallback(
        (component: PlayerLike) => {
            service.setContent((c) => removePlayer(component, c))
            if (component.type == "player") insertRackedPlayer(component)
        },
        [service],
    )

    const doMoveBall = useCallback(
        (newBounds: DOMRect, from?: PlayerLike) => {
            service.setContent((content) => {
                if (from) {
                    content =
                        spreadNewStateFromOriginStateChange(
                            from,
                            BallState.NONE,
                            content,
                        ) ?? content
                }

                content = placeBallAt(newBounds, courtBounds(), content)

                return content
            })
        },
        [courtBounds, service],
    )

    const validatePlayerPosition = useCallback(
        (player: PlayerLike, info: PlayerInfo, newPos: Pos) => {
            service.setContent((content) =>
                moveComponent(
                    newPos,
                    player,
                    info,
                    courtBounds(),
                    content,

                    (content) => {
                        if (player.type === "player") insertRackedPlayer(player)
                        return removePlayer(player, content)
                    },
                ),
            )
        },
        [courtBounds, service],
    )

    const renderAvailablePlayerActions = useCallback(
        (info: PlayerInfo, player: PlayerLike) => {
            let canPlaceArrows: boolean
            let isFrozen: boolean = false

            if (player.type == "player") {
                canPlaceArrows =
                    player.path == null ||
                    player.actions.findIndex(
                        (p) => p.type != ActionKind.SHOOT,
                    ) == -1
                isFrozen = player.frozen
            } else {
                const origin = getOrigin(
                    player,
                    selectContent(player.id, content, parentContent).components,
                )
                const path = origin.path!
                // phantoms can only place other arrows if they are the head of the path
                canPlaceArrows =
                    path.items.indexOf(player.id) == path.items.length - 1
                if (canPlaceArrows) {
                    // and if their only action is to shoot the ball
                    const phantomActions = player.actions
                    canPlaceArrows =
                        phantomActions.length == 0 ||
                        phantomActions.findIndex(
                            (c) => c.type != ActionKind.SHOOT,
                        ) == -1
                }
            }

            return [
                canPlaceArrows && (
                    <CourtPlayerArrowAction
                        key={1}
                        player={player}
                        isInvalid={previewAction?.isInvalid ?? false}
                        setPreviewAction={setPreviewAction}
                        playerInfo={info}
                        content={content}
                        courtRef={courtRef}
                        setContent={service.setContent}
                    />
                ),
                !isFrozen &&
                    (info.ballState === BallState.HOLDS_ORIGIN ||
                        info.ballState === BallState.PASSED_ORIGIN) && (
                        <BallAction
                            key={2}
                            onDrop={(ballBounds) => {
                                doMoveBall(ballBounds, player)
                            }}
                        />
                    ),
            ]
        },
        [
            content,
            courtRef,
            doMoveBall,
            parentContent,
            previewAction?.isInvalid,
            service.setContent,
        ],
    )

    const renderPlayer = useCallback(
        (component: PlayerLike, isFromParent: boolean) => {
            let info: PlayerInfo
            const isPhantom = component.type == "phantom"

            let forceFreeze = isFromParent

            const usedContent = isFromParent ? parentContent! : content

            if (isPhantom) {
                info = getPhantomInfo(
                    component,
                    usedContent,
                    relativePositions,
                    courtBounds(),
                )
            } else {
                info = component
                forceFreeze ||= component.frozen
            }

            const className =
                (isPhantom ? "phantom" : "player") +
                " " +
                (isFromParent ? "from-parent" : "")

            if (forceFreeze) {
                return (
                    <CourtPlayer
                        key={component.id}
                        playerInfo={info}
                        className={className}
                        availableActions={() =>
                            renderAvailablePlayerActions(info, component)
                        }
                    />
                )
            }

            return (
                <EditableCourtPlayer
                    key={component.id}
                    className={className}
                    playerInfo={info}
                    onPositionValidated={(newPos) =>
                        validatePlayerPosition(component, info, newPos)
                    }
                    onRemove={() => doRemovePlayer(component)}
                    courtRef={courtRef}
                    availableActions={() =>
                        renderAvailablePlayerActions(info, component)
                    }
                />
            )
        },
        [
            parentContent,
            content,
            courtRef,
            relativePositions,
            courtBounds,
            renderAvailablePlayerActions,
            validatePlayerPosition,
            doRemovePlayer,
        ],
    )

    const doDeleteAction = useCallback(
        (_: Action, idx: number, origin: TacticComponent) => {
            service.setContent((content) => removeAction(origin, idx, content))
        },
        [service],
    )

    const doUpdateAction = useCallback(
        (component: TacticComponent, action: Action, actionIndex: number) => {
            service.setContent((content) =>
                updateComponent(
                    {
                        ...component,
                        actions: component.actions.toSpliced(
                            actionIndex,
                            1,
                            action,
                        ),
                    },
                    content,
                ),
            )
        },
        [service],
    )

    const renderComponent = useCallback(
        (component: TacticComponent, isFromParent: boolean): ReactNode => {
            if (component.type === "player" || component.type === "phantom") {
                return renderPlayer(component, isFromParent)
            }
            if (component.type === BALL_TYPE && !isFromParent) {
                return (
                    <CourtBall
                        key="ball"
                        ball={component}
                        onPosValidated={doMoveBall}
                        onRemove={() => {
                            service.setContent((content) => removeBall(content))
                            setObjects((objects) => [
                                ...objects,
                                { key: "ball" },
                            ])
                        }}
                    />
                )
            }
            return <></>
        },
        [service, renderPlayer, doMoveBall],
    )

    const renderActions = useCallback(
        (component: TacticComponent, isFromParent: boolean) =>
            component.actions.map((action, i) => {
                return (
                    <CourtAction
                        key={"action-" + component.id + "-" + i}
                        action={action}
                        origin={component.id}
                        courtRef={courtRef}
                        color={isFromParent ? "gray" : "black"}
                        isEditable={!isFromParent}
                        onActionDeleted={() => {
                            if (!isFromParent)
                                doDeleteAction(action, i, component)
                        }}
                        onActionChanges={(action) => {
                            if (!isFromParent)
                                doUpdateAction(component, action, i)
                        }}
                    />
                )
            }),
        [courtRef, doDeleteAction, doUpdateAction],
    )

    const contentNode = (
        <div id="content-div">
            <div id="racks">
                <PlayerRack
                    id={"allies"}
                    objects={allies}
                    setComponents={setComponents}
                    courtRef={courtRef}
                />

                <Rack
                    id={"objects"}
                    objects={objects}
                    onChange={setObjects}
                    canDetach={useCallback(
                        (div) =>
                            overlaps(
                                courtBounds(),
                                div.getBoundingClientRect(),
                            ),
                        [courtBounds],
                    )}
                    onElementDetached={useCallback(
                        (r, e: RackedCourtObject) =>
                            service.setContent((content) =>
                                placeObjectAt(
                                    r.getBoundingClientRect(),
                                    courtBounds(),
                                    e,
                                    content,
                                ),
                            ),
                        [courtBounds, service],
                    )}
                    render={renderCourtObject}
                />

                <PlayerRack
                    id={"opponents"}
                    objects={opponents}
                    setComponents={setComponents}
                    courtRef={courtRef}
                />
            </div>
            <div id="court-div">
                <div id="court-div-bounds">
                    <BasketCourt
                        parentComponents={parentContent?.components ?? null}
                        components={content.components}
                        courtImage={<Court courtType={courtType} />}
                        courtRef={courtRef}
                        previewAction={previewAction}
                        renderComponent={renderComponent}
                        renderActions={renderActions}
                    />
                </div>
            </div>
        </div>
    )

    const stepsTreeNode = (
        <EditorStepsTree
            selectedStepId={stepId}
            root={stepsTree}
            onAddChildren={useCallback(
                async (parent) => {
                    const addedNode = await service.addStep(
                        parent,
                        computeTerminalState(content, relativePositions),
                    )
                    if (typeof addedNode === "string") {
                        console.error("could not add step : " + addedNode)
                        return
                    }
                    await service.selectStep(addedNode.id)
                },
                [service, content, relativePositions],
            )}
            onRemoveNode={useCallback(
                async (removed) => {
                    await service.removeStep(removed.id)
                    await service.selectStep(
                        getParent(stepsTree, removed.id)!.id,
                    )
                },
                [service, stepsTree],
            )}
            onStepSelected={useCallback(
                (node) => service.selectStep(node.id),
                [service],
            )}
        />
    )

    return (
        <div id="main-div">
            <div id="topbar-div">
                <div id="topbar-left">
                    <SavingState state={contentSaveState} />
                </div>
                <TitleInput
                    style={titleStyle}
                    default_value={name}
                    onValidated={useCallback(
                        (new_name) => {
                            service.setName(new_name).then((state) => {
                                setTitleStyle(
                                    state == SaveStates.Ok ? {} : ERROR_STYLE,
                                )
                            })
                        },
                        [service],
                    )}
                />
                <div id="topbar-right">
                    <button
                        id="toggle-visualisation"
                        onClick={service.openVisualizer}>
                        VISUALISER
                    </button>
                    <button
                        id={"show-steps-button"}
                        onClick={() => setStepsTreeVisible((b) => !b)}>
                        ETAPES
                    </button>
                </div>
            </div>
            <div id="editor-div">
                {isStepsTreeVisible ? (
                    <SplitLayout
                        rightWidth={editorContentCurtainWidth}
                        onRightWidthChange={setEditorContentCurtainWidth}>
                        {contentNode}
                        {stepsTreeNode}
                    </SplitLayout>
                ) : (
                    contentNode
                )}
            </div>
        </div>
    )
}

interface EditorStepsTreeProps {
    selectedStepId: number
    root: StepInfoNode
    onAddChildren: (parent: StepInfoNode) => void
    onRemoveNode: (node: StepInfoNode) => void
    onStepSelected: (node: StepInfoNode) => void
}

function EditorStepsTree({
    selectedStepId,
    root,
    onAddChildren,
    onRemoveNode,
    onStepSelected,
}: EditorStepsTreeProps) {
    return (
        <div id="steps-div">
            <StepsTree
                root={root}
                selectedStepId={selectedStepId}
                onStepSelected={onStepSelected}
                onAddChildren={onAddChildren}
                onRemoveNode={onRemoveNode}
            />
        </div>
    )
}

interface PlayerRackProps {
    id: string
    objects: RackedPlayer[]
    setObjects?: (state: RackedPlayer[]) => void
    setComponents: (
        f: (components: TacticComponent[]) => TacticComponent[],
    ) => void
    courtRef: RefObject<HTMLDivElement>
}

function PlayerRack({
    id,
    objects,
    setObjects,
    courtRef,
    setComponents,
}: PlayerRackProps) {
    const courtBounds = useCallback(
        () => courtRef.current!.getBoundingClientRect(),
        [courtRef],
    )

    return (
        <Rack
            id={id}
            objects={objects}
            onChange={setObjects}
            canDetach={useCallback(
                (div) => overlaps(courtBounds(), div.getBoundingClientRect()),
                [courtBounds],
            )}
            onElementDetached={useCallback(
                (r, e: RackedPlayer) =>
                    setComponents((components) => [
                        ...components,
                        placePlayerAt(
                            r.getBoundingClientRect(),
                            courtBounds(),
                            e,
                        ),
                    ]),
                [courtBounds, setComponents],
            )}
            render={useCallback(
                ({ team, key }: { team: PlayerTeam; key: string }) => (
                    <PlayerPiece
                        team={team}
                        text={key}
                        key={key}
                        hasBall={false}
                    />
                ),
                [],
            )}
        />
    )
}

interface CourtPlayerArrowActionProps {
    playerInfo: PlayerInfo
    player: PlayerLike
    isInvalid: boolean

    content: StepContent
    setContent: (state: SetStateAction<StepContent>) => void
    setPreviewAction: (state: SetStateAction<ActionPreview | null>) => void
    courtRef: RefObject<HTMLDivElement>
}

function CourtPlayerArrowAction({
    playerInfo,
    player,
    isInvalid,

    content,
    setContent,
    setPreviewAction,
    courtRef,
}: CourtPlayerArrowActionProps) {
    const courtBounds = useCallback(
        () => courtRef.current!.getBoundingClientRect(),
        [courtRef],
    )

    return (
        <ArrowAction
            key={1}
            onHeadMoved={(headPos) => {
                const arrowHeadPos = middlePos(headPos)
                const targetIdx = getComponentCollided(
                    headPos,
                    content.components,
                )
                const target = content.components[targetIdx]

                setPreviewAction((action) => ({
                    ...action!,
                    segments: [
                        {
                            next: ratioWithinBase(arrowHeadPos, courtBounds()),
                        },
                    ],
                    type: getActionKind(target, playerInfo.ballState).kind,
                    isInvalid:
                        !overlaps(headPos, courtBounds()) ||
                        !isActionValid(player, target, content.components),
                }))
            }}
            onHeadPicked={(headPos) => {
                ;(document.activeElement as HTMLElement).blur()

                setPreviewAction({
                    origin: playerInfo.id,
                    type: getActionKind(null, playerInfo.ballState).kind,
                    target: ratioWithinBase(headPos, courtBounds()),
                    segments: [
                        {
                            next: ratioWithinBase(
                                middlePos(headPos),
                                courtBounds(),
                            ),
                        },
                    ],
                    isInvalid: false,
                })
            }}
            onHeadDropped={(headRect) => {
                if (isInvalid) {
                    setPreviewAction(null)
                    return
                }

                setContent((content) => {
                    let { createdAction, newContent } = createAction(
                        player,
                        courtBounds(),
                        headRect,
                        content,
                    )

                    if (createdAction.type == ActionKind.SHOOT) {
                        const targetIdx = newContent.components.findIndex(
                            (c) => c.id == createdAction.target,
                        )
                        newContent = dropBallOnComponent(
                            targetIdx,
                            newContent,
                            false,
                        )
                        const ballState =
                            player.ballState === BallState.HOLDS_ORIGIN
                                ? BallState.PASSED_ORIGIN
                                : BallState.PASSED
                        newContent = updateComponent(
                            {
                                ...(newContent.components.find(
                                    (c) => c.id == player.id,
                                )! as PlayerLike),
                                ballState,
                            },
                            newContent,
                        )
                    }

                    return newContent
                })
                setPreviewAction(null)
            }}
        />
    )
}

function isBallOnCourt(content: StepContent) {
    return (
        content.components.findIndex(
            (c) =>
                ((c.type === "player" || c.type === "phantom") &&
                    (c.ballState === BallState.HOLDS_ORIGIN ||
                        c.ballState === BallState.PASSED_ORIGIN)) ||
                c.type === BALL_TYPE,
        ) != -1
    )
}

function renderCourtObject(courtObject: RackedCourtObject) {
    if (courtObject.key == "ball") {
        return <BallPiece />
    }
    throw new Error("unknown racked court object " + courtObject.key)
}

function debounceAsync<A, B>(
    f: (args: A) => Promise<B>,
    delay = 1000,
): (args: A) => Promise<B> {
    let task = 0
    return (args: A) => {
        clearTimeout(task)
        return new Promise((resolve, reject) => {
            task = setTimeout(() => f(args).then(resolve).catch(reject), delay)
        })
    }
}

function useContentState<S>(
    initialContent: S,
    initialSaveState: SaveState,
    applyStateCallback: (content: S) => Promise<SaveState>,
): [S, (newState: SetStateAction<S>, runCallback: boolean) => void, SaveState] {
    const [content, setContent] = useState(initialContent)
    const [savingState, setSavingState] = useState(initialSaveState)

    const setContentSynced = useCallback(
        (newState: SetStateAction<S>, callSaveCallback: boolean) => {
            setContent((content) => {
                const state =
                    typeof newState === "function"
                        ? (newState as (state: S) => S)(content)
                        : newState

                if (state !== content && callSaveCallback) {
                    setSavingState(SaveStates.Saving)
                    applyStateCallback(state)
                        .then(setSavingState)
                        .catch((e) => {
                            setSavingState(SaveStates.Err)
                            console.error(e)
                        })
                }
                return state
            })
        },
        [applyStateCallback],
    )

    return [content, setContentSynced, savingState]
}

async function updateStepContents(
    stepId: number,
    stepsTree: StepInfoNode,
    getStepContent: (stepId: number) => Promise<ComputedStepContent>,
    setStepContent: (stepId: number, content: StepContent) => Promise<void>,
) {
    async function updateSteps(
        step: StepInfoNode,
        content: StepContent,
        relativePositions: ComputedRelativePositions,
    ) {
        const terminalStateContent = computeTerminalState(
            content,
            relativePositions,
        )

        for (const child of step.children) {
            const {
                content: childContent,
                relativePositions: childRelativePositions,
            } = await getStepContent(child.id)
            const childUpdatedContent = drainTerminalStateOnChildContent(
                terminalStateContent,
                childContent,
            )
            if (childUpdatedContent) {
                await setStepContent(child.id, childUpdatedContent)
                await updateSteps(
                    child,
                    childUpdatedContent,
                    childRelativePositions,
                )
            }
        }
    }

    const { content, relativePositions } = await getStepContent(stepId)
    const startNode = getStepNode(stepsTree!, stepId)!

    await updateSteps(startNode, content, relativePositions)
}
