-- lzb.lua
-- Enforced and/or automatic train override control, providing the on_train_approach callback

--[[
Documentation of train.lzb table
train.lzb = {
	trav_index = Current index that the traverser has advanced so far
	checkpoints = table containing oncoming signals, in order of index
		{
			pos = position of the point
			index = where this is on the path
			speed = speed allowed to pass. nil = no effect
			callback = function(pos, id, train, index, speed, lzbdata)
			-- Function that determines what to do on the train in the moment it drives over that point.
			-- When spd==0, called instead when train has stopped in front
			-- nil = no effect
			lzbdata = {}
			-- Table of custom data filled in by approach callbacks
			-- Whenever an approach callback inserts an LZB checkpoint with changed lzbdata,
			-- all consecutive approach callbacks will see these passed as lzbdata table.
			
			udata = arbitrary user data, no official way to retrieve (do not use)
		}
	trav_lzbdata = currently active lzbdata table at traverser index
}
The LZB subsystem keeps track of "checkpoints" the train will pass in the future, and has two main tasks:
1. run approach callbacks, and run callbacks when passing LZB checkpoints
2. keep track of the permitted speed at checkpoints, and make sure that the train brakes accordingly
To perform 2, it populates the train.path_speed table which is handled along with the path subsystem.
This table is used in trainlogic.lua/train_step_b() and applied to the velocity calculations.

Note: As of 2024-11-25, node enter callbacks as well as LZB callbacks are executed on passing the .0 index mark!
If an LZB checkpoint has speed 0, it will stop at the 0.9 index mark (for details, see SLOW_APPROACH in trainlogic.lua) and will not enter the node.
(previous behavior: node enter callback was called at .5 index mark)

The start point for the LZB traverser (and thus the first node that will receive an approach callback) is floor(train.index) + 1. This means, once the LZB checkpoint callback has fired,
this path node will not receive any further approach callbacks for the same approach situation
]]


local params = {
	BRAKE_SPACE = 10,
	AWARE_ZONE  = 50,

	ADD_STAND  =  2.5,
	ADD_SLOW   =  1.5,
	ADD_FAST   =  7,
	ZONE_ROLL  =  2,
	ZONE_HOLD  =  5, -- added on top of ZONE_ROLL
	ZONE_VSLOW =  3, -- When speed is <2, still allow accelerating

	DST_FACTOR =  3,--1.5,

	SHUNT_SPEED_MAX = advtrains.SHUNT_SPEED_MAX,
}

function advtrains.set_lzb_param(par, val)
	if params[par] and tonumber(val) then
		params[par] = tonumber(val)
	else
		error("Inexistant param or not a number")
	end
end

local function resolve_latest_lzbdata(ckp, index)
	local i = #ckp
	local ckpi
	while i>0 do
		ckpi = ckp[i]
		if ckpi.index <= index and ckpi.lzbdata then
			return ckpi.lzbdata
		end
		i=i-1
	end
	return {}
end

local function look_ahead(id, train)
	local lzb = train.lzb
	if lzb.zero_checkpoint then
		-- if the checkpoints list contains a zero checkpoint, don't look ahead
		-- in order to not trigger approach callbacks on the wrong path
		return
	end
	
	local acc = advtrains.get_acceleration(train, 1)
	-- worst-case: the starting point is maximum speed
	local vel = train.max_speed or train.velocity
	local brakedst = ( -(vel*vel) / (2*acc) ) * params.DST_FACTOR
	
	--local brake_i = advtrains.path_get_index_by_offset(train, train.index, brakedst + params.BRAKE_SPACE)
	-- worst case (don't use index_by_offset)
	local brake_i = atfloor(train.index + brakedst + params.BRAKE_SPACE)
	--atprint("LZB: looking ahead up to ", brake_i)
	
	--local aware_i = advtrains.path_get_index_by_offset(train, brake_i, AWARE_ZONE)
	
	local trav = lzb.trav_index
	-- retrieve latest lzbdata
	if not lzb.trav_lzbdata then
		lzb.trav_lzbdata = resolve_latest_lzbdata(lzb.checkpoints, trav)
	end
	
	if lzb.trav_lzbdata.off_track then
		--previous position was off track, do not scan any further
	end
	
	while trav <= brake_i and not lzb.zero_checkpoint do
		local pos = advtrains.path_get(train, trav)
		-- check offtrack
		if trav - 1 == train.path_trk_f then
			lzb.trav_lzbdata.off_track = true
			advtrains.lzb_add_checkpoint(train, trav - 1, 0, nil, lzb.trav_lzbdata)
		else
			-- run callbacks
			-- Note: those callbacks are defined in trainlogic.lua for consistency with the other node callbacks
			advtrains.tnc_call_approach_callback(pos, id, train, trav, lzb.trav_lzbdata)
			
		end
		trav = trav + 1
		
	end
	
	lzb.trav_index = trav
	
end
advtrains.lzb_look_ahead = look_ahead


local function call_runover_callbacks(id, train)
	if not train.lzb then return end
	
	local i = 1
	local idx = atfloor(train.index)
	local ckp = train.lzb.checkpoints
	while ckp[i] do
		if ckp[i].index <= idx then
			--atprint("LZB: checkpoint run over: i=",ckp[i].index,"s=",ckp[i].speed,"p=",ckp[i].pos)
			-- call callback
			local it = ckp[i]
			if it.callback then
				it.callback(it.pos, id, train, it.index, it.speed, train.lzb.lzbdata)
			end
			-- note: lzbdata is always defined as look_ahead was called before
			table.remove(ckp, i)
		else
			i = i + 1
		end
	end
end

-- Flood-fills train.path_speed, based on this checkpoint 
local function apply_checkpoint_to_path(train, checkpoint)
	if not checkpoint.speed then
		return
	end
	--atprint("LZB: applying checkpoint: i=",checkpoint.index,"s=",checkpoint.speed,"p=",checkpoint.pos)
	
	if checkpoint.speed == 0 then
		train.lzb.zero_checkpoint = true
	end
	
	-- make sure path exists until checkpoint
	local pos = advtrains.path_get(train, checkpoint.index)
	
	local brake_accel = advtrains.get_acceleration(train, 11)
	
	-- start with the checkpoint index at specified speed
	local index = checkpoint.index
	local p_speed -- speed in path_speed
	local c_speed = checkpoint.speed -- calculated speed at current index
	while true do
		p_speed = train.path_speed[index]
		if (p_speed and p_speed <= c_speed) or index < train.index then
			--we're done. train already slower than wanted at this position
			return
		end
		-- insert calculated target speed
		train.path_speed[index] = c_speed
		-- calculate c_speed at previous index
		advtrains.path_get(train, index-1)
		local eldist = train.path_dist[index] - train.path_dist[index-1]
		-- Calculate the start velocity the train would have if it had a end velocity of c_speed and accelerating with brake_accel, after a distance of eldist:
		-- v0² = v1² - 2*a*s
		c_speed = math.sqrt( (c_speed * c_speed) - (2 * brake_accel * eldist) )
		index = index - 1
	end
end

--[[
Distance needed to accelerate from v0 to v1 with constant acceleration a:

         v1 - v0     a   / v1 - v0 \ 2     v1^2 - v0^2
s = v0 * -------  +  - * | ------- |    =  -----------
            a        2   \    a    /           2*a
]]

-- Removes all LZB checkpoints and restarts the traverser at the current train index
function advtrains.lzb_invalidate(train)
	--advtrains.atprint_context_tid = train.id
	--atprint("LZB: invalidate")
	--advtrains.atprint_context_tid = nil
	train.lzb = {
		trav_index = atfloor(train.index) + 1,
		checkpoints = {},
	}
end

-- LZB part of path_invalidate_ahead. Clears all checkpoints that are ahead of start_idx
-- in contrast to path_inv_ahead, doesn't complain if start_idx is behind train.index, clears everything then
function advtrains.lzb_invalidate_ahead(train, start_idx)
	--advtrains.atprint_context_tid = train.id
	--atprint("LZB: invalidate ahead i=",start_idx)
	if train.lzb then
		local idx = atfloor(start_idx)
		--atprint("LZB: invalidate ahead p=",train.path[start_idx])
		local i = 1
		while train.lzb.checkpoints[i] do
			if train.lzb.checkpoints[i].index >= idx then
				table.remove(train.lzb.checkpoints, i)
			else
				i=i+1
			end
		end
		train.lzb.trav_index = idx
		-- FIX reset trav_lzbdata (look_ahead fetches these when required)
		train.lzb.trav_lzbdata = nil
		-- re-apply all checkpoints to path_speed
		train.path_speed = {}
		train.lzb.zero_checkpoint = false
		for _,ckp in ipairs(train.lzb.checkpoints) do
			apply_checkpoint_to_path(train, ckp)
		end
	end
	--advtrains.atprint_context_tid = nil
end

-- Add LZB control point
-- lzbdata: If you modify lzbdata in an approach callback, you MUST add a checkpoint AND pass the (modified) lzbdata into it.
-- If you DON'T modify lzbdata, you MUST pass nil as lzbdata. Always modify the lzbdata table in place, never overwrite it!
-- udata: user-defined data, do not use externally
function advtrains.lzb_add_checkpoint(train, index, speed, callback, lzbdata, udata)
	local lzb = train.lzb
	local pos = advtrains.path_get(train, index)
	local lzbdata_c = nil
	if lzbdata then
		-- make a shallow copy of lzbdata
		lzbdata_c = {}
		for k,v in pairs(lzbdata) do lzbdata_c[k] = v end
	end
	local ckp = {
		pos = pos,
		index = index,
		speed = speed,
		callback = callback,
		lzbdata = lzbdata_c,
		udata = udata,
	}
	table.insert(lzb.checkpoints, ckp)
	
	apply_checkpoint_to_path(train, ckp)
end


advtrains.te_register_on_new_path(function(id, train)
	advtrains.lzb_invalidate(train)
	-- Taken care of in pre-move hook (see train_step_b)
	-- 2025-01-28 - do anyway, there seems to be an issue
	look_ahead(id, train)
end)

advtrains.te_register_on_invalidate_ahead(function(id, train, start_idx)
	advtrains.lzb_invalidate_ahead(train, start_idx)
end)

advtrains.te_register_on_update(function(id, train)
	if not train.path or not train.lzb then
		atprint("LZB run: no path on train, skip step")
		return
	end
	-- Note: look_ahead called from train_step_b before applying movement
	-- TODO: if more pre-move hooks are added, make a separate callback hook
	--look_ahead(id, train)
	call_runover_callbacks(id, train)
end, true)
