#!/usr/bin/lua

-- InfoDump.lua

--[[
Loads plugins' Info.lua and dumps its g_PluginInfo into various text formats
This is used for generating plugin documentation for the forum and for GitHub's INFO.md files

This script can be used in two ways:
Executing "lua InfoDump.lua" will go through all subfolders and dump each Info.lua file it can find
	Note that this mode of operation requires LuaRocks with LFS installed; instructions are printed
	when the prerequisites are not met.
Executing "lua InfoDump.lua PluginName" will load the Info.lua file from PluginName's folder and dump
only that one plugin's documentation. This mode of operation doesn't require LuaRocks
--]]





-- If this file is called using the loadfile function the arg variable isn't filled. We have to do it manualy then.
local arg = arg or {...}





-- Check Lua version. We use 5.1-specific construct when loading the plugin info, 5.2 is not compatible!
if (_VERSION ~= "Lua 5.1") then
	print("Unsupported Lua version. This script requires Lua version 5.1, this Lua is version " .. (_VERSION or "<nil>"))
	return
end





--- Removes any whitespace at the beginning and end of the string
local function TrimString(a_Str)
	return (string.match(a_Str, "^%s*(.-)%s*$"))
end





--- Replaces generic formatting with forum-specific formatting
-- Also removes the single line-ends
local function ForumizeString(a_Str)
	assert(type(a_Str) == "string")

	-- Remove the indentation, unless in the code tag:
	-- Only one code or /code tag per line is supported!
	local IsInCode = false
	local function RemoveIndentIfNotInCode(s)
		if (IsInCode) then
			-- we're in code section, check if this line terminates it
			IsInCode = (s:find("{%%/code}") ~= nil)
			return s .. "\n"
		else
			-- we're not in code section, check if this line starts it
			IsInCode = (s:find("{%%code}") ~= nil)
			return s:gsub("^%s*", "") .. "\n"
		end
	end
	a_Str = a_Str:gsub("(.-)\n", RemoveIndentIfNotInCode)

	-- Replace multiple line ends with {%p} and single line ends with a space,
	-- so that manual word-wrap in the Info.lua file doesn't wrap in the forum.
	a_Str = a_Str:gsub("\n\n", "{%%p}")
	a_Str = a_Str:gsub("\n", " ")

	-- Replace the generic formatting:
	a_Str = a_Str:gsub("{%%p}", "\n\n")
	a_Str = a_Str:gsub("{%%b}", "[b]"):gsub("{%%/b}", "[/b]")
	a_Str = a_Str:gsub("{%%i}", "[i]"):gsub("{%%/i}", "[/i]")
	a_Str = a_Str:gsub("{%%list}", "\n[list]"):gsub("{%%/list}", "[/list]")
	a_Str = a_Str:gsub("{%%li}", "\n[*]"):gsub("{%%/li}", "\n")

	-- Process links: {%a LinkDestination}LinkText{%/a}
	a_Str = a_Str:gsub("{%%a%s([^}]*)}([^{]*){%%/a}", "[url=%1]%2[/url]")

	-- TODO: Other formatting

	return a_Str
end





--- Replaces generic formatting with forum-specific formatting
-- Also removes the single line-ends
local function GithubizeString(a_Str)
	assert(type(a_Str) == "string")

	-- Remove the indentation, unless in the code tag:
	-- Only one code or /code tag per line is supported!
	local IsInCode = false
	local function RemoveIndentIfNotInCode(s)
		if (IsInCode) then
			-- we're in code section, check if this line terminates it
			IsInCode = (s:find("{%%/code}") ~= nil)
			return s .. "\n"
		else
			-- we're not in code section, check if this line starts it
			IsInCode = (s:find("{%%code}") ~= nil)
			return s:gsub("^%s*", "") .. "\n"
		end
	end
	a_Str = a_Str:gsub("(.-)\n", RemoveIndentIfNotInCode)

	-- Replace multiple line ends with {%p} and single line ends with a space,
	-- so that manual word-wrap in the Info.lua file doesn't wrap in the forum.
	a_Str = a_Str:gsub("\n\n", "{%%p}")
	a_Str = a_Str:gsub("\n", " ")

	-- Replace the generic formatting:
	a_Str = a_Str:gsub("{%%p}", "\n\n")
	a_Str = a_Str:gsub("{%%b}", "**"):gsub("{%%/b}", "**")
	a_Str = a_Str:gsub("{%%i}", "*"):gsub("{%%/i}", "*")
	a_Str = a_Str:gsub("{%%list}", "\n"):gsub("{%%/list}", "\n")
	a_Str = a_Str:gsub("{%%li}", "\n - "):gsub("{%%/li}", "")

	-- Process links: {%a LinkDestination}LinkText{%/a}
	a_Str = a_Str:gsub("{%%a%s([^}]*)}([^{]*){%%/a}", "[%2](%1)")

	-- TODO: Other formatting

	return a_Str
end





--- Builds an array of categories, each containing all the commands belonging to the category,
-- and the category description, if available.
-- Returns the array table, each item has the following format:
-- { Name = "CategoryName", Description = "CategoryDescription", Commands = {{CommandString = "/cmd verb", Info = {...}}, ...}}
local function BuildCategories(a_PluginInfo)
	-- The returned result
	-- This will contain both an array and a dict of the categories, to allow fast search
	local res = {}

	-- For each command add a reference to it into all of its categories:
	local function AddCommands(a_CmdPrefix, a_Commands)
		for cmd, info in pairs(a_Commands or {}) do
			local NewCmd =
			{
				CommandString = a_CmdPrefix .. cmd,
				Info = info,
			}

			if ((info.HelpString ~= nil) and (info.HelpString ~= "")) then
				-- Add to each specified category:
				local Category = info.Category
				if (type(Category) == "string") then
					Category = {Category}
				end
				for idx, cat in ipairs(Category or {""}) do
					local CatEntry = res[cat]
					if (CatEntry == nil) then
						-- First time we came across this category, create it:
						local NewCat = {Name = cat, Description = "", Commands = {NewCmd}}
						table.insert(res, NewCat)
						res[cat] = NewCat
					else
						-- We already have this category, just add the command to its list of commands:
						table.insert(CatEntry.Commands, NewCmd)
					end
				end  -- for idx, cat - Category[]
			end  -- if (HelpString valid)

			-- Recurse all subcommands:
			if (info.Subcommands ~= nil) then
				AddCommands(a_CmdPrefix .. cmd .. " ", info.Subcommands)
			end
		end  -- for cmd, info - a_Commands[]
	end  -- AddCommands()

	AddCommands("", a_PluginInfo.Commands)

	-- Assign descriptions to categories:
	for name, desc in pairs(a_PluginInfo.Categories or {}) do
		local CatEntry = res[name]
		if (CatEntry ~= nil) then
			-- The result has this category, add the description:
			CatEntry.Description = desc.Description
		end
	end

	-- Alpha-sort each category's command list:
	for idx, cat in ipairs(res) do
		table.sort(cat.Commands,
			function (cmd1, cmd2)
				return (string.lower(cmd1.CommandString) < string.lower(cmd2.CommandString))
			end
		)
	end

	return res
end





--- Returns a string specifying the command.
-- If a_Command is a simple string, returns a_Command colorized to blue
-- If a_Command is a table, expects members Name (full command name) and Params (command parameters),
-- colorizes command name blue and params green
local function GetCommandRefForum(a_Command)
	if (type(a_Command) == "string") then
		return "[color=blue]" .. a_Command .. "[/color]"
	end
	return "[color=blue]" .. a_Command.Name .. "[/color] [color=green]" .. (a_Command.Params or "") .. "[/color]"
end





--- Returns a string specifying the command.
-- If a_CommandParams is nil, returns a_CommandName apostrophed
-- If a_CommandParams is a string, apostrophes a_CommandName with a_CommandParams
local function GetCommandRefGithub(a_CommandName, a_CommandParams)
	assert(type(a_CommandName) == "string")
	if (a_CommandParams == nil) then
		return "`" .. a_CommandName .. "`"
	end

	assert(type(a_CommandParams) == "table")
	if ((a_CommandParams.Params == nil) or (a_CommandParams.Params == "")) then
		return "`" .. a_CommandName .. "`"
	end

	assert(type(a_CommandParams.Params) == "string")
	return "`" .. a_CommandName .. " " .. a_CommandParams.Params .. "`"
end





--- Writes the specified command detailed help array to the output file, in the forum dump format
local function WriteCommandParameterCombinationsForum(a_CmdString, a_ParameterCombinations, f)
	assert(type(a_CmdString) == "string")
	assert(type(a_ParameterCombinations) == "table")
	assert(f ~= nil)

	if (#a_ParameterCombinations == 0) then
		-- No explicit parameter combinations to write
		return
	end

	f:write("The following parameter combinations are recognized:\n")
	for idx, combination in ipairs(a_ParameterCombinations) do
		f:write("[color=blue]", a_CmdString, "[/color] [color=green]", combination.Params or "", "[/color]")
		if (combination.Help ~= nil) then
			f:write(" - ", ForumizeString(combination.Help))
		end
		if (combination.Permission ~= nil) then
			f:write(" (Requires permission '[color=red]", combination.Permission, "[/color]')")
		end
		f:write("\n")
	end
end





--- Writes the specified command detailed help array to the output file, in the forum dump format
local function WriteCommandParameterCombinationsGithub(a_CmdString, a_ParameterCombinations, f)
	assert(type(a_CmdString) == "string")
	assert(type(a_ParameterCombinations) == "table")
	assert(f ~= nil)

	if (#a_ParameterCombinations == 0) then
		-- No explicit parameter combinations to write
		return
	end

	f:write("The following parameter combinations are recognized:\n\n")
	for idx, combination in ipairs(a_ParameterCombinations) do
		f:write(GetCommandRefGithub(a_CmdString, combination))
		if (combination.Help ~= nil) then
			f:write(" - ", GithubizeString(combination.Help))
		end
		if (combination.Permission ~= nil) then
			f:write("   (Requires permission '**", combination.Permission, "**')")
		end
		f:write("\n")
	end
end





--- Writes all commands in the specified category to the output file, in the forum dump format
local function WriteCommandsCategoryForum(a_Category, f)
	-- Write category name:
	local CategoryName = a_Category.Name
	if (CategoryName == "") then
		CategoryName = "General"
	end
	f:write("\n[size=large]", ForumizeString(a_Category.DisplayName or CategoryName), "[/size]\n")

	-- Write description:
	if (a_Category.Description ~= "") then
		f:write(ForumizeString(a_Category.Description), "\n")
	end

	-- Write commands:
	f:write("\n[list]")
	for idx2, cmd in ipairs(a_Category.Commands) do
		f:write("\n[b]", cmd.CommandString, "[/b] - ", ForumizeString(cmd.Info.HelpString or "UNDOCUMENTED"), "\n")
		if (cmd.Info.Permission ~= nil) then
			f:write("Permission required: [color=red]", cmd.Info.Permission, "[/color]\n")
		end
		if (cmd.Info.DetailedDescription ~= nil) then
			f:write(ForumizeString(cmd.Info.DetailedDescription))
		end
		if (cmd.Info.ParameterCombinations ~= nil) then
			WriteCommandParameterCombinationsForum(cmd.CommandString, cmd.Info.ParameterCombinations, f)
		end
	end
	f:write("[/list]\n\n")
end





--- Writes all commands in the specified category to the output file, in the Github dump format
local function WriteCommandsCategoryGithub(a_Category, f)
	-- Write category name:
	local CategoryName = a_Category.Name
	if (CategoryName == "") then
		CategoryName = "General"
	end
	f:write("\n### ", GithubizeString(a_Category.DisplayName or CategoryName), "\n")

	-- Write description:
	if (a_Category.Description ~= "") then
		f:write(GithubizeString(a_Category.Description), "\n\n")
	end

	f:write("| Command | Permission | Description |\n")
	f:write("| ------- | ---------- | ----------- |\n")

	-- Write commands:
	for idx2, cmd in ipairs(a_Category.Commands) do
		f:write("|", cmd.CommandString, " | ", cmd.Info.Permission or "", " | ", GithubizeString(cmd.Info.HelpString or "UNDOCUMENTED"), "|\n")
	end
	f:write("\n\n")
end





local function DumpCommandsForum(a_PluginInfo, f)
	-- Copy all Categories from a dictionary into an array:
	local Categories = BuildCategories(a_PluginInfo)

	-- Sort the categories by name:
	table.sort(Categories,
		function(cat1, cat2)
			return (string.lower(cat1.Name) < string.lower(cat2.Name))
		end
	)

	if (#Categories == 0) then
		return
	end

	f:write("\n[size=x-large]Commands[/size]\n")

	-- Dump per-category commands:
	for idx, cat in ipairs(Categories) do
		WriteCommandsCategoryForum(cat, f)
	end
end





local function DumpCommandsGithub(a_PluginInfo, f)
	-- Copy all Categories from a dictionary into an array:
	local Categories = BuildCategories(a_PluginInfo)

	-- Sort the categories by name:
	table.sort(Categories,
		function(cat1, cat2)
			return (string.lower(cat1.Name) < string.lower(cat2.Name))
		end
	)

	if (#Categories == 0) then
		return
	end

	f:write("\n# Commands\n")

	-- Dump per-category commands:
	for idx, cat in ipairs(Categories) do
		WriteCommandsCategoryGithub(cat, f)
	end
end





local function DumpAdditionalInfoForum(a_PluginInfo, f)
	local AInfo = a_PluginInfo.AdditionalInfo
	if (type(AInfo) ~= "table") then
		-- There is no AdditionalInfo in a_PluginInfo
		return
	end

	for idx, info in ipairs(a_PluginInfo.AdditionalInfo) do
		if ((info.Title ~= nil) and (info.Contents ~= nil)) then
			f:write("\n[size=x-large]", ForumizeString(info.Title), "[/size]\n")
			f:write(ForumizeString(info.Contents), "\n")
		end
	end
end





local function DumpAdditionalInfoGithub(a_PluginInfo, f)
	local AInfo = a_PluginInfo.AdditionalInfo
	if (type(AInfo) ~= "table") then
		-- There is no AdditionalInfo in a_PluginInfo
		return
	end

	for idx, info in ipairs(a_PluginInfo.AdditionalInfo) do
		if ((info.Title ~= nil) and (info.Contents ~= nil)) then
			f:write("\n# ", GithubizeString(info.Title), "\n")
			f:write(GithubizeString(info.Contents), "\n")
		end
	end
end





--- Collects all permissions mentioned in the info, returns them as a sorted array
-- Each array item is {Name = "PermissionName", Info = { PermissionInfo }}
local function BuildPermissions(a_PluginInfo)
	-- Collect all used permissions from Commands, reference the commands that use the permission:
	local Permissions = a_PluginInfo.Permissions or {}
	local function CollectPermissions(a_CmdPrefix, a_Commands)
		for cmd, info in pairs(a_Commands or {}) do
			CommandString = a_CmdPrefix .. cmd
			if ((info.Permission ~= nil) and (info.Permission ~= "")) then
				-- Add the permission to the list of permissions:
				local Permission = Permissions[info.Permission] or {}
				Permissions[info.Permission] = Permission
				-- Add the command to the list of commands using this permission:
				Permission.CommandsAffected = Permission.CommandsAffected or {}
				-- First, make sure that we don't already have this command in the list,
				-- it may have already been present in a_PluginInfo
				local NewCommand = true
				for _, existCmd in ipairs(Permission.CommandsAffected) do
					if CommandString == existCmd then
						NewCommand = false
						break
					end
				end
				if NewCommand then
					table.insert(Permission.CommandsAffected, CommandString)
				end
			end

			-- Process the command param combinations for permissions:
			local ParamCombinations = info.ParameterCombinations or {}
			for idx, comb in ipairs(ParamCombinations) do
				if ((comb.Permission ~= nil) and (comb.Permission ~= "")) then
					-- Add the permission to the list of permissions:
					local Permission = Permissions[comb.Permission] or {}
					Permissions[info.Permission] = Permission
					-- Add the command to the list of commands using this permission:
					Permission.CommandsAffected = Permission.CommandsAffected or {}
					table.insert(Permission.CommandsAffected, {Name = CommandString, Params = comb.Params})
				end
			end

			-- Process subcommands:
			if (info.Subcommands ~= nil) then
				CollectPermissions(CommandString .. " ", info.Subcommands)
			end
		end
	end
	CollectPermissions("", a_PluginInfo.Commands)

	-- Copy the list of permissions to an array:
	local PermArray = {}
	for name, perm in pairs(Permissions) do
		table.insert(PermArray, {Name = name, Info = perm})
	end

	-- Sort the permissions array:
	table.sort(PermArray,
		function(p1, p2)
			return (p1.Name < p2.Name)
		end
	)
	return PermArray
end





local function DumpPermissionsForum(a_PluginInfo, f)
	-- Get the processed sorted array of permissions:
	local Permissions = BuildPermissions(a_PluginInfo)
	if ((Permissions == nil) or (#Permissions <= 0)) then
		return
	end

	-- Dump the permissions:
	f:write("\n[size=x-large]Permissions[/size]\n[list]\n")
	for idx, perm in ipairs(Permissions) do
		f:write("  - [color=red]", perm.Name, "[/color] - ")
		f:write(ForumizeString(perm.Info.Description or ""))
		local CommandsAffected = perm.Info.CommandsAffected or {}
		if (#CommandsAffected > 0) then
			f:write("\n[list] Commands affected:\n- ")
			local Affects = {}
			for idx2, cmd in ipairs(CommandsAffected) do
				table.insert(Affects, GetCommandRefForum(cmd))
			end
			f:write(table.concat(Affects, "\n - "))
			f:write("\n[/list]")
		end
		if (perm.Info.RecommendedGroups ~= nil) then
			f:write("\n[list] Recommended groups: ", perm.Info.RecommendedGroups, "[/list]")
		end
		f:write("\n")
	end
	f:write("[/list]")
end





local function DumpPermissionsGithub(a_PluginInfo, f)
	-- Get the processed sorted array of permissions:
	local Permissions = BuildPermissions(a_PluginInfo)
	if ((Permissions == nil) or (#Permissions <= 0)) then
		return
	end

	-- Dump the permissions:
	f:write("\n# Permissions\n")
	f:write("| Permissions | Description | Commands | Recommended groups |\n")
	f:write("| ----------- | ----------- | -------- | ------------------ |\n")
	for idx, perm in ipairs(Permissions) do
		f:write("| ", perm.Name, " | ")
		f:write(GithubizeString(perm.Info.Description or ""), " | ")
		local CommandsAffected = perm.Info.CommandsAffected or {}
		if (#CommandsAffected > 0) then
			local Affects = {}
			for idx2, cmd in ipairs(CommandsAffected) do
				if (type(cmd) == "string") then
					table.insert(Affects, GetCommandRefGithub(cmd))
				else
					table.insert(Affects, GetCommandRefGithub(cmd.Name, cmd))
				end
			end
			f:write(table.concat(Affects, ", "))
		end
		f:write(" | ")
		if (perm.Info.RecommendedGroups ~= nil) then
			f:write(perm.Info.RecommendedGroups)
		end
		f:write(" |\n")
	end
end





--- Dumps the forum-format info for the plugin
-- Returns true on success, nil and error message on failure
local function DumpPluginInfoForum(a_PluginFolder, a_PluginInfo)
	-- Open the output file:
	local f, msg = io.open(a_PluginFolder .. "/forum_info.txt", "w")
	if (f == nil) then
		return nil, msg
	end

	-- Write the description:
	f:write(ForumizeString(a_PluginInfo.Description), "\n")
	DumpAdditionalInfoForum(a_PluginInfo, f)
	DumpCommandsForum(a_PluginInfo, f)
	DumpPermissionsForum(a_PluginInfo, f)
	if (a_PluginInfo.SourceLocation ~= nil) then
		f:write("\n[b]Source[/b]: ", a_PluginInfo.SourceLocation, "\n")
	end
	if (a_PluginInfo.DownloadLocation ~= nil) then
		f:write("[b]Download[/b]: ", a_PluginInfo.DownloadLocation)
	end
	f:close()
	return true
end





--- Dumps the README.md file into the plugin's folder with the GitHub Markdown format
-- Returns true on success, nil and error message on failure
local function DumpPluginInfoGithub(a_PluginFolder, a_PluginInfo)
	-- Check the params:
	assert(type(a_PluginFolder) == "string")
	assert(type(a_PluginInfo) == "table")

	-- Open the output file:
	local f, msg = io.open(a_PluginFolder .. "/README.md", "w")
	if (f == nil) then
		print("\tCannot dump github info for plugin " .. a_PluginFolder .. ": " .. msg)
		return nil, msg
	end

	-- Write the description:
	f:write(TrimString(GithubizeString(a_PluginInfo.Description)), "\n")
	DumpAdditionalInfoGithub(a_PluginInfo, f)
	DumpCommandsGithub(a_PluginInfo, f)
	DumpPermissionsGithub(a_PluginInfo, f)

	f:close()
	return true
end





--- Tries to load the g_PluginInfo from the plugin's Info.lua file
-- Returns the g_PluginInfo table on success, or nil and error message on failure
local function LoadPluginInfo(a_FolderName)
	-- Load and compile the Info file:
	local cfg, err = loadfile(a_FolderName .. "/Info.lua")
	if (cfg == nil) then
		return nil, "Cannot open 'Info.lua': " .. err
	end

	-- Execute the loaded file in a sandbox:
	-- This is Lua-5.1-specific and won't work in Lua 5.2!
	local Sandbox = {}
	setfenv(cfg, Sandbox)
	local isSuccess, errMsg = pcall(cfg)
	if not(isSuccess) then
		return nil, "Cannot load Info.lua: " .. (errMsg or "<unknown error>")
	end

	if (Sandbox.g_PluginInfo == nil) then
		return nil, "Info.lua doesn't contain the g_PluginInfo declaration"
	end
	return Sandbox.g_PluginInfo
end





--- Processes the info for one plugin
-- Returns true on success, nil and error message on failure
local function ProcessPluginFolder(a_FolderName)
	-- Load the plugin info:
	local PluginInfo, Msg = LoadPluginInfo(a_FolderName)
	if (PluginInfo == nil) then
		return nil, "Cannot load info for plugin " .. a_FolderName .. ": " .. (Msg or "<unknown error>")
	end

	-- Dump the forum format:
	local isSuccess
	isSuccess, Msg = DumpPluginInfoForum(a_FolderName, PluginInfo)
	if not(isSuccess) then
		return nil, "Cannot dump forum info for plugin " .. a_FolderName .. ": " .. (Msg or "<unknown error>")
	end

	-- Dump the GitHub format:
	isSuccess, Msg = DumpPluginInfoGithub(a_FolderName, PluginInfo)
	if not(isSuccess) then
		return nil, "Cannot dump GitHub info for plugin " .. a_FolderName .. ": " .. (Msg or "<unknown error>")
	end

	-- All OK, return success
	return true
end





--- Tries to load LFS through LuaRocks, returns the LFS instance, or nil on error
local function LoadLFS()
	-- Try to load lfs, do not abort if not found ...
	local lfs, err = pcall(
		function()
			return require("lfs")
		end
	)

	-- ... rather, print a nice message with instructions:
	if not(lfs) then
		print([[
	Cannot load LuaFileSystem
	Install it through luarocks by executing the following command:
		luarocks install luafilesystem (Windows)
		sudo luarocks install luafilesystem (*nix)

	If you don't have luarocks installed, you need to install them using your OS's package manager, usually:
		sudo apt-get install luarocks (Ubuntu / Debian)
	On windows, a binary distribution can be downloaded from the LuaRocks homepage, https://github.com/luarocks/luarocks/wiki/Download
	]])

		print("Original error text: ", err)
		return nil
	end

	-- We now know that LFS is present, get it normally:
	return require("lfs")
end





local Arg1 = arg[1]
if ((Arg1 ~= nil) and (Arg1 ~= "")) then
	-- Called with a plugin folder name, export only that one
	local isSuccess, msg = ProcessPluginFolder(Arg1)
	if not(isSuccess) then
		print(msg or "<unknown error>")
	end
else
	-- Called without any arguments, process all subfolders:
	local lfs = LoadLFS()
	if (lfs == nil) then
		-- LFS not loaded, error has already been printed, just bail out
		return
	end
	print("Processing plugin subfolders:")
	for fnam in lfs.dir(".") do
		if ((fnam ~= ".") and (fnam ~= "..")) then
			local Attributes = lfs.attributes(fnam)
			if (Attributes ~= nil) then
				if (Attributes.mode == "directory") then
					print(fnam)
					local isSuccess, msg = ProcessPluginFolder(fnam)
					if not(isSuccess) then
						print("  " .. (msg or "<unknown error>"))
					end
				end
			end
		end
	end
end
print("Done.")


