class_name HaulingProvider extends WorkProvider
## WorkProvider for the Hauling work category. Slots into the 5-layer pawn AI
## (Decision → WorkProvider → Job + JobRunner) as layer 2.
##
## Each call to find_best_for(pawn) scans World.items_needing_haul for the
## item closest to `pawn` that has a valid, reachable destination, then builds
## a 4-toil haul job: walk → pickup → walk → deposit.
##
## sweep_for_better_destinations() is a periodic helper (called by World every
## ~100 sim ticks) that marks items in lower-priority destinations dirty when a
## higher-priority destination has space — enabling the "items flow upward"
## priority cascade described in design.md.
##
## When an item needs hauling but no stockpile accepts it, the provider emits
## EventBus.no_stockpile_accepts once per item_type per ALERT_COOLDOWN_TICKS
## ticks (30 in-game seconds at 20 Hz).
##
## Pawn is intentionally duck-typed (no class_name reference) to match the
## WorkProvider convention and avoid init-order issues.
##
## See docs/architecture.md "HaulingProvider".

## Rate-limit for no_stockpile_accepts alerts: one emit per item_type per
## 30 in-game seconds (20 Hz × 30 s = 600 ticks).
const ALERT_COOLDOWN_TICKS: int = 600

## Per-item-type cooldown map: StringName → tick at which the next emit is allowed.
var _alert_cooldown: Dictionary = {}


func _init() -> void:
	category = &"haul"
	# Priority 3 — below chop (5) and mine (4); above rest (1).
	# Adjusted once the full 9-category matrix is authored in Phase 17.
	priority = 3


# ── WorkProvider override ─────────────────────────────────────────────────────

## Returns a haul Job for `pawn`, or null if no valid work exists.
## Picks the item (or corpse) closest to `pawn` (Manhattan distance) that has
## an open slot in the highest-priority destination accepting its type.
## Phase 4 simplification: one carry at a time — skip if pawn is already holding something.
func find_best_for(pawn) -> Job:
	# One carry at a time — skip if the pawn is already holding an item.
	if pawn.get("carried_item") != null:
		return null

	var best_item = null
	var best_dest = null
	var best_drop_cell: Vector2i = Vector2i(-1, -1)
	var best_dist: int = 999999
	var best_is_corpse: bool = false

	# Tracks the first item_type that needed hauling but had no valid destination.
	# Used to emit no_stockpile_accepts once per cooldown window.
	var first_orphan_type: StringName = &""
	var first_orphan_tile: Vector2i = Vector2i(-1, -1)

	# ── regular items ─────────────────────────────────────────────────────────
	for item in World.items_needing_haul.keys():
		# Skip items another pawn is already carrying.
		if item.being_carried:
			continue
		# Skip items another pawn has already claimed (target_node = item node).
		if Job.is_target_taken_by_other(item, pawn):
			continue
		# Reachability gate — don't hand out a haul this pawn can't path to.
		# Without this, JobRunner cancels the job on the walk toil and Decision
		# reassigns it next tick, busy-spinning at 20 Hz with no progress.
		# Pre-check is O(pathfinder.find_path) ~10 μs; cheap at MVP scale.
		if pawn.tile != item.tile and World.pathfinder.find_path(pawn.tile, item.tile).is_empty():
			continue

		# Find the best destination for this item type + priority.
		var dest = _find_best_destination_for(item)
		if dest == null:
			# Item needs hauling but no stockpile accepts it — record first occurrence.
			if first_orphan_type == &"":
				first_orphan_type = item.item_type
				first_orphan_tile = item.tile
			continue

		var drop: Vector2i = dest.find_drop_position(item)
		if drop == Vector2i(-1, -1):
			continue

		# Skip an item that is already sitting in the destination we'd haul it to.
		# Avoids pointless re-haul of an item that is exactly where it should be.
		# (Phase 16 refines this once the item→destination link is persisted.)
		var current_dest = _destination_for_tile(item.tile)
		if current_dest != null and current_dest == dest:
			continue

		# Nearest-first heuristic (pawn → item only).
		var d: int = abs(item.tile.x - pawn.tile.x) + abs(item.tile.y - pawn.tile.y)
		if d < best_dist:
			best_dist = d
			best_item = item
			best_dest = dest
			best_drop_cell = drop
			best_is_corpse = false

	# Emit alert for the first orphaned item_type, rate-limited per type.
	if first_orphan_type != &"":
		if _alert_cooldown.get(first_orphan_type, 0) <= Sim.tick:
			EventBus.no_stockpile_accepts.emit(first_orphan_type, first_orphan_tile)
			_alert_cooldown[first_orphan_type] = Sim.tick + ALERT_COOLDOWN_TICKS

	# ── Phase 14: corpses ─────────────────────────────────────────────────────
	# Corpses route to GraveSlot StorageDestinations exactly like items, but
	# use PICKUP_CORPSE / DEPOSIT_CORPSE toils (since Corpse is not an Item).
	for corpse in World.corpses:
		# Skip corpses another pawn is already carrying.
		if corpse.get_meta("being_carried_corpse", false):
			continue
		# Skip corpses another pawn has already claimed.
		if Job.is_target_taken_by_other(corpse, pawn):
			continue
		# Reachability gate — same rationale as the item loop above.
		if pawn.tile != corpse.tile and World.pathfinder.find_path(pawn.tile, corpse.tile).is_empty():
			continue

		var dest = _find_best_destination_for(corpse)
		if dest == null:
			continue

		var drop: Vector2i = dest.find_drop_position(corpse)
		if drop == Vector2i(-1, -1):
			continue

		var d: int = abs(corpse.tile.x - pawn.tile.x) + abs(corpse.tile.y - pawn.tile.y)
		if d < best_dist:
			best_dist = d
			best_item = corpse
			best_dest = dest
			best_drop_cell = drop
			best_is_corpse = true

	if best_item == null:
		return null

	var j := Job.new()
	# target_node is the item/corpse being picked up — the carry-from side.
	# Other pawns' HaulingProviders read this to avoid double-claiming the same item.
	j.target_node = best_item
	if best_is_corpse:
		j.label = "Haul corpse '%s' -> (%d,%d)" % [
			best_item.deceased_name,
			best_drop_cell.x,
			best_drop_cell.y,
		]
		j.toils.append(Toil.walk_to(best_item.tile))
		j.toils.append(Toil.pickup_corpse())
		j.toils.append(Toil.walk_to(best_drop_cell))
		j.toils.append(Toil.deposit_corpse())
	else:
		j.label = "Haul %s x%d -> (%d,%d)" % [
			best_item.item_type,
			best_item.stack_size,
			best_drop_cell.x,
			best_drop_cell.y,
		]
		j.toils.append(Toil.walk_to(best_item.tile))
		j.toils.append(Toil.pickup())
		j.toils.append(Toil.walk_to(best_drop_cell))
		j.toils.append(Toil.deposit())
	return j


# ── priority cascade ──────────────────────────────────────────────────────────

## Periodic sweep (called by World every ~100 sim ticks).
## Walks all items NOT already in the dirty set and marks them dirty when:
##   (a) they are loose on the floor with no destination covering their tile, OR
##   (b) they are in a stockpile but a higher-priority destination now has room.
## Returns the count of newly marked items (logged when > 0).
## This is the mechanism that makes "items flow up" to Critical stockpiles.
func sweep_for_better_destinations() -> int:
	var count: int = 0
	for item in World.items:
		if item.being_carried:
			continue
		# Already flagged — HaulingProvider will handle it.
		if World.items_needing_haul.has(item):
			continue

		var current = _destination_for_tile(item.tile)
		var best = _find_best_destination_for(item)

		if current == null and best != null:
			# Loose item with a valid destination — mark it.
			World.items_needing_haul[item] = true
			count += 1
		elif current != null and best != null:
			# Item is stored, but a better destination exists.
			if int(best.priority) < int(current.priority):
				World.items_needing_haul[item] = true
				count += 1

	if count > 0:
		Audit.log("hauling", "sweep marked %d items for re-haul" % count)
	return count


# ── private helpers ───────────────────────────────────────────────────────────

## Returns the highest-priority StorageDestination that accepts `item` and has
## at least one open slot. Among equal-priority destinations, first found wins.
## Returns null when no destination qualifies.
func _find_best_destination_for(item):
	var best = null
	for dest in World.stockpiles:
		if not dest.accepts(item):
			continue
		if dest.find_drop_position(item) == Vector2i(-1, -1):
			continue
		# Lower enum int = higher priority (CRITICAL=0 beats HIGH=1).
		if best == null or int(dest.priority) < int(best.priority):
			best = dest
	return best


## Returns the StorageDestination whose region contains `tile`, or null if the
## tile is not inside any registered destination.
func _destination_for_tile(tile: Vector2i):
	for dest in World.stockpiles:
		if dest.covers_tile(tile):
			return dest
	return null
