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 dismiss_requested(employee_id: String)
signal takeout_recipe_requested(recipe_id: String)

enum Mode { COOK, CONSOLE }
# The takeout setup belongs to management, not to the physical work station.
# Appending the section preserves the existing indices used by the other tabs.
enum Section { SHOP, MENU, BRIGADE, TAKEOUT, INGREDIENTS }

const SUFFICIENT_COLOR := Color("6ee7a0")
const MISSING_COLOR := Color("ff7b7b")
const DIMMED := Color(0.62, 0.6, 0.66, 1.0)
const OPEN_COLOR := Color("6ee7a0")
const CLOSED_COLOR := Color("ffa66e")
const SECTION_COLOR := Color("ffd166")

# 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_TAKEOUT_SWITCH := "takeout_switch"
const ROW_BUILD := "build"
const ROW_EMPLOYEE := "employee"
const ROW_SWITCH := "switch"
const ROW_ROLE := "role"
const ROW_TAKEOUT_RULE := "takeout_rule"
const ROW_INGREDIENT := "ingredient"

@onready var list: VBoxContainer = %RecipeList
@onready var scroll: ScrollContainer = %RecipeScroll
@onready var close_button: Button = %CloseButton
@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
var brigade_role_filter := "server"

# 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] = []
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 section: int = Section.SHOP
var icon: ConsoleIcon

func _ready() -> void:
	close_button.pressed.connect(close)
	close_button.mouse_entered.connect(close_button.grab_focus)
	GameState.state_changed.connect(_on_state_changed)
	build_list()

func open(new_mode: int = Mode.COOK, new_section: int = Section.SHOP) -> void:
	mode = new_mode
	section = new_section
	_apply_layout()
	visible = true
	build_list()
	call_deferred("_focus_first_available")

func close() -> void:
	if not visible:
		return
	visible = false
	menu_closed.emit()

func _apply_layout() -> void:
	var compact := mode == Mode.COOK
	panel.offset_left = 256.0 if compact else 226.0
	panel.offset_top = 94.0 if compact else 34.0
	panel.offset_right = 896.0 if compact else 926.0
	panel.offset_bottom = 554.0 if compact else 614.0
	scroll.custom_minimum_size.y = 210.0 if compact else 300.0
	backdrop.color.a = 0.28 if compact else 0.72

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

func _input(event: InputEvent) -> void:
	if not visible:
		return
	if event.is_action_pressed("pause") or event.is_action_pressed("ui_cancel"):
		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
	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
	var focused_index := _focused_index()
	row_buttons.clear()
	row_ids.clear()
	row_kinds.clear()
	ingredient_amount_labels.clear()
	for child: Node in list.get_children():
		list.remove_child(child)
		child.queue_free()
	if mode == Mode.CONSOLE:
		match section:
			Section.MENU:
				for id: String in listed_recipes():
					list.add_child(_build_recipe_row(id))
			Section.BRIGADE:
				_build_brigade()
			Section.TAKEOUT:
				_build_takeout()
			Section.INGREDIENTS:
				_build_ingredients()
			_:
				list.add_child(_build_switch_row())
	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 visible:
		call_deferred("_restore_focus", focused_index)

# Le four ne propose que la carte du jour: depuis que la console existe,
# cuisiner un plat qu'on n'offre à personne n'a plus de sens. La console, elle,
# montre tout le catalogue — c'est là qu'on décide de la carte.
func listed_recipes() -> Array[String]:
	var result: Array[String] = []
	for id: String in DataCatalog.RECIPES:
		if mode == Mode.CONSOLE or GameState.is_recipe_on_menu(id):
			result.append(id)
	return result

const SECTION_KEYS := ["ui.console.tab_shop", "ui.console.tab_menu", "ui.console.tab_brigade", "ui.console.tab_takeout", "ui.console.tab_ingredients"]
const SECTION_TITLES := ["ui.console.title", "ui.console.section_menu", "ui.console.section_brigade", "ui.console.section_takeout", "ui.console.section_ingredients"]

func _refresh_chrome() -> void:
	var console := mode == Mode.CONSOLE
	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.text = Loc.ui("ui.console.hint") if console else 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.menu.count") % [GameState.menu_enabled_recipes.size(), GameState.get_menu_slots()]
	if icon_slot != null:
		icon_slot.visible = console
	if tab_row != null:
		tab_row.visible = console
	if console:
		_build_tabs()
		_refresh_icon()

# 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)
	icon.setup(section, not shop_closed)

func _build_tabs() -> void:
	tab_buttons.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 range(SECTION_KEYS.size()):
		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)

func _select_section(new_section: int) -> void:
	if section == new_section:
		return
	section = new_section
	build_list()
	call_deferred("_focus_tab")

func _focus_tab() -> void:
	if section >= 0 and section < tab_buttons.size() and is_instance_valid(tab_buttons[section]):
		tab_buttons[section].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 mode != Mode.CONSOLE:
		return
	var focused := get_viewport().gui_get_focus_owner() as Button
	if focused != null and tab_buttons.has(focused):
		_select_section(wrapi(section + step, 0, tab_buttons.size()))
		return
	_confirm_focused()

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

# L'interrupteur d'ouverture. Il vit dans la liste plutôt qu'au-dessus d'elle,
# pour que la navigation clavier l'atteigne sans cas particulier.
# Deux interrupteurs, un par rideau. Leur combinaison donne les quatre états
# d'ouverture, et la ligne d'état les nomme pour qu'on ne les déduise pas.
# Tout est ajouté d'un coup, pas en différé: un bouton enregistré comme ligne
# navigable doit déjà être dans l'arbre quand le focus se pose dessus.
func _build_switch_row() -> Control:
	var block := VBoxContainer.new()
	block.add_theme_constant_override("separation", 6)
	var row := HBoxContainer.new()
	row.add_theme_constant_override("separation", 8)
	var button := Button.new()
	button.custom_minimum_size = Vector2(268, 44)
	button.text = Loc.ui("ui.console.closed") if shop_closed else Loc.ui("ui.console.open")
	button.tooltip_text = Loc.ui("ui.console.switch_tooltip")
	button.add_theme_color_override("font_color", CLOSED_COLOR if shop_closed else OPEN_COLOR)
	button.pressed.connect(_press_switch)
	button.mouse_entered.connect(button.grab_focus)
	row.add_child(button)
	var rate := Label.new()
	rate.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	rate.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	rate.text = Loc.ui("ui.console.rate") % served_per_minute
	rate.modulate = DIMMED
	row.add_child(rate)
	block.add_child(row)
	_register_row(button, "", ROW_SWITCH)
	if takeout_installed:
		var counter_button := Button.new()
		counter_button.custom_minimum_size = Vector2(268, 44)
		counter_button.text = Loc.ui("ui.console.takeout_closed") if takeout_closed else Loc.ui("ui.console.takeout_open")
		counter_button.tooltip_text = Loc.ui("ui.console.takeout_switch_tooltip")
		counter_button.add_theme_color_override("font_color", CLOSED_COLOR if takeout_closed else OPEN_COLOR)
		counter_button.pressed.connect(_press_takeout_switch)
		counter_button.mouse_entered.connect(counter_button.grab_focus)
		block.add_child(counter_button)
		_register_row(counter_button, "", ROW_TAKEOUT_SWITCH)
	# Le mode construction est un geste du magasin, pas un réglage: il ferme la
	# console et rend la salle au joueur, curseur en main. Sans le permis, le
	# bouton n'existe pas: mieux vaut ne rien offrir qu'offrir un refus.
	if build_permit:
		block.add_child(_build_mode_row())
	block.add_child(_section_label(Loc.ui("ui.console.open_state") % Loc.ui(opening_state_key()), true))
	# Le débit réel n'était visible nulle part ailleurs; il tient sur la ligne.
	block.add_child(_section_label(Loc.ui("ui.console.rate") % served_per_minute, true))
	return block

func _build_mode_row() -> Control:
	var build_button := Button.new()
	build_button.custom_minimum_size = Vector2(268, 44)
	build_button.text = Loc.ui("ui.console.build_mode")
	build_button.tooltip_text = Loc.ui("ui.console.build_tooltip")
	build_button.pressed.connect(_press_build_mode)
	build_button.mouse_entered.connect(build_button.grab_focus)
	_register_row(build_button, "", ROW_BUILD)
	return build_button

# 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()

func _build_recipe_row(id: String) -> Control:
	var recipe: Dictionary = DataCatalog.RECIPES[id]
	var cookable := GameState.can_cook(id)
	var row := HBoxContainer.new()
	row.add_theme_constant_override("separation", 8)
	var focusable: Button = null
	if mode != Mode.CONSOLE:
		# Au four, la ligne est le bouton de cuisson.
		focusable = Button.new()
		focusable.custom_minimum_size = Vector2(168, 44)
		focusable.text = DataCatalog.recipe_name(id)
		focusable.clip_text = true
		focusable.tooltip_text = Loc.ui("ui.menu.cook_tooltip") if cookable else Loc.ui("ui.menu.missing_tooltip")
		# A recipe that cannot be cooked is dimmed but stays browsable.
		focusable.modulate = Color.WHITE if cookable else DIMMED
		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, et la
		# bascule est ce qu'on vient régler.
		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.recipe_name(id)
		name_label.modulate = Color.WHITE if cookable else DIMMED
		row.add_child(name_label)

	row.add_child(_column("%.1fs" % float(recipe.cook_time), 58, cookable))
	row.add_child(_column("%d c" % int(recipe.sale_price), 54, cookable))
	row.add_child(_ingredient_column(recipe))

	if mode == Mode.CONSOLE:
		focusable = Button.new()
		focusable.custom_minimum_size = Vector2(96, 44)
		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))
		else:
			focusable.toggle_mode = true
			focusable.button_pressed = GameState.is_recipe_on_menu(id)
			focusable.text = Loc.ui("ui.menu.on_menu") if focusable.button_pressed else Loc.ui("ui.menu.off_menu")
			focusable.tooltip_text = Loc.ui("ui.menu.offer_tooltip")
			focusable.toggled.connect(_on_menu_toggled.bind(id))
		focusable.mouse_entered.connect(focusable.grab_focus)
		row.add_child(focusable)

	_register_row(focusable, id, ROW_RECIPE)
	return row

# La brigade. Le catalogue entier est montré, même sans poste ouvert: c'est ce
# qui apprend au joueur ce qu'une upgrade lui donnerait.
func _build_brigade() -> void:
	list.add_child(_section_label(Loc.ui("ui.console.section_brigade")))
	var role_tabs := HBoxContainer.new()
	role_tabs.add_theme_constant_override("separation", 10)
	for role: String in DataCatalog.EMPLOYEE_ROLES:
		var role_button := Button.new()
		role_button.custom_minimum_size = Vector2(250, 40)
		role_button.text = ("● " if role == brigade_role_filter else "") + _role_name(role)
		role_button.pressed.connect(_select_brigade_role.bind(role))
		role_button.mouse_entered.connect(role_button.grab_focus)
		role_tabs.add_child(role_button)
		_register_row(role_button, role, ROW_ROLE)
	list.add_child(role_tabs)
	var slots := GameState.employee_slots(brigade_role_filter)
	list.add_child(_section_label(
		Loc.ui("ui.console.slots") % [_role_name(brigade_role_filter), GameState.hired_of_role(brigade_role_filter).size(), slots], true))
	for id: String in DataCatalog.EMPLOYEES:
		if str(DataCatalog.EMPLOYEES[id].get("role", "")) == brigade_role_filter:
			list.add_child(_build_employee_row(id, brigade_role_filter, slots))
	if slots <= 0:
		list.add_child(_section_label(Loc.ui("ui.console.no_slots"), true))

func _select_brigade_role(role: String) -> void:
	if not DataCatalog.EMPLOYEE_ROLES.has(role):
		return
	brigade_role_filter = role
	build_list()

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))

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)
		var name_label := Label.new()
		name_label.custom_minimum_size = Vector2(360, 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
		list.add_child(row)

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 _build_employee_row(id: String, role: String, slots: int) -> Control:
	var definition: Dictionary = DataCatalog.EMPLOYEES[id]
	var hired := GameState.employee_is_hired(id)
	var cost := int(definition.get("hire_cost", 0))
	var allowed := GameState.conditions_met(definition.get("conditions", []))
	var has_room := GameState.hired_of_role(role).size() < slots
	var affordable := GameState.coins >= cost
	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)
	row.add_child(_column("%d c" % cost, 58, affordable or hired))

	if role == "server":
		var profile := VBoxContainer.new()
		profile.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		profile.add_theme_constant_override("separation", 2)
		profile.add_child(_server_preference_panel(definition, allowed))
		var traits := Label.new()
		traits.text = _skill_summary(definition, 2)
		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)
	else:
		var skills := Label.new()
		skills.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		skills.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
		skills.text = _skill_summary(definition)
		skills.modulate = Color.WHITE if allowed else DIMMED
		row.add_child(skills)

	var button := Button.new()
	button.custom_minimum_size = Vector2(150, 44)
	if hired:
		button.text = Loc.ui("ui.console.dismiss")
		button.tooltip_text = Loc.ui("ui.console.dismiss_tooltip")
		button.pressed.connect(_press_dismiss.bind(id))
	else:
		button.text = Loc.ui("ui.console.hire")
		# Une ligne refusée reste pressable: le refus dit alors pourquoi, ce
		# qu'un bouton grisé ne dirait jamais.
		button.tooltip_text = _hire_tooltip(allowed, has_room, affordable)
		button.modulate = Color.WHITE if (allowed and has_room and affordable) 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(allowed: bool, has_room: bool, affordable: bool) -> String:
	if not allowed:
		return Loc.ui("ui.console.hire_locked")
	if not has_room:
		return Loc.ui("ui.console.hire_no_slot")
	if not affordable:
		return Loc.ui("ui.console.hire_no_coins")
	return Loc.ui("ui.console.hire_tooltip")

func _role_name(role: String) -> String:
	return Loc.ui("ui.role.cook") if role == "cook" else Loc.ui("ui.role.server")

# Four compact gauges are easier to compare at hiring time than another list of
# prose stats. Their common 0–5 scale controls choice only, never performance.
func _server_preference_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 values: Dictionary = definition.get("task_preferences", {})
	for preference: String in DataCatalog.SERVER_TASK_PREFERENCES:
		var value := clampf(float(values.get(preference, 1.0)), 0.0, 5.0)
		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 %.1f" % [Loc.ui("ui.server_preference.%s" % preference), value]
		title.add_theme_font_size_override("font_size", 9)
		title.tooltip_text = title.text
		gauge.add_child(title)
		var bar := ProgressBar.new()
		bar.custom_minimum_size = Vector2(74, 7)
		bar.max_value = 5.0
		bar.value = 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 meilleures compétences, et son
# affinité de poste s'il en a une — c'est elle qui fait d'un cuisinier un
# épicier, sans qu'aucun champ ne le déclare.
func _skill_summary(definition: Dictionary, skill_limit: int = 3) -> String:
	var parts: Array[String] = []
	for entry: Dictionary in _ranked_pairs(definition.get("skills", {})):
		if parts.size() >= skill_limit:
			break
		parts.append("%s ×%.1f" % [Loc.ui("ui.skill.%s" % entry.key), float(entry.value)])
	var affinities: Array[Dictionary] = _ranked_pairs(definition.get("station_affinity", {}))
	if not affinities.is_empty():
		parts.append("%s ×%.1f" % [Loc.ui("ui.station_kind.%s" % affinities[0].key), float(affinities[0].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

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.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])
		var owned := GameState.get_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.text = "%s %d/%d" % [DataCatalog.ingredient_name(ingredient_id), owned, required]
		box.add_child(entry)
	return box

func _on_menu_toggled(enabled: bool, id: String) -> void:
	# The request can be refused when every menu slot is already taken, so the
	# list is rebuilt from the resulting state rather than from the click.
	GameState.set_recipe_on_menu(id, enabled)
	build_list()

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

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

func _press_dismiss(id: String) -> void:
	dismiss_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:
	for button: Button in row_buttons:
		if is_instance_valid(button) and not button.disabled:
			_focus_button(button)
			return
	close_button.grab_focus()

func _navigate(direction: int) -> void:
	var focused := get_viewport().gui_get_focus_owner() as Button
	# Depuis la barre d'onglets: vers le bas on entre dans la liste, vers le
	# haut on sort par le bouton de fermeture.
	if focused != null and tab_buttons.has(focused):
		if direction > 0 and not row_buttons.is_empty():
			_focus_button(row_buttons[0])
		else:
			close_button.grab_focus()
		return
	# Une carte vidée à la console ne laisse que le bouton de fermeture: sans
	# cette garde, la navigation indexerait un tableau vide.
	if row_buttons.is_empty():
		_focus_top()
		return
	if focused == close_button:
		_focus_button(row_buttons[0] if direction > 0 else row_buttons[-1])
		return
	var index := row_buttons.find(focused)
	if index < 0:
		_focus_first_available()
		return
	var target_index := index + direction
	if target_index < 0:
		_focus_top()
	elif target_index >= row_buttons.size():
		_focus_button(close_button)
	else:
		_focus_button(row_buttons[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 mode == Mode.CONSOLE and not tab_buttons.is_empty():
		_focus_tab()
		return
	close_button.grab_focus()

func _focus_button(button: Button) -> void:
	button.grab_focus()
	if button != close_button:
		scroll.call_deferred("ensure_control_visible", button)

func _get_navigation_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)
	available.append(close_button)
	return available

func _confirm_focused() -> void:
	var focused := get_viewport().gui_get_focus_owner() as Button
	if focused == close_button:
		close_button.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.can_cook(id):
		GameState.toast_requested.emit(Loc.ui("ui.menu.missing_for") % DataCatalog.recipe_name(id))
		return
	recipe_selected.emit(id)
	close()
