local cmp = require('cmp')

---@param patterns string[]
---@param head boolean
---@return table #regex object
local function create_regex(patterns, head)
  local pattern = [[\%(]] .. table.concat(patterns, [[\|]]) .. [[\)]]
  if head then
    pattern = '^' .. pattern
  end
  return vim.regex(pattern)
end

local DEFAULT_OPTION = {
  ignore_cmds = { 'Man', '!' }
}

local MODIFIER_REGEX = create_regex({
  [=[\s*abo\%[veleft]\s*]=],
  [=[\s*bel\%[owright]\s*]=],
  [=[\s*bo\%[tright]\s*]=],
  [=[\s*bro\%[wse]\s*]=],
  [=[\s*conf\%[irm]\s*]=],
  [=[\s*hid\%[e]\s*]=],
  [=[\s*keepal\s*t]=],
  [=[\s*keeppa\%[tterns]\s*]=],
  [=[\s*lefta\%[bove]\s*]=],
  [=[\s*loc\%[kmarks]\s*]=],
  [=[\s*nos\%[wapfile]\s*]=],
  [=[\s*rightb\%[elow]\s*]=],
  [=[\s*sil\%[ent]\s*]=],
  [=[\s*tab\s*]=],
  [=[\s*to\%[pleft]\s*]=],
  [=[\s*verb\%[ose]\s*]=],
  [=[\s*vert\%[ical]\s*]=],
}, true)

local COUNT_RANGE_REGEX = create_regex({
  [=[\s*\%(\d\+\|\$\)\%[,\%(\d\+\|\$\)]\s*]=],
  [=[\s*'\%[<,'>]\s*]=],
  [=[\s*\%(\d\+\|\$\)\s*]=],
}, true)

local ONLY_RANGE_REGEX = create_regex({
  [=[^\s*\%(\d\+\|\$\)\%[,\%(\d\+\|\$\)]\s*$]=],
  [=[^\s*'\%[<,'>]\s*$]=],
  [=[^\s*\%(\d\+\|\$\)\s*$]=],
}, true)

local OPTION_NAME_COMPLETION_REGEX = create_regex({
  [=[se\%[tlocal][^=]*$]=],
}, true)

---@param word string
---@return boolean?
local function is_boolean_option(word)
  local ok, opt = pcall(function()
    return vim.opt[word]:get()
  end)
  if ok then
    return type(opt) == 'boolean'
  end
end

---@class cmp.Cmdline.Definition
---@field ctype string
---@field regex string
---@field kind lsp.CompletionItemKind
---@field isIncomplete boolean
---@field exec fun(option: table, arglead: string, cmdline: string, force: boolean): lsp.CompletionItem[]
---@field fallback boolean?

---@type cmp.Cmdline.Definition[]
local definitions = {
  {
    ctype = 'cmdline',
    regex = [=[[^[:blank:]]*$]=],
    kind = cmp.lsp.CompletionItemKind.Variable,
    isIncomplete = true,
    exec = function(option, arglead, cmdline, force)
      -- Ignore range only cmdline. (e.g.: 4, '<,'>)
      if not force and ONLY_RANGE_REGEX:match_str(cmdline) then
        return {}
      end

      local _, parsed = pcall(function()
        local target = cmdline
        local s, e = COUNT_RANGE_REGEX:match_str(target)
        if s and e then
          target = target:sub(e + 1)
        end
        -- nvim_parse_cmd throw error when the cmdline contains range specifier.
        return vim.api.nvim_parse_cmd(target, {}) or {}
      end)
      parsed = parsed or {}

      -- Check ignore cmd.
      if vim.tbl_contains(option.ignore_cmds, parsed.cmd) then
        return {}
      end

      -- Cleanup modifiers.
      -- We can just remove modifiers because modifiers is always separated by space.
      if arglead ~= cmdline then
        while true do
          local s, e = MODIFIER_REGEX:match_str(cmdline)
          if s == nil then
            break
          end
          cmdline = string.sub(cmdline, e + 1)
        end
      end

      -- Support `lua vim.treesitter._get|` or `'<,'>del|` completion.
      -- In this case, the `vim.fn.getcompletion` will return only `get_query` for `vim.treesitter.get_|`.
      -- We should detect `vim.treesitter.` and `get_query` separately.
      -- TODO: The `\h\w*` was choosed by huristic. We should consider more suitable detection.
      local fixed_input
      do
        local suffix_pos = vim.regex([[\h\w*$]]):match_str(arglead)
        fixed_input = string.sub(arglead, 1, suffix_pos or #arglead)
      end

      -- The `vim.fn.getcompletion` does not return `*no*cursorline` option.
      -- cmp-cmdline corrects `no` prefix for option name.
      local is_option_name_completion = OPTION_NAME_COMPLETION_REGEX:match_str(cmdline) ~= nil

      local items = {}
      local escaped = cmdline:gsub([[\\]], [[\\\\]]);
      for _, word_or_item in ipairs(vim.fn.getcompletion(escaped, 'cmdline')) do
        local word = type(word_or_item) == 'string' and word_or_item or word_or_item.word
        local item = { label = word }
        table.insert(items, item)
        if is_option_name_completion and is_boolean_option(word) then
          table.insert(items, vim.tbl_deep_extend('force', {}, item, {
            label = 'no' .. word,
            filterText = word,
          }))
        end
      end
      for _, item in ipairs(items) do
        if not string.find(item.label, fixed_input, 1, true) then
          item.label = fixed_input .. item.label
        end
      end
      return items
    end
  },
}

local source = {}

source.new = function()
  return setmetatable({
    before_line = '',
    offset = -1,
    ctype = '',
    items = {},
  }, { __index = source })
end

source.get_keyword_pattern = function()
  return [=[[^[:blank:]]*]=]
end

source.get_trigger_characters = function()
  return { ' ', '.', '#', '-' }
end

source.complete = function(self, params, callback)
  local offset = 0
  local ctype = ''
  local items = {}
  local kind
  local isIncomplete = false
  for _, def in ipairs(definitions) do
    local s, e = vim.regex(def.regex):match_str(params.context.cursor_before_line)
    if s and e then
      offset = s
      ctype = def.ctype
      items = def.exec(
        vim.tbl_deep_extend('keep', params.option or {}, DEFAULT_OPTION),
        string.sub(params.context.cursor_before_line, s + 1),
        params.context.cursor_before_line,
        params.context:get_reason() == cmp.ContextReason.Manual
      )
      kind = def.kind
      isIncomplete = def.isIncomplete
      if not (#items == 0 and def.fallback) then
        break
      end
    end
  end

  local labels = {}
  for _, item in ipairs(items) do
    item.kind = kind
    labels[item.label] = true
  end

  -- `vim.fn.getcompletion` does not handle fuzzy matches. So, we must return all items, including items that were matched in the previous input.
  local should_merge_previous_items = false
  if #params.context.cursor_before_line > #self.before_line then
    should_merge_previous_items = string.find(params.context.cursor_before_line, self.before_line, 1, true) == 1
  elseif #params.context.cursor_before_line < #self.before_line then
    should_merge_previous_items = string.find(self.before_line, params.context.cursor_before_line, 1, true) == 1
  end

  if should_merge_previous_items and self.offset == offset and self.ctype == ctype then
    for _, item in ipairs(self.items) do
      if not labels[item.label] then
        table.insert(items, item)
      end
    end
  end
  self.before_line = params.context.cursor_before_line
  self.offset = offset
  self.ctype = ctype
  self.items = items

  callback({
    isIncomplete = isIncomplete,
    items = items,
  })
end

return source