extends Node
class_name _NetworkTime

## This class handles timing.
##
## @tutorial(NetworkTime Guide): https://foxssake.github.io/netfox/latest/netfox/guides/network-time/

## Number of ticks per second.
##
## [i]read-only[/i], you can change this in the project settings
var tickrate: int:
	get:
		if sync_to_physics:
			return Engine.physics_ticks_per_second
		else:
			return _tickrate
	set(v):
		push_error("Trying to set read-only variable tickrate")

## Whether to sync the network ticks to physics updates.
##
## When set to true, tickrate will be the same as the physics ticks per second,
## and the network tick loop will be run inside the physics update process.
##
## [i]read-only[/i], you can change this in the project settings
var sync_to_physics: bool:
	get:
		return _sync_to_physics
	set(v):
		push_error("Trying to set read-only variable sync_to_physics")

## Maximum number of ticks to simulate per frame.
##
## If the game itself runs slower than the configured tickrate, multiple ticks
## will be run in a single go. However, to avoid an endless feedback loop of
## running too many ticks in a frame, which makes the game even slower, which
## results in even more ticks and so on, this setting is an upper limit on how
## many ticks can be simulated in a single go.
##
## [i]read-only[/i], you can change this in the project settings
var max_ticks_per_frame: int:
	get:
		return _max_ticks_per_frame
	set(v):
		push_error("Trying to set read-only variable max_ticks_per_frame")

## Current network time in seconds.
##
## Time is measured from the start of NetworkTime, in practice this is often the
## time from the server's start.
##
## Use this value in cases where timestamps need to be shared with the server.
##
## [i]Note:[/i] Time is continuously synced with the server. If the difference
## between local and server time is above a certain threshold, this value will
## be adjusted.
##
## See [NetworkTimeSynchronizer].
## See the setting [code]"netfox/time/recalibrate_threshold"[/code].
##
## [i]read-only[/i]
var time: float:
	get:
		return float(_tick) / tickrate
	set(v):
		push_error("Trying to set read-only variable time")

## Current network time in ticks.
##
## Time is measured from the start of NetworkTime, in practice this is often the
## time from the server's start.
##
## Use this value in cases where timestamps need to be shared with the server.
##
## [i]Note:[/i] Time is continuously synced with the server. If the difference
## between local and server time is above a certain threshold, this value will
## be adjusted.
##
## See [NetworkTimeSynchronizer].
## See the setting [code]"netfox/time/recalibrate_threshold"[/code].
##
## [i]read-only[/i]
var tick: int:
	get:
		return _tick
	set(v):
		push_error("Trying to set read-only variable tick")

## Threshold before recalibrating [member tick] and [member time].
##
## Time is continuously synced to the server. In case the time difference is
## excessive between local and the server, both [code]tick[/code] and
## [code]time[/code] will be reset to the estimated server values.
## [br][br]
## This property determines the difference threshold in seconds for
## recalibration.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
## [br][br]
## @deprecated: Use [member _NetworkTimeSynchronizer.panic_threshold] instead.
var recalibrate_threshold: float:
	get:
		return _recalibrate_threshold
	set(v):
		push_error("Trying to set read-only variable recalibrate_threshold")

## Seconds required to pass before considering the game stalled.
##
## If the game becomes unresponsive for some time - e.g. it becomes minimized,
## unfocused, or freezes -, the game time needs to be readjusted. These stalls
## are detected by checking how much time passes between frames. If it's more
## than this threshold, it's considered a stall, and will be compensated
## against.
var stall_threshold: float:
	get:
		return _stall_threshold
	set(v):
		push_error("Trying to set read-only variable stall_threshold")

## Current network time in ticks on the server.
##
## This is value is only an estimate, and is regularly updated. This means that
## this value can and probably will change depending on network conditions.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member tick].
var remote_tick: int:
	get:
		return tick
	set(v):
		push_error("Trying to set read-only variable remote_tick")

## Current network time in seconds on the server.
##
## This is value is only an estimate, and is regularly updated. This means that
## this value can and probably will change depending on network conditions.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member time].
var remote_time: float:
	get:
		return time
	set(v):
		push_error("Trying to set read-only variable remote_time")

## Estimated roundtrip time to server.
##
## This value is updated regularly, during server time sync. Latency can be
## estimated as half of the roundtrip time. Returns the same as [member
## _NetworkTimeSynchronizer.rtt].
## [br][br]
## Will always be 0 on servers.
## [br][br]
## [i]read-only[/i]
var remote_rtt: float:
	get:
		return NetworkTimeSynchronizer.rtt
	set(v):
		push_error("Trying to set read-only variable remote_rtt")

## Current network time in ticks.
##
## On clients, this value is synced to the server [i]only once[/i] when joining
## the game. After that, it will increase monotonically, incrementing every
## single tick.
## [br][br]
## When hosting, this value is simply the number of ticks since game start.
## [br][br]
## This property can be used for things that require a timer that is guaranteed
## to be linear, i.e. no jumps in time.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member tick].
var local_tick: int:
	get:
		return tick
	set(v):
		push_error("Trying to set read-only variable local_tick")

## Current network time in seconds.
##
## On clients, this value is synced to the server [i]only once[/i] when joining
## the game. After that, it will increase monotonically, incrementing every
## single tick.
## [br][br]
## When hosting, this value is simply the seconds elapsed since game start.
## [br][br]
## This property can be used for things that require a timer that is guaranteed
## to be linear, i.e. no jumps in time.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member time].
var local_time: float:
	get:
		return time
	set(v):
		push_error("Trying to set read-only variable local_time")


## Amount of time a single tick takes, in seconds.
##
## This is the reverse of tickrate
##
## [i]read-only[/i]
var ticktime: float:
	get:
		return 1.0 / tickrate
	set(v):
		push_error("Trying to set read-only variable ticktime")

## Percentage of where we are in time for the current tick.
##
## 0.0 - the current tick just happened[br]
## 0.5 - we're halfway to the next tick[br]
## 1.0 - the next tick is right about to be simulated[br]
##
## [i]read-only[/i]
var tick_factor: float:
	get:
		if not sync_to_physics:
			return 1.0 - clampf((_next_tick_time - _last_process_time) * tickrate, 0, 1)
		else:
			return Engine.get_physics_interpolation_fraction()
	set(v):
		push_error("Trying to set read-only variable tick_factor")

## Multiplier to get from physics process speeds to tick speeds.
##
## Some methods, like CharacterBody's move_and_slide take velocity in units/sec
## and figure out the time delta on their own. However, they are not aware of
## netfox's time, so motion is all wrong in a network tick. For example, the
## network ticks run at 30 fps, while the game is running at 60fps, thus
## move_and_slide will also assume that it's running on 60fps, resulting in
## slower than expected movement.
##
## To circument this, you can multiply any velocities with this variable, and
## get the desired speed. Don't forget to then divide by this value if it's a
## persistent variable ( e.g. CharacterBody's velocity ).
##
## NOTE: This works correctly both in regular and in physics frames, but may
## yield different values.
##
## [i]read-only[/i]
var physics_factor: float:
	get:
		if Engine.is_in_physics_frame():
			return Engine.physics_ticks_per_second / tickrate
		else:
			return ticktime / _process_delta
	set(v):
		push_error("Trying to set read-only variable physics_factor")

## The maximum clock stretch factor allowed.
##
## For more context on clock stretch, see [member clock_stretch_factor]. The
## minimum allowed clock stretch factor is derived as 1.0 / clock_stretch_max.
## Setting this to larger values will allow for quicker clock adjustment at the
## cost of bigger deviations in game speed.
## [br][br]
## Make sure to adjust this value based on the game's needs.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var clock_stretch_max: float:
	get:
		return _clock_stretch_max
	set(v):
		push_error("Trying to set read-only variable stretch_max")

## Suppress warning when calling [member start] with an [OfflineMultiplayerPeer]
## active.
var suppress_offline_peer_warning: bool:
	get:
		return _suppress_offline_peer_warning
	set(v):
		push_error("Trying to set read-only variable suppress_offline_peer_warning")

## The currently used clock stretch factor.
##
## As the game progresses, the simulation clock may be ahead of, or behind the
## host's remote clock. To compensate, whenever the simulation clock is ahead of
## the remote clock, the game will slightly slow down, to allow the remote clock
## to catch up. When the remote clock is ahead of the simulation clock, the game
## will run slightly faster to catch up with the remote clock.
## [br][br]
## This value indicates the current clock speed multiplier. Values over 1.0
## indicate speeding up, under 1.0 indicate slowing down.
## [br][br]
## See [member clock_stretch_max] for clock stretch bounds.[br]
## See [_NetworkTimeSynchronizer] for more on the reference- and simulation
## clock.
## [br][br]
## [i]read-only[/i]
var clock_stretch_factor: float:
	get:
		return _clock_stretch_factor

## The current estimated offset between the reference clock and the simulation
## clock.
##
## Positive values mean the simulation clock is behind, and needs to run
## slightly faster to catch up. Negative values mean the simulation clock is
## ahead, and needs to slow down slightly.
## [br][br]
## See [member clock_stretch] for more clock speed adjustment.
## [br][br]
## [i]read-only[/i]
var clock_offset: float:
	get:
		# Offset is synced time - local time
		return NetworkTimeSynchronizer.get_time() - _clock.get_time()

## The current estimated offset between the reference clock and the remote
## clock.
##
## Positive values mean the reference clock is behind the remote clock.
## Negative values mean the reference clock is ahead of the remote clock.
## [br][br]
## Returns the same as [member _NetworkTimeSynchronizer.remote_offset].
## [br][br]
## [i]read-only[/i]
var remote_clock_offset: float:
	get:
		return NetworkTimeSynchronizer.remote_offset

## Emitted before a tick loop is run.
signal before_tick_loop()

## Emitted before a tick is run.
signal before_tick(delta: float, tick: int)

## Emitted for every network tick.
signal on_tick(delta: float, tick: int)

## Emitted after every network tick.
signal after_tick(delta: float, tick: int)

## Emitted after the tick loop is run.
signal after_tick_loop()

## Emitted after time is synchronized.
##
## This happens once the NetworkTime is started, and the first time sync process
## concludes. When running as server, this is emitted instantly after started.
signal after_sync()

## Emitted after a client synchronizes their time.
##
## This is only emitted on the server, and is emitted when the client concludes
## their time sync process. This is useful as this event means that the client
## is ticking and gameplay has started on their end.
signal after_client_sync(peer_id: int)

## Emitted when a tickrate mismatch is encountered, and
## [member NetworkTickrateHandshake.mismatch_action] is set to
## [constant NetworkTickrateHandshake.SIGNAL].
signal on_tickrate_mismatch(peer: int, tickrate: int)

# NetworkTime states
const _STATE_INACTIVE := 0
const _STATE_SYNCING := 1
const _STATE_ACTIVE := 2

# Settings
var _tickrate: int = ProjectSettings.get_setting(&"netfox/time/tickrate", 30)
var _sync_to_physics: bool = ProjectSettings.get_setting(&"netfox/time/sync_to_physics", false)
var _max_ticks_per_frame: int = ProjectSettings.get_setting(&"netfox/time/max_ticks_per_frame", 8)
var _recalibrate_threshold: float = ProjectSettings.get_setting(&"netfox/time/recalibrate_threshold", 8.0)
var _stall_threshold: float = ProjectSettings.get_setting(&"netfox/time/stall_threshold", 1.0)
var _clock_stretch_max: float = ProjectSettings.get_setting(&"netfox/time/max_time_stretch", 1.25)
var _suppress_offline_peer_warning: bool = ProjectSettings.get_setting(&"netfox/time/suppress_offline_peer_warning", false)

var _state: int = _STATE_INACTIVE

# Timing
var _tick: int = 0
var _was_paused: bool = false
var _initial_sync_done = false
var _process_delta: float = 0

var _next_tick_time: float = 0
var _last_process_time: float = 0.

var _clock: NetworkClocks.SteppingClock = NetworkClocks.SteppingClock.new()
var _clock_stretch_factor: float = 1.

var _tickrate_handshake: NetworkTickrateHandshake

var _synced_peers: Dictionary = {}

static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("NetworkTime")

## Start NetworkTime.
##
## Once this is called, time will be synchronized and ticks will be consistently
## emitted.
## [br][br]
## On clients, the initial time sync must complete before any ticks are emitted.
## [br][br]
## To check if this initial sync is done, see [method is_initial_sync_done]. If
## you need a signal, see [signal after_sync].
## [br][br]
## Returns [constant OK] on success.[br]
## Returns [constant ERR_ALREADY_IN_USE] if the tick loop is currently active.[br]
## Returns [constant ERR_UNAVAILABLE] if there's no available [MultiplayerPeer].
func start() -> int:
	# Check if time loop can be started
	if _state != _STATE_INACTIVE:
		_logger.warning(
			"Multiple calls to NetworkTime.start()! " +
			"Are you manually calling *and* have NetworkEvents enabled?")
		return ERR_ALREADY_IN_USE

	if not multiplayer.has_multiplayer_peer():
		_logger.error("Starting time loop without a multiplayer peer!")
		return ERR_UNAVAILABLE

	if multiplayer.multiplayer_peer is OfflineMultiplayerPeer and not suppress_offline_peer_warning:
		_logger.warning("Starting time loop with an offline peer! " +
			"If this is intended, suppress this warning in the project settings, " +
			"under netfox/Time/Suppress Offline Peer Warning.")

	# Reset state
	_tick = 0
	_initial_sync_done = false

	# Host is always synced, as their time is considered ground truth
	_synced_peers[1] = true

	# Start sync
	NetworkTimeSynchronizer.start()
	_state = _STATE_SYNCING

	if not multiplayer.is_server():
		await NetworkTimeSynchronizer.on_initial_sync

		_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time())
		_initial_sync_done = true
		_state = _STATE_ACTIVE

		_submit_sync_success.rpc()
	else:
		_state = _STATE_ACTIVE
		_initial_sync_done = true

	# Remove clients from the synced cache when disconnected
	multiplayer.peer_disconnected.connect(_handle_peer_disconnect)

	# Set initial clock state
	_clock.set_time(NetworkTimeSynchronizer.get_time())
	_last_process_time = _clock.get_time()
	_next_tick_time = _clock.get_time()
	after_sync.emit()

	# Handle tickrate handshake
	_tickrate_handshake.run()

	return OK

## Stop NetworkTime.
##
## This will stop the time sync in the background, and no more ticks will be
## emitted until the next start.
func stop() -> void:
	NetworkTimeSynchronizer.stop()
	_tickrate_handshake.stop()
	
	# Reset state
	_state = _STATE_INACTIVE
	_synced_peers.clear()
	_tick = 0
	_initial_sync_done = false

	if multiplayer.peer_disconnected.is_connected(_handle_peer_disconnect):
		multiplayer.peer_disconnected.disconnect(_handle_peer_disconnect)

## Check if the initial time sync is done.
func is_initial_sync_done() -> bool:
	return _initial_sync_done

## Check if client's time sync is complete.
##
## Using this from a client is considered an error.
func is_client_synced(peer_id: int) -> bool:
	return _synced_peers.has(peer_id)

## Convert a duration of ticks to seconds.
func ticks_to_seconds(ticks: int) -> float:
	return ticks * ticktime

## Convert a duration of seconds to ticks.
func seconds_to_ticks(seconds: float) -> int:
	return int(seconds * tickrate)

## Calculate the duration between two ticks in seconds
##
## [i]Note:[/i] Returns negative values if tick_to is smaller than tick_from
func seconds_between(tick_from: int, tick_to: int) -> float:
	return ticks_to_seconds(tick_to - tick_from)

## Calculate the duration between two points in time as ticks
##
## [i]Note:[/i] Returns negative values if seconds_to is smaller than seconds_from
func ticks_between(seconds_from: float, seconds_to: float) -> int:
	return seconds_to_ticks(seconds_to - seconds_from)

func _ready() -> void:
	NetfoxLogger.register_tag(_get_tick_tag, -100)

	_tickrate_handshake = NetworkTickrateHandshake.new()
	add_child(_tickrate_handshake)

	# Proxy tickrate mismatch event
	_tickrate_handshake.on_tickrate_mismatch.connect(func(peer, tickrate):
		on_tickrate_mismatch.emit(peer, tickrate)
	)

func _exit_tree() -> void:
	NetfoxLogger.free_tag(_get_tick_tag)

func _get_tick_tag() -> String:
	return "@%d" % tick

func _loop() -> void:
	# Adjust local clock
	_clock.step(_clock_stretch_factor)
	var clock_diff := NetworkTimeSynchronizer.get_time() - _clock.get_time()

	# Ignore diffs under 1ms
	clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.)

	var clock_stretch_min := 1. / clock_stretch_max
	# var clock_stretch_f = (1. + clock_diff / (1. * ticktime)) / 2.
	var clock_stretch_f := inverse_lerp(-ticktime, +ticktime, clock_diff)
	clock_stretch_f = clampf(clock_stretch_f, 0., 1.)

	var previous_stretch_factor := _clock_stretch_factor
	_clock_stretch_factor = lerpf(clock_stretch_min, clock_stretch_max, clock_stretch_f)

	# Detect editor pause
	var clock_step := _clock.get_time() - _last_process_time
	var clock_step_raw := clock_step / previous_stretch_factor
	if clock_step_raw > stall_threshold:
			# Game stalled for a while, probably paused, don't run extra ticks
			# to catch up
			_was_paused = true
			_logger.debug("Game stalled for %.4fs, assuming it was a pause", [clock_step_raw])

	# Handle pause
	if _was_paused:
		_was_paused = false
		_next_tick_time += clock_step
		_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time())

	# Run tick loop if needed
	var ticks_in_loop := 0
	_last_process_time = _clock.get_time()
	while _next_tick_time < _last_process_time and ticks_in_loop < max_ticks_per_frame:
		if ticks_in_loop == 0:
			before_tick_loop.emit()

		before_tick.emit(ticktime, tick)

		on_tick.emit(ticktime, tick)

		after_tick.emit(ticktime, tick)
		NetworkHistoryServer._record_sync_state(tick + 1)
		NetworkSynchronizationServer._synchronize_sync_state(tick + 1)

		_tick += 1
		ticks_in_loop += 1
		_next_tick_time += ticktime

	if ticks_in_loop > 0:
		after_tick_loop.emit()
		NetworkHistoryServer._restore_synchronizer_state(tick)

func _process(delta: float) -> void:
	_process_delta = delta

	if _is_active() and not sync_to_physics:
		_loop()

func _physics_process(delta: float) -> void:
	if _is_active() and sync_to_physics:
		_loop()

func _notification(what) -> void:
	if what == NOTIFICATION_UNPAUSED:
		_was_paused = true

func _is_active() -> bool:
	return _state == _STATE_ACTIVE

func _handle_peer_disconnect(peer: int) -> void:
	_synced_peers.erase(peer)

@rpc("any_peer", "reliable", "call_local")
func _submit_sync_success() -> void:
	var peer_id := multiplayer.get_remote_sender_id()

	_logger.trace("Received time sync success from #%s, synced peers: %s", [peer_id, _synced_peers.keys()])

	if not _synced_peers.has(peer_id):
		_synced_peers[peer_id] = true
		after_client_sync.emit(peer_id)
		_logger.debug("Peer #%s is now on time!", [peer_id])
