/* eslint-disable no-loop-func */
"use strict";

// Design note: We return stateLogs instead of passing in an object of registered handlers to call, because a node can become obsolete in mid-processing, and in those cases all of its state sets should be ignored. By far the easiest way to implement this, is to just keep a stateLog in the node handling context (since that entire context gets thrown away when processing gets aborted due to a subtree change), and let the parent deal with actually applying any still-relevant setStates to the correct handler functions.
// FIXME: Figure out a way to track 'loss factor' per optimizer, ie. how many (partial or complete) node evaluations have been discarded due to the actions of that optimizer, including subtrees. This can give insight into which optimizers cause unreasonably much wasted work.

const util = require("util");
const splitFilter = require("split-filter");
const mapObj = require("map-obj");
const defaultValue = require("default-value");

const NoChange = require("../../optimizers/util/no-change");
const RemoveNode = require("../../optimizers/util/remove-node");
const typeOf = require("../../type-of");
const concat = require("../../concat");
const deriveNode = require("../../derive-node");
const measureTime = require("../../measure-time");
const unreachable = require("../../unreachable");

const createHandlerTracker = require("./handler-tracker");
const createTimings = require("./timings-tracker");
const combineOptimizers = require("./combine-optimizers");
const createDebuggers = require("./create-debuggers");

// FIXME: Implement a scope tracker of some sort, to decouple the code here a bit more

let EVALUATION_LIMIT = 10;

function defer(func) {
	return { __type: "defer", func: func };
}

function handleNodeChildren(node, handle) {
	let changedProperties = {};
	let stateLogs = [];

	for (let [ property, value ] of Object.entries(node)) {
		if (value == null) {
			continue;
		} else if (value.__raqbASTNode === true) {
			let result = handle(value);

			if (result.stateLog.length > 0) {
				stateLogs.push(result.stateLog);
			}

			if (result.node !== value) {
				changedProperties[property] = result.node;
			}
		} else if (Array.isArray(value) && value.length > 0 && value[0].__raqbASTNode === true) {
			// NOTE: We assume that if an array in an AST node property contains one AST node, *all* of its items are AST nodes. This should be ensured by the input wrapping in the operations API.
			// eslint-disable-next-line no-loop-func
			let results = value.map((item) => handle(item));

			let newStateLogs = results
				.filter((result) => result.stateLog.length > 0)
				.map((result) => result.stateLog);

			if (newStateLogs.length > 0) {
				stateLogs.push(... newStateLogs);
			}

			let newNodes = results.map((result) => result.node);
			let hasChangedItems = newNodes.some((newNode, i) => newNode !== value[i]);

			if (hasChangedItems) {
				changedProperties[property] = newNodes.filter((item) => item !== RemoveNode);
			}
		} else {
			// Probably some kind of literal value; we don't touch these.
			continue;
		}
	}

	return {
		changedProperties: changedProperties,
		stateLog: concat(stateLogs)
	};
}

module.exports = function optimizeTree(ast, optimizers) {
	let debuggers = createDebuggers(optimizers);
	let visitors = combineOptimizers(optimizers);
	let timings = createTimings(optimizers);

	let visitorsByType = mapObj(visitors, (key, value) => {
		return [
			key,
			concat([
				defaultValue(value, []),
				defaultValue(visitors["*"], []),
			])
		];
	});

	function handleNode(node, iterations = 0) {
		// The stateLog contains a record of every setState call that was made during the handling of this node and its children. We keep a log for this rather than calling handlers directly, because setState calls should always apply to *ancestors*, not to the current node. That is, if the current node does a setState for `foo`, and also has a handler registered for `foo`, then that handler should not be called, but the `foo` handler in the *parent* node should be.
		// FIXME: Scope stateLog entries by optimizer name? To avoid name clashes for otherwise similar functionality. Like when multiple optimizers track column names.
		let stateLog = [];
		let defers = [];
		let handlers = createHandlerTracker();
		let nodeVisitors = visitorsByType[node.type];

		function handleResult({ debuggerName, result, permitDefer }) {
			if (result === NoChange) {
				// no-op
			} else if (result == null) {
				// FIXME: Improve this error so that it actually tells you in what visitor things broke
				throw new Error(`A visitor is not allowed to return null or undefined; if you intended to leave the node untouched, return a NoChange marker instead`);
			} else if (result === RemoveNode) {
				debuggers[debuggerName](`Node of type '${typeOf(node)}' removed`);
				return { node: RemoveNode, stateLog: [] };
			} else if (result.__type === "defer") {
				if (permitDefer) {
					debuggers[debuggerName](`Defer was scheduled for node of type '${typeOf(node)}'`);
					defers.push({ debuggerName, func: result.func });
				} else {
					throw new Error(`Cannot schedule a defer from within a defer handler`);
				}
			} else if (result.__raqbASTNode === true) {
				if (result === node) {
					// Visitor returned the original node again; but in this case, it should return NoChange instead. We enforce this because after future changes to the optimizer implementation (eg. using an internally-mutable deep copy of the tree), we may no longer be able to *reliably* detect when the original node is returned; so it's best to already get people into the habit of returning a NoChange marker in those cases, by disallowing this.
					throw new Error(`Visitor returned original node, but this may not work reliably; if you intended to leave the node untouched, return a NoChange marker instead`);
				} else {
					debuggers[debuggerName](`Node of type '${typeOf(node)}' replaced by node of type '${typeOf(result)}'`);

					if (iterations >= EVALUATION_LIMIT) {
						throw new Error(`Exceeded evaluation limit in optimizer ${debuggerName}; aborting optimization. If you are a user of raqb, please report this as a bug. If you are writing an optimizer, make sure that your optimizer eventually stabilizes on a terminal condition (ie. NoChange)!`);
					} else {
						return handleNode(result, iterations + 1);
					}
				}
			} else {
				throw new Error(`Visitor returned an unexpected type of return value: ${util.inspect(result)}`);
			}
		}

		function applyVisitorFunction({ visitorName, func, node, permitDefer }) {
			let { value: result, time } = measureTime(() => {
				return func(node, {
					// eslint-disable-next-line no-loop-func
					setState: (name, value) => {
						// FIXME: util.inspect is slow, and not necessary when debug mode is disabled
						debuggers[visitorName](`Setting state for '${name}' from node of type '${typeOf(node)}': ${util.inspect(value, { colors: true })}`);
						stateLog.push({ name, value });
					},
					registerStateHandler: (name, func) => handlers.add(name, func),
					defer: (permitDefer === true) ? defer : null
				});
			});

			timings[visitorName] += time;

			return result;
		}
		
		if (nodeVisitors != null) {
			for (let visitor of nodeVisitors) {
				let handled = handleResult({
					debuggerName: visitor.name,
					result: applyVisitorFunction({
						visitorName: visitor.name,
						func: visitor.func,
						node: node,
						permitDefer: true
					}),
					permitDefer: true
				});
		
				if (handled != null) {
					// Handling of the current node was aborted
					return handled;
				}
			}
		}
		
		let childResult = handleNodeChildren(node, handleNode);

		if (Object.keys(childResult.changedProperties).length > 0) {
			let newNode = deriveNode(node, childResult.changedProperties);
	
			// We already know that the new node is a different one, but let's just lead it through the same handleResult process, for consistency. Handling of the pre-child-changes node is aborted here, and we re-evaluate with the new node.
			return handleResult({
				debuggerName: "(subtree change)",
				result: newNode,
				permitDefer: false
			});
		}
	
		if (childResult.stateLog.length > 0) {
			let [ relevantState, otherState ] = splitFilter(childResult.stateLog, (entry) => handlers.has(entry.name));
			
			stateLog = stateLog.concat(otherState);

			for (let item of relevantState) {
				// FIXME: Log these, and which visitor they originate from
				handlers.call(item.name, item.value);
			}
		}
	
		for (let defer of defers) {
			let handled = handleResult({
				debuggerName: `${defer.debuggerName} (deferred)`,
				result: applyVisitorFunction({
					visitorName: defer.debuggerName,
					func: defer.func,
					node: node,
					permitDefer: false
				}),
				permitDefer: false
			});
	
			if (handled != null) {
				// Handling of the current node was aborted
				return handled;
			}
		}
	
		return {
			stateLog: stateLog,
			node: node
		};
	}

	let { value: rootResult, time } = measureTime(() => {
		return handleNode(ast);
	});
	
	let timeSpentInOptimizers = Object.values(timings).reduce((sum, n) => sum + n, 0);

	if (rootResult.node !== RemoveNode) {
		return {
			ast: rootResult.node,
			timings: {
				"# Total": time,
				"# Walker overhead": time - timeSpentInOptimizers,
				... timings,
			}
		};
	} else {
		unreachable("Root node was removed");
	}
};
