-- Ordinal indices are mapped beginning from the entry positioned nearest to this angle in either direction.
--   0 deg = bottom-most entry
--  90 deg = left-most entry
-- 180 deg = top-most entry
-- 270 deg = right-most entry
-- Note that preference can be given to the entry slightly to the left or right of an exact angle;
-- For example, 179 degrees would give preference to the right, top-most entry when there are an
-- odd number of entries in the radial menu.
ZO_RADIAL_MENU_PREFERRED_ORDINAL_STARTING_ANGLE_RADIANS = math.rad(179)

-- The rotational direction in which ordinal indices increment.
--  -1 = Clockwise
--   1 = Counter-clockwise
ZO_RADIAL_MENU_ORDINAL_DIRECTION = -1

ZO_RadialMenu = ZO_Object:Subclass()

function ZO_RadialMenu:New(...)
    local radialMenu = ZO_Object.New(self)
    radialMenu:Initialize(...)
    return radialMenu
end

local g_activeMenu = nil
local MIN_DISTANCE = 10 -- The distance the mouse has to move before we start selecting anything
local DEFAULT_DIRECTIONAL_INPUTS = {ZO_DI_LEFT_STICK, ZO_DI_RIGHT_STICK}

function ZO_RadialMenu.ForceActiveMenuClosed()
    if g_activeMenu then
        g_activeMenu:Clear()
    end
end

function ZO_RadialMenu:Initialize(control, entryTemplate, animationTemplate, entryAnimationTemplate, actionLayerName, directionInputs, enableMouse, selectIfCentered, showKeybinds)
    self.control = control

    self.selectedBackground = control:GetNamedChild("SelectedBackground")
    self.unselectedBackground = control:GetNamedChild("UnselectedBackground")
    self.actionLabel = control:GetNamedChild("Action")
    self.directionInputs = directionInputs or DEFAULT_DIRECTIONAL_INPUTS
    self.enableMouse = (enableMouse ~= false) -- nil should be true.
    self.selectIfCentered = (selectIfCentered ~= false) -- nil should be true.
    self.activateOnShow = true
    self.showKeybinds = showKeybinds

    self.control:SetHandler("OnUpdate", function() self:OnUpdate() end)

    local function ResetEntryControl(entryControl)
        entryControl:SetHidden(true)
        entryControl:ClearAnchors()
        if entryControl.animation then
            entryControl.animation:PlayBackward()
        end

        if entryControl.keybindLabel then
            ZO_Keybindings_UnregisterLabelForBindingUpdate(entryControl.keybindLabel)
            entryControl.keybindLabel:SetHidden(true)
        end
    end
    
    local function CreateEntryControl(objectPool)
        local entryControl = ZO_ObjectPool_CreateNamedControl(control:GetName() .. "Entry", entryTemplate, objectPool, control)
        entryControl.icon = entryControl:GetNamedChild("Icon")
        if entryAnimationTemplate then
            entryControl.animation = ANIMATION_MANAGER:CreateTimelineFromVirtual(entryAnimationTemplate, entryControl)
        end
        return entryControl
    end

    self.entryPool = ZO_ObjectPool:New(CreateEntryControl, ResetEntryControl)
    self.entries = {}

    if animationTemplate then
        self.animation = ANIMATION_MANAGER:CreateTimelineFromVirtual(animationTemplate, control)
        self.animation:SetHandler("OnStop", function() self:OnAnimationStopped() end)
    end

    self.actionLayerName = actionLayerName

    self:Clear()
end

function ZO_RadialMenu:SetActivateOnShow(activateOnShow)
    self.activateOnShow = activateOnShow
end

function ZO_RadialMenu:SetOnClearCallback(callback)
    self.onClearCallback = callback
end

function ZO_RadialMenu:SetOnSelectionChangedCallback(callback)
    self.onSelectionChangedCallback = callback
end

function ZO_RadialMenu:SetCustomControlSetUpFunction(setupFunction)
    self.setupFunction = setupFunction
end

function ZO_RadialMenu:SelectCurrentEntry()
    if self:IsShown() and self.selectedEntry and self.selectedEntry.callback and (not self.animation or not self.animation:IsPlayingBackward()) then
        self.selectedEntry.callback(self)
        self:Clear(true)
    else
        self:Clear()
    end
end

local function Dot(x1, y1, x2, y2)
    return x1 * x2 + y1 * y2
end

local function SetupActionLabel(actionLabel, textData)
    if actionLabel then
        if textData then
            actionLabel:SetHidden(false)

            if type(textData) == "table"
            then
                actionLabel:SetText(textData[1])
                actionLabel:SetColor(textData[2].r, textData[2].g, textData[2].b, 1)
            else
                actionLabel:SetText(textData)
                actionLabel:SetColor(1, 1, 1, 1)
            end
        else
            actionLabel:SetHidden(true)
        end
    end
end

function ZO_RadialMenu:FindSelectedEntry(x, y, suppressSound)
    local shouldSelect = true

    if not self.selectIfCentered then
        local lenSquared = Dot(self.virtualMouseX, self.virtualMouseY, self.virtualMouseX, self.virtualMouseY)
        local outerRadius = zo_max(self.control:GetDimensions()) * .5
        local innerRadius = zo_max(outerRadius * .35, MIN_DISTANCE + 1)
        if lenSquared < innerRadius * innerRadius then
            shouldSelect = false
            self.selectedEntry = nil
            self.selectedControl = nil
            if self.onSelectionChangedCallback then
                self.onSelectionChangedCallback(nil)
            end
        end
    end

    for key, entryControl in pairs(self.entryPool:GetActiveObjects()) do   
        if shouldSelect and Dot(x, y, -entryControl.startY, entryControl.startX) < 0 and Dot(x, y, -entryControl.endY, entryControl.endX) > 0 then
            if self.selectedEntry ~= entryControl.entry then
                self.selectedEntry = entryControl.entry
                self.selectedControl = entryControl
                if not suppressSound then
                    PlaySound(SOUNDS.RADIAL_MENU_MOUSEOVER)
                end
                if self.onSelectionChangedCallback then
                    self.onSelectionChangedCallback(self.selectedEntry)
                end
            end

            SetupActionLabel(self.actionLabel, self.selectedEntry.name)

            if entryControl.icon then
                entryControl.icon:SetTexture(self.selectedEntry.activeIcon)
            end
            if entryControl.animation then
                entryControl.animation:PlayForward()
            end
        else
            if entryControl.icon then
                entryControl.icon:SetTexture(entryControl.entry.inactiveIcon)
            end
            if entryControl.animation then
                entryControl.animation:PlayBackward()
            end
        end
    end
end

do
    local MIN_DISTANCE_SQUARED = MIN_DISTANCE * MIN_DISTANCE

    function ZO_RadialMenu:ShouldUpdateSelection()
        local lenSquared = Dot(self.virtualMouseX, self.virtualMouseY, self.virtualMouseX, self.virtualMouseY)

        if not self.selectedEntry and lenSquared < MIN_DISTANCE_SQUARED then
            return false
        end

        local outerRadius = zo_max(self.control:GetDimensions()) * .5
        if lenSquared > outerRadius * outerRadius then
            local len = zo_sqrt(lenSquared)
            self.virtualMouseX = self.virtualMouseX / len * outerRadius
            self.virtualMouseY = self.virtualMouseY / len * outerRadius
        elseif self.selectIfCentered then
            local innerRadius = zo_max(outerRadius * .35, MIN_DISTANCE + 1)
            if lenSquared < innerRadius * innerRadius then
                return false --don't update the selection while we're in the inner radius
            end
        end

        return true
    end

    function ZO_RadialMenu:UpdateVirtualMousePosition()
        if self.enableMouse then
            local deltaX, deltaY = GetUIMouseDeltas()
            if deltaX ~= 0 or deltaY ~= 0 then
                self.virtualMouseX = self.virtualMouseX + deltaX
                self.virtualMouseY = self.virtualMouseY + deltaY

                return self:ShouldUpdateSelection()
            end
        end
        return false
    end

    function ZO_RadialMenu:UpdateVirtualMousePositionFromGamepad()
        local outerRadius = zo_max(self.control:GetDimensions()) * .5
        local x, y = DIRECTIONAL_INPUT:GetXY(unpack(self.directionInputs))
        if (not self.selectIfCentered) or (x ~= 0) or (y ~= 0) then
            self.virtualMouseX = x * outerRadius
            self.virtualMouseY = -y * outerRadius
            
            return self:ShouldUpdateSelection()
        end
        return false
    end   
end

function ZO_RadialMenu:SetOnUpdateRotationFunction(rotationFunc)
    self.onUpdateRotationFunc = rotationFunc
end

function ZO_RadialMenu:SetShowKeybinds(showKeybinds)
    self.showKeybinds = showKeybinds
end

--Specifies an action layer to push specifically when showing the keybinds on the wheel
function ZO_RadialMenu:SetKeybindActionLayer(keybindActionLayer)
    self.keybindActionLayer = keybindActionLayer
end

function ZO_RadialMenu:ShouldShowKeybinds()
    if type(self.showKeybinds) == "function" then
        return self.showKeybinds()
    else
        return self.showKeybinds
    end
end

do
    local atan2 = math.atan2
    local ROTATION_OFFSET = 3 * ZO_HALF_PI

    function ZO_RadialMenu:UpdateSelectedEntryFromVirtualMousePosition(suppressSound)
        self:FindSelectedEntry(self.virtualMouseX, self.virtualMouseY, suppressSound)
        local hasSelection = (self.selectedEntry ~= nil)
        local rotation = atan2(-self.virtualMouseY, self.virtualMouseX) + ROTATION_OFFSET
        if self.selectedBackground then
            self.selectedBackground:SetHidden(not hasSelection)
            self.selectedBackground:SetTextureRotation(rotation)
        end
        if self.unselectedBackground then
            self.unselectedBackground:SetHidden(hasSelection)
        end
        if self.onUpdateRotationFunc then
            self.onUpdateRotationFunc(rotation)
        end
    end

    function ZO_RadialMenu:OnUpdate()
        if not IsInGamepadPreferredMode() then
            if self:UpdateVirtualMousePosition() then
                self:UpdateSelectedEntryFromVirtualMousePosition()
            end
        end
    end
    
    function ZO_RadialMenu:UpdateDirectionalInput()
        if self:UpdateVirtualMousePositionFromGamepad() then
            self:UpdateSelectedEntryFromVirtualMousePosition()
        end
    end
end

-- Returns the angle (radians) alloted per Entry and
-- the starting Entry's offset angle (radians).
function ZO_RadialMenu:GetArcAnglePerEntryAndStartingOffsetAngle()
    local numEntries = #self.entries
    if numEntries == 0 then
        return 0, 0
    end

    local arcAnglePerEntryRadians = ZO_TWO_PI / numEntries
    local startingOffsetAngleRadians = (numEntries == 2 and ZO_HALF_PI or 0) + arcAnglePerEntryRadians
    return arcAnglePerEntryRadians, startingOffsetAngleRadians
end

-- Returns the Entry at the ordinal position [1..n] beginning with the entry nearest to
-- ZO_RADIAL_MENU_PREFERRED_ORDINAL_STARTING_ANGLE_RADIANS and progressing clockwise or
-- counter-clockwise as defined by ZO_RADIAL_MENU_ORDINAL_DIRECTION.
function ZO_RadialMenu:GetOrdinalEntry(ordinalIndex)
    local numEntries = #self.entries
    if ordinalIndex > numEntries then
        -- Invalid ordinal index and/or no entries exist.
        return nil
    end

    -- Determine the shortest arc distance between the positional angle
    -- of the first entry and the preferred starting ordinal angle.
    local arcAnglePerEntryRadians, startingOffsetAngleRadians = self:GetArcAnglePerEntryAndStartingOffsetAngle()
    local preferredStartingAngleDistanceRadians = (ZO_RADIAL_MENU_PREFERRED_ORDINAL_STARTING_ANGLE_RADIANS - startingOffsetAngleRadians) % ZO_TWO_PI

    -- Determine the entry index offset of the starting ordinal index.
    local startingOrdinalEntryIndexOffset = zo_round(preferredStartingAngleDistanceRadians / arcAnglePerEntryRadians)

    -- Determine the final entry index associated with the requested ordinal
    -- index and clamp within the range [1..n] for n entries.
    local ordinalEntryIndex = startingOrdinalEntryIndexOffset + ((ordinalIndex - 1) * ZO_RADIAL_MENU_ORDINAL_DIRECTION)
    ordinalEntryIndex = (ordinalEntryIndex % numEntries) + 1

    return self.entries[ordinalEntryIndex]
end

-- Returns an iterator that iterates over each Entry in the ordinal
-- order defined by ZO_RadialMenu:GetOrdinalEntry.
function ZO_RadialMenu:OrdinalEntryIterator()
    local ordinalIndex = 0
    return function()
        ordinalIndex = ordinalIndex + 1
        local entry = self:GetOrdinalEntry(ordinalIndex)
        if entry then
            return ordinalIndex, entry
        end
        return nil, nil
    end
end

-- Invokes callbackFunction once for each Entry in the ordinal
-- order defined by ZO_RadialMenu:GetOrdinalEntry.
function ZO_RadialMenu:ForEachOrdinalEntry(callbackFunction)
    for ordinalIndex, entry in self:OrdinalEntryIterator() do
        callbackFunction(ordinalIndex, entry)
    end
end

function ZO_RadialMenu:PerformLayout()
    local width, height = self.control:GetDimensions()
    local halfWidth, halfHeight = width / 2 / self.control:GetScale(), height / 2 / self.control:GetScale()
    local arcAnglePerEntryRadians, initialRotation = self:GetArcAnglePerEntryAndStartingOffsetAngle()
    local halfArcAnglePerEntryRadians = arcAnglePerEntryRadians * 0.5

    self.entryPool:ReleaseAllObjects()

    -- For this circle, 0 rotation points straight down from the circle's center and rotation is in CCW direction.
    for i, entry in ipairs(self.entries) do
        local centerAngle = initialRotation + (i - 1) * arcAnglePerEntryRadians
        local x = math.sin(centerAngle)
        local y = math.cos(centerAngle)

        --- math.sin is returning very small numbers instead of 0 for PI and TWO_PI
        if math.abs(x) < 0.01 then
            x = 0
        end

        local entryControl = self.entryPool:AcquireObject()
        if entryControl.icon then
            entryControl.icon:SetTexture(entry.inactiveIcon)

            if entryControl.label then
                entryControl.label:ClearAnchors()
                if x > 0 then
                    entryControl.label:SetAnchor(LEFT, entryControl.icon, RIGHT, 15, 0)
                elseif x < 0 then
                    entryControl.label:SetAnchor(RIGHT, entryControl.icon, LEFT, -15, 0)
                elseif y > 0 then        
                    entryControl.label:SetAnchor(TOP, entryControl.icon, BOTTOM, 0, 0)
                else
                    entryControl.label:SetAnchor(BOTTOM, entryControl.icon, TOP, 0, -5)
                end
            end
        end

        if self.setupFunction then
            self.setupFunction(entryControl, entry.data)
        end

        entryControl:SetAnchor(CENTER, nil, CENTER, x * halfWidth, y * halfHeight)
        entryControl:SetHidden(false)

        entryControl.startX = math.sin(centerAngle - halfArcAnglePerEntryRadians)
        entryControl.startY = math.cos(centerAngle - halfArcAnglePerEntryRadians)

        entryControl.centerX = x
        entryControl.centerY = y

        entryControl.endX = math.sin(centerAngle + halfArcAnglePerEntryRadians)
        entryControl.endY = math.cos(centerAngle + halfArcAnglePerEntryRadians)

        entryControl.entry = entry
        entry.control = entryControl
    end

    --If we're showing keybinds, do the layout for those now
    --We need to do this *after* the rest of the layout has been completed so we can correctly calculate the ordinal indices
    if self:ShouldShowKeybinds() then
        self:LayoutOrdinalKeybinds()
    end
end

do
    local function LayoutOrdinalKeybind(ordinalIndex, entry)
        local control = entry.control
        if control and control.keybindLabel then
            ZO_Keybindings_RegisterLabelForBindingUpdate(control.keybindLabel, ZO_GetRadialMenuActionNameForOrdinalIndex(ordinalIndex))
            control.keybindLabel:SetHidden(false)
        end
    end

    function ZO_RadialMenu:LayoutOrdinalKeybinds()
        self:ForEachOrdinalEntry(LayoutOrdinalKeybind)
    end
end

-- name can be either a text string or a table containing the text in the first entry, and a color table in the second entry
-- e.g.
--      Passing "Test" in name will just set the label to "Test" in white
--      Passing {"Test", {r = 1, g = 0, b = 0}} will set the label to "Test" in the color red

function ZO_RadialMenu:AddEntry(name, inactiveIcon, activeIcon, callback, data)
    self.entries[#self.entries + 1] = { name = name, inactiveIcon = inactiveIcon, activeIcon = activeIcon, callback = callback, data = data }
end

function ZO_RadialMenu:UpdateEntriesByName(name, inactiveIcon, activeIcon, callback, data)
    for i, entry in ipairs(self.entries) do
        if self.entries[i].name == name then
            self.entries[i].inactiveIcon = inactiveIcon
            self.entries[i].activeIcon = activeIcon
            self.entries[i].callback = callback
            self.entries[i].data = data
        end
    end

    self:Refresh()
end

function ZO_RadialMenu:UpdateFirstEntryByFilter(filterFunction, name, inactiveIcon, activeIcon, callback, data)
    for i, entry in ipairs(self.entries) do
        if filterFunction(entry) then
            self.entries[i].name = name
            self.entries[i].inactiveIcon = inactiveIcon
            self.entries[i].activeIcon = activeIcon
            self.entries[i].callback = callback
            self.entries[i].data = data
            break
        end
    end

    self:Refresh()
end

function ZO_RadialMenu:SelectFirstEntryByFilter(filterFunction)
    for i, entry in ipairs(self.entries) do
        if filterFunction(entry) then
            --Set the virtual mouse position to the center of the control we are trying to select
            self.virtualMouseX = entry.control.centerX
            self.virtualMouseY = entry.control.centerY
            self:UpdateSelectedEntryFromVirtualMousePosition()
            return true
        end
    end

    return false
end

function ZO_RadialMenu:SelectOrdinalEntry(ordinalIndex)
    local entry = self:GetOrdinalEntry(ordinalIndex)
    if entry then
        --Set the virtual mouse position to the center of the control we are trying to select
        self.virtualMouseX = entry.control.centerX
        self.virtualMouseY = entry.control.centerY
        self:UpdateSelectedEntryFromVirtualMousePosition()
        return true
    end

    return false
end

function ZO_RadialMenu:OnAnimationStopped()
    if self.animation:IsPlayingBackward() then
        self:FinalizeClear()
    end
end

function ZO_RadialMenu:Clear(entrySelected)
    ZO_ClearNumericallyIndexedTable(self.entries)

    if not self.control:IsHidden() then
        if entrySelected then
            PlaySound(SOUNDS.RADIAL_MENU_SELECTION)
        else
            PlaySound(SOUNDS.RADIAL_MENU_CLOSE)
        end

        if self.animation then
            self.animation:PlayBackward()
        else
            self:FinalizeClear()
        end
    end
end

function ZO_RadialMenu:Activate()
    DIRECTIONAL_INPUT:Activate(self, self.control)
end

function ZO_RadialMenu:Deactivate()
    DIRECTIONAL_INPUT:Deactivate(self)
end

function ZO_RadialMenu:ClearSelection()
    self.virtualMouseX = 0
    self.virtualMouseY = 0
    if self.selectedEntry ~= nil then
        self.selectedEntry = nil
        if self.onSelectionChangedCallback then
            self.onSelectionChangedCallback(nil)
        end
    end
    self:UpdateSelectedEntryFromVirtualMousePosition()
end

function ZO_RadialMenu:FinalizeClear()
    if g_activeMenu == self then
        g_activeMenu = nil
    end
    self.entryPool:ReleaseAllObjects()
    self.control:SetHidden(true)
    if self.actionLabel then
        self.actionLabel:SetHidden(true)
    end
    if self.selectedBackground then
        self.selectedBackground:SetHidden(true)
    end
    if self.unselectedBackground then
        self.unselectedBackground:SetHidden(false)
    end
    
    if self.onClearCallback and (not self.animation or not self.animation:IsPlaying()) then
        self.onClearCallback(self)
    end

    if self.actionLayerName then
        if type(self.actionLayerName) == "table" then
            for _, actionLayer in pairs(self.actionLayerName) do
                RemoveActionLayerByName(actionLayer)
            end
        else
            RemoveActionLayerByName(self.actionLayerName)
        end
    end

    if self:ShouldShowKeybinds() and self.keybindActionLayer then
        RemoveActionLayerByName(self.keybindActionLayer)
    end

    self:ClearSelection()
    self:Deactivate()
end

function ZO_RadialMenu:Show(suppressSound)
    if g_activeMenu then
        g_activeMenu:FinalizeClear()
    end
    g_activeMenu = self

    self:ClearSelection()

    if self.control:IsHidden() and not suppressSound then
        PlaySound(SOUNDS.RADIAL_MENU_OPEN)
    end

    self.control:SetHidden(false)

    if self.animation then
        self.animation:PlayForward()
    end
    self:PerformLayout()
    if self.actionLayerName then
        if type(self.actionLayerName) == "table" then
            for _, actionLayer in pairs(self.actionLayerName) do
                PushActionLayerByName(actionLayer)
            end
        else
            PushActionLayerByName(self.actionLayerName)
        end
    end

    if self:ShouldShowKeybinds() and self.keybindActionLayer then
        PushActionLayerByName(self.keybindActionLayer)
    end

    if self.activateOnShow then
        self:Activate()
    end
end

--Helper functions added to clear and refresh the menu while it is still showing
function ZO_RadialMenu:ResetData()
    ZO_ClearNumericallyIndexedTable(self.entries)
    self.entryPool:ReleaseAllObjects()
end

function ZO_RadialMenu:Refresh()
    self:ClearSelection()
    self:PerformLayout()
end

function ZO_RadialMenu:IsShown()
    return not self.control:IsControlHidden()
end

function ZO_RadialMenu:GetEntries()
    return self.entries
end

--Helper function that gets the corresponding action name for a given ordinal index
function ZO_GetRadialMenuActionNameForOrdinalIndex(ordinalIndex)
    internalassert(ordinalIndex <= 10, "Invalid ordinal index")
    internalassert(ZO_IsIngameUI(), "Radial menu action hotkeys do not exist in this GUI")
    return "ACCESSIBLE_WHEEL_HOTKEY_SLOT_" .. ordinalIndex
end

--Helper function that gets whether or not togglable wheels are enabled
function ZO_AreTogglableWheelsEnabled()
    return GetSetting_Bool(SETTING_TYPE_ACCESSIBILITY, ACCESSIBILITY_SETTING_ACCESSIBLE_QUICKWHEELS)
end