class_name ProjectileArc extends Node3D
## Visually project the arc of a projectile through space.
##
## If this node has any children, they will be positioned wherever the projection ends.

## Constant attrition factor for putting collisions
## The actual velocity attrition is dependent on lots of factors
#const PUTT_ATTRITION := 0.75  # rough?
const PUTT_ATTRITION := 0.8325  # green?

## Initial speed of the projectile, in m/s.
## The projectile's initial direction vector is the negative Z direction relative to this node.
@export var initial_speed := 1.0

@export_category("Projection")
## Time between projection steps, in seconds.
@export var time_step := 0.2
## Maximum number of steps to predict before stopping.
@export var max_steps := 50
## Ticks between each projection update. 0 means update every tick.
@export var ticks_per_update := 0
## If enabled, project a linear putt instead of an arcing shot
@export var putt_projection := false

@export_category("Collision & Physics")
## Enables collision checking. Projection will end at the point where a collision is detected.
## Uses continuous collision detection.
@export var check_collision := true
## Mask for collision checking.
@export_flags_3d_physics var collision_mask := 1 | 2
## Bodies excluded from collision checking.
## This should probably include the ball!
@export var excluded_bodies: Array[CollisionObject3D] = []

## Enables checking local gravity at each point along the trajectory.
## If disabled, global gravity will be used instead.
@export var check_gravity := true

## Improves performance by caching gravity at each point along the projection.
## This can cause problems if there is a moving gravity field.
@export var cache_gravity := true

## Linear damping factor of the shot.
@export var linear_damp := 0.0

var _tick_counter := 0

var _debug_points: Array[Vector3] = []

var _gravity_cache: Array[Vector3] = []

var _cached_pos: Vector3
var _cached_vel: Vector3

@onready var polygon: CSGPolygon3D = %Polygon
@onready var path: Path3D = %Path

@onready var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
@onready var gravity_vec: Vector3 = ProjectSettings.get_setting("physics/3d/default_gravity_vector")

@onready var debug_draw: Control = %DebugDraw


func set_ball(ball: GameBall) -> void:
	global_position = ball.global_position
	linear_damp = ball.terrain_physics.default.linear_damp
	print("using linear damp: ", linear_damp)


func _process(_delta: float) -> void:
	if not visible:
		# Don't bother if we're not visible
		return

	# Short-circuit if we need to wait more ticks
	if _tick_counter > 0:
		_tick_counter -= 1
		return
	_tick_counter = ticks_per_update

	_debug_points = []

	# Rebuild path curve
	path.global_basis = Basis.IDENTITY
	path.curve.clear_points()

	var space := get_world_3d().direct_space_state
	var excluded_rid: Array[RID] = []
	excluded_rid.assign(excluded_bodies.map(func(k: CollisionObject3D) -> RID: return k.get_rid()))

	var pos := global_position
	var vel := -global_basis.z * initial_speed

	if not cache_gravity or pos != _cached_pos or vel != _cached_vel:
		_gravity_cache = []
	_cached_pos = pos
	_cached_vel = vel

	var final_normal: Vector3

	for t in range(0, max_steps):
		# TODO: smooth curve with bezier handles
		path.curve.add_point(pos - global_position)

		# Get local gravity if enabled
		var local_gravity := gravity * gravity_vec
		if check_gravity and Game.settings.projection_gravity:
			if t >= len(_gravity_cache):
				_gravity_cache.append(_get_gravity(pos))
			local_gravity = _gravity_cache[t]

		# Integrate projectile path
		var next_pos := pos + vel * time_step + 0.5 * local_gravity * time_step * time_step
		vel += local_gravity * time_step
		vel *= maxf(1 - linear_damp * time_step, 0)

		# Collision
		if check_collision and Game.settings.projection_collisions:
			var ray_params := PhysicsRayQueryParameters3D.create(
				pos, next_pos, collision_mask, excluded_rid
			)
			var collision := space.intersect_ray(ray_params)
			if collision:
				# Set current position to collision point, so it will be added to the path
				pos = collision["position"]
				_debug_points.append(pos)
				if putt_projection:
					@warning_ignore("unsafe_cast")
					var norm: Vector3 = (collision["normal"] as Vector3).normalized()
					next_pos = pos + norm * 0.05
					vel = PUTT_ATTRITION * (vel - 2 * norm * vel.dot(norm))
				else:
					# End projection!
					final_normal = collision["normal"]
					break

		pos = next_pos

	# Add terminal point (possibly collision point)
	path.curve.add_point(pos - global_position)

	var child_basis := Basis.IDENTITY
	if final_normal:
		var up := final_normal.normalized()
		var forward := Vector3(up.y, -up.x, 0)
		var right := up.cross(forward).normalized()
		forward = right.cross(up).normalized()
		child_basis = Basis(right, up, forward)

	# Reposition any children
	for n: Node in get_children():
		if n is Node3D and n != path:
			var node_3d: Node3D = n
			node_3d.global_position = pos
			node_3d.global_basis = child_basis

	(%DebugDraw as CanvasItem).queue_redraw()


func _get_gravity(point: Vector3) -> Vector3:
	# Start with global gravity
	var local_gravity := gravity * gravity_vec

	# TODO this is awful, surely there has to be a better way than this!!!
	# Get areas at point
	var point_params := PhysicsPointQueryParameters3D.new()
	point_params.collide_with_areas = true
	point_params.collide_with_bodies = false
	point_params.collision_mask = collision_mask
	point_params.position = point
	var collisions := get_world_3d().direct_space_state.intersect_point(point_params)
	var gravity_areas: Array[Area3D] = []
	@warning_ignore("unsafe_cast")
	gravity_areas.assign(
		collisions.map(func(d: Dictionary) -> Area3D: return d["collider"] as Area3D)
	)
	gravity_areas.sort_custom(func(a: Area3D, b: Area3D) -> bool: return a.priority < b.priority)

	# Iteratively integrate gravity
	for area: Area3D in gravity_areas:
		var point_local := point - area.global_position
		var area_gravity: Vector3
		if area.gravity_point:
			var v := area.transform * area.gravity_direction - point_local
			if area.gravity_point_unit_distance > 0:
				var v_sq := v.length_squared()
				if v_sq > 0:
					area_gravity = (
						v.normalized()
						* area.gravity
						* pow(area.gravity_point_unit_distance, 2)
						/ v_sq
					)
				else:
					area_gravity = Vector3.ZERO
			else:
				area_gravity = v.normalized() * area.gravity
		else:
			area_gravity = area.gravity * area.gravity_direction

		match area.gravity_space_override:
			Area3D.SPACE_OVERRIDE_COMBINE, Area3D.SPACE_OVERRIDE_COMBINE_REPLACE:
				local_gravity += area_gravity
			Area3D.SPACE_OVERRIDE_REPLACE_COMBINE, Area3D.SPACE_OVERRIDE_REPLACE:
				local_gravity = area_gravity
			Area3D.SPACE_OVERRIDE_COMBINE_REPLACE, Area3D.SPACE_OVERRIDE_REPLACE:
				break

	return local_gravity


func _on_visibility_changed() -> void:
	# Force update as soon as visible
	_tick_counter = 0
