/**
 * Scavengers Games Plugin
 * Pokemon Showdown - http://pokemonshowdown.com/
 *
 * This plugin stores the different possible game modes and twists that take place in scavengers room
 *
 * @license MIT license
 */

import {ScavengerHunt, ScavengerHuntPlayer} from './scavengers';
import {Utils} from '../../lib';

export type TwistEvent = (this: ScavengerHunt, ...args: any[]) => void;
interface Twist {
	name: string;
	id: string;
	isGameMode?: true;
	desc?: string;
	[eventid: string]: string | number | TwistEvent | boolean | undefined;
}

type GameModeFunction = (this: ScavengerGameTemplate, ...args: any[]) => void;
interface GameMode {
	name: string;
	id: string;
	mod: Twist;
	round?: number;
	leaderboard?: true;
	teamAnnounce?: GameModeFunction;
	getPlayerTeam?: GameModeFunction;
	advanceTeam?: GameModeFunction;
	[k: string]: any;
}

function toSeconds(time: string) {
	// hhmmss => ss
	const parts = time.split(':').reverse();
	return parts.map((value, index) => parseInt(value) * Math.pow(60, index)).reduce((a, b) => a + b);
}

class Leaderboard {
	data: {[userid: string]: AnyObject};

	constructor() {
		this.data = {};
	}

	addPoints(name: string, aspect: string, points: number, noUpdate?: boolean) {
		const userid: string = toID(name);

		if (!userid || userid === 'constructor' || !points) return this;
		if (!this.data[userid]) this.data[userid] = {name: name};

		if (!this.data[userid][aspect]) this.data[userid][aspect] = 0;
		this.data[userid][aspect] += points;

		if (!noUpdate) this.data[userid].name = name; // always keep the last used name

		return this; // allow chaining
	}

	visualize(sortBy: string): Promise<({rank: number} & AnyObject)[]>;
	visualize(sortBy: string, userid: string): Promise<({rank: number} & AnyObject) | undefined>;
	visualize(sortBy: string, userid?: string) {
		// FIXME: this is not how promises work
		// return a promise for async sorting - make this less exploitable
		return new Promise((resolve, reject) => {
			let lowestScore = Infinity;
			let lastPlacement = 1;

			const ladder = Utils.sortBy(
				Object.entries(this.data).filter(([u, bit]) => sortBy in bit),
				([u, bit]) => -bit[sortBy]
			).map(([u, bit], i) => {
				if (bit[sortBy] !== lowestScore) {
					lowestScore = bit[sortBy];
					lastPlacement = i + 1;
				}
				return {
					rank: lastPlacement,
					...bit,
				} as {rank: number} & AnyObject;
			}); // identify ties
			if (userid) {
				const rank = ladder.find(entry => toID(entry.name) === userid);
				resolve(rank);
			} else {
				resolve(ladder);
			}
		});
	}

	async htmlLadder(): Promise<string> {
		const data = await this.visualize('points');
		const display = `<div class="ladder" style="overflow-y: scroll; max-height: 170px;"><table style="width: 100%"><tr><th>Rank</th><th>Name</th><th>Points</th></tr>${data.map(line =>
			`<tr><td>${line.rank}</td><td>${line.name}</td><td>${line.points}</td></tr>`).join('')
		}</table></div>`;
		return display;
	}
}

const TWISTS: {[k: string]: Twist} = {
	perfectscore: {
		name: 'Perfect Score',
		id: 'perfectscore',
		desc: "Players who finish the hunt without submitting a single wrong answer get a shoutout!",

		onLeave(player) {
			if (!this.leftGame) this.leftGame = [];
			this.leftGame.push(player.id);
		},

		onSubmitPriority: 1,
		onSubmit(player, value) {
			const currentQuestion = player.currentQuestion;

			if (!player.answers) player.answers = {};
			if (!player.answers[currentQuestion]) player.answers[currentQuestion] = [];

			if (player.answers[currentQuestion].includes(value)) return;

			player.answers[currentQuestion].push(value);
		},

		onComplete(player, time, blitz) {
			const isPerfect = !this.leftGame?.includes(player.id) &&
				Object.values(player.answers).every((attempts: any) => attempts.length <= 1);
			return {name: player.name, time, blitz, isPerfect};
		},

		onAfterEndPriority: 1,
		onAfterEnd() {
			const perfect = this.completed.filter(entry => entry.isPerfect).map(entry => entry.name);
			if (perfect.length) {
				this.announce(Utils.html`${Chat.toListString(perfect)} ${perfect.length > 1 ? 'have' : 'has'} completed the hunt without a single wrong answer!`);
			}
		},
	},

	bonusround: {
		name: 'Bonus Round',
		id: 'bonusround',
		desc: "Players can choose whether or not they choose to complete the 4th question.",

		onAfterLoad() {
			if (this.questions.length === 3) {
				this.announce('This twist requires at least four questions.  Please reset the hunt and make it again.');
				this.huntLocked = true;
			} else {
				this.questions[this.questions.length - 1].hint += ' (You may choose to skip this question using ``/scavenge skip``.)';
			}
		},

		onAnySubmit(player) {
			if (this.huntLocked) {
				player.sendRoom('The hunt was not set up correctly.  Please wait for the host to reset the hunt and create a new one.');
				return true;
			}
		},

		onSubmitPriority: 1,
		onSubmit(player, value) {
			const currentQuestion = player.currentQuestion;

			if (value === 'skip' && currentQuestion + 1 === this.questions.length) {
				player.sendRoom('You have opted to skip the current question.');
				player.skippedQuestion = true;
				this.onComplete(player);
				return true;
			}
		},

		onComplete(player, time, blitz) {
			const noSkip = !player.skippedQuestion;
			return {name: player.name, time, blitz, noSkip};
		},

		onAfterEndPriority: 1,
		onAfterEnd() {
			const noSkip = this.completed.filter(entry => entry.noSkip).map(entry => entry.name);
			if (noSkip.length) {
				this.announce(Utils.html`${Chat.toListString(noSkip)} ${noSkip.length > 1 ? 'have' : 'has'} completed the hunt without skipping the last question!`);
			}
		},
	},

	incognito: {
		name: 'Incognito',
		id: 'incognito',
		desc: "Upon answering the last question correctly, the player's finishing time will not be announced in the room!  Results will only be known at the end of the hunt.",

		onCorrectAnswer(player, value) {
			if (player.currentQuestion + 1 >= this.questions.length) {
				this.runEvent('PreComplete', player);

				player.sendRoom(`Congratulations! You have gotten the correct answer.`);
				player.sendRoom(`This is a special style where finishes aren't announced! To see your placement, wait for the hunt to end. Until then, it's your secret that you finished!`);
				return false;
			}
		},

		onPreComplete(player) {
			const now = Date.now();
			const time = Chat.toDurationString(now - this.startTime, {hhmmss: true});
			const canBlitz = this.completed.length < 3;

			const blitz = now - this.startTime <= 60000 && canBlitz &&
				(this.room.settings.scavSettings?.blitzPoints?.[this.gameType] || this.gameType === 'official');

			const result = this.runEvent('Complete', player, time, blitz) || {name: player.name, time, blitz};

			this.preCompleted = this.preCompleted ? [...this.preCompleted, result] : [result];
			player.completed = true;
			player.destroy();
		},

		onEnd() {
			this.completed = this.preCompleted || [];
		},
	},

	spamfilter: {
		name: 'Spam Filter',
		id: 'spamfilter',

		desc: 'Every wrong answer adds 30 seconds to your final time!',

		onIncorrectAnswer(player: ScavengerHuntPlayer, value: string) {
			if (!player.incorrect) player.incorrect = [];
			const id = `${player.currentQuestion}-${value}`;
			if (player.incorrect.includes(id)) return;

			player.incorrect.push(id);
		},

		onComplete(player, time, blitz) {
			const seconds = toSeconds(time);
			if (!player.incorrect) return {name: player.name, total: seconds, blitz, time, original_time: time};

			const total = seconds + (30 * player.incorrect.length);
			const finalTime = Chat.toDurationString(total * 1000, {hhmmss: true});
			if (total > 60) blitz = false;

			return {name: player.name, total, blitz, time: finalTime, original_time: time};
		},

		onConfirmCompletion(player, time, blitz, place, result) {
			blitz = result.blitz;
			time = result.time;
			const deductionMessage = player.incorrect?.length ?
				Chat.count(player.incorrect, 'incorrect guesses', 'incorrect guess') :
				"Perfect!";
			return `<em>${Utils.escapeHTML(player.name)}</em> has finished the hunt! (Final Time: ${time} - ${deductionMessage}${(blitz ? " - BLITZ" : "")})`;
		},

		onEnd() {
			Utils.sortBy(this.completed, entry => entry.total);
		},
	},

	blindincognito: {
		name: 'Blind Incognito',
		id: 'blindincognito',
		desc: "Upon completing the last question, neither you nor other players will know if the last question is correct!  You may be in for a nasty surprise when the hunt ends!",

		onAnySubmit(player, value) {
			if (player.precompleted) {
				player.sendRoom(`That may or may not be the right answer - if you aren't confident, you can try again!`);
				return true;
			}
		},

		onCorrectAnswer(player, value) {
			if (player.currentQuestion + 1 >= this.questions.length) {
				this.runEvent('PreComplete', player);

				player.sendRoom(`That may or may not be the right answer - if you aren't confident, you can try again!`);
				return true;
			}
		},

		onIncorrectAnswer(player, value) {
			if (player.currentQuestion + 1 >= this.questions.length) {
				player.sendRoom(`That may or may not be the right answer - if you aren't confident, you can try again!`);
				return false;
			}
		},

		onPreComplete(player) {
			const now = Date.now();
			const time = Chat.toDurationString(now - this.startTime, {hhmmss: true});
			const canBlitz = this.completed.length < 3;

			const blitz = now - this.startTime <= 60000 && canBlitz &&
				(this.room.settings.scavSettings?.blitzPoints?.[this.gameType] || this.gameType === 'official');

			const result = this.runEvent('Complete', player, time, blitz) || {name: player.name, time, blitz};

			this.preCompleted = this.preCompleted ? [...this.preCompleted, result] : [result];
			player.precompleted = true;
		},

		onEnd() {
			this.completed = this.preCompleted || [];
		},
	},
};

const MODES: {[k: string]: GameMode | string} = {
	ko: 'kogames',
	kogames: {
		name: 'KO Games',
		id: 'kogames',

		mod: {
			name: 'KO Games',
			id: 'KO Games',
			isGameMode: true,

			onLoad() {
				this.allowRenames = false; // don't let people change their name in the middle of the hunt.
			},

			onJoin(user: User) {
				const game = this.room.scavgame!;
				if (game.playerlist && !game.playerlist.includes(user.id)) {
					user.sendTo(this.room, 'You are not allowed to join this scavenger hunt.');
					return true;
				}
			},

			onAfterEnd() {
				const game = this.room.scavgame!;
				if (!this.completed.length) {
					this.announce('No one has completed the hunt - the round has been void.');
					return;
				}

				game.round++;

				// elimination
				if (!game.playerlist) {
					game.playerlist = this.completed.map(entry => toID(entry.name));
					this.announce(`Round ${game.round} - ${Chat.toListString(this.completed.map(p => `<em>${p.name}</em>`))} have successfully completed the last hunt and have moved on to the next round!`);
				} else {
					let eliminated = [];
					const completed = this.completed.map(entry => toID(entry.name)) as string[];
					if (completed.length === game.playerlist.length) {
						eliminated.push(completed.pop()); // eliminate one
						game.playerlist = game.playerlist.filter(userid => completed.includes(userid));
					} else {
						eliminated = game.playerlist.filter(userid => !completed.includes(userid));
						for (const username of eliminated) {
							const userid = toID(username);
							game.playerlist = game.playerlist.filter(pid => pid !== userid);
						}
					}

					this.announce(`Round ${game.round} - ${Chat.toListString(eliminated.map(n => `<em>${n}</em>`))} ${Chat.plural(eliminated, 'have', 'has')} been eliminated! ${Chat.toListString(game.playerlist.map(p => `<em>${this.playerTable[p].name}</em>`))} have successfully completed the last hunt and have moved on to the next round!`);
				}

				// process end of game
				if (game.playerlist.length === 1) {
					const winningUser = Users.get(game.playerlist[0]);
					const winner = winningUser ? winningUser.name : game.playerlist[0];

					this.announce(`Congratulations to the winner - ${winner}!`);
					game.destroy();
				} else if (!game.playerlist.length) {
					this.announce('Everyone has been eliminated!  Better luck next time!');
					game.destroy();
				}
			},
		},

		round: 0,
		playerlist: null,
	},

	scav: 'scavengergames',
	scavgames: 'scavengergames',
	scavengergames: {
		name: 'Scavenger Games',
		id: 'scavengergames',

		mod: {
			name: 'Scavenger Games',
			id: 'scavengergames',
			isGameMode: true,

			onLoad() {
				this.allowRenames = false; // don't let people change their name in the middle of the hunt.
				this.setTimer(1);
			},

			onJoin(user) {
				const game = this.room.scavgame!;
				if (game.playerlist && !game.playerlist.includes(user.id)) {
					user.sendTo(this.room, 'You are not allowed to join this scavenger hunt.');
					return true;
				}
			},

			onAfterEnd() {
				const game = this.room.scavgame!;
				if (!this.completed.length) {
					this.announce('No one has completed the hunt - the round has been void.');
					return;
				}
				game.round++;

				let eliminated: string[] = [];
				if (!game.playerlist) {
					game.playerlist = this.completed.map(entry => toID(entry.name) as string);
				} else {
					const completed = this.completed.map(entry => toID(entry.name) as string);
					eliminated = game.playerlist.filter(userid => !completed.includes(userid));
					for (const username of eliminated) {
						const userid = toID(username);
						game.playerlist = game.playerlist.filter(pid => pid !== userid);
					}
				}

				this.announce(`Round ${game.round} - ${Chat.toListString(eliminated.map(n => `<em>${n}</em>`))} ${eliminated.length ? `${Chat.plural(eliminated, 'have', 'has')} been eliminated!` : ''} ${Chat.toListString(game.playerlist.map(p => `<em>${this.playerTable[p].name}</em>`))} have successfully completed the last hunt and have moved on to the next round!`);

				// process end of game
				if (game.playerlist.length === 1) {
					const winningUser = Users.get(game.playerlist[0]);
					const winner = winningUser ? winningUser.name : game.playerlist[0];

					this.announce(`Congratulations to the winner - ${winner}!`);
					game.destroy();
				} else if (!game.playerlist.length) {
					this.announce('Everyone has been eliminated!  Better luck next time!');
					game.destroy();
				}
			},
		},

		round: 0,
		playerlist: null,
	},

	pr: 'pointrally',
	pointrally: {
		name: 'Point Rally',
		id: 'pointrally',

		pointDistribution: [50, 40, 32, 25, 20, 15, 10],

		mod: {
			name: 'Point Rally',
			id: 'pointrally',
			isGameMode: true,

			onLoad() {
				const game = this.room.scavgame!;
				this.announce(`Round ${++game.round}`);
			},

			async onAfterEnd() {
				const game = this.room.scavgame!;
				for (const [i, completed] of this.completed.map(e => e.name).entries()) {
					const points = (game.pointDistribution[i] ||
						game.pointDistribution[game.pointDistribution.length - 1]);
					game.leaderboard.addPoints(completed, 'points', points);
				}
				// post leaderboard
				const room = this.room;
				const html = await game.leaderboard.htmlLadder() as string;
				room.add(`|raw|${html}`).update();
			},
		},
		round: 0,
		leaderboard: true,
	},

	js: 'jumpstart',
	jump: 'jumpstart',
	jumpstart: {
		name: 'Jump Start',
		id: 'jumpstart',

		jumpstart: [60, 40, 30, 20, 10],
		round: 0,
		mod: {
			name: 'Jump Start',
			id: 'jumpstart',
			isGameMode: true,

			onLoad() {
				const game = this.room.scavgame!;
				if (game.round === 0) return;
				const maxTime = Math.max(...game.jumpstart);

				this.jumpstartTimers = [];
				this.answerLock = true;

				for (const [i, time] of game.jumpstart.entries()) {
					if (!game.completed[i]) break;

					this.jumpstartTimers[i] = setTimeout(() => {
						const target = game.completed.shift();
						if (!target) return;

						const staffHost = Users.get(this.staffHostId);
						const targetUser = Users.get(target);

						if (targetUser) {
							if (staffHost) staffHost.sendTo(this.room, `${targetUser.name} has received their first hint early.`);
							targetUser.sendTo(
								this.room,
								`|raw|<strong>The first hint to the next hunt is:</strong> ${Chat.formatText(this.questions[0].hint)}`
							);
							targetUser.sendTo(
								this.room,
								`|notify|Early Hint|The first hint to the next hunt is: ${Chat.formatText(this.questions[0].hint)}`
							);
						}
					}, (maxTime - time) * 1000 + 5000);
				}

				// when the jump starts are all given to eligible players
				this.jumpstartTimers[this.jumpstartTimers.length] = setTimeout(() => {
					this.answerLock = false;
					const message = this.getCreationMessage(true);
					this.room.add(message).update();
					this.announce('You may start guessing!');
					this.startTime = Date.now();
				}, 1000 * (maxTime + 5));
			},

			onJoin(user) {
				if (this.answerLock) {
					user.sendTo(this.room, `The hunt is not open for guesses yet!`);
					return true;
				}
			},

			onViewHunt(user) {
				if (this.answerLock && !(this.hosts.some(h => h.id === user.id) || user.id === this.staffHostId)) {
					return true;
				}
			},

			onCreateCallback() {
				if (this.answerLock) {
					return `|raw|<div class="broadcast-blue"><strong>${['official', 'unrated'].includes(this.gameType) ? 'An' : 'A'} ${this.gameType} ` +
						`Scavenger Hunt by <em>${Utils.escapeHTML(Chat.toListString(this.hosts.map(h => h.name)))}</em> ` +
						`has been started${(this.hosts.some(h => h.id === this.staffHostId) ? '' : ` by <em>${Utils.escapeHTML(this.staffHostName)}</em>`)}.` +
						`<br />The first hint is currently being handed out to early finishers.`;
				}
			},

			onEnd(reset) {
				if (this.jumpstartTimers) {
					for (const timer of this.jumpstartTimers) {
						clearTimeout(timer);
					}
				}
				const game = this.room.scavgame!;
				if (!reset) {
					if (game.round === 0) {
						game.completed = this.completed.map(entry => toID(entry.name));
						game.round++;
					} else {
						game.destroy();
					}
				}
			},
		},
	},

	ts: 'teamscavs',
	tscav: 'teamscavs',
	teamscavengers: 'teamscavs',
	teamscavs: {
		name: 'Team Scavs',
		id: 'teamscavs',
		/* {
			[team
			name: string]: {
			  name: string[],
			  players: UserID[],
			  answers: string[],
			  question: number,
			  completed: boolean
			}
		  } */
		teams: {},

		teamAnnounce(player: ScavengerHuntPlayer | User, message: string) {
			const team = this.getPlayerTeam(player);

			for (const userid of team.players) {
				const user = Users.getExact(userid);
				if (!user?.connected) continue; // user is offline

				user.sendTo(this.room, `|raw|<div class="infobox">${message}</div>`);
			}
		},

		getPlayerTeam(player: ScavengerHuntPlayer | User) {
			const game = this.room.scavgame!;
			for (const teamID in game.teams) {
				const team = game.teams[teamID];
				if (team.players.includes(player.id)) {
					return team;
				}
			}
			return null;
		},

		advanceTeam(answerer: ScavengerHuntPlayer, isFinished?: boolean) {
			const hunt = this.room.getGame(ScavengerHunt)!;

			const team = this.getPlayerTeam(answerer);

			team.question++;
			const question = hunt.getQuestion(team.question);
			for (const userid of team.players) {
				const user = Users.getExact(userid);
				if (!user) continue;

				user.sendTo(this.room, question);
			}
			team.answers = [];
		},

		mod: {
			name: 'Team Scavs',
			id: 'teamscavs',
			isGameMode: true,

			onLoad() {
				// don't let people change their name in the middle of the hunt.
				this.allowRenames = false;
			},

			onViewHunt(user) {
				const game = this.room.scavgame!;
				const team = game.getPlayerTeam(user);
				const player = this.playerTable[user.id];

				if (!player || !team) return;

				if (player.currentQuestion !== team.question - 1) player.currentQuestion = team.question - 1;
				if (team.completed) player.completed = true;
			},

			onSendQuestion(player) {
				const game = this.room.scavgame!;
				const team = game.getPlayerTeam(player);

				if (!team) return;

				if (player.currentQuestion !== team.question - 1) player.currentQuestion = team.question - 1;
				if (team.completed) {
					player.completed = true;
					player.sendRoom('Your team has already completed the hunt!');
					return true;
				}
			},

			onConnect(user) {
				const player = this.playerTable[user.id];
				if (!player) return;

				const game = this.room.scavgame!;
				const team = game.getPlayerTeam(player);

				if (team) {
					if (player.currentQuestion !== team.question - 1) player.currentQuestion = team.question - 1;
					if (team.completed) player.completed = true;
				}
			},

			onJoin(user) {
				const game = this.room.scavgame!;
				const team = game.getPlayerTeam(user);
				if (!team) {
					user.sendTo(this.room, 'You are not allowed to join this scavenger hunt as you are not on any of the teams.');
					return true;
				}
			},

			onAfterLoadPriority: -9999,
			onAfterLoad() {
				const game = this.room.scavgame!;

				if (Object.keys(game.teams).length === 0) {
					this.announce('Teams have not been set up yet.  Please reset the hunt.');
				}
			},

			// -1 so that blind incog takes precedence of this
			onCorrectAnswerPriority: -1,
			onCorrectAnswer(player, value) {
				const game = this.room.scavgame!;

				if (player.currentQuestion + 1 < this.questions.length) {
					game.teamAnnounce(
						player,
						Utils.html`<strong>${player.name}</strong> has gotten the correct answer (${value}) for question #${player.currentQuestion + 1}.`
					);
					game.advanceTeam(player);

					return false;
				}
			},

			onAnySubmit(player, value) {
				const game = this.room.scavgame!;

				const team = game.getPlayerTeam(player);

				if (!team) return true; // handle players who get kicked during the hunt.

				if (player.currentQuestion !== team.question - 1) player.currentQuestion = team.question - 1;
				if (team.completed) player.completed = true;

				if (player.completed) return;

				if (team.answers.includes(value)) return;
				game.teamAnnounce(player, Utils.html`${player.name} has guessed "${value}".`);
				team.answers.push(value);
			},

			onCompletePriority: 2,
			onComplete(player, time, blitz) {
				const game = this.room.scavgame!;

				const team = game.getPlayerTeam(player);
				return {name: team.name, time, blitz};
			},

			// workaround that gives the answer after verifying that completion should not be hidden
			onConfirmCompletion(player, time, blitz) {
				const game = this.room.scavgame!;
				const team = game.getPlayerTeam(player);
				team.completed = true;
				team.question++;

				game.teamAnnounce(
					player,
					Utils.html`<strong>${player.name}</strong> has gotten the correct answer for question #${player.currentQuestion}.  Your team has completed the hunt!`
				);
			},

			onEnd() {
				const game = this.room.scavgame!;
				for (const teamID in game.teams) {
					const team = game.teams[teamID];
					team.answers = [];
					team.question = 1;
					team.completed = false;
				}
			},
		},
	},
};

export class ScavengerGameTemplate {
	room: Room;
	playerlist: null | string[];
	timer: NodeJS.Timer | null;

	[k: string]: any;
	constructor(room: Room) {
		this.room = room;
		this.playerlist = null;
		this.timer = null;
	}

	destroy(force?: boolean) {
		if (this.timer) clearTimeout(this.timer);
		const game = this.room.getGame(ScavengerHunt);
		if (force && game) game.onEnd(false);
		this.room.scavgame = null;
	}

	eliminate(userid: string) {
		if (!this.playerlist?.includes(userid)) return false;
		this.playerlist = this.playerlist.filter(pid => pid !== userid);

		if (this.leaderboard) delete this.leaderboard.data[userid];
		return true;
	}

	announce(msg: string) {
		this.room.add(`|raw|<div class="broadcast-blue"><strong>${msg}</strong></div>`).update();
	}
}

const LoadGame = function (room: Room, gameid: string) {
	let game = MODES[gameid];
	if (!game) return false; // invalid id
	if (typeof game === 'string') game = MODES[game];

	const base = new ScavengerGameTemplate(room);

	const scavgame = Object.assign(base, Utils.deepClone(game));

	// initialize leaderboard if required
	if (scavgame.leaderboard) {
		scavgame.leaderboard = new Leaderboard();
	}
	return scavgame;
};

export const ScavMods = {
	LoadGame,
	twists: TWISTS,
	modes: MODES,
};
