--
-- TreeList Widget heavily based on TreeView plugin.
-- @copyright Jefferson Gonzalez
-- @license MIT
--

local core = require "core"
local common = require "core.common"
local style = require "core.style"
local Widget = require "widget"

---@class widget.treelist.item
---@field name string
---@field label string
---@field data any?
---@field expanded boolean?
---@field expanded_original_stored boolean?
---@field expanded_original boolean?
---@field visible boolean?
---@field visible_original_stored boolean?
---@field visible_original boolean?
---@field tooltip string?
---@field depth integer?
---@field childs widget.treelist.item[]?
---@field parent widget.treelist.item[]?
---@field icon string?

---@class widget.treelist : widget
---@field items widget.treelist.item[]
---@field selected_item widget.treelist.item?
---@field hovered_item widget.treelist.item?
---@field icon_font renderer.font?
---@field private count_lines integer
---@field private scroll_width integer
---@overload fun(parent:widget?):widget.treelist
local TreeList = Widget:extend()

---@alias widget.treelist.filtercb fun(self:widget.treelist, item:widget.treelist.item):number|boolean?

---Constructor
---@param parent? widget
function TreeList:new(parent)
  TreeList.super.new(self, parent)

  self.type_name = "widget.treelist"

  self.scrollable = true
  self.init_size = true
  self.count_lines = 0
  self.scroll_width = 0
  self.items = {}
  self.tooltip = { x = 0, y = 0, begin = 0, alpha = 0 }
  self.last_scroll_y = 0

  self.item_icon_width = 0
  self.item_text_spacing = 0
end

---Get the height of a single item.
---@return number h
function TreeList:get_item_height()
  return style.font:get_height() + style.padding.y
end

---Add new item to to tree list
---@param item widget.treelist.item
function TreeList:add_item(item)
  table.insert(self.items, item)
end

---Remove all items from the tree
function TreeList:clear()
  self.items = {}
  self.selected_item = nil
  self.hovered_item = nil
end

local function restore_item(item)
  if item.visible_original_stored then
    item.visible = item.visible_original
    item.visible_original = nil
    item.visible_original_stored = nil
  end
  if item.expanded_original_stored then
    item.expanded = item.expanded_original
    item.expanded_original = nil
    item.expanded_original_stored = nil
  end
  if item.childs then
    for _, child in ipairs(item.childs) do
      restore_item(child)
    end
  end
end

---@param self widget.treelist
---@param item widget.treelist.item
---@param match string|widget.treelist.filtercb
---@param match_type string
---@return boolean
local function filter_item(self, item, match, match_type)
  if not item.visible_original_stored then
    item.visible_original = item.visible
    item.visible_original_stored = true
  end
  if not item.expanded_original_stored then
    item.expanded_original = item.expanded
    item.expanded_original_stored = true
  end

  local matched
  if match_type == "function" then
    matched = match(self, item)
  else
    matched = system.fuzzy_match(item.label or item.name, match, false)
  end

  local child_matched = false
  if item.childs then
    for _, child in ipairs(item.childs) do
      child_matched = filter_item(self, child, match, match_type) or child_matched
    end
  end

  item.visible = not not matched or child_matched
  if child_matched then
    item.expanded = true
  end

  return item.visible
end

---Set which tree items to show using the specified match string or callback,
---if nil all items visibility is restored.
---@param match? string | widget.treelist.filtercb
function TreeList:filter(match)
  if not match or match == "" then
    for _, item in ipairs(self.items) do
      restore_item(item)
    end
  else
    local match_type = type(match)
    for _, item in ipairs(self.items) do
      filter_item(self, item, match, match_type)
    end
  end

  if self.selected_item and self.selected_item.visible == false then
    self.selected_item = nil
  end
  if self.hovered_item and self.hovered_item.visible == false then
    self.hovered_item = nil
  end
end

---Retrieve the amount of visible items and also yield them.
---@param item widget.treelist.item
---@param x number
---@param y number
---@param w number
---@param h number
---@param depth? number
---@return integer items_count
function TreeList:get_items(item, x, y, w, h, depth)
  if item.visible == false then return 0 end
  if not depth then depth = 0 else depth = depth + 1 end
  item.depth = depth
  coroutine.yield(item, x, y, w, h)
  local count_lines = 1
  if item and item.childs and item.expanded then
    for _, child in ipairs(item.childs) do
      if child.visible ~= false then
        count_lines = count_lines + self:get_items(
          child, x, y + count_lines * h, w, h, depth
        )
      end
    end
  end
  return count_lines
end

---Allows iterating the currently visible items only.
---@return fun():widget.treelist.item,number,number,number,number
function TreeList:each_item()
  return coroutine.wrap(function()
    local count_lines = 0
    local ox, oy = self:get_content_offset()
    local h = self:get_item_height()
    if #self.items > 0 then
      for i=1, #self.items do
        count_lines = count_lines + self:get_items(
          self.items[i],
          ox, oy + style.padding.y + h * count_lines,
          self.size.x, h
        )
      end
    end
    self.count_lines = count_lines
  end)
end

---Retrieve an item by name using the query format:
---"parent_name>child_name_2>child_name_2>etc..."
---@param query string
---@param items? widget.treelist.item[]
---@param separator? string Use a different separator (default: >)
---@return widget.treelist.item?
function TreeList:query_item(query, items, separator)
  local parent = items or self.items
  local item = nil
  separator = separator or ">"
  for name in query:gmatch("([^"..separator.."]+)") do
      if parent then
        local found = false
        for _, child in ipairs(parent) do
          if name == child.name then
            item = child
            parent = child.childs
            found = true
            break
          end
        end
        if not found then return nil end
      else
        return nil
      end
  end
  return item
end

---Set the active item.
---@param selection widget.treelist.item
---@param selection_y? number
---@param center? boolean
---@param instant? boolean
function TreeList:set_selection(selection, selection_y, center, instant)
  self.selected_item = selection
  if selection and selection_y
      and (selection_y <= 0 or selection_y >= self.size.y) then
    local lh = self:get_item_height()
    if not center and selection_y >= self.size.y - lh then
      selection_y = selection_y - self.size.y + lh
    end
    if center then
      selection_y = selection_y - (self.size.y - lh) / 2
    end
    local _, y = self:get_content_offset()
    self.scroll.to.y = selection_y - y
    self.scroll.to.y = common.clamp(
      self.scroll.to.y, 0, self:get_scrollable_size() - self.size.y
    )
    if instant then
      self.scroll.y = self.scroll.to.y
    end
  end
end

---Sets the selection to the file with the specified path.
---TODO: Not tested, idea is to allow query like selections using item names
---by introducing a get_item/set_item methods that accepts a string query,
---For the moment we leave this inherited TreeView function here.
---@param path string #Absolute path of item to select
---@param expand boolean #Expand dirs leading to the item
---@param scroll_to boolean #Scroll to make the item visible
---@param instant boolean #Don't animate the scroll
---@return table? #The selected item
function TreeList:set_selection_from_path(path, expand, scroll_to, instant)
  local separator = "||"
  local to_select, to_select_y
  local let_it_finish, done
  ::restart::
  for item, _,y,_,_ in self:each_item() do
    if not done then
      if item.childs and #item.childs > 0 then
        local _, to = string.find(path, item.name..separator, 1, true)
        if to and to == #item.name + #separator then
          to_select, to_select_y = item, y
          if expand and not item.expanded then
            self:toggle_expand(true, item)
            -- Because we altered the size of the TreeList
            -- and because TreeList:get_scrollable_size uses self.count_lines
            -- which gets updated only when TreeList:each_item finishes,
            -- we can't stop here or we risk that the scroll
            -- gets clamped by View:clamp_scroll_position.
            let_it_finish = true
            -- We need to restart the process because if TreeList:toggle_expand
            -- altered the cache, TreeList:each_item risks looping indefinitely.
            goto restart
          end
        end
      else
        if item.name == path then
          to_select, to_select_y = item, y
          done = true
          if not let_it_finish then break end
        end
      end
    end
  end
  if to_select then
    self:set_selection(to_select, scroll_to and to_select_y, true, instant)
  end
  return to_select
end

---Set the icon font used to render the items icon.
---@param font renderer.font
function TreeList:set_icon_font(font)
  if font:get_size() ~= style.icon_font:get_size() then
    font:set_size(style.icon_font:get_size())
  end
  self.icon_font = font
end

---Keep the icon font size updated to match current scale.
function TreeList:on_scale_change()
  if self.icon_font then
    self.icon_font:set_size(style.icon_font:get_size())
  end
end

function TreeList:on_mouse_moved(px, py, ...)
  if TreeList.super.on_mouse_moved(self, px, py, ...) then
    self.hovered_item = nil
  else
    return false
  end

  local position = self.position
  local item_changed, tooltip_changed
  for item, x,y,w,h in self:each_item() do
    if px > x and py > y and px <= (self.size.x + position.x) and py <= y + h then
      item_changed = true
      self.hovered_item = item

      x = math.max(x, self.position.x)
      if px > x and py > y and px <= x + w and py <= y + h then
        tooltip_changed = true
        self.tooltip.x, self.tooltip.y = px, py
        self.tooltip.begin = system.get_time()
      end
      break
    end
  end
  if not item_changed then self.hovered_item = nil end
  if not tooltip_changed then self.tooltip.x, self.tooltip.y = nil, nil end

  return true
end

---Override to listen for item click events.
---@param item widget.treelist.item
---@param button string
---@param clicks integer
function TreeList:on_item_click(item, button, x, y, clicks) end

local function clear_hover_state(self)
  self.tooltip.x, self.tooltip.y = nil, nil
  self.tooltip.alpha = 0
  self.tooltip.begin = 0
  self.hovered_item = nil
end

function TreeList:on_mouse_pressed(button, x, y, clicks)
  if TreeList.super.on_mouse_pressed(self, button, x, y, clicks) then
    if self:scrollbar_hovering() then return true end
    self:on_mouse_moved(x, y, 0, 0)
    self:set_selection(self.hovered_item)
    if self.hovered_item then
      local emit_click = false
      if clicks > 1 then
        self:toggle_expand()
      else
        if self.hovered_item.childs then
          local x1, x2 = self:get_chevron_position(self.hovered_item)
          if x >= x1 and x <= x2 then
            self:toggle_expand()
          else
            emit_click = true
          end
        else
          emit_click = true
        end
      end
      if emit_click then
        self:on_item_click(self.hovered_item, button, x, y, clicks)
      end
    end
    return true
  end
  return false
end

function TreeList:on_mouse_left()
  TreeList.super.on_mouse_left(self)
  clear_hover_state(self)
end

function TreeList:on_mouse_leave(x, y, dx, dy)
  TreeList.super.on_mouse_leave(self, x, y, dx, dy)
  clear_hover_state(self)
end

function TreeList:update()
  if not TreeList.super.update(self) then return false end

  local duration = system.get_time() - self.tooltip.begin
  local tooltip_delay = 0.5
  if self.hovered_item and self.tooltip.x and duration > tooltip_delay then
    self:move_towards(self.tooltip, "alpha", 255, 1, "treeview")
  else
    self.tooltip.alpha = 0
  end

  self.item_icon_width = style.icon_font:get_width("D")
  self.item_text_spacing = style.icon_font:get_width("f") / 2

  -- this will make sure hovered_item is updated
  local dy = math.abs(self.last_scroll_y - self.scroll.y)
  if dy > 0 then
    self:on_mouse_moved(core.root_view.mouse.x, core.root_view.mouse.y, 0, 0)
    self.last_scroll_y = self.scroll.y
  end

  return true
end

function TreeList:get_scrollable_size()
  return self.count_lines and self:get_item_height() * (self.count_lines + 1) or math.huge
end

function TreeList:get_h_scrollable_size()
  local  _, _, v_scroll_w = self.v_scrollbar:get_thumb_rect()
  return self.scroll_width + (
    self.size.x > self.scroll_width + v_scroll_w and 0 or style.padding.x
  )
end

local function replace_alpha(color, alpha)
  local r, g, b = table.unpack(color)
  return { r, g, b, alpha }
end

function TreeList:draw_tooltip()
  if not self.hovered_item or not self.hovered_item.tooltip then
    return
  end

  local text = common.home_encode(self.hovered_item.tooltip)
  local w, h = style.font:get_width(text), style.font:get_height()

  local tooltip_offset = style.font:get_height()
  local x, y = self.tooltip.x + tooltip_offset, self.tooltip.y + tooltip_offset
  w, h = w + style.padding.x, h + style.padding.y

  if x + w > core.root_view.root_node.size.x then -- check if we can span right
    x = x - w -- span left instead
  end

  local tooltip_border = 1
  local bx, by = x - tooltip_border, y - tooltip_border
  local bw, bh = w + 2 * tooltip_border, h + 2 * tooltip_border
  renderer.draw_rect(
    bx, by, bw, bh, replace_alpha(style.text, self.tooltip.alpha)
  )
  renderer.draw_rect(
    x, y, w, h, replace_alpha(style.background2, self.tooltip.alpha)
  )
  common.draw_text(
    style.font,
    replace_alpha(style.text, self.tooltip.alpha),
    text, "center", x, y, w, h
  )
end

---@param item widget.treelist.item
---@param active boolean
---@param hovered boolean
function TreeList:get_item_icon(item, active, hovered)
  local character = item.icon
  local font = self.icon_font or style.icon_font
  local color = style.text
  if active or hovered then
    color = style.accent
  end
  return character, font, color
end

---@param item widget.treelist.item
---@param active boolean
---@param hovered boolean
function TreeList:get_item_text(item, active, hovered)
  local text = item.label
  local font = style.font
  local color = style.text
  if active or hovered then
    color = style.accent
  end
  return text, font, color
end

---@param item widget.treelist.item
---@param active boolean
---@param hovered boolean
---@param x number
---@param y number
---@param w number
---@param h number
function TreeList:draw_item_text(item, active, hovered, x, y, w, h)
  local item_text, item_font, item_color = self:get_item_text(item, active, hovered)
  return common.draw_text(item_font, item_color, item_text, "left", x, y, 0, h)
end

---@param item widget.treelist.item
---@param active boolean
---@param hovered boolean
---@param x number
---@param y number
---@param w number
---@param h number
function TreeList:draw_item_icon(item, active, hovered, x, y, w, h)
  local icon_char, icon_font, icon_color = self:get_item_icon(item, active, hovered)
  if not icon_char then return 0 end
  common.draw_text(icon_font, icon_color, icon_char, "left", x, y, 0, h)
  return self.item_icon_width + self.item_text_spacing
end

---@param item widget.treelist.item
---@param active boolean
---@param hovered boolean
---@param x number
---@param y number
---@param w number
---@param h number
function TreeList:draw_item_body(item, active, hovered, x, y, w, h)
    x = x + self:draw_item_icon(item, active, hovered, x, y, w, h)
    return self:draw_item_text(item, active, hovered, x, y, w, h)
end

---@param item widget.treelist.item
---@param active boolean
---@param hovered boolean
---@param x number
---@param y number
---@param w number
---@param h number
function TreeList:draw_item_chevron(item, active, hovered, x, y, w, h)
  if item.childs and #item.childs > 0 then
    local chevron_icon = item.expanded and "-" or "+"
    local chevron_color = hovered and style.accent or style.text
    common.draw_text(style.icon_font, chevron_color, chevron_icon, "left", x, y, 0, h)
  end
  return style.padding.x
end

---Get an item chevron starting and ending positions.
---@return number x1
---@return number x2
function TreeList:get_chevron_position(item)
  local ox = self:get_content_offset()
  local x1 = ox + item.depth * style.padding.x
  local x2 = x1 + style.padding.x * 2
  return x1, x2
end

---@param item widget.treelist.item
---@param active boolean
---@param hovered boolean
---@param x number
---@param y number
---@param w number
---@param h number
function TreeList:draw_item_background(item, active, hovered, x, y, w, h)
  if hovered then
    local hover_color = { table.unpack(style.line_highlight) }
    hover_color[4] = 160
    renderer.draw_rect(self.position.x, y, self.size.x, h, hover_color)
  elseif active then
    renderer.draw_rect(self.position.x, y, self.size.x, h, style.line_highlight)
  end
end

---@param item widget.treelist.item
---@param active boolean
---@param hovered boolean
---@param x number
---@param y number
---@param w number
---@param h number
function TreeList:draw_item(item, active, hovered, x, y, w, h)
  self:draw_item_background(item, active, hovered, x, y, w, h)

  x = x + item.depth * style.padding.x + style.padding.x
  x = x + self:draw_item_chevron(item, active, hovered, x, y, w, h)

  return self:draw_item_body(item, active, hovered, x, y, w, h)
end

function TreeList:draw()
  if not TreeList.super.draw(self) then return false end

  local position, ox = self.position, self:get_content_offset()
  local _y, _h, sw = position.y, self.size.y, 0
  for item, x,y,w,h in self:each_item() do
    if y + h >= _y and y < _y + _h then
      w = self:draw_item(
        item,
        item == self.selected_item,
        item == self.hovered_item,
        x, y, w, h
      ) - position.x + (position.x - ox)
      sw = math.max(w, sw)
    end
  end
  self.scroll_width = sw

  self:draw_scrollbar()

  if
    self.hovered_item and self.hovered_item.tooltip
    and
    self.tooltip.x and self.tooltip.alpha > 0
  then
    core.root_view:defer_draw(self.draw_tooltip, self)
  end

  return true
end

---@param item? widget.treelist.item
---@param where integer
---@return widget.treelist.item item
---@return number x
---@return number y
---@return number w
---@return number h
function TreeList:get_item(item, where)
  item = item or self.selected_item

  local last_item, last_x, last_y, last_w, last_h
  local stop = false

  for it, x, y, w, h in self:each_item() do
    if not item and where >= 0 then
      return it, x, y, w, h
    end
    if item == it then
      if where < 0 and last_item then
        break
      elseif where == 0 or (where < 0 and not last_item) then
        return it, x, y, w, h
      end
      stop = true
    elseif stop then
      item = it
      return it, x, y, w, h
    end
    last_item, last_x, last_y, last_w, last_h = it, x, y, w, h
  end
  return last_item, last_x, last_y, last_w, last_h
end

---@param item? widget.treelist.item
---@return widget.treelist.item item
---@return number x
---@return number y
---@return number w
---@return number h
function TreeList:get_next(item)
  return self:get_item(item, 1)
end

---@param item? widget.treelist.item
---@return widget.treelist.item item
---@return number x
---@return number y
---@return number w
---@return number h
function TreeList:get_previous(item)
  return self:get_item(item, -1)
end

function TreeList:select_next()
  self.selected_item = self:get_next()
end

function TreeList:select_prev()
  self.selected_item = self:get_previous()
end

---Expand or collapse the currently selected or given item.
---@param toggle? boolean
---@param item? widget.treelist.item
function TreeList:toggle_expand(toggle, item)
  item = item or self.selected_item

  if not item then return end

  if item.childs and #item.childs > 0 then
    if type(toggle) == "boolean" then
      item.expanded = toggle
    else
      item.expanded = not item.expanded
    end
  end
end


return TreeList
