class_name JobRunner
extends Node
## Executes a Job's toils on behalf of a Pawn.
##
## Sits between the Decision layer and the Pawn's physical state.  The
## Decision layer (or a WorkProvider) hands us a Job; we tick through its
## toils one-by-one and fire job_completed when the last toil is done.
##
## Design notes (docs/architecture.md — Pawn AI 5-layer pipeline):
##   - JobRunner is layer 3 of 5.  Don't add control-flow that belongs to
##     Decision (layer 1) or WorkProvider (layer 2) here.
##   - Pawn and Pathfinder are held as untyped vars to avoid class_name
##     registration-order issues between autoloads and scene scripts.
##   - tick() is called from Pawn._on_sim_tick each sim tick.  Never spin
##     render-frame work off this function.
##
## Save / load contract (NON-NEGOTIABLE, Phase 3 acceptance criterion):
##   to_dict() / from_dict() round-trip mid-toil state exactly.  A WALK
##   toil with started=true restores correctly: on the first tick after load
##   the runner sits in the "already started, waiting for walk_completed"
##   branch, so pawn.walk_along_path() is NOT called again (which would
##   reset the pawn's progress).  The pawn finishes its own restored walk
##   under its own steam, eventually fires walk_completed, and the toil is
##   marked done.  See _tick_walk() for the branch logic.

signal job_started(job)
signal job_completed(job)

## Untyped — avoids class_name registration-order trap.
var pawn = null
## Untyped — avoids class_name registration-order trap.
var pathfinder = null
## Current Job being executed; null when idle.
var job = null


# ── lifecycle ────────────────────────────────────────────────────────────────

## Wire refs.  Must be called once before any other method.
## Connects pawn.walk_completed → _on_pawn_walk_completed.
func setup(pawn_ref, pathfinder_ref) -> void:
	pawn = pawn_ref
	pathfinder = pathfinder_ref
	pawn.walk_completed.connect(_on_pawn_walk_completed)


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

## Replace the current job (if any) and begin executing the new one.
## Resets nothing on the new job — current_toil_index is used as-is so
## that a restored-from-save job continues from its saved toil position.
func start_job(j) -> void:
	job = j
	Audit.log(
		"job_runner",
		"%s start: %s (%d toils)" % [pawn.pawn_name, j.label, j.toils.size()]
	)
	emit_signal("job_started", j)


## Drop the current job without signalling completion.
## Any walk already in progress is left to finish naturally
## (Phase 3 simplicity; Phase 5+ may add a hard-abort path).
func cancel_job() -> void:
	job = null


## True when a job is currently assigned.
func has_job() -> bool:
	return job != null


# ── sim tick ────────────────────────────────────────────────────────────────

## Called from Pawn._on_sim_tick each sim tick.
## Executes the active toil; advances to the next when it is done;
## emits job_completed when the last toil completes.
func tick() -> void:
	if job == null:
		return

	var t = job.active_toil()
	if t == null:
		_emit_complete()
		return

	match t.kind:
		Toil.KIND_WALK:
			_tick_walk(t)
		Toil.KIND_WAIT:
			_tick_wait(t)
		Toil.KIND_IDLE:
			pass  # Never completes on its own — Decision or player overrides.
		Toil.KIND_INTERACT:
			_tick_interact(t)
		Toil.KIND_BUILD:
			_tick_build(t)
		Toil.KIND_PICKUP:
			_tick_pickup(t)
		Toil.KIND_DEPOSIT:
			_tick_deposit(t)
		Toil.KIND_CRAFT:
			_tick_craft(t)
		Toil.KIND_EAT:
			_tick_eat(t)
		Toil.KIND_SLEEP:
			_tick_sleep(t)
		Toil.KIND_RESCUE:
			_tick_rescue(t)
		Toil.KIND_TREAT:
			_tick_treat(t)
		Toil.KIND_CLEAN:
			_tick_clean(t)
		Toil.KIND_PICKUP_CORPSE:
			_tick_pickup_corpse(t)
		Toil.KIND_DEPOSIT_CORPSE:
			_tick_deposit_corpse(t)

	if t.done:
		job.advance()
		if job.is_complete():
			_emit_complete()


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

## Serialise the runner's persistent state.
## {"job": <dict or null>}
func to_dict() -> Dictionary:
	return {
		"job": job.to_dict() if job != null else null,
	}


## Restore from a dict produced by to_dict().
## If the "job" key holds a Dictionary, reconstructs a Job via Job.from_dict().
func from_dict(d: Dictionary) -> void:
	var job_data = d.get("job", null)
	if job_data is Dictionary:
		job = Job.from_dict(job_data)


# ── signal handlers ──────────────────────────────────────────────────────────

## Fired by the Pawn when it finishes walking its path.
## Marks the active WALK toil done so the next tick() advances past it.
## Does NOT call job.advance() directly — tick() handles that.
func _on_pawn_walk_completed() -> void:
	if job == null:
		return
	var t = job.active_toil()
	if t != null and t.kind == Toil.KIND_WALK:
		t.done = true


# ── toil executors ──────────────────────────────────────────────────────────

## Execute one tick of a WALK toil.
##
## On the FIRST tick (started=false):
##   - If the pawn is already at the destination, complete immediately.
##   - Otherwise ask the pathfinder for a route.  If unreachable, log and
##     complete (skip-and-continue; the WorkProvider is responsible for
##     vetting reachability before issuing the job).
##   - Hand the path to the pawn and mark started=true.  From now on this
##     function is a no-op — we just wait for the walk_completed signal.
##
## On SUBSEQUENT ticks (started=true):
##   - No-op.  The pawn walks under its own steam.
##
## After LOAD (started=true from saved state):
##   - Same as subsequent ticks — pawn restores its own path and fires
##     walk_completed when it arrives.  We do NOT call walk_along_path again.
func _tick_walk(t) -> void:
	if not t.data.get("started", false):
		var dest: Vector2i = t.get_walk_destination()
		if pawn.tile == dest:
			t.done = true
			return
		var path: Array[Vector2i] = pathfinder.find_path(pawn.tile, dest)
		if path.is_empty():
			# Unreachable — abort the entire job so Decision can pick something
			# else next tick. Previously this marked the walk toil done and the
			# subsequent toils (pickup, deposit, etc.) ran at the wrong tile,
			# making haul/pickup silently fail with "no item at <pawn.tile>".
			Audit.log(
				"job_runner",
				"%s unreachable: %s → %s (canceling job '%s')" %
				[pawn.pawn_name, pawn.tile, dest, job.label]
			)
			cancel_job()
			return
		pawn.walk_along_path(path)
		t.data["started"] = true


## Execute one tick of a WAIT toil.
## Decrements the counter; sets done when it reaches zero.
func _tick_wait(t) -> void:
	t.data["ticks_remaining"] -= 1
	if t.data["ticks_remaining"] <= 0:
		t.done = true


## Execute one tick of an INTERACT toil.
##
## First tick: resolve the target node from the stored NodePath string.
## If the target is gone or freed, log and skip immediately (done=true).
## Otherwise mark started and log the action start.
##
## Every subsequent tick: call tick_method on the target.  After the call,
## check whether the target has been consumed (is_choppable/is_mineable
## returns false, or the node was freed).  If so, mark done.
func _tick_interact(t) -> void:
	var target_path := NodePath(t.data.get("target", ""))
	var target = get_tree().get_root().get_node_or_null(target_path)

	if not t.data.get("started", false):
		if target == null or not is_instance_valid(target):
			Audit.log(
				"job_runner",
				"%s interact target gone — skipping" % pawn.pawn_name
			)
			t.done = true
			return
		t.data["started"] = true
		Audit.log(
			"job_runner",
			"%s interact start: %s.%s" % [pawn.pawn_name, target.name, t.data.get("tick_method", "")]
		)

	# Re-resolve each tick in case the node was freed between ticks.
	target = get_tree().get_root().get_node_or_null(target_path)
	if target == null or not is_instance_valid(target):
		t.done = true
		return

	target.call(StringName(t.data.get("tick_method", "")))

	# Re-check validity after the call (the call may have freed the node).
	if target == null or not is_instance_valid(target):
		t.done = true
		return
	# Probe any known "still-active?" predicate. First match that returns false
	# closes the toil. Order matches natural work priority (chop → mine → plant).
	var _probes: Array[StringName] = [
		&"is_choppable", &"is_mineable", &"is_harvestable", &"is_sowable"
	]
	for probe in _probes:
		if target.has_method(probe) and not target.call(probe):
			Audit.log(
				"job_runner",
				"%s interact done: %s.%s() → false" % [pawn.pawn_name, target.name, probe]
			)
			t.done = true
			return


## Execute one tick of a BUILD toil.
##
## Mirrors _tick_interact's pattern, but drives construction entities (Wall,
## Floor, Door) via on_build_tick() / is_buildable() instead of on_chop_tick()
## / is_choppable(). The site is "done" (toil complete) when is_buildable()
## returns false — meaning the entity finished building OR was removed.
##
## First tick: resolve target from NodePath. If already gone, skip immediately.
## Every subsequent tick: call on_build_tick() then check is_buildable(). Once
## false the site is built (or cancelled); mark toil done.
func _tick_build(t) -> void:
	var target_path := NodePath(t.data.get("target", ""))
	var target = get_tree().get_root().get_node_or_null(target_path)

	if not t.data.get("started", false):
		if target == null or not is_instance_valid(target):
			Audit.log(
				"job_runner",
				"%s build target gone — skipping" % pawn.pawn_name
			)
			t.done = true
			return
		t.data["started"] = true
		Audit.log(
			"job_runner",
			"%s build start: %s" % [pawn.pawn_name, target.name]
		)

	# Re-resolve each tick in case the node was freed between ticks.
	target = get_tree().get_root().get_node_or_null(target_path)
	if target == null or not is_instance_valid(target):
		t.done = true
		return

	target.on_build_tick()

	# Re-check after the call (on_build_tick may complete + free the ghost state).
	if target == null or not is_instance_valid(target):
		t.done = true
		return
	if target.has_method("is_buildable") and not target.is_buildable():
		Audit.log(
			"job_runner",
			"%s build done: %s" % [pawn.pawn_name, target.name]
		)
		t.done = true


## Execute one tick of a PICKUP toil.
##
## Finds the first unheld Item at pawn.tile in World.items.
## Transfers it into pawn.carried_item via set_being_carried(true).
## Completes in a single tick.
func _tick_pickup(t) -> void:
	var item = null
	for it in World.items:
		if it.tile == pawn.tile and not it.being_carried:
			item = it
			break
	if item == null:
		Audit.log(
			"job_runner",
			"%s pickup: no item at %s" % [pawn.pawn_name, pawn.tile]
		)
		t.done = true
		return
	pawn.carried_item = item
	item.set_being_carried(true)
	Audit.log(
		"job_runner",
		"%s pickup: %s ×%d" % [pawn.pawn_name, item.item_type, item.stack_size]
	)
	t.done = true


## Execute one tick of a DEPOSIT toil.
##
## Places pawn.carried_item at pawn.tile (pixel-centred in the 16 px grid).
## Clears pawn.carried_item. Completes in a single tick.
func _tick_deposit(t) -> void:
	if pawn.carried_item == null:
		Audit.log(
			"job_runner",
			"%s deposit: nothing to deposit" % pawn.pawn_name
		)
		t.done = true
		return
	var item = pawn.carried_item
	item.tile = pawn.tile
	item.position = Vector2(pawn.tile.x * 16 + 8, pawn.tile.y * 16 + 8)
	item.set_being_carried(false)
	pawn.carried_item = null
	# Phase 4: clear the haul-dirty flag — item has landed at its destination.
	# The periodic sweep_for_better_destinations will re-mark it if a higher-
	# priority destination opens up later.
	World.clear_item_haul_flag(item)
	# Phase 5: if the destination tile is covered by a Crate (single-tile
	# container), register the item into the crate's contents. StockpileZone
	# doesn't need this — its items just live at the floor tile.
	var dest = World.stockpile_at_tile(pawn.tile)
	if dest != null and dest.has_method("register_item"):
		dest.register_item(item)
	Audit.log(
		"job_runner",
		"%s deposit: %s ×%d at %s" % [pawn.pawn_name, item.item_type, item.stack_size, pawn.tile]
	)
	t.done = true


## Execute one tick of a CRAFT toil.
##
## First tick (started=false):
##   - Resolves the Workbench node from the stored NodePath. Missing → skip.
##   - Looks up the Bill at data["bill_index"]. Out-of-range → skip.
##   - Validates pawn position matches workbench.tile. Mismatch → skip.
##   - Validates pawn is carrying the correct ingredient type. Missing → skip.
##   - Calls wb.begin_craft(bill) to register the active bill + reset progress.
##   - Marks started=true and logs the craft start.
##
## Every tick (including first, after the checks above):
##   - Calls wb.tick_craft() which increments current_work_progress and returns
##     true when bill.recipe.work_ticks is reached.
##   - On completion:
##       * Consumes the carried ingredient (queue_free + clear pawn slot).
##       * Rolls quality via QualityCalc.roll() using the pawn's skill level.
##       * Spawns an Item scene child of wb.get_parent() at wb.tile.
##       * Calls wb.on_craft_complete() (records bill completion + resets state).
##       * Logs the result and marks toil done.
func _tick_craft(t) -> void:
	var wb_path := NodePath(t.data.get("workbench", ""))
	var wb = get_tree().get_root().get_node_or_null(wb_path)

	if not t.data.get("started", false):
		# ── first-tick validation ─────────────────────────────────────────────
		if wb == null or not is_instance_valid(wb):
			Audit.log("job_runner", "%s craft: workbench gone — skipping" % pawn.pawn_name)
			t.done = true
			return

		var bill_index: int = int(t.data.get("bill_index", -1))
		if bill_index < 0 or bill_index >= wb.bills.size():
			Audit.log(
				"job_runner",
				"%s craft: invalid bill_index %d — skipping" % [pawn.pawn_name, bill_index]
			)
			t.done = true
			return

		var bill = wb.bills[bill_index]

		if pawn.tile != wb.tile:
			Audit.log(
				"job_runner",
				"%s not at workbench (pawn=%s wb=%s) — skipping" % [pawn.pawn_name, pawn.tile, wb.tile]
			)
			t.done = true
			return

		if pawn.carried_item == null or pawn.carried_item.item_type != bill.recipe.ingredient_type:
			Audit.log(
				"job_runner",
				"%s craft: wrong or missing ingredient — skipping" % pawn.pawn_name
			)
			t.done = true
			return

		# Register the bill with the workbench and reset its progress counter.
		wb.begin_craft(bill)
		t.data["started"] = true
		Audit.log("job_runner", "%s craft start: %s" % [pawn.pawn_name, bill.recipe.label])

	# ── per-tick progress ─────────────────────────────────────────────────────
	# Re-resolve each tick in case the workbench was freed between ticks.
	wb = get_tree().get_root().get_node_or_null(wb_path)
	if wb == null or not is_instance_valid(wb):
		t.done = true
		return

	# tick_craft() advances the progress counter and returns true when done.
	var craft_done: bool = wb.tick_craft()
	if not craft_done:
		return  # Still working — toil remains active.

	# ── craft complete ────────────────────────────────────────────────────────

	# Retrieve the bill now (before on_craft_complete clears current_bill).
	var bill_index: int = int(t.data.get("bill_index", -1))
	if bill_index < 0 or bill_index >= wb.bills.size():
		# Guard against the workbench mutating bills mid-craft.
		wb.on_craft_complete()
		t.done = true
		return
	var bill = wb.bills[bill_index]

	# Consume ingredient.
	var ingredient = pawn.carried_item
	pawn.carried_item = null
	if ingredient != null and is_instance_valid(ingredient):
		ingredient.queue_free()

	# Roll quality based on pawn skill for this recipe.
	var skill_level: int = pawn.get_skill(bill.recipe.required_skill)
	var quality: int = QualityCalc.roll(skill_level)

	# Spawn output Item as a sibling of the workbench (World scene level).
	var item_scene: PackedScene = load("res://scenes/entities/item.tscn")
	var output_item = item_scene.instantiate()
	wb.get_parent().add_child(output_item)
	output_item.setup(bill.recipe.output_type, 1, wb.tile)
	output_item.quality = quality

	# Record completion on the bill and reset workbench state.
	wb.on_craft_complete()

	Audit.log(
		"job_runner",
		"%s crafted %s ×1 quality=%s at %s" % [
			pawn.pawn_name,
			bill.recipe.output_type,
			Item.Quality.keys()[quality],
			wb.tile,
		]
	)
	t.done = true


## Execute one tick of an EAT toil.
##
## Consumes pawn.carried_item and restores hunger based on the item type:
##   MEAL: +60   BREAD: +45   VEGETABLE: +25   GRAIN: +10 (raw, last resort)
## The item is freed and the carry slot is cleared.  Completes in a single tick.
## If the pawn has nothing to eat, logs and completes immediately so the job
## runner does not stall.
func _tick_eat(t) -> void:
	if pawn.carried_item == null:
		Audit.log("job_runner", "%s eat: nothing to eat" % pawn.pawn_name)
		t.done = true
		return

	var item_type: StringName = pawn.carried_item.item_type
	var nutrition: int
	match item_type:
		Item.TYPE_MEAL:      nutrition = 60
		Item.TYPE_BREAD:     nutrition = 45
		Item.TYPE_VEGETABLE: nutrition = 25
		Item.TYPE_GRAIN:     nutrition = 10
		_:
			# Fallback for any unrecognised food type — treat as vegetables.
			nutrition = 25

	pawn.set_hunger(pawn.hunger + float(nutrition))
	# Phase 8 — fire ate_meal thought for cooked food (MEAL or BREAD).
	# Raw food (VEGETABLE, GRAIN) gives no mood bonus — player incentive to
	# build a cooking hearth per design.md "Thought list".
	if item_type == Item.TYPE_MEAL or item_type == Item.TYPE_BREAD:
		if pawn.has_method("add_thought"):
			pawn.add_thought(ThoughtCatalog.ate_meal())
	pawn.carried_item.queue_free()
	pawn.carried_item = null

	Audit.log(
		"job_runner",
		"%s ate %s (+%d hunger → %.1f)" % [pawn.pawn_name, item_type, nutrition, pawn.hunger]
	)
	t.done = true


## Execute one tick of a SLEEP toil.
##
## Sleep recovery rates (per sim tick):
##   In a bed : 0.5 / tick  (fast, restful; pawn wakes when sleep ≥ SLEEP_MAX - 1)
##   On floor : 0.25 / tick (slow, poor rest — mood thought fires via Mood system)
##
## First tick (started=false):
##   - Resolves the Bed node from t.data["bed"] (empty string → floor sleep).
##   - If bed path given but node invalid/freed: log + fall back to floor sleep.
##   - If valid bed: call bed.claim(pawn). If claim() returns false (race lost):
##     log + fall back to floor sleep.
##   - Reset ticks_slept = 0, mark started = true.
##   - Log sleep start (bed vs floor).
##
## Every tick (including the first after validation):
##   - Increment ticks_slept.
##   - Restore pawn.sleep by the appropriate recovery rate.
##   - When sleep ≥ SLEEP_MAX - 1 (essentially full): release bed, log, done.
##   - Emergency ceiling SLEEP_TICKS_MAX: prevents stuck-asleep loops.
const SLEEP_RECOVERY_BED: float = 0.5    # /tick in bed
const SLEEP_RECOVERY_FLOOR: float = 0.25  # /tick on floor
const SLEEP_TICKS_MAX: int = 2000         # emergency wake ceiling (~100 s at 1×)

func _tick_sleep(t) -> void:
	var using_bed: bool = false  # resolved on first tick; re-read from data each tick

	if not t.data.get("started", false):
		# ── first-tick: resolve and claim bed ─────────────────────────────────
		var bed_path_str: String = t.data.get("bed", "")
		var bed = null

		if bed_path_str != "":
			var bed_path := NodePath(bed_path_str)
			bed = get_tree().get_root().get_node_or_null(bed_path)

			if bed == null or not is_instance_valid(bed):
				# Bed gone between job creation and arrival.
				Audit.log(
					"job_runner",
					"%s bed gone at %s — sleeping on floor" % [pawn.pawn_name, bed_path_str]
				)
				t.data["bed"] = ""
				bed = null
			elif not bed.claim(pawn):
				# Bed claimed by another pawn during the walk — fall back.
				Audit.log(
					"job_runner",
					"%s bed claim failed at %s — sleeping on floor" % [pawn.pawn_name, bed_path_str]
				)
				t.data["bed"] = ""
				bed = null

		t.data["started"] = true
		t.data["ticks_slept"] = 0

		var has_bed: bool = (bed != null)
		Audit.log(
			"job_runner",
			"%s sleep start: %s" % [pawn.pawn_name, "bed" if has_bed else "floor"]
		)

	# ── per-tick recovery ──────────────────────────────────────────────────────
	t.data["ticks_slept"] = int(t.data.get("ticks_slept", 0)) + 1
	var ticks_slept: int = int(t.data["ticks_slept"])

	# Determine which rate to use this tick based on whether a bed is still live.
	var current_bed = null
	var bed_path_str: String = t.data.get("bed", "")
	if bed_path_str != "":
		current_bed = get_tree().get_root().get_node_or_null(NodePath(bed_path_str))
		if current_bed != null and not is_instance_valid(current_bed):
			current_bed = null

	using_bed = (current_bed != null)
	var rate: float = SLEEP_RECOVERY_BED if using_bed else SLEEP_RECOVERY_FLOOR
	pawn.set_sleep(pawn.sleep + rate)

	# Wake when sleep is essentially full.  Compare against the literal 99.0
	# (SLEEP_MAX - 1) rather than pawn.SLEEP_MAX because pawn is duck-typed
	# and const access on an untyped var fails at runtime in GDScript.
	if pawn.sleep >= 99.0:
		if current_bed != null:
			current_bed.release()
		# Phase 8 — fire sleep-quality thought on natural wakeup.
		if pawn.has_method("add_thought"):
			if using_bed:
				pawn.add_thought(ThoughtCatalog.well_rested())
			else:
				pawn.add_thought(ThoughtCatalog.slept_on_floor())
		Audit.log(
			"job_runner",
			"%s woke (slept %d ticks; sleep=%.1f)" % [pawn.pawn_name, ticks_slept, pawn.sleep]
		)
		t.done = true
		return

	# Emergency ceiling — prevent stuck-asleep loops.
	if ticks_slept > SLEEP_TICKS_MAX:
		if current_bed != null:
			current_bed.release()
		# Phase 8 — fire sleep-quality thought on emergency wake too.
		if pawn.has_method("add_thought"):
			if using_bed:
				pawn.add_thought(ThoughtCatalog.well_rested())
			else:
				pawn.add_thought(ThoughtCatalog.slept_on_floor())
		Audit.log(
			"job_runner",
			"%s emergency wake after %d ticks (sleep=%.1f)" % [pawn.pawn_name, ticks_slept, pawn.sleep]
		)
		t.done = true


## Execute one tick of a RESCUE toil.
##
## Single-tick marker.  The doctor walks to the patient via a preceding WALK_TO
## toil; this toil records the visit and immediately completes.  The actual
## "transport" of the patient to the medical bed is handled by the subsequent
## WALK_TO(bed) + TREAT toil sequence: the TREAT toil snaps the patient to the
## doctor's tile on its first tick, which is the bed tile.
##
## If the patient is gone or no longer downed, we log and skip (done=true)
## so the job runner doesn't stall on a phantom task.
func _tick_rescue(t) -> void:
	var patient = _resolve_patient(t)
	if patient == null:
		Audit.log(
			"job_runner",
			"%s rescue: patient gone or invalid — skipping" % pawn.pawn_name
		)
		t.done = true
		return
	if not patient.has_method("is_downed") or not patient.is_downed():
		Audit.log(
			"job_runner",
			"%s rescue: patient %s is not downed — skipping" % [pawn.pawn_name, patient.pawn_name]
		)
		t.done = true
		return
	t.data["started"] = true
	Audit.log(
		"job_runner",
		"%s reached downed %s — walking to bed for treatment" % [pawn.pawn_name, patient.pawn_name]
	)
	t.done = true


## Execute one tick of a TREAT toil.
##
## First tick (started=false):
##   - Resolve the patient node.  If gone or no longer downed, skip immediately.
##   - Snap the patient to the doctor's current tile (the medical-bed tile the
##     doctor just walked to).  This is the Phase 9 simplification: no carry
##     visual; Phase 17 may replace this with a proper carry animation.
##   - Mark started=true, ticks_treating=0.
##
## Every tick (including first, after the snap):
##   - Increment ticks_treating.
##   - Call patient.heal(0.5).
##   - Every 100 ticks: remove "bleeding" status if present (full cure, Phase 9
##     simplification; Phase 17 may reduce severity incrementally).
##   - Done when patient.hp >= HP_REVIVE_THRESHOLD AND no "bleeding" status.
##   - Done (safety ceiling) when ticks_treating > 600.
##
## HP_REVIVE_THRESHOLD mirrors Pawn.HP_REVIVE_THRESHOLD (50.0).  Compared as a
## literal so the duck-typed pawn ref doesn't need const access.
const _HP_REVIVE_THRESHOLD: float = 50.0
const _TREAT_TICKS_MAX: int = 600

func _tick_treat(t) -> void:
	var patient = _resolve_patient(t)

	if not t.data.get("started", false):
		# ── first-tick: validate and snap patient to bed tile ─────────────────
		if patient == null:
			Audit.log("job_runner", "%s treat: patient gone — skipping" % pawn.pawn_name)
			t.done = true
			return
		if not patient.has_method("is_downed") or not patient.is_downed():
			Audit.log(
				"job_runner",
				"%s treat: patient %s already up — skipping" % [pawn.pawn_name, patient.pawn_name]
			)
			t.done = true
			return
		# Snap patient to the doctor's current tile (the medical-bed tile).
		# Phase 9 simplification: instant teleport; Phase 17 may add carry visual.
		patient.tile = pawn.tile
		patient.position = Vector2(pawn.tile.x * 16 + 8, pawn.tile.y * 16 + 8)
		t.data["started"] = true
		t.data["ticks_treating"] = 0
		Audit.log(
			"job_runner",
			"%s treating %s at %s" % [pawn.pawn_name, patient.pawn_name, pawn.tile]
		)

	# ── per-tick treatment ─────────────────────────────────────────────────────
	if patient == null or not is_instance_valid(patient):
		t.done = true
		return

	t.data["ticks_treating"] = int(t.data.get("ticks_treating", 0)) + 1
	var ticks: int = int(t.data["ticks_treating"])

	patient.heal(0.5)

	# Cure bleeding in full after 100 ticks of treatment (Phase 9 simplification).
	if ticks % 100 == 0 and patient.has_method("has_status") and patient.has_status(&"bleeding"):
		patient.remove_status_by_id(&"bleeding")
		Audit.log(
			"job_runner",
			"%s cleared bleeding on %s (tick %d)" % [pawn.pawn_name, patient.pawn_name, ticks]
		)

	# Done when HP is above revive threshold AND bleeding is cleared.
	var hp_ok: bool = patient.hp >= _HP_REVIVE_THRESHOLD
	var bleed_ok: bool = not (patient.has_method("has_status") and patient.has_status(&"bleeding"))
	if hp_ok and bleed_ok:
		Audit.log(
			"job_runner",
			"%s finished treating %s (hp=%.1f, ticks=%d)" % [pawn.pawn_name, patient.pawn_name, patient.hp, ticks]
		)
		t.done = true
		return

	# Safety ceiling — prevent the toil from stalling if HP/bleed can't converge.
	if ticks > _TREAT_TICKS_MAX:
		Audit.log(
			"job_runner",
			"%s treatment timeout for %s (hp=%.1f, ticks=%d)" % [pawn.pawn_name, patient.pawn_name, patient.hp, ticks]
		)
		t.done = true


## Execute one tick of a CLEAN toil.
##
## First tick (started=false):
##   - Validate the dirtiness system is available. If not, skip immediately.
##   - Validate the target tile still has dirt >= DIRTY_THRESHOLD. If not, skip.
##   - Mark started=true and log the clean start.
##
## Every tick:
##   - Reduce dirt at the tile by DIRT_REDUCTION_PER_TICK via DirtinessSystem.bump_clean().
##   - Done when dirt <= 0.
##
## DIRTY_THRESHOLD and DIRT_REDUCTION_PER_TICK mirror CleaningProvider constants.
## 100.0 / 40 ticks = 2.5/tick ensures any tile (max 100 dirt) is clean in 40 ticks.
const _CLEAN_DIRTY_THRESHOLD: float = 25.0
const _DIRT_REDUCTION_PER_TICK: float = 2.5  # 100 / 40 ticks

func _tick_clean(t) -> void:
	var tile := Vector2i(int(t.data.get("clean_x", 0)), int(t.data.get("clean_y", 0)))

	# Safety — dirtiness system may not be wired yet during early boot.
	var ds = World.get("dirtiness_system")
	if ds == null:
		t.done = true
		return

	if not t.data.get("started", false):
		# ── first-tick: validate tile is still worth cleaning ─────────────────
		var current_dirt: float = ds.dirt_at(tile)
		if current_dirt < _CLEAN_DIRTY_THRESHOLD:
			Audit.log(
				"job_runner",
				"%s clean: tile %s already clean (dirt=%.1f) — skipping" % [pawn.pawn_name, tile, current_dirt]
			)
			t.done = true
			return
		t.data["started"] = true
		Audit.log(
			"job_runner",
			"%s clean start at %s (dirt=%.1f)" % [pawn.pawn_name, tile, current_dirt]
		)

	# ── per-tick cleaning ──────────────────────────────────────────────────────
	ds.bump_clean(tile, _DIRT_REDUCTION_PER_TICK)
	var remaining: float = ds.dirt_at(tile)
	if remaining <= 0.0:
		Audit.log(
			"job_runner",
			"%s clean done at %s" % [pawn.pawn_name, tile]
		)
		t.done = true


## Phase 14 — Execute one tick of a PICKUP_CORPSE toil.
##
## Scans World.corpses for a Corpse at pawn.tile that is not flagged as
## being_carried_corpse. Transfers it into pawn.carried_item and marks
## the corpse's being_carried_corpse flag so the sweep skips it.
## Completes in a single tick.
func _tick_pickup_corpse(t) -> void:
	var corpse = null
	for c in World.corpses:
		if c.tile == pawn.tile and not c.get("being_carried_corpse"):
			corpse = c
			break
	if corpse == null:
		Audit.log(
			"job_runner",
			"%s pickup_corpse: no corpse at %s" % [pawn.pawn_name, pawn.tile]
		)
		t.done = true
		return
	pawn.carried_item = corpse
	# Use a dynamic property so Corpse script need not be modified (Agent A owns it).
	corpse.set_meta("being_carried_corpse", true)
	# Also hide the corpse while being carried.
	corpse.visible = false
	Audit.log(
		"job_runner",
		"%s pickup_corpse: '%s' at %s" % [pawn.pawn_name, corpse.deceased_name, pawn.tile]
	)
	t.done = true


## Phase 14 — Execute one tick of a DEPOSIT_CORPSE toil.
##
## Finds the GraveSlot in World.stockpiles covering pawn.tile (must be dug).
## Calls slot.accept_corpse(corpse, world_parent) which spawns the GraveMarker,
## emits EventBus.corpse_buried, and frees both the corpse and the slot.
## Clears pawn.carried_item. Completes in a single tick.
func _tick_deposit_corpse(t) -> void:
	if pawn.carried_item == null:
		Audit.log(
			"job_runner",
			"%s deposit_corpse: nothing to bury" % pawn.pawn_name
		)
		t.done = true
		return

	var corpse = pawn.carried_item

	# Resolve the GraveSlot covering pawn.tile.
	var slot = null
	for dest in World.stockpiles:
		if dest.has_method("is_grave_slot_dug") and dest.covers_tile(pawn.tile):
			slot = dest
			break

	if slot == null or not slot.is_grave_slot_dug():
		Audit.log(
			"job_runner",
			"%s deposit_corpse: no dug GraveSlot at %s — dropping corpse" % [pawn.pawn_name, pawn.tile]
		)
		# Restore corpse visibility and clear carry flag so it can be re-hauled.
		if is_instance_valid(corpse):
			corpse.set_meta("being_carried_corpse", false)
			corpse.visible = true
		pawn.carried_item = null
		t.done = true
		return

	# Hand off to GraveSlot — it spawns the marker, emits the signal, frees both.
	var world_parent := get_tree().get_root().get_node_or_null("World")
	if world_parent == null:
		world_parent = pawn.get_parent()
	slot.accept_corpse(corpse, world_parent)
	pawn.carried_item = null
	Audit.log(
		"job_runner",
		"%s deposit_corpse: '%s' buried at %s" % [pawn.pawn_name, corpse.deceased_name, pawn.tile]
	)
	t.done = true


## Resolve the patient Pawn node from the NodePath stored in `t.data["patient"]`.
## Returns null and logs if the node is absent or no longer valid.
## Shared by _tick_rescue and _tick_treat.
func _resolve_patient(t):
	var path := NodePath(t.data.get("patient", ""))
	if path == NodePath(""):
		return null
	var node = get_tree().get_root().get_node_or_null(path)
	if node == null or not is_instance_valid(node):
		return null
	return node


# ── helpers ──────────────────────────────────────────────────────────────────

## Emit job_completed, log, and clear the job reference.
func _emit_complete() -> void:
	var completed = job
	job = null
	Audit.log(
		"job_runner",
		"%s done: %s" % [pawn.pawn_name, completed.label]
	)
	emit_signal("job_completed", completed)
