extends CharacterBody3D

# WANDER  → bull outside flee range, roaming freely
# FLEE    → bull within range, matador backs away (raycast-steered around walls)
# BRACE   → charge detected: plant feet, face the bull (cite phase)
# SIDESTEP→ bull commits: sharp lateral step to let it pass (pase phase)
# ATTACK  → post-dodge or mid-range: chase and strike
# ROLL    → hit by bull but roll chance fires: survive and recover
# THROW   → planted, winding up an overhand throw, then releasing the sword
# RAGDOLL → hit by bull, no roll chance
enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL }

# The matador's high-level *want*, chosen by a weighted utility vote (see
# _choose_intent). Each intent maps onto one or more concrete States below.
enum Intent { WANDER, FLEE, ATTACK, DODGE, THROW }

signal killed

var _state:         State                   = State.WANDER
var _skeleton:      Skeleton3D              = null
var _sim:           PhysicalBoneSimulator3D = null
var _anim_player:   AnimationPlayer         = null
var _wander_target: Vector3                 = Vector3.ZERO
var _idle_timer:    float                   = 0.0
var _taunt_swap:    float                   = 0.0
var _idle_anim:     StringName              = _ANIM_IDLE
var _bull:          CharacterBody3D         = null
var _step_dir:      Vector3                 = Vector3.ZERO
var _brace_timer:   float                   = 0.0
var _step_timer:    float                   = 0.0
var _dodge_cd:      float                   = 0.0
var _will_dodge:    bool                    = true
var _attack_timer:  float                   = 0.0
var _swing_timer:   float                   = 0.0
var _lunge_timer:   float                   = 0.0
var _roll_timer:    float                   = 0.0
var _roll_dir:      Vector3                 = Vector3.ZERO
var _throw_timer:   float                   = 0.0
var _throw_aim_cd:  float                   = 0.0
var _throw_dir:     Vector3                 = Vector3.ZERO
var _steer_dir:     Vector3                 = Vector3.ZERO
var _steer_cd:      float                   = 0.0
var _intent:        Intent                  = Intent.WANDER
var _decide_cd:     float                   = 0.0
var _ole_player:    AudioStreamPlayer       = null
var _sword_hand_attach: BoneAttachment3D    = null
var _sword_rest_attach: BoneAttachment3D    = null
var _sword_node:    Node3D                  = null
var _sword_in_hand: bool                    = false
var _drawing:       bool                    = false
var _sheathing:     bool                    = false
var _draw_timer:    float                   = 0.0
var _draw_len:      float                   = 0.0
var _sword_blade_area: Area3D               = null
var _spear_node:    Node3D                  = null
var _blade_hit_cd:  float                   = 0.0
var _death_player:  AudioStreamPlayer       = null
var _blood_burst:   CPUParticles3D          = null

@onready var _mesh:      Node3D           = $matador2
@onready var _hit_area:  Area3D           = $HitArea

const _ANIM_RUN:    StringName = &"Run"
const _ANIM_IDLE:   StringName = &"Taunt"
const _ANIM_ATTACK: StringName = &"Attack"
const _ANIM_ROLL:   StringName = &"Roll"
const _ANIM_DRAW:   StringName = &"Draw_weapon"

# Idle taunt clips — one is picked at random each time the matador pauses to
# taunt while wandering, so the crowd doesn't see the same gesture every time.
const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"]

# Fraction of the draw clip at which the hand grabs the sword — the sword
# reparents from holster to hand at this point in the animation.
const _DRAW_GRAB_AT: float = 0.55

const _SWORD_SCENE: PackedScene = preload("res://Assets/sword.glb")
const _SPEAR_SCENE: PackedScene = preload("res://Assets/spear.glb")

const _GRAVITY: float = 9.8           # arena gravity, for the spear's ballistic arc

const _STEER_LOOKAHEAD: float = 3.5   # longer = turns before hitting wall
const _STEER_INTERVAL:  float = 0.12  # recompute direction at most ~8×/sec
# Side angles (radians) tried when the straight-ahead path is blocked.
const _STEER_CANDIDATES: Array = [-0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI]

# Movement tuning. Ramp/turn rates are deliberately snappy so the matador reads as
# a quick, dangerous opponent rather than a sluggish one.
const _ACCEL_FACTOR: float = 13.0   # velocity ramp = speed * factor * delta
const _TURN_MOVE:    float = 11.0   # face the travel direction while roaming
const _TURN_FACE:    float = 16.0   # face the bull while engaging
const _TURN_SHARP:   float = 24.0   # snap onto a sidestep / roll direction


func _ready() -> void:
	add_to_group(&"matador")
	_skeleton = _find_skeleton(_mesh)
	_anim_player = _find_anim_player(_mesh)
	if _skeleton:
		_sim = MatadorRagdoll.build(_skeleton)
	if _anim_player:
		for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS:
			_ensure_loop(anim)
		_flatten_roll_travel()
		if not _anim_player.has_animation(_ANIM_RUN):
			push_warning("Matador: expected animations not found. Available: %s" %
				str(_anim_player.get_animation_list()))
		_anim_player.playback_default_blend_time = DP.f("mat_anim_blend")
		_anim_player.play(_ANIM_IDLE)
	_setup_sword()
	DP.any_changed.connect(_on_dp_changed)
	_throw_aim_cd = randf_range(2.0, 5.0)
	_hit_area.body_entered.connect(_on_body_entered)
	_pick_wander_target()
	_blood_burst = preload("res://blood_burst.gd").new()
	add_child(_blood_burst)
	if ResourceLoader.exists("res://sounds/ole.ogg"):
		_ole_player = AudioStreamPlayer.new()
		_ole_player.stream = load("res://sounds/ole.ogg")
		_ole_player.volume_db = 0.0
		add_child(_ole_player)
	if ResourceLoader.exists("res://sounds/matador_death.ogg"):
		_death_player = AudioStreamPlayer.new()
		_death_player.stream = load("res://sounds/matador_death.ogg")
		_death_player.volume_db = 2.0
		add_child(_death_player)


func _on_dp_changed(_key: String, _value: Variant) -> void:
	_apply_sword_grip()
	if _anim_player:
		_anim_player.playback_default_blend_time = DP.f("mat_anim_blend")


func _acquire_bull() -> void:
	if _bull != null:
		return
	var players := get_tree().get_nodes_in_group(&"player")
	if not players.is_empty():
		_bull = players[0] as CharacterBody3D


func _physics_process(delta: float) -> void:
	_acquire_bull()

	if DP.b("show_raycasts") and _steer_dir != Vector3.ZERO:
		var o := global_position + Vector3.UP * 0.6
		DebugDraw.ray(o, o + _steer_dir * _STEER_LOOKAHEAD, Color(0.3, 1.0, 0.9))

	if not is_on_floor():
		velocity += get_gravity() * delta

	_dodge_cd      = maxf(0.0, _dodge_cd - delta)
	_blade_hit_cd  = maxf(0.0, _blade_hit_cd - delta)
	_throw_aim_cd  = maxf(0.0, _throw_aim_cd - delta)
	if _state != State.RAGDOLL and _bull != null:
		_update_ai_state(delta)

	match _state:
		State.WANDER:   _tick_wander(delta)
		State.FLEE:     _tick_flee(delta)
		State.BRACE:    _tick_brace(delta)
		State.SIDESTEP: _tick_sidestep(delta)
		State.ATTACK:   _tick_attack(delta)
		State.ROLL:     _tick_roll(delta)
		State.THROW:    _tick_throw(delta)
		State.RAGDOLL:  _tick_ragdoll(delta)

	_update_sword_carry()
	_advance_draw(delta)


# Utility-driven brain. The physically committed states (a brace, a pass, a roll, a
# throw wind-up) run to completion on their own timers; from the free states the
# matador re-scores its options every mat_decide_interval and acts on the winner.
func _update_ai_state(delta: float) -> void:
	if _state == State.BRACE or _state == State.SIDESTEP \
			or _state == State.ROLL or _state == State.THROW:
		return

	_decide_cd -= delta
	if _decide_cd > 0.0:
		return
	_decide_cd = DP.f("mat_decide_interval")

	var intent := _choose_intent()
	_intent = intent
	if not _realizes_intent(intent):
		_enter_intent(intent)


# Score each intent from weighted heuristics and return the winner. Weights (the
# mat_w_* params) shape the personality; a small commit bonus on the current intent
# stops it flip-flopping between two near-tied options.
func _choose_intent() -> Intent:
	var dist := global_position.distance_to(_bull.global_position)
	var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
	var bull_speed := bull_flat.length()

	# close = 1 right on top of us, 0 at attack range or beyond. `beyond` measures
	# how far past throw range the bull is (0 point-blank, 1 at/outside throw range):
	# the matador only idles when the bull is out there, and engages otherwise.
	var reach := maxf(DP.f("mat_attack_range"), 0.5)
	var throw_range := maxf(DP.f("mat_throw_range"), 1.0)
	var close := clampf(1.0 - dist / reach, 0.0, 1.0)
	var beyond := clampf(dist / throw_range, 0.0, 1.0)
	# danger blends "the bull is fast and near" with "a charge is aimed at me".
	var danger := clampf(bull_speed / maxf(DP.f("mat_charge_speed"), 0.1), 0.0, 1.0) * close
	if _is_charge_incoming():
		danger = clampf(danger + 0.7, 0.0, 1.0)

	# A matador won't commit to a strike it can't bail out of, so a spent dodge
	# cooldown makes attacking (and dodging outright) less attractive.
	var dodge_ready := 1.0 if _dodge_cd <= 0.0 else 0.35
	var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
		and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")

	# Only the nearest few matadors work the bull at once; the rest hang back and
	# taunt (see _engagement_rank). This turns a crowd into a bullring — matadors
	# spreading out and taking turns — instead of a swarm all piling on together.
	var is_engager := _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots")))
	# Supporting matadors strut and circle constantly; the lead only struts between
	# passes, once the bull is back out past horn reach.
	var strut := 0.65 if not is_engager else 0.3 * beyond
	var engage_mult := 1.0 if is_engager else 0.15

	var score := {
		# Showmanship: circle the bull at a taunting distance and pose for the crowd.
		Intent.WANDER: DP.f("mat_w_showmanship") * strut,
		# Retreat only when the bull is close AND actually dangerous.
		Intent.FLEE:   DP.f("mat_w_flee") * close * (0.25 + 0.75 * danger),
		# Press the attack; ease off a touch when a charge is barrelling in. Supporting
		# matadors barely press — they yield the pass to whoever's engaging.
		Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \
			* (1.0 - 0.55 * danger) * dodge_ready * engage_mult,
		# Sidestep / plant against an incoming charge.
		Intent.DODGE:  DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0),
		# Hurl the blade — an occasional ranged flourish from an engaging matador when
		# there's a clean lane, not a barrage (see mat_w_throw). Supporters never pelt.
		Intent.THROW:  (DP.f("mat_w_throw") * (0.35 + 0.65 * (1.0 - close))) \
			if (throw_ok and is_engager) else -1.0,
	}
	score[_intent] = float(score[_intent]) + DP.f("mat_w_commit")

	var best: Intent = Intent.WANDER
	var best_score := -INF
	for intent: int in score:
		if float(score[intent]) > best_score:
			best_score = float(score[intent])
			best = intent
	return best


# True when the current State already carries out the intent, so we needn't restart
# it (restarting ATTACK every tick would reset its swing and stutter the animation).
func _realizes_intent(intent: Intent) -> bool:
	match intent:
		Intent.WANDER:
			return _state == State.WANDER
		Intent.FLEE:
			return _state == State.FLEE
		Intent.ATTACK:
			return _state == State.ATTACK
		Intent.DODGE:
			return _state == State.BRACE or _state == State.SIDESTEP
		Intent.THROW:
			return _state == State.THROW
	return false


func _enter_intent(intent: Intent) -> void:
	match intent:
		Intent.WANDER:
			_state = State.WANDER
			_steer_cd = 0.0
			_pick_wander_target()
		Intent.FLEE:
			_state = State.FLEE
			_steer_cd = 0.0
		Intent.ATTACK:
			_steer_cd = 0.0
			_start_attack()
		Intent.DODGE:
			_start_brace()
		Intent.THROW:
			_start_throw()


# How many other live matadors are closer to the bull than this one. 0 = the
# closest (the natural lead engager); the utility brain gives the nearest
# mat_engage_slots the go-ahead to press while the rest spread out and taunt.
func _engagement_rank() -> int:
	if _bull == null:
		return 0
	var my_d := global_position.distance_squared_to(_bull.global_position)
	var rank := 0
	for node: Node in get_tree().get_nodes_in_group(&"matador"):
		if node == self:
			continue
		var other := node as Node3D
		if other == null or not is_instance_valid(other) or not other.call(&"is_active"):
			continue
		if other.global_position.distance_squared_to(_bull.global_position) < my_d:
			rank += 1
	return rank


# Live and vying for the bull's attention — a ragdolled corpse doesn't hold a slot.
func is_active() -> bool:
	return _state != State.RAGDOLL


# True when this matador is safely out of the action — not one of the engaging few,
# and with the bull far enough off to strike a long, unhurried taunt for the crowd.
func _out_of_harm() -> bool:
	if _bull == null:
		return true
	if _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots"))):
		return false
	return global_position.distance_to(_bull.global_position) >= DP.f("mat_taunt_ring") * 0.6


# Bull is moving fast and aimed within ~30° of the matador
func _is_charge_incoming() -> bool:
	var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
	if bull_flat.length() < DP.f("mat_charge_speed"):
		return false
	var to_me := (global_position - _bull.global_position)
	to_me.y = 0.0
	if to_me.length() > DP.f("mat_brace_range"):
		return false
	return bull_flat.normalized().dot(to_me.normalized()) > 0.85


# Returns a wall-clear direction, recomputed at most every _STEER_INTERVAL seconds.
# Randomises among available clear angles so juke direction is unpredictable.
func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3:
	_steer_cd -= delta
	if _steer_cd > 0.0 and _steer_dir != Vector3.ZERO:
		return _steer_dir
	_steer_cd = _STEER_INTERVAL
	var space := get_world_3d().direct_space_state
	var origin := global_position + Vector3.UP * 0.6
	var params := PhysicsRayQueryParameters3D.new()
	params.exclude = [get_rid()]
	params.collision_mask = 1
	# Try straight ahead first; if blocked, pick randomly from the side candidates.
	params.from = origin
	params.to = origin + desired_dir * _STEER_LOOKAHEAD
	if space.intersect_ray(params).is_empty():
		_steer_dir = desired_dir
		return desired_dir
	var candidates := _STEER_CANDIDATES.duplicate()
	candidates.shuffle()
	for angle: float in candidates:
		var dir := desired_dir.rotated(Vector3.UP, angle)
		params.from = origin
		params.to = origin + dir * _STEER_LOOKAHEAD
		if space.intersect_ray(params).is_empty():
			_steer_dir = dir
			return dir
	_steer_dir = desired_dir
	return desired_dir


# Ramp horizontal velocity toward dir*spd; vertical (gravity) is left untouched.
func _accelerate(dir: Vector3, spd: float, delta: float) -> void:
	velocity.x = move_toward(velocity.x, dir.x * spd, spd * _ACCEL_FACTOR * delta)
	velocity.z = move_toward(velocity.z, dir.z * spd, spd * _ACCEL_FACTOR * delta)


func _decelerate(rate: float, delta: float) -> void:
	velocity.x = move_toward(velocity.x, 0.0, rate * delta)
	velocity.z = move_toward(velocity.z, 0.0, rate * delta)


# Turn the mesh to face travel direction — skipped at near-zero speed so a
# stopping matador doesn't snap to atan2(0, 0) == facing +Z (a jitter source).
func _face_velocity(delta: float, rate: float) -> void:
	if Vector2(velocity.x, velocity.z).length_squared() > 0.04:
		_mesh.rotation.y = lerp_angle(
			_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * rate)


func _face_point(point: Vector3, delta: float, rate: float) -> void:
	var to := point - global_position
	to.y = 0.0
	if to.length_squared() > 0.01:
		_mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(to.x, to.z), delta * rate)


func _tick_wander(delta: float) -> void:
	# Plant the feet while drawing / sheathing instead of sliding through the clip.
	if _drawing or _sheathing or _idle_timer > 0.0:
		_idle_timer = maxf(0.0, _idle_timer - delta)
		_decelerate(10.0, delta)
		# Taunt toward the bull so the posturing plays to it, not off into space.
		if _bull != null and _idle_timer > 0.0:
			_face_point(_bull.global_position, delta, _TURN_MOVE)
		# Cycle gestures through a long hold so a posing matador stays lively.
		_taunt_swap -= delta
		if _taunt_swap <= 0.0 and not _drawing and not _sheathing:
			_idle_anim = _pick_taunt()
			_taunt_swap = randf_range(1.5, 3.0)
		_play_anim(_idle_anim)
		move_and_slide()
		return

	var to_target := Vector3(
		_wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z)
	if to_target.length() < 0.8:
		# A matador out of harm's way settles into a long taunt for the crowd; one still
		# in the mix only pauses briefly between passes.
		_idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3) \
			if _out_of_harm() else randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
		_idle_anim = _pick_taunt()
		_taunt_swap = randf_range(1.5, 3.0)
		_pick_wander_target()
	else:
		var dir := _steer_clear(to_target.normalized(), delta)
		_accelerate(dir, DP.f("mat_walk_speed"), delta)
		_face_velocity(delta, _TURN_MOVE)
		_play_anim(_ANIM_RUN)
	move_and_slide()


func _tick_flee(delta: float) -> void:
	# Plant the feet while sheathing instead of sliding away through the clip.
	if _drawing or _sheathing:
		_decelerate(18.0, delta)
		_face_point(_bull.global_position, delta, _TURN_FACE)
		move_and_slide()
		return

	var away := global_position - _bull.global_position
	away.y = 0.0
	if away.length() < 0.01:
		away = Vector3(randf() - 0.5, 0.0, randf() - 0.5)
	var dir := _steer_clear(away.normalized(), delta)
	_accelerate(dir, DP.f("mat_flee_speed"), delta)
	_face_velocity(delta, _TURN_MOVE)
	_play_anim(_ANIM_RUN)
	move_and_slide()


# Cite phase: plant feet, face the bull — wait for it to commit
func _start_brace() -> void:
	_steer_cd = 0.0
	_state = State.BRACE
	var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
	var charge_dir: Vector3
	if bull_flat.length() > 1.0:
		charge_dir = bull_flat.normalized()
	else:
		charge_dir = (_bull.global_position - global_position)
		charge_dir.y = 0.0
		charge_dir = charge_dir.normalized()
	_will_dodge = randf() < DP.f("mat_dodge_chance")
	var side := 1.0 if randf() > 0.5 else -1.0
	_step_dir = charge_dir.rotated(Vector3.UP, PI * 0.5 * side)
	_brace_timer = DP.f("mat_brace_duration")


func _tick_brace(delta: float) -> void:
	_decelerate(18.0, delta)
	_face_point(_bull.global_position, delta, _TURN_FACE)
	_play_anim(_ANIM_ATTACK)
	move_and_slide()

	_brace_timer -= delta
	var dist := global_position.distance_to(_bull.global_position)
	if _will_dodge:
		# Dodger: wait for the bull to commit, then whip into the lateral pass.
		if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0:
			_state = State.SIDESTEP
			_step_timer = DP.f("mat_step_duration")
			if _ole_player:
				_ole_player.pitch_scale = randf_range(0.88, 1.12)
				_ole_player.play()
	else:
		# Planter (estocada): the bull only dies to an active, presented thrust, not to
		# brushing a braced matador — so when it commits in, break into a real attack
		# SWING (which lunges the blade forward and stabs), otherwise hold then press.
		if dist < DP.f("mat_commit_dist") or (_brace_timer <= 0.0 and not _is_charge_incoming()):
			_dodge_cd = DP.f("mat_dodge_cooldown")
			_start_attack()


# Pase phase: sharp lateral step biased toward the bull so the drawn blade sweeps
# across its path as it charges by; face the bull so the sword points at it.
func _tick_sidestep(delta: float) -> void:
	_step_timer -= delta
	if _step_timer <= 0.0:
		_start_attack()
		return
	var dir := _step_dir
	var to_bull := _bull.global_position - global_position
	to_bull.y = 0.0
	if to_bull.length() > 0.1:
		dir = (_step_dir + to_bull.normalized() * DP.f("mat_pass_lunge")).normalized()
	var spd := DP.f("mat_step_speed")
	velocity.x = dir.x * spd
	velocity.z = dir.z * spd
	_face_point(_bull.global_position, delta, _TURN_SHARP)
	_play_anim(_ANIM_ATTACK)
	_try_melee_hit(DP.f("mat_stab_reach"))
	move_and_slide()


func _start_attack() -> void:
	_state = State.ATTACK
	_attack_timer = DP.f("mat_attack_duration")
	_swing_timer = 0.0
	_dodge_cd = DP.f("mat_dodge_cooldown")


func _tick_attack(delta: float) -> void:
	_attack_timer -= delta
	_swing_timer = maxf(0.0, _swing_timer - delta)
	# Plant the feet while drawing instead of sliding toward the bull mid-clip.
	if _drawing or _sheathing:
		_decelerate(22.0, delta)
		_face_point(_bull.global_position, delta, _TURN_FACE)
		move_and_slide()
		return
	_face_point(_bull.global_position, delta, _TURN_FACE)
	var to_bull := _bull.global_position - global_position
	to_bull.y = 0.0
	var dist := to_bull.length()

	# Commit to a full swing once in range instead of flipping Run/Attack at the
	# distance boundary every frame (the old foot-slide jitter). When the swing
	# ends the matador re-closes if the bull slipped away, or stabs again if not.
	if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist") and _sword_in_hand:
		_swing_timer = _anim_length(_ANIM_ATTACK, 0.9)
		_lunge_timer = DP.f("mat_lunge_time")

	if _swing_timer > 0.0:
		_lunge_timer = maxf(0.0, _lunge_timer - delta)
		if _lunge_timer > 0.0 and dist > 0.1:
			_accelerate(to_bull.normalized(), DP.f("mat_lunge_speed"), delta)
		else:
			_decelerate(22.0, delta)
		_play_anim(_ANIM_ATTACK)
		_try_melee_hit(DP.f("mat_stab_reach"))
	else:
		var dir := _steer_clear(to_bull.normalized(), delta)
		_accelerate(dir, DP.f("mat_attack_speed"), delta)
		_play_anim(_ANIM_RUN)

	move_and_slide()


func _enter_roll(hit_dir: Vector3) -> void:
	_state = State.ROLL
	# Match the state to the actual clip so the roll plays through once instead of
	# cutting to Run partway. mat_roll_duration is only a fallback.
	_roll_timer = _anim_length(_ANIM_ROLL, DP.f("mat_roll_duration"))
	hit_dir.y = 0.0
	var side := 1.0 if randf() > 0.5 else -1.0
	_roll_dir = hit_dir.normalized().rotated(Vector3.UP, PI * 0.5 * side)
	if _anim_player and _anim_player.has_animation(_ANIM_ROLL):
		_anim_player.play(_ANIM_ROLL)
		_anim_player.seek(0.0, true)


func _tick_roll(delta: float) -> void:
	_roll_timer -= delta
	if _roll_timer <= 0.0:
		_state = State.FLEE
		_dodge_cd = DP.f("mat_dodge_cooldown")
		return
	var spd := DP.f("mat_step_speed")
	velocity.x = _roll_dir.x * spd
	velocity.z = _roll_dir.z * spd
	_face_velocity(delta, _TURN_SHARP)
	_play_anim(_ANIM_ROLL)
	move_and_slide()


func _start_throw() -> void:
	_state = State.THROW
	_throw_timer = DP.f("mat_throw_windup")
	velocity = Vector3.ZERO
	_cancel_transitions()
	_carry_sword(false)     # the sword is melee-only — holster it for the throw
	_spawn_spear_in_hand()  # a fresh spear is drawn for the overhand throw
	if _anim_player:
		_anim_player.pause()
	var to_bull := _bull.global_position - global_position
	to_bull.y = 0.0
	_throw_dir = to_bull.normalized() if to_bull.length() > 0.1 else _mesh.global_transform.basis.z


func _tick_throw(delta: float) -> void:
	_decelerate(20.0, delta)
	_face_point(_bull.global_position, delta, _TURN_FACE)

	var to_bull := _bull.global_position - global_position
	to_bull.y = 0.0
	if to_bull.length() > 0.1:
		_throw_dir = to_bull.normalized()

	var dur := maxf(DP.f("mat_throw_windup"), 0.001)
	var prev := clampf(1.0 - _throw_timer / dur, 0.0, 1.0)
	_throw_timer -= delta
	var phase := clampf(1.0 - _throw_timer / dur, 0.0, 1.0)
	_pose_throw_arm(phase)

	var t_rel := DP.f("mat_throw_release")
	if prev < t_rel and phase >= t_rel and is_instance_valid(_spear_node):
		_release_spear()

	if _throw_timer <= 0.0:
		_end_throw()
	move_and_slide()


func _end_throw() -> void:
	_state = State.FLEE
	_clear_spear()  # drop any spear still in hand if the wind-up was cut short
	_dodge_cd = DP.f("mat_dodge_cooldown")
	# Space throws well apart so a hurl stays a flourish, not a barrage.
	_throw_aim_cd = randf_range(4.0, 7.0)
	_intent = Intent.FLEE
	# Blend to locomotion so the manually-posed throw arm eases out (resume() snaps).
	if _anim_player:
		_anim_player.play(_ANIM_RUN)


# Drive arm_R + forearm_R through a cock-back → release arc while the
# AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md), DP-tunable.
func _pose_throw_arm(phase: float) -> void:
	if not _skeleton:
		return
	var t_rel := DP.f("mat_throw_release")
	var cock := DP.f("mat_throw_arm_cock")
	var release := DP.f("mat_throw_arm_release")
	var elbow := DP.f("mat_throw_elbow")
	var arm_ang: float
	var elbow_ang: float
	if phase < t_rel:
		var u := phase / maxf(t_rel, 0.001)
		arm_ang = lerpf(0.0, cock, u)
		elbow_ang = lerpf(0.0, elbow, u)
	else:
		var u := (phase - t_rel) / maxf(1.0 - t_rel, 0.001)
		arm_ang = lerpf(cock, release, u)
		elbow_ang = lerpf(elbow, 0.0, u)
	_set_bone_swing("arm_R", arm_ang)
	_set_bone_swing("forearm_R", elbow_ang)


func _set_bone_swing(bone: String, deg: float) -> void:
	var idx := _skeleton.find_bone(bone)
	if idx < 0:
		return
	var rest := _skeleton.get_bone_rest(idx).basis.get_rotation_quaternion()
	_skeleton.set_bone_pose_rotation(idx, rest * Quaternion(Vector3.RIGHT, deg_to_rad(deg)))


# Draw a fresh spear into the throwing hand for the wind-up (no melee collider).
func _spawn_spear_in_hand() -> void:
	if is_instance_valid(_spear_node) or _sword_hand_attach == null:
		return
	_spear_node = _SPEAR_SCENE.instantiate() as Node3D
	_sword_hand_attach.add_child(_spear_node)
	_spear_node.position = Vector3(
		DP.f("spear_pos_x"), DP.f("spear_pos_y"), DP.f("spear_pos_z"))
	_spear_node.rotation_degrees = Vector3(
		DP.f("spear_rot_x"), DP.f("spear_rot_y"), DP.f("spear_rot_z"))


func _clear_spear() -> void:
	if is_instance_valid(_spear_node):
		_spear_node.queue_free()
	_spear_node = null


# Hurl the held spear on a ballistic arc that lands on the bull's low body. The
# launch velocity is solved so gravity (mat_throw_gravity) carries it down onto the
# lead point; SpearProjectile aims the shaft along the arc and embeds it on a hit.
func _release_spear() -> void:
	if not is_instance_valid(_spear_node):
		return
	var spear := _spear_node
	var origin := spear.global_position
	_spear_node = null

	var hs := maxf(DP.f("mat_throw_speed"), 0.1)         # horizontal speed
	var g := _GRAVITY * maxf(DP.f("mat_throw_gravity"), 0.05)
	# Aim low (the bull's collision spheres ride ~0.5 m below its origin) and lead it
	# across the arc's flight time.
	var target := origin + _throw_dir * 5.0
	if is_instance_valid(_bull):
		target = _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0)
		var lead := target - origin
		lead.y = 0.0
		target += _bull.velocity * (lead.length() / hs)
	var flat := target - origin
	var rise := flat.y
	flat.y = 0.0
	var dist := flat.length()
	var hdir := flat.normalized() if dist > 0.01 else _throw_dir
	# Aim scatter so the arc can be read and sidestepped, not a hitscan.
	var spread := deg_to_rad(DP.f("mat_throw_spread"))
	hdir = hdir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized()
	# Ballistic solve: with horizontal speed hs over distance dist, the flight lasts t;
	# pick the vertical launch speed that lands the arc on the aim point's height.
	var t := dist / hs
	var vy := (rise + 0.5 * g * t * t) / t
	var vel := hdir * hs + Vector3.UP * vy
	SpearProjectile.launch(get_tree().current_scene, spear, origin, vel,
		maxf(DP.f("mat_throw_gravity"), 0.05))


func _tick_ragdoll(_delta: float) -> void:
	velocity.x = 0.0
	velocity.z = 0.0
	move_and_slide()


# Current state as its enum name (WANDER / FLEE / …), for the debug state overlay.
func ai_state_name() -> String:
	return State.keys()[_state]


func apply_ability_hit(hit_dir: Vector3, strength: float, up_boost: float = 0.0) -> void:
	if _state == State.RAGDOLL or _state == State.ROLL:
		return
	_acquire_bull()
	_enter_ragdoll(hit_dir, strength, up_boost)


func _on_body_entered(body: Node3D) -> void:
	if _state == State.RAGDOLL or _state == State.ROLL:
		return
	if not body.is_in_group(&"player"):
		return
	var player := body as CharacterBody3D
	if player.velocity.length() < DP.f("mat_hit_threshold"):
		return
	var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z)
	var hit_dir  := flat_vel.normalized() if flat_vel.length() > 0.5 else \
		(global_position - player.global_position).normalized()

	if randf() < DP.f("mat_roll_chance"):
		_enter_roll(hit_dir)
	else:
		_enter_ragdoll(hit_dir, player.velocity.length())


func _on_blade_hit(body: Node3D) -> void:
	if _state != State.ATTACK and _state != State.SIDESTEP and _state != State.BRACE:
		return
	if not body.is_in_group(&"player"):
		return
	if _blade_hit_cd > 0.0:
		return
	_blade_hit_cd = DP.f("mat_stab_cooldown")
	body.call(&"take_sword_hit", "gored")


# Reach-based stab. A charging bull crosses metres per physics frame, so it easily
# tunnels through the thin blade Area between frames — this distance check is the
# reliable hit. Called from the offensive ticks (active swing / pass) whenever the
# blade is drawn: if the bull is inside reach AND the matador is facing it — the
# blade actually presented, not held off to the side — it's gored.
func _try_melee_hit(reach: float) -> void:
	if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null:
		return
	var to_bull := _bull.global_position - global_position
	to_bull.y = 0.0
	var dist := to_bull.length()
	if dist > reach or dist < 0.01:
		return
	# Only lands within the frontal arc — you're gored on a presented blade, not by
	# brushing a matador whose sword is pointing elsewhere.
	var facing := Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y))
	if facing.dot(to_bull / dist) < cos(deg_to_rad(DP.f("mat_stab_arc"))):
		return
	_blade_hit_cd = DP.f("mat_stab_cooldown")
	_bull.call(&"take_sword_hit", "gored")


func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void:
	_state = State.RAGDOLL
	if _sword_blade_area:
		_sword_blade_area.monitoring = false
	_clear_spear()  # a spear mid-wind-up dies with the matador, not in the air
	killed.emit()
	var cleanup_timer := get_tree().create_timer(4.0)
	cleanup_timer.timeout.connect(func() -> void:
		if is_instance_valid(self):
			queue_free()
	)
	hit_dir.y = 0.0
	hit_dir = hit_dir.normalized()
	var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized()

	_blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir)
	if _death_player:
		_death_player.pitch_scale = randf_range(0.9, 1.1)
		_death_player.play()

	if _bull != null:
		var cam := _bull.get_node_or_null("Camera3D")
		if cam:
			cam.call(&"trigger_hit", clampf(bull_speed / 15.0, 0.4, 1.0))

	velocity = Vector3.ZERO
	collision_layer = 0

	if _anim_player:
		_anim_player.pause()

	if not _sim:
		return
	for child: Node in _sim.get_children():
		if not (child is PhysicalBone3D):
			continue
		var pb := child as PhysicalBone3D
		var bone_idx := _skeleton.find_bone(pb.bone_name)
		if bone_idx >= 0:
			pb.global_transform = _skeleton.global_transform * _skeleton.get_bone_global_pose(bone_idx)
	_sim.active = true
	_sim.physical_bones_start_simulation()
	_pin_cape_to_body()
	var strength := DP.f("mat_ragdoll_impulse") * clampf(bull_speed / 15.0, 0.4, 1.3)
	var right := throw_dir.cross(Vector3.UP).normalized()
	for child: Node in _sim.get_children():
		if not (child is PhysicalBone3D):
			continue
		var pb := child as PhysicalBone3D
		var impulse := Vector3.ZERO
		match pb.bone_name:
			"COG", "matador":
				impulse = throw_dir * strength
			"chest":
				impulse = (throw_dir + Vector3.UP * 0.3).normalized() * strength * 0.9
			"head":
				impulse = (throw_dir + Vector3.UP * 0.5).normalized() * strength * 0.7
			"leg_L":
				impulse = (throw_dir * 0.4 + right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6
			"leg_R":
				impulse = (throw_dir * 0.4 - right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6
			"arm_L":
				impulse = (throw_dir * 0.3 + right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5
			"arm_R":
				impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5
			"Bone", "Bone.001", "Bone.002":
				# Scale by mass so the light cape gets the same velocity as the body
				# (Δv = impulse/mass) and flies along with it instead of rocketing off.
				impulse = (throw_dir + Vector3.UP * 0.35).normalized() * strength * 2.0 * pb.mass
			_:
				continue
		var noise := Vector3(randf_range(-0.15, 0.15), randf_range(-0.08, 0.15), randf_range(-0.15, 0.15))
		pb.apply_central_impulse(impulse + noise * strength * 0.2)

	# A floor slam launches the whole ragdoll straight up: a uniform per-bone Δv
	# (impulse = mass · speed) so the joints carry every bone up together.
	if up_boost > 0.0:
		for child: Node in _sim.get_children():
			if child is PhysicalBone3D:
				var pbb := child as PhysicalBone3D
				pbb.apply_central_impulse(Vector3.UP * up_boost * pbb.mass)


# Pin the cape's root link to the chest so it stays attached at the shoulders
# while ragdolling (its bone chain is a skeleton root, so it has no joint to the
# body otherwise). The chain hangs and swings from this point.
func _pin_cape_to_body() -> void:
	var cape := _physical_bone(&"Bone")
	var chest := _physical_bone(&"chest")
	if cape == null or chest == null:
		return
	var joint := PinJoint3D.new()
	_sim.add_child(joint)
	joint.global_position = cape.global_position
	joint.node_a = chest.get_path()
	joint.node_b = cape.get_path()


func _physical_bone(bone: StringName) -> PhysicalBone3D:
	if not _sim:
		return null
	for child: Node in _sim.get_children():
		if child is PhysicalBone3D and (child as PhysicalBone3D).bone_name == bone:
			return child as PhysicalBone3D
	return null


# ── Animation helpers ─────────────────────────────────────────────────────────

func _ensure_loop(anim_name: StringName) -> void:
	if _anim_player.has_animation(anim_name):
		var anim := _anim_player.get_animation(anim_name)
		anim.loop_mode = Animation.LOOP_LINEAR


# Roll was authored *travelling* (COG drives ~4.5 m forward on Z), which snapped the
# mesh back when the clip ended. _tick_roll moves the dodge, so strip that COG travel.
func _flatten_roll_travel() -> void:
	if _anim_player == null or not _anim_player.has_animation(_ANIM_ROLL):
		return
	var anim := _anim_player.get_animation(_ANIM_ROLL)
	if anim.has_meta(&"_travel_flattened"):
		return
	anim.set_meta(&"_travel_flattened", true)
	var cog := anim.find_track(NodePath("Armature/Skeleton3D:COG"), Animation.TYPE_POSITION_3D)
	if cog < 0:
		return
	# Snapshot the COG travel before mutating (flattening COG first would zero it).
	var travel: Dictionary = {}
	for i in anim.get_track_count():
		if anim.track_get_type(i) == Animation.TYPE_POSITION_3D:
			for k in anim.track_get_key_count(i):
				var t := anim.track_get_key_time(i, k)
				travel[t] = anim.position_track_interpolate(cog, t).z
	for i in anim.get_track_count():
		if anim.track_get_type(i) == Animation.TYPE_POSITION_3D:
			for k in anim.track_get_key_count(i):
				var v: Vector3 = anim.track_get_key_value(i, k)
				anim.track_set_key_value(i, k, v - Vector3(0.0, 0.0, travel[anim.track_get_key_time(i, k)]))


func _pick_taunt() -> StringName:
	var choices: Array[StringName] = []
	for anim in _ANIM_TAUNTS:
		if _anim_player != null and _anim_player.has_animation(anim):
			choices.append(anim)
	if choices.is_empty():
		return _ANIM_IDLE
	return choices[randi() % choices.size()]


func _anim_length(anim_name: StringName, fallback: float) -> float:
	if _anim_player and _anim_player.has_animation(anim_name):
		return _anim_player.get_animation(anim_name).length
	return fallback


func _play_anim(anim_name: StringName) -> void:
	if _anim_player == null:
		return
	if _drawing or _sheathing:
		return  # let the draw / sheathe clip play through uninterrupted
	if _anim_player.current_animation == anim_name:
		return
	_anim_player.play(anim_name)


func _pick_wander_target() -> void:
	# Circle the bull for showmanship: mostly settle on the taunt ring (spreading the
	# crowd out around it), with an occasional close pass for daring flair.
	if _bull != null:
		var bull_flat := _bull.global_position
		bull_flat.y = 0.0
		var r: float
		if randf() < 0.2:
			r = randf_range(1.5, 4.0)                       # close daring pass
		else:
			var ring := DP.f("mat_taunt_ring")
			r = randf_range(ring * 0.7, ring * 1.3)         # the showmanship ring
		var angle := randf() * TAU
		_wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r)
		# Keep the pose inside the fighting floor rather than backing onto the benches.
		var arena_r := DP.f("arena_spawn_radius")
		var flat := Vector2(_wander_target.x, _wander_target.z)
		if flat.length() > arena_r:
			flat = flat.normalized() * arena_r
			_wander_target = Vector3(flat.x, 0.0, flat.y)
		return
	var radius := DP.f("mat_wander_radius")
	var angle  := randf() * TAU
	var dist   := randf_range(2.0, radius)
	_wander_target = Vector3(cos(angle) * dist, 0.0, sin(angle) * dist)


# ── Sword attachment ──────────────────────────────────────────────────────────

# The rig (matador_v03) carries two purpose-built bones: weapon_bone (right-hand
# grip) and weapon_rest_bone (hip holster). The sword rides in the holster and is
# carried to the hand in combat (see _carry_sword); each bone has its own DP-tunable
# local offset/rotation (sword_* / sword_rest_*) so the poses seat independently.
func _setup_sword() -> void:
	if not _skeleton:
		return
	var has_hand := _skeleton.find_bone("weapon_bone") != -1
	var has_rest := _skeleton.find_bone("weapon_rest_bone") != -1
	if not has_hand and not has_rest:
		return
	if has_hand:
		_sword_hand_attach = BoneAttachment3D.new()
		_sword_hand_attach.bone_name = "weapon_bone"
		_skeleton.add_child(_sword_hand_attach)
	if has_rest:
		_sword_rest_attach = BoneAttachment3D.new()
		_sword_rest_attach.bone_name = "weapon_rest_bone"
		_skeleton.add_child(_sword_rest_attach)
	_spawn_sword()


# Instantiate the matador's sword into the holster, with its own blade collider.
func _spawn_sword() -> void:
	if is_instance_valid(_sword_node):
		return
	if _sword_rest_attach == null and _sword_hand_attach == null:
		return
	_sword_node = _SWORD_SCENE.instantiate() as Node3D
	# Start holstered (fall back to the hand if the rig only has one of the bones).
	var start_attach := _sword_rest_attach if _sword_rest_attach else _sword_hand_attach
	start_attach.add_child(_sword_node)
	_sword_in_hand = start_attach == _sword_hand_attach
	_drawing = false
	_apply_sword_grip()

	# Blade collider along +Z (hilt at origin), tipped from the capsule's default Y.
	# Only monitors while the sword is in hand (see _carry_sword).
	var blade_cap := CapsuleShape3D.new()
	blade_cap.radius = 0.18
	blade_cap.height = 1.30
	var blade_cs := CollisionShape3D.new()
	blade_cs.shape = blade_cap
	blade_cs.position = Vector3(0.0, 0.0, 0.8)
	blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)

	_sword_blade_area = Area3D.new()
	_sword_blade_area.collision_layer = 0
	_sword_blade_area.collision_mask  = 1
	_sword_blade_area.monitoring = _sword_in_hand
	_sword_blade_area.add_child(blade_cs)
	_sword_node.add_child(_sword_blade_area)
	_sword_blade_area.body_entered.connect(_on_blade_hit)


# The sword is MELEE-only: drawn while fighting up close (ATTACK) or guarding
# (BRACE / SIDESTEP), holstered otherwise. Ranged throws use a spear instead
# (see _spawn_spear_in_hand), so THROW keeps the sword holstered.
func _wants_sword_drawn() -> bool:
	return _state == State.ATTACK \
		or _state == State.BRACE or _state == State.SIDESTEP


func _update_sword_carry() -> void:
	if not is_instance_valid(_sword_node) or _state == State.RAGDOLL:
		return
	if _wants_sword_drawn():
		if not _sword_in_hand and not _drawing:
			_begin_draw()
	elif _sword_in_hand or _drawing:
		# Animate the sheathe only into the unhurried locomotion states; snap for
		# a dodge / roll / brace where the body clip can't spare the time.
		if _state == State.WANDER or _state == State.FLEE:
			if not _sheathing:
				_begin_sheathe()
		else:
			_cancel_transitions()
			_carry_sword(false)


func _begin_draw() -> void:
	if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)):
		_carry_sword(true)  # no draw clip on this rig — snap to hand
		return
	_sheathing = false
	_drawing = true
	_draw_timer = 0.0
	_draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed()
	_anim_player.play(_ANIM_DRAW, -1.0, _draw_speed())
	_anim_player.seek(0.0, true)


func _begin_sheathe() -> void:
	if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)):
		_carry_sword(false)  # no clip — snap to holster
		return
	_drawing = false
	_sheathing = true
	_draw_timer = 0.0
	_draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed()
	# Draw_weapon reversed from its end frame = a put-the-sword-away motion.
	_anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true)


# Progress a draw or sheathe; the blade reparents at the grab point (mirrored on the reverse).
func _advance_draw(delta: float) -> void:
	if not _drawing and not _sheathing:
		return
	_draw_timer += delta
	var t := _draw_timer / maxf(_draw_len, 0.001)
	if _drawing:
		if not _sword_in_hand and t >= _DRAW_GRAB_AT:
			_carry_sword(true)
		if t >= 1.0:
			_drawing = false
			_carry_sword(true)
	else:
		if _sword_in_hand and t >= 1.0 - _DRAW_GRAB_AT:
			_carry_sword(false)
		if t >= 1.0:
			_sheathing = false
			_carry_sword(false)


func _cancel_transitions() -> void:
	_drawing = false
	_sheathing = false


func _draw_speed() -> float:
	return maxf(DP.f("mat_draw_speed"), 0.1)


# Move the sword between hand grip and hip holster, reapplying the local seating.
func _carry_sword(in_hand: bool) -> void:
	if not is_instance_valid(_sword_node):
		return
	if in_hand == _sword_in_hand:
		return
	var target := _sword_hand_attach if in_hand else _sword_rest_attach
	if target == null:
		return
	_sword_in_hand = in_hand
	_sword_node.reparent(target, false)
	_apply_sword_grip()
	if _sword_blade_area:
		_sword_blade_area.monitoring = in_hand


func _apply_sword_grip() -> void:
	if not is_instance_valid(_sword_node):
		return
	if _sword_in_hand:
		_sword_node.position = Vector3(
			DP.f("sword_pos_x"), DP.f("sword_pos_y"), DP.f("sword_pos_z"))
		_sword_node.rotation_degrees = Vector3(
			DP.f("sword_rot_x"), DP.f("sword_rot_y"), DP.f("sword_rot_z"))
	else:
		_sword_node.position = Vector3(
			DP.f("sword_rest_pos_x"), DP.f("sword_rest_pos_y"), DP.f("sword_rest_pos_z"))
		_sword_node.rotation_degrees = Vector3(
			DP.f("sword_rest_rot_x"), DP.f("sword_rest_rot_y"), DP.f("sword_rest_rot_z"))


# ── Utility ───────────────────────────────────────────────────────────────────

func _find_skeleton(node: Node) -> Skeleton3D:
	if node is Skeleton3D:
		return node as Skeleton3D
	for child: Node in node.get_children():
		var r := _find_skeleton(child)
		if r:
			return r
	return null


func _find_anim_player(node: Node) -> AnimationPlayer:
	if node is AnimationPlayer:
		return node as AnimationPlayer
	for child: Node in node.get_children():
		var r := _find_anim_player(child)
		if r:
			return r
	return null
