class_name BeautySystem extends Node
## Phase 13 — per-tile beauty score, derived from nearby furniture and its quality.
##
## beauty_map stores only tiles with non-zero beauty (sparse). Default = 0.
##
## Beauty contributions (base × quality_multiplier, spread over a radius):
##   Bed        +2  radius 3
##   Workbench  +1  radius 3
##   Torch      +3  radius 3
##   Hearth     +4  radius 3  (warm glow variant of Workbench by label_text)
##
## Falloff: linear from base at the source tile to 0 at radius+1.
##   Contribution at distance d = base_beauty × multiplier × max(0, 1 - d/radius)
##
## Usage:
##   Call recompute_around(tile) after any furniture build or removal.
##   Query beauty_at(tile) for the aggregated beauty value at a tile.
##   EventBus.tile_beauty_changed fires for every tile whose beauty changes.
##
## Wire as child of the World scene (after Pathfinder). world.gd exposes the
## instance on the World autoload as World.beauty_system for entity code.
##
## Negative beauty (corpses, etc.) is Phase 14 work — just expose bump() then.

## Sparse map: only tiles with non-zero beauty are stored.
## Keys = Vector2i tile coords; Values = float beauty score.
var beauty_map: Dictionary = {}

## Registered furniture entities. Each must expose:
##   tile: Vector2i
##   is_completed() -> bool   (or _completed var — checked via has_method)
##   label_text: String       (for Hearth vs generic Workbench beauty)
## and optionally:
##   quality: int             (Item.Quality int; defaults to NORMAL=1 if absent)
var _furniture: Array = []

## Base beauty values by duck-typed entity type (label_text takes priority for Workbench).
const BASE_BEAUTY: Dictionary = {
	"Bed":        2.0,
	"Torch":      3.0,
	"Hearth":     4.0,
	"Workbench":  1.0,   # fallback for Carpenter / Smelter / Millstone etc.
	"Carpenter":  1.0,
	"Smelter":    1.0,
	"Millstone":  1.0,
}

## Default beauty spread radius in tiles.
const DEFAULT_RADIUS: int = 3


# ── public API ────────────────────────────────────────────────────────────────

## Returns the aggregated beauty score for `tile`.  0 if tile is not in the map.
func beauty_at(tile: Vector2i) -> float:
	return float(beauty_map.get(tile, 0.0))


## Register a furniture entity so BeautySystem tracks it.
## Call from the entity's _ready() or on_build_complete hook.
func register_furniture(entity) -> void:
	if not _furniture.has(entity):
		_furniture.append(entity)


## Unregister a furniture entity.
## Call from the entity's _exit_tree().
func unregister_furniture(entity) -> void:
	_furniture.erase(entity)


## Recompute beauty for all tiles within `radius` of `tile`.
## Call after a furniture piece is built or removed.
## Emits EventBus.tile_beauty_changed for every tile whose value changes.
func recompute_around(tile: Vector2i, radius: int = DEFAULT_RADIUS) -> void:
	# Collect all tiles in the affected area (square bbox, then clamp to map).
	var affected_tiles: Array[Vector2i] = []
	for dy in range(-radius, radius + 1):
		for dx in range(-radius, radius + 1):
			affected_tiles.append(Vector2i(tile.x + dx, tile.y + dy))
	_recompute_tiles(affected_tiles)
	Audit.log("beauty", "recompute_around %s radius=%d" % [tile, radius])


## Full recompute across all tracked furniture. Useful on load or large changes.
## Returns immediately and logs if no furniture is registered.
func recompute_all() -> void:
	if _furniture.is_empty():
		return
	# Collect every tile that is currently in the map OR within reach of any furniture.
	var dirty_tiles: Dictionary = {}
	for tile in beauty_map.keys():
		dirty_tiles[tile] = true
	for entity in _furniture:
		if not _entity_completed(entity):
			continue
		var t: Vector2i = entity.tile
		for dy in range(-DEFAULT_RADIUS, DEFAULT_RADIUS + 1):
			for dx in range(-DEFAULT_RADIUS, DEFAULT_RADIUS + 1):
				dirty_tiles[Vector2i(t.x + dx, t.y + dy)] = true
	_recompute_tiles(dirty_tiles.keys())


# ── internal ──────────────────────────────────────────────────────────────────

## Recompute beauty for each tile in `tiles` from scratch.
## Emits tile_beauty_changed for any tile whose value changed.
func _recompute_tiles(tiles: Array) -> void:
	for tile in tiles:
		var new_val: float = _compute_beauty_at(tile)
		var old_val: float = float(beauty_map.get(tile, 0.0))
		if is_equal_approx(new_val, old_val):
			continue
		if new_val == 0.0:
			beauty_map.erase(tile)
		else:
			beauty_map[tile] = new_val
		EventBus.tile_beauty_changed.emit(tile, new_val)
		Audit.log("beauty", "tile %s → %.2f" % [tile, new_val])


## Sum all furniture contributions at a given tile.
func _compute_beauty_at(tile: Vector2i) -> float:
	var total: float = 0.0
	for entity in _furniture:
		if not _entity_completed(entity):
			continue
		var et: Vector2i = entity.tile
		var dist: int = abs(et.x - tile.x) + abs(et.y - tile.y)
		if dist > DEFAULT_RADIUS:
			continue
		var base: float = _base_beauty_for(entity)
		if base == 0.0:
			continue
		var multiplier: float = _quality_multiplier_for(entity)
		var falloff: float = maxf(0.0, 1.0 - float(dist) / float(DEFAULT_RADIUS))
		total += base * multiplier * falloff
	return total


## Returns the base beauty value for an entity by its label_text (or type name).
func _base_beauty_for(entity) -> float:
	var label: String = ""
	if "label_text" in entity:
		label = str(entity.label_text)
	# Check specific labels first, then fall back to class_name.
	if BASE_BEAUTY.has(label):
		return float(BASE_BEAUTY[label])
	# Fallback by class_name string (e.g. "Bed", "Torch").
	var cname: String = entity.get_class()
	if BASE_BEAUTY.has(cname):
		return float(BASE_BEAUTY[cname])
	return 0.0


## Returns the quality multiplier for an entity.
## Uses Item.Quality int (0=SHODDY..4=LEGENDARY) → 0.5/1.0/1.5/2.0/2.5.
## Defaults to 1.0 (NORMAL) if the entity has no quality field.
func _quality_multiplier_for(entity) -> float:
	var q: int = 1  # NORMAL default
	if "quality" in entity:
		q = int(entity.quality)
	match q:
		0:  return 0.5   # SHODDY
		1:  return 1.0   # NORMAL
		2:  return 1.5   # EXCELLENT
		3:  return 2.0   # MASTERWORK
		4:  return 2.5   # LEGENDARY
		_:  return 1.0


# ── save / load ───────────────────────────────────────────────────────────────

## Serialise the sparse beauty map as an array of {x, y, v} dicts.
## Only non-zero tiles are stored (map is already sparse).
func save_dict() -> Dictionary:
	var entries: Array = []
	for t in beauty_map:
		entries.append({"x": t.x, "y": t.y, "v": float(beauty_map[t])})
	return {"beauty": entries}


## Restore the beauty map from a dict produced by save_dict().
## Replaces the current map; furniture list is NOT restored here (entities
## re-register themselves when their nodes are re-added by the loader).
func apply_dict(d: Dictionary) -> void:
	beauty_map.clear()
	for entry in d.get("beauty", []):
		if entry is Dictionary:
			var t := Vector2i(int(entry.get("x", 0)), int(entry.get("y", 0)))
			var v: float = float(entry.get("v", 0.0))
			if v != 0.0:
				beauty_map[t] = v


## Returns true if `entity` has finished building.
## Checks the _completed bool directly (all furniture exposes is_completed() and the var).
func _entity_completed(entity) -> bool:
	if entity.has_method("is_completed"):
		return entity.is_completed()
	if "_completed" in entity:
		return bool(entity._completed)
	return true   # assume complete if we can't tell
