import './components/Activation.mjs';
import './components/AgentCap.mjs';
import './components/Block.mjs';
import './components/Connect.mjs';
import './components/Divider.mjs';
import './components/Marker.mjs';
import './components/Note.mjs';
import './components/Parallel.mjs';
import {
	cleanRenderPreResult,
	getComponents,
} from './components/BaseComponent.mjs';
import DOMWrapper from '../../core/DOMWrapper.mjs';
import EventObject from '../../core/EventObject.mjs';
import SVG from '../../svg/SVG.mjs';
import {mergeSets} from '../../core/ArrayUtilities.mjs';

function findExtremes(agentInfos, agentIDs) {
	let min = null;
	let max = null;
	agentIDs.forEach((id) => {
		const info = agentInfos.get(id);
		if(min === null || info.index < min.index) {
			min = info;
		}
		if(max === null || info.index > max.index) {
			max = info;
		}
	});
	return {
		left: min.id,
		right: max.id,
	};
}

function makeThemes(themes) {
	if(themes.length === 0) {
		throw new Error('Cannot render without a theme');
	}
	const themeMap = new Map();
	themes.forEach((theme) => {
		themeMap.set(theme.name, theme);
	});
	themeMap.set('', themes[0]);
	return themeMap;
}

let globalNamespace = 0;

function parseNamespace(namespace) {
	if(namespace === null) {
		return 'R' + (globalNamespace ++);
	}
	return namespace;
}

export default class Renderer extends EventObject {
	constructor({
		themes = [],
		namespace = null,
		components = null,
		document,
		textSizerFactory = null,
	} = {}) {
		super();

		this._bindMethods();

		this.state = {};
		this.width = 0;
		this.height = 0;
		this.themes = makeThemes(themes);
		this.themeBuilder = null;
		this.theme = null;
		this.namespace = parseNamespace(namespace);
		this.components = components || getComponents();
		this.svg = new SVG(new DOMWrapper(document), textSizerFactory);
		this.knownThemeDefs = new Set();
		this.knownTextFilterDefs = new Map();
		this.knownDefs = new Set();
		this.highlights = new Map();
		this.collapsed = new Set();
		this.currentHighlight = -1;
		this.buildStaticElements();
		this.components.forEach((component) => {
			component.makeState(this.state);
		});
	}

	_bindMethods() {
		this.separationStage = this.separationStage.bind(this);
		this.prepareMeasurementsStage =
			this.prepareMeasurementsStage.bind(this);
		this.renderStage = this.renderStage.bind(this);
		this.addThemeDef = this.addThemeDef.bind(this);
		this.addThemeTextDef = this.addThemeTextDef.bind(this);
		this.addDef = this.addDef.bind(this);
	}

	addTheme(theme) {
		this.themes.set(theme.name, theme);
	}

	buildStaticElements() {
		const {el} = this.svg;

		this.metaCode = this.svg.txt();
		this.themeDefs = el('defs');
		this.defs = el('defs');
		this.fullMask = el('mask').attrs({
			'id': this.namespace + 'FullMask',
			'maskUnits': 'userSpaceOnUse',
		});
		this.lineMask = el('mask').attrs({
			'id': this.namespace + 'LineMask',
			'maskUnits': 'userSpaceOnUse',
		});
		this.fullMaskReveal = el('rect').attr('fill', '#FFFFFF');
		this.lineMaskReveal = el('rect').attr('fill', '#FFFFFF');
		this.backgroundFills = el('g');
		this.agentLines = el('g')
			.attr('mask', 'url(#' + this.namespace + 'LineMask)');
		this.blocks = el('g');
		this.shapes = el('g');
		this.unmaskedShapes = el('g');
		this.title = this.svg.formattedText();

		this.svg.body.add(
			this.svg.el('metadata')
				.add(this.metaCode),
			this.themeDefs,
			this.defs,
			this.backgroundFills,
			this.title,
			this.unmaskedShapes,
			el('g')
				.attr('mask', 'url(#' + this.namespace + 'FullMask)')
				.add(
					this.agentLines,
					this.blocks,
					this.shapes,
				),
		);
	}

	addThemeDef(name, generator) {
		const namespacedName = this.namespace + name;
		if(!this.knownThemeDefs.has(name)) {
			this.knownThemeDefs.add(name);
			this.themeDefs.add(generator().attr('id', namespacedName));
		}
		return namespacedName;
	}

	addThemeTextDef(name, generator) {
		const namespacedName = this.namespace + name;
		if(!this.knownTextFilterDefs.has(name)) {
			const def = generator().attr('id', namespacedName);
			this.knownTextFilterDefs.set(name, def);
		}
		this.svg.registerTextFilter(name, namespacedName);
	}

	addDef(name, generator) {
		let nm = name;
		let gen = generator;

		if(typeof generator !== 'function') {
			nm = 'P' + this.knownDefs.size;
			gen = () => name;
		}

		const namespacedName = this.namespace + nm;
		if(!this.knownDefs.has(nm)) {
			this.knownDefs.add(nm);
			this.defs.add(gen().attr('id', namespacedName));
		}
		return namespacedName;
	}

	addSeparation(agentID1, agentID2, dist) {
		const info1 = this.agentInfos.get(agentID1);
		const info2 = this.agentInfos.get(agentID2);

		const d1 = info1.separations.get(agentID2) || 0;
		info1.separations.set(agentID2, Math.max(d1, dist));

		const d2 = info2.separations.get(agentID1) || 0;
		info2.separations.set(agentID1, Math.max(d2, dist));
	}

	checkHidden(stage) {
		const component = this.components.get(stage.type);
		const env = {
			agentInfos: this.agentInfos,
			components: this.components,
			renderer: this,
			state: this.state,
			textSizer: this.svg.textSizer,
			theme: this.theme,
		};

		const hide = component.shouldHide(stage, env) || {};

		const wasHidden = (this.hideNest > 0);
		this.hideNest += hide.nest || 0;
		const isHidden = (this.hideNest > 0);

		if(this.hideNest < 0) {
			throw new Error('Unexpected nesting in ' + stage.type);
		}
		if(wasHidden === isHidden) {
			return isHidden;
		} else {
			return Boolean(hide.self);
		}
	}

	separationStage(stage) {
		const agentSpaces = new Map();
		const agentIDs = this.visibleAgentIDs.slice();
		const seps = [];

		const addSpacing = (agentID, {left, right}) => {
			const current = agentSpaces.get(agentID);
			current.left = Math.max(current.left, left);
			current.right = Math.max(current.right, right);
		};

		const addSeparation = (agentID1, agentID2, dist) => {
			seps.push({agentID1, agentID2, dist});
		};

		this.agentInfos.forEach((agentInfo) => {
			const rad = agentInfo.currentRad;
			agentInfo.currentMaxRad = rad;
			agentSpaces.set(agentInfo.id, {left: rad, right: rad});
		});

		const env = {
			addSeparation,
			addSpacing,
			agentInfos: this.agentInfos,
			components: this.components,
			momentaryAgentIDs: agentIDs,
			renderer: this,
			state: this.state,
			textSizer: this.svg.textSizer,
			theme: this.theme,
			visibleAgentIDs: this.visibleAgentIDs,
		};

		const component = this.components.get(stage.type);
		if(!component) {
			throw new Error('Unknown component: ' + stage.type);
		}

		component.separationPre(stage, env);
		component.separation(stage, env);

		if(this.checkHidden(stage)) {
			return;
		}

		mergeSets(agentIDs, this.visibleAgentIDs);

		seps.forEach(({agentID1, agentID2, dist}) => {
			this.addSeparation(agentID1, agentID2, dist);
		});

		agentIDs.forEach((agentIDR) => {
			const infoR = this.agentInfos.get(agentIDR);
			const sepR = agentSpaces.get(agentIDR);
			infoR.maxRPad = Math.max(infoR.maxRPad, sepR.right);
			infoR.maxLPad = Math.max(infoR.maxLPad, sepR.left);
			agentIDs.forEach((agentIDL) => {
				const infoL = this.agentInfos.get(agentIDL);
				if(infoL.index >= infoR.index) {
					return;
				}
				const sepL = agentSpaces.get(agentIDL);
				this.addSeparation(
					agentIDR,
					agentIDL,
					sepR.left + sepL.right + this.theme.agentMargin,
				);
			});
		});
	}

	prepareMeasurementsStage(stage) {
		const env = {
			agentInfos: this.agentInfos,
			components: this.components,
			renderer: this,
			state: this.state,
			textSizer: this.svg.textSizer,
			theme: this.theme,
		};

		const component = this.components.get(stage.type);
		if(!component) {
			throw new Error('Unknown component: ' + stage.type);
		}

		component.prepareMeasurements(stage, env);
	}

	checkAgentRange(agentIDs, topY = 0) {
		if(agentIDs.length === 0) {
			return topY;
		}
		const {left, right} = findExtremes(this.agentInfos, agentIDs);
		const leftX = this.agentInfos.get(left).x;
		const rightX = this.agentInfos.get(right).x;
		let baseY = topY;
		this.agentInfos.forEach((agentInfo) => {
			if(agentInfo.x >= leftX && agentInfo.x <= rightX) {
				baseY = Math.max(baseY, agentInfo.latestY);
			}
		});
		return baseY;
	}

	markAgentRange(agentIDs, y) {
		if(agentIDs.length === 0) {
			return;
		}
		const {left, right} = findExtremes(this.agentInfos, agentIDs);
		const leftX = this.agentInfos.get(left).x;
		const rightX = this.agentInfos.get(right).x;
		this.agentInfos.forEach((agentInfo) => {
			if(agentInfo.x >= leftX && agentInfo.x <= rightX) {
				agentInfo.latestY = y;
			}
		});
	}

	drawAgentLine(agentInfo, toY) {
		if(
			agentInfo.latestYStart !== null &&
			toY > agentInfo.latestYStart
		) {
			this.agentLines.add(this.theme.renderAgentLine({
				className: 'agent-' + agentInfo.index + '-line',
				options: agentInfo.options,
				width: agentInfo.currentRad * 2,
				x: agentInfo.x,
				y0: agentInfo.latestYStart,
				y1: toY,
			}));
		}
	}

	addHighlightObject(line, o) {
		let list = this.highlights.get(line);
		if(!list) {
			list = [];
			this.highlights.set(line, list);
		}
		list.push(o);
	}

	forwardEvent(source, sourceEvent, forwardEvent, forwardArgs) {
		source.on(
			sourceEvent,
			this.trigger.bind(this, forwardEvent, forwardArgs),
		);
	}

	renderStage(stage) {
		this.agentInfos.forEach((agentInfo) => {
			const rad = agentInfo.currentRad;
			agentInfo.currentMaxRad = rad;
		});

		const envPre = {
			agentInfos: this.agentInfos,
			components: this.components,
			renderer: this,
			state: this.state,
			textSizer: this.svg.textSizer,
			theme: this.theme,
		};
		const component = this.components.get(stage.type);
		const result = component.renderPre(stage, envPre);
		const {agentIDs, topShift, asynchronousY} =
			cleanRenderPreResult(result, this.currentY);

		const topY = this.checkAgentRange(agentIDs, asynchronousY);

		const makeRegion = ({
			stageOverride = null,
			unmasked = false,
		} = {}) => {
			const o = this.svg.el('g').setClass('region');
			const targetStage = (stageOverride || stage);
			this.addHighlightObject(targetStage.ln, o);
			this.forwardEvent(o, 'mouseenter', 'mouseover', [targetStage]);
			this.forwardEvent(o, 'mouseleave', 'mouseout', [targetStage]);
			this.forwardEvent(o, 'click', 'click', [targetStage]);
			this.forwardEvent(o, 'dblclick', 'dblclick', [targetStage]);
			return o.attach(unmasked ? this.unmaskedShapes : this.shapes);
		};

		const env = {
			addDef: this.addDef,
			agentInfos: this.agentInfos,
			blockLayer: this.blocks,
			components: this.components,
			drawAgentLine: (agentID, toY, andStop = false) => {
				const agentInfo = this.agentInfos.get(agentID);
				this.drawAgentLine(agentInfo, toY);
				agentInfo.latestYStart = andStop ? null : toY;
			},
			fillLayer: this.backgroundFills,
			fullMaskLayer: this.fullMask,
			lineMaskLayer: this.lineMask,
			makeRegion,
			primaryY: topY + topShift,
			renderer: this,
			state: this.state,
			svg: this.svg,
			textSizer: this.svg.textSizer,
			theme: this.theme,
			topY,
		};

		let bottomY = topY;
		if(this.checkHidden(stage)) {
			env.primaryY = topY;
			component.renderHidden(stage, env);
		} else {
			bottomY = Math.max(bottomY, component.render(stage, env) || 0);
		}

		this.markAgentRange(agentIDs, bottomY);
		this.currentY = bottomY;
	}

	positionAgents() {
		// Map guarantees insertion-order iteration
		const orderedInfos = [];
		this.agentInfos.forEach((agentInfo) => {
			let currentX = 0;
			agentInfo.separations.forEach((dist, otherAgent) => {
				const otherAgentInfo = this.agentInfos.get(otherAgent);
				if(otherAgentInfo.index < agentInfo.index) {
					currentX = Math.max(currentX, otherAgentInfo.x + dist);
				}
			});
			agentInfo.x = currentX;
			orderedInfos.push(agentInfo);
		});

		let previousInfo = {x: 0};
		orderedInfos.reverse().forEach((agentInfo) => {
			let currentX = previousInfo.x;
			previousInfo = agentInfo;
			if(!agentInfo.anchorRight) {
				return;
			}
			agentInfo.separations.forEach((dist, otherAgent) => {
				const otherAgentInfo = this.agentInfos.get(otherAgent);
				if(otherAgentInfo.index > agentInfo.index) {
					currentX = Math.min(currentX, otherAgentInfo.x - dist);
				}
			});
			agentInfo.x = currentX;
		});

		this.agentInfos.forEach(({x, maxRPad, maxLPad}) => {
			this.minX = Math.min(this.minX, x - maxLPad);
			this.maxX = Math.max(this.maxX, x + maxRPad);
		});
	}

	buildAgentInfos(agents) {
		this.agentInfos = new Map();
		agents.forEach((agent, index) => {
			this.agentInfos.set(agent.id, {
				anchorRight: agent.anchorRight,
				currentActivation: 0,
				currentMaxRad: 0,
				currentRad: 0,
				formattedLabel: agent.formattedLabel,
				id: agent.id,
				index,
				isVirtualSource: agent.isVirtualSource,
				latestY: 0,
				latestYStart: null,
				maxLPad: 0,
				maxRPad: 0,
				options: agent.options,
				separations: new Map(),
				x: null,
			});
		});
	}

	updateBounds(stagesHeight) {
		const cx = (this.minX + this.maxX) / 2;
		const titleSize = this.svg.textSizer.measure(this.title);
		const titleY = ((titleSize.height > 0) ?
			(-this.theme.titleMargin - titleSize.height) : 0
		);
		this.title.set({x: cx, y: titleY});

		const halfTitleWidth = titleSize.width / 2;
		const margin = this.theme.outerMargin;
		const x0 = Math.min(this.minX, cx - halfTitleWidth) - margin;
		const x1 = Math.max(this.maxX, cx + halfTitleWidth) + margin;
		const y0 = titleY - margin;
		const y1 = stagesHeight + margin;

		this.width = x1 - x0;
		this.height = y1 - y0;

		const fullSize = {
			'height': this.height,
			'width': this.width,
			'x': x0,
			'y': y0,
		};

		this.fullMaskReveal.attrs(fullSize);
		this.lineMaskReveal.attrs(fullSize);

		this.svg.body.attr('viewBox', (
			x0 + ' ' + y0 + ' ' +
			this.width + ' ' + this.height
		));
	}

	_resetState() {
		this.components.forEach((component) => {
			component.resetState(this.state);
		});
		this.currentY = 0;
		this.hideNest = 0;
	}

	_resetAgentInfo() {
		this.agentInfos.forEach((agentInfo) => {
			agentInfo.currentActivation = 0;
			agentInfo.currentRad = 0;
		});
	}

	_reset(theme) {
		if(theme) {
			this.knownThemeDefs.clear();
			this.knownTextFilterDefs.clear();
			this.themeDefs.empty();
		}

		this.knownDefs.clear();
		this.highlights.clear();
		this.defs.empty();
		this.fullMask.empty();
		this.lineMask.empty();
		this.backgroundFills.empty();
		this.agentLines.empty();
		this.blocks.empty();
		this.shapes.empty();
		this.unmaskedShapes.empty();
		this.defs.add(
			this.fullMask.add(this.fullMaskReveal),
			this.lineMask.add(this.lineMaskReveal),
		);
		this._resetState();
	}

	setHighlight(line = null) {
		const ln = (line === null) ? -1 : line;
		if(this.currentHighlight === ln) {
			return;
		}
		if(this.highlights.has(this.currentHighlight)) {
			this.highlights.get(this.currentHighlight).forEach((o) => {
				o.delClass('focus');
			});
		}
		if(this.highlights.has(ln)) {
			this.highlights.get(ln).forEach((o) => {
				o.addClass('focus');
			});
		}
		this.currentHighlight = ln;
	}

	isCollapsed(line) {
		return this.collapsed.has(line);
	}

	setCollapseAll(collapsed) {
		if(collapsed) {
			throw new Error('Cannot collapse all');
		} else {
			if(this.collapsed.size === 0) {
				return false;
			}
			this.collapsed.clear();
		}
		return true;
	}

	_setCollapsed(line, collapsed) {
		if(typeof line !== 'number') {
			return false;
		}
		if(collapsed === this.isCollapsed(line)) {
			return false;
		}
		if(collapsed) {
			this.collapsed.add(line);
		} else {
			this.collapsed.delete(line);
		}
		return true;
	}

	setCollapsed(line, collapsed = true) {
		if(line === null) {
			return this.setCollapseAll(collapsed);
		}
		if(Array.isArray(line)) {
			return line
				.map((ln) => this._setCollapsed(ln, collapsed))
				.some((changed) => changed);
		}
		return this._setCollapsed(line, collapsed);
	}

	_switchTheme(name) {
		const oldThemeBuilder = this.themeBuilder;
		this.themeBuilder = this.getThemeNamed(name);
		if(this.themeBuilder !== oldThemeBuilder) {
			this.theme = this.themeBuilder.build(this.svg);
		}
		this.theme.reset();

		return (this.themeBuilder !== oldThemeBuilder);
	}

	optimisedRenderPreReflow(sequence) {
		const themeChanged = this._switchTheme(sequence.meta.theme);
		this._reset(themeChanged);

		this.metaCode.nodeValue = sequence.meta.code;
		this.svg.resetTextFilters();
		this.theme.addDefs(this.addThemeDef, this.addThemeTextDef);
		for(const def of this.knownTextFilterDefs.values()) {
			def.detach();
		}

		this.title.set({
			attrs: Object.assign({
				'class': 'title',
			}, this.theme.getTitleAttrs()),
			formatted: sequence.meta.title,
		});
		this.svg.textSizer.expectMeasure(this.title);

		this.minX = 0;
		this.maxX = 0;

		this.buildAgentInfos(sequence.agents);

		sequence.stages.forEach(this.prepareMeasurementsStage);
		this._resetState();
		this._resetAgentInfo();
		this.svg.textSizer.performMeasurementsPre();
	}

	optimisedRenderReflow() {
		this.svg.textSizer.performMeasurementsAct();
	}

	optimisedRenderPostReflow(sequence) {
		this.visibleAgentIDs = ['[', ']'];
		sequence.stages.forEach(this.separationStage);
		this._resetState();
		this._resetAgentInfo();

		this.positionAgents();

		sequence.stages.forEach(this.renderStage);
		const bottomY = this.checkAgentRange(['[', ']'], this.currentY);

		this.svg.getUsedTextFilterNames().forEach((name) => {
			this.themeDefs.add(this.knownTextFilterDefs.get(name));
		});

		const stagesHeight = Math.max(bottomY - this.theme.actionMargin, 0);
		this.updateBounds(stagesHeight);

		const prevHighlight = this.currentHighlight;
		this.currentHighlight = -1;
		this.setHighlight(prevHighlight);

		this.svg.textSizer.performMeasurementsPost();
		this.svg.textSizer.resetCache();
	}

	render(sequence) {
		this.optimisedRenderPreReflow(sequence);
		this.optimisedRenderReflow();
		this.optimisedRenderPostReflow(sequence);
	}

	getThemeNames() {
		return (Array.from(this.themes.keys())
			.filter((name) => (name !== ''))
		);
	}

	getThemes() {
		return this.getThemeNames().map((name) => this.themes.get(name));
	}

	getThemeNamed(themeName) {
		const theme = this.themes.get(themeName);
		if(theme) {
			return theme;
		}
		return this.themes.get('');
	}

	getAgentX(id) {
		return this.agentInfos.get(id).x;
	}

	dom() {
		return this.svg.body.element;
	}
}
