extends RefCounted
class_name BattleState

const DataCatalogScript := preload("res://scripts/core/data_catalog.gd")

signal changed
signal log_added(message: String)
signal dialogue_requested(lines: Array)
signal combat_feedback_requested(unit_id: String, text: String, kind: String)
signal unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i)
signal unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String)
signal objective_updated(victory: String, defeat: String)

const TEAM_PLAYER := "player"
const TEAM_ENEMY := "enemy"
const STATUS_ACTIVE := "active"
const STATUS_VICTORY := "victory"
const STATUS_DEFEAT := "defeat"

const EXP_ATTACK := 10
const EXP_COUNTER := 5
const EXP_MISS := 2
const EXP_SKILL := 12
const EXP_HEAL := 8
const EXP_SUPPORT := 8
const EXP_DEFEAT_BONUS := 30
const EXP_LEVEL := 100
const BASE_HIT_CHANCE := 90
const MIN_HIT_CHANCE := 25

const CARDINAL_DIRECTIONS := [
	Vector2i(1, 0),
	Vector2i(-1, 0),
	Vector2i(0, 1),
	Vector2i(0, -1)
]

const GROWTH_GRADE_GAINS := {
	"hp": {"A": 5, "B": 4, "C": 3, "D": 2, "E": 1},
	"mp": {"A": 3, "B": 2, "C": 1, "D": 1, "E": 0},
	"atk": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0},
	"def": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0},
	"int": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0},
	"agi": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0}
}

const DEFAULT_TERRAIN_DEFS := {
	"G": {
		"name": "Plain",
		"move_cost": 1,
		"defense": 0,
		"avoid": 0,
		"color": Color(0.43, 0.62, 0.31)
	},
	"F": {
		"name": "Forest",
		"move_cost": 2,
		"defense": 2,
		"avoid": 10,
		"color": Color(0.20, 0.45, 0.23)
	},
	"H": {
		"name": "Hill",
		"move_cost": 2,
		"defense": 1,
		"avoid": 5,
		"color": Color(0.55, 0.50, 0.34)
	},
	"R": {
		"name": "Road",
		"move_cost": 1,
		"defense": 0,
		"avoid": 0,
		"color": Color(0.64, 0.55, 0.42)
	},
	"W": {
		"name": "Water",
		"move_cost": 99,
		"defense": 0,
		"avoid": 0,
		"color": Color(0.18, 0.36, 0.62)
	}
}

var battle_id := ""
var battle_name := ""
var objectives := {}
var battle_conditions := {}
var briefing := {}
var rewards := {}
var post_battle_choices: Array = []
var post_battle_dialogue: Array = []
var shop := {}
var deployment_rules := {}
var formation_cells: Array[Vector2i] = []
var map_size := Vector2i.ZERO
var map_background := ""
var terrain_rows: Array[String] = []
var terrain_defs := DEFAULT_TERRAIN_DEFS.duplicate(true)
var units: Array[Dictionary] = []
var current_team := TEAM_PLAYER
var turn_number := 1
var selected_unit_id := ""
var battle_status := STATUS_ACTIVE
var battle_events: Array[Dictionary] = []
var fired_event_ids := {}
var roster_overrides_snapshot := {}
var battle_inventory := {}
var battle_gold_reward := 0
var campaign_flags := {}
var campaign_joined_officers := {}
var progression_events: Array[Dictionary] = []
var data_catalog := DataCatalogScript.new()
var rng := RandomNumberGenerator.new()


func load_battle(path: String, roster_overrides := {}, inventory_overrides := {}, flag_overrides := {}, joined_officer_overrides := []) -> bool:
	var text := FileAccess.get_file_as_string(path)
	if text.is_empty():
		push_error("Battle file is empty or missing: %s" % path)
		return false

	var parsed = JSON.parse_string(text)
	if typeof(parsed) != TYPE_DICTIONARY:
		push_error("Battle file is not valid JSON: %s" % path)
		return false

	data_catalog.load_defaults()
	rng.randomize()
	if typeof(roster_overrides) == TYPE_DICTIONARY:
		roster_overrides_snapshot = roster_overrides.duplicate(true)
	else:
		roster_overrides_snapshot = {}
	if typeof(inventory_overrides) == TYPE_DICTIONARY:
		battle_inventory = inventory_overrides.duplicate(true)
	else:
		battle_inventory = {}
	if typeof(flag_overrides) == TYPE_DICTIONARY:
		campaign_flags = flag_overrides.duplicate(true)
	else:
		campaign_flags = {}
	campaign_joined_officers = _joined_officer_lookup(joined_officer_overrides)
	terrain_defs = data_catalog.get_runtime_terrain_defs(DEFAULT_TERRAIN_DEFS)
	_apply_battle_data(parsed, roster_overrides_snapshot)
	_emit_log("Battle loaded: %s" % battle_name)
	_run_events("battle_start")
	_run_events("turn_start", current_team, turn_number)
	_notify_changed()
	return true


func run_battle_begin_events() -> void:
	if battle_status != STATUS_ACTIVE:
		return
	_run_events("battle_begin")
	_notify_changed()


func _apply_battle_data(data: Dictionary, roster_overrides := {}) -> void:
	battle_id = String(data.get("id", "unknown"))
	battle_name = String(data.get("name", "Untitled Battle"))
	objectives = data.get("objectives", {})
	battle_conditions = _normalized_battle_conditions(data.get("conditions", {}))
	briefing = _normalized_briefing(data.get("briefing", {}))
	rewards = data.get("rewards", {})
	post_battle_choices = _normalized_post_battle_choices(data.get("post_battle_choices", []))
	post_battle_dialogue = _normalized_dialogue_lines(data.get("post_battle_dialogue", []))
	shop = _normalized_shop(data.get("shop", {}))
	deployment_rules = _normalized_deployment_rules(data.get("roster", {}))
	formation_cells = _normalized_formation_cells(data.get("formation", {}))
	battle_events.clear()
	for event in data.get("events", []):
		if typeof(event) == TYPE_DICTIONARY:
			battle_events.append(event)
	fired_event_ids.clear()
	battle_gold_reward = 0
	progression_events.clear()

	var map_data: Dictionary = data.get("map", {})
	map_size = Vector2i(int(map_data.get("width", 0)), int(map_data.get("height", 0)))
	map_background = str(map_data.get("background", ""))
	terrain_rows.clear()
	for row in map_data.get("terrain", []):
		terrain_rows.append(String(row))

	units.clear()
	var source_units: Array = []
	if data.has("deployments"):
		for deployment in data.get("deployments", []):
			if typeof(deployment) != TYPE_DICTIONARY:
				push_error("Skipping malformed deployment in %s." % battle_id)
				continue
			var hydrated_unit := data_catalog.hydrate_deployment(deployment)
			_apply_roster_overlay(hydrated_unit, roster_overrides)
			if not _is_deployment_available(hydrated_unit):
				continue
			source_units.append(hydrated_unit)
	else:
		source_units = data.get("units", [])

	for source_unit in source_units:
		var unit := _prepare_unit(source_unit)
		if unit.is_empty():
			continue
		units.append(unit)

	_apply_deployment_rules()

	if formation_cells.is_empty():
		for unit in units:
			if unit.get("team", "") == TEAM_PLAYER and unit.get("deployed", true):
				formation_cells.append(unit["pos"])

	current_team = TEAM_PLAYER
	turn_number = 1
	selected_unit_id = ""
	battle_status = STATUS_ACTIVE


func _normalized_battle_conditions(source) -> Dictionary:
	var conditions := {}
	if typeof(source) == TYPE_DICTIONARY:
		conditions = source.duplicate(true)
	if not conditions.has("victory") or not _is_condition_shape_valid(conditions["victory"]):
		conditions["victory"] = {"type": "all_enemies_defeated"}
	if not conditions.has("defeat") or not _is_condition_shape_valid(conditions["defeat"]):
		conditions["defeat"] = {"type": "all_players_defeated"}
	return conditions


func _normalized_briefing(source) -> Dictionary:
	if typeof(source) != TYPE_DICTIONARY:
		return {}
	var result: Dictionary = source.duplicate(true)
	var lines := []
	for line in result.get("lines", []):
		lines.append(str(line))
	for block in result.get("conditional_lines", []):
		if typeof(block) != TYPE_DICTIONARY:
			continue
		if not _campaign_flags_match(block.get("campaign_flags", {})):
			continue
		for line in block.get("lines", []):
			lines.append(str(line))
	result["lines"] = lines
	result["camp_dialogue"] = _normalized_dialogue_lines(result.get("camp_dialogue", []))
	result["camp_conversations"] = _normalized_camp_conversations(result.get("camp_conversations", []))
	result.erase("conditional_lines")
	return result


func _normalized_camp_conversations(source) -> Array:
	var result := []
	if typeof(source) != TYPE_ARRAY:
		return result
	var seen := {}
	for entry in source:
		if typeof(entry) != TYPE_DICTIONARY:
			continue
		var conversation_id := str(entry.get("id", "")).strip_edges()
		if conversation_id.is_empty() or seen.has(conversation_id):
			continue
		var lines := _normalized_dialogue_lines(entry.get("lines", []))
		if lines.is_empty():
			continue
		seen[conversation_id] = true
		var label := str(entry.get("label", conversation_id.capitalize())).strip_edges()
		if label.is_empty():
			label = conversation_id.capitalize()
		result.append({
			"id": conversation_id,
			"group": str(entry.get("group", "topic")).strip_edges(),
			"officer_id": str(entry.get("officer_id", "")).strip_edges(),
			"label": label,
			"speaker": str(entry.get("speaker", "")).strip_edges(),
			"summary": str(entry.get("summary", "")).strip_edges(),
			"lines": lines
		})
	return result


func _is_condition_shape_valid(condition) -> bool:
	return typeof(condition) == TYPE_DICTIONARY or typeof(condition) == TYPE_ARRAY


func _normalized_shop(source) -> Dictionary:
	var item_ids := []
	var seen := {}
	var stock := {}
	if typeof(source) != TYPE_DICTIONARY:
		return {"items": item_ids, "stock": stock, "merchant": {}}
	for entry in source.get("items", []):
		var item_id := _shop_item_id_from_entry(entry)
		if item_id.is_empty() or seen.has(item_id):
			continue
		seen[item_id] = true
		item_ids.append(item_id)
		_apply_shop_stock_entry(stock, item_id, entry)
	_apply_shop_stock_block(stock, source.get("stock", {}), seen)
	for block in source.get("conditional_items", []):
		if typeof(block) != TYPE_DICTIONARY:
			continue
		if not _campaign_flags_match(block.get("campaign_flags", {})):
			continue
		for entry in block.get("items", []):
			var item_id := _shop_item_id_from_entry(entry)
			if item_id.is_empty() or seen.has(item_id):
				continue
			seen[item_id] = true
			item_ids.append(item_id)
			_apply_shop_stock_entry(stock, item_id, entry)
		_apply_shop_stock_block(stock, block.get("stock", {}), seen)
	return {"items": item_ids, "stock": stock, "merchant": _normalized_shop_merchant(source.get("merchant", {}))}


func _normalized_shop_merchant(source) -> Dictionary:
	if typeof(source) != TYPE_DICTIONARY:
		return {}
	var lines := []
	for line in source.get("lines", []):
		var text := str(line).strip_edges()
		if not text.is_empty():
			lines.append(text)
	var name := str(source.get("name", "Merchant")).strip_edges()
	if name.is_empty() and lines.is_empty():
		return {}
	return {
		"name": "Merchant" if name.is_empty() else name,
		"lines": lines
	}


func _shop_item_id_from_entry(entry) -> String:
	if typeof(entry) == TYPE_DICTIONARY:
		return str(entry.get("id", ""))
	return str(entry)


func _apply_shop_stock_entry(stock: Dictionary, item_id: String, entry) -> void:
	if item_id.is_empty() or typeof(entry) != TYPE_DICTIONARY or not entry.has("stock"):
		return
	var count := int(entry.get("stock", -1))
	if count >= 0:
		stock[item_id] = count


func _apply_shop_stock_block(stock: Dictionary, block, seen: Dictionary) -> void:
	if typeof(block) != TYPE_DICTIONARY:
		return
	for item_id in block.keys():
		var normalized_id := str(item_id)
		if normalized_id.is_empty() or not seen.has(normalized_id):
			continue
		var count := int(block[item_id])
		if count >= 0:
			stock[normalized_id] = count


func _normalized_deployment_rules(source) -> Dictionary:
	var rules := {
		"max_units": 0,
		"required_officers": [],
		"required_units": []
	}
	if typeof(source) != TYPE_DICTIONARY:
		return rules
	rules["max_units"] = max(0, int(source.get("max_units", 0)))
	var required_officers := []
	var seen_officers := {}
	for officer_id in source.get("required_officers", []):
		var normalized := str(officer_id)
		if normalized.is_empty() or seen_officers.has(normalized):
			continue
		seen_officers[normalized] = true
		required_officers.append(normalized)
	rules["required_officers"] = required_officers

	var required_units := []
	var seen_units := {}
	for unit_id in source.get("required_units", []):
		var normalized := str(unit_id)
		if normalized.is_empty() or seen_units.has(normalized):
			continue
		seen_units[normalized] = true
		required_units.append(normalized)
	rules["required_units"] = required_units
	return rules


func _normalized_post_battle_choices(source) -> Array:
	var result := []
	if typeof(source) != TYPE_ARRAY:
		return result
	var seen := {}
	for choice in source:
		if typeof(choice) != TYPE_DICTIONARY:
			continue
		var choice_id := str(choice.get("id", ""))
		if choice_id.is_empty() or seen.has(choice_id):
			continue
		seen[choice_id] = true
		result.append(choice.duplicate(true))
	return result


func _normalized_dialogue_lines(source) -> Array:
	var result := []
	if typeof(source) != TYPE_ARRAY:
		return result
	for line in source:
		var normalized := _normalize_dialogue_line(line)
		if not normalized.is_empty():
			result.append(normalized)
	return result


func _normalized_formation_cells(source) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	if typeof(source) != TYPE_DICTIONARY:
		return result
	var seen := {}
	for entry in source.get("cells", []):
		if typeof(entry) != TYPE_ARRAY or entry.size() < 2:
			continue
		var cell := Vector2i(int(entry[0]), int(entry[1]))
		var key := "%d,%d" % [cell.x, cell.y]
		if seen.has(key):
			continue
		seen[key] = true
		result.append(cell)
	return result


func _joined_officer_lookup(source) -> Dictionary:
	var result := {}
	if typeof(source) == TYPE_DICTIONARY:
		for officer_id in source.keys():
			if bool(source[officer_id]):
				result[str(officer_id)] = true
		return result
	if typeof(source) == TYPE_ARRAY:
		for officer_id in source:
			var normalized := str(officer_id)
			if normalized.is_empty():
				continue
			result[normalized] = true
	return result


func _is_deployment_available(unit: Dictionary) -> bool:
	if unit.get("team", "") != TEAM_PLAYER:
		return true
	if not bool(unit.get("requires_joined", false)):
		return true
	var officer_id := str(unit.get("officer_id", ""))
	return not officer_id.is_empty() and campaign_joined_officers.has(officer_id)


func _apply_deployment_rules() -> void:
	var player_units := get_player_units(true)
	var max_units := get_deployment_max_units()
	var required_officers: Array = deployment_rules.get("required_officers", [])
	var required_units: Array = deployment_rules.get("required_units", [])
	var deployed_count := 0
	for unit in player_units:
		var required := required_units.has(str(unit.get("id", ""))) or required_officers.has(str(unit.get("officer_id", "")))
		unit["required_deployment"] = required
		unit["deployed"] = required
		if required:
			deployed_count += 1
	for unit in player_units:
		if unit.get("deployed", false):
			continue
		var deploy := max_units <= 0 or deployed_count < max_units
		unit["deployed"] = deploy
		if deploy:
			deployed_count += 1


func _prepare_unit(source_unit: Dictionary) -> Dictionary:
	if source_unit.is_empty():
		return {}

	var unit: Dictionary = source_unit.duplicate(true)
	if not unit.has("id") and unit.has("unit_id"):
		unit["id"] = unit["unit_id"]
	if str(unit.get("id", "")).is_empty():
		push_error("Unit is missing id: %s" % str(source_unit))
		return {}

	var pos_array: Array = unit.get("pos", [0, 0])
	if pos_array.size() < 2:
		push_error("Unit has malformed position: %s" % str(source_unit))
		return {}
	unit["pos"] = Vector2i(int(pos_array[0]), int(pos_array[1]))
	unit["class"] = str(unit.get("class", unit.get("class_id", "Unit"))).capitalize()
	unit["hp"] = int(unit.get("hp", unit.get("max_hp", 1)))
	unit["max_hp"] = int(unit.get("max_hp", unit["hp"]))
	unit["max_mp"] = int(unit.get("max_mp", unit.get("mp", 0)))
	unit["mp"] = int(unit.get("mp", unit["max_mp"]))
	unit["atk"] = int(unit.get("atk", 1))
	unit["def"] = int(unit.get("def", 0))
	unit["int"] = int(unit.get("int", 0))
	unit["agi"] = int(unit.get("agi", 0))
	unit["move"] = int(unit.get("move", 3))
	unit["move_type"] = str(unit.get("move_type", "foot"))
	if not unit.has("skills") or typeof(unit["skills"]) != TYPE_ARRAY:
		unit["skills"] = []
	else:
		unit["skills"] = unit["skills"].duplicate(true)
	if typeof(unit.get("range", 1)) == TYPE_ARRAY:
		var range_array: Array = unit["range"]
		if range_array.size() >= 2:
			unit["min_range"] = max(1, int(unit.get("min_range", range_array[0])))
			unit["range"] = max(int(unit["min_range"]), int(range_array[1]))
		elif range_array.size() == 1:
			unit["range"] = max(1, int(range_array[0]))
			unit["min_range"] = int(unit["range"])
		else:
			unit["range"] = 1
			unit["min_range"] = 1
	else:
		unit["range"] = max(1, int(unit.get("range", 1)))
		unit["min_range"] = clampi(int(unit.get("min_range", min(1, int(unit["range"])))), 1, int(unit["range"]))
	unit["level"] = int(unit.get("level", 1))
	unit["exp"] = int(unit.get("exp", 0))
	unit["portrait"] = _portrait_for_unit(unit)
	if not unit.has("growth") or typeof(unit["growth"]) != TYPE_DICTIONARY:
		unit["growth"] = {}
	if not unit.has("growth_bonus") or typeof(unit["growth_bonus"]) != TYPE_DICTIONARY:
		unit["growth_bonus"] = {}
	unit["alive"] = bool(unit.get("alive", true))
	unit["deployed"] = bool(unit.get("deployed", true))
	unit["required_deployment"] = bool(unit.get("required_deployment", false))
	unit["controllable"] = bool(unit.get("controllable", true))
	unit["persist_progression"] = bool(unit.get("persist_progression", true))
	unit["ai_target_priority"] = max(0, int(unit.get("ai_target_priority", 0)))
	unit["status_effects"] = []
	unit["acted"] = false
	unit["moved"] = false
	return unit


func _apply_roster_overlay(unit: Dictionary, roster_overrides) -> void:
	if typeof(roster_overrides) != TYPE_DICTIONARY:
		return
	if unit.get("team", "") != TEAM_PLAYER:
		return

	var overlay := _find_roster_overlay(unit, roster_overrides)
	if overlay.is_empty():
		return

	for key in ["class_id", "class", "level", "exp", "max_hp", "max_mp", "atk", "def", "int", "agi"]:
		if overlay.has(key):
			unit[key] = overlay[key]

	if overlay.has("equipment") and typeof(overlay["equipment"]) == TYPE_DICTIONARY:
		unit["equipment"] = overlay["equipment"].duplicate(true)
	if overlay.has("skills") and typeof(overlay["skills"]) == TYPE_ARRAY:
		unit["skills"] = overlay["skills"].duplicate(true)
	if overlay.has("class_id"):
		_apply_class_runtime_fields(unit, str(unit.get("class_id", "")))
	else:
		_refresh_attack_range_from_equipment(unit)

	unit["hp"] = int(unit.get("max_hp", unit.get("hp", 1)))
	unit["mp"] = int(unit.get("max_mp", unit.get("mp", 0)))
	unit["loaded_from_roster"] = true


func _portrait_for_unit(unit: Dictionary) -> String:
	var portrait := str(unit.get("portrait", ""))
	if not portrait.is_empty():
		return portrait
	return data_catalog.get_portrait_for_officer(str(unit.get("officer_id", "")))


func _find_roster_overlay(unit: Dictionary, roster_overrides: Dictionary) -> Dictionary:
	var unit_id := str(unit.get("id", ""))
	if roster_overrides.has(unit_id) and typeof(roster_overrides[unit_id]) == TYPE_DICTIONARY:
		return roster_overrides[unit_id]

	var officer_id := str(unit.get("officer_id", ""))
	if not officer_id.is_empty() and roster_overrides.has(officer_id) and typeof(roster_overrides[officer_id]) == TYPE_DICTIONARY:
		return roster_overrides[officer_id]

	return {}


func select_unit(unit_id: String) -> bool:
	var unit := get_unit(unit_id)
	if unit.is_empty() or not unit.get("alive", false):
		return false
	if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true):
		return false
	if not bool(unit.get("controllable", true)):
		return false
	if unit.get("team", "") != current_team:
		return false
	if unit.get("acted", false):
		_emit_log("%s has already acted." % unit["name"])
		return false

	selected_unit_id = unit_id
	_emit_log("Selected %s." % unit["name"])
	_notify_changed()
	return true


func clear_selection() -> void:
	selected_unit_id = ""
	_notify_changed()


func try_move_selected(cell: Vector2i) -> bool:
	var unit := get_selected_unit()
	if unit.is_empty() or battle_status != STATUS_ACTIVE:
		return false
	if not bool(unit.get("controllable", true)):
		return false
	if unit.get("moved", false) or unit.get("acted", false):
		return false
	if _unit_has_action_lock(unit, "move"):
		_emit_log("%s cannot move while snared." % unit["name"])
		return false
	if not get_movement_range(unit["id"]).has(cell):
		return false
	if not get_unit_at(cell).is_empty():
		return false

	var from_cell: Vector2i = unit["pos"]
	unit["pos"] = cell
	unit["moved"] = true
	unit_motion_requested.emit(str(unit.get("id", "")), from_cell, cell)
	_emit_log("%s moved to %s." % [unit["name"], _format_cell(cell)])
	_run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit)
	_check_battle_status()
	_notify_changed()
	return true


func try_attack_selected(target_id: String) -> bool:
	var attacker := get_selected_unit()
	var target := get_unit(target_id)
	if attacker.is_empty() or target.is_empty() or battle_status != STATUS_ACTIVE:
		return false
	if not bool(attacker.get("controllable", true)):
		return false
	if attacker.get("acted", false):
		return false
	if _unit_has_action_lock(attacker, "attack"):
		_emit_log("%s cannot attack while disarmed." % attacker["name"])
		return false
	if attacker.get("team", "") == target.get("team", ""):
		return false
	if not _is_in_attack_range(attacker, target["pos"]):
		return false

	_resolve_combat(attacker, target)
	attacker["acted"] = true
	attacker["moved"] = true
	selected_unit_id = ""
	_check_battle_status()
	_notify_changed()
	return true


func try_cast_selected_skill(skill_id: String, target_cell: Vector2i) -> bool:
	var caster := get_selected_unit()
	var skill := get_skill_def(skill_id)
	if caster.is_empty() or skill.is_empty() or battle_status != STATUS_ACTIVE:
		return false
	if not bool(caster.get("controllable", true)):
		return false
	if caster.get("acted", false):
		return false
	if _unit_has_action_lock(caster, "skill"):
		_emit_log("%s cannot use tactics while sealed." % caster["name"])
		return false
	if not _unit_has_skill(caster, skill_id):
		return false
	if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)):
		_emit_log("%s does not have enough MP." % caster["name"])
		return false
	if not _skill_cells_from(caster, skill).has(target_cell):
		return false

	var skill_kind := str(skill.get("kind", "damage"))
	var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell)
	if targets.is_empty():
		if skill_kind == "heal":
			_emit_log("%s has no injured targets in range." % skill.get("name", "Skill"))
		else:
			_emit_log("%s has no valid targets." % skill.get("name", "Skill"))
		return false
	if skill_kind == "support" and not _skill_has_support_effects(skill):
		return false

	caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0)))
	if skill_kind == "heal":
		var total_healed := 0
		for target in targets:
			total_healed += _resolve_skill_heal(caster, target, skill)
		if total_healed > 0:
			_grant_experience(caster, EXP_HEAL)
	elif skill_kind == "support":
		var applied_support := false
		for target in targets:
			applied_support = _resolve_skill_support(caster, target, skill_id, skill) or applied_support
		if applied_support:
			_grant_experience(caster, EXP_SUPPORT)
	else:
		var defeated_count := 0
		for target in targets:
			if _resolve_skill_damage(caster, target, skill):
				defeated_count += 1
		_grant_experience(caster, EXP_SKILL + defeated_count * EXP_DEFEAT_BONUS)

	caster["acted"] = true
	caster["moved"] = true
	selected_unit_id = ""
	_check_battle_status()
	_notify_changed()
	return true


func try_use_selected_item_on_cell(item_id: String, target_cell: Vector2i) -> bool:
	var user := get_selected_unit()
	var item := get_item_def(item_id)
	if user.is_empty() or item.is_empty() or battle_status != STATUS_ACTIVE:
		return false
	if not bool(user.get("controllable", true)):
		return false
	if user.get("acted", false):
		return false
	if int(battle_inventory.get(item_id, 0)) <= 0:
		_emit_log("No %s remains." % item.get("name", item_id))
		return false
	if str(item.get("kind", "")) != "consumable":
		return false
	if not _item_target_cells_from(user, item).has(target_cell):
		return false

	var target := get_unit_at(target_cell)
	if not _item_target_valid(user, target, item):
		return false

	var applied := _apply_item_effects(user, target, item)
	if not applied:
		return false

	battle_inventory[item_id] = max(0, int(battle_inventory.get(item_id, 0)) - 1)
	user["acted"] = true
	user["moved"] = true
	selected_unit_id = ""
	_emit_log("%s uses %s on %s." % [user["name"], item.get("name", item_id), target["name"]])
	_check_battle_status()
	_notify_changed()
	return true


func get_damage_preview(attacker_id: String, target_id: String) -> Dictionary:
	var attacker := get_unit(attacker_id)
	var target := get_unit(target_id)
	if attacker.is_empty() or target.is_empty():
		return {}
	if not attacker.get("alive", false) or not target.get("alive", false):
		return {}
	if attacker.get("team", "") == target.get("team", ""):
		return {}

	var attack_locked := _unit_has_action_lock(attacker, "attack")
	var effective_bonus := 0 if attack_locked else _weapon_effectiveness_bonus(attacker, target)
	var damage := 0 if attack_locked else calculate_damage(attacker, target)
	var target_hp_after: int = maxi(0, int(target["hp"]) - damage)
	var counter_damage := 0
	var attacker_hp_after := int(attacker["hp"])
	var hit_chance := 0 if attack_locked else calculate_hit_chance(attacker, target)
	var counter_hit_chance := 0
	var counter_effective_bonus := 0
	var counter_in_range := false
	if not attack_locked and (target_hp_after > 0 or hit_chance < 100):
		counter_in_range = _is_in_attack_range(target, attacker["pos"])
		if counter_in_range:
			counter_effective_bonus = _weapon_effectiveness_bonus(target, attacker)
			counter_damage = calculate_damage(target, attacker)
			counter_hit_chance = calculate_hit_chance(target, attacker)
			attacker_hp_after = maxi(0, int(attacker["hp"]) - counter_damage)
	return {
		"attacker_id": attacker_id,
		"target_id": target_id,
		"damage": damage,
		"effective_bonus": effective_bonus,
		"effective_target_type": _weapon_effectiveness_target_type(target) if effective_bonus > 0 else "",
		"hit_chance": hit_chance,
		"target_hp_after": target_hp_after,
		"would_defeat": int(target["hp"]) - damage <= 0,
		"in_range": not attack_locked and _is_in_attack_range(attacker, target["pos"]),
		"attack_locked": attack_locked,
		"counter_in_range": counter_in_range,
		"counter_damage": counter_damage,
		"counter_effective_bonus": counter_effective_bonus,
		"counter_effective_target_type": _weapon_effectiveness_target_type(attacker) if counter_effective_bonus > 0 else "",
		"counter_hit_chance": counter_hit_chance,
		"attacker_hp_after": attacker_hp_after,
		"counter_would_defeat": attacker_hp_after <= 0
	}


func get_skill_preview(caster_id: String, skill_id: String, target_cell: Vector2i) -> Dictionary:
	var caster := get_unit(caster_id)
	var skill := get_skill_def(skill_id)
	if caster.is_empty() or skill.is_empty() or not is_inside(target_cell):
		return {}
	if not caster.get("alive", false) or not _unit_has_skill(caster, skill_id):
		return {}

	var target := get_unit_at(target_cell)
	var skill_kind := str(skill.get("kind", "damage"))
	var skill_locked := _unit_has_action_lock(caster, "skill")
	var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell)
	var area_cells := _skill_area_cells(target_cell, skill)
	var has_area := _skill_has_area(skill)
	var preview := {
		"skill_id": skill_id,
		"skill_name": str(skill.get("name", skill_id)),
		"kind": skill_kind,
		"mp_cost": int(skill.get("mp_cost", 0)),
		"has_mp": int(caster.get("mp", 0)) >= int(skill.get("mp_cost", 0)),
		"in_range": not skill_locked and _skill_cells_from(caster, skill).has(target_cell),
		"valid_target": not skill_locked and not targets.is_empty(),
		"skill_locked": skill_locked,
		"has_area": has_area,
		"area_cells": area_cells,
		"target_count": targets.size()
	}
	if targets.is_empty():
		return preview

	var primary_target: Dictionary = target if not target.is_empty() and _skill_target_valid(caster, target, skill) else targets[0]
	preview["target_id"] = primary_target.get("id", "")
	if skill_kind == "heal":
		var heal_amount := calculate_skill_heal(caster, primary_target, skill)
		var total_heal := 0
		for area_target in targets:
			total_heal += calculate_skill_heal(caster, area_target, skill)
		preview["heal"] = heal_amount
		preview["total_heal"] = total_heal
		preview["target_hp_after"] = min(int(primary_target.get("max_hp", 1)), int(primary_target.get("hp", 0)) + heal_amount)
	elif skill_kind == "support":
		preview["effect_text"] = _format_support_effects(skill)
		preview["already_active"] = _has_status_effect_from_skill(primary_target, skill_id)
	else:
		var damage := calculate_skill_damage(caster, primary_target, skill)
		var total_damage := 0
		var defeat_count := 0
		for area_target in targets:
			var area_damage := calculate_skill_damage(caster, area_target, skill)
			total_damage += area_damage
			if int(area_target.get("hp", 0)) - area_damage <= 0:
				defeat_count += 1
		preview["damage"] = damage
		preview["total_damage"] = total_damage
		preview["defeat_count"] = defeat_count
		preview["target_hp_after"] = max(0, int(primary_target.get("hp", 0)) - damage)
		preview["would_defeat"] = int(primary_target.get("hp", 0)) - damage <= 0
	return preview


func get_item_preview(user_id: String, item_id: String, target_cell: Vector2i) -> Dictionary:
	var user := get_unit(user_id)
	var item := get_item_def(item_id)
	if user.is_empty() or item.is_empty() or not is_inside(target_cell):
		return {}

	var target := get_unit_at(target_cell)
	var preview := {
		"item_id": item_id,
		"item_name": str(item.get("name", item_id)),
		"count": int(battle_inventory.get(item_id, 0)),
		"in_range": _item_target_cells_from(user, item).has(target_cell),
		"valid_target": _item_target_valid(user, target, item)
	}
	if target.is_empty():
		return preview

	var hp_heal_amount := _item_hp_heal_amount(target, item)
	var mp_heal_amount := _item_mp_heal_amount(target, item)
	var cured_statuses := _item_curable_status_names(target, item)
	cured_statuses.append_array(_item_cleansable_debuff_names(target, item))
	preview["heal"] = hp_heal_amount
	preview["mp_heal"] = mp_heal_amount
	preview["cure_statuses"] = cured_statuses
	preview["target_hp_after"] = min(int(target.get("max_hp", 1)), int(target.get("hp", 0)) + hp_heal_amount)
	preview["target_mp_after"] = min(int(target.get("max_mp", 0)), int(target.get("mp", 0)) + mp_heal_amount)
	return preview


func calculate_damage(attacker: Dictionary, target: Dictionary) -> int:
	var terrain_defense := get_terrain_defense(target["pos"])
	var effective_bonus := _weapon_effectiveness_bonus(attacker, target)
	return max(1, _effective_stat(attacker, "atk") + effective_bonus - _effective_stat(target, "def") - terrain_defense)


func calculate_hit_chance(attacker: Dictionary, target: Dictionary) -> int:
	var terrain_avoid := get_terrain_avoid(target["pos"])
	var agility_delta := _effective_stat(attacker, "agi") - _effective_stat(target, "agi")
	return clampi(BASE_HIT_CHANCE + agility_delta - terrain_avoid, MIN_HIT_CHANCE, 100)


func calculate_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int:
	var terrain_defense := get_terrain_defense(target["pos"])
	var stat_name := str(skill.get("stat", "int"))
	var stat_value := _effective_stat(caster, stat_name)
	return max(1, int(skill.get("power", 1)) + stat_value - _effective_stat(target, "def") - terrain_defense)


func calculate_skill_heal(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int:
	var stat_name := str(skill.get("stat", "int"))
	var raw_heal := int(skill.get("power", 1)) + _effective_stat(caster, stat_name)
	var missing_hp := int(target.get("max_hp", 1)) - int(target.get("hp", 0))
	return max(0, min(raw_heal, missing_hp))


func _weapon_effectiveness_bonus(attacker: Dictionary, target: Dictionary) -> int:
	var weapon := _equipped_weapon(attacker)
	if weapon.is_empty():
		return 0
	if not _weapon_is_effective_against(weapon, target):
		return 0
	return max(0, int(weapon.get("effective_bonus_damage", 0)))


func _weapon_is_effective_against(weapon: Dictionary, target: Dictionary) -> bool:
	var target_move_type := _weapon_effectiveness_target_type(target)
	if target_move_type.is_empty():
		return false
	var effective_types = weapon.get("effective_vs_move_types", [])
	if typeof(effective_types) != TYPE_ARRAY:
		return false
	for move_type in effective_types:
		if str(move_type) == target_move_type:
			return true
	return false


func _weapon_effectiveness_target_type(target: Dictionary) -> String:
	return str(target.get("move_type", "foot")).strip_edges()


func _format_move_type(move_type: String) -> String:
	return move_type.replace("_", " ").capitalize()


func _equipped_weapon(unit: Dictionary) -> Dictionary:
	var equipment = unit.get("equipment", {})
	if typeof(equipment) != TYPE_DICTIONARY:
		return {}
	var weapon_id := str(equipment.get("weapon", ""))
	if weapon_id.is_empty():
		return {}
	var weapon := get_item_def(weapon_id)
	if str(weapon.get("kind", "")) != "weapon":
		return {}
	return weapon


func get_status_effect_summary(unit_id: String) -> String:
	var unit := get_unit(unit_id)
	if unit.is_empty():
		return ""
	return _format_active_status_effects(unit)


func is_unit_skill_sealed(unit_id: String) -> bool:
	var unit := get_unit(unit_id)
	if unit.is_empty():
		return false
	return _unit_has_action_lock(unit, "skill")


func _effective_stat(unit: Dictionary, stat: String) -> int:
	var base_value := int(unit.get(stat, 0))
	for effect in _active_status_effects(unit):
		if str(effect.get("stat", "")) != stat:
			continue
		base_value += int(effect.get("amount", 0))
	if stat == "atk":
		return max(1, base_value)
	return max(0, base_value)


func _active_status_effects(unit: Dictionary) -> Array:
	var result := []
	if typeof(unit.get("status_effects", [])) != TYPE_ARRAY:
		return result
	for effect in unit.get("status_effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		if int(effect.get("remaining_phases", 0)) <= 0:
			continue
		result.append(effect)
	return result


func _skill_has_support_effects(skill: Dictionary) -> bool:
	for effect in skill.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		var effect_type := str(effect.get("type", ""))
		if effect_type == "stat_bonus" and _is_supported_status_stat(str(effect.get("stat", ""))) and int(effect.get("amount", 0)) != 0:
			return true
		if effect_type == "damage_over_time" and int(effect.get("amount", 0)) > 0:
			return true
		if effect_type == "action_lock" and not str(effect.get("action", "")).strip_edges().is_empty() and not str(effect.get("status", "")).strip_edges().is_empty():
			return true
	return false


func _resolve_skill_support(caster: Dictionary, target: Dictionary, skill_id: String, skill: Dictionary) -> bool:
	if not _skill_has_support_effects(skill):
		return false
	var status_effects := _status_effects_without_skill(target, skill_id)
	for effect in skill.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		var effect_type := str(effect.get("type", ""))
		if effect_type == "stat_bonus":
			var stat := str(effect.get("stat", ""))
			var amount := int(effect.get("amount", 0))
			if not _is_supported_status_stat(stat) or amount == 0:
				continue
			status_effects.append({
				"source_skill": skill_id,
				"name": str(skill.get("name", skill_id)),
				"type": effect_type,
				"stat": stat,
				"amount": amount,
				"remaining_phases": max(1, int(effect.get("duration_turns", 1)))
			})
		elif effect_type == "damage_over_time":
			var damage: int = max(0, int(effect.get("amount", 0)))
			if damage <= 0:
				continue
			var status := str(effect.get("status", "poison"))
			status_effects.append({
				"source_skill": skill_id,
				"name": str(skill.get("name", skill_id)),
				"type": effect_type,
				"status": status,
				"amount": damage,
				"remaining_phases": max(1, int(effect.get("duration_turns", 1)))
			})
		elif effect_type == "action_lock":
			var action := str(effect.get("action", ""))
			var status := str(effect.get("status", ""))
			if action.strip_edges().is_empty() or status.strip_edges().is_empty():
				continue
			status_effects.append({
				"source_skill": skill_id,
				"name": str(skill.get("name", skill_id)),
				"type": effect_type,
				"status": status,
				"action": action,
				"remaining_phases": max(1, int(effect.get("duration_turns", 1)))
			})
	target["status_effects"] = status_effects
	_emit_log("%s casts %s on %s. %s" % [
		caster["name"],
		skill.get("name", "Skill"),
		target["name"],
		_format_support_effects(skill)
	])
	_emit_combat_feedback(target, _format_support_popup(skill), _support_feedback_kind(skill))
	return true


func _status_effects_without_skill(unit: Dictionary, skill_id: String) -> Array:
	var result := []
	for effect in _active_status_effects(unit):
		if str(effect.get("source_skill", "")) == skill_id:
			continue
		result.append(effect)
	return result


func _has_status_effect_from_skill(unit: Dictionary, skill_id: String) -> bool:
	for effect in _active_status_effects(unit):
		if str(effect.get("source_skill", "")) == skill_id:
			return true
	return false


func _format_support_effects(skill: Dictionary) -> String:
	var parts := []
	for effect in skill.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		var effect_type := str(effect.get("type", ""))
		var duration: int = maxi(1, int(effect.get("duration_turns", 1)))
		var duration_text := "next phase" if duration == 1 else "%d phases" % duration
		if effect_type == "stat_bonus":
			var stat := str(effect.get("stat", ""))
			var amount := int(effect.get("amount", 0))
			if not _is_supported_status_stat(stat) or amount == 0:
				continue
			var sign := "+" if amount > 0 else ""
			parts.append("%s %s%d until %s" % [stat.to_upper(), sign, amount, duration_text])
		elif effect_type == "damage_over_time":
			var damage := int(effect.get("amount", 0))
			if damage <= 0:
				continue
			var status_name := _status_display_name(str(effect.get("status", "poison")))
			parts.append("%s -%d HP for %s" % [status_name, damage, duration_text])
		elif effect_type == "action_lock":
			var status_name := _status_display_name(str(effect.get("status", "status")))
			parts.append("%s %s for %s" % [status_name, _action_lock_display_name(str(effect.get("action", ""))), duration_text])
	if parts.is_empty():
		return "No support effect."
	return _join_strings(parts, ", ")


func _format_support_popup(skill: Dictionary) -> String:
	var parts := []
	for effect in skill.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		var effect_type := str(effect.get("type", ""))
		if effect_type == "stat_bonus":
			var stat := str(effect.get("stat", ""))
			var amount := int(effect.get("amount", 0))
			if not _is_supported_status_stat(stat) or amount == 0:
				continue
			var sign := "+" if amount > 0 else ""
			parts.append("%s %s%d" % [stat.to_upper(), sign, amount])
		elif effect_type == "damage_over_time":
			var damage := int(effect.get("amount", 0))
			if damage > 0:
				parts.append(_status_display_name(str(effect.get("status", "poison"))).to_upper())
		elif effect_type == "action_lock":
			parts.append(_status_display_name(str(effect.get("status", "status"))).to_upper())
	if parts.is_empty():
		return "Support"
	return _join_strings(parts, ", ")


func _support_feedback_kind(skill: Dictionary) -> String:
	for effect in skill.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		if str(effect.get("type", "")) == "action_lock":
			return str(effect.get("status", "status"))
	for effect in skill.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		if str(effect.get("type", "")) == "damage_over_time" and int(effect.get("amount", 0)) > 0:
			return str(effect.get("status", "poison"))
	for effect in skill.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		if str(effect.get("type", "")) == "stat_bonus" and int(effect.get("amount", 0)) < 0:
			return "debuff"
	return "support"


func _format_active_status_effects(unit: Dictionary) -> String:
	var parts := []
	for effect in _active_status_effects(unit):
		var effect_type := str(effect.get("type", "stat_bonus"))
		var amount := int(effect.get("amount", 0))
		var duration_text := _format_status_remaining_phases(effect)
		if effect_type == "damage_over_time":
			if amount > 0:
				parts.append("%s -%d HP %s" % [
					_status_display_name(str(effect.get("status", "poison"))),
					amount,
					duration_text
				])
			continue
		if effect_type == "action_lock":
			parts.append("%s %s %s" % [
				_status_display_name(str(effect.get("status", "status"))),
				_action_lock_display_name(str(effect.get("action", ""))),
				duration_text
			])
			continue
		var stat := str(effect.get("stat", ""))
		if not _is_supported_status_stat(stat) or amount == 0:
			continue
		var sign := "+" if amount > 0 else ""
		parts.append("%s %s%d %s" % [stat.to_upper(), sign, amount, duration_text])
	return _join_strings(parts, ", ")


func _format_status_remaining_phases(effect: Dictionary) -> String:
	var remaining: int = maxi(1, int(effect.get("remaining_phases", 1)))
	var label := "phase" if remaining == 1 else "phases"
	return "(%d %s)" % [remaining, label]


func _status_display_name(status: String) -> String:
	var normalized := status.strip_edges()
	if normalized.is_empty():
		return "Status"
	return normalized.replace("_", " ").capitalize()


func _is_supported_status_stat(stat: String) -> bool:
	return stat == "atk" or stat == "def" or stat == "int" or stat == "agi" or stat == "move"


func _unit_has_action_lock(unit: Dictionary, action: String) -> bool:
	var normalized_action := action.strip_edges().to_lower()
	if normalized_action.is_empty():
		return false
	for effect in _active_status_effects(unit):
		if str(effect.get("type", "")) == "action_lock" and str(effect.get("action", "")).strip_edges().to_lower() == normalized_action:
			return true
	return false


func _action_lock_display_name(action: String) -> String:
	if action == "skill":
		return "no tactics"
	if action == "move":
		return "no movement"
	if action == "attack":
		return "no attacks"
	return "no action"


func try_wait_selected() -> bool:
	var unit := get_selected_unit()
	if unit.is_empty() or battle_status != STATUS_ACTIVE:
		return false
	if not bool(unit.get("controllable", true)):
		return false

	unit["acted"] = true
	unit["moved"] = true
	selected_unit_id = ""
	_emit_log("%s waits." % unit["name"])
	_notify_changed()
	return true


func end_player_turn() -> void:
	if battle_status != STATUS_ACTIVE or current_team != TEAM_PLAYER:
		return

	selected_unit_id = ""
	current_team = TEAM_ENEMY
	_emit_log("Enemy phase begins.")
	_reset_team_actions(TEAM_ENEMY)
	if battle_status == STATUS_ACTIVE:
		_run_events("turn_start", current_team, turn_number)
	_check_battle_status()
	if battle_status == STATUS_ACTIVE:
		_run_enemy_turn()
	else:
		_notify_changed()


func get_unit(unit_id: String) -> Dictionary:
	for unit in units:
		if unit.get("id", "") == unit_id:
			return unit
	return {}


func get_selected_unit() -> Dictionary:
	if selected_unit_id.is_empty():
		return {}
	return get_unit(selected_unit_id)


func get_unit_at(cell: Vector2i, include_dead := false) -> Dictionary:
	for unit in units:
		if not unit.get("deployed", true):
			continue
		if unit.get("pos", Vector2i(-99, -99)) == cell:
			if include_dead or unit.get("alive", false):
				return unit
	return {}


func get_player_units(include_reserve := false) -> Array[Dictionary]:
	var result: Array[Dictionary] = []
	for unit in units:
		if unit.get("team", "") != TEAM_PLAYER:
			continue
		if not include_reserve and not unit.get("deployed", true):
			continue
		result.append(unit)
	return result


func get_controllable_player_units(include_reserve := false) -> Array[Dictionary]:
	var result: Array[Dictionary] = []
	for unit in get_player_units(include_reserve):
		if not unit.get("alive", false):
			continue
		if not bool(unit.get("controllable", true)):
			continue
		result.append(unit)
	return result


func get_living_units(team := "") -> Array[Dictionary]:
	var result: Array[Dictionary] = []
	for unit in units:
		if not unit.get("deployed", true):
			continue
		if not unit.get("alive", false):
			continue
		if not team.is_empty() and unit.get("team", "") != team:
			continue
		result.append(unit)
	return result


func get_movement_range(unit_id: String) -> Array[Vector2i]:
	var unit := get_unit(unit_id)
	if unit.is_empty() or unit.get("acted", false) or unit.get("moved", false):
		return []
	if not bool(unit.get("controllable", true)):
		return []
	if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true):
		return []
	if _unit_has_action_lock(unit, "move"):
		return []
	return _movement_range_for_unit(unit)


func get_attack_cells(unit_id: String) -> Array[Vector2i]:
	var unit := get_unit(unit_id)
	if unit.is_empty() or unit.get("acted", false):
		return []
	if not bool(unit.get("controllable", true)):
		return []
	if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true):
		return []
	if _unit_has_action_lock(unit, "attack"):
		return []
	return _attack_cells_from(unit, unit["pos"])


func get_skill_cells(unit_id: String, skill_id: String) -> Array[Vector2i]:
	var unit := get_unit(unit_id)
	var skill := get_skill_def(skill_id)
	if unit.is_empty() or skill.is_empty() or unit.get("acted", false):
		return []
	if not bool(unit.get("controllable", true)):
		return []
	if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true):
		return []
	if _unit_has_action_lock(unit, "skill"):
		return []
	if not _unit_has_skill(unit, skill_id):
		return []
	if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)):
		return []
	return _skill_cells_from(unit, skill)


func get_skill_area_cells(unit_id: String, skill_id: String, target_cell: Vector2i) -> Array[Vector2i]:
	var unit := get_unit(unit_id)
	var skill := get_skill_def(skill_id)
	if unit.is_empty() or skill.is_empty() or not is_inside(target_cell):
		return []
	if not _unit_has_skill(unit, skill_id):
		return []
	if not _skill_cells_from(unit, skill).has(target_cell):
		return []
	return _skill_area_cells(target_cell, skill)


func get_item_target_cells(unit_id: String, item_id: String) -> Array[Vector2i]:
	var unit := get_unit(unit_id)
	var item := get_item_def(item_id)
	if unit.is_empty() or item.is_empty() or unit.get("acted", false):
		return []
	if not bool(unit.get("controllable", true)):
		return []
	if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true):
		return []
	if int(battle_inventory.get(item_id, 0)) <= 0:
		return []
	return _item_target_cells_from(unit, item)


func get_threat_cells(source_team := TEAM_ENEMY) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	for unit in get_living_units(source_team):
		for cell in _unit_threat_cells(unit):
			if not result.has(cell):
				result.append(cell)
	return result


func get_threatening_unit_names(cell: Vector2i, source_team := TEAM_ENEMY) -> Array[String]:
	var result: Array[String] = []
	if not is_inside(cell):
		return result
	for unit in get_living_units(source_team):
		if not _unit_threat_cells(unit).has(cell):
			continue
		var unit_name := str(unit.get("name", unit.get("id", "Unit")))
		if not result.has(unit_name):
			result.append(unit_name)
	return result


func get_physical_threat_previews(cell: Vector2i, source_team := TEAM_ENEMY) -> Array[Dictionary]:
	var result: Array[Dictionary] = []
	if not is_inside(cell):
		return result
	var target := get_unit_at(cell)
	if target.is_empty() or target.get("team", "") == source_team:
		return result

	for unit in get_living_units(source_team):
		if not _unit_physical_threat_cells(unit).has(cell):
			continue
		var damage := calculate_damage(unit, target)
		var hit_chance := calculate_hit_chance(unit, target)
		var effective_bonus := _weapon_effectiveness_bonus(unit, target)
		result.append({
			"source_id": str(unit.get("id", "")),
			"source_name": str(unit.get("name", unit.get("id", "Unit"))),
			"damage": damage,
			"hit_chance": hit_chance,
			"target_hp_after": max(0, int(target.get("hp", 0)) - damage),
			"would_defeat": int(target.get("hp", 0)) - damage <= 0,
			"effective_bonus": effective_bonus,
			"effective_target_type": _weapon_effectiveness_target_type(target) if effective_bonus > 0 else ""
		})
	result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
		if bool(a.get("would_defeat", false)) != bool(b.get("would_defeat", false)):
			return bool(a.get("would_defeat", false))
		if int(a.get("damage", 0)) != int(b.get("damage", 0)):
			return int(a.get("damage", 0)) > int(b.get("damage", 0))
		return str(a.get("source_name", "")) < str(b.get("source_name", ""))
	)
	return result


func get_skill_threat_previews(cell: Vector2i, source_team := TEAM_ENEMY) -> Array[Dictionary]:
	var result: Array[Dictionary] = []
	if not is_inside(cell):
		return result
	var target := get_unit_at(cell)
	if target.is_empty() or target.get("team", "") == source_team:
		return result

	for unit in get_living_units(source_team):
		if _unit_has_action_lock(unit, "skill"):
			continue
		if typeof(unit.get("skills", [])) != TYPE_ARRAY:
			continue
		for skill_id_value in unit.get("skills", []):
			var skill_id := str(skill_id_value)
			var skill := get_skill_def(skill_id)
			if skill.is_empty():
				continue
			if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)):
				continue
			if not _skill_threatens_opponents(skill):
				continue
			if not _skill_target_valid(unit, target, skill):
				continue
			var preview := _best_skill_threat_preview_for_target(unit, skill_id, skill, target, cell)
			if not preview.is_empty():
				result.append(preview)
	result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
		var a_score := _skill_threat_preview_sort_score(a)
		var b_score := _skill_threat_preview_sort_score(b)
		if a_score != b_score:
			return a_score > b_score
		if str(a.get("source_name", "")) != str(b.get("source_name", "")):
			return str(a.get("source_name", "")) < str(b.get("source_name", ""))
		return str(a.get("skill_name", "")) < str(b.get("skill_name", ""))
	)
	return result


func get_objective_cells() -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	_collect_objective_cells(battle_conditions.get("victory", {}), result)
	return result


func get_objective_progress_lines(include_defeat_risks := false, include_turn_limit := true) -> Array[String]:
	var result: Array[String] = []
	_append_condition_progress_lines(battle_conditions.get("victory", {}), result, "victory")
	if include_defeat_risks:
		_append_condition_progress_lines(battle_conditions.get("defeat", {}), result, "defeat")
	elif include_turn_limit:
		_append_turn_limit_progress_line(result)
	return result


func get_objective_progress_text(include_defeat_risks := false, include_turn_limit := true) -> String:
	return _join_strings(get_objective_progress_lines(include_defeat_risks, include_turn_limit), " | ")


func get_defeat_progress_lines() -> Array[String]:
	var result: Array[String] = []
	_append_condition_progress_lines(battle_conditions.get("defeat", {}), result, "defeat")
	return result


func get_defeat_progress_text() -> String:
	return _join_strings(get_defeat_progress_lines(), " | ")


func get_first_skill_id(unit_id: String) -> String:
	var unit := get_unit(unit_id)
	if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY:
		return ""
	for skill_id in unit.get("skills", []):
		if not get_skill_def(str(skill_id)).is_empty():
			return str(skill_id)
	return ""


func get_skill_ids(unit_id: String) -> Array:
	var result := []
	var unit := get_unit(unit_id)
	if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY:
		return result
	if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true):
		return result
	for skill_id in unit.get("skills", []):
		var normalized := str(skill_id)
		if not get_skill_def(normalized).is_empty():
			result.append(normalized)
	return result


func get_first_usable_skill_id(unit_id: String) -> String:
	var unit := get_unit(unit_id)
	if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY:
		return ""
	if _unit_has_action_lock(unit, "skill"):
		return ""
	for skill_id in unit.get("skills", []):
		var normalized := str(skill_id)
		var skill := get_skill_def(normalized)
		if skill.is_empty():
			continue
		if int(unit.get("mp", 0)) >= int(skill.get("mp_cost", 0)):
			return normalized
	return ""


func get_usable_skill_ids(unit_id: String) -> Array:
	var result := []
	var unit := get_unit(unit_id)
	if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY:
		return result
	if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true):
		return result
	if _unit_has_action_lock(unit, "skill"):
		return result
	for skill_id in unit.get("skills", []):
		var normalized := str(skill_id)
		var skill := get_skill_def(normalized)
		if skill.is_empty():
			continue
		if int(unit.get("mp", 0)) >= int(skill.get("mp_cost", 0)):
			result.append(normalized)
	return result


func get_skill_def(skill_id: String) -> Dictionary:
	return data_catalog.get_skill(skill_id)


func get_item_def(item_id: String) -> Dictionary:
	return data_catalog.get_item(item_id)


func get_shop_item_ids() -> Array:
	var result := []
	for item_id in shop.get("items", []):
		var normalized := str(item_id)
		var item := get_item_def(normalized)
		if item.is_empty() or int(item.get("price", 0)) <= 0:
			continue
		result.append(normalized)
	result.sort()
	return result


func get_shop_stock_limit(item_id: String) -> int:
	var normalized := str(item_id)
	if normalized.is_empty():
		return -1
	var stock: Dictionary = shop.get("stock", {})
	if not stock.has(normalized):
		return -1
	return int(stock.get(normalized, -1))


func get_shop_merchant() -> Dictionary:
	var merchant = shop.get("merchant", {})
	if typeof(merchant) != TYPE_DICTIONARY:
		return {}
	return merchant.duplicate(true)


func get_formation_cells() -> Array[Vector2i]:
	return formation_cells.duplicate()


func get_deployment_max_units() -> int:
	var configured := int(deployment_rules.get("max_units", 0))
	if configured > 0:
		return configured
	return get_player_units(true).size()


func has_deployment_roster() -> bool:
	return (
		int(deployment_rules.get("max_units", 0)) > 0
		or not Array(deployment_rules.get("required_officers", [])).is_empty()
		or not Array(deployment_rules.get("required_units", [])).is_empty()
	)


func has_deployment_roster_choices() -> bool:
	if not has_deployment_roster():
		return false
	var deployed_count := get_deployed_player_count()
	var max_units := get_deployment_max_units()
	for unit in get_player_units(true):
		if bool(unit.get("required_deployment", false)):
			continue
		if bool(unit.get("deployed", true)):
			return true
		if deployed_count < max_units:
			return true
	return false


func get_deployed_player_count() -> int:
	return get_player_units(false).size()


func is_required_deployment(unit_id: String) -> bool:
	var unit := get_unit(unit_id)
	if unit.is_empty():
		return false
	return bool(unit.get("required_deployment", false))


func try_set_unit_deployed(unit_id: String, deployed: bool) -> bool:
	var unit := get_unit(unit_id)
	if unit.is_empty() or unit.get("team", "") != TEAM_PLAYER:
		return false
	if bool(unit.get("required_deployment", false)) and not deployed:
		_emit_log("%s must deploy." % unit["name"])
		return false
	if bool(unit.get("deployed", true)) == deployed:
		return true
	if deployed:
		if get_deployed_player_count() >= get_deployment_max_units():
			_emit_log("Deployment limit reached.")
			return false
		var target_cell := _formation_cell_for_deployment(unit)
		if not is_inside(target_cell):
			_emit_log("No open formation cell for %s." % unit["name"])
			return false
		unit["pos"] = target_cell
		unit["alive"] = true
		unit["deployed"] = true
		_emit_log("%s joins the sortie." % unit["name"])
	else:
		unit["deployed"] = false
		_emit_log("%s moves to reserve." % unit["name"])
	_notify_changed()
	return true


func is_formation_cell(cell: Vector2i) -> bool:
	return formation_cells.has(cell)


func try_set_prebattle_formation(unit_id: String, cell: Vector2i) -> bool:
	var unit := get_unit(unit_id)
	if unit.is_empty() or unit.get("team", "") != TEAM_PLAYER:
		return false
	if not bool(unit.get("controllable", true)):
		return false
	if not unit.get("deployed", true):
		return false
	if not is_formation_cell(unit.get("pos", Vector2i(-99, -99))):
		return false
	if not is_formation_cell(cell) or not is_inside(cell):
		return false
	if get_move_cost(cell, str(unit.get("move_type", "foot"))) >= 99:
		return false

	var occupant := get_unit_at(cell)
	if not occupant.is_empty():
		if occupant.get("team", "") != TEAM_PLAYER:
			return false
		if not bool(occupant.get("controllable", true)):
			return false
		if occupant.get("id", "") == unit_id:
			return true
		occupant["pos"] = unit["pos"]
	unit["pos"] = cell
	_emit_log("%s takes formation at %s." % [unit["name"], _format_cell(cell)])
	_notify_changed()
	return true


func _formation_cell_for_deployment(unit: Dictionary) -> Vector2i:
	var current: Vector2i = unit.get("pos", Vector2i(-99, -99))
	if is_formation_cell(current) and get_unit_at(current).is_empty() and get_move_cost(current, str(unit.get("move_type", "foot"))) < 99:
		return current
	for cell in formation_cells:
		if not get_unit_at(cell).is_empty():
			continue
		if get_move_cost(cell, str(unit.get("move_type", "foot"))) >= 99:
			continue
		return cell
	return Vector2i(-99, -99)


func try_equip_item(unit_id: String, item_id: String) -> bool:
	var unit := get_unit(unit_id)
	var item := get_item_def(item_id)
	if unit.is_empty() or item.is_empty() or battle_status != STATUS_ACTIVE:
		return false
	if unit.get("team", "") != TEAM_PLAYER or current_team != TEAM_PLAYER:
		return false
	if not bool(unit.get("controllable", true)):
		return false
	if unit.get("moved", false) or unit.get("acted", false):
		_emit_log("%s can change equipment before moving or acting." % unit["name"])
		return false

	var slot := _equipment_slot_for_item(item)
	if slot.is_empty():
		return false
	if int(battle_inventory.get(item_id, 0)) <= 0:
		_emit_log("No %s is in inventory." % item.get("name", item_id))
		return false
	if not _unit_can_equip_item(unit, item):
		_emit_log("%s cannot equip %s." % [unit["name"], item.get("name", item_id)])
		return false

	var equipment: Dictionary = unit.get("equipment", {}).duplicate(true)
	var old_item_id := _equipment_item_id_for_slot(equipment, slot)
	if old_item_id == item_id:
		_emit_log("%s already has %s equipped." % [unit["name"], item.get("name", item_id)])
		return false

	battle_inventory[item_id] = max(0, int(battle_inventory.get(item_id, 0)) - 1)
	var old_item := get_item_def(old_item_id)
	if not old_item_id.is_empty() and not old_item.is_empty():
		battle_inventory[old_item_id] = int(battle_inventory.get(old_item_id, 0)) + 1

	equipment[slot] = item_id
	unit["equipment"] = equipment
	_apply_equipment_stat_delta(unit, old_item, item)
	_refresh_attack_range_from_equipment(unit)
	_emit_log("%s equips %s." % [unit["name"], item.get("name", item_id)])
	_notify_changed()
	return true


func try_unequip_item(unit_id: String, slot: String) -> bool:
	var unit := get_unit(unit_id)
	if unit.is_empty() or battle_status != STATUS_ACTIVE:
		return false
	if unit.get("team", "") != TEAM_PLAYER or current_team != TEAM_PLAYER:
		return false
	if not bool(unit.get("controllable", true)):
		return false
	if unit.get("moved", false) or unit.get("acted", false):
		_emit_log("%s can change equipment before moving or acting." % unit["name"])
		return false

	var normalized_slot := _normalized_equipment_slot(slot)
	if normalized_slot.is_empty():
		return false

	var equipment: Dictionary = unit.get("equipment", {}).duplicate(true)
	var old_item_id := _equipment_item_id_for_slot(equipment, normalized_slot)
	if old_item_id.is_empty():
		_emit_log("%s has no %s equipped." % [unit["name"], normalized_slot])
		return false

	var old_item := get_item_def(old_item_id)
	if old_item.is_empty():
		push_error("Cannot unequip unknown item %s from %s." % [old_item_id, unit["name"]])
		return false

	equipment.erase(normalized_slot)
	unit["equipment"] = equipment
	_apply_equipment_stat_delta(unit, old_item, {})
	_refresh_attack_range_from_equipment(unit)
	battle_inventory[old_item_id] = int(battle_inventory.get(old_item_id, 0)) + 1
	_emit_log("%s unequips %s." % [unit["name"], old_item.get("name", old_item_id)])
	_notify_changed()
	return true


func get_equippable_item_ids(unit_id: String) -> Array:
	var result := []
	var unit := get_unit(unit_id)
	if unit.is_empty() or unit.get("team", "") != TEAM_PLAYER:
		return result
	if not bool(unit.get("controllable", true)):
		return result
	if not unit.get("deployed", true):
		return result
	var equipment: Dictionary = unit.get("equipment", {})
	for item_id in battle_inventory.keys():
		var normalized := str(item_id)
		if int(battle_inventory.get(normalized, 0)) <= 0:
			continue
		var item := get_item_def(normalized)
		if item.is_empty() or _equipment_slot_for_item(item).is_empty():
			continue
		var slot := _equipment_slot_for_item(item)
		if _equipment_item_id_for_slot(equipment, slot) == normalized:
			continue
		if not _unit_can_equip_item(unit, item):
			continue
		result.append(normalized)
	result.sort()
	return result


func get_equipped_equipment_slots(unit_id: String) -> Array:
	var result := []
	var unit := get_unit(unit_id)
	if unit.is_empty() or unit.get("team", "") != TEAM_PLAYER:
		return result
	if not bool(unit.get("controllable", true)):
		return result
	var equipment: Dictionary = unit.get("equipment", {})
	for slot in ["weapon", "armor", "accessory"]:
		var item_id := _equipment_item_id_for_slot(equipment, slot)
		if item_id.is_empty():
			continue
		if get_item_def(item_id).is_empty():
			continue
		result.append(slot)
	return result


func get_equipment_snapshot(unit_id: String) -> Dictionary:
	var unit := get_unit(unit_id)
	if unit.is_empty():
		return {}
	return unit.get("equipment", {}).duplicate(true)


func get_usable_item_ids() -> Array:
	var result := []
	for item_id in battle_inventory.keys():
		var normalized := str(item_id)
		if int(battle_inventory.get(normalized, 0)) <= 0:
			continue
		var item := get_item_def(normalized)
		if item.is_empty() or str(item.get("kind", "")) != "consumable":
			continue
		result.append(normalized)
	result.sort()
	return result


func get_inventory_snapshot() -> Dictionary:
	return battle_inventory.duplicate(true)


func set_inventory_snapshot(inventory_snapshot: Dictionary) -> void:
	battle_inventory = inventory_snapshot.duplicate(true)
	_notify_changed()


func get_battle_gold_reward() -> int:
	return max(0, battle_gold_reward)


func get_terrain_key(cell: Vector2i) -> String:
	if not is_inside(cell):
		return "G"
	if cell.y >= terrain_rows.size():
		return "G"
	var row := terrain_rows[cell.y]
	if cell.x >= row.length():
		return "G"
	return row.substr(cell.x, 1)


func get_map_background_path() -> String:
	return map_background


func get_terrain_name(cell: Vector2i) -> String:
	return String(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("name", "Plain"))


func get_terrain_color(cell: Vector2i) -> Color:
	return terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("color", DEFAULT_TERRAIN_DEFS["G"]["color"])


func get_move_cost(cell: Vector2i, move_type := "foot") -> int:
	var move_cost = terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("move_cost", 1)
	if typeof(move_cost) == TYPE_DICTIONARY:
		return int(move_cost.get(move_type, move_cost.get("foot", 1)))
	return int(move_cost)


func get_terrain_defense(cell: Vector2i) -> int:
	return int(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("defense", 0))


func get_terrain_avoid(cell: Vector2i) -> int:
	return int(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("avoid", 0))


func get_cell_summary(cell: Vector2i) -> Dictionary:
	if not is_inside(cell):
		return {}

	var unit := get_unit_at(cell)
	return {
		"cell": cell,
		"terrain": get_terrain_name(cell),
		"move_cost": get_move_cost(cell),
		"defense": get_terrain_defense(cell),
		"avoid": get_terrain_avoid(cell),
		"unit": unit
	}


func get_briefing() -> Dictionary:
	return briefing.duplicate(true)


func get_rewards() -> Dictionary:
	return rewards.duplicate(true)


func get_post_battle_choices() -> Array:
	return post_battle_choices.duplicate(true)


func get_post_battle_dialogue() -> Array:
	return post_battle_dialogue.duplicate(true)


func get_progression_events() -> Array:
	return progression_events.duplicate(true)


func get_player_roster_snapshot() -> Dictionary:
	var snapshot := {}
	for unit in units:
		if unit.get("team", "") != TEAM_PLAYER:
			continue
		if not bool(unit.get("persist_progression", true)):
			continue
		var roster_key := str(unit.get("officer_id", unit["id"]))
		if roster_key.is_empty():
			roster_key = str(unit["id"])
		snapshot[roster_key] = {
			"id": unit["id"],
			"officer_id": roster_key,
			"name": unit["name"],
			"class_id": unit.get("class_id", ""),
			"class": unit["class"],
			"move": int(unit.get("move", 3)),
			"move_type": str(unit.get("move_type", "foot")),
			"min_range": int(unit.get("min_range", 1)),
			"range": int(unit.get("range", 1)),
			"level": int(unit.get("level", 1)),
			"exp": int(unit.get("exp", 0)),
			"hp": int(unit.get("hp", 0)),
			"max_hp": int(unit.get("max_hp", 1)),
			"mp": int(unit.get("mp", 0)),
			"max_mp": int(unit.get("max_mp", 0)),
			"atk": int(unit.get("atk", 1)),
			"def": int(unit.get("def", 0)),
			"int": int(unit.get("int", 0)),
			"agi": int(unit.get("agi", 0)),
			"growth": unit.get("growth", {}).duplicate(true),
			"skills": unit.get("skills", []).duplicate(true),
			"equipment": unit.get("equipment", {}).duplicate(true)
		}
	return snapshot


func is_inside(cell: Vector2i) -> bool:
	return cell.x >= 0 and cell.y >= 0 and cell.x < map_size.x and cell.y < map_size.y


func can_player_act() -> bool:
	return battle_status == STATUS_ACTIVE and current_team == TEAM_PLAYER


func get_status_text() -> String:
	if battle_status == STATUS_VICTORY:
		return "Victory"
	if battle_status == STATUS_DEFEAT:
		return "Defeat"
	var turn_limit := get_turn_limit()
	if turn_limit > 0:
		return "Turn %d/%d - %s" % [turn_number, turn_limit, current_team.capitalize()]
	return "Turn %d - %s" % [turn_number, current_team.capitalize()]


func get_turn_limit() -> int:
	return _find_turn_limit(battle_conditions.get("defeat", {}))


func _movement_range_for_unit(unit: Dictionary) -> Array[Vector2i]:
	if _unit_has_action_lock(unit, "move"):
		return []
	var start: Vector2i = unit["pos"]
	var frontier: Array[Vector2i] = [start]
	var costs := {start: 0}
	var result: Array[Vector2i] = []

	while not frontier.is_empty():
		var current: Vector2i = frontier.pop_front()
		for direction in CARDINAL_DIRECTIONS:
			var next_cell: Vector2i = current + direction
			if not is_inside(next_cell):
				continue

			var terrain_cost := get_move_cost(next_cell, str(unit.get("move_type", "foot")))
			if terrain_cost >= 99:
				continue
			if next_cell != start and not get_unit_at(next_cell).is_empty():
				continue

			var new_cost := int(costs[current]) + terrain_cost
			if new_cost > _effective_stat(unit, "move"):
				continue
			if not costs.has(next_cell) or new_cost < int(costs[next_cell]):
				costs[next_cell] = new_cost
				frontier.append(next_cell)
				if next_cell != start and not result.has(next_cell):
					result.append(next_cell)

	return result


func _unit_threat_cells(unit: Dictionary) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	if unit.is_empty() or not unit.get("alive", false) or not unit.get("deployed", true):
		return result
	if not bool(unit.get("controllable", true)):
		return result

	for cell in _unit_physical_threat_cells(unit):
		if not result.has(cell):
			result.append(cell)
	for cell in _unit_skill_threat_cells(unit):
		if not result.has(cell):
			result.append(cell)
	return result


func _unit_threat_origins(unit: Dictionary) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	var origin: Vector2i = unit.get("pos", Vector2i(-1, -1))
	if not is_inside(origin):
		return result
	result.append(origin)
	for cell in _movement_range_for_unit(unit):
		if not result.has(cell):
			result.append(cell)
	return result


func _unit_physical_threat_cells(unit: Dictionary) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	if _unit_has_action_lock(unit, "attack"):
		return result
	for origin in _unit_threat_origins(unit):
		for cell in _attack_cells_from(unit, origin):
			if not result.has(cell):
				result.append(cell)
	return result


func _unit_skill_threat_cells(unit: Dictionary) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	if _unit_has_action_lock(unit, "skill"):
		return result
	if typeof(unit.get("skills", [])) != TYPE_ARRAY:
		return result
	for skill_id_value in unit.get("skills", []):
		var skill_id := str(skill_id_value)
		var skill := get_skill_def(skill_id)
		if skill.is_empty():
			continue
		if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)):
			continue
		if not _skill_threatens_opponents(skill):
			continue
		for origin in _unit_threat_origins(unit):
			for center_cell in _skill_cells_from_origin(unit, skill, origin):
				for cell in _skill_area_cells(center_cell, skill):
					if not result.has(cell):
						result.append(cell)
	return result


func _best_skill_threat_preview_for_target(caster: Dictionary, skill_id: String, skill: Dictionary, target: Dictionary, target_cell: Vector2i) -> Dictionary:
	var best_preview := {}
	var best_score := -999999
	for origin in _unit_threat_origins(caster):
		for center_cell in _skill_cells_from_origin(caster, skill, origin):
			if not _skill_area_cells(center_cell, skill).has(target_cell):
				continue
			var targets := _skill_targets_for_cast(caster, skill_id, skill, center_cell)
			if targets.is_empty():
				continue
			var preview := _build_skill_threat_preview(caster, skill_id, skill, target, origin, center_cell, targets)
			var score := _skill_threat_preview_sort_score(preview)
			if score > best_score:
				best_score = score
				best_preview = preview
	return best_preview


func _build_skill_threat_preview(caster: Dictionary, skill_id: String, skill: Dictionary, target: Dictionary, origin: Vector2i, center_cell: Vector2i, targets: Array[Dictionary]) -> Dictionary:
	var skill_kind := str(skill.get("kind", "damage"))
	var preview := {
		"source_id": str(caster.get("id", "")),
		"source_name": str(caster.get("name", caster.get("id", "Unit"))),
		"skill_id": skill_id,
		"skill_name": str(skill.get("name", skill_id)),
		"kind": skill_kind,
		"mp_cost": int(skill.get("mp_cost", 0)),
		"origin_cell": origin,
		"cast_cell": center_cell,
		"requires_move": origin != caster.get("pos", origin),
		"has_area": _skill_has_area(skill),
		"area_shape": _skill_area_shape(skill),
		"area_radius": _skill_area_radius(skill),
		"target_count": targets.size()
	}
	if skill_kind == "support":
		preview["effect_text"] = _format_skill_threat_effect_summary(skill)
		preview["already_active"] = _has_status_effect_from_skill(target, skill_id)
	else:
		var damage := calculate_skill_damage(caster, target, skill)
		var total_damage := 0
		var defeat_count := 0
		for area_target in targets:
			var area_damage := calculate_skill_damage(caster, area_target, skill)
			total_damage += area_damage
			if int(area_target.get("hp", 0)) - area_damage <= 0:
				defeat_count += 1
		preview["damage"] = damage
		preview["total_damage"] = total_damage
		preview["target_hp_after"] = max(0, int(target.get("hp", 0)) - damage)
		preview["would_defeat"] = int(target.get("hp", 0)) - damage <= 0
		preview["defeat_count"] = defeat_count
	return preview


func _skill_threat_preview_sort_score(preview: Dictionary) -> int:
	var score := 0
	if str(preview.get("kind", "")) == "damage":
		score += int(preview.get("damage", 0)) * 10
		score += int(preview.get("defeat_count", 0)) * 2000
		if bool(preview.get("would_defeat", false)):
			score += 10000
	else:
		score += _skill_threat_effect_score(preview)
		if bool(preview.get("already_active", false)):
			score -= 250
	score += int(preview.get("target_count", 0)) * 20
	if bool(preview.get("has_area", false)):
		score += 5
	if not bool(preview.get("requires_move", false)):
		score += 2
	return score


func _skill_threat_effect_score(preview: Dictionary) -> int:
	var skill := get_skill_def(str(preview.get("skill_id", "")))
	if skill.is_empty():
		return 0
	var score := 400
	for effect in skill.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		var effect_type := str(effect.get("type", ""))
		if effect_type == "action_lock" and not str(effect.get("action", "")).strip_edges().is_empty():
			score += 350
		elif effect_type == "damage_over_time":
			score += 250 + max(0, int(effect.get("amount", 0))) * 20
		elif effect_type == "stat_bonus":
			score += 150 + abs(int(effect.get("amount", 0))) * 25
	return score


func _format_skill_threat_effect_summary(skill: Dictionary) -> String:
	var parts := []
	for effect in skill.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		var effect_type := str(effect.get("type", ""))
		if effect_type == "stat_bonus":
			var stat := str(effect.get("stat", ""))
			var amount := int(effect.get("amount", 0))
			if not _is_supported_status_stat(stat) or amount == 0:
				continue
			var sign := "+" if amount > 0 else ""
			parts.append("%s %s%d" % [stat.to_upper(), sign, amount])
		elif effect_type == "damage_over_time":
			var damage := int(effect.get("amount", 0))
			if damage <= 0:
				continue
			parts.append("%s -%d HP" % [_status_display_name(str(effect.get("status", "poison"))), damage])
		elif effect_type == "action_lock":
			var status_name := _status_display_name(str(effect.get("status", "status")))
			parts.append("%s %s" % [status_name, _action_lock_display_name(str(effect.get("action", "")))])
	if parts.is_empty():
		return _format_support_effects(skill)
	return _join_strings(parts, ", ")


func _skill_threatens_opponents(skill: Dictionary) -> bool:
	var target_rule := str(skill.get("target", "enemy"))
	if target_rule != "enemy" and target_rule != "any":
		return false
	var skill_kind := str(skill.get("kind", "damage"))
	if skill_kind == "damage":
		return true
	if skill_kind == "support":
		return _skill_has_harmful_support_effects(skill)
	return false


func _skill_has_harmful_support_effects(skill: Dictionary) -> bool:
	for effect in skill.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		var effect_type := str(effect.get("type", ""))
		if effect_type == "damage_over_time" and int(effect.get("amount", 0)) > 0:
			return true
		if effect_type == "action_lock" and not str(effect.get("action", "")).strip_edges().is_empty():
			return true
		if effect_type == "stat_bonus" and int(effect.get("amount", 0)) < 0:
			return true
	return false


func _attack_cells_from(unit: Dictionary, origin: Vector2i) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	var attack_range := int(unit.get("range", 1))
	var min_range := int(unit.get("min_range", min(1, attack_range)))
	for y in range(origin.y - attack_range, origin.y + attack_range + 1):
		for x in range(origin.x - attack_range, origin.x + attack_range + 1):
			var cell := Vector2i(x, y)
			if cell == origin or not is_inside(cell):
				continue
			var distance := _manhattan(origin, cell)
			if distance >= min_range and distance <= attack_range:
				result.append(cell)
	return result


func _is_in_attack_range(attacker: Dictionary, target_cell: Vector2i) -> bool:
	if _unit_has_action_lock(attacker, "attack"):
		return false
	return _attack_cells_from(attacker, attacker["pos"]).has(target_cell)


func _skill_cells_from(unit: Dictionary, skill: Dictionary) -> Array[Vector2i]:
	return _skill_cells_from_origin(unit, skill, unit["pos"])


func _skill_cells_from_origin(_unit: Dictionary, skill: Dictionary, origin: Vector2i) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	var range_pair := _skill_range_pair(skill)
	var max_range := int(range_pair["max"])
	var min_range := int(range_pair["min"])
	for y in range(origin.y - max_range, origin.y + max_range + 1):
		for x in range(origin.x - max_range, origin.x + max_range + 1):
			var cell := Vector2i(x, y)
			if not is_inside(cell):
				continue
			var distance := _manhattan(origin, cell)
			if distance >= min_range and distance <= max_range:
				result.append(cell)
	return result


func _skill_area_cells(center: Vector2i, skill: Dictionary) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	if not is_inside(center):
		return result
	var radius: int = _skill_area_radius(skill)
	var shape := _skill_area_shape(skill)
	if radius <= 0 or shape == "single":
		result.append(center)
		return result

	for y in range(center.y - radius, center.y + radius + 1):
		for x in range(center.x - radius, center.x + radius + 1):
			var cell := Vector2i(x, y)
			if not is_inside(cell):
				continue
			var distance := _manhattan(center, cell)
			if shape == "diamond" and distance <= radius:
				result.append(cell)
	return result


func _skill_has_area(skill: Dictionary) -> bool:
	return _skill_area_radius(skill) > 0 and _skill_area_shape(skill) != "single"


func _skill_area_shape(skill: Dictionary) -> String:
	var shape := str(skill.get("area_shape", "single")).strip_edges().to_lower()
	if shape == "diamond":
		return shape
	return "single"


func _skill_area_radius(skill: Dictionary) -> int:
	return maxi(0, int(skill.get("area_radius", 0)))


func _skill_targets_for_cast(caster: Dictionary, skill_id: String, skill: Dictionary, target_cell: Vector2i, skip_existing_support := false) -> Array[Dictionary]:
	var result: Array[Dictionary] = []
	var skill_kind := str(skill.get("kind", "damage"))
	for cell in _skill_area_cells(target_cell, skill):
		var target := get_unit_at(cell)
		if not _skill_target_valid(caster, target, skill):
			continue
		if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0:
			continue
		if skill_kind == "support" and skip_existing_support and _has_status_effect_from_skill(target, skill_id):
			continue
		result.append(target)
	return result


func _skill_range_pair(skill: Dictionary) -> Dictionary:
	var value = skill.get("range", 1)
	if typeof(value) == TYPE_ARRAY:
		if value.size() >= 2:
			return _normalized_skill_range(int(value[0]), int(value[1]))
		if value.size() == 1:
			return _normalized_skill_range(int(value[0]), int(value[0]))
		return _normalized_skill_range(1, 1)
	if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT:
		return _normalized_skill_range(1, 1)
	var scalar: int = maxi(0, int(value))
	return _normalized_skill_range(scalar, scalar)


func _normalized_skill_range(min_range: int, max_range: int) -> Dictionary:
	var safe_min: int = maxi(0, min_range)
	var safe_max: int = maxi(safe_min, max_range)
	return {"min": safe_min, "max": safe_max}


func _unit_has_skill(unit: Dictionary, skill_id: String) -> bool:
	if typeof(unit.get("skills", [])) != TYPE_ARRAY:
		return false
	for known_skill_id in unit.get("skills", []):
		if str(known_skill_id) == skill_id:
			return true
	return false


func _skill_target_valid(caster: Dictionary, target: Dictionary, skill: Dictionary) -> bool:
	if target.is_empty() or not target.get("alive", false):
		return false
	var target_rule := str(skill.get("target", "enemy"))
	if target_rule == "ally":
		return target.get("team", "") == caster.get("team", "")
	if target_rule == "self":
		return target.get("id", "") == caster.get("id", "")
	if target_rule == "any":
		return true
	return target.get("team", "") != caster.get("team", "")


func _item_target_cells_from(unit: Dictionary, item: Dictionary) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	var range_pair := _item_range_pair(item)
	var max_range := int(range_pair["max"])
	var min_range := int(range_pair["min"])
	var origin: Vector2i = unit["pos"]
	for y in range(origin.y - max_range, origin.y + max_range + 1):
		for x in range(origin.x - max_range, origin.x + max_range + 1):
			var cell := Vector2i(x, y)
			if not is_inside(cell):
				continue
			var distance := _manhattan(origin, cell)
			if distance >= min_range and distance <= max_range:
				result.append(cell)
	return result


func _item_range_pair(item: Dictionary) -> Dictionary:
	var value = item.get("range", [0, 1])
	if typeof(value) == TYPE_ARRAY:
		if value.size() >= 2:
			return _normalized_skill_range(int(value[0]), int(value[1]))
		if value.size() == 1:
			return _normalized_skill_range(int(value[0]), int(value[0]))
		return _normalized_skill_range(0, 1)
	if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT:
		return _normalized_skill_range(0, 1)
	var scalar: int = maxi(0, int(value))
	return _normalized_skill_range(scalar, scalar)


func _item_target_valid(user: Dictionary, target: Dictionary, item: Dictionary) -> bool:
	if target.is_empty() or not target.get("alive", false):
		return false
	var target_rule := str(item.get("target", "ally"))
	if target_rule == "ally":
		return target.get("team", "") == user.get("team", "")
	if target_rule == "self":
		return target.get("id", "") == user.get("id", "")
	if target_rule == "any":
		return true
	return target.get("team", "") != user.get("team", "")


func _apply_item_effects(user: Dictionary, target: Dictionary, item: Dictionary) -> bool:
	var applied := false
	for effect in item.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		var effect_type := str(effect.get("type", ""))
		if effect_type == "heal_hp":
			var healed := _apply_item_heal(target, int(effect.get("amount", 0)))
			if healed > 0:
				applied = true
				_emit_log("%s recovers %d HP." % [target["name"], healed])
				_emit_combat_feedback(target, "+%d HP" % healed, "heal")
		elif effect_type == "heal_mp":
			var restored := _apply_item_mp_heal(target, int(effect.get("amount", 0)))
			if restored > 0:
				applied = true
				_emit_log("%s recovers %d MP." % [target["name"], restored])
				_emit_combat_feedback(target, "+%d MP" % restored, "mp")
		elif effect_type == "cure_status":
			var cured_statuses := _apply_item_status_cure(target, str(effect.get("status", "")))
			if not cured_statuses.is_empty():
				applied = true
				_emit_log("%s is cured of %s." % [target["name"], _join_strings(cured_statuses, ", ")])
				_emit_combat_feedback(target, "CURE", "support")
		elif effect_type == "cleanse_debuffs":
			var cleansed_debuffs := _apply_item_debuff_cleanse(target)
			if not cleansed_debuffs.is_empty():
				applied = true
				_emit_log("%s is cleansed of %s." % [target["name"], _join_strings(cleansed_debuffs, ", ")])
				_emit_combat_feedback(target, "CLEANSE", "support")
	if not applied:
		_emit_log("%s has no effect on %s." % [item.get("name", "Item"), target["name"]])
	return applied


func _apply_item_heal(target: Dictionary, amount: int) -> int:
	if amount <= 0:
		return 0
	var missing_hp := int(target.get("max_hp", 1)) - int(target.get("hp", 0))
	var healed = min(amount, max(0, missing_hp))
	target["hp"] = min(int(target.get("max_hp", 1)), int(target.get("hp", 0)) + healed)
	return healed


func _apply_item_mp_heal(target: Dictionary, amount: int) -> int:
	if amount <= 0:
		return 0
	var missing_mp := int(target.get("max_mp", 0)) - int(target.get("mp", 0))
	var restored = min(amount, max(0, missing_mp))
	target["mp"] = min(int(target.get("max_mp", 0)), int(target.get("mp", 0)) + restored)
	return restored


func _apply_item_status_cure(target: Dictionary, status: String) -> Array:
	var cured_statuses := []
	if typeof(target.get("status_effects", [])) != TYPE_ARRAY:
		return cured_statuses
	var normalized_status := status.strip_edges().to_lower()
	var kept_effects := []
	for effect in _active_status_effects(target):
		var effect_status := str(effect.get("status", "")).strip_edges().to_lower()
		var should_cure := not effect_status.is_empty() and (normalized_status.is_empty() or effect_status == normalized_status)
		if should_cure:
			var display_name := _status_display_name(str(effect.get("status", normalized_status)))
			if not cured_statuses.has(display_name):
				cured_statuses.append(display_name)
			continue
		kept_effects.append(effect)
	target["status_effects"] = kept_effects
	return cured_statuses


func _apply_item_debuff_cleanse(target: Dictionary) -> Array:
	var cleansed_debuffs := []
	if typeof(target.get("status_effects", [])) != TYPE_ARRAY:
		return cleansed_debuffs
	var kept_effects := []
	for effect in _active_status_effects(target):
		if _is_negative_stat_bonus_effect(effect):
			var display_name := _stat_debuff_display_name(effect)
			if not cleansed_debuffs.has(display_name):
				cleansed_debuffs.append(display_name)
			continue
		kept_effects.append(effect)
	target["status_effects"] = kept_effects
	return cleansed_debuffs


func _item_hp_heal_amount(target: Dictionary, item: Dictionary) -> int:
	var total := 0
	for effect in item.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		if str(effect.get("type", "")) == "heal_hp":
			total += int(effect.get("amount", 0))
	var missing_hp := int(target.get("max_hp", 1)) - int(target.get("hp", 0))
	return max(0, min(total, missing_hp))


func _item_mp_heal_amount(target: Dictionary, item: Dictionary) -> int:
	var total := 0
	for effect in item.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		if str(effect.get("type", "")) == "heal_mp":
			total += int(effect.get("amount", 0))
	var missing_mp := int(target.get("max_mp", 0)) - int(target.get("mp", 0))
	return max(0, min(total, missing_mp))


func _item_curable_status_names(target: Dictionary, item: Dictionary) -> Array:
	var result := []
	if target.is_empty():
		return result
	for effect in item.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		if str(effect.get("type", "")) != "cure_status":
			continue
		var status := str(effect.get("status", "")).strip_edges().to_lower()
		for active_effect in _active_status_effects(target):
			var active_status := str(active_effect.get("status", "")).strip_edges().to_lower()
			if active_status.is_empty():
				continue
			if not status.is_empty() and active_status != status:
				continue
			var display_name := _status_display_name(str(active_effect.get("status", status)))
			if not result.has(display_name):
				result.append(display_name)
	return result


func _item_cleansable_debuff_names(target: Dictionary, item: Dictionary) -> Array:
	var result := []
	if target.is_empty() or not _item_has_effect_type(item, "cleanse_debuffs"):
		return result
	for active_effect in _active_status_effects(target):
		if not _is_negative_stat_bonus_effect(active_effect):
			continue
		var display_name := _stat_debuff_display_name(active_effect)
		if not result.has(display_name):
			result.append(display_name)
	return result


func _item_has_effect_type(item: Dictionary, effect_type: String) -> bool:
	for effect in item.get("effects", []):
		if typeof(effect) == TYPE_DICTIONARY and str(effect.get("type", "")) == effect_type:
			return true
	return false


func _is_negative_stat_bonus_effect(effect: Dictionary) -> bool:
	if str(effect.get("type", "")) != "stat_bonus":
		return false
	var stat := str(effect.get("stat", ""))
	return _is_supported_status_stat(stat) and int(effect.get("amount", 0)) < 0


func _stat_debuff_display_name(effect: Dictionary) -> String:
	var stat := str(effect.get("stat", "stat"))
	if not _is_supported_status_stat(stat):
		return "Debuff"
	return "%s down" % stat.to_upper()


func _equipment_slot_for_item(item: Dictionary) -> String:
	var kind := str(item.get("kind", ""))
	if kind == "weapon" or kind == "armor" or kind == "accessory":
		return kind
	return ""


func _normalized_equipment_slot(slot: String) -> String:
	var normalized := slot.strip_edges().to_lower()
	if normalized == "weapon" or normalized == "armor" or normalized == "accessory":
		return normalized
	return ""


func _equipment_item_id_for_slot(equipment: Dictionary, slot: String) -> String:
	var item_id = equipment.get(slot, "")
	if item_id == null:
		return ""
	return str(item_id)


func _unit_can_equip_item(unit: Dictionary, item: Dictionary) -> bool:
	var slot := _equipment_slot_for_item(item)
	if slot.is_empty():
		return false
	var class_def := data_catalog.get_class_def(str(unit.get("class_id", "")))
	var equipment_slots: Dictionary = class_def.get("equipment_slots", {})
	var allowed = equipment_slots.get(slot, [])
	if typeof(allowed) != TYPE_ARRAY:
		return false
	if slot == "weapon":
		return allowed.has(str(item.get("weapon_type", "")))
	if slot == "armor":
		return allowed.has(str(item.get("armor_type", "")))
	if slot == "accessory":
		var accessory_type := str(item.get("accessory_type", "accessory"))
		return allowed.has(accessory_type)
	return false


func _apply_equipment_stat_delta(unit: Dictionary, old_item: Dictionary, new_item: Dictionary) -> void:
	var old_bonuses: Dictionary = old_item.get("bonuses", {}) if not old_item.is_empty() else {}
	var new_bonuses: Dictionary = new_item.get("bonuses", {}) if not new_item.is_empty() else {}
	_apply_stat_bonus_delta(unit, old_bonuses, new_bonuses)


func _apply_stat_bonus_delta(unit: Dictionary, old_bonuses: Dictionary, new_bonuses: Dictionary) -> void:
	for stat in ["hp", "mp", "atk", "def", "int", "agi"]:
		var delta := int(new_bonuses.get(stat, 0)) - int(old_bonuses.get(stat, 0))
		if delta == 0:
			continue
		if stat == "hp":
			unit["max_hp"] = max(1, int(unit.get("max_hp", 1)) + delta)
			unit["hp"] = clampi(int(unit.get("hp", 1)) + delta, 1, int(unit["max_hp"]))
		elif stat == "mp":
			unit["max_mp"] = max(0, int(unit.get("max_mp", 0)) + delta)
			unit["mp"] = clampi(int(unit.get("mp", 0)) + delta, 0, int(unit["max_mp"]))
		elif stat == "atk":
			unit[stat] = max(1, int(unit.get(stat, 1)) + delta)
		else:
			unit[stat] = max(0, int(unit.get(stat, 0)) + delta)


func _apply_class_runtime_fields(unit: Dictionary, class_id: String) -> void:
	var class_def := data_catalog.get_class_def(class_id)
	if class_def.is_empty():
		return
	unit["class"] = str(class_def.get("name", class_id.capitalize()))
	unit["move"] = int(class_def.get("move", unit.get("move", 3)))
	unit["move_type"] = str(class_def.get("move_type", unit.get("move_type", "foot")))
	unit["growth"] = class_def.get("growth", {}).duplicate(true)
	_add_missing_skills(unit, class_def.get("skills", []))
	_refresh_attack_range_from_equipment(unit)


func _refresh_attack_range_from_equipment(unit: Dictionary) -> void:
	var class_def := data_catalog.get_class_def(str(unit.get("class_id", "")))
	var range_pair := _equipment_attack_range_pair(class_def, unit.get("equipment", {}))
	unit["min_range"] = int(range_pair["min"])
	unit["range"] = int(range_pair["max"])


func _equipment_attack_range_pair(class_def: Dictionary, equipment: Dictionary) -> Dictionary:
	var attack_range := _attack_range_pair_from_value(class_def.get("attack_range", 1))
	for slot in equipment.keys():
		var item_id = equipment[slot]
		if item_id == null:
			continue
		var item := get_item_def(str(item_id))
		if item.has("range"):
			var item_range := _attack_range_pair_from_value(item["range"])
			attack_range["min"] = min(int(attack_range["min"]), int(item_range["min"]))
			attack_range["max"] = max(int(attack_range["max"]), int(item_range["max"]))
	return attack_range


func _attack_range_pair_from_value(value) -> Dictionary:
	if typeof(value) == TYPE_ARRAY:
		if value.size() >= 2:
			return _normalized_attack_range_pair(int(value[0]), int(value[1]))
		if value.size() == 1:
			return _normalized_attack_range_pair(int(value[0]), int(value[0]))
		return _normalized_attack_range_pair(1, 1)
	if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT:
		return _normalized_attack_range_pair(1, 1)
	var scalar: int = maxi(1, int(value))
	return _normalized_attack_range_pair(scalar, scalar)


func _normalized_attack_range_pair(min_range: int, max_range: int) -> Dictionary:
	var safe_min: int = maxi(1, min_range)
	var safe_max: int = maxi(safe_min, max_range)
	return {"min": safe_min, "max": safe_max}


func _run_enemy_turn() -> void:
	for enemy in get_living_units(TEAM_ENEMY):
		if battle_status != STATUS_ACTIVE:
			break
		if enemy.get("acted", false):
			continue
		_enemy_take_action(enemy)
		enemy["acted"] = true
		enemy["moved"] = true
		_check_battle_status()

	if battle_status == STATUS_ACTIVE:
		turn_number += 1
		current_team = TEAM_PLAYER
		_emit_log("Player phase begins.")
		_reset_team_actions(TEAM_PLAYER)
		if battle_status == STATUS_ACTIVE:
			_run_events("turn_start", current_team, turn_number)
		_check_battle_status()

	_notify_changed()


func _enemy_take_action(enemy: Dictionary) -> void:
	if _try_enemy_skill_action(enemy):
		return

	var attack_action := _best_ai_physical_attack_action(enemy, [enemy["pos"]])
	if not attack_action.is_empty():
		_resolve_combat(enemy, attack_action["target"])
		return

	var best_cell: Vector2i = enemy["pos"]
	var move_attack_action := _best_ai_physical_attack_action(enemy, _movement_range_for_unit(enemy))
	if not move_attack_action.is_empty():
		best_cell = move_attack_action["origin"]
	else:
		var target := _find_nearest_target(enemy, TEAM_PLAYER)
		if target.is_empty():
			return

		var best_distance := _manhattan(best_cell, target["pos"])
		for cell in _movement_range_for_unit(enemy):
			var distance := _manhattan(cell, target["pos"])
			if distance < best_distance:
				best_distance = distance
				best_cell = cell

	if best_cell != enemy["pos"]:
		var from_cell: Vector2i = enemy["pos"]
		enemy["pos"] = best_cell
		enemy["moved"] = true
		unit_motion_requested.emit(str(enemy.get("id", "")), from_cell, best_cell)
		_emit_log("%s advances to %s." % [enemy["name"], _format_cell(best_cell)])
		_run_events("unit_reaches_tile", str(enemy.get("team", "")), turn_number, enemy)
		if battle_status != STATUS_ACTIVE:
			return

	if _try_enemy_skill_action(enemy):
		return

	attack_action = _best_ai_physical_attack_action(enemy, [enemy["pos"]])
	if not attack_action.is_empty():
		_resolve_combat(enemy, attack_action["target"])


func _best_ai_physical_attack_action(attacker: Dictionary, origins: Array[Vector2i]) -> Dictionary:
	var best_action := {}
	var best_score := -999999
	var target_team := _opposing_team(str(attacker.get("team", "")))
	if target_team.is_empty() or _unit_has_action_lock(attacker, "attack"):
		return {}

	for origin in origins:
		if not is_inside(origin):
			continue
		for target in get_living_units(target_team):
			if not _attack_cells_from(attacker, origin).has(target["pos"]):
				continue
			var damage := calculate_damage(attacker, target)
			var score := _physical_attack_ai_score(attacker, target, damage)
			score -= _manhattan(attacker["pos"], origin) * 2
			if score > best_score:
				best_score = score
				best_action = {
					"origin": origin,
					"target": target,
					"damage": damage,
					"score": score
				}
	return best_action


func _physical_attack_ai_score(attacker: Dictionary, target: Dictionary, damage: int) -> int:
	var score := damage * 10
	score += _weapon_effectiveness_bonus(attacker, target) * 6
	score += _ai_target_priority_score(target)
	if int(target.get("hp", 0)) - damage <= 0:
		score += 1000
	return score


func _try_enemy_skill_action(enemy: Dictionary) -> bool:
	if enemy.is_empty() or enemy.get("acted", false):
		return false
	if typeof(enemy.get("skills", [])) != TYPE_ARRAY:
		return false
	if _unit_has_action_lock(enemy, "skill"):
		return false

	var finishing_action := _best_ai_damage_skill_action(enemy, true)
	if not finishing_action.is_empty() and _execute_ai_skill_action(enemy, finishing_action):
		return true

	var heal_action := _best_ai_heal_skill_action(enemy)
	if not heal_action.is_empty() and _execute_ai_skill_action(enemy, heal_action):
		return true

	var support_action := _best_ai_support_skill_action(enemy)
	if not support_action.is_empty() and _execute_ai_skill_action(enemy, support_action):
		return true

	var damage_action := _best_ai_damage_skill_action(enemy, false)
	if not damage_action.is_empty() and _execute_ai_skill_action(enemy, damage_action):
		return true

	return false


func _best_ai_heal_skill_action(caster: Dictionary) -> Dictionary:
	var best_action := {}
	var best_score := -1
	for skill_id in caster.get("skills", []):
		var normalized_skill_id := str(skill_id)
		var skill := get_skill_def(normalized_skill_id)
		if skill.is_empty() or str(skill.get("kind", "")) != "heal":
			continue
		if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)):
			continue
		for target_cell in _skill_cells_from(caster, skill):
			var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell)
			if targets.is_empty():
				continue
			var total_heal := 0
			var total_missing_hp := 0
			var lowest_hp_ratio := 1.0
			for ally in targets:
				var heal_amount := calculate_skill_heal(caster, ally, skill)
				var missing_hp := int(ally.get("max_hp", 1)) - int(ally.get("hp", 0))
				var hp_ratio := float(ally.get("hp", 0)) / float(max(1, int(ally.get("max_hp", 1))))
				total_heal += heal_amount
				total_missing_hp += missing_hp
				lowest_hp_ratio = minf(lowest_hp_ratio, hp_ratio)
			if lowest_hp_ratio > 0.5 and total_missing_hp < 10:
				continue
			var score := total_heal * 10 + total_missing_hp + targets.size() * 4
			score -= _manhattan(caster["pos"], target_cell)
			if score > best_score:
				best_score = score
				best_action = {
					"skill_id": normalized_skill_id,
					"skill": skill,
					"target": targets[0],
					"target_cell": target_cell
				}
	return best_action


func _best_ai_support_skill_action(caster: Dictionary) -> Dictionary:
	var best_action := {}
	var best_score := -1
	for skill_id in caster.get("skills", []):
		var normalized_skill_id := str(skill_id)
		var skill := get_skill_def(normalized_skill_id)
		if skill.is_empty() or str(skill.get("kind", "")) != "support":
			continue
		if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)):
			continue
		if not _skill_has_support_effects(skill):
			continue
		for target_cell in _skill_cells_from(caster, skill):
			var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell, true)
			if targets.is_empty():
				continue
			var score := 0
			for target in targets:
				score += _support_ai_score(caster, target, skill)
			if score <= 0:
				continue
			score += targets.size() * 3
			score -= _manhattan(caster["pos"], target_cell)
			if score > best_score:
				best_score = score
				best_action = {
					"skill_id": normalized_skill_id,
					"skill": skill,
					"target": targets[0],
					"target_cell": target_cell
				}
	return best_action


func _support_ai_targets_for_skill(caster: Dictionary, skill: Dictionary) -> Array[Dictionary]:
	var target_rule := str(skill.get("target", "ally"))
	if target_rule == "self":
		return [caster]
	if target_rule == "enemy":
		var target_team: String = _opposing_team(str(caster.get("team", "")))
		return get_living_units(target_team) if not target_team.is_empty() else []
	if target_rule == "any":
		var targets: Array[Dictionary] = get_living_units(str(caster.get("team", "")))
		var opposing_team: String = _opposing_team(str(caster.get("team", "")))
		if not opposing_team.is_empty():
			targets.append_array(get_living_units(opposing_team))
		return targets
	return get_living_units(str(caster.get("team", "")))


func _support_ai_score(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int:
	var score: int = 0
	var target_is_ally: bool = target.get("team", "") == caster.get("team", "")
	for effect in skill.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		var effect_type := str(effect.get("type", ""))
		if effect_type == "stat_bonus":
			var stat := str(effect.get("stat", ""))
			var amount := int(effect.get("amount", 0))
			if not _is_supported_status_stat(stat) or amount == 0:
				continue
			if amount > 0 and target_is_ally:
				score += amount * _support_ai_stat_weight(stat)
				if target.get("id", "") == caster.get("id", ""):
					score += 3
			elif amount < 0 and not target_is_ally:
				score += absi(amount) * _support_ai_stat_weight(stat)
				score += _ai_target_priority_score(target)
		elif effect_type == "damage_over_time" and not target_is_ally:
			var damage := int(effect.get("amount", 0))
			if damage <= 0:
				continue
			var duration: int = max(1, int(effect.get("duration_turns", 1)))
			score += damage * duration * 5
			score += _ai_target_priority_score(target)
		elif effect_type == "action_lock" and not target_is_ally:
			var action := str(effect.get("action", ""))
			if _unit_has_action_lock(target, action):
				continue
			var threat_score := _unit_action_threat_score(target, action)
			if threat_score <= 0:
				continue
			score += 18 + threat_score
			score += _ai_target_priority_score(target)
	return score


func _support_ai_stat_weight(stat: String) -> int:
	if stat == "atk":
		return 9
	if stat == "move":
		return 8
	if stat == "def":
		return 7
	if stat == "int":
		return 6
	if stat == "agi":
		return 4
	return 1


func _unit_skill_threat_score(unit: Dictionary) -> int:
	if typeof(unit.get("skills", [])) != TYPE_ARRAY:
		return 0
	if int(unit.get("mp", 0)) <= 0:
		return 0

	var score := 0
	for skill_id in unit.get("skills", []):
		var skill := get_skill_def(str(skill_id))
		if skill.is_empty():
			continue
		if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)):
			continue
		var kind := str(skill.get("kind", "damage"))
		if kind == "damage":
			score += 12
		elif kind == "heal":
			score += 9
		elif kind == "support":
			score += 7
	return score


func _unit_action_threat_score(unit: Dictionary, action: String) -> int:
	if action == "skill":
		return _unit_skill_threat_score(unit)
	if action == "move":
		var move_score := int(unit.get("move", 0)) * 5
		if int(unit.get("range", 1)) <= 1:
			move_score += 10
		return move_score
	if action == "attack":
		return _effective_stat(unit, "atk") * 4 + int(unit.get("range", 1)) * 4
	return 0


func _best_ai_damage_skill_action(caster: Dictionary, defeating_only := false) -> Dictionary:
	var best_action := {}
	var best_score := -1
	var target_team := _opposing_team(str(caster.get("team", "")))
	if target_team.is_empty():
		return {}

	for skill_id in caster.get("skills", []):
		var normalized_skill_id := str(skill_id)
		var skill := get_skill_def(normalized_skill_id)
		if skill.is_empty() or str(skill.get("kind", "")) != "damage":
			continue
		if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)):
			continue
		for target_cell in _skill_cells_from(caster, skill):
			var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell)
			if targets.is_empty():
				continue
			var total_damage := 0
			var defeat_count := 0
			var priority_score := 0
			for target in targets:
				var damage := calculate_skill_damage(caster, target, skill)
				total_damage += damage
				priority_score += _ai_target_priority_score(target)
				if int(target.get("hp", 0)) - damage <= 0:
					defeat_count += 1
			if defeating_only and defeat_count <= 0:
				continue
			var score := total_damage + priority_score + targets.size() * 5
			if defeat_count > 0:
				score += 1000 * defeat_count
			score -= _manhattan(caster["pos"], target_cell)
			if score > best_score:
				best_score = score
				best_action = {
					"skill_id": normalized_skill_id,
					"skill": skill,
					"target": targets[0],
					"target_cell": target_cell
				}
	return best_action


func _execute_ai_skill_action(caster: Dictionary, action: Dictionary) -> bool:
	var skill_id := str(action.get("skill_id", ""))
	var skill: Dictionary = action.get("skill", {})
	var target: Dictionary = action.get("target", {})
	if skill_id.is_empty() or skill.is_empty():
		return false
	if not _unit_has_skill(caster, skill_id):
		return false
	if _unit_has_action_lock(caster, "skill"):
		return false
	if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)):
		return false
	var target_cell: Vector2i = action.get("target_cell", target.get("pos", Vector2i(-999, -999)))
	if not is_inside(target_cell):
		return false
	var skill_kind := str(skill.get("kind", "damage"))
	var skip_existing_support := skill_kind == "support"
	var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell, skip_existing_support)
	if targets.is_empty():
		return false
	if not _skill_cells_from(caster, skill).has(target_cell):
		return false
	if skill_kind == "support" and not _skill_has_support_effects(skill):
		return false

	caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0)))
	if skill_kind == "heal":
		var total_healed := 0
		for area_target in targets:
			total_healed += _resolve_skill_heal(caster, area_target, skill)
		if total_healed > 0:
			_grant_experience(caster, EXP_HEAL)
	elif skill_kind == "support":
		var applied_support := false
		for area_target in targets:
			applied_support = _resolve_skill_support(caster, area_target, skill_id, skill) or applied_support
		if applied_support:
			_grant_experience(caster, EXP_SUPPORT)
	else:
		var defeated_count := 0
		for area_target in targets:
			if _resolve_skill_damage(caster, area_target, skill):
				defeated_count += 1
		_grant_experience(caster, EXP_SKILL + defeated_count * EXP_DEFEAT_BONUS)
	_emit_log("%s spends %d MP." % [caster["name"], int(skill.get("mp_cost", 0))])
	return true


func _find_nearest_target(unit: Dictionary, target_team: String) -> Dictionary:
	var best_target := {}
	var best_score := 9999
	for target in get_living_units(target_team):
		var distance := _manhattan(unit["pos"], target["pos"])
		var score := distance - int(target.get("ai_target_priority", 0))
		if score < best_score:
			best_score = score
			best_target = target
	return best_target


func _ai_target_priority_score(target: Dictionary) -> int:
	return max(0, int(target.get("ai_target_priority", 0))) * 8


func _opposing_team(team: String) -> String:
	if team == TEAM_PLAYER:
		return TEAM_ENEMY
	if team == TEAM_ENEMY:
		return TEAM_PLAYER
	return ""


func _resolve_combat(attacker: Dictionary, target: Dictionary) -> void:
	var attack_result := _resolve_attack(attacker, target, false)
	var attack_exp := EXP_ATTACK if bool(attack_result.get("hit", false)) else EXP_MISS
	if bool(attack_result.get("defeated", false)) or battle_status != STATUS_ACTIVE:
		_grant_experience(attacker, attack_exp + (EXP_DEFEAT_BONUS if bool(attack_result.get("defeated", false)) else 0))
		return

	var counter_exp := 0
	if bool(target.get("controllable", true)) and _is_in_attack_range(target, attacker["pos"]):
		var counter_result := _resolve_attack(target, attacker, true)
		counter_exp = (EXP_COUNTER if bool(counter_result.get("hit", false)) else EXP_MISS) + (EXP_DEFEAT_BONUS if bool(counter_result.get("defeated", false)) else 0)

	_grant_experience(attacker, attack_exp)
	_grant_experience(target, counter_exp)


func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := false) -> Dictionary:
	var hit_chance := calculate_hit_chance(attacker, target)
	var verb := "counterattacks" if is_counter else "attacks"
	unit_action_motion_requested.emit(
		str(attacker.get("id", "")),
		attacker.get("pos", Vector2i(-1, -1)),
		target.get("pos", Vector2i(-1, -1)),
		"counter" if is_counter else "attack"
	)
	if rng.randi_range(1, 100) > hit_chance:
		_emit_log("%s %s %s but misses. Hit %d%%." % [attacker["name"], verb, target["name"], hit_chance])
		_emit_combat_feedback(target, "MISS", "miss")
		return {"hit": false, "defeated": false}
	var damage := calculate_damage(attacker, target)
	target["hp"] = max(0, int(target["hp"]) - damage)
	var effective_bonus := _weapon_effectiveness_bonus(attacker, target)
	var effective_text := ""
	if effective_bonus > 0:
		effective_text = " Effective +%d vs %s." % [effective_bonus, _format_move_type(_weapon_effectiveness_target_type(target))]
	_emit_log("%s %s %s for %d damage. Hit %d%%.%s" % [attacker["name"], verb, target["name"], damage, hit_chance, effective_text])
	_emit_combat_feedback(target, "-%d" % damage, "damage")

	if int(target["hp"]) <= 0:
		target["alive"] = false
		_emit_log("%s is defeated." % target["name"])
		_emit_combat_feedback(target, "DOWN", "defeat")
		_handle_unit_defeated(target)
		return {"hit": true, "defeated": true}
	return {"hit": true, "defeated": false}


func _resolve_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictionary) -> bool:
	var damage := calculate_skill_damage(caster, target, skill)
	target["hp"] = max(0, int(target["hp"]) - damage)
	_emit_log("%s casts %s on %s for %d damage." % [
		caster["name"],
		skill.get("name", "Skill"),
		target["name"],
		damage
	])
	_emit_combat_feedback(target, "-%d" % damage, "damage")

	if int(target["hp"]) <= 0:
		target["alive"] = false
		_emit_log("%s is defeated." % target["name"])
		_emit_combat_feedback(target, "DOWN", "defeat")
		_handle_unit_defeated(target)
		return true
	return false


func _handle_unit_defeated(unit: Dictionary) -> void:
	if unit.is_empty():
		return
	_run_events("unit_defeated", str(unit.get("team", "")), turn_number, unit)


func _resolve_skill_heal(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int:
	var healed := calculate_skill_heal(caster, target, skill)
	target["hp"] = min(int(target["max_hp"]), int(target["hp"]) + healed)
	_emit_log("%s casts %s on %s, restoring %d HP." % [
		caster["name"],
		skill.get("name", "Skill"),
		target["name"],
		healed
	])
	_emit_combat_feedback(target, "+%d HP" % healed, "heal")
	return healed


func _grant_experience(unit: Dictionary, amount: int) -> void:
	if unit.is_empty() or not unit.get("alive", false) or amount <= 0:
		return
	unit["exp"] = int(unit.get("exp", 0)) + amount
	_emit_log("%s gains %d EXP." % [unit["name"], amount])
	while int(unit["exp"]) >= EXP_LEVEL:
		unit["exp"] = int(unit["exp"]) - EXP_LEVEL
		_level_up(unit)


func _level_up(unit: Dictionary) -> void:
	var previous_level := int(unit.get("level", 1))
	unit["level"] = int(unit.get("level", 1)) + 1
	var hp_gain := _growth_gain(unit, "hp", 3)
	var mp_gain := _growth_gain(unit, "mp", 1)
	unit["max_hp"] = int(unit.get("max_hp", 1)) + hp_gain
	unit["hp"] = min(int(unit["max_hp"]), int(unit.get("hp", 1)) + hp_gain)
	unit["max_mp"] = int(unit.get("max_mp", 0)) + mp_gain
	unit["mp"] = min(int(unit["max_mp"]), int(unit.get("mp", 0)) + mp_gain)
	for stat in ["atk", "def", "int", "agi"]:
		unit[stat] = int(unit.get(stat, 0)) + _growth_gain(unit, stat, 1)
	_emit_log("%s reaches Lv.%d." % [unit["name"], unit["level"]])
	_emit_combat_feedback(unit, "Lv.%d" % int(unit["level"]), "progress")
	if str(unit.get("team", "")) == TEAM_PLAYER:
		_record_progression_event({
			"type": "level_up",
			"unit_id": str(unit.get("id", "")),
			"officer_id": str(unit.get("officer_id", "")),
			"name": str(unit.get("name", "Unit")),
			"from_level": previous_level,
			"to_level": int(unit.get("level", previous_level + 1)),
			"class_id": str(unit.get("class_id", "")),
			"class": str(unit.get("class", ""))
		})
	_try_promote_unit(unit)


func _try_promote_unit(unit: Dictionary) -> void:
	var class_id := str(unit.get("class_id", ""))
	var class_def := data_catalog.get_class_def(class_id)
	if class_def.is_empty():
		return
	var promotion = class_def.get("promotion", {})
	if typeof(promotion) != TYPE_DICTIONARY:
		return
	var required_level := int(promotion.get("level", 0))
	var next_class_id := str(promotion.get("to", ""))
	if required_level <= 0 or next_class_id.is_empty() or int(unit.get("level", 1)) < required_level:
		return
	var next_class_def := data_catalog.get_class_def(next_class_id)
	if next_class_def.is_empty():
		return
	var previous_class_id := class_id
	var previous_class_name := str(class_def.get("name", class_id.capitalize()))
	_apply_stat_bonus_delta(unit, class_def.get("base_bonus", {}), next_class_def.get("base_bonus", {}))
	unit["class_id"] = next_class_id
	_apply_class_runtime_fields(unit, next_class_id)
	_emit_log("%s promotes to %s." % [unit["name"], unit["class"]])
	_emit_combat_feedback(unit, "PROMOTE", "progress")
	if str(unit.get("team", "")) == TEAM_PLAYER:
		_record_progression_event({
			"type": "promotion",
			"unit_id": str(unit.get("id", "")),
			"officer_id": str(unit.get("officer_id", "")),
			"name": str(unit.get("name", "Unit")),
			"level": int(unit.get("level", 1)),
			"from_class_id": previous_class_id,
			"from_class": previous_class_name,
			"to_class_id": next_class_id,
			"to_class": str(unit.get("class", next_class_id.capitalize()))
		})


func _add_missing_skills(unit: Dictionary, source) -> void:
	if typeof(source) != TYPE_ARRAY:
		return
	if not unit.has("skills") or typeof(unit["skills"]) != TYPE_ARRAY:
		unit["skills"] = []
	for skill_id in source:
		var normalized := str(skill_id)
		if normalized.is_empty() or unit["skills"].has(normalized):
			continue
		unit["skills"].append(normalized)


func _record_progression_event(event: Dictionary) -> void:
	if str(event.get("name", "")).is_empty() or str(event.get("type", "")).is_empty():
		return
	progression_events.append(event.duplicate(true))


func _growth_gain(unit: Dictionary, stat: String, fallback: int) -> int:
	var growth: Dictionary = unit.get("growth", {})
	var grade := str(growth.get(stat, "C"))
	var table: Dictionary = GROWTH_GRADE_GAINS.get(stat, {})
	var gain := int(table.get(grade, fallback))
	var bonus: Dictionary = unit.get("growth_bonus", {})
	return max(0, gain + int(bonus.get(stat, 0)))


func _check_battle_status() -> void:
	if _is_condition_group_met(battle_conditions.get("defeat", {})):
		battle_status = STATUS_DEFEAT
		current_team = TEAM_PLAYER
		selected_unit_id = ""
		_emit_log("Defeat condition met.")
	elif _is_condition_group_met(battle_conditions.get("victory", {})):
		battle_status = STATUS_VICTORY
		current_team = TEAM_PLAYER
		selected_unit_id = ""
		_emit_log("Victory condition met.")


func _is_condition_group_met(condition_group) -> bool:
	if typeof(condition_group) == TYPE_ARRAY:
		for condition in condition_group:
			if _is_condition_met(condition):
				return true
		return false
	return _is_condition_met(condition_group)


func _is_condition_met(condition) -> bool:
	if typeof(condition) != TYPE_DICTIONARY:
		return false
	if not _condition_gate_open(condition):
		return false

	var condition_type := str(condition.get("type", ""))
	if condition_type == "all":
		return _all_conditions_met(condition.get("conditions", []))
	if condition_type == "any":
		return _is_condition_group_met(condition.get("conditions", []))
	if condition_type == "all_units_defeated":
		return get_living_units(str(condition.get("team", ""))).is_empty()
	if condition_type == "all_enemies_defeated":
		return get_living_units(TEAM_ENEMY).is_empty()
	if condition_type == "all_players_defeated":
		return get_living_units(TEAM_PLAYER).is_empty()
	if condition_type == "any_unit_defeated":
		return _any_unit_defeated(condition.get("unit_ids", []))
	if condition_type == "any_officer_defeated":
		return _any_officer_defeated(condition.get("officer_ids", []), str(condition.get("team", "")))
	if condition_type == "unit_reaches_tile":
		return _unit_reaches_tile(condition)
	if condition_type == "turn_limit":
		return _is_turn_limit_exceeded(condition)
	if condition_type == "turn_reached":
		return _is_turn_reached(condition)
	return false


func _append_condition_progress_lines(condition_group, result: Array[String], mode: String, pending_parent := false) -> void:
	if typeof(condition_group) == TYPE_ARRAY:
		for condition in condition_group:
			_append_condition_progress_lines(condition, result, mode, pending_parent)
		return
	if typeof(condition_group) != TYPE_DICTIONARY:
		return

	var pending := pending_parent or not _condition_gate_open(condition_group)
	if not _condition_gate_open(condition_group):
		_append_after_event_progress_line(result, str(condition_group.get("after_event", "")))
	var condition_type := str(condition_group.get("type", ""))
	if condition_type == "all" or condition_type == "any":
		_append_condition_progress_lines(condition_group.get("conditions", []), result, mode, pending)
		return
	if condition_type == "all_units_defeated":
		_append_all_units_progress_line(result, str(condition_group.get("team", "")), mode, pending)
	elif condition_type == "all_enemies_defeated":
		_append_all_units_progress_line(result, TEAM_ENEMY, mode, pending)
	elif condition_type == "all_players_defeated":
		_append_all_units_progress_line(result, TEAM_PLAYER, mode, pending)
	elif condition_type == "any_unit_defeated":
		_append_target_units_progress_line(result, condition_group.get("unit_ids", []), mode, pending)
	elif condition_type == "any_officer_defeated":
		_append_target_officers_progress_line(
			result,
			condition_group.get("officer_ids", []),
			str(condition_group.get("team", "")),
			mode,
			pending
		)
	elif condition_type == "unit_reaches_tile":
		_append_destination_progress_line(result, condition_group, pending)
	elif condition_type == "turn_limit" or condition_type == "turn_reached":
		_append_turn_condition_progress_line(result, condition_group, mode, pending)


func _append_all_units_progress_line(result: Array[String], team: String, mode: String, pending := false) -> void:
	var total := _count_deployed_units(team)
	if total <= 0:
		return
	var living := _count_living_deployed_units(team)
	if mode == "defeat":
		_append_progress_line(result, "%s remaining %d/%d" % [_condition_team_label(team), living, total], pending)
		return
	_append_progress_line(result, "%s defeated %d/%d" % [_condition_team_label(team), total - living, total], pending)


func _append_target_units_progress_line(result: Array[String], unit_ids, mode: String, pending := false) -> void:
	var counts := _count_condition_units(unit_ids)
	var total := int(counts.get("total", 0))
	if total <= 0:
		return
	var defeated := int(counts.get("defeated", 0))
	if mode == "defeat" and total == 1:
		var unit_name := _condition_unit_display_name(unit_ids[0])
		var status := "defeated" if defeated > 0 else "safe"
		_append_progress_line(result, "%s %s" % [unit_name, status], pending)
		return
	var label := "Protected losses" if mode == "defeat" else "Target units defeated"
	var suffix := " (defeat at 1)" if mode == "defeat" else ""
	_append_progress_line(result, "%s %d/%d%s" % [label, defeated, total, suffix], pending)


func _append_target_officers_progress_line(
	result: Array[String],
	officer_ids,
	team: String,
	mode: String,
	pending := false
) -> void:
	var counts := _count_condition_officers(officer_ids, team)
	var total := int(counts.get("total", 0))
	if total <= 0:
		return
	var defeated := int(counts.get("defeated", 0))
	if mode == "defeat" and total == 1:
		var officer_name := _condition_officer_display_name(str(officer_ids[0]), team)
		var status := "defeated" if defeated > 0 else "safe"
		_append_progress_line(result, "%s %s" % [officer_name, status], pending)
		return
	var label := "Officer losses" if mode == "defeat" else "Target officers defeated"
	var suffix := " (defeat at 1)" if mode == "defeat" else ""
	_append_progress_line(result, "%s %d/%d%s" % [label, defeated, total, suffix], pending)


func _append_destination_progress_line(result: Array[String], condition: Dictionary, pending := false) -> void:
	var targets := _condition_cells(condition)
	if targets.is_empty():
		return
	var status := "reached" if _unit_reaches_tile(condition) else "not reached"
	_append_progress_line(result, "Destination %s: %s" % [_format_cells(targets), status], pending)


func _append_turn_limit_progress_line(result: Array[String]) -> void:
	var limit := get_turn_limit()
	if limit <= 0:
		return
	var turns_left: int = maxi(0, limit - turn_number + 1)
	_append_progress_line(result, "Turns left %d" % turns_left)


func _append_after_event_progress_line(result: Array[String], event_id: String) -> void:
	if event_id.is_empty() or fired_event_ids.has(event_id):
		return
	var event := _event_by_id(event_id)
	if event.is_empty():
		return
	var when: Dictionary = event.get("when", {})
	var trigger_type := str(when.get("type", ""))
	if trigger_type == "unit_reaches_tile":
		_append_event_destination_progress_line(result, when)
	elif trigger_type == "turn_start":
		var target_turn := int(when.get("turn", 0))
		if target_turn > 0:
			_append_progress_line(result, "Objective unlocks on Turn %d" % target_turn)


func _append_event_destination_progress_line(result: Array[String], when: Dictionary) -> void:
	var targets := _condition_cells(when)
	if targets.is_empty():
		return
	var status := "reached" if _unit_reaches_tile(when) else "not reached"
	_append_progress_line(result, "Reach %s: %s" % [_format_cells(targets), status])


func _append_turn_condition_progress_line(result: Array[String], condition: Dictionary, mode: String, pending := false) -> void:
	var condition_type := str(condition.get("type", ""))
	if condition_type == "turn_limit":
		var limit := int(condition.get("turn", condition.get("limit", 0)))
		if limit <= 0:
			return
		_append_progress_line(result, "Turns left %d" % maxi(0, limit - turn_number + 1), pending)
	elif condition_type == "turn_reached":
		var target_turn := int(condition.get("turn", 0))
		if target_turn <= 0:
			return
		if mode == "defeat":
			var target_team := str(condition.get("team", ""))
			var limit_turn := target_turn - 1 if target_team == TEAM_PLAYER else target_turn
			_append_progress_line(result, "Turns left %d" % maxi(0, limit_turn - turn_number + 1), pending)
			return
		_append_progress_line(result, "Turn %d/%d" % [min(turn_number, target_turn), target_turn], pending)


func _append_progress_line(result: Array[String], text: String, pending := false) -> void:
	if text.is_empty():
		return
	if pending:
		text += " (pending)"
	if not result.has(text):
		result.append(text)


func _count_deployed_units(team := "") -> int:
	var count := 0
	for unit in units:
		if not unit.get("deployed", true):
			continue
		if not team.is_empty() and unit.get("team", "") != team:
			continue
		count += 1
	return count


func _count_living_deployed_units(team := "") -> int:
	var count := 0
	for unit in units:
		if not unit.get("deployed", true):
			continue
		if not unit.get("alive", false):
			continue
		if not team.is_empty() and unit.get("team", "") != team:
			continue
		count += 1
	return count


func _count_condition_units(unit_ids) -> Dictionary:
	var result := {"total": 0, "defeated": 0}
	if typeof(unit_ids) != TYPE_ARRAY:
		return result
	var seen := {}
	for unit_id in unit_ids:
		var normalized_id := str(unit_id)
		if normalized_id.is_empty() or seen.has(normalized_id):
			continue
		seen[normalized_id] = true
		var unit := get_unit(normalized_id)
		if unit.is_empty() or not unit.get("deployed", true):
			continue
		result["total"] = int(result["total"]) + 1
		if not unit.get("alive", false):
			result["defeated"] = int(result["defeated"]) + 1
	return result


func _count_condition_officers(officer_ids, team := "") -> Dictionary:
	var result := {"total": 0, "defeated": 0}
	if typeof(officer_ids) != TYPE_ARRAY:
		return result
	var seen := {}
	for officer_id in officer_ids:
		var normalized_id := str(officer_id)
		if normalized_id.is_empty() or seen.has(normalized_id):
			continue
		seen[normalized_id] = true
		if not _condition_officer_exists(normalized_id, team):
			continue
		result["total"] = int(result["total"]) + 1
		if _is_officer_defeated(normalized_id, team):
			result["defeated"] = int(result["defeated"]) + 1
	return result


func _condition_officer_exists(officer_id: String, team := "") -> bool:
	if officer_id.is_empty():
		return false
	for unit in units:
		if not team.is_empty() and unit.get("team", "") != team:
			continue
		if not unit.get("deployed", true):
			continue
		if str(unit.get("officer_id", "")) == officer_id:
			return true
	return false


func _condition_unit_display_name(unit_id: String) -> String:
	var unit := get_unit(unit_id)
	if unit.is_empty():
		return unit_id
	return str(unit.get("name", unit_id))


func _condition_officer_display_name(officer_id: String, team := "") -> String:
	for unit in units:
		if not team.is_empty() and unit.get("team", "") != team:
			continue
		if not unit.get("deployed", true):
			continue
		if str(unit.get("officer_id", "")) == officer_id:
			return str(unit.get("name", officer_id))
	var officer: Dictionary = data_catalog.officers.get(officer_id, {})
	return str(officer.get("name", officer_id))


func _condition_team_label(team: String) -> String:
	if team == TEAM_ENEMY:
		return "Enemies"
	if team == TEAM_PLAYER:
		return "Allies"
	return "Units"


func _event_by_id(event_id: String) -> Dictionary:
	if event_id.is_empty():
		return {}
	for event in battle_events:
		if str(event.get("id", "")) == event_id:
			return event
	return {}


func _is_turn_limit_exceeded(condition: Dictionary) -> bool:
	var limit := int(condition.get("turn", condition.get("limit", 0)))
	if limit <= 0:
		return false
	return turn_number > limit and current_team == TEAM_PLAYER


func _is_turn_reached(condition: Dictionary) -> bool:
	var target_turn := int(condition.get("turn", 0))
	if target_turn <= 0:
		return false
	var target_team := str(condition.get("team", ""))
	if not target_team.is_empty() and current_team != target_team:
		return false
	return turn_number >= target_turn


func _unit_reaches_tile(condition: Dictionary) -> bool:
	var targets := _condition_cells(condition)
	if targets.is_empty():
		return false
	var team := str(condition.get("team", ""))
	var unit_ids = condition.get("unit_ids", [])
	var officer_ids = condition.get("officer_ids", [])
	for unit in units:
		if not unit.get("deployed", true):
			continue
		if not unit.get("alive", false):
			continue
		if not team.is_empty() and unit.get("team", "") != team:
			continue
		if not _unit_matches_condition_ids(unit, unit_ids, officer_ids):
			continue
		if targets.has(unit.get("pos", Vector2i(-9999, -9999))):
			return true
	return false


func _unit_matches_condition_ids(unit: Dictionary, unit_ids, officer_ids) -> bool:
	var has_unit_filter: bool = typeof(unit_ids) == TYPE_ARRAY and not unit_ids.is_empty()
	var has_officer_filter: bool = typeof(officer_ids) == TYPE_ARRAY and not officer_ids.is_empty()
	if not has_unit_filter and not has_officer_filter:
		return true

	var unit_id := str(unit.get("id", ""))
	if has_unit_filter:
		for allowed_unit_id in unit_ids:
			if unit_id == str(allowed_unit_id):
				return true

	var officer_id := str(unit.get("officer_id", ""))
	if has_officer_filter:
		for allowed_officer_id in officer_ids:
			if officer_id == str(allowed_officer_id):
				return true
	return false


func _condition_cell(pos_value) -> Vector2i:
	if typeof(pos_value) != TYPE_ARRAY or pos_value.size() < 2:
		return Vector2i(-9999, -9999)
	return Vector2i(int(pos_value[0]), int(pos_value[1]))


func _condition_cells(source: Dictionary) -> Array[Vector2i]:
	var result: Array[Vector2i] = []
	if source.has("cells") and typeof(source["cells"]) == TYPE_ARRAY:
		for pos_value in source["cells"]:
			var cell := _condition_cell(pos_value)
			if is_inside(cell) and not result.has(cell):
				result.append(cell)
	elif source.has("pos"):
		var cell := _condition_cell(source.get("pos", []))
		if is_inside(cell):
			result.append(cell)
	return result


func _condition_gate_open(condition: Dictionary) -> bool:
	var after_event := str(condition.get("after_event", ""))
	if after_event.is_empty():
		return true
	return fired_event_ids.has(after_event)


func _all_conditions_met(conditions) -> bool:
	if typeof(conditions) != TYPE_ARRAY or conditions.is_empty():
		return false
	for condition in conditions:
		if not _is_condition_met(condition):
			return false
	return true


func _collect_objective_cells(condition_group, result: Array[Vector2i]) -> void:
	if typeof(condition_group) == TYPE_ARRAY:
		for condition in condition_group:
			_collect_objective_cells(condition, result)
		return
	if typeof(condition_group) != TYPE_DICTIONARY:
		return
	if not _condition_gate_open(condition_group):
		_collect_after_event_objective_cells(str(condition_group.get("after_event", "")), result)
		return

	var condition_type := str(condition_group.get("type", ""))
	if condition_type == "all" or condition_type == "any":
		_collect_objective_cells(condition_group.get("conditions", []), result)
		return
	if condition_type != "unit_reaches_tile":
		return

	for cell in _condition_cells(condition_group):
		if not result.has(cell):
			result.append(cell)


func _collect_after_event_objective_cells(event_id: String, result: Array[Vector2i]) -> void:
	if event_id.is_empty() or fired_event_ids.has(event_id):
		return
	var event := _event_by_id(event_id)
	if event.is_empty():
		return
	var when: Dictionary = event.get("when", {})
	if str(when.get("type", "")) != "unit_reaches_tile":
		return
	if not _event_gate_open(when) or not _campaign_flags_match(when.get("campaign_flags", {})):
		return
	for cell in _condition_cells(when):
		if not result.has(cell):
			result.append(cell)


func _find_turn_limit(condition_group) -> int:
	if typeof(condition_group) == TYPE_ARRAY:
		var best_limit := 0
		for condition in condition_group:
			var limit := _find_turn_limit(condition)
			if limit > 0 and (best_limit == 0 or limit < best_limit):
				best_limit = limit
		return best_limit
	if typeof(condition_group) != TYPE_DICTIONARY:
		return 0
	var condition_type := str(condition_group.get("type", ""))
	if condition_type == "turn_limit":
		return int(condition_group.get("turn", condition_group.get("limit", 0)))
	if condition_type == "turn_reached":
		var target_team := str(condition_group.get("team", ""))
		if target_team == TEAM_PLAYER:
			return max(0, int(condition_group.get("turn", 0)) - 1)
		return int(condition_group.get("turn", 0))
	if condition_type == "all" or condition_type == "any":
		return _find_turn_limit(condition_group.get("conditions", []))
	return 0


func _any_unit_defeated(unit_ids) -> bool:
	if typeof(unit_ids) != TYPE_ARRAY:
		return false
	for unit_id in unit_ids:
		var unit := get_unit(str(unit_id))
		if not unit.is_empty() and unit.get("deployed", true) and not unit.get("alive", false):
			return true
	return false


func _any_officer_defeated(officer_ids, team := "") -> bool:
	if typeof(officer_ids) != TYPE_ARRAY:
		return false
	for officer_id in officer_ids:
		if _is_officer_defeated(str(officer_id), team):
			return true
	return false


func _is_officer_defeated(officer_id: String, team := "") -> bool:
	if officer_id.is_empty():
		return false
	for unit in units:
		if not team.is_empty() and unit.get("team", "") != team:
			continue
		if not unit.get("deployed", true):
			continue
		if str(unit.get("officer_id", "")) == officer_id:
			return not unit.get("alive", false)
	return false


func _run_events(trigger_type: String, team := "", turn := -1, trigger_unit: Dictionary = {}) -> void:
	for event_index in range(battle_events.size()):
		if battle_status != STATUS_ACTIVE:
			break
		var event := battle_events[event_index]
		var event_id := str(event.get("id", ""))
		if event_id.is_empty():
			event_id = "%s_%d" % [trigger_type, event_index]
		if bool(event.get("once", false)) and fired_event_ids.has(event_id):
			continue
		var when: Dictionary = event.get("when", {})
		if str(when.get("type", "")) != trigger_type:
			continue
		if when.has("team") and str(when["team"]) != team:
			continue
		if when.has("turn") and int(when["turn"]) != turn:
			continue
		if trigger_type == "unit_reaches_tile" and not _unit_reaches_event_tile(trigger_unit, when):
			continue
		if trigger_type == "unit_defeated" and not _unit_defeated_event_matches(trigger_unit, when):
			continue
		if not _event_gate_open(when):
			continue
		if not _campaign_flags_match(when.get("campaign_flags", {})):
			continue
		_execute_event_actions(event.get("actions", []), trigger_unit)
		if bool(event.get("once", false)) and not event_id.is_empty():
			fired_event_ids[event_id] = true
		_check_battle_status()


func _unit_reaches_event_tile(unit: Dictionary, when: Dictionary) -> bool:
	if unit.is_empty() or not unit.get("deployed", true) or not unit.get("alive", false):
		return false
	var targets := _condition_cells(when)
	if targets.is_empty():
		return false
	if not targets.has(unit.get("pos", Vector2i(-9999, -9999))):
		return false
	return _unit_matches_condition_ids(unit, when.get("unit_ids", []), when.get("officer_ids", []))


func _unit_defeated_event_matches(unit: Dictionary, when: Dictionary) -> bool:
	if unit.is_empty() or not unit.get("deployed", true) or unit.get("alive", true):
		return false
	return _unit_matches_condition_ids(unit, when.get("unit_ids", []), when.get("officer_ids", []))


func _event_gate_open(when: Dictionary) -> bool:
	var after_event := str(when.get("after_event", ""))
	if after_event.is_empty():
		return true
	return fired_event_ids.has(after_event)


func _campaign_flags_match(required_flags) -> bool:
	if typeof(required_flags) != TYPE_DICTIONARY:
		return true
	for flag_name in required_flags.keys():
		var key := str(flag_name)
		if key.is_empty():
			return false
		if not campaign_flags.has(key):
			return false
		if campaign_flags[key] != required_flags[flag_name]:
			return false
	return true


func _execute_event_actions(actions: Array, trigger_unit: Dictionary = {}) -> void:
	for action in actions:
		if typeof(action) != TYPE_DICTIONARY:
			continue
		_execute_event_action(action, trigger_unit)


func _execute_event_action(action: Dictionary, trigger_unit: Dictionary = {}) -> void:
	var action_type := str(action.get("type", ""))
	if action_type == "log":
		_emit_log(str(action.get("text", "")))
	elif action_type == "dialogue":
		var lines := _dialogue_lines_from_action(action)
		if not lines.is_empty():
			dialogue_requested.emit(lines)
	elif action_type == "set_objective":
		_apply_objective_event(action)
	elif action_type == "grant_item":
		_grant_event_item(action, trigger_unit)
	elif action_type == "grant_items":
		_grant_event_items(action.get("items", []), trigger_unit)
	elif action_type == "grant_gold":
		_grant_event_gold(action, trigger_unit)
	elif action_type == "spawn_deployment":
		_spawn_event_deployment(action.get("deployment", {}))
	elif action_type == "spawn_deployments":
		var deployments = action.get("deployments", [])
		if typeof(deployments) != TYPE_ARRAY:
			push_error("Skipping malformed deployment list in %s." % battle_id)
			return
		for deployment in deployments:
			_spawn_event_deployment(deployment)
	elif action_type == "withdraw_unit":
		_withdraw_event_unit(str(action.get("unit_id", action.get("id", ""))))
	elif action_type == "withdraw_units":
		var unit_ids = action.get("unit_ids", [])
		if typeof(unit_ids) != TYPE_ARRAY:
			push_error("Skipping malformed withdrawal list in %s." % battle_id)
			return
		for unit_id in unit_ids:
			_withdraw_event_unit(str(unit_id))
	elif action_type == "set_ai_target_priority":
		_set_event_ai_target_priority(action)


func _grant_event_items(item_entries, trigger_unit: Dictionary = {}) -> void:
	if typeof(item_entries) != TYPE_ARRAY:
		push_error("Skipping malformed event item grants in %s." % battle_id)
		return
	for entry in item_entries:
		if typeof(entry) == TYPE_STRING:
			_grant_event_item({"item_id": str(entry)}, trigger_unit)
		elif typeof(entry) == TYPE_DICTIONARY:
			_grant_event_item(entry, trigger_unit)
		else:
			push_error("Skipping malformed event item grant in %s." % battle_id)


func _grant_event_item(action: Dictionary, trigger_unit: Dictionary = {}) -> bool:
	var item_id := str(action.get("item_id", action.get("id", ""))).strip_edges()
	if item_id.is_empty():
		push_error("Skipping event item grant with no item_id in %s." % battle_id)
		return false
	var item := get_item_def(item_id)
	if item.is_empty():
		push_error("Skipping unknown event item grant %s in %s." % [item_id, battle_id])
		return false
	var count: int = maxi(1, int(action.get("count", 1)))
	battle_inventory[item_id] = int(battle_inventory.get(item_id, 0)) + count
	var item_name: String = str(item.get("name", item_id))
	var count_text: String = "" if count == 1 else " x%d" % count
	_emit_log("Received %s%s." % [item_name, count_text])
	if not trigger_unit.is_empty() and bool(trigger_unit.get("alive", false)):
		_emit_combat_feedback(trigger_unit, "+%s" % item_name, "item")
	return true


func _grant_event_gold(action: Dictionary, trigger_unit: Dictionary = {}) -> bool:
	var amount := maxi(0, int(action.get("amount", action.get("gold", 0))))
	if amount <= 0:
		push_error("Skipping event gold grant with invalid amount in %s." % battle_id)
		return false
	battle_gold_reward += amount
	var text := str(action.get("text", "")).strip_edges()
	if text.is_empty():
		text = "Received %d gold." % amount
	_emit_log(text)
	if not trigger_unit.is_empty() and bool(trigger_unit.get("alive", false)):
		_emit_combat_feedback(trigger_unit, "+%dG" % amount, "gold")
	return true


func _set_event_ai_target_priority(action: Dictionary) -> void:
	var unit_ids := _event_action_unit_ids(action)
	if unit_ids.is_empty():
		push_error("Skipping AI target priority event with no unit ids in %s." % battle_id)
		return
	var priority := clampi(int(action.get("priority", 0)), 0, 20)
	for unit_id in unit_ids:
		var unit := get_unit(unit_id)
		if unit.is_empty():
			push_error("Skipping unknown AI target priority unit %s in %s." % [unit_id, battle_id])
			continue
		var previous_priority := int(unit.get("ai_target_priority", 0))
		unit["ai_target_priority"] = priority
		if bool(action.get("silent", false)):
			continue
		_emit_ai_target_priority_feedback(unit, previous_priority, priority, str(action.get("text", "")))
	_notify_changed()


func _event_action_unit_ids(action: Dictionary) -> Array[String]:
	var result: Array[String] = []
	if action.has("unit_ids") and typeof(action["unit_ids"]) == TYPE_ARRAY:
		for unit_id in action["unit_ids"]:
			var unit_id_text := str(unit_id).strip_edges()
			if not unit_id_text.is_empty() and not result.has(unit_id_text):
				result.append(unit_id_text)
	else:
		var unit_id_text := str(action.get("unit_id", action.get("id", ""))).strip_edges()
		if not unit_id_text.is_empty():
			result.append(unit_id_text)
	return result


func _emit_ai_target_priority_feedback(unit: Dictionary, previous_priority: int, priority: int, text: String = "") -> void:
	if text.is_empty():
		if priority > previous_priority:
			text = "%s is exposed to enemy focus." % str(unit.get("name", "Unit"))
		elif priority < previous_priority:
			text = "%s is less exposed to enemy focus." % str(unit.get("name", "Unit"))
		else:
			text = "%s remains an enemy priority." % str(unit.get("name", "Unit"))
	_emit_log(text)
	if bool(unit.get("alive", false)) and bool(unit.get("deployed", true)):
		_emit_combat_feedback(unit, "TARGET", "priority")


func _apply_objective_event(action: Dictionary) -> void:
	var updated := false
	var updated_victory := ""
	var updated_defeat := ""
	if action.has("victory"):
		objectives["victory"] = str(action["victory"])
		updated_victory = str(objectives.get("victory", ""))
		updated = true
	if action.has("defeat"):
		objectives["defeat"] = str(action["defeat"])
		updated_defeat = str(objectives.get("defeat", ""))
		updated = true
	if not updated:
		return
	if action.has("victory") and not updated_victory.is_empty():
		_emit_log("Objective updated: %s" % updated_victory)
	if action.has("defeat") and not updated_defeat.is_empty():
		_emit_log("Defeat condition updated: %s" % updated_defeat)
	objective_updated.emit(updated_victory, updated_defeat)
	_notify_changed()


func _dialogue_lines_from_action(action: Dictionary) -> Array:
	var result := []
	if action.has("lines") and typeof(action["lines"]) == TYPE_ARRAY:
		for line in action["lines"]:
			var normalized := _normalize_dialogue_line(line)
			if not normalized.is_empty():
				result.append(normalized)
	else:
		var normalized := _normalize_dialogue_line(action)
		if not normalized.is_empty():
			result.append(normalized)
	return result


func _normalize_dialogue_line(line) -> Dictionary:
	if typeof(line) == TYPE_STRING:
		var text := str(line)
		if text.is_empty():
			return {}
		return {"speaker": "", "text": text, "portrait": "", "side": "left"}
	if typeof(line) == TYPE_DICTIONARY:
		var text := str(line.get("text", ""))
		if text.is_empty():
			return {}
		var speaker := str(line.get("speaker", ""))
		var portrait := str(line.get("portrait", ""))
		if portrait.is_empty():
			portrait = data_catalog.get_portrait_for_speaker(speaker)
		return {
			"speaker": speaker,
			"text": text,
			"portrait": portrait,
			"side": _normalize_dialogue_side(line.get("side", "left"))
		}
	return {}


func _normalize_dialogue_side(value) -> String:
	var side := str(value).strip_edges().to_lower()
	if side == "right":
		return "right"
	return "left"


func _spawn_event_deployment(deployment) -> bool:
	if typeof(deployment) != TYPE_DICTIONARY:
		push_error("Skipping malformed event deployment in %s." % battle_id)
		return false

	var hydrated_unit := data_catalog.hydrate_deployment(deployment)
	_apply_roster_overlay(hydrated_unit, roster_overrides_snapshot)
	if not _is_deployment_available(hydrated_unit):
		_emit_log("Reinforcement %s is not available." % hydrated_unit.get("name", hydrated_unit.get("id", "unit")))
		return false
	var unit := _prepare_unit(hydrated_unit)
	if unit.is_empty():
		return false
	if not is_inside(unit["pos"]):
		_emit_log("Reinforcement %s could not deploy outside the battlefield." % unit["name"])
		return false
	if get_move_cost(unit["pos"], str(unit.get("move_type", "foot"))) >= 99:
		_emit_log("Reinforcement %s could not deploy on impassable terrain." % unit["name"])
		return false
	if not get_unit(unit["id"]).is_empty():
		_emit_log("Reinforcement %s already exists." % unit["name"])
		return false
	if not get_unit_at(unit["pos"]).is_empty():
		_emit_log("Reinforcement %s could not deploy at %s." % [unit["name"], _format_cell(unit["pos"])])
		return false

	unit["acted"] = current_team == unit.get("team", "")
	unit["moved"] = current_team == unit.get("team", "")
	units.append(unit)
	_emit_log("%s arrives at %s." % [unit["name"], _format_cell(unit["pos"])])
	return true


func _withdraw_event_unit(unit_id: String) -> bool:
	var normalized_id := unit_id.strip_edges()
	if normalized_id.is_empty():
		push_error("Skipping event withdrawal with no unit_id in %s." % battle_id)
		return false
	var unit := get_unit(normalized_id)
	if unit.is_empty() or not unit.get("deployed", true) or not unit.get("alive", false):
		return false
	if selected_unit_id == normalized_id:
		selected_unit_id = ""
	_emit_combat_feedback(unit, "WITHDRAW", "fade")
	unit["deployed"] = false
	unit["acted"] = true
	unit["moved"] = true
	_emit_log("%s withdraws from the battlefield." % unit.get("name", normalized_id))
	_notify_changed()
	return true


func _reset_team_actions(team: String) -> void:
	_apply_phase_status_effects_for_team(team)
	_check_battle_status()
	if battle_status != STATUS_ACTIVE:
		return
	_expire_status_effects_for_team(team)
	for unit in units:
		if unit.get("team", "") == team and unit.get("alive", false):
			unit["acted"] = false
			unit["moved"] = false


func _apply_phase_status_effects_for_team(team: String) -> void:
	for unit in units:
		if unit.get("team", "") != team or not unit.get("alive", false):
			continue
		for effect in _active_status_effects(unit):
			if str(effect.get("type", "")) != "damage_over_time":
				continue
			var damage: int = max(0, int(effect.get("amount", 0)))
			if damage <= 0:
				continue
			unit["hp"] = max(0, int(unit.get("hp", 0)) - damage)
			var status := str(effect.get("status", "poison"))
			_emit_log("%s suffers %d %s damage." % [unit["name"], damage, status.replace("_", " ")])
			_emit_combat_feedback(unit, "-%d" % damage, status)
			if int(unit.get("hp", 0)) <= 0:
				unit["alive"] = false
				_emit_log("%s is defeated by %s." % [unit["name"], status.replace("_", " ")])
				_emit_combat_feedback(unit, "DOWN", "defeat")
				_handle_unit_defeated(unit)
				break


func _expire_status_effects_for_team(team: String) -> void:
	for unit in units:
		if unit.get("team", "") != team:
			continue
		if typeof(unit.get("status_effects", [])) != TYPE_ARRAY:
			continue
		var kept_effects := []
		var expired_names := []
		for effect in _active_status_effects(unit):
			var next_remaining := int(effect.get("remaining_phases", 1)) - 1
			if next_remaining > 0:
				var kept_effect: Dictionary = effect.duplicate(true)
				kept_effect["remaining_phases"] = next_remaining
				kept_effects.append(kept_effect)
			else:
				var effect_name := str(effect.get("name", "Support"))
				if not expired_names.has(effect_name):
					expired_names.append(effect_name)
		unit["status_effects"] = kept_effects
		for expired_effect_name in expired_names:
			_emit_log("%s's %s fades." % [unit["name"], expired_effect_name])
			_emit_combat_feedback(unit, "FADES", "fade")


func _manhattan(a: Vector2i, b: Vector2i) -> int:
	return absi(a.x - b.x) + absi(a.y - b.y)


func _format_cell(cell: Vector2i) -> String:
	return "(%d, %d)" % [cell.x + 1, cell.y + 1]


func _format_cells(cells: Array[Vector2i]) -> String:
	if cells.is_empty():
		return ""
	if cells.size() == 1:
		return _format_cell(cells[0])
	var labels := []
	for cell in cells:
		labels.append(_format_cell(cell))
	return _join_strings(labels, ", ")


func _join_strings(values: Array, delimiter: String) -> String:
	var text := ""
	for value in values:
		if not text.is_empty():
			text += delimiter
		text += str(value)
	return text


func _emit_log(message: String) -> void:
	log_added.emit(message)


func _emit_combat_feedback(unit: Dictionary, text: String, kind: String) -> void:
	if unit.is_empty() or text.is_empty():
		return
	var unit_id := str(unit.get("id", ""))
	if unit_id.is_empty():
		return
	combat_feedback_requested.emit(unit_id, text, kind)


func _notify_changed() -> void:
	changed.emit()
