--
-- SearchReplaceList Widget.
-- @copyright Jefferson Gonzalez
-- @license MIT
--

local core = require "core"
local common = require "core.common"
local style = require "core.style"
local syntax = require "core.syntax"
local tokeninzer = require "core.tokenizer"
local Widget = require "widget"

---@class widget.searchreplacelist.lineposition
---@field col1 integer
---@field col2 integer
---@field checked boolean?

---@class widget.searchreplacelist.line
---@field line integer
---@field text string
---@field positions widget.searchreplacelist.lineposition[]

---@class widget.searchreplacelist.file
---@field path string
---@field lines widget.searchreplacelist.line[]
---@field expanded boolean

---@class widget.searchreplacelist.item
---@field checked boolean
---@field file widget.searchreplacelist.file?
---@field parent widget.searchreplacelist.item?
---@field line widget.searchreplacelist.line?
---@field position widget.searchreplacelist.lineposition?

---@class widget.searchreplacelist : widget
---@field replacement string?
---@field case_sensitive boolean
---@field regex? regex
---@field selected integer
---@field hovered integer
---@field items widget.searchreplacelist.item[]
---@field total_files integer
---@field total_results integer
---@field base_dir string
---@field syntax_highlighting boolean if true enables syntax highlight for searches
---@overload fun(parent:widget, find:string, replacement:string?):widget.searchreplacelist
local SearchReplaceList = Widget:extend()

---Colors to use when performing a replacement.
local DIFF = {
  ADD = { common.color "#72b886" },
  DEL = { common.color "#F36161" },
  TEXT = { common.color "#000000" }
}

---Constructor
---@param parent widget
---@param replacement? string
---@param search_regex? string
---@param case_sensitive? boolean
function SearchReplaceList:new(parent, replacement, search_regex, case_sensitive)
  SearchReplaceList.super.new(self, parent)

  self.type_name = "widget.searchreplacelist"

  self.replacement = replacement
  self.selected = 0
  self.syntax_highlighting = false
  self.hovered = 0
  self.items = {}
  self.max_width = 0
  self.hovered_checkbox = false
  self.hovered_expander = false
  self.scrollable = true
  self.total_files = 0
  self.total_results = 0
  self.base_dir = ""
  self.last_min = 0
  self.last_max = 0
  self.tokens_cache = {}

  self:set_search_regex(search_regex, case_sensitive)
end

---Set the search text regular expression for correct replacement substitutions.
---@param search_regex? string
---@param case_sensitive? boolean
function SearchReplaceList:set_search_regex(search_regex, case_sensitive)
  if search_regex then
    self.regex = regex.compile(search_regex, case_sensitive and "" or "i")
  else
    self.regex = nil
  end
end

---Overridable event triggered when an item is clicked.
---@param item widget.searchreplacelist.item
---@param clicks integer
function SearchReplaceList:on_item_click(item, clicks) end

---Add a new file with all the matching lines and positions.
---@param path string
---@param lines widget.searchreplacelist.line[]
---@param expanded boolean?
function SearchReplaceList:add_file(path, lines, expanded)
  if type(expanded) == "nil" then expanded = true end
  table.insert(self.items, {
    checked = true,
    file = {
      path = path,
      lines = lines,
      expanded = false
    }
  })
  self.total_files = self.total_files + 1
  -- expand results and count them
  if expanded and #lines > 0 then
    self:expand(#self.items, true)
  -- count the results only
  else
    for _, line in ipairs(self.items[#self.items]) do
      self.total_results = self.total_results + #line.positions
    end
  end
end

---Removes all elements from the list and reset it.
function SearchReplaceList:clear()
  self.selected = 0
  self.hovered = 0
  self.items = {}
  self.max_width = 0
  self.hovered_checkbox = false
  self.hovered_expander = false
  self.total_files = 0
  self.total_results = 0
  self.last_min = 0
  self.last_max = 0
  self.tokens_cache = {}
end

---Uncollapse a file line results.
---@param position integer
function SearchReplaceList:expand(position, count_results)
  local parent = self.items[position]
  if
    parent and parent.file
    and
    not parent.file.expanded
  then
    local insert_pos = position+1
    local items = {}
    for _, line in ipairs(parent.file.lines) do
      for _, line_pos in ipairs(line.positions) do
        table.insert(items, {
          parent = parent,
          line = line,
          position = line_pos
        })
        if count_results then
          self.total_results = self.total_results + 1
        end
      end
    end
    common.splice(self.items, insert_pos, 0, items)
    parent.file.expanded = true
    self.tokens_cache = {}
  end
end

---Collapse a file line results.
---@param position integer
function SearchReplaceList:contract(position)
  local parent = self.items[position]
  if
    parent and parent.file
    and
    parent.file.expanded
  then
    local start_pos = position+1
    local end_pos = 0
    for _, line in ipairs(parent.file.lines) do
      for _, _ in ipairs(line.positions) do
        end_pos = end_pos + 1
      end
    end
    common.splice(self.items, start_pos, end_pos)
    parent.file.expanded = false
    self.tokens_cache = {}
  end
end

---Collapse/uncollapse a file line results.
---@param position integer
function SearchReplaceList:toggle_expand(position)
  local parent = self.items[position]
  if parent and parent.file then
    if parent.file.expanded then
      self:contract(position)
    else
      self:expand(position)
    end
  end
end

---Toggle the checkbox of the given item position.
---@param pos integer
function SearchReplaceList:toggle_check(pos)
  local item = self.items[pos]
  if not item or self.replacement == nil then return end
  if item.file then
    item.checked = not item.checked
    for _, line in ipairs(item.file.lines) do
      for _, position in ipairs(line.positions) do
        position.checked = item.checked
      end
    end
  else
    if type(item.position.checked) == "nil" then
      item.position.checked = false
    else
      item.position.checked = not item.position.checked
    end
    local parent_checked = true
    for _, line in ipairs(item.parent.file.lines) do
      for _, position in ipairs(line.positions) do
        if position.checked == false then
          parent_checked = false
          break
        end
      end
    end
    item.parent.checked = parent_checked
  end
end

---Replace a position on a string with a given replacement.
---@param self widget.searchreplacelist
---@param str string
---@param s integer
---@param e integer
---@param rep string
---@return string replaced_string
---@return integer replacement_len
local function replace_substring(self, str, s, e, rep)
    local head = s <= 1 and "" or string.sub(str, 1, s - 1)
    local tail = e >= #str and "" or string.sub(str, e + 1)
    if self.regex then
      local target = string.sub(str, s, e)
      rep = self.regex:gsub(target, rep, 1)
    end
    return head .. rep .. tail, #rep
end

---Applies the replacement on the given item position but not on the real file.
---
---The purpose of this function is to reflect the changes on the listed items.
---@param position integer
function SearchReplaceList:apply_replacement(position)
  if not self.replacement then return end
  local item = self.items[position]
  if item and item.file and self.replacement then
    local replacement = self.replacement
    local replacement_len
    for _, line in ipairs(item.file.lines) do
      local offset = 0
      for _, pos in ipairs(line.positions) do
        local col1 = pos.col1 + offset
        local col2 = pos.col2 + offset
        if pos.checked or type(pos.checked) == "nil" then
          line.text, replacement_len = replace_substring(
            self, line.text, col1, col2, replacement
          )
          local current_len = col2 - col1 + 1
          local len_diff = 0
          if current_len > replacement_len then
            len_diff = current_len - replacement_len
            offset = offset - len_diff
            col2 = col2 - len_diff
          elseif current_len < replacement_len then
            len_diff = replacement_len - current_len
            offset = offset + len_diff
            col2 = col2 + len_diff
          end
        end
        pos.col1, pos.col2 = col1, col2
      end
    end
  end
end

---Select the item that follows currently selected item.
---@return widget.searchreplacelist.item?
function SearchReplaceList:select_next()
  local items_count = #self.items
  if items_count <= 0 then return nil end
  local selected = self.selected+1
  if selected > items_count then selected = 1 end
  self.selected = common.clamp(selected, 1, items_count)
  self:scroll_to_selected()
  return self.items[self.selected]
end

---Select the item that precedes currently selected item.
---@return widget.searchreplacelist.item?
function SearchReplaceList:select_prev()
  local items_count = #self.items
  if items_count <= 0 then return nil end
  local selected = self.selected-1
  if selected < 1 then selected = items_count end
  self.selected = common.clamp(selected, 1, items_count)
  self:scroll_to_selected()
  return self.items[self.selected]
end

---Get the currently selected item.
---@return widget.searchreplacelist.item?
function SearchReplaceList:get_selected()
  if self.selected > 0 and self.selected <= #self.items then
    return self.items[self.selected]
  end
  return nil
end

---Get the line height used when drawing each item row.
---@return number height
function SearchReplaceList:get_line_height()
  return style.padding.y + style.font:get_height()
end

---Used when calculating if vertical scrolling is needed.
---@return number size
function SearchReplaceList:get_scrollable_size()
  return #self.items * self:get_line_height() + style.padding.y
end

---Used when calculating if horizontal scrolling is needed.
---@return number size
function SearchReplaceList:get_h_scrollable_size()
  local width = style.padding.x / 2
    + style.icon_font:get_width("-")
    + style.padding.x / 2
    + self.max_width
  if self.replacement then
    local cb_w = self:get_checkbox_size()
    width = width
      + cb_w + style.padding.x / 2
      + style.font:get_width(self.replacement)
  end
  return width
end

---Get the checkbox size based on the line height.
---@param y? number
---@return number w
---@return number h
---@return number y Vertically center align coord based on given y param
function SearchReplaceList:get_checkbox_size(y)
  if not y then y = 0 end
  local lh = self:get_line_height()
  local w = lh * 0.6
  local h = w
  y = y + ((lh / 2) - (h / 2))
  return w, h, y
end

---Get the position of first and last visible items.
---@return integer first
---@return integer last
function SearchReplaceList:get_visible_items_range()
  local lh = self:get_line_height()
  local min = math.max(1, math.floor(self.scroll.y / lh))
  return min, min + math.floor(self.size.y / lh) + 1
end

---Allows iterating the currently visible items only.
---@return fun():integer,widget.searchreplacelist.item,number,number,number,number
function SearchReplaceList:each_visible_item()
  return coroutine.wrap(function()
    local lh = self:get_line_height()
    local x, y = self:get_content_offset()
    local min, max = self:get_visible_items_range()
    if self.last_min ~= min or self.last_max ~= max then
      self.tokens_cache = {}
    end
    self.last_min, self.last_max = min, max
    y = y + lh * (min - 1)
    for i = min, max do
      local item = self.items[i]
      if not item then break end
      coroutine.yield(i, item, x, y, self.size.x, lh)
      y = y + lh
    end
  end)
end

---Iterates over all files, only those that have a position checked on replacement mode.
---@return fun():integer,widget.searchreplacelist.file
function SearchReplaceList:each_file()
  return coroutine.wrap(function()
    local replacement = self.replacement
    for i, item in ipairs(self.items) do
      if item.file then
        if replacement then
          local none_checked = true
          for _, line in ipairs(item.file.lines) do
            for _, pos in ipairs(line.positions) do
              if type(pos.checked) == "nil" or pos.checked then
                none_checked = false
                break
              end
            end
          end
          if none_checked then
            goto continue
          end
        end
        coroutine.yield(i, item.file)
      end
      ::continue::
    end
  end)
end

---Scroll to currently selected item only if not already visible.
function SearchReplaceList:scroll_to_selected()
  if self.selected == 0 then return end
  local h = self:get_line_height()
  local y = h * (self.selected - 1)
  self.scroll.to.y = math.min(self.scroll.to.y, y)
  self.scroll.to.y = math.max(self.scroll.to.y, y + h - self.size.y)
end

function SearchReplaceList:on_mouse_moved(mx, my, ...)
  if not SearchReplaceList.super.on_mouse_moved(self, mx, my, ...) then
    return false
  end
  self.hovered = 0
  for i, item, x,y,w,h in self:each_visible_item() do
    w = self.size.x
    if mx >= x and my >= y and mx < x + w and my < y + h then
      self.hovered = i
      x = x + style.padding.x / 2
      w = style.icon_font:get_width('-')
      if item.file and mx >= x and my >= y and mx < x + w and my < y + h then
        self.hovered_expander = true
        self.hovered_checkbox = false
      else
        self.hovered_expander = false
      end
      if not self.hovered_expander and self.replacement then
        x = x + w + style.padding.x / 2
        w, h, y = self:get_checkbox_size(y)
        if mx >= x and my >= y and mx < x + w and my < y + h then
          self.hovered_checkbox = true
          self.hovered_expander = false
        else
          self.hovered_checkbox = false
        end
      end
      break
    end
  end
  return true
end

function SearchReplaceList:on_mouse_pressed(button, x, y, clicks)
  if
    not SearchReplaceList.super.on_mouse_pressed(self, button, x, y, clicks)
  then
    return false
  end
  if self:scrollbar_hovering() then return true end
  local item = self.items[self.hovered]
  if not item then return true end
  self.selected = self.hovered
  if self.hovered_checkbox then
    self:toggle_check(self.selected)
  elseif item.file and (clicks > 1 or self.hovered_expander) then
    if not item.file.expanded then
      self:expand(self.hovered)
    else
      self:contract(self.hovered)
    end
  else
    self:on_item_click(item, clicks)
  end
  return true
end

function SearchReplaceList:draw_checkbox(checked, hovered, x, y, lh)
  local w, h, cy = self:get_checkbox_size(y)
  renderer.draw_rect(x, cy, w, h, style.text)
  renderer.draw_rect(
    x + 2, cy + 2, w-4, h-4,
    hovered and style.dim or style.background
  )
  if checked then
    renderer.draw_rect(x + 5, cy + 5, w-10, h-10, style.caret)
  end
  return x + w
end

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

  core.push_clip_rect(
    self.position.x,
    self.position.y,
    self.size.x,
    self.size.y
  )

  local font = self:get_font()

  local replacement = self.replacement
  local replacement_width = font:get_width(replacement or "")
  local file_path

  self.max_width = 0

  local syn = nil
  local last_file_path = nil
  for i, item, x,y,w,h in self:each_visible_item() do
    if item.file then
      file_path = common.relative_path(self.base_dir, item.file.path)
    end

    -- add left padding
    x = x + style.padding.x / 2

    local text_color = style.text

    if i == self.selected then
      renderer.draw_rect(self.position.x, y, self.size.x, h, style.selection)
    elseif i == self.hovered then
      renderer.draw_rect(self.position.x, y, self.size.x, h, style.line_highlight)
      text_color = style.accent
    end

    -- draw collapse/contract symbol
    if item.file then
      common.draw_text(
        style.icon_font,
        (self.hovered == i and self.hovered_expander) and style.accent or style.text,
        item.file.expanded and "-" or "+",
        "left",
        x, y, w, h
      )
      x = x + style.icon_font:get_width("-")
    else
      x = x + style.icon_font:get_width("-")
    end

    x = x + style.padding.x / 2

    -- draw checkbox
    if replacement then
      local checked = false
      if item.file then
        checked = item.checked
      else
        if type(item.position.checked) == "nil" then
          checked = true
        else
          checked = item.position.checked
        end
      end
      x = style.padding.x / 2 + self:draw_checkbox(
        checked,
        i == self.hovered and self.hovered_checkbox or false,
        x, y, h
      )
    end

    -- draw text
    local text = item.file and file_path or item.line.text
    local all_text = ""

    if item.line then
      local fpath = item.parent.file.path
      if not self.replacement and self.syntax_highlighting and fpath ~= last_file_path then
        syn = syntax.get(fpath)
        if syn == syntax.plain_text_syntax then syn = nil end
        last_file_path = fpath
      end

      local start_pos, end_pos = 1, #text
      local prefix, postfix = "", ""
      -- truncate long lines to keep good rendering performance
      if #text > 120 then
        start_pos = math.max(item.position.col1 - 50, 1)
        end_pos = math.min(item.position.col2 + 50, end_pos)
        if start_pos ~= 1 then prefix = "..." end
        if end_pos ~= #text then postfix = "..." end
      end
      x = common.draw_text(
        font,
        style.syntax["number"],
        tostring(item.line.line) .. ": ",
        "left",
        x, y, w, h
      )
      local start_text = item.position.col1 ~= 1 and
        prefix .. text:sub(start_pos, item.position.col1-1)
        or
        ""
      local end_text = item.position.col2 ~= #text and
        text:sub(item.position.col2+1, end_pos) .. postfix
        or
        ""
      local found_text = text:sub(item.position.col1, item.position.col2)
      local found_width = style.font:get_width(found_text)

      all_text = item.line.line .. ": " .. start_text .. found_text .. end_text

      if syn then
        local rx = x
        if start_text ~= "" then
          rx = rx + font:get_width(start_text)
        end
        local highlight = i == self.selected and style.line_highlight or style.selection
        renderer.draw_rect(rx, y, found_width, h, highlight)
        local tokens = self.tokens_cache[i]
        if not tokens then
          tokens = tokeninzer.tokenize(
            syn,
            utf8extra.clean(start_text .. found_text .. end_text, "") .. "\n",
            ""
          )
          self.tokens_cache[i] = tokens
        end
        for _, ttype, ttext in tokeninzer.each_token(tokens) do
          x = common.draw_text(
            style.font,
            style.syntax[ttype] or text_color,
            ttext, "left", x, y, w, h
          )
          if x > self.size.x + self.position.x then break end
        end
      else
        if start_text ~= "" then
          x = common.draw_text(
            font,
            text_color,
            start_text,
            "left",
            x, y, w, h
          )
        end
        local found_color = not replacement and style.dim or DIFF.DEL
        local found_text_color = not replacement and text_color or DIFF.TEXT
        renderer.draw_rect(x, y, found_width, h, found_color)
        x = common.draw_text(font, found_text_color, found_text, "left", x, y, w, h)
        if replacement then
          if self.regex then
            local repl = self.regex:gsub(found_text, replacement, 1)
            local repl_width = font:get_width(repl or "")
            renderer.draw_rect(x, y, repl_width, h, DIFF.ADD)
            x = common.draw_text(font, DIFF.TEXT, repl, "left", x, y, w, h)
          else
            renderer.draw_rect(x, y, replacement_width, h, DIFF.ADD)
            x = common.draw_text(font, DIFF.TEXT, replacement, "left", x, y, w, h)
          end
        end
        if end_text ~= "" then
          x = common.draw_text(
            font,
            text_color,
            end_text,
            "left",
            x, y, w, h
          )
        end
      end
    else
      x = common.draw_text(font, text_color, text, "left", x, y, w, h)
      all_text = file_path
    end

    -- recalc max_width for horizontal scrollbar
    self.max_width = math.max(self.max_width, style.font:get_width(all_text))
  end

  core.pop_clip_rect()

  self:draw_scrollbar()

  return true
end


return SearchReplaceList
