-- gitano.util
--
-- Low level utility routines for Gitano
--
-- Copyright 2012-2017 Daniel Silverstone <dsilvers@digital-scurf.org>
-- All rights reserved.
--
-- Redistribution and use in source and binary forms, with or without
-- modification, are permitted provided that the following conditions
-- are met:
-- 1. Redistributions of source code must retain the above copyright
--    notice, this list of conditions and the following disclaimer.
-- 2. Redistributions in binary form must reproduce the above copyright
--    notice, this list of conditions and the following disclaimer in the
--    documentation and/or other materials provided with the distribution.
-- 3. Neither the name of the author nor the names of their contributors
--    may be used to endorse or promote products derived from this software
--    without specific prior written permission.
--
-- THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
-- ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-- ARE DISCLAIMED.  IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
-- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
-- OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
-- HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
-- LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
-- OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
-- SUCH DAMAGE.
--
--
--

local luxio = require 'luxio'
local sio = require 'luxio.simple'
local log = require 'gitano.log'
local i18n = require 'gitano.i18n'
local scrypt = require 'scrypt'

local tconcat = table.concat

local check_password = scrypt.verify_password

local function run_command(cmd, cmdline, parsed_cmdline, user,
                           config, env, repo)
   log.debug(i18n.expand("DEBUG_WELCOME_TO", {site=config.global.site_name}))
   log.debug(i18n.expand("DEBUG_RUNNING"))
   for i = 1, #parsed_cmdline do
      log.debug(" => " .. parsed_cmdline[i])
   end
   log.debug("")
   log.debug(i18n.expand("DEBUG_ON_BEHALF_OF", {user=user, key=env["GITANO_KEYTAG"]}))

   local how, why = cmd.run(config, repo, parsed_cmdline, env)

   if how ~= "exit" or why ~= 0 then
      if not cmd.suppress_error_msgs then
         log.critical(i18n.expand("ERROR_RUNNING_COMMAND",
                                  {cmd=parsed_cmdline[1], reason=how, code=why}))
         log.critical(i18n.expand("UNABLE_TO_CONTINUE"))
      end
      return why, cmd.suppress_error_msgs
   else
      log.syslog.info(i18n.expand("MSG_COMPLETED_SUCCESSFULLY",
                                  {cmd=cmdline}))
      return 0, false
   end
end

local function hash_password(password)
   -- For the moment we are using scrypt,
   -- we may decide to use other hash functions in the future
   -- so it's useful to provide some way to identify which hash
   -- function was used
   return 'scrypt', scrypt.hash_password(password, 2^14, 8, 1)
end

local function _deep_copy(t, memo)
   if not memo then memo = {} end
   if memo[t] then return memo[t] end
   local ret = {}
   local kk, vv
   for k, v in pairs(t) do
      kk, vv = k, v
      if type(k) == "table" then
         kk = _deep_copy(k, memo)
      end
      if type(v) == "table" then
         vv = _deep_copy(v, memo)
      end
      ret[kk] = vv
   end
   return ret
end

local function _parse_cmdline(cmdline)
   local r = {}
   local acc = ""
   local c
   local escaping = false
   local quoting = false
   while #cmdline > 0 do
      c, cmdline = cmdline:match("^(.)(.*)$")
      if escaping then
         if c == "n" then
            acc = acc .. "\n"
         elseif c == "t" then
            acc = acc .. "\t"
         else
            acc = acc .. c
         end
         escaping = false
      else
         if c == "'" and quoting == false then
            -- Start single quotes
            quoting = c
         elseif c == '"' and quoting == false then
            -- Start double quotes
            quoting = c
         elseif c == "'" and quoting == c then
            -- End single quotes
            quoting = false
         elseif c == '"' and quoting == c then
            -- End double quotes
            quoting = false
         elseif c == "\\" then
            -- A backslash, entering escaping mode
            escaping = true
         elseif quoting then
            -- Within quotes, so accumulate
            acc = acc .. c
         elseif c == " " then
            -- A space and not quoting, so clear the accumulator
            if acc ~= "" then
               r[#r+1] = acc
            end
            acc = ""
         else
            acc = acc .. c
         end
      end
   end
   if acc ~= "" then
      r[#r+1] = acc
   end

   local warnings = {}
   if quoting then
      warnings[#warnings+1] = i18n.expand("WARN_UNTERMINATED_STRING")
   end
   if escaping then
      warnings[#warnings+1] = i18n.expand("WARN_UNUSED_ESCAPE")
   end
   if #r == 0 then
      warnings[#warnings+1] = i18n.expand("WARN_NO_COMMAND")
   end

   return r, warnings
end

local function patesc(s)
   return ((s:gsub("[%%%*%-%?%.%+%[%]%(%)]", "%%%0"):gsub("%z", "%%z")))
end

local function path_components(path)
   local ret = {}
   for elem in path:gmatch("([^/]*)/*") do
      ret[#ret+1] = elem
   end
   ret[#ret] = nil
   return ret
end

local function path_join(...)
   return tconcat({...}, "/")
end

local function dirname(path)
   local t = path_components(path)
   t[#t] = nil
   return table.concat(t, "/")
end

local function basename(path, ext)
   local t = path_components(path)
   local ret = t[#t]
   if ext then
      local pat = patesc(ext) .. "$"
      if ret:find(pat) then
         ret = ret:sub(1, -(#ext+1))
      end
   end
   return ret
end

local function mkdir_p(path, mode)
   if not mode then
      mode = sio.tomode("0755")
   end

   if path == "" or path == "/" then
      return true
   end

   if path:find("/") then
      local ok, msg = mkdir_p(dirname(path))
      if not ok then
         return ok, msg
      end
   end

   local r, err = luxio.mkdir(path, mode)
   if r < 0 then
      if err == luxio.EEXIST then
         return true
      end
      return nil, "mkdir(" .. path .. "): " .. luxio.strerror(err)
   end
   return true
end

local function rm_rf(path)
   local ret, err, dirp
   dirp, err = luxio.opendir(path)
   if not dirp then
      return false, luxio.strerror(err)
   end
   local e, i
   repeat
      e, i = luxio.readdir(dirp)
      if e == 0 then
         if i.d_name ~= "." and i.d_name ~= ".." then
            local elem = path .. "/" .. i.d_name
            ret, err = luxio.unlink(elem)
            if ret ~= 0 and err == luxio.EISDIR then
               ret, err = rm_rf(elem)
               if not ret then
                  return ret, err
               end
            elseif ret ~= 0 then
               return false, luxio.strerror(err)
            end
         end
      end
   until not e

   -- explicitly close the dir so we can remove it
   luxio.closedir(dirp)
   ret, err = luxio.rmdir(path)
   return (ret == 0), luxio.strerror(err)
end

local function _write_all(file, data)
   local towrite = #data
   local written = 0
   while written < towrite do
      local write_count, emsg = file:write(data, written)
      if not write_count then
         return false, written, emsg
      end
      written = written + write_count
   end
   return true, written
end

local function copy_file(from, to, buffer_size)
   -- Default buffer size is 4M, but can be changed
   buffer_size = buffer_size or 4 * 1024 * 1024
   local fromfile, emsg = sio.open(from, "r")
   if not fromfile then
      return false, emsg
   end
   local tofile, emsg = sio.open(to, "wce")
   if not tofile then
      return false, emsg
   end
   local write_count
   repeat
      local ok
      local bytes, emsg = fromfile:read(buffer_size)
      if (not bytes) and emsg then
         fromfile:close()
         tofile:close()
         return false, emsg
      end
      if bytes then
         ok, write_count, emsg = _write_all(tofile, bytes)
         if not ok then
            fromfile:close()
            tofile:close()
            return false, emsg
         end
      else
         write_count = 0
      end
   until write_count == 0
   fromfile:close()
   tofile:close()
   return true
end

-- Adapter function, so hardlink follows the same return convention
-- as the copy_file function
local function hardlink_file(from, to)
   local ret, err = luxio.link(from, to)
   return ret == 0, luxio.strerror(err)
end

-- TODO: optionally re-base absolute paths when target is moved
--       outside its base directory
local function copy_symlink(from, to)
   local link_target, ret, err
   ret, link_target = luxio.readlink(from)
   if ret == -1 then
      return false, luxio.strerror(link_target)
   end
   ret, err = luxio.symlink(link_target, to)
   if ret ~= 0 then
      return false, luxio.strerror(err)
   end
   return true
end

local function copy_pathname_filter(exclude_set)
   return function(parent_path, filename, fileinfo)
      return exclude_set[filename]
   end
end
local _exclude_builtin = { ["."] = true, [".."] = true }
local copy_dir_filter_base = copy_pathname_filter(_exclude_builtin)

local copy_dir_copy_callbacks
-- filter_cb is a function, which takes (parent_path, filename, fileinfo)
-- and returns true if the component should not be copied
-- parent_path is required, since filter_cb is passed on to subdirectories
-- copy_cbs is an optional table of callbacks
local function copy_dir(from, to, copy_cbs, filter_cb)
   filter_cb = filter_cb or copy_dir_filter_base
   copy_cbs = copy_cbs or copy_dir_copy_callbacks
   local ret, err, dirp
   ret, err = mkdir_p(to)
   if not ret then
      return ret, err
   end
   dirp, err, ret = sio.opendir(from)
   if not dirp then
      return ret, err
   end
   for filename, fileinfo in dirp:iterate() do
      local filefrom = path_join(from, filename)
      if fileinfo.d_type == luxio.DT_UNKNOWN then
         -- Stat and translate mode to type if type unknown
         local stat, err = sio.lstat(filefrom)
         if not stat then
            log.critical(i18n.expand("ERROR_STAT_FILE_FAILED",
                                     {file=filefrom, reason=err}))
            return false, err
         end
         fileinfo.d_type = ({
               [luxio.S_IFBLK] = luxio.DT_BLK,
               [luxio.S_IFCHR] = luxio.DT_CHR,
               [luxio.S_IFDIR] = luxio.DT_DIR,
               [luxio.S_IFIFO] = luxio.DT_FIFO,
               [luxio.S_IFLNK] = luxio.DT_LNK,
               [luxio.S_IFREG] = luxio.DT_REG,
               [luxio.S_IFSOCK] = luxio.DT_SOCK,
                           })[luxio.bit.band(stat.mode, luxio.S_IFMT)]
      end
      local fileto = path_join(to, filename)
      local copycb = copy_cbs[fileinfo.d_type]
      if not copycb then
         return false, i18n.expand("ERROR_NO_CB")
      end
      if filter_cb(from, filename, fileinfo) then
         log.ddebug(i18n.expand("MSG_SKIPPING_FILE", {file=filename}))
      elseif fileinfo.d_type == luxio.DT_REG then
         log.ddebug(i18n.expand("MSG_COPYING_FILE", {from=filefrom, to=fileto}))
         ret, err = copycb(filefrom, fileto)
         if not ret then
            log.critical(i18n.expand("ERROR_COPY_FILE_FAILED",
                                     {from=filefrom, to=fileto, reason=err}))
            return false, err
         end
      elseif fileinfo.d_type == luxio.DT_LNK then
         log.ddebug(i18n.expand("MSG_COPYING_SYMLINK", {from=filefrom, to=fileto}))
         ret, err = copycb(filefrom, fileto)
         if not ret then
            log.critical(i18n.expand("ERROR_COPY_SYMLINK_FAILED",
                                     {from=filefrom, to=fileto, reason=err}))
            return false, err
         end
      elseif fileinfo.d_type == luxio.DT_DIR then
         log.ddebug(i18n.expand("MSG_COPYING_DIR", {from=filefrom, to=fileto}))
         ret, err = copycb(filefrom, fileto, copy_cbs, filter_cb)
         if not ret then
            log.critical(i18n.expand("ERROR_COPY_DIR_FAILED",
                                     {from=filefrom, to=fileto, reason=err}))
            return ret, err
         end
      else
         return false, i18n.expand("ERROR_UNSUPPORTED_TYPE",
                                   {type=tostring(fileinfo.d_type)})
      end
   end
   return true
end

copy_dir_copy_callbacks = {
   [luxio.DT_DIR] = copy_dir,
   [luxio.DT_REG] = copy_file,
   [luxio.DT_LNK] = copy_symlink,
}

local function html_escape(s)
   return (s:gsub("&", "&amp;"):
              gsub("<", "&lt;"):
              gsub(">", "&gt;"):
              gsub('"', "&quot;"))
end

local tagname_pattern = "^[a-z0-9_%-/]*[a-z0-9_%-]*$"

local cached_expansions = {}

local function prep_expansion(str)
   -- Parse 'str' and return a table representing a sequence of
   -- operations required to evaluate the expansion of the string.
   -- in the simple case, it's merely the string in a table
   -- if the entry in ret is a string, it's copied.  If it's a table
   -- then that table's [1] is a string which is a tag name to expand.
   if cached_expansions[str] then
      return cached_expansions[str]
   end

   local ret = {}
   local acc = ""
   local c
   local seen = false
   while #str > 0 do
      c, str = str:match("^(.)(.*)$")
      if seen == false then
         if c == "$" then
            seen = c
         else
            acc = acc .. c
         end
      elseif seen == "$" then
         if c == "{" then
            seen = c
            if acc ~= "" then
               ret[#ret+1] = acc
               acc = ""
            end
         else
            acc = acc .. c
            seen = false
         end
      elseif seen == "{" then
         if c == "}" then
            seen = false
            assert(acc:match(tagname_pattern), i18n.expand("ERROR_EXPECTED_TAG_NAME"))
            ret[#ret+1] = { acc }
            acc = ""
         else
            acc = acc .. c
         end
      end
   end
   if seen == "$" then
      acc = acc .. seen
   elseif seen ~= false then
      error(i18n.expand("ERROR_UNTERMINATED_TAG"))
   end
   if acc ~= "" then
      ret[#ret+1] = acc
   end

   cached_expansions[str] = ret

   return ret
end

local function process_expansion(tags, expn, tagsactive)
   -- Expand expn with tags and return a single string
   if type(expn) == "string" then
      return expn
   end
   if not tagsactive then
      tagsactive = {}
   end
   local r = {}
   for i = 1, #expn do
      local elem = expn[i]
      if type(elem) == "string" then
         r[#r+1] = elem
      else
         elem = elem[1]
         if tagsactive[elem] then
            return do_deny(tags, i18n.expand("ERROR_LOOP_IN_EXPN"))
         end
         local tag = tags[elem]
         if type(tag) == "function" then
            tags[elem] = tag(tags)
            tag = tags[elem]
         end
         if type(tag) == "string" then
            tagsactive[elem] = true
            tag = process_expansion(tags, tag, tagsactive)
            tagsactive[elem] = nil
         else
            -- Can't implicitly expand lists etc.
            tag = ""
         end
         r[#r+1] = tag
      end
   end
   return tconcat(r)
end

local function set(t)
   local ret = {}
   for i, v in ipairs(t) do
      ret[i] = v
      ret[v] = i
   end
   return ret
end

local function add_splitable(context, key, value, splitter,
                             prefix_name, suffix_name)
   if not value or value == "" then
      return
   end
   local function _(k, v)
      context[k] = v
   end
   _(key, value)
   local prefix, suffix = value:match("^(.*%" .. splitter .. ")" ..
                                         "([^%" .. splitter .. "]+)$")

   if prefix then
      _(key .. "/" .. prefix_name, prefix:sub(1, -2))
      _(key .. "/" .. suffix_name, suffix)
   else
      _(key .. "/" .. suffix_name, value)
   end

   local i = 1
   for section in value:gmatch("([^%" .. splitter .. "]+)") do
      _(key .. "/" .. tostring(i), section)
      i = i + 1
   end
end

local tempfilecounter = 0
local function tempfile(repo)
   local repopath = repo:fs_path()
   local temppattern = path_join(
      repopath, ("gitanotmp.%d.%d.XXXXXX"):format(tempfilecounter,
                                                  luxio.getpid()))
   tempfilecounter = tempfilecounter + 1
   if luxio.mkstemp then
      return luxio.mkstemp(temppattern)
   end
   -- No mkstemp so let's hope that the %d.%d is enough...
   local fd = luxio.open(temppattern, luxio.O_RDWR + luxio.O_CREAT,
                         tonumber("700", 8))
   return fd, temppattern
end

local function lockfile(path)
   local fh = assert(sio.open(path, "cw+"))
   local ok, msg = fh:lock("w", "set", 0, 0, true, false)
   if not ok then
      fh:close()
      error(msg)
   end
   return fh
end

local function unlockfile(fh)
   fh:lock("", "set", 0, 0, true, false)
   fh:close()
end

local function ssh_type_is_invalid(keytype)
   if (keytype ~= "ssh-rsa") and
      (keytype ~= "ssh-dss") and
      (keytype ~= "ecdsa-sha2-nistp256") and
      (keytype ~= "ecdsa-sha2-nistp384") and
      (keytype ~= "ecdsa-sha2-nistp521") and
      (keytype ~= "ssh-ed25519") then
      return true
   end
   return false
end

return {
   parse_cmdline = _parse_cmdline,

   patesc = patesc,

   path_components = path_components,
   path_join = path_join,
   dirname = dirname,
   basename = basename,
   tempfile = tempfile,

   copy_symlink = copy_symlink,
   hardlink_file = hardlink_file,
   copy_file = copy_file,
   mkdir_p = mkdir_p,
   rm_rf = rm_rf,
   copy_pathname_filter = copy_pathname_filter,
   copy_dir_filter_base = copy_dir_filter_base,
   copy_dir_copy_callbacks = copy_dir_copy_callbacks,
   copy_dir = copy_dir,

   html_escape = html_escape,

   deep_copy = _deep_copy,

   prep_expansion = prep_expansion,

   add_splitable = add_splitable,
   process_expansion = process_expansion,

   set = set,

   hash_password = hash_password,
   check_password = check_password,

   run_command = run_command,

   lockfile = lockfile,
   unlockfile = unlockfile,
   ssh_type_is_invalid = ssh_type_is_invalid,
}
