class_name CraftingProvider extends WorkProvider
## WorkProvider for the Crafting 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.workbenches for the best
## (Workbench, Bill) pair the pawn qualifies for, then builds a 4-toil job:
##   walk_to(ingredient.tile) → pickup → walk_to(wb.tile) → craft_at(wb, bill_index)
##
## Scoring: Manhattan distance pawn→ingredient + ingredient→workbench (lower wins).
##
## Phase 6 simplification: pawn must be empty-handed (one task at a time); ingredient
## search is global (no per-bench radius restriction — Phase 17 polish item per
## docs/architecture.md "Ingredient acquisition radius").
##
## When a bill cannot proceed it emits EventBus.bill_blocked once per
## (workbench × reason) per BILL_BLOCKED_COOLDOWN_TICKS (60 in-game seconds).
##
## Workbench and Pawn are intentionally duck-typed (no class_name reference) to match
## WorkProvider convention and avoid init-order issues. Only Item.Quality and
## QualityCalc are referenced by class_name (in job_runner.gd, not here).
##
## See docs/architecture.md "CraftingProvider" and docs/design.md "Bills".

## Rate-limit for bill_blocked alerts: one emit per (workbench × reason) per
## 60 in-game seconds (20 Hz × 60 s = 1200 ticks).
const BILL_BLOCKED_COOLDOWN_TICKS: int = 1200

## Per-(workbench_id|reason) cooldown map: String → tick at which next emit is allowed.
var _bill_blocked_cooldown: Dictionary = {}


func _init() -> void:
	category = &"crafting"
	# Priority 4 — above haul (3), below chop (5).
	# Phase 6 demo ordering; final 9-category matrix is authored in Phase 17.
	priority = 4


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

## Returns a craft Job for `pawn`, or null if no valid work exists.
## Pawn must expose: .carried_item, .tile (Vector2i), .get_skill(StringName) -> int.
##
## Single-ingredient recipes produce a 4-toil job:
##   walk_to(ing1) → pickup → walk_to(wb) → craft_at(wb, bill_index)
##
## Two-ingredient recipes (ingredient2_type != &"") build the toil sequence:
##   For each item needed to satisfy ingredient2_count (one trip per item):
##     walk_to(ing2_item) → pickup → walk_to(wb) → deposit_at_wb(wb)
##   Then: walk_to(ing1) → pickup → craft_at(wb, bill_index)
## Stack-aware: if a single wood item has stack_size >= ingredient2_count, only
## one deposit trip is needed. Multiple smaller stacks each get their own trip.
## The primary ingredient (ingredient_type / corpse) is always the last pickup
## before crafting; ingredient2 is deposited in the buffer beforehand.
##
## No-ingredient recipes (ingredient_type == &"") produce a 2-toil job:
##   walk_to(wb) → craft_at(wb, bill_index)
func find_best_for(pawn) -> Job:
	# Skip if pawn is already carrying something — deposit first.
	if pawn.get("carried_item") != null:
		return null

	var best_wb = null
	var best_bill = null
	var best_bill_index: int = -1
	var best_src1 = null
	var best_src2 = null
	var best_dist: int = 999999

	for wb in World.workbenches:
		# Duck-type guard: skip workbenches that aren't fully set up yet.
		if not wb.get("_completed"):
			continue
		# Skip workbenches another pawn has already claimed for crafting.
		if Job.is_target_taken_by_other(wb, pawn):
			continue

		for i in wb.bills.size():
			var b = wb.bills[i]
			if not b.is_active():
				continue

			# Skill threshold check — pawn must meet the bill's minimum.
			if pawn.get_skill(b.recipe.required_skill) < b.recipe.skill_threshold:
				_emit_bill_blocked(b.recipe.label, &"skill_too_low", wb)
				continue

			# Ingredient availability check.
			# Gate on ingredient_type being non-empty (ingredient_count is informational;
			# the canonical "no ingredient" signal is ingredient_type == &"").
			var src1 = null
			var src2 = null
			if b.recipe.ingredient_type != &"":
				src1 = _find_ingredient_item(b.recipe.ingredient_type)
				if src1 == null:
					_emit_bill_blocked(b.recipe.label, &"missing_ingredient", wb)
					continue

			# Two-ingredient check: enough secondary ingredient must be on the floor.
			# ingredient2_count items (or fewer stacks totalling that count) required.
			if b.recipe.ingredient2_type != &"":
				var ing2_items := _find_ingredient_items_for_count(b.recipe.ingredient2_type, b.recipe.ingredient2_count)
				if ing2_items.is_empty():
					_emit_bill_blocked(b.recipe.label, &"missing_ingredient2", wb)
					continue
				# Use the first item as src2 for the scoring distance heuristic.
				src2 = ing2_items[0]

			# Score: total Manhattan travel distance including both ingredient trips.
			# No-ingredient: pawn → wb.
			# One ingredient: pawn → ing1 → wb.
			# Two ingredients: pawn → ing1 → wb → ing2 → wb.
			var d: int = _manhattan(pawn.tile, wb.tile)
			if src1 != null:
				d = _manhattan(pawn.tile, src1.tile) + _manhattan(src1.tile, wb.tile)
			if src2 != null:
				d += _manhattan(wb.tile, src2.tile) + _manhattan(src2.tile, wb.tile)

			if d < best_dist:
				best_dist = d
				best_wb = wb
				best_bill = b
				best_bill_index = i
				best_src1 = src1
				best_src2 = src2

	if best_wb == null:
		return null

	# Re-resolve ingredient items to guard against concurrent assignment races.
	if best_bill.recipe.ingredient_type != &"":
		best_src1 = _find_ingredient_item(best_bill.recipe.ingredient_type)
		if best_src1 == null:
			return null
	var ing2_items: Array = []
	if best_bill.recipe.ingredient2_type != &"":
		ing2_items = _find_ingredient_items_for_count(best_bill.recipe.ingredient2_type, best_bill.recipe.ingredient2_count)
		if ing2_items.is_empty():
			return null

	var j := Job.new()
	j.label = "Craft %s at %s" % [best_bill.recipe.label, best_wb.get("label_text") if best_wb.get("label_text") != null else "workbench"]
	j.target_node = best_wb
	# Cache the primary ingredient ref so _tick_pickup can validate it is still
	# available when the pawn arrives (guards against concurrent haul/crafting races).
	j.ingredient_item = best_src1

	if not ing2_items.is_empty():
		# Two-ingredient path: deposit ingredient2 item(s) at workbench buffer,
		# then fetch the primary ingredient and craft.
		# One deposit trip per item in ing2_items (stack-aware: fewer trips when
		# item.stack_size covers multiple units, e.g. a 5-stack of wood = 1 trip).
		for ing2 in ing2_items:
			j.toils.append(Toil.walk_to(ing2.tile))
			j.toils.append(Toil.pickup())
			j.toils.append(Toil.walk_to(best_wb.tile))
			j.toils.append(Toil.deposit_at_wb(best_wb.get_path()))
		if best_src1 != null:
			j.toils.append(Toil.walk_to(best_src1.tile))
			j.toils.append(Toil.pickup())
	elif best_src1 != null:
		# Single-ingredient path: carry ing1 directly to craft.
		j.toils.append(Toil.walk_to(best_src1.tile))
		j.toils.append(Toil.pickup())

	j.toils.append(Toil.walk_to(best_wb.tile))
	j.toils.append(Toil.craft_at(best_wb.get_path(), best_bill_index))
	return j


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

## Returns the first on-floor Item of matching type that is not being carried.
## Phase 6 simplification: global search, first match wins (no nearest-first
## at this layer — distance is factored into the outer loop scoring instead).
func _find_ingredient_item(item_type: StringName):
	for it in World.items:
		if it.being_carried:
			continue
		if it.item_type == item_type:
			return it
	return null


## Returns a list of on-floor Items of `item_type` whose combined stack_size
## totals at least `needed_count`. Returns an empty array if not enough exist.
## Stack-aware: a single item with stack_size >= needed_count yields one entry.
## Multiple smaller stacks are collected until the total is met.
## This powers the multi-trip deposit loop for ingredient2_count > 1 recipes.
func _find_ingredient_items_for_count(item_type: StringName, needed_count: int) -> Array:
	var result: Array = []
	var accumulated: int = 0
	for it in World.items:
		if it.being_carried:
			continue
		if it.item_type != item_type:
			continue
		result.append(it)
		accumulated += int(it.get("stack_size") if it.get("stack_size") != null else 1)
		if accumulated >= needed_count:
			return result
	# Not enough items found.
	return []


## Manhattan distance between two Vector2i tile coordinates.
func _manhattan(a: Vector2i, b: Vector2i) -> int:
	return abs(a.x - b.x) + abs(a.y - b.y)


## Emits EventBus.bill_blocked for the given workbench and reason, rate-limited
## to once per (workbench × reason) per BILL_BLOCKED_COOLDOWN_TICKS.
func _emit_bill_blocked(recipe_label: String, reason: StringName, wb) -> void:
	var key: String = "%s|%s" % [wb.get_instance_id(), reason]
	if _bill_blocked_cooldown.get(key, 0) > Sim.tick:
		return
	_bill_blocked_cooldown[key] = Sim.tick + BILL_BLOCKED_COOLDOWN_TICKS
	var focus: Vector2i = wb.get("tile") if wb.get("tile") != null else Vector2i(-1, -1)
	EventBus.bill_blocked.emit(recipe_label, reason, focus)
