--[[

	Networks
	========

	Copyright (C) 2021 Joachim Stolberg

	AGPL v3
	See LICENSE.txt for more information

]]--

-- for lazy programmers
local S2P = minetest.string_to_pos
local P2S = function(pos) if pos then return minetest.pos_to_string(pos) end end
local M = minetest.get_meta
local N = tubelib2.get_node_lvm

local Networks = {} -- cache for networks: {netw_type = {netID = <network>, ...}, ...}
local NetIDs = {}   -- cache for netw IDs: {pos_hash = {outdir = netID, ...}, ...}

local MAX_NUM_NODES = 1000  -- per network including junctions
local TTL = 5 * 60  -- 5 minutes
local Route = {} -- Used to determine the already passed nodes while walking
local NumNodes = 0  -- Used to determine the number of network nodes

local Flip = tubelib2.Turn180Deg
local get_nodename = networks.get_nodename
local get_node = networks.get_node
local tubelib2_get_pos = tubelib2.get_pos
local tubelib2_side_to_dir = tubelib2.side_to_dir

-------------------------------------------------------------------------------
-- Debugging
-------------------------------------------------------------------------------
-- Table for all registered tubelib2 instances
networks.registered_networks = {}  -- {api_type = {instance,...}}

-- Maintain simple numbers for the bulky netID hashes
local DbgNetIDs = {}
local DbgCounter = 1

local function netw_num(netID)
	if not netID or netID < 1 then
		return netID or 0
	end
	if not DbgNetIDs[netID] then
		DbgNetIDs[netID] = DbgCounter
		DbgCounter = DbgCounter + 1
	end
	return DbgNetIDs[netID]
end

local function network_nodes(netID, network)
	local tbl = {}
	for node_type,table in pairs(network or {}) do
		if type(table) == "table" then
			tbl[#tbl+1] = "#" .. node_type .. " = " .. #table
		end
	end
	tbl[#tbl+1] = "num_nodes = " .. (network.num_nodes or 0)
	return "Network " .. netw_num(netID) .. ": " .. table.concat(tbl, ", ")
end

-- Marker entities for debugging purposes
function networks.set_marker(pos, text, size, ttl)
	local marker = minetest.add_entity(pos, "networks:marker_cube")
	if marker ~= nil then
		marker:set_nametag_attributes({color = "#FFFFFF", text = text})
		size = size or 1
		marker:set_properties({visual_size = {x = size, y = size}})
		if ttl then
			minetest.after(ttl, marker.remove, marker)
		end
	end
end

minetest.register_entity("networks:marker_cube", {
	initial_properties = {
		visual = "cube",
		textures = {
			"networks_marker.png",
			"networks_marker.png",
			"networks_marker.png",
			"networks_marker.png",
			"networks_marker.png",
			"networks_marker.png",
		},
		physical = false,
		visual_size = {x = 1.1, y = 1.1},
		collisionbox = {-0.55,-0.55,-0.55, 0.55,0.55,0.55},
		glow = 8,
		static_save = false,
	},
	on_punch = function(self)
		self.object:remove()
	end,
})

-------------------------------------------------------------------------------
-- Helper
-------------------------------------------------------------------------------
-- return the networks table from the node definition
local function net_def(pos, netw_type)
	local ndef = minetest.registered_nodes[get_nodename(pos)]
	if ndef and ndef.networks then
		return ndef.networks[netw_type]
	end
	error("Node " ..  get_nodename(pos) .. " at ".. P2S(pos) .. " has no 'ndef.networks'")
end

local function net_def2(pos, node_name, netw_type)
	local ndef = minetest.registered_nodes[node_name]
	if ndef and ndef.networks then
		return ndef.networks[netw_type]
	end
	return net_def(pos, netw_type)
end

-- Don't allow direct connections between to nodes of the same type
local function valid_secondary_node_connection(tlib2, pos, dir)
	local node1 = tlib2:get_secondary_node(pos, dir)
	if node1 then
		local node2 = N(pos)
		local ndef1 = minetest.registered_nodes[node1.name]
		local ndef2 = minetest.registered_nodes[node2.name]
		local ntype1 = ((ndef1.networks or {})[tlib2.tube_type] or {}).ntype
		local ntype2 = ((ndef2.networks or {})[tlib2.tube_type] or {}).ntype
		return ntype1 == "junc" or ntype1 ~= ntype2
	end
end

-- Returns true if node is connected with another network node
local function connected(tlib2, pos, dir)
	local param2, npos = tlib2:get_primary_node_param2(pos, dir)
	if param2 then
		local d1, d2, num = tlib2:decode_param2(npos, param2)
		if not num then return end
		return Flip[dir] == d1 or Flip[dir] == d2
	end
	-- secondary nodes allowed?
	if tlib2.force_to_use_tubes then
		return tlib2:is_special_node(pos, dir)
	else
		return valid_secondary_node_connection(tlib2, pos, dir)
	end
end

local function side_to_outdir(pos, side)
	return tubelib2.side_to_dir(side, N(pos).param2)
end

-- determine outdir based on node type
local function get_outdir(node_type, indir)
	if node_type == "junc" then
		return 0 -- same network on all sides
	else
		return Flip[indir]
	end
end

-------------------------------------------------------------------------------
-- Node Connections
-------------------------------------------------------------------------------

-- Get tlib2 connection dirs as table
-- used e.g. for the connection walk
local function get_node_connection_dirs(pos, netw_type)
	local val = M(pos):get_int(netw_type.."_conn")
    local tbl = {}
    if val % 0x40 >= 0x20 then tbl[#tbl+1] = 1 end
    if val % 0x20 >= 0x10 then tbl[#tbl+1] = 2 end
    if val % 0x10 >= 0x08 then tbl[#tbl+1] = 3 end
    if val % 0x08 >= 0x04 then tbl[#tbl+1] = 4 end
    if val % 0x04 >= 0x02 then tbl[#tbl+1] = 5 end
    if val % 0x02 >= 0x01 then tbl[#tbl+1] = 6 end
    return tbl
end

local function get_node_connection_dirs_table(pos, netw_type)
	local val = M(pos):get_int(netw_type.."_conn")
    local tbl = {}
    if val % 0x40 >= 0x20 then tbl[1] = true end
    if val % 0x20 >= 0x10 then tbl[2] = true end
    if val % 0x10 >= 0x08 then tbl[3] = true end
    if val % 0x08 >= 0x04 then tbl[4] = true end
    if val % 0x04 >= 0x02 then tbl[5] = true end
    if val % 0x02 >= 0x01 then tbl[6] = true end
    return tbl
end

-- store all node sides with tube connections as nodemeta
local function store_node_connection_sides(pos, tlib2)
	local node = get_node(pos)
	local val = 0
	for dir = 1,6 do
		val = val * 2
		if tlib2:is_valid_dir(node, dir) and connected(tlib2, pos, dir) then
			val = val + 1
		end
	end
	M(pos):set_int(tlib2.tube_type.."_conn", val)
end

-- If outdir is given, return outdir, otherwise return all valid node dirs with tube connections
local function get_outdirs(pos, tlib2, outdir)
	if outdir then
		return {outdir}
	end
	return get_node_connection_dirs(pos, tlib2.tube_type)
end

-------------------------------------------------------------------------------
-- Connection Walk
-------------------------------------------------------------------------------
local function pos_already_reached(pos)
	local key = minetest.hash_node_position(pos)
	if not Route[key] and NumNodes < MAX_NUM_NODES then
		Route[key] = true
		NumNodes = NumNodes + 1
		return false
	end
	return true
end

-- check if the given tube dir into the node is valid
local function valid_indir(pos, indir, node, tlib2)
	local outdir = Flip[indir]
	return tlib2:is_valid_dir(node, outdir)
end

local function is_junction(pos, name, tlib2)
	local ndef = net_def2(pos, name,  tlib2.tube_type)
	if ndef then
		return ndef.ntype == "junc"
	end
end

-- Do the walk through the tubelib2 network.
-- `indir` is the direction which should not be covered by the walk
-- (coming from there).
-- if outdir is given, only this dir is used
local function connection_walk(pos, outdir, indir, node, tlib2, clbk)
	if clbk then clbk(pos, indir, node) end
	if outdir or is_junction(pos, node.name, tlib2) then
		for _,outdir in ipairs(get_outdirs(pos, tlib2, outdir)) do
			local pos2, indir2 = tlib2:get_connected_node_pos(pos, outdir)
			local node = get_node(pos2)
			if valid_indir(pos2, indir2, node, tlib2) and not pos_already_reached(pos2) then
				connection_walk(pos2, nil, indir2, node, tlib2, clbk)
			end
		end
	end
end

local function collect_network_nodes(pos, tlib2, outdir)
	local t = minetest.get_us_time()
	Route = {}
	NumNodes = 0
	pos_already_reached(pos)
	local netw = {}
	local node = N(pos)
	local netw_type = tlib2.tube_type
	local tbl = get_node_connection_dirs_table(pos, netw_type)
	if tbl[outdir] then -- valid conncetion
		-- outdir corresponds to the indir coming from
		connection_walk(pos, outdir, Flip[outdir], node, tlib2, function(pos, indir, node)
			local ndef = net_def2(pos, node.name, netw_type)
			if ndef and ndef.ntype then
				local ntype = ndef.ntype
				if not netw[ntype] then netw[ntype] = {} end
				netw[ntype][#netw[ntype] + 1] = {pos = pos, indir = indir}
			end
		end)
	end
	netw.ttl = minetest.get_gametime() + TTL
	netw.num_nodes = NumNodes
	t = minetest.get_us_time() - t
	--print("collect_network_nodes in " .. t .. " us", NumNodes, P2S(pos), N(pos).name)
	return netw
end

-------------------------------------------------------------------------------
-- Maintain Network
-------------------------------------------------------------------------------
local function set_network(netw_type, netID, network)
	assert(netID)
	if netID > 0 then
		Networks[netw_type] = Networks[netw_type] or {}
		Networks[netw_type][netID] = network
		Networks[netw_type][netID].ttl = minetest.get_gametime() + TTL
	end
end

-- Return network if available, or dummy network.
-- The function updates the network TTL, thus keeping the network alive.
local function get_network(netw_type, netID)
	assert(netID)
	if netID > 0 then
		local netw = Networks[netw_type] and Networks[netw_type][netID]
		if netw then
			netw.ttl = minetest.get_gametime() + TTL
			return netw
		end
	end
	return {num_nodes = 0}
end

local function delete_network(netw_type, netID)
	assert(netID)
	if netID > 0 then
		if Networks[netw_type] and Networks[netw_type][netID] then
			Networks[netw_type][netID] = nil
		end
	end
end

-- Keep data in memory small
local function remove_outdated_networks()
	local to_be_deleted = {}
	local t = minetest.get_gametime()
	for net_name,tbl in pairs(Networks) do
		for netID,network in pairs(tbl) do
			local valid = (network.ttl or 0) - t
			if valid < 0 then
				to_be_deleted[#to_be_deleted+1] = {net_name, netID}
			end
		end
	end
	for _,item in ipairs(to_be_deleted) do
		local net_name, netID = unpack(item)
		Networks[net_name][netID] = nil
		--print("Network " .. netw_num(netID) .. " timed out")
	end
	minetest.after(60, remove_outdated_networks)
end
minetest.after(60, remove_outdated_networks)

-------------------------------------------------------------------------------
-- Maintain netID
-------------------------------------------------------------------------------
local function set_netID(pos, outdir, netID)
	local hash = minetest.hash_node_position(pos)
	NetIDs[hash] = NetIDs[hash] or {}
	NetIDs[hash][outdir] = netID
end

local function get_netID(pos, outdir)
	local hash = minetest.hash_node_position(pos)
	NetIDs[hash] = NetIDs[hash] or {}
	return NetIDs[hash][outdir]
end

-- determine network ID (largest hash number of all nodes with given type)
local function determine_netID(netw)
	local netID = 0
	for node_type, table in pairs(netw) do
		if type(table) == "table" then
			for _, item in ipairs(netw[node_type] or {}) do
				local outdir = Flip[item.indir]
				local new = minetest.hash_node_position(item.pos) * 8 + outdir
				if netID <= new then
					netID = new
				end
			end
		end
	end
	return netID
end

-- store network ID for each network node
local function store_netID(tlib2, netw, netID)
	for node_type, table in pairs(netw) do
		if type(table) == "table" then
			for _, item in ipairs(table) do
				local outdir = get_outdir(node_type, item.indir)
				set_netID(item.pos, outdir, netID)
			end
		end
	end
	set_network(tlib2.tube_type, netID, netw)
end

-- delete network and netID for all nodes in the network
-- `outdir` shall be 0 for junctions
local function delete_netID(pos, tlib2, outdir)
	local netID = get_netID(pos, outdir)
	if netID then
		local netw = get_network(tlib2.tube_type, netID)
		for node_type, table in pairs(netw) do
			if type(table) == "table" then
				for _, item in ipairs(table) do
					local outdir = get_outdir(node_type, item.indir)
					set_netID(item.pos, outdir, nil)
				end
			end
		end
		set_netID(pos, outdir, nil)
		delete_network(tlib2.tube_type, netID)
	end
end

-------------------------------------------------------------------------------
-- API Functions
-------------------------------------------------------------------------------

networks.MAX_NUM_NODES = MAX_NUM_NODES

-- Table for a 180 degree turn: indir => outdir and vice versa
networks.Flip = tubelib2.Turn180Deg

-- networks.net_def(pos, netw_type)
networks.net_def = net_def

--	sides:                               outdir:
--              U
--              |    B
--              |   /                              6  (N)
--           +--|-----+                            |  1
--          /   o    /|                            | /
--         +--------+ |                            |/
--  L <----|        |o----> R        (W) 4 <-------+-------> 2 (O)
--         |    o   | |                           /|
--         |   /    | +                          / |
--         |  /     |/                          3  |
--         +-/------+                        (S)   5
--          /   |
--         F    |
--              D
--

-- Determine the pos relative to the given 'pos', 'param2'
-- and the path based on 'sides' like "FUL"
function networks.get_relpos(pos, sides, param2)
	local pos1 = {x = pos.x, y = pos.y, z = pos.z}
	for side in sides:gmatch(".") do
		pos1 = tubelib2_get_pos(pos1, tubelib2_side_to_dir(side, param2))
	end
	return pos1
end

-- networks.side_to_outdir(pos, side)
networks.side_to_outdir = side_to_outdir

-- networks.is_junction(pos, name, tlib2)
networks.is_junction = is_junction

-- Return a simple number instead of the netID
-- Useful for debuging purposes
-- networks.netw_num(netID)
networks.netw_num = netw_num

-- For debugging purposes
-- networks.network_nodes(netID, network)
networks.network_nodes = network_nodes

-- networks.get_network(netw_type, netID)
networks.get_network = get_network

-- return the networks table from the node definition
-- networks.net_def(pos, netw_type)
networks.net_def = net_def

-- Function returns {outdir} or all node dirs with connections
-- networks.get_outdirs(pos, tlib2, outdir)
networks.get_outdirs = get_outdirs

-- Provide own netID
-- networks.get_netID(pos, outdir)
networks.get_netID = get_netID

-- networks.get_node_connection_dirs(pos, netw_type)
networks.get_node_connection_dirs = get_node_connection_dirs

-- To be called from each node via 'tubelib2_on_update2'
-- 'output' is optional and only needed for nodes with dedicated
-- pipe sides. Junctions have to provide 0 (= same network on all sides).
function networks.update_network(pos, outdir, tlib2, node)
	store_node_connection_sides(pos, tlib2) -- update node internal data
	delete_netID(pos, tlib2, outdir) -- delete node netIDs and network
end

-- Provide or determine netID
function networks.determine_netID(pos, tlib2, outdir)
	assert(outdir)
	local netID = get_netID(pos, outdir)
	if netID and Networks[tlib2.tube_type] and Networks[tlib2.tube_type][netID] then
		return netID
	elseif netID == 0 then
		return -- no network available
	end

	local netw = collect_network_nodes(pos, tlib2, outdir)
	if netw.num_nodes > 1 then
		netID = determine_netID(netw)
		store_netID(tlib2, netw, netID)
		return netID
	end
	-- mark as "no network"
	set_netID(pos, outdir, 0)
end

-- Provide network with all node tables
function networks.get_network_table(pos, tlib2, outdir)
	assert(outdir)
	local netID = networks.determine_netID(pos, tlib2, outdir)
	if netID and netID > 0 then
		return get_network(tlib2.tube_type, netID)
	end
end
