class_name RoomDetector extends Node
## Phase 13 — BFS-based room detection, auto-roof, and no-roof exclusion.
##
## RoomDetector owns the lifecycle of Room objects: it creates them, destroys
## them when the geometry changes, and writes `World.rooms`.  Nothing else
## should mutate `World.rooms` directly.
##
## Entry point: call `recompute_around(tile)` after any wall/floor/door build or
## removal.  World._ready() calls it once for the cabin demo seed so MCP runtime
## tests can see a Room in the registry immediately after boot.
##
## No-roof exclusion: tiles in `World.no_roof_cells` are treated as "open sky"
## during BFS.  If any such tile appears in a BFS result the room is detected
## but marked `is_under_roof = false` regardless of size.
##
## Delegation model: this node IS a child of the World scene node (not the
## World autoload).  `World.room_detector` is a convenience reference set in
## World._ready() so autoloads can call recompute_around().

## Give enough BFS headroom to detect rooms up to 4× the auto-roof cap.
## BFS counts exceeding this without finding a wall ring → treated as outdoor.
const BFS_CAP_FOR_ROOM: int = Room.ROOM_AUTOROOF_CAP * 4

## Radius (in tiles) around a changed tile to re-evaluate.
const RECOMPUTE_RADIUS: int = 3

## 4-directional offsets (no diagonals — matches pathfinder convention).
const _DIRS: Array[Vector2i] = [
	Vector2i(0, -1), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(1, 0),
]

## Map bounds — set by World._ready() after pathfinder is configured.
var _map_size: Vector2i = Vector2i(80, 80)

## Monotonically-increasing id counter.  Invalidated ids are never reused.
var _next_room_id: int = 0


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

## Set the canonical map size so BFS can detect edge-of-map as "outdoor".
func setup(map_size: Vector2i) -> void:
	_map_size = map_size


## Main entry point.  Call after any wall/floor/door change at `changed_tile`.
## Invalidates rooms that overlap the 3-tile radius, then re-BFS from every
## open floor tile in that radius.
func recompute_around(changed_tile: Vector2i) -> void:
	# 1. Gather the recompute radius set.
	var radius_tiles: Array[Vector2i] = _tiles_in_radius(changed_tile, RECOMPUTE_RADIUS)

	# 2. Destroy any Room whose tiles overlap the radius.
	_invalidate_rooms_touching(radius_tiles)

	# 3. For each floor or door tile in the radius that isn't a wall, try a BFS.
	#    Track which tiles we've already started a BFS from so we don't start two
	#    BFS runs from tiles that will find the same room.
	var already_claimed: Dictionary = {}  # Vector2i → true, for tiles inside a found room.

	for candidate in radius_tiles:
		if already_claimed.has(candidate):
			continue
		if not _is_floor_or_door(candidate):
			continue

		var result: Dictionary = _bfs_room(candidate)
		if result.is_empty():
			continue  # outdoor or BFS cap hit without enclosure

		var found_tiles: Array[Vector2i] = result["tiles"]
		var has_no_roof: bool = result["has_no_roof"]

		# Mark all found tiles so sibling candidates skip them.
		for t in found_tiles:
			already_claimed[t] = true

		_create_room(found_tiles, has_no_roof)


# ── room invalidation ─────────────────────────────────────────────────────────

func _invalidate_rooms_touching(radius_tiles: Array[Vector2i]) -> void:
	var radius_set: Dictionary = {}
	for t in radius_tiles:
		radius_set[t] = true

	# Collect ids to destroy (can't erase while iterating).
	var to_destroy: Array[int] = []
	for id in World.rooms:
		var r: Room = World.rooms[id]
		for t in r.tiles:
			if radius_set.has(t):
				to_destroy.append(id)
				break

	for id in to_destroy:
		World.rooms.erase(id)
		EventBus.room_changed.emit(id)
		Audit.log("room", "destroyed room #%d" % id)


# ── BFS ───────────────────────────────────────────────────────────────────────

## Returns a dict {"tiles": Array[Vector2i], "has_no_roof": bool} when a clean
## enclosed room is found.  Returns an empty dict for outdoor / over-cap areas.
##
## Door semantics: a door tile is a BOUNDARY like a wall — BFS stops at it and
## does NOT expand through it to the outside.  The door tile IS included in the
## room's tile list (it's part of the interior enclosure).  This matches the
## spec: "treat door tiles as boundary cells" that stop the BFS but are counted.
func _bfs_room(start: Vector2i) -> Dictionary:
	# interior_tiles: tiles that are inside the room (floor + door tiles).
	# boundary_visited: tiles that stopped BFS expansion (walls + doors), tracked
	# so we don't re-process them as neighbours.
	var interior_visited: Dictionary = {}   # Vector2i → true
	var boundary_visited: Dictionary = {}   # Vector2i → true (walls + doors)
	var queue: Array[Vector2i] = [start]
	interior_visited[start] = true
	var has_no_roof: bool = false

	while queue.size() > 0:
		if interior_visited.size() > BFS_CAP_FOR_ROOM:
			# Exceeded cap without finding a closed wall ring → treat as outdoor.
			return {}

		var current: Vector2i = queue.pop_front()

		# Check for no-roof designation.
		if World.no_roof_cells.has(current):
			has_no_roof = true

		for dir in _DIRS:
			var neighbour: Vector2i = current + dir

			# Already classified — skip.
			if interior_visited.has(neighbour) or boundary_visited.has(neighbour):
				continue

			# Map edge → open sky → discard entire BFS.
			if not _in_bounds(neighbour):
				return {}

			if _is_wall(neighbour):
				# Wall is a valid enclosing boundary — mark as boundary, stop expansion.
				boundary_visited[neighbour] = true
				continue

			if _is_door(neighbour):
				# Door acts as a boundary that stops BFS from escaping, but the door
				# tile itself is part of the room interior.  Add to interior but do
				# NOT expand through it — treat it like a wall for expansion purposes.
				interior_visited[neighbour] = true
				# Do NOT append to queue — door blocks outward traversal.
				continue

			if _is_floor(neighbour):
				interior_visited[neighbour] = true
				queue.append(neighbour)
			else:
				# Bare terrain → open sky → discard.
				return {}

	if interior_visited.is_empty():
		return {}

	var interior: Array[Vector2i] = []
	for t: Vector2i in interior_visited.keys():
		interior.append(t)

	return {"tiles": interior, "has_no_roof": has_no_roof}


# ── room creation ─────────────────────────────────────────────────────────────

func _create_room(interior: Array[Vector2i], has_no_roof: bool) -> void:
	var r := Room.new()
	r.id = _next_room_id
	_next_room_id += 1
	r.tiles = interior
	r.recompute_bounds()

	var count: int = interior.size()
	var top_left: Vector2i = Vector2i(r.bounds.position.x, r.bounds.position.y)

	if has_no_roof:
		# No-roof tile inside — courtyard; detects as room but stays open.
		r.is_under_roof = false
		World.rooms[r.id] = r
		EventBus.room_changed.emit(r.id)
		Audit.log("room", "discovered #%d size=%d at bounds=%s (no-roof courtyard)" % [
			r.id, count, r.bounds
		])

	elif count <= Room.ROOM_AUTOROOF_CAP:
		r.is_under_roof = true
		World.rooms[r.id] = r
		EventBus.room_changed.emit(r.id)
		Audit.log("room", "discovered #%d size=%d at bounds=%s (auto-roofed)" % [
			r.id, count, r.bounds
		])

	else:
		# Clean enclosure but over the auto-roof cap.
		r.is_under_roof = false
		World.rooms[r.id] = r
		EventBus.room_too_large.emit(top_left, count)
		EventBus.room_changed.emit(r.id)
		Audit.log("room", "WARNING room #%d too large size=%d at bounds=%s (roof suppressed)" % [
			r.id, count, r.bounds
		])


# ── tile classification helpers ───────────────────────────────────────────────

func _in_bounds(tile: Vector2i) -> bool:
	return tile.x >= 0 and tile.y >= 0 and tile.x < _map_size.x and tile.y < _map_size.y


## True if the tile has a wall stamp on the Wall TileMapLayer.
func _is_wall(tile: Vector2i) -> bool:
	if World.wall_layer == null:
		return false
	return World.wall_layer.get_cell_source_id(tile) != -1


## True if the tile has a floor stamp on the Floor TileMapLayer.
func _is_floor(tile: Vector2i) -> bool:
	if World.floor_layer == null:
		return false
	return World.floor_layer.get_cell_source_id(tile) != -1


## True if the tile is a completed door entity (World.doors registry).
## Doors act as room boundaries in the BFS (walkable but block outward expansion).
func _is_door(tile: Vector2i) -> bool:
	for door in World.doors:
		if door.tile == tile and door.is_completed():
			return true
	return false


## Convenience: true if tile is floor OR door.  Used to seed BFS start points.
func _is_floor_or_door(tile: Vector2i) -> bool:
	return _is_floor(tile) or _is_door(tile)


## Collect all tiles within `radius` Manhattan distance of `center`.
func _tiles_in_radius(center: Vector2i, radius: int) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	for dx in range(-radius, radius + 1):
		for dy in range(-radius, radius + 1):
			var t := center + Vector2i(dx, dy)
			if _in_bounds(t):
				result.append(t)
	return result
