---@class snacks.debug
---@overload fun(...)
local M = setmetatable({}, {
  __call = function(t, ...)
    return t.inspect(...)
  end,
})

M.meta = {
  desc = "Pretty inspect & backtraces for debugging",
}

---@class snacks.debug.cmd
---@field cmd string|string[]
---@field level? snacks.notifier.level
---@field title? string
---@field args? string[]
---@field cwd? string
---@field group? boolean
---@field notify? boolean
---@field footer? string
---@field header? string
---@field props? table<string, string>

local uv = vim.uv or vim.loop

local MAX_INSPECT_LINES = 2000

vim.schedule(function()
  Snacks.util.set_hl({
    Indent = "LineNr",
    Print = "NonText",
  }, { prefix = "SnacksDebug", default = true })
end)

-- Show a notification with a pretty printed dump of the object(s)
-- with lua treesitter highlighting and the location of the caller
function M.inspect(...)
  local len = select("#", ...) ---@type number
  local obj = { ... } ---@type unknown[]
  local caller = debug.getinfo(1, "S")
  for level = 2, 10 do
    local info = debug.getinfo(level, "S")
    if
      info
      and info.source ~= caller.source
      and info.what ~= "C"
      and info.source ~= "lua"
      and info.source ~= "@" .. (os.getenv("MYVIMRC") or "")
    then
      caller = info
      break
    end
  end
  vim.schedule(function()
    local title = "Debug: " .. vim.fn.fnamemodify(caller.source:sub(2), ":~:.") .. ":" .. caller.linedefined
    local lines = vim.split(vim.inspect(len == 1 and obj[1] or len > 0 and obj or nil), "\n")
    if #lines > MAX_INSPECT_LINES then
      local c = #lines
      lines = vim.list_slice(lines, 1, MAX_INSPECT_LINES)
      lines[#lines + 1] = ""
      lines[#lines + 1] = (c - MAX_INSPECT_LINES) .. " more lines have been truncated …"
    end
    Snacks.notify.warn(lines, { title = title, ft = "lua" })
  end)
end

--- Run the current buffer or a range of lines.
--- Shows the output of `print` inlined with the code.
--- Any error will be shown as a diagnostic.
---@param opts? {name?:string, buf?:number, print?:boolean}
function M.run(opts)
  local ns = vim.api.nvim_create_namespace("snacks_debug")
  opts = vim.tbl_extend("force", { print = true }, opts or {})
  local buf = opts.buf or 0
  buf = buf == 0 and vim.api.nvim_get_current_buf() or buf
  local name = opts.name or vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf), ":t")

  -- Get the lines to run
  local lines ---@type string[]
  local mode = vim.fn.mode()
  if mode:find("[vV]") then
    if mode == "v" then
      vim.cmd("normal! v")
    elseif mode == "V" then
      vim.cmd("normal! V")
    end
    local from = vim.api.nvim_buf_get_mark(buf, "<")
    local to = vim.api.nvim_buf_get_mark(buf, ">")

    -- for some reason, sometimes the column is off by one
    -- see: https://github.com/folke/snacks.nvim/issues/190
    local col_to = math.min(to[2] + 1, #vim.api.nvim_buf_get_lines(buf, to[1] - 1, to[1], false)[1])

    lines = vim.api.nvim_buf_get_text(buf, from[1] - 1, from[2], to[1] - 1, col_to, {})
    -- Insert empty lines to keep the line numbers
    for _ = 1, from[1] - 1 do
      table.insert(lines, 1, "")
    end
    vim.fn.feedkeys("gv", "nx")
  else
    lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
  end

  -- Clear diagnostics and extmarks
  local function reset()
    vim.diagnostic.reset(ns, buf)
    vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1)
  end
  reset()
  vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, {
    group = vim.api.nvim_create_augroup("snacks_debug_run_" .. buf, { clear = true }),
    buffer = buf,
    callback = reset,
  })

  -- Get the line number from the msg or stack
  local function get_line(msg)
    local line = msg and msg:match("^" .. vim.pesc(name) .. ":(%d+):")
    if line then
      return line
    end
    for level = 2, 20 do
      local info = debug.getinfo(level, "Sln")
      if info and info.source == "@" .. name then
        return info.currentline
      end
    end
  end

  -- Error handler
  local function on_error(err)
    local line = get_line(err)
    if line then
      vim.diagnostic.set(ns, buf, {
        { col = 0, lnum = line - 1, message = err, severity = vim.diagnostic.severity.ERROR },
      })
    end
    M.backtrace({ err, "" }, { title = "Error in " .. name, level = vim.log.levels.ERROR })
  end

  -- Print handler
  local function on_print(...)
    local str = table.concat(
      vim.tbl_map(function(v)
        return type(v) == "string" and v or vim.inspect(v)
      end, { ... }),
      " "
    )
    ---@type string[][][]
    local virt_lines = {}
    for _, line in ipairs(vim.split(str, "\n", { plain = true })) do
      table.insert(virt_lines, { { "  │ ", "SnacksDebugIndent" }, { line, "SnacksDebugPrint" } })
    end

    local line = (get_line() or 1) - 1
    vim.schedule(function()
      vim.api.nvim_buf_set_extmark(buf, ns, line, 0, {
        virt_lines = virt_lines,
      })
    end)
  end

  -- Load the code
  local chunk, err = load(table.concat(lines, "\n"), "@" .. name)
  if not chunk then
    return on_error(err)
  end

  -- Setup the env
  local env = { print = opts.print and on_print or nil }
  package.seeall(env)
  setfenv(chunk, env)
  xpcall(chunk, function(e)
    on_error(e)
  end)
end

-- Show a notification with a pretty backtrace
---@param msg? string|string[]
---@param opts? snacks.notify.Opts
function M.backtrace(msg, opts)
  opts = vim.tbl_deep_extend("force", {
    level = vim.log.levels.WARN,
    title = "Backtrace",
  }, opts or {})
  ---@type string[]
  local trace = type(msg) == "table" and msg or type(msg) == "string" and { msg } or {}
  for level = 2, 20 do
    local info = debug.getinfo(level, "Sln")
    if info and info.what ~= "C" and info.source ~= "lua" and not info.source:find("snacks[/\\]debug") then
      local line = "- `" .. vim.fn.fnamemodify(info.source:sub(2), ":p:~:.") .. "`:" .. info.currentline
      if info.name then
        line = line .. " _in_ **" .. info.name .. "**"
      end
      table.insert(trace, line)
    end
  end
  Snacks.notify(#trace > 0 and (table.concat(trace, "\n")) or "", opts)
end

-- Very simple function to profile a lua function.
-- * **flush**: set to `true` to use `jit.flush` in every iteration.
-- * **count**: defaults to 100
---@param fn fun()
---@param opts? {count?: number, flush?: boolean, title?: string}
function M.profile(fn, opts)
  opts = vim.tbl_extend("force", { count = 100, flush = true }, opts or {})
  local start = uv.hrtime()
  for _ = 1, opts.count, 1 do
    if opts.flush then
      jit.flush(fn, true)
    end
    fn()
  end
  Snacks.notify(((uv.hrtime() - start) / 1e6 / opts.count) .. "ms", { title = opts.title or "Profile" })
end

-- Log a message to the file `./debug.log`.
-- - a timestamp will be added to every message.
-- - accepts multiple arguments and pretty prints them.
-- - if the argument is not a string, it will be printed using `vim.inspect`.
-- - if the message is smaller than 120 characters, it will be printed on a single line.
--
-- ```lua
-- Snacks.debug.log("Hello", { foo = "bar" }, 42)
-- -- 2024-11-08 08:56:52 Hello { foo = "bar" } 42
-- ```
function M.log(...)
  local file = "./debug.log"
  local fd = io.open(file, "a+")
  if not fd then
    error(("Could not open file %s for writing"):format(file))
  end
  local c = select("#", ...)
  local parts = {} ---@type string[]
  for i = 1, c do
    local v = select(i, ...)
    parts[i] = type(v) == "string" and v or vim.inspect(v)
  end
  local msg = table.concat(parts, " ")
  msg = #msg < 120 and msg:gsub("%s+", " ") or msg
  fd:write(os.date("%Y-%m-%d %H:%M:%S ") .. msg)
  fd:write("\n")
  fd:close()
end

---@alias snacks.debug.Trace {name: string, time: number, [number]:snacks.debug.Trace}
---@alias snacks.debug.Stat {name:string, time:number, count?:number, depth?:number}

---@type snacks.debug.Trace[]
M._traces = { { name = "__TOP__", time = 0 } }

---@param name string?
function M.trace(name)
  if name then
    local entry = { name = name, time = uv.hrtime() } ---@type snacks.debug.Trace
    table.insert(M._traces[#M._traces], entry)
    table.insert(M._traces, entry)
    return entry
  else
    local entry = assert(table.remove(M._traces), "trace not ended?") ---@type snacks.debug.Trace
    entry.time = uv.hrtime() - entry.time
    return entry
  end
end

---@param modname string
---@param mod? table
---@param suffix? string
function M.tracemod(modname, mod, suffix)
  mod = mod or require(modname)
  suffix = suffix or "."
  for k, v in pairs(mod) do
    if type(v) == "function" and k ~= "trace" then
      mod[k] = function(...)
        M.trace(modname .. suffix .. k)
        local ok, ret = pcall(v, ...)
        M.trace()
        return ok == false and error(ret) or ret
      end
    end
  end
end

---@param opts? {min?: number, show?:boolean}
---@return {summary:table<string, snacks.debug.Stat>, trace:snacks.debug.Stat[], traces:snacks.debug.Trace[]}
function M.stats(opts)
  opts = opts or {}
  local stack, lines, trace = {}, {}, {} ---@type string[], string[], snacks.debug.Stat[]
  local summary = {} ---@type table<string, snacks.debug.Stat>
  ---@param stat snacks.debug.Trace
  local function collect(stat)
    if #stack > 0 then
      local recursive = vim.list_contains(stack, stat.name)
      summary[stat.name] = summary[stat.name] or { time = 0, count = 0, name = stat.name }
      summary[stat.name].time = summary[stat.name].time + (recursive and 0 or stat.time)
      summary[stat.name].count = summary[stat.name].count + 1
      table.insert(trace, { name = stat.name, time = stat.time or 0, depth = #stack - 1 })
    end
    table.insert(stack, stat.name)
    for _, entry in ipairs(stat) do
      collect(entry)
    end
    table.remove(stack)
  end
  collect(M._traces[1])

  ---@param entries snacks.debug.Stat[]
  local function add(entries)
    for _, stat in ipairs(entries) do
      local ms = math.floor(stat.time / 1e4) / 1e2
      if ms >= (opts.min or 0) then
        local line = ("%s- `%s`: **%.2f**ms"):format(("  "):rep(stat.depth or 0), stat.name, ms)
        table.insert(lines, line .. (stat.count and (" ([%d])"):format(stat.count) or ""))
      end
    end
  end

  if opts.show ~= false then
    lines[#lines + 1] = "# Summary"
    summary = vim.tbl_values(summary)
    table.sort(summary, function(a, b)
      return a.time > b.time
    end)
    add(summary)
    lines[#lines + 1] = "\n# Trace"
    add(trace)
    Snacks.notify.warn(lines, { title = "Traces" })
  end
  return { summary = summary, trace = trace, tree = M._traces }
end

function M.size(bytes)
  local sizes = { "B", "KB", "MB", "GB", "TB" }
  local s = 1
  while bytes > 1024 and s < #sizes do
    bytes = bytes / 1024
    s = s + 1
  end
  return ("%.2f%s"):format(bytes, sizes[s])
end

function M.metrics()
  collectgarbage("collect")
  local lines = {} ---@type string[]
  local function add(name, value)
    lines[#lines + 1] = ("- **%s**: %s"):format(name, value)
  end

  add("lua", M.size(collectgarbage("count") * 1024))

  for _, stat in ipairs({ "get_total_memory", "get_free_memory", "get_available_memory", "resident_set_memory" }) do
    add(stat:gsub("get_", ""):gsub("_", " "), M.size(uv[stat]()))
  end
  lines[#lines + 1] = ("```lua\n%s\n```"):format(vim.inspect(uv.getrusage()))
  Snacks.notify.warn(lines, { title = "Metrics" })
end

---@param opts snacks.debug.cmd
function M.cmd(opts)
  local cmd = opts.cmd
  local args = vim.deepcopy(opts.args or {})
  if type(cmd) == "table" then
    vim.list_extend(args, cmd, 2)
    cmd = cmd[1]
  end
  args = vim.tbl_map(tostring, args)
  ---@cast cmd string
  local lines = { cmd } ---@type string[]
  for _, arg in ipairs(args or {}) do
    arg = arg:find("[%$%s%?]") and vim.fn.shellescape(arg) or arg
    if #arg + #lines[#lines] > 40 then
      lines[#lines] = lines[#lines] .. " \\"
      table.insert(lines, "  " .. arg)
    else
      lines[#lines] = lines[#lines] .. " " .. arg
    end
  end
  local props = vim.deepcopy(opts.props or {})
  props.cwd = props.cwd or vim.fn.fnamemodify(opts.cwd or uv.cwd() or ".", ":~")
  local prop_keys = vim.tbl_keys(props) ---@type string[]
  table.sort(prop_keys)
  local prop_lines = {} ---@type string[]
  for _, key in ipairs(prop_keys) do
    table.insert(prop_lines, ("- **%s**: %s"):format(key, props[key]))
  end

  local id = cmd
  lines = {
    opts.header or "",
    table.concat(prop_lines, "\n"),
    "```sh",
    table.concat(lines, " \n"),
    "```",
    opts.footer or "",
  }
  if opts.title and not opts.notify then
    table.insert(lines, 1, ("# %s\n"):format(opts.title))
  end
  local msg = vim.trim(table.concat(lines, "\n")):gsub("\n\n+", "\n\n")
  if opts.notify ~= false then
    Snacks.notify(msg, {
      id = opts.group and ("snacks.debug.cmd." .. id) or nil,
      level = opts.level or vim.log.levels.INFO,
      title = opts.title or "Cmd Debug",
    })
  end
  return msg
end

return M
