---@class snacks.notifier
---@overload fun(msg: string, level?: snacks.notifier.level|number, opts?: snacks.notifier.Notif.opts): number|string
local M = setmetatable({}, {
  __call = function(t, ...)
    return t.notify(...)
  end,
})

M.meta = {
  desc = "Pretty `vim.notify`",
  needs_setup = true,
}

local uv = vim.uv or vim.loop

--- Render styles:
--- * compact: use border for icon and title
--- * minimal: no border, only icon and message
--- * fancy: similar to the default nvim-notify style
---@alias snacks.notifier.style snacks.notifier.render|"compact"|"fancy"|"minimal"

--- ### Notifications
---
--- Notification options
---@class snacks.notifier.Notif.opts
---@field id? number|string
---@field msg? string
---@field level? number|snacks.notifier.level
---@field title? string
---@field icon? string
---@field timeout? number|boolean timeout in ms. Set to 0|false to keep until manually closed
---@field ft? string
---@field keep? fun(notif: snacks.notifier.Notif): boolean
---@field style? snacks.notifier.style
---@field opts? fun(notif: snacks.notifier.Notif) -- dynamic opts
---@field hl? snacks.notifier.hl -- highlight overrides
---@field history? boolean

--- Notification object
---@class snacks.notifier.Notif: snacks.notifier.Notif.opts
---@field id number|string
---@field msg string
---@field win? snacks.win
---@field icon string
---@field level snacks.notifier.level
---@field timeout number
---@field dirty? boolean
---@field added number timestamp with nano precision
---@field updated number timestamp with nano precision
---@field shown? number timestamp with nano precision
---@field hidden? number timestamp with nano precision
---@field layout? { top?: number, width: number, height: number }

--- ### Rendering
---@alias snacks.notifier.render fun(buf: number, notif: snacks.notifier.Notif, ctx: snacks.notifier.ctx)

---@class snacks.notifier.hl
---@field title string
---@field icon string
---@field border string
---@field footer string
---@field msg string

---@class snacks.notifier.ctx
---@field opts snacks.win.Config
---@field notifier snacks.notifier.Class
---@field hl snacks.notifier.hl
---@field ns number

--- ### History
---@class snacks.notifier.history
---@field filter? snacks.notifier.level|fun(notif: snacks.notifier.Notif): boolean
---@field sort? string[] # sort fields, default: {"added"}
---@field reverse? boolean

---@type snacks.notifier.history
local history_opts = {
  sort = { "added" },
}

Snacks.config.style("notification", {
  border = "rounded",
  zindex = 100,
  ft = "markdown",
  wo = {
    winblend = 5,
    wrap = false,
    conceallevel = 2,
    colorcolumn = "",
  },
  bo = { filetype = "snacks_notif" },
})

Snacks.config.style("notification_history", {
  border = "rounded",
  zindex = 100,
  width = 0.6,
  height = 0.6,
  minimal = false,
  title = " Notification History ",
  title_pos = "center",
  ft = "markdown",
  bo = { filetype = "snacks_notif_history", modifiable = false },
  wo = { winhighlight = "Normal:SnacksNotifierHistory" },
  keys = { q = "close" },
})

---@class snacks.notifier.Config
---@field enabled? boolean
---@field keep? fun(notif: snacks.notifier.Notif): boolean # global keep function
---@field filter? fun(notif: snacks.notifier.Notif): boolean # filter our unwanted notifications (return false to hide)
local defaults = {
  timeout = 3000, -- default timeout in ms
  width = { min = 40, max = 0.4 },
  height = { min = 1, max = 0.6 },
  -- editor margin to keep free. tabline and statusline are taken into account automatically
  margin = { top = 0, right = 1, bottom = 0 },
  padding = true, -- add 1 cell of left/right padding to the notification window
  sort = { "level", "added" }, -- sort by level and time
  -- minimum log level to display. TRACE is the lowest
  -- all notifications are stored in history
  level = vim.log.levels.TRACE,
  icons = {
    error = " ",
    warn = " ",
    info = " ",
    debug = " ",
    trace = " ",
  },
  keep = function(notif)
    return vim.fn.getcmdpos() > 0
  end,
  ---@type snacks.notifier.style
  style = "compact",
  top_down = true, -- place notifications from top to bottom
  date_format = "%R", -- time format for notifications
  -- format for footer when more lines are available
  -- `%d` is replaced with the number of lines.
  -- only works for styles with a border
  ---@type string|boolean
  more_format = " ↓ %d lines ",
  refresh = 50, -- refresh at most every 50ms
}

---@class snacks.notifier.Class
---@field queue table<string|number, snacks.notifier.Notif>
---@field history table<string|number, snacks.notifier.Notif>
---@field sorted? snacks.notifier.Notif[]
---@field opts snacks.notifier.Config
local N = {}

N.ns = vim.api.nvim_create_namespace("snacks.notifier")

---@param str string
local function cap(str)
  return str:sub(1, 1):upper() .. str:sub(2):lower()
end

---@param name string
---@param level? snacks.notifier.level
local function hl(name, level)
  return "SnacksNotifier" .. name .. (level and cap(level) or "")
end

---@type table<string, snacks.notifier.render>
N.styles = {
  -- style using border title
  compact = function(buf, notif, ctx)
    local title = vim.trim(notif.icon .. " " .. (notif.title or ""))
    if title ~= "" then
      ctx.opts.title = { { " " .. title .. " ", ctx.hl.title } }
      ctx.opts.title_pos = "center"
    end
    vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(notif.msg, "\n"))
  end,
  minimal = function(buf, notif, ctx)
    ctx.opts.border = "none"
    local whl = ctx.opts.wo.winhighlight
    ctx.opts.wo.winhighlight = whl:gsub(ctx.hl.msg, "SnacksNotifierMinimal")
    vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(notif.msg, "\n"))
    vim.api.nvim_buf_set_extmark(buf, ctx.ns, 0, 0, {
      virt_text = { { notif.icon, ctx.hl.icon } },
      virt_text_pos = "right_align",
    })
  end,
  history = function(buf, notif, ctx)
    local lines = vim.split(notif.msg, "\n", { plain = true })
    local prefix = {
      { os.date(ctx.notifier.opts.date_format, notif.added), hl("HistoryDateTime") },
      { notif.icon, ctx.hl.icon },
      { notif.level:upper(), ctx.hl.title },
      { notif.title, hl("HistoryTitle") },
    }
    prefix = vim.tbl_filter(function(v)
      return (v[1] or "") ~= ""
    end, prefix)
    local prefix_width = 0
    for i = 1, #prefix do
      prefix_width = prefix_width + vim.fn.strdisplaywidth(prefix[i * 2 - 1][1]) + 1
      table.insert(prefix, i * 2, { " " })
    end
    local top = vim.api.nvim_buf_line_count(buf)
    local empty = top == 1 and #vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] == 0
    top = empty and 0 or top
    lines[1] = string.rep(" ", prefix_width) .. (lines[1] or "")
    vim.api.nvim_buf_set_lines(buf, top, -1, false, lines)
    vim.api.nvim_buf_set_extmark(buf, ctx.ns, top, 0, {
      virt_text = prefix,
      virt_text_pos = "overlay",
      priority = 10,
    })
  end,
  -- similar to the default nvim-notify style
  fancy = function(buf, notif, ctx)
    vim.api.nvim_buf_set_lines(buf, 0, 1, false, { "", "" })
    vim.api.nvim_buf_set_lines(buf, 2, -1, false, vim.split(notif.msg, "\n"))
    vim.api.nvim_buf_set_extmark(buf, ctx.ns, 0, 0, {
      virt_text = { { " " }, { notif.icon, ctx.hl.icon }, { " " }, { notif.title or "", ctx.hl.title } },
      virt_text_win_col = 0,
      priority = 10,
    })
    vim.api.nvim_buf_set_extmark(buf, ctx.ns, 0, 0, {
      virt_text = { { " " }, { os.date(ctx.notifier.opts.date_format, notif.added), ctx.hl.title }, { " " } },
      virt_text_pos = "right_align",
      priority = 10,
    })
    vim.api.nvim_buf_set_extmark(buf, ctx.ns, 1, 0, {
      virt_text = { { string.rep("━", vim.o.columns - 2), ctx.hl.border } },
      virt_text_win_col = 0,
      priority = 10,
    })
  end,
}

---@alias snacks.notifier.level "trace"|"debug"|"info"|"warn"|"error"

---@type table<number, snacks.notifier.level>
N.levels = {
  [vim.log.levels.TRACE] = "trace",
  [vim.log.levels.DEBUG] = "debug",
  [vim.log.levels.INFO] = "info",
  [vim.log.levels.WARN] = "warn",
  [vim.log.levels.ERROR] = "error",
}
N.level_names = vim.tbl_values(N.levels) ---@type snacks.notifier.level[]

---@param level number|string
---@return snacks.notifier.level
local function normlevel(level)
  return type(level) == "string" and (vim.tbl_contains(N.level_names, level:lower()) and level:lower() or "info")
    or N.levels[level]
    or "info"
end

---@param level number|string
---@return integer
local function numlevel(level)
  return type(level) == "number" and level or vim.log.levels[normlevel(level):upper()] or 0
end

local function ts()
  if uv.clock_gettime then
    local ret = assert(uv.clock_gettime("realtime"))
    return ret.sec + ret.nsec / 1e9
  end
  local sec, usec = uv.gettimeofday()
  return sec + usec / 1e6
end

local _id = 0

local function next_id()
  _id = _id + 1
  return _id
end

---@param opts? snacks.notifier.Config
---@return snacks.notifier.Class
function N.new(opts)
  local self = setmetatable({}, { __index = N })
  self.opts = Snacks.config.get("notifier", defaults, opts)
  self.queue = {}
  self.history = {}
  self:init()
  self:start()
  return self
end

function N:init()
  local links = {
    [hl("History")] = "Normal",
    [hl("HistoryTitle")] = "Title",
    [hl("HistoryDateTime")] = "Special",
    SnacksNotifierMinimal = "NormalFloat",
  }
  for _, level in ipairs(N.level_names) do
    local Level = cap(level)
    local link = vim.tbl_contains({ "Trace", "Debug" }, Level) and "NonText" or nil
    links[hl("", level)] = "Normal"
    links[hl("Icon", level)] = link or ("DiagnosticSign" .. Level)
    links[hl("Border", level)] = link or ("Diagnostic" .. Level)
    links[hl("Title", level)] = link or ("Diagnostic" .. Level)
    links[hl("Footer", level)] = link or ("Diagnostic" .. Level)
  end
  Snacks.util.set_hl(links, { default = true })

  -- resize handler
  vim.api.nvim_create_autocmd("VimResized", {
    group = vim.api.nvim_create_augroup("snacks_notifier", {}),
    callback = function()
      for _, notif in pairs(self.queue) do
        notif.dirty = true
      end
      self.sorted = nil
    end,
  })
end

function N:start()
  local running = false
  uv.new_timer():start(self.opts.refresh, self.opts.refresh, function()
    if running or not next(self.queue) then
      return
    end
    running = true
    vim.schedule(function()
      if self.in_search() then
        running = false
        return
      end
      xpcall(function()
        self:process()
      end, function(err)
        if err:find("E565") then
          return
        end
        local trace = debug.traceback(2)
        vim.schedule(function()
          vim.api.nvim_err_writeln(
            ("Snacks notifier failed. Dropping queue. Error:\n%s\n\nTrace:\n%s"):format(err, trace)
          )
        end)
        self.queue = {}
      end)
      running = false
    end)
  end)
end

function N:process()
  self:update()
  self:layout()
end

function N:is_blocking()
  local mode = vim.api.nvim_get_mode()
  for _, m in ipairs({ "ic", "ix", "c", "no", "r%?", "rm" }) do
    if mode.mode:find(m) == 1 then
      return true
    end
  end
  return mode.blocking
end

local health_msg = false

---@param opts snacks.notifier.Notif.opts
function N:add(opts)
  if opts.checkhealth then
    health_msg = true
    return
  end
  local now = ts()
  local notif = vim.deepcopy(opts) --[[@as snacks.notifier.Notif]]
  notif.msg = notif.msg or ""

  -- NOTE: support nvim-notify style replace
  ---@diagnostic disable-next-line: undefined-field
  if not notif.id and notif.replace then
    ---@diagnostic disable-next-line: undefined-field
    notif.id = type(notif.replace) == "table" and notif.replace.id or notif.replace
  end

  notif.title = (notif.title or ""):gsub("\n", " ")
  notif.id = notif.id or next_id()
  notif.level = normlevel(notif.level)
  notif.icon = notif.icon or self.opts.icons[notif.level]
  notif.timeout = notif.timeout == false and 0 or notif.timeout
  notif.timeout = notif.timeout == true and self.opts.timeout or notif.timeout
  notif.timeout = notif.timeout or self.opts.timeout
  notif.added = now

  if opts.id and self.queue[opts.id] then
    local n = self.queue[opts.id] --[[@as snacks.notifier.Notif]]
    notif.added = n.added
    notif.updated = now
    notif.shown = n.shown and now or nil -- reset shown time
    notif.win = n.win
    notif.layout = n.layout
    notif.dirty = true
  end
  self.sorted = nil
  local want = numlevel(notif.level) >= numlevel(self.opts.level)
  want = want and (not self.opts.filter or self.opts.filter(notif))
  if not want then
    return notif.id
  end
  self.queue[notif.id] = notif
  if opts.history ~= false then
    self.history[notif.id] = notif
  end
  if self:is_blocking() then
    pcall(function()
      self:process()
    end)
  end
  return notif.id
end

function N:update()
  local now = ts()
  --- Cleanup queue
  for id, notif in pairs(self.queue) do
    local timeout = notif.timeout or self.opts.timeout
    local keep = not notif.shown -- not shown yet
      or timeout == 0 -- no timeout
      or (notif.win and notif.win:win_valid() and vim.api.nvim_get_current_win() == notif.win.win) -- current window
      or (notif.win and notif.win:buf_valid() and vim.api.nvim_get_current_buf() == notif.win.buf) -- current buffer
      or (notif.keep and notif.keep(notif)) -- custom keep
      or (self.opts.keep and self.opts.keep(notif)) -- global keep
      or (notif.shown + timeout / 1e3 > now) -- not timed out
    if not keep then
      self:hide(id)
    end
  end
  self.sorted = self.sorted or self:sort()
end

---@param opts? snacks.notifier.history
---@return snacks.notifier.Notif[]
function N:get_history(opts)
  ---@type snacks.notifier.history
  opts = vim.tbl_deep_extend("force", {}, history_opts, opts or {})
  local notifs = vim.tbl_values(self.history)
  local filter = opts.filter
  if type(filter) == "string" or type(filter) == "number" then
    local level = normlevel(filter)
    filter = function(n)
      return n.level == level
    end
  end
  notifs = filter and vim.tbl_filter(filter, notifs) or notifs
  local ret = self:sort(notifs, opts.sort)
  if opts.reverse then
    local rev = {}
    for i = #ret, 1, -1 do
      table.insert(rev, ret[i])
    end
    ret = rev
  end
  return ret
end

---@param opts? snacks.notifier.history
function N:show_history(opts)
  if vim.bo.filetype == "snacks_notif_history" then
    vim.cmd("close")
    return
  end
  local win = Snacks.win({ style = "notification_history", enter = true, show = false })
  local buf = win:open_buf()
  opts = opts or {}
  if opts.reverse == nil then
    opts.reverse = true
  end
  for _, notif in ipairs(self:get_history(opts)) do
    N.styles.history(buf, notif, {
      opts = win.opts,
      notifier = self,
      ns = N.ns,
      hl = self:hl(notif),
    })
  end
  return win:show()
end

---@param id? number|string
function N:hide(id)
  if not id then
    for i in pairs(self.queue) do
      self:hide(i)
    end
    return
  end
  local notif = self.queue[id]
  if not notif then
    return
  end
  self.queue[id], self.sorted = nil, nil
  notif.hidden = ts()
  if notif.win then
    notif.win:close()
    notif.win = nil
  end
end

---@param value number
---@param min number
---@param max number
---@param parent number
local function dim(value, min, max, parent)
  min = math.floor(min < 1 and (parent * min) or min)
  max = math.floor(max < 1 and (parent * max) or max)
  return math.min(max, math.max(min, value))
end

---@param style? snacks.notifier.style
---@return snacks.notifier.render
function N:get_render(style)
  style = style or self.opts.style
  return type(style) == "function" and style or N.styles[style] or N.styles.compact
end

---@param notif snacks.notifier.Notif
function N:hl(notif)
  ---@type snacks.notifier.hl
  return vim.tbl_extend("force", {
    title = hl("Title", notif.level),
    icon = hl("Icon", notif.level),
    border = hl("Border", notif.level),
    footer = hl("Footer", notif.level),
    msg = hl("", notif.level),
  }, notif.hl or {})
end

---@param notif snacks.notifier.Notif
function N:render(notif)
  if type(notif.opts) == "function" then
    notif.opts(notif)
  end

  ---@type snacks.notifier.hl
  local notif_hl = self:hl(notif)

  local win = notif.win
    or Snacks.win({
      show = false,
      style = "notification",
      enter = false,
      backdrop = false,
      ft = notif.ft,
      noautocmd = true,
      keys = {
        q = function()
          self:hide(notif.id)
        end,
      },
    })
  win.opts.wo.winhighlight = table.concat({
    "Normal:" .. notif_hl.msg,
    "NormalNC:" .. notif_hl.msg,
    "FloatBorder:" .. notif_hl.border,
    "FloatTitle:" .. notif_hl.title,
    "FloatFooter:" .. notif_hl.footer,
  }, ",")
  notif.win = win
  ---@diagnostic disable-next-line: invisible
  local buf = win:open_buf()
  vim.api.nvim_buf_clear_namespace(buf, N.ns, 0, -1)
  local render = self:get_render(notif.style)

  vim.bo[buf].modifiable = true
  vim.api.nvim_buf_set_lines(buf, 0, -1, false, {})
  render(buf, notif, {
    opts = win.opts,
    notifier = self,
    ns = N.ns,
    hl = notif_hl,
  })
  vim.bo[buf].modifiable = false

  local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)

  local pad = self.opts.padding and (win:add_padding() or 2) or 0
  local width = win:border_text_width()
  for _, line in ipairs(lines) do
    width = math.max(width, vim.fn.strdisplaywidth(line) + pad)
  end
  width = dim(width, self.opts.width.min, self.opts.width.max, vim.o.columns)

  local height = #lines
  -- calculate wrapped height
  if win.opts.wo.wrap then
    height = 0
    for _, line in ipairs(lines) do
      height = height + math.ceil((vim.fn.strdisplaywidth(line) + pad) / width)
    end
  end
  local wanted_height = height
  height = dim(height, self.opts.height.min, self.opts.height.max, vim.o.lines)

  if wanted_height > height and win:has_border() and self.opts.more_format and not win.opts.footer then
    win.opts.footer = self.opts.more_format:format(wanted_height - height)
    win.opts.footer_pos = "right"
  end

  win.opts.width = width
  win.opts.height = height
end

---@param notifs? snacks.notifier.Notif[]
---@param fields? string[]
function N:sort(notifs, fields)
  fields = fields or self.opts.sort
  notifs = notifs or vim.tbl_values(self.queue)
  table.sort(notifs, function(a, b)
    for _, key in ipairs(fields) do
      local function v(n)
        if key == "level" then
          return 10 - numlevel(n[key])
        end
        return n[key]
      end
      local av, bv = v(a), v(b)
      if av ~= bv then
        return av < bv
      end
    end
    return false
  end)
  return notifs
end

function N:new_layout()
  ---@class snacks.notifier.layout
  local layout = {}
  layout.free = 0
  layout.rows = {} ---@type boolean[]
  ---@param row number
  ---@param height number
  ---@param free boolean
  function layout.mark(row, height, free)
    for i = row, math.min(row + height - 1, vim.o.lines) do
      layout.free = layout.free + (free and 1 or -1)
      layout.rows[i] = free
    end
  end
  ---@param height number
  ---@param row? number wanted row
  function layout.find(height, row)
    local from, to, down = row or 1, vim.o.lines - height, self.opts.top_down
    for i = down and from or to, down and to or from, down and 1 or -1 do
      local ret = true
      for j = i, i + height - 1 do
        if not layout.rows[j] then
          ret = false
          break
        end
      end
      if ret then
        return i
      end
    end
  end
  layout.mark(1, vim.o.lines, true)
  layout.mark(1, self.opts.margin.top + (vim.o.tabline == "" and 0 or 1), false)
  layout.mark(vim.o.lines - (self.opts.margin.bottom + (vim.o.laststatus == 0 and 0 or 1)) + 1, vim.o.lines, false)
  return layout
end

function N:layout()
  local layout = self:new_layout()
  local wins_updated = 0
  local wins_created = 0
  local update = {} ---@type snacks.win[]
  for _, notif in ipairs(assert(self.sorted)) do
    if layout.free < (self.opts.height.min + 2) then -- not enough space
      if notif.win then
        notif.shown = nil
        notif.win:hide()
      end
    else
      local prev_layout = notif.layout
        and { top = notif.layout.top, height = notif.layout.height, width = notif.layout.width }
      if not notif.win or notif.dirty or not notif.win:buf_valid() or type(notif.opts) == "function" then
        notif.dirty = true
        self:render(notif)
        notif.dirty = false
        notif.layout = notif.win:size()
        notif.layout.top = prev_layout and prev_layout.top
        prev_layout = nil -- always re-render since opts might've changed
      end
      notif.layout.top = layout.find(notif.layout.height, notif.layout.top)
      if notif.layout.top then
        layout.mark(notif.layout.top, notif.layout.height, false)
        if not vim.deep_equal(prev_layout, notif.layout) then
          if notif.win:win_valid() then
            wins_updated = wins_updated + 1
          else
            wins_created = wins_created + 1
          end
          update[#update + 1] = notif.win
          notif.win.opts.row = notif.layout.top - 1
          notif.win.opts.col = vim.o.columns - notif.layout.width - self.opts.margin.right
          notif.shown = notif.shown or ts()
          notif.win:show()
        end
      elseif notif.win then
        notif.shown = nil
        notif.win:hide()
      end
    end
  end

  if #update > 0 and not self.in_search() then
    if vim.api.nvim__redraw then
      for _, win in ipairs(update) do
        win:redraw()
      end
    else
      vim.cmd.redraw()
    end
  end
end

function N.in_search()
  return vim.tbl_contains({ "/", "?" }, vim.fn.getcmdtype())
end

---@param msg string
---@param level? snacks.notifier.level|number
---@param opts? snacks.notifier.Notif.opts
function N:notify(msg, level, opts)
  opts = opts or {}
  opts.msg = msg
  opts.level = level
  return self:add(opts)
end

-- Global instance
local notifier = N.new()

---@param msg string
---@param level? snacks.notifier.level|number
---@param opts? snacks.notifier.Notif.opts
function M.notify(msg, level, opts)
  return notifier:notify(msg, level, opts)
end

---@param id? number|string
function M.hide(id)
  return notifier:hide(id)
end

---@param opts? snacks.notifier.history
function M.get_history(opts)
  return notifier:get_history(opts)
end

---@param opts? snacks.notifier.history
function M.show_history(opts)
  return notifier:show_history(opts)
end

---@private
function M.health()
  health_msg = false
  vim.notify("", nil, { checkhealth = true })
  vim.wait(500, function()
    return health_msg
  end, 10)
  if health_msg then
    Snacks.health.ok("is ready")
  else
    Snacks.health.error("is not ready")
  end
end

return M
