// Copyright Marcus Del Favero 2025
// Licensed under the GNU AGPLv3 with an exception, see `README.md` for details
use std::io::Read;
use std::net::{SocketAddr, IpAddr};
use std::sync::Arc;

use sysinfo::Networks;
use tokio::{net::UdpSocket, sync::Notify};

use super::{PROTOCOL_VERSION, DISCOVER_MAGIC_NUMBER, RESPONSE_MAGIC_NUMBER, SERVER_PORT};

use crate::net::{serp::vu30::{ReadVu30, ExtendVu30}, utils::{Ipv4Network, SocketAddrToCanonical, udp}};

#[derive(Clone)]
pub struct LanDiscoveryServer {
	socket: Arc<UdpSocket>,
	response: Box<[u8]>,
}

impl LanDiscoveryServer {
	pub fn try_new(game_addr: SocketAddr) -> Result<LanDiscoveryServer, String> {
		let bind_addr = SocketAddr::new(LanDiscoveryServer::game_to_broadcast_addr(game_addr.ip())?, SERVER_PORT);
		let socket = udp::new_tokio(bind_addr).map_err(|err| format!("cannot bind to port {SERVER_PORT}: {err}"))?;

		let mut response = Vec::with_capacity(RESPONSE_MAGIC_NUMBER.len() + PROTOCOL_VERSION.len() + 8 + 2); // Magic number, protocol version, id, game port
		response.extend_from_slice(RESPONSE_MAGIC_NUMBER);
		response.extend_vu30(PROTOCOL_VERSION);
		response.extend_from_slice(#[allow(clippy::host_endian_bytes)] &rand::random::<u64>().to_ne_bytes() /* Don't care about endianness, just want random data */);
		response.extend_from_slice(&game_addr.port().to_le_bytes());

		Ok(LanDiscoveryServer {
			socket: Arc::new(socket),
			response: response.into_boxed_slice(),
		})
	}

	pub async fn loop_forever(self, close_notify: Arc<Notify>) {
		// Magic number, four bytes for the vu30 version, and another byte to check for invalid messages that are too long
		let mut recv_buf = [0; DISCOVER_MAGIC_NUMBER.len() + 5];
		let mut recv_fail_count = 0;

		loop {
			let res = tokio::select! {
				() = close_notify.notified() => return,
				res = self.socket.recv_from(&mut recv_buf) => res,
			};

			let (len, addr) = match res {
				Ok((len, addr)) => (len, addr),
				Err(err) => {
					log::warn!("failed receiving from socket: {err}");
					recv_fail_count += 1;

					// If for whatever reason the socket fails, don't want to exhaust disk space with the above log
					if recv_fail_count > 100 {
						log::warn!("failed receiving from socket too many times, stopping LAN discovery server");
						return;
					}
					continue;
				},
			};

			let mut reader = &recv_buf[..len];
			let mut magic = [0; DISCOVER_MAGIC_NUMBER.len()];
			if reader.read_exact(&mut magic).is_err() || magic != DISCOVER_MAGIC_NUMBER {
				log::debug!("magic number from {addr} doesn't match, got {:?}", &magic[..magic.len().min(len)]);
				continue;
			}

			let Ok(version) = reader.read_vu30() else {
				log::debug!("failed reading protocol version from {addr}");
				continue;
			};

			// Not bothing to support older versions in the server, and the client won't understand the new version
			if version < PROTOCOL_VERSION {
				log::debug!("protocol version from {addr} not supported, got {version}");
				continue;
			}

			// Trailing data might be allowed in later versions of the protocol
			if !reader.is_empty() && version <= PROTOCOL_VERSION {
				log::debug!("trailing data found in datagram from {addr}");
				continue;
			}

			// Now it has been successfully received
			log::info!("Received LAN discovery message from {}", addr.to_canonical());

			// Respond to the request
			if let Err(err) = self.socket.send_to(&self.response, addr).await {
				log::warn!("failed sending LAN discovery response to {addr}: {err}");
			}
		}
	}

	/**
	 * Converts the IP address the game server is bound to into a suitable IP
	 * address that the LAN discovery server should bind to. This suitable IP
	 * address is either a broadcast address or unspecified.
	 *
	 * To see why the unspecified address cannot be bound to, consider the case
	 * that a game server is started and bound to 127.0.0.1. Then if a user on
	 * another host on the local network uses LAN discovery to find games on the
	 * local network, then this game will show up but the user won't be able to
	 * access it.
	 *
	 * The solution is to bind the LAN discovery server to the directed broadcast
	 * address if binding to a specific address, or leave it unchanged if the
	 * game server bound to an unspecified address.
	 *
	 * Note that directed broadcast isn't available on IPv6. This means that LAN
	 * discovery will only work on IPv6 if the game is bound to the unspecified
	 * address, which is `::`.
	 */
	fn game_to_broadcast_addr(game_addr: IpAddr) -> Result<IpAddr, String> {
		if game_addr.is_unspecified() {
			Ok(game_addr)
		} else if let IpAddr::V4(addr) = game_addr {
			Ipv4Network::networks(&Networks::new_with_refreshed_list())
				.filter(|ip_net| ip_net.contains(addr))
				.max_by_key(|ip_net| ip_net.net_prefix)
				.map(|ip_net| IpAddr::V4(ip_net.directed_broadcast()))
				.ok_or_else(|| format!("IP address {addr} doesn't belong to any network"))
		} else {
			Err(String::from("LAN discovery server isn't supported when binding the game server to an IPv6 address that isn't `::`"))
		}
	}
}
