extends Node

const GUIDESet = preload("guide_set.gd")
const GUIDEReset = preload("guide_reset.gd")
const GUIDEInputTracker = preload("guide_input_tracker.gd")

## This is emitted whenever input mappings change (either due to mapping
## contexts being enabled/disabled or remapping configs being re-applied or
## joystick devices being connected/disconnected).
## This is useful for updating UI prompts.
signal input_mappings_changed()

## The currently active contexts. Key is the context, value is the priority
var _active_contexts:Dictionary = {}
## The currently active action mappings.
var _active_action_mappings:Array[GUIDEActionMapping] = []

## The currently active remapping config.
var _active_remapping_config:GUIDERemappingConfig

## All currently active inputs as collected from the active input mappings
var _active_inputs:Array[GUIDEInput] = []

## A dictionary of actions sharing input. Key is the action, value
## is an array of lower-priority actions that share input with the 
## key action.
var _actions_sharing_input:Dictionary = {}

## A reference to the reset node which resets inputs that need a reset per frame
## This is an extra node because the reset should run at the end of the frame
## before new input is processed at the beginning of the frame.
var _reset_node:GUIDEReset

## The current input state. This is used to track the state of the inputs
## and serves as a basis for the GUIDEInputs.
var _input_state:GUIDEInputState


func _ready():
	process_mode = Node.PROCESS_MODE_ALWAYS
	_reset_node = GUIDEReset.new()
	_input_state = GUIDEInputState.new()
	add_child(_reset_node)
	# attach to the current viewport to get input events
	GUIDEInputTracker._instrument.call_deferred(get_viewport())
	
	get_tree().node_added.connect(_on_node_added)
	
	# Emit a change of input mappings whenever a joystick was connected
	# or disconnected.
	Input.joy_connection_changed.connect(func(ig, ig2): input_mappings_changed.emit())


## Called when a node is added to the tree. If the node is a window
## GUIDE will instrument it to get events when the window is focused.	
func _on_node_added(node:Node) -> void:
	if not node is Window:
		return
		
	GUIDEInputTracker._instrument(node)
	

## Injects input into GUIDE. GUIDE will call this automatically but 
## can also be used to manually inject input for GUIDE to handle 
func inject_input(event:InputEvent) -> void:
	if event is InputEventAction:
		return  # we don't react to Godot's built-in events

	# The input state is the sole consumer of input events. It will notify
	# GUIDEInputs when relevant input events happen. This way we don't need
	# to process input events multiple times and at the same time always have
	# the full picture of the input state.
	_input_state._input(event)


## Applies an input remapping config. This will override all input bindings in the 
## currently loaded mapping contexts with the bindings from the configuration.	
## Note that GUIDE will not track changes to the remapping config. If your remapping
## config changes, you will need to call this method again.
func set_remapping_config(config:GUIDERemappingConfig) -> void:
	_active_remapping_config = config
	_update_caches()
	
	
## Enables the given context with the given priority. Lower numbers have higher priority. If 
## disable_others is set to true, all other currently enabled mapping contexts will be disabled.
func enable_mapping_context(context:GUIDEMappingContext, disable_others:bool = false,  priority:int = 0):
	if not is_instance_valid(context):
		push_error("Null context given. Ignoring.")
		return
	
	if disable_others:
		_active_contexts.clear()	
	
	_active_contexts[context] = priority
	_update_caches()
	
	
## Disables the given mapping context.
func disable_mapping_context(context:GUIDEMappingContext):
	if not is_instance_valid(context):
		push_error("Null context given. Ignoring.")
		return

	_active_contexts.erase(context)
	_update_caches()


## Checks whether the given mapping context is currently enabled.
func is_mapping_context_enabled(context:GUIDEMappingContext) -> bool:
	return _active_contexts.has(context)


## Returns the currently enabled mapping contexts
func get_enabled_mapping_contexts() -> Array[GUIDEMappingContext]:
	var result:Array[GUIDEMappingContext] = []
	for key in _active_contexts.keys():
		result.append(key)
	return result


## Processes all currently active actions
func _process(delta:float) -> void:
	var blocked_actions:GUIDESet = GUIDESet.new()
	
	for action_mapping:GUIDEActionMapping in _active_action_mappings:
		
		var action:GUIDEAction = action_mapping.action
				
		# Walk over all input mappings for this action and consolidate state
		# and result value.
		var consolidated_value:Vector3 = Vector3.ZERO
		var consolidated_trigger_state:GUIDETrigger.GUIDETriggerState
		
		for input_mapping:GUIDEInputMapping in action_mapping.input_mappings:
			input_mapping._update_state(delta, action.action_value_type)
			consolidated_value += input_mapping._value
			consolidated_trigger_state = max(consolidated_trigger_state, input_mapping._state)
		
		# we do the blocking check only here because triggers may need to run anyways
		# (e.g. to collect hold times).
		if blocked_actions.has(action):
			consolidated_trigger_state = GUIDETrigger.GUIDETriggerState.NONE
		
		if action.block_lower_priority_actions and \
			consolidated_trigger_state == GUIDETrigger.GUIDETriggerState.TRIGGERED and \
			_actions_sharing_input.has(action):
			for blocked_action in _actions_sharing_input[action]:
				blocked_actions.add(blocked_action)
			
		
		# Now state change events.
		match(action._last_state):
			GUIDEAction.GUIDEActionState.TRIGGERED:
				match(consolidated_trigger_state):
					GUIDETrigger.GUIDETriggerState.NONE:
						action._completed(consolidated_value)
					GUIDETrigger.GUIDETriggerState.ONGOING:
						action._ongoing(consolidated_value, delta)
					GUIDETrigger.GUIDETriggerState.TRIGGERED:
						action._triggered(consolidated_value, delta)
						
			GUIDEAction.GUIDEActionState.ONGOING:
				match(consolidated_trigger_state):
					GUIDETrigger.GUIDETriggerState.NONE:
						action._cancelled(consolidated_value)
					GUIDETrigger.GUIDETriggerState.ONGOING:
						action._ongoing(consolidated_value, delta)
					GUIDETrigger.GUIDETriggerState.TRIGGERED:
						action._triggered(consolidated_value, delta)
						
			GUIDEAction.GUIDEActionState.COMPLETED:
				match(consolidated_trigger_state):
					GUIDETrigger.GUIDETriggerState.NONE:
						# make sure the value updated but don't emit any other events
						action._update_value(consolidated_value)
					GUIDETrigger.GUIDETriggerState.ONGOING:
						action._started(consolidated_value)
					GUIDETrigger.GUIDETriggerState.TRIGGERED:
						action._triggered(consolidated_value, delta)
						
func _update_caches():
	# Notify existing inputs that they aren no longer required
	for input:GUIDEInput in _active_inputs:
		input._reset()
		input._end_usage()
		
	# Cancel all actions, so they don't remain in weird states.
	for mapping:GUIDEActionMapping in _active_action_mappings:
		match mapping.action._last_state:
			GUIDEAction.GUIDEActionState.ONGOING:
				mapping.action._cancelled(Vector3.ZERO)
			GUIDEAction.GUIDEActionState.TRIGGERED:
				mapping.action._completed(Vector3.ZERO)
		# notify all modifiers they are no longer in use
		for input_mapping in mapping.input_mappings:
			for modifier in input_mapping.modifiers:
				modifier._end_usage()
		
	_active_inputs.clear()
	_active_action_mappings.clear()
	_actions_sharing_input.clear()
	
	var sorted_contexts:Array[Dictionary] = []
	
	for context:GUIDEMappingContext in _active_contexts.keys():
		sorted_contexts.append({"context": context, "priority": _active_contexts[context]})

	sorted_contexts.sort_custom( func(a,b): return a.priority < b.priority )
	
	# The actions we already have processed. Same action may appear in different
	# contexts, so if we find the same action twice, only the first instance wins.
	var processed_actions:GUIDESet = GUIDESet.new()
	var consolidated_inputs:GUIDESet = GUIDESet.new()

	for entry:Dictionary in sorted_contexts:
		var context:GUIDEMappingContext = entry.context
		var position:int = 0
		for action_mapping:GUIDEActionMapping in context.mappings:
			position += 1
			var action := action_mapping.action
			
			# Mapping may be misconfigured, so we need to handle the case
			# that the action is missing.
			if action == null:
				push_warning("Mapping at position %s in context %s has no action set. This mapping will be ignored." % [position, context.resource_path])
				continue
			
			# If the action was already configured in a higher priority context,
			# we'll skip it.
			if processed_actions.has(action):
				# skip
				continue
				
			processed_actions.add(action)
			
			# We consolidate the inputs here, so we'll internally build a new
			# action mapping that uses consolidated inputs rather than the
			# original ones. This achieves multiple things:
			# - if two actions check for the same input, we only need to
			#   process the input once instead of twice.
			# - it allows us to prioritize input, if two actions check for  
			#   the same input. This way the first action can consume the
			#   input and not have it affect further actions.
			# - we make sure nobody shares triggers as they are stateful and
			#   should not be shared.
			
			var effective_mapping  = GUIDEActionMapping.new()
			effective_mapping.action = action

			# now update the input mappings
			for index in action_mapping.input_mappings.size():
				var bound_input:GUIDEInput = action_mapping.input_mappings[index].input
				
				# if the mapping has an override for the input, apply it.
				if _active_remapping_config != null and \
						_active_remapping_config._has(context, action, index):
					bound_input = _active_remapping_config._get_bound_input_or_null(context, action, index)

				# make a new input mapping
				var new_input_mapping := GUIDEInputMapping.new()

				# can be null for combo mappings, so check that
				if bound_input != null:
					# check if we already have this kind of input
					var existing = consolidated_inputs.first_match(func(it:GUIDEInput): return it.is_same_as(bound_input))
					if existing != null:
						# if we have this already, use the instance we have
						bound_input = existing
					else:
						# otherwise register this input into the consolidated input
						consolidated_inputs.add(bound_input)
					
				new_input_mapping.input = bound_input
				# modifiers cannot be re-bound so we can just use the one
				# from the original configuration. this is also needed for shared
				# modifiers to work.
				new_input_mapping.modifiers = action_mapping.input_mappings[index].modifiers
				# triggers also cannot be re-bound but we still make a copy 
				# to ensure that no shared triggers exist.
				new_input_mapping.triggers = []
				
				for trigger in action_mapping.input_mappings[index].triggers:
					new_input_mapping.triggers.append(trigger.duplicate())
				
				new_input_mapping._initialize()
				
				# and add it to the new mapping
				effective_mapping.input_mappings.append(new_input_mapping)

				
			# if any binding remains, add the mapping to the list of active
			# action mappings
			if not effective_mapping.input_mappings.is_empty():
				_active_action_mappings.append(effective_mapping)

	# INVARIANT: all _active_action_mappings now have actions.

	# now we have a new set of active inputs		
	for input:GUIDEInput in consolidated_inputs.values():
		_active_inputs.append(input)
		
	# prepare the action input share lookup table
	for i:int in _active_action_mappings.size():
		
		var mapping = _active_action_mappings[i]
		
		if mapping.action.block_lower_priority_actions:
			# first find out if the action uses any chorded actions and 
			# collect all inputs that this action uses
			var chorded_actions:GUIDESet = GUIDESet.new()
			var inputs:GUIDESet = GUIDESet.new()
			var blocked_actions:GUIDESet = GUIDESet.new()
			for input_mapping:GUIDEInputMapping in mapping.input_mappings:
				if input_mapping.input != null:
					inputs.add(input_mapping.input)		
						
				for trigger:GUIDETrigger in input_mapping.triggers:
					if trigger is GUIDETriggerChordedAction and trigger.action != null:
						chorded_actions.add(trigger.action)
						
			# Now the action that has a chorded action (A) needs to make sure that
			# the chorded action it depends upon (B) is not blocked (otherwise A would 
			# never trigger) and if that chorded action (B) in turn depends on chorded actions. So 
			# if chorded actions build a chain, we need to keep the full
			# chain unblocked. In addition we need to add the inputs of all
			# these chorded actions to the list of blocked inputs.
			for j:int in range(i+1, _active_action_mappings.size()):
				var inner_mapping = _active_action_mappings[j]
				# this is a chorded action that is used by one other action
				# in the chain.
				if chorded_actions.has(inner_mapping.action):
					for input_mapping:GUIDEInputMapping in inner_mapping.input_mappings:
						# put all of its inputs into the list of blocked inputs
						if input_mapping.input != null:
							inputs.add(input_mapping.input)
			
						# also if this mapping in turn again depends on a chorded
						# action, ad this one to the list of chorded actions
						for trigger:GUIDETrigger in input_mapping.triggers:
							if trigger is GUIDETriggerChordedAction and trigger.action != null:
								chorded_actions.add(trigger.action)
			
			# now find lower priority actions that share input
			for j:int in range(i+1, _active_action_mappings.size()):
				var inner_mapping = _active_action_mappings[j]
				if chorded_actions.has(inner_mapping.action):
					continue
					
				for input_mapping:GUIDEInputMapping in inner_mapping.input_mappings:
					if input_mapping.input == null:
						continue
					
					# because we consolidated input, we can now do an == comparison
					# to find equal input.
					if inputs.has(input_mapping.input):
						blocked_actions.add(inner_mapping.action)
						# we can continue to the next action
						break 
										
			if not blocked_actions.is_empty():
				_actions_sharing_input[mapping.action] = blocked_actions.values()
				
	_reset_node._inputs_to_reset.clear()
	for input:GUIDEInput in _active_inputs:
		# finally collect which inputs we need to reset per frame
		if input._needs_reset():
			_reset_node._inputs_to_reset.append(input)

		# Give the state to the input
		input._state = _input_state		
		# Notify inputs that GUIDE is about to use them
		input._begin_usage()
	
	for mapping in _active_action_mappings:
		for input_mapping in mapping.input_mappings:
			# notify modifiers they will be used.
			for modifier in input_mapping.modifiers:
				modifier._begin_usage()
		
			# and copy over the hold time threshold from the mapping
			mapping.action._trigger_hold_threshold = input_mapping._trigger_hold_threshold
		
	# and notify interested parties that the input mappings have changed
	input_mappings_changed.emit()


