extends Node
## Body wobble and tail simulation. Legs stay in rest pose (no IK, no ragdoll).
## Created at runtime by player.gd; call setup() before the first _process tick.

var _player:   CharacterBody3D
var _bull:     Node3D
var _skeleton: Skeleton3D

# Body wobble
var _prev_vel:     Vector3 = Vector3.ZERO
var _tilt_x:       float   = 0.0
var _tilt_z:       float   = 0.0
var _aerial_pitch: float   = 0.0

var charge_pitch_target: float = 0.0
var _charge_pitch_curr:  float = 0.0

# When true, another system (the roll's ball-tumble) owns the bull's full
# orientation — skip writing body wobble so we don't fight it.
var external_rotation: bool = false

# Tail — Verlet chain simulation
const TAIL_BONES: Array = [
	&"tail_1", &"tail_2", &"tail_3", &"tail_4",
	&"tail_5", &"tail_6",
	&"tail_tip_1", &"tail_tip_2", &"tail_tip_2_end",
]
var _tail_idx:        Array[int]     = []
var _tail_world:      Array[Vector3] = []
var _tail_prev:       Array[Vector3] = []
var _tail_lengths:    Array[float]   = []
var _tail_rest_dir:   Array[Vector3] = []
var _tail_parent_idx: int            = -1

var tail_ragdoll:   bool = false
var _prev_on_floor: bool = true

var _dbg_on:    bool                    = false
var _dbg_nodes: Array[MeshInstance3D]   = []


func setup(player: CharacterBody3D, bull: Node3D) -> void:
	_player   = player
	_bull     = bull
	_skeleton = _find_skeleton(bull)
	if not _skeleton:
		push_error("BullLegs: Skeleton3D not found under bull")
		return

	# Disable any physical bone simulator
	var phys_sim := _find_physical_bones(_skeleton)
	if phys_sim:
		phys_sim.active = false

	_setup_tail()


func _setup_tail() -> void:
	for bone_name: StringName in TAIL_BONES:
		var idx: int = _skeleton.find_bone(bone_name)
		if idx == -1:
			push_warning("BullLegs: tail bone '%s' not found" % bone_name)
			continue
		_tail_idx.append(idx)

	if _tail_idx.size() < 2:
		push_error("BullLegs: not enough tail bones found")
		return

	for idx: int in _tail_idx:
		var world: Vector3 = _skeleton.to_global(_skeleton.get_bone_global_rest(idx).origin)
		_tail_world.append(world)
		_tail_prev.append(world)

	var max_sep: float = 0.0
	for i in range(_tail_world.size() - 1):
		max_sep = maxf(max_sep, _tail_world[i].distance_to(_tail_world[i + 1]))
	if max_sep < 0.01:
		push_warning("BullLegs: tail bones have zero separation in rest pose, distributing along spine")
		var root: Vector3 = _tail_world[0]
		var back: Vector3 = (_skeleton.global_transform.basis.z + Vector3.DOWN * 0.3).normalized()
		for i in range(_tail_world.size()):
			_tail_world[i] = root + back * (i * 0.09)
			_tail_prev[i]  = _tail_world[i]

	const MIN_SEG: float = 0.06
	for i in range(_tail_world.size() - 1):
		var seg_len: float = _tail_world[i].distance_to(_tail_world[i + 1])
		_tail_lengths.append(maxf(seg_len, MIN_SEG))

	for i in range(_tail_world.size() - 1):
		var p1_skel: Vector3 = _skeleton.to_local(_tail_world[i])
		var p2_skel: Vector3 = _skeleton.to_local(_tail_world[i + 1])
		var seg_skel: Vector3 = p2_skel - p1_skel
		_tail_rest_dir.append(seg_skel.normalized() if seg_skel.length() > 0.001 else Vector3(0, 0, 1))
	_tail_rest_dir.append(_tail_rest_dir[-1])

	_tail_parent_idx = _skeleton.get_bone_parent(_tail_idx[0])


func _process(delta: float) -> void:
	if not _skeleton:
		return
	_wobble_body(delta)
	_update_tail(delta)
	_apply_tail()
	if DP.b("show_tail") != _dbg_on:
		_dbg_toggle()
	if _dbg_on:
		_dbg_update()


# ── Body Wobble ───────────────────────────────────────────────────────────────

func _wobble_body(delta: float) -> void:
	var vel:   Vector3 = _player.velocity
	var accel: Vector3 = (vel - _prev_vel) / maxf(delta, 0.001)
	_prev_vel = vel

	var la: Vector3 = _bull.global_transform.basis.inverse() * accel

	var tx := clampf(-la.z * DP.f("body_tilt_fwd"),  -0.35, 0.35)
	var tz := clampf(-la.x * DP.f("body_tilt_side"), -0.25, 0.25)

	_tilt_x = lerpf(_tilt_x, tx, delta * 10.0)
	_tilt_z = lerpf(_tilt_z, tz, delta * 10.0)

	var flat_spd: float = Vector2(vel.x, vel.z).length()
	var target_pitch: float = 0.0
	if not _player.is_on_floor() and flat_spd > 0.5:
		target_pitch = clampf(-atan2(vel.y, flat_spd) * DP.f("aerial_pitch_scale"), -1.4, 1.4)
	_aerial_pitch = lerpf(_aerial_pitch, target_pitch, delta * DP.f("aerial_pitch_speed"))

	_charge_pitch_curr = lerpf(_charge_pitch_curr, charge_pitch_target, delta * DP.f("charge_head_speed"))
	if external_rotation:
		return
	_bull.rotation.x = _tilt_x + _aerial_pitch + _charge_pitch_curr
	_bull.rotation.z = _tilt_z


# ── Tail ──────────────────────────────────────────────────────────────────────

func _update_tail(delta: float) -> void:
	if _tail_world.is_empty():
		return

	var gravity := Vector3(0.0, -DP.f("tail_gravity"), 0.0)
	var damping := 1.0 - clampf(DP.f("tail_damping"), 0.0, 0.99)

	var anchor: Vector3
	if _tail_parent_idx >= 0:
		var parent_t := _skeleton.get_bone_global_pose(_tail_parent_idx)
		anchor = _skeleton.to_global((parent_t * _skeleton.get_bone_rest(_tail_idx[0])).origin)
	else:
		anchor = _skeleton.to_global(_skeleton.get_bone_global_rest(_tail_idx[0]).origin)
	_tail_world[0] = anchor
	_tail_prev[0]  = anchor

	var on_floor := _player.is_on_floor()
	if not on_floor and _prev_on_floor:
		# Bull just left the ground — push all tail particles upward with it
		for i: int in range(1, _tail_world.size()):
			_tail_prev[i] = _tail_world[i] - _player.velocity * delta
	_prev_on_floor = on_floor

	var gravity_mult: float
	if tail_ragdoll:
		gravity_mult = 2.5
	elif not on_floor:
		gravity_mult = 0.15
	else:
		gravity_mult = 1.0
	gravity *= gravity_mult

	var flat_speed: float = Vector2(_player.velocity.x, _player.velocity.z).length()
	var wag_strength: float = lerpf(DP.f("tail_wag_idle"), 0.0, clampf(flat_speed / 3.0, 0.0, 1.0))
	var wag_freq: float = DP.f("tail_wag_freq")
	var time: float = Time.get_ticks_msec() / 1000.0
	var basis_x: Vector3 = _bull.global_transform.basis.x

	for i in range(1, _tail_world.size()):
		var vel: Vector3 = (_tail_world[i] - _tail_prev[i]) * damping
		_tail_prev[i]  = _tail_world[i]
		var wag := Vector3.ZERO if tail_ragdoll \
			else basis_x * sin(time * wag_freq + i * 0.5) * wag_strength
		_tail_world[i] = _tail_world[i] + vel + gravity * (delta * delta) + wag * delta

	for i in range(1, _tail_world.size()):
		var dir: Vector3 = _tail_world[i] - _tail_world[i - 1]
		var len: float   = dir.length()
		var target_len: float = _tail_lengths[i - 1]
		if len > 0.0001:
			_tail_world[i] = _tail_world[i - 1] + dir * (target_len / len)
		else:
			_tail_world[i] = _tail_world[i - 1] + Vector3(0.0, -target_len, 0.0)

	var body_radius: float = DP.f("tail_body_radius")
	for i in range(1, _tail_world.size()):
		var p := _bull.to_local(_tail_world[i])
		var closest := Vector3(0.0, 0.0, clampf(p.z, -0.3, 0.5))
		var diff := p - closest
		var dist := diff.length()
		if dist < body_radius and dist > 0.001:
			p = closest + diff.normalized() * body_radius
			_tail_world[i] = _bull.to_global(p)


func _apply_tail() -> void:
	if _tail_world.is_empty():
		return

	var n := _tail_world.size()
	var local_pos: Array[Vector3] = []
	local_pos.resize(n)
	for i in n:
		local_pos[i] = _skeleton.to_local(_tail_world[i])

	for i in n:
		var idx: int = _tail_idx[i]
		var pos_skel: Vector3 = local_pos[i]

		var point_dir_skel: Vector3
		if i < n - 1:
			point_dir_skel = (local_pos[i + 1] - pos_skel).normalized()
		else:
			point_dir_skel = (pos_skel - local_pos[i - 1]).normalized()

		var rest_dir: Vector3 = _tail_rest_dir[i]
		var rest_t: Transform3D = _skeleton.get_bone_global_rest(idx)
		var new_basis: Basis
		if rest_dir.length_squared() > 0.0001 and point_dir_skel.length_squared() > 0.0001:
			new_basis = Basis(Quaternion(rest_dir, point_dir_skel)) * rest_t.basis
		else:
			new_basis = rest_t.basis

		_skeleton.set_bone_global_pose_override(idx, Transform3D(new_basis, pos_skel), 1.0, false)


# ── Debug Visualization ───────────────────────────────────────────────────────

func _dbg_toggle() -> void:
	if _dbg_on:
		for n: MeshInstance3D in _dbg_nodes:
			if is_instance_valid(n):
				n.queue_free()
		_dbg_nodes.clear()
		_dbg_on = false
	else:
		_dbg_build()
		_dbg_on = true


func _dbg_build() -> void:
	var root: Node = _player.get_parent()
	var n: int = _tail_world.size()
	for i: int in n:
		var mat := StandardMaterial3D.new()
		mat.shading_mode  = BaseMaterial3D.SHADING_MODE_UNSHADED
		mat.render_priority = 1
		mat.albedo_color  = Color.RED if i == 0 \
			else Color(1.0 - float(i) / n, 0.4 + 0.6 * float(i) / n, 0.0)
		var sm := SphereMesh.new()
		sm.radius          = 0.10 if i == 0 else 0.06
		sm.height          = sm.radius * 2.0
		sm.radial_segments = 6
		sm.rings           = 3
		sm.material        = mat
		var mi := MeshInstance3D.new()
		mi.mesh = sm
		root.add_child(mi)
		_dbg_nodes.append(mi)


func _dbg_update() -> void:
	for i: int in _dbg_nodes.size():
		_dbg_nodes[i].global_position = _tail_world[i]


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

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


func _find_physical_bones(node: Node) -> PhysicalBoneSimulator3D:
	for child in node.get_children():
		if child is PhysicalBoneSimulator3D:
			return child as PhysicalBoneSimulator3D
	return null
