--[[
ADCH++-PtokaX: bridge to run PtokaX scripts on ADCH++.
Copyright (C) 2010-2013 poy <poy@123gen.com>

This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
]]

local base = _G
module('adchpp-ptokax')
local this = _NAME
base.require('luadchpp')
local adchpp = base.luadchpp
local autil = base.require('autil')
local io = base.require('io')
local math = base.require('math')
local os = base.require('os')
local string = base.require('string')
local table = base.require('table')

base.assert(math.ceil(adchpp.versionFloat * 100) >= 291, 'ADCH++ 2.10.0 or later is required to run ADCH++-PtokaX')

for _, dep in base.ipairs({ 'access' }) do
	base.assert(base[dep], dep .. '.lua must be loaded and running before ' .. this .. '.lua')
end
local access = base.access
for _, dep in base.ipairs({ 'bans', 'bot', 'op' }) do
	base.assert(access[dep], 'access.' .. dep .. '.lua must be loaded and running before ' .. this .. '.lua')
end
for _, dep in base.ipairs({ 'history', 'motd' }) do
	base.assert(not base[dep], this .. '.lua must be loaded and running before ' .. dep .. '.lua')
end

local cm = adchpp.getCM()
local lm = adchpp.getLM()
local sm = adchpp.getSM()

-- forward declarations.
local add_pipe, convert, convert_command, utf8, acp, escape,
change_cfg, refresh_opchat,
nicks, find_entity,
profiles, profile_fields, level_profile, reg_table, registered_users,
scripts, load_script, unload_script, formalize_script_name, save_scripts, scripts_file, script_path,
sub_env, call, execute, execute_r,
users, user_fields, user_entry
local bot_level = 1000000 -- 1 million
local full_name = 'ADCH++-PtokaX'
local opchat_bot = nil
local user_peak = 0

-- no locale to avoid discrepancies in string conversions and such.
os.setlocale('C')

local os_win = string.find(string.lower(os.getenv('OS') or ''), '^windows')

local function log(message)
	lm:log(this, message)
end

access.add_setting('px_opchat', {
	change = function() adchpp.getPM():attention(function()
		if opchat_bot then
			opchat_bot:setField('NI', access.settings.px_opchat.value)
			cm:sendToAll(adchpp.AdcCommand(adchpp.AdcCommand_CMD_INF, adchpp.AdcCommand_TYPE_BROADCAST, opchat_bot:getSID()):addParam('NI', access.settings.px_opchat.value):getBuffer())
		end
		refresh_opchat()
	end) end,

	help = '[' .. full_name .. '] name of the operator chat bot, empty = no op chat',

	value = 'OpChat',

	validate = access.validate_ni
})

access.add_setting('px_opchatcid', {
	help = '[' .. full_name .. '] CID of the operator chat bot, restart the hub after the change',

	value = adchpp.CID_generate():toBase32(),

	validate = function(new)
		if adchpp.CID(new.value):toBase32() ~= new.value then
			return 'the CID must be a valid 39-byte base32 representation'
		end
	end
})

access.add_setting('px_opchatdescription', {
	change = function()
		if opchat_bot then
			opchat_bot:setField('DE', access.settings.px_opchatdescription.value)
			cm:sendToAll(adchpp.AdcCommand(adchpp.AdcCommand_CMD_INF, adchpp.AdcCommand_TYPE_BROADCAST, opchat_bot:getSID()):addParam('DE', access.settings.px_opchatdescription.value):getBuffer())
		end
	end,

	help = '[' .. full_name .. '] description of the operator chat bot',

	value = '',

	validate = access.validate_de
})

access.add_setting('px_opchatemail', {
	change = function()
		if opchat_bot then
			opchat_bot:setField('EM', access.settings.px_opchatemail.value)
			cm:sendToAll(adchpp.AdcCommand(adchpp.AdcCommand_CMD_INF, adchpp.AdcCommand_TYPE_BROADCAST, opchat_bot:getSID()):addParam('EM', access.settings.px_opchatemail.value):getBuffer())
		end
	end,

	help = '[' .. full_name .. '] email of the operator chat bot',

	value = ''
})

access.add_setting('px_port', {
	help = '[' .. full_name .. '] TCP port this hub is listening on',

	value = 0
})

access.add_setting('px_redirect', {
	help = '[' .. full_name .. '] main redirect address',

	value = ''
})

access.add_setting('px_tempban', {
	help = '[' .. full_name .. '] default number of minutes for temp bans',

	value = 20,

	validate = function(new)
		if new.value <= 0 then
			return 'must be > 0'
		end
	end
})

access.commands.pxadd = {
	command = function(c, parameters)
		if not access.commands.pxadd.protected(c) then
			return
		end

		if #parameters == 0 then
			autil.reply(c, 'Specify the path to the PtokaX script to add')
			return
		end
		parameters = formalize_script_name(parameters)
		for _, script in base.ipairs(scripts) do
			if script.name == parameters then
				autil.reply(c, 'The ' .. parameters .. ' script has already been added to ' .. full_name)
				return
			end
		end

		local script = { name = parameters }
		table.insert(scripts, script)
		autil.reply(c, load_script(script))
		if script.enabled then
			save_scripts()
		end
	end,

	help = 'path - add the designated PtokaX script to ' .. full_name .. ' (the path is relative to the ADCH++ script directory)',

	protected = access.is_op,

	user_command = {
		name = full_name .. autil.ucmd_sep .. 'Add a script',
		params = { autil.ucmd_line('Path (relative to the ADCH++ script directory)') }
	}
}

access.commands.pxlist = {
	alias = { pxscripts = true },

	command = function(c)
		if not access.commands.pxlist.protected(c) then
			return
		end

		local str = table.maxn(scripts) .. ' PtokaX scripts currently loaded by ' .. full_name .. ':'
		for _, script in base.ipairs(scripts) do
			str = str .. '\n' .. script.name .. ' ('
			if script.enabled then
				str = str .. 'enabled'
			else
				str = str .. 'disabled'
			end
			str = str .. ')'
		end
		autil.reply(c, str)
	end,

	help = '- list the PtokaX scripts currently loaded by ' .. full_name,

	protected = access.is_op,

	user_command = { name = full_name .. autil.ucmd_sep .. 'List current scripts' }
}

access.commands.pxremove = {
	alias = { pxdel = true, pxrem = true },

	command = function(c, parameters)
		if not access.commands.pxremove.protected(c) then
			return
		end

		if #parameters == 0 then
			autil.reply(c, 'Specify the path to the PtokaX script to remove')
			return
		end
		parameters = formalize_script_name(parameters)
		for index, script in base.ipairs(scripts) do
			if script.name == parameters then
				if script.enabled then
					unload_script(script)
				end
				table.remove(scripts, index)
				save_scripts()
				autil.reply(c, 'The ' .. parameters .. ' script has been removed from ' .. full_name)
				return
			end
		end

		autil.reply(c, 'The ' .. parameters .. ' script has never been added to ' .. full_name)
	end,

	help = 'path - remove the designated PtokaX script from ' .. full_name .. ' (the path is relative to the ADCH++ script directory)',

	protected = access.is_op,

	user_command = {
		name = full_name .. autil.ucmd_sep .. 'Remove a script',
		params = { autil.ucmd_line('Path (relative to the ADCH++ script directory)') }
	}
}

access.commands.pxenable = {
	command = function(c, parameters)
		if not access.commands.pxenable.protected(c) then
			return
		end

		if #parameters == 0 then
			autil.reply(c, 'Specify the path to the PtokaX script to enable')
			return
		end
		parameters = formalize_script_name(parameters)
		for _, script in base.ipairs(scripts) do
			if script.name == parameters then
				if script.enabled then
					autil.reply(c, 'The ' .. parameters .. ' script has already been enabled')
				else
					autil.reply(c, load_script(script))
					if script.enabled then
						save_scripts()
					end
				end
				return
			end
		end

		autil.reply(c, 'The ' .. parameters .. ' script has never been added to ' .. full_name)
	end,

	help = 'path - enable the designated PtokaX script (the path is relative to the ADCH++ script directory)',

	protected = access.is_op,

	user_command = {
		name = full_name .. autil.ucmd_sep .. 'Enable a script',
		params = { autil.ucmd_line('Path (relative to the ADCH++ script directory)') }
	}
}

access.commands.pxdisable = {
	command = function(c, parameters)
		if not access.commands.pxdisable.protected(c) then
			return
		end

		if #parameters == 0 then
			autil.reply(c, 'Specify the path to the PtokaX script to disable')
			return
		end
		parameters = formalize_script_name(parameters)
		for _, script in base.ipairs(scripts) do
			if script.name == parameters then
				if script.enabled then
					unload_script(script)
					save_scripts()
					autil.reply(c, 'The ' .. parameters .. ' script has been disabled')
				else
					autil.reply(c, 'The ' .. parameters .. ' script has already been disabled')
				end
				return
			end
		end

		autil.reply(c, 'The ' .. parameters .. ' script has never been added to ' .. full_name)
	end,

	help = 'path - disable the designated PtokaX script (the path is relative to the ADCH++ script directory)',

	protected = access.is_op,

	user_command = {
		name = full_name .. autil.ucmd_sep .. 'Disable a script',
		params = { autil.ucmd_line('Path (relative to the ADCH++ script directory)') }
	}
}

access.commands.pxenableall = {
	command = function(c)
		if not access.commands.pxenableall.protected(c) then
			return
		end

		local total = 0
		local enabled = 0
		local status = ''

		for _, script in base.ipairs(scripts) do
			if not script.enabled then
				total = total + 1
				status = status .. '\n' .. load_script(script)
				if script.enabled then
					enabled = enabled + 1
				end
			end
		end
		if enabled > 0 then
			save_scripts()
		end

		if total == 0 then
			autil.reply(c, 'All scripts have already been enabled')
		else
			autil.reply(c, base.tostring(enabled) .. ' / ' .. base.tostring(total) .. ' scripts have been enabled:' .. status)
		end
	end,

	help = '- enable all PtokaX scripts',

	protected = access.is_op,

	user_command = { name = full_name .. autil.ucmd_sep .. 'Enable all scripts' }
}

access.commands.pxdisableall = {
	command = function(c)
		if not access.commands.pxdisableall.protected(c) then
			return
		end

		local disabled = 0
		local status = ''

		for _, script in base.ipairs(scripts) do
			if script.enabled then
				status = status .. '\n' .. script.name
				unload_script(script)
				disabled = disabled + 1
			end
		end
		if disabled > 0 then
			save_scripts()
		end

		if disabled == 0 then
			autil.reply(c, 'All scripts have already been disabled')
		else
			autil.reply(c, base.tostring(disabled) .. ' scripts have been disabled:' .. status)
		end
	end,

	help = '- disabled all PtokaX scripts',

	protected = access.is_op,

	user_command = { name = full_name .. autil.ucmd_sep .. 'Disable all scripts' }
}

access.commands.pxmoveup = {
	command = function(c, parameters)
		if not access.commands.pxmoveup.protected(c) then
			return
		end

		if #parameters == 0 then
			autil.reply(c, 'Specify the path to the PtokaX script to move up')
			return
		end
		parameters = formalize_script_name(parameters)

		local pos = nil
		for index, script in base.ipairs(scripts) do
			if script.name == parameters then
				pos = index
				break
			end
		end

		if pos == nil then
			autil.reply(c, 'The ' .. parameters .. ' script has never been added to ' .. full_name)
		elseif pos == 1 then
			autil.reply(c, 'The ' .. parameters .. ' script is already at the top of the list')
		else
			scripts[pos - 1], scripts[pos] = scripts[pos], scripts[pos - 1]
			save_scripts()
			autil.reply(c, 'The ' .. parameters .. ' script has been moved up')
		end
	end,

	help = 'path - move the designated PtokaX script upwards in the list (the path is relative to the ADCH++ script directory)',

	protected = access.is_op,

	user_command = {
		name = full_name .. autil.ucmd_sep .. 'Move a script up',
		params = { autil.ucmd_line('Path (relative to the ADCH++ script directory)') }
	}
}

access.commands.pxmovedown = {
	command = function(c, parameters)
		if not access.commands.pxmovedown.protected(c) then
			return
		end

		if #parameters == 0 then
			autil.reply(c, 'Specify the path to the PtokaX script to move down')
			return
		end
		parameters = formalize_script_name(parameters)

		local pos = nil
		for index, script in base.ipairs(scripts) do
			if script.name == parameters then
				pos = index
				break
			end
		end

		if pos == nil then
			autil.reply(c, 'The ' .. parameters .. ' script has never been added to ' .. full_name)
		elseif pos == table.maxn(scripts) then
			autil.reply(c, 'The ' .. parameters .. ' script is already at the bottom of the list')
		else
			scripts[pos], scripts[pos + 1] = scripts[pos + 1], scripts[pos]
			save_scripts()
			autil.reply(c, 'The ' .. parameters .. ' script has been moved down')
		end
	end,

	help = 'path - move the designated PtokaX script downwards in the list (the path is relative to the ADCH++ script directory)',

	protected = access.is_op,

	user_command = {
		name = full_name .. autil.ucmd_sep .. 'Move a script down',
		params = { autil.ucmd_line('Path (relative to the ADCH++ script directory)') }
	}
}

-- give bots a high enough level so that they can issue bans and other hub-wide modifications.
if access.get_level(access.bot.main_bot) < bot_level + 1 then
	access.set_level(access.bot.main_bot, bot_level + 1)
end

-- sub-envs that we are going to modify.
local copy_env_forced = {
	io = true, os = true, package = true, string = true,
	Core = true, ScriptMan = true, TmrMan = true
}

local function copy_env(from, child)
	-- can't just do "return from", because Lua plays smart and doesn't actually copy.
	local ret = {}
	for k, v in base.pairs(from) do
		if not child and copy_env_forced[k] then
			ret[k] = copy_env(v, true)
		else
			ret[k] = v
		end
	end
	return ret
end

-- build a default environment with the variables and functions expected by a PtokaX script.
local def = copy_env(base)
def.collectgarbage = function(operation)
	if operation == 'count' then
		return base.collectgarbage(operation)
	end
	-- disallow other operations as sandboxed scripts don't have the same environment as the main script.
end
def.Core = {
	Version = '0.4.1.2',
	Restart = function()
		-- TODO
	end,
	Shutdown = function()
		-- TODO
	end,
	ResumeAccepts = function()
		-- TODO
	end,
	SuspendAccepts = function()
		-- TODO
	end,
	GetBots = function()
		local ret = {}
		for _, script in base.ipairs(scripts) do
			if script.enabled then
				for name, bot in base.pairs(script.bots) do
					table.insert(ret, {
						sNick = acp(name),
						sMyINFO = '', -- TODO
						bIsOP = bot:isSet(adchpp.Entity_FLAG_OP),
						sScriptName = script.name
					})
				end
			end
		end
		return ret
	end,
	GetActualUsersPeak = function()
		return user_peak
	end,
	GetMaxUsersPeak = function()
		return user_peak
	end,
	GetCurrentSharedSize = function()
		local ret = 0
		for _, user in base.pairs(users) do
			ret = ret + user.iShareSize
		end
		return ret
	end,
	GetHubIP = function()
		return adchpp.Util_getLocalIp()
	end,
	GetHubSecAlias = function()
		return acp(access.bot.main_bot:getField('NI'))
	end,
	GetUsersCount = function()
		return cm:getEntities():size()
	end,
	GetUpTime = function()
		return cm:getUpTime()
	end,
	GetOnlineNonOps = function()
		local ret = {}
		for _, user in base.pairs(users) do
			if not user.bOperator then
				table.insert(ret, user)
			end
		end
		return ret
	end,
	GetOnlineOps = function()
		local ret = {}
		for _, user in base.pairs(users) do
			if user.bOperator then
				table.insert(ret, user)
			end
		end
		return ret
	end,
	GetOnlineRegs = function()
		local ret = {}
		for _, user in base.pairs(users) do
			if user.iProfile > -1 then
				table.insert(ret, user)
			end
		end
		return ret
	end,
	GetOnlineUsers = function(profile)
		if profile ~= nil and base.type(profile) ~= 'number' then
			profile = nil
		end
		local ret = {}
		for _, user in base.pairs(users) do
			if profile == nil or user.iProfile == profile then
				table.insert(ret, user)
			end
		end
		return ret
	end,
	GetUser = function(nick)
		local entity = find_entity(nick)
		if entity then
			return users[entity:getSID()]
		end
		return nil
	end,
	GetUserAllData = function()
		-- our user data is always up-to-date.
		return true
	end,
	GetUserData = function(user)
		-- our user data is always up-to-date.
		return def.Core.GetUser(user.sNick) ~= nil
	end,
	GetUserValue = function(user, field)
		user = def.Core.GetUser(user.sNick)
		if not user then
			return nil
		end
		if not user_fields then
			user_fields = {
				'sMode', 'sMyInfoString', 'sDescription', 'sTag', 'sConnection', 'sEmail',
				'sClient', 'sClientVersion', 'sVersion', 'bConnected', 'bActive', 'bOperator',
				'bUserCommand', 'bQuickList', 'bSuspiciousTag', 'iProfile', 'iShareSize', 'iHubs',
				'iNormalHubs', 'iRegHubs', 'iOpHubs', 'iSlots', 'iLlimit', 'iDefloodWarns',
				'iMagicByte', 'iLoginTime', 'sCountryCode'
			}
		end
		return user[user_fields[field + 1]]
	end,
	GetUsers = function(ip)
		local ret = {}
		for _, user in base.pairs(users) do
			if user.sIP == ip then
				table.insert(ret, user)
			end
		end
		return ret
	end,
	Disconnect = function(user)
		if base.type(user) == 'table' then
			user = user.sNick
		end
		access.commands.kick.command(access.bot.main_bot, utf8(user))
		return true
	end,
	Kick = function(user, from, reason)
		from = find_entity(from)
		if from then
			access.commands.kick.command(from, utf8(user.sNick) .. ' ' .. utf8(reason))
			return true
		end
		return nil
	end,
	Redirect = function(user, address, reason)
		access.commands.redirect.command(access.bot.main_bot, utf8(user.sNick) .. ' ' .. utf8(address))
		return true
	end,
	DefloodWarn = function(user)
		return nil
	end
}
def.BanMan = {
	Save = function()
	end,
	GetBans = function()
		-- TODO
	end,
	GetTempBans = function()
		-- TODO
	end,
	GetPermBans = function()
		-- TODO
	end,
	GetBan = function()
		-- TODO
	end,
	GetPermBan = function()
		-- TODO
	end,
	GetTempBan = function()
		-- TODO
	end,
	GetRangeBans = function()
		-- TODO
	end,
	GetTempRangeBans = function()
		-- TODO
	end,
	GetPermRangeBans = function()
		-- TODO
	end,
	GetRangeBan = function()
		-- TODO
	end,
	GetRangePermBan = function()
		-- TODO
	end,
	GetRangeTempBan = function()
		-- TODO
	end,
	Unban = function()
		-- TODO
	end,
	UnbanPerm = function()
		-- TODO
	end,
	UnbanTemp = function()
		-- TODO
	end,
	UnbanAll = function()
		-- TODO
	end,
	UnbanPermAll = function()
		-- TODO
	end,
	UnbanTempAll = function()
		-- TODO
	end,
	RangeUnban = function()
		-- TODO
	end,
	RangeUnbanPerm = function()
		-- TODO
	end,
	RangeUnbanTemp = function()
		-- TODO
	end,
	ClearBans = function()
		-- TODO
	end,
	ClearPermBans = function()
		-- TODO
	end,
	ClearTempBans = function()
		-- TODO
	end,
	ClearRangeBans = function()
		-- TODO
	end,
	ClearRangePermBans = function()
		-- TODO
	end,
	ClearRangeTempBans = function()
		-- TODO
	end,
	Ban = function(user, reason, banner)
		return def.BanMan.BanNick(user.sNick, reason, banner)
	end,
	BanIP = function(ip, reason, banner)
		banner = find_entity(banner)
		if not banner then
			banner = access.bot.main_bot
		end
		access.commands.banip.command(banner, utf8(ip) .. ' ' .. utf8(reason))
		return true
	end,
	BanNick = function(nick, reason, banner)
		local command
		local entity = find_entity(nick)
		if entity then
			command = access.commands.ban
		else
			command = access.commands.bannick
		end
		banner = find_entity(banner)
		if not banner then
			banner = access.bot.main_bot
		end
		command.command(banner, utf8(nick) .. ' ' .. utf8(reason))
		return true
	end,
	TempBan = function(user, minutes, reason, banner)
		return def.BanMan.TempBanNick(user.sNick, minutes, reason, banner)
	end,
	TempBanIP = function(ip, minutes, reason, banner)
		if minutes == 0 then
			minutes = access.settings.px_tempban.value
		end
		banner = find_entity(banner)
		if not banner then
			banner = access.bot.main_bot
		end
		access.commands.banip.command(banner, utf8(ip) .. ' ' .. utf8(reason) .. ' ' .. base.tostring(minutes))
		return true
	end,
	TempBanNick = function(nick, minutes, reason, banner)
		local command
		local entity = find_entity(nick)
		if entity then
			command = access.commands.ban
		else
			command = access.commands.bannick
		end
		if minutes == 0 then
			minutes = access.settings.px_tempban.value
		end
		banner = find_entity(banner)
		if not banner then
			banner = access.bot.main_bot
		end
		command.command(banner, utf8(nick) .. ' ' .. utf8(reason) .. ' ' .. base.tostring(minutes))
		return true
	end,
	RangeBan = function()
		-- TODO
	end,
	RangeTempBan = function()
		-- TODO
	end
}
def.IP2Country = {
	GetCountryCode = function(ip)
		-- TODO
		return ''
	end,
	GetCountryName = function(ip)
		if base.type(ip) == 'table' then
			ip = ip.sIP
		end
		-- TODO
		return ''
	end
}
def.ProfMan = {
	AddProfile = function()
		-- TODO
	end,
	RemoveProfile = function()
		-- TODO
	end,
	MoveDown = function()
		-- TODO
	end,
	MoveUp = function()
		-- TODO
	end,
	GetProfile = function(id)
		local id_str = base.type(id) == 'string'
		for _, profile in base.ipairs(profiles) do
			if id_str then
				if profile.sProfileName == id then
					return profile
				end
			else
				if profile.iProfileNumber == id then
					return profile
				end
			end
		end
		return nil
	end,
	GetProfiles = function()
		return profiles
	end,
	GetProfilePermission = function(id, permission)
		if not profile_fields then
			profile_fields = {
				'bIsOP', 'bNoDefloodGetNickList', 'bNoDefloodNMyINFO', 'bNoDefloodSearch',
				'bNoDefloodPM', 'bNoDefloodMainChat', 'bMassMsg', 'bTopic', 'bTempBan',
				'bTempUnban', 'bRefreshTxt', 'bNoTagCheck', 'bNoTagCheck', 'bDelRegUser',
				'bDelRegUser', 'bAddRegUser', 'bNoChatLimits', 'bNoMaxHubCheck', 'bNoSlotHubRatio',
				'bNoSlotCheck', 'bNoShareLimit', 'bClrPermBan', 'bClrTempBan', 'bGetInfo',
				'bGetBans', 'bRestartScripts', 'bRestartHub', 'bTempOP', 'bGag', 'bRedirect',
				'bBan', 'bUnban', 'bKick', 'bDrop', 'bEnterFullHub', 'bEnterIfIPBan',
				'bAllowedOPChat', 'bSendFullMyinfos', 'bSendAllUserIP', 'bRangeBan', 'bRangeUnban',
				'bRangeTempBan', 'bRangeTempUnban', 'bGetRangeBans', 'bClearRangePermBans',
				'bClearRangeTempBans', 'bNoIpCheck', 'bClose', 'bNoSearchLimits', 'bNoDefloodCTM',
				'bNoDefloodRCTM', 'bNoDefloodSR', 'bNoDefloodRecv', 'bNoChatInterval',
				'bNoPMInterval', 'bNoSearchInterval', 'bNoMaxUsersSameIP', 'bNoReConnTime'
			}
		end
		for _, profile in base.ipairs(profiles) do
			if profile.iProfileNumber == id then
				return profile.tProfilePermissions[profile_fields[permission + 1]]
			end
		end
		return nil
	end,
	GetProfilePermissions = function(id)
		for _, profile in base.ipairs(profiles) do
			if profile.iProfileNumber == id then
				return profile.tProfilePermissions
			end
		end
		return nil
	end,
	SetProfileName = function()
		-- TODO
	end,
	SetProfilePermission = function()
		-- TODO
	end,
	Save = function()
	end
}
def.RegMan = {
	Save = function()
	end,
	GetRegsByProfile = function(profile)
		local ret = {}
		for _, user in base.ipairs(registered_users()) do
			if level_profile(user.level) == profile then
				table.insert(ret, reg_table(user, profile))
			end
		end
		return ret
	end,
	GetNonOps = function()
		local ret = {}
		for _, user in base.ipairs(registered_users()) do
			local profile = level_profile(user.level)
			if profile == 2 or profile == 3 then
				table.insert(ret, reg_table(user, profile))
			end
		end
		return ret
	end,
	GetOps = function()
		local ret = {}
		for _, user in base.ipairs(registered_users()) do
			local profile = level_profile(user.level)
			if profile == 0 or profile == 1 then
				table.insert(ret, reg_table(user, profile))
			end
		end
		return ret
	end,
	GetReg = function(nick)
		nick = string.lower(nick)
		for _, user in base.ipairs(registered_users()) do
			if string.lower(user.nick) == nick or string.lower(acp(user.nick)) == nick then
				return reg_table(user)
			end
		end
		return nil
	end,
	GetRegs = function()
		local ret = {}
		for _, user in base.ipairs(registered_users()) do
			table.insert(ret, reg_table(user))
		end
		return ret
	end,
	AddReg = function(nick, password, profile)
		if #nick == 0 or #password == 0 then
			return nil
		end
		local perm = def.ProfMan.GetProfilePermissions(profile)
		if not perm then
			return nil
		end
		if perm.bIsOP then
			profile = access.level_op
		else
			profile = 1
		end
		access.commands.regnick.command(access.bot.main_bot, utf8(nick) .. ' ' .. utf8(password) .. ' ' .. base.tostring(profile))
		return true
	end,
	DelReg = function(nick)
		if #nick == 0 then
			return nil
		end
		access.commands.regnick.command(access.bot.main_bot, utf8(nick))
		return true
	end,
	ChangeReg = function(nick, password, profile)
		if #nick == 0 or #password == 0 then
			return nil
		end
		local nick_w = utf8(nick)
		local reg = access.users.nicks[nick_w]
		if not reg then
			return nil
		end
		if profile ~= level_profile(reg.level) then
			def.RegMan.DelReg(nick)
			return def.RegMan.AddReg(nick, password, profile)
		end
		password = utf8(password)
		if password ~= reg.password then
			local entity = find_entity(nick)
			if entity then
				access.commands.mypass.command(entity, password)
			else
				access.users.nicks[nick_w].password = password
				access.register_user() -- no-op to save the user database
			end
			return true
		end
		return nil
	end,
	ClrRegBadPass = function()
		return true
	end
}
def.ScriptMan = {
	GetScripts = function()
		local ret = {}
		for _, script in base.ipairs(scripts) do
			table.insert(ret, { sName = script.name, bEnabled = script.enabled, iMemUsage = 0 })
		end
		return ret
	end,
	MoveUp = function(name)
		access.commands.pxmoveup.command(access.bot.main_bot, name)
		return true
	end,
	MoveDown = function(name)
		access.commands.pxmovedown.command(access.bot.main_bot, name)
		return true
	end,
	StartScript = function(name)
		access.commands.pxenable.command(access.bot.main_bot, name)
		return true
	end,
	RestartScript = function(name)
		return def.ScriptMan.StopScript(name) and def.ScriptMan.StartScript(name)
	end,
	StopScript = function(name)
		access.commands.pxdisable.command(access.bot.main_bot, name)
		return true
	end,
	Restart = function()
		for _, script in base.ipairs(scripts) do
			if script.enabled then
				def.ScriptMan.RestartScript(script.name)
			end
		end
	end,
	Refresh = function()
	end
}
def.SetMan = {
	Save = function()
	end,
	GetMOTD = function()
		-- TODO
	end,
	SetMOTD = function(data)
		-- TODO
	end,
	GetBool = function(id)
		if id == 1 then
			return true
		elseif id == 5 then
			return access.settings.maxusers.value == 0
		elseif id == 17 then
			return true
		elseif id == 18 then
			return true
		elseif id == 19 then
			return opchat_bot ~= nil
		elseif id == 22 then
			return true
		end
		return nil
	end,
	SetBool = function(id, new)
		if id == 5 then
			local new_maxusers
			if new then
				new_maxusers = '0'
			else
				new_maxusers = '-1'
			end
			change_cfg('maxusers', new_maxusers)
		elseif id == 19 then
			if new then
				if not opchat_bot then
					change_cfg('px_opchat', 'OpChat')
				end
			elseif opchat_bot then
				change_cfg('px_opchat', '')
			end
		end
	end,
	GetNumber = function(id)
		if id == 0 then
			if access.settings.maxusers.value < 0 then
				return 0
			end
			return access.settings.maxusers.value
		elseif id == 16 then
			return access.settings.px_tempban.value
		end
		return 0
	end,
	SetNumber = function(id)
	end,
	GetString = function(id)
		if id == 0 then
			return acp(access.settings.name.value)
		elseif id == 1 then
			return acp(access.settings.owner.value)
		elseif id == 2 then
			return acp(access.settings.address.value)
		elseif id == 3 then
			return base.tostring(access.settings.px_port.value)
		elseif id == 4 then
			return '0'
		elseif id == 5 then
			return acp(access.settings.description.value)
		elseif id == 6 then
			return acp(access.settings.px_redirect.value)
		elseif id == 10 then
			return acp(access.settings.topic.value)
		elseif id == 21 then
			return acp(access.settings.botname.value)
		elseif id == 22 then
			return acp(access.settings.botdescription.value)
		elseif id == 23 then
			return acp(access.settings.botemail.value)
		elseif id == 24 then
			return acp(access.settings.px_opchat.value)
		elseif id == 25 then
			return acp(access.settings.px_opchatdescription.value)
		elseif id == 26 then
			return acp(access.settings.px_opchatemail.value)
		elseif id == 29 then
			return '!+'
		end
		return nil
	end,
	SetString = function(id, new)
		local function change_cfg_(name)
			change_cfg(name, utf8(new))
		end
		if id == 0 then
			change_cfg_('name')
		elseif id == 1 then
			change_cfg_('owner')
		elseif id == 2 then
			change_cfg_('address')
		elseif id == 5 then
			change_cfg_('description')
		elseif id == 10 then
			change_cfg_('topic')
		elseif id == 21 then
			change_cfg_('botname')
		elseif id == 22 then
			change_cfg_('botdescription')
		elseif id == 23 then
			change_cfg_('botemail')
		elseif id == 24 then
			change_cfg_('px_opchat')
		elseif id == 25 then
			change_cfg_('px_opchatdescription')
		elseif id == 26 then
			change_cfg_('px_opchatemail')
		end
	end,
	GetMinShare = function()
		return 0
	end,
	SetMinShare = function()
	end,
	GetMaxShare = function()
		return 0
	end,
	SetMaxShare = function()
	end,
	SetHubSlotRatio = function()
	end,
	GetOpChat = function()
		return {
			sNick = acp(access.settings.px_opchat.value),
			sDescription = acp(access.settings.px_opchatdescription.value),
			sEmail = acp(access.settings.px_opchatemail.value),
			bEnabled = opchat_bot ~= nil
		}
	end,
	SetOpChat = function(enabled, name, description, email)
		if not enabled then
			name = ''
		end
		change_cfg('px_opchat', utf8(name))
		change_cfg('px_opchatdescription', utf8(description))
		change_cfg('px_opchatemail', utf8(email))
		return true
	end,
	GetHubBot = function()
		return {
			sNick = acp(access.settings.botname.value),
			sDescription = acp(access.settings.botdescription.value),
			sEmail = acp(access.settings.botemail.value),
			bEnabled = true,
			bUsedAsHubSecAlias = true
		}
	end,
	SetHubBot = function(enabled, name, description, email, secalias)
		if not enabled or not secalias then
			return nil
		end
		change_cfg('botname', utf8(name))
		change_cfg('botdescription', utf8(description))
		change_cfg('botemail', utf8(email))
		return true
	end
}
def.TmrMan = {
}
def.UDPDbg = {
	Reg = function()
		return nil
	end,
	Unreg = function()
	end,
	Send = function()
		return nil
	end
}

add_pipe = function(data)
	if string.sub(data, -1) ~= '|' then
		data = data .. '|'
	end
	return data
end

-- build an AdcCommand table from NMDC commands.
-- absent_users: if true, use the hidden bot to send messages from non-existent users; otherwise,
-- just use the main bot.
convert = function(data, script, absent_users)
	data = utf8(data)
	local ret = {}
	local start = 1
	while true do
		local pos = string.find(data, '|', start)
		if not pos then
			break
		end
		local commands = convert_command(string.sub(data, start, pos - 1), script, absent_users)
		if commands then
			for _, command in base.ipairs(commands) do
				table.insert(ret, command)
			end
		end
		start = pos + 1
	end
	return ret
end

-- build an AdcCommand table from one NMDC command.
convert_command = function(str, script, absent_users)
	str = string.gsub(str, '&#36;', '$')
	str = string.gsub(str, '&#124;', '|')
	str = string.gsub(str, '&amp;', '&')

	local ucmd, _, operand, context, text = string.find(str, '^%$UserCommand (%d+) (%d+) ?(.*)')
	if ucmd then
		local command = adchpp.AdcCommand(adchpp.AdcCommand_CMD_CMD, adchpp.AdcCommand_TYPE_INFO, adchpp.AdcCommand_HUB_SID)

		if operand == '0' then
			command:addParam(''):addParam('SP', '1')

		elseif operand == '255' then
			-- TODO remove all sent commands
			return nil

		else
			local dollar, _, name, back_text = string.find(text, '([^%$]+)%$(.+)')
			if not dollar then
				return nil
			end

			name = string.gsub(name, '/', '//')
			name = string.gsub(name, '\\', '/')
			command:addParam(name)

			command:addParam('TT',
			adchpp.AdcCommand(adchpp.AdcCommand_CMD_CMD, adchpp.AdcCommand_TYPE_HUB, adchpp.AdcCommand_HUB_SID)
			:addParam(this .. back_text)
			:toString())

			if operand == '2' then
				command:addParam('CO', '1')
			end
		end

		command:addParam('CT', context)

		return { command }
	end

	if script then

		local op_list, _, tokens = string.find(str, '^%$OpList (.+)')
		if op_list then
			local start = 1
			while true do
				local pos = string.find(tokens, '%$%$', start)
				if not pos then
					return nil
				end
				script.sub.Core.RegBot(acp(string.sub(tokens, start, pos - 1)), nil, nil, true)
				start = pos + 2
			end
		end

		local info, _, nick, description, email = string.find(str,
		'^%$MyINFO %$ALL ([^ ]+) ([^%$]*)%$ %$[^%$]+%$([^%$]*)%$[^%$]*%$$')
		if info then
			script.sub.Core.RegBot(acp(nick), acp(description), acp(email))
			return nil
		end

		local quit, _, nick = string.find(str, '^%$Quit (.+)')
		if quit then
			script.sub.Core.UnregBot(acp(nick))
			return nil
		end

	end

	local ctm, _, to, port = string.find(str, '^%$ConnectToMe ([^ ]+) [^:]+:(.+)')
	if ctm then
		from = access.bot.main_bot
		to = find_entity(acp(to))
		if not to then
			return nil
		end
		local command = adchpp.AdcCommand(adchpp.AdcCommand_CMD_CTM, adchpp.AdcCommand_TYPE_DIRECT, from:getSID())
		:addParam('ADC/1.0'):addParam(port):addParam('0')
		command:setTo(to:getSID())
		return { command }
	end

	local rcm, _, from, to = string.find(str, '^%$RevConnectToMe ([^ ]+) (.+)')
	if rcm then
		from = find_entity(acp(from))
		if not from then
			from = access.bot.main_bot
		end
		to = find_entity(acp(to))
		if not to then
			return nil
		end
		local command = adchpp.AdcCommand(adchpp.AdcCommand_CMD_RCM, adchpp.AdcCommand_TYPE_DIRECT, from:getSID())
		:addParam('ADC/1.0'):addParam('0')
		command:setTo(to:getSID())
		return { command }
	end

	local sr = string.find(str, '^%$SR')
	if sr then
		-- TODO convert search results
		return nil
	end

	local commands = {}

	local function parse_msg(msg)
		local ret = {}
		local found, _, nick, text = string.find(msg, '^<([^>]+)> (.+)')
		if not found then
			found, _, nick, text = string.find(msg, '^%*+ ?([^ ]+) (.+)')
			if found then
				ret.third_person = true
			end
		end
		if found then
			ret.message = text
			local entity = find_entity(acp(nick))
			if entity then
				ret.from = entity
			elseif absent_users then
				table.insert(commands, adchpp.AdcCommand(adchpp.AdcCommand_CMD_INF, adchpp.AdcCommand_TYPE_BROADCAST, hidden_bot:getSID()):addParam('NI', nick))
				ret.from = hidden_bot
			end
		else
			ret.message = msg
		end
		return ret
	end

	local pm, _, to, from, message = string.find(str, '^%$To: ([^ %$]+) From: ([^ %$]+) ?%$(.+)')
	if pm then
		from = find_entity(acp(from))
		if not from then
			from = access.bot.main_bot
		end
		local parsed = parse_msg(message)
		if not parsed.from then
			parsed.from = from
		end
		local command = adchpp.AdcCommand(adchpp.AdcCommand_CMD_MSG, adchpp.AdcCommand_TYPE_DIRECT, parsed.from:getSID())
		:addParam(parsed.message)
		if parsed.third_person then
			command:addParam('ME', '1')
		end
		command:addParam('PM', adchpp.AdcCommand_fromSID(from:getSID()))
		to = find_entity(acp(to))
		if not to then
			to = access.bot.main_bot
		end
		command:setTo(to:getSID())
		table.insert(commands, command)
		return commands
	end

	local parsed = parse_msg(str)
	if not parsed.from then
		parsed.from = access.bot.main_bot
	end
	local command = adchpp.AdcCommand(adchpp.AdcCommand_CMD_MSG, adchpp.AdcCommand_TYPE_BROADCAST, parsed.from:getSID())
	:addParam(parsed.message)
	if parsed.third_person then
		command:addParam('ME', '1')
	end
	table.insert(commands, command)
	return commands
end

utf8 = function(str)
	if str == nil or base.type(str) ~= 'string' or adchpp.Text_validateUtf8(str) then
		return str
	end
	local nick = nicks[str]
	if nick then
		return nick
	end
	return adchpp.Text_acpToUtf8(str)
end

acp = function(str)
	if str == nil or base.type(str) ~= 'string' then
		return str
	end
	return adchpp.Text_utf8ToAcp(str)
end

escape = function(str)
	if str == nil or base.type(str) ~= 'string' then
		return str
	end
	str = string.gsub(str, '&', '&amp;')
	str = string.gsub(str, '%$', '&#36;')
	str = string.gsub(str, '|', '&#124;')
	return str
end

change_cfg = function(name, value)
	access.commands.cfg.command(access.bot.main_bot, name .. ' ' .. value)
end

refresh_opchat = function()
	if #access.settings.px_opchat.value > 0 then
		if opchat_bot == nil then
			opchat_bot = cm:createSimpleBot()
			opchat_bot:setCID(adchpp.CID(access.settings.px_opchatcid.value))
			opchat_bot:setField('ID', access.settings.px_opchatcid.value)
			opchat_bot:setField('NI', access.settings.px_opchat.value)
			opchat_bot:setField('DE', access.settings.px_opchatdescription.value)
			opchat_bot:setField('EM', access.settings.px_opchatemail.value)
			opchat_bot:setFlag(adchpp.Entity_FLAG_OP)
			cm:regBot(opchat_bot)
		end

	elseif opchat_bot then
		opchat_bot:disconnect(adchpp.Util_REASON_PLUGIN)
		opchat_bot = nil
	end
end

nicks = {} -- table to hold ACP / UTF-8 nick pairs.
find_entity = function(nick)
	-- Like adchpp::ClientManager::findByNick, but take care of case insensitivity and encodings.
	nick = string.lower(nick)
	local entities = cm:getEntities()
	for i = 0, entities:size() - 1 do
		local entity = entities[i]
		local entity_ni = entity:getField('NI')
		if string.lower(entity_ni) == nick or string.lower(acp(entity_ni)) == nick then
			return entity
		end
	end
	return nil
end

-- Ordered list of loaded scripts. Fields:
-- * name.
-- * sub: sub-environment in which the script runs.
-- * enabled.
-- * bots: bots added by the script.
-- * timers: timers added by the script.
scripts = {}

load_script = function(script)
	local status

	local f, err = base.loadfile(script_path .. script.name)
	if f then

		script.bots = {}
		script.timers = {}

		-- set a sub-environment in which the script will execute.
		script.sub = sub_env(def, script)
		base.setfenv(f, script.sub)

		script.enabled = true
		status = 'Loaded ' .. script.name

		call(f, script)

	else
		script.enabled = false
		status = 'Could not load ' .. script.name .. ': ' .. err
	end

	log(status)

	if script.enabled and script.sub.OnStartup then
		call(script.sub.OnStartup, script)
	end

	return status
end

unload_script = function(script)
	-- cancel timers
	for id, _ in base.pairs(script.timers) do
		script.sub.TmrMan.RemoveTimer(id)
	end
	script.timers = {}

	if script.sub.OnExit then
		call(script.sub.OnExit, script)
	end

	-- remove bots
	for _, bot in base.pairs(script.bots) do
		bot:disconnect(adchpp.Util_REASON_PLUGIN)
	end
	script.bots = {}

	script.sub = nil
	script.enabled = false
end

formalize_script_name = function(name)
	-- ignore the front '!' as it could be a remnant of the old "merge_modules" feature.
	if #name >= 2 and (string.sub(name, 1, 1) == '#' or string.sub(name, 1, 1) == '!') then
		name = string.sub(name, 2)
	end
	name = string.gsub(name, '\\', '/')
	if string.sub(name, -4) ~= '.lua' then
		name = name .. '.lua'
	end
	return name
end

save_scripts = function()
	base.pcall(function()
		local file = io.open(scripts_file, 'w')
		for _, script in base.ipairs(scripts) do
			if not script.enabled then
				file:write('#')
			end
			file:write(script.name .. '\n')
		end
		file:close()
	end)
end

-- The file which contains the names of the PtokaX scripts to load.
-- That file contains one script name per line. The path to the PtokaX scripts is relative to the
-- script directory. A '#' symbol can be prepended to a line to temporarily disable a script.
scripts_file = adchpp.Util_getCfgPath() .. this .. '.txt'

script_path = base.scriptPath .. '/'
script_path = string.gsub(script_path, '\\', '/')
script_path = string.gsub(script_path, '//+', '/')

-- functions of the LuaFileSystem lib that take a path as their first argument.
local lfs_path_funcs = {
	attributes = true,
	chdir = true,
	dir = true,
	lock_dir = true,
	mkdir = true,
	rmdir = true,
	setmode = true,
	symlinkattributes = true,
	touch = true
}

sub_env = function(base_env, script)
	local sub = copy_env(base_env)

	sub._G = sub

	-- build the absolute path to the script currently being loaded and store it in its env.
	sub[this .. 'path'] = script_path
	local found, _, sub_path = string.find(script.name, '(.+)[/\\][^/\\]+$')
	if found then
		sub[this .. 'path'] = sub[this .. 'path'] .. sub_path .. '/'
	end

	-- make up for scripts that hard-code PtokaX paths.
	-- "complex" is true when "path" is potentially a complicated argument (in which case the
	-- caller is hopefully using absolute paths).
	local function convert_path(path, complex)
		if not path or base.type(path) ~= 'string' then
			return path
		end

		if not complex then
			-- make absolute if necessary.
			local first_1 = string.sub(path, 1, 1)
			local first_2 = string.sub(path, 2, 3)
			if first_1 ~= '/' and first_1 ~= '\\' and first_2 ~= ':/' and first_2 ~= ':\\' then
				path = sub[this .. 'path'] .. path
			end
		end

		-- we already are in the script directory.
		local pattern = sub[this .. 'path']
		pattern = string.gsub(pattern, '([%^%$%(%)%%%.%[%]%*%+%-%?])', '%%%1') -- escape magic chars
		pattern = string.gsub(pattern, '/', '[/\\]') -- accept more path seps (some scripts change them)
		path = string.gsub(path, pattern .. '[/\\]*scripts[/\\]+', sub[this .. 'path'] .. '/')

		return path
	end

	sub.loadfile = function(path)
		local f, err = base.loadfile(convert_path(path))
		if f then
			base.setfenv(f, sub)
		end
		return f, err
	end

	sub.dofile = function(path)
		local f = sub.loadfile(path)
		if f then
			return f()
		end
		return nil
	end

	sub.io.open = function(path, mode)
		return io.open(convert_path(path), mode)
	end

	sub.io.input = function(path)
		return io.input(convert_path(path))
	end

	sub.io.output = function(path)
		return io.output(convert_path(path))
	end

	sub.io.lines = function(path)
		return io.lines(convert_path(path))
	end

	sub.os.execute = function(cmd)

		if os_win then
			-- convert path separators of simple Windows commands to '\'.
			for _, win_cmd in base.ipairs({ 'md', 'mkdir' }) do
				local found, _, path = string.find(cmd, '^' .. win_cmd .. ' "?([^"|]+)"?$')
				if found then
					path = convert_path(path)
					path = string.gsub(path, '/', '\\')
					return os.execute(win_cmd .. ' ' .. path)
				end
			end
		end

		return os.execute(convert_path(cmd, true))
	end

	sub.os.remove = function(path)
		return os.remove(convert_path(path))
	end

	sub.os.rename = function(source, target)
		return os.rename(convert_path(source), convert_path(target))
	end

	local require_name_changes = {
		pxlfs = 'lfs',
		pxsocket = 'socket'
	}

	-- imitate Lua's built-in "require" function, but:
	-- * convert paths when scripts have changed them.
	-- * provide an environment to loaded scripts (via "dofile").
	-- * propagate the results back to the parent environment using metatable tricks.
	sub.require = function(name)
		local new_name = require_name_changes[name]
		if new_name then
			name = new_name
		end
		if sub.package.loaded[name] then
			if base.type(sub.package.loaded[name]) == 'table' then
				local pack_name = sub.package.loaded[name]._PACKAGE
				if pack_name and #pack_name > 0 then
					pack_name = string.sub(pack_name, 1, #pack_name - 1) -- remove the last dot
					sub[pack_name] = sub.package.loaded[pack_name]._M or sub.package.loaded[pack_name]
				end
				sub[name] = sub.package.loaded[name]._M or sub.package.loaded[name]
				return sub[name]
			end
			return true
		end

		local function parse_paths(paths, cpath)
			local start = 1
			while true do
				local pos = string.find(paths, ';', start)
				if not pos then
					pos = #paths + 1
				end

				local path = name
				path = string.gsub(path, '%.', '/')
				path = string.gsub(string.sub(paths, start, pos - 1), '%?', path)
				if string.sub(path, 1, 2) == './' then
					-- Linux relative path; make absolute.
					path = sub[this .. 'path'] .. string.sub(path, 3)
				end
				local ok, file = sub.pcall(sub.io.open, path, 'rb')
				if ok and file then
					file:close()

					if cpath then
						-- this is a dynamic library; open it and look for a loader.

						local f_name = name
						f_name = string.gsub(f_name, '%.', '_')
						local f = sub.package.loadlib(path, 'luaopen_' .. f_name)
						if not f then
							f = sub.package.loadlib(path, 'luaopen_' .. f_name .. '_core')
						end
						if f then

							-- a valid loader function has been found in the dynamic library; call
							-- it within the sandbox.
							base.setmetatable(base, { __index = sub, __newindex = sub })
							local entries = f()
							base.setmetatable(base, nil)

							-- convert paths of lua-filesystem functions.
							if name == 'lfs' then
								for k, v in base.pairs(entries) do
									if lfs_path_funcs[k] then
										local prev_v = v
										v = function(path, ...)
											return prev_v(convert_path(path, true), ...)
										end
										entries[k] = v
									end
								end
							end

							if entries and base.type(entries) == 'table' then
								for k, v in base.pairs(entries) do
									sub[k] = v
								end
								return entries
							end
						end
						return false
					end

					-- create a new environment for each file, in case the file creates a module.
					-- by default, the new environment just redirects to the parent environment.
					local file_env = sub_env(sub, script)
					base.setmetatable(file_env, { __index = sub, __newindex = sub })

					file_env.dofile(path)

					sub.package.loaded[name] = file_env.package.loaded[name] or true
					if base.type(sub.package.loaded[name]) == 'table' then
						return sub.package.loaded[name]._M
					end
					return true
				end

				if pos >= #paths then
					break
				end
				start = pos + 1
			end
		end

		local ret = parse_paths(convert_path(sub.package.path, true), false)
		if ret then
			return ret
		end

		ret = parse_paths(convert_path(sub.package.cpath, true), true)
		if ret then
			return ret
		end

		if #name > 8 and string.sub(name, 1, 8) == 'scripts.' then

			ret = parse_paths(sub[this .. 'path'] .. '?.lua', false)
			if ret then
				return ret
			end

			ret = parse_paths(sub[this .. 'path'] .. '?.dll;' .. sub[this .. 'path'] .. '?.so', true)
			if ret then
				return ret
			end

		end

		base.error('Error loading the module "' .. name .. '" via "require"')
	end

	-- sandboxed version of the "module" function that keeps variables in our sub-environment.
	sub.module = function(name, ...)
		if not sub[name] then
			sub[name] = {}
		end
		sub.package.loaded[name] = { _M = sub[name], _NAME = name, _PACKAGE = '' }

		local _, _, pack_name = string.find(name, '^([^%.]+%.)')
		if pack_name then
			sub.package.loaded[name]._PACKAGE = pack_name
		end

		-- redirect execution of the current module to its own environment.
		base.setmetatable(sub[name], { __index = base.getmetatable(sub).__index })
		base.setmetatable(sub, { __index = sub[name], __newindex = sub[name] })

		if ... == sub.package.seeall then
			base.setmetatable(sub.package.loaded[name], { __index = sub })
		end
	end

	-- PtokaX seems to be lenient when it comes to validating parameters...
	sub.string.rep = function(s, n)
		if n == nil or base.type(n) ~= 'number' then
			n = 0
		end
		return string.rep(s, n)
	end

	-- functions expected by a PtokaX script that need to be aware of their context.
	sub.Core.RegBot = function(name, description, email, op)
		if not description then
			description = ''
		end
		if not email then
			email = ''
		end
		description = utf8(description)
		email = utf8(email)
		local bot = find_entity(name)
		if bot and (script.bots[name] or bot:getSID() == access.bot.main_bot:getSID()) then
			local update = adchpp.AdcCommand(adchpp.AdcCommand_CMD_INF, adchpp.AdcCommand_TYPE_BROADCAST, bot:getSID())
			if bot:getField('DE') ~= description then
				bot:setField('DE', description)
				update:addParam('DE', description)
			end
			if bot:getField('EM') ~= email then
				bot:setField('EM', email)
				update:addParam('EM', email)
			end
			if op then
				if not bot:isSet(adchpp.Entity_FLAG_OP) then
					bot:setFlag(adchpp.Entity_FLAG_OP)
					update:addParam('CT', bot:getField('CT'))
				end
			elseif bot:isSet(adchpp.Entity_FLAG_OP) then
				bot:unsetFlag(adchpp.Entity_FLAG_OP)
				update:addParam('CT', bot:getField('CT'))
			end
			if update:getParameters():size() > 0 then
				cm:sendToAll(update:getBuffer())
			end
			return true
		end
		name = utf8(name)
		local cid
		local regged = access.users.nicks[name]
		if regged then
			cid = regged.cid
		else
			cid = adchpp.CID_generate():toBase32()
			access.register_user(cid, name, nil, bot_level)
		end
		local async_reg = bot ~= nil
		bot = cm:createSimpleBot()
		bot:setCID(adchpp.CID(cid))
		bot:setField('ID', bot:getCID():toBase32())
		bot:setField('NI', name)
		bot:setField('DE', description)
		bot:setField('EM', email)
		access.set_level(bot, bot_level)
		if not op then
			bot:unsetFlag(adchpp.Entity_FLAG_OP)
		end
		script.bots[name] = bot
		if async_reg then
			adchpp.getPM():attention(function() cm:regBot(bot) end)
		else
			cm:regBot(bot)
		end
		return true
	end
	sub.Core.UnregBot = function(name)
		name = utf8(name)
		if not script.bots[name] then
			return nil
		end
		access.users.cids[script.bots[name]:getCID():toBase32()] = nil
		access.users.nicks[name] = nil
		access.register_user() -- no-op to save the user database
		script.bots[name]:disconnect(adchpp.Util_REASON_PLUGIN)
		script.bots[name] = nil
		return true
	end
	sub.Core.GetPtokaXPath = function()
		return sub[this .. 'path']
	end
	sub.Core.SendToAll = function(data)
		for _, command in base.ipairs(convert(add_pipe(data), script, true)) do
			cm:sendToAll(command:getBuffer())
		end
	end
	sub.Core.SendToNick = function(to, data)
		to = find_entity(to)
		if to then
			for _, command in base.ipairs(convert(add_pipe(data), script, true)) do
				to:send(command)
			end
		end
	end
	sub.Core.SendToOpChat = function(data)
		if opchat_bot then
			local nick = acp(opchat_bot:getField('NI'))
			local commands = convert(add_pipe('$To: ' .. nick .. ' From: ' .. nick .. ' $' .. data), script, true)
			local entities = cm:getEntities()
			for i = 0, entities:size() - 1 do
				local e = entities[i]
				if e:isSet(adchpp.Entity_FLAG_OP) then
					for _, command in base.ipairs(commands) do
						e:send(command)
					end
				end
			end
		end
	end
	sub.Core.SendToOps = function(data, data_)
		-- some scripts provide a user table as the first argument...
		if base.type(data) ~= 'string' then
			if base.type(data_) == 'string' then
				data = data_
			else
				return
			end
		end
		for _, user in base.pairs(users) do
			if user.bOperator then
				sub.Core.SendToUser(user, data)
			end
		end
	end
	sub.Core.SendToProfile = function(profile, data)
		for _, user in base.pairs(users) do
			if user.iProfile == profile then
				sub.Core.SendToUser(user, data)
			end
		end
	end
	sub.Core.SendToUser = function(user, data)
		sub.Core.SendToNick(user.sNick, data)
	end
	sub.Core.SendPmToAll = function(from, data)
		for _, user in base.pairs(users) do
			sub.Core.SendPmToUser(user, from, data)
		end
	end
	sub.Core.SendPmToNick = function(to, from, data)
		sub.Core.SendToNick(to, '$To: ' .. to .. ' From: ' .. from .. ' $' .. data)
	end
	sub.Core.SendPmToOps = function(from, data)
		for _, user in base.pairs(users) do
			if user.bOperator then
				sub.Core.SendPmToUser(user, from, data)
			end
		end
	end
	sub.Core.SendPmToProfile = function(profile, from, data)
		for _, user in base.pairs(users) do
			if user.iProfile == profile then
				sub.Core.SendPmToUser(user, from, data)
			end
		end
	end
	sub.Core.SendPmToUser = function(user, from, data)
		sub.Core.SendPmToNick(user.sNick, from, data)
	end
	sub.ScriptMan.GetScript = function()
		return { sName = script.name, bEnabled = true, iMemUsage = 0 }
	end
	sub.TmrMan.AddTimer = function(interval, func)
		local f = nil
		if func then
			if sub[func] then
				f = sub[func]
			end
		else
			f = sub.OnTimer
		end
		if not f then
			return nil
		end
		local id = table.getn(script.timers) + 1
		script.timers[id] = sm:addTimedJob(interval, function() call(f, script, id) end)
		return id
	end
	sub.TmrMan.RemoveTimer = function(id)
		local cancel_f = script.timers[id]
		if cancel_f then
			cancel_f()
			script.timers[id] = nil
		end
	end

	return sub
end

-- run a PtokaX call in one script.
call = function(f, script, ...)
	base.setfenv(f, script.sub)
	local ret = { base.pcall(f, ...) }
	if ret[1] then
		return ret
	end

	-- disable the script on errors.
	if script.sub.OnError then
		base.setfenv(script.sub.OnError, script.sub)
		base.pcall(script.sub.OnError, ret[2])
	end
	log('Error in the ' .. script.name .. ' script: ' .. ret[2])
	unload_script(script)
	save_scripts()
end

-- run a PtokaX call in every loaded script.
execute = function(f, ...)
	for _, script in base.ipairs(scripts) do
		if script.enabled and script.sub[f] then
			call(script.sub[f], script, ...)
		end
	end
end

-- run a PtokaX call in every loaded script; forward the return value.
execute_r = function(f, ...)
	for _, script in base.ipairs(scripts) do
		if script.enabled and script.sub[f] then
			local ret = call(script.sub[f], script, ...)
			if ret then
				-- ignore the first value (success flag set by pcall).
				for i = 2, table.maxn(ret) do
					if ret[i] then
						return false
					end
				end
			end
		end
	end
	return true
end

profiles = {}
level_profile = function(level)
	-- default profiles in PtokaX are "Master", "Operator", "VIP", "Regged"; moreover, PtokaX uses
	-- -1 for un-regged whereas access.lua uses 0. many scripts hard-code these values instead of
	-- using the "ProfMan" profile manager, so make up for it...
	if level == 0 then
		return -1 -- un-regged
	end
	if level == 1 then
		return 3 -- Regged
	end
	if level < access.level_op then
		return 2 -- VIP
	end
	if level == access.level_op then
		return 1 -- Operator
	end
	return 0 -- Master
end
local function add_profile(number, name, op)
	table.insert(profiles, {
		sProfileName = name,
		iProfileNumber = number,
		tProfilePermissions = {
			bIsOP = op,
			bNoDefloodGetNickList = true,
			bNoDefloodNMyINFO = true,
			bNoDefloodSearch = true,
			bNoDefloodPM = true,
			bNoDefloodMainChat = true,
			bMassMsg = op,
			bTopic = op,
			bTempBan = op,
			bTempUnban = op,
			bRefreshTxt = op,
			bNoTagCheck = true,
			bDelRegUser = op,
			bAddRegUser = op,
			bNoChatLimits = op,
			bNoMaxHubCheck = true,
			bNoSlotHubRatio = true,
			bNoSlotCheck = true,
			bNoShareLimit = true,
			bClrPermBan = op,
			bClrTempBan = op,
			bGetInfo = true,
			bGetBans = op,
			bRestartScripts = op,
			bRestartHub = op,
			bTempOP = false,
			bGag = op,
			bRedirect = op,
			bBan = op,
			bUnban = op,
			bKick = op,
			bDrop = op,
			bEnterFullHub = false,
			bEnterIfIPBan = false,
			bAllowedOPChat = op,
			bSendFullMyinfos = true,
			bSendAllUserIP = true,
			bRangeBan = op,
			bRangeUnban = op,
			bRangeTempBan = op,
			bRangeTempUnban = op,
			bGetRangeBans = op,
			bClearRangePermBans = op,
			bClearRangeTempBans = op,
			bNoIpCheck = false,
			bClose = op,
			bNoSearchLimits = true,
			bNoDefloodCTM = true,
			bNoDefloodRCTM = true,
			bNoDefloodSR = true,
			bNoDefloodRecv = true,
			bNoChatInterval = true,
			bNoPMInterval = true,
			bNoSearchInterval = true,
			bNoMaxUsersSameIP = true,
			bNoReConnTime = true
		}
	})
end
add_profile(0, 'Master', true)
add_profile(1, 'Operator', true)
add_profile(2, 'VIP', false)
add_profile(3, 'Regged', false)
reg_table = function(user, profile)
	local nick = user.nick
	if not nick then
		nick = ''
	end
	local password = user.password
	if not password then
		password = ''
	end
	if not profile then
		profile = level_profile(user.level)
	end
	return {
		sNick = acp(nick),
		sPassword = acp(password),
		iProfile = profile
	}
end
registered_users = function()
	local ret = {}
	for _, user in base.ipairs(access.registered_users()) do
		if user.nick and user.level ~= bot_level and user.level ~= bot_level + 1 then
			table.insert(ret, user)
		end
	end
	return ret
end
local function str_profile(user)
	if user.iProfile == -1 then
		return 'User'
	end
	if user.bOperator then
		return 'Op'
	end
	return 'Reg'
end

-- user objects usable by PtokaX scripts
users = {}

local function update_user(entity)
	local function int_field(code)
		local field = entity:getField(code)
		if #field > 0 then
			return base.tonumber(field)
		end
		return 0
	end
	local ni = entity:getField('NI')
	local t = {
		sNick = acp(ni),
		sIP = entity:getField('I4'),
		uptr = 0,
		iProfile = level_profile(access.get_level(entity)),
		sMode = '', -- set later.
		sMyInfoString = '', -- set later.
		sDescription = acp(entity:getField('DE')),
		sTag = '', -- set later.
		sConnection = 'DSL',
		sEmail = acp(entity:getField('EM')),
		sClient = acp(entity:getField('AP')),
		sClientVersion = acp(entity:getField('VE')),
		sVersion = '1,0091',
		sCountryCode = '', -- TODO
		bConnected = entity:getState() == adchpp.Entity_STATE_NORMAL,
		bActive = true, -- set later.
		bOperator = entity:isSet(adchpp.Entity_FLAG_OP),
		bUserCommand = entity:hasSupport(adchpp.AdcCommand_toFourCC('UCMD')) or entity:hasSupport(adchpp.AdcCommand_toFourCC('UCM0')),
		bQuickList = true,
		bSuspiciousTag = false,
		iShareSize = int_field('SS'),
		iHubs = 0, -- set later.
		iNormalHubs = int_field('HN'),
		iRegHubs = int_field('HR'),
		iOpHubs = int_field('HO'),
		iSlots = int_field('SL'),
		iLlimit = 0,
		iDefloodWarns = 0,
		iMagicByte = 0,
		iLoginTime = 0 -- TODO
	}
	local su = entity:getField('SU')
	t.bActive = #t.sIP > 0 and string.find(su, 'TCP4') and
	string.len(entity:getField('U4')) > 0 and string.find(su, 'UDP4')
	if t.bActive then
		t.sMode = 'A'
	else
		t.sMode = 'P'
	end
	if #t.sClient == 0 then -- AP is an extension; default to VE.
		local ve_found, _, ve_client, ve_ver = string.find(t.sClientVersion, '(.+) ([^ ]+)$')
		if ve_found then
			t.sClient = ve_client
			t.sClientVersion = ve_ver
		end
	end
	local client = t.sClient
	if t.sClient == '++' then
		t.sClient = 'DC++'
	end
	t.sTag = '<' .. client .. ' V:' .. t.sClientVersion .. ',M:' .. t.sMode .. ',H:' .. base.tostring(t.iNormalHubs) .. '/' .. base.tostring(t.iRegHubs) .. '/' .. base.tostring(t.iOpHubs) .. ',S:' .. base.tostring(t.iSlots) .. '>'
	local aw = entity:getField('AW')
	if #aw > 0 then
		aw = 2
	else
		aw = 1
	end
	t.sMyInfoString = '$MyINFO $ALL ' .. t.sNick .. ' ' .. t.sDescription .. t.sTag .. '$ $' .. t.sConnection .. string.char(aw) .. '$' .. t.sEmail .. '$' .. base.tostring(t.iShareSize) .. '$|'
	t.iHubs = t.iNormalHubs + t.iRegHubs + t.iOpHubs
	users[entity:getSID()] = t
	nicks[t.sNick] = ni
end

user_entry = function(entity)
	if entity:getState() ~= adchpp.Entity_STATE_NORMAL then
		return
	end

	if entity:isSet(adchpp.Entity_FLAG_BOT) then
		return
	end

	local user_count = cm:getEntities():size()
	if user_count > user_peak then
		user_peak = user_count
	end

	update_user(entity)
	local sid = entity:getSID()
	local user = users[sid]
	local supports = 'NoGetINFO NoHello UserIP2 TTHSearch ZPipe0'
	if user.bUserCommand then
		supports = 'UserCommand ' .. supports
	end
	if entity:hasSupport(adchpp.AdcCommand_toFourCC('DHT0')) then
		supports = supports .. ' DHT0'
	end
	execute('SupportsArrival', users[sid], '$Supports ' .. supports .. ' |')
	execute('VersionArrival', users[sid], '$Version ' .. user.sVersion .. '|')
	execute('GetNickListArrival', users[sid], '$GetNickList|')
	execute('MyINFOArrival', users[sid], user.sMyInfoString)
	execute(str_profile(users[sid]) .. 'Connected', users[sid])
end

-- hidden bot used for scripts that want to send messages from non-exsistent users.
hidden_bot = cm:createSimpleBot()
hidden_bot:setField('ID', hidden_bot:getCID():toBase32())
hidden_bot:setField('NI', this .. '-hidden_bot')
hidden_bot:setField('DE', 'Hidden bot used by ' .. full_name)
hidden_bot:setFlag(adchpp.Entity_FLAG_HIDDEN)
cm:regBot(hidden_bot)

refresh_opchat()

-- Wait for the whole script to be loaded and for the current dir to have been switched back from
-- the script dir to the ADCH++ bin dir to avoid path problems when scripts use relative paths.
autil.on_loaded(this, function() adchpp.getPM():attention(function()

	base.pcall(function()
		for line in io.lines(scripts_file) do
			if #line > 0 then
				table.insert(scripts, {
					name = formalize_script_name(line),
					enabled = string.sub(line, 1, 1) ~= '#'
				})
			end
		end
	end)

	for _, script in base.ipairs(scripts) do
		if script.enabled then
			load_script(script)
		end
	end

	-- Simulate connections for users who are already online when scripts are loaded.
	local entities = cm:getEntities()
	for i = 0, entities:size() - 1 do
		user_entry(entities[i])
	end
end) end)

autil.on_unloading(this, function()
	for _, script in base.ipairs(scripts) do
		if script.enabled then
			unload_script(script)
		end
	end
end)

autil.on_unloaded(this, function()
	if opchat_bot then
		opchat_bot:disconnect(adchpp.Util_REASON_PLUGIN)
	end
	hidden_bot:disconnect(adchpp.Util_REASON_PLUGIN)
end)

ptokax_1 = cm:signalState():connect(user_entry)

ptokax_2 = cm:signalReceive():connect(function(entity, cmd, ok)
	if cmd:getCommand() == adchpp.AdcCommand_CMD_INF then
		local nick = cmd:getParam('NI', 0)
		if #nick > 0 and string.find(nick, '[&%$|;]') then
			autil.dump(entity:asClient(), adchpp.AdcCommand_ERROR_NICK_INVALID, 'The following characters are not allowed in nicks: & $ | ;')
			cmd:setPriority(adchpp.AdcCommand_PRIORITY_IGNORE)
			ok = false
		end
	end

	if not ok then
		return ok
	end

	if entity:isSet(adchpp.Entity_FLAG_BOT) then
		return ok
	end

	local parse_cmd = function(entity, cmd)

		if cmd:getCommand() == adchpp.AdcCommand_CMD_INF then
			if entity:getState() == adchpp.Entity_STATE_NORMAL then
				-- imitate ClientManager::handle(AdcCommand::INF, ...) to make sure the user info
				-- is up-to-date before we dispatch it.
				if not cm:verifyINF(entity, cmd) then
					return false
				end
				cm:send(cmd) -- send now because we are going to set it to ignored later on.
				update_user(entity)
				execute('MyINFOArrival', users[entity:getSID()], users[entity:getSID()].sMyInfoString)
				return false
			end

		elseif cmd:getCommand() == adchpp.AdcCommand_CMD_MSG then
			local user = users[entity:getSID()]
			if user then
				local str = '<' .. user.sNick .. '> ' .. escape(cmd:getParam(0)) .. '|'
				if #cmd:getParam('PM', 1) > 0 then
					local to = cm:getEntity(cmd:getTo())
					if not to then
						to = access.bot.main_bot
					end
					if opchat_bot and to:getSID() == opchat_bot:getSID() then
						if not entity:isSet(adchpp.Entity_FLAG_OP) then
							return false
						end
						local opchat_cmd = adchpp.AdcCommand(cmd:getBuffer())
						opchat_cmd:delParam('PM', 1)
						opchat_cmd:addParam('PM', adchpp.AdcCommand_fromSID(opchat_bot:getSID()))
						local entities = cm:getEntities()
						for i = 0, entities:size() - 1 do
							local e = entities[i]
							if e:isSet(adchpp.Entity_FLAG_OP) and e:getSID() ~= entity:getSID() then
								e:send(opchat_cmd:getBuffer())
							end
						end
					end
					if not execute_r('ToArrival', user, '$To: ' .. acp(to:getField('NI')) ..
						' From: ' .. user.sNick .. ' $' .. acp(str)) then
						-- still dispatch the echo PM back to the user.
						entity:send(cmd)
						return false
					end
					return true
				end
				return execute_r('ChatArrival', user, acp(str))
			end

		elseif cmd:getCommand() == adchpp.AdcCommand_CMD_CTM then
			local user = users[entity:getSID()]
			if user then
				local to = cm:getEntity(cmd:getTo())
				if to then
					return execute_r('ConnectToMeArrival', user, '$ConnectToMe ' ..
					acp(to:getField('NI')) .. ' ' .. user.sIP .. ':' .. acp(cmd:getParam(1)) ..
					'|')
				end
			end

		elseif cmd:getCommand() == adchpp.AdcCommand_CMD_RCM then
			local user = users[entity:getSID()]
			if user then
				local to = cm:getEntity(cmd:getTo())
				if to then
					return execute_r('RevConnectToMeArrival', user, '$RevConnectToMe ' ..
					user.sNick .. ' ' .. acp(to:getField('NI')) .. '|')
				end
			end

		elseif cmd:getCommand() == adchpp.AdcCommand_CMD_SCH then
			if cmd:getType() == adchpp.AdcCommand_TYPE_FEATURE then
				if string.find(cmd:getFeatures(), '%+NAT[0T]') then
					-- ignore double-searches sent by passive DC++.
					return true
				end
			end
			local user = users[entity:getSID()]
			if user then
				local str = '$Search '
				if entity:hasSupport(adchpp.AdcCommand_toFourCC('UDP4')) then
					str = str .. entity:getField('I4') .. ':' .. entity:getField('U4')
				else
					str = str .. 'Hub:' .. entity:getField('NI')
				end
				str = str .. ' '
				local le = cmd:getParam('LE', 0)
				local ge = cmd:getParam('GE', 0)
				local eq = cmd:getParam('EQ', 0)
				if #le > 0 or #ge > 0 or #eq > 0 then
					str = str .. 'T'
				else
					str = str .. 'F'
				end
				str = str .. '?'
				if #ge > 0 then
					str = str .. 'T'
				else
					str = str .. 'F'
				end
				str = str .. '?'
				if #le > 0 then
					str = str .. le
				elseif #ge > 0 then
					str = str .. ge
				elseif #eq > 0 then
					str = str .. eq
				else
					str = str .. '0'
				end
				str = str .. '?'
				local tr = cmd:getParam('TR', 0)
				if #tr > 0 then
					str = str .. '9'
				elseif cmd:getParam('TY', 0) == '2' then
					str = str .. '8'
				else
					str = str .. '1'
				end
				str = str .. '?'
				if #tr > 0 then
					str = str .. 'TTH:' .. tr
				else
					local terms = {}
					local params = cmd:getParameters()
					local params_size = params:size()
					if params_size > 0 then
						for i = 0, params_size - 1 do
							local param = params[i]
							if #param > 2 then
								local operator = string.sub(param, 1, 2)
								if operator == 'AN' or operator == 'EX' then
									local term = escape(string.sub(param, 3))
									term = string.gsub(term, ' ', '$')
									table.insert(terms, term)
								end
							end
						end
					end
					str = str .. table.concat(terms, '$')
				end
				str = str .. '|'
				return execute_r('SearchArrival', user, acp(str))
			end
		end

		return true
	end

	if cmd:getCommand() == adchpp.AdcCommand_CMD_CMD then
		local param = cmd:getParam(0)
		if #param > #this and string.sub(param, 1, #this) == this then
			for _, back_cmd in base.ipairs(convert(string.sub(param, #this + 1), nil, false)) do
				parse_cmd(entity, back_cmd)
			end
			ok = false
		end

	else
		ok = parse_cmd(entity, cmd)
	end

	if not ok then
		cmd:setPriority(adchpp.AdcCommand_PRIORITY_IGNORE)
	end
	return ok
end)

ptokax_3 = cm:signalDisconnected():connect(function(entity)
	if entity:isSet(adchpp.Entity_FLAG_BOT) then
		return
	end

	local user = users[entity:getSID()]
	if user then
		execute(str_profile(user) .. 'Disconnected', user)
		nicks[user.sNick] = nil
		users[entity:getSID()] = nil
	end
end)
