--trainlogic.lua
--controls train entities stuff about connecting/disconnecting/colliding trains and other things

local print=function(t) minetest.log("action", t) minetest.chat_send_all(t) end

advtrains.train_accel_force=5--per second and divided by number of wagons
advtrains.train_brake_force=3--per second, not divided by number of wagons
advtrains.train_emerg_force=10--for emergency brakes(when going off track)

advtrains.audit_interval=10

advtrains.all_traintypes={}
function advtrains.register_train_type(name, drives_on, max_speed)
	advtrains.all_traintypes[name]={}
	advtrains.all_traintypes[name].drives_on=drives_on
	advtrains.all_traintypes[name].max_speed=max_speed or 10
end


advtrains.trains={}
advtrains.wagon_save={}

--load initially
advtrains.fpath=minetest.get_worldpath().."/advtrains"
local file, err = io.open(advtrains.fpath, "r")
if not file then
	local er=err or "Unknown Error"
	print("[advtrains]Failed loading advtrains save file "..er)
else
	local tbl = minetest.deserialize(file:read("*a"))
	if type(tbl) == "table" then
		advtrains.trains=tbl
	end
	file:close()
end
advtrains.fpath_ws=minetest.get_worldpath().."/advtrains_wagon_save"
local file, err = io.open(advtrains.fpath_ws, "r")
if not file then
	local er=err or "Unknown Error"
	print("[advtrains]Failed loading advtrains save file "..er)
else
	local tbl = minetest.deserialize(file:read("*a"))
	if type(tbl) == "table" then
		advtrains.wagon_save=tbl
	end
	file:close()
end


advtrains.save = function()
	--print("[advtrains]saving")
	advtrains.invalidate_all_paths()
	local datastr = minetest.serialize(advtrains.trains)
	if not datastr then
		minetest.log("error", "[advtrains] Failed to serialize train data!")
		return
	end
	local file, err = io.open(advtrains.fpath, "w")
	if err then
		return err
	end
	file:write(datastr)
	file:close()
	
	-- update wagon saves
	for _,wagon in pairs(minetest.luaentities) do
		if wagon.is_wagon and wagon.initialized then
			advtrains.wagon_save[wagon.unique_id]=advtrains.merge_tables(wagon)--so, will only copy non_metatable elements
		end
	end
	--cross out userdata
	for w_id, data in pairs(advtrains.wagon_save) do
		data.name=nil
		data.object=nil
		if data.driver then
			data.driver_name=data.driver:get_player_name()
			data.driver=nil
		else
			data.driver_name=nil
		end
	end
	--print(dump(advtrains.wagon_save))
	datastr = minetest.serialize(advtrains.wagon_save)
	if not datastr then
		minetest.log("error", "[advtrains] Failed to serialize train data!")
		return
	end
	file, err = io.open(advtrains.fpath_ws, "w")
	if err then
		return err
	end
	file:write(datastr)
	file:close()
	
	advtrains.save_trackdb()
end
minetest.register_on_shutdown(advtrains.save)

advtrains.save_and_audit_timer=advtrains.audit_interval
minetest.register_globalstep(function(dtime)
	advtrains.save_and_audit_timer=advtrains.save_and_audit_timer-dtime
	if advtrains.save_and_audit_timer<=0 then
		--print("[advtrains] audit step")
		--clean up orphaned trains
		for k,v in pairs(advtrains.trains) do
			--advtrains.update_trainpart_properties(k)
			if #v.trainparts==0 then
				advtrains.trains[k]=nil
			end
		end
		--save
		advtrains.save()
		advtrains.save_and_audit_timer=advtrains.audit_interval
	end
	--regular train step
	for k,v in pairs(advtrains.trains) do
		advtrains.train_step(k, v, dtime)
	end
end)

function advtrains.train_step(id, train, dtime)
	
	--TODO check for all vars to be present
	
	--if not train.last_pos then advtrains.trains[id]=nil return end
	
	if not advtrains.pathpredict(id, train) then 
		print("pathpredict failed(returned false)")
		train.velocity=0
		train.tarvelocity=0
		return
	end
	
	local path=advtrains.get_or_create_path(id, train)
	if not path then
		train.velocity=0
		train.tarvelocity=0
		print("train has no path for whatever reason")
		return 
	end
	--apply off-track handling:
	local front_off_track=train.max_index_on_track and train.index>train.max_index_on_track
	local back_off_track=train.min_index_on_track and (train.index-train.trainlen)<train.min_index_on_track
	if front_off_track and back_off_track then--allow movement in both directions
		if train.tarvelocity>1 then train.tarvelocity=1 end
		if train.tarvelocity<-1 then train.tarvelocity=-1 end
	elseif front_off_track then--allow movement only backward
		if train.tarvelocity>0 then train.tarvelocity=0 end
		if train.tarvelocity<-1 then train.tarvelocity=-1 end
	elseif back_off_track then--allow movement only forward
		if train.tarvelocity>1 then train.tarvelocity=1 end
		if train.tarvelocity<0 then train.tarvelocity=0 end
	end
	
	--move
	if not train.velocity then
		train.velocity=0
	end
	train.index=train.index and train.index+((train.velocity/(train.path_dist[math.floor(train.index)] or 1))*dtime) or 0
	--check for collisions by finding objects
	--front
	local search_radius=4
	
	local posfront=path[math.floor(train.index+1)]
	if posfront then
		local objrefs=minetest.get_objects_inside_radius(posfront, search_radius)
		for _,v in pairs(objrefs) do
			local le=v:get_luaentity()
			if le and le.is_wagon and le.initialized and le.train_id~=id then
				advtrains.try_connect_trains(id, le.train_id)
			end
		end
	end
	local posback=path[math.floor(train.index-(train.trainlen or 0)-1)]
	if posback then
		local objrefs=minetest.get_objects_inside_radius(posback, search_radius)
		for _,v in pairs(objrefs) do
			local le=v:get_luaentity()
			if le and le.is_wagon and le.initialized and le.train_id~=id then
				advtrains.try_connect_trains(id, le.train_id)
			end
		end
	end
	
	--check for any trainpart entities if they have been unloaded. do this only if train is near a player, to not spawn entities into unloaded areas
	train.check_trainpartload=(train.check_trainpartload or 0)-dtime
	local node_range=(math.max((minetest.setting_get("active_block_range") or 0),1)*16)
	if train.check_trainpartload<=0 and posfront and posback then
		--print(minetest.pos_to_string(posfront))
		local should_check=false
		for _,p in ipairs(minetest.get_connected_players()) do
			should_check=should_check or ((vector.distance(posfront, p:getpos())<node_range) and (vector.distance(posback, p:getpos())<node_range))
		end
		if should_check then
			--it is better to iterate luaentites only once
			--print("check_trainpartload")
			local found_uids={}
			for _,wagon in pairs(minetest.luaentities) do
				if wagon.is_wagon and wagon.initialized and wagon.train_id==id then
					if found_uids[wagon.unique_id] then
						--duplicate found, delete it
						if wagon.object then wagon.object:remove() end
					else
						found_uids[wagon.unique_id]=true
					end
				end
			end
			--print("found_uids: "..dump(found_uids))
			--now iterate trainparts and check. then cross them out to see if there are wagons over for any reason
			for pit, w_id in ipairs(train.trainparts) do
				if found_uids[w_id] then
					--print(w_id.." still loaded")
				elseif advtrains.wagon_save[w_id] then
					--print(w_id.." not loaded, but save available")
					--spawn a new and initialize it with the properties from wagon_save
					local le=minetest.env:add_entity(posfront, advtrains.wagon_save[w_id].entity_name):get_luaentity()
					for k,v in pairs(advtrains.wagon_save[w_id]) do
						le[k]=v
					end
					advtrains.wagon_save[w_id].name=nil
					advtrains.wagon_save[w_id].object=nil
				else
					print(w_id.." not loaded and no save available")
					--what the hell...
					table.remove(train.trainparts, pit)
				end
			end
		end
		train.check_trainpartload=1
	end
	
	
	--handle collided_with_env
	if train.recently_collided_with_env then
		train.tarvelocity=0
		if train.velocity==0 then
			train.recently_collided_with_env=false--reset status when stopped
		end
	end
	if train.locomotives_in_train==0 then
		train.tarvelocity=0
	end
	--apply tarvel(but with physics in mind!)
	if train.velocity~=train.tarvelocity then
		local applydiff=0
		local mass=#train.trainparts
		local diff=math.abs(train.tarvelocity)-math.abs(train.velocity)
		if diff>0 then--accelerating, force will be brought on only by locomotives.
			--print("accelerating with default force")
			applydiff=(math.min((advtrains.train_accel_force*train.locomotives_in_train*dtime)/mass, math.abs(diff)))
		else--decelerating
			if front_off_track or back_off_track or train.recently_collided_with_env then --every wagon has a brake, so not divided by mass.
				--print("braking with emergency force")
				applydiff=(math.min((advtrains.train_emerg_force*dtime), math.abs(diff)))
			else
				--print("braking with default force")
				applydiff=(math.min((advtrains.train_brake_force*dtime), math.abs(diff)))
			end
		end
		train.velocity=train.velocity+(applydiff*math.sign(train.tarvelocity-train.velocity))
	end
	
end

--the 'leader' concept has been overthrown, we won't rely on MT's "buggy object management"
--structure of train table:
--[[
trains={
	[train_id]={
		trainparts={
			[n]=wagon_id
		}
		path={path}
		velocity
		tarvelocity
		index
		trainlen
		path_inv_level
		last_pos       |
		last_dir       | for pathpredicting.
		no_connect_for_movements (index way counter for when not to connect again) TODO implement
	}
}
--a wagon itself has the following properties:
wagon={
	unique_id
	train_id
	pos_in_train (is index difference, including train_span stuff)
	pos_in_trainparts (is index in trainparts tabel of trains)
}
inherited by metatable:
wagon_proto={
	wagon_span
}
]]

--returns new id
function advtrains.create_new_train_at(pos, pos_prev, traintype)
	local newtrain_id=os.time()..os.clock()
	while advtrains.trains[newtrain_id] do newtrain_id=os.time()..os.clock() end--ensure uniqueness(will be unneccessary)
	
	advtrains.trains[newtrain_id]={}
	advtrains.trains[newtrain_id].last_pos=pos
	advtrains.trains[newtrain_id].last_pos_prev=pos_prev
	advtrains.trains[newtrain_id].traintype=traintype
	advtrains.trains[newtrain_id].tarvelocity=0
	advtrains.trains[newtrain_id].velocity=0
	advtrains.trains[newtrain_id].trainparts={}
	return newtrain_id
end

--returns false on failure. handle this case!
function advtrains.pathpredict(id, train)
	
	--print("pos ",x,y,z)
	--::rerun::
	if not train.index then train.index=0 end
	if not train.path or #train.path<2 then
		if not train.last_pos then
			--no chance to recover
			print("[advtrains]train hasn't saved last-pos, removing train.")
			advtrains.train[id]=nil
			return false
		end
		
		local node_ok=advtrains.get_rail_info_at(advtrains.round_vector_floor_y(train.last_pos), train.traintype)
		
		if node_ok==nil then
			--block not loaded, do nothing
			return nil
		elseif node_ok==false then
			print("[advtrains]no track here, (fail) removing train.")
			advtrains.trains[id]=nil
			return false
		end
		
		if not train.last_pos_prev then
			--no chance to recover
			print("[advtrains]train hasn't saved last-pos_prev, removing train.")
			advtrains.trains[id]=nil
			return false
		end
		
		local prevnode_ok=advtrains.get_rail_info_at(advtrains.round_vector_floor_y(train.last_pos_prev), train.traintype)
		
		if prevnode_ok==nil then
			--block not loaded, do nothing
			return nil
		elseif prevnode_ok==false then
			print("[advtrains]no track at prev, (fail) removing train.")
			advtrains.trains[id]=nil
			return false
		end
		
		train.index=(train.restore_add_index or 0)+(train.savedpos_off_track_index_offset or 0)
		--restore_add_index is set by save() to prevent trains hopping to next round index. should be between -0.5 and 0.5
		--savedpos_off_track_index_offset is set if train went off track. see below.
		train.path={}
		train.path_dist={}
		train.path[0]=train.last_pos
		train.path[-1]=train.last_pos_prev
		train.path_dist[-1]=vector.distance(train.last_pos, train.last_pos_prev)
	end
	
	local maxn=advtrains.maxN(train.path)
	while (maxn-train.index) < 2 do--pregenerate
		--print("[advtrains]maxn conway for ",maxn,minetest.pos_to_string(path[maxn]),maxn-1,minetest.pos_to_string(path[maxn-1]))
		local conway=advtrains.conway(train.path[maxn], train.path[maxn-1], train.traintype)
		if conway then
			train.path[maxn+1]=conway
			train.max_index_on_track=maxn
		else
			--do as if nothing has happened and preceed with path
			--but do not update max_index_on_track
			print("over-generating path max to index "..maxn+1)
			train.path[maxn+1]=vector.add(train.path[maxn], vector.subtract(train.path[maxn], train.path[maxn-1]))
		end
		train.path_dist[maxn]=vector.distance(train.path[maxn+1], train.path[maxn])
		maxn=advtrains.maxN(train.path)
	end
	
	local minn=advtrains.minN(train.path)
	while (train.index-minn) < (train.trainlen or 0) + 2 do --post_generate. has to be at least trainlen.
		--print("[advtrains]minn conway for ",minn,minetest.pos_to_string(path[minn]),minn+1,minetest.pos_to_string(path[minn+1]))
		local conway=advtrains.conway(train.path[minn], train.path[minn+1], train.traintype)
		if conway then
			train.path[minn-1]=conway
			train.min_index_on_track=minn
		else
			--do as if nothing has happened and preceed with path
			--but do not update min_index_on_track
			print("over-generating path min to index "..minn-1)
			train.path[minn-1]=vector.add(train.path[minn], vector.subtract(train.path[minn], train.path[minn+1]))
		end
		train.path_dist[minn-1]=vector.distance(train.path[minn], train.path[minn-1])
		minn=advtrains.minN(train.path)
	end
	if not train.min_index_on_track then train.min_index_on_track=0 end
	if not train.max_index_on_track then train.max_index_on_track=0 end
	
	--make pos/yaw available for possible recover calls
	if train.max_index_on_track<train.index then --whoops, train went too far. the saved position will be the last one that lies on a track, and savedpos_off_track_index_offset will hold how far to go from here
		train.savedpos_off_track_index_offset=train.index-train.max_index_on_track
		train.last_pos=train.path[train.max_index_on_track]
		train.last_pos_prev=train.path[train.max_index_on_track-1]
		--print("train is off-track (front), last positions kept at "..minetest.pos_to_string(train.last_pos).." / "..minetest.pos_to_string(train.last_pos_prev))
	elseif train.min_index_on_track+1>train.index then --whoops, train went even more far. same behavior
		train.savedpos_off_track_index_offset=train.index-train.min_index_on_track
		train.last_pos=train.path[train.min_index_on_track+1]
		train.last_pos_prev=train.path[train.min_index_on_track]
		--print("train is off-track (back), last positions kept at "..minetest.pos_to_string(train.last_pos).." / "..minetest.pos_to_string(train.last_pos_prev))
	else --regular case
		train.savedpos_off_track_index_offset=nil
		train.last_pos=train.path[math.floor(train.index+0.5)]
		train.last_pos_prev=train.path[math.floor(train.index-0.5)]
	end
	return train.path
end

function advtrains.get_or_create_path(id, train)
	if not train.path then return advtrains.pathpredict(id, train) end
	
	return train.path
end

function advtrains.add_wagon_to_train(wagon, train_id, index)
	local train=advtrains.trains[train_id]
	if index then
		table.insert(train.trainparts, index, wagon.unique_id)
	else
		table.insert(train.trainparts, wagon.unique_id)
	end
	--this is not the usual case!!!
	--we may set initialized because the wagon has no chance to step()
	wagon.initialized=true
	advtrains.update_trainpart_properties(train_id)
end
function advtrains.update_trainpart_properties(train_id, invert_flipstate)
	local train=advtrains.trains[train_id]
	local rel_pos=0
	local count_l=0
	for i, w_id in ipairs(train.trainparts) do
		local any_loaded=false
		for _,wagon in pairs(minetest.luaentities) do
			if wagon.is_wagon and wagon.initialized and wagon.unique_id==w_id then
				rel_pos=rel_pos+wagon.wagon_span
				wagon.train_id=train_id
				wagon.pos_in_train=rel_pos
				wagon.pos_in_trainparts=i
				wagon.old_velocity_vector=nil
				if wagon.is_locomotive then
					count_l=count_l+1
				end
				if invert_flipstate then
					wagon.wagon_flipped = not wagon.wagon_flipped
				end
				rel_pos=rel_pos+wagon.wagon_span
				any_loaded=true
			end
		end
		if not any_loaded then
			print("update_trainpart_properties wagon "..w_id.." not loaded, ignoring it.")
		end
	end
	train.trainlen=rel_pos
	train.locomotives_in_train=count_l
end

function advtrains.split_train_at_wagon(wagon)
	--get train
	local train=advtrains.trains[wagon.train_id]
	local pos_for_new_train=advtrains.get_or_create_path(wagon.train_id, train)[math.floor((train.index or 0)-wagon.pos_in_train-0.5)]
	local pos_for_new_train_prev=advtrains.get_or_create_path(wagon.train_id, train)[math.floor((train.index or 0)-wagon.pos_in_train-1.5)]
	
	--before doing anything, check if both are rails. else do not allow
	if not pos_for_new_train then
		print("split_train: pos_for_new_train not set")
		return false
	end
	local node_ok=advtrains.get_rail_info_at(advtrains.round_vector_floor_y(pos_for_new_train), train.traintype)
	if not node_ok then
		print("split_train: pos_for_new_train "..minetest.pos_to_string(advtrains.round_vector_floor_y(pos_for_new_train_prev)).." not loaded or is not a rail")
		return false
	end
	
	if not train.last_pos_prev then
		print("split_train: pos_for_new_train_prev not set")
		return false
	end
	
	local prevnode_ok=advtrains.get_rail_info_at(advtrains.round_vector_floor_y(pos_for_new_train), train.traintype)
	if not prevnode_ok then
		print("split_train: pos_for_new_train_prev "..minetest.pos_to_string(advtrains.round_vector_floor_y(pos_for_new_train_prev)).." not loaded or is not a rail")
		return false
	end
	
	--create subtrain
	local newtrain_id=advtrains.create_new_train_at(pos_for_new_train, pos_for_new_train_prev, train.traintype)
	local newtrain=advtrains.trains[newtrain_id]
	--insert all wagons to new train
	for k,v in ipairs(train.trainparts) do
		if k>=wagon.pos_in_trainparts then
			table.insert(newtrain.trainparts, v)
			train.trainparts[k]=nil
		end
	end
	--update train parts
	advtrains.update_trainpart_properties(wagon.train_id)--atm it still is the desierd id.
	advtrains.update_trainpart_properties(newtrain_id)
	train.tarvelocity=0
	newtrain.velocity=train.velocity
	newtrain.tarvelocity=0
end

--there are 4 cases:
--1/2. F<->R F<->R regular, put second train behind first
--->frontpos of first train will match backpos of second
--3.   F<->R R<->F flip one of these trains, take the other as new train
--->backpos's will match
--4.   R<->F F<->R flip one of these trains and take it as new parent
--->frontpos's will match
function advtrains.try_connect_trains(id1, id2)
	local train1=advtrains.trains[id1]
	local train2=advtrains.trains[id2]
	if not train1 or not train2 then return end
	if not train1.path or not train2.path then return end
	if train1.traintype~=train2.traintype then
		--TODO implement collision without connection
		return
	end
	if #train1.trainparts==0 or #train2.trainparts==0 then return end
	
	local frontpos1=train1.path[math.floor(train1.index+0.5)]
	local backpos1=train1.path[math.floor(train1.index-(train1.trainlen or 2)+0.5)]
	local frontpos2=train2.path[math.floor(train2.index+0.5)]
	local backpos2=train2.path[math.floor(train2.index-(train1.trainlen or 2)+0.5)]
	
	if not frontpos1 or not frontpos2 or not backpos1 or not backpos2 then return end
	
	--case 1 (first train is front)
	if vector.equals(frontpos2, backpos1) then
		advtrains.do_connect_trains(id1, id2)
	--case 2 (second train is front)
	elseif vector.equals(frontpos1, backpos2) then
		advtrains.do_connect_trains(id2, id1)
	--case 3 
	elseif vector.equals(backpos2, backpos1) then
		advtrains.invert_train(id2)
		advtrains.do_connect_trains(id1, id2)
	--case 4 
	elseif vector.equals(frontpos2, frontpos1) then
		advtrains.invert_train(id1)
		advtrains.do_connect_trains(id1, id2)
	end
end
function advtrains.do_connect_trains(first_id, second_id)
	local first_wagoncnt=#advtrains.trains[first_id].trainparts
	local second_wagoncnt=#advtrains.trains[second_id].trainparts
	
	for _,v in ipairs(advtrains.trains[second_id].trainparts) do
		table.insert(advtrains.trains[first_id].trainparts, v)
	end
	--kick it like physics (with mass being #wagons)
	local new_velocity=((advtrains.trains[first_id].velocity*first_wagoncnt)+(advtrains.trains[second_id].velocity*second_wagoncnt))/(first_wagoncnt+second_wagoncnt)
	advtrains.trains[second_id]=nil
	advtrains.update_trainpart_properties(first_id)
	advtrains.trains[first_id].velocity=new_velocity
	advtrains.trains[first_id].tarvelocity=0
end

function advtrains.invert_train(train_id)
	local train=advtrains.trains[train_id]
	
	local old_path=advtrains.get_or_create_path(train_id, train)
	train.path={}
	train.index=-train.index+train.trainlen
	train.velocity=-train.velocity
	train.tarvelocity=-train.tarvelocity
	for k,v in pairs(old_path) do
		train.path[-k]=v
	end
	local old_trainparts=train.trainparts
	train.trainparts={}
	for k,v in ipairs(old_trainparts) do
		table.insert(train.trainparts, 1, v)--notice insertion at first place
	end
	advtrains.update_trainpart_properties(train_id, true)
end

function advtrains.is_train_at_pos(pos)
	--print("istrainat: pos "..minetest.pos_to_string(pos))
	local checked_trains={}
	local objrefs=minetest.get_objects_inside_radius(pos, 2)
	for _,v in pairs(objrefs) do
		local le=v:get_luaentity()
		if le and le.is_wagon and le.initialized and le.train_id and not checked_trains[le.train_id] then
			--print("istrainat: checking "..le.train_id)
			checked_trains[le.train_id]=true
			local path=advtrains.get_or_create_path(le.train_id, le:train())
			if path then
				--print("has path")
				for i=math.floor(le:train().index-le:train().trainlen+0.5),math.floor(le:train().index+0.5) do
					if path[i] then
						--print("has pathitem "..i.." "..minetest.pos_to_string(path[i]))
						if vector.equals(advtrains.round_vector_floor_y(path[i]), pos) then
							return true
						end
					end
				end
			end
		end
	end
	return false
end
function advtrains.invalidate_all_paths()
	--print("invalidating all paths")
	for k,v in pairs(advtrains.trains) do
		if v.index then
			v.restore_add_index=v.index-math.floor(v.index+0.5)
		end
		v.path=nil
		v.index=nil
		v.min_index_on_track=nil
		v.max_index_on_track=nil
	end
end