-- gitano.config
--
-- Load, parse, manage etc configuration for gitano
--
-- Note: This is only the admin repo management, not the
-- generic repository rules management (see gitano.lace for that).
--
-- 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 gall = require 'gall'
local log = require 'gitano.log'
local lace = require 'gitano.lace'
local i18n = require 'gitano.i18n'
local pat = require 'gitano.patterns'
local luxio = require 'luxio'
local sio = require 'luxio.simple'
local clod = require 'clod'

local pcall = pcall
local pairs = pairs
local tconcat = table.concat

local lib_bin_path = "/tmp/DOES_NOT_EXIST"
local share_path = "/tmp/DOES_NOT_EXIST"
local repo_path = "/tmp/DOES_NOT_EXIST"

local admin_name = {
   realname = "Gitano",
   email = "gitano@gitano-admin.git"
}

local required_confs = {
   site_name = "string",
}

local function repository()
   return require 'gitano.repository'
end

-- Handy Metatable so user.real_name etc works
local user_mt = {}

function user_mt:__index(key)
   return self.clod.settings[key]
end

function user_mt:__newindex(key, value)
   self.clod.settings[key] = value
end

local function parse_admin_config(commit)
   local gittree = commit.content.tree
   local flat_tree = gall.tree.flatten(gittree.content)

   local function is_blob(thingy)
      return thingy and thingy.type and thingy.type == "blob"
   end

   if not is_blob(flat_tree["site.conf"]) then
      return nil, i18n.expand("NO_SITE_CONF")
   end
   if not is_blob(flat_tree["rules/core.lace"]) then
      return nil, i18n.expand("NO_CORE_RULES")
   end

   local conf, err =
      clod.parse(flat_tree["site.conf"].obj.content,
                 "gitano-admin:" .. commit.sha .. ":site.conf")

   if not conf then
      return nil, err
   end

   -- Parsed site.conf, check for core config entries

   for k, t in pairs(required_confs) do
      if type(conf.settings[k]) ~= t then
         return nil, i18n.expand("ERROR_SITE_CONF_TYPE",
                                 {sha=commit.sha, key=k, want=t, got=type(conf.settings[k])})
      end
   end

   -- Gather the users
   local users = {}
   for filename, obj in pairs(flat_tree) do
      local prefix, username = filename:match(pat.USER_CONF_MATCH)
      if prefix and username then
         if not is_blob(obj) then
            return nil, prefix .. username .. "/user.conf is not a blob?"
         end
         if users[username] then
            return nil, i18n.expand("ERROR_DUPLICATE_USER_NAME", {name=username})
         end

         -- Found a user, fill them out
         local user_clod, err = clod.parse(obj.obj.content,
                                           commit.sha .. ":" .. prefix .. username .. "/user.conf")

         if not user_clod then
            return nil, err
         end

         if type(user_clod.settings.real_name) ~= "string" then
            return nil, i18n.expand("ERROR_USER_MISSING_REAL_NAME",
                                    { sha=commit.sha, path=prefix..username })
         end
         if (user_clod.settings.email_address and
             type(user_clod.settings.email_address) ~= "string") then
            return nil, i18n.expand("ERROR_USER_MISSING_EMAIL_ADDRESS",
                                    { sha=commit.sha, path=prefix..username })
         end

         users[username] = setmetatable({ clod = user_clod, keys = {},
                                          meta = { prefix = prefix }, }, user_mt)
      end
   end

   -- Now gather the users' keys
   local all_keys = {}
   for filename, obj in pairs(flat_tree) do
      local prefix, username, keyname = filename:match(pat.USER_KEY_MATCH)
      if prefix and username and keyname then
         if not users[username] then
            return nil, i18n.expand("ERROR_ORPHAN_KEY",
                                    {key=keyname, user=username})
         end
         local this_key = obj.obj.content

         this_key = this_key:gsub("\n*$", "")

         if this_key:match("\n") then
            return nil, i18n.expand("ERROR_BAD_KEY_NEWLINES", {filename=filename})
         end

         local keytype, keydata, keytag = this_key:match(pat.SSH_KEY_CONTENTS)
         if not (keytype and keydata and keytag) then
            return nil, i18n.expand("ERROR_BAD_KEY_SMELL", {filename=filename})
         end
         if (keytype ~= "ssh-rsa") and (keytype ~= "ssh-dss") and
            (keytype ~= "ecdsa-sha2-nistp256") and
            (keytype ~= "ecdsa-sha2-nistp384") and
         (keytype ~= "ecdsa-sha2-nistp521") then
            return nil, i18n.expand("ERROR_BAD_KEY_TYPE",
                                    {keytype=keytype, filename=filename})
         end

         if all_keys[this_key] then
            return nil, i18n.expand(
               "ERROR_DUPLICATE_KEY",
               {keyname=keyname, username=username,
                otherkey=all_keys[this_key].keyname,
                otheruser=all_keys[this_key].username})
         end
         all_keys[this_key] = { keyname = keyname, username = username }
         users[username].keys[keyname] = {
            data = this_key,
            keyname = keyname,
            username = username,
            keytag = keytag,
         }
      end
   end

   -- Now gather the groups
   local groups = {}
   for filename, obj in pairs(flat_tree) do
      local prefix, groupname = filename:match(pat.GROUP_CONF_MATCH)
      if prefix and groupname then
         if groups[groupname] then
            return nil, i18n.expand("ERROR_DUPLICATE_GROUP", {name=groupname})
         end
         if not is_blob(obj) then
            return nil, i18n.expand("ERROR_GROUP_NOT_BLOB", {path=prefix..groupname})
         end
         local group_clod, err =
            clod.parse(obj.obj.content,
                       "gitano-admin:" .. commit.sha .. ":" ..
                          prefix .. groupname .. ".conf", true)

         if not group_clod then
            return nil, err
         end
         if type(group_clod.settings.description) ~= "string" then
            return nil, i18n.expand("ERROR_GROUP_NO_DESCRIPTION", {name=groupname})
         end
         local group_globals = {
            clod = group_clod,
            settings = group_clod.settings,
            members = {},
            subgroups = {},
         }
         for i, member in ipairs(group_clod:get_list("members")) do
            group_globals.members[i] = member
            group_globals.members[member] = i
         end
         for i, subgroup in ipairs(group_clod:get_list("subgroups")) do
            group_globals.subgroups[i] = subgroup
            group_globals.subgroups[subgroup] = i
         end
         function group_globals.changed_tables()
            group_globals.clod:set_list("members", group_globals.members)
            group_globals.clod:set_list("subgroups", group_globals.subgroups)
         end
         groups[groupname] = group_globals
         groups[groupname].meta = { prefix = prefix }
      end
   end

   -- Attempt a clean flattening of each group to ensure no loops
   for gname, gtab in pairs(groups) do
      local all_members = {}
      local all_subgroups = {}
      local function add_group(grname, grtab, path)
         if not grtab then
            log.fatal(i18n.expand("ERROR_GROUP_NOT_FOUND", {name=grname, path=path}))
            return
         end
         if all_subgroups[grname] then
            return nil, i18n.expand("ERROR_GROUP_LOOP", {path=path})
         end
         for _, un in ipairs(grtab.members) do
            if users[un] then
               all_members[un] = path
            end
         end
         all_subgroups[grname] = true
         for _, gn in ipairs(grtab.subgroups) do
            local ok, msg = add_group(gn, groups[gn], path .. "!" .. gn)
            if not ok then
               return nil, msg
            end
         end
         all_subgroups[grname] = false
         return true
      end
      local ok, msg = add_group(gname, gtab, gname)
      if not ok then
         return nil, msg
      end
      gtab.filtered_members = all_members
   end

   -- Now gather the keyrings
   local keyrings = {}
   for filename, obj in pairs(flat_tree) do
      local prefix, keyringname = filename:match(pat.KEYRING_MATCH)
      if prefix and keyringname then
         if keyrings[keyringname] then
            return nil, i18n.expand("ERROR_DUPLICATE_KEYRING", {name=keyringname})
         end
         if not is_blob(obj) then
            return nil, i18n.expand("ERROR_KEYRING_NOT_BLOB", {path=prefix .. groupname})
         end
         keyrings[keyringname] = {
            meta = { prefix = prefix },
            blob = obj.obj
         }
      end
   end

   -- Validate that the bypass user (a) exists (b) isn't in any groups
   -- and (c) has at least one key and/or a password set
   if users["gitano-bypass"] then
      for gname, gtab in pairs(groups) do
         if gtab.filtered_members["gitano-bypass"] then
            return nil, i18n.expand("ERROR_BYPASS_USER_IN_GROUP", {group=gname})
         end
      end
      local okay = next(users["gitano-bypass"].keys) or users["gitano-bypass"].clod["passwd.hash"]
      if not okay then
         return nil, i18n.expand("ERROR_BYPASS_USER_LACKING")
      end
   else
      return nil, i18n.expand("ERROR_NO_BYPASS_USER_FOUND")
   end

   -- Finally, return an object representing this configuration

   local config = {
      clod = conf,
      global = conf.settings,
      users = users,
      groups = groups,
      keyrings = keyrings,
      content = flat_tree,
      commit = commit,
   }
   local msg
   config.repo, msg = repository().find(config, 'gitano-admin')
   if not config.repo then
      return nil, msg
   end

   -- Attempt to parse the lace against the admin repo which uses the core sha
   config.lace, msg = lace.compile(config.repo)
   if not config.lace then
      return nil, msg
   end

   -- Configure the logging system
   local log_prefix = config.global["log.prefix"]
   if log_prefix ~= nil then
      log.set_prefix(log_prefix)
   end

   return config
end

local function load_file_content(conf, filename)
   local entry = conf.content[filename]
   if not entry then
      return nil, i18n.expand("ERROR_GENERIC_CONF_NOT_FOUND", {sha=conf.commit.sha, name=filename})
   end
   if entry.type ~= "blob" then
      return nil, i18n.expand("ERROR_GENERIC_CONF_NOT_BLOB", {sha=conf.sha.commit, name=filename})
   end
   return entry.obj.content, conf.commit.sha .. "::" .. filename
end

local function has_global_hook(conf, hook)
   return (conf.content["global-hooks/" .. hook .. ".lua"] ~= nil and
              conf.content["global-hooks/" .. hook .. ".lua"].type == "blob")
end

local function get_default_hook_content(conf, filename)
   return [[
(function(hookf, ...) return hookf(...) end)(...)
]], conf.commit.sha .. "::[[" .. filename .. "]]"
end

local function generate_ssh_config(conf)
   local ret = {"","### Gitano Keys ###"}
   for u, t in pairs(conf.users) do
      for ktag, keytab in pairs(t.keys) do
         log.debug(i18n.expand("DEBUG_ADDING_SSH_KEY", {user=u, key=ktag}))
         ret[#ret+1] =
            (('command="%s/gitano-auth \\"%s\\" \\"%s\\" \\"%s\\"",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding %s'):
                  format(lib_bin_path, repo_path, u, ktag, keytab.data))
      end
   end
   ret[#ret+1] = "### End Gitano Keys ###"
   ret[#ret+1] = ""
   return tconcat(ret, "\n")
end

local function update_ssh_keys(conf, ssh_path)
   local ssh_config = generate_ssh_config(conf)

   if not ssh_path then
      local home = luxio.getenv "HOME"

      if not home then
         log.fatal(i18n.expand("ERROR_CANNOT_FIND_HOME"))
      end

      ssh_path = home .. "/.ssh/authorized_keys"
   end

   local create_path = ssh_path .. ".new"

   local cfh, err = sio.open(create_path, "cew")

   if not cfh or cfh == -1 then
      log.fatal(i18n.expand("ERROR_UNABLE_TO_CREATE", {path=create_path}))
   end

   cfh:write(ssh_config)
   cfh:close()

   local ret, errno = luxio.rename(create_path, ssh_path)
   if ret ~= 0 then
      log.fatal(i18n.expand("ERROR_UNABLE_TO_RENAME", {path=ssh_path}))
   end

   log.chat(i18n.expand("AUTHORIZED_KEYS_UPDATED"))
end

local function populate_context(conf, ctx, username)
   if ctx.user and not username then
      username = ctx.user
   end
   ctx.user = username
   local grps = {}
   for grp, gtab in pairs(conf.groups) do
      if gtab.filtered_members[username] then
         -- Array for pattern matches
         grps[#grps+1] = grp
         -- Set for exact matches
         grps[grp] = true
      end
   end
   ctx.group = grps
end

local function serialise_conf(tab)
   local ret = {}
   local keys = {}
   for k in pairs(tab) do
      keys[#keys+1] = k
   end
   table.sort(keys)
   for _, k in ipairs(keys) do
      ret[#ret+1] = ("%s = %q"):format(k,tab[k])
   end
   ret[#ret+1] = ""
   return table.concat(ret, "\n")
end

local function commit_config_changes(conf, desc, author, committer)
   -- Take extant flat tree, clean out users and groups.
   -- write out everything we have here, and then prepare
   -- and write out a commit.
   local newtree = {}

   -- Shallow copy the tree ready for mods, skipping keyrings, users and groups
   for k,v in pairs(conf.content) do
      if not (k:match(pat.USER_INFO_PREFIX) or
                 k:match(pat.GROUP_INFO_PREFIX) or
              k:match(pat.KEYRING_INFO_PREFIX)) then
         newtree[k] = v
      end
   end

   -- Write out the site.conf
   local obj = conf.repo.git:hash_object("blob", conf.clod:serialise(), true)
   newtree["site.conf"] = conf.repo.git:get(obj)

   -- Construct all the users and write them out.
   for u, utab in pairs(conf.users) do
      local str = utab.clod:serialise()
      local obj = conf.repo.git:hash_object("blob", str, true)
      newtree[utab.meta.prefix .. u .. "/user.conf"] = conf.repo.git:get(obj)

      -- Now the keys
      for k, ktab in pairs(utab.keys) do
         obj = conf.repo.git:hash_object("blob", ktab.data .. "\n", true)
         newtree[utab.meta.prefix .. u .. "/" .. k .. ".key"] = conf.repo.git:get(obj)
      end
   end

   -- Do the same for the groups
   for g, gtab in pairs(conf.groups) do
      obj = conf.repo.git:hash_object("blob", gtab.clod:serialise(), true)
      newtree[gtab.meta.prefix .. g .. ".conf"] = conf.repo.git:get(obj)
   end

   -- And for keyrings
   for k, ktab in pairs(conf.keyrings) do
      newtree[ktab.meta.prefix .. k .. ".gpg"] = ktab.blob
   end

   local tree, msg = gall.tree.create(conf.repo.git, newtree)
   if not tree then
      return nil, msg
   end

   author = (author and {
                realname = conf.users[author].real_name,
                email = conf.users[author].email_address
            }) or admin_name

   committer = (committer and {
                   realname = conf.users[committer].real_name,
                   email = conf.users[committer].email_address
               }) or author

   local commit, msg =
      gall.commit.create(conf.repo.git, {
                            author = author,
                            committer = committer,
                            message = desc or "Updated",
                            tree = tree,
                            parents = { conf.commit }
      })
   if not commit then
      return nil, msg
   end

   -- Verify we can parse the updated configuration repository
   local newconf, msg = parse_admin_config(commit)
   if not newconf then
      return nil, msg
   end

   -- Create/Update the HEAD ref
   local ok, msg = conf.repo.git:update_ref(conf.repo.git.HEAD,
                                            commit.sha, nil,
                                            conf.commit.sha)
   if not ok then
      return nil, msg
   end

   -- Okay, updated, so apply the new config... (SSH keys really)
   update_ssh_keys(newconf)

   return true, commit
end

local function get_set_lib_bin_path(p)
   if p then
      lib_bin_path = p
   end
   return lib_bin_path
end

local function get_set_share_path(p)
   if p then
      share_path = p
   end
   return share_path
end

local function get_set_repo_path(p)
   if p then
      repo_path = p
   end
   return repo_path
end

return {
   genssh = generate_ssh_config,
   writessh = update_ssh_keys,
   parse = parse_admin_config,
   populate_context = populate_context,
   commit = commit_config_changes,
   load_file_content = load_file_content,
   get_default_hook_content = get_default_hook_content,
   has_global_hook = has_global_hook,
   lib_bin_path = get_set_lib_bin_path,
   share_path = get_set_share_path,
   repo_path = get_set_repo_path,
}
