--- Path manipulation and file queries.
--
-- This is modelled after Python's os.path library (10.1); see @{04-paths.md|the Guide}.
--
-- Dependencies: `tagen.core`, `lfs`
-- @module tagen.path

-- imports and locals
local _G = _G
local sub = string.sub
local getenv = os.getenv
local tmpnam = os.tmpname
local attributes, currentdir, link_attrib
local package = package
local io = io
local append = table.insert
local ipairs = ipairs
local tagen = require 'tagen.core'
local assert_arg,assert_string,raise = tagen.assert_arg,tagen.assert_string,tagen.raise

local attrib
local path = {}

local res,lfs = _G.pcall(_G.require,'lfs')
if res then
  attributes = lfs.attributes
  currentdir = lfs.currentdir
  link_attrib = lfs.symlinkattributes
else
  error("tagen.path requires LuaFileSystem")
end

attrib = attributes
path.attrib = attrib
path.link_attrib = link_attrib
path.dir = lfs.dir
path.mkdir = lfs.mkdir
path.rmdir = lfs.rmdir
path.chdir = lfs.chdir

--- is this a directory?
-- @param P A file path
function path.isdir(P)
	assert_string(1,P)
  if P:match("\\$") then
    P = P:sub(1,-2)
  end
  return attrib(P,'mode') == 'directory'
end

--- is this a file?.
-- @param P A file path
function path.isfile(P)
	assert_string(1,P)
  return attrib(P,'mode') == 'file'
end

-- is this a symbolic link?
-- @param P A file path
function path.islink(P)
	assert_string(1,P)
  if link_attrib then
    return link_attrib(P,'mode')=='link'
  else
    return false
  end
end

--- return size of a file.
-- @param P A file path
function path.getsize(P)
	assert_string(1,P)
  return attrib(P,'size')
end

--- does a path exist?.
-- @param P A file path
-- @return the file path if it exists, nil otherwise
function path.exists(P)
	assert_string(1,P)
  return attrib(P,'mode') ~= nil and P
end

--- Return the time of last access as the number of seconds since the epoch.
-- @param P A file path
function path.getatime(P)
	assert_string(1,P)
  return attrib(P,'access')
end

--- Return the time of last modification
-- @param P A file path
function path.getmtime(P)
  return attrib(P,'modification')
end

---Return the system's ctime.
-- @param P A file path
function path.getctime(P)
	assert_string(1,P)
  return path.attrib(P,'change')
end


local function at(s,i)
  return sub(s,i,i)
end

path.is_windows = tagen.dir_separator == '\\'

local other_sep
-- !constant sep is the directory separator for this platform.
if path.is_windows then
  path.sep = '\\'; other_sep = '/'
  path.dirsep = ';'
else
  path.sep = '/'
  path.dirsep = ':'
end
local sep,dirsep = path.sep,path.dirsep

--- are we running Windows?
-- @class field
-- @name path.is_windows

--- path separator for this platform.
-- @class field
-- @name path.sep

--- separator for PATH for this platform
-- @class field
-- @name path.dirsep

--- given a path, return the directory part and a file part.
-- if there's no directory part, the first value will be empty
-- @param P A file path
function path.splitpath(P)
  assert_string(1,P)
  local i = #P
  local ch = at(P,i)
  while i > 0 and ch ~= sep and ch ~= other_sep do
    i = i - 1
    ch = at(P,i)
  end
  if i == 0 then
    return '',P
  else
    return sub(P,1,i-1), sub(P,i+1)
  end
end

--- return an absolute path.
-- @param P A file path
-- @param pwd optional start path to use (default is current dir)
function path.abspath(P,pwd)
  assert_string(1,P)
	if pwd then assert_string(2,pwd) end
  local use_pwd = pwd ~= nil
  if not use_pwd and not currentdir then return P end
  P = P:gsub('[\\/]$','')
  pwd = pwd or currentdir()
  if not path.isabs(P) then
    P = path.join(pwd,P)
  elseif path.is_windows and not use_pwd and at(P,2) ~= ':' and at(P,2) ~= '\\' then
    P = pwd:sub(1,2)..P -- attach current drive to path like '\\fred.txt'
  end
  return path.normpath(P)
end

--- given a path, return the root part and the extension part.
-- if there's no extension part, the second value will be empty
-- @param P A file path
function path.splitext(P)
  assert_string(1,P)
  local i = #P
  local ch = at(P,i)
  while i > 0 and ch ~= '.' do
    if ch == sep or ch == other_sep then
      return P,''
    end
    i = i - 1
    ch = at(P,i)
  end
  if i == 0 then
    return P,''
  else
    return sub(P,1,i-1),sub(P,i)
  end
end

--- return the directory part of a path
-- @param P A file path
function path.dirname(P)
  assert_string(1,P)
  local p1,p2 = path.splitpath(P)
  return p1
end

--- return the file part of a path
-- @param P A file path
function path.basename(P)
  assert_string(1,P)
  local p1,p2 = path.splitpath(P)
  return p2
end

--- get the extension part of a path.
-- @param P A file path
function path.extension(P)
  assert_string(1,P)
  local p1,p2 = path.splitext(P)
  return p2
end

--- is this an absolute path?.
-- @param P A file path
function path.isabs(P)
  assert_string(1,P)
  if path.is_windows then
    return at(P,1) == '/' or at(P,1)=='\\' or at(P,2)==':'
  else
    return at(P,1) == '/'
  end
end

--- return the path resulting from combining the individual paths.
-- if the second path is absolute, we return that path.
-- @param p1 A file path
-- @param p2 A file path
-- @param ... more file paths
function path.join(p1,p2,...)
  assert_string(1,p1)
  assert_string(2,p2)
  if select('#',...) > 0 then
    local p = path.join(p1,p2)
    local args = {...}
    for i = 1,#args do
      assert_string(i,args[i])
      p = path.join(p,args[i])
    end
    return p
  end
  if path.isabs(p2) then return p2 end
  local endc = at(p1,#p1)
  if endc ~= path.sep and endc ~= other_sep then
    p1 = p1..path.sep
  end
  return p1..p2
end

--- normalize the case of a pathname. On Unix, this returns the path unchanged;
--  for Windows, it converts the path to lowercase, and it also converts forward slashes
-- to backward slashes.
-- @param P A file path
function path.normcase(P)
  assert_string(1,P)
  if path.is_windows then
    return (P:lower():gsub('/','\\'))
  else
    return P
  end
end

local np_gen1,np_gen2 = '[^SEP]+SEP%.%.SEP?','SEP+%.?SEP'
local np_pat1, np_pat2

--- normalize a path name.
--  A//B, A/./B and A/foo/../B all become A/B.
-- @param P a file path
function path.normpath(P)
  assert_string(1,P)
  if path.is_windows then
    if P:match '^\\\\' then -- UNC
      return '\\\\'..path.normpath(P:sub(3))
    end
    P = P:gsub('/','\\')
  end
  if not np_pat1 then
    np_pat1 = np_gen1:gsub('SEP',sep)
    np_pat2 = np_gen2:gsub('SEP',sep)
  end
  local k
  repeat -- /./ -> /
    P,k = P:gsub(np_pat2,sep)
  until k == 0
  repeat -- A/../ -> (empty)
    P,k = P:gsub(np_pat1,'')
  until k == 0
  if P == '' then P = '.' end
  return P
end

local function ATS (P)
  if at(P,#P) ~= path.sep then
    P = P..path.sep
  end
  return path.normcase(P)
end

--- relative path from current directory or optional start point
-- @param P a path
-- @param start optional start point (default current directory)
function path.relpath (P,start)
  assert_string(1,P)
	if start then assert_string(2,start) end
  local split,normcase,min,append = tagen.split, path.normcase, math.min, table.insert
  P = normcase(path.abspath(P,start))
  start = start or currentdir()
  start = normcase(start)
  local startl, Pl = split(start,sep), split(P,sep)
  local n = min(#startl,#Pl)
  local k = n+1 -- default value if this loop doesn't bail out!
  for i = 1,n do
    if startl[i] ~= Pl[i] then
      k = i
      break
    end
  end
  local rell = {}
  for i = 1, #startl-k+1 do rell[i] = '..' end
  if k <= #Pl then
    for i = k,#Pl do append(rell,Pl[i]) end
  end
  return table.concat(rell,sep)
end


--- Replace a starting '~' with the user's home directory.
-- In windows, if HOME isn't set, then USERPROFILE is used in preference to
-- HOMEDRIVE HOMEPATH. This is guaranteed to be writeable on all versions of Windows.
-- @param P A file path
function path.expanduser(P)
  assert_string(1,P)
  if at(P,1) == '~' then
    local home = getenv('HOME')
    if not home then -- has to be Windows
      home = getenv 'USERPROFILE' or (getenv 'HOMEDRIVE' .. getenv 'HOMEPATH')
    end
    return home..sub(P,2)
  else
    return P
  end
end


---Return a suitable full path to a new temporary file name.
-- unlike os.tmpnam(), it always gives you a writeable path (uses %TMP% on Windows)
function path.tmpname ()
  local res = tmpnam()
  if path.is_windows then res = getenv('TMP')..res end
  return res
end

--- return the largest common prefix path of two paths.
-- @param path1 a file path
-- @param path2 a file path
function path.common_prefix (path1,path2)
  assert_string(1,path1)
  assert_string(2,path2)
  path1, path2 = path.normcase(path1), path.normcase(path2)
  -- get them in order!
  if #path1 > #path2 then path2,path1 = path1,path2 end
  for i = 1,#path1 do
    local c1 = at(path1,i)
    if c1 ~= at(path2,i) then
      local cp = path1:sub(1,i-1)
      if at(path1,i-1) ~= sep then
        cp = path.dirname(cp)
      end
      return cp
    end
  end
  if at(path2,#path1+1) ~= sep then path1 = path.dirname(path1) end
  return path1
  --return ''
end


--- return the full path where a particular Lua module would be found.
-- Both package.path and package.cpath is searched, so the result may
-- either be a Lua file or a shared libarary.
-- @param mod name of the module
-- @return on success: path of module, lua or binary
-- @return on error: nil,error string
function path.package_path(mod)
  assert_string(1,mod)
  local res
  mod = mod:gsub('%.',sep)
  res = package.searchpath(mod,package.path)
  if res then return res,true end
  res = package.searchpath(mod,package.cpath)
  if res then return res,false end
  return raise 'cannot find module on path'
end


---- finis -----
return path
