local api = vim.api

---@alias linenr integer
---@alias bufnr integer

---@class Buffer
---@field alternate  boolean  Previous file, indicated by '#' in :ls
---@field filetype  "file" | "directory"
---@field number  integer  bufnr
---@field path  string  Absolute path to file
---@field visible  boolean  Attached to a window?
---@field windows  integer[]  Windows containing this buffer

local state = {
   ---@type integer?
   buf = nil,

   ---@type integer?
   win = nil,

   -- For sorting purposes.
   ---@type integer
   lastused = nil,

   -- Window number (winnr) of the window prior to launching 'buffy'. Necessary
   -- to open a selected buffer in the correct window.
   ---@type integer?
   parent = nil,

   -- Mapping from displayed line number -> Buffer obj.
   ---@type table<linenr, Buffer>
   map = {},

   -- Mapping from buffer number (bufnr) -> Buffer obj.
   ---@type table<bufnr, Buffer>
   buffers = {},
}


---@retrun integer bufnr
local function buf_create()
   local opts = {
      bufhidden  = "wipe",
      buflisted  = false,
      buftype    = "nofile",
      filetype   = "buffy",
      modifiable = false,
      swapfile   = false,
      undolevels = -1,
   }

   local buf = api.nvim_create_buf(false, true)
   for key, value in pairs(opts) do
      api.nvim_set_option_value(key, value, { buf=buf })
   end

   return buf
end


---@param buf  integer
---@param buffers  Buffer[]
---@return integer winnr
local function win_create(buf, buffers)
   local width = 40
   local padding = (
        3     -- number place values
      + 6     -- whitespace padding
      + 2     -- sign column
   )

   for _, buffer in ipairs(buffers) do
      local test = #buffer.path + padding
      if test > width then width = test end
   end

   local height = #buffers
   if height == 0 then height = 1 end

   local win = api.nvim_open_win(buf, true, {
      style = "minimal",
      relative = "editor",
      row = math.floor(vim.o.lines / 3),
      col = math.floor(vim.o.columns / 2) - math.floor(width / 2),
      anchor = "NW",
      width = width,
      height = height,
      border = "single",
   })

   -- Floating windows are different. Need to set window options on the winnr,
   -- can't just use an ftplugin or something. Took me altogether too long to
   -- figure out.
   api.nvim_set_option_value("cursorline", true, { win=win })
   api.nvim_set_option_value("cursorlineopt", "both", { win=win })

   return win
end


---@param path  string
---@return string?
local function filetype(path)
   ---@diagnostic disable-next-line: undefined-field
   local stat = vim.uv.fs_stat(path)
   return stat and stat.type
end


---@return Buffer[]
local function get_buffers()
   state.buffers = {}
   state.map = {}

   local buffers = vim.iter(vim.fn.getbufinfo({ buflisted=1 }))
      :filter(function(buffer)
         return (buffer.name ~= "") and (buffer.listed == 1)
      end)
      :map(function(buffer)
         return {
            alternate = vim.fn.bufnr('#') == buffer.bufnr,
            filetype  = filetype(buffer.name),
            lastused  = buffer.lastused,
            number    = buffer.bufnr,
            path      = buffer.name:gsub(assert(os.getenv("HOME")), "~", 1),
            visible   = #buffer.windows > 0,
            windows   = buffer.windows,
         }
      end):totable()

   table.sort(buffers, function(m, n)
      return m.lastused > n.lastused
   end)

   return buffers
end


local function sign_column()
   local ns = api.nvim_create_namespace("buffy")

   for row, buffer in pairs(state.map) do
      local sign
      if buffer.visible then
         sign = "●"
      elseif buffer.alternate then
         sign = "○"
      else
         sign = ""
      end

      vim.api.nvim_buf_set_extmark(state.buf, ns, row-1, 4, {
         strict = false,
         sign_text = sign
      })
   end
end


---@param buffers  Buffer[]
---@return string[]
local function format_lines(buffers)
   state.map = {}

   local lines = {}
   for _, buffer in ipairs(buffers) do
      table.insert(lines, ("  %03d  %s%s"):format(
         buffer.number,
         buffer.path,
         (buffer.filetype == "directory") and "/" or ""
      ))
      state.map[#lines] = buffer
   end

   return lines
end


---@param lines  string[]
---@return nil
local function redraw(lines)
   api.nvim_set_option_value("modifiable", true, { buf=state.buf })
   api.nvim_buf_set_lines(state.buf, 0, -1, false, lines)
   api.nvim_win_set_height(state.win, #lines)
   api.nvim_set_option_value("modifiable", false, { buf=state.buf })
   sign_column()
end


-- Useful to show all buffers after filtering.
local function reload()
   local buffers = get_buffers()
   state.buffers = buffers
   redraw(format_lines(buffers))
end


local function filter()
   vim.ui.input({
      prompt = "filter> "
   }, function(input)
      if not input then return end

      ---@type Buffer[]
      local matches = vim.fn.matchfuzzy(state.buffers, input, {
         ---@param buffer Buffer
         text_cb = function(buffer) return buffer.path end
      })

      redraw(format_lines(matches))
   end)
end


---@return nil
local function open()
   if state.win and api.nvim_win_is_valid(state.win) then
      return
   end

   -- Save before opening. Allows for inserting new buffer into the correct
   -- window.
   state.parent = api.nvim_get_current_win()

   local buffers = get_buffers()
   local buf     = buf_create()
   local win     = win_create(buf, buffers)

   state.buf     = buf
   state.win     = win
   state.buffers = buffers

   redraw(format_lines(buffers))
end


local function close()
   if state.win and api.nvim_win_is_valid(state.win) then
      api.nvim_win_close(state.win, true)
      state.win = nil
   end
end


local function select()
   local cursor = api.nvim_win_get_cursor(state.win)
   local buffer = state.map[cursor[1]]
   api.nvim_win_set_buf(state.parent, buffer.number)
   close()
end


local function delete()
   local cursor = api.nvim_win_get_cursor(state.win)
   local row    = cursor[1]
   local buffer = state.map[row]
   if not buffer then return end

   if api.nvim_get_option_value("modified", { buf=buffer.number }) then
      vim.notify("No write since last change", vim.log.levels.ERROR)
      return
   end

   -- When deleting a buffer, the window layout may change. This is annoying. A
   -- "safe" bufnr is any that's not about to be deleted. Pre-emptively swapping
   -- to the safe buffer preserves window layout.
   local safe_buffer = vim.iter(api.nvim_list_bufs())
      :filter(function(b) return b ~= buffer.number end):next()

   for _, winnr in pairs(buffer.windows) do
      api.nvim_win_set_buf(winnr, safe_buffer)
   end

   if pcall(api.nvim_buf_delete, buffer.number, { force = false }) then
      table.remove(state.map, row)
      table.remove(state.buffers, buffer.number)

      local buffers = get_buffers()
      state.buffers = buffers

      redraw(format_lines(buffers))
   end
end


return {
   close  = close,
   delete = delete,
   filter = filter,
   open   = open,
   reload = reload,
   select = select,
}