local actionBars = {}
local settings = {}
local settingsFile = ""
local cachedSettings = nil
local window = nil
local mouseGrabberWidget = nil

local TYPE = {
  BLANK = 0,
  TEXT = 1,
  SPELL = 2,
  ITEM = 3
}

local ACTION = {
  BLANK = 0,
  EQUIP = 1,
  USE = 2,
  USE_SELF = 3,
  USE_TARGET = 4,
  USE_CROSS = 5
}

-- servers may have different id's, change if not working properly (only for protocols 910+)
local function translateVocation(id) 
  if id == 1 or id == 11 then
    return 8 -- ek
  elseif id == 2 or id == 12 then
    return 7 -- rp
  elseif id == 3 or id == 13 then
    return 5 -- ms
  elseif id == 4 or id == 14 then
    return 6 -- ed
  end
end

local function isSpell(text) -- returns bool or table (spelldata, param text)
  text = text:lower():trim()

  for spellName, spellData in pairs(SpellInfo['Default']) do
    local words = spellData.words
    local param = spellData.parameter
    local data = spellData
    data.spellName = spellName

    if not param then
      if words == text then
        return {data=data}
      end
    else
      if text:find(words) then
        text = text:gsub(words, ""):trim()
        text = text:gsub('"', "")
        text = text:gsub("'", "")
        return {data=data, param=text}
      end
    end
  end

  return false
end

function init()
  connect(g_game, {
    onGameStart = online,
    onGameEnd = offline,
    onSpellGroupCooldown = onSpellGroupCooldown,
    onSpellCooldown = onSpellCooldown
  })

  if g_game.isOnline() then
    online()
  end

  -- taken from game_hotkeys
  mouseGrabberWidget = g_ui.createWidget('UIWidget')
  mouseGrabberWidget:setVisible(false)
  mouseGrabberWidget:setFocusable(false)
  mouseGrabberWidget.onMouseRelease = onDropActionButton
end

function terminate()
  disconnect(g_game, {
    onGameStart = online,
    onGameEnd = offline,
    onSpellGroupCooldown = onSpellGroupCooldown,
    onSpellCooldown = onSpellCooldown
  })
end

function createActionBars()
  local bottomPanel = modules.game_interface.getBottomActionPanel()
  local leftPanel = modules.game_interface.getLeftActionPanel()
  local rightPanel = modules.game_interface.getRightActionPanel()

  -- 1-3: bottom
  -- 4-6: left
  -- 7-9: right
  for i=1,9 do
    local parent
    local index
    local layout

    if i <= 3 then
      parent = bottomPanel
      index = i
      layout = 'actionbar'
    elseif i <= 6 then
      parent = leftPanel
      index = i - 3
      layout = 'sideactionbar'
    else
      parent = rightPanel
      index = i - 6
      layout = 'sideactionbar'
    end

    actionBars[i] = g_ui.loadUI(layout, parent)
    actionBars[i]:setId("actionbar."..i)
    actionBars[i].n = i
    parent:moveChildToIndex(actionBars[i], index)
  end
end

function offline()
  -- save settings to json
  save()

  -- destroy windows
  destroyAssignWindows()
  mouseGrabberWidget:destroy()

  -- remove binds
  for index, actionbar in ipairs(actionBars) do
    if actionbar.tabBar then
      for i, actionButton in ipairs(actionbar.tabBar:getChildren()) do
        local callback = actionButton.callback
        local hotkey = actionButton.hotkey and actionButton.hotkey:len() > 0 and actionButton.hotkey or false

        if callback and hotkey then
          local gameRootPanel = modules.game_interface.getRootPanel()
          g_keyboard.unbindKeyPress(hotkey, callback, gameRootPanel)
        end
      end
    end
  end

  -- destroy actionbars
  for i, panel in ipairs(actionBars) do
    panel:destroy()
  end
end

function online()
  settingsFile = modules.client_profiles.getSettingsFilePath("actionbar_v2.json")
  -- load settings
  load()

  -- create actionbars
  createActionBars()

  -- show & setup actionbars
  show()

  destroyAssignWindows()
end

function show()
  for i=1,#actionBars do
    local actionbar = actionBars[i]
    local enabled = g_settings.getBoolean("actionbar"..i, false)

    actionbar:setOn(enabled)
    setupActionBar(i)
  end
end

function refresh()
  -- first save
  save()

  -- recheck file
  settingsFile = modules.client_profiles.getSettingsFilePath("actionbar_v2.json")

  -- load settings
  load()

  -- setup actionbars
  show()

  destroyAssignWindows()
end

function translateHotkeyDesc(text)
  -- formatting similar to cip Tibia 12
  if not text then 
    return ""
  end

  local values = {
    {"Shift", "S"},
    {"Ctrl", "C"},
    {"+", ""},
    {"PageUp", "PgUp"},
    {"PageDown", "PgDown"},
    {"Enter", "Return"},
    {"Insert", "Ins"},
    {"Delete", "Del"},
    {"Escape", "Esc"}
  }

  for i, v in pairs(values) do
    text = text:gsub(v[1], v[2])
  end

  if text:len() > 6 then
    text = text:sub(text:len()-3,text:len())
    text = "..."..text
  end

  return text
end

function destroyAssignWindows()
  local windows = {
    'assignItemWindow',
    'assignSpellWindow',
    'assignTextWindow',
    'assignHotkeyWindow'
  }

  local rootWidget = g_ui.getRootWidget()
  for i, id in ipairs(windows) do
    local widget = rootWidget[id]

    if widget then
      widget:destroy()
    end
  end
end

function changeLockState(widget)
  local actionbar = widget:getParent():getParent()

  widget:setOn(not widget:isOn())
  widget.image:setOn(widget:isOn())
  actionbar.locked = not widget:isOn()

  settings[actionbar:getId()] = not widget:isOn() or nil
end

function moveActionButtons(widget)
  local dir = widget:getId()
  local actionBar = widget:getParent():getParent()
  local scroll = actionBar.actionScroll
  local buttons = {actionBar.prevPanel.prev, actionBar.prevPanel.first, actionBar.nextPanel.next, actionBar.nextPanel.last}

  if dir == "next" then
    scroll:increment(37)
  elseif dir == "last" then
    scroll:setValue(scroll:getMaximum())
  elseif dir == "prev" then
    scroll:decrement(37)
  else
    scroll:setValue(scroll:getMinimum())
  end

  local prevEnabled = scroll:getValue() > 0
  local nextEnabled = scroll:getValue() < scroll:getMaximum()
  
  buttons[1]:setOn(prevEnabled)
  buttons[2]:setOn(prevEnabled)
  buttons[3]:setOn(nextEnabled)
  buttons[4]:setOn(nextEnabled)
  buttons[1].image:setOn(prevEnabled)
  buttons[2].image:setOn(prevEnabled)
  buttons[3].image:setOn(nextEnabled)
  buttons[4].image:setOn(nextEnabled)
end

function onDropActionButton(self, mousePosition, mouseButton)
  if not g_ui.isMouseGrabbed() then return end

  local clickedWidget = modules.game_interface.getRootPanel():recursiveGetChildByPos(mousePosition, false)
  if clickedWidget and clickedWidget:getParent() and clickedWidget:getParent():getStyleName():find('ActionButton') then
    if cachedSettings then
      clickedWidget = clickedWidget:getParent()
      if clickedWidget ~= cachedSettings.widget then
        local clickedHotkey = clickedWidget.hotkey
        local cachedHotkey = cachedSettings.widget.hotkey

        settings[cachedSettings.id] = settings[clickedWidget:getId()]
        settings[clickedWidget:getId()] = cachedSettings.data
        
        local clickedTill = clickedWidget.cooldownTill or 0
        local clickedStart = clickedWidget.cooldownStart or 0
        local cachedTill = cachedSettings.widget.cooldownTill or 0
        local cachedStart = cachedSettings.widget.cooldownStart or 0

        cachedSettings.widget.cooldownTill = clickedTill
        cachedSettings.widget.cooldownStart = clickedStart
        clickedWidget.cooldownTill = cachedTill
        clickedWidget.cooldownStart = cachedStart

        -- hotkeys remain unchanged
        settings[cachedSettings.id] = settings[cachedSettings.id] or {}
        settings[cachedSettings.id].hotkey = cachedHotkey
        settings[clickedWidget:getId()] = settings[clickedWidget:getId()] or {}
        settings[clickedWidget:getId()].hotkey = clickedHotkey

        updateCooldown(clickedWidget)
        updateCooldown(cachedSettings.widget)
        setupButton(cachedSettings.widget)
        setupButton(clickedWidget)
      end
    end
  end

  cachedSettings.widget.item:setBorderColor('#00000000')
  cachedSettings = nil
  g_mouse.popCursor('target')
  self:ungrabMouse()
end

function setupActionBar(n)
  local actionbar = actionBars[n]
  local visible = actionbar:isVisible()
  locked = settings[actionbar:getId()]
  actionbar.tabBar.onMouseWheel = nil -- disable scroll wheel

  actionbar.locked = locked
  actionbar.nextPanel.lock:setOn(not locked)
  actionbar.nextPanel.lock.image:setOn(not locked)

  if not visible then
    return actionbar.tabBar:destroyChildren() -- will hopefully lower stress
  else
    actionbar.tabBar:destroyChildren()
    for i=1,50 do
      local layout = n < 4 and 'ActionButton' or 'SideActionButton'
      local widget = g_ui.createWidget(layout, actionbar.tabBar)
      widget:setId(actionbar.n.."."..i)

      setupButton(widget)
    end
  end
end

function setupButton(widget)
  local id = widget:getId()
  local config = settings[id]
  local actionbar = widget:getParent():getParent()

  -- disable count
  widget.item:setShowCount(false)

  -- remove callback to avoid recurrency
  widget.item.onItemChange = nil

  -- clear settings
  widget.type = TYPE.BLANK
  widget.text:setText("")
  widget.parameterText:setText("")
  if widget.item:getItemId() ~= 0 then
    widget.item:setItemId(0)
  end
  widget.item:setOn(false)
  widget.autoSay = nil
  widget.action = ACTION.BLANK
  widget.spellData = nil
  widget.item:setItemVisible(true)
  widget.text:setImageSource('')
  widget.hotkey = config and config.hotkey or ""
  widget.callback = nil

  -- add new settings
  if config and config.type then
    widget.item:setOn(true)
    widget.type = config.type
    widget.text:setText(config.sayText or "")
    if widget.item:getItemId() ~= (config.itemId and config.itemId > 100 and config.itemId or 0) then
      widget.item:setItem(Item.create(config.itemId, 50))
    end
    widget.sayText = config.sayText
    widget.autoSay = config.autoSay
    widget.action = config.action
    widget.spellData = config.spellData
    if config.type ~= 0 and config.type ~= 3 then
      widget.item:setItemVisible(false)
    end
  end

  -- callback
  setupAction(widget)

  --hotkey
  widget.hotkeyLabel:setText(translateHotkeyDesc(widget.hotkey))
  
  if widget.spellData then
    --image
    widget.text:setImageSource(widget.spellData.source)
    widget.text:setImageClip(widget.spellData.clip)

    --param
    local param = widget.spellData.param
    if param and param:len() > 6 then
      param = param:sub(1,5) .. "..."
    end
    widget.parameterText:setText(param or "")
  else
    widget.text:setImageSource('')
  end

  widget.item.onDragEnter = function(self)
    if g_ui.isMouseGrabbed() or actionbar.locked then return end
    mouseGrabberWidget:grabMouse()
    g_mouse.pushCursor('target')

    self:setBorderColor('#FFFFFF')
    cachedSettings = {id=widget:getId(), data=settings[widget:getId()], widget=widget}
  end

  -- popupmenu & execute action
  widget.onMouseRelease = function(widget, mousePos, mouseButton)
    if mouseButton == MouseRightButton then 

      local menu = g_ui.createWidget('PopupMenu')
      menu:setGameMenu(true)
      menu:addOption(widget.spellId and tr('Edit Spell') or tr('Assign Spell'), function() assignSpell(widget) end)
      -- ARCFALL: 'Assign Object' menu entry removed. Item hotkeys are not allowed.
      menu:addOption(widget.text:getText():len() > 0 and tr('Edit Text') or tr('Assign Text'), function() assignText(widget) end)
      menu:addOption(widget.hotkey and tr('Edit Hotkey') or tr('Assign Hotkey'), function() assignHotkey(widget) end)

      if widget.type > 0 then
        menu:addSeparator()
        menu:addOption(tr('Clear Action'), function() resetSlot(widget) end)
      end
      menu:display(mousePos)
    elseif mouseButton == MouseLeftButton and widget.callback then 
      widget.callback()
    end
  end

  -- ARCFALL: drag-drop into action box previously called assignItem(); disabled.
  widget.item.onItemChange = function(widget)
    -- intentionally no-op: item hotkeys disabled server-wide.
  end

  -- tooltip
  local itemAction
  if widget.type == TYPE.ITEM then
    if widget.action == ACTION.EQUIP then
      itemAction = "Equip/Unequip this object"
    elseif widget.action == ACTION.USE then
      itemAction = "Use this object"
    elseif widget.action == ACTION.USE_SELF then
      itemAction = "Use this object on Yourself"
    elseif widget.action == ACTION.USE_TARGET then
      itemAction = "Use this object on Attack Target"
    elseif widget.action == ACTION.USE_CROSS then
      itemAction = "Use this object with Crosshair"
    end
  end

  local actionDesc
  local spellData = widget.spellData
  if widget.type == TYPE.BLANK then
    actionDesc = "None"
  elseif widget.type == TYPE.TEXT then
    actionDesc = 'Say: "'..widget.text:getText()..'"\n'
    actionDesc = actionDesc.. "Auto sent:  " .. (widget.autoSay and "Yes" or "No")
  elseif widget.type == TYPE.SPELL then
    local paramText 
    if spellData.param and spellData.param:len() > 0 then
      paramText = ' "'.. spellData.param ..'"' 
    else 
      paramText = ""
    end
    actionDesc = "Cast " .. spellData.name .."\n"
    actionDesc = actionDesc.. "Formula:  ".. spellData.words .. paramText.."\n"
    actionDesc = actionDesc.. "Cooldown:  "..spellData.cd.."s\n"
    actionDesc = actionDesc.. "Mana:  "..spellData.mana
  elseif widget.type == TYPE.ITEM then
    actionDesc = itemAction
  end

  local hotkeyDesc = widget.hotkey and widget.hotkey:len() > 0 and widget.hotkey or "None"
  local tooltip = "Action Button "..id
  tooltip = tooltip.."\n\n\tAction:  ".. actionDesc
  tooltip = tooltip.."\nHotkeys:  ".. hotkeyDesc

  widget.item:setTooltip(tooltip)
end

function resetSlot(widget)
  local hotkey = settings[widget:getId()] and settings[widget:getId()].hotkey or nil
  if hotkey and hotkey:len() > 0 and widget.callback then
    local gameRootPanel = modules.game_interface.getRootPanel()
    g_keyboard.unbindKeyPress(widget.hotkey, widget.callback, gameRootPanel)
  end
  
  if hotkey then
    settings[widget:getId()] = {hotkey=hotkey}
  else
    settings[widget:getId()] = nil
  end

  setupButton(widget)
end

function assignItem(widget)
  destroyAssignWindows()
  local radio = UIRadioGroup.create()
  local item = widget.item:getItem()
  local id = widget.item:getItemId()

  -- check if item wasn't cleared
  if id == 0 and widget.item:isOn() then
    return resetSlot(widget) 
  end

  -- create window
  window = g_ui.loadUI('object', g_ui.getRootWidget())
  window:show()
  window:raise()
  window:focus()

  -- basics
  window:setText("Assign Object to Action Button "..widget:getId())
  window:setId("assignItemWindow")

  -- select item
  window.select.onClick = function()
    modules.game_itemselector.show(widget.item)
  end

  -- checks
  window.item:setShowCount(false)
  window.item.onItemChange = function(widget)
    local item = window.item:getItem()

    if item then
      for i, child in ipairs(window.checks:getChildren()) do
        -- add to radio grouo
        radio:addWidget(child)

        -- enabled
        if item:getId() < 100 then
          child:setEnabled(false)
        elseif i < 4 then
          local check = item:isMultiUse()
          child:setEnabled(check)
          if check then
            radio:selectWidget(child)
          end
        elseif g_game.getClientVersion() >= 910 then
          if i == 4 then
  --           local check = item:getClothSlot() > 0
            local check = true -- unfortunately two handed weapons returns cloth 0, so we have to disable this check
            child:setEnabled(check)
            if check then
              radio:selectWidget(child)
            end
          else
            child:setEnabled(true)
            radio:selectWidget(child)
          end
        else
          child:setVisible(false)
        end
      end
    end

    -- validation
    window.buttonOk:setEnabled(item and item:getId() > 100)
    window.buttonApply:setEnabled(item and item:getId() > 100)
  end

  window.item:setItemId(id)

  -- select current action, if exists
  local actionType = widget.action or 0
  if actionType > ACTION.BLANK then
    local id
    if actionType == ACTION.USE_SELF then
      id = "useSelf"
    elseif actionType == ACTION.USE_TARGET then
      id = "useTarget"
    elseif actionType == ACTION.USE_CROSS then
      id = "useCross"
    elseif actionType == ACTION.EQUIP then
      id = "equip"
    elseif actionType == ACTION.USE then
      id = "use"
    end

    for i, child in ipairs(radio.widgets) do
      local childId = child:getId()
      if childId == id then
        radio:selectWidget(child)
        break
      end
    end
  end

  -- functions
  local okFunc = function(destroy)
    local hotkey = settings[widget:getId()] and settings[widget:getId()].hotkey
    if hotkey and hotkey:len() > 0 and widget.callback then
      local gameRootPanel = modules.game_interface.getRootPanel()
      g_keyboard.unbindKeyPress(widget.hotkey, widget.callback, gameRootPanel)
    end

    settings[widget:getId()] = {hotkey=hotkey}
    settings[widget:getId()].itemId = window.item:getItemId()
    settings[widget:getId()].type = TYPE.ITEM

    local selected = radio:getSelectedWidget():getId()
    if selected == "useSelf" then
      settings[widget:getId()].action = ACTION.USE_SELF
    elseif selected == "useTarget" then
      settings[widget:getId()].action = ACTION.USE_TARGET
    elseif selected == "useCross" then
      settings[widget:getId()].action = ACTION.USE_CROSS
    elseif selected == "equip" then
      settings[widget:getId()].action = ACTION.EQUIP
    else
      settings[widget:getId()].action = ACTION.USE
    end

    if destroy then
      window:destroy()
      radio:destroy()
    end
    setupButton(widget)
  end

  local cancelFunc = function()
    setupButton(widget)
    window:destroy()
    radio:destroy()
  end

  -- callbacks
  window.buttonOk.onClick = function() okFunc(true) end
  window.onEnter = function() okFunc(true) end
  window.buttonApply.onClick = function() okFunc(false) end
  window.buttonClose.onClick = cancelFunc
  window.onEscape = cancelFunc

  local actionbar = widget:getParent():getParent()
  if actionbar.locked then
    cancelFunc()
  end
end

function assignText(widget)
  destroyAssignWindows()

  -- create window
  window = g_ui.loadUI('text', g_ui.getRootWidget())
  window:show()
  window:raise()
  window:focus()

  window.text.onTextChange = function(self, text)
    window.buttonOk:setEnabled(text:len() > 0)
    window.buttonApply:setEnabled(text:len() > 0)
  end

  -- copy settings from current widget
  window.text:setText(widget.text:getText())
  if widget.type > 0 then
    window.checkPanel.tick:setChecked(widget.autoSay)
  end

  -- functions
  local okFunc = function(destroy) 
    local autoSay = window.checkPanel.tick:isChecked()
    local text = window.text:getText()

    local hotkey = settings[widget:getId()] and settings[widget:getId()].hotkey
    if hotkey and hotkey:len() > 0 and widget.callback then
      local gameRootPanel = modules.game_interface.getRootPanel()
      g_keyboard.unbindKeyPress(hotkey, widget.callback, gameRootPanel)
    end

    settings[widget:getId()] = {hotkey=hotkey}

    local spell = isSpell(text)
    if spell then -- entered text is spell
      local paramText = spell.param
      local spellData = spell.data
      local newGroup = {}
      for groupId, duration in pairs(spellData.group) do
        table.insert(newGroup, groupId)
      end
      spellData.group = newGroup

  
      settings[widget:getId()].type = TYPE.SPELL
      settings[widget:getId()].spellData = {
        words = spellData.words,
        cd = spellData.exhaustion/1000,
        mana = spellData.mana,
        source = SpelllistSettings['Default'].iconFile,
        clip = Spells.getImageClip(SpellIcons[spellData.icon][1], 'Default'),
        name = spellData.spellName,
        param = paramText,
        group = spellData.group,
        id = spellData.id
      }
    else -- is just text
      settings[widget:getId()].sayText = text
      settings[widget:getId()].type = TYPE.TEXT
      settings[widget:getId()].autoSay = autoSay
    end
  
    if destroy then
      window:destroy()
    end
    setupButton(widget)
  end
  local cancelFunc = function()
    window:destroy()
    setupButton(widget)
  end

  -- buttons
  window.buttonOk.onClick = function() okFunc(true) end
  window.buttonApply.onClick = function() okFunc(false) end
  window.buttonClose.onClick = cancelFunc
  window.onEscape = cancelFunc
  window.onEnter = function() okFunc(true) end

  local actionbar = widget:getParent():getParent()
  if actionbar.locked then
    cancelFunc()
  end
end

function assignSpell(widget)
  destroyAssignWindows()
  local radio = UIRadioGroup.create()

  -- create window
  window = g_ui.loadUI('spell', g_ui.getRootWidget())
  window:show()
  window:raise()
  window:focus()

  window:setText("Assign Spell to Action Button "..widget:getId())

  -- old tibia does not send vocation
  if g_game.getClientVersion() < 910 then
    window.checkPanel:setVisible(false)
  end

  local spells = modules.gamelib.SpellInfo['Default']
  for spellName, spellData in pairs(spells) do
    local widget = g_ui.createWidget('SpellPreview', window.spellList)
    -- radio
    radio:addWidget(widget)
    local newGroup = {}
    for groupId, duration in pairs(spellData.group) do
      table.insert(newGroup, groupId)
    end
    spellData.group = newGroup
    
    widget:setId(spellData.id)
    widget:setText(spellName.."\n"..spellData.words)
    widget.voc = spellData.vocations
    widget.param = spellData.parameter
    widget.source = SpelllistSettings['Default'].iconFile
    widget.clip = Spells.getImageClip(SpellIcons[spellData.icon][1], 'Default')
    widget.image:setImageSource(widget.source)
    widget.image:setImageClip(widget.clip)

    widget.spellData = {
      words = spellData.words,
      cd = spellData.exhaustion/1000,
      mana = spellData.mana,
      source = widget.source,
      clip = widget.clip,
      name = spellName,
      param = spellData.parameter,
      group = spellData.group,
      id = spellData.id
    }
  end

  -- sort alphabetically
  local widgets = window.spellList:getChildren()
  table.sort(widgets, function(a, b) return a.spellData.name < b.spellData.name end)
  for i, widget in ipairs(widgets) do
    window.spellList:moveChildToIndex(widget, i)
  end

  local function filterByVocation(a, filter)
     -- disable callback
    window.spellList.onChildFocusChange = nil

    local widgets = window.spellList:getChildren()
    local vocation = translateVocation(g_game.getLocalPlayer():getVocation())

    -- visible
    local fistVisible = nil
    for i, widget in ipairs(widgets) do
      local viable = not filter or table.find(widget.voc, vocation) and true or false
      widget:setVisible(viable)
      if viable and not fistVisible then
        fistVisible = widget
        radio:selectWidget(fistVisible)
      end
    end

    -- select current one if exist
    local currentSpell = widget.spellData and widget.spellData.id or 0
    if currentSpell > 0 then
      for i, child in ipairs(radio.widgets) do
        local childId = child.spellData.id

        if childId == currentSpell then
          child:setVisible(true)
          window.spellList:ensureChildVisible(child)
          radio:selectWidget(child)
          break
        end
      end
    end
  end

  -- callback
  radio.onSelectionChange = function(widget, selected)
    if selected then
      local name = selected:getText()
      local source = selected.source
      local clip = selected.clip
      local param = selected.param

      -- preview
      window.preview:setText(name)
      window.preview.image:setImageSource(source)
      window.preview.image:setImageClip(clip)

      -- param
      window.paramLabel:setOn(param)
      window.paramText:setEnabled(param)
      window.spellList:ensureChildVisible(widget)
    end
  end

  window.checkPanel.tick.onCheckChange = filterByVocation
  filterByVocation(nil, true)

  local okFunc = function(destroy) 
    local selected = radio:getSelectedWidget()
    local paramWidgetText = window.paramText:getText()
    if not selected then return end

    selected.spellData.param = paramWidgetText

    local hotkey = settings[widget:getId()] and settings[widget:getId()].hotkey   
    if hotkey and hotkey:len() > 0 and widget.callback then
      local gameRootPanel = modules.game_interface.getRootPanel()
      g_keyboard.unbindKeyPress(widget.hotkey, widget.callback, gameRootPanel)
    end

    settings[widget:getId()] = {hotkey=hotkey}
    settings[widget:getId()].spellData = selected.spellData
    settings[widget:getId()].type = TYPE.SPELL
 
    if destroy then
      window:destroy()
    end
    setupButton(widget)
  end
  local cancelFunc = function() 
    window:destroy()
    setupButton(widget)
  end

  window.buttonOk.onClick = function() okFunc(true) end
  window.buttonApply.onClick = function() okFunc(false) end
  window.buttonClose.onClick = cancelFunc
  window.onEscape = cancelFunc
  window.onEnter = function() okFunc(true) end

  local actionbar = widget:getParent():getParent()
  if actionbar.locked then
    cancelFunc()
  end
end

function assignHotkey(widget)
  destroyAssignWindows()

  -- create window
  window = g_ui.loadUI('hotkey', g_ui.getRootWidget())
  window:show()
  window:raise()
  window:focus()

  local barN = widget:getParent():getParent().n
  local barDesc
  if barN < 4 then
    barDesc = "Bottom"
  elseif barN < 7 then
    barDesc = "Left"
  else
    barDesc = "Right"
  end

  -- things
  barDesc = barDesc.." Action Bar: Action Button "..widget:getId()
  window:setText('Edit Hotkey for "'..barDesc)
  window.desc:setText(window.desc:getText()..barDesc..'"')
  window.display:setText(widget.hotkey or "")
  
  -- hotkey
  window:grabKeyboard()
  window.onKeyDown = function(window, keyCode, keyboardModifiers)
    local keyCombo = determineKeyComboDesc(keyCode, keyboardModifiers)
    window.display:setText(keyCombo)
    return true
  end

  local okFunc = function() 
    local hotkey = window.display:getText()

    if settings[widget:getId()].hotkey and settings[widget:getId()].hotkey:len() > 0 and widget.callback then
      local gameRootPanel = modules.game_interface.getRootPanel()
      g_keyboard.unbindKeyPress(widget.hotkey, widget.callback, gameRootPanel)
    end
    settings[widget:getId()] = settings[widget:getId()] or {}
    settings[widget:getId()].hotkey = hotkey
  
    window:destroy()
    setupButton(widget)
  end
  local clearFunc = function() 
    window.display:setText('')
    local hotkey = window.display:getText()

    if settings[widget:getId()].hotkey and settings[widget:getId()].hotkey:len() > 0 and widget.callback then
      local gameRootPanel = modules.game_interface.getRootPanel()
      g_keyboard.unbindKeyPress(widget.hotkey, widget.callback, gameRootPanel)
    end
    settings[widget:getId()] = settings[widget:getId()] or {}
    settings[widget:getId()].hotkey = hotkey
  
    window:destroy()
    setupButton(widget)
  end
  local closeFunc = function() 
    window:destroy()
    setupButton(widget)
  end

  window.buttonOk.onClick = okFunc
  window.buttonClear.onClick = clearFunc
  window.buttonClose.onClick = closeFunc

  local actionbar = widget:getParent():getParent()
  if actionbar.locked then
    cancelFunc()
  end
end

function setupAction(widget)
  if widget.type == TYPE.BLANK then 
    return
  end
  if widget.type == TYPE.TEXT then
    widget.callback = function()
      if modules.game_interface.isChatVisible() then
        if widget.autoSay then
          modules.game_console.sendMessage(widget.sayText)
        else
          modules.game_console.setTextEditText(widget.sayText)
        end
      elseif widget.autoSay then
        g_game.talk(widget.sayText)
      end
    end
  elseif widget.type == TYPE.SPELL then
    widget.callback = function()
      if g_app.isMobile() then -- turn to direction of targer
        local target = g_game.getAttackingCreature()
        if target then
          local pos = g_game.getLocalPlayer():getPosition()
          local tpos = target:getPosition()
          if pos and tpos then
            local offx = tpos.x - pos.x
            local offy = tpos.y - pos.y
            if offy < 0 and offx <= 0 and math.abs(offx) < math.abs(offy) then
              g_game.turn(Directions.North)
            elseif offy > 0 and offx >= 0 and math.abs(offx) < math.abs(offy) then
              g_game.turn(Directions.South)
            elseif offx < 0 and offy <= 0 and math.abs(offx) > math.abs(offy) then
              g_game.turn(Directions.West)
            elseif offx > 0 and offy >= 0 and math.abs(offx) > math.abs(offy) then
              g_game.turn(Directions.East)
            end
          end
        end
      end
      local paramText 
      if widget.spellData.param and widget.spellData.param:len() > 0 then
        paramText = ' "'.. widget.spellData.param ..'"' 
      else 
        paramText = ""
      end
      g_game.talk(widget.spellData.words..paramText)
    end
  elseif widget.type == TYPE.ITEM then
    -- ARCFALL: action-bar item slots are read-only.
    -- Old-school 7.x: only spell hotkeys allowed, no rune/potion shortcuts.
    -- The original use/use-self/use-target/use-cross/equip code paths
    -- have been removed wholesale to make bypass via direct itemId entry
    -- (right-click -> Assign Object -> type id) impossible.
    widget.callback = function()
      if modules.game_textmessage and modules.game_textmessage.displayPrivateMessage then
        modules.game_textmessage.displayPrivateMessage("Item action-bar shortcuts are disabled on Arcfall. Click runes/potions directly. Only spell action-bar entries fire.")
      end
      return
    end
  end

  if widget.hotkey and widget.hotkey:len() > 0 and widget.callback then
    local gameRootPanel = modules.game_interface.getRootPanel()
    g_keyboard.bindKeyPress(widget.hotkey, widget.callback, gameRootPanel)
  end
end

function onSpellCooldown(iconId, duration)
  for index, actionbar in ipairs(actionBars) do
    for i, child in ipairs(actionbar.tabBar:getChildren()) do
      if child.type == 2 and child.spellData.id == iconId then
        startCooldown(child, duration)
      end
    end
  end
end

function onSpellGroupCooldown(groupId, duration)
  for index, actionbar in ipairs(actionBars) do
    for i, child in ipairs(actionbar.tabBar:getChildren()) do
      if child.type == 2 and child.spellData.group then
        for i, group in ipairs(child.spellData.group) do
          if groupId == group then
            startCooldown(child, duration)
          end
        end
      end
    end
  end
end

function startCooldown(action, duration)
  if type(action.cooldownTill) == 'number' and action.cooldownTill > g_clock.millis() + duration then
    return -- already has cooldown with greater duration
  end
  action.cooldownStart = g_clock.millis()
  action.cooldownTill = g_clock.millis() + duration
  updateCooldown(action)
end

function updateCooldown(action)
  if not action or not action.cooldownTill then return end
  local timeleft = action.cooldownTill - g_clock.millis()
  if timeleft <= 50 then
    action.cooldown:setPercent(100)
    action.cooldownEvent = nil
    action.cooldown:setText("")
    return
  end
  local duration = action.cooldownTill - action.cooldownStart
  local formattedText
  if timeleft > 60000 then
    formattedText = math.floor(timeleft / 60000) .. "m"
  else
    formattedText = timeleft/1000
    formattedText = math.floor(formattedText * 10) / 10
    formattedText = math.floor(formattedText) .. "." .. math.floor(formattedText * 10) % 10
  end

  local retry
  if timeleft > 60000 then
    retry = math.min(math.floor(timeleft * 0.1), 60 * 1000) -- max 1min
    retry = math.max(retry, 100) -- min 100
  elseif timeleft > 1000 then
    retry = 100
  else
    retry = 30
  end
  action.cooldown:setText(formattedText) 
  action.cooldown:setPercent(100 - math.floor(100 * timeleft / duration))
  action.cooldownEvent = scheduleEvent(function() updateCooldown(action) end, retry)
end

function save()
  local status, result = pcall(function() return json.encode(settings, 2) end)
  if not status then
      return g_logger.error(
                 "Error while saving top bar settings. Data won't be saved. Details: " ..
                     result)
  end

  if result:len() > 100 * 1024 * 1024 then
      return g_logger.error(
                 "Something went wrong, file is above 100MB, won't be saved")
  end

  g_resources.writeFileContents(settingsFile, result)
end

function load()
  if g_resources.fileExists(settingsFile) then
      local status, result = pcall(function()
          return json.decode(g_resources.readFileContents(settingsFile))
      end)
      if not status then
          return g_logger.error(
                     "Error while reading top bar settings file. To fix this problem you can delete storage.json. Details: " ..
                         result)
      end
      settings = result
  else
      settings = {}
  end
end