class_name ModifierPool
extends RefCounted

# Accumulates stat modifiers into four layers that combine at fixed positions:
#
#   final = (base + Σflat) × (coef_base + Σscaling) × (1 + Σglobal) × Π override
#
# The layers exist so a temporary penalty stays independent of progression: a
# curse of -0.5 in `global` always halves the result, while the same -0.5 in
# `scaling` would barely dent a player already sitting at 1.75.

const LAYERS := ["flat", "scaling", "global", "override"]

# Application order is imposed rather than inherited from iteration order.
# Without it, a `set` landing between two `add` would make the result depend on
# the order the player happened to buy their upgrades.
const OP_ORDER := {"set": 0, "add": 1, "multiply": 2}

static func layer_of(modifier: Dictionary) -> String:
	var layer := str(modifier.get("layer", "flat"))
	return layer if LAYERS.has(layer) else "flat"

static func is_valid_layer(layer: String) -> bool:
	return LAYERS.has(layer)

# Sorted deterministically: by operation class first, then by source, then by
# the stable index so two identical sources never swap.
static func sort_entries(entries: Array) -> Array:
	var indexed: Array = []
	for index: int in range(entries.size()):
		indexed.append({"index": index, "entry": entries[index]})
	indexed.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
		var op_a := int(OP_ORDER.get(str(a.entry.get("operation", a.entry.get("op", "add"))), 1))
		var op_b := int(OP_ORDER.get(str(b.entry.get("operation", b.entry.get("op", "add"))), 1))
		if op_a != op_b:
			return op_a < op_b
		var source_a := str(a.entry.get("source", ""))
		var source_b := str(b.entry.get("source", ""))
		if source_a != source_b:
			return source_a < source_b
		return int(a.index) < int(b.index))
	var result: Array = []
	for item: Dictionary in indexed:
		result.append(item.entry)
	return result

# Folds every entry into its layer. `starting` seeds flat and scaling with the
# stat's own stored bonuses so existing saves resolve unchanged.
static func accumulate(entries: Array, starting_flat: float, starting_scaling: float) -> Dictionary:
	var totals := {"flat": starting_flat, "scaling": starting_scaling, "global": 0.0, "override": 1.0}
	for entry: Variant in sort_entries(entries):
		if not entry is Dictionary:
			continue
		var layer := layer_of(entry)
		var value := float(entry.get("value", 0.0))
		if layer == "override":
			# A true multiplicative layer: each entry multiplies on its own
			# instead of diluting into a shared sum.
			totals.override = float(totals.override) * value
			continue
		var operation := str(entry.get("operation", entry.get("op", "add")))
		match operation:
			"set": totals[layer] = value
			"multiply": totals[layer] = float(totals[layer]) * value
			_: totals[layer] = float(totals[layer]) + value
	return totals

static func combine(base_value: float, base_coefficient: float, totals: Dictionary) -> float:
	return (base_value + float(totals.get("flat", 0.0))) \
		* (base_coefficient + float(totals.get("scaling", 0.0))) \
		* (1.0 + float(totals.get("global", 0.0))) \
		* float(totals.get("override", 1.0))

# An entry is live when it has no expiry or its expiry is still ahead. Reading
# filters rather than deleting: a read can happen from a draw call or a tooltip,
# and mutating the list there invites re-entrancy bugs. The tick prunes instead.
static func is_live(entry: Dictionary, now: float) -> bool:
	var expires: Variant = entry.get("expires_at", null)
	return expires == null or float(expires) > now
