/*
	Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
	Copyright (C) 2023 Spacebar and Spacebar Contributors

	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, or
	(at your option) any later version.

	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/>.
*/

import { HTTPError } from "lambert-server";
import {
	Column,
	Entity,
	JoinColumn,
	ManyToOne,
	OneToMany,
	RelationId,
} from "typeorm";
import { DmChannelDTO } from "../dtos";
import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces";
import { InvisibleCharacters, Snowflake, containsAll, emitEvent, getPermission, trimSpecial, Permissions, BitField } from "../util";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { Invite } from "./Invite";
import { Message } from "./Message";
import { ReadState } from "./ReadState";
import { Recipient } from "./Recipient";
import { PublicUserProjection, User } from "./User";
import { VoiceState } from "./VoiceState";
import { Webhook } from "./Webhook";
import { dbEngine } from "../util/Database";
import { Member } from "./Member";

export enum ChannelType {
	GUILD_TEXT = 0, // a text channel within a guild
	DM = 1, // a direct message between users
	GUILD_VOICE = 2, // a voice channel within a guild
	GROUP_DM = 3, // a direct message between multiple users
	GUILD_CATEGORY = 4, // an organizational category that contains zero or more channels
	GUILD_NEWS = 5, // a channel that users can follow and crosspost into a guild or route
	GUILD_STORE = 6, // a channel in which game developers can sell their things
	ENCRYPTED = 7, // end-to-end encrypted channel
	ENCRYPTED_THREAD = 8, // end-to-end encrypted thread channel
	TRANSACTIONAL = 9, // event chain style transactional channel
	GUILD_NEWS_THREAD = 10, // a temporary sub-channel within a GUILD_NEWS channel
	GUILD_PUBLIC_THREAD = 11, // a temporary sub-channel within a GUILD_TEXT channel
	GUILD_PRIVATE_THREAD = 12, // a temporary sub-channel within a GUILD_TEXT channel that is only viewable by those invited and those with the MANAGE_THREADS permission
	GUILD_STAGE_VOICE = 13, // a voice channel for hosting events with an audience
	DIRECTORY = 14, // guild directory listing channel
	GUILD_FORUM = 15, // forum composed of IM threads
	TICKET_TRACKER = 33, // ticket tracker, individual ticket items shall have type 12
	KANBAN = 34, // confluence like kanban board
	VOICELESS_WHITEBOARD = 35, // whiteboard but without voice (whiteboard + voice is the same as stage)
	CUSTOM_START = 64, // start custom channel types from here
	UNHANDLED = 255, // unhandled unowned pass-through channel type
}

@Entity({
	name: "channels",
	engine: dbEngine,
})
export class Channel extends BaseClass {
	@Column()
	created_at: Date;

	@Column({ nullable: true })
	name?: string;

	@Column({ type: "text", nullable: true })
	icon?: string | null;

	@Column({ type: "int" })
	type: ChannelType;

	@OneToMany(() => Recipient, (recipient: Recipient) => recipient.channel, {
		cascade: true,
		orphanedRowAction: "delete",
	})
	recipients?: Recipient[];

	@Column({ nullable: true })
	last_message_id?: string;

	@Column({ nullable: true })
	@RelationId((channel: Channel) => channel.guild)
	guild_id?: string;

	@JoinColumn({ name: "guild_id" })
	@ManyToOne(() => Guild, (guild) => guild.channels, {
		onDelete: "CASCADE",
		nullable: true,
	})
	guild?: Guild;

	@Column({ nullable: true })
	@RelationId((channel: Channel) => channel.parent)
	parent_id: string | null;

	@JoinColumn({ name: "parent_id" })
	@ManyToOne(() => Channel)
	parent?: Channel;

	// for group DMs and owned custom channel types
	@Column({ nullable: true })
	@RelationId((channel: Channel) => channel.owner)
	owner_id?: string;

	@JoinColumn({ name: "owner_id" })
	@ManyToOne(() => User)
	owner: User;

	@Column({ nullable: true })
	last_pin_timestamp?: number;

	@Column({ nullable: true })
	default_auto_archive_duration?: number;

	@Column({ type: "simple-json", nullable: true })
	permission_overwrites?: ChannelPermissionOverwrite[];

	@Column({ nullable: true })
	video_quality_mode?: number;

	@Column({ nullable: true })
	bitrate?: number;

	@Column({ nullable: true })
	user_limit?: number;

	@Column()
	nsfw: boolean = false;

	@Column({ nullable: true })
	rate_limit_per_user?: number;

	@Column({ nullable: true })
	topic?: string;

	@OneToMany(() => Invite, (invite: Invite) => invite.channel, {
		cascade: true,
		orphanedRowAction: "delete",
	})
	invites?: Invite[];

	@Column({ nullable: true })
	retention_policy_id?: string;

	@OneToMany(() => Message, (message: Message) => message.channel, {
		cascade: true,
		orphanedRowAction: "delete",
	})
	messages?: Message[];

	@OneToMany(
		() => VoiceState,
		(voice_state: VoiceState) => voice_state.channel,
		{
			cascade: true,
			orphanedRowAction: "delete",
		},
	)
	voice_states?: VoiceState[];

	@OneToMany(() => ReadState, (read_state: ReadState) => read_state.channel, {
		cascade: true,
		orphanedRowAction: "delete",
	})
	read_states?: ReadState[];

	@OneToMany(() => Webhook, (webhook: Webhook) => webhook.channel, {
		cascade: true,
		orphanedRowAction: "delete",
	})
	webhooks?: Webhook[];

	@Column()
	flags: number = 0;

	@Column()
	default_thread_rate_limit_per_user: number = 0;

	/** Must be calculated Channel.calculatePosition */
	position: number;

	// TODO: DM channel
	static async createChannel(
		channel: Partial<Channel>,
		user_id: string = "0",
		opts?: {
			keepId?: boolean;
			skipExistsCheck?: boolean;
			skipPermissionCheck?: boolean;
			skipEventEmit?: boolean;
			skipNameChecks?: boolean;
		},
	) {
		if (!opts?.skipPermissionCheck) {
			// Always check if user has permission first
			const permissions = await getPermission(user_id, channel.guild_id);
			permissions.hasThrow("MANAGE_CHANNELS");
		}

		const guild = await Guild.findOneOrFail({
			where: { id: channel.guild_id },
			select: {
				features: !opts?.skipNameChecks,
				channel_ordering: true,
				id: true,
			},
		});

		if (!opts?.skipNameChecks) {
			if (
				!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") &&
				channel.name
			) {
				for (const character of InvisibleCharacters)
					if (channel.name.includes(character))
						throw new HTTPError(
							"Channel name cannot include invalid characters",
							403,
						);

				// Categories skip these checks on discord.com
				if (
					channel.type !== ChannelType.GUILD_CATEGORY ||
					guild.features.includes("IRC_LIKE_CATEGORY_NAMES")
				) {
					if (channel.name.includes(" "))
						throw new HTTPError(
							"Channel name cannot include invalid characters",
							403,
						);

					if (channel.name.match(/--+/g))
						throw new HTTPError(
							"Channel name cannot include multiple adjacent dashes.",
							403,
						);

					if (
						channel.name.charAt(0) === "-" ||
						channel.name.charAt(channel.name.length - 1) === "-"
					)
						throw new HTTPError(
							"Channel name cannot start/end with dash.",
							403,
						);
				} else channel.name = channel.name.trim(); //category names are trimmed client side on discord.com
			}

			if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) {
				if (!channel.name)
					throw new HTTPError("Channel name cannot be empty.", 403);
			}
		}

		switch (channel.type) {
			case ChannelType.GUILD_TEXT:
			case ChannelType.GUILD_NEWS:
			case ChannelType.GUILD_VOICE:
				if (channel.parent_id && !opts?.skipExistsCheck) {
					const exists = await Channel.findOneOrFail({
						where: { id: channel.parent_id },
					});
					if (!exists)
						throw new HTTPError(
							"Parent id channel doesn't exist",
							400,
						);
					if (exists.guild_id !== channel.guild_id)
						throw new HTTPError(
							"The category channel needs to be in the guild",
						);
				}
				break;
			case ChannelType.GUILD_CATEGORY:
			case ChannelType.UNHANDLED:
				break;
			case ChannelType.DM:
			case ChannelType.GROUP_DM:
				throw new HTTPError("You can't create a dm channel in a guild");
			case ChannelType.GUILD_STORE:
			default:
				throw new HTTPError("Not yet supported");
		}

		if (!channel.permission_overwrites) channel.permission_overwrites = [];
		// TODO: eagerly auto generate position of all guild channels

		const position =
			(channel.type === ChannelType.UNHANDLED ? 0 : channel.position) ||
			0;

		channel = {
			...channel,
			...(!opts?.keepId && { id: Snowflake.generate() }),
			created_at: new Date(),
			position,
		};

		const ret = Channel.create(channel);

		await Promise.all([
			ret.save(),
			!opts?.skipEventEmit
				? emitEvent({
						event: "CHANNEL_CREATE",
						data: channel,
						guild_id: channel.guild_id,
					} as ChannelCreateEvent)
				: Promise.resolve(),
			Guild.insertChannelInOrder(guild.id, ret.id, position, guild),
		]);

		return ret;
	}

	static async createDMChannel(
		recipients: string[],
		creator_user_id: string,
		name?: string,
	) {
		recipients = recipients.unique().filter((x) => x !== creator_user_id);
		// TODO: check config for max number of recipients
		/** if you want to disallow note to self channels, uncomment the conditional below

		const otherRecipientsUsers = await User.find({ where: recipients.map((x) => ({ id: x })) });
		if (otherRecipientsUsers.length !== recipients.length) {
			throw new HTTPError("Recipient/s not found");
		}
		**/

		const type =
			recipients.length > 1 ? ChannelType.GROUP_DM : ChannelType.DM;

		let channel = null;

		const channelRecipients = [...recipients, creator_user_id];

		const userRecipients = await Recipient.find({
			where: { user_id: creator_user_id },
			relations: ["channel", "channel.recipients"],
		});

		for (const ur of userRecipients) {
			if (!ur.channel.recipients) continue;
			const re = ur.channel.recipients.map((r) => r.user_id);
			if (re.length === channelRecipients.length) {
				if (containsAll(re, channelRecipients)) {
					if (channel == null) {
						channel = ur.channel;
						await ur.assign({ closed: false }).save();
					}
				}
			}
		}

		if (channel == null) {
			name = trimSpecial(name);

			channel = await Channel.create({
				name,
				type,
				owner_id: undefined,
				created_at: new Date(),
				last_message_id: undefined,
				recipients: channelRecipients.map((x) =>
					Recipient.create({
						user_id: x,
						closed: !(
							type === ChannelType.GROUP_DM ||
							x === creator_user_id
						),
					}),
				),
				nsfw: false,
			}).save();
		}

		const channel_dto = await DmChannelDTO.from(channel);

		if (type === ChannelType.GROUP_DM && channel.recipients) {
			for (const recipient of channel.recipients) {
				await emitEvent({
					event: "CHANNEL_CREATE",
					data: channel_dto.excludedRecipients([recipient.user_id]),
					user_id: recipient.user_id,
				});
			}
		} else {
			await emitEvent({
				event: "CHANNEL_CREATE",
				data: channel_dto,
				user_id: creator_user_id,
			});
		}

		if (recipients.length === 1) return channel_dto;
		else return channel_dto.excludedRecipients([creator_user_id]);
	}

	static async removeRecipientFromChannel(channel: Channel, user_id: string) {
		await Recipient.delete({ channel_id: channel.id, user_id: user_id });
		channel.recipients = channel.recipients?.filter(
			(r) => r.user_id !== user_id,
		);

		if (channel.recipients?.length === 0) {
			await Channel.deleteChannel(channel);
			await emitEvent({
				event: "CHANNEL_DELETE",
				data: await DmChannelDTO.from(channel, [user_id]),
				user_id: user_id,
			});
			return;
		}

		await emitEvent({
			event: "CHANNEL_DELETE",
			data: await DmChannelDTO.from(channel, [user_id]),
			user_id: user_id,
		});

		//If the owner leave the server user is the new owner
		if (channel.owner_id === user_id) {
			channel.owner_id = "1"; // The channel is now owned by the server user
			await emitEvent({
				event: "CHANNEL_UPDATE",
				data: await DmChannelDTO.from(channel, [user_id]),
				channel_id: channel.id,
			});
		}

		await channel.save();

		await emitEvent({
			event: "CHANNEL_RECIPIENT_REMOVE",
			data: {
				channel_id: channel.id,
				user: await User.findOneOrFail({
					where: { id: user_id },
					select: PublicUserProjection,
				}),
			},
			channel_id: channel.id,
		} as ChannelRecipientRemoveEvent);
	}

	static async deleteChannel(channel: Channel) {
		// TODO Delete attachments from the CDN for messages in the channel
		await Channel.delete({ id: channel.id });

		const guild = await Guild.findOneOrFail({
			where: { id: channel.guild_id },
			select: { channel_ordering: true },
		});

		const updatedOrdering = guild.channel_ordering.filter(
			(id) => id != channel.id,
		);
		await Guild.update(
			{ id: channel.guild_id },
			{ channel_ordering: updatedOrdering },
		);
	}

	static async calculatePosition(
		channel_id: string,
		guild_id: string,
		guild?: Guild,
	) {
		if (!guild)
			guild = await Guild.findOneOrFail({
				where: { id: guild_id },
				select: { channel_ordering: true },
			});

		return guild.channel_ordering.findIndex((id) => channel_id == id);
	}

	static async getOrderedChannels(guild_id: string, guild?: Guild) {
		if (!guild)
			guild = await Guild.findOneOrFail({
				where: { id: guild_id },
				select: { channel_ordering: true },
			});

		const channels = await Promise.all(
			guild.channel_ordering.map((id) =>
				Channel.findOne({ where: { id } }),
			),
		);

		return channels
			.filter((channel) => channel !== null)
			.reduce((r, v) => {
				v = v as Channel;

				v.position = (guild as Guild).channel_ordering.indexOf(v.id);
				r[v.position] = v;
				return r;
			}, [] as Array<Channel>);
	}

	isDm() {
		return (
			this.type === ChannelType.DM || this.type === ChannelType.GROUP_DM
		);
	}

	// Does the channel support sending messages ( eg categories do not )
	isWritable() {
		const disallowedChannelTypes = [
			ChannelType.GUILD_CATEGORY,
			ChannelType.GUILD_STAGE_VOICE,
			ChannelType.VOICELESS_WHITEBOARD,
		];
		return disallowedChannelTypes.indexOf(this.type) == -1;
	}

	async getUserPermissions(opts: {user_id?: string, user?: User, member?: Member, guild?: Guild}): Promise<Permissions> {
		let guild = opts.guild;
		if (!guild) {
			if (this.guild) guild = this.guild;
			else if (this.guild_id) guild = await Guild.findOneOrFail({ where: { id: this.guild_id } });
			else {
				console.error("Channel.getUserPermissions: called without guild for non-DM channel.");
				return Permissions.NONE;
			}
		}

		// check if we can resolve here to short-circuit possibly calling the database unnecessarily
		// TODO: do we want to have an instance-wide opt out of this behavior? It would just be an extra if statement here
		const ownerId = guild?.owner?.id ?? guild?.owner_id;
		if (!!opts.user_id && ownerId === opts.user_id) return Permissions.ALL;
		if (!!opts.user?.id && ownerId === opts.user?.id) return Permissions.ALL;
		if (!!opts.member?.id && ownerId === opts.member?.id) return Permissions.ALL;

		let member = opts.member;
		if (!member) {
			if (opts.user) member = await Member.findOneOrFail({ where: { guild_id: guild.id, id: opts.user.id }, relations: [ "roles" ] });
			else if (opts.user_id) member = await Member.findOneOrFail({ where: { guild_id: guild.id, id: opts.user_id }, relations: [ "roles" ] });
			else {
				console.error("Channel.getUserPermissions: called without user or member for non-DM channel.");
				return Permissions.NONE;
			}
		}

		const roles = (member.roles || (await Member.findOneOrFail({ where: { guild_id: guild.id, index: member.index }, relations: [ "roles" ] })).roles)
			.sort((a, b) => a.position - b.position); // ascending by position

		// calculate user's channel perms - should in theory match https://docs.discord.food/topics/permissions#permission-overwrites
		// start at role permissions
		let userPerms = new Permissions(new BitField(0).add(roles.map(r => r.permissions)));

		// TODO: do we want to have an instance-wide opt out of this behavior? It would just be an extra if statement here
		if (userPerms.has(Permissions.FLAGS.ADMINISTRATOR)) return userPerms;

		// apply channel overrides
		if (this.permission_overwrites) {
			// role overwrites - TODO: this probably violates the geneva conventions - we should probably be ordering roles here
			for (const overwrite of this.permission_overwrites.filter(o => o.type === ChannelPermissionOverwriteType.role && roles.map(r => r.id).includes(o.id)))
				userPerms = new Permissions(userPerms.remove(overwrite.deny).add(overwrite.allow));

			// member overwrite, throws if somehow we have multiple overwrites for the same member
			const memberOverwrite = this.permission_overwrites.single(o => o.type === ChannelPermissionOverwriteType.member && o.id === member?.id);
			if (memberOverwrite) userPerms = new Permissions(userPerms.remove(memberOverwrite.deny).add(memberOverwrite.allow));
		}

		return userPerms;
	}

	// TODO: should we throw for missing args?
	async canViewChannel(opts: {user_id?: string, user?: User, member?: Member, guild?: Guild}): Promise<boolean> {
		if(this.isDm()) return await this.canViewDmChannel(opts.user_id, opts.user);

		const userPerms = await this.getUserPermissions(opts);
		return userPerms.has("VIEW_CHANNEL");
	}

	private async canViewDmChannel(user_id?: string, user?: User): Promise<boolean> {
		const userId = user_id ?? user?.id;
		if (!userId) {
			console.error("Channel.canViewChannel: called without user for DM channel.");
			return false;
		}
		if (!user) return false;
		if (this.recipients)
			return this.recipients.some((r) => r.user_id === user.id && !r.closed);
		else { // we dont have recipients on hand
			const recipient = await Recipient.findOne({ where: { channel_id: this.id, user_id: user.id } });
			return recipient == null ? false : !recipient.closed;
		}
	}

	toJSON() {
		return {
			...this,

			// these fields are not returned depending on the type of channel
			bitrate: this.bitrate || undefined,
			user_limit: this.user_limit || undefined,
			rate_limit_per_user: this.rate_limit_per_user || undefined,
			owner_id: this.owner_id || undefined,
		};
	}
}

export interface ChannelPermissionOverwrite {
	allow: string;
	deny: string;
	id: string;
	type: ChannelPermissionOverwriteType;
}

export enum ChannelPermissionOverwriteType {
	role = 0,
	member = 1,
	group = 2,
}

export interface DMChannel extends Omit<Channel, "type" | "recipients"> {
	type: ChannelType.DM | ChannelType.GROUP_DM;
	recipients: Recipient[];
}

// TODO: probably more props
export function isTextChannel(type: ChannelType): boolean {
	switch (type) {
		case ChannelType.GUILD_STORE:
		case ChannelType.GUILD_STAGE_VOICE:
		case ChannelType.GUILD_CATEGORY:
		case ChannelType.GUILD_FORUM:
		case ChannelType.DIRECTORY:
			throw new HTTPError("not a text channel", 400);
		case ChannelType.DM:
		case ChannelType.GROUP_DM:
		case ChannelType.GUILD_NEWS:
		case ChannelType.GUILD_VOICE:
		case ChannelType.GUILD_NEWS_THREAD:
		case ChannelType.GUILD_PUBLIC_THREAD:
		case ChannelType.GUILD_PRIVATE_THREAD:
		case ChannelType.GUILD_TEXT:
		case ChannelType.ENCRYPTED:
		case ChannelType.ENCRYPTED_THREAD:
			return true;
		default:
			throw new HTTPError("unimplemented", 400);
	}
}
