class_name Registry
extends RefCounted

# Reads any value of the game through a single address. Writing is deliberately
# narrower than reading: only stored cells accept a write, and computed values
# have a resolver instead of a setter.
#
# Reads are uniform across stored cells, authored data and computed values;
# writes reach stored cells only.

const LIVE_DEFAULT := 0

static func read(path: String, fallback: Variant = null) -> Variant:
	var error := AddressCatalog.path_error(path)
	if not error.is_empty():
		push_warning("Registry: adresse illisible '%s' — %s" % [path, error])
		return fallback
	var parts := AddressCatalog.split_path(path)
	if AddressCatalog.SCALAR_ROOTS.has(parts[0]):
		return _read_scalar(parts[0], parts[1], fallback)
	return _read_entity(parts[0], parts[1], parts[2], fallback)

static func _read_scalar(root: String, field: String, fallback: Variant) -> Variant:
	match root:
		"fact":
			return GameState.get_fact_value(field)
		"stat":
			return GameState.resolve_game_variable(field)
		"player":
			if field == "coins":
				return GameState.coins
		"run", "meta":
			return GameState.get_scoped_value(root, field, fallback)
	return fallback

static func _read_entity(collection: String, id: String, field: String, fallback: Variant) -> Variant:
	if collection == "ingredients" and field == "owned":
		return GameState.get_ingredient_amount(id)
	match AddressCatalog.field_nature(collection, field):
		AddressCatalog.Nature.COMPUTED:
			return _read_computed(collection, id, field, fallback)
		AddressCatalog.Nature.LIVE:
			return GameState.get_entity_counter(collection, id, field, LIVE_DEFAULT)
		AddressCatalog.Nature.AUTHORED:
			var definition: Dictionary = AddressCatalog.collection_data(collection).get(id, {})
			return definition.get(field, fallback)
	return fallback

static func _read_computed(collection: String, id: String, field: String, fallback: Variant) -> Variant:
	match "%s.%s" % [collection, field]:
		"ingredients.owned":
			return GameState.get_ingredient_amount(id)
		"recipes.can_cook":
			return GameState.can_cook(id)
		"employees.hired":
			return GameState.employee_is_hired(id)
		"restaurant_elements.placed":
			return GameState.restaurant_element_is_placed(id)
		"upgrade_nodes.purchased":
			return GameState.has_upgrade(id)
		"upgrade_nodes.state":
			return GameState.get_upgrade_state(id)
		"dimensions.reached":
			return DiveMap.current_dimension_id() == id
		"dimensions.unlocked":
			# Une dimension hors cycle s'ouvre par sa convocation, pas par le rang.
			if not DiveMap.is_cycle_dimension(id):
				return DiveMap.gate_pending_id() == id
			return DiveMap.cycle_dimension_ids().find(id) <= GameState.get_dimension_index()
		"biomes.current":
			return DiveMap.current_biome_id() == id
		"rooms.current":
			return DiveMap.current_room_id() == id
	return fallback

static func write(path: String, value: Variant) -> bool:
	if not AddressCatalog.is_writable(path):
		push_warning("Registry: adresse non inscriptible '%s'" % path)
		return false
	var parts := AddressCatalog.split_path(path)
	if AddressCatalog.SCALAR_ROOTS.has(parts[0]):
		return _write_scalar(parts[0], parts[1], value)
	if parts[0] == "ingredients" and parts[2] == "owned":
		GameState.set_ingredient_amount(parts[1], int(value))
		return true
	return GameState.set_entity_counter(parts[0], parts[1], parts[2], value)

static func _write_scalar(root: String, field: String, value: Variant) -> bool:
	match root:
		"fact":
			GameState.set_fact_value(field, value)
			return true
		"player":
			if field == "coins":
				GameState.add_coins(int(value) - GameState.coins)
				return true
		"run", "meta":
			GameState.set_scoped_value(root, field, value)
			return true
	return false

# --- Declarative effects --------------------------------------------------
#
# An effect is a flat {op, path, value} record. There are no expressions and no
# nested conditions on purpose: the moment those appear this becomes a scripting
# language, and a poor one. Anything that does not fit stays in GDScript.

const EFFECT_OPS := ["set", "add", "multiply", "min", "max", "toggle"]

static func apply_effect(effect: Dictionary) -> bool:
	var path := str(effect.get("path", ""))
	var op := str(effect.get("op", "add"))
	if not EFFECT_OPS.has(op):
		push_warning("Registry: opération d'effet inconnue '%s'" % op)
		return false
	if not AddressCatalog.is_writable(path):
		push_warning("Registry: effet vers une adresse non inscriptible '%s'" % path)
		return false
	var current: Variant = read(path)
	var value: Variant = effect.get("value", 0)
	var result: Variant = current
	match op:
		"set": result = value
		"toggle": result = not bool(current)
		"add": result = _as_number(current) + _as_number(value)
		"multiply": result = _as_number(current) * _as_number(value)
		"min": result = minf(_as_number(current), _as_number(value))
		"max": result = maxf(_as_number(current), _as_number(value))
	# Integer cells stay integers so counters never drift into floats.
	if current is int and result is float:
		result = int(result)
	return write(path, result)

static func apply_effects(effects: Variant) -> int:
	if not effects is Array:
		return 0
	var applied := 0
	for effect: Variant in effects:
		if effect is Dictionary and apply_effect(effect):
			applied += 1
	return applied

# Runs the hook declared on an entity, if any. This is the single entry point
# the gameplay uses, so a collection without hooks costs one dictionary lookup.
# Un crochet porte deux choses: ce qu'il change, et ce qu'on en voit. Les deux
# partent du même point d'appel, sinon l'un des deux finirait par être oublié.
# Sans origine, il n'y a nulle part où jouer l'effet visuel — et c'est légitime:
# un achat d'upgrade se produit dans un menu.
static func run_hook(collection: String, id: String, hook: String, origin: Node2D = null) -> int:
	var definition: Dictionary = AddressCatalog.collection_data(collection).get(id, {})
	if origin != null and is_instance_valid(origin):
		Vfx.play(collection, id, hook, origin)
	return apply_effects(definition.get(hook, []))

static func _as_number(value: Variant) -> float:
	if value is bool:
		return 1.0 if value else 0.0
	if value is int or value is float:
		return float(value)
	return 0.0

# Conditions carry an address and nothing else: the tool publishes them in this
# format, so no translation happens at runtime.
static func condition_path(condition: Dictionary) -> String:
	return str(condition.get("path", ""))
