class_name RecipeMenu
extends Control

# Deux usages, un seul panneau. Le four demande « quel plat », la console
# demande « comment tourne le magasin ». Ce qu'ils partagent — la liste, la
# navigation clavier, le focus — n'est pas trivial; ce qui les sépare tient
# dans le mode.
#
# Le panneau ne décide de rien: il émet, le restaurant applique. C'est ce qui
# lui permet de porter l'interrupteur d'ouverture et l'embauche sans rien
# savoir ni du magasin ni de la brigade.

signal recipe_selected(recipe_id: String)
signal menu_closed
signal open_toggle_requested
signal takeout_toggle_requested
signal build_mode_requested
signal hire_requested(employee_id: String)
signal employee_work_requested(employee_id: String)
signal employee_standby_requested(employee_id: String)
signal takeout_recipe_requested(recipe_id: String)
signal recipe_ban_requested(recipe_id: String)
signal weapon_purchase_requested(weapon_id: String)
signal weapon_equip_requested(weapon_id: String, slot: int)
signal equipment_purchase_requested(equipment_id: String)
signal equipment_equip_requested(equipment_id: String, slot: int)
signal recipe_consumable_prepare_requested(recipe_id: String)
signal consumable_equip_requested(consumable_id: String, slot: int)

## Trois usages d'un même composant (réorganisation du 12/10/2026, ligne 25 du
## suivi) : le four (COOK), le menu de gestion du resto (CONSOLE, au resto
## seulement) et le menu joueur (PLAYER, partout, plongée comprise ; le HUD
## en porte un exemplaire). Même fenêtre, même barre d'onglets, mêmes gestes.
enum Mode { COOK, CONSOLE, PLAYER }
# Les actions générales vivent dans le bandeau permanent, jamais dans un onglet.
# HERO est ajoute EN FIN: les numeros des autres sections ne bougent pas.
## ARSENAL est l'Armurerie de la gestion (acheter, cuisiner les plats de
## combat) ; EQUIPMENT, l'équipement du menu joueur (équiper, partout) ; BAG,
## les ingrédients du menu joueur (sac de plongée, réserve, total).
enum Section { MENU, RECIPES, BRIGADE, TAKEOUT, INGREDIENTS, ARSENAL, HERO, ITEMS, QUESTS, EQUIPMENT, BAG }

const SUFFICIENT_COLOR := Color("6ee7a0")
const MISSING_COLOR := Color("ff7b7b")
const DIMMED := Color(0.62, 0.6, 0.66, 1.0)
const SECTION_COLOR := Color("ffd166")
const HeroStats := preload("res://scripts/core/hero_stats.gd")
const QuestProgress := preload("res://scripts/core/quest_progress.gd")
const ScreenFit := preload("res://scripts/core/screen_fit.gd")
const EmployeeContract := preload("res://scripts/core/employee_contract.gd")

## Une ligne de statistique, et son calcul au survol. Une infobulle maison
## plutot qu'un texte brut: le calcul se lit en colonnes — la source a gauche,
## ce qu'elle fait a droite — et le total ressort.
class StatRow:
	extends HBoxContainer
	var title := ""
	var lines: Array[Dictionary] = []

	func _make_custom_tooltip(_for_text: String) -> Object:
		var frame := PanelContainer.new()
		var style := StyleBoxFlat.new()
		style.bg_color = Color(0.08, 0.06, 0.11, 0.96)
		style.border_color = Color("574769")
		style.set_border_width_all(1)
		style.set_corner_radius_all(6)
		style.content_margin_left = 12.0
		style.content_margin_right = 12.0
		style.content_margin_top = 8.0
		style.content_margin_bottom = 10.0
		frame.add_theme_stylebox_override("panel", style)
		var column := VBoxContainer.new()
		column.add_theme_constant_override("separation", 3)
		frame.add_child(column)
		var heading := Label.new()
		heading.text = title
		heading.add_theme_font_size_override("font_size", 15)
		heading.add_theme_color_override("font_color", Color("ffd166"))
		column.add_child(heading)
		var grid := _grid()
		column.add_child(grid)
		for entry: Dictionary in lines:
			var total := str(entry.get("kind", "")) == "total"
			if total:
				column.add_child(HSeparator.new())
				grid = _grid()
				column.add_child(grid)
			var label := Label.new()
			label.text = str(entry.get("label", ""))
			label.add_theme_font_size_override("font_size", 13)
			label.add_theme_color_override("font_color", Color.WHITE if total else Color("c9c2d6"))
			label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			grid.add_child(label)
			var detail := Label.new()
			detail.text = str(entry.get("detail", ""))
			detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
			detail.add_theme_font_size_override("font_size", 14 if total else 13)
			detail.add_theme_color_override("font_color", Color("ffd166") if total else Color("ece7f3"))
			grid.add_child(detail)
		var formula := Label.new()
		formula.text = Loc.ui("ui.stats.formula")
		formula.add_theme_font_size_override("font_size", 11)
		formula.add_theme_color_override("font_color", Color("857c92"))
		column.add_child(formula)
		return frame

	func _grid() -> GridContainer:
		var grid := GridContainer.new()
		grid.columns = 2
		grid.add_theme_constant_override("h_separation", 24)
		grid.add_theme_constant_override("v_separation", 2)
		return grid

# Ce qu'une ligne désigne. La liste porte trois natures depuis que la console
# existe, et le clavier doit savoir laquelle il a sous le curseur.
const ROW_RECIPE := "recipe"
const ROW_EMPLOYEE := "employee"
const ROW_ROLE := "role"
const ROW_TAKEOUT_RULE := "takeout_rule"
const ROW_INGREDIENT := "ingredient"
const ROW_WEAPON := "weapon"
const ROW_EQUIPMENT := "equipment"
const ROW_CONSUMABLE := "consumable"
const ROW_MENU_OPTION := "menu_option"
const ROW_INGREDIENT_SALE := "ingredient_sale"

@onready var list: VBoxContainer = %RecipeList
@onready var scroll: ScrollContainer = %RecipeScroll
@onready var close_button: Button = %CloseButton
@onready var dining_toggle: OpenCloseButton = %DiningToggle
@onready var takeout_toggle: OpenCloseButton = %TakeoutToggle
@onready var build_button: Button = %BuildButton
@onready var console_status: Label = %ConsoleStatus
@onready var menu_count_label: Label = %MenuCount
@onready var title_label: Label = %Title
@onready var hint_label: Label = %Hint
@onready var menu_column_label: Label = %ColMenu
@onready var column_header: HBoxContainer = %Header
@onready var icon_slot: CenterContainer = %IconSlot
@onready var tab_row: HBoxContainer = %TabRow
@onready var panel: PanelContainer = $Panel
@onready var backdrop: ColorRect = $Backdrop

var mode: int = Mode.COOK
# Renseignés par le restaurant avant l'ouverture et après chaque bascule. Le
# panneau n'a aucun moyen — ni aucune raison — d'aller les chercher lui-même.
var shop_closed := false
# Le comptoir a son propre rideau, et il n'existe qu'une fois construit: les
# deux drapeaux sont séparés pour que la console ne les confonde jamais.
var takeout_closed := false
var takeout_installed := false
# Le permis de chantier: sans lui, la console n'offre pas le mode construction.
var build_permit := false
var served_per_minute := 0.0

# Un bouton par ligne: c'est lui qui prend le focus. Les tableaux parallèles
# disent ce que la ligne désigne, parce qu'une même liste porte maintenant des
# recettes, des employés et l'interrupteur.
var row_buttons: Array[Button] = []
var row_ids: Array[String] = []
var row_kinds: Array[String] = []
## Le bloc auquel appartient une ligne, quand il déborde d'elle : une quête en
## cours et ses étapes. La navigation au clavier le montre en entier.
var row_blocks: Dictionary = {}
var ingredient_amount_labels: Dictionary = {}
# La barre d'onglets vit à part de la liste: elle est horizontale, et la liste
# ne l'est pas. Les côtés y changent d'onglet, le haut et le bas en sortent.
var tab_buttons: Array[Button] = []
var tab_sections: Array[int] = []
var section: int = Section.MENU
var icon: ConsoleIcon
## Ce qu'il reste à cuire pour l'ardoise, plat par plat ({recette: nombre}).
## Le restaurant le fournit; sans lui, le four garde l'ordre de la carte.
var order_shortfalls := Callable()
## Le manque lu au dernier affichage. Le four le relit pendant qu'il est ouvert:
## une commande prise ou un plat sorti réordonne la liste sous les yeux du joueur.
var order_demand: Dictionary = {}
const ORDERED_COLOR := Color("ffd166")
const DEMAND_POLL := 0.2
var _demand_poll := 0.0

var _was_paused := false

func _ready() -> void:
	# Il vit pendant l'arrêt qu'il impose.
	process_mode = Node.PROCESS_MODE_ALWAYS
	_fit_to_screen()
	get_viewport().size_changed.connect(_fit_to_screen)
	close_button.pressed.connect(close)
	dining_toggle.pressed.connect(_press_switch)
	takeout_toggle.pressed.connect(_press_takeout_switch)
	build_button.pressed.connect(_press_build_mode)
	for button: Button in [dining_toggle, takeout_toggle, build_button, close_button]:
		button.mouse_entered.connect(button.grab_focus)
	GameState.state_changed.connect(_on_state_changed)
	build_list()

func _fit_to_screen() -> void:
	ScreenFit.fit(self)

## Les menus à onglets : la gestion et le menu joueur.
func is_tabbed() -> bool:
	return mode == Mode.CONSOLE or mode == Mode.PLAYER

func open(new_mode: int = Mode.COOK, new_section: int = Section.MENU) -> void:
	# Le four et la console arrêtent le temps (règles du mapping, étape 3) : le
	# resto attend qu'on ait choisi. L'état d'avant revient à la fermeture.
	if not visible:
		_was_paused = get_tree().paused
		get_tree().paused = true
	mode = new_mode
	_demand_poll = DEMAND_POLL
	section = new_section if _available_sections().has(new_section) else _available_sections()[0]
	_apply_layout()
	visible = true
	Stage.push_interface(self)
	build_list()
	call_deferred("_focus_first_available")

func close() -> void:
	if not visible:
		return
	visible = false
	Stage.pop_interface(self)
	get_tree().paused = _was_paused
	menu_closed.emit()

## Le panneau est ancre au centre: ses bords se comptent depuis le milieu de
## l'ecran, pour rester centre quand l'ecran n'est pas en 16:9.
func _apply_layout() -> void:
	var compact := mode == Mode.COOK
	var half := Vector2(320.0, 230.0) if compact else Vector2(386.0, 306.0)
	panel.offset_left = -half.x
	panel.offset_top = -half.y
	panel.offset_right = half.x
	panel.offset_bottom = half.y
	scroll.custom_minimum_size.y = 210.0 if compact else 260.0
	backdrop.color.a = 0.28 if compact else 0.72

func _on_state_changed() -> void:
	if visible:
		build_list()

# Une assiette qui sort du four ou une commande qu'un employé note ne passent
# pas toutes par `state_changed`: le four compare donc le manque de l'ardoise à
# intervalle court, et ne se reconstruit que s'il a bougé.
func _process(delta: float) -> void:
	if not visible or mode != Mode.COOK:
		return
	_demand_poll -= delta
	if _demand_poll > 0.0:
		return
	_demand_poll = DEMAND_POLL
	if current_order_demand() != order_demand:
		build_list()

func current_order_demand() -> Dictionary:
	if not order_shortfalls.is_valid():
		return {}
	var reported: Variant = order_shortfalls.call()
	return reported if reported is Dictionary else {}

func _input(event: InputEvent) -> void:
	# Sous la pause, le menu garde la main de ce qu'il montrait mais ne lit plus
	# rien : Retour fermerait le four caché derrière elle.
	if not visible or not Stage.accepts_input(self):
		return
	if Stage.is_back(event) or event.is_action_pressed("console_toggle"):
		# La tabulation ferme comme elle a ouvert. Consommer l'événement ici est
		# indispensable: sans ça elle déplacerait aussi le focus dans la liste
		# qu'on est en train de quitter.
		close()
		get_viewport().set_input_as_handled()
		return
	# Left mouse is also mapped to action, but mouse clicks must be handled by
	# the actual hovered Button instead of confirming the currently focused one.
	if event is InputEventMouseButton:
		return
	# Q/E, L/R : l'onglet voisin de la console (étape 2).
	if event.is_action_pressed("tab_prev") or event.is_action_pressed("tab_next"):
		_cycle_section(-1 if event.is_action_pressed("tab_prev") else 1)
		get_viewport().set_input_as_handled()
		return
	if event.is_action_pressed("move_up") or event.is_action_pressed("ui_up"):
		_navigate(-1)
		get_viewport().set_input_as_handled()
	elif event.is_action_pressed("move_down") or event.is_action_pressed("ui_down"):
		_navigate(1)
		get_viewport().set_input_as_handled()
	elif event.is_action_pressed("move_left") or event.is_action_pressed("ui_left"):
		_horizontal(-1)
		get_viewport().set_input_as_handled()
	elif event.is_action_pressed("move_right") or event.is_action_pressed("ui_right"):
		_horizontal(1)
		get_viewport().set_input_as_handled()
	elif event.is_action_pressed("action") or event.is_action_pressed("ui_accept"):
		_confirm_focused()
		get_viewport().set_input_as_handled()

func build_list() -> void:
	if list == null:
		return
	if is_tabbed() and not _available_sections().has(section):
		section = _available_sections()[0]
	var focused_index := _focused_index()
	# Le focus suit la ligne, pas son rang: au four, une commande qui arrive
	# réordonne la liste, et la touche Action ne doit pas lancer un autre plat
	# que celui que le joueur regardait.
	var focused_key := ""
	if focused_index >= 0 and focused_index < row_ids.size():
		focused_key = row_kinds[focused_index] + ":" + row_ids[focused_index]
	order_demand = current_order_demand() if mode == Mode.COOK else {}
	# L'ajout automatique avant de construire : s'il ajoute un plat, l'état
	# change et une reconstruction complète passe avant celle-ci, au lieu de
	# survenir au milieu (12/10/2026).
	GameState.auto_fill_menu()
	row_buttons.clear()
	row_ids.clear()
	row_kinds.clear()
	row_blocks.clear()
	ingredient_amount_labels.clear()
	for child: Node in list.get_children():
		list.remove_child(child)
		child.queue_free()
	if is_tabbed():
		match section:
			Section.MENU:
				_build_menu_tab()
			Section.RECIPES:
				for id: String in listed_recipes():
					list.add_child(_build_recipe_row(id))
				if list.get_child_count() == 0:
					list.add_child(_section_label(Loc.ui("ui.console.recipes_empty"), true))
			Section.BRIGADE:
				_build_brigade()
			Section.TAKEOUT:
				_build_takeout()
			Section.INGREDIENTS:
				_build_ingredients()
			Section.ARSENAL:
				_build_arsenal()
			Section.HERO:
				_build_hero_stats()
			Section.ITEMS:
				_build_key_items()
			Section.QUESTS:
				_build_quests()
			Section.EQUIPMENT:
				_build_equipment_tab()
			Section.BAG:
				_build_bag()
			_:
				section = _available_sections()[0]
	else:
		for id: String in listed_recipes():
			list.add_child(_build_recipe_row(id))
		# Une carte vidée n'est pas une impasse: la console reste là pour la
		# regarnir, et le four le dit au lieu d'afficher une liste vide.
		if row_buttons.is_empty():
			list.add_child(_section_label(Loc.ui("ui.menu.empty_cook"), true))
	_refresh_chrome()
	if not focused_key.is_empty():
		for index: int in range(row_ids.size()):
			if row_kinds[index] + ":" + row_ids[index] == focused_key:
				focused_index = index
				break
	if visible:
		call_deferred("_restore_focus", focused_index)

# Le four ne propose que la carte du jour: cuisiner un plat qu'on n'offre à
# personne n'a plus de sens. La console ne révèle que les recettes débloquées;
# une découverte doit précéder toute décision de les mettre à la carte.
func listed_recipes() -> Array[String]:
	if mode == Mode.COOK:
		# Le four ne propose que ce qui est réellement cuisinable, et met en
		# tête ce que l'ardoise attend encore.
		return ordered_first(GameState.offered_recipes(), order_demand)
	match section:
		Section.MENU:
			return menu_tab_order()
		Section.RECIPES:
			return GameState.unlocked_recipes()
		Section.TAKEOUT:
			return GameState.unlocked_recipes()
		_:
			return GameState.offered_recipes()

## L'ordre de l'onglet Carte (12/10/2026) : les plats choisis dans l'ordre du
## choix, puis les cuisinables du plus cher au moins cher, puis les autres.
static func menu_tab_order() -> Array[String]:
	var ordered: Array[String] = []
	var cookable: Array[String] = []
	var rest: Array[String] = []
	for id: String in GameState.menu_recipes:
		if GameState.recipe_is_unlocked(id):
			ordered.append(id)
	for id: String in GameState.unlocked_recipes():
		if ordered.has(id):
			continue
		if GameState.can_cook(id):
			cookable.append(id)
		else:
			rest.append(id)
	var by_price := func(a: String, b: String) -> bool:
		var price_a := int(GameState.recipe_for_menu(a).get("sale_price", 0))
		var price_b := int(GameState.recipe_for_menu(b).get("sale_price", 0))
		return price_a > price_b if price_a != price_b else a < b
	cookable.sort_custom(by_price)
	rest.sort_custom(by_price)
	ordered.append_array(cookable)
	ordered.append_array(rest)
	return ordered

## Les plats commandés d'abord, le plus demandé en tête; les autres gardent
## l'ordre de la carte. Pure: se teste sans four.
static func ordered_first(ids: Array[String], demand: Dictionary) -> Array[String]:
	if demand.is_empty():
		return ids
	var ordered: Array[String] = []
	var rest: Array[String] = []
	for id: String in ids:
		if int(demand.get(id, 0)) > 0:
			ordered.append(id)
		else:
			rest.append(id)
	ordered.sort_custom(func(a: String, b: String) -> bool:
		var wanted_a := int(demand[a])
		var wanted_b := int(demand[b])
		return wanted_a > wanted_b if wanted_a != wanted_b else ids.find(a) < ids.find(b))
	ordered.append_array(rest)
	return ordered

const SECTION_ICONS := {
	Section.MENU: ConsoleIcon.Kind.MENU,
	Section.RECIPES: ConsoleIcon.Kind.MENU,
	Section.BRIGADE: ConsoleIcon.Kind.BRIGADE,
	Section.TAKEOUT: ConsoleIcon.Kind.TAKEOUT,
	Section.INGREDIENTS: ConsoleIcon.Kind.INGREDIENTS,
	Section.ARSENAL: ConsoleIcon.Kind.ARSENAL,
	Section.HERO: ConsoleIcon.Kind.HERO,
	Section.ITEMS: ConsoleIcon.Kind.ITEMS,
	Section.QUESTS: ConsoleIcon.Kind.QUESTS,
	Section.EQUIPMENT: ConsoleIcon.Kind.ARSENAL,
	Section.BAG: ConsoleIcon.Kind.INGREDIENTS,
}
const SECTION_KEYS := {
	Section.MENU: "ui.console.tab_menu",
	Section.RECIPES: "ui.console.tab_recipes",
	Section.BRIGADE: "ui.console.tab_brigade",
	Section.TAKEOUT: "ui.console.tab_takeout",
	Section.INGREDIENTS: "ui.console.tab_ingredients",
	Section.ARSENAL: "ui.console.tab_arsenal",
	Section.HERO: "ui.console.tab_hero",
	Section.ITEMS: "ui.console.tab_items",
	Section.QUESTS: "ui.console.tab_quests",
	Section.EQUIPMENT: "ui.console.tab_equipment",
	Section.BAG: "ui.console.tab_bag",
}
const SECTION_TITLES := {
	Section.MENU: "ui.console.section_menu",
	Section.RECIPES: "ui.console.section_recipes",
	Section.BRIGADE: "ui.console.section_brigade",
	Section.TAKEOUT: "ui.console.section_takeout",
	Section.INGREDIENTS: "ui.console.section_ingredients",
	Section.ARSENAL: "ui.console.section_arsenal",
	Section.HERO: "ui.console.section_hero",
	Section.ITEMS: "ui.console.section_items",
	Section.QUESTS: "ui.console.section_quests",
	Section.EQUIPMENT: "ui.console.section_equipment",
	Section.BAG: "ui.console.section_bag",
}

## Les onglets d'un menu. La gestion : ce qui fait tourner le resto. Le menu
## joueur : ce qui suit le héros, en plongée comme au resto.
func _available_sections() -> Array[int]:
	var available: Array[int] = []
	if mode == Mode.PLAYER:
		available.append_array([Section.HERO, Section.EQUIPMENT, Section.BAG])
		# Les objets de quête n'ont un onglet qu'une fois le premier en poche: un
		# sac vide n'apprend rien, et il ne faut pas annoncer ce qui n'existe pas.
		if not GameState.owned_key_items().is_empty():
			available.append(Section.ITEMS)
		# Le journal paraît avec la première quête acceptée.
		if not GameState.journal_quests().is_empty():
			available.append(Section.QUESTS)
		return available
	available.append_array([Section.MENU, Section.RECIPES])
	if GameState.brigade_is_unlocked():
		available.append(Section.BRIGADE)
	available.append_array([Section.TAKEOUT, Section.INGREDIENTS, Section.ARSENAL])
	return available

func _refresh_chrome() -> void:
	var console := is_tabbed()
	if title_label != null:
		title_label.text = Loc.ui(SECTION_TITLES[section]) if console else Loc.ui("ui.menu.title_cook")
	if hint_label != null:
		hint_label.visible = not console
		hint_label.text = Loc.ui("ui.menu.hint_cook")
	# Les colonnes ne décrivent que des recettes: ailleurs, elles mentiraient.
	var recipe_rows := (console and (section == Section.MENU or section == Section.TAKEOUT)) or not console
	if menu_column_label != null:
		menu_column_label.visible = console and recipe_rows
		menu_column_label.text = Loc.ui("ui.console.takeout_select") if section == Section.TAKEOUT else Loc.ui("ui.menu.column_menu")
	if column_header != null:
		column_header.visible = recipe_rows
	if menu_count_label != null:
		menu_count_label.visible = console and section == Section.MENU
		menu_count_label.text = Loc.ui("ui.console.menu_slots") % [GameState.menu_recipes.size(), GameState.menu_slot_count()]
	if icon_slot != null:
		icon_slot.visible = console
	if tab_row != null:
		tab_row.visible = console
	_refresh_console_actions()
	if console:
		_build_tabs()
		_refresh_icon()

func _refresh_console_actions() -> void:
	var console := mode == Mode.CONSOLE
	dining_toggle.visible = console
	dining_toggle.setup(
		Loc.ui("ui.console.dining"),
		not shop_closed,
		Loc.ui("ui.console.open_word"),
		Loc.ui("ui.console.close_word")
	)
	dining_toggle.tooltip_text = Loc.ui("ui.console.switch_tooltip")
	takeout_toggle.visible = console and takeout_installed
	takeout_toggle.setup(
		Loc.ui("ui.console.tab_takeout"),
		not takeout_closed,
		Loc.ui("ui.console.open_word"),
		Loc.ui("ui.console.close_word")
	)
	takeout_toggle.tooltip_text = Loc.ui("ui.console.takeout_switch_tooltip")
	build_button.visible = console and build_permit
	build_button.text = Loc.ui("ui.console.build_mode")
	build_button.tooltip_text = Loc.ui("ui.console.build_tooltip")
	console_status.visible = console
	console_status.text = "%s  •  %s" % [
		Loc.ui("ui.console.open_state") % Loc.ui(opening_state_key()),
		Loc.ui("ui.console.rate") % served_per_minute,
	]

# La grosse icône du haut. Elle dit de quoi parle l'onglet avant qu'on lise
# quoi que ce soit — et, sur le magasin, elle dit s'il est ouvert.
func _refresh_icon() -> void:
	if icon_slot == null:
		return
	if icon == null or not is_instance_valid(icon):
		icon = ConsoleIcon.new()
		icon_slot.add_child(icon)
	# Les numeros des sections et ceux des icones ne se correspondent pas: la
	# Brigade (2) dessinait l'icone de l'emporter. On traduit explicitement.
	icon.setup(int(SECTION_ICONS.get(section, ConsoleIcon.Kind.MENU)))

func _build_tabs() -> void:
	tab_buttons.clear()
	tab_sections.clear()
	if tab_row == null:
		return
	for child: Node in tab_row.get_children():
		tab_row.remove_child(child)
		child.queue_free()
	for index: int in _available_sections():
		var button := Button.new()
		button.custom_minimum_size = Vector2(112, 42)
		button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		button.toggle_mode = true
		button.button_pressed = index == section
		button.text = Loc.ui(SECTION_KEYS[index])
		button.pressed.connect(_select_section.bind(index))
		button.mouse_entered.connect(button.grab_focus)
		tab_row.add_child(button)
		tab_buttons.append(button)
		tab_sections.append(index)

func _select_section(new_section: int) -> void:
	if section == new_section or not _available_sections().has(new_section):
		return
	section = new_section
	build_list()
	call_deferred("_focus_tab")

func _focus_tab() -> void:
	var tab_index := tab_sections.find(section)
	if tab_index >= 0 and tab_index < tab_buttons.size() and is_instance_valid(tab_buttons[tab_index]):
		tab_buttons[tab_index].grab_focus()

# Une liste n'a pas de navigation horizontale. Sur la barre d'onglets, les côtés
# changent d'onglet; sur une ligne, ils l'actionnent.
func _horizontal(step: int) -> void:
	if not is_tabbed():
		return
	var focused := get_viewport().gui_get_focus_owner() as Button
	var actions := _top_action_buttons()
	if focused != null and actions.has(focused):
		var current_action := actions.find(focused)
		_focus_button(actions[wrapi(current_action + step, 0, actions.size())])
		return
	if focused != null and tab_buttons.has(focused):
		var current_tab := tab_sections.find(section)
		_select_section(tab_sections[wrapi(current_tab + step, 0, tab_sections.size())])
		return
	_confirm_focused()

func _cycle_section(step: int) -> void:
	if not is_tabbed() or tab_sections.is_empty():
		return
	var current := tab_sections.find(section)
	_select_section(tab_sections[wrapi(current + step, 0, tab_sections.size()) if current >= 0 else 0])

func _register_row(button: Button, id: String, kind: String) -> void:
	row_buttons.append(button)
	row_ids.append(id)
	row_kinds.append(kind)

func _section_label(text: String, quiet: bool = false) -> Control:
	var label := Label.new()
	label.text = text
	label.add_theme_font_size_override("font_size", 12 if quiet else 14)
	label.add_theme_color_override("font_color", DIMMED if quiet else SECTION_COLOR)
	return label

# Les quatre états, nommés. Un comptoir non construit ne compte pas comme fermé:
# il n'existe pas encore, et « seulement salle ouverte » serait un mensonge.
func opening_state_key() -> String:
	var counter_open := takeout_installed and not takeout_closed
	if not shop_closed and (counter_open or not takeout_installed):
		return "ui.console.state_all_open"
	if not shop_closed:
		return "ui.console.state_dining_only"
	if counter_open:
		return "ui.console.state_takeout_only"
	return "ui.console.state_all_closed"

func _press_takeout_switch() -> void:
	takeout_toggle_requested.emit()

func _press_build_mode() -> void:
	build_mode_requested.emit()

## Une ligne de plat se lit en diagonale: le nom, le rang, deux chiffres et
## les ingredients. Assez petite pour qu'une carte entiere tienne a l'ecran,
## le detail vivant dans l'infobulle plutot que dans la ligne.
const ROW_HEIGHT := 30.0
const ROW_FONT_SIZE := 13

## L'infobulle par defaut de Godot est une etiquette sur un fond a demi
## transparent: illisible des qu'une scene chargee passe derriere. Ces deux
## controles rendent leur propre panneau — opaque, avec de la marge, une
## bordure et des coins arrondis.
##
## Il en faut deux parce qu'une ligne n'est pas un bouton: la ligne porte la
## fiche partout ou la souris passe, le bouton doit rester cliquable.
class TooltipRow extends HBoxContainer:
	func _make_custom_tooltip(for_text: String) -> Object:
		return RecipeMenu.build_tooltip_panel(for_text)

class TooltipButton extends Button:
	func _make_custom_tooltip(for_text: String) -> Object:
		return RecipeMenu.build_tooltip_panel(for_text)

class TooltipCheck extends CheckBox:
	func _make_custom_tooltip(for_text: String) -> Object:
		return RecipeMenu.build_tooltip_panel(for_text)

class TooltipSwitch extends CheckButton:
	func _make_custom_tooltip(for_text: String) -> Object:
		return RecipeMenu.build_tooltip_panel(for_text)

## Le cadenas d'un ingrédient (12/10/2026) : fermé et doré quand l'ingrédient
## est mis de côté, ouvert et pâle sinon. Un bouton plat, pour rester discret.
class LockButton extends Button:
	var locked := false

	func _make_custom_tooltip(for_text: String) -> Object:
		return RecipeMenu.build_tooltip_panel(for_text)

	func _draw() -> void:
		var tint := RecipeMenu.SECTION_COLOR if locked else Color(0.62, 0.6, 0.66, 0.7)
		RecipeMenu.draw_padlock(self, size * 0.5, minf(size.x, size.y) / 22.0, locked, tint)

## Le même cadenas, en simple marque : l'ingrédient d'une recette mis de côté,
## ou la ligne du sac au menu joueur.
class PadlockIcon extends Control:
	var locked := true
	var tint := Color.WHITE

	func _init(is_locked: bool = true, color: Color = Color.WHITE, side: float = 16.0) -> void:
		locked = is_locked
		tint = color
		custom_minimum_size = Vector2(side, side)
		size_flags_vertical = Control.SIZE_SHRINK_CENTER
		mouse_filter = Control.MOUSE_FILTER_IGNORE

	func _make_custom_tooltip(for_text: String) -> Object:
		return RecipeMenu.build_tooltip_panel(for_text)

	func _draw() -> void:
		RecipeMenu.draw_padlock(self, size * 0.5, minf(size.x, size.y) / 16.0, locked, tint)

## Un cadenas d'environ 10 × 13 unités autour de `center`. Ouvert, l'anse
## monte et sa branche gauche sort du corps.
static func draw_padlock(canvas: CanvasItem, center: Vector2, unit: float, locked: bool, color: Color) -> void:
	var width := maxf(1.0, 1.6 * unit)
	var radius := 3.2 * unit
	var top := center + Vector2(0.0, -3.5 if locked else -6.0) * unit
	canvas.draw_arc(top, radius, PI, TAU, 12, color, width, true)
	var left_end := center.y + (-1.0 if locked else -4.0) * unit
	canvas.draw_line(top + Vector2(-radius, 0.0), Vector2(top.x - radius, left_end), color, width, true)
	canvas.draw_line(top + Vector2(radius, 0.0), Vector2(top.x + radius, center.y - unit), color, width, true)
	canvas.draw_rect(Rect2(center + Vector2(-5.0, -1.0) * unit, Vector2(10.0, 7.5) * unit), color)
	if locked:
		canvas.draw_circle(center + Vector2(0.0, 2.4) * unit, 1.1 * unit, Color(0.08, 0.06, 0.11, 1.0))

## Le panneau lui-meme. Une seule fabrique, pour que la fiche se presente
## pareil qu'on survole la ligne ou son bouton.
static func build_tooltip_panel(text: String) -> Control:
	var panel := PanelContainer.new()
	var box := StyleBoxFlat.new()
	# Opaque: une fiche a demi transparente se lit mal sur une salle chargee.
	box.bg_color = Color(0.07, 0.09, 0.14, 1.0)
	box.border_color = Color(0.36, 0.42, 0.55, 1.0)
	box.set_border_width_all(1)
	box.set_corner_radius_all(8)
	box.content_margin_left = 14.0
	box.content_margin_right = 14.0
	box.content_margin_top = 11.0
	box.content_margin_bottom = 11.0
	panel.add_theme_stylebox_override("panel", box)
	var label := Label.new()
	label.text = text
	label.add_theme_font_size_override("font_size", 13)
	label.add_theme_color_override("font_color", Color(0.93, 0.95, 0.98, 1.0))
	panel.add_child(label)
	return panel

## La police du bouton, grossie. La même famille que le reste de la liste:
## seul le poids change, pour que la ligne ressorte sans détonner.
func _bold_font(control: Control) -> Font:
	var variation := FontVariation.new()
	variation.base_font = control.get_theme_font("font")
	variation.variation_embolden = 0.8
	return variation

func is_row_emphasized(index: int) -> bool:
	if index < 0 or index >= row_buttons.size() or not is_instance_valid(row_buttons[index]):
		return false
	var font := row_buttons[index].get_theme_font("font")
	return font is FontVariation and (font as FontVariation).variation_embolden > 0.0

func _build_recipe_row(id: String) -> Control:
	var recipe: Dictionary = GameState.recipe_for_menu(id)
	var unlocked := GameState.recipe_is_unlocked(id)
	var cookable := GameState.can_cook(id)
	var on_menu_tab := mode == Mode.CONSOLE and section == Section.MENU
	var in_menu := GameState.is_recipe_selected(id)
	var row := TooltipRow.new()
	row.add_theme_constant_override("separation", 8)
	# La fiche detaillee se prend sur la ligne entiere: posee sur une seule
	# etiquette, elle ne se declenchait que sur quelques pixels de texte.
	row.mouse_filter = Control.MOUSE_FILTER_STOP
	row.tooltip_text = recipe_tooltip_text(id)
	var focusable: Button = null
	if mode == Mode.COOK:
		# Au four, la ligne est le bouton de cuisson.
		focusable = TooltipButton.new()
		focusable.custom_minimum_size = Vector2(200, ROW_HEIGHT)
		focusable.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
		focusable.text = DataCatalog.recipe_name(id)
		focusable.clip_text = true
		# Le survol donne la fiche complete du plat: prix, cuisson, ingredients,
		# categories et consommable, avec la valeur du jour quand le niveau l'a
		# changee. La phrase d'action reste en tete.
		var action_hint := Loc.ui("ui.menu.cook_tooltip") if cookable else (Loc.ui("ui.menu.missing_tooltip") if unlocked else Loc.ui("ui.menu.locked_tooltip"))
		var ordered := int(order_demand.get(id, 0))
		if ordered > 0:
			# Commandé et pas encore couvert: en gras, en or, avec le nombre
			# d'assiettes qui manquent. La liste l'a déjà mis en tête.
			focusable.text = Loc.ui("ui.menu.ordered_name") % [DataCatalog.recipe_name(id), ordered]
			focusable.add_theme_font_override("font", _bold_font(focusable))
			for color_name: String in ["font_color", "font_hover_color", "font_focus_color", "font_pressed_color"]:
				focusable.add_theme_color_override(color_name, ORDERED_COLOR)
			action_hint = (Loc.ui("ui.menu.ordered_tooltip") % ordered) + "\n" + action_hint
		focusable.tooltip_text = action_hint
		# A recipe that cannot be cooked is dimmed but stays browsable.
		focusable.modulate = Color.WHITE if cookable else DIMMED
		focusable.disabled = not unlocked
		focusable.pressed.connect(_select.bind(id))
		focusable.mouse_entered.connect(focusable.grab_focus)
		row.add_child(focusable)
	else:
		# À la console, on ne cuisine pas: le nom n'est qu'une étiquette. La carte
		# du jour se lit, le livre de recettes se consulte.
		var name_label := Label.new()
		name_label.custom_minimum_size = Vector2(200, ROW_HEIGHT)
		name_label.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
		name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
		name_label.clip_text = true
		name_label.text = DataCatalog.recipe_name(id)
		name_label.modulate = Color.WHITE if unlocked and not GameState.is_recipe_banned(id) else DIMMED
		if on_menu_tab:
			name_label.custom_minimum_size.x = 180.0
			_style_menu_name(name_label, cookable, in_menu)
		row.add_child(name_label)
		if on_menu_tab:
			row.add_child(_menu_badge(in_menu))

	row.add_child(_level_column(id, cookable))
	row.add_child(_column("%.1fs" % float(recipe.cook_time), 58, cookable))
	row.add_child(_column("%d c" % int(recipe.sale_price), 54, cookable))
	if on_menu_tab:
		row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	else:
		row.add_child(_ingredient_column(recipe))

	if mode == Mode.CONSOLE:
		focusable = TooltipButton.new()
		focusable.custom_minimum_size = Vector2(96, ROW_HEIGHT)
		focusable.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
		if section == Section.TAKEOUT:
			var selected := id == GameState.takeout_recipe
			focusable.text = Loc.ui("ui.console.takeout_selected") if selected else Loc.ui("ui.console.takeout_select")
			focusable.tooltip_text = Loc.ui("ui.console.takeout_recipe_tooltip")
			focusable.pressed.connect(_press_takeout_recipe.bind(id))
			focusable.disabled = not unlocked
		elif on_menu_tab:
			# La carte se choisit : les portions que le stock permet, puis
			# Ajouter ou Retirer. Carte pleine, Ajouter reste parcourable et dit
			# pourquoi il ne fait rien.
			var spacer := Control.new()
			spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			row.add_child(spacer)
			var portions := GameState.cookable_portions(id)
			row.add_child(_column(Loc.ui("ui.console.portions_left") % portions, 70, portions > 0))
			var full := not in_menu and GameState.menu_recipes.size() >= GameState.menu_slot_count()
			focusable.text = Loc.ui("ui.console.menu_remove") if in_menu else Loc.ui("ui.console.menu_add")
			if full:
				focusable.tooltip_text = Loc.ui("ui.console.menu_full") % [GameState.menu_recipes.size(), GameState.menu_slot_count()]
			else:
				focusable.tooltip_text = Loc.ui("ui.console.menu_remove_tooltip" if in_menu else "ui.console.menu_add_tooltip")
			focusable.modulate = DIMMED if full else Color.WHITE
			focusable.pressed.connect(_press_menu_recipe.bind(id))
			focusable.disabled = not unlocked
		elif section == Section.RECIPES and GameState.recipe_ban_capacity() > 0:
			# Le livre sert aussi a retirer un plat du service, une fois
			# l'amelioration achetee. Le bouton dit l'etat, l'infobulle le compte.
			var banned := GameState.is_recipe_banned(id)
			focusable.text = Loc.ui("ui.console.recipe_banned") if banned else Loc.ui("ui.console.recipe_served")
			focusable.tooltip_text = Loc.ui("ui.console.ban_tooltip") % [GameState.banned_recipe_count(), GameState.recipe_ban_capacity()]
			focusable.modulate = DIMMED if banned else Color.WHITE
			focusable.pressed.connect(_press_recipe_ban.bind(id))
			focusable.disabled = not unlocked
		else:
			# Plus rien à cocher: la carte du jour se lit. Le bouton reste, mais
			# il ne fait plus rien — il porte le seul chiffre que le gérant ne
			# peut pas deviner de tête, et il garde la ligne parcourable au
			# clavier et à la manette.
			var portions := GameState.cookable_portions(id)
			focusable.custom_minimum_size = Vector2(112, ROW_HEIGHT)
			focusable.text = Loc.ui("ui.console.portions_left") % portions
			focusable.tooltip_text = Loc.ui("ui.console.portions_tooltip")
			focusable.modulate = Color.WHITE if portions > 0 else DIMMED
		focusable.mouse_entered.connect(focusable.grab_focus)
		row.add_child(focusable)

	_apply_row_tooltip(row, recipe_tooltip_text(id))
	_register_row(focusable, id, ROW_RECIPE)
	if on_menu_tab:
		return _menu_row_frame(row, in_menu)
	return row

# --- L'onglet Carte (12/10/2026) ---------------------------------------------

## En tête, l'ajout automatique et sa règle ; puis les plats, dans l'ordre de
## `menu_tab_order()`.
func _build_menu_tab() -> void:
	list.add_child(_menu_options_row())
	if GameState.unlocked_recipes().is_empty():
		list.add_child(_section_label(Loc.ui("ui.console.recipes_empty"), true))
		return
	if GameState.offered_recipes().is_empty():
		# Une carte qui ne sert rien n'est pas une impasse : elle le dit.
		list.add_child(_section_label(Loc.ui("ui.console.menu_none_cookable"), true))
	for id: String in menu_tab_order():
		list.add_child(_build_recipe_row(id))

## L'interrupteur de l'ajout automatique, puis ses deux règles, cochables une
## seule à la fois ; aucune cochée, le hasard choisit.
func _menu_options_row() -> Control:
	var row := HBoxContainer.new()
	row.add_theme_constant_override("separation", 14)
	var auto := TooltipSwitch.new()
	auto.text = Loc.ui("ui.console.auto_add")
	auto.button_pressed = GameState.menu_auto_add
	auto.tooltip_text = Loc.ui("ui.console.auto_add_tooltip")
	auto.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
	auto.toggled.connect(func(enabled: bool) -> void: GameState.set_menu_auto_add(enabled))
	auto.mouse_entered.connect(auto.grab_focus)
	row.add_child(auto)
	_register_row(auto, "auto", ROW_MENU_OPTION)
	for rule: String in ["price", "portions"]:
		var box := TooltipCheck.new()
		box.text = Loc.ui("ui.console.auto_rule_" + rule)
		box.button_pressed = GameState.menu_auto_rule == rule
		box.disabled = not GameState.menu_auto_add
		box.tooltip_text = Loc.ui("ui.console.auto_rule_tooltip")
		box.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
		box.toggled.connect(_toggle_auto_rule.bind(rule))
		box.mouse_entered.connect(box.grab_focus)
		row.add_child(box)
		_register_row(box, rule, ROW_MENU_OPTION)
	return row

func _toggle_auto_rule(enabled: bool, rule: String) -> void:
	GameState.set_menu_auto_rule(rule if enabled else "")

## Les deux surlignages : un plat cuisinable porte le cadre des boutons du
## four ; un plat choisi s'écrit en or et en gras.
func _style_menu_name(label: Label, cookable: bool, selected: bool) -> void:
	label.modulate = Color.WHITE if cookable else DIMMED
	if cookable:
		label.add_theme_stylebox_override("normal", get_theme_stylebox("normal", "Button"))
	if selected:
		label.add_theme_color_override("font_color", SECTION_COLOR)
		label.add_theme_font_override("font", _bold_font(label))

## La pastille « À la carte », dans une case de largeur fixe : les colonnes
## restent alignées d'une ligne à l'autre.
func _menu_badge(selected: bool) -> Control:
	var slot := CenterContainer.new()
	slot.custom_minimum_size = Vector2(84, ROW_HEIGHT)
	if not selected:
		return slot
	var badge := Label.new()
	badge.text = Loc.ui("ui.console.menu_badge")
	badge.add_theme_font_size_override("font_size", 10)
	badge.add_theme_color_override("font_color", Color("1d1426"))
	var style := StyleBoxFlat.new()
	style.bg_color = SECTION_COLOR
	style.set_corner_radius_all(4)
	style.content_margin_left = 6.0
	style.content_margin_right = 6.0
	style.content_margin_top = 1.0
	style.content_margin_bottom = 1.0
	badge.add_theme_stylebox_override("normal", style)
	slot.add_child(badge)
	return slot

## Le cadre d'une ligne : or pour un plat choisi, invisible sinon (mêmes
## marges, pour l'alignement).
func _menu_row_frame(row: Control, selected: bool) -> Control:
	var frame := PanelContainer.new()
	var style := StyleBoxFlat.new()
	style.bg_color = Color(1.0, 0.82, 0.4, 0.13) if selected else Color(0.0, 0.0, 0.0, 0.0)
	style.border_color = SECTION_COLOR if selected else Color(0.0, 0.0, 0.0, 0.0)
	style.set_border_width_all(2)
	style.set_corner_radius_all(6)
	style.content_margin_left = 6.0
	style.content_margin_right = 6.0
	style.content_margin_top = 2.0
	style.content_margin_bottom = 2.0
	frame.add_theme_stylebox_override("panel", style)
	frame.add_child(row)
	return frame

func _press_menu_recipe(id: String) -> void:
	var selected := GameState.is_recipe_selected(id)
	if not GameState.set_menu_recipe(id, not selected):
		GameState.toast_from(self, Loc.ui("ui.console.menu_full") % [GameState.menu_recipes.size(), GameState.menu_slot_count()])

# La brigade ne révèle jamais une fiche encore verrouillée. Un seul compte de
# postes: plus de métiers séparés, chacun fait ce que ses affinités dictent.
func _build_brigade() -> void:
	list.add_child(_section_label(Loc.ui("ui.console.section_brigade")))
	var slots := GameState.employee_slots()
	list.add_child(_section_label(
		Loc.ui("ui.console.employee_slots") % [GameState.working_employees.size(), slots], true))
	var visible_employees := 0
	for id: String in DataCatalog.EMPLOYEES:
		if GameState.employee_is_unlocked(id) or GameState.employee_is_hired(id):
			list.add_child(_build_employee_row(id, slots))
			visible_employees += 1
	if visible_employees <= 0:
		list.add_child(_section_label(Loc.ui("ui.console.no_unlocked_employees"), true))
	if slots <= 0:
		list.add_child(_section_label(Loc.ui("ui.console.no_slots"), true))

func _build_takeout() -> void:
	if not GameState.takeout_is_open():
		list.add_child(_section_label(Loc.ui("ui.console.takeout_not_installed"), true))
		return
	list.add_child(_section_label(Loc.ui("ui.console.takeout_stock") % [GameState.takeout_stock, GameState.takeout_capacity()]))
	var recipe_name := DataCatalog.recipe_name(GameState.takeout_recipe) if DataCatalog.RECIPES.has(GameState.takeout_recipe) else Loc.ui("ui.takeout.no_recipe")
	list.add_child(_section_label(Loc.ui("ui.console.takeout_recipe") % recipe_name, true))
	list.add_child(_section_label(Loc.ui("ui.console.takeout_restock_rule") % [GameState.takeout_restock_threshold_value(), GameState.takeout_restock_target_value()], true))
	list.add_child(_build_takeout_rule_row("threshold"))
	list.add_child(_build_takeout_rule_row("target"))
	for id: String in listed_recipes():
		list.add_child(_build_recipe_row(id))

# --- Statistiques du heros -------------------------------------------------
#
# Une page qui REGARDE: les valeurs que le joueur subit vraiment, rangees par
# theme, et le detail du calcul au survol. Rien ne s'y regle.

const ROW_KEY_ITEM := "key_item"

# Ce que le héros garde pour quelqu'un. Rien ne s'y achète ni ne s'y vend: la
# ligne se lit, et son infobulle dit à quoi l'objet sert.
func _build_key_items() -> void:
	list.add_child(_section_label(Loc.ui("ui.console.section_items")))
	for id: String in GameState.owned_key_items():
		var definition: Dictionary = DataCatalog.KEY_ITEMS.get(id, {})
		var row := TooltipRow.new()
		row.add_theme_constant_override("separation", 8)
		row.mouse_filter = Control.MOUSE_FILTER_STOP
		var shown_name := Loc.entity("key_items", id, "display_name")
		var name_label := Label.new()
		name_label.custom_minimum_size = Vector2(260, ROW_HEIGHT)
		name_label.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
		name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
		name_label.add_theme_color_override("font_color", definition.get("color", Color("f4d35e")))
		name_label.text = shown_name
		row.add_child(name_label)
		var count := TooltipButton.new()
		count.custom_minimum_size = Vector2(72, ROW_HEIGHT)
		count.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
		count.text = Loc.ui("ui.console.item_count") % GameState.key_item_amount(id)
		count.mouse_entered.connect(count.grab_focus)
		row.add_child(count)
		var detail := Loc.ui("ui.console.item_tooltip") % [shown_name, Loc.entity("key_items", id, "description")]
		count.tooltip_text = detail
		_apply_row_tooltip(row, detail)
		_register_row(count, id, ROW_KEY_ITEM)
		list.add_child(row)

const ROW_QUEST := "quest"

# Le journal: chaque quête et où elle en est. Une quête en cours se montre
# dépliée, sans qu'on ait à la choisir (ligne 82 du Sheet) : ses étapes déjà
# franchies, cochées, puis l'étape en cours, son texte et ses objectifs tenus à
# jour. Les étapes suivantes restent cachées. Finie ou ratée, elle tient sur une
# ligne. La quête reste une seule cible de navigation : son badge.
func _build_quests() -> void:
	list.add_child(_section_label(Loc.ui("ui.console.section_quests")))
	for id: String in GameState.journal_quests():
		var state := GameState.quest_value(id, "state")
		var block := VBoxContainer.new()
		block.add_theme_constant_override("separation", 2)
		var row := TooltipRow.new()
		row.add_theme_constant_override("separation", 8)
		row.mouse_filter = Control.MOUSE_FILTER_STOP
		var title := Label.new()
		title.custom_minimum_size = Vector2(300, ROW_HEIGHT)
		title.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
		title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
		title.text = Loc.entity("quests", id, "display_name")
		title.modulate = Color.WHITE if state == 1 else DIMMED
		row.add_child(title)
		var badge := TooltipButton.new()
		badge.custom_minimum_size = Vector2(120, ROW_HEIGHT)
		badge.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
		badge.text = Loc.ui("ui.quest.state.%d" % state)
		badge.mouse_entered.connect(badge.grab_focus)
		row.add_child(badge)
		# L'étape se lit maintenant sous la ligne : l'infobulle garde la quête.
		var detail := Loc.entity("quests", id, "description").strip_edges()
		badge.tooltip_text = detail
		_apply_row_tooltip(row, detail)
		_register_row(badge, id, ROW_QUEST)
		block.add_child(row)
		var steps: Control = _quest_steps(id) if state == 1 else null
		if steps != null:
			block.add_child(steps)
			row_blocks[badge] = block
		list.add_child(block)

const QUEST_STEP_INDENT := 28
const QUEST_LABEL_WIDTH := 300.0
const QUEST_COUNT_WIDTH := 64.0

## Les étapes d'une quête en cours : les franchies, puis la courante et ses
## objectifs. Rien pour une quête sans étape.
func _quest_steps(id: String) -> Control:
	var stages := QuestProgress.stages_of(id)
	var current := QuestProgress.current_stage(id)
	if current < 0:
		return null
	var margin := MarginContainer.new()
	margin.add_theme_constant_override("margin_left", QUEST_STEP_INDENT)
	margin.add_theme_constant_override("margin_bottom", 6)
	margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
	var box := VBoxContainer.new()
	box.add_theme_constant_override("separation", 2)
	box.mouse_filter = Control.MOUSE_FILTER_IGNORE
	margin.add_child(box)
	for index: int in range(current):
		var past: Dictionary = stages[index] if stages[index] is Dictionary else {}
		box.add_child(_quest_line(StepMark.new(true, DIMMED), str(past.get("title", "")), DIMMED))
	var stage: Dictionary = stages[current] if stages[current] is Dictionary else {}
	var heading := _quest_line(StepMark.new(false, SECTION_COLOR), str(stage.get("title", "")), SECTION_COLOR)
	(heading.get_child(1) as Label).add_theme_font_override("font", _bold_font(heading.get_child(1) as Label))
	box.add_child(heading)
	var text := str(stage.get("description", "")).strip_edges()
	if not text.is_empty():
		var description := Label.new()
		description.text = text
		description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
		description.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		description.add_theme_font_size_override("font_size", 12)
		description.add_theme_color_override("font_color", Color(0.86, 0.87, 0.92, 1.0))
		description.mouse_filter = Control.MOUSE_FILTER_IGNORE
		var indented := MarginContainer.new()
		indented.add_theme_constant_override("margin_left", 22)
		indented.mouse_filter = Control.MOUSE_FILTER_IGNORE
		indented.add_child(description)
		box.add_child(indented)
	for objective: Variant in QuestProgress.objectives_of(stage):
		if objective is Dictionary:
			box.add_child(_objective_line(QuestProgress.objective_view(objective)))
	return margin

## Une ligne d'étape : sa marque, puis son titre.
func _quest_line(mark: Control, text: String, color: Color) -> HBoxContainer:
	var line := HBoxContainer.new()
	line.add_theme_constant_override("separation", 6)
	line.mouse_filter = Control.MOUSE_FILTER_IGNORE
	line.add_child(mark)
	var label := Label.new()
	label.text = text
	label.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
	label.add_theme_color_override("font_color", color)
	label.mouse_filter = Control.MOUSE_FILTER_IGNORE
	line.add_child(label)
	return line

## Un objectif : sa case, son libellé, et pour un compteur « x/N », avec la part
## du sac pendant une plongée.
func _objective_line(view: Dictionary) -> HBoxContainer:
	var met := bool(view.met)
	var line := HBoxContainer.new()
	line.add_theme_constant_override("separation", 6)
	line.mouse_filter = Control.MOUSE_FILTER_IGNORE
	var inset := Control.new()
	inset.custom_minimum_size = Vector2(16, 0)
	inset.mouse_filter = Control.MOUSE_FILTER_IGNORE
	line.add_child(inset)
	line.add_child(StepMark.new(met, SUFFICIENT_COLOR if met else DIMMED))
	var label := _column(str(view.label), QUEST_LABEL_WIDTH, true)
	label.add_theme_font_size_override("font_size", 12)
	if met:
		label.add_theme_color_override("font_color", SUFFICIENT_COLOR)
	line.add_child(label)
	if bool(view.counted):
		var count := _column(Loc.ui("ui.quest.progress") % [int(view.value), int(view.target)], QUEST_COUNT_WIDTH, true)
		count.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
		count.add_theme_font_size_override("font_size", 12)
		count.add_theme_color_override("font_color", SUFFICIENT_COLOR if met else Color.WHITE)
		line.add_child(count)
		if int(view.bag) > 0:
			var bag := _column(Loc.ui("ui.quest.in_bag") % int(view.bag), 160.0, true)
			bag.add_theme_font_size_override("font_size", 11)
			bag.add_theme_color_override("font_color", DIMMED)
			line.add_child(bag)
	return line

## Une case d'étape ou d'objectif, dessinée : cochée, ou vide. Dessinée plutôt
## qu'écrite, comme le cadenas : la police n'a pas forcément le glyphe.
class StepMark extends Control:
	var checked := false
	var tint := Color.WHITE

	func _init(is_checked: bool = false, color: Color = Color.WHITE, side: float = 14.0) -> void:
		checked = is_checked
		tint = color
		custom_minimum_size = Vector2(side, side)
		size_flags_vertical = Control.SIZE_SHRINK_CENTER
		mouse_filter = Control.MOUSE_FILTER_IGNORE

	func _draw() -> void:
		var side := minf(size.x, size.y)
		var box := Rect2((size - Vector2(side, side)) * 0.5 + Vector2(1.5, 1.5), Vector2(side - 3.0, side - 3.0))
		draw_rect(box, tint, false, 1.5)
		if checked:
			var a := box.position + Vector2(0.22, 0.52) * box.size
			var b := box.position + Vector2(0.42, 0.74) * box.size
			var c := box.position + Vector2(0.8, 0.26) * box.size
			draw_polyline(PackedVector2Array([a, b, c]), tint, 2.0, true)

func _build_hero_stats() -> void:
	var hero := GameState.scoped_first(self, "player")
	list.add_child(_section_label(Loc.ui("ui.stats.hint"), true))
	for group: Dictionary in HeroStats.GROUPS:
		list.add_child(_section_label(Loc.ui(str(group.title))))
		for row: Dictionary in group.rows:
			list.add_child(_build_stat_row(row, hero))

func _build_stat_row(row: Dictionary, hero: Node) -> Control:
	var line := StatRow.new()
	line.lines = HeroStats.breakdown(row, hero)
	line.title = Loc.ui(str(row.label))
	line.add_theme_constant_override("separation", 12)
	line.custom_minimum_size = Vector2(0, 34)
	line.add_child(_column(line.title, 320, true))
	var format := str(row.get("format", "dec1"))
	var final := HeroStats.final_value(row, hero)
	var value_label := _column(HeroStats.format_value(final, format), 120, true)
	value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
	value_label.add_theme_color_override("font_color", SECTION_COLOR)
	line.add_child(value_label)
	# La valeur de depart, en retrait: c'est l'ecart qui dit ce que les
	# ameliorations, l'arme et les effets ont deja apporte.
	var components := GameState.get_game_variable_components(str(row.variable), HeroStats._base_of(row, hero))
	var base := float(components.base_value)
	var delta_label := _column("", 170, true)
	delta_label.add_theme_font_size_override("font_size", 13)
	if not is_equal_approx(base, final):
		delta_label.text = Loc.ui("ui.stats.from_base") % HeroStats.format_value(base, format)
		delta_label.add_theme_color_override("font_color", SUFFICIENT_COLOR if final > base else MISSING_COLOR)
	else:
		delta_label.text = Loc.ui("ui.stats.unchanged")
		delta_label.modulate = DIMMED
	line.add_child(delta_label)
	# Une infobulle n'apparait que si son texte n'est pas vide; le contenu, lui,
	# est dessine par `_make_custom_tooltip`.
	line.tooltip_text = line.title
	line.mouse_filter = Control.MOUSE_FILTER_STOP
	return line

## L'Armurerie de la gestion : acheter ce qu'on n'a pas encore, et cuisiner les
## plats de combat. Équiper se fait au menu joueur (onglet Équipement).
func _build_arsenal() -> void:
	list.add_child(_section_label(Loc.ui("ui.console.armory_weapons"), true))
	var offered := 0
	for weapon_id: String in DataCatalog.WEAPONS:
		# Comme la brigade: une arme verrouillée n'existe pas encore à la console.
		if GameState.weapon_is_unlocked(weapon_id) and not GameState.weapon_is_owned(weapon_id):
			list.add_child(_build_weapon_row(weapon_id))
			offered += 1
	list.add_child(_section_label(Loc.ui("ui.console.armory_equipment"), true))
	for equipment_id: String in DataCatalog.EQUIPMENT:
		if not GameState.equipment_is_owned(equipment_id):
			list.add_child(_build_equipment_row(equipment_id))
			offered += 1
	if offered == 0:
		list.add_child(_section_label(Loc.ui("ui.console.armory_none"), true))
	list.add_child(_section_label(Loc.ui("ui.console.armory_dishes") % [
		GameState.prepared_consumable_total(), GameState.consumable_capacity()], true))
	for recipe_id: String in GameState.unlocked_recipes():
		list.add_child(_build_recipe_consumable_row(recipe_id, false))

## L'équipement du héros, au menu joueur : ce qu'il possède, et ce qu'il porte.
## Équiper se fait partout, plongée comprise (décision du 12/10).
func _build_equipment_tab() -> void:
	list.add_child(_section_label("ARME PRINCIPALE", true))
	for weapon_id: String in DataCatalog.WEAPONS:
		if GameState.weapon_is_owned(weapon_id):
			list.add_child(_build_weapon_row(weapon_id))
	if GameState.secondary_weapon_slot_count() > 0:
		var secondary_name := DataCatalog.weapon_name(GameState.secondary_weapon_id) \
			if not GameState.secondary_weapon_id.is_empty() else "—"
		var secondary_key: String = load("res://scripts/core/input_prompts.gd").label("secondary_action")
		list.add_child(_section_label("ACTION SECONDAIRE [%s]  %s" % [secondary_key.to_upper(), secondary_name], true))
	else:
		list.add_child(_section_label("ACTION SECONDAIRE  — À DÉBLOQUER", true))
	list.add_child(_section_label("ÉQUIPEMENT  %d SLOT(S)" % GameState.equipment_slot_count(), true))
	for equipment_id: String in DataCatalog.EQUIPMENT:
		if GameState.equipment_is_owned(equipment_id):
			list.add_child(_build_equipment_row(equipment_id))
	list.add_child(_section_label("PLATS DE COMBAT  %d/%d PORTÉS · %d SLOT(S)" % [
		GameState.prepared_consumable_total(), GameState.consumable_capacity(),
		GameState.consumable_slot_count()], true))
	var carried := 0
	for recipe_id: String in GameState.unlocked_recipes():
		if GameState.get_consumable_amount(recipe_id) > 0:
			list.add_child(_build_recipe_consumable_row(recipe_id, true))
			carried += 1
	if carried == 0:
		list.add_child(_section_label(Loc.ui("ui.player.no_dishes"), true))

## Les ingrédients du menu joueur : en plongée, le sac (ce qui est ramassé
## depuis le départ), la réserve du resto et le total ; au resto, la réserve.
func _build_bag() -> void:
	var diving := GameState.dive_bag_open
	var header := HBoxContainer.new()
	header.add_theme_constant_override("separation", 12)
	header.add_child(_column(Loc.ui("ui.player.bag_ingredient"), 360, true))
	if diving:
		header.add_child(_column(Loc.ui("ui.player.bag_dive"), 110, true))
	header.add_child(_column(Loc.ui("ui.player.bag_stock"), 110, true))
	if diving:
		header.add_child(_column(Loc.ui("ui.player.bag_total"), 110, true))
	list.add_child(header)
	var totals := [0, 0]
	for ingredient_id: String in DataCatalog.INGREDIENTS:
		var bagged := GameState.dive_bag_amount(ingredient_id) if diving else 0
		var stocked := GameState.get_ingredient_amount(ingredient_id)
		if bagged + stocked <= 0:
			continue
		totals[0] += bagged
		totals[1] += stocked
		var row := HBoxContainer.new()
		row.add_theme_constant_override("separation", 12)
		row.custom_minimum_size = Vector2(0, 34)
		# Le nom, et le cadenas d'un ingrédient mis de côté à la console : il se
		# lit ici, il se change à la gestion du resto.
		var name_cell := HBoxContainer.new()
		name_cell.custom_minimum_size = Vector2(360, 0)
		name_cell.add_theme_constant_override("separation", 6)
		name_cell.add_child(_column(DataCatalog.ingredient_name(ingredient_id), 0, true))
		if GameState.is_ingredient_locked(ingredient_id):
			var marker := PadlockIcon.new(true, SECTION_COLOR, 16.0)
			marker.tooltip_text = Loc.ui("ui.player.bag_locked_tooltip")
			marker.mouse_filter = Control.MOUSE_FILTER_STOP
			name_cell.add_child(marker)
		row.add_child(name_cell)
		if diving:
			row.add_child(_column("%d" % bagged, 110, bagged > 0))
		row.add_child(_column("%d" % stocked, 110, stocked > 0))
		if diving:
			row.add_child(_column("%d" % (bagged + stocked), 110, true))
		list.add_child(row)
	if totals[0] + totals[1] == 0:
		list.add_child(_section_label(Loc.ui("ui.player.bag_empty"), true))
		return
	if diving:
		list.add_child(_section_label(Loc.ui("ui.player.bag_totals_diving") % [totals[0], totals[1], totals[0] + totals[1]], true))
	else:
		list.add_child(_section_label(Loc.ui("ui.console.ingredients_total") % totals[1], true))

func _build_weapon_row(id: String) -> Control:
	var definition: Dictionary = DataCatalog.WEAPONS[id]
	var owned := GameState.weapon_is_owned(id)
	var row := HBoxContainer.new()
	row.add_theme_constant_override("separation", 8)
	var name_label := _column(DataCatalog.weapon_name(id), 210, owned)
	name_label.tooltip_text = str(definition.get("description", ""))
	row.add_child(name_label)
	var delivery := Loc.ui("ui.console.weapon_projectile") if str(definition.get("delivery", "direct")) == "projectile" \
		else Loc.ui("ui.console.weapon_direct")
	var summary := _column("%s  ×%.2f  ↦%d" % [delivery,
		float(definition.get("damage_multiplier", 1.0)), int(definition.get("piercing", 0))], 190, true)
	# Le berimbau ne frappe pas : il rend l'écho.
	if str(definition.get("special_action", "")) == "berimbau":
		summary.text = Loc.ui("ui.console.weapon_echo")
	row.add_child(summary)
	if not owned:
		var cost := int(definition.get("purchase_cost", 0))
		var buy := Button.new()
		buy.custom_minimum_size = Vector2(190, 42)
		buy.text = Loc.ui("ui.console.buy_cost") % cost
		buy.disabled = GameState.coins < cost
		buy.modulate = Color.WHITE if not buy.disabled else DIMMED
		buy.pressed.connect(_press_weapon_purchase.bind(id))
		buy.mouse_entered.connect(buy.grab_focus)
		row.add_child(buy)
		_register_row(buy, id, ROW_WEAPON)
		return row
	var slot_type := str(definition.get("slot_type", "either"))
	if slot_type != "secondary":
		var button := Button.new()
		button.custom_minimum_size = Vector2(132, 42)
		var equipped := GameState.active_weapon_id() == id
		button.text = ("● " if equipped else "") + "PRINCIPALE"
		button.tooltip_text = Loc.ui("ui.console.weapon_active") if equipped \
			else Loc.ui("ui.console.weapon_equip")
		button.pressed.connect(_press_weapon_equip.bind(id, 0))
		button.mouse_entered.connect(button.grab_focus)
		row.add_child(button)
		_register_row(button, id, ROW_WEAPON)
	if slot_type != "main" and GameState.secondary_weapon_slot_count() > 0:
		var secondary := Button.new()
		secondary.custom_minimum_size = Vector2(148, 42)
		var secondary_equipped := GameState.secondary_weapon_id == id
		var secondary_key: String = load("res://scripts/core/input_prompts.gd").label("secondary_action")
		secondary.text = ("● " if secondary_equipped else "") + "SECONDAIRE [%s]" % secondary_key.to_upper()
		secondary.pressed.connect(_press_weapon_equip.bind(id, 1))
		secondary.mouse_entered.connect(secondary.grab_focus)
		row.add_child(secondary)
		_register_row(secondary, id, ROW_WEAPON)
	return row

func _build_equipment_row(id: String) -> Control:
	var definition: Dictionary = DataCatalog.EQUIPMENT[id]
	var owned := GameState.equipment_is_owned(id)
	var row := HBoxContainer.new()
	row.add_theme_constant_override("separation", 8)
	var label := _column(DataCatalog.equipment_name(id), 210, owned)
	label.tooltip_text = str(definition.get("description", ""))
	row.add_child(label)
	row.add_child(_column(str(definition.get("type", "armor")).to_upper(), 105, true))
	if not owned:
		var buy := Button.new()
		var cost := int(definition.get("purchase_cost", 0))
		buy.custom_minimum_size = Vector2(150, 42)
		buy.text = Loc.ui("ui.console.buy_cost") % cost
		buy.disabled = GameState.coins < cost
		buy.pressed.connect(_press_equipment_purchase.bind(id))
		buy.mouse_entered.connect(buy.grab_focus)
		row.add_child(buy)
		_register_row(buy, id, ROW_EQUIPMENT)
		return row
	if GameState.equipment_slot_count() <= 0:
		row.add_child(_column("SLOT VERROUILLÉ", 180, false))
		return row
	for slot: int in range(GameState.equipment_slot_count()):
		var button := Button.new()
		button.custom_minimum_size = Vector2(76, 42)
		var equipped := slot < GameState.equipped_equipment.size() and GameState.equipped_equipment[slot] == id
		button.text = ("● " if equipped else "") + str(slot + 1)
		button.pressed.connect(_press_equipment_equip.bind(id, slot))
		button.mouse_entered.connect(button.grab_focus)
		row.add_child(button)
		_register_row(button, id, ROW_EQUIPMENT)
	return row

## Un plat de combat : à l'Armurerie, on le cuisine ; à l'Équipement, on le
## range dans une case (`equip_view`).
func _build_recipe_consumable_row(id: String, equip_view: bool = false) -> Control:
	var definition: Dictionary = GameState.recipe_for_menu(id)
	var combat: Dictionary = definition.get("combat_consumable", {})
	var amount := GameState.get_consumable_amount(id)
	var row := HBoxContainer.new()
	row.add_theme_constant_override("separation", 8)
	var label := _column(DataCatalog.recipe_name(id), 210, amount > 0)
	label.tooltip_text = "Mangé" if str(combat.get("mode", "eat")) == "eat" else "Lancé en zone"
	row.add_child(label)
	row.add_child(_column("×%d" % amount, 58, amount > 0))
	if not equip_view:
		var prepare := Button.new()
		prepare.custom_minimum_size = Vector2(128, 42)
		prepare.text = "CUISINER"
		prepare.disabled = not GameState.can_prepare_recipe_consumable(id)
		prepare.tooltip_text = "Ingrédients manquants ou capacité pleine" if prepare.disabled else "Dépense les ingrédients pour préparer une portion"
		prepare.pressed.connect(_press_recipe_consumable_prepare.bind(id))
		prepare.mouse_entered.connect(prepare.grab_focus)
		row.add_child(prepare)
		_register_row(prepare, id, ROW_CONSUMABLE)
		return row
	for slot: int in range(GameState.consumable_slot_count()):
		var button := Button.new()
		button.custom_minimum_size = Vector2(64, 42)
		var equipped := slot < GameState.equipped_consumable_slots.size() \
			and GameState.equipped_consumable_slots[slot] == id
		button.text = ("● " if equipped else "") + str(slot + 1)
		button.disabled = amount <= 0
		button.pressed.connect(_press_consumable_equip.bind(id, slot))
		button.mouse_entered.connect(button.grab_focus)
		row.add_child(button)
		_register_row(button, id, ROW_CONSUMABLE)
	return row

func _press_weapon_purchase(id: String) -> void:
	weapon_purchase_requested.emit(id)

## Équiper ne demande rien au resto : le menu joueur le fait aussi en plongée.
func _press_weapon_equip(id: String, slot: int) -> void:
	GameState.equip_weapon(id, slot)
	build_list()

func _press_equipment_purchase(id: String) -> void:
	equipment_purchase_requested.emit(id)

func _press_equipment_equip(id: String, slot: int) -> void:
	GameState.equip_equipment(id, slot)
	build_list()

func _press_recipe_consumable_prepare(id: String) -> void:
	recipe_consumable_prepare_requested.emit(id)

func _press_consumable_equip(id: String, slot: int) -> void:
	GameState.equip_consumable(id, slot)
	build_list()

func _build_ingredients() -> void:
	var total := 0
	for ingredient_id: String in DataCatalog.INGREDIENTS:
		total += GameState.get_ingredient_amount(ingredient_id)
	list.add_child(_section_label(Loc.ui("ui.console.ingredients_total") % total, true))
	for ingredient_id: String in DataCatalog.INGREDIENTS:
		var row := HBoxContainer.new()
		row.add_theme_constant_override("separation", 12)
		# Le cadenas (12/10/2026) : un ingrédient mis de côté n'est ni cuisiné
		# ni vendu ; les quêtes et les améliorations le voient toujours.
		var locked := GameState.is_ingredient_locked(ingredient_id)
		var lock := LockButton.new()
		lock.locked = locked
		lock.flat = true
		lock.custom_minimum_size = Vector2(30, 30)
		lock.size_flags_vertical = Control.SIZE_SHRINK_CENTER
		lock.tooltip_text = Loc.ui("ui.console.lock_tooltip_locked" if locked else "ui.console.lock_tooltip_free")
		lock.pressed.connect(_press_ingredient_lock.bind(ingredient_id))
		lock.mouse_entered.connect(lock.grab_focus)
		row.add_child(lock)
		_register_row(lock, ingredient_id, ROW_INGREDIENT)
		var name_label := Label.new()
		name_label.custom_minimum_size = Vector2(318, 42)
		name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
		name_label.text = DataCatalog.ingredient_name(ingredient_id)
		row.add_child(name_label)
		var amount_label := Label.new()
		amount_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		amount_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
		amount_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
		amount_label.add_theme_color_override("font_color", SECTION_COLOR)
		amount_label.text = Loc.ui("ui.console.ingredient_amount") % GameState.get_ingredient_amount(ingredient_id)
		row.add_child(amount_label)
		ingredient_amount_labels[ingredient_id] = amount_label
		# Le bouton n'apparait que si le marche est ouvert: sans lien vers
		# l'exterieur, proposer une vente serait mentir sur ce qui est possible.
		if GameState.market_is_open():
			var sell := _sell_button(ingredient_id)
			row.add_child(sell)
			_register_row(sell, ingredient_id, ROW_INGREDIENT_SALE)
		list.add_child(row)

func _press_ingredient_lock(ingredient_id: String) -> void:
	GameState.set_ingredient_locked(ingredient_id, not GameState.is_ingredient_locked(ingredient_id))

## Vendre tout le stock d'un materiau, d'un seul bouton. Le prix affiche est
## ce que la vente rapporte maintenant — pas le prix unitaire, qui obligerait
## a multiplier de tete.
func _sell_button(ingredient_id: String) -> Button:
	var button := Button.new()
	button.custom_minimum_size = Vector2(150, 34)
	button.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
	var earned := GameState.ingredient_sale_total(ingredient_id)
	var sellable := GameState.can_sell_ingredient(ingredient_id)
	button.text = Loc.ui("ui.console.sell_ingredient") % earned
	button.tooltip_text = Loc.ui("ui.console.sell_ingredient_tooltip")
	button.disabled = not sellable
	button.modulate = Color.WHITE if sellable else DIMMED
	button.pressed.connect(_press_sell_ingredient.bind(ingredient_id))
	button.mouse_entered.connect(button.grab_focus)
	return button

func _press_sell_ingredient(ingredient_id: String) -> void:
	if GameState.sell_ingredient(ingredient_id) <= 0:
		return
	build_list()

func _build_takeout_rule_row(kind: String) -> Control:
	var row := HBoxContainer.new()
	row.add_theme_constant_override("separation", 8)
	var threshold := GameState.takeout_restock_threshold_value()
	var target := GameState.takeout_restock_target_value()
	var value := threshold if kind == "threshold" else target
	var label := Label.new()
	label.custom_minimum_size = Vector2(350, 40)
	label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	label.text = (Loc.ui("ui.console.takeout_threshold") if kind == "threshold" else Loc.ui("ui.console.takeout_target")) % value
	row.add_child(label)
	for delta: int in [-1, 1]:
		var button := Button.new()
		button.custom_minimum_size = Vector2(80, 40)
		button.text = "−" if delta < 0 else "+"
		button.pressed.connect(_adjust_takeout_rule.bind(kind, delta))
		button.mouse_entered.connect(button.grab_focus)
		row.add_child(button)
		_register_row(button, kind, ROW_TAKEOUT_RULE)
	return row

func _adjust_takeout_rule(kind: String, delta: int) -> void:
	var threshold := GameState.takeout_restock_threshold_value()
	var target := GameState.takeout_restock_target_value()
	if kind == "threshold":
		threshold += delta
	else:
		target += delta
	GameState.set_takeout_restock_rule(threshold, target)

func _press_takeout_recipe(id: String) -> void:
	takeout_recipe_requested.emit(id)

func _press_recipe_ban(id: String) -> void:
	recipe_ban_requested.emit(id)

func _build_employee_row(id: String, _slots: int) -> Control:
	var definition: Dictionary = DataCatalog.EMPLOYEES[id]
	var hired := GameState.employee_is_hired(id)
	var working := GameState.employee_is_working(id)
	# De passage : il vient et repart, jamais embauché (le Saule).
	var passing := GameState.employee_is_passing(id) and not hired
	var cost := int(definition.get("hire_cost", 0))
	var allowed := GameState.employee_is_unlocked(id) or hired
	var affordable := GameState.coins >= cost
	var has_locker := GameState.employee_locker_is_installed()
	var row := HBoxContainer.new()
	row.add_theme_constant_override("separation", 8)

	var name_label := Label.new()
	name_label.custom_minimum_size = Vector2(168, 44)
	name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	name_label.clip_text = true
	name_label.text = DataCatalog.employee_name(id)
	name_label.modulate = Color.WHITE if allowed else DIMMED
	row.add_child(name_label)
	var state_label: Label
	if working:
		state_label = _column(Loc.ui("ui.console.employee_working"), 112, true)
	elif hired:
		state_label = _column(Loc.ui("ui.console.employee_standby"), 112, false)
		var reason := GameState.employee_standby_reason(id)
		state_label.tooltip_text = Loc.ui("ui.console.standby_reason_%s" % reason)
	elif passing:
		var here := GameState.employee_is_visiting(id)
		state_label = _column(Loc.ui("ui.console.employee_visiting" if here else "ui.console.employee_passing"), 112, here)
	else:
		state_label = _column("%d c" % cost, 112, affordable)
	row.add_child(state_label)

	var profile := VBoxContainer.new()
	profile.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	profile.add_theme_constant_override("separation", 2)
	profile.add_child(_affinity_panel(definition, allowed))
	var traits := Label.new()
	traits.text = _skill_summary(definition, 3)
	# Le salaire se lit avant de mettre au travail: c'est lui qui décide de ce
	# qu'il restera de chaque plat.
	var salary := GameState.employee_salary_percent(id)
	if salary > 0.0:
		var salary_text := Loc.ui("ui.console.employee_salary") % String.num(salary, 1).trim_suffix(".0")
		traits.text = salary_text if traits.text.is_empty() else "%s  ·  %s" % [salary_text, traits.text]
	traits.add_theme_font_size_override("font_size", 10)
	traits.modulate = DIMMED if allowed else DIMMED.darkened(0.25)
	profile.add_child(traits)
	row.add_child(profile)

	var button := Button.new()
	button.custom_minimum_size = Vector2(176, 44)
	if working:
		button.text = Loc.ui("ui.console.standby")
		button.tooltip_text = Loc.ui("ui.console.standby_tooltip")
		button.pressed.connect(_press_standby.bind(id))
	elif hired:
		var blocker := GameState.employee_work_blocker(id)
		button.text = Loc.ui("ui.console.work")
		button.tooltip_text = Loc.ui("ui.console.work_tooltip") if blocker.is_empty() else blocker
		button.disabled = not blocker.is_empty()
		button.modulate = Color.WHITE if blocker.is_empty() else DIMMED
		button.pressed.connect(_press_work.bind(id))
	elif passing:
		button.text = Loc.ui("ui.console.passing")
		button.tooltip_text = Loc.ui("ui.console.passing_tooltip")
		button.disabled = true
		button.modulate = DIMMED
	else:
		button.text = Loc.ui("ui.console.hire") if affordable else Loc.ui("ui.console.insufficient_resources")
		button.tooltip_text = _hire_tooltip(has_locker, affordable)
		button.disabled = not (has_locker and affordable)
		button.modulate = Color.WHITE if not button.disabled else DIMMED
		button.pressed.connect(_press_hire.bind(id))
	button.mouse_entered.connect(button.grab_focus)
	row.add_child(button)

	_register_row(button, id, ROW_EMPLOYEE)
	return row

func _hire_tooltip(has_locker: bool, affordable: bool) -> String:
	if not has_locker:
		return Loc.ui("ui.console.hire_no_locker")
	if not affordable:
		return Loc.ui("ui.console.hire_no_coins")
	return Loc.ui("ui.console.hire_tooltip")

# Ce qu'un employé préfère faire, d'un coup d'oeil: ses quatre plus fortes
# affinités en jauges 0–5. Une égalité garde l'ordre du contrat.
const AFFINITY_GAUGES := 4

func _affinity_panel(definition: Dictionary, allowed: bool) -> Control:
	var panel := HBoxContainer.new()
	panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	panel.add_theme_constant_override("separation", 6)
	var ranked: Array[Dictionary] = []
	for task_id: String in EmployeeContract.task_ids():
		var level := EmployeeContract.affinity(definition, task_id)
		if level > 0:
			ranked.append({"key": task_id, "value": level})
	ranked.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a.value) > int(b.value))
	for entry: Dictionary in ranked.slice(0, AFFINITY_GAUGES):
		var gauge := VBoxContainer.new()
		gauge.custom_minimum_size = Vector2(76, 28)
		gauge.add_theme_constant_override("separation", 1)
		var title := Label.new()
		title.text = "%s %d" % [Loc.ui("ui.task.%s" % entry.key), int(entry.value)]
		title.add_theme_font_size_override("font_size", 9)
		title.tooltip_text = str(EmployeeContract.task_entry(str(entry.key)).get("help", ""))
		gauge.add_child(title)
		var bar := ProgressBar.new()
		bar.custom_minimum_size = Vector2(74, 7)
		bar.max_value = EmployeeContract.bounds("affinity").y
		bar.value = float(entry.value)
		bar.show_percentage = false
		bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
		bar.modulate = Color.WHITE if allowed else DIMMED
		gauge.add_child(bar)
		panel.add_child(gauge)
	return panel

# Ce qui distingue vraiment un employé: ses compétences passives les plus
# hautes, au-dessus de leur valeur par défaut.
func _skill_summary(definition: Dictionary, skill_limit: int = 3) -> String:
	var ranked: Array[Dictionary] = []
	var parts: Array[String] = []
	# L'Allure est un multiplicateur: elle se lit « ×1.4 », à part des niveaux.
	var walk := EmployeeContract.walk_speed(definition)
	if not is_equal_approx(walk, EmployeeContract.coefficient_default("pace")):
		parts.append("%s ×%s" % [Loc.ui("ui.skill.pace"), String.num(walk, 2)])
	for skill_id: String in EmployeeContract.skill_ids():
		if EmployeeContract.is_coefficient(skill_id):
			continue
		var level := EmployeeContract.skill(definition, skill_id)
		if level > EmployeeContract.default_skill(skill_id):
			ranked.append({"key": skill_id, "value": level})
	ranked.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a.value) > int(b.value))
	for entry: Dictionary in ranked.slice(0, maxi(0, skill_limit - parts.size())):
		parts.append(Loc.ui("ui.console.skill_level") % [Loc.ui("ui.skill.%s" % entry.key), int(entry.value)])
	return "   ".join(parts)

func _ranked_pairs(source: Variant) -> Array[Dictionary]:
	var ranked: Array[Dictionary] = []
	if not source is Dictionary:
		return ranked
	for key: String in (source as Dictionary):
		var value := float((source as Dictionary)[key])
		if value > 0.0:
			ranked.append({"key": key, "value": value})
	ranked.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return float(a.value) > float(b.value))
	return ranked

## Le rang atteint et ce qu'il reste a servir pour le suivant. La barre dit
## d'un coup d'oeil ou en est le plat; le chiffre exact vit dans l'infobulle.
## Le survol se resout sur le controle sous le curseur, puis remonte vers ses
## parents — et la remontee s'arrete au premier qui capte la souris. Une
## etiquette de la ligne suffisait donc a avaler la fiche du plat.
##
## Tout ce qui n'a pas besoin de la souris devient transparent au survol; le
## bouton, lui, doit rester cliquable, alors il porte la fiche a son tour.
func _apply_row_tooltip(row: Control, detailed: String) -> void:
	if detailed.is_empty():
		return
	for child: Node in row.get_children():
		var control := child as Control
		if control == null:
			continue
		if control is Button:
			var own: String = control.tooltip_text
			control.tooltip_text = detailed if own.is_empty() else own + "\n\n" + detailed
			continue
		control.mouse_filter = Control.MOUSE_FILTER_IGNORE
		for grand_child: Node in control.get_children():
			var inner := grand_child as Control
			if inner != null:
				inner.mouse_filter = Control.MOUSE_FILTER_IGNORE

func _level_column(id: String, cookable: bool) -> Control:
	var box := VBoxContainer.new()
	box.custom_minimum_size = Vector2(66, 0)
	box.add_theme_constant_override("separation", 1)
	box.alignment = BoxContainer.ALIGNMENT_CENTER
	box.modulate = Color.WHITE if cookable else DIMMED
	var rank := GameState.recipe_level(id)
	var badge := Label.new()
	var cooking_rank := int(GameState.recipe_for_menu(id).get("_cooking_level", rank))
	badge.text = "Niv. %d" % rank if cooking_rank == rank else "Niv. %d / %d" % [cooking_rank, rank]
	badge.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
	box.add_child(badge)
	var threshold := GameState.recipe_next_level_threshold(id)
	if threshold <= 0:
		# Plus de palier: une barre pleine mentirait moins qu'une barre vide,
		# mais aucune barre dit la verite — ce plat a fini d'evoluer.
		return box
	var served := GameState.recipe_served_count(id)
	var previous := _previous_level_threshold(id, rank)
	var span := maxi(1, threshold - previous)
	var bar := ProgressBar.new()
	bar.custom_minimum_size = Vector2(60, 4)
	bar.show_percentage = false
	bar.max_value = span
	bar.value = clampi(served - previous, 0, span)
	box.add_child(bar)
	return box

## Le seuil du rang courant, zero au niveau 1: c'est le point de depart de la
## barre, sinon un plat au niveau 3 repartirait toujours de zero.
func _previous_level_threshold(id: String, rank: int) -> int:
	var levels: Array = DataCatalog.RECIPES.get(id, {}).get("levels", [])
	if rank <= 1 or levels.is_empty():
		return 0
	return int((levels[mini(rank, levels.size() + 1) - 2] as Dictionary).get("served_to_unlock", 0))

## L'infobulle detaillee d'un plat. Elle montre la valeur de base et, quand le
## niveau l'a changee, la valeur d'aujourd'hui en vert — la meme convention que
## l'outil, pour qu'un reglage se relise pareil des deux cotes.
static func recipe_tooltip_text(id: String) -> String:
	var base: Dictionary = DataCatalog.RECIPES.get(id, {})
	if base.is_empty():
		return ""
	var now: Dictionary = GameState.recipe_for_menu(id)
	var rank := GameState.recipe_level(id)
	var levels: Array = base.get("levels", [])
	var lines: Array[String] = []
	lines.append("%s — niveau %d" % [DataCatalog.recipe_name(id), rank])
	var cooking_rank := int(now.get("_cooking_level", rank))
	if cooking_rank != rank:
		lines[0] = "%s — niveau %d / %d" % [DataCatalog.recipe_name(id), cooking_rank, rank]
	var threshold := GameState.recipe_next_level_threshold(id)
	if threshold > 0:
		lines.append("servi de vos mains %d fois — %d pour le niveau %d" % [GameState.recipe_served_count(id), threshold, rank + 1])
	elif not levels.is_empty():
		lines.append("servi de vos mains %d fois — niveau maximal" % GameState.recipe_served_count(id))
	else:
		lines.append("servi de vos mains %d fois — ce plat n'evolue pas" % GameState.recipe_served_count(id))
	lines.append("")
	lines.append(_spec_line("Prix", "%d pieces" % int(base.get("sale_price", 0)), "%d pieces" % int(now.get("sale_price", 0))))
	lines.append(_spec_line("Cuisson", "%.1f s" % float(base.get("cook_time", 0.0)), "%.1f s" % float(now.get("cook_time", 0.0))))
	lines.append(_spec_line("Ingredients", _amount_text(base.get("ingredients", {})), _amount_text(now.get("ingredients", {}))))
	lines.append(_spec_line("Categories", _category_text(base.get("categories", [])), _category_text(now.get("categories", []))))
	var base_consumable: Dictionary = base.get("combat_consumable", {})
	var now_consumable: Dictionary = now.get("combat_consumable", {})
	if str(now_consumable.get("mode", "")) != "":
		lines.append(_spec_line("Soin", "%d" % int(base_consumable.get("heal", 0)), "%d" % int(now_consumable.get("heal", 0))))
		lines.append(_spec_line("Stamina", "%d" % int(base_consumable.get("stamina_restore", 0)), "%d" % int(now_consumable.get("stamina_restore", 0))))
	return "\n".join(lines)

## Une ligne de specification. Tant que le niveau n'a rien change, une seule
## valeur; des qu'il change quelque chose, la base puis la valeur du jour.
static func _spec_line(label: String, base_text: String, now_text: String) -> String:
	if base_text == now_text:
		return "%s: %s" % [label, base_text]
	return "%s: %s -> %s" % [label, base_text, now_text]

static func _amount_text(values: Dictionary) -> String:
	var parts: Array[String] = []
	for ingredient_id: String in values:
		parts.append("%d x %s" % [int(values[ingredient_id]), DataCatalog.ingredient_name(ingredient_id)])
	return ", ".join(parts) if not parts.is_empty() else "aucun"

static func _category_text(values: Array) -> String:
	var parts: Array[String] = []
	for raw_category: Variant in values:
		parts.append(str(raw_category))
	return ", ".join(parts) if not parts.is_empty() else "aucune"

func _column(text: String, width: float, cookable: bool) -> Label:
	var label := Label.new()
	label.custom_minimum_size = Vector2(width, 0)
	label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	label.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
	label.text = text
	label.modulate = Color.WHITE if cookable else DIMMED
	return label

# Chaque ingrédient montre toujours possédé/requis. La couleur suffit à dire si
# la quantité couvre la recette; le format ne change donc jamais sous le joueur.
func _ingredient_column(recipe: Dictionary) -> Control:
	var box := HBoxContainer.new()
	box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	box.add_theme_constant_override("separation", 10)
	for ingredient_id: String in recipe.ingredients:
		var required := int(recipe.ingredients[ingredient_id])
		# Un ingrédient mis de côté compte comme absent : zéro, et le cadenas.
		var owned := GameState.cookable_ingredient_amount(ingredient_id)
		var enough := owned >= required
		var entry := Label.new()
		entry.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
		entry.add_theme_color_override("font_color", SUFFICIENT_COLOR if enough else MISSING_COLOR)
		entry.add_theme_font_size_override("font_size", ROW_FONT_SIZE)
		entry.text = "%s %d/%d" % [DataCatalog.ingredient_name(ingredient_id), owned, required]
		if not GameState.is_ingredient_locked(ingredient_id):
			box.add_child(entry)
			continue
		var cell := HBoxContainer.new()
		cell.add_theme_constant_override("separation", 3)
		cell.add_child(entry)
		cell.add_child(PadlockIcon.new(true, MISSING_COLOR, 13.0))
		box.add_child(cell)
	return box

func _press_switch() -> void:
	open_toggle_requested.emit()

func _press_hire(id: String) -> void:
	hire_requested.emit(id)

func _press_work(id: String) -> void:
	employee_work_requested.emit(id)

func _press_standby(id: String) -> void:
	employee_standby_requested.emit(id)

func _focused_index() -> int:
	var focused := get_viewport().gui_get_focus_owner() as Button
	return row_buttons.find(focused) if focused != null else -1

func _restore_focus(index: int) -> void:
	if index >= 0 and index < row_buttons.size():
		_focus_button(row_buttons[index])
	else:
		_focus_first_available()

func _focus_first_available() -> void:
	if mode == Mode.CONSOLE:
		var actions := _top_action_buttons()
		if not actions.is_empty():
			_focus_button(actions[0])
			return
	var rows := _available_row_buttons()
	_focus_button(rows[0] if not rows.is_empty() else close_button)

func _navigate(direction: int) -> void:
	var focused := get_viewport().gui_get_focus_owner() as Button
	var actions := _top_action_buttons()
	var rows := _available_row_buttons()
	if focused != null and actions.has(focused):
		if direction > 0:
			if is_tabbed() and not tab_buttons.is_empty():
				_focus_tab()
			else:
				_focus_button(rows[0] if not rows.is_empty() else close_button)
		elif not rows.is_empty():
			_focus_button(rows[-1])
		return
	# Depuis la barre d'onglets: vers le bas on entre dans la liste, vers le
	# haut on revient aux actions permanentes.
	if focused != null and tab_buttons.has(focused):
		if direction > 0 and not rows.is_empty():
			_focus_button(rows[0])
		else:
			_focus_button(actions[0] if not actions.is_empty() else close_button)
		return
	if rows.is_empty():
		_focus_top()
		return
	var index := rows.find(focused)
	if index < 0:
		_focus_first_available()
		return
	var target_index := index + direction
	if target_index < 0:
		_focus_top()
	elif target_index >= rows.size():
		_focus_button(actions[0] if is_tabbed() and not actions.is_empty() else close_button)
	else:
		_focus_button(rows[target_index])

# Ce qui est au-dessus de la liste: la barre d'onglets quand elle existe, le
# bouton de fermeture sinon.
func _focus_top() -> void:
	if is_tabbed() and not tab_buttons.is_empty():
		_focus_tab()
		return
	close_button.grab_focus()

func _focus_button(button: Button) -> void:
	# Deferred focus can outlive a menu close, a rebuilt row or a departure.
	if not is_inside_tree() or not visible or GameState.is_offstage(self) \
			or not is_instance_valid(button) or not button.is_visible_in_tree():
		return
	button.grab_focus()
	if row_buttons.has(button):
		# Le bloc d'abord, la ligne ensuite : un bloc plus haut que la liste
		# garde sa ligne en vue. La souris ne passe pas ici, la liste ne saute
		# donc pas sous elle.
		var block: Variant = row_blocks.get(button)
		if block is Control:
			_ensure_visible.call_deferred(block)
		scroll.call_deferred("ensure_control_visible", button)

## Un bloc reconstruit entre-temps n'est plus là : rien à montrer.
func _ensure_visible(control: Variant) -> void:
	if is_instance_valid(control) and control is Control and scroll.is_ancestor_of(control):
		scroll.ensure_control_visible(control)

func _top_action_buttons() -> Array[Button]:
	var available: Array[Button] = []
	for button: Button in [dining_toggle, takeout_toggle, build_button, close_button]:
		if is_instance_valid(button) and button.visible and not button.disabled:
			available.append(button)
	return available

func _available_row_buttons() -> Array[Button]:
	var available: Array[Button] = []
	for button: Button in row_buttons:
		if is_instance_valid(button) and not button.disabled:
			available.append(button)
	return available

func _get_navigation_buttons() -> Array[Button]:
	var available := _top_action_buttons()
	available.append_array(tab_buttons)
	available.append_array(_available_row_buttons())
	return available

func _confirm_focused() -> void:
	var focused := get_viewport().gui_get_focus_owner() as Button
	if focused != null and (_top_action_buttons().has(focused) or tab_buttons.has(focused)):
		focused.pressed.emit()
	elif focused != null and row_buttons.has(focused):
		# Une bascule ne s'active pas par `pressed`: c'est `toggled` qui porte le
		# changement, donc le clavier doit inverser l'état lui-même.
		if focused.toggle_mode:
			focused.button_pressed = not focused.button_pressed
		else:
			focused.pressed.emit()
	else:
		_focus_first_available()

func _select(id: String) -> void:
	if not GameState.recipe_is_unlocked(id):
		GameState.toast_from(self, Loc.ui("ui.menu.locked_for") % DataCatalog.recipe_name(id))
		return
	if not GameState.can_cook(id):
		GameState.toast_from(self, Loc.ui("ui.menu.missing_for") % DataCatalog.recipe_name(id))
		return
	recipe_selected.emit(id)
	close()
