if not _G.charSelectExists then return end

if not _G.physBoneInit then
    -- working variables for physbones
    _G.physBoneMem = _G.physBoneMem or {}
    if not _G.physBoneMem[0] then
        for i = 0, MAX_PLAYERS - 1 do
            _G.physBoneMem[i] = {}
        end
    end
    -- configurable data for physbones, indexed by model ID & physbone index
    _G.physBoneData = _G.physBoneData or {}

    -- initialize a physbone entry for a character model. fields left `nil` will use their default value.
    ---@param modelId ModelExtendedId|integer
    ---@param index integer
    ---@param pull number|nil (0.0 - 1.0, default 0.25) amount of force used to return the physbone chain to rest position.
    ---@param spring number|nil (0.0 - 1.0, default 0.5) how much physbones will wobble while reaching rest position.
    ---@param yawLimit number|nil (0 - 90, default 45) the maximum yaw angle that physbones can be from rest rotation.
    ---@param pitchLimit number|nil (0 - 90) the maximum pitch angle for physbones. matches yaw when `nil`.
    function _G.init_physbone(modelId, index, pull, spring, yawLimit, pitchLimit)
        _G.physBoneData[modelId] = _G.physBoneData[modelId] or {}
        _G.physBoneData[modelId][index] = {}
        local data = _G.physBoneData[modelId][index]

        data.pull = math.clamp(pull or 0.25, 0.0, 1.0)
        data.spring = math.clamp(spring or 0.5, 0.0, 1.0)
        data.yawLimit = math.clamp(degrees_to_sm64(math.abs(yawLimit or 45)), 0x0000, 0x3FFF)
        data.pitchLimit = math.clamp(degrees_to_sm64(math.abs(pitchLimit or yawLimit or 45)), 0x0000, 0x3FFF)
    end

    _G.physBoneEnabled = true
    hook_chat_command('physbone', " - Enable/Disable physbones", function(msg)
        _G.physBoneEnabled = not _G.physBoneEnabled
        djui_chat_message_create("Physbones " .. (_G.physBoneEnabled and "Enabled" or "Disabled"))
        return true
    end)

    _G.physBoneInit = true
end

-- UTILS

local gCS = _G.charSelect.gCSPlayers

function wrap(n, min, max)
    local range = max - min
    while n < min do
        n = n + range
    end
    while n > max do
        n = n - range
    end
    return n
end

local function convert_s16(n)
    return wrap(n, -0x8000, 0x8000)
end

---@param a Vec3f
---@param b Vec3f
---@return integer yaw
---@return integer pitch
local function yaw_pitch_between_points(a, b)
    local dir = { x = b.x - a.x, y = b.y - a.y, z = b.z - a.z }
    return atan2s(dir.z, dir.x), -atan2s(math.sqrt(dir.x ^ 2 + dir.z ^ 2), dir.y)
end

---@param dir Vec3f
---@return integer yaw
---@return integer pitch
local function yaw_pitch(dir)
    return radians_to_sm64(math.atan(dir.x, dir.z)), radians_to_sm64(math.atan(dir.y, math.sqrt(dir.x ^ 2 + dir.z ^ 2)))
end

-- GEO FUNCTION

if VERSION_NUMBER >= 41 then
    ---@param node GraphNode
    ---@param matStackIndex integer
    function geo_physbone_chain(node, matStackIndex)
        local m = geo_get_mario_state()
        local i = cast_graph_node(node).parameter

        -- mirror room fix
        if m.marioBodyState.mirrorMario then return end

        if not _G.physBoneEnabled then
            local child = node.next
            while child do
                if child.type == GRAPH_NODE_TYPE_ROTATION then
                    local rotNode = cast_graph_node(child)
                    vec3s_copy(rotNode.rotation, gVec3sZero())
                end
                if child.type == GRAPH_NODE_TYPE_DISPLAY_LIST then
                    child = child.next
                else
                    child = child.children
                end
            end
            return
        end

        local mem = _G.physBoneMem[m.playerIndex][i]
        if not mem then
            _G.physBoneMem[m.playerIndex][i] = { prevPos = gVec3fZero(), prevYaw = 0, prevPitch = 0, yaw = 0, pitch = 0 }
            mem = _G.physBoneMem[m.playerIndex][i]
        end

        local data = _G.physBoneData[gCS[m.playerIndex].modelId]
        if not data then return end
        data = data[i]
        if not data then return end

        local camInv = gMat4Zero()
        mtxf_inverse(camInv, geo_get_current_camera().matrixPtr)
        ---@type Mat4
        local mtx = gMat4Zero()
        mtxf_mul(mtx, gMatStack[matStackIndex], camInv)       -- convert root matrix into world space
        mtxf_scale_vec3f(mtx, mtx, { x = 4.0, y = 4.0, z = 4.0 }) -- rescale from 0.25 -> 1.0
        local pos = { x = mtx.m30, y = mtx.m31, z = mtx.m32 }
        local posDiff = { x = mem.prevPos.x, y = mem.prevPos.y, z = mem.prevPos.z }
        vec3f_sub(posDiff, pos)

        local curYaw, curPitch = yaw_pitch({ x = mtx.m10, y = mtx.m11, z = mtx.m12 })

        local yawDiff = convert_s16(curYaw - mem.prevYaw)
            - (vec3f_dot(posDiff, { x = mtx.m20, y = mtx.m21, z = mtx.m22 }) * 0x40) --+ (posDiff.y * 0x08)
        local pitchDiff = convert_s16(curPitch - mem.prevPitch)
            + (vec3f_dot(posDiff, { x = mtx.m00, y = mtx.m01, z = mtx.m02 }) * 0x40) + (posDiff.y * 0x10)

        local rotX = mem.yaw - yawDiff
        local rotZ = mem.pitch - pitchDiff
        rotX = clamp(approach_s16_symmetric(rotX, 0, math.abs(rotX) * data.pull), -data.yawLimit,
            data.yawLimit)
        rotZ = clamp(approach_s16_symmetric(rotZ, 0, math.abs(rotZ) * data.pull), -data.pitchLimit,
            data.pitchLimit)

        mem.yaw = rotX
        mem.pitch = rotZ

        local j = 1
        local child = node.next

        while child do
            if child.type == GRAPH_NODE_TYPE_ROTATION then
                local rotNode = cast_graph_node(child)
                local rot = rotNode.rotation

                local fac = clamp(1.0 - j * 0.1, 0.1, 1.0)
                rot.x = rotX * fac
                rot.z = rotZ * fac

                j = j + 1

                child = child.children
            elseif child.type == GRAPH_NODE_TYPE_DISPLAY_LIST then
                child = child.next
            else
                child = child.children
            end
        end

        mem.prevYaw = curYaw
        mem.prevPitch = curPitch
        vec3f_copy(mem.prevPos, pos)
    end
else
    ---@param node GraphNode
    ---@param matStackIndex integer
    function geo_physbone_chain(node, matStackIndex)
        local m = geo_get_mario_state()
        local i = cast_graph_node(node).parameter

        -- mirror room fix
        if m.marioBodyState.mirrorMario then return end

        if not _G.physBoneEnabled then
            local child = node.next
            while child do
                if child.type == GRAPH_NODE_TYPE_ROTATION then
                    local rotNode = cast_graph_node(child)
                    vec3s_copy(rotNode.rotation, gVec3sZero())
                end
                if child.type == GRAPH_NODE_TYPE_DISPLAY_LIST then
                    child = child.next
                else
                    child = child.children
                end
            end
            return
        end

        local mem = _G.physBoneMem[m.playerIndex][i]
        if not mem then
            _G.physBoneMem[m.playerIndex][i] = { prevPos = gVec3fZero(), prevYaw = 0, prevPitch = 0, yaw = 0, pitch = 0 }
            mem = _G.physBoneMem[m.playerIndex][i]
        end

        local data = _G.physBoneData[gCS[m.playerIndex].modelId]
        if not data then return end
        data = data[i]
        if not data then return end

        local camInv = gMat4Zero()
        mtxf_inverse(camInv, geo_get_current_camera().matrixPtr)
        ---@type Mat4
        local mtx = gMat4Zero()
        mtxf_mul(mtx, gMatStack[matStackIndex], camInv)       -- convert root matrix into world space
        mtxf_scale_vec3f(mtx, mtx, { x = 4.0, y = 4.0, z = 4.0 }) -- rescale from 0.25 -> 1.0
        local pos = { x = mtx.m30, y = mtx.m31, z = mtx.m32 }
        local posDiff = { x = mem.prevPos.x, y = mem.prevPos.y, z = mem.prevPos.z }
        vec3f_sub(posDiff, pos)

        local curYaw, curPitch = yaw_pitch({ x = mtx.m10, y = mtx.m11, z = mtx.m12 })

        local yawDiff = convert_s16(curYaw - mem.prevYaw)
            - (vec3f_dot(posDiff, { x = mtx.m20, y = mtx.m21, z = mtx.m22 }) * 0x40) --+ (posDiff.y * 0x08)
        local pitchDiff = convert_s16(curPitch - mem.prevPitch)
            + (vec3f_dot(posDiff, { x = mtx.m00, y = mtx.m01, z = mtx.m02 }) * 0x40) + (posDiff.y * 0x10)

        local rotX = mem.yaw - yawDiff
        local rotZ = mem.pitch - pitchDiff
        rotX = clamp(approach_s16_symmetric(rotX, 0, math.abs(rotX) * data.pull), -data.yawLimit,
            data.yawLimit)
        rotZ = clamp(approach_s16_symmetric(rotZ, 0, math.abs(rotZ) * data.pull), -data.pitchLimit,
            data.pitchLimit)

        mem.yaw = rotX
        mem.pitch = rotZ

        local j = 1
        local child = node.next

        while child do
            if child.type == GRAPH_NODE_TYPE_ROTATION then
                local rotNode = cast_graph_node(child)
                local rot = rotNode.rotation

                local fac = clamp(1.0 - j * 0.1, 0.1, 1.0)
                rot.x = rotX * fac
                rot.z = rotZ * fac

                j = j + 1

                if m.playerIndex ~= 0 then
                    vec3s_copy(rotNode.prevRotation, rotNode.rotation)
                    rotNode.prevTimestamp = get_global_timer()
                end

                child = child.children
            elseif child.type == GRAPH_NODE_TYPE_DISPLAY_LIST then
                child = child.next
            else
                child = child.children
            end
        end

        mem.prevYaw = curYaw
        mem.prevPitch = curPitch
        vec3f_copy(mem.prevPos, pos)
    end
end
