extends Node

signal state_changed
signal toast_requested(message: String)
signal restaurant_simulation_advanced(seconds: float)

const SAVE_PATH := "user://savegame.json"

var coins: int = 0
var ingredients: Dictionary = {}
var purchased_upgrades: Array[String] = []
var variable_base_bonuses: Dictionary = {}
var variable_coefficient_bonuses: Dictionary = {}
var fact_values: Dictionary = {}
var pending_toast := ""
var display_mode_id := "1920x1080"
var language := "en"
var restaurant_operations: Dictionary = {
	"automated_cooking": false,
	"automated_service": false,
	"automated_cleanup": false,
	"automated_supply": false,
	"pending_simulation_seconds": 0.0,
}
var restaurant_simulation_accumulator := 0.0
# Physical plates keep their state and their spot in the restaurant across scene
# changes. Stored as plain dictionaries so they survive a save/load round trip.
var restaurant_plates: Array = []
# Les convives survivent au changement de scène comme les assiettes. Sans ça,
# ouvrir l'arbre d'upgrades trois secondes vidait la salle et laissait derrière
# une assiette sans propriétaire.
var restaurant_customers: Array = []
# Per-entity runtime counters, addressed as <collection>.<id>.<field>. Phase 1
# only reads and writes them; phase 2 makes the gameplay feed them.
var entity_counters: Dictionary = {}
# Instanced modifiers: buffs, debuffs and anything with a lifetime of its own.
# Derived sources (purchased upgrades) are never stored here, they are computed.
var active_modifiers: Array = []
# Game clock used by expirations. It does not advance while the tree is paused,
# so a curse never melts away behind a menu.
var game_time := 0.0
var _stat_cache: Dictionary = {}
# Effets temporaires du joueur: le noeud joueur est recree a chaque scene, donc
# son porteur d'effets doit vivre ici, comme les assiettes du restaurant.
var player_effects: Array = []
# Recettes actuellement proposées aux clients. Le nombre simultané est plafonné
# par la statistique menu_slots, elle-même améliorable.
var menu_enabled_recipes: Array[String] = []
# Volatile namespaces reachable through run.<field> and meta.<field>.
var run_values: Dictionary = {}
var meta_values: Dictionary = {}

func _ready() -> void:
	process_mode = Node.PROCESS_MODE_ALWAYS
	DataCatalog.load_master_data()
	_ensure_runtime_values()
	load_game()
	Loc.set_language(language)
	ensure_menu_defaults()
	call_deferred("apply_display_mode")

func _process(delta: float) -> void:
	# This autoload runs even while paused, so the game clock has to opt out of
	# pause explicitly; expirations are measured against it.
	var tree := get_tree()
	if tree != null and not tree.paused:
		game_time += delta
		_expire_modifiers()
	# GameState is an autoload, so this clock keeps advancing in the dungeon or
	# any future scene. An automation simulator can consume the accumulated time.
	if not restaurant_operations_can_continue():
		return
	restaurant_simulation_accumulator += delta
	if restaurant_simulation_accumulator < 1.0:
		return
	var elapsed := floorf(restaurant_simulation_accumulator)
	restaurant_simulation_accumulator -= elapsed
	restaurant_operations.pending_simulation_seconds = float(restaurant_operations.get("pending_simulation_seconds", 0.0)) + elapsed
	restaurant_simulation_advanced.emit(elapsed)

func restaurant_operations_can_continue() -> bool:
	return bool(restaurant_operations.get("automated_cooking", false)) \
		and bool(restaurant_operations.get("automated_service", false)) \
		and bool(restaurant_operations.get("automated_cleanup", false)) \
		and bool(restaurant_operations.get("automated_supply", false))

func set_restaurant_automation(capability: String, enabled: bool) -> void:
	if not ["automated_cooking", "automated_service", "automated_cleanup", "automated_supply"].has(capability):
		push_error("Unknown restaurant automation capability: " + capability)
		return
	restaurant_operations[capability] = enabled
	_changed()

func take_pending_restaurant_simulation_seconds() -> float:
	var elapsed := float(restaurant_operations.get("pending_simulation_seconds", 0.0))
	restaurant_operations.pending_simulation_seconds = 0.0
	return elapsed

func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("pause"):
		get_tree().paused = not get_tree().paused
		toast_requested.emit("PAUSED — press Escape to resume" if get_tree().paused else "Resumed")
		get_viewport().set_input_as_handled()

func add_coins(amount: int) -> void:
	coins = max(0, coins + amount)
	_changed()

func spend_coins(amount: int) -> bool:
	if amount < 0 or coins < amount:
		return false
	coins -= amount
	_changed()
	return true

func add_ingredient(id: String, amount: int = 1) -> void:
	if not DataCatalog.INGREDIENTS.has(id):
		push_error("Unknown ingredient: " + id)
		return
	ingredients[id] = get_ingredient_amount(id) + amount
	toast_requested.emit("Picked up %s x%d" % [DataCatalog.ingredient_name(id), amount])
	_changed()

func get_ingredient_amount(id: String) -> int:
	return int(ingredients.get(id, 0))

func restore_ingredient_inventory(snapshot: Dictionary) -> void:
	ingredients = snapshot.duplicate(true)
	_changed()

func set_pending_toast(message: String) -> void:
	pending_toast = message

func consume_pending_toast() -> String:
	var message := pending_toast
	pending_toast = ""
	return message

func set_language(code: String) -> void:
	# La langue vit dans la sauvegarde; Loc n'est que le résolveur.
	if not Loc.set_language(code):
		return
	language = code
	_changed()

func set_display_mode(id: String) -> void:
	if not ["1280x720", "1600x900", "1920x1080", "native"].has(id):
		push_error("Unknown display mode: " + id)
		return
	display_mode_id = id
	apply_display_mode()
	save_game()
	state_changed.emit()

func apply_display_mode() -> void:
	if DisplayServer.get_name() == "headless" or is_editor_embedded():
		return
	if display_mode_id == "native":
		DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
		return
	var parts := display_mode_id.split("x")
	if parts.size() != 2:
		return
	var requested_size := Vector2i(int(parts[0]), int(parts[1]))
	DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
	DisplayServer.window_set_size(requested_size)
	var screen := DisplayServer.window_get_current_screen()
	var screen_position := DisplayServer.screen_get_position(screen)
	var screen_size := DisplayServer.screen_get_size(screen)
	DisplayServer.window_set_position(screen_position + (screen_size - requested_size) / 2)

func is_editor_embedded() -> bool:
	# Godot's embedded game is parented into the editor through --wid. It has
	# no independently movable/resizable OS window, so DisplayServer operations
	# would only emit "Embedded window can't be resized/moved" warnings.
	var arguments := OS.get_cmdline_args()
	for index: int in range(arguments.size()):
		var argument := str(arguments[index])
		if argument == "--wid" or argument.begins_with("--wid="):
			return true
	return false

func get_menu_slots() -> int:
	return maxi(1, int(resolve_game_variable("menu_slots")))

func is_recipe_on_menu(recipe_id: String) -> bool:
	return menu_enabled_recipes.has(recipe_id)

# Un menu vide n'aurait aucun sens au premier lancement: on garnit les places
# disponibles avec les premières recettes du catalogue.
func ensure_menu_defaults() -> void:
	var cleaned: Array[String] = []
	for recipe_id: String in menu_enabled_recipes:
		if DataCatalog.RECIPES.has(recipe_id) and not cleaned.has(recipe_id):
			cleaned.append(recipe_id)
	menu_enabled_recipes = cleaned
	if menu_enabled_recipes.is_empty():
		for recipe_id: String in DataCatalog.RECIPES:
			if menu_enabled_recipes.size() >= get_menu_slots():
				break
			menu_enabled_recipes.append(recipe_id)

func set_recipe_on_menu(recipe_id: String, enabled: bool) -> bool:
	if not DataCatalog.RECIPES.has(recipe_id):
		return false
	if not enabled:
		menu_enabled_recipes.erase(recipe_id)
		_changed()
		return true
	if menu_enabled_recipes.has(recipe_id):
		return true
	if menu_enabled_recipes.size() >= get_menu_slots():
		toast_requested.emit(Loc.ui("ui.restaurant.menu_full") % get_menu_slots())
		return false
	menu_enabled_recipes.append(recipe_id)
	_changed()
	return true

func can_cook(recipe_id: String) -> bool:
	var recipe: Dictionary = DataCatalog.RECIPES.get(recipe_id, {})
	if recipe.is_empty():
		return false
	for id: String in recipe.ingredients:
		if get_ingredient_amount(id) < int(recipe.ingredients[id]):
			return false
	return true

func consume_recipe(recipe_id: String) -> bool:
	if not can_cook(recipe_id):
		return false
	var recipe: Dictionary = DataCatalog.RECIPES[recipe_id]
	for id: String in recipe.ingredients:
		var spent := int(recipe.ingredients[id])
		ingredients[id] = get_ingredient_amount(id) - spent
		add_entity_counter("ingredients", id, "total_spent", spent)
	_changed()
	return true

func purchase_upgrade(id: String) -> bool:
	if purchased_upgrades.has(id):
		return false
	var upgrade: Dictionary = DataCatalog.UPGRADES.get(id, {})
	if upgrade.is_empty():
		push_error("Unknown upgrade: " + id)
		return false
	var state := get_upgrade_state(id)
	if state != "available":
		if state == "insufficient":
			toast_requested.emit("Not enough coins")
		else:
			toast_requested.emit("Upgrade conditions are not met")
		return false
	var cost := int(upgrade.get("cost", 0))
	if coins < cost:
		return false
	coins -= cost
	purchased_upgrades.append(id)
	Registry.run_hook("upgrade_nodes", id, "on_purchase")
	toast_requested.emit("Purchased %s" % upgrade.display_name)
	_changed()
	return true

func has_upgrade(id: String) -> bool:
	return purchased_upgrades.has(id)

func resolve_game_variable(variable_id: String, context_base: Variant = null) -> Variant:
	return get_game_variable_components(variable_id, context_base).final_value

func get_game_variable(variable_id: String) -> Variant:
	return resolve_game_variable(variable_id)

func get_game_variable_components(variable_id: String, context_base: Variant = null) -> Dictionary:
	var definition: Dictionary = DataCatalog.GAME_VARIABLES.get(variable_id, {})
	if definition.is_empty():
		return {"base_value": 0.0, "base_bonus": 0.0, "base_coefficient": 1.0, "coefficient_bonus": 0.0, "coefficient": 1.0, "final_value": 0.0}
	var base_value := float(definition.get("base_value", 0.0) if context_base == null else context_base)
	var base_coefficient := float(definition.get("base_coefficient", 1.0))
	var totals := ModifierPool.accumulate(
		collect_modifiers(variable_id),
		float(variable_base_bonuses.get(variable_id, definition.get("base_bonus", 0.0))),
		float(variable_coefficient_bonuses.get(variable_id, definition.get("coefficient_bonus", 0.0))))
	var base_bonus := float(totals.flat)
	var coefficient_bonus := float(totals.scaling)
	var coefficient := base_coefficient + coefficient_bonus
	var final_value := ModifierPool.combine(base_value, base_coefficient, totals)
	if definition.has("minimum"):
		final_value = maxf(final_value, float(definition.minimum))
	if definition.has("maximum"):
		final_value = minf(final_value, float(definition.maximum))
	match str(definition.get("rounding", "round" if str(definition.get("type", "float")) == "int" else "none")):
		"round": final_value = float(roundi(final_value))
		"floor": final_value = floorf(final_value)
		"ceil": final_value = ceilf(final_value)
	var typed_final: Variant = int(final_value) if str(definition.get("type", "float")) == "int" else final_value
	return {
		"base_value": base_value,
		"base_bonus": base_bonus,
		"base_coefficient": base_coefficient,
		"coefficient_bonus": coefficient_bonus,
		"coefficient": coefficient,
		# The four layers, named as the tool names them.
		"flat": float(totals.flat),
		"scaling": float(totals.scaling),
		"global": float(totals.global),
		"override": float(totals.override),
		"final_value": typed_final,
	}

# Gathers every modifier touching a stat: derived sources are recomputed from
# the state that already exists, instanced ones are read from the pool and
# filtered by expiry without being deleted here.
func collect_modifiers(variable_id: String) -> Array:
	var entries: Array = []
	for upgrade_id: String in purchased_upgrades:
		var upgrade: Dictionary = DataCatalog.UPGRADES.get(upgrade_id, {})
		for modifier: Dictionary in upgrade.get("modifiers", []):
			if str(modifier.get("variable", "")) == variable_id:
				var entry := modifier.duplicate()
				entry["source"] = "upgrade:%s" % upgrade_id
				entries.append(entry)
	for modifier: Variant in active_modifiers:
		if modifier is Dictionary and str(modifier.get("variable", "")) == variable_id and ModifierPool.is_live(modifier, game_time):
			entries.append(modifier)
	return entries

# --- Instanced modifiers --------------------------------------------------

func add_modifier(source: String, variable_id: String, layer: String, value: float, duration: float = 0.0, operation: String = "add") -> bool:
	if not DataCatalog.GAME_VARIABLES.has(variable_id) or not ModifierPool.is_valid_layer(layer):
		push_error("Invalid modifier for '%s' on layer '%s'" % [variable_id, layer])
		return false
	active_modifiers.append({
		"source": source, "variable": variable_id, "layer": layer,
		"operation": operation, "value": value,
		"expires_at": null if duration <= 0.0 else game_time + duration,
	})
	_changed(false)
	return true

# Removing by source drops every layer that source had posted at once: a weapon
# granting both a flat bonus and a scaling bonus is unequipped in one call.
func remove_modifiers_by_source(source: String) -> int:
	var kept: Array = []
	var removed := 0
	for modifier: Variant in active_modifiers:
		if modifier is Dictionary and str(modifier.get("source", "")) == source:
			removed += 1
		else:
			kept.append(modifier)
	active_modifiers = kept
	if removed > 0:
		_changed(false)
	return removed

# Retire toutes les sources partageant un prefixe, ce qui permet de republier
# d'un bloc les modificateurs venant des effets.
func remove_modifiers_by_source_prefix(prefix: String) -> int:
	var kept: Array = []
	var removed := 0
	for modifier: Variant in active_modifiers:
		if modifier is Dictionary and str(modifier.get("source", "")).begins_with(prefix):
			removed += 1
		else:
			kept.append(modifier)
	active_modifiers = kept
	if removed > 0:
		_changed(false)
	return removed

func has_modifier_source(source: String) -> bool:
	for modifier: Variant in active_modifiers:
		if modifier is Dictionary and str(modifier.get("source", "")) == source and ModifierPool.is_live(modifier, game_time):
			return true
	return false

# The tick prunes what the read already ignores. Missing a tick is harmless.
func _expire_modifiers() -> void:
	if active_modifiers.is_empty():
		return
	var kept: Array = []
	for modifier: Variant in active_modifiers:
		if modifier is Dictionary and ModifierPool.is_live(modifier, game_time):
			kept.append(modifier)
	if kept.size() != active_modifiers.size():
		active_modifiers = kept
		_changed(false)

# Compatibility helper for older scripts. New code should request the final
# value directly through resolve_game_variable().
func get_effect_total(effect: String, reference_base: float = 0.0) -> float:
	if not is_zero_approx(reference_base):
		return float(resolve_game_variable(effect, reference_base)) - reference_base
	var definition: Dictionary = DataCatalog.GAME_VARIABLES.get(effect, {})
	return float(resolve_game_variable(effect)) - float(definition.get("base_value", 0.0))

func _apply_numeric_modifier(current: float, modifier: Dictionary) -> float:
	match str(modifier.get("operation", "add")):
		"set": return float(modifier.get("value", current))
		"multiply": return current * float(modifier.get("value", 1.0))
		_: return current + float(modifier.get("value", 0.0))

func set_game_variable_base_bonus(id: String, value: float) -> void:
	if not DataCatalog.GAME_VARIABLES.has(id):
		push_error("Unknown game variable: " + id)
		return
	variable_base_bonuses[id] = value
	_changed()

func set_game_variable_coefficient_bonus(id: String, value: float) -> void:
	if not DataCatalog.GAME_VARIABLES.has(id):
		push_error("Unknown game variable: " + id)
		return
	variable_coefficient_bonuses[id] = value
	_changed()

func set_game_variable(id: String, value: Variant) -> void:
	set_game_variable_base_bonus(id, float(value))

func set_game_variable_coefficient(id: String, value: float) -> void:
	var definition: Dictionary = DataCatalog.GAME_VARIABLES.get(id, {})
	set_game_variable_coefficient_bonus(id, value - float(definition.get("base_coefficient", 1.0)))

func _counter_key(collection: String, id: String, field: String) -> String:
	return "%s.%s.%s" % [collection, id, field]

func get_entity_counter(collection: String, id: String, field: String, fallback: Variant = 0) -> Variant:
	return entity_counters.get(_counter_key(collection, id, field), fallback)

func set_entity_counter(collection: String, id: String, field: String, value: Variant) -> bool:
	entity_counters[_counter_key(collection, id, field)] = value
	_changed(false)
	return true

func add_entity_counter(collection: String, id: String, field: String, amount: int = 1) -> void:
	var key := _counter_key(collection, id, field)
	entity_counters[key] = int(entity_counters.get(key, 0)) + amount
	_changed(false)

func get_scoped_value(scope: String, field: String, fallback: Variant = null) -> Variant:
	return (run_values if scope == "run" else meta_values).get(field, fallback)

func set_scoped_value(scope: String, field: String, value: Variant) -> void:
	if scope == "run":
		run_values[field] = value
	else:
		meta_values[field] = value

func clear_run_values() -> void:
	run_values.clear()

# --- Portées de progression -----------------------------------------------
#
# Trois durées de vie distinctes:
#   run    une plongée      — vidée à chaque retour au restaurant
#   dimension  une dimension — remise à zéro quand son boss final tombe
#   meta   la sauvegarde    — traverse tous les cycles
#
# La portée est déclarée par la donnée (`scope` sur un nœud d'upgrade ou un
# fait), jamais codée en dur ici: sans ça, la remise à zéro deviendrait une
# liste d'exceptions qui se désynchronise dès le premier ajout dans l'outil.

func get_dimension_index() -> int:
	return int(get_scoped_value("meta", "dimension_index", 0))

func is_meta_upgrade(id: String) -> bool:
	return str(DataCatalog.UPGRADES.get(id, {}).get("scope", "dimension")) == "meta"

func is_meta_fact(id: String) -> bool:
	return str(DataCatalog.GAME_FACTS.get(id, {}).get("scope", "dimension")) == "meta"

# Assurance de plongée: la fraction `insurance_rate` de ce qui a été amassé
# survit à une mise hors service. Le tirage porte sur les unités et non sur les
# types, sinon l'assurance effacerait systématiquement le tas le plus gros.
func apply_dive_insurance(entry_ingredients: Dictionary, entry_coins: int) -> Dictionary:
	var rate := clampf(float(resolve_game_variable("insurance_rate")), 0.0, 1.0)
	var gathered: Array[String] = []
	for id: String in ingredients:
		var surplus := int(ingredients[id]) - int(entry_ingredients.get(id, 0))
		for _index: int in range(maxi(0, surplus)):
			gathered.append(id)
	gathered.shuffle()
	var kept_count := int(floor(float(gathered.size()) * rate))
	ingredients = entry_ingredients.duplicate(true)
	for index: int in range(kept_count):
		var id := gathered[index]
		ingredients[id] = int(ingredients.get(id, 0)) + 1
	var kept_coins := int(floor(float(maxi(0, coins - entry_coins)) * rate))
	coins = entry_coins + kept_coins
	_changed()
	return {
		"rate": rate, "coins": kept_coins, "materials": kept_count,
		"lost_materials": gathered.size() - kept_count,
	}

# Fin d'une plongée: seul ce qui appartient à la plongée disparaît. Appelé quelle
# que soit la raison du retour — stamina, mise hors service ou victoire.
func end_run() -> void:
	clear_run_values()
	remove_modifiers_by_source_prefix("run:")
	_changed()

# Victoire sur le boss final d'une dimension: tout son avancement repart à
# zéro, les acquis `meta` survivent, et la dimension suivante commence.
func complete_dimension() -> int:
	end_run()
	var kept: Array[String] = []
	for id: String in purchased_upgrades:
		if is_meta_upgrade(id):
			kept.append(id)
	purchased_upgrades = kept
	for id: String in DataCatalog.GAME_FACTS:
		if not is_meta_fact(id):
			fact_values[id] = DataCatalog.GAME_FACTS[id].get("default_value", false)
	# L'économie repart à zéro elle aussi: sans ça, la dimension suivante
	# démarre riche et les cycles perdent leur enjeu.
	coins = 0
	ingredients.clear()
	entity_counters.clear()
	restaurant_plates.clear()
	restaurant_customers.clear()
	menu_enabled_recipes.clear()
	active_modifiers.clear()
	player_effects.clear()
	variable_base_bonuses.clear()
	variable_coefficient_bonuses.clear()
	_ensure_runtime_values()
	ensure_menu_defaults()
	# Ce compteur est un fait de portée meta: il traverse la remise à zéro
	# ci-dessus, contrairement à tout le reste de l'avancement.
	if DataCatalog.GAME_FACTS.has("dimensions_cleared"):
		fact_values["dimensions_cleared"] = int(get_fact_value("dimensions_cleared")) + 1
	var next_dimension := get_dimension_index() + 1
	set_scoped_value("meta", "dimension_index", next_dimension)
	_changed()
	return next_dimension

func get_fact_value(id: String) -> Variant:
	var definition: Dictionary = DataCatalog.GAME_FACTS.get(id, {})
	return fact_values.get(id, definition.get("default_value", false))

func set_fact_value(id: String, value: Variant) -> void:
	if not DataCatalog.GAME_FACTS.has(id):
		push_error("Unknown game fact: " + id)
		return
	fact_values[id] = value
	_changed()

func get_upgrade_state(id: String) -> String:
	if has_upgrade(id):
		return "purchased"
	var node: Dictionary = DataCatalog.UPGRADES.get(id, {})
	if node.is_empty() or not conditions_met(node.get("visibility_conditions", [])):
		return "invisible"
	if not conditions_met(node.get("reveal_conditions", [])):
		return "mystery"
	if not conditions_met(node.get("unlock_conditions", [])):
		return "locked"
	return "available" if coins >= int(node.get("cost", 0)) else "insufficient"

func conditions_met(conditions: Variant) -> bool:
	if not conditions is Array:
		return false
	for condition: Variant in conditions:
		if not condition is Dictionary or not _condition_met(condition):
			return false
	return true

func _condition_met(condition: Dictionary) -> bool:
	# Conditions are resolved through the address space. The legacy {source, id}
	# shape is translated on the fly, so no data file has to change yet.
	var path := Registry.condition_path(condition)
	if path.is_empty() or not AddressCatalog.is_valid(path):
		return false
	var actual: Variant = Registry.read(path)
	var expected: Variant = condition.get("value", true)
	match str(condition.get("operator", "==")):
		"truthy": return bool(actual)
		"falsy": return not bool(actual)
		"==": return actual == expected
		"!=": return actual != expected
		">=": return float(actual) >= float(expected)
		"<=": return float(actual) <= float(expected)
		">": return float(actual) > float(expected)
		"<": return float(actual) < float(expected)
	return false

func record_stat(id: String, amount: int = 1) -> void:
	# Facts are the single store for counters. The parallel `statistics`
	# dictionary was removed in save version 6.
	if not DataCatalog.GAME_FACTS.has(id):
		push_error("Unknown game fact counter: " + id)
		return
	fact_values[id] = int(get_fact_value(id)) + amount
	_changed(false)

# Convenience for the gameplay events that feed both a global fact and a
# per-entity counter, which is the common shape now that entities are addressed.
func record_entity_event(collection: String, id: String, field: String, fact_id: String = "", amount: int = 1) -> void:
	add_entity_counter(collection, id, field, amount)
	if not fact_id.is_empty() and DataCatalog.GAME_FACTS.has(fact_id):
		fact_values[fact_id] = int(get_fact_value(fact_id)) + amount
		_changed(false)

func save_game() -> void:
	var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
	if file == null:
		push_error("Could not open save file: " + SAVE_PATH)
		return
	var payload := {
		"version": 7,
		"coins": coins,
		"ingredients": ingredients,
		"purchased_upgrades": purchased_upgrades,
		"entity_counters": entity_counters,
		"active_modifiers": active_modifiers,
		"player_effects": player_effects,
		"menu_enabled_recipes": menu_enabled_recipes,
		"game_time": game_time,
		"meta_values": meta_values,
		"variable_base_bonuses": variable_base_bonuses,
		"variable_coefficient_bonuses": variable_coefficient_bonuses,
		"fact_values": fact_values,
		"display_mode": display_mode_id,
		"language": language,
		"restaurant_operations": restaurant_operations,
		"restaurant_plates": restaurant_plates,
		"restaurant_customers": restaurant_customers,
	}
	file.store_string(JSON.stringify(payload, "  "))

func load_game() -> void:
	if not FileAccess.file_exists(SAVE_PATH):
		return
	var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
	if file == null:
		push_warning("Could not read save file.")
		return
	var parsed: Variant = JSON.parse_string(file.get_as_text())
	if not parsed is Dictionary:
		push_warning("Save file is malformed; using default progress.")
		return
	coins = max(0, int(parsed.get("coins", 0)))
	ingredients = parsed.get("ingredients", {}).duplicate()
	purchased_upgrades.clear()
	for value: Variant in parsed.get("purchased_upgrades", []):
		if DataCatalog.UPGRADES.has(str(value)):
			purchased_upgrades.append(str(value))
	if not purchased_upgrades.is_empty() and DataCatalog.UPGRADES.has("big_egg_core") and not purchased_upgrades.has("big_egg_core"):
		purchased_upgrades.append("big_egg_core")
	_load_saved_variable_channels(parsed)
	fact_values = parsed.get("fact_values", {}).duplicate()
	_ensure_runtime_values()
	_migrate_legacy_statistics(parsed)
	entity_counters = parsed.get("entity_counters", {}).duplicate()
	game_time = float(parsed.get("game_time", 0.0))
	menu_enabled_recipes.clear()
	for recipe_id: Variant in parsed.get("menu_enabled_recipes", []):
		if DataCatalog.RECIPES.has(str(recipe_id)):
			menu_enabled_recipes.append(str(recipe_id))
	player_effects.clear()
	for effect: Variant in parsed.get("player_effects", []):
		if effect is Dictionary:
			player_effects.append((effect as Dictionary).duplicate(true))
	active_modifiers.clear()
	for modifier: Variant in parsed.get("active_modifiers", []):
		if modifier is Dictionary:
			active_modifiers.append((modifier as Dictionary).duplicate(true))
	meta_values = parsed.get("meta_values", {}).duplicate()
	run_values.clear()
	display_mode_id = str(parsed.get("display_mode", "1920x1080"))
	set_language(str(parsed.get("language", language)))
	var saved_operations: Variant = parsed.get("restaurant_operations", {})
	if saved_operations is Dictionary:
		for key: String in restaurant_operations:
			if saved_operations.has(key):
				restaurant_operations[key] = saved_operations[key]
	# Saves before version 5 carried no plates; an empty restaurant is the correct
	# migration because every plate was discarded on scene change back then.
	restaurant_plates.clear()
	var saved_plates: Variant = parsed.get("restaurant_plates", [])
	if saved_plates is Array:
		for entry: Variant in saved_plates:
			if entry is Dictionary:
				restaurant_plates.append((entry as Dictionary).duplicate(true))
	# Les sauvegardes jusqu'à la version 6 ne portaient aucun convive: une salle
	# vide est la bonne migration, c'est exactement ce qu'elles produisaient.
	restaurant_customers.clear()
	var saved_customers: Variant = parsed.get("restaurant_customers", [])
	if saved_customers is Array:
		for entry: Variant in saved_customers:
			if entry is Dictionary:
				restaurant_customers.append((entry as Dictionary).duplicate(true))
	state_changed.emit()

func capture_restaurant_plates(plates: Array) -> void:
	# Assigns without emitting state_changed: this runs while the restaurant is
	# mutating plates, and a refresh from here would re-enter that same logic.
	restaurant_plates = plates.duplicate(true)

func take_restaurant_plates() -> Array:
	return restaurant_plates.duplicate(true)

func capture_restaurant_customers(entries: Array) -> void:
	restaurant_customers = entries.duplicate(true)

func take_restaurant_customers() -> Array:
	return restaurant_customers.duplicate(true)

func _migrate_legacy_statistics(parsed: Dictionary) -> void:
	# Saves up to version 5 kept the same counter in both `statistics` and
	# `fact_values`. They are merged into facts alone; per decision D8 the fact
	# wins, because that is the value the upgrade conditions were reading.
	if int(parsed.get("version", 0)) >= 6:
		return
	var legacy: Variant = parsed.get("statistics", {})
	if not legacy is Dictionary:
		return
	var saved_facts: Dictionary = parsed.get("fact_values", {})
	for id: String in legacy:
		if DataCatalog.GAME_FACTS.has(id) and not saved_facts.has(id):
			fact_values[id] = legacy[id]

func _load_saved_variable_channels(parsed: Dictionary) -> void:
	if int(parsed.get("version", 0)) >= 4:
		variable_base_bonuses = parsed.get("variable_base_bonuses", variable_base_bonuses).duplicate()
		variable_coefficient_bonuses = parsed.get("variable_coefficient_bonuses", variable_coefficient_bonuses).duplicate()
		return
	# Save v3 stored the additive side as variable_values and the complete
	# multiplier as variable_coefficients. Convert it without changing stats.
	variable_base_bonuses = parsed.get("variable_values", variable_base_bonuses).duplicate()
	var old_coefficients: Dictionary = parsed.get("variable_coefficients", {})
	for variable_id: String in old_coefficients:
		var definition: Dictionary = DataCatalog.GAME_VARIABLES.get(variable_id, {})
		variable_coefficient_bonuses[variable_id] = float(old_coefficients[variable_id]) - float(definition.get("base_coefficient", 1.0))

func reset_save() -> void:
	coins = 0
	ingredients.clear()
	purchased_upgrades.clear()
	variable_base_bonuses.clear()
	variable_coefficient_bonuses.clear()
	fact_values.clear()
	restaurant_plates.clear()
	entity_counters.clear()
	active_modifiers.clear()
	player_effects.clear()
	menu_enabled_recipes.clear()
	game_time = 0.0
	run_values.clear()
	meta_values.clear()
	_ensure_runtime_values()
	display_mode_id = "1920x1080"
	restaurant_operations = {
		"automated_cooking": false, "automated_service": false,
		"automated_cleanup": false, "automated_supply": false,
		"pending_simulation_seconds": 0.0,
	}
	restaurant_simulation_accumulator = 0.0
	apply_display_mode()
	if FileAccess.file_exists(SAVE_PATH):
		DirAccess.remove_absolute(SAVE_PATH)
	toast_requested.emit("Save reset")
	state_changed.emit()

func debug_supplies() -> void:
	coins += 100
	for id: String in DataCatalog.INGREDIENTS:
		ingredients[id] = get_ingredient_amount(id) + 5
	toast_requested.emit("Debug supplies added")
	_changed()

func _ensure_runtime_values() -> void:
	for id: String in DataCatalog.GAME_VARIABLES:
		if not variable_base_bonuses.has(id):
			variable_base_bonuses[id] = float(DataCatalog.GAME_VARIABLES[id].get("base_bonus", 0.0))
		if not variable_coefficient_bonuses.has(id):
			variable_coefficient_bonuses[id] = float(DataCatalog.GAME_VARIABLES[id].get("coefficient_bonus", 0.0))
	for id: String in DataCatalog.GAME_FACTS:
		if not fact_values.has(id):
			fact_values[id] = DataCatalog.GAME_FACTS[id].get("default_value", false)

func _changed(auto_save: bool = true) -> void:
	state_changed.emit()
	if auto_save:
		save_game()
