local ffi = require("ffi")
---@diagnostic disable-next-line: no-unknown
local uv = vim.loop

local M = {}
M.dirty = false
M.VERSION = "1"

---@class LazyCacheConfig
M.config = {
  enabled = true,
  path = vim.fn.stdpath("state") .. "/lazy/cache",
  -- Once one of the following events triggers, caching will be disabled.
  -- To cache all modules, set this to `{}`, but that is not recommended.
  -- The default is to disable on:
  --  * VimEnter: not useful to cache anything else beyond startup
  --  * BufReadPre: this will be triggered early when opening a file from the command line directly
  disable_events = { "VimEnter", "BufReadPre" },
}
M.debug = false

---@type CacheHash
local cache_hash

---@alias CacheHash {mtime: {sec:number, nsec:number}, size:number}
---@alias CacheEntry {hash:CacheHash, modpath:string, chunk:string, used:number}
---@type table<string,CacheEntry?>
M.cache = {}
M.enabled = true
M.ttl = 3600 * 24 * 5 -- keep unused modules for up to 5 days
---@type string[]
M.rtp = nil
-- selene:allow(global_usage)
M._loadfile = _G.loadfile

-- checks whether the cached modpath is still valid
function M.check_path(modname, modpath)
  -- check rtp excluding plugins. This is a very small list, so should be fast
  for _, path in ipairs(M.get_rtp()) do
    if modpath:find(path, 1, true) == 1 then
      return true
    end
  end

  -- the correct lazy path should be part of rtp.
  -- so if we get here, this is folke using the local dev instance ;)
  if modname:sub(1, 4) == "lazy" then
    return false
  end

  -- check plugins. Again fast, since we check the plugin name from the path.
  -- only needed when the plugin mod has been loaded
  if package.loaded["lazy.core.plugin"] then
    local plugin = require("lazy.core.plugin").find(modpath)
    if plugin and modpath:find(plugin.dir, 1, true) == 1 then
      if not plugin._.loaded then
        if plugin.module == false then
          error("Plugin " .. plugin.name .. " is not loaded and is configured with module=false")
        end
        require("lazy.core.loader").load(plugin, { require = modname })
      end
      return true
    end
  end
  return false
end

function M.disable()
  if not M.enabled then
    return
  end
  -- selene:allow(global_usage)
  _G.loadfile = M._loadfile
  ---@diagnostic disable-next-line: no-unknown
  for i, loader in ipairs(package.loaders) do
    if loader == M.loader then
      table.remove(package.loaders, i)
      break
    end
  end
  M.enabled = false
end

---@param modname string
---@return any
function M.loader(modname)
  local entry = M.cache[modname]

  local chunk, err
  if entry and M.check_path(modname, entry.modpath) then
    local mod = package.loaded[modname]
    if type(mod) == "table" then
      return function()
        return mod
      end
    end
    chunk, err = M.load(modname, entry.modpath)
  end
  if not chunk then
    -- find the modpath and load the module
    local modpath = M.find(modname)
    if modpath then
      chunk, err = M.load(modname, modpath)
    end
  end
  return chunk or (err and error(err)) or "not found in lazy cache"
end

---@param modpath string
---@return any, string?
function M.loadfile(modpath)
  modpath = modpath:gsub("\\", "/")
  return M.load(modpath, modpath)
end

---@param modkey string
---@param modpath string
---@return function?, string? error_message
function M.load(modkey, modpath)
  modpath = modpath:gsub("\\", "/")
  local hash = M.hash(modpath)
  if not hash then
    -- trigger correct error
    return M._loadfile(modpath)
  end

  local entry = M.cache[modkey]
  if entry then
    entry.modpath = modpath
    entry.used = os.time()
    if M.eq(entry.hash, hash) then
      -- found in cache and up to date
      return loadstring(entry.chunk --[[@as string]], "@" .. entry.modpath)
    end
  else
    entry = { hash = hash, modpath = modpath, used = os.time() }
    M.cache[modkey] = entry
  end
  entry.hash = hash

  if M.debug then
    vim.schedule(function()
      vim.notify("[cache:load] " .. modpath)
    end)
  end

  local chunk, err = M._loadfile(entry.modpath)
  if chunk then
    M.dirty = true
    entry.chunk = string.dump(chunk)
  end
  return chunk, err
end

function M.require(modname)
  return M.loader(modname)()
end

---@param modname string
---@return string?
function M.find(modname)
  local basename = modname:gsub("%.", "/")
  local paths = { "lua/" .. basename .. ".lua", "lua/" .. basename .. "/init.lua" }
  return vim.api.nvim__get_runtime(paths, false, { is_lua = true })[1]
end

-- returns the cached RTP excluding plugin dirs
function M.get_rtp()
  if not M.rtp then
    M.rtp = {}
    ---@type table<string,true>
    local skip = {}
    -- only skip plugins once Config has been setup
    if package.loaded["lazy.core.config"] then
      local Config = require("lazy.core.config")
      for _, plugin in pairs(Config.plugins) do
        if plugin.name ~= "lazy.nvim" then
          skip[plugin.dir] = true
        end
      end
    end
    for _, path in ipairs(vim.api.nvim_list_runtime_paths()) do
      path = path:gsub("\\", "/")
      if not skip[path] and not path:find("after/?$") then
        M.rtp[#M.rtp + 1] = path
      end
    end
  end
  return M.rtp
end

---@param opts? LazyConfig
function M.setup(opts)
  -- no fancy deep extend here. just set the options
  if opts and opts.performance and opts.performance.cache then
    ---@diagnostic disable-next-line: no-unknown
    for k, v in pairs(opts.performance.cache) do
      ---@diagnostic disable-next-line: no-unknown
      M.config[k] = v
    end
  end
  M.debug = opts and opts.debug

  M.load_cache()
  table.insert(package.loaders, 2, M.loader)
  -- selene:allow(global_usage)
  _G.loadfile = M.loadfile

  -- reset rtp when it changes
  vim.api.nvim_create_autocmd("OptionSet", {
    pattern = "runtimepath",
    callback = function()
      M.rtp = nil
    end,
  })

  if #M.config.disable_events > 0 then
    vim.api.nvim_create_autocmd(M.config.disable_events, { once = true, callback = M.disable })
  end
  return M
end

---@return CacheHash?
function M.hash(file)
  local ok, ret = pcall(uv.fs_stat, file)
  return ok and ret or nil
end

---@param h1 CacheHash
---@param h2 CacheHash
function M.eq(h1, h2)
  return h1 and h2 and h1.size == h2.size and h1.mtime.sec == h2.mtime.sec and h1.mtime.nsec == h2.mtime.nsec
end

function M.save_cache()
  vim.fn.mkdir(vim.fn.fnamemodify(M.config.path, ":p:h"), "p")
  local f = assert(uv.fs_open(M.config.path, "w", 438))
  uv.fs_write(f, M.VERSION)
  uv.fs_write(f, "\0")
  for modname, entry in pairs(M.cache) do
    if entry.used > os.time() - M.ttl then
      entry.modname = modname
      local header = {
        entry.hash.size,
        entry.hash.mtime.sec,
        entry.hash.mtime.nsec,
        #modname,
        #entry.chunk,
        #entry.modpath,
        entry.used,
      }
      uv.fs_write(f, ffi.string(ffi.new("const uint32_t[7]", header), 28))
      uv.fs_write(f, modname)
      uv.fs_write(f, entry.chunk)
      uv.fs_write(f, entry.modpath)
    end
  end
  uv.fs_close(f)
end

function M.load_cache()
  M.cache = {}
  local f = uv.fs_open(M.config.path, "r", 438)
  if f then
    cache_hash = uv.fs_fstat(f) --[[@as CacheHash]]
    local data = uv.fs_read(f, cache_hash.size, 0) --[[@as string]]
    uv.fs_close(f)

    local zero = data:find("\0", 1, true)
    if not zero then
      return
    end

    if M.VERSION ~= data:sub(1, zero - 1) then
      return
    end

    local offset = zero + 1
    while offset + 1 < #data do
      local header = ffi.cast("uint32_t*", ffi.new("const char[28]", data:sub(offset, offset + 27)))
      offset = offset + 28
      local modname = data:sub(offset, offset + header[3] - 1)
      offset = offset + header[3]
      local chunk = data:sub(offset, offset + header[4] - 1)
      offset = offset + header[4]
      local file = data:sub(offset, offset + header[5] - 1)
      offset = offset + header[5]
      M.cache[modname] = {
        hash = { size = header[0], mtime = { sec = header[1], nsec = header[2] } },
        chunk = chunk,
        modpath = file,
        used = header[6],
      }
    end
  end
end

function M.autosave()
  vim.api.nvim_create_autocmd("VimLeavePre", {
    callback = function()
      if M.dirty then
        local hash = M.hash(M.config.path)
        -- abort when the file was changed in the meantime
        if hash == nil or M.eq(cache_hash, hash) then
          M.save_cache()
        end
      end
    end,
  })
end

return M