// Copyright Marcus Del Favero 2025
// Licensed under the GNU AGPLv3 with an exception, see `README.md` for details
use glam::Vec2;

use super::{Player, Blocks, RADIUS};

use crate::utils::maths::{CollidingRect, glam_fix::Fix};

pub(super) const RESTITUTION_K: f32 = 0.75; // Coefficient of restitution for collisions with the blocks and world boundary (0 = inelastic, 1 = fully elastic)

impl Player {
	/**
	 * Returns whether the player *responded* to a collision with the blocks.
	 */
	pub(super) fn collide_blocks(&mut self, world_size: Vec2, blocks: &Blocks, prev_pos: Vec2, dt: f32) -> bool {
		const BSEARCH_STEPS: usize = 8;

		if !Player::colliding(self.pos, blocks) { return false; }

		/*
		 * Uses binary search to find a very precise point of collision in a
		 * pretty small number of steps.
		 *
		 * This will work well because the function of is_colliding is very
		 * likely to be simple piecewise function of "No" and "Yes" for t ∈ [0,
		 * 1) (which is probably true right now), so the point of collision found
		 * will be near the first point of collision.
		 *
		 * Note that the point at `high` will always be colliding, while the
		 * point at `low` will never be colliding if it is non-zero (if it's zero
		 * then it could be that the player was previously colliding before the
		 * update). This can be seen by the if-statement in the binary search,
		 * setting the values of `low` and `high` when colliding/not colliding
		 * (and t = 1 is colliding as checked above).
		 */
		let (mut low, mut high) = (0.0, 1.0);
		for _ in 0..BSEARCH_STEPS {
			#[allow(clippy::manual_midpoint)] // No risk of overflow
			let mid = (low + high) / 2.0;
			let pos = prev_pos.lerp(self.pos, mid);

			if Player::colliding(pos, blocks) {
				high = mid;
			} else {
				low = mid;
			}
		}

		/*
		 * Don't do collision response if was colliding before the update (which
		 * shouldn't happen unless the player spawns inside a block).
		 */
		if low == 0.0 && Player::colliding(prev_pos, blocks) { return false; }

		let not_colliding_pos = prev_pos.lerp(self.pos, low);

		/*
		 * Need a special case to check for `high == 1.0` because `lerp(a, b, 1)`
		 * isn't always guaranteed to be `b` because of floating point error.
		 *
		 * This can result in a bug because `colliding_pos` might be a position
		 * where the player isn't actually colliding with the blocks, and so can
		 * cause problems when finding the normal vectors.
		 *
		 * In all other cases, the above loop shows that a player at
		 * `lerp(prev_pos, pos, high)` will be colliding with the blocks.
		 */
		let colliding_pos = if high != 1.0 { prev_pos.lerp(self.pos, high) } else { self.pos };

		debug_assert!(!Player::colliding(not_colliding_pos, blocks));

		/*
		 * Averages the normals because the player might be colliding at the
		 * "corners" of two blocks, but those blocks are merged together and
		 * they're effectively colliding with an edge.
		 *
		 * Without averaging, one of the corner normals would be calculated and
		 * returned.
		 */
		let normals = blocks
			.iter_range(colliding_pos, RADIUS)
			.filter(|block| (colliding_pos, RADIUS).colliding_rect(block.get_rect()))
			.map(|block| block.get_normal(colliding_pos))
			.collect::<Vec<_>>();

		let normal = match normals.len() {
			0 => {
				log::warn!("player should be colliding with the blocks when they aren't.");
				return false;
			},
			1 => normals[0], // Avoids normalising an already normalised vector
			_ => normals.iter().sum::<Vec2>().normalise_or_zero(),
		};

		// Reflects the velocity about the normal
		let proj = self.vel.dot(normal) * normal;
		self.vel -= proj * (1.0 + RESTITUTION_K);

		// Change in the position from the point right before colliding
		let dpos = self.vel * (1.0 - low) * dt;
		let new_pos = not_colliding_pos + dpos;

		if Player::colliding(new_pos, blocks) || new_pos.abs().cmpgt(world_size / 2.0 - RADIUS).any() { // If still colliding with the blocks, or colliding with the border
			let prev_pos = self.pos;
			self.pos = not_colliding_pos;

			/*
			 * Prevents velocity from accumulating when the player thrusts into
			 * the corner which results in the thrust particles looking weird.
			 */
			self.vel = (self.pos - prev_pos) / dt;
		} else {
			self.pos = new_pos;
		}

		true
	}

	pub fn colliding(pos: Vec2, blocks: &Blocks) -> bool {
		blocks.iter_range(pos, RADIUS).any(|block| (pos, RADIUS).colliding_rect(block.get_rect()))
	}
}
