local api = vim.api
local uv = vim.loop

local config = require("dirbuf.config")

local M = {}

M.path_separator = package.config:sub(1, 1)

function M.is_hidden(fname)
  return fname:sub(1, 1) == "."
end

function M.join_paths(...)
  local string_builder = {}
  for _, path in ipairs({ ... }) do
    if path:sub(-1, -1) == M.path_separator then
      path = path:sub(0, -2)
    end
    table.insert(string_builder, path)
  end
  return table.concat(string_builder, M.path_separator)
end

function M.is_directory(path)
  return vim.fn.isdirectory(path) == 1
end

-- FTypes are taken from
-- https://github.com/tbastos/luv/blob/2fed9454ebb870548cef1081a1f8a3dd879c1e70/src/fs.c#L420-L430
--[[
local enum FType
  "file"
  "directory"
  "link"
  "fifo"
  "socket"
  "char"
  "block"
end
local record FSEntry
  fname: string
  ftype: FType
  path: string
end
--]]

M.FSEntry = {}
local FSEntry = M.FSEntry

function FSEntry.new(fname, parent, ftype)
  return { fname = fname, path = M.join_paths(parent, fname), ftype = ftype }
end

function FSEntry.temp(ftype)
  local temppath = vim.fn.tempname()
  return {
    -- XXX: This technically violates fname's assumption that it is alwaies a
    -- simple name and not a path
    fname = temppath,
    path = temppath,
    ftype = ftype,
  }
end

function M.get_fs_entries(dir, show_hidden)
  local fs_entries = {}

  local handle, err, _ = uv.fs_scandir(dir)
  if handle == nil then
    return err
  end

  while true do
    local fname, ftype = uv.fs_scandir_next(handle)
    if fname == nil then
      break
    end
    if show_hidden or not M.is_hidden(fname) then
      table.insert(fs_entries, FSEntry.new(fname, dir, ftype))
    end
  end
  table.sort(fs_entries, config.get("sort_order"))

  return nil, fs_entries
end

M.plan = {}
M.actions = {}

local DEFAULT_FILE_MODE = tonumber("644", 8)
-- Directories have to be executable for you to chdir into them
local DEFAULT_DIR_MODE = tonumber("755", 8)

local function cp(src_path, dst_path, ftype)
  if ftype == "directory" then
    local ok, err, _ = uv.fs_mkdir(dst_path, DEFAULT_DIR_MODE)
    if not ok then
      return err
    end

    local handle = uv.fs_scandir(src_path)
    while true do
      local next_fname, next_ftype = uv.fs_scandir_next(handle)
      if next_fname == nil then
        break
      end
      err = cp(M.join_paths(src_path, next_fname), M.join_paths(dst_path, next_fname), next_ftype)
      if err ~= nil then
        return err
      end
    end

    return nil
  elseif ftype == "link" then
    local src_points_to, err, _ = uv.fs_readlink(src_path)
    if src_points_to == nil then
      return err
    end
    local ok
    ok, err, _ = uv.fs_symlink(src_points_to, dst_path)
    if not ok then
      return err
    end

    return nil
  else
    local ok, err, _ = uv.fs_copyfile(src_path, dst_path)
    if not ok then
      return err
    end
    return nil
  end
end

local function rm(path, ftype)
  if ftype == "directory" then
    local handle = uv.fs_scandir(path)
    while true do
      local next_fname, next_ftype = uv.fs_scandir_next(handle)
      if next_fname == nil then
        break
      end
      local err = rm(M.join_paths(path, next_fname), next_ftype)
      if err ~= nil then
        return err
      end
    end
    local ok, err, _ = uv.fs_rmdir(path)
    if not ok then
      return err
    end
    return nil
  else
    local ok, err, _ = uv.fs_unlink(path)
    if not ok then
      return err
    end
    return nil
  end
end

local function mv(src_path, dst_path, ftype)
  -- FIXME: This is a TOCTOU
  if uv.fs_access(dst_path, "W") then
    return string.format("'%s' already exists", dst_path)
  end
  local ok, err, err_type = uv.fs_rename(src_path, dst_path)

  if not ok and err_type == "EXDEV" then
    err = cp(src_path, dst_path, ftype)
    if err ~= nil then
      return err
    end
    err = rm(src_path, ftype)
    if err ~= nil then
      return err
    end
  elseif not ok then
    return err
  end

  return nil
end

local function is_child_of(maybe_child, parent)
  local exact_match = maybe_child == parent
  local child_match = vim.startswith(maybe_child, parent .. M.path_separator)
  return exact_match or child_match
end

-- `rename_loaded_buffers` finds all renamed buffers under `old_path` and
-- renames them to be under `new_path`.
local function rename_loaded_buffers(old_path, new_path)
  for _, buf in ipairs(api.nvim_list_bufs()) do
    if not api.nvim_buf_is_loaded(buf) then
      goto continue
    end

    -- api.nvim_buf_get_name() returns absolute path so no post-processing
    local buf_name = api.nvim_buf_get_name(buf)
    if is_child_of(buf_name, old_path) then
      api.nvim_buf_set_name(buf, new_path .. buf_name:sub(#old_path + 1))

      -- We have to :write! normal files to avoid `E13: File exists (add ! to
      -- override)` error when manually calling :write
      if api.nvim_buf_get_option(buf, "buftype") == "" then
        api.nvim_buf_call(buf, function()
          vim.cmd("silent! write!")
        end)
      end
    end

    ::continue::
  end
end

-- `delete_loaded_buffers` finds all deleted buffers under `path` and replaces
-- them with their alternate buffer, or a [No Name] buffer if its alternate
-- buffer doesn't exist.
local function delete_loaded_buffers(path)
  for _, buf in ipairs(api.nvim_list_bufs()) do
    if not api.nvim_buf_is_loaded(buf) then
      goto continue
    end

    -- api.nvim_buf_get_name() returns absolute path so no post-processing
    local buf_name = api.nvim_buf_get_name(buf)
    if is_child_of(buf_name, path) then
      for _, win in ipairs(vim.fn.win_findbuf(buf)) do
        api.nvim_win_call(win, function()
          local altbuf = vim.fn.bufnr("#")
          if api.nvim_buf_is_valid(altbuf) then
            api.nvim_win_set_buf(win, altbuf)
          else
            vim.cmd("enew!")
          end
        end)
      end
      api.nvim_buf_delete(buf, { force = true })
    end

    ::continue::
  end
end

function M.plan.create(fs_entry)
  return { type = "create", fs_entry = fs_entry }
end

function M.actions.create(args)
  local fs_entry = args.fs_entry

  -- FIXME: This is a TOCTOU
  if uv.fs_access(fs_entry.path, "W") then
    return string.format("'%s' already exists", fs_entry.ftype, fs_entry.path)
  end

  if fs_entry.ftype == "file" then
    local fd, err = uv.fs_open(fs_entry.path, "w", DEFAULT_FILE_MODE)
    if fd == nil then
      return err
    end
    local ok
    ok, err = uv.fs_close(fd)
    if not ok then
      return err
    end
  elseif fs_entry.ftype == "directory" then
    local ok, err = uv.fs_mkdir(fs_entry.path, DEFAULT_DIR_MODE)
    if not ok then
      return err
    end
  else
    return string.format("Cannot create %s", fs_entry.ftype)
  end

  return nil
end

function M.plan.copy(src_fs_entry, dst_fs_entry)
  return { type = "copy", src_fs_entry = src_fs_entry, dst_fs_entry = dst_fs_entry }
end

function M.actions.copy(args)
  local src_fs_entry, dst_fs_entry = args.src_fs_entry, args.dst_fs_entry
  -- planner ensures src and dst have same ftype
  return cp(src_fs_entry.path, dst_fs_entry.path, src_fs_entry.ftype)
end

function M.plan.delete(fs_entry)
  return { type = "delete", fs_entry = fs_entry }
end

function M.actions.delete(args)
  local fs_entry = args.fs_entry
  local err = rm(fs_entry.path, fs_entry.ftype)
  if err ~= nil then
    return string.format("Delete %s: %s", fs_entry.path, err)
  end

  delete_loaded_buffers(fs_entry.path)
  return nil
end

function M.plan.move(src_fs_entry, dst_fs_entry)
  return { type = "move", src_fs_entry = src_fs_entry, dst_fs_entry = dst_fs_entry }
end

function M.actions.move(args)
  local src_fs_entry, dst_fs_entry = args.src_fs_entry, args.dst_fs_entry
  -- planner ensures src and dst have same ftype
  local err = mv(src_fs_entry.path, dst_fs_entry.path, src_fs_entry.ftype)
  if err ~= nil then
    return string.format("Move failed for %s -> %s: %s", src_fs_entry.path, dst_fs_entry.path, err)
  end

  rename_loaded_buffers(src_fs_entry.path, dst_fs_entry.path)
  return nil
end

return M
