import { Application, Assets, TilingSprite, AnimatedSprite, Sprite, Container } from 'pixi';
import Starship from '../objects/starship.js';
import WeaponShot from '../objects/weapon-shot.js';

// Server-side Area of Interest (AoI) half-extents, in simulation units (pixels)
// These MUST match the MMORoom AoI configured on the server: each value is the distance from the
// user along an axis, so the tracked area around the ship is (2 * AOI_X) by (2 * AOI_Y)
const AOI_X = 1400;
const AOI_Y = 750;

// Fraction of the AoI the viewport edge is allowed to reach; keeping it below 1 leaves a ring inside
// the AoI where entities are already tracked before they enter the viewport, avoiding proximity pop-in
const AOI_VIEWPORT_FILL = 0.9;

// Background layers parallax factors
const PARALLAX_REAR = 0.15;
const PARALLAX_FRONT = 0.35;

// Percentage of the viewport size used as a scroll padding around the player's starship
const SCROLL_AREA_PADDING = 15;

/**
 * Hosts the PixiJS application and runs the client-side game simulation.
 *
 * The game logic (physics, collisions, weapons) runs on the server-side Room Extension.
 * This class reproduces the server-side simulation to render smooth movement, compensating
 * for the client-server lag, and sends the player controls back to the Extension.
 *
 * It dispatches "rotate", "thrust" and "fire" events that the GameController forwards to the
 * server as Extension requests.
 */
export default class GameManager extends EventTarget
{
	static ROTATE_EVT = 'rotate';
	static THRUST_EVT = 'thrust';
	static FIRE_EVT = 'fire';

	constructor(sfs, htmlContainer)
	{
		super();

		this.sfs = sfs;
		this.htmlContainer = htmlContainer;
		this.inited = false;

		this.starships = new Map();
		this.weaponShots = new Map();
		this.myStarship = null;
		this.planetMass = 0;

		// Input state
		this.leftDown = false;
		this.rightDown = false;
		this.pressedKeys = new Set();
	}

	async init(level)
	{
		this.app = new Application();

		await this.app.init({
			background: '#05070f',
			resizeTo: this.htmlContainer,
			antialias: true
		});

		this.htmlContainer.appendChild(this.app.canvas);

		// Load the game spritesheet and the starfield textures
		this.sheet = await Assets.load('assets/images/spritesheet-game.json');
		const rearTex = await Assets.load('assets/images/starfield-rear.png');
		const frontTex = await Assets.load('assets/images/starfield-front.png');

		// Background (fixed to the viewport, scrolled through the tile position for a parallax effect)
		this.bgRear = new TilingSprite({ texture: rearTex, width: this.app.screen.width, height: this.app.screen.height });
		this.bgFront = new TilingSprite({ texture: frontTex, width: this.app.screen.width, height: this.app.screen.height });
		this.app.stage.addChild(this.bgRear);
		this.app.stage.addChild(this.bgFront);

		// World container (holds planet, starships and weapon shots)
		this.world = new Container();
		this.app.stage.addChild(this.world);

		this.inited = true;

		// Create the planet
		this.createPlanet(level);

		// Set up input and the simulation loop
		this.setupInput();

		this.boundUpdate = this.update.bind(this);
		this.app.ticker.add(this.boundUpdate);
	}

	destroy()
	{
		if (!this.inited)
			return;

		this.inited = false;

		this.removeInput();

		this.app.ticker.remove(this.boundUpdate);

		// Remove the canvas and destroy the display objects, but keep the textures in the
		// Assets cache so they can be reused if the player starts another game
		this.app.destroy({ removeView: true }, { children: true });

		this.starships.clear();
		this.weaponShots.clear();
		this.myStarship = null;
	}

	/**
	 * World scale (screen pixels per simulation unit), derived from the server-side Area of Interest so
	 * the visible area always stays inside it, whatever the (responsive) canvas size or aspect ratio.
	 *
	 * The player is kept within the central (100 - 2*SCROLL_AREA_PADDING)% of the viewport, so the
	 * farthest visible edge is up to REACH = (1 - padding) of a viewport dimension away from the ship.
	 * We require that reach, on each axis, to stay within AOI_VIEWPORT_FILL of the AoI half-extent:
	 *
	 *     (1 - padding) * (screenDim / scale)  <=  AOI_VIEWPORT_FILL * AOI_dim
	 *
	 * Solving for scale per axis and taking the larger value (the tighter axis) guarantees no proximity
	 * pop-in for any aspect ratio; the looser axis simply shows less than the full AoI. As a result the
	 * amount of world shown depends on the AoI and the aspect ratio, not on the canvas pixel size (which
	 * then only affects resolution).
	 */
	get scale()
	{
		const reach = (1 - SCROLL_AREA_PADDING / 100) / AOI_VIEWPORT_FILL;
		const sx = reach * this.app.screen.width / AOI_X;
		const sy = reach * this.app.screen.height / AOI_Y;
		return Math.max(sx, sy);
	}

	//----------------------------------------------------------
	// Object creation / removal
	//----------------------------------------------------------

	/**
	 * Create the planet at the center of the play field, based on the level configuration.
	 *
	 * The planet sprite is looked up by name in the spritesheet (planet_<name>), so new levels with
	 * different planets can be added just by adding the matching sprite, without changing this code.
	 * The mass (which makes the planet pull ships and weapon shots) is independent of the sprite: a
	 * level with no matching sprite simply shows no planet, and one with zero mass applies no gravity.
	 */
	createPlanet(level)
	{
		this.planetMass = level != null ? level.getNumber('mass') : 0;

		const name = level != null ? level.getString('name') : null;
		const texture = name != null ? this.sheet.textures[`planet_${name.toLowerCase()}`] : null;

		if (texture != null)
		{
			this.planet = new Sprite(texture);
			this.planet.anchor.set(0.5);
			this.planet.x = 0;
			this.planet.y = 0;
			this.world.addChild(this.planet);
		}
	}

	createStarship(userId, userName, isMine, type, settings)
	{
		if (!this.inited || this.starships.has(userId))
			return;

		const ship = new Starship(this.sheet, userId, userName, isMine, type, settings);
		this.starships.set(userId, ship);
		this.world.addChild(ship.container);

		if (isMine)
			this.myStarship = ship;
	}

	hasStarship(userId)
	{
		return this.starships.has(userId);
	}

	setStarshipPosition(userId, x, y, vx, vy, d, t, s, elapsed)
	{
		const ship = this.starships.get(userId);
		if (ship == null)
			return;

		ship.xx = x;
		ship.yy = y;
		ship.velocity.vx = vx;
		ship.velocity.vy = vy;
		ship.lastRenderTime = performance.now() - elapsed;

		ship.doThrust = t;
		ship.rotationRad = d;
		ship.shield = s;

		// Render immediately, simulating the elapsed time since the server sent the update
		this.renderStarship(ship, performance.now());
	}

	setStarshipRotating(userId, r)
	{
		const ship = this.starships.get(userId);
		if (ship != null)
			ship.rotatingDir = r;
	}

	removeStarship(userId, doExplode)
	{
		const ship = this.starships.get(userId);
		if (ship == null)
			return;

		this.starships.delete(userId);

		if (doExplode)
			this.explodeAt(ship.xx, ship.yy, false);

		this.world.removeChild(ship.container);
		ship.destroy();

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

	createWeaponShot(id, settings, x, y, vx, vy, elapsed)
	{
		if (!this.inited || this.weaponShots.has(id))
			return;

		const model = settings != null ? settings.getString('model') : null;

		const shot = new WeaponShot(this.sheet, id, model);
		this.weaponShots.set(id, shot);
		this.world.addChild(shot.container);

		shot.xx = x;
		shot.yy = y;
		shot.velocity.vx = vx;
		shot.velocity.vy = vy;
		shot.lastRenderTime = performance.now() - elapsed;
	}

	removeWeaponShot(id, doExplode, posX = null, posY = null)
	{
		const shot = this.weaponShots.get(id);
		if (shot == null)
			return;

		this.weaponShots.delete(id);

		if (doExplode)
			this.explodeAt(posX != null ? posX : shot.xx, posY != null ? posY : shot.yy, true);

		this.world.removeChild(shot.container);
		shot.destroy();
	}

	/**
	 * Play an explosion animation at the given simulation position.
	 */
	explodeAt(xx, yy, small)
	{
		const frames = small ? this.sheet.animations.explosion_small : this.sheet.animations.explosion;

		const explosion = new AnimatedSprite(frames);
		explosion.anchor.set(0.5);
		explosion.animationSpeed = 0.4;
		explosion.loop = false;
		explosion.x = xx;
		explosion.y = -yy;
		explosion.onComplete = () => {
			this.world.removeChild(explosion);
			explosion.destroy();
		};
		this.world.addChild(explosion);
		explosion.play();
	}

	//----------------------------------------------------------
	// Simulation loop
	//----------------------------------------------------------

	update()
	{
		if (!this.inited)
			return;

		// Keep the world scale and background size in sync with the (resizable) canvas
		this.world.scale.set(this.scale);
		this.bgRear.width = this.bgFront.width = this.app.screen.width;
		this.bgRear.height = this.bgFront.height = this.app.screen.height;

		const now = performance.now();

		for (const ship of this.starships.values())
			this.renderStarship(ship, now);

		for (const shot of this.weaponShots.values())
			this.renderWeaponShot(shot, now);

		this.updateCamera();
	}

	/**
	 * Advance a starship to its current position, integrating the applied forces
	 * one millisecond at a time since the last render.
	 */
	renderStarship(ship, now)
	{
		let elapsed = now - ship.lastRenderTime;

		// Discard a large backlog (e.g. the tab was inactive) to avoid a long catch-up
		if (elapsed > 1000)
		{
			ship.lastRenderTime = now - 1000;
			elapsed = 1000;
		}

		// Integrate one millisecond at a time; only whole milliseconds are consumed and the
		// sub-millisecond remainder is carried to the next frame, so the simulation advances at
		// real time regardless of the display refresh rate
		const steps = Math.floor(elapsed);

		for (let i = 0; i < steps; i++)
		{
			// Rotation
			ship.rotationRad += ship.rotatingDir * ship.rotationSpeed;

			// Thruster force
			if (ship.doThrust)
			{
				ship.velocity.vx += Math.cos(ship.rotationRad) * ship.thrustAcceleration;
				ship.velocity.vy += Math.sin(ship.rotationRad) * ship.thrustAcceleration;
			}

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

			// Planet gravitational attraction
			this.applyGravityForce(ship);

			// Update position
			ship.xx += ship.velocity.vx;
			ship.yy += ship.velocity.vy;
		}

		ship.lastRenderTime += steps;
		ship.render();
	}

	/**
	 * Advance a weapon shot to its current position.
	 */
	renderWeaponShot(shot, now)
	{
		let elapsed = now - shot.lastRenderTime;

		// Discard a large backlog (e.g. the tab was inactive) to avoid a long catch-up
		if (elapsed > 1000)
		{
			shot.lastRenderTime = now - 1000;
			elapsed = 1000;
		}

		// Consume whole milliseconds only, carrying the remainder (see renderStarship)
		const steps = Math.floor(elapsed);

		for (let i = 0; i < steps; i++)
		{
			this.applyGravityForce(shot);

			shot.xx += shot.velocity.vx;
			shot.yy += shot.velocity.vy;
		}

		shot.lastRenderTime += steps;
		shot.render();
	}

	applyGravityForce(item)
	{
		if (this.planetMass > 0)
		{
			const dist2 = item.xx * item.xx + item.yy * item.yy;

			if (dist2 > 0)
			{
				const angle = Math.atan2(item.yy, item.xx);
				const attr = this.planetMass / dist2;

				item.velocity.vx -= Math.cos(angle) * attr;
				item.velocity.vy -= Math.sin(angle) * attr;
			}
		}
	}

	/**
	 * Move the world so that the player's starship stays inside a padded central area,
	 * and scroll the background layers for a parallax effect.
	 */
	updateCamera()
	{
		const S = this.scale;
		const cx = this.app.screen.width / 2;
		const cy = this.app.screen.height / 2;

		if (this.myStarship != null)
		{
			// Target world position that keeps the ship centered
			const targetX = cx - this.myStarship.xx * S;
			const targetY = cy + this.myStarship.yy * S;

			// Center the camera on the ship the first time it appears
			if (!this.cameraReady)
			{
				this.world.x = targetX;
				this.world.y = targetY;
				this.cameraReady = true;
			}

			// Dead zone: only scroll when the ship gets close to the viewport borders
			const padX = (SCROLL_AREA_PADDING / 100) * this.app.screen.width;
			const padY = (SCROLL_AREA_PADDING / 100) * this.app.screen.height;

			if (targetX < this.world.x - (cx - padX)) this.world.x = targetX + (cx - padX);
			else if (targetX > this.world.x + (cx - padX)) this.world.x = targetX - (cx - padX);

			if (targetY < this.world.y - (cy - padY)) this.world.y = targetY + (cy - padY);
			else if (targetY > this.world.y + (cy - padY)) this.world.y = targetY - (cy - padY);
		}
		else if (!this.cameraReady)
		{
			// No local starship yet: center the world origin (planet) in the viewport
			this.world.x = cx;
			this.world.y = cy;
		}
		// else: the local starship has been removed (game over); keep the last camera position

		// Parallax background
		this.bgRear.tilePosition.set(this.world.x * PARALLAX_REAR, this.world.y * PARALLAX_REAR);
		this.bgFront.tilePosition.set(this.world.x * PARALLAX_FRONT, this.world.y * PARALLAX_FRONT);
	}

	//----------------------------------------------------------
	// Input
	//----------------------------------------------------------

	setupInput()
	{
		this.boundKeyDown = this.onKeyDown.bind(this);
		this.boundKeyUp = this.onKeyUp.bind(this);
		window.addEventListener('keydown', this.boundKeyDown);
		window.addEventListener('keyup', this.boundKeyUp);
	}

	removeInput()
	{
		window.removeEventListener('keydown', this.boundKeyDown);
		window.removeEventListener('keyup', this.boundKeyUp);
	}

	onKeyDown(e)
	{
		// Ignore auto-repeat
		if (this.pressedKeys.has(e.code))
			return;
		this.pressedKeys.add(e.code);

		switch (e.code)
		{
			case 'ArrowLeft':
			case 'KeyA':
				this.leftDown = true;
				this.applyRotation();
				break;

			case 'ArrowRight':
			case 'KeyD':
				this.rightDown = true;
				this.applyRotation();
				break;

			case 'ArrowUp':
			case 'KeyW':
				this.onThrust(true);
				break;

			case 'Space':
				this.onFire(1);
				e.preventDefault();
				break;

			case 'KeyC':
				this.onFire(2);
				break;
		}
	}

	onKeyUp(e)
	{
		this.pressedKeys.delete(e.code);

		switch (e.code)
		{
			case 'ArrowLeft':
			case 'KeyA':
				this.leftDown = false;
				this.applyRotation();
				break;

			case 'ArrowRight':
			case 'KeyD':
				this.rightDown = false;
				this.applyRotation();
				break;

			case 'ArrowUp':
			case 'KeyW':
				this.onThrust(false);
				break;
		}
	}

	applyRotation()
	{
		// Left rotates counterclockwise (+1), right rotates clockwise (-1)
		const dir = (this.leftDown ? 1 : 0) + (this.rightDown ? -1 : 0);
		this.onMove(dir);
	}

	onMove(dir)
	{
		if (this.myStarship == null || dir === this.myStarship.rotatingDir)
			return;

		// Apply locally for immediate feedback
		this.setStarshipRotating(this.myStarship.userId, dir);

		// Notify the controller to send the request to the server
		this.dispatchEvent(new CustomEvent(GameManager.ROTATE_EVT, { detail: { dir } }));
	}

	onThrust(activate)
	{
		if (this.myStarship == null)
			return;

		// Show a small flame until the server confirms the new thruster state: on activation the thrust
		// force is not applied yet, on deactivation it is still being applied
		this.myStarship.thrusterValue = 1;

		this.dispatchEvent(new CustomEvent(GameManager.THRUST_EVT, { detail: { activate } }));
	}

	onFire(type)
	{
		if (this.myStarship == null)
			return;

		this.dispatchEvent(new CustomEvent(GameManager.FIRE_EVT, { detail: { fire: type } }));
	}
}
