import { resourceBlobPath } from '../utils/joplinUtils';
import { Change, ChangeType, Item, Share, ShareType, ShareUserStatus, User, Uuid } from '../services/database/types';
import { unique } from '../utils/array';
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from '../utils/errors';
import { setQueryParameters } from '../utils/urlUtils';
import BaseModel, { AclAction, DeleteOptions, LoadOptions, ValidateOptions } from './BaseModel';
import { userIdFromUserContentUrl } from '../utils/routeUtils';
import { getCanShareFolder } from './utils/user';
import { isUniqueConstraintError } from '../db';
import Logger from '@joplin/utils/Logger';
import { PerformanceTimer } from '../utils/time';

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

export default class ShareModel extends BaseModel<Share> {

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

	public async checkIfAllowed(user: User, action: AclAction, resource: Share = null): Promise<void> {
		if (action === AclAction.Create) {
			if (resource.type === ShareType.Folder && !getCanShareFolder(user)) throw new ErrorForbidden('The sharing feature is not enabled for this account');

			// Note that currently all users can always share notes by URL so
			// there's no check on the permission

			if (!await this.models().item().userHasItem(user.id, resource.item_id)) throw new ErrorForbidden('cannot share an item not owned by the user');

			if (resource.type === ShareType.Folder) {
				const item = await this.models().item().loadByJopId(user.id, resource.folder_id);
				if (item.jop_parent_id) throw new ErrorForbidden('A shared notebook must be at the root');
			}
		}

		if (action === AclAction.Read) {
			if (user.id !== resource.owner_id) throw new ErrorForbidden('no access to this share');
		}

		if (action === AclAction.Delete) {
			if (user.id !== resource.owner_id) throw new ErrorForbidden('no access to this share');
		}
	}

	public checkShareUrl(share: Share, shareUrl: string) {
		if (this.userContentBaseUrl === 'http://joplinusercontent.local:22300') return; // OK - testing
		if (this.baseUrl === this.userContentBaseUrl) return; // OK

		const userId = userIdFromUserContentUrl(shareUrl);
		const shareUserId = share.owner_id.toLowerCase();

		if (userId.length >= 10 && shareUserId.indexOf(userId) === 0) {
			// OK
		} else {
			throw new ErrorBadRequest('Invalid origin (User Content)');
		}
	}

	protected async objectToApiOutput(object: Share): Promise<Share> {
		const output: Share = {};

		if (object.id) output.id = object.id;
		if (object.type) output.type = object.type;
		if (object.folder_id) output.folder_id = object.folder_id;
		if (object.owner_id) output.owner_id = object.owner_id;
		if (object.note_id) output.note_id = object.note_id;
		if (object.master_key_id) output.master_key_id = object.master_key_id;

		return output;
	}

	protected async validate(share: Share, options: ValidateOptions = {}): Promise<Share> {
		if ('type' in share && ![ShareType.Note, ShareType.Folder].includes(share.type)) throw new ErrorBadRequest(`Invalid share type: ${share.type}`);
		if (share.type !== ShareType.Note && await this.itemIsShared(share.type, share.item_id)) throw new ErrorBadRequest('A shared item cannot be shared again');

		const item = await this.models().item().load(share.item_id);
		if (!item) throw new ErrorNotFound(`Could not find item: ${share.item_id}`);

		return super.validate(share, options);
	}

	public async createShare(userId: Uuid, shareType: ShareType, itemId: Uuid): Promise<Share> {
		const toSave: Share = {
			type: shareType,
			item_id: itemId,
			owner_id: userId,
		};

		return this.save(toSave);
	}

	public async itemShare(shareType: ShareType, itemId: string, options: LoadOptions = null): Promise<Share> {
		return this
			.db(this.tableName)
			.select(this.selectFields(options))
			.where('item_id', '=', itemId)
			.where('type', '=', shareType)
			.first();
	}

	public async itemIsShared(shareType: ShareType, itemId: string): Promise<boolean> {
		const r = await this.itemShare(shareType, itemId);
		return !!r;
	}

	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	public shareUrl(shareOwnerId: Uuid, id: Uuid, query: any = null): string {
		return setQueryParameters(`${this.personalizedUserContentBaseUrl(shareOwnerId)}/shares/${id}`, query);
	}

	public async linkedNoteShareUrl(share: Share, linkedNoteJopId: string): Promise<string | null> {
		if (share.recursive) return null;

		const noteItem = await this.models().item().loadByJopId(share.owner_id, linkedNoteJopId);
		if (!noteItem) return null;

		const noteShare = await this.itemShare(ShareType.Note, noteItem.id);
		if (!noteShare) return null;

		return this.shareUrl(noteShare.owner_id, noteShare.id);
	}

	public async byItemId(itemId: Uuid): Promise<Share | null> {
		const r = await this.byItemIds([itemId]);
		return r.length ? r[0] : null;
	}

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

	public async byItemAndRecursive(itemId: Uuid, recursive: boolean): Promise<Share | null> {
		return this.db(this.tableName)
			.select(this.defaultFields)
			.where('item_id', itemId)
			.where('recursive', recursive ? 1 : 0)
			.first();
	}

	public async byUserId(userId: Uuid, type: ShareType): Promise<Share[]> {
		const query1 = this
			.db(this.tableName)
			.select(this.defaultFields)
			.where('type', '=', type)
			.whereIn('id', this
				.db('share_users')
				.select('share_id')
				.where('user_id', '=', userId),
			);

		const query2 = this
			.db(this.tableName)
			.select(this.defaultFields)
			.where('type', '=', type)
			.where('owner_id', '=', userId);

		return query1.union(query2);
	}

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

	public async sharesByUser(userId: Uuid, type: ShareType = null): Promise<Share[]> {
		const query = this.db(this.tableName)
			.select(this.defaultFields)
			.where('owner_id', '=', userId);

		if (type) void query.andWhere('type', '=', type);

		return query;
	}

	public async participatedSharesByUser(userId: Uuid, type: ShareType = null): Promise<Share[]> {
		const query = this.db(this.tableName)
			.select(this.defaultFields)
			.whereIn('id', this.db('share_users')
				.select('share_id')
				.where('user_id', '=', userId)
				.andWhere('status', '=', ShareUserStatus.Accepted,
				));

		if (type) void query.andWhere('type', '=', type);

		return query;
	}

	// Returns all user IDs concerned by the share. That includes all the users
	// the folder has been shared with, as well as the folder owner.
	public async allShareUserIds(share: Share): Promise<Uuid[]> {
		const shareUsers = await this.models().shareUser().byShareId(share.id, ShareUserStatus.Accepted);
		const userIds = shareUsers.map(su => su.user_id);
		userIds.push(share.owner_id);
		return userIds;
	}

	public async updateSharedItems3() {
		const perfTimer = new PerformanceTimer(logger, 'updateSharedItems3');

		const addUserItem = async (shareUserId: Uuid, itemId: Uuid) => {
			try {
				await this.models().userItem().add(shareUserId, itemId, { queryContext: { uniqueConstraintErrorLoggingDisabled: true } });
			} catch (error) {
				if (!isUniqueConstraintError(error)) throw error;
			}
		};

		const removeUserItem = async (shareUserId: Uuid, itemId: Uuid) => {
			try {
				await this.models().userItem().remove(shareUserId, itemId);
			} catch (error) {
				if (error.httpCode === ErrorNotFound.httpCode) {
					logger.warn('Could not remove a user item because it has already been removed:', error);
				} else {
					throw error;
				}
			}
		};

		// For performance, handleCreated acts on all changes for a particular item at once.
		//
		// This function must behave correctly regardless of whether it is called before or after
		// other events are processed.
		const handleCreated = async (item: Item, changes: Change[], share: Share) => {
			if (!item.jop_share_id) return;

			// When a folder is unshared, the share object is deleted, then all
			// items that were shared get their 'share_id' property set to an
			// empty string. This is all done client side.
			//
			// However it means that if a share object is deleted but the items
			// are not synced, we'll find items that are associated with a share
			// that no longer exists. This is fine, but we need to handle it
			// properly below, otherwise the share update process will fail.

			if (!share) {
				logger.warn(`Found an item (${item.id}) associated with a share that no longer exists (${item.jop_share_id}) - skipping it`);
				return;
			}

			perfTimer.push('handleCreated');

			const shareUserIds = await this.allShareUserIds(share);
			for (const shareUserId of shareUserIds) {
				const hasCreationEvent = changes.some(change => change.user_id === shareUserId);
				if (hasCreationEvent) continue;

				await addUserItem(shareUserId, item.id);
			}

			perfTimer.pop();
		};

		const getPreviousShareId = (change: Change) => {
			return this.models().change().unserializePreviousItem(change.previous_item)?.jop_share_id;
		};

		const handleUpdated = async (change: Change, item: Item, share: Share, nextShareId: Uuid) => {
			const previousShareId = getPreviousShareId(change);
			const shareId = share ? share.id : '';

			const changesShareId = previousShareId !== nextShareId;
			if (previousShareId === shareId || !changesShareId) {
				return;
			}

			perfTimer.push('handleUpdated');

			try {
				const previousShare = previousShareId ? await this.models().share().load(previousShareId) : null;

				if (previousShare) {
					const shareUserIds = await this.allShareUserIds(previousShare);
					for (const shareUserId of shareUserIds) {
						if (shareUserId === change.user_id) continue;
						await removeUserItem(shareUserId, item.id);
					}
				}

				if (share) {
					const shareUserIds = await this.allShareUserIds(share);
					for (const shareUserId of shareUserIds) {
						if (shareUserId === change.user_id) continue;
						await addUserItem(shareUserId, item.id);
					}
				}
			} finally {
				perfTimer.pop();
			}
		};

		const handleDeleted = async (change: Change, item: Item|null, share: Share|null) => {
			// On deletion, we check for extra user_items entries and incorrect ownership for
			// items that still exist:
			// - Unexpected user_items can be created by race conditions between updateSharedItems3
			//   and logic for removing users from a share.
			// - Outdated owner_id information can be caused by moving an item into a share,
			//   then removing the item's original owner from the share.
			//
			// For now, only check the case where the item exists, and thus the user_items entry
			// could allow access to the item.
			if (!item) return;

			perfTimer.push('handleDeleted');

			// If the userItem exists, the user still has access to the item, despite the deletion change:
			let userItem = await this.models().userItem().byUserAndItemId(change.user_id, change.item_id);
			if (userItem) {

				const isShareMember = async () => {
					if (!share) return false;
					const shareUsers = await this.allShareUserIds(share);
					return shareUsers.includes(change.user_id);
				};

				// Check if the user should still have access to the item. If not, the userItem was probably created
				// by a race condition (e.g. handleUpdated adding UserItems) and should be deleted.
				if (!await isShareMember()) {
					logger.warn('Deleting unexpected userItem for user', change.user_id, 'and share', item?.jop_share_id);

					// Delete by the UserItem's ID to avoid race conditions. If a new user item is created for the same
					// (user, item) pair (perhaps after removing the original), it should not be deleted by this task:
					await this.models().userItem().deleteByUserItemIds([userItem.id]);
					userItem = null;
				}

			}

			// If an item was deleted for the owner, and the owner no longer has access, the item should now be owned by
			// a different user:
			const deletedForOwner = item.owner_id === change.user_id;
			if (deletedForOwner && !userItem) {
				const userItems = await this.models().userItem().byItemIds([item.id]);
				const usersWithAccess = userItems.map(item => item.user_id);

				let newOwnerId;
				// Check that the share owner still has access: Handle the case where the item's parent share
				// is changed after the item and share have been loaded.
				if (share && usersWithAccess.includes(share.owner_id)) {
					// Case where the item was moved to a different share or the original owner was removed from the
					// share:
					newOwnerId = share.owner_id;
				} else if (usersWithAccess.length === 1) {
					// Case where the item was moved out of a share by a user that didn't previously own the item,
					// or the item's share was deleted:
					newOwnerId = usersWithAccess[0];
				} else {
					// May happen due to a race condition related to moving an item between shares
					// while processing the item's shares/deletions.
					logger.warn('handleDeleted: Unable to accurately fix owner_id for item', item.id, 'in share', share?.id, 'and users with access', usersWithAccess);
				}

				if (!newOwnerId) {
					logger.warn('handleDeleted: Item', item.id, 'deleted for owner', item.owner_id, 'and still exists, but no new owner ID was assigned.');
				} else {
					try {
						await this.models().item().saveForUser(newOwnerId, {
							id: item.id,
							owner_id: newOwnerId,
						}, { isNew: false });
					} catch (error) {
						// Guard against a potential race condition: Handle the case where the item was deleted for all users
						// during the share update process. Additionally, we should not throw an error here as that would
						// prevent the sharing service from running at all.
						logger.warn('handleDeleted: Unable to update owner_id on item', item.id, error);
					}
				}
			}

			perfTimer.pop();
		};

		// This function add any missing item to a user's collection. Normally
		// it shouldn't be necessary since items are added or removed based on
		// the Change events, but it seems it can happen anyway, possibly due to
		// a race condition somewhere. So this function corrects this by
		// re-assigning any missing items.
		//
		// It should be relatively quick to call since it's restricted to shares
		// that have recently changed, and the performed SQL queries are
		// index-based.
		const checkForMissingUserItems = async (shares: Share[]) => {
			perfTimer.push(`checkForMissingUserItems: ${shares.length} shares`);

			for (const share of shares) {
				const realShareItemCount = await this.itemCountByShareId(share.id);
				const shareItemCountPerUser = await this.itemCountByShareIdPerUser(share.id);
				const shareParticipants = await this.allShareUserIds(share);

				for (const row of shareItemCountPerUser) {
					if (row.item_count > 0 && !shareParticipants.includes(row.user_id)) {
						// It's possible for user_items entries to still exist as the result of a race
						// between loops that create user_items and the logic that deletes user_items
						// when removing a user from a share.
						//
						// This cleanup logic and handleDeleted are both responsible for cleaning up
						// after such a race condition. Both are important:
						// - This logic cleans up user_items related to deletions that occurred before
						//   the handleDeleted logic was added (around March 2026).
						// - handleDeleted handles some cases not handled here, but only applies to new changes.
						logger.warn(`checkForMissingUserItems: User ${row.user_id} has items but is not authorized for share ${share.id}. Cleaning up.`);
						await this.models().userItem().deleteByShareAndUserId(share.id, row.user_id);
						continue;
					}

					if (row.item_count < realShareItemCount) {
						logger.warn(`checkForMissingUserItems: User is missing some items: Share ${share.id}: User ${row.user_id}`);
						await this.createSharedFolderUserItems(share.id, row.user_id);
					} else if (row.item_count > realShareItemCount) {
						// Shouldn't be possible but log it just in case
						logger.warn(`checkForMissingUserItems: User has too many items (??): Share ${share.id}: User ${row.user_id}`);
					}
				}
			}

			perfTimer.pop();
		};

		const buildItemToChangeTypeMap = (changeType: ChangeType, changes: Change[]) => {
			const itemToChanges = new Map<Uuid, Change[]>();
			for (const change of changes) {
				if (change.type !== changeType) continue;

				const itemChanges = itemToChanges.get(change.item_id);
				if (itemChanges) {
					itemChanges.push(change);
				} else {
					itemToChanges.set(change.item_id, [change]);
				}
			}
			return itemToChanges;
		};

		// This loop essentially applies the change made by one user to all the
		// other users in the share.
		//
		// While it's processing changes, it's going to create new user_item
		// objects, which in turn generate more Change items, which are processed
		// again. However there are guards to ensure that it doesn't result in
		// an infinite loop - in particular once a user_item has been added,
		// adding it again will result in a UNIQUE constraint error and thus it
		// won't generate a Change object the second time.
		//
		// Rather than checking if the user_item exists before creating it, we
		// create it directly and let it fail, while catching the Unique error.
		// This is probably safer in terms of avoiding race conditions and
		// possibly faster.

		perfTimer.push('Main');

		while (true) {
			perfTimer.push('Get latestProcessedChange');
			const latestProcessedChange = await this.models().keyValue().value<string>('ShareService::latestProcessedChange');
			perfTimer.pop();

			perfTimer.push('Get paginated changes');
			const paginatedChanges = await this.models().change().allFromId(latestProcessedChange || '');
			perfTimer.pop();
			const changes = paginatedChanges.items;

			if (!changes.length) {
				perfTimer.push('Set latestProcessedChange');
				await this.models().keyValue().setValue('ShareService::latestProcessedChange', paginatedChanges.cursor);
				perfTimer.pop();
			} else {
				perfTimer.push(`Load items for ${changes.length} changes`);
				const items = await this.models().item().loadByIds(changes.map(c => c.item_id));
				perfTimer.pop();
				const shareIds = unique(items.filter(i => !!i.jop_share_id).map(i => i.jop_share_id));

				perfTimer.push(`Load ${shareIds.length} shares`);
				const shares = await this.models().share().loadByIds(shareIds);
				perfTimer.pop();

				perfTimer.push('Change processing transaction');
				await this.withTransaction(async () => {
					perfTimer.push(`Processing ${changes.length} changes`);

					// Performance: Group creation events per-item
					const itemToCreations = buildItemToChangeTypeMap(ChangeType.Create, changes);
					for (const [itemId, itemChanges] of itemToCreations.entries()) {
						const item = items.find(i => i.id === itemId);
						if (!item) continue;

						const itemShare = shares.find(s => s.id === item.jop_share_id);
						await handleCreated(item, itemChanges, itemShare);
					}

					const itemToUpdates = buildItemToChangeTypeMap(ChangeType.Update, changes);
					for (const change of changes) {
						const item = items.find(i => i.id === change.item_id);

						// Item associated with the change may have been
						// deleted, so take this into account.
						if (item) {
							const itemShare = shares.find(s => s.id === item.jop_share_id);

							if (change.type === ChangeType.Update) {
								const allUpdates = itemToUpdates.get(item.id);
								const changeIndex = allUpdates.indexOf(change);
								const nextChange = allUpdates[changeIndex + 1];

								let nextShareId;
								if (nextChange) {
									nextShareId = getPreviousShareId(nextChange);
								} else {
									nextShareId = item.jop_share_id;
								}

								await handleUpdated(change, item, itemShare, nextShareId);
							}

							// An item can still be found for a delete change, for example, if an item was removed from the share:
							if (change.type === ChangeType.Delete) {
								await handleDeleted(change, item, itemShare);
							}
						} else if (change.type === ChangeType.Delete) {
							await handleDeleted(change, null, null);
						}
					}

					await checkForMissingUserItems(shares);

					await this.models().keyValue().setValue('ShareService::latestProcessedChange', paginatedChanges.cursor);

					perfTimer.pop();
				}, 'ShareService::updateSharedItems3');
				perfTimer.pop();
			}

			if (!paginatedChanges.has_more) break;
		}

		perfTimer.pop();
	}

	public async updateResourceShareStatus(doShare: boolean, _shareId: Uuid, changerUserId: Uuid, toUserId: Uuid, resourceIds: string[]) {
		const resourceItems = await this.models().item().loadByJopIds(changerUserId, resourceIds);
		const resourceBlobNames = resourceIds.map(id => resourceBlobPath(id));
		const resourceBlobItems = await this.models().item().loadByNames(changerUserId, resourceBlobNames);

		for (const resourceItem of resourceItems) {
			if (doShare) {
				try {
					await this.models().userItem().add(toUserId, resourceItem.id, { queryContext: { uniqueConstraintErrorLoggingDisabled: true } });
				} catch (error) {
					if (isUniqueConstraintError(error)) {
						continue;
					}
					throw error;
				}
			} else {
				await this.models().userItem().remove(toUserId, resourceItem.id);
			}
		}

		for (const resourceBlobItem of resourceBlobItems) {
			if (doShare) {
				try {
					await this.models().userItem().add(toUserId, resourceBlobItem.id, { queryContext: { uniqueConstraintErrorLoggingDisabled: true } });
				} catch (error) {
					if (isUniqueConstraintError(error)) {
						continue;
					}
					throw error;
				}
			} else {
				await this.models().userItem().remove(toUserId, resourceBlobItem.id);
			}
		}
	}

	// The items that are added or removed from a share are processed by the
	// share service, and added as user_utems to each user. This function
	// however can be called after a user accept a share, or to correct share
	// errors, but re-assigning all items to a user.
	public async createSharedFolderUserItems(shareId: Uuid, userId: Uuid) {
		const query = this.models().item().byShareIdQuery(shareId, { fields: ['id', 'name'] });
		await this.models().userItem().addMulti(
			// Don't throw if a (user_id, item_id) pair already exists to avoid race conditions.
			userId, query, { ignoreAlreadyExists: true },
		);
	}

	public async shareFolder(owner: User, folderId: string, masterKeyId: string): Promise<Share> {
		const folderItem = await this.models().item().loadByJopId(owner.id, folderId);
		if (!folderItem) throw new ErrorNotFound(`No such folder: ${folderId}`);

		const share = await this.models().share().byUserAndItemId(owner.id, folderItem.id);
		if (share) return share;

		const shareToSave: Share = {
			type: ShareType.Folder,
			item_id: folderItem.id,
			owner_id: owner.id,
			folder_id: folderId,
			master_key_id: masterKeyId,
		};

		await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
		return super.save(shareToSave);
	}

	public async shareNote(owner: User, noteId: string, masterKeyId: string, recursive: boolean): Promise<Share> {
		const noteItem = await this.models().item().loadByJopId(owner.id, noteId);
		if (!noteItem) throw new ErrorNotFound(`No such note: ${noteId}`);

		const existingShare = await this.byItemAndRecursive(noteItem.id, recursive);
		if (existingShare) return existingShare;

		const shareToSave: Share = {
			type: ShareType.Note,
			item_id: noteItem.id,
			owner_id: owner.id,
			note_id: noteId,
			master_key_id: masterKeyId,
			recursive: recursive ? 1 : 0,
		};

		await this.checkIfAllowed(owner, AclAction.Create, shareToSave);

		return this.save(shareToSave);
	}

	public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
		const ids = typeof id === 'string' ? [id] : id;
		const shares = await this.loadByIds(ids);

		await this.withTransaction(async () => {
			for (const share of shares) {
				await this.models().shareUser().deleteByShare(share);
				await this.models().userItem().deleteByShare({ id: share.id, owner_id: share.owner_id });
				await super.delete(share.id, options);
			}
		}, 'ShareModel::delete');
	}

	public async deleteByUserId(userId: Uuid) {
		const shares = await this.sharesByUser(userId);

		await this.withTransaction(async () => {
			for (const share of shares) {
				await this.delete(share.id);
			}
		}, 'ShareModel::deleteByUserId');
	}

	public async itemCountByShareId(shareId: Uuid): Promise<number> {
		const r = await this
			.db('items')
			.count('id', { as: 'item_count' })
			.where('jop_share_id', '=', shareId);
		return r[0].item_count;
	}

	public async itemCountByShareIdPerUser(shareId: Uuid): Promise<{ item_count: number; user_id: Uuid }[]> {
		return this.db('user_items')
			.select(this.db.raw('user_id, count(user_id) as item_count'))
			.whereIn('item_id',
				this.db('items')
					.select('id')
					.where('jop_share_id', '=', shareId),
			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
			).groupBy('user_id') as any;
	}


}
