import { InternalService } from "@autoplex/microservice";
import { Client, Collection, Message, TextChannel, User as DiscordUser } from "discord.js";
import { env, formatImdbId, generateToken, secret } from "@autoplex/utils";
import { DiscordAccount, DiscordChannel, DiscordLinkRequest, MovieTicket } from "@autoplex/database";
import Application from "../Application";
import MovieSearch from "./MovieSearch";

/**
 * The required role to perfrom administrative commands on the bot
 */
const ROLE_ADMIN = "Autoplex Admin";

/**
 * The message prefix for commands
 */
const COMMAND_PREFIX = "!plex";

/**
 * How frequently to update Discord status messages
 */
const UPDATE_STATUS_INTERVAL = 1500;

interface IChannelMap {
	[channelId: string]: Message
}

interface ICommandMap {
	[key: string]: (message: Message) => void|Promise<void>
}

interface IAuthCommandmap {
	[key: string]: (account: DiscordAccount, message: Message) => void|Promise<void>
}

export default class DiscordBot extends InternalService<Application>
{
	/**
	 * The internal Discord bot instance
	 */
	protected bot: Client;

	/**
	 * Maintain a set of request channels
	 */
	protected channels: IChannelMap = {};

	/**
	 * Admin commands available
	 */
	protected adminCommands: ICommandMap;

	/**
	 * Guest commands available
	 */
	protected guestCommands: ICommandMap;

	/**
	 * Authorized commands available
	 */
	protected authCommands: IAuthCommandmap;

	/**
	 * The status update interval
	 */
	protected updateStatusTimeout: NodeJS.Timeout | null = null;

	/**
	 * Timestamp of the most recent status update
	 */
	protected lastStatusUpdate: number = 0;

	/**
	 * The current status of the bot
	 */
	protected status: string = "";

	/**
	 * Create a new Discord bot instance
	 */
	public constructor(app: Application) {
		super(app);
		this.bot = new Client();
		this.adminCommands = {
			"install": this.cmdInstallChannel.bind(this)
		};
		this.guestCommands = {
			"link": this.cmdLinkAccount.bind(this)
		}
		this.authCommands = {};
	}

	/**
	 * The service name
	 */
	public readonly NAME = "Discord Bot";

	/**
	 * Boot the discord bot
	 */
	public async boot() {
		let token = await secret(env("DISCORD_BOT_KEY_FILE"));
		await this.bot.login(token);
	}

	/**
	 * Invoked when all other services are booted and ready
	 */
	public async start() {
		await this.loadChannels();
		this.bot.on("message", message => this.onMessage(message));
		setInterval(() => {
			this.updateStatus(Date.now().toString());
		}, 500);
	}

	/**
	 * Shutdown the discord bot
	 */
	public async shutdown() {
		this.bot.destroy();
	}

	// Message Handling ----------------------------------------------------------------------------

	/**
	 * Invoked when a Discord message is received
	 */
	protected onMessage(message: Message) {
		if (message.content.startsWith(`${COMMAND_PREFIX} `)) {
			this.handleMessage(message);
		}
	}

	/**
	 * Handle an incoming Discord message
	 */
	protected async handleMessage(message: Message) {
		let command = message.content.split(/\s+/)[1];
		if (message.member?.roles.cache.some(role => role.name == ROLE_ADMIN)) {
			if (command in this.adminCommands) {
				this.adminCommands[command](message);
				return;
			}
		}
		// Check if the channel is an authorized channel
		if (!(message.channel.id in this.channels)) {
			this.informUserOfAuthorizedChannels(message);
			return;
		}
		if (command in this.guestCommands) {
			this.guestCommands[command](message);
			return;
		}
		// Only allow plex members
		let account = await DiscordAccount.findOne({ where: { discordId: message.author.id }, relations: ["user", "user.quota"] });
		if (account === undefined) {
			console.log("Unauthorized");
			return;
		}
		if (command in this.authCommands) {
			this.authCommands[command](account, message);
		}
		this.cmdRequestMovie(account, message);
		if (message.deletable) {
			message.delete();
		}
	}

	/**
	 * Inform a message sender of authorized channels within the same server if available
	 */
	protected async informUserOfAuthorizedChannels(message: Message) {
		if (!(message.channel instanceof TextChannel)) {
			return;
		}
		let serverId = message.channel.guild.id;
		let authorizedChannels = <Collection<string, TextChannel>>message.channel.client.channels.cache.filter(channel => (
			channel instanceof TextChannel &&
			channel.guild.id == serverId &&
			channel.id in this.channels));
		let response = "Autoplex commands must be issued through authorized channels. ";
		if (authorizedChannels.size == 0) {
			response += `Unfortunately, **${message.channel.guild.name}** does not have any available channels.`;
		} else {
			response += `The following channels in **${message.channel.guild.name}** are:`;
			for (let channel of authorizedChannels) {
				response += `\n  - \`#${channel[1].name}\``;
			};
		}
		this.sendDm(message.author, response);
	}

	// Channel Handling ----------------------------------------------------------------------------

	/**
	 * Load the channels from the database
	 */
	protected async loadChannels() {
		let channels = await DiscordChannel.find();
		for (let channel of channels) {
			this.loadChannel(channel);
		}
	}

	/**
	 * Load the channel's status message. Reinstall if the message can't be found
	 */
	protected async loadChannel(discordChannel: DiscordChannel) {
		// Load the channel
		let channel: TextChannel
		try {
			channel = <TextChannel> await this.bot.channels.fetch(discordChannel.channelId);
		} catch(e) {
			this.uninstall(discordChannel.channelId);
			return;
		}
		// Load the message, attempt to install if failed
		let message: Message;
		try {
			message = await channel.messages.fetch(discordChannel.messageId);
		} catch(e) {
			await this.install(channel, "Autoplex reinstalled");
			return;
		}
		this.channels[channel.id] = message;
	}

	/**
	 * Install the Discord bot to a channel
	 */
	protected async install(channel: TextChannel, status?: string): Promise<boolean>;
	protected async install(channelId: string, status?: string): Promise<boolean>;
	protected async install(channel: string | TextChannel, status?: string) {
		// Fetch the channel if necessary
		if (typeof channel === "string") {
			channel = <TextChannel> await this.bot.channels.fetch(channel);
		}
		// Ensure the channel is a text channel
		if (!(channel instanceof TextChannel)) {
			return false;
		}
		try {
			// Create the initial status message
			let message = await channel.send(status ?? "Autoplex installed.");
			// Fetch the channel model or create a new one
			let discordChannel = (await DiscordChannel.findOne({ where: { channelId: channel.id } }))
								?? new DiscordChannel();
			discordChannel.channelId = channel.id;
			discordChannel.messageId = message.id;
			await discordChannel.save();
			this.channels[channel.id] = message;
		} catch(e) {
			console.error("Failed to install to channel.", e);
			this.uninstall(channel)
			return false;
		}
		return true;
	}

	/**
	 * Uninstall the Discord bot from a channel
	 */
	 protected async uninstall(channel: TextChannel): Promise<void>;
	 protected async uninstall(channelId: string): Promise<void>;
	 protected async uninstall(channel: string | TextChannel) {
		 // Fetch the channel if necessary
		 if (typeof channel === "string") {
			 channel = <TextChannel> await this.bot.channels.fetch(channel);
		 }
		 if (channel.id in this.channels) {
			delete this.channels[channel.id];
		 }
		 await DiscordChannel.delete({ channelId: channel.id });
		 console.error("Channel uninstalled");
	 }

	// Message Handling ----------------------------------------------------------------------------

	/**
	 * Update the status of the server
	 */
	public updateStatus(status: string) {
		this.status = status;
		if (this.updateStatusTimeout !== null) {
			return;
		}
		this.updateStatusTimeout = setTimeout(() => {
			this.lastStatusUpdate = Date.now();
			this.updateStatusTimeout = null;
			for (let channelId in this.channels) {
				this.updateStatusMessage(channelId, status);
			}
		}, this.lastStatusUpdate + UPDATE_STATUS_INTERVAL - Date.now());
	}

	/**
	 * Update the status message in a single channel
	 */
	protected async updateStatusMessage(channelId: string, status: string) {
		try {
			await this.channels[channelId].edit(status);
		} catch(e) {
			await this.install(channelId, status);
		}
	}

	/**
	 * Send a DM to a user
	 */
	protected async sendDm(user: DiscordUser, message: string) {
		return await (await user.createDM()).send(message);
	}

	// Discord Commands ----------------------------------------------------------------------------

	/**
	 * Install the Autoplex bot onto a channel
	 */
	protected async cmdInstallChannel(message: Message) {
		let isInstalled = await this.install(message.channel.id);
		if (!isInstalled) {
			message.reply("Installation failed. Check server logs");
			return;
		}
	}

	/**
	 * Link a Discord account to an Autoplex account
	 */
	protected async cmdLinkAccount(message: Message) {
		// Ensure the account hasn't already been linked
		let numAccounts = await DiscordAccount.count({ where: { discordId: message.author.id } });
		if (numAccounts > 0) {
			this.sendDm(message.author, "This Discord account has already been linked to an Autoplex account");
			return;
		}

		// Create the link request
		let linkRequest = await DiscordLinkRequest.findOne({
			where: { discordId: message.author.id }
		}) ?? new DiscordLinkRequest();
		linkRequest.discordId = message.author.id;
		linkRequest.token = await generateToken();
		await linkRequest.save();

		// Send the link to the user
		this.sendDm(message.author,
			"Link your Autoplex account by clicking the link and logging in.\n" +
			`${env("BASE_URL")}/link/discord/${linkRequest.token}`
		);
	}

	/**
	 * @TODO movie requests have been disabled while the project is reorganized
	 * Request a movie
	 */
	protected async cmdRequestMovie(account: DiscordAccount, message: Message) {
		// Ensure an IMDb ID was entered
		let imdbIds = message.content.match(/\btt[0-9]{7,25}\b/g);
		if (imdbIds === null) {
			this.sendDm(message.author, "Usage: `!plex https://www.imdb.com/title/ttxxxxxx/`");
			return;
		}
		let imdbId = formatImdbId(imdbIds[0]);

		// Ensure this movie hasn't already been requested
		let ticket = await MovieTicket.findOne({ where: { imdbId, isCanceled: false }, relations: ["user"] });
		if (ticket !== undefined) {
			if (ticket.user.id == account.user.id) {
				this.sendDm(message.author, `You have already requested the movie: *${ticket.title}*.`);
			} else {
				this.sendDm(message.author, `The movie *${ticket.title}* `);
			}
			return;
		}

		// Ensure the user hasn't gone over their quota
		let quota = await account.user.availableQuota();
		if (quota !== null && quota < 1) {
			this.sendDm(
				message.author,
				`You have reach your quota limit of ${account.user.quota?.moviesPerWeek}` +
				` movie${account.user.quota?.moviesPerWeek != 0 ? 's' : ''} for this week.`);
			return;
		}

		// Fetch the movie details and create the ticket
		let movie = await this.app.service<MovieSearch>("Movie Search").findImdb(imdbId);
		if (movie === null) {
			this.sendDm(
				message.author,
				`Unfortunately, the movie you requested could net be located: https://imdb.com/title/${imdbId}/`);
			return;
		}
		let info = await this.app.service<MovieSearch>("Movie Search").details(movie.tmdbId);
		// await MovieTicket.requestTmdb(account.user, info);
		this.sendDm(message.author, `*${info.title}* has been requested successfully! (TEMP DISABLE)`);
	}
}
