// Copyright Marcus Del Favero 2025
// Licensed under the GNU AGPLv3 with an exception, see `README.md` for details
use std::collections::{BTreeSet, BTreeMap};

use glam::Vec2;
use rand::RngCore;

use crate::server::multiplayer::config::{Portal, PortalDefaultColourConfig, Rotation};
use crate::world::{World, WorldRng, event::{WorldEvent, MoveType}, player::{Player, PlayerId, RADIUS as PLAYER_RADIUS}, special_area::SpecialArea, blocks::Blocks};
use crate::utils::maths::{Rect, Circle, CollidingRect};

// INVARIANTS:
// 1. All indices in the portals are less than portal.len()
// 2. All portals have at least one incoming or outgoing neighbour
pub struct PortalManager {
	portals: Box<[Portal]>,

	/**
	 * Keeps track of the players that are currently inside a portal that has at
	 * least one outgoing neighbour, so the player doesn't rapidly teleport
	 * between portals in a cycle.
	 */
	players_in_portal: BTreeMap<PlayerId, bool>,
}

impl PortalManager {
	pub fn build(mut portals: Box<[Portal]>, default_colours: PortalDefaultColourConfig, special_areas: &mut Vec<SpecialArea>) -> Result<PortalManager, String> {
		let mut has_incoming = BTreeSet::new();

		// First checks that all indices are valid and builds up `has_incoming`
		let len = portals.len();
		for (i, portal) in portals.iter_mut().enumerate() {
			for link in &portal.out {
				if link.index >= len {
					return Err(format!("portal {i} has outgoing portal index of {} which is too large, expected at most {len}", link.index));
				}

				has_incoming.insert(link.index);
			}
		}

		for (i, portal) in portals.iter().enumerate() {
			let (source, sink) = (has_incoming.contains(&i), !portal.out.is_empty());
			if !source && !sink {
				return Err(format!("portal {i} isn't connected to another portal"));
			}

			let colour = match portal.colour {
				Some(colour) => colour,
				None if source && sink => default_colours.bidirectional,
				None if source => default_colours.source,
				None => default_colours.sink,
			};

			special_areas.extend_from_slice(&SpecialArea::rect(portal.rect, colour));
		}

		Ok(PortalManager { portals, players_in_portal: BTreeMap::new() })
	}

	pub fn reset(&mut self) {
		self.players_in_portal.clear();
	}

	/**
	 * Called when the player dies or teleports from a teleportation powerup.
	 *
	 * This ensures that if a player respawns or teleports to (through
	 * teleportation powerup) a portal, they get teleported immediately to the
	 * next portal (without any flash before the teleportation) even if before
	 * the random movement they were already on a teleportation powerup.
	 *
	 * This property can be used to ensure that spawning players into an enclosed
	 * area completely filled with a sink portal will always teleport them to a
	 * source portal, and they won't be trapped.
	 */
	pub fn on_random_move(&mut self, player_id: PlayerId) {
		self.players_in_portal.insert(player_id, false);
	}

	pub fn sink_colliding(&self, other: &impl CollidingRect) -> bool {
		self.portals.iter().filter_map(|portal| (!portal.out.is_empty()).then_some(&portal.rect)).any(|rect| other.colliding_rect(rect))
	}

	pub fn events(&mut self, world: &World, rng: &mut WorldRng, events: &mut Vec<WorldEvent>) {
		// Removes old players
		self.players_in_portal.retain(|&id, _| world.has_player(id));

		for (&id, player) in world.get_players() {
			let mut found = false;

			for in_portal in &self.portals {
				if !in_portal.out.is_empty() && player.colliding_rect(&in_portal.rect) && in_portal.team_only.is_none_or(|id| Some(id) == player.get_team()) {
					found = true;

					// Prevents teleporting too much too quickly
					if *self.players_in_portal.entry(id).or_default() {
						break;
					}

					let link = &in_portal.out[rng.next_u32() as usize % in_portal.out.len()];
					let out_portal = &self.portals[link.index];

					/*
					 * Calculates the new player position after teleporting through
					 * the portal.
					 *
					 * The basic idea of this is that the output position depends on
					 * the input position. For example, if you enter one end of a
					 * pair of horizontal portals on the left, you should leave the
					 * other end on the left as well. However, things are more
					 * complicated if you enter a horizontal portal but then leave a
					 * vertical portal in which rotations of the position and
					 * velocity are required.
					 *
					 * This process involves three steps:
					 *
					 * 1. First convert the input player position into "portal
					 *    teleportation coordinates" (PTC).
					 * 2. Rotate and flip the PTC as necessary.
					 * 3. Convert the transformed PTC back into a new player position
					 *    based on the output portal.
					 *
					 * This PTC that I'm referring to is 0 being the centre of the
					 * portal, ±0.5 being the edges of the portal and outside 0.5
					 * being outside the portal. This is done for each axis.
					 *
					 * Inside the portal, scaling is done based on the size of each
					 * portal. Outside the portal there is a constant 1:1 scaling
					 * between world coordinates and PTC.
					 */
					let in_pos = player.get_pos();
					let in_p0 = in_portal.rect.get_p0();
					let in_p1 = in_portal.rect.get_p1();
					let out_p0 = out_portal.rect.get_p0();
					let out_p1 = out_portal.rect.get_p1();

					// Step 1
					let mut t = Vec2::ZERO;
					for i in 0..2 {
						t[i] = if in_pos[i] < in_p0[i] {
							-0.5 + in_pos[i] - in_p0[i]
						} else if in_pos[i] <= in_p1[i] {
							if in_p0[i] != in_p1[i] {
								(in_pos[i] - f32::midpoint(in_p0[i], in_p1[i])) / (in_p1[i] - in_p0[i])
							} else { in_p0[i] } // Prevents division by zero problems
						} else {
							0.5 + in_pos[i] - in_p1[i]
						};
					}

					// Step 2
					t = link.rotation.rotate(t);
					if link.flip_x { t.x *= -1.0; }
					if link.flip_y { t.y *= -1.0; }

					// Step 3
					let mut out_pos = Vec2::ZERO;
					for i in 0..2 {
						out_pos[i] = if t[i] < -0.5 {
							out_p0[i] + t[i] + 0.5
						} else if t[i] <= 0.5 {
							f32::midpoint(out_p0[i], out_p1[i]) + t[i] * (out_p1[i] - out_p0[i])
						} else {
							out_p1[i] + t[i] - 0.5
						};
					}

					/*
					 * Now I have a position that can be used, but there is a
					 * problem: it might be colliding with a block or the world
					 * border.
					 *
					 * To prevent this from happening, I am binary searching along
					 * the line connecting the centre of the outgoing portal and the
					 * initial position candidate to find a position that is very
					 * close to the block but not colliding.
					 *
					 * For this to work, this assumes that the player at the portal
					 * centre isn't inside a block.
					 */
					let out_pos = PortalManager::try_find_not_colliding(world.get_blocks(), out_p0.midpoint(out_p1), out_pos, world.get_size() / 2.0);
					events.push(WorldEvent::PlayerPos(id, out_pos, MoveType::Teleportation));

					if link.rotation != Rotation::D0 || link.vel_scl != Vec2::ONE || link.vel_off != Vec2::ZERO {
						let vel = link.rotation.rotate(player.get_vel()) * link.vel_scl + link.vel_off;
						events.push(WorldEvent::PlayerVel(id, vel));
					}

					break; // Goes through only the first portal found
				}
			}

			self.players_in_portal.insert(id, found);
		}
	}

	fn try_find_not_colliding(blocks: &Blocks, centre: Vec2, init_out: Vec2, world_radii: Vec2) -> Vec2 {
		let colliding = |pos| Player::colliding(pos, blocks) || pos.abs().cmpgt(world_radii - PLAYER_RADIUS).any();

		/*
		 * If not colliding, can use the initial output position calculated just
		 * fine.
		 */
		if !colliding(init_out) { return init_out; }

		/*
		 * If colliding even at the centre (possible if badly configured), just
		 * use the centre and say that's good enough.
		 */
		if colliding(centre) { return centre; }

		// Now binary search is performed
		let (mut low, mut high) = (0.0, 1.0);
		for _ in 0..16 {
			let mid = f32::midpoint(low, high);
			let pos = centre.lerp(init_out, mid);

			if colliding(pos) {
				high = mid;
			} else {
				low = mid;
			}
		}

		let not_colliding_pos = centre.lerp(init_out, low);
		debug_assert!(!Player::colliding(not_colliding_pos, blocks));
		not_colliding_pos
	}
}
