class_name VisualHolder
extends Node2D

# Porte le visuel d'une entité. On lui donne un état, il se charge du reste:
# feuille de sprites ou scène, cadence, calage, miroir selon l'orientation.
#
# Il ne décide de rien d'autre. La collision et la portée restent chez leur
# propriétaire — `ShapeFactory`, `Enemy.body_radius()` — parce qu'une hitbox qui
# suivrait une image ferait de chaque retouche d'art une session d'équilibrage.

const CACHE_LIMIT := 128

# Les `SpriteFrames` construites sont partagées entre toutes les entités qui
# emploient le même visuel: dix slimes ne découpent pas dix fois la même feuille.
static var _frames_cache: Dictionary = {}

var collection := ""
var entity_id := ""
var state := ""
var visual_id := ""

var _sprite: AnimatedSprite2D
var _scene_instance: Node2D
# Rectangle à couvrir, pour un fond. Vide = placement normal, celui du visuel.
var cover_rect := Rect2()

# Vrai quand une image est réellement affichée. Le propriétaire s'en sert pour
# sauter son dessin procédural — et seulement celui du corps.
func is_showing() -> bool:
	return not visual_id.is_empty()

func bind(new_collection: String, new_entity_id: String) -> void:
	collection = new_collection
	entity_id = new_entity_id

# Change d'état. Ne fait rien si l'état résout vers le même visuel: repasser de
# `walk` à `idle` quand les deux héritent de la même image ne doit pas relancer
# l'animation à chaque frame.
func show_state(new_state: String) -> void:
	state = new_state
	show_visual(VisualCatalog.resolve(collection, entity_id, new_state))

# Montre un visuel nommé, sans passer par la chaîne de repli. C'est ce dont un
# effet ponctuel a besoin: il connaît son visuel, il n'a pas d'état.
func show_visual(new_visual_id: String) -> void:
	if new_visual_id == visual_id:
		return
	visual_id = new_visual_id
	_rebuild()

func _rebuild() -> void:
	# Retiré de l'arbre tout de suite, libéré ensuite: `queue_free` seul laisse
	# l'ancien noeud compté jusqu'à la fin de la frame, et le porteur mentirait
	# sur ce qu'il montre.
	for previous: Node in [_sprite, _scene_instance]:
		if previous != null and is_instance_valid(previous):
			remove_child(previous)
			previous.queue_free()
	_sprite = null
	_scene_instance = null
	if visual_id.is_empty():
		return
	var definition := VisualCatalog.definition(visual_id)
	if definition.is_empty():
		visual_id = ""
		return
	if str(definition.get("kind", "frames")) == "scene":
		_build_scene(definition)
	else:
		_build_frames(definition)

func _build_scene(definition: Dictionary) -> void:
	var packed := load(str(definition.path)) as PackedScene
	if packed == null:
		visual_id = ""
		return
	var instance := packed.instantiate()
	if not instance is Node2D:
		instance.queue_free()
		visual_id = ""
		return
	_scene_instance = instance
	_apply_placement(_scene_instance, definition)
	add_child(_scene_instance)

func _build_frames(definition: Dictionary) -> void:
	var frames := _frames_for(visual_id, definition)
	if frames == null:
		visual_id = ""
		return
	_sprite = AnimatedSprite2D.new()
	_sprite.sprite_frames = frames
	_sprite.animation = "default"
	_apply_placement(_sprite, definition)
	add_child(_sprite)
	_sprite.play()

func _apply_placement(node: Node2D, definition: Dictionary) -> void:
	if _apply_cover(node):
		return
	node.position = Vector2(float(definition.get("offset_x", 0.0)), float(definition.get("offset_y", 0.0)))
	var factor := maxf(0.01, float(definition.get("scale", 1.0)))
	node.scale = Vector2.ONE * factor

# Étire le visuel pour remplir un rectangle. Réservé aux feuilles: une scène est
# composée à sa propre échelle, l'étirer déformerait ce que son auteur a posé.
func cover(rect: Rect2) -> void:
	cover_rect = rect
	var target: Node2D = _sprite if _sprite != null else _scene_instance
	if target != null and is_instance_valid(target):
		_apply_cover(target)

func _apply_cover(node: Node2D) -> bool:
	if cover_rect.size.x <= 0.0 or cover_rect.size.y <= 0.0:
		return false
	var sprite := node as AnimatedSprite2D
	if sprite == null or sprite.sprite_frames == null:
		return false
	var frame := sprite.sprite_frames.get_frame_texture("default", 0)
	if frame == null or frame.get_width() <= 0 or frame.get_height() <= 0:
		return false
	# Le sprite est centré: l'échelle vient du rapport, la position du centre.
	node.scale = cover_rect.size / Vector2(frame.get_size())
	node.position = cover_rect.position + cover_rect.size * 0.5
	return true

# Découpe la feuille en atlas, une fois par visuel. `count` peut être inférieur
# à colonnes × rangées quand la dernière ligne est incomplète.
static func _frames_for(id: String, definition: Dictionary) -> SpriteFrames:
	if _frames_cache.has(id):
		return _frames_cache[id]
	var texture := load(str(definition.get("path", ""))) as Texture2D
	if texture == null:
		return null
	var columns := maxi(1, int(definition.get("columns", 1)))
	var rows := maxi(1, int(definition.get("rows", 1)))
	var count := clampi(int(definition.get("count", 1)), 1, columns * rows)
	var cell := Vector2i(texture.get_width() / columns, texture.get_height() / rows)
	if cell.x <= 0 or cell.y <= 0:
		return null
	var frames := SpriteFrames.new()
	frames.set_animation_speed("default", maxf(0.0, float(definition.get("fps", 8.0))))
	frames.set_animation_loop("default", bool(definition.get("loop", true)))
	for index: int in range(count):
		var atlas := AtlasTexture.new()
		atlas.atlas = texture
		atlas.region = Rect2i(Vector2i(index % columns, index / columns) * cell, cell)
		frames.add_frame("default", atlas)
	# Le cache est borné: quelques centaines de visuels ne posent pas de
	# problème, mais il ne doit pas grandir indéfiniment.
	if _frames_cache.size() >= CACHE_LIMIT:
		_frames_cache.clear()
	_frames_cache[id] = frames
	return frames

# Le miroir suit l'orientation, quand le visuel le demande. Une flèche qui
# pointe déjà dans les deux sens ne veut pas être retournée.
func face(direction: Vector2) -> void:
	if not is_showing() or absf(direction.x) < 0.05:
		return
	var definition := VisualCatalog.definition(visual_id)
	if not bool(definition.get("flip_follows_facing", true)):
		return
	var target: Node2D = _sprite if _sprite != null else _scene_instance
	if target != null and is_instance_valid(target):
		target.scale.x = absf(target.scale.x) * (-1.0 if direction.x < 0.0 else 1.0)

static func clear_cache() -> void:
	_frames_cache.clear()
