"use strict";

const matchValue = require("match-value");
const flatten = require("flatten");
const syncpipe = require("syncpipe");

const operations = require("../operations");
const internalOperations = require("../internal-operations");
const concat = require("../concat");
const typeOf = require("../type-of");
const NoChange = require("./util/no-change");

let parameterizableTypes = new Set([ "literalValue", "placeholder" ]);
let disallowedNestedTypes = new Set([ "_arrayOf" ]);

// FIXME: Have some sort of internally-cacheable way to find nodes of a certain type? So that different optimizer visitors don't need to filter the list of clauses over and over again...

function leftIdentity(left) {
	// NOTE: This uses JSON.stringify, since that gives us fast escaping for free; which is important to prevent bugs and/or injection-related security issues in the serialized names
	if (left.type === "field") {
		return `field:${JSON.stringify(left.name)}`;
	} else if (left.type === "remoteField") {
		return `remoteField:${JSON.stringify([ left.collection.name, left.field.name ])}`;
	} else if (left.type === "sqlExpression") {
		return `sqlExpression:${JSON.stringify(left.expression)}`;
	} else {
		return null;
	}
}

function createExpressionTracker() {
	let leftMapping = new Map();
	let hasSeenPossibleArray = false;

	return {
		addExpression: function (expression) {
			let identity = leftIdentity(expression.left);
			let conditionType = expression.condition.conditionType;

			if (!leftMapping.has(identity)) {
				leftMapping.set(identity, new Map());
			}

			let conditionTypeMapping = leftMapping.get(identity);

			if (!conditionTypeMapping.has(conditionType)) {
				conditionTypeMapping.set(conditionType, []);
			} else {
				// Both the left identity and conditionType match, so this can be turned into an array
				hasSeenPossibleArray = true;
			}

			// We store the entire original expression object, so that the new-node-generation code can pick out the expression metadata later. Since everything is grouped by identity and condition type, that code can just assume that the metadata of the first item in the list (if there's more than one) applies to *all* of the items in that list.
			conditionTypeMapping.get(conditionType).push(expression);
		},
		getMapping: function () {
			return leftMapping;
		},
		arrayIsPossible: function () {
			return hasSeenPossibleArray;
		}
	};
}

function createExpressionHandler(type) {
	// FIXME: Improve matchValue to distinguish between "arm not specified at all" and "arm holds undefined as a specified value", to deal with things like accidental operations.anyOfExpressions
	let expressionOperation = matchValue.literal(type, {
		"anyOfExpressions": operations.anyOf,
		"allOfExpressions": operations.allOf
	});

	let internalArrayType = matchValue(type, {
		"anyOfExpressions": "anyOf",
		"allOfExpressions": "allOf"
	});

	return function arrayifyPredicateList(node) {
		// FIXME: Also detect non-parameterizable cases like raw SQL!
		let tracker = createExpressionTracker();

		for (let item of node.items) {
			// Only regular expressions can be arrayified, not {all,any}OfExpressions, which will get visited by this optimizer later on anyway
			if (item.type === "expression") { // FIXME: typeOf
				if (disallowedNestedTypes.has(typeOf(item.condition.expression))) { // FIXME: Check that this full path is always valid
					// Abort immediately, we have encountered a previously-processed array which means we cannot nest any further.
					return NoChange;
				} else {
					tracker.addExpression(item);
				}
			}
		}

		if (tracker.arrayIsPossible()) {
			let newExpressions = syncpipe(tracker, [
				(_) => _.getMapping(),
				(_) => Array.from(_.values()),
				(_) => _.map((conditionMapping) => syncpipe(conditionMapping, [
					(_) => Array.from(_.entries()),
					(_) => _.map(([ conditionType, expressions ]) => {
						if (expressions.length === 1) {
							return expressions[0];
						} else {
							let allValues = expressions.map((expression) => expression.condition.expression);
							let canBeParameterized = allValues.every((value) => parameterizableTypes.has(typeOf(value)));
							
							return operations.expression({
								left: expressions[0].left,
								condition: internalOperations._condition({
									type: conditionType,
									expression: internalOperations._arrayOf({
										type: internalArrayType,
										canBeParameterized: canBeParameterized,
										items: allValues
									})
								})
							});
						}
					})
				])),
				(_) => flatten(_)
			]);

			let untouchedExpressions = node.items.filter((item) => item.type !== "expression");

			return expressionOperation(concat([
				newExpressions,
				untouchedExpressions
			]));
		} else {
			return NoChange;
		}
	};
}

function createValueHandler(type) {
	let internalArrayType = matchValue(type, {
		"anyOfValues": "anyOf",
		"allOfValues": "allOf"
	});

	return function arrayifyValueList(node) {
		if (!Array.isArray(node.items)) {
			// We only handle the placeholder / single SQL fragment / etc. special cases here, not the usual list-of-items cases
			return internalOperations._arrayOf({
				type: internalArrayType,
				canBeParameterized: parameterizableTypes.has(typeOf(node.items)),
				items: node.items
			});
		} else {
			return NoChange;
		}
	};
}

module.exports = {
	name: "arrayify-predicate-lists",
	category: [ "readability" ],
	visitors: {
		allOfExpressions: createExpressionHandler("allOfExpressions"),
		anyOfExpressions: createExpressionHandler("anyOfExpressions"),
		allOfValues: createValueHandler("allOfValues"),
		anyOfValues: createValueHandler("anyOfValues"),
	}
};
