local mymodule = {}

-- Load libraries
modelfunctions = require("modelfunctions")
posix = require("posix")
fs = require("acf.fs")
format = require("acf.format")
validator = require("acf.validator")
subprocess = require("subprocess")

-- Set variables
local processname = "radiusd"
local packagename = "freeradius3"
local baseurl = "/etc/raddb"
local owner = "radius"
local group = "root"

local config
local configtable
local macauthfiles

-- ################################################################################
-- LOCAL FUNCTIONS

local is_valid_filename = function(filename)
	local dirname = posix.dirname(filename)
	return validator.is_valid_filename(filename) and string.match(dirname, baseurl) and not string.match(dirname, "%.%.")
end

local get_config = function()
	if config then return true end
	local code, cmdresult = subprocess.call_capture({"radiusd", "-XC"})
	if 0 ~= code then
		return false, string.match(cmdresult, "([^\n]+)\n$")
	end
	config = {}
	for line in string.gmatch(cmdresult, "[^\n]+") do
		if string.match(line, "^including") then
		elseif string.match(line, "^Using") then
		elseif string.match(line, "^reading") then
		elseif string.match(line, "^Ignoring") then
		--elseif string.match(line, "^Configuration ") then
		elseif string.match(line, "^radiusd: ") then
		elseif string.match(line, "^rlm_passwd: ") then
		elseif string.match(line, "^%[/etc/raddb/") then
		elseif string.match(line, "^%s*#") then
		elseif string.match(line, "^%c") then
		else
			-- We want to remove spaces at beginning or end, and comments from end (being careful of quotes)
			local tmp = string.match(line, "%S.*")
			local nextcomment = string.find(tmp, "#")
			local quotestart, quoteend
			quotestart, quoteend = string.find(tmp, '%b""')

			while nextcomment and (nextcomment < #tmp) and quotestart and (quotestart < nextcomment) do
				if nextcomment < quoteend then
					nextcomment = string.find(tmp, "#", nextcomment+1)
				else
					quotestart, quoteend = string.find(tmp, '%b""', quoteend+1)
				end
			end

			if nextcomment then
				tmp = string.sub(tmp, 1, nextcomment-1)
			end
			config[#config+1] = tmp
		end
	end
	config[#config] = nil

	-- At this point, every line should have {, =, or }
	-- We will parse the lines to create a table structure
	function parselines(index)
		local result = {}
		local i=index
		local name,value
		while i<#config do
			-- Look for = first because line might contain { or }
			if string.find(config[i], '=') then
				name, value = string.match(config[i], "(.*%S)%s*=%s*(.*)")
				if string.find(value, "^\".*\"$") then
					value = string.sub(value, 2, -2)
				end
				result[#result+1] = {name=name, value=value}
				i = i+1
			elseif string.find(config[i], '}') then
				break
			elseif string.find(config[i], '{') then
				name = string.match(config[i], "(.*%S)%s*{")
				value, i = parselines(i+1)
				result[#result+1] = {name=name, value=value}
			else
				mymodule.logevent("radiusd bad config line:"..config[i])
			end
		end

		return result, i+1
	end

	configtable = parselines(1)
	config = table.concat(config,"\n")

	return true
end

local get_passwd_files = function()
	local files
	local configs
	local result,errtxt = get_config()
	if result then
		-- Find the files by searching for modules / passwd
		files = {}
		configs = {}
		for i,first in ipairs(configtable) do
			if string.find(first.name, "^modules$") then
				for j,second in ipairs(first.value) do
					if string.find(second.name, "^passwd ") then
						for k,third in ipairs(second.value) do
							if string.find(third.name, "^filename$") then
								files[#files+1] = third.value
								configs[#configs+1] = second.value
							end
						end
					end
				end
			end
		end
	end
	return files, errtxt, configs
end

local parse_passwd_config = function(config)
	local retval = {filename="", delimiter=":", fields={}, key={}, allowmultiplekeys=false}
	for i,l in ipairs(config) do
		if string.match(l.name, "^delimiter$") then
			retval.delimiter = l.value
		elseif string.match(l.name, "^allowmultiplekeys$") then
			retval.allowmultiplekeys = (l.value == "yes")
		elseif string.match(l.name, "^format$") then
  			-- ex. format = "*User-Name:Crypt-Password:"
			for field in string.gmatch(l.value, "([^:]*):?") do
				field = field or ""
				retval.fields[#retval.fields+1] = string.match(field, "^[*~=,]*(.*)")
				if string.match(field, "^%*,") then
					retval.key.index = #retval.fields
					retval.key.multiple = true
				elseif string.match(field, "^%*") then
					retval.key.index = #retval.fields
					retval.key.multiple = false
				end
			end
		end
	end

	return retval
end

local b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789./"
local mksalt = function()
        local file = io.open("/dev/urandom")
	local str = ""
	if file == nil then return nil end
	for i = 1,22 do
		local offset = (string.byte(file:read(1)) % 64) + 1
		str = str .. string.sub (b64, offset, offset)
	end
	return str
end

local get_passwd_file = function(self, clientdata, readonly)
	local retval = cfe({ type="group", value={}, label="Freeradius passwd file" })
	retval.value.filename = cfe({ type="select", label="File name", option ={}, key=true, seq=1 })
	local files,configs,passwdconfig
	files,retval.errtxt,configs = get_passwd_files()
	if files then
		retval.value.filename.option = files
		self.handle_clientdata(retval, clientdata)
		if retval.value.filename.value then
			retval.value.filename.errtxt = "Invalid selection"
			for i,f in ipairs(files) do
				if f == retval.value.filename.value then
					retval.value.filename.errtxt = nil
					if readonly then retval.value.filename.readonly = true end
					local stat = posix.stat(retval.value.filename.value)
					retval.value.mode = cfe({ label="Permissions", value=stat.mode, seq=2, readonly=true })
					passwdconfig = parse_passwd_config(configs[i])
					break
				end
			end
		end
	end

	-- return the CFE structure and the config info for this filename
	return retval, passwdconfig
end

local get_passwd_entry_private = function(self, clientdata, create)
	local retval,passwdconfig = get_passwd_file(self, clientdata, true)
	retval.label = "Freeradius passwd entry"
	if retval.value.mode and string.find(retval.value.mode.value, "^.%-") then
		retval.value.filename.errtxt = "Readonly file"
		return retval
	end
	retval.value.mode = nil
	local entry = 0
	local entryline = {}
	if not create then
		retval.value.entry = cfe({ label="Entry index", key=true, seq=2 })
		self.handle_clientdata(retval, clientdata)
		entry = tonumber(retval.value.entry.value) or 0
	end
	local hash
	if passwdconfig then
		-- The filename is valid, and we should create the fields 
		retval.value.fields = cfe({ type="group", value={}, label="Fields", seq=3, delimiter=passwdconfig.delimiter })
		if not create then retval.value.entry.errtxt = "Invalid entry" end
		local content = fs.read_file(retval.value.filename.value) or ""
		local maxcount = 0
		local i=1
		for line in string.gmatch(content, "[^\n]+") do
			local _,count = string.gsub(line, format.escapemagiccharacters(passwdconfig.delimiter), " ")
			maxcount = math.max(maxcount, count)
			if i == entry then
				retval.value.entry.readonly = true
				retval.value.entry.errtxt = nil
				entryline = format.string_to_table(line, format.escapemagiccharacters(passwdconfig.delimiter))
			end
			i = i+1
		end
		for i=1, math.max(#passwdconfig.fields, maxcount+1), 1 do
			local label = passwdconfig.fields[i]
			if not label or label == "" then
				label = "Unlabeled field "..i
			end
			if label == "Crypt-Password" then
				hash = entryline[i]
				-- We do not return the encrypted password, but will leave unchanged if blank
				retval.value.fields.value[tostring(i)] = cfe({ type="password", label=label, descr="Must not be blank", seq=i })
				if not create then retval.value.fields.value[tostring(i)].descr="Leave blank to leave unchanged" end
				retval.value.fields.value["algorithm"..i] = cfe({ type="select", value="$6$", label="Algorithm", option={{value="", label="DES"}, {value="$1$", label="MD5"}, {value="$2a$07$", label="Blowfish"}, {value="$5$", label="SHA-256"}, {value="$6$", label="SHA-512"}}, seq=i })
				-- Hide the algorithm so user does not use insecure algorithms unless they REALLY want to
				retval.value.fields.value["algorithm"..i].type = "hidden"
			else
				retval.value.fields.value[tostring(i)] = cfe({ label=label, value=entryline[i] or "", seq=i })
			end
		end
	end
	return retval, hash
end

local update_passwd_entry_private = function(self, entry, create)
	-- The filename/entry fields have already been validated
	if not entry.value.fields then
		entry.errtxt = "Invalid passwd entry"
	else
		-- The only fields we can validate are the password algorithms
		-- Don't search for 'select' cfe's because they have been changed to hidden
		-- And check for blank password
		for n,v in pairs(entry.value.fields.value) do
			if v.option and not modelfunctions.validateselect(v) then
				entry.errtxt = "Invalid passwd entry"
			elseif v.type == "password" and v.value == "" and create then
				v.errtxt = "Cannot be blank"
				entry.errtxt = "Invalid passwd entry"
			end
		end
		if not entry.errtxt then
			-- Set the value
			local content = fs.read_file_as_array(entry.value.filename.value) or {}
			local values = {}
			for n,v in pairs(entry.value.fields.value) do
				if v.type=="password" and v.value=="" then
					-- Keep the same password
					local line = {}
					if not create then line = format.string_to_table(content[tonumber(entry.value.entry.value)], entry.value.fields.delimiter) or {} end
					values[tonumber(n)] = line[tonumber(n)] or ""
				elseif v.type=="password" then
					local salt = entry.value.fields.value["algorithm"..n].value
					if salt == "" then
						salt = string.sub(mksalt(), 1, 2)
					else
						salt = salt..(mksalt() or "").."$"
					end
					local crypt,errtxt = posix.crypt(v.value, salt)
					if crypt then
						values[tonumber(n)] = crypt
					else
						v.errtxt = errtxt
						entry.errtxt = "Invalid passwd entry"
						break
					end
				elseif tonumber(n) then
					values[tonumber(n)] = v.value
				end
			end
			if not entry.errtxt then
				if create then
					content[#content+1] = table.concat(values, entry.value.fields.delimiter)
				else
					content[tonumber(entry.value.entry.value)] = table.concat(values, entry.value.fields.delimiter)
				end
				fs.write_file(entry.value.filename.value, table.concat(content, "\n"))
			end
		end
	end
	return entry
end

local get_macauth_files = function()
	if macauthfiles then
		return macauthfiles
	end

	local result,errtxt = get_config()
	if result then
		-- Find the files by searching for modules / files / usersfile where key="%{Calling-Station-Id}"
		macauthfiles = {}
		for i,first in ipairs(configtable) do
			if string.find(first.name, "^modules$") then
				for j,second in ipairs(first.value) do
					if string.find(second.name, "^files ") then
						local key,file
						for k,third in ipairs(second.value) do
							if string.find(third.name, "^usersfile$") then
								file = third.value
							elseif string.find(third.name, "^key$") and string.find(third.value, format.escapemagiccharacters("%{Calling-Station-Id}")) then
								key = true
							end
							if key and file then
								macauthfiles[#macauthfiles+1] = file
								break
							end
						end
					end
				end
			end
		end
	end
	return macauthfiles, errtxt
end

local is_valid_macauth_filename = function(filename)
	macauthfiles = macauthfiles or get_macauth_files()
	if macauthfiles then
		for i,f in ipairs(macauthfiles) do
			if filename == f then
				return true
			end
		end
	end
	return false
end

-- ################################################################################
-- PUBLIC FUNCTIONS

function mymodule.get_status()
	return modelfunctions.getstatus(processname, packagename, "Freeradius Status")
end

function mymodule.get_startstop(self, clientdata)
        return modelfunctions.get_startstop(processname)
end
	
function mymodule.startstop_service(self, startstop, action)
        return modelfunctions.startstop_service(startstop, action)
end

function mymodule.get_file(self, clientdata)
	local filename = clientdata.filename
	return modelfunctions.getfiledetails(filename, is_valid_filename)
end

function mymodule.update_file(self, filedetails)
	local ret = modelfunctions.setfiledetails(self, filedetails, is_valid_filename)
	if not ret.errtxt then
		posix.chmod(filedetails.value.filename.value, "rw-r-----")
		posix.chown(filedetails.value.filename.value, posix.getpasswd(owner, "uid") or 0, posix.getpasswd(group, "gid") or 0)
	end
	return ret
end

function mymodule.list_files()
	local retval = {}
	for file in fs.find(null, baseurl) do
		local details = fs.stat(file)
		if details.type == "regular" then
			details.filename = file
			table.insert(retval, details)
		end
	end
	table.sort(retval, function(a,b) return a.filename < b.filename end)
	return cfe({ type="structure", value=retval, label="List of Freeradius files" })
end

function mymodule.getnewfile()
	local filename = cfe({ label="File Name", descr="Must be in "..baseurl })
	return cfe({ type="group", value={filename=filename}, label="Freeradius File" })
end

function mymodule.createfile(self, filedetails)
	local success = true
	local path = string.match(filedetails.value.filename.value, "^%s*(.*%S)%s*$") or ""
	if not string.find(path, "/") then
		path = baseurl.."/"..path
	end

	if not is_valid_filename(path) then
		success = false
		filedetails.value.filename.errtxt = "Invalid filename"
	else
		if not fs.is_dir(baseurl) then fs.create_directory(baseurl) end
		if posix.stat(path) then
			success = false
			filedetails.value.filename.errtxt = "Filename already exists"
		end
	end

	if success then
		fs.create_file(path)
	else
		filedetails.errtxt = "Failed to Create File"
	end

	return filedetails
end

function mymodule.getdeletefile(self, clientdata)
	local retval = {}
	retval.filename = cfe({ label="File Name", value=clientdata.filename or "" })
	return cfe({ type="group", value=retval, label="Delete Freeradius File" })
end

function mymodule.deletefile(self, delfile)
	delfile.errtxt = "Failed to delete Freeradius File - invalid filename"
	for i,file in ipairs(mymodule.list_files().value) do
		if delfile.value.filename.value == file.filename then
			delfile.errtxt = nil
			os.remove(delfile.value.filename.value)
			break
		end
	end
	
	return delfile
end

function mymodule.list_passwd_files()
	local retval = {}
	local files,errtxt = get_passwd_files()
	if files then
		for i,file in ipairs(files) do
			local details = fs.stat(file)
			details.filename = file
			table.insert(retval, details)
		end
		table.sort(retval, function(a,b) return a.filename < b.filename end)
	end

	return cfe({ type="structure", value=retval, label="List of Freeradius passwd files", errtxt=errtxt })
end

function mymodule.view_passwd_file(self, clientdata)
	local retval,passwdconfig = get_passwd_file(self, clientdata, true)
	if passwdconfig then
		-- We have a valid filename, now parse the file using the format from the config
		retval.value.fields = cfe({ type="structure", value=passwdconfig.fields, label="Fields" })
		retval.value.data = cfe({ type="structure", value={}, label="Data" })
		local content = fs.read_file(retval.value.filename.value) or ""
		for line in string.gmatch(content, "[^\n]+") do
			retval.value.data.value[#retval.value.data.value+1] = format.string_to_table(line, format.escapemagiccharacters(passwdconfig.delimiter))
		end
	end
	return retval
end

function mymodule.get_passwd_entry(self, clientdata)
	return (get_passwd_entry_private(self, clientdata, false))
end

function mymodule.update_passwd_entry(self, entry)
	return update_passwd_entry_private(self, entry, false)
end

function mymodule.get_new_passwd_entry(self, clientdata)
	return (get_passwd_entry_private(self, clientdata, true))
end

function mymodule.create_passwd_entry(self, entry)
	return update_passwd_entry_private(self, entry, true)
end

function mymodule.get_delete_passwd_entry(self, clientdata)
	local retval,passwdconfig = get_passwd_file(self, clientdata)
	retval.label = "Delete Freeradius passwd entry"
	if retval.value.mode and string.find(retval.value.mode.value, "^.%-") then
		retval.value.filename.errtxt = "Readonly file"
		return retval
	end
	retval.value.mode = nil
	retval.value.entry = cfe({ label="Entry index", seq=2 })
	return retval
end

function mymodule.delete_passwd_entry(self, entry)
	local success = (nil ~= entry.value.entry)
	success = modelfunctions.validateselect(entry.value.filename) and success
	if success then
		local contenttable = fs.read_file_as_array(entry.value.filename.value) or {}
		if contenttable[tonumber(entry.value.entry.value) or 0] then
			table.remove(contenttable, tonumber(entry.value.entry.value))
			fs.write_file(entry.value.filename.value, table.concat(contenttable, "\n"))
		else
			success = false
			entry.value.entry.errtxt = "Invalid entry"
		end
	end
	if not success then
		entry.errtxt = "Failed to delete entry"
	end
	return entry
end

function mymodule.get_passwd(self, clientdata)
	local retval,passwdconfig = get_passwd_file(self, clientdata, true)
	retval.label = "Freeradius password"
	if retval.value.mode and string.find(retval.value.mode.value, "^.%-") then
		retval.value.filename.errtxt = "Readonly file"
		return retval
	end
	retval.value.mode = nil
	retval.value.entry = cfe({ label="Entry index", key=true, seq=2 })
	self.handle_clientdata(retval, clientdata)
	if passwdconfig then
		local success = false
		-- The filename is valid, need to validate the entry id and presense of "Crypt-Password" field
		local passwordfield = 0
		local usernamefield = 0
		retval.value.filename.errtxt = "No password field present"
		for i,f in ipairs(passwdconfig.fields) do
			if f == "Crypt-Password" then
				success = true
				passwordfield = i
				retval.value.filename.errtxt = nil
			elseif f == "User-Name" then
				usernamefield = i
			end
		end
			
		local contenttable = fs.read_file_as_array(retval.value.filename.value) or {}
		local entry = tonumber(retval.value.entry.value) or 0
		if contenttable[entry] then
			local entryline = format.string_to_table(contenttable[entry], format.escapemagiccharacters(passwdconfig.delimiter))
			if 0 < passwordfield then
				if not entryline[passwordfield] or entryline[passwordfield] == "" then
					success = false
					retval.value.entry.errtxt = "Password access disabled for this entry"
				else
					retval.value.entry.readonly = true
				end
			end
			if 0 < usernamefield then
				retval.value.username = cfe({ value=entryline[usernamefield] or "", label="User-Name", readonly=true, seq=3 })
			end
		else
			success = false
			retval.value.entry.errtxt = "Invalid entry"
		end
		if success then
			retval.value.oldpassword = cfe({ type="password", label="Current Password", seq=4 })
			retval.value.password = cfe({ type="password", label="New Password", seq=5 })
			retval.value.password_confirm = cfe({ type="password", label="New Password (confirm)", seq=6 })
			retval.value.algorithm = cfe({ type="select", value="$6$", label="Algorithm", option={{value="", label="DES"}, {value="$1$", label="MD5"}, {value="$2a$07$", label="Blowfish"}, {value="$5$", label="SHA-256"}, {value="$6$", label="SHA-512"}}, seq=i })
			-- Hide the algorithm so user does not use insecure algorithms unless they REALLY want to
			retval.value.algorithm.type = "hidden"
		end
	end
	return retval
end

function mymodule.update_passwd(self, passwd)
	-- The filename/entry fields have already been validated
	passwd.errtxt = "Failed to set password"
	if passwd.value.password and passwd.value.password.value == "" then
		passwd.value.password.errtxt = "Cannot be blank"
	elseif passwd.value.entry and passwd.value.password then
		-- Get the entry form and current password hash
		local form,pwhash = get_passwd_entry_private(self, {filename=passwd.value.filename.value, entry=passwd.value.entry.value}, false)

		-- Validate the old password
		local success = true
		if (pwhash ~= posix.crypt(passwd.value.oldpassword.value, pwhash)) then
			success = false
			passwd.value.oldpassword.errtxt = "Incorrect password"
	        end

		-- Validate the new password
		if passwd.value.password.value ~= passwd.value.password_confirm.value then
			success = false
			passwd.value.password_confirm.errtxt = "Must match password"
		end

		-- Validate the algorithm
		success = modelfunctions.validateselect(passwd.value.algorithm) and success

		if success then
			for n,f in pairs(form.value.fields.value) do
				if f.label == "Crypt-Password" then
					f.value = passwd.value.password.value
					form.value.fields.value["algorithm"..n].value = passwd.value.algorithm.value
					break
				end
			end
			form = update_passwd_entry_private(self, form, false)
			passwd.errtxt = form.errtxt
		end
	end
	return passwd
end

function mymodule.list_macauth_files()
	local retval = {}
	local files,errtxt = get_macauth_files()
	if files then
		for i,file in ipairs(files) do
			local details = fs.stat(file)
			details.filename = file
			table.insert(retval, details)
		end
		table.sort(retval, function(a,b) return a.filename < b.filename end)
	end

	return cfe({ type="structure", value=retval, label="List of Freeradius MAC authentication files", errtxt=errtxt })
end

function mymodule.get_macauth_file(self, clientdata)
	local filename = clientdata.filename
	return modelfunctions.getfiledetails(filename, is_valid_macauth_filename)
end

function mymodule.update_macauth_file(self, filedetails)
	local ret = modelfunctions.setfiledetails(self, filedetails, is_valid_macauth_filename)
	if not ret.errtxt then
		posix.chmod(filedetails.value.filename.value, "rw-r-----")
		posix.chown(filedetails.value.filename.value, posix.getpasswd(owner, "uid") or 0, posix.getpasswd(group, "gid") or 0)
	end
	return ret
end

return mymodule
