---@class snacks.dashboard
---@overload fun(opts?: snacks.dashboard.Opts): snacks.dashboard.Class
local M = setmetatable({}, {
  __call = function(M, opts)
    return M.open(opts)
  end,
})

M.meta = {
  desc = " Beautiful declarative dashboards",
  needs_setup = true,
}

local uv = vim.uv or vim.loop
math.randomseed(os.time())

---@class snacks.dashboard.Item
---@field indent? number
---@field align? "left" | "center" | "right"
---@field gap? number the number of empty lines between child items
---@field padding? number | {[1]:number, [2]:number} bottom or {bottom, top} padding
--- The action to run when the section is selected or the key is pressed.
--- * if it's a string starting with `:`, it will be run as a command
--- * if it's a string, it will be executed as a keymap
--- * if it's a function, it will be called
---@field action? snacks.dashboard.Action
---@field enabled? boolean|fun(opts:snacks.dashboard.Opts):boolean if false, the section will be disabled
---@field section? string the name of a section to include. See `Snacks.dashboard.sections`
---@field [string] any section options
---@field key? string shortcut key
---@field hidden? boolean when `true`, the item will not be shown, but the key will still be assigned
---@field autokey? boolean automatically assign a numerical key
---@field label? string
---@field desc? string
---@field file? string
---@field footer? string
---@field header? string
---@field icon? string
---@field title? string
---@field text? string|snacks.dashboard.Text[]

---@alias snacks.dashboard.Format.ctx {width?:number}
---@alias snacks.dashboard.Action string|fun(self:snacks.dashboard.Class)
---@alias snacks.dashboard.Gen fun(self:snacks.dashboard.Class):snacks.dashboard.Section?
---@alias snacks.dashboard.Section snacks.dashboard.Item|snacks.dashboard.Gen|snacks.dashboard.Section[]

---@class snacks.dashboard.Text
---@field [1] string the text
---@field hl? string the highlight group
---@field width? number the width used for alignment
---@field align? "left" | "center" | "right"

---@private
---@class snacks.dashboard.Item
---@field package _? snacks.dashboard.Item._ the position of the item in the dashboard

---@private
---@class snacks.dashboard.Item._
---@field pane number 1-indexed
---@field row number 1-indexed
---@field col number 0-indexed

---@private
---@class snacks.dashboard.Line
---@field [number] snacks.dashboard.Text
---@field width number

---@private
---@class snacks.dashboard.Block
---@field [number] snacks.dashboard.Line
---@field width number

---@class snacks.dashboard.Config
---@field enabled? boolean
---@field sections snacks.dashboard.Section
---@field formats table<string, snacks.dashboard.Text|fun(item:snacks.dashboard.Item, ctx:snacks.dashboard.Format.ctx):snacks.dashboard.Text>
local defaults = {
  width = 60,
  row = nil, -- dashboard position. nil for center
  col = nil, -- dashboard position. nil for center
  pane_gap = 4, -- empty columns between vertical panes
  autokeys = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", -- autokey sequence
  -- These settings are used by some built-in sections
  preset = {
    -- Defaults to a picker that supports `fzf-lua`, `telescope.nvim` and `mini.pick`
    ---@type fun(cmd:string, opts:table)|nil
    pick = nil,
    -- Used by the `keys` section to show keymaps.
    -- Set your custom keymaps here.
    -- When using a function, the `items` argument are the default keymaps.
    -- stylua: ignore
    ---@type snacks.dashboard.Item[]
    keys = {
      { icon = " ", key = "f", desc = "Find File", action = ":lua Snacks.dashboard.pick('files')" },
      { icon = " ", key = "n", desc = "New File", action = ":ene | startinsert" },
      { icon = " ", key = "g", desc = "Find Text", action = ":lua Snacks.dashboard.pick('live_grep')" },
      { icon = " ", key = "r", desc = "Recent Files", action = ":lua Snacks.dashboard.pick('oldfiles')" },
      { icon = " ", key = "c", desc = "Config", action = ":lua Snacks.dashboard.pick('files', {cwd = vim.fn.stdpath('config')})" },
      { icon = " ", key = "s", desc = "Restore Session", section = "session" },
      { icon = "󰒲 ", key = "L", desc = "Lazy", action = ":Lazy", enabled = package.loaded.lazy ~= nil },
      { icon = " ", key = "q", desc = "Quit", action = ":qa" },
    },
    -- Used by the `header` section
    header = [[
███╗   ██╗███████╗ ██████╗ ██╗   ██╗██╗███╗   ███╗
████╗  ██║██╔════╝██╔═══██╗██║   ██║██║████╗ ████║
██╔██╗ ██║█████╗  ██║   ██║██║   ██║██║██╔████╔██║
██║╚██╗██║██╔══╝  ██║   ██║╚██╗ ██╔╝██║██║╚██╔╝██║
██║ ╚████║███████╗╚██████╔╝ ╚████╔╝ ██║██║ ╚═╝ ██║
╚═╝  ╚═══╝╚══════╝ ╚═════╝   ╚═══╝  ╚═╝╚═╝     ╚═╝]],
  },
  -- item field formatters
  formats = {
    icon = function(item)
      if item.file and item.icon == "file" or item.icon == "directory" then
        return M.icon(item.file, item.icon)
      end
      return { item.icon, width = 2, hl = "icon" }
    end,
    footer = { "%s", align = "center" },
    header = { "%s", align = "center" },
    file = function(item, ctx)
      local fname = vim.fn.fnamemodify(item.file, ":~")
      fname = ctx.width and #fname > ctx.width and vim.fn.pathshorten(fname) or fname
      if #fname > ctx.width then
        local dir = vim.fn.fnamemodify(fname, ":h")
        local file = vim.fn.fnamemodify(fname, ":t")
        if dir and file then
          file = file:sub(-(ctx.width - #dir - 2))
          fname = dir .. "/…" .. file
        end
      end
      local dir, file = fname:match("^(.*)/(.+)$")
      return dir and { { dir .. "/", hl = "dir" }, { file, hl = "file" } } or { { fname, hl = "file" } }
    end,
  },
  sections = {
    { section = "header" },
    { section = "keys", gap = 1, padding = 1 },
    { section = "startup" },
  },
  debug = false,
}

-- The default style for the dashboard.
-- When opening the dashboard during startup, only the `bo` and `wo` options are used.
-- The other options are used with `:lua Snacks.dashboard()`
Snacks.config.style("dashboard", {
  zindex = 10,
  height = 0,
  width = 0,
  bo = {
    bufhidden = "wipe",
    buftype = "nofile",
    buflisted = false,
    filetype = "snacks_dashboard",
    swapfile = false,
    undofile = false,
  },
  wo = {
    colorcolumn = "",
    cursorcolumn = false,
    cursorline = false,
    foldmethod = "manual",
    list = false,
    number = false,
    relativenumber = false,
    sidescrolloff = 0,
    signcolumn = "no",
    spell = false,
    statuscolumn = "",
    statusline = "",
    winbar = "",
    winhighlight = "Normal:SnacksDashboardNormal,NormalFloat:SnacksDashboardNormal",
    wrap = false,
  },
})

M.ns = vim.api.nvim_create_namespace("snacks_dashboard")

local links = {
  Desc = "Special",
  File = "Special",
  Dir = "NonText",
  Footer = "Title",
  Header = "Title",
  Icon = "Special",
  Key = "Number",
  Normal = "Normal",
  Terminal = "SnacksDashboardNormal",
  Special = "Special",
  Title = "Title",
}
local hl_groups = {} ---@type table<string, string>
for group in pairs(links) do
  hl_groups[group:lower()] = "SnacksDashboard" .. group
end
Snacks.util.set_hl(links, { prefix = "SnacksDashboard", default = true })

---@class snacks.dashboard.Opts: snacks.dashboard.Config
---@field buf? number the buffer to use. If not provided, a new buffer will be created
---@field win? number the window to use. If not provided, a new floating window will be created

---@class snacks.dashboard.Class
---@field opts snacks.dashboard.Opts
---@field buf number
---@field win number
---@field _size? {width:number, height:number}
---@field items snacks.dashboard.Item[]
---@field row? number
---@field col? number
---@field panes? snacks.dashboard.Item[][]
---@field lines? string[]
---@field augroup integer
local D = {}

---@param opts? snacks.dashboard.Opts
---@return snacks.dashboard.Class
function M.open(opts)
  local self = setmetatable({}, { __index = D })
  self.opts = Snacks.config.get("dashboard", defaults, opts) --[[@as snacks.dashboard.Opts]]
  self.buf = self.opts.buf or vim.api.nvim_create_buf(false, true)
  self.buf = self.buf == 0 and vim.api.nvim_get_current_buf() or self.buf
  self.win = self.opts.win or Snacks.win({ style = "dashboard", buf = self.buf, enter = true }).win --[[@as number]]
  self.win = self.win == 0 and vim.api.nvim_get_current_win() or self.win
  self.augroup = vim.api.nvim_create_augroup("snacks_dashboard", { clear = true })
  self:init()
  self:update()
  self.fire("Opened")
  return self
end

---@param name? string
function D:trace(name)
  return self.opts.debug and Snacks.debug.trace(name and ("dashboard:" .. name) or nil)
end

function D:init()
  vim.api.nvim_win_set_buf(self.win, self.buf)
  vim.o.ei = "all"
  Snacks.util.wo(self.win, Snacks.config.styles.dashboard.wo)
  Snacks.util.bo(self.buf, Snacks.config.styles.dashboard.bo)
  vim.b[self.buf].snacks_main = true
  vim.o.ei = ""
  if self:is_float() then
    vim.keymap.set("n", "<esc>", "<cmd>bd<cr>", { silent = true, buffer = self.buf })
  end
  vim.keymap.set("n", "q", "<cmd>bd<cr>", { silent = true, buffer = self.buf })
  vim.api.nvim_create_autocmd({ "WinResized", "VimResized" }, {
    group = self.augroup,
    callback = function()
      -- only re-render if the size has changed
      if not vim.deep_equal(self._size, self:size()) then
        self:update()
      end
    end,
  })
  vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
    buffer = self.buf,
    callback = function()
      self.fire("Closed")
      vim.api.nvim_del_augroup_by_id(self.augroup)
    end,
  })
  vim.api.nvim_create_autocmd("WinEnter", {
    group = self.augroup,
    callback = function(ev)
      if ev.buf == self.buf and not vim.api.nvim_win_is_valid(self.win) then
        self.win = vim.fn.bufwinid(self.buf)
        self:update()
      end
    end,
  })
  self.on("Update", function()
    self:update()
  end, self.augroup)
end

---@return {width:number, height:number}
function D:size()
  return {
    width = vim.api.nvim_win_get_width(self.win),
    height = vim.api.nvim_win_get_height(self.win) + (vim.o.laststatus >= 2 and 1 or 0),
  }
end

function D:is_float()
  return vim.api.nvim_win_get_config(self.win).relative ~= ""
end

---@param action snacks.dashboard.Action
function D:action(action)
  -- close the window before running the action if it's floating
  if self:is_float() then
    vim.api.nvim_win_close(self.win, true)
    self.win = nil
  end
  if type(action) == "string" then
    if action:find("^:") then
      return vim.cmd(action:sub(2))
    else
      local keys = vim.api.nvim_replace_termcodes(action, true, true, true)
      return vim.api.nvim_feedkeys(keys, "tm", true)
    end
  end
  action(self)
end

---@param item snacks.dashboard.Item
---@param field string
---@param width? number
---@return snacks.dashboard.Text|snacks.dashboard.Text[]
function D:format_field(item, field, width)
  if type(item[field]) == "table" then
    return item[field]
  end
  local format = self.opts.formats[field]
  if format == nil then
    return { item[field], hl = field }
  elseif type(format) == "function" then
    return format(item, { width = width })
  else
    local text = format and vim.deepcopy(format) or { "%s" }
    text.hl = text.hl or field
    text[1] = text[1] == "%s" and item[field] or text[1]:format(item[field])
    return text
  end
end

---@param item snacks.dashboard.Text|snacks.dashboard.Line
---@param width? number
---@param align? "left"|"center"|"right"
function D:align(item, width, align)
  local len = 0
  if type(item[1]) == "string" then ---@cast item snacks.dashboard.Text
    width, align, len = width or item.width, align or item.align, vim.api.nvim_strwidth(item[1])
  else ---@cast item snacks.dashboard.Line
    if #item == 1 then -- only one text, so align that instead
      self:align(item[1], width, align)
      item.width = item[1].width
      return
    end
    len = item.width
  end

  if not width or width <= 0 or width == len then
    item.width = math.max(width or 0, len)
    return
  end

  align = align or "left"
  local before = align == "center" and math.floor((width - len) / 2) or align == "right" and width - len or 0
  local after = align == "center" and width - len - before or align == "left" and width - len or 0

  if type(item[1]) == "string" then ---@cast item snacks.dashboard.Text
    item[1] = (" "):rep(before) .. item[1] .. (" "):rep(after)
    item.width = math.max(width, len)
  else ---@cast item snacks.dashboard.Line
    if before > 0 then
      table.insert(item, 1, { (" "):rep(before) })
    end
    if after > 0 then
      table.insert(item, { (" "):rep(after) })
    end
    item.width = math.max(width, len)
  end
end

---@param texts snacks.dashboard.Text[]|snacks.dashboard.Text|string
function D:texts(texts)
  texts = type(texts) == "string" and { { texts } } or texts
  texts = type(texts[1]) == "string" and { texts } or texts
  return texts --[[ @as snacks.dashboard.Text[] ]]
end

--- Create a block from a list of texts (possibly with newlines)
---@param texts snacks.dashboard.Text[]
function D:block(texts)
  local ret = { { width = 0 }, width = 0 } ---@type snacks.dashboard.Block
  for _, text in ipairs(texts) do
    -- PERF: only split lines when needed
    local lines = text[1]:find("\n", 1, true) and vim.split(text[1], "\n", { plain = true }) or { text[1] }
    for l, line in ipairs(lines) do
      if l > 1 then
        ret[#ret + 1] = { width = 0 }
      end
      local child = setmetatable({ line }, { __index = text })
      self:align(child)
      ret[#ret].width = ret[#ret].width + vim.api.nvim_strwidth(child[1])
      ret.width = math.max(ret.width, ret[#ret].width)
      table.insert(ret[#ret], child)
    end
  end
  return ret
end

---@param item snacks.dashboard.Item
function D:format(item)
  local width = item.indent or 0

  ---@param fields string[]
  ---@param opts {align?:"left"|"center"|"right", padding?:number, flex?:boolean, multi?:boolean}
  local function find(fields, opts)
    local flex = opts.flex and math.max(0, self.opts.width - width) or nil
    local texts = {} ---@type snacks.dashboard.Text[]
    for _, k in ipairs(fields) do
      if item[k] then
        vim.list_extend(texts, self:texts(self:format_field(item, k, flex)))
        if not opts.multi then
          break
        end
      end
    end
    if #texts == 0 then
      return { width = 0 }
    end
    local block = self:block(texts)
    block.width = block.width + (opts.padding or 0)
    width = width + block.width
    return block
  end

  local block = item.text and self:block(self:texts(item.text))
  local left = block and { width = 0 } or find({ "icon" }, { align = "left", padding = 1 })
  local right = block and { width = 0 } or find({ "label", "key" }, { align = "right", padding = 1 })
  local center = block or find({ "header", "footer", "title", "desc", "file" }, { flex = true, multi = true })

  local padding = self:padding(item)
  local ret = { width = self.opts.width } ---@type snacks.dashboard.Block
  for l = 1, math.max(#left, #center, #right, 1) + padding[1] do
    ret[l] = { width = 0 }
    left[l] = left[l] or { width = 0 }
    right[l] = right[l] or { width = 0 }
    center[l] = center[l] or { width = 0 }
    self:align(left[l], left.width, "left")
    if item.indent then
      self:align(left[l], left[l].width + item.indent, "right")
    end
    self:align(right[l], right.width, "right")
    self:align(center[l], self.opts.width - left[l].width - right[l].width, item.align)
    vim.list_extend(ret[l], left[l])
    vim.list_extend(ret[l], center[l])
    vim.list_extend(ret[l], right[l])
    ret[l].width = left[l].width + center[l].width + right[l].width
  end
  for _ = 1, padding[2] do
    table.insert(ret, 1, { width = self.opts.width })
  end
  return ret
end

---@param item snacks.dashboard.Item
function D:enabled(item)
  local e = item.enabled
  if type(e) == "function" then
    return e(self.opts)
  end
  return e == nil or e
end

---@param item snacks.dashboard.Section?
---@param results? snacks.dashboard.Item[]
---@param parent? snacks.dashboard.Item
function D:resolve(item, results, parent)
  results = results or {}
  if not item then
    return results
  end
  if type(item) == "table" and vim.tbl_isempty(item) then
    return results
  end
  if type(item) == "table" and parent then -- inherit parent properties
    for _, prop in ipairs({ "indent", "align", "pane" }) do
      item[prop] = item[prop] or parent[prop]
    end
  end

  if type(item) == "function" then
    return self:resolve(item(self), results, parent)
  elseif type(item) == "table" and self:enabled(item) then
    if not item.section and not item[1] then
      table.insert(results, item)
      return results
    end
    local first_child = #results + 1
    if item.section then -- add section items
      self:trace("resolve." .. item.section)
      local items = M.sections[item.section](item) ---@type snacks.dashboard.Section?
      self:resolve(items, results, item)
      self:trace()
    end
    if item[1] then -- add child items
      for _, child in ipairs(item) do
        self:resolve(child, results, item)
      end
    end

    -- add the title if there are child items
    if #results >= first_child and item.title then
      table.insert(results, first_child, {
        title = item.title,
        icon = item.icon,
        pane = item.pane,
        action = item.action,
        key = item.key,
        label = item.label,
      })
      item.action = nil
      item.label = nil
      item.key = nil
      first_child = first_child + 1
    end

    -- correct first/last taking hidden items into account
    local first, last = first_child, #results
    for c = first_child, #results do
      first = first or not results[c].hidden and c or nil
      last = not results[c].hidden and c or last
    end

    if item.gap then -- add padding between child items
      for i = first, last - 1 do
        results[i].padding = item.gap
      end
    end
    if item.padding then -- add padding to the first and last child items
      local padding = self:padding(item)
      if padding[2] > 0 and results[first] then
        results[first].padding = { 0, padding[2] }
      end
      if padding[1] > 0 and results[last] then
        results[last].padding = { padding[1], 0 }
      end
    end
  elseif type(item) ~= "table" then
    Snacks.notify.error("Invalid item:\n```lua\n" .. vim.inspect(item) .. "\n```", { title = "Dashboard" })
  end
  return results
end

---@return {[1]: number, [2]: number}
function D:padding(item)
  return item.padding and (type(item.padding) == "table" and item.padding or { item.padding, 0 }) or { 0, 0 }
end

function D.fire(event)
  vim.api.nvim_exec_autocmds("User", { pattern = "SnacksDashboard" .. event, modeline = false })
end

---@param event string|string[]
---@param cb fun()
---@param group? string|integer
function D.on(event, cb, group)
  vim.api.nvim_create_autocmd("User", { pattern = "SnacksDashboard" .. event, callback = cb, group = group })
end

---@param pos {[1]:number, [2]:number}
---@param from? {[1]:number, [2]:number}
function D:find(pos, from)
  from = from or pos
  local line = self.lines[pos[1]]
  local char = vim.fn.charidx(line, pos[2]) -- map col to charachter index

  local pane = math.floor((char - self.col) / (self.opts.width + self.opts.pane_gap)) + 1
  pane = math.max(1, math.min(pane, #self.panes))
  if pos[1] == from[1] then
    if pos[2] == from[2] - 1 then
      pane = pane - 1
    elseif pos[2] == from[2] + 1 then
      pane = pane + 1
    end
  end
  pane = math.max(1, math.min(pane, #self.panes))

  local ret ---@type snacks.dashboard.Item?
  for _, item in ipairs(self.items) do
    if item._ and item._.pane == pane and item.action then
      if ret and pos[1] < from[1] and item._.row > pos[1] then
        break
      end
      ret = item
      if pos[1] >= from[1] and item._.row >= pos[1] then
        break
      end
    end
  end
  return ret
end

-- Layout in panes
function D:layout()
  local max_panes =
    math.max(1, math.floor((self._size.width + self.opts.pane_gap) / (self.opts.width + self.opts.pane_gap)))
  self.panes = {} ---@type snacks.dashboard.Item[][]
  for _, item in ipairs(self.items) do
    if not item.hidden then
      local pane = item.pane or 1
      pane = math.fmod(pane - 1, max_panes) + 1 -- distribute panes evenly
      self.panes[pane] = self.panes[pane] or {}
      table.insert(self.panes[pane], item)
    end
  end
  for p = 1, math.max(unpack(vim.tbl_keys(self.panes))) or 1 do
    self.panes[p] = self.panes[p] or {}
  end
end

-- Format and render the dashboard
function D:render()
  -- horizontal position
  self.col = self.opts.col
    or math.floor(self._size.width - (self.opts.width * #self.panes + self.opts.pane_gap * (#self.panes - 1))) / 2

  self.lines = {} ---@type string[]
  local extmarks = {} ---@type {row:number, col:number, opts:vim.api.keyset.set_extmark}[]
  for p, pane in ipairs(self.panes) do
    local indent = (" "):rep(p == 1 and self.col or self.opts.pane_gap)
    local row = 0
    for _, item in ipairs(pane or {}) do
      for l, line in ipairs(self:format(item)) do
        row = row + 1
        if p > 1 and not self.lines[row] then -- add lines for empty panes
          self.lines[row] = (" "):rep(self.col + (self.opts.width + self.opts.pane_gap) * (p - 1))
        elseif p == 1 and line.width > self.opts.width then
          self.lines[row] = (" "):rep(self.col - math.floor((line.width - self.opts.width) / 2))
        else
          self.lines[row] = (self.lines[row] or "") .. indent
        end
        if l == 1 then
          item._ = { pane = p, row = row, col = #self.lines[row] - 1 }
        end
        ---@cast line snacks.dashboard.Line
        for _, text in ipairs(line) do
          self.lines[row] = self.lines[row] .. text[1]
          if text.hl then
            table.insert(extmarks, {
              row = row - 1,
              col = #self.lines[row] - #text[1],
              opts = { hl_group = hl_groups[text.hl] or text.hl, end_col = #self.lines[row] },
            })
          end
        end
      end
    end
  end

  -- vertical position
  self.row = self.opts.row or math.max(math.floor((self._size.height - #self.lines) / 2), 0)
  for _ = 1, self.row do
    table.insert(self.lines, 1, "")
  end

  -- fix item positions
  for _, item in ipairs(self.items) do
    if item._ then
      item._.row = item._.row + self.row
      if item.render then
        item.render(self, { item._.row, item._.col })
      end
    end
  end

  self:render_buf(extmarks)
end

---@param extmarks {row:number, col:number, opts:vim.api.keyset.set_extmark}[]
function D:render_buf(extmarks)
  -- set lines
  vim.bo[self.buf].modifiable = true
  vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, self.lines)
  vim.bo[self.buf].modifiable = false

  -- extmarks
  vim.api.nvim_buf_clear_namespace(self.buf, M.ns, 0, -1)
  for _, extmark in ipairs(extmarks) do
    vim.api.nvim_buf_set_extmark(self.buf, M.ns, extmark.row + self.row, extmark.col, extmark.opts)
  end
end

function D:keys()
  local autokeys = self.opts.autokeys:gsub("[hjklq]", "")
  for _, item in ipairs(self.items) do
    if item.key and not item.autokey then
      autokeys = autokeys:gsub(vim.pesc(item.key), "", 1)
    end
  end
  for _, item in ipairs(self.items) do
    if item.autokey then
      item.key, autokeys = autokeys:sub(1, 1), autokeys:sub(2)
    end
    if item.key then
      vim.keymap.set("n", item.key, function()
        self:action(item.action)
      end, { buffer = self.buf, nowait = not item.autokey, desc = "Dashboard action" })
    end
  end
end

function D:update()
  if not (self.buf and vim.api.nvim_buf_is_valid(self.buf)) then
    return
  end
  self.fire("UpdatePre")
  self._size = self:size()

  self.items = self:resolve(self.opts.sections)

  self:layout()
  self:keys()
  self:render()

  -- actions on enter
  vim.keymap.set("n", "<cr>", function()
    local item = self:find(vim.api.nvim_win_get_cursor(self.win))
    return item and item.action and self:action(item.action)
  end, { buffer = self.buf, nowait = true, desc = "Dashboard action" })

  -- cursor movement
  local last = { 1, 0 }
  vim.api.nvim_create_autocmd("CursorMoved", {
    group = vim.api.nvim_create_augroup("snacks_dashboard_cursor", { clear = true }),
    buffer = self.buf,
    callback = function()
      local item = self:find(vim.api.nvim_win_get_cursor(self.win), last)
      -- can happen for panes without actionable items
      item = item or vim.tbl_filter(function(it)
        return it.action and it._
      end, self.items)[1]
      if item then
        local col = self.lines[item._.row]:find("[%w%d%p]", item._.col + 1)
        col = col or (item._.col + 1 + (item.indent and (item.indent + 1) or 0))
        last = { item._.row, (col or item._.col + 1) - 1 }
      end
      vim.api.nvim_win_set_cursor(self.win, last)
    end,
  })
  self.fire("UpdatePost")
end

-- Get an icon
---@param name string
---@param cat? string
---@return snacks.dashboard.Text
function M.icon(name, cat)
  local icon, hl = Snacks.util.icon(name, cat)
  return { icon or " ", hl = hl or "icon", width = 2 }
end

-- Used by the default preset to pick something
---@param cmd? string
function M.pick(cmd, opts)
  cmd = cmd or "files"
  local config = Snacks.config.get("dashboard", defaults, opts)
  local picker = Snacks.picker.config.get()
  -- stylua: ignore
  local try = {
    function() return config.preset.pick(cmd, opts) end,
    function() return require("fzf-lua")[cmd](opts) end,
    function() return require("telescope.builtin")[cmd == "files" and "find_files" or cmd](opts) end,
    function() return require("mini.pick").builtin[cmd](opts) end,
    function() return Snacks.picker(cmd, opts) end,
  }
  if picker.enabled then
    table.insert(try, 2, table.remove(try, #try))
  end
  for _, fn in ipairs(try) do
    if pcall(fn) then
      return
    end
  end
  Snacks.notify.error("No picker found for " .. cmd)
end

-- Checks if the plugin is installed.
-- Only works with [lazy.nvim](https://github.com/folke/lazy.nvim)
---@param name string
function M.have_plugin(name)
  return package.loaded.lazy and require("lazy.core.config").spec.plugins[name] ~= nil
end

---@param opts? {filter?: table<string, boolean>}
---@return fun():string?
function M.oldfiles(opts)
  opts = vim.tbl_deep_extend("force", {
    filter = {
      [vim.fn.stdpath("data")] = false,
      [vim.fn.stdpath("cache")] = false,
      [vim.fn.stdpath("state")] = false,
    },
  }, opts or {})
  ---@cast opts {filter:table<string, boolean>}

  local filter = {} ---@type {path:string, want:boolean}[]
  for path, want in pairs(opts.filter or {}) do
    table.insert(filter, { path = svim.fs.normalize(path), want = want })
  end
  local done = {} ---@type table<string, boolean>
  local i = 1
  local oldfiles = vim.v.oldfiles
  return function()
    while oldfiles[i] do
      local file = svim.fs.normalize(oldfiles[i], { _fast = true, expand_env = false })
      local want = not done[file]
      if want then
        done[file] = true
        for _, f in ipairs(filter) do
          if (file:sub(1, #f.path) == f.path) ~= f.want then
            want = false
            break
          end
        end
      end
      i = i + 1
      if want and uv.fs_stat(file) then
        return file
      end
    end
  end
end

M.sections = {}

-- Adds a section to restore the session if any of the supported plugins are installed.
---@param item? snacks.dashboard.Item
---@return snacks.dashboard.Item?
function M.sections.session(item)
  local plugins = {
    { "persistence.nvim", ":lua require('persistence').load()" },
    { "persisted.nvim", ":lua require('persisted').load()" },
    { "neovim-session-manager", ":SessionManager load_current_dir_session" },
    { "possession.nvim", ":PossessionLoadCwd" },
    { "mini.sessions", ":lua require('mini.sessions').read()" },
    { "mini.nvim", ":lua require('mini.sessions').read()" },
  }
  for _, plugin in pairs(plugins) do
    if M.have_plugin(plugin[1]) then
      return setmetatable({ -- add the action and disable the section
        action = plugin[2],
        section = false,
      }, { __index = item })
    end
  end
end

--- Get the most recent files, optionally filtered by the
--- current working directory or a custom directory.
---@param opts? {limit?:number, cwd?:string|boolean, filter?:fun(file:string):boolean?}
---@return snacks.dashboard.Gen
function M.sections.recent_files(opts)
  return function()
    opts = opts or {}
    local limit = opts.limit or 5
    local root = opts.cwd and svim.fs.normalize(opts.cwd == true and vim.fn.getcwd() or opts.cwd) or ""
    local ret = {} ---@type snacks.dashboard.Section
    for file in M.oldfiles({ filter = { [root] = true } }) do
      if not opts.filter or opts.filter(file) then
        ret[#ret + 1] = {
          file = file,
          icon = "file",
          action = ":e " .. vim.fn.fnameescape(file),
          autokey = true,
        }
        if #ret >= limit then
          break
        end
      end
    end
    return ret
  end
end

--- Get the most recent projects based on git roots of recent files.
--- The default action will change the directory to the project root,
--- try to restore the session and open the picker if the session is not restored.
--- You can customize the behavior by providing a custom action.
--- Use `opts.dirs` to provide a list of directories to use instead of the git roots.
---@param opts? {limit?:number, dirs?:(string[]|fun():string[]), pick?:boolean, session?:boolean, action?:fun(dir)}
function M.sections.projects(opts)
  opts = vim.tbl_extend("force", { pick = true, session = true }, opts or {})
  local limit = opts.limit or 5
  local dirs = opts.dirs or {}
  dirs = type(dirs) == "function" and dirs() or dirs --[[ @as string[] ]]
  dirs = vim.list_slice(dirs, 1, limit)

  if not opts.dirs then
    for file in M.oldfiles() do
      local dir = Snacks.git.get_root(file)
      if dir and not vim.tbl_contains(dirs, dir) then
        table.insert(dirs, dir)
        if #dirs >= limit then
          break
        end
      end
    end
  end

  local ret = {} ---@type snacks.dashboard.Item[]
  for _, dir in ipairs(dirs) do
    ret[#ret + 1] = {
      file = dir,
      icon = "directory",
      action = function(self)
        if opts.action then
          return opts.action(dir)
        end
        vim.fn.chdir(dir)
        local session = M.sections.session()
        -- stylua: ignore
        if opts.session and session then
          local session_loaded = false
          vim.api.nvim_create_autocmd("SessionLoadPost", { once = true, callback = function() session_loaded = true end })
          vim.defer_fn(function() if not session_loaded and opts.pick then M.pick() end end, 100)
          self:action(session.action)
        elseif opts.pick then
          M.pick()
        end
      end,
      autokey = true,
    }
  end
  return ret
end

---@return snacks.dashboard.Gen
function M.sections.header()
  return function(self)
    return { header = self.opts.preset.header, padding = 2 }
  end
end

---@return snacks.dashboard.Gen
function M.sections.keys()
  return function(self)
    return vim.deepcopy(self.opts.preset.keys)
  end
end

---@param opts {cmd:string|string[], ttl?:number, height?:number, width?:number, random?:number}|snacks.dashboard.Item
---@return snacks.dashboard.Gen
function M.sections.terminal(opts)
  return function(self)
    local cmd = opts.cmd or 'echo "No `cmd` provided"'
    local ttl = opts.ttl or 3600
    local height = opts.height or 10
    local width = opts.width
    if not width then
      width = self.opts.width - (opts.indent or 0)
    end

    local cache_parts = {
      table.concat(type(cmd) == "table" and cmd or { cmd }, " "),
      uv.cwd(),
      opts.random and math.random(1, opts.random) or "",
    }
    local hashed_cache_key = vim.fn.sha256(table.concat(cache_parts, "."))

    local cache_dir = vim.fn.stdpath("cache") .. "/snacks"
    local cache_file = cache_dir .. "/" .. hashed_cache_key .. ".txt"
    local stat = uv.fs_stat(cache_file)
    local buf = vim.api.nvim_create_buf(false, true)
    local chan = vim.api.nvim_open_term(buf, {})

    local function send(data, refresh)
      vim.api.nvim_chan_send(chan, data)
      if refresh then
        -- HACK: this forces a refresh of the terminal buffer and prevents flickering
        vim.bo[buf].scrollback = 9999
        vim.bo[buf].scrollback = 9998
      end
    end

    local jid, stopped ---@type number?, boolean?
    local has_cache = stat and stat.type == "file" and stat.size > 0
    local is_expired = has_cache and stat and os.time() - stat.mtime.sec >= ttl
    if has_cache and stat then
      local fin = assert(uv.fs_open(cache_file, "r", 438))
      send(uv.fs_read(fin, stat.size, 0) or "", true)
      uv.fs_close(fin)
    end
    if not has_cache or is_expired then
      local output, recording = {}, assert(uv.new_timer())
      -- record output for max 5 seconds. otherwise assume its streaming
      recording:start(5000, 0, function()
        output = {}
      end)
      local first = true
      jid = vim.fn.jobstart(cmd, {
        height = height,
        width = width,
        pty = true,
        on_stdout = function(_, data)
          data = table.concat(data, "\n")

          local termenv = {
            ["\27%]11;%?\27\\"] = function() -- OSC 11
              local rgb = (vim.o.background == "light") and "ffff/ffff/ffff" or "0000/0000/0000"
              return "\x1b]11;rgb:" .. rgb .. "\x1b\\"
            end,
            ["\27%[6n"] = function() -- CSI 6 n
              return "\x1b[1;" .. tostring(width) .. "R"
            end,
          }
          for seq, repl in pairs(termenv) do
            if data:find(seq) then
              pcall(vim.fn.chansend, jid, repl())
              data = data:gsub(seq, "")
            end
          end
          if data == "" then
            return
          end

          if recording:is_active() then
            table.insert(output, data)
          end
          if first and has_cache then -- clear the screen if cache was expired
            first = false
            data = "\27[2J\27[H" .. data -- clear screen
          end
          pcall(send, data)
        end,
        on_exit = function(_, code)
          if not recording:is_active() or stopped then
            return
          end
          if code ~= 0 then
            Snacks.notify.error(
              ("Terminal **cmd** `%s` failed with code `%d`:\n- `vim.o.shell = %q`\n\nOutput:\n%s"):format(
                cmd,
                code,
                vim.o.shell,
                vim.trim(table.concat(output, ""))
              )
            )
          elseif ttl > 0 then -- save the output
            vim.fn.mkdir(cache_dir, "p")
            local fout = assert(uv.fs_open(cache_file, "w", 438))
            uv.fs_write(fout, table.concat(output, ""))
            uv.fs_close(fout)
          end
        end,
      })
      if jid <= 0 then
        Snacks.notify.error(("Failed to start terminal **cmd** `%s`"):format(cmd))
      end
    end
    return {
      action = not opts.title and opts.action or nil,
      key = not opts.title and opts.key or nil,
      label = not opts.title and opts.label or nil,
      render = function(_, pos)
        self:trace("terminal.render")
        local win = vim.api.nvim_open_win(buf, false, {
          bufpos = { pos[1] - 1, pos[2] + 1 },
          col = opts.indent or 0,
          focusable = false,
          height = height,
          noautocmd = true,
          relative = "win",
          row = 0,
          zindex = Snacks.config.styles.dashboard.zindex + 1,
          style = "minimal",
          width = width,
          win = self.win,
        })
        local hl = opts.hl and hl_groups[opts.hl] or opts.hl or "SnacksDashboardTerminal"
        Snacks.util.wo(win, { winhighlight = "TermCursorNC:" .. hl .. ",NormalFloat:" .. hl })
        Snacks.util.bo(buf, { filetype = Snacks.config.styles.dashboard.bo.filetype })
        local close = vim.schedule_wrap(function()
          stopped = true
          pcall(vim.api.nvim_win_close, win, true)
          pcall(vim.api.nvim_buf_delete, buf, { force = true })
          pcall(vim.fn.jobstop, jid)
          return true
        end)
        self.on("UpdatePre", close, self.augroup)
        self.on("Closed", close, self.augroup)
        self:trace()
      end,
      text = ("\n"):rep(height - 1),
    }
  end
end

--- Add the startup section
---@param opts? {icon?:string}
---@return snacks.dashboard.Section?
function M.sections.startup(opts)
  opts = opts or {}
  M.lazy_stats = M.lazy_stats and M.lazy_stats.startuptime > 0 and M.lazy_stats or require("lazy.stats").stats()
  local ms = (math.floor(M.lazy_stats.startuptime * 100 + 0.5) / 100)
  local icon = opts.icon or "⚡ "
  return {
    align = "center",
    text = {
      { icon .. "Neovim loaded ", hl = "footer" },
      { M.lazy_stats.loaded .. "/" .. M.lazy_stats.count, hl = "special" },
      { " plugins in ", hl = "footer" },
      { ms .. "ms", hl = "special" },
    },
  }
end

M.status = {
  did_setup = false,
  opened = false,
  reason = nil, ---@type string?
}

--- Check if the dashboard should be opened
function M.setup()
  local explorer = Snacks.config.get("explorer", defaults).enabled == true

  M.status.did_setup = true
  local buf = 1

  local skip = false
  if explorer and vim.fn.argc(-1) == 1 then
    local arg = vim.fn.argv(0) --[[@as string]]
    if arg ~= "" and vim.fn.isdirectory(arg) == 1 then
      skip = true
    end
  end

  -- don't open the dashboard if there are any arguments
  if not skip and vim.fn.argc(-1) > 0 then
    M.status.reason = "argc(-1) > 0"
    return
  end

  -- don't open dashboard if Neovim was invoked for example `nvim +'Octo issue edit 1'`
  if not skip and vim.api.nvim_buf_get_name(0) ~= "" then
    M.status.reason = "buffer has a name"
    return
  end

  -- there should be only one non-floating window and it should be the first buffer
  local wins = vim.tbl_filter(function(win)
    local b = vim.api.nvim_win_get_buf(win)
    return vim.api.nvim_win_get_config(win).relative == "" and not vim.bo[b].filetype:find("snacks")
  end, vim.api.nvim_list_wins())
  if #wins ~= 1 then
    M.status.reason = "more than one non-floating window"
    return
  elseif vim.api.nvim_win_get_buf(wins[1]) ~= buf then
    M.status.reason = "window does not contain the first buffer"
    return
  end

  if vim.bo[buf].modified then
    M.status.reason = "buffer is modified"
    return
  end

  local uis = vim.api.nvim_list_uis()

  -- check for headless
  if #uis == 0 then
    M.status.reason = "headless"
    return
  end

  -- don't open the dashboard if in TUI and input is piped
  if uis[1].stdout_tty and not uis[1].stdin_tty then
    M.status.reason = "stdin is not a tty"
    return
  end

  -- don't open the dashboard if there is any text in the buffer
  if vim.api.nvim_buf_line_count(buf) > 1 or #(vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or "") > 0 then
    M.status.reason = "buffer is not empty"
    return
  end
  M.status.opened = true

  if Snacks.config.dashboard.debug then
    Snacks.debug.tracemod("dashboard", M)
    Snacks.debug.tracemod("dashboard", D, ":")
  end

  local options = { showtabline = vim.o.showtabline, laststatus = vim.o.laststatus }
  vim.o.showtabline, vim.o.laststatus = 0, 0
  local dashboard = M.open({ buf = buf, win = wins[1] })
  D.on("Closed", function()
    for k, v in pairs(options) do
      if vim.o[k] == 0 and v ~= 0 then
        vim.o[k] = v
      end
    end
  end, dashboard.augroup)

  if Snacks.config.dashboard.debug then
    Snacks.debug.stats({ min = 0.2 })
  end
end

-- Update the dashboard
function M.update()
  D.fire("Update")
end

function M.health()
  if Snacks.config.dashboard.enabled then
    if M.status.did_setup then
      Snacks.health.ok("setup ran")
      if M.status.opened then
        Snacks.health.ok("dashboard opened")
      else
        Snacks.health.warn("dashboard did not open: `" .. M.status.reason .. "`")
      end
    else
      Snacks.health.error("setup did not run")
    end
    local modnames = { "alpha", "dashboard", "mini.starter" }
    for _, modname in ipairs(modnames) do
      if package.loaded[modname] then
        Snacks.health.error("`" .. modname .. "` conflicts with `Snacks.dashboard`")
      end
    end
  end
end

M.Dashboard = D

return M
