using Godot;
using Sfs3.Client.Entities.Data;

using System.Collections.Generic;

/**
 * Script attached to the GameEngine node in the Game scene.
 *
 * The engine renders the game world: the parallax background, the planet, the starships and the weapon shots.
 * The world is drawn in the server coordinates system (pixels), with the y axis flip applied when placing
 * each item; the camera zoom scales it to the viewport.
 *
 * Starships and weapon shots are simulated locally between the updates sent by the server, so that they
 * move smoothly: the simulation applies the same forces (thrust, gravity) the server-side one does.
 */
public partial class GameEngine : Node2D
{
	private const string PLANETS_PATH = "res://images/planets/";

	// The spritesheets are drawn at 2x, so world sprites are halved: one texture pixel
	// is half a server pixel (see the same scale in the Starship and WeaponShot scenes)
	private const float SPRITE_SCALE = 0.5f;
	private const string STARSHIP_SCENE = "res://scenes/sub/Starship.tscn";
	private const string WEAPON_SHOT_SCENE = "res://scenes/sub/WeaponShot.tscn";

	// Half-extents of the Area of Interest configured for the MMORoom on the server side
	private const float AOI_X = 1400f;
	private const float AOI_Y = 750f;

	// Portion of the viewport (%) the starship is kept away from the borders before the camera starts scrolling
	private const int SCROLL_AREA_PADDING = 15;

	// Portion of the Area of Interest the viewport is allowed to fill, so that items become visible before entering it
	private const float AOI_VIEWPORT_FILL = 0.9f;

	// Longest interval (milliseconds) which can be simulated in a single frame
	private const int MAX_SIMULATION_BACKLOG = 1000;

	// INPUT ACTIONS
	private const string ACTION_ROTATE_LEFT = "rotate_left";
	private const string ACTION_ROTATE_RIGHT = "rotate_right";
	private const string ACTION_THRUST = "thrust";
	private const string ACTION_FIRE1 = "fire1";
	private const string ACTION_FIRE2 = "fire2";

	[Export]
	public Camera2D camera;
	[Export]
	public Node2D world;

	private PackedScene starshipScene;
	private PackedScene weaponShotScene;

	private Planet planet;
	private Starship myStarship;

	private Dictionary<int, Starship> starships;
	private Dictionary<int, WeaponShot> weaponShots;

	private bool cameraReady;

	//----------------------------------------------------------
	// Callback methods
	//----------------------------------------------------------
	#region
	public override void _Ready()
	{
		starships = [];
		weaponShots = [];

		starshipScene = ResourceLoader.Load<PackedScene>(STARSHIP_SCENE);
		weaponShotScene = ResourceLoader.Load<PackedScene>(WEAPON_SHOT_SCENE);

		// Scale the world to the viewport, and keep it scaled if the window is resized
		GetViewport().SizeChanged += UpdateCameraZoom;
		UpdateCameraZoom();

		// The camera follows the player's starship, but it only scrolls when the ship gets close to
		// the viewport borders; drag margins are relative to half the viewport, hence twice the padding
		float dragMargin = 1 - 2 * (SCROLL_AREA_PADDING / 100f);

		camera.DragHorizontalEnabled = true;
		camera.DragVerticalEnabled = true;
		camera.DragLeftMargin = dragMargin;
		camera.DragRightMargin = dragMargin;
		camera.DragTopMargin = dragMargin;
		camera.DragBottomMargin = dragMargin;
	}

	public override void _Process(double delta)
	{
		foreach (Starship ship in starships.Values)
			RenderStarship(ship);

		foreach (WeaponShot shot in weaponShots.Values)
			RenderWeaponShot(shot);
	}

	public override void _UnhandledInput(InputEvent evt)
	{
		if (evt.IsActionPressed(ACTION_ROTATE_LEFT))
			OnMove(1);     // Left rotates counterclockwise
		else if (evt.IsActionPressed(ACTION_ROTATE_RIGHT))
			OnMove(-1);    // Right rotates clockwise
		else if (evt.IsActionReleased(ACTION_ROTATE_LEFT) || evt.IsActionReleased(ACTION_ROTATE_RIGHT))
			OnMove(0);
		else if (evt.IsActionPressed(ACTION_THRUST))
			OnThrust(true);
		else if (evt.IsActionReleased(ACTION_THRUST))
			OnThrust(false);
		else if (evt.IsActionPressed(ACTION_FIRE1))
			OnFire(1);
		else if (evt.IsActionPressed(ACTION_FIRE2))
			OnFire(2);
	}
	#endregion

	//----------------------------------------------------------
	// Game events
	//----------------------------------------------------------
	#region
	[Signal]
	public delegate void StarshipRotateEventHandler(int rotationDir);

	[Signal]
	public delegate void StarshipThrustEventHandler(bool activate);

	[Signal]
	public delegate void StarshipFireEventHandler(int weaponNum);
	#endregion

	//----------------------------------------------------------
	// Input event listeners
	//----------------------------------------------------------
	#region
	private void OnMove(int direction)
	{
		if (myStarship == null)
			return;

		if (direction != myStarship.rotatingDir)
		{
			SetStarshipRotating(myStarship.userId, direction);

			// Fire event to send a request to the server
			EmitSignal(SignalName.StarshipRotate, myStarship.rotatingDir);
		}
	}

	private void OnThrust(bool activate)
	{
		if (myStarship == null)
			return;

		// Thrust activation is made of 3 steps:
		// 1) on activate the ship shows a small flame; no actual force is applied to the ship (no trajectory change)
		// 2) request is sent to the server which activates the thrust and sends a position reset event
		// 3) when the event is received the ship shows a bigger flame and the thrust force is applied during the simulation

		// Similarly for thrust deactivation:
		// 1) on deactivate the ship shows a small flame; the actual force is still applied to the ship (trajectory keeps changing)
		// 2) request is sent to the server which deactivates the thrust and sends a position reset event
		// 3) when the event is received the ship stops showing the flame and the thrust force is not applied anymore

		// In both cases the ship shows the small flame until the server confirms the new thruster state
		myStarship.thrusterValue = 1;

		// Fire event to send a request to the server
		EmitSignal(SignalName.StarshipThrust, activate);
	}

	private void OnFire(int fireType)
	{
		if (myStarship == null)
			return;

		// Fire event to send a request to the server
		EmitSignal(SignalName.StarshipFire, fireType);
	}
	#endregion

	//----------------------------------------------------------
	// Public methods
	//----------------------------------------------------------
	#region
	/**
	 * Create the planet at the center of the level.
	 * Levels with no planet (deep space) have no texture, but the planet is created anyway
	 * because its mass is used by the gravity simulation.
	 */
	public void CreatePlanet(ISFSObject planetData)
	{
		string texturePath = PLANETS_PATH + planetData.GetString("name").ToLower() + ".tres";

		// The planet keeps the default z index (0), so it sits above the parallax starfields
		// (-2 and -1) and below the radar (1) and the starships and their weapon shots (2)
		planet = new Planet
		{
			Name = "Planet",
			mass = (int)planetData.GetInt("mass"),
			Scale = new Vector2(SPRITE_SCALE, SPRITE_SCALE)
		};

		if (ResourceLoader.Exists(texturePath))
			planet.Texture = ResourceLoader.Load<Texture2D>(texturePath);

		// The planet sits at the center of the level, which is the origin of the world coordinates
		world.AddChild(planet);
	}

	public void CreateStarship(int userId, string userName, bool isMine, string type, ISFSObject settings)
	{
		if (starships.ContainsKey(userId))
			return;

		// Create instance
		Starship ship = starshipScene.Instantiate<Starship>();
		ship.Name = "Starship" + userId;
		ship.userId = userId;
		ship.username = userName;
		ship.Configure(type, settings, isMine);

		if (isMine)
			myStarship = ship;

		world.AddChild(ship);

		// Add starship to the container
		starships.Add(userId, ship);
	}

	public void SetStarshipPosition(int userId, float x, float y, float vx, float vy, float d, bool t, int s, int elapsed)
	{
		if (starships.TryGetValue(userId, out Starship ship))
		{
			// Set position and velocity
			ship.xx = x;
			ship.yy = y;
			ship.velocity.vx = vx;
			ship.velocity.vy = vy;
			ship.lastRenderTime = GetTimer() - elapsed;

			// Set thruster
			ship.doThrust = t;

			// Set rotation angle
			ship.rotation = d;

			// Set shield
			ship.shield = s;

			// Render the starship
			// This simulates the starship movement taking into account the elapsed time since the server sent the new position/speed
			// and places the starship in the current coordinates
			RenderStarship(ship);
		}
	}

	public void SetStarshipRotating(int userId, int r)
	{
		if (starships.TryGetValue(userId, out Starship ship))
			ship.rotatingDir = r;
	}

	public void RemoveStarship(int userId, bool doExplode)
	{
		if (!starships.TryGetValue(userId, out Starship ship))
			return;

		starships.Remove(userId);

		// Show explosion...
		if (doExplode)
			ship.Explode();

		// ...or remove immediately
		else
			ship.QueueFree();

		if (ship == myStarship)
			myStarship = null;
	}

	public void CreateWeaponShot(int id, ISFSObject settings, float x, float y, float vx, float vy, int elapsed)
	{
		if (weaponShots.ContainsKey(id))
			return;

		// Create instance
		WeaponShot shot = weaponShotScene.Instantiate<WeaponShot>();
		shot.Name = "WeaponShot" + id;
		shot.id = id;
		shot.Configure(settings.GetString("model"));

		world.AddChild(shot);

		// Add weapon shot to the container
		weaponShots.Add(id, shot);

		// Set position and velocity
		shot.xx = x;
		shot.yy = y;
		shot.velocity.vx = vx;
		shot.velocity.vy = vy;
		shot.lastRenderTime = GetTimer() - elapsed;
	}

	public void RemoveWeaponShot(int id, bool doExplode, int? posX = null, int? posY = null)
	{
		// The shot could have already been removed if the explosion
		// was notified by the server before the proximity update
		if (!weaponShots.TryGetValue(id, out WeaponShot shot))
			return;

		weaponShots.Remove(id);

		// Show explosion...
		if (doExplode)
		{
			if (posX != null && posY != null)
				shot.Explode(new Vector2((int)posX, (int)posY));
			else
				shot.Explode();
		}

		// ...or remove immediately
		else
			shot.QueueFree();
	}
	#endregion

	//----------------------------------------------------------
	// Private methods
	//----------------------------------------------------------
	#region
	/**
	 * Move a starship to the next position based on applied forces.
	 */
	private void RenderStarship(Starship ship)
	{
		int steps = GetSimulationSteps(ship);

		for (int i = 0; i < steps; i++)
		{
			// Ship rotation
			ship.rotation += ship.rotatingDir * ship.rotationSpeed;

			// Thruster force
			if (ship.doThrust)
			{
				ship.velocity.vx += Mathf.Cos(ship.rotation) * ship.thrustAcceleration;
				ship.velocity.vy += Mathf.Sin(ship.rotation) * ship.thrustAcceleration;
			}

			// Limit speed
			ship.velocity.LimitSpeed(ship.maxSpeed);

			// Planet gravitational attraction
			ApplyGravityForce(ship);

			// Update ship position due to the calculated velocity
			ship.xx += ship.velocity.vx;
			ship.yy += ship.velocity.vy;
		}

		// Update starship node position
		ship.WorldPosition = new Vector2(ship.xx, ship.yy);

		// Follow the player's ship; the camera drag margins keep it still until the ship
		// gets close to the viewport borders
		if (ship.isMine)
		{
			camera.GlobalPosition = ship.GlobalPosition;

			// Center the view on the ship the first time it appears, instead of letting the drag
			// margins pull it in from the border it entered from
			if (!cameraReady)
			{
				camera.Align();
				cameraReady = true;
			}
		}
	}

	/**
	 * Move a weapon shot to the next position based on applied forces.
	 */
	private void RenderWeaponShot(WeaponShot shot)
	{
		int steps = GetSimulationSteps(shot);

		for (int i = 0; i < steps; i++)
		{
			// Planet gravitational attraction
			ApplyGravityForce(shot);

			// Update shot position due to the calculated velocity
			shot.xx += shot.velocity.vx;
			shot.yy += shot.velocity.vy;
		}

		// Update shot node position
		shot.WorldPosition = new Vector2(shot.xx, shot.yy);
	}

	/**
	 * Evaluate the zoom at which the world is rendered, so that the visible area is always
	 * smaller than the Area of Interest, no matter the viewport size and aspect ratio.
	 */
	private void UpdateCameraZoom()
	{
		Vector2 viewportSize = GetViewportRect().Size;

		float fill = (1 - SCROLL_AREA_PADDING / 100f) / AOI_VIEWPORT_FILL;
		float zoom = Mathf.Max(fill * viewportSize.X / AOI_X, fill * viewportSize.Y / AOI_Y);

		camera.Zoom = new Vector2(zoom, zoom);
	}

	/**
	 * Number of one millisecond long simulation steps to be applied to a game item.
	 */
	private static int GetSimulationSteps(GameItem gameItem)
	{
		double now = GetTimer();

		// Discard a backlog which is too long to be simulated in a single frame
		if (now - gameItem.lastRenderTime > MAX_SIMULATION_BACKLOG)
			gameItem.lastRenderTime = now - MAX_SIMULATION_BACKLOG;

		int steps = (int)Mathf.Max(0, (float)(now - gameItem.lastRenderTime));

		// The fraction of millisecond which is left out is simulated by the next frames:
		// this keeps the simulation aligned with the server one at any frame rate
		gameItem.lastRenderTime += steps;

		return steps;
	}

	private void ApplyGravityForce(GameItem gameItem)
	{
		if (planet != null && planet.mass > 0)
		{
			// Square distance from planet center (always 0,0)
			float dist2 = gameItem.xx * gameItem.xx + gameItem.yy * gameItem.yy;
			float angle = Mathf.Atan2(gameItem.yy, gameItem.xx); // In radians
			float attr = planet.mass / dist2;

			gameItem.velocity.vx -= Mathf.Cos(angle) * attr;
			gameItem.velocity.vy -= Mathf.Sin(angle) * attr;
		}
	}

	private static double GetTimer()
	{
		return Time.GetTicksMsec();
	}
	#endregion
}
