/**
 * 
 * @source http://tilde.club/~chmod777/ts/pong.ts
 * 
 * @license AGPL-3.0-only
 * @licstart
 * Copyright (c) 2021 chmod777
 * 
 * This program is free software: you can redistribute it and/or modify it under
 * the terms of the GNU Affero General Public License as published by the Free
 * Software Foundation, either version 3 of the License.
 * 
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
 * details.
 * 
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 * @licend
 */

class Vec2 {
	constructor(public x: number, public y: number) {}

	static zero(): Vec2 {
		return new Vec2(0, 0);
	}
	static one(): Vec2 {
		return new Vec2(1, 1);
	}

	public clone(): Vec2 {
		return new Vec2(this.x, this.y);
	}

	public add(other: Vec2): Vec2 {
		return new Vec2(this.x + other.x, this.y + other.y);
	}
	public sub(other: Vec2): Vec2 {
		return new Vec2(this.x - other.x, this.y - other.y);
	}

	public mul(s: number): Vec2 {
		return new Vec2(this.x * s, this.y * s);
	}
	public div(s: number): Vec2 {
		return new Vec2(this.x / s, this.y / s);
	}

	public cross(other: Vec2): number {
		return this.x * other.y - this.y * other.x;
	}
	public dot(other: Vec2): number {
		return this.x * other.y + this.y * other.y;
	}

	public magnitude2(): number {
		return this.x * this.x + this.y * this.y;
	}
	public magnitude(): number {
		return Math.sqrt(this.magnitude2());
	}

	public asUnit(): Vec2 {
		return this.div(this.magnitude());
	}

	static numberAsString(n: number): string {
		if (n != Math.floor(n)) {
			n.toFixed(2);
		}
		return n.toString();
	}
	public asString(): string {
		return `${Vec2.numberAsString(this.x)},${Vec2.numberAsString(this.y)} `;
	}
	public asAbsolute(): string {
		return `M${this.asString()}`;
	}
	public asLine(): string {
		return `L${this.asString()}`;
	}
}
class Line {
	constructor(public p1: Vec2, public p2: Vec2) {}
	public lineLineIntersect(l2: Line): null | Vec2 {
		const p = this.p1;
		const r = this.p2.sub(p);
	
		const q = l2.p1;
		const s = l2.p2.sub(q);
	
		const r_cross_s = r.cross(s);
		const q_minus_p = q.sub(p);
	
		if (r_cross_s === 0) {
			return null;
			// let q_minus_p_cross_r = q_minus_p.cross(r);
			// if (q_minus_p_cross_r === 0)
			//     return null; // collinear
			// else
			//     return null; // parallel
		} else {
			const t = q_minus_p.cross(s.div(r_cross_s));
			const u = q_minus_p.cross(r.div(r_cross_s));
			if (0 <= t && t <= 1 && 0 <= u && u <= 1) {
				if (t === 0)
					return q.add(s.mul(u));
				else
					return p.add(r.mul(t));
			}
			else {
				return null; // divergent
			}
		}
	}
}
class PongElements {
	public playerPaddle: SVGRectElement;
	public aiPaddle: SVGRectElement;

	public playerScoreElement: SVGTextElement;
	public aiScoreElement: SVGTextElement;

	public ball: SVGCircleElement;
	public ballAnimation: SVGAnimateMotionElement;
	public ballPath: SVGPathElement;
	public collisionPath: SVGPathElement;

	public resetButton: HTMLInputElement;
	public serveButton: HTMLInputElement;
	public upButton: HTMLInputElement;
	public stopButton: HTMLInputElement;
	public downButton: HTMLInputElement;

	public fps: HTMLSpanElement;

	constructor(svgContent: Document) {
		this.playerPaddle = getSVGRectElement(svgContent, 'pong-player-paddle');
		this.aiPaddle = getSVGRectElement(svgContent, 'pong-ai-paddle');
		
		this.playerScoreElement = getSVGTextElement(svgContent, 'pong-player-score');
		this.aiScoreElement = getSVGTextElement(svgContent, 'pong-ai-score');
		
		this.ball = getSVGCircleElement(svgContent, 'pong-ball');
		this.ballAnimation = getSVGAnimateMotionElement(svgContent, 'pong-ball-animation');
		this.ballPath = getSVGPathElement(svgContent, 'pong-ball-path');
		this.collisionPath = getSVGPathElement(svgContent, 'pong-collision-path');
		
		this.resetButton = getHTMLInput('pong-reset');
		this.serveButton = getHTMLInput('pong-serve');
		this.upButton = getHTMLInput('pong-paddle-up');
		this.stopButton = getHTMLInput('pong-paddle-stop');
		this.downButton = getHTMLInput('pong-paddle-down');
		
		this.fps = getHTMLSpan('fps');

		const WIDTH_STRING = Pong.PADDLE_WIDTH.toString();
		this.aiPaddle.setAttribute('width', WIDTH_STRING);
		this.playerPaddle.setAttribute('width', WIDTH_STRING);

		const BALL_RADIUS_STRING = Pong.BALL_RADIUS.toString();
		this.ball.setAttribute('r', BALL_RADIUS_STRING);

		this.resetPaddles();
		this.resetBall();
	}
	public resetBall() {
		this.ballPath.setAttribute('d', Pong.ABSOLUTE_CENTER);
		this.collisionPath.setAttribute('d', Pong.ABSOLUTE_CENTER);
		this.ballAnimation.beginElement();
	}
	public resetPaddles() {
		translateToPosition(this.playerPaddle, Pong.PLAYER_STARTING_POSITION);
		translateToPosition(this.aiPaddle, Pong.AI_STARTING_POSITION);
	}
	public resetScores() {
		this.playerScoreElement.innerHTML = '0';
		this.aiScoreElement.innerHTML = '0';
	}
}
class PongState {
	public lastAnimationFrame = 0;

	public playerPosition = Pong.PLAYER_STARTING_POSITION.clone();
	public aiPosition = Pong.AI_STARTING_POSITION.clone();

	public ballSpeed = Pong.BALL_BASE_SPEED;
	public ballVelocity = Vec2.zero();

	public running = false;
	public shouldServe = false;
	public playerServe = false;

	public time = 0;
	public lastTime = 0;
	public serveTime = 0;
	public dt_ms = 0;
	public dt = 0;

	public playerScore = 0;
	public aiScore = 0;

	public scoreDuration = 0;
	public collisionDuration = 0;
	public collisionPoint = Vec2.zero();

	public moveUp = false;
	public moveDown = false;

	get timeSinceServe(): number { return (this.time - this.serveTime) / 1000; }

	public update_dt(time: number) {
		this.lastTime = this.time;
		this.time = time;
		this.dt_ms = this.time - this.lastTime;
		this.dt = this.dt_ms / 1000;
	}
	public resetBall() {
		this.ballSpeed = Pong.BALL_BASE_SPEED;
		this.ballVelocity = Vec2.zero();
	}
	public resetPaddles() {
		this.playerPosition = Pong.PLAYER_STARTING_POSITION.clone();
		this.aiPosition = Pong.AI_STARTING_POSITION.clone();
		this.moveUp = false;
		this.moveDown = false;
	}
}
class Pong {
	/*
	  x          w
	y +---------------------+
	  |          |          |
	h |          0          |
	  |          |          |
	  +---------------------+
	*/
	static readonly WIDTH = 512;
	static readonly HEIGHT = 256;
	static readonly HALF_WIDTH = Pong.WIDTH / 2;
	static readonly HALF_HEIGHT = Pong.HEIGHT / 2;
	static readonly CENTER = new Vec2(Pong.HALF_WIDTH, Pong.HALF_HEIGHT);
	static readonly ABSOLUTE_CENTER =  Pong.CENTER.asAbsolute();

	static readonly PADDLE_WIDTH = 4;
	static readonly PADDLE_HEIGHT = 28;
	static readonly PADDLE_HALF_WIDTH = Pong.PADDLE_WIDTH / 2;
	static readonly PADDLE_HALF_HEIGHT = Pong.PADDLE_HEIGHT / 2;
	static readonly PADDLE_MAX_POSITION = Pong.HEIGHT - Pong.PADDLE_HEIGHT;
	static readonly PADDLE_VELOCITY = 150;

	static readonly BALL_RADIUS = 5;
	static readonly BALL_SPEED_INCREASE = 10;
	static readonly BALL_BASE_SPEED = 200;

	static readonly PADDLE_STARTING_POSITION_Y = Pong.CENTER.y - Pong.PADDLE_HALF_HEIGHT;
	static readonly PLAYER_STARTING_POSITION = new Vec2(0, Pong.PADDLE_STARTING_POSITION_Y);
	static readonly AI_STARTING_POSITION = new Vec2(Pong.WIDTH - Pong.PADDLE_WIDTH, Pong.PADDLE_STARTING_POSITION_Y);

	static readonly RAW_CORNERS: Array<Vec2> = [
		// top left
		new Vec2(0, 0+Pong.BALL_RADIUS),
		// top right
		new Vec2(Pong.WIDTH, 0+Pong.BALL_RADIUS),
		// bottom right
		new Vec2(Pong.WIDTH, Pong.HEIGHT-Pong.BALL_RADIUS),
		// bottom left
		new Vec2(0, Pong.HEIGHT-Pong.BALL_RADIUS)
	];
	static readonly BOARD_CORNERS: Array<Vec2> = [
		// top left
		new Vec2(
			Pong.RAW_CORNERS[0].x-Pong.BALL_RADIUS,
			Pong.RAW_CORNERS[0].y),
		// top right
		new Vec2(
			Pong.RAW_CORNERS[1].x+Pong.BALL_RADIUS,
			Pong.RAW_CORNERS[1].y),
		// bottom right
		new Vec2(
			Pong.RAW_CORNERS[2].x+Pong.BALL_RADIUS,
			Pong.RAW_CORNERS[2].y),
		// bottom left
		new Vec2(
			Pong.RAW_CORNERS[3].x-Pong.BALL_RADIUS,
			Pong.RAW_CORNERS[3].y)
	];
	static readonly PADDLE_COLLISION_CORNERS: Array<Vec2> = [
		// top left
		new Vec2(
			Pong.RAW_CORNERS[0].x+Pong.PADDLE_WIDTH+Pong.BALL_RADIUS,
			Pong.RAW_CORNERS[0].y),
		// top right
		new Vec2(
			(Pong.RAW_CORNERS[1].x-Pong.PADDLE_WIDTH)-Pong.BALL_RADIUS,
			Pong.RAW_CORNERS[1].y),
		// bottom right
		new Vec2(
			(Pong.RAW_CORNERS[2].x-Pong.PADDLE_WIDTH)-Pong.BALL_RADIUS,
			Pong.RAW_CORNERS[2].y),
		// bottom left
		new Vec2(
			Pong.RAW_CORNERS[3].x+Pong.PADDLE_WIDTH+Pong.BALL_RADIUS,
			Pong.RAW_CORNERS[3].y)
	];

	static readonly BOARD_BOUNDS: Array<[Line, boolean]> = [
		// top
		[new Line(Pong.BOARD_CORNERS[0], Pong.BOARD_CORNERS[1]), false],
		// left
		[new Line(Pong.BOARD_CORNERS[0], Pong.BOARD_CORNERS[3]), true],
		// bottom
		[new Line(Pong.BOARD_CORNERS[3], Pong.BOARD_CORNERS[2]), false],
		// right
		[new Line(Pong.BOARD_CORNERS[1], Pong.BOARD_CORNERS[2]), true]
	];
	static readonly PADDLE_COLLISION_BOUNDS: Array<[Line, boolean]> = [
		// top
		[new Line(Pong.PADDLE_COLLISION_CORNERS[0], Pong.PADDLE_COLLISION_CORNERS[1]), false],
		// left
		[new Line(Pong.PADDLE_COLLISION_CORNERS[0], Pong.PADDLE_COLLISION_CORNERS[3]), true],
		// bottom
		[new Line(Pong.PADDLE_COLLISION_CORNERS[3], Pong.PADDLE_COLLISION_CORNERS[2]), false],
		// right
		[new Line(Pong.PADDLE_COLLISION_CORNERS[1], Pong.PADDLE_COLLISION_CORNERS[2]), true]
	];

	private elements: PongElements;
	private state = new PongState();

	constructor(svgContent: Document) {
		this.elements = new PongElements(svgContent);
		this.elements.resetButton.addEventListener('click', () => { this.reset(); });
		this.elements.serveButton.addEventListener('click', () => {
			if (!this.state.running) {
				this.state.running = true;
				this.state.shouldServe = true;
			}
		});
		this.elements.upButton.addEventListener('click', () => {
			this.state.moveUp = true;
			this.state.moveDown = false;
		 });
		this.elements.downButton.addEventListener('click', () => {
			this.state.moveUp = false;
			this.state.moveDown = true;
		});
		this.elements.stopButton.addEventListener('click', () => {
			this.state.moveUp = false;
			this.state.moveDown = false;
		});
		document.addEventListener('keydown', (event) => { this.handleKeydown(event); });
		document.addEventListener('keyup', (event) => { this.handleKeyup(event); });

		this.state.lastAnimationFrame = window.requestAnimationFrame((timestamp) => {
			this.update(timestamp);
		});
	}

	private serve() {
		this.state.shouldServe = false;
		this.state.serveTime = this.state.time;
		this.state.ballVelocity.x = genRandom();
		this.state.ballVelocity.y = genRandom();
		this.state.ballVelocity = this.state.ballVelocity.asUnit().mul(this.state.ballSpeed);
		if (this.state.ballVelocity.x > 0) {
			this.state.playerServe = true;
		}
		this.buildBallPaths(Pong.CENTER);
	}
	public reset() {
		this.state = new PongState();

		window.cancelAnimationFrame(this.state.lastAnimationFrame);
		
		this.updatePaddles();
		this.elements.resetScores();
		this.elements.resetBall();
	}
	private resetBall() {
		this.state.resetBall();
		this.elements.resetBall();
	}
	private resetPaddles() {
		this.state.resetPaddles();
		this.updatePaddles();
	}
	private updatePaddles() {
		translateToPosition(this.elements.playerPaddle, this.state.playerPosition);
		translateToPosition(this.elements.aiPaddle, this.state.aiPosition);
	}

	private update(timestamp: number) {
		this.state.update_dt(timestamp);

		const fps = 1 / this.state.dt;
		this.elements.fps.innerHTML = fps.toString();

		if (this.state.shouldServe) {
			this.serve();
		}

		this.updatePlayer();
		if (this.state.running) {
			this.updateAI(this.elements.aiPaddle, this.state.aiPosition);
			this.processScore();
			if (this.state.running) {
				this.processHit();
			}
		}

		this.state.lastAnimationFrame = window.requestAnimationFrame((timestamp) => {
			this.update(timestamp);
		});
	}
	private isColliding(paddle: Vec2): boolean {
		return paddle.y + Pong.PADDLE_HEIGHT > this.state.collisionPoint.y &&
			paddle.y < this.state.collisionPoint.y;
	}
	private processScore() {
		if (this.state.timeSinceServe > this.state.scoreDuration) {
			if (this.state.playerServe) {
				this.state.playerScore++;
				this.elements.playerScoreElement.innerHTML = this.state.playerScore.toString();
			} else {
				this.state.aiScore++;
				this.elements.aiScoreElement.innerHTML = this.state.aiScore.toString();
			}
			this.state.running = false;
			this.resetBall();
			this.resetPaddles();
		}
	}
	private processHit() {
		if (this.state.timeSinceServe > this.state.collisionDuration) {
			let position: Vec2 = this.state.playerPosition;
			let collisionOffset: number = 0.1;
			if (this.state.playerServe) {
				position = this.state.aiPosition;
				collisionOffset = -collisionOffset;
			}
			if (this.isColliding(position)) {
				this.state.ballVelocity.x = -this.state.ballVelocity.x;
				this.state.ballSpeed += Pong.BALL_SPEED_INCREASE;
		
				this.state.serveTime = this.state.time;
				this.state.playerServe = !this.state.playerServe;

				this.state.collisionPoint.x += collisionOffset;
				this.buildBallPaths(this.state.collisionPoint);
			}
		}
	}
	private clampPaddlePosition(paddlePosition: Vec2) {
		paddlePosition.y = Math.max(Math.min(paddlePosition.y, Pong.PADDLE_MAX_POSITION), 0);
	}
	private updatePlayer() {
		if (this.state.moveUp && this.state.moveDown
			|| !(this.state.moveUp || this.state.moveDown))
		{
			return;
		}

		const dy = this.state.dt * Pong.PADDLE_VELOCITY;
		if (this.state.moveDown) {
			this.state.playerPosition.y += dy;
		} else {
			this.state.playerPosition.y -= dy;
		}
		this.clampPaddlePosition(this.state.playerPosition);
		translateToPosition(this.elements.playerPaddle, this.state.playerPosition);
	}
	private updateAI(paddle: SVGRectElement, position: Vec2) {
		let targetPosition: number;
		if (this.state.playerServe) {
			targetPosition = this.state.collisionPoint.y;
		} else {
			targetPosition = Pong.HALF_HEIGHT;
		}
		targetPosition -= Pong.PADDLE_HALF_HEIGHT;

		const targetRange = Pong.PADDLE_HALF_HEIGHT / 2;
		const dy = this.state.dt * Pong.PADDLE_VELOCITY;

		let shouldMove = false;
		if (position.y > targetPosition + targetRange) {
			shouldMove = true;
			position.y -= dy;
		} else if (position.y < targetPosition - targetRange) {
			shouldMove = true;
			position.y += dy;
		}
		if (shouldMove) {
			this.clampPaddlePosition(position);
			translateToPosition(paddle, position);
		}
	}
	private buildBallPath(startingPosition: Vec2, dir: Vec2, bounds: [Line, boolean][]): Vec2[] {
		const path = new Line(startingPosition, startingPosition.add(dir));
		const points: Array<Vec2> = [];
		while (true) {
			const maybeIntersect = this.findNextCollision(path, bounds);
			if (maybeIntersect !== null) {
				const [point, isEnd] = maybeIntersect;
				if (isEnd) {
					if (point.x > Pong.HALF_WIDTH)
						point.x += 0.01;
					else
						point.x -= 0.01;
				} else {
					if (point.y > Pong.HALF_HEIGHT)
						point.y -= 0.01;
					else
						point.y += 0.01;
				}            
				points.push(point);
				
				if (isEnd) {
					break;
				}
				
				dir.y = -dir.y;
				path.p1 = point;
				path.p2 = point.add(dir);
			} else {
				console.error("intersect is null")
				break;
			}
		}
		return points;
	}
	private buildBallPaths(startingPosition: Vec2) {
		const dir = this.state.ballVelocity.mul(1000);
		const animationPoints = this.buildBallPath(startingPosition, dir.clone(), Pong.BOARD_BOUNDS);
		const collisionPoints = this.buildBallPath(startingPosition, dir, Pong.PADDLE_COLLISION_BOUNDS);
		this.state.collisionPoint = collisionPoints[collisionPoints.length - 1].clone();
		this.state.ballVelocity = dir.div(1000);

		const animationPath = this.buildSVGPathFromPoints(startingPosition, animationPoints);
		this.elements.ballPath.setAttribute('d', animationPath);
		this.state.scoreDuration = this.elements.ballPath.getTotalLength() / this.state.ballSpeed;
		this.elements.ballAnimation.setAttribute('dur', this.state.scoreDuration.toString());

		const collisionPath = this.buildSVGPathFromPoints(startingPosition, collisionPoints);
		this.elements.collisionPath.setAttribute('d', collisionPath);
		this.state.collisionDuration = this.elements.collisionPath.getTotalLength() / this.state.ballSpeed;

		this.elements.ballAnimation.beginElement();
	}
	private buildSVGPathFromPoints(start: Vec2, points: Array<Vec2>): string {
		let directions = start.asAbsolute();
		for (const point of points) {
			directions = directions.concat(point.asLine());
		}
		return directions;
	}
	private findNextCollision(path: Line, bounds: [Line, boolean][]): null | [Vec2, boolean] {
		for (const [line, isEnd] of bounds) {
			const maybeIntersect = path.lineLineIntersect(line);
			if (maybeIntersect !== null) {
				return [maybeIntersect, isEnd];
			}
		}
		return null;
	}
	private handleKeydown(event: KeyboardEvent) {
		const key = event.key;
		if (key === ',') {
			this.state.moveUp = true;
		} else if (key === '.') {
			this.state.moveDown = true;
		} else if (key === 's') {
			if (!this.state.running) {
				this.state.running = true;
				this.state.shouldServe = true;
			}
		} else if (key === 'n') {
			this.reset();
		}
	}
	private handleKeyup(event: KeyboardEvent) {
		const key = event.key;
		if (key === ',') {
			this.state.moveUp = false;
		}
		else if (key === '.') {
			this.state.moveDown = false;
		}
	}
}

// generate random number in range(-1, 1)
function genRandom() {
	return (Math.random() - 0.5) * 2;
}
function translateToPosition(element: SVGElement, position: Vec2) {
	translateTo(element, position.x, position.y);
}
function translateTo(element: SVGElement, x: number, y: number) {
	element.setAttribute('transform', `translate(${x}, ${y})`);
}
