import { ChangeType, Item, UserItem, Uuid, ItemType } from '../services/database/types';
import BaseModel, { DeleteOptions, LoadOptions, SaveOptions } from './BaseModel';
import { unique } from '../utils/array';
import { ErrorNotFound } from '../utils/errors';
import { Knex } from 'knex';
import { PerformanceTimer } from '../utils/time';
import Logger from '@joplin/utils/Logger';
import { isUniqueConstraintError } from '../db';

const logger = Logger.create('UserItemModel');

interface DeleteByShare {
	id: Uuid;
	owner_id: Uuid;
}

export interface UserItemDeleteOptions extends DeleteOptions {
	byItemIds?: string[];
	byShareId?: string;
	byUserId?: string;
	byUserItem?: UserItem;
	byUserItemIds?: number[];
	byShare?: DeleteByShare;
	recordChanges?: boolean;
}

interface UserItemSaveOptions extends SaveOptions {
	// Ignore unique constraint errors if a (user_id, item_id) pair already
	// exists.
	ignoreAlreadyExists?: boolean;
}

export default class UserItemModel extends BaseModel<UserItem> {

	public get tableName(): string {
		return 'user_items';
	}

	protected hasUuid(): boolean {
		return false;
	}

	public async remove(userId: Uuid, itemId: Uuid): Promise<void> {
		await this.deleteByUserItem(userId, itemId);
	}

	public async userIdsByItemIds(itemIds: Uuid[]): Promise<Record<Uuid, Uuid[]>> {
		const rows: UserItem[] = await this.db(this.tableName).select('item_id', 'user_id').whereIn('item_id', itemIds);
		const output: Record<Uuid, Uuid[]> = {};
		for (const row of rows) {
			if (!output[row.item_id]) output[row.item_id] = [];
			output[row.item_id].push(row.user_id);
		}
		return output;
	}

	public async byItemIds(itemIds: Uuid[]): Promise<UserItem[]> {
		return this.db(this.tableName).select(this.defaultFields).whereIn('item_id', itemIds);
	}

	public async byShareId(shareId: Uuid, options: LoadOptions = {}): Promise<UserItem[]> {
		return this
			.db(this.tableName)
			.leftJoin('items', 'user_items.item_id', 'items.id')
			.select(this.selectFields(options, this.defaultFields, 'user_items'))
			.where('items.jop_share_id', '=', shareId);
	}

	public async byShareAndUserId(shareId: Uuid, userId: Uuid, options: LoadOptions = {}): Promise<UserItem[]> {
		return this
			.db(this.tableName)
			.leftJoin('items', 'user_items.item_id', 'items.id')
			.select(this.selectFields(options, this.defaultFields, 'user_items'))
			.where('items.jop_share_id', '=', shareId)
			.where('user_items.user_id', '=', userId);
	}

	public async countWithUserId(userId: Uuid): Promise<number> {
		const count = await this.db(this.tableName).count('*').where('user_id', '=', userId);
		return count[0].count;
	}

	public async byUserId(userId: Uuid): Promise<UserItem[]> {
		return this.db(this.tableName).where('user_id', '=', userId);
	}

	public async byUserAndItemId(userId: Uuid, itemId: Uuid): Promise<UserItem> {
		return this.db(this.tableName).where('user_id', '=', userId).where('item_id', '=', itemId).first();
	}

	// Returns any user item that is part of a share
	public async itemsInShare(userId: Uuid, options: LoadOptions = {}): Promise<UserItem[]> {
		return this
			.db(this.tableName)
			.leftJoin('items', 'user_items.item_id', 'items.id')
			.select(this.selectFields(options, this.defaultFields, 'user_items'))
			.where('items.jop_share_id', '!=', '')
			.where('user_items.user_id', '=', userId);
	}

	public async deleteByUserItem(userId: Uuid, itemId: Uuid, options: UserItemDeleteOptions = null): Promise<void> {
		const userItem = await this.byUserAndItemId(userId, itemId);
		if (!userItem) throw new ErrorNotFound(`No such user_item: ${userId} / ${itemId}`);
		await this.deleteBy({ ...options, byUserItem: userItem });
	}

	public async deleteByItemIds(itemIds: Uuid[], options: UserItemDeleteOptions = null): Promise<void> {
		await this.deleteBy({ ...options, byItemIds: itemIds });
	}

	public async deleteByShareId(shareId: Uuid): Promise<void> {
		await this.deleteBy({ byShareId: shareId });
	}

	public async deleteByShare(share: DeleteByShare): Promise<void> {
		await this.deleteBy({ byShare: share });
	}

	public async deleteByUserId(userId: Uuid): Promise<void> {
		await this.deleteBy({ byUserId: userId });
	}

	public async deleteByUserItemIds(userItemIds: number[]): Promise<void> {
		await this.deleteBy({ byUserItemIds: userItemIds });
	}

	public async deleteByShareAndUserId(shareId: Uuid, userId: Uuid): Promise<void> {
		await this.deleteBy({ byShareId: shareId, byUserId: userId });
	}

	public async add(userId: Uuid, itemId: Uuid, options: UserItemSaveOptions = {}): Promise<void> {
		const item = await this.models().item().load(itemId, { fields: ['id', 'name'] });
		if (!item) {
			throw new ErrorNotFound(`No such item: ${itemId}`);
		}
		await this.addMulti(userId, [item], options);
	}

	public async addMulti(userId: Uuid, itemsQuery: Knex.QueryBuilder | Item[], options: UserItemSaveOptions = {}): Promise<void> {
		const perfTimer = new PerformanceTimer(logger, 'addMulti');

		perfTimer.push('Main');

		const items: Item[] = Array.isArray(itemsQuery) ? itemsQuery : await itemsQuery.whereNotIn('id', this.db('user_items').select('item_id').where('user_id', '=', userId));
		if (!items.length) return;

		perfTimer.push(`Processing ${items.length} items`);

		for (const item of items) {
			if (!('name' in item) || !('id' in item)) throw new Error('item.id and item.name must be set');

			await this.withTransaction(async () => {
				try {
					await super.save({
						user_id: userId,
						item_id: item.id,
					}, options);

					if (this.models().item().shouldRecordChange(item.name)) {
						await this.models().change().recordChange({
							itemName: item.name,
							itemId: item.id,
							sourceUserId: userId,
							shareId: item.jop_share_id ?? '',
							previousItem: { jop_share_id: '' },
							type: ChangeType.Create,
							itemType: ItemType.UserItem,
						});
					}
				} catch (error) {
					if (!options.ignoreAlreadyExists || !isUniqueConstraintError(error)) {
						throw error;
					}
				}
			}, 'UserItemModel::addMulti');
		}

		perfTimer.pop();

		perfTimer.pop();
	}

	public async save(_userItem: UserItem, _options: SaveOptions = {}): Promise<UserItem> {
		throw new Error('Call add() or addMulti()');
	}

	public async delete(_id: string | string[], _options: DeleteOptions = {}): Promise<void> {
		throw new Error('Use one of the deleteBy methods');
	}

	private async deleteBy(options: UserItemDeleteOptions = {}): Promise<void> {
		options = {
			recordChanges: true,
			...options,
		};

		let userItems: UserItem[] = [];

		if (options.byShareId && options.byUserId) {
			userItems = await this.byShareAndUserId(options.byShareId, options.byUserId);
		} else if (options.byItemIds) {
			userItems = await this.byItemIds(options.byItemIds);
		} else if (options.byShareId) {
			userItems = await this.byShareId(options.byShareId);
		} else if (options.byShare) {
			userItems = await this.byShareId(options.byShare.id);
			userItems = userItems.filter(u => u.user_id !== options.byShare.owner_id);
		} else if (options.byUserId) {
			userItems = await this.byUserId(options.byUserId);
		} else if (options.byUserItem) {
			userItems = [options.byUserItem];
		} else if (options.byUserItemIds) {
			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
			userItems = await this.loadByIds(options.byUserItemIds as any);
		} else {
			throw new Error('Invalid options');
		}

		const itemIds = unique(userItems.map(ui => ui.item_id));
		const items = await this.models().item().loadByIds(itemIds, { fields: ['id', 'name', 'jop_share_id'] });

		await this.withTransaction(async () => {
			for (const userItem of userItems) {
				const item = items.find(i => i.id === userItem.item_id);

				// The item may have been deleted between the async calls above
				if (!item) continue;

				if (options.recordChanges && this.models().item().shouldRecordChange(item.name)) {
					const shareId = item.jop_share_id;
					await this.models().change().recordChange({
						itemName: item.name,
						itemId: userItem.item_id,
						sourceUserId: userItem.user_id,
						shareId,
						previousItem: { jop_share_id: shareId },
						type: ChangeType.Delete,
						itemType: ItemType.UserItem,
					});
				}
			}

			await this.db(this.tableName).whereIn('id', userItems.map(ui => ui.id)).delete();
		}, 'UserItemModel::delete');
	}

}
