-- LUALOCALS < ---------------------------------------------------------
local error, math, minetest, nodecore, pairs, string, table, type,
      vector
    = error, math, minetest, nodecore, pairs, string, table, type,
      vector
local math_floor, string_format, table_shuffle
    = math.floor, string.format, table.shuffle
-- LUALOCALS > ---------------------------------------------------------

local modname = minetest.get_current_modname()

local optic_distance = 16
local optic_speed = 12
local optic_tick_limit = nodecore.setting_float(modname .. "_tick_limit", 0.2,
	"Optics - tick limit", [[Maximum amount of time in seconds that may be
	spent during a single server step to calculate optic state. Optics
	will be allowed to slow don to stay within this limit.]])
local optic_interval = nodecore.setting_float(modname .. "_interval", 5,
	"Optics - check interval", [[ABM interval for periodically pushing
	optics into the "passive" queue.
	Passive checks are used to catch optics in an inconsistent state, e.g.
	that missed their change event.]])
local optic_passive_max = nodecore.setting_float(modname .. "_passive_max", 25,
	"Optics - check passive max", [[The maximum number of optics that can be
	queued in a single pass for "passive" checks to run; pending passive
	checks will be included to fill up remaining spaces up to this total.
	Passive checks are used to catch optics in an inconsistent state, e.g.
	that missed their change event.]])
local optic_passive_min = nodecore.setting_float(modname .. "_passive_min", 5,
	"Optics - check passive min", [[The minimum number of "passive" optic
	checks that are run each cycle, overriding the max if needed.
	Passive checks are used to catch optics in an inconsistent state, e.g.
	that missed their change event.]])

local microtime = minetest.get_us_time
local hashpos = minetest.hash_node_position
local unhash = minetest.get_position_from_hash
local get_node = minetest.get_node

local node_optic_checks = {}
local node_optic_sources = {}
local node_opaque = {}
local node_visinv = {}
local node_storebox_access = {}
minetest.after(0, function()
		for k, v in pairs(minetest.registered_items) do
			if k ~= "" then
				node_optic_checks[k] = v.optic_check or nil
				node_optic_sources[k] = v.optic_source or nil
				node_visinv[k] = v.groups and v.groups.visinv or nil
				node_storebox_access[k] = v.storebox_access or nil

				local grp_t = minetest.get_item_group(k, "optic_transparent") ~= 0
				local grp_o = minetest.get_item_group(k, "optic_opaque") ~= 0
				if (grp_t and grp_o) then
					error("node cannot be BOTH optic_opaque and optic_transparent")
				end
				node_opaque[k] = grp_o or (not
					(grp_t or v.sunlight_propagates)) or nil
			end
		end
	end)

local optic_queue = {}
local passive_queue = {}
local dependency_index = {}
local dependency_reverse = {}

local function mapblock(pos)
	return {
		x = math_floor((pos.x + 0.5) / 16),
		y = math_floor((pos.y + 0.5) / 16),
		z = math_floor((pos.z + 0.5) / 16),
	}
end

local function scan(pos, dir, max, getnode, cbbs)
	local p = pos
	if (not max) or (max > optic_distance) then max = optic_distance end
	for _ = 1, max do
		local o = p
		p = vector.add(p, dir)

		if cbbs and not vector.equals(mapblock(o), mapblock(p)) then
			cbbs[#cbbs + 1] = {
				pos = vector.add(o, vector.multiply(dir, 0.5)),
				dir = dir,
				plane = {
					x = dir.x == 0 and 1 or 0,
					y = dir.y == 0 and 1 or 0,
					z = dir.z == 0 and 1 or 0,
				}
			}
		end
		local node = getnode(p)
		if (not node) or node.name == "ignore" then return end
		if node_opaque[node.name] and not node_visinv[node.name] then return p, node end
		if node_visinv[node.name] then
			if node_opaque[node.name] then
				local acc = node_storebox_access[node.name]
				if not acc then return p, node end
				-- check that “light” can come in from the old position by checking for access
				if not acc({above = o, under = p}, p, {}) then return p, node end
				-- check that “light” can go out: this should be checked after checking
				-- if the content is opaque, but we're going to return the same p, node anyway
				-- so it doesn't really matter
				if not acc({above = vector.add(p, dir), under = p}, p, {}) then return p, node end
			end
			local stack = nodecore.stack_get(p)
			if node_opaque[stack:get_name()] then
				return p, node
			end
		end
	end
end
nodecore.optic_scan = scan

local function scan_recv(pos, dir, max, getnode)
	local hit, node = scan(pos, dir, max, getnode)
	if not node then return end
	local src = node_optic_sources[node.name]
	src = src and src(hit, node)
	if not src then return end
	local rev = vector.multiply(dir, -1)
	for _, v in pairs(src) do
		if vector.equals(v, rev) then
			return hit, node
		end
	end
end
nodecore.optic_scan_recv = scan_recv

local function optic_check(pos)
	optic_queue[hashpos(pos)] = pos
end
nodecore.optic_check = optic_check

local function optic_trigger(start, dir, max)
	local pos, node = scan(start, dir, max, get_node)
	if node and node_optic_checks[node.name] then
		return optic_check(pos)
	end
end

local function optic_process(trans, pos, defer)
	local node = get_node(pos)
	if node.name == "ignore" then return end

	local check = node_optic_checks[node.name]
	if check then
		local ignored
		local deps = {}
		local getnode = function(p)
			local gn = get_node(p)
			deps[hashpos(p)] = true
			ignored = ignored or gn.name == "ignore"
			return gn
		end
		local recv = function(dir, max)
			return scan_recv(pos, dir, max, getnode)
		end
		local nn = check(pos, node, recv, getnode, defer)
		if (not ignored) and nn then
			trans[hashpos(pos)] = {
				pos = pos,
				nn = nn,
				deps = deps
			}
		end
	end
end

local mkdefer = function()
	local list = {}
	return function(f)
		list[#list + 1] = f
	end,
	function()
		for i = 1, #list do (list[i])() end
	end
end

local function optic_immediate(pos)
	local trans = {}
	local defer, finish = mkdefer()
	optic_process(trans, pos, defer)
	for _, v in pairs(trans) do
		if vector.equals(v.pos, pos) then
			local node = get_node(pos)
			if v.nn ~= node.name then
				node.name = v.nn
				nodecore.set_node(pos, node)
			end
			break
		end
	end
	finish()
	return optic_check(pos)
end
nodecore.optic_immediate = optic_immediate

local function optic_commit(v)
	local node = get_node(v.pos)

	local oldidx = {}
	local oldsrc = node_optic_sources[node.name]
	oldsrc = oldsrc and oldsrc(v.pos, node)
	if oldsrc then
		for _, dir in pairs(oldsrc) do
			oldidx[hashpos(dir)] = dir
		end
	end

	local nn = v.nn
	if type(nn) == "string" then nn = {name = nn} end
	nn.param = nn.param or node.param
	nn.param2 = nn.param2 or node.param2
	local vhash = hashpos(v.pos)
	if node.name ~= nn.name or node.param ~= nn.param or node.param2 ~= nn.param2 then
		local odef = minetest.registered_nodes[node.name] or {}
		local ndef = minetest.registered_nodes[nn.name] or {}
		if odef.nc_optic_family ~= ndef.nc_optic_family then
			nodecore.log("warning", string_format(
					"optic_commit tried to replace %s with %s at %s",
					node.name, nn.name, minetest.pos_to_string(v.pos)))
			return
		end

		minetest.set_node(v.pos, nn)
		local src = node_optic_sources[nn.name]
		src = src and src(v.pos, nn)
		local newidx = {}
		if src then
			for _, dir in pairs(src) do
				local hash = hashpos(dir)
				if not oldidx[hash] then
					optic_trigger(v.pos, dir)
				end
				newidx[hash] = dir
			end
		end
		for hash, dir in pairs(oldidx) do
			if not newidx[hash] then optic_trigger(v.pos, dir) end
		end
	end

	local olddep = dependency_reverse[vhash]
	if olddep then
		for k in pairs(olddep) do
			local t = dependency_index[k]
			if t then t[vhash] = nil end
		end
	end
	for k in pairs(v.deps) do
		local t = dependency_index[k]
		if not t then
			t = {}
			dependency_index[k] = t
		end
		t[vhash] = true
	end
end

minetest.register_abm({
		label = "optic check",
		interval = optic_interval,
		chance = 1,
		nodenames = {"group:optic_check"},
		action = function(pos)
			passive_queue[#passive_queue + 1] = pos
		end
	})
nodecore.register_lbm({
		name = modname .. ":optic_check",
		run_at_every_load = true,
		nodenames = {"group:optic_check"},
		action = optic_check
	})

local optic_check_pump
do
	local passive_batch = {}
	optic_check_pump = function()
		local batch = optic_queue
		optic_queue = {}

		if nodecore.stasis then
			passive_queue = {}
			return
		end

		if #passive_queue > 0 then
			passive_batch = passive_queue
			passive_queue = {}
			table_shuffle(passive_batch)
		end
		local max = optic_passive_max - #batch
		if max < optic_passive_min then max = optic_passive_min end
		if max > #passive_batch then max = #passive_batch end
		for _ = 1, max do
			local pos = passive_batch[#passive_batch]
			passive_batch[#passive_batch] = nil
			batch[hashpos(pos)] = pos
		end

		local trans = {}
		local defer, finish = mkdefer()
		for _, pos in pairs(batch) do
			optic_process(trans, pos, defer)
		end
		for _, v in pairs(trans) do
			optic_commit(v)
		end
		finish()
	end
end

do
	local tick = 1 / optic_speed
	local total = 0
	nodecore.register_globalstep(function(dtime)
			total = total + dtime / tick
			local starttime = microtime()
			local exp = starttime + optic_tick_limit * 1000000
			local starttotal = total
			while total >= 1 do
				optic_check_pump()
				total = total - 1
				if microtime() >= exp and total >= 1 then
					nodecore.log("warning", string_format("optics overbudget"
							.. " after %d cycle(s) in %0.3fs"
							.. ", losing %0.2f cycle(s)",
							starttotal - total,
							(microtime() - starttime) / 1000000,
							total))
					total = 0
				end
			end
		end)
end

local function optic_check_dependents(pos)
	local t = dependency_index[hashpos(pos)]
	if t then
		for k in pairs(t) do
			optic_check(unhash(k))
		end
	end
end
nodecore.optic_check_dependents = optic_check_dependents
nodecore.register_on_nodeupdate({func = optic_check_dependents})
