--- === hs.hints ===
---
--- Switch focus with a transient per-application keyboard shortcut

local hints = require "hs.hints.internal"
local window = require "hs.window"
local hotkey = require "hs.hotkey"
local modal_hotkey = hotkey.modal

--- hs.hints.hintChars
--- Variable
--- This controls the set of characters that will be used for window hints. They must be characters found in hs.keycodes.map
--- The default is the letters A-Z. Note that if `hs.hints.style` is set to "vimperator", this variable will be ignored.
hints.hintChars = {"A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"}

-- vimperator mode requires to use full set of alphabet to represent applications.
hints.hintCharsVimperator = {"A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"}

--- hs.hints.style
--- Variable
--- If this is set to "vimperator", every window hint starts with the first character
--- of the parent application's title
hints.style = "default"

--- hs.hints.fontName
--- Variable
--- A fully specified family-face name, preferrably the PostScript name, such as Helvetica-BoldOblique or Times-Roman. (The Font Book app displays PostScript names of fonts in the Font Info panel.)
--- The default value is the system font
hints.fontName = nil

--- hs.hints.fontSize
--- Variable
--- The size of font that should be used. A value of 0.0 will use the default size.
hints.fontSize = 0.0

--- hs.hints.showTitleThresh
--- Variable
--- If there are less than or equal to this many windows on screen their titles will be shown in the hints.
--- The default is 4. Setting to 0 will disable this feature.
hints.showTitleThresh = 4

--- hs.hints.titleMaxSize
--- Variable
--- If the title is longer than maxSize, the string is truncated, -1 to disable, valid value is >= 6
hints.titleMaxSize = -1

--- hs.hints.iconAlpha
--- Variable
--- Opacity of the application icon. Default is 0.95.
hints.iconAlpha = 0.95

local openHints = {}
local takenPositions = {}
local hintDict = {}
local hintChars = nil
local modalKey = nil
local selectionCallback = nil

local bumpThresh = 40^2
local bumpMove = 80

local invalidWindowRoles = {
   AXScrollArea = true, --This excludes the main finder window.
   AXUnknown = true
}

local function isValidWindow(win, allowNonStandard)
   if not allowNonStandard then
      return win:isStandard()
   else
      return invalidWindowRoles[win:role()] == nil
   end
end

function hints.bumpPos(x,y)
  for _, pos in ipairs(takenPositions) do
    if ((pos.x-x)^2 + (pos.y-y)^2) < bumpThresh then
      return hints.bumpPos(x,y+bumpMove)
    end
  end
  return {x = x,y = y}
end

function hints.addWindow(dict, win)
  local n = dict['count']
  if n == nil then
    dict['count'] = 0
    n = 0
  end
  local m = (n % #hintChars) + 1
  local char = hintChars[m]
  if n < #hintChars then
    dict[char] = win
  else
    if type(dict[char]) == "userdata" then
      -- dict[m] is already occupied by another window
      -- which me must convert into a new dictionary
      local otherWindow = dict[char]
      dict[char] = {}
      hints.addWindow(dict, otherWindow)
    end
    hints.addWindow(dict[char], win)
  end
  dict['count'] = dict['count'] + 1
end

-- Private helper to recursively find the total number of hints in a dict
function hints._dictSize(t)
  if type(t) == "userdata" and t:screen() then -- onscreen window
    return 1
  elseif type(t) == "table" then
    local count = 0
    for _,v in pairs(t) do count = count + hints._dictSize(v) end
    return count
  end
  return 0 -- screenless window or something else
end

function hints.displayHintsForDict(dict, prefixstring, showTitles, allowNonStandard)
  if showTitles == nil then
    showTitles = hints._dictSize(hintDict) <= hints.showTitleThresh
  end
  for key, val in pairs(dict) do
    if type(val) == "userdata" and val:screen() then -- this is an onscreen window
      local win = val
      local app = win:application()
      local fr = win:frame()
      local sfr = win:screen():frame()
      if app and app:bundleID() and isValidWindow(win, allowNonStandard) then
        local c = {x = fr.x + (fr.w/2) - sfr.x, y = fr.y + (fr.h/2) - sfr.y}
        local d = hints.bumpPos(c.x, c.y)
        if d.y > (sfr.y + sfr.h - bumpMove) then
            d.x = d.x + bumpMove
            d.y = fr.y + (fr.h/2) - sfr.y
            d = hints.bumpPos(d.x, d.y)
        end
        c = d
        if c.y < 0 then
          print("hs.hints: Skipping offscreen window: "..win:title())
        else
          local suffixString = ""
          if showTitles then
            local win_title = win:title()
            if hints.titleMaxSize > 1 and #win_title > hints.titleMaxSize then
                local end_idx = math.max(0, hints.titleMaxSize-3)
                win_title = string.sub(win_title, 1, end_idx) .. "..."
            end
            suffixString = ": "..win_title
          end
          -- print(win:title().." x:"..c.x.." y:"..c.y) -- debugging
          local hint = hints.new(c.x, c.y, prefixstring .. key .. suffixString, app:bundleID(), win:screen(), hints.fontName, hints.fontSize, hints.iconAlpha)
          table.insert(takenPositions, c)
          table.insert(openHints, hint)
        end
      end
    elseif type(val) == "table" then -- this is another window dict
      hints.displayHintsForDict(val, prefixstring .. key, showTitles, allowNonStandard)
    end
  end
end

function hints.processChar(char)
  local toFocus = nil

  if hintDict[char] ~= nil then
    hints.closeHints()
    if type(hintDict[char]) == "userdata" then
      if hintDict[char] then
          toFocus = hintDict[char]
      end
    elseif type(hintDict[char]) == "table" then
      hintDict = hintDict[char]
      if hintDict.count == 1 then
        toFocus = hintDict[hintChars[1]]
      else
        takenPositions = {}
        hints.displayHintsForDict(hintDict, "")
      end
    end
  end

  if toFocus then
      toFocus:focus()
      modalKey:exit()
      if selectionCallback then
          selectionCallback(toFocus)
      end
  end
end

function hints.setupModal()
  local k = modal_hotkey.new(nil, nil)
  k:bind({}, 'escape', function() hints.closeHints(); k:exit() end)

  for _, c in ipairs(hintChars) do
    k:bind({}, c, function() hints.processChar(c) end)
  end
  return k
end

--- hs.hints.windowHints([windows, callback, allowNonStandard])
--- Function
--- Displays a keyboard hint for switching focus to each window
---
--- Parameters:
---  * windows - An optional table containing some `hs.window` objects. If this value is nil, all windows will be hinted
---  * callback - An optional function that will be called when a window has been selected by the user. The function will be called with a single argument containing the `hs.window` object of the window chosen by the user
---  * allowNonStandard - An optional boolean.  If true, all windows will be included, not just standard windows
---
--- Returns:
---  * None
---
--- Notes:
---  * If there are more windows open than there are characters available in hs.hints.hintChars, multiple characters will be used
---  * If hints.style is set to "vimperator", every window hint is prefixed with the first character of the parent application's name
---  * To display hints only for the currently focused application, try something like:
---   * `hs.hints.windowHints(hs.window.focusedWindow():application():allWindows())`
function hints.windowHints(windows, callback, allowNonStandard)

  if hints.style == "vimperator" then
    hintChars = hints.hintCharsVimperator
  else
    hintChars = hints.hintChars
  end

  windows = windows or window.allWindows()
  selectionCallback = callback

  if (modalKey == nil) then
    modalKey = hints.setupModal()
  end
  hints.closeHints()
  hintDict = {}
  for _, win in ipairs(windows) do
    local app = win:application()
    if app and app:bundleID() and isValidWindow(win, allowNonStandard) then
      if hints.style == "vimperator" then
        local appchar = string.upper(string.sub(app:title(), 1, 1))
        if hintDict[appchar] == nil then
          hintDict[appchar] = {}
        end
        hints.addWindow(hintDict[appchar], win)
      else
        hints.addWindow(hintDict, win)
      end
    end
  end
  takenPositions = {}
  if next(hintDict) ~= nil then
    hints.displayHintsForDict(hintDict, "", nil, allowNonStandard)
    modalKey:enter()
  end
end

function hints.closeHints()
  for _, hint in ipairs(openHints) do
    hint:close()
  end
  openHints = {}
  takenPositions = {}
end

return hints
