class_name InfoBadge3D
extends Node3D

# Ce que l'entité a à dire, affiché au-dessus d'elle, en volume.
#
# Le jeu 2D dessine ces informations — commande d'un client, avancement d'une
# cuisson — **aux coordonnées du monde**. Sous une caméra 3D inclinée, ce point
# n'est plus au même endroit à l'écran: les rallumer telles quelles les
# afficherait à côté de ce qu'elles décrivent.
#
# Elles remontent donc dans la scène 3D, en panneau toujours face au joueur. Le
# badge ne décide de rien: il lit ce que l'entité publie déjà, et se tait quand
# il n'y a rien à dire.

const BAR_WIDTH := 44.0
const BAR_HEIGHT := 4.0
const CUSTOMER_LINE_CHARACTERS := 26
# Hauteur du texte **en unités du monde**: `font_size × pixel_size`. L'écrire
# ainsi évite de régler deux nombres qui se multiplient sans qu'on voie le
# résultat — c'est comme ça qu'il s'est retrouvé cinq fois trop gros.
const TEXT_WORLD_HEIGHT := 7.0
# L'air entre deux lignes. Sans lui les lignes se collent: `Label3D` n'espace
# rien par défaut, et une commande sous une réplique devenait un pâté.
const TEXT_LINE_GAP := 3.2
const FONT_SIZE := 64

# Le nom: discret par construction — plus petit, plus terne, et jamais devant
# l'information qui compte. Il se lit à deux endroits selon l'entité — dans la
# bulle pâle d'un client, ou à même le décor au-dessus d'une créature — donc il
# change de contraste avec son fond.
const NAME_WORLD_HEIGHT := 5.5
const NAME_ON_BUBBLE := Color(0.36, 0.31, 0.42)
# La vedette d'une annonce: le mot qui compte, sur sa propre rangée. `Label3D` ne
# connaît ni balise ni gras, donc le gras se fait au contour — de la même couleur
# que les lettres, ce qui les épaissit sans les cerner. Un vert franc mais foncé:
# sur un centre pâle, un vert clair se lirait mal.
const HIGHLIGHT_COLOR := Color(0.07, 0.44, 0.18)
const HIGHLIGHT_WEIGHT := 12
const NAME_ON_WORLD := Color(0.99, 0.97, 0.92)
# L'espace entre deux rangées de la bulle: le nom ne doit pas toucher le texte,
# ni le texte la jauge.
const BUBBLE_ROW_GAP := 2.5

# --- La bulle -----------------------------------------------------------------
#
# Une bulle de bande dessinée: **ovale**, centre pâle, contour foncé et gras, et
# un appendice qui désigne celui qui parle. Aucune primitive ne sait faire ça,
# donc elle est **peinte** dans une texture, à la demande et une seule fois par
# taille — il n'y en a qu'une poignée, une par nombre de lignes.
const BUBBLE_FILL := Color(0.96, 0.94, 0.87)
const BUBBLE_EDGE := Color(0.08, 0.05, 0.11)
const BUBBLE_TEXT := Color(0.11, 0.08, 0.15)
const BUBBLE_PADDING := 5.0
const BUBBLE_MIN_WIDTH := 40.0
const BUBBLE_BORDER := 1.8
const BUBBLE_TAIL_WIDTH := 10.0
const BUBBLE_TAIL_HEIGHT := 8.0
# Un ovale ne contient pas le rectangle qui l'inscrit: il faut l'élargir de la
# diagonale pour que le texte ne morde jamais le contour. `sqrt(2)` est la
# valeur exacte, un cheveu au-dessus pour l'épaisseur du trait.
const BUBBLE_OVAL_MARGIN := 1.46
# Échantillonnage de la texture: quatre pixels par unité de monde suffisent à
# garder le contour net sans peindre une image inutilement grande.
const BUBBLE_PIXELS_PER_UNIT := 4.0

static var _bubble_cache := {}

var _name: Label3D
var _label: Label3D
var _highlight: Label3D
var _panel: Sprite3D
var _track: MeshInstance3D
var _track_mesh: QuadMesh
var _fill: MeshInstance3D
var _fill_mesh: QuadMesh

func _ready() -> void:
	_panel = Sprite3D.new()
	_panel.name = "BubblePanel"
	_panel.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	_panel.shaded = false
	_panel.no_depth_test = true
	_panel.render_priority = -1
	_panel.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR
	_panel.pixel_size = 1.0 / BUBBLE_PIXELS_PER_UNIT
	add_child(_panel)

	_label = Label3D.new()
	_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	_label.no_depth_test = true
	_label.fixed_size = false
	_label.font_size = FONT_SIZE
	_label.pixel_size = TEXT_WORLD_HEIGHT / float(FONT_SIZE)
	# `line_spacing` se compte dans l'échelle de la police, pas du monde.
	_label.line_spacing = TEXT_LINE_GAP / (TEXT_WORLD_HEIGHT / float(FONT_SIZE))
	_label.outline_size = 0
	_label.modulate = BUBBLE_TEXT
	add_child(_label)

	# Le nom flotte au-dessus de la bulle, donc au-dessus du décor: il lui faut
	# un texte clair **et** un contour foncé, sinon il disparaît sur un sol pâle.
	# Le contour se règle en points de police, pas en unités du monde: à cette
	# taille de texte, l'ancienne valeur noyait les lettres.
	_name = Label3D.new()
	_name.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	_name.no_depth_test = true
	_name.font_size = FONT_SIZE
	_name.pixel_size = NAME_WORLD_HEIGHT / float(FONT_SIZE)
	_name.outline_size = 22
	_name.modulate = NAME_ON_WORLD
	_name.outline_modulate = Color(0.05, 0.03, 0.08, 0.95)
	add_child(_name)

	_highlight = Label3D.new()
	_highlight.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	_highlight.no_depth_test = true
	_highlight.font_size = FONT_SIZE
	_highlight.pixel_size = TEXT_WORLD_HEIGHT / float(FONT_SIZE)
	_highlight.outline_size = HIGHLIGHT_WEIGHT
	_highlight.modulate = HIGHLIGHT_COLOR
	_highlight.outline_modulate = HIGHLIGHT_COLOR
	_highlight.visible = false
	add_child(_highlight)

	_track_mesh = QuadMesh.new()
	_track_mesh.size = Vector2(BAR_WIDTH, BAR_HEIGHT)
	_track = _bar(_track_mesh, Color(0.20, 0.16, 0.24))
	add_child(_track)
	_fill_mesh = QuadMesh.new()
	_fill_mesh.size = Vector2(BAR_WIDTH, BAR_HEIGHT)
	_fill = _bar(_fill_mesh, Color("4ade80"))
	add_child(_fill)
	hide_all()

func _bar(mesh: QuadMesh, tint: Color) -> MeshInstance3D:
	var node := MeshInstance3D.new()
	node.mesh = mesh
	var material := StandardMaterial3D.new()
	material.albedo_color = tint
	material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
	material.no_depth_test = true
	node.material_override = material
	return node

# Faire passer le badge devant le voile de l'annonce, ou le laisser derrière.
# Rien ne teste la profondeur ici: c'est la priorité de rendu qui décide de
# l'ordre, et un badge ordinaire doit s'assombrir comme le reste du décor.
func set_above_veil(above: bool) -> void:
	var lift := Camera3DRig.VEIL_PRIORITY + 2 if above else 0
	_panel.render_priority = lift - 1
	_label.render_priority = lift
	_highlight.render_priority = lift
	_name.render_priority = lift

func hide_all() -> void:
	_label.visible = false
	_highlight.visible = false
	_panel.visible = false
	_track.visible = false
	_fill.visible = false

# Ce que le badge montre réellement, pour qui veut le vérifier. Lire `visible`
# sur le badge ne dirait rien: le noeud reste affiché, ce sont ses parties qui
# s'allument.
func shows_bar() -> bool:
	return _fill != null and _fill.visible

func shows_customer_bubble() -> bool:
	return _panel != null and _panel.visible

# La bulle et son texte, mesurés en unités du monde. Le contrôle 3D s'en sert
# pour vérifier qu'une annonce se lit bien plus gros qu'une réplique.
func bubble_world_size() -> Vector2:
	if _panel == null or _panel.texture == null:
		return Vector2.ZERO
	return Vector2(_panel.texture.get_size()) * _panel.pixel_size

func text_world_height() -> float:
	return 0.0 if _label == null else _label.pixel_size * float(FONT_SIZE)

# La vedette telle qu'elle est réellement affichée, pour qui veut la vérifier.
func highlight_text() -> String:
	return _highlight.text if _highlight != null and _highlight.visible else ""

func highlight_color() -> Color:
	return Color.TRANSPARENT if _highlight == null else _highlight.modulate

func displayed_text() -> String:
	return _label.text if _label != null and _label.visible else ""

# Le nom vit sa vie: il reste affiché même quand l'entité n'a rien à dire.
func show_name(text: String, elevation: float) -> void:
	position.y = elevation
	_name.visible = not text.is_empty()
	_name.text = text
	_name.position = Vector3(0.0, NAME_WORLD_HEIGHT, 0.0)

# `text` peut être vide, `ratio` négatif: chaque moitié se montre pour elle-même.
func show_info(text: String, ratio: float, tint: Color, elevation: float) -> void:
	position.y = elevation
	# Le texte est partagé avec la bulle, qui sait le grossir: on le rend ici à sa
	# taille ordinaire, sinon une annonce laisserait énormes toutes les suivantes.
	_set_text_height(TEXT_WORLD_HEIGHT)
	_highlight.visible = false
	_panel.visible = false
	_label.visible = not text.is_empty()
	_label.text = text
	_track.visible = ratio >= 0.0
	_fill.visible = ratio >= 0.0
	_label.position = Vector3(0.0, TEXT_WORLD_HEIGHT if ratio >= 0.0 else 0.0, 0.0)
	_name.position.y = (TEXT_WORLD_HEIGHT * 2.0 if not text.is_empty() else 0.0) + NAME_WORLD_HEIGHT
	_name.modulate = NAME_ON_WORLD
	_name.outline_size = 22
	if ratio < 0.0:
		return
	_set_bar_ratio(ratio, tint)

# La barre se remplit **depuis la gauche**. Le remplissage et son fond restent au
# même endroit et ne bougent jamais: c'est la **géométrie** du quad qui se décale
# via `center_offset`. Déplacer le noeud, comme avant, dissociait les deux — un
# matériau en panneau publicitaire pivote autour de l'origine de son noeud, donc
# deux origines différentes tournaient l'une par rapport à l'autre.
func _set_bar_ratio(ratio: float, tint: Color) -> void:
	var clamped := clampf(ratio, 0.0, 1.0)
	var filled := maxf(0.5, BAR_WIDTH * clamped)
	_fill_mesh.size = Vector2(filled, BAR_HEIGHT)
	_fill_mesh.center_offset = Vector3(-BAR_WIDTH * 0.5 + filled * 0.5, 0.0, 0.02)
	var material: StandardMaterial3D = _fill.material_override
	material.albedo_color = tint

# Commande, parole et patience partagent une seule bulle et une seule verticale.
# Le texte spontané reste en haut, la commande en dessous et la jauge ferme la
# bulle: aucune des trois informations ne peut dériver indépendamment.
# `emphasis` grossit la bulle **et** son texte d'un même facteur. Une annonce
# passe une seule fois et porte une information à retenir: elle mérite d'être
# lue de plus loin qu'une réplique de passage. Tout en est dérivé, donc la bulle
# garde exactement ses proportions.
func show_customer_info(speech: String, order: String, ratio: float,
		tint: Color, elevation: float, emphasis: float = 1.0,
		highlight: String = "") -> void:
	var text_unit := TEXT_WORLD_HEIGHT * emphasis
	var line_gap := TEXT_LINE_GAP * emphasis
	var padding := BUBBLE_PADDING * emphasis
	var row_gap := BUBBLE_ROW_GAP * emphasis
	var lines: Array[String] = []
	if not speech.strip_edges().is_empty():
		lines.append_array(_wrap_customer_text(speech.strip_edges()))
	if not order.strip_edges().is_empty():
		lines.append(order.strip_edges())
	var combined := "\n".join(lines)
	show_info(combined, ratio, tint, elevation)
	_set_text_height(text_unit)
	_highlight.text = highlight
	_highlight.visible = not highlight.strip_edges().is_empty()
	_highlight.pixel_size = text_unit / float(FONT_SIZE)
	if combined.is_empty() and ratio < 0.0:
		return
	_panel.visible = true
	# Le nom est une **rangée de la bulle**, pas une étiquette posée au-dessus.
	# Laissé dehors, il chevauchait le contour dès que la bulle grandissait, et
	# rien dans sa taille ne tenait compte de lui.
	var line_count := maxi(1, lines.size())
	var bar_space := BAR_HEIGHT + row_gap if ratio >= 0.0 else 0.0
	var highlight_space := text_unit + row_gap if _highlight.visible else 0.0
	var text_height := float(line_count) * text_unit \
		+ float(maxi(0, line_count - 1)) * line_gap
	var name_space := NAME_WORLD_HEIGHT + row_gap if _name.visible else 0.0
	# La bulle épouse son contenu: la rangée la plus large décide de sa largeur,
	# leur empilement de sa hauteur. Une largeur fixe obligeait un « Oui ? » à
	# occuper la place d'une phrase.
	var content_width := maxf(BAR_WIDTH if ratio >= 0.0 else 0.0, _widest_line(lines, text_unit))
	if _highlight.visible:
		content_width = maxf(content_width, _text_width(_highlight.text, text_unit))
	if _name.visible:
		content_width = maxf(content_width, _text_width(_name.text, NAME_WORLD_HEIGHT))
	var content_height := bar_space + highlight_space + text_height + name_space
	var width := maxf(BUBBLE_MIN_WIDTH * emphasis,
		(content_width + padding * 2.0) * BUBBLE_OVAL_MARGIN)
	var height := (content_height + padding * 2.0) * BUBBLE_OVAL_MARGIN
	# L'appendice grandit avec le reste: laissé à sa taille, il rétrécissait à vue
	# d'oeil sur une bulle grossie et la bulle n'était plus tout à fait la même.
	var tail_tall := BUBBLE_TAIL_HEIGHT * emphasis
	# La bulle est **peinte à taille normale, puis agrandie**. Une image 2,25 fois
	# plus large fait cinq fois plus de pixels, et ce sont des pixels posés un par
	# un en GDScript: la frame tressautait au moment précis où l'on veut que le
	# joueur regarde. Agrandir le panneau ne coûte rien et fait tomber toutes les
	# emphases sur les mêmes entrées de cache.
	_panel.pixel_size = emphasis / BUBBLE_PIXELS_PER_UNIT
	_panel.texture = _bubble_texture(width / emphasis, height / emphasis,
		BUBBLE_TAIL_HEIGHT, BUBBLE_TAIL_WIDTH)
	# Les rangées s'empilent depuis le bas: jauge, texte, nom. Le tout est ensuite
	# centré dans l'ovale, et l'image descend de la hauteur de l'appendice pour
	# que le corps de la bulle reste à sa place.
	var middle := content_height * 0.5
	_panel.position = Vector3(0.0, middle - tail_tall * 0.5, -0.5)
	_track.position.y = BAR_HEIGHT * 0.5
	_fill.position.y = BAR_HEIGHT * 0.5
	# La vedette se lit **sous** la phrase, comme dans la maquette: les rangées
	# s'empilent depuis le bas, donc elle passe avant le texte.
	_highlight.position = Vector3(0.0, bar_space + text_unit * 0.5, 1.0)
	_label.position = Vector3(0.0, bar_space + highlight_space + text_height * 0.5, 1.0)
	_name.position = Vector3(0.0,
		bar_space + highlight_space + text_height + row_gap + NAME_WORLD_HEIGHT * 0.5, 1.0)
	_name.modulate = NAME_ON_BUBBLE
	_name.outline_size = 0

# La hauteur du texte, en unités du monde. `line_spacing` se compte dans
# l'échelle de la police: l'air entre les lignes grandit du même facteur que les
# lettres, donc son nombre ne change pas avec l'emphase.
func _set_text_height(world_height: float) -> void:
	_label.pixel_size = world_height / float(FONT_SIZE)

# La plus longue ligne, mesurée dans la police que `Label3D` utilise réellement.
func _widest_line(lines: Array[String], world_height: float) -> float:
	var widest := 0.0
	for line: String in lines:
		widest = maxf(widest, _text_width(line, world_height))
	return widest

func _text_width(text: String, world_height: float) -> float:
	var measured := ThemeDB.fallback_font.get_string_size(
		text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, FONT_SIZE).x
	return measured * (world_height / float(FONT_SIZE))

# Une bulle de bande dessinée, peinte une fois par taille. Le cache est partagé
# par tous les badges: les tailles se comptent sur les doigts d'une main, et
# repeindre à chaque image une image qui ne change pas serait du gaspillage.
static func _bubble_texture(width: float, height: float,
		tail_tall: float, tail_wide: float) -> ImageTexture:
	var pixels_wide := maxi(8, int(round(width * BUBBLE_PIXELS_PER_UNIT)))
	var body_high := maxi(8, int(round(height * BUBBLE_PIXELS_PER_UNIT)))
	var tail_high := maxi(2, int(round(tail_tall * BUBBLE_PIXELS_PER_UNIT)))
	var pixels_high := body_high + tail_high
	# L'appendice fait partie de la clé: deux bulles de même image totale mais
	# d'appendice différent ne sont pas la même image.
	var key := "%dx%d+%d" % [pixels_wide, pixels_high, int(round(tail_wide))]
	if _bubble_cache.has(key):
		return _bubble_cache[key]

	var image := Image.create(pixels_wide, pixels_high, false, Image.FORMAT_RGBA8)
	image.fill(Color(0.0, 0.0, 0.0, 0.0))
	var border := BUBBLE_BORDER * BUBBLE_PIXELS_PER_UNIT
	var tail_half := tail_wide * BUBBLE_PIXELS_PER_UNIT * 0.5
	for y: int in range(pixels_high):
		for x: int in range(pixels_wide):
			# `depth` est la distance au bord de la silhouette: positive dedans,
			# négative dehors. Le contour est simplement sa première tranche.
			var depth := _bubble_depth(float(x) + 0.5, float(y) + 0.5,
				float(pixels_wide), float(body_high), float(tail_high), tail_half)
			if depth <= 0.0:
				continue
			image.set_pixel(x, y, BUBBLE_FILL if depth > border else BUBBLE_EDGE)

	var texture := ImageTexture.create_from_image(image)
	_bubble_cache[key] = texture
	return texture

# Profondeur d'un point dans la silhouette. L'ovale et l'appendice sont deux
# formes dont on garde la plus généreuse, ce qui les soude sans couture.
static func _bubble_depth(x: float, y: float, wide: float, body_high: float,
		tail_high: float, tail_half: float) -> float:
	var radius := Vector2(wide, body_high) * 0.5
	var offset := Vector2(x - radius.x, y - radius.y)
	# Ovale: on mesure sur le cercle unité, puis on ramène la marge en pixels par
	# le plus petit rayon — c'est lui qui décide de l'épaisseur visible du trait.
	var normalised := Vector2(offset.x / maxf(1.0, radius.x), offset.y / maxf(1.0, radius.y))
	var oval := (1.0 - normalised.length()) * minf(radius.x, radius.y)
	# L'appendice descend du bas de l'ovale jusqu'au bas de l'image, en se
	# fermant en pointe. Sa normalisation suit sa hauteur réelle: la calculer sur
	# autre chose le refermait avant d'avoir atteint le bord.
	var tail_top := body_high * 0.72
	var tail := -1.0
	if y >= tail_top:
		var descent := (y - tail_top) / maxf(1.0, (body_high - tail_top) + tail_high)
		var span := tail_half * maxf(0.0, 1.0 - descent * descent)
		tail = span - absf(x - wide * 0.5)
	return maxf(oval, tail)

func _wrap_customer_text(text: String) -> Array[String]:
	var result: Array[String] = []
	for paragraph: String in text.split("\n", true):
		var current := ""
		for word: String in paragraph.split(" ", false):
			var candidate := word if current.is_empty() else "%s %s" % [current, word]
			if candidate.length() <= CUSTOMER_LINE_CHARACTERS:
				current = candidate
			else:
				if not current.is_empty():
					result.append(current)
				current = word
		if not current.is_empty():
			result.append(current)
	if result.is_empty() and not text.is_empty():
		result.append(text)
	return result
