local M = {}

---@class snacks.picker.history.Config: snacks.picker.Config
---@field name string

function M.commands()
  local commands = vim.api.nvim_get_commands({})
  for k, v in pairs(vim.api.nvim_buf_get_commands(0, {})) do
    if type(k) == "string" then -- fixes vim.empty_dict() bug
      commands[k] = v
    end
  end
  for _, c in ipairs(vim.fn.getcompletion("", "command")) do
    if not commands[c] and c:find("^[a-z]") then
      commands[c] = { definition = "completion" }
    end
  end
  ---@async
  ---@param cb async fun(item: snacks.picker.finder.Item)
  return function(cb)
    ---@type string[]
    local names = vim.tbl_keys(commands)
    table.sort(names)
    for _, name in pairs(names) do
      local def = commands[name]
      cb({
        text = name,
        desc = def.script_id and def.script_id < 0 and def.definition or nil,
        command = def,
        cmd = name,
        preview = {
          text = vim.inspect(def),
          ft = "lua",
        },
      })
    end
  end
end

---@param opts snacks.picker.history.Config
function M.history(opts)
  local count = vim.fn.histnr(opts.name)
  local items = {}
  for i = count, 1, -1 do
    local line = vim.fn.histget(opts.name, i)
    if not line:find("^%s*$") then
      table.insert(items, {
        text = line,
        cmd = line,
        preview = {
          text = line,
          ft = "text",
        },
      })
    end
  end
  return items
end

---@param opts snacks.picker.marks.Config
function M.marks(opts)
  local marks = {} ---@type vim.fn.getmarklist.ret.item[]
  if opts.global then
    vim.list_extend(marks, vim.fn.getmarklist())
  end
  if opts["local"] then
    vim.list_extend(marks, vim.fn.getmarklist(vim.api.nvim_get_current_buf()))
  end

  ---@type snacks.picker.finder.Item[]
  local items = {}
  local bufname = vim.api.nvim_buf_get_name(0)
  for _, mark in ipairs(marks) do
    local file = mark.file or bufname
    local buf = mark.pos[1] and mark.pos[1] > 0 and mark.pos[1] or nil
    local line ---@type string?
    if buf and mark.pos[2] > 0 and vim.api.nvim_buf_is_valid(mark.pos[2]) then
      line = vim.api.nvim_buf_get_lines(buf, mark.pos[2] - 1, mark.pos[2], false)[1]
    end
    local label = mark.mark:sub(2, 2)
    items[#items + 1] = {
      text = table.concat({ label, file, line }, " "),
      label = label,
      line = line,
      buf = buf,
      file = file,
      pos = mark.pos[2] > 0 and { mark.pos[2], mark.pos[3] },
    }
  end
  table.sort(items, function(a, b)
    return a.label < b.label
  end)
  return items
end

function M.jumps()
  local jumps = vim.fn.getjumplist()[1]
  local items = {} ---@type snacks.picker.finder.Item[]
  for _, jump in ipairs(jumps) do
    local buf = jump.bufnr and vim.api.nvim_buf_is_valid(jump.bufnr) and jump.bufnr or 0
    local file = jump.filename or buf and vim.api.nvim_buf_get_name(buf) or nil
    if buf or file then
      local line ---@type string?
      if buf then
        line = vim.api.nvim_buf_get_lines(buf, jump.lnum - 1, jump.lnum, false)[1]
      end
      local label = tostring(#jumps - #items)
      table.insert(items, 1, {
        label = Snacks.picker.util.align(label, #tostring(#jumps), { align = "right" }),
        buf = buf,
        line = line,
        text = table.concat({ file, line }, " "),
        file = file,
        pos = jump.lnum and jump.lnum > 0 and { jump.lnum, jump.col } or nil,
      })
    end
  end
  return items
end

function M.autocmds()
  local autocmds = vim.api.nvim_get_autocmds({})
  local items = {} ---@type snacks.picker.finder.Item[]
  for _, au in ipairs(autocmds) do
    local item = au --[[@as snacks.picker.finder.Item]]
    item.text = Snacks.picker.util.text(item, { "event", "group_name", "pattern", "command" })
    item.preview = {
      text = vim.inspect(au),
      ft = "lua",
    }
    item.item = au
    if au.callback then
      local info = debug.getinfo(au.callback, "S")
      if info.what == "Lua" then
        item.file = info.source:sub(2)
        item.pos = { info.linedefined, 0 }
        item.preview = "file"
      end
    end
    items[#items + 1] = item
  end
  return items
end

function M.highlights()
  local hls = vim.api.nvim_get_hl(0, {}) --[[@as table<string,vim.api.keyset.get_hl_info> ]]
  local items = {} ---@type snacks.picker.finder.Item[]
  for group, hl in pairs(hls) do
    local defs = {} ---@type {group:string, hl:vim.api.keyset.get_hl_info}[]
    defs[#defs + 1] = { group = group, hl = hl }
    local link = hl.link
    local done = { [group] = true } ---@type table<string, boolean>
    while link and not done[link] do
      done[link] = true
      local hl_link = hls[link]
      if hl_link then
        defs[#defs + 1] = { group = link, hl = hl_link }
        link = hl_link.link
      else
        break
      end
    end
    local code = {} ---@type string[]
    local extmarks = {} ---@type snacks.picker.Extmark[]
    local row = 1
    for _, def in ipairs(defs) do
      for _, prop in ipairs({ "fg", "bg", "sp" }) do
        local v = def.hl[prop]
        if type(v) == "number" then
          def.hl[prop] = ("#%06X"):format(v)
        end
      end
      code[#code + 1] = ("%s = %s"):format(def.group, vim.inspect(def.hl))
      extmarks[#extmarks + 1] = { row = row, col = 0, hl_group = def.group, end_col = #def.group }
      row = row + #vim.split(code[#code], "\n") + 1
    end
    items[#items + 1] = {
      text = vim.inspect(defs):gsub("\n", " "),
      hl_group = group,
      preview = {
        text = table.concat(code, "\n\n"),
        ft = "lua",
        extmarks = extmarks,
      },
    }
  end
  table.sort(items, function(a, b)
    return a.hl_group < b.hl_group
  end)
  return items
end

function M.colorschemes()
  local items = {} ---@type snacks.picker.finder.Item[]
  local rtp = vim.o.runtimepath
  if package.loaded.lazy then
    rtp = rtp .. "," .. table.concat(require("lazy.core.util").get_unloaded_rtp(""), ",")
  end
  local files = vim.fn.globpath(rtp, "colors/*", false, true) ---@type string[]
  for _, file in ipairs(files) do
    local name = vim.fn.fnamemodify(file, ":t:r")
    local ext = vim.fn.fnamemodify(file, ":e")
    if ext == "vim" or ext == "lua" then
      items[#items + 1] = {
        text = name,
        file = file,
      }
    end
  end
  return items
end

---@param opts snacks.picker.keymaps.Config
function M.keymaps(opts)
  local items = {} ---@type snacks.picker.finder.Item[]
  local maps = {} ---@type vim.api.keyset.get_keymap[]
  for _, mode in ipairs(opts.modes) do
    if opts.global then
      vim.list_extend(maps, vim.api.nvim_get_keymap(mode))
    end
    if opts["local"] then
      vim.list_extend(maps, vim.api.nvim_buf_get_keymap(0, mode))
    end
  end
  local done = {} ---@type table<string, boolean>
  for _, km in ipairs(maps) do
    local key = Snacks.picker.util.text(km, { "mode", "lhs", "buffer" })
    local keep = true
    if opts.plugs == false and km.lhs:match("^<Plug>") then
      keep = false
    end
    if keep and not done[key] then
      done[key] = true
      local item = {
        mode = km.mode,
        item = km,
        key = km.lhs,
        preview = {
          text = vim.inspect(km),
          ft = "lua",
        },
      }
      if km.callback then
        local info = debug.getinfo(km.callback, "S")
        if info.what == "Lua" then
          item.file = info.source:sub(2)
          item.pos = { info.linedefined, 0 }
          item.preview = "file"
        end
      end
      item.text = Snacks.util.normkey(km.lhs)
        .. " "
        .. Snacks.picker.util.text(km, { "mode", "lhs", "rhs", "desc" })
        .. (item.file or "")
      items[#items + 1] = item
    end
  end
  return items
end

function M.registers()
  local registers = '*+"-:.%/#=_abcdefghijklmnopqrstuvwxyz0123456789'
  local items = {} ---@type snacks.picker.finder.Item[]
  local is_osc52 = vim.g.clipboard and vim.g.clipboard.name == "OSC 52"
  local has_clipboard = vim.g.loaded_clipboard_provider == 2
  for i = 1, #registers, 1 do
    local reg = registers:sub(i, i)
    local value = ""
    if is_osc52 and reg:match("[%+%*]") then
      value = "OSC 52 detected, register not checked to maintain compatibility"
    elseif has_clipboard or not reg:match("[%+%*]") then
      local ok, reg_value = pcall(vim.fn.getreg, reg, 1)
      value = (ok and reg_value or "") --[[@as string]]
    end
    if value ~= "" then
      table.insert(items, {
        text = ("%s: %s"):format(reg, value:gsub("\n", "\\n"):gsub("\r", "\\r")),
        reg = reg,
        label = reg,
        data = value,
        value = value:gsub("\n", "\\n"):gsub("\r", "\\r"),
        preview = {
          text = value,
          ft = "text",
        },
      })
    end
  end
  return items
end

function M.spelling()
  local buf = vim.api.nvim_get_current_buf()
  local win = vim.api.nvim_get_current_win()
  local cursor = vim.api.nvim_win_get_cursor(0)
  local line = vim.api.nvim_buf_get_lines(buf, cursor[1] - 1, cursor[1], false)[1]

  -- get a misspelled word from under the cursor, if not found, then use the cursor_word instead
  local bad = vim.fn.spellbadword() ---@type string[]
  local word = bad[1] == "" and vim.fn.expand("<cword>") or bad[1]
  local suggestions = vim.fn.spellsuggest(word, 25, bad[2] == "caps")

  local items = {} ---@type snacks.picker.finder.Item[]

  for _, label in ipairs(suggestions) do
    table.insert(items, {
      text = label,
      action = function()
        -- skip whitespace
        local col = cursor[2] + 1
        while line:sub(col, col):match("%s") and col < #line do
          col = col + 1
          vim.api.nvim_win_set_cursor(win, { cursor[1], col - 1 })
        end
        vim.cmd('normal! "_ciw' .. label)
      end,
    })
  end
  return items
end

---@param opts snacks.picker.undo.Config
---@type snacks.picker.finder
function M.undo(opts, ctx)
  local tree = vim.fn.undotree()
  local buf = vim.api.nvim_get_current_buf()
  local file = vim.api.nvim_buf_get_name(buf)
  local items = {} ---@type snacks.picker.finder.Item[]

  -- Copy the current buffer to a temporary file and load the undo history.
  -- This is done to prevent the current buffer from being modified,
  -- and is way better for performance, since LSP change tracking won't be triggered
  local tmp_file = vim.fn.stdpath("cache") .. "/snacks-undo"
  local tmp_undo = tmp_file .. ".undo"
  local tmpbuf = vim.fn.bufadd(tmp_file)
  vim.bo[tmpbuf].swapfile = false
  vim.fn.writefile(vim.api.nvim_buf_get_lines(buf, 0, -1, false), tmp_file)
  vim.fn.bufload(tmpbuf)
  vim.api.nvim_buf_call(buf, function()
    vim.cmd("silent wundo! " .. tmp_undo)
  end)
  vim.api.nvim_buf_call(tmpbuf, function()
    pcall(vim.cmd, "silent rundo " .. tmp_undo)
  end)

  ---@param item snacks.picker.finder.Item
  local function resolve(item)
    local entry = item.item ---@type vim.fn.undotree.entry
    ---@type string[], string[]
    local before, after = {}, {}

    local ei = vim.o.eventignore
    vim.o.eventignore = "all"
    vim.api.nvim_buf_call(tmpbuf, function()
      -- state after the undo
      vim.cmd("noautocmd silent undo " .. entry.seq)
      after = vim.api.nvim_buf_get_lines(tmpbuf, 0, -1, false)
      -- state before the undo
      vim.cmd("noautocmd silent undo")
      before = vim.api.nvim_buf_get_lines(tmpbuf, 0, -1, false)
    end)
    vim.o.eventignore = ei

    local diff = vim.diff(table.concat(before, "\n") .. "\n", table.concat(after, "\n") .. "\n", opts.diff) --[[@as string]]
    local changes = {} ---@type string[]
    local added_lines = {} ---@type string[]
    local removed_lines = {} ---@type string[]

    for _, line in ipairs(vim.split(diff, "\n")) do
      if line:sub(1, 1) == "+" then
        changes[#changes + 1] = line:sub(2)
        added_lines[#added_lines + 1] = line:sub(2)
      elseif line:sub(1, 1) == "-" then
        changes[#changes + 1] = line:sub(2)
        removed_lines[#removed_lines + 1] = line:sub(2)
      end
    end
    diff = Snacks.picker.util.tpl(
      "diff --git a/{file} b/{file}\n--- {file}\n+++ {file}\n{diff}",
      { file = vim.fn.fnamemodify(file, ":."), diff = diff }
    )
    item.text = table.concat(changes, " ")
    item.data = table.concat(added_lines, "\n")
    item.added_lines = table.concat(added_lines, "\n")
    item.removed_lines = table.concat(removed_lines, "\n")
    item.added = #added_lines
    item.removed = #removed_lines
    item.diff = diff
  end

  ---@param entries? vim.fn.undotree.entry[]
  ---@param parent? snacks.picker.finder.Item
  local function add(entries, parent)
    entries = entries or {}
    table.sort(entries, function(a, b)
      return a.seq < b.seq
    end)
    local last ---@type snacks.picker.finder.Item?
    for e, entry in ipairs(entries) do
      add(entry.alt, last or parent)
      local item = {
        seq = entry.seq,
        buf = buf,
        resolve = resolve,
        file = file,
        item = entry,
        current = entry.seq == tree.seq_cur,
        parent = parent,
        last = e == #entries,
        action = function()
          vim.api.nvim_buf_call(buf, function()
            vim.cmd("undo " .. entry.seq)
          end)
        end,
      }
      items[#items + 1] = item
      last = item
    end
  end
  add(tree.entries)

  -- Resolve the items in batches to prevent blocking the UI
  ---@param cb async fun(item: snacks.picker.finder.Item)
  ---@async
  return function(cb)
    for i = #items, 1, -1 do
      cb(items[i])
    end

    while #items > 0 do
      vim.schedule(function()
        local count = 0
        while #items > 0 and count < 5 do
          count = count + 1
          local item = table.remove(items, 1)
          Snacks.picker.util.resolve(item)
        end
        ctx.async:resume()
      end)
      ctx.async:suspend()
    end
    vim.schedule(function()
      vim.api.nvim_buf_delete(tmpbuf, { force = true })
    end)
  end
end

return M
