class_name EffectHolder
extends Node

const DamageTypes := preload("res://scripts/combat/damage_types.gd")

# Holds the temporary effects of one entity. Instances are per application and
# grouped by stack key, which is what makes a per-stack expiry free: each
# application carries its own deadline and falls off on its own.
#
# The address space deliberately does not reach instances — three slimes on
# screen are three instances of one definition, and their identifiers would be
# meaningless in a save file or in the editor. Only aggregates flow back up.

signal effect_added(effect_id: String)
signal effect_expired(effect_id: String)

# Player effects are pushed into GameState so they survive the scene change that
# recreates the player node. Enemy effects die with the enemy.
@export var persistent := false

var entity: Node2D
var instances: Array[Dictionary] = []

func _ready() -> void:
	entity = get_parent() as Node2D
	if persistent:
		_restore_from_state()

func now() -> float:
	return GameState.game_time

# stack_key defaults to the effect id, so two casters aggravate one shared
# effect and max_stacks keeps its promise as a real damage ceiling.
func apply(effect_id: String, source: String = "", stack_key: String = "") -> bool:
	var definition: Dictionary = DataCatalog.EFFECTS.get(effect_id, {})
	if definition.is_empty():
		push_error("Unknown effect: " + effect_id)
		return false
	# Une fiche immunisee ne recoit pas l'effet du tout: ni pile, ni controle,
	# ni degats periodiques.
	if entity != null and entity.has_method("is_immune_to_effect") \
			and bool(entity.call("is_immune_to_effect", effect_id)):
		return false
	var key := stack_key if not stack_key.is_empty() else effect_id
	var policy := str(definition.get("stacking", "refresh"))
	var duration := float(definition.get("duration", 0.0))
	var group := _group(key)
	var deadline := now() + _duration_for_application(definition, group.size() + 1)
	match policy:
		"unique":
			if not group.is_empty():
				return false
		"refresh":
			if not group.is_empty():
				for instance: Dictionary in group:
					instance.expires_at = deadline
					_roll_control(instance, definition, group.size())
				_sync_state()
				return true
		"extend":
			if not group.is_empty():
				var extension := duration
				if str(definition.get("stack_scaling", "none")) == "duration":
					extension *= float(definition.get("stack_bonus", 1.0))
				group[0].expires_at = float(group[0].expires_at) + extension
				_roll_control(group[0], definition, group.size())
				_sync_state()
				return true
		"strongest":
			if not group.is_empty():
				return false
		"stack", "stack_refresh":
			var maximum := maxi(1, int(definition.get("max_stacks", 1)))
			if policy == "stack_refresh":
				for instance: Dictionary in group:
					instance.expires_at = deadline
			if group.size() >= maximum:
				# At the cap, the application replaces whichever stack was due to
				# fall off first, which is what "maintaining" a poison means.
				var soonest: Dictionary = group[0]
				for instance: Dictionary in group:
					if float(instance.expires_at) < float(soonest.expires_at):
						soonest = instance
				soonest.expires_at = deadline
				_roll_control(soonest, definition, group.size())
				_sync_state()
				return true
	var instance := {
		"definition": effect_id, "stack_key": key, "source": source,
		"expires_at": deadline, "next_tick_at": now() + float(definition.get("tick_interval", 0.0)),
		"control_until": 0.0, "control_flags": [],
	}
	# Billet 60: each charm draws once whether the creature turns on its allies
	# or simply gives up the fight.
	if str(definition.get("behavior", "")) == "charm":
		instance["charm_allies"] = randf() < float(definition.get("charm_ally_chance", 0.5))
	_roll_control(instance, definition, group.size() + 1)
	instances.append(instance)
	_sync_state()
	effect_added.emit(effect_id)
	return true

func try_apply(effect_id: String, chance_percent: float, source: String = "", stack_key: String = "") -> bool:
	var definition: Dictionary = DataCatalog.EFFECTS.get(effect_id, {})
	if definition.is_empty():
		return false
	var key := stack_key if not stack_key.is_empty() else effect_id
	var chance := clampf(chance_percent, 0.0, 100.0)
	if str(definition.get("stack_scaling", "none")) == "application_chance":
		chance += float(definition.get("stack_bonus", 0.0)) * 100.0 * float(stacks_of(key))
	if chance < 100.0 and randf() * 100.0 >= chance:
		return false
	return apply(effect_id, source, key)

func _duration_for_application(definition: Dictionary, stack_count: int) -> float:
	var duration := float(definition.get("duration", 0.0))
	if str(definition.get("stack_scaling", "none")) == "duration":
		duration *= 1.0 + float(definition.get("stack_bonus", 0.0)) * float(maxi(0, stack_count - 1))
	return duration

func _roll_control(instance: Dictionary, definition: Dictionary, stack_count: int) -> void:
	var chance := float(definition.get("control_chance", 0.0))
	if str(definition.get("stack_scaling", "none")) == "control_chance":
		chance += float(definition.get("stack_bonus", 0.0)) * float(maxi(0, stack_count - 1))
	var duration := float(definition.get("control_duration", 0.0))
	var flags: Variant = definition.get("control_flags", [])
	if duration > 0.0 and flags is Array and not flags.is_empty() and randf() < clampf(chance, 0.0, 1.0):
		instance.control_until = maxf(float(instance.get("control_until", 0.0)), now() + duration)
		instance.control_flags = flags.duplicate()

func _process(_delta: float) -> void:
	if instances.is_empty():
		return
	var tree := get_tree()
	if tree != null and tree.paused:
		return
	_advance_ticks()
	_expire()

func _advance_ticks() -> void:
	var current := now()
	var advanced := {}
	for instance: Dictionary in instances:
		var definition: Dictionary = DataCatalog.EFFECTS.get(str(instance.definition), {})
		var interval := float(definition.get("tick_interval", 0.0))
		var key := str(instance.stack_key)
		if advanced.has(key) or interval <= 0.0 or current < float(instance.next_tick_at):
			continue
		advanced[key] = true
		for grouped: Dictionary in _group(key):
			grouped.next_tick_at = current + interval
		_run_tick_actions(definition, key)

func _run_tick_actions(definition: Dictionary, stack_key: String) -> void:
	if entity == null or not is_instance_valid(entity):
		return
	var stacks := maxi(1, _group(stack_key).size())
	var scaling := str(definition.get("stack_scaling", "none"))
	for action: Variant in definition.get("tick_actions", []):
		if not action is Dictionary:
			continue
		var amount := float(action.get("amount", 0.0))
		if scaling == "damage" and str(action.get("op", "damage")) == "damage":
			amount *= 1.0 + float(definition.get("stack_bonus", 0.0)) * float(stacks - 1)
		elif bool(action.get("per_stack", false)):
			amount *= float(stacks)
		match str(action.get("op", "damage")):
			"damage":
				if entity.has_method("take_damage"):
					# Ce qui ronge n'a pas de geste: seules les particules de l'element
					# se montrent, et moins souvent qu'un coup.
					entity.take_damage(DamageTypes.apply(amount, definition.get("damage_types", []), entity,
						{"periodic": true}))
			"heal":
				if entity.has_method("take_damage"):
					entity.take_damage(-amount)

func _expire() -> void:
	var current := now()
	var kept: Array[Dictionary] = []
	var expired: Array[String] = []
	for instance: Dictionary in instances:
		if float(instance.expires_at) > current:
			kept.append(instance)
		else:
			expired.append(str(instance.definition))
	if expired.is_empty():
		return
	instances = kept
	_sync_state()
	for effect_id: String in expired:
		effect_expired.emit(effect_id)

func _group(stack_key: String) -> Array[Dictionary]:
	var result: Array[Dictionary] = []
	for instance: Dictionary in instances:
		if str(instance.stack_key) == stack_key:
			result.append(instance)
	return result

func stacks_of(stack_key: String) -> int:
	return _group(stack_key).size()

func has_effect(effect_id: String) -> bool:
	for instance: Dictionary in instances:
		if str(instance.definition) == effect_id:
			return true
	return false

## Porte-t-il un effet de cette famille (feu, glace...) ? La garde d'une
## creature tombe tant qu'elle en porte un qui la brise.
func has_kind(kind: String) -> bool:
	for instance: Dictionary in instances:
		if str(DataCatalog.EFFECTS.get(str(instance.definition), {}).get("kind", "")) == kind:
			return true
	return false

# A capability is denied as soon as a single active effect denies it, so there
# is no ordering question between two effects.
func has_flag(flag: String) -> bool:
	for instance: Dictionary in instances:
		var definition: Dictionary = DataCatalog.EFFECTS.get(str(instance.definition), {})
		if definition.get("flags", []).has(flag):
			return true
		if now() < float(instance.get("control_until", 0.0)) \
		and instance.get("control_flags", []).has(flag):
			return true
	return false

## Billet 60: the animation an active effect imposes ("dance"), or "".
func imposed_animation() -> String:
	for instance: Dictionary in instances:
		var animation := str(DataCatalog.EFFECTS.get(str(instance.definition), {}).get("animation", ""))
		if not animation.is_empty():
			return animation
	return ""

## Billet 60: the most recent active effect with this behaviour ("charm",
## "confuse"): {"allies": bool, "source": String}, or {} when none applies.
func behavior_state(behavior: String) -> Dictionary:
	for index: int in range(instances.size() - 1, -1, -1):
		var instance: Dictionary = instances[index]
		if str(DataCatalog.EFFECTS.get(str(instance.definition), {}).get("behavior", "")) == behavior:
			return {"allies": bool(instance.get("charm_allies", false)), "source": str(instance.get("source", ""))}
	return {}

func movement_multiplier() -> float:
	var result := 1.0
	var handled := {}
	for instance: Dictionary in instances:
		var key := str(instance.stack_key)
		if handled.has(key):
			continue
		handled[key] = true
		var definition: Dictionary = DataCatalog.EFFECTS.get(str(instance.definition), {})
		# Au-dessus de 1 l'effet accelere. Le plafond vivait ICI et dans le
		# schema: lever l'un sans l'autre ne changeait rien.
		var factor := clampf(float(definition.get("movement_multiplier", 1.0)), 0.0, 10.0)
		if str(definition.get("stack_scaling", "none")) == "slow":
			factor -= float(definition.get("stack_bonus", 0.0)) * float(maxi(0, stacks_of(key) - 1))
		result *= clampf(factor, 0.0, 10.0)
	return result

func clear() -> void:
	if instances.is_empty():
		return
	instances.clear()
	_sync_state()

# --- Persistence ----------------------------------------------------------

func _sync_state() -> void:
	if not persistent:
		return
	GameState.player_effects = instances.duplicate(true)
	_publish_modifiers()

func _restore_from_state() -> void:
	instances.clear()
	for entry: Variant in GameState.player_effects:
		if entry is Dictionary and DataCatalog.EFFECTS.has(str((entry as Dictionary).get("definition", ""))):
			instances.append((entry as Dictionary).duplicate(true))
	_publish_modifiers()

# Effect modifiers reach the stat pool through the same named-source mechanism
# as everything else, so removing them is one call and never a subtraction.
func _publish_modifiers() -> void:
	GameState.remove_modifiers_by_source_prefix("effect:")
	for instance: Dictionary in instances:
		var definition: Dictionary = DataCatalog.EFFECTS.get(str(instance.definition), {})
		for modifier: Variant in definition.get("modifiers", []):
			if not modifier is Dictionary:
				continue
			GameState.add_modifier(
				"effect:%s" % str(instance.stack_key),
				str((modifier as Dictionary).get("variable", "")),
				ModifierPool.layer_of(modifier),
				float((modifier as Dictionary).get("value", 0.0)),
				0.0,
				str((modifier as Dictionary).get("operation", "add")))
