local posix = require("posix")

-- Targets:
--
-- {
--   dir = target's build directory
--   outs = target's object files
--   is = { set of rule types which made the target }
-- }

local environment = {}
local rules = {}
local targets = {}
local buildfiles = {}
local globals
local cwd = "."

local function inherit(high, low)
	local o = {}
	setmetatable(o, {
		__index = function(self, k)
			local x = high[k]
			if x then
				return x
			end
			return low[k]
		end
	})
	return o
end

local function asstring(o)
	local t = type(o)
	if (t == "nil") then
		return ""
	elseif (t == "string") then
		return o
	elseif (t == "number") then
		return o
	elseif (t == "table") then
		if o.is then
			return asstring(o.outs)
		else
			local s = {}
			for _, v in pairs(o) do
				s[#s+1] = asstring(v)
			end
			return table.concat(s, " ")
		end
	else
		error(string.format("can't turn values of type '%s' into strings", t))
	end
end

local function concatpath(...)
	local p = table.concat({...}, "/")
	return p:gsub("/+", "/"):gsub("^%./", ""):gsub("/%./", "/")
end

local function basename(filename)
	local _, _, b = filename:find("^.*/([^/]*)$")
	if not b then
		return ""
	end
	return b
end

local function basenames(collection)
	local o = {}
	for _, s in pairs(collection) do
		local b = basename(s)
		if (b ~= "") then
			o[#o+1] = b
		end
	end
	return o
end

local function dirname(filename)
	local _, _, b = filename:find("^(.*)/[^/]*$")
	if not b then
		return ""
	end
	return b
end

local function dirnames(collection)
	local o = {}
	for _, s in pairs(collection) do
		local b = dirname(s)
		if (b ~= "") then
			o[#o+1] = b
		end
	end
	return o
end

local function filenamesof(targets, pattern)
	local f = {}
	if targets then
		for _, r in pairs(targets) do
			if (type(r) == "table") and r.is then
				if r.outs then
					for _, o in pairs(r.outs) do
						if not pattern or o:find(pattern) then
							f[#f+1] = o
						end
					end
				end
			else
				error("list of targets contains something which isn't a target")
			end
		end
	end
	return f
end

-- Selects all targets containing at least one output file that matches
-- the pattern.
local function selectof(targets, pattern)
	local o = {}
	for k, v in pairs(targets) do
		if v.is and v.outs then
			local matches = false
			for _, f in pairs(v.outs) do
				if f:find(pattern) then
					matches = true
					break
				end
			end
			if matches then
				o[#o+1] = v
			end
		end
	end
	return o
end

local function uniquify(collection)
	local s = {}
	local o = {}
	for _, v in pairs(collection) do
		if not s[v] then
			s[v] = true
			o[#o+1] = v
		end
	end
	return o
end

local function startswith(needle, haystack)
	return haystack:sub(1, #needle) == needle
end

local function emit(...)
	local n = select("#", ...)
	local args = {...}

	for i=1, n do
		local s = asstring(args[i])
		io.stdout:write(s)
		if not s:find("\n$") then
			io.stdout:write(" ")
		end
	end
end

local function templateexpand(list, vars)
	vars = inherit(vars, globals)

	local o = {}
	for _, s in ipairs(list) do
		o[#o+1] = s:gsub("%%%b{}",
			function(expr)
				expr = expr:sub(3, -2)
				local chunk, e = loadstring("return "..expr, expr, "text", vars)
				if e then
					error(string.format("error evaluating expression: %s", e))
				end
				return asstring(chunk())
			end
		)
	end
	return o
end
	
local function loadbuildfile(filename)
	if not buildfiles[filename] then
		buildfiles[filename] = true

		local fp, data, chunk, e
		io.stderr:write("loading ", filename, "\n")
		fp, e = io.open(filename)
		if not e then
			data, e = fp:read("*a")
			fp:close()
			if not e then
				local thisglobals = {_G = thisglobals}
				setmetatable(thisglobals, {__index = globals})
				chunk, e = loadstring(data, filename, "text", thisglobals)
			end
		end
		if e then
			error(string.format("couldn't load '%s': %s", filename, e))
		end

		local oldcwd = cwd
		cwd = dirname(filename)
		chunk()
		cwd = oldcwd
	end
end

local function loadtarget(targetname)
	if targets[targetname] then
		return targets[targetname]
	end

	local target
	if not targetname:find(":") then
		local files
		if targetname:find("[?*]") then
			files = posix.glob(targetname)
			if not files then
				error(string.format("glob '%s' matches no files", targetname))
			end
		else
			files = {targetname}
		end

		target = {
			outs = files,
			is = {
				__implicitfile = true
			}
		}
		targets[targetname] = target
	else
		local _, _, filepart, targetpart = targetname:find("^([^:]*):(%w+)$")
		if not filepart or not targetpart then
			error(string.format("malformed target name '%s'", targetname))
		end
		if (filepart == "") then
			filepart = cwd
		end
		local filename = concatpath(filepart, "/build.lua")
		loadbuildfile(concatpath(filename))

		target = targets[targetname]
		if not target then
			error(string.format("build file '%s' contains no rule '%s'",
				filename, targetpart))
		end
	end

	return target
end

local typeconverters = {
	targets = function(propname, i)
		if (type(i) == "string") then
			i = {i}
		elseif (type(i) ~= "table") then
			error(string.format("property '%s' must be a target list", propname))
		end

		local o = {}
		for _, s in ipairs(i) do
			if (type(s) == "table") and s.is then
				o[#o+1] = s
			elseif (type(s) == "string") then
				if s:find("^:") then
					s = cwd..s
				elseif s:find("^%./") then
					s = concatpath(cwd, s)
				end
				o[#o+1] = loadtarget(s)
			else
				error(string.format("member of target list '%s' is not a string or a target",
					propname))
			end
		end
		return o
	end,

	strings = function(propname, i)
		if (type(i) == "string") then
			i = {i}
		elseif (type(i) ~= "table") then
			error(string.format("property '%s' must be a string list", propname))
		end
		return i
	end,

	string = function(propname, i)
		if (type(i) ~= "string") then
			error(string.format("property '%s' must be a string", propname))
		end
		return i
	end,

	table = function(propname, i)
		if (type(i) ~= "table") then
			error(string.format("property '%s' must be a table", propname))
		end
		return i
	end,
}
	
local function definerule(rulename, types, cb)
	if rules[rulename] then
		error(string.format("rule '%s' is already defined", rulename))
	end

	types.name = { type="string" }

	for propname, typespec in pairs(types) do
		if not typeconverters[typespec.type] then
			error(string.format("property '%s' has unrecognised type '%s'",
				propname, typespec.type))
		end
	end

	rules[rulename] = function(e)
		local args = {}
		for propname, typespec in pairs(types) do
			if not e[propname] then
				if not typespec.optional and not typespec.default then
					error(string.format("missing mandatory property '%s'", propname))
				end

				args[propname] = typespec.default
			else
				args[propname] = typeconverters[typespec.type](propname, e[propname])
				e[propname] = nil
			end
		end

		local propname, _ = next(e)
		if propname then
			error(string.format("don't know what to do with property '%s'", propname))
		end

		args.environment = environment

		local result = cb(args) or {}
		result.is = result.is or {}
		result.is[rulename] = true
		targets[cwd..":"..args.name] = result
		return result
	end
end

-----------------------------------------------------------------------------
--                              DEFAULT RULES                              --
-----------------------------------------------------------------------------

function environment:rule(ins, outs)
	local firstout = outs[1]
	for i = 2, #outs do
		emit(outs[i]..":", outs[1], "\n")
	end
	for i = 1, #ins do
		emit(firstout..":", ins[i], "\n")
	end
	emit(firstout..":\n")
end

function environment:label(...)
	local s = table.concat({...}, " ")
	emit("\t@echo", s, "\n")
end

function environment:mkdirs(dirs)
	dirs = uniquify(dirs)
	if (#dirs > 0) then
		emit("\t@mkdir -p", dirs, "\n")
	end
end

function environment:exec(commands)
	emit("\t$(hide)", table.concat(commands, " && "), "\n")
end

function environment:endrule()
	emit("\n")
end

definerule("simplerule",
	{
		ins = { type="targets" },
		outs = { type="strings" },
		label = { type="string", optional=true },
		commands = { type="strings" },
		vars = { type="table", default={} },
	},
	function (e)
		e.environment:rule(filenamesof(e.ins), e.outs)
		e.environment:label(cwd..":"..e.name, " ", e.label or "")
		e.environment:mkdirs(dirnames(e.outs))

		local vars = inherit(e.vars, {
			ins = e.ins,
			outs = e.outs
		})

		e.environment:exec(templateexpand(e.commands, vars))
		e.environment:endrule()

		return {
			outs = e.outs
		}
	end
)

-----------------------------------------------------------------------------
--                               MAIN PROGRAM                              --
-----------------------------------------------------------------------------

globals = {
	asstring = asstring,
	basename = basename,
	basenames = basenames,
	concatpath = concatpath,
	cwd = cwd,
	definerule = definerule,
	dirname = dirname,
	dirnames = dirnames,
	emit = emit,
	environment = environment,
	filenamesof = filenamesof,
	inherit = inherit,
	selectof = selectof,
	startswith = startswith,
	uniquify = uniquify,
}
setmetatable(globals,
	{
		__index = function(self, k)
			local rule = rules[k]
			if rule then
				return rule
			else
				return _G[k]
			end
		end
	}
)

emit("hide=@\n")
for _, file in ipairs({...}) do
	loadbuildfile(file)
end