--- Path.lua
---
--- Goal: Create objects that are extremely similar to Python's `Path` Objects.
--- Reference: https://docs.python.org/3/library/pathlib.html

local bit = require "plenary.bit"
local uv = vim.loop

local F = require "plenary.functional"

local S_IF = {
  -- S_IFDIR  = 0o040000  # directory
  DIR = 0x4000,
  -- S_IFREG  = 0o100000  # regular file
  REG = 0x8000,
}

local path = {}
path.home = vim.loop.os_homedir()

path.sep = (function()
  if jit then
    local os = string.lower(jit.os)
    if os == "linux" or os == "osx" or os == "bsd" then
      return "/"
    else
      return "\\"
    end
  else
    return package.config:sub(1, 1)
  end
end)()

path.root = (function()
  if path.sep == "/" then
    return function()
      return "/"
    end
  else
    return function(base)
      base = base or vim.loop.cwd()
      return base:sub(1, 1) .. ":\\"
    end
  end
end)()

path.S_IF = S_IF

local band = function(reg, value)
  return bit.band(reg, value) == reg
end

local concat_paths = function(...)
  return table.concat({ ... }, path.sep)
end

local function is_root(pathname)
  if path.sep == "\\" then
    return string.match(pathname, "^[A-Z]:\\?$")
  end
  return pathname == "/"
end

local _split_by_separator = (function()
  local formatted = string.format("([^%s]+)", path.sep)
  return function(filepath)
    local t = {}
    for str in string.gmatch(filepath, formatted) do
      table.insert(t, str)
    end
    return t
  end
end)()

local is_uri = function(filename)
  return string.match(filename, "^%w+://") ~= nil
end

local is_absolute = function(filename, sep)
  if sep == "\\" then
    return string.match(filename, "^[A-Z]:\\.*$")
  end
  return string.sub(filename, 1, 1) == sep
end

local function _normalize_path(filename, cwd)
  if is_uri(filename) then
    return filename
  end

  -- handles redundant `./` in the middle
  local redundant = path.sep .. "%." .. path.sep
  if filename:match(redundant) then
    filename = filename:gsub(redundant, path.sep)
  end

  local out_file = filename

  local has = string.find(filename, path.sep .. "..", 1, true) or string.find(filename, ".." .. path.sep, 1, true)

  if has then
    local parts = _split_by_separator(filename)

    local idx = 1
    local initial_up_count = 0

    repeat
      if parts[idx] == ".." then
        if idx == 1 then
          initial_up_count = initial_up_count + 1
        end
        table.remove(parts, idx)
        table.remove(parts, idx - 1)
        if idx > 1 then
          idx = idx - 2
        else
          idx = idx - 1
        end
      end
      idx = idx + 1
    until idx > #parts

    local prefix = ""
    if is_absolute(filename, path.sep) or #_split_by_separator(cwd) == initial_up_count then
      prefix = path.root(filename)
    end

    out_file = prefix .. table.concat(parts, path.sep)
  end

  return out_file
end

local clean = function(pathname)
  if is_uri(pathname) then
    return pathname
  end

  -- Remove double path seps, it's annoying
  pathname = pathname:gsub(path.sep .. path.sep, path.sep)

  -- Remove trailing path sep if not root
  if not is_root(pathname) and pathname:sub(-1) == path.sep then
    return pathname:sub(1, -2)
  end
  return pathname
end

-- S_IFCHR  = 0o020000  # character device
-- S_IFBLK  = 0o060000  # block device
-- S_IFIFO  = 0o010000  # fifo (named pipe)
-- S_IFLNK  = 0o120000  # symbolic link
-- S_IFSOCK = 0o140000  # socket file

---@class Path
local Path = {
  path = path,
}

local check_self = function(self)
  if type(self) == "string" then
    return Path:new(self)
  end

  return self
end

Path.__index = Path

-- TODO: Could use this to not have to call new... not sure
-- Path.__call = Path:new

Path.__div = function(self, other)
  assert(Path.is_path(self))
  assert(Path.is_path(other) or type(other) == "string")

  return self:joinpath(other)
end

Path.__tostring = function(self)
  return self.filename
end

-- TODO: See where we concat the table, and maybe we could make this work.
Path.__concat = function(self, other)
  return self.filename .. other
end

Path.is_path = function(a)
  return getmetatable(a) == Path
end

function Path:new(...)
  local args = { ... }

  if type(self) == "string" then
    table.insert(args, 1, self)
    self = Path
  end

  local path_input
  if #args == 1 then
    path_input = args[1]
  else
    path_input = args
  end

  -- If we already have a Path, it's fine.
  --   Just return it
  if Path.is_path(path_input) then
    return path_input
  end

  -- TODO: Should probably remove and dumb stuff like double seps, periods in the middle, etc.
  local sep = path.sep
  if type(path_input) == "table" then
    sep = path_input.sep or path.sep
    path_input.sep = nil
  end

  local path_string
  if type(path_input) == "table" then
    -- TODO: It's possible this could be done more elegantly with __concat
    --       But I'm unsure of what we'd do to make that happen
    local path_objs = {}
    for _, v in ipairs(path_input) do
      if Path.is_path(v) then
        table.insert(path_objs, v.filename)
      else
        assert(type(v) == "string")
        table.insert(path_objs, v)
      end
    end

    path_string = table.concat(path_objs, sep)
  else
    assert(type(path_input) == "string", vim.inspect(path_input))
    path_string = path_input
  end

  local obj = {
    filename = path_string,

    _sep = sep,

    -- Cached values
    _absolute = uv.fs_realpath(path_string),
    _cwd = uv.fs_realpath ".",
  }

  setmetatable(obj, Path)

  return obj
end

function Path:_fs_filename()
  return self:absolute() or self.filename
end

function Path:_stat()
  return uv.fs_stat(self:_fs_filename()) or {}
  -- local stat = uv.fs_stat(self:absolute())
  -- if not self._absolute then return {} end

  -- if not self._stat_result then
  --   self._stat_result =
  -- end

  -- return self._stat_result
end

function Path:_st_mode()
  return self:_stat().mode or 0
end

function Path:joinpath(...)
  return Path:new(self.filename, ...)
end

function Path:absolute()
  if self:is_absolute() then
    return _normalize_path(self.filename, self._cwd)
  else
    return _normalize_path(self._absolute or table.concat({ self._cwd, self.filename }, self._sep), self._cwd)
  end
end

function Path:exists()
  return not vim.tbl_isempty(self:_stat())
end

function Path:expand()
  if is_uri(self.filename) then
    return self.filename
  end

  -- TODO support windows
  local expanded
  if string.find(self.filename, "~") then
    expanded = string.gsub(self.filename, "^~", vim.loop.os_homedir())
  elseif string.find(self.filename, "^%.") then
    expanded = vim.loop.fs_realpath(self.filename)
    if expanded == nil then
      expanded = vim.fn.fnamemodify(self.filename, ":p")
    end
  elseif string.find(self.filename, "%$") then
    local rep = string.match(self.filename, "([^%$][^/]*)")
    local val = os.getenv(rep)
    if val then
      expanded = string.gsub(string.gsub(self.filename, rep, val), "%$", "")
    else
      expanded = nil
    end
  else
    expanded = self.filename
  end
  return expanded and expanded or error "Path not valid"
end

function Path:make_relative(cwd)
  if is_uri(self.filename) then
    return self.filename
  end

  self.filename = clean(self.filename)
  cwd = clean(F.if_nil(cwd, self._cwd, cwd))
  if self.filename == cwd then
    self.filename = "."
  else
    if cwd:sub(#cwd, #cwd) ~= path.sep then
      cwd = cwd .. path.sep
    end

    if self.filename:sub(1, #cwd) == cwd then
      self.filename = self.filename:sub(#cwd + 1, -1)
    end
  end

  return self.filename
end

function Path:normalize(cwd)
  if is_uri(self.filename) then
    return self.filename
  end

  self:make_relative(cwd)

  -- Substitute home directory w/ "~"
  -- string.gsub is not useful here because usernames with dashes at the end
  -- will be seen as a regexp pattern rather than a raw string
  local home = path.home
  if string.sub(path.home, -1) ~= path.sep then
    home = home .. path.sep
  end
  local start, finish = string.find(self.filename, home, 1, true)
  if start == 1 then
    self.filename = "~" .. path.sep .. string.sub(self.filename, (finish + 1), -1)
  end

  return _normalize_path(clean(self.filename), self._cwd)
end

local function shorten_len(filename, len, exclude)
  len = len or 1
  exclude = exclude or { -1 }
  local exc = {}

  -- get parts in a table
  local parts = {}
  local empty_pos = {}
  for m in (filename .. path.sep):gmatch("(.-)" .. path.sep) do
    if m ~= "" then
      parts[#parts + 1] = m
    else
      table.insert(empty_pos, #parts + 1)
    end
  end

  for _, v in pairs(exclude) do
    if v < 0 then
      exc[v + #parts + 1] = true
    else
      exc[v] = true
    end
  end

  local final_path_components = {}
  local count = 1
  for _, match in ipairs(parts) do
    if not exc[count] and #match > len then
      table.insert(final_path_components, string.sub(match, 1, len))
    else
      table.insert(final_path_components, match)
    end
    table.insert(final_path_components, path.sep)
    count = count + 1
  end

  local l = #final_path_components -- so that we don't need to keep calculating length
  table.remove(final_path_components, l) -- remove final slash

  -- add back empty positions
  for i = #empty_pos, 1, -1 do
    table.insert(final_path_components, empty_pos[i], path.sep)
  end

  return table.concat(final_path_components)
end

local shorten = (function()
  if jit and path.sep ~= "\\" then
    local ffi = require "ffi"
    ffi.cdef [[
    typedef unsigned char char_u;
    void shorten_dir(char_u *str);
    ]]
    return function(filename)
      if not filename or is_uri(filename) then
        return filename
      end

      local c_str = ffi.new("char[?]", #filename + 1)
      ffi.copy(c_str, filename)
      ffi.C.shorten_dir(c_str)
      return ffi.string(c_str)
    end
  end
  return function(filename)
    return shorten_len(filename, 1)
  end
end)()

function Path:shorten(len, exclude)
  assert(len ~= 0, "len must be at least 1")
  if (len and len > 1) or exclude ~= nil then
    return shorten_len(self.filename, len, exclude)
  end
  return shorten(self.filename)
end

function Path:mkdir(opts)
  opts = opts or {}

  local mode = opts.mode or 448 -- 0700 -> decimal
  local parents = F.if_nil(opts.parents, false, opts.parents)
  local exists_ok = F.if_nil(opts.exists_ok, true, opts.exists_ok)

  local exists = self:exists()
  if not exists_ok and exists then
    error("FileExistsError:" .. self:absolute())
  end

  -- fs_mkdir returns nil if folder exists
  if not uv.fs_mkdir(self:_fs_filename(), mode) and not exists then
    if parents then
      local dirs = self:_split()
      local processed = ""
      for _, dir in ipairs(dirs) do
        if dir ~= "" then
          local joined = concat_paths(processed, dir)
          if processed == "" and self._sep == "\\" then
            joined = dir
          end
          local stat = uv.fs_stat(joined) or {}
          local file_mode = stat.mode or 0
          if band(S_IF.REG, file_mode) then
            error(string.format("%s is a regular file so we can't mkdir it", joined))
          elseif band(S_IF.DIR, file_mode) then
            processed = joined
          else
            if uv.fs_mkdir(joined, mode) then
              processed = joined
            else
              error("We couldn't mkdir: " .. joined)
            end
          end
        end
      end
    else
      error "FileNotFoundError"
    end
  end

  return true
end

function Path:rmdir()
  if not self:exists() then
    return
  end

  uv.fs_rmdir(self:absolute())
end

function Path:rename(opts)
  opts = opts or {}
  if not opts.new_name or opts.new_name == "" then
    error "Please provide the new name!"
  end

  -- handles `.`, `..`, `./`, and `../`
  if opts.new_name:match "^%.%.?/?\\?.+" then
    opts.new_name = {
      uv.fs_realpath(opts.new_name:sub(1, 3)),
      opts.new_name:sub(4, #opts.new_name),
    }
  end

  local new_path = Path:new(opts.new_name)

  if new_path:exists() then
    error "File or directory already exists!"
  end

  local status = uv.fs_rename(self:absolute(), new_path:absolute())
  self.filename = new_path.filename

  return status
end

--- Copy files or folders with defaults akin to GNU's `cp`.
---@param opts table: options to pass to toggling registered actions
---@field destination string|Path: target file path to copy to
---@field recursive bool: whether to copy folders recursively (default: false)
---@field override bool: whether to override files (default: true)
---@field interactive bool: confirm if copy would override; precedes `override` (default: false)
---@field respect_gitignore bool: skip folders ignored by all detected `gitignore`s (default: false)
---@field hidden bool: whether to add hidden files in recursively copying folders (default: true)
---@field parents bool: whether to create possibly non-existing parent dirs of `opts.destination` (default: false)
---@field exists_ok bool: whether ok if `opts.destination` exists, if so folders are merged (default: true)
---@return table {[Path of destination]: bool} indicating success of copy; nested tables constitute sub dirs
function Path:copy(opts)
  opts = opts or {}
  opts.recursive = F.if_nil(opts.recursive, false, opts.recursive)
  opts.override = F.if_nil(opts.override, true, opts.override)

  local dest = opts.destination
  -- handles `.`, `..`, `./`, and `../`
  if not Path.is_path(dest) then
    if type(dest) == "string" and dest:match "^%.%.?/?\\?.+" then
      dest = {
        uv.fs_realpath(dest:sub(1, 3)),
        dest:sub(4, #dest),
      }
    end
    dest = Path:new(dest)
  end
  -- success is true in case file is copied, false otherwise
  local success = {}
  if not self:is_dir() then
    if opts.interactive and dest:exists() then
      vim.ui.select(
        { "Yes", "No" },
        { prompt = string.format("Overwrite existing %s?", dest:absolute()) },
        function(_, idx)
          success[dest] = uv.fs_copyfile(self:absolute(), dest:absolute(), { excl = not (idx == 1) }) or false
        end
      )
    else
      -- nil: not overriden if `override = false`
      success[dest] = uv.fs_copyfile(self:absolute(), dest:absolute(), { excl = not opts.override }) or false
    end
    return success
  end
  -- dir
  if opts.recursive then
    dest:mkdir {
      parents = F.if_nil(opts.parents, false, opts.parents),
      exists_ok = F.if_nil(opts.exists_ok, true, opts.exists_ok),
    }
    local scan = require "plenary.scandir"
    local data = scan.scan_dir(self.filename, {
      respect_gitignore = F.if_nil(opts.respect_gitignore, false, opts.respect_gitignore),
      hidden = F.if_nil(opts.hidden, true, opts.hidden),
      depth = 1,
      add_dirs = true,
    })
    for _, entry in ipairs(data) do
      local entry_path = Path:new(entry)
      local suffix = table.remove(entry_path:_split())
      local new_dest = dest:joinpath(suffix)
      -- clear destination as it might be Path table otherwise failing w/ extend
      opts.destination = nil
      local new_opts = vim.tbl_deep_extend("force", opts, { destination = new_dest })
      -- nil: not overriden if `override = false`
      success[new_dest] = entry_path:copy(new_opts) or false
    end
    return success
  else
    error(string.format("Warning: %s was not copied as `recursive=false`", self:absolute()))
  end
end

function Path:touch(opts)
  opts = opts or {}

  local mode = opts.mode or 420
  local parents = F.if_nil(opts.parents, false, opts.parents)

  if self:exists() then
    local new_time = os.time()
    uv.fs_utime(self:_fs_filename(), new_time, new_time)
    return
  end

  if parents then
    Path:new(self:parent()):mkdir { parents = true }
  end

  local fd = uv.fs_open(self:_fs_filename(), "w", mode)
  if not fd then
    error("Could not create file: " .. self:_fs_filename())
  end
  uv.fs_close(fd)

  return true
end

function Path:rm(opts)
  opts = opts or {}

  local recursive = F.if_nil(opts.recursive, false, opts.recursive)
  if recursive then
    local scan = require "plenary.scandir"
    local abs = self:absolute()

    -- first unlink all files
    scan.scan_dir(abs, {
      hidden = true,
      on_insert = function(file)
        uv.fs_unlink(file)
      end,
    })

    local dirs = scan.scan_dir(abs, { add_dirs = true, hidden = true })
    -- iterate backwards to clean up remaining dirs
    for i = #dirs, 1, -1 do
      uv.fs_rmdir(dirs[i])
    end

    -- now only abs is missing
    uv.fs_rmdir(abs)
  else
    uv.fs_unlink(self:absolute())
  end
end

-- Path:is_* {{{
function Path:is_dir()
  -- TODO: I wonder when this would be better, if ever.
  -- return self:_stat().type == 'directory'

  return band(S_IF.DIR, self:_st_mode())
end

function Path:is_absolute()
  return is_absolute(self.filename, self._sep)
end
-- }}}

function Path:_split()
  return vim.split(self:absolute(), self._sep)
end

local _get_parent = (function()
  local formatted = string.format("^(.+)%s[^%s]+", path.sep, path.sep)
  return function(abs_path)
    return abs_path:match(formatted)
  end
end)()

function Path:parent()
  return Path:new(_get_parent(self:absolute()) or path.root(self:absolute()))
end

function Path:parents()
  local results = {}
  local cur = self:absolute()
  repeat
    cur = _get_parent(cur)
    table.insert(results, cur)
  until not cur
  table.insert(results, path.root(self:absolute()))
  return results
end

function Path:is_file()
  local stat = uv.fs_stat(self:expand())
  if stat then
    return stat.type == "file" and true or nil
  end
end

-- TODO:
--  Maybe I can use libuv for this?
function Path:open() end

function Path:close() end

function Path:write(txt, flag, mode)
  assert(flag, [[Path:write_text requires a flag! For example: 'w' or 'a']])

  mode = mode or 438

  local fd = assert(uv.fs_open(self:expand(), flag, mode))
  assert(uv.fs_write(fd, txt, -1))
  assert(uv.fs_close(fd))
end

-- TODO: Asyncify this and use vim.wait in the meantime.
--  This will allow other events to happen while we're waiting!
function Path:_read()
  self = check_self(self)

  local fd = assert(uv.fs_open(self:expand(), "r", 438)) -- for some reason test won't pass with absolute
  local stat = assert(uv.fs_fstat(fd))
  local data = assert(uv.fs_read(fd, stat.size, 0))
  assert(uv.fs_close(fd))

  return data
end

function Path:_read_async(callback)
  vim.loop.fs_open(self.filename, "r", 438, function(err_open, fd)
    if err_open then
      print("We tried to open this file but couldn't. We failed with following error message: " .. err_open)
      return
    end
    vim.loop.fs_fstat(fd, function(err_fstat, stat)
      assert(not err_fstat, err_fstat)
      if stat.type ~= "file" then
        return callback ""
      end
      vim.loop.fs_read(fd, stat.size, 0, function(err_read, data)
        assert(not err_read, err_read)
        vim.loop.fs_close(fd, function(err_close)
          assert(not err_close, err_close)
          return callback(data)
        end)
      end)
    end)
  end)
end

function Path:read(callback)
  if callback then
    return self:_read_async(callback)
  end
  return self:_read()
end

function Path:head(lines)
  lines = lines or 10
  self = check_self(self)
  local chunk_size = 256

  local fd = uv.fs_open(self:expand(), "r", 438)
  if not fd then
    return
  end
  local stat = assert(uv.fs_fstat(fd))
  if stat.type ~= "file" then
    uv.fs_close(fd)
    return nil
  end

  local data = ""
  local index, count = 0, 0
  while count < lines and index < stat.size do
    local read_chunk = assert(uv.fs_read(fd, chunk_size, index))

    local i = 0
    for char in read_chunk:gmatch "." do
      if char == "\n" then
        count = count + 1
        if count >= lines then
          break
        end
      end
      index = index + 1
      i = i + 1
    end
    data = data .. read_chunk:sub(1, i)
  end
  assert(uv.fs_close(fd))

  -- Remove potential newline at end of file
  if data:sub(-1) == "\n" then
    data = data:sub(1, -2)
  end

  return data
end

function Path:tail(lines)
  lines = lines or 10
  self = check_self(self)
  local chunk_size = 256

  local fd = uv.fs_open(self:expand(), "r", 438)
  if not fd then
    return
  end
  local stat = assert(uv.fs_fstat(fd))
  if stat.type ~= "file" then
    uv.fs_close(fd)
    return nil
  end

  local data = ""
  local index, count = stat.size - 1, 0
  while count < lines and index > 0 do
    local real_index = index - chunk_size
    if real_index < 0 then
      chunk_size = chunk_size + real_index
      real_index = 0
    end

    local read_chunk = assert(uv.fs_read(fd, chunk_size, real_index))

    local i = #read_chunk
    while i > 0 do
      local char = read_chunk:sub(i, i)
      if char == "\n" then
        count = count + 1
        if count >= lines then
          break
        end
      end
      index = index - 1
      i = i - 1
    end
    data = read_chunk:sub(i + 1, #read_chunk) .. data
  end
  assert(uv.fs_close(fd))

  return data
end

function Path:readlines()
  self = check_self(self)

  local data = self:read()

  data = data:gsub("\r", "")
  return vim.split(data, "\n")
end

function Path:iter()
  local data = self:readlines()
  local i = 0
  local n = #data
  return function()
    i = i + 1
    if i <= n then
      return data[i]
    end
  end
end

function Path:readbyterange(offset, length)
  self = check_self(self)

  local fd = uv.fs_open(self:expand(), "r", 438)
  if not fd then
    return
  end
  local stat = assert(uv.fs_fstat(fd))
  if stat.type ~= "file" then
    uv.fs_close(fd)
    return nil
  end

  if offset < 0 then
    offset = stat.size + offset
    -- Windows fails if offset is < 0 even though offset is defined as signed
    -- http://docs.libuv.org/en/v1.x/fs.html#c.uv_fs_read
    if offset < 0 then
      offset = 0
    end
  end

  local data = ""
  while #data < length do
    local read_chunk = assert(uv.fs_read(fd, length - #data, offset))
    if #read_chunk == 0 then
      break
    end
    data = data .. read_chunk
    offset = offset + #read_chunk
  end

  assert(uv.fs_close(fd))

  return data
end

return Path