
'use strict';

const async = require('async');
const validator = require('validator');
const _ = require('lodash');

const db = require('../database');
const meta = require('../meta');
const user = require('../user');
const categories = require('../categories');
const plugins = require('../plugins');
const privileges = require('../privileges');
const notifications = require('../notifications');
const translator = require('../translator');
const utils = require('../utils');
const batch = require('../batch');
const cache = require('../cache');

module.exports = function (Topics) {
	Topics.createTags = async function (tags, tid, timestamp) {
		if (!Array.isArray(tags) || !tags.length) {
			return;
		}

		const cid = await Topics.getTopicField(tid, 'cid');
		const topicSets = tags.map(tag => `tag:${tag}:topics`).concat(
			tags.map(tag => `cid:${cid}:tag:${tag}:topics`)
		);
		await db.sortedSetsAdd(topicSets, timestamp, tid);
		await Topics.updateCategoryTagsCount([cid], tags);
		await Promise.all(tags.map(updateTagCount));
	};

	Topics.filterTags = async function (tags, cid) {
		const result = await plugins.hooks.fire('filter:tags.filter', { tags: tags, cid: cid });
		tags = _.uniq(result.tags)
			.map(tag => utils.cleanUpTag(tag, meta.config.maximumTagLength))
			.filter(tag => tag && tag.length >= (meta.config.minimumTagLength || 3));

		return await filterCategoryTags(tags, cid);
	};

	Topics.updateCategoryTagsCount = async function (cids, tags) {
		await Promise.all(cids.map(async (cid) => {
			const counts = await db.sortedSetsCard(
				tags.map(tag => `cid:${cid}:tag:${tag}:topics`)
			);
			const tagToCount = _.zipObject(tags, counts);
			const set = `cid:${cid}:tags`;

			const bulkAdd = tags.filter(tag => tagToCount[tag] > 0)
				.map(tag => [set, tagToCount[tag], tag]);

			const bulkRemove = tags.filter(tag => tagToCount[tag] <= 0)
				.map(tag => [set, tag]);

			await Promise.all([
				db.sortedSetAddBulk(bulkAdd),
				db.sortedSetRemoveBulk(bulkRemove),
			]);
		}));

		await db.sortedSetsRemoveRangeByScore(
			cids.map(cid => `cid:${cid}:tags`), '-inf', 0
		);
	};

	Topics.validateTags = async function (tags, cid, uid, tid = null) {
		if (!Array.isArray(tags)) {
			throw new Error('[[error:invalid-data]]');
		}
		tags = _.uniq(tags);
		const [categoryData, isPrivileged, currentTags] = await Promise.all([
			categories.getCategoryFields(cid, ['minTags', 'maxTags']),
			user.isPrivileged(uid),
			tid ? Topics.getTopicTags(tid) : [],
		]);
		if (tags.length < parseInt(categoryData.minTags, 10)) {
			throw new Error(`[[error:not-enough-tags, ${categoryData.minTags}]]`);
		} else if (tags.length > parseInt(categoryData.maxTags, 10)) {
			throw new Error(`[[error:too-many-tags, ${categoryData.maxTags}]]`);
		}

		const addedTags = tags.filter(tag => !currentTags.includes(tag));
		const removedTags = currentTags.filter(tag => !tags.includes(tag));
		const systemTags = (meta.config.systemTags || '').split(',');

		if (!isPrivileged && systemTags.length && addedTags.length && addedTags.some(tag => systemTags.includes(tag))) {
			throw new Error('[[error:cant-use-system-tag]]');
		}

		if (!isPrivileged && systemTags.length && removedTags.length && removedTags.some(tag => systemTags.includes(tag))) {
			throw new Error('[[error:cant-remove-system-tag]]');
		}
	};

	async function filterCategoryTags(tags, cid) {
		const tagWhitelist = await categories.getTagWhitelist([cid]);
		if (!Array.isArray(tagWhitelist[0]) || !tagWhitelist[0].length) {
			return tags;
		}
		const whitelistSet = new Set(tagWhitelist[0]);
		return tags.filter(tag => whitelistSet.has(tag));
	}

	Topics.createEmptyTag = async function (tag) {
		if (!tag) {
			throw new Error('[[error:invalid-tag]]');
		}
		if (tag.length < (meta.config.minimumTagLength || 3)) {
			throw new Error('[[error:tag-too-short]]');
		}
		const isMember = await db.isSortedSetMember('tags:topic:count', tag);
		if (!isMember) {
			await db.sortedSetAdd('tags:topic:count', 0, tag);
			cache.del('tags:topic:count');
		}
		const allCids = await categories.getAllCidsFromSet('categories:cid');
		const isMembers = await db.isMemberOfSortedSets(
			allCids.map(cid => `cid:${cid}:tags`), tag
		);
		const bulkAdd = allCids.filter((cid, index) => !isMembers[index])
			.map(cid => ([`cid:${cid}:tags`, 0, tag]));
		await db.sortedSetAddBulk(bulkAdd);
	};

	Topics.renameTags = async function (data) {
		for (const tagData of data) {
			// eslint-disable-next-line no-await-in-loop
			await renameTag(tagData.value, tagData.newName);
		}
	};

	async function renameTag(tag, newTagName) {
		if (!newTagName || tag === newTagName) {
			return;
		}
		newTagName = utils.cleanUpTag(newTagName, meta.config.maximumTagLength);

		await Topics.createEmptyTag(newTagName);
		const allCids = {};

		await batch.processSortedSet(`tag:${tag}:topics`, async (tids) => {
			const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid', 'tags']);
			const cids = topicData.map(t => t.cid);
			topicData.forEach((t) => { allCids[t.cid] = true; });
			const scores = await db.sortedSetScores(`tag:${tag}:topics`, tids);
			// update tag:<tag>:topics
			await db.sortedSetAdd(`tag:${newTagName}:topics`, scores, tids);
			await db.sortedSetRemove(`tag:${tag}:topics`, tids);

			// update cid:<cid>:tag:<tag>:topics
			await db.sortedSetAddBulk(topicData.map(
				(t, index) => [`cid:${t.cid}:tag:${newTagName}:topics`, scores[index], t.tid]
			));
			await db.sortedSetRemove(cids.map(cid => `cid:${cid}:tag:${tag}:topics`), tids);

			// update 'tags' field in topic hash
			topicData.forEach((topic) => {
				topic.tags = topic.tags.map(tagItem => tagItem.value);
				const index = topic.tags.indexOf(tag);
				if (index !== -1) {
					topic.tags.splice(index, 1, newTagName);
				}
			});
			await db.setObjectBulk(
				topicData.map(t => [`topic:${t.tid}`, { tags: t.tags.join(',') }]),
			);
		}, {});
		const followers = await db.getSortedSetRangeWithScores(`tag:${tag}:followers`, 0, -1);
		if (followers.length) {
			const userKeys = followers.map(item => `uid:${item.value}:followed_tags`);
			const scores = await db.sortedSetsScore(userKeys, tag);
			await db.sortedSetsRemove(userKeys, tag);
			await db.sortedSetsAdd(userKeys, scores, newTagName);
			await db.sortedSetAdd(
				`tag:${newTagName}:followers`,
				followers.map(item => item.score),
				followers.map(item => item.value),
			);
		}
		await Topics.deleteTag(tag);
		await updateTagCount(newTagName);
		await Topics.updateCategoryTagsCount(Object.keys(allCids), [newTagName]);
	}

	async function updateTagCount(tag) {
		const count = await Topics.getTagTopicCount(tag);
		await db.sortedSetAdd('tags:topic:count', count || 0, tag);
		cache.del('tags:topic:count');
	}

	Topics.getTagTids = async function (tag, start, stop) {
		const tids = await db.getSortedSetRevRange(`tag:${tag}:topics`, start, stop);
		const payload = await plugins.hooks.fire('filter:topics.getTagTids', { tag, start, stop, tids });
		return payload.tids;
	};

	Topics.getTagTidsByCids = async function (tag, cids, start, stop) {
		const keys = cids.map(cid => `cid:${cid}:tag:${tag}:topics`);
		const tids = await db.getSortedSetRevRange(keys, start, stop);
		const payload = await plugins.hooks.fire('filter:topics.getTagTidsByCids', { tag, cids, start, stop, tids });
		return payload.tids;
	};

	Topics.getTagTopicCount = async function (tag, cids = []) {
		let count = 0;
		if (cids.length) {
			count = await db.sortedSetsCardSum(
				cids.map(cid => `cid:${cid}:tag:${tag}:topics`)
			);
		} else {
			count = await db.sortedSetCard(`tag:${tag}:topics`);
		}

		const payload = await plugins.hooks.fire('filter:topics.getTagTopicCount', { tag, count, cids });
		return payload.count;
	};

	Topics.deleteTags = async function (tags) {
		if (!Array.isArray(tags) || !tags.length) {
			return;
		}
		await Promise.all([
			removeTagsFromTopics(tags),
			removeTagsFromUsers(tags),
		]);
		const keys = tags.map(tag => `tag:${tag}:topics`);
		await db.deleteAll(keys);
		await db.sortedSetRemove('tags:topic:count', tags);
		cache.del('tags:topic:count');
		const cids = await categories.getAllCidsFromSet('categories:cid');

		await db.sortedSetRemove(cids.map(cid => `cid:${cid}:tags`), tags);

		const deleteKeys = [];
		tags.forEach((tag) => {
			deleteKeys.push(`tag:${tag}`);
			deleteKeys.push(`tag:${tag}:followers`);
			cids.forEach((cid) => {
				deleteKeys.push(`cid:${cid}:tag:${tag}:topics`);
			});
		});
		await db.deleteAll(deleteKeys);
	};

	async function removeTagsFromTopics(tags) {
		await async.eachLimit(tags, 50, async (tag) => {
			const tids = await db.getSortedSetRange(`tag:${tag}:topics`, 0, -1);
			if (!tids.length) {
				return;
			}
			let topicsTags = await Topics.getTopicsTags(tids);
			topicsTags = topicsTags.map(
				topicTags => topicTags.filter(topicTag => topicTag && topicTag !== tag)
			);

			await db.setObjectBulk(
				tids.map((tid, index) => ([
					`topic:${tid}`, { tags: topicsTags[index].join(',') },
				]))
			);
		});
	}

	async function removeTagsFromUsers(tags) {
		await async.eachLimit(tags, 50, async (tag) => {
			const uids = await db.getSortedSetRange(`tag:${tag}:followers`, 0, -1);
			await db.sortedSetsRemove(uids.map(uid => `uid:${uid}:followed_tags`), tag);
		});
	}

	Topics.deleteTag = async function (tag) {
		await Topics.deleteTags([tag]);
	};

	Topics.getTags = async function (start, stop) {
		return await getFromSet('tags:topic:count', start, stop);
	};

	Topics.getCategoryTags = async function (cids, start, stop) {
		if (Array.isArray(cids)) {
			return await db.getSortedSetRevUnion({
				sets: cids.map(cid => `cid:${cid}:tags`),
				start,
				stop,
			});
		}
		return await db.getSortedSetRevRange(`cid:${cids}:tags`, start, stop);
	};

	Topics.getCategoryTagsData = async function (cids, start, stop) {
		return await getFromSet(
			Array.isArray(cids) ? cids.map(cid => `cid:${cid}:tags`) : `cid:${cids}:tags`,
			start,
			stop
		);
	};

	async function getFromSet(set, start, stop) {
		let tags;
		if (Array.isArray(set)) {
			tags = await db.getSortedSetRevUnion({
				sets: set,
				start,
				stop,
				withScores: true,
			});
		} else {
			tags = await db.getSortedSetRevRangeWithScores(set, start, stop);
		}

		const payload = await plugins.hooks.fire('filter:tags.getAll', {
			tags: tags,
		});
		return Topics.getTagData(payload.tags);
	}

	Topics.getTagData = function (tags) {
		if (!tags || !tags.length) {
			return [];
		}
		tags.forEach((tag) => {
			tag.valueEscaped = validator.escape(String(tag.value));
			tag.valueEncoded = encodeURIComponent(tag.valueEscaped);
			tag.class = tag.valueEscaped.replace(/\s/g, '-');
		});
		return tags;
	};

	Topics.getTopicTags = async function (tid) {
		const data = await Topics.getTopicsTags([tid]);
		return data && data[0];
	};

	Topics.getTopicsTags = async function (tids) {
		const topicTagData = await Topics.getTopicsFields(tids, ['tags']);
		return tids.map((tid, i) => topicTagData[i].tags.map(tagData => tagData.value));
	};

	Topics.getTopicTagsObjects = async function (tid) {
		const data = await Topics.getTopicsTagsObjects([tid]);
		return Array.isArray(data) && data.length ? data[0] : [];
	};

	Topics.getTopicsTagsObjects = async function (tids) {
		const topicTags = await Topics.getTopicsTags(tids);
		const uniqueTopicTags = _.uniq(_.flatten(topicTags));

		const tags = uniqueTopicTags.map(tag => ({ value: tag }));
		const tagData = Topics.getTagData(tags);
		const tagDataMap = _.zipObject(uniqueTopicTags, tagData);

		topicTags.forEach((tags, index) => {
			if (Array.isArray(tags)) {
				topicTags[index] = tags.map(tag => tagDataMap[tag]);
			}
		});

		return topicTags;
	};

	Topics.addTags = async function (tags, tids) {
		const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid', 'timestamp', 'tags']);
		const bulkAdd = [];
		const bulkSet = [];
		topicData.forEach((t) => {
			const topicTags = t.tags.map(tagItem => tagItem.value);
			tags.forEach((tag) => {
				bulkAdd.push([`tag:${tag}:topics`, t.timestamp, t.tid]);
				bulkAdd.push([`cid:${t.cid}:tag:${tag}:topics`, t.timestamp, t.tid]);
				if (!topicTags.includes(tag)) {
					topicTags.push(tag);
				}
			});
			bulkSet.push([`topic:${t.tid}`, { tags: topicTags.join(',') }]);
		});
		await Promise.all([
			db.sortedSetAddBulk(bulkAdd),
			db.setObjectBulk(bulkSet),
		]);

		await Promise.all(tags.map(updateTagCount));
		await Topics.updateCategoryTagsCount(_.uniq(topicData.map(t => t.cid)), tags);
	};

	Topics.removeTags = async function (tags, tids) {
		const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid', 'tags']);
		const bulkRemove = [];
		const bulkSet = [];

		topicData.forEach((t) => {
			const topicTags = t.tags.map(tagItem => tagItem.value);
			tags.forEach((tag) => {
				bulkRemove.push([`tag:${tag}:topics`, t.tid]);
				bulkRemove.push([`cid:${t.cid}:tag:${tag}:topics`, t.tid]);
				if (topicTags.includes(tag)) {
					topicTags.splice(topicTags.indexOf(tag), 1);
				}
			});
			bulkSet.push([`topic:${t.tid}`, { tags: topicTags.join(',') }]);
		});
		await Promise.all([
			db.sortedSetRemoveBulk(bulkRemove),
			db.setObjectBulk(bulkSet),
		]);

		await Promise.all(tags.map(updateTagCount));
		await Topics.updateCategoryTagsCount(_.uniq(topicData.map(t => t.cid)), tags);
	};

	Topics.updateTopicTags = async function (tid, tags) {
		await Topics.deleteTopicTags(tid);
		const cid = await Topics.getTopicField(tid, 'cid');

		tags = await Topics.filterTags(tags, cid);
		await Topics.addTags(tags, [tid]);
		plugins.hooks.fire('action:topic.updateTags', { tags, tid });
	};

	Topics.deleteTopicTags = async function (tid) {
		const topicData = await Topics.getTopicFields(tid, ['cid', 'tags']);
		const { cid } = topicData;
		const tags = topicData.tags.map(tagItem => tagItem.value);
		await db.deleteObjectField(`topic:${tid}`, 'tags');

		const sets = tags.map(tag => `tag:${tag}:topics`)
			.concat(tags.map(tag => `cid:${cid}:tag:${tag}:topics`));
		await db.sortedSetsRemove(sets, tid);

		await Topics.updateCategoryTagsCount([cid], tags);
		await Promise.all(tags.map(updateTagCount));
	};

	Topics.searchTags = async function (data) {
		if (!data || !data.query) {
			return [];
		}
		let result;
		if (plugins.hooks.hasListeners('filter:topics.searchTags')) {
			result = await plugins.hooks.fire('filter:topics.searchTags', { data: data });
		} else {
			result = await findMatches(data);
		}
		result = await plugins.hooks.fire('filter:tags.search', { data: data, matches: result.matches });
		return result.matches;
	};

	Topics.autocompleteTags = async function (data) {
		if (!data || !data.query) {
			return [];
		}
		let result;
		if (plugins.hooks.hasListeners('filter:topics.autocompleteTags')) {
			result = await plugins.hooks.fire('filter:topics.autocompleteTags', { data: data });
		} else {
			result = await findMatches(data);
		}
		return result.matches;
	};

	async function getAllTags() {
		const cached = cache.get('tags:topic:count');
		if (cached !== undefined) {
			return cached;
		}
		const tags = await db.getSortedSetRevRangeWithScores('tags:topic:count', 0, -1);
		cache.set('tags:topic:count', tags);
		return tags;
	}

	async function findMatches(data) {
		let { query } = data;
		let tagWhitelist = [];
		if (parseInt(data.cid, 10)) {
			tagWhitelist = await categories.getTagWhitelist([data.cid]);
		}
		let tags = [];
		if (Array.isArray(tagWhitelist[0]) && tagWhitelist[0].length) {
			const scores = await db.sortedSetScores(`cid:${data.cid}:tags`, tagWhitelist[0]);
			tags = tagWhitelist[0].map((tag, index) => ({ value: tag, score: scores[index] }));
		} else if (data.cids) {
			tags = await db.getSortedSetRevUnion({
				sets: data.cids.map(cid => `cid:${cid}:tags`),
				start: 0,
				stop: -1,
				withScores: true,
			});
		} else {
			tags = await getAllTags();
		}

		query = query.toLowerCase();

		const matches = [];
		for (let i = 0; i < tags.length; i += 1) {
			if (tags[i].value && tags[i].value.toLowerCase().startsWith(query)) {
				matches.push(tags[i]);
				if (matches.length > 39) {
					break;
				}
			}
		}

		matches.sort((a, b) => {
			if (a.value < b.value) {
				return -1;
			} else if (a.value > b.value) {
				return 1;
			}
			return 0;
		});
		return { matches: matches };
	}

	Topics.searchAndLoadTags = async function (data) {
		const searchResult = {
			tags: [],
			matchCount: 0,
			pageCount: 1,
		};

		if (!data || !data.query || !data.query.length) {
			return searchResult;
		}
		const tags = await Topics.searchTags(data);

		const tagData = Topics.getTagData(tags.map(tag => ({ value: tag.value })));

		tagData.forEach((tag, index) => {
			tag.score = tags[index].score;
		});
		tagData.sort((a, b) => b.score - a.score);
		searchResult.tags = tagData;
		searchResult.matchCount = tagData.length;
		searchResult.pageCount = 1;
		return searchResult;
	};

	Topics.getRelatedTopics = async function (topicData, uid) {
		if (plugins.hooks.hasListeners('filter:topic.getRelatedTopics')) {
			const result = await plugins.hooks.fire('filter:topic.getRelatedTopics', { topic: topicData, uid: uid, topics: [] });
			return result.topics;
		}

		let maximumTopics = meta.config.maximumRelatedTopics;
		if (maximumTopics === 0 || !topicData.tags || !topicData.tags.length) {
			return [];
		}

		maximumTopics = maximumTopics || 5;
		let tids = await Promise.all(topicData.tags.map(tag => Topics.getTagTids(tag.value, 0, 5)));
		tids = _.shuffle(_.uniq(_.flatten(tids))).slice(0, maximumTopics);
		const topics = await Topics.getTopics(tids, uid);
		return topics.filter(t => t && !t.deleted && parseInt(t.uid, 10) !== parseInt(uid, 10));
	};

	Topics.isFollowingTag = async function (tag, uid) {
		return await db.isSortedSetMember(`tag:${tag}:followers`, uid);
	};

	Topics.getTagFollowers = async function (tag, start = 0, stop = -1) {
		return await db.getSortedSetRange(`tag:${tag}:followers`, start, stop);
	};

	Topics.followTag = async (tag, uid) => {
		if (!(parseInt(uid, 10) > 0)) {
			throw new Error('[[error:not-logged-in]]');
		}
		const now = Date.now();
		await db.sortedSetAddBulk([
			[`tag:${tag}:followers`, now, uid],
			[`uid:${uid}:followed_tags`, now, tag],
		]);
		plugins.hooks.fire('action:tags.follow', { tag, uid });
	};

	Topics.unfollowTag = async (tag, uid) => {
		if (!(parseInt(uid, 10) > 0)) {
			throw new Error('[[error:not-logged-in]]');
		}
		await db.sortedSetRemoveBulk([
			[`tag:${tag}:followers`, uid],
			[`uid:${uid}:followed_tags`, tag],
		]);
		plugins.hooks.fire('action:tags.unfollow', { tag, uid });
	};

	Topics.notifyTagFollowers = async function (postData, exceptUid) {
		let { tags } = postData.topic;
		if (!tags.length) {
			return;
		}
		tags = tags.map(tag => tag.value);

		const [followersOfPoster, allFollowers] = await Promise.all([
			db.getSortedSetRange(`followers:${exceptUid}`, 0, -1),
			db.getSortedSetRange(tags.map(tag => `tag:${tag}:followers`), 0, -1),
		]);
		const followerSet = new Set(followersOfPoster);
		// filter out followers of the poster since they get a notification already
		let followers = _.uniq(allFollowers).filter(uid => !followerSet.has(uid) && uid !== String(exceptUid));
		followers = await privileges.topics.filterUids('topics:read', postData.topic.tid, followers);
		if (!followers.length) {
			return;
		}

		const { displayname } = postData.user;

		const notifBase = 'notifications:user-posted-topic-with-tag';
		let bodyShort = translator.compile(notifBase, displayname, postData.topic.title, tags[0]);
		if (tags.length === 2) {
			bodyShort = translator.compile(`${notifBase}-dual`, displayname, postData.topic.title, tags[0], tags[1]);
		} else if (tags.length === 3) {
			bodyShort = translator.compile(`${notifBase}-triple`, displayname, postData.topic.title, tags[0], tags[1], tags[2]);
		} else if (tags.length > 3) {
			bodyShort = translator.compile(`${notifBase}-multiple`, displayname, postData.topic.title, tags.join(', '));
		}

		const notification = await notifications.create({
			type: 'new-topic-with-tag',
			nid: `new_topic:tid:${postData.topic.tid}:uid:${exceptUid}`,
			bodyShort: bodyShort,
			bodyLong: postData.content,
			pid: postData.pid,
			path: `/post/${postData.pid}`,
			tid: postData.topic.tid,
			from: exceptUid,
		});
		notifications.push(notification, followers);
	};
};
