// @flow
import './ToolchainEditor.css';
import * as React from 'react';
import {
	Container,
	Row,
	Col,
	Button,
	ButtonGroup,
	Form,
	FormGroup,
	Label,
	Input,
	InputGroup,
	FormText,
	Collapse,
	Card,
	CardHeader,
	CardBody,
	TabContent, TabPane,
	Navbar,
	Nav, NavItem, NavLink, CardTitle, CardText,
	FormFeedback,
	Alert,
	InputGroupAddon,
	Badge,
} from 'reactstrap';

import { connect } from 'react-redux';
import { ContextMenu, MenuItem, ContextMenuTrigger, SubMenu } from 'react-contextmenu';

import { getRandomBrightColor, generateNewKey, copyObj } from '@helpers';
import {
	getValidToolchainObj as getValidObj,
	nameValidator, nameSegmentsForEntity, getTcFromExpName, getValidAlgorithmObj
} from '@helpers/beat';
import type { BeatObject } from '@helpers/beat';
import { fetchLayout, genModuleApiFuncs } from '@helpers/api';
import Validate, { VALIDATORS } from '@helpers/schema';

import * as Actions from '@store/actions.js';
import * as Selectors from '@store/selectors.js';
import type { FlattenedDatabaseEntry } from '@store/selectors';

import Block from './ToolchainBlock.jsx';
import type { NormalBlock, AnalyzerBlock, BlockType, ConnectionType, BlockSet, Group, Contents } from '@helpers/toolchainTypes';
import { connectionToId } from './ToolchainConnection.jsx';
import ValidSchemaBadge from '../ValidSchemaBadge.jsx';
import CacheInput from '../CacheInput.jsx';
import EditModal from './ToolchainModal.jsx';
import type { ModalCache, ModalAction } from './ToolchainModal.jsx';
import GraphicalEditor, {
	blockWidth,
	gridDistance,
	convertWorldToDataCoords,
	clampCoordsToGrid,
} from './GraphicalEditor.jsx';
import type { Protocol, Set as ProtocolSet } from '../database/DatabaseEditor.jsx';
import InsertObjectModal from './InsertObjectModal.jsx';
import RenameGroupModal from './RenameGroupModal.jsx';

type Props = {
	// saved data for the current toolchain
	data: {
		name: string,
		contents: Contents,
		extraContents: {
			groups: Group[]
		},
	},
	// all toolchains
	toolchains: BeatObject[],
	// all databases
	databases: BeatObject[],
	// sets of protocols of databases
	// get protocols for InsertObjectModal
	protocols: any,
	sets: FlattenedDatabaseEntry[],
	normalAlgorithms: BeatObject[],
	analyzerAlgorithms: BeatObject[],
	// func to save changes on the current tc
	saveFunc: (BeatObject) => any,
	updateFunc: (BeatObject) => any,
	// creates an algorithm from block data from the ToolchainModal
	createAlgorithmFromBlock: (data: NormalBlock | AnalyzerBlock, conns: ConnectionType[]) => any,
};

// represents a timeline of state changes
// the current state sits between the paste & future states
// enables very fast redo/undo
type History = {
	// past states
	past: BeatObject[],
	// future states
	future: BeatObject[],
};

type State = {
	// info for the modal for editing blocks
	// if the fields are undefined, the modal shouldnt be up
	modalBlockInfo: {
		// navigating to a specific block in the JSON
		// requires a set and a name
		set: ?BlockSet,
		name: ?string,
		active: boolean,
	},
	// the clipboard for copy/pasting blocks
	clipboard?: BlockType[],
	// is the object insertion modal active?
	insertModalActive: boolean,
	// is the modal for renaming groups active?
	renameGroupModalInfo: ?string,
	// the current history for the editor
	history: History,
};

// generates a new history object whenever the user does an action that changes state
const generateNewHistory = (state: State, data: any): History => {
	return {
		past: [
			...state.history.past,
			copyObj(data),
		],
		future: [],
	};
};


const generateConnectionRepresentations = (connections: ConnectionType[]) => Object.assign({}, ...connections.map(c => ({[`${ c.from }/${ c.to }`]: []})));

// helper func for addBlocks()
// generates all the new data necessary for a new block,
// including the representation (location & dimensions),
// the channel color,
// and an initial block object
const generateNewBlockData = (blockName: string, set: BlockSet, x: number, y: number, copyBlock?: ?BlockType) => {
	let newBlock = copyBlock ? { ...copyBlock } : undefined;
	if(newBlock === undefined){
		newBlock = {};
		if(set === 'blocks' || set === 'analyzers'){
			newBlock.inputs = [
				'input',
			];
			newBlock.synchronized_channel = '';
		}
		if(set === 'blocks' || set === 'datasets'){
			newBlock.outputs = [
				'output',
			];
		}
	}
	newBlock.name = blockName;

	const repBlock = {
		col: x,
		row: y,
		height: 3,
		width: blockWidth / gridDistance,
	};

	let newChannelColor = set === 'datasets' ? getRandomBrightColor() : null;

	return {
		block: newBlock,
		rep: repBlock,
		channelColor: newChannelColor,
	};
};

// finds a block by name from the three block sub-arrays:
// blocks (normal blocks), datasets (dataset blocks), analyzers (analyzer blocks)
const findBlock = (contents: any, name: string): BlockType => {
	const b = contents.blocks.find(b => b.name === name) ||
		contents.datasets.find(b => b.name === name) ||
		contents.analyzers.find(b => b.name === name)
	;
	if(!b)
		throw new Error(`invalid block name: ${ name }`);

	return b;
};


export class ToolchainEditor extends React.PureComponent<Props, State> {
	constructor(props: Props) {
		super(props);
	}

	state = {
		modalBlockInfo: {
			name: undefined,
			set: undefined,
			active: false,
		},
		clipboard: undefined,
		insertModalActive: false,
		renameGroupModalInfo: undefined,
		history: {
			past: [],
			future: [],
		},
	}

	/* HELPERS */

	// helper to set the contents (this.props.data.contents) object
	setContents = (newContents: any) => {
		this.setState((prevState, props) => ({
			history: generateNewHistory(prevState, props.data),
		}));

		this.props.updateFunc({
			...this.props.data,
			contents: {
				...this.props.data.contents,
				...newContents,
			}
		});
	}

	// helper to set the groups (this.props.data.extraContents.groups)
	setGroups = (groups: Group[]) => {
		this.setState((prevState, props) => ({
			history: generateNewHistory(prevState, props.data),
		}));

		this.props.updateFunc({
			...this.props.data,
			extraContents: {
				...this.props.data.extraContents,
				groups,
			}
		});
	}

	// finds a block by name from the three block sub-arrays:
	// blocks (normal blocks), datasets (dataset blocks), analyzers (analyzer blocks)
	findBlock = (name: string): BlockType => findBlock(this.props.data.contents, name);

	// gets possible channels the block can sync to
	getPossibleChannels = (blockName: string) => {
		const relConns = this.props.data.contents.connections.filter(c => c.to.split('.')[0] === blockName) || [];
		const possibleChannels = Array.from(new Set(relConns.map(c => c.channel)))
		.reduce((o, channel) => ({...o, [channel]: this.props.data.contents.representation.channel_colors[channel]}), {});

		return possibleChannels;
	}

	/* HISTORY */

	// undoes history by saving the current state to the future
	// and switching to the latest history state
	undoHistory = () => {
		if(this.state.history.past.length === 0)
			return this.state;

		const newPast = [...this.state.history.past];
		const newData = newPast.pop();

		const newFuture = [...this.state.history.future, copyObj(this.props.data)];
		this.setState({
			history: {
				past: newPast,
				future: newFuture,
			},
		});

		if(!newData){
			console.error(`No past state found!`);
			return;
		}

		this.props.updateFunc(newData);
	};

	// redoes history by putting the current state in the past
	// and switching to the earliest future state
	redoHistory = () => {
		if(this.state.history.future.length === 0)
			return this.state;

		const newFuture = [...this.state.history.future];
		const newData = newFuture.pop();

		const newPast = [...this.state.history.past, copyObj(this.props.data)];
		this.setState({
			history: {
				past: newPast,
				future: newFuture,
			},
		});

		if(!newData){
			console.error(`No future state found!`);
			return;
		}

		this.props.updateFunc(newData);
	};

	/* MODALS */

	// toggles whether the object insert modal is active or not
	toggleInsertModal = () => {
		this.setState((prevState) => ({ insertModalActive: !prevState.insertModalActive }));
	}

	// toggles whether the group rename modal is active or not
	toggleRenameGroupModal = (val: ?string) => {
		this.setState((prevState) => ({ renameGroupModalInfo: val }));
	}

	closeEditModal = () => {
		this.setState({
			modalBlockInfo: {
				...this.state.modalBlockInfo,
				active: false,
			}
		});
	}

	/* GROUPS */

	// renames a group from an old name to a new one
	renameGroup = (oldName: string, newName: string) => {
		this.setGroups(this.props.data.extraContents.groups.map(g => g.name === oldName ? {...g, name: newName} : g));
	}

	/* CONNECTIONS */

	// creates a connection between a block's output and a block's input with a specific channel
	createConnections = (connectionData: ConnectionType[]) => {
		const rep = this.props.data.contents.representation;

		// assign empty synchronized_channel vals on blocks that are getting their first connection
		const toBlockNamesAndChannels = connectionData.map(c => [c.to.split('.')[0], c.channel]);
		const assignBlockChannels = (blocks: BlockType[]) => {
			return blocks.map(b => {
				if(this.props.data.contents.connections.find(c => c.to.startsWith(`${ b.name }.`)) && b.synchronized_channel !== '')
					return b;
				const tbnc = toBlockNamesAndChannels.find(([n, c]) => n === b.name);
				if(!tbnc)
					return b;
				return {
					...b,
					synchronized_channel: tbnc[1],
				};
			})
			;
		};

		const newBlocks = assignBlockChannels(this.props.data.contents.blocks);
		const newAnalyzers = assignBlockChannels(this.props.data.contents.analyzers);


		const newRepConns = generateConnectionRepresentations(connectionData);
		this.setContents({
			blocks: newBlocks,
			analyzers: newAnalyzers,
			connections: [
				...this.props.data.contents.connections,
				...connectionData,
			],
			representation: {
				...rep,
				connections: {
					...rep.connections,
					...newRepConns,
				}
			}
		});
	}

	// deletes a connection
	// looks at the representation object and the connections array
	deleteConnection = (cn: ConnectionType) => {
		const rep = {...this.props.data.contents.representation};

		rep.connections = Object.entries(rep.connections).filter(([name, rep]) => {
			const [from, to] = name.split('/');
			if(from === cn.from && to === cn.to)
				return false;
			return true;
		})
		.reduce((cs, [name, rep]) => ({...cs, [name]: rep}), {})
		;

		const newConns = this.props.data.contents.connections.filter(c => {
			if(c.from === cn.from && c.to === cn.to)
				return false;
			return true;
		});

		// if the given block has no conns to it, the syncd channel should become ''
		const removeInvalidChannels = (b: NormalBlock | AnalyzerBlock): NormalBlock | AnalyzerBlock => {
			if(newConns.find(c => c.to.split('.')[0] == b.name))
				return b;

			return {
				...b,
				synchronized_channel: '',
			};
		};

		const newContents = {
			connections: newConns,
			blocks: this.props.data.contents.blocks.map(b => removeInvalidChannels(b)),
			analyzers: this.props.data.contents.analyzers.map(b => removeInvalidChannels(b)),
			representation: rep,
		};

		this.setContents(newContents);
	}

	/* BLOCKS */

	// adds new blocks at a certain location via an array of arrays of info about the new block(s)
	// it will also copy connections via looking for connections between copied blocks and copying them to the related new blocks
	// Parameters:
	// blockData: each array is info about a new block:
	// 	- the first string is the new name
	// 	- the BlockSet is the set to put the block in
	// 	- the numbers are the x & y coords of the new block
	// 	- last entry is an optional block object to copy from
	// connections:
	// 	if you are inserting blocks from different toolchains, give the new connections as the second arg!
	// 	these new connections should already be updated to have the new syncd channel names
	addBlocks = (blockData: [string, BlockSet, number, number, ?BlockType][], connections: ConnectionType[]) => {
		const newBlocks = blockData.map(d => generateNewBlockData(...d));
		const sets = blockData.map(b => b[1]);

		const rep = {
			...this.props.data.contents.representation,
			blocks: {
				...this.props.data.contents.representation.blocks,
				...newBlocks.reduce((newReps, b) => ({...newReps, [b.block.name]: b.rep}), {}),
			},
			channel_colors: {
				...this.props.data.contents.representation.channel_colors,
				...newBlocks.filter(b => b.channelColor !== null)
				.reduce((newColors, b) => ({...newColors, [b.block.name]: b.channelColor}), {}),
			}
		};


		const newContents = {
			blocks: [...this.props.data.contents.blocks, ...newBlocks.filter((b, i) => sets[i] === 'blocks').map(b => b.block)],
			datasets: [...this.props.data.contents.datasets, ...newBlocks.filter((b, i) => sets[i] === 'datasets').map(b => b.block)],
			analyzers: [...this.props.data.contents.analyzers, ...newBlocks.filter((b, i) => sets[i] === 'analyzers').map(b => b.block)],
			representation: rep,
			connections: this.props.data.contents.connections,
		};

		// if the blocks werent copied from an external toolchain,
		// check for connection info from the copied blocks and create new connections between the new blocks
		if(!connections){
			const copyBlocks = blockData.filter(bd => bd.length > 4);
			const newConnections: ConnectionType[] = [...this.props.data.contents.connections].map(({ from, to, channel }) => {
				const fromBlock = copyBlocks.find(bd => from.startsWith(`${ bd[4].name }.`));
				const toBlock = copyBlocks.find(bd => to.startsWith(`${ bd[4].name }.`));
				if(!fromBlock || !toBlock)
					return false;
				const newFromBlockData = newBlocks.find(b => b.block.name === fromBlock[0]);
				if(!newFromBlockData)
					return false;
				else {
					return {
						from: from.replace(`${ fromBlock[4].name }.`, `${ fromBlock[0] }.`),
						to: to.replace(`${ toBlock[4].name }.`, `${ toBlock[0] }.`),
						channel: newFromBlockData.block.synchronized_channel || newFromBlockData.block.name,
					};
				}
			})
			.filter(c => c);
			newContents.connections = [...this.props.data.contents.connections, ...newConnections];
			const newRepConns = generateConnectionRepresentations(newConnections);
			newContents.representation.connections = {...rep.connections, ...newRepConns};
		} else {
			// add the new connections from the copied toolchain
			const newConnections = connections;
			newContents.connections = [...this.props.data.contents.connections, ...newConnections];
			const newRepConns = generateConnectionRepresentations(newConnections);
			newContents.representation.connections = {...rep.connections, ...newRepConns};
		}

		this.setContents(newContents);
	}

	// updates a blocks location in the graphical editor by providing an x & y offset relative to the current location.
	updateBlockLocations = (bLocs: {blockName: string, x: number, y: number}[]) => {
		const rep = this.props.data.contents.representation;
		const newReps = bLocs
		.map(bLoc => ({ [bLoc.blockName]: { ...rep.blocks[bLoc.blockName], col: bLoc.x, row: bLoc.y }}))
		.reduce((o, bLoc) => ({ ...o, ...bLoc }), {});;
		this.setContents({
			representation: {
				...rep,
				blocks: {
					...rep.blocks,
					...newReps,
				}
			}
		});
	}

	// deletes a block from the toolchain,
	// as well as any connections to/from it
	// if it was the sole member of a group, deletes the group too
	deleteBlocks = (names: string[]) => {
		const rep = copyObj(this.props.data.contents.representation);

		names.forEach(name => {
			delete rep.blocks[name];
			delete rep.channel_colors[name];
		});

		rep.connections = Object.entries(rep.connections).filter(([name, rep]) => {
			const [from, to] = name.split('/');
			if(names.find(blockName => to.startsWith(`${ blockName }.`) || from.startsWith(`${ blockName }.`)))
				return false;
			return true;
		})
		.reduce((cs, [name, r]) => ({...cs, [name]: r}), {})
		;

		const newContents = {
			...this.props.data.contents,
			blocks: this.props.data.contents.blocks.filter(s => !names.includes(s.name)),
			datasets: this.props.data.contents.datasets.filter(s => !names.includes(s.name)),
			analyzers: this.props.data.contents.analyzers.filter(s => !names.includes(s.name)),
			connections: this.props.data.contents.connections.filter(c => {
				if(names.find(blockName => c.to.startsWith(`${ blockName }.`) || c.from.startsWith(`${ blockName }.`)))
					return false;
				return true;
			}),
			representation: rep,
		};

		const newGroups = this.props.data.extraContents.groups
		.map(({ name, blocks }) => ({ name, blocks: blocks.filter(n => !names.includes(n)) }))
		.filter(g => g.blocks.length !== 0);

		this.setState({
			history: generateNewHistory(this.state, this.props.data),
		});

		this.props.updateFunc({
			...this.props.data,
			contents: newContents,
			extraContents: {
				...this.props.data.extraContents,
				groups: newGroups,
			},
		});
	}

	/* BLOCK EDIT MODAL */

	// updates the block data for a given block
	// by sequentially applying all updates made from the ToolchainModal editor
	// to the data of the toolchain.
	updateBlockData = (data: ModalCache) => {
		const set = this.state.modalBlockInfo.set;
		const name = this.state.modalBlockInfo.name;
		if(!set || !name){
			console.error(`Modal not open for update for ${ data.name }`);
			return;
		}

		const oldData = this.props.data;
		const oldBlock = oldData.contents[set].find(b => b.name === name);
		if(!oldBlock){
			console.error(`cannot find block ${ name }`);
			return;
		}

		let newData = copyObj(oldData);

		// update name
		if(name !== data.name) {
			newData = this.updateBlockNameFunc(newData, set, name, data.name);
		}

		// update channel
		if(data.synchronized_channel && oldBlock.synchronized_channel !== data.synchronized_channel){
			newData = this.updateBlockChannelFunc(newData, data.name, set, data.synchronized_channel);
		}

		const findNewBlock = () => newData.contents[set].find(b => b.name === data.name);
		let newBlock = findNewBlock();

		// update inputs
		if(Array.isArray(data.inputs)){
			// first change names
			// when the modal opens,
			// a new "change" operation is added to the cache
			// for each input/output already in the block, with a name === original
			// to avoid extra processing, skip these if they exist
			const [oldNames, newNames] = data.inputs.filter(o => o.action === 'change' && o.original !== o.name)
			.reduce(([oldNames, newNames], o) => [[...oldNames, o.original], [...newNames, o.name]], [[], []]);

			newData = this.updateBlockIONamesFunc(newData, newBlock.name, set, 'input', oldNames, newNames);
			newBlock = findNewBlock();

			data.inputs.filter(o => o.action !== 'change')
			.forEach(o => {
				switch(o.action){
					case 'delete':
						newData = this.deleteBlockIOFunc(newData, newBlock.name, set, 'input', o.original);
						break;
					case 'add':
						newData = this.addBlockIOFunc(newData, newBlock.name, set, 'input', o.name);
						break;
					default:
						break;
				}
				newBlock = findNewBlock();
			});
		}

		// update outputs
		if(Array.isArray(data.outputs)){
			const [oldNames, newNames] = data.outputs.filter(o => o.action === 'change' && o.original !== o.name)
			.reduce(([oldNames, newNames], o) => [[...oldNames, o.original], [...newNames, o.name]], [[], []]);

			newData = this.updateBlockIONamesFunc(newData, newBlock.name, set, 'output', oldNames, newNames);
			newBlock = findNewBlock();

			data.outputs.filter(o => o.action !== 'change')
			.forEach(o => {
				switch(o.action){
					case 'delete':
						newData = this.deleteBlockIOFunc(newData, newBlock.name, set, 'output', o.original);
						break;
					case 'add':
						newData = this.addBlockIOFunc(newData, newBlock.name, set, 'output', o.name);
						break;
					default:
						break;
				}
				newBlock = findNewBlock();
			});
		}

		this.setState((prevState, props) => ({
			history: generateNewHistory(prevState, props.data),
		}));

		this.props.updateFunc(newData);
	}

	// changes a block's name given the set it belongs to, the old name, and the new name
	updateBlockNameFunc = (oldData: any, set: BlockSet, oldName: string, newName: string): any => {
		// func to update from/to strings with old names to new names
		const updateConn = (from, to): string[] => {
			const fromSplit = from.split('.');
			fromSplit[0] = fromSplit[0] === oldName ? newName : fromSplit[0];
			const toSplit = to.split('.');
			toSplit[0] = toSplit[0] === oldName ? newName : toSplit[0];
			return [fromSplit.join('.'), toSplit.join('.')];
		};

		// update the block & conn representation with the new names
		const rep = {...oldData.contents.representation};
		rep.blocks = {...rep.blocks};
		rep.blocks[newName] = rep.blocks[oldName];
		delete rep.blocks[oldName];
		rep.connections = Object.entries(rep.connections).map(([name, rep]) => {
			if(!name.includes(oldName))
				return [name, rep];

			const [from, to] = name.split('/');
			const updated = updateConn(from, to);

			return [updated.join('/'), rep];
		})
		.reduce((cs, c) => ({...cs, [c[0]]: c[1]}), {})
		;
		// update the channel colors
		if(set === 'datasets'){
			rep.channel_colors[newName] = rep.channel_colors[oldName];
			delete rep.channel_colors[oldName];
		}

		// update blocks & connections
		const newContents = {
			...oldData.contents,
			[set]: oldData.contents[set].map(s => s.name === oldName ? {...s, name: newName} : s),
			// update the conns, both the names of the blocks and the channel
			connections: oldData.contents.connections.map(c => {
				if(!c.from.includes(oldName) && !c.to.includes(oldName) && c.channel !== oldName)
					return c;

				const newChannel = c.channel === oldName ? newName : c.channel;

				const updated = updateConn(c.from, c.to);
				return {
					channel: newChannel,
					from: updated[0],
					to: updated[1],
				};
			}),
			representation: rep,
		};

		// if the changed block is a dataset, update the synchronized_channel on all blocks using its old name
		if(set === 'datasets'){
			newContents['blocks'] = oldData.contents.blocks
			.map(s => s.synchronized_channel === oldName ? {...s, synchronized_channel: newName} : s)
			;

			newContents['analyzers'] = oldData.contents.analyzers
			.map(s => s.synchronized_channel === oldName ? {...s, synchronized_channel: newName} : s)
			;
		}

		const newGroups = oldData.extraContents.groups
		.map(({ name, blocks }) => ({ name, blocks: blocks.map(n => n === oldName ? newName : n) }));
		this.setState((prevState, props) => ({
			history: generateNewHistory(prevState, props.data),
			modalBlockInfo: {
				active: true,
				name: newName,
				set
			}
		}));

		return {
			...oldData,
			contents: newContents,
			extraContents: {
				...oldData.extraContents,
				groups: newGroups,
			},
		};
	}

	// update multiple block names at once
	updateBlockIONamesFunc = (oldData: any, blockName: string, set: BlockSet, ioType: 'input' | 'output', oldNames: string[], newNames: string[]) => {
		const combinedNames = oldNames.map(oldName => `${ blockName }.${ oldName }`);
		// gets the new name for the i/o from the old name OR from the old combined name (in the form `${blockName}.${oldName}`)
		const getNewFromOld = (oldName: string): ?string => {
			const foundCombined = combinedNames.findIndex(cn => cn === oldName);
			const found = oldNames.findIndex(o => o === oldName);
			if(foundCombined > -1){
				return newNames[foundCombined];
			} else if(found > -1){
				return newNames[found];
			} else {
				return undefined;
			}
		};

		// updates a connection to use the new names
		const updateConn = (from, to): string[] => {
			const fromSplit = from.split('.');
			const toSplit = to.split('.');

			if(ioType === 'output')
				fromSplit[1] = getNewFromOld(from) || fromSplit[1];
			else if(ioType === 'input')
				toSplit[1] = getNewFromOld(to) || toSplit[1];

			return [fromSplit.join('.'), toSplit.join('.')];
		};

		const rep = {...oldData.contents.representation};
		// fix all the names in `contents.representation.connections`
		const newRepConns = Object.entries(rep.connections).map(([name, crep]) => {
			if(!oldNames.some(oldName => name.includes(`.${ oldName }`)))
				return [name, crep];

			const [from, to] = name.split('/');
			const updated = updateConn(from, to);

			return [updated.join('/'), crep];
		})
		.reduce((cs, [name, crep]) => ({...cs, [name]: crep}), {})
		;

		// fix all the connection objects in `contents.connections`
		const newConns = oldData.contents.connections.map(c => {
			const updated = updateConn(c.from, c.to);
			return {
				...c,
				from: updated[0],
				to: updated[1],
			};
		});

		const newContents = {
			...oldData.contents,
			[set]: oldData.contents[set].map(s => {
				if(s.name !== blockName)
					return s;
				const newBlock = {
					...s,
				};
				if(ioType === 'input')
					newBlock.inputs = newBlock.inputs.map(str => getNewFromOld(str) || str);
				if(ioType === 'output')
					newBlock.outputs = newBlock.outputs.map(str => getNewFromOld(str) || str);
				return newBlock;
			}),
			connections: newConns,
			representation: {
				...rep,
				connections: newRepConns,
			},
		};

		return {
			...oldData,
			contents: newContents,
		};
	}

	// add a new input or output to a block
	addBlockIOFunc = (oldData: any, blockName: string, set: BlockSet, ioType: 'input' | 'output', newName: string) => {
		const newContents = {
			...oldData.contents,
			[set]: oldData.contents[set].map(s => {
				if(s.name !== blockName)
					return s;
				const newBlock = {
					...s,
				};
				if(ioType === 'input')
					newBlock.inputs = [...newBlock.inputs, newName];
				if(ioType === 'output')
					newBlock.outputs = [...newBlock.outputs, newName];
				return newBlock;
			}),
		};

		return {
			...oldData,
			contents: newContents,
		};
	}

	// deletes an input or output from a block,
	// as well as any connections connected to it
	deleteBlockIOFunc = (oldData: any, blockName: string, set: BlockSet, ioType: 'input' | 'output', ioName: string) => {
		const rep = {...oldData.contents.representation};

		const connectionLabel = `${ blockName }.${ ioName }`;

		rep.connections = Object.entries(rep.connections).filter(([name, rep]) => {
			if(!name.includes(ioName))
				return true;

			const [from, to] = name.split('/');
			if(ioType === 'input' && to === connectionLabel)
				return false;
			if(ioType === 'output' && from === connectionLabel)
				return false;
			return true;
		})
		.reduce((cs, [name, rep]) => ({...cs, [name]: rep}), {})
		;
		const newContents = {
			...oldData.contents,
			[set]: oldData.contents[set].map(s => {
				if(s.name !== blockName)
					return s;
				const newBlock = {
					...s,
				};
				if(ioType === 'input')
					newBlock.inputs = newBlock.inputs.filter(str => str !== ioName);
				if(ioType === 'output')
					newBlock.outputs = newBlock.outputs.filter(str => str !== ioName);
				return newBlock;
			}),
			connections: oldData.contents.connections.filter(c => {
				if(ioType === 'input' && c.to === connectionLabel)
					return false;
				if(ioType === 'output' && c.from === connectionLabel)
					return false;
				return true;
			}),
		};

		return {
			...oldData,
			contents: newContents,
		};
	}

	// changes the block's sync'd channel
	// also updates all connections from the block
	updateBlockChannelFunc = (oldData: any, blockName: string, set: BlockSet, channel: string) => {
		const queue = [oldData.contents[set].find(b => b.name === blockName)];
		const oldChannel = `${ queue[0].synchronized_channel }`;
		const bNames = Object.keys(oldData.contents.representation.blocks);
		const conns = oldData.contents.connections;
		const updatedBlocks: { [string]: BlockType } = {};
		const updatedConnections: { [string]: ConnectionType } = {};
		while(queue.length > 0){
			const b = queue.shift();
			if(b.synchronized_channel === oldChannel){
				updatedBlocks[b.name] = {
					...b,
					synchronized_channel: channel,
				};

				conns
				.filter(c => c.from.startsWith(`${ b.name }.`))
				.forEach(c => {
					const curr = c.to.split('.')[0];
					// if theres more than one connection to this block
					// that is synced to the old channel,
					// dont change the channel of this block
					// because the old channel is still valid
					if(
						conns
						.filter(c => c.to.startsWith(`${ curr }.`) && c.channel === oldChannel)
						.length === 1
					)
						queue.push(findBlock(oldData.contents, curr));

					updatedConnections[connectionToId(c)] = {
						...c,
						channel,
					};
				});
			}
		}

		const newContents = {
			...oldData.contents,
			datasets: oldData.contents.datasets.map(b => updatedBlocks[b.name] || b),
			blocks: oldData.contents.blocks.map(b => updatedBlocks[b.name] || b),
			analyzers: oldData.contents.analyzers.map(b => updatedBlocks[b.name] || b),
			connections: oldData.contents.connections.map(c => updatedConnections[connectionToId(c)] || c),
		};

		return {
			...oldData,
			contents: newContents,
		};
	}

	createAlgorithmFromBlock = async (algName: string) => {
		const {active, name, set} = this.state.modalBlockInfo;
		const data = this.props.data.contents[set].find(b => b.name === name);

		if(!data){
			console.error(`cannot find ${ name } to create ${ algName } in blocks or analyzers`);
			return;
		}

		const conns = this.props.data.contents.connections
		.filter(c => c.to.startsWith(`${ name }.`))
		;

		await this.props.createAlgorithmFromBlock({ ...data, name: algName }, conns);
	}

	/* CONTEXT MENUS */

	svgContextMenuLocation: ?[number, number] = undefined

	// handles the left click on the svg's background
	// shows a menu to add/paste blocks at the click location
	handleSvgContextMenu = (e: any, data: { x: number, y: number, selectBlocks: (string[]) => any, clicked: string }) => {
		const {blocks, datasets, analyzers, representation} = this.props.data.contents;
		const { x, y, selectBlocks, clicked } = data;
		let newBlockName = '';
		const usedNames = this.getUsedNames();
		switch(clicked){
			case 'addBlock':
				newBlockName = generateNewKey('block', usedNames);
				this.addBlocks([[newBlockName, 'blocks', x, y]]);
				break;
			case 'addDataset':
				newBlockName = generateNewKey('dataset', usedNames);
				this.addBlocks([[newBlockName, 'datasets', x, y]]);
				break;
			case 'addAnalyzer':
				newBlockName = generateNewKey('analyzer', usedNames);
				this.addBlocks([[newBlockName, 'analyzers', x, y]]);
				break;
			case 'addObject':
				this.svgContextMenuLocation = [x, y];
				this.toggleInsertModal();
				break;
			case 'paste':
				if(!this.state.clipboard || this.state.clipboard.length === 0)
					return;
				const blocksToCopy = this.state.clipboard;

				const sets = blocksToCopy.map(b => {
					if(!b.hasOwnProperty('inputs'))
						return 'datasets';
					if(!b.hasOwnProperty('outputs'))
						return 'analyzers';
					return 'blocks';
				});
				//first element in the blocksToCopy array is the one used to offset the other blocks
				const b0 = blocksToCopy[0];
				const b0Rep = representation.blocks[b0.name];
				const offsetX = x - b0Rep.col;
				const offsetY = y - b0Rep.row;
				const newBlockData = blocksToCopy.map((b, i, bs) => {
					const bRep = representation.blocks[b.name];
					const set = sets[i];
					const newX = bRep.col + offsetX;
					const newY = bRep.row + offsetY;
					const newName = generateNewKey(b.name, usedNames);
					return [newName, set, newX, newY, {...b}];
				});
				this.addBlocks(newBlockData);
				selectBlocks(newBlockData.map(d => d[0]));
				break;
			default:
				break;
		}
	}

	// copy either a block or array of blocks to the clipboard
	copyToClipboard = (data: any[]) => {
		this.setState({
			clipboard: data,
		});
	}

	// handles a right click on a block
	handleBlockContextMenu = (e: any, data: { name: string, clicked: string }) => {
		const {name, clicked} = data;
		switch(clicked){
			case 'delete':
				this.deleteBlocks([name]);
				break;
			case 'copy':
				this.copyToClipboard([this.findBlock(name)]);
				break;
			default:
				break;
		}
	}

	// handles a right click on a connection
	handleConnectionContextMenu = (e: any, data: { connection: ConnectionType, clicked: string }) => {
		const {connection, clicked} = data;
		switch(clicked){
			case 'delete':
				this.deleteConnection(connection);
				break;
			default:
				break;
		}
	}

	// handles a right click on a group
	handleGroupContextMenu = (e: any, data: { group: string, selectBlocks: (BlockType[]) => any, clicked: string }) => {
		const {group, clicked, selectBlocks} = data;
		const groups = this.props.data.extraContents.groups;
		const g = groups.find(g => g.name === group);
		switch(clicked){
			case 'delete':
				this.setGroups(groups.filter(g => g.name !== group));
				break;
			case 'copy':
				this.copyToClipboard(g.blocks.map(b => this.findBlock(b)).filter(b => b));
				break;
			case 'select':
				selectBlocks(g.blocks.map(b => this.findBlock(b)).filter(b => b));
				break;
			case 'collapse':
				this.setGroups(groups.map(g => g.name === group ? { ...g, collapsed: !g.collapsed } : g));
				break;
			case 'rename':
				this.toggleRenameGroupModal(group);
				break;
			default:
				break;
		}
	}

	// gets all the used names in the toolchain:
	// group names & block names
	getUsedNames = (): string[] => [
		...this.props.data.extraContents.groups.map(g => g.name),
		...Object.keys(this.props.data.contents.representation.blocks)
	];

	// handles a right click on an area selection
	handleAreaSelectContextMenu = (e: any, data: { selection: BlockType[], clicked: string }) => {
		const {selection, clicked} = data;
		const groups = this.props.data.extraContents.groups;
		switch(clicked){
			case 'createGroup':
				const newGroupName = generateNewKey('group', this.getUsedNames());
				this.setGroups([
					...groups,
					{
						name: newGroupName,
						blocks: selection.map(b => b.name),
						collapsed: false,
					}
				]);
				break;
			case 'copy':
				this.copyToClipboard(selection);
				break;
			case 'delete':
				this.deleteBlocks(selection.map(b => b.name));
				break;
			default:
				break;
		}
	}

	lastClickBlockName = ''
	lastClickMs = 0
	// handles left clicking on a block
	handleBlockClick = (blockName: string, set: BlockSet) => {
		const currTime = Date.now();
		// 1sec throttling
		const delay = 1000;
		if(currTime - this.lastClickMs > delay || blockName !== this.lastClickBlockName){
			this.lastClickBlockName = blockName;
			const newMBI = {
				set,
				name: blockName,
				active: true,
			};
			this.setState({
				modalBlockInfo: newMBI,
			});
		}
		this.lastClickBlockName = blockName;
		this.lastClickMs = currTime;
	}

	// uses the server's /layout endpoint to get a graphviz layout for this toolchain
	autoLayout = async () => {
		// first save the tc to the BEAT prefix
		const serverSaveFunc = genModuleApiFuncs('toolchain').put;
		await serverSaveFunc(this.props.data);
		// then fetch the new layout
		const json = await fetchLayout(this.props.data.name);

		// transform the received layout into objs for updateBlockLocations
		const posRaw = json.objects
		.filter(o => o.hasOwnProperty('pos'))
		.map(o => {
			const [x, y] = o.pos.split(',').map(n => Number.parseFloat(n));
			return {
				blockName: o.name,
				x,
				y,
			};
		})
		;

		// flip the blocks' y-coord across the middle of the layout
		// because graphviz's y-axis starts at the bottom while svg
		// starts at the top
		const midY = Math.max(...posRaw.map(o => o.y)) / 2;
		const pos = posRaw.map(({blockName, x, y}) => {
			const ry = y - (2 * (y - midY));
			return {
				blockName,
				// round to the nearest grid lines
				x: Math.round(x / gridDistance),
				y: Math.round(ry / gridDistance),
			};
		});

		// update locations
		this.updateBlockLocations(pos);
		// dont save locally because it kills the undo/redo stack!
	}

	renderGraphicalEditor = () => {
		return (
			<Container>
				<Row>
				</Row>
				<Row>
					<GraphicalEditor
						interactable
						repData={this.props.data.contents.representation}
						blocks={this.props.data.contents.blocks}
						datasets={this.props.data.contents.datasets}
						analyzers={this.props.data.contents.analyzers}
						connections={this.props.data.contents.connections}
						groups={this.props.data.extraContents.groups}
						handleBlockClick={this.handleBlockClick}
						updateBlockLocations={this.updateBlockLocations}
						createConnection={(from: string, to: string, channel: string) => this.createConnections([{ from, to, channel }])}
						toolbarContent={
							<React.Fragment>
								<Button
									size='sm'
									onClick={this.undoHistory}
									disabled={this.state.history.past.length === 0}
								>
									Undo
								</Button>
								<Button
									size='sm'
									onClick={this.redoHistory}
									disabled={this.state.history.future.length === 0}
								>
									Redo
								</Button>
								<Button
									size='sm'
									onClick={this.autoLayout}
									title={'This will use Graphviz to generate a more visually understandable layout for your toolchain.'}
									disabled={!!Validate(VALIDATORS['toolchain'], this.props.data)}
								>
									Layout
								</Button>
							</React.Fragment>
						}
						divChildren={
							<React.Fragment>
								<ContextMenu
									id='blockContextMenu'
									hideOnLeave={true}
								>
									<MenuItem
										data={{ clicked: 'delete'}}
										onClick={this.handleBlockContextMenu}
									>
										Delete Block
									</MenuItem>
									<MenuItem
										data={{ clicked: 'copy'}}
										onClick={this.handleBlockContextMenu}
									>
										Copy Block
									</MenuItem>
								</ContextMenu>
								<ContextMenu
									id='connectionContextMenu'
									hideOnLeave={true}
								>
									<MenuItem
										data={{ clicked: 'delete'}}
										onClick={this.handleConnectionContextMenu}
									>
										Delete Connection
									</MenuItem>
								</ContextMenu>
								<ContextMenu
									id='svgContextMenu'
									hideOnLeave={true}
								>
									{
										this.state.clipboard !== undefined &&
											<MenuItem
												data={{ clicked: 'paste'}}
												onClick={this.handleSvgContextMenu}
											>
												Paste Here
											</MenuItem>
									}
									<MenuItem
										data={{ clicked: 'addBlock'}}
										onClick={this.handleSvgContextMenu}
									>
										Add Block Here
									</MenuItem>
									<MenuItem
										data={{ clicked: 'addDataset'}}
										onClick={this.handleSvgContextMenu}
									>
										Add Dataset Here
									</MenuItem>
									<MenuItem
										data={{ clicked: 'addAnalyzer'}}
										onClick={this.handleSvgContextMenu}
									>
										Add Analyzer Here
									</MenuItem>
									<MenuItem
										data={{ clicked: 'addObject'}}
										onClick={this.handleSvgContextMenu}
									>
										Insert Object Here
									</MenuItem>
								</ContextMenu>
								<ContextMenu
									id='groupContextMenu'
									hideOnLeave={true}
								>
									<MenuItem
										data={{ clicked: 'copy'}}
										onClick={this.handleGroupContextMenu}
									>
										Copy Group Blocks
									</MenuItem>
									<MenuItem
										data={{ clicked: 'select'}}
										onClick={this.handleGroupContextMenu}
									>
										Select Group Blocks
									</MenuItem>
									<MenuItem
										data={{ clicked: 'collapse'}}
										onClick={this.handleGroupContextMenu}
									>
										Toggle Collapsed
									</MenuItem>
									<MenuItem
										data={{ clicked: 'rename'}}
										onClick={this.handleGroupContextMenu}
									>
										Rename
									</MenuItem>
									<MenuItem
										data={{ clicked: 'delete'}}
										onClick={this.handleGroupContextMenu}
									>
										Delete Group
									</MenuItem>
								</ContextMenu>
								<ContextMenu
									id='areaSelectContextMenu'
									hideOnLeave={true}
								>
									<MenuItem
										data={{ clicked: 'copy'}}
										onClick={this.handleAreaSelectContextMenu}
									>
										Copy Blocks
									</MenuItem>
									<MenuItem
										data={{ clicked: 'createGroup'}}
										onClick={this.handleAreaSelectContextMenu}
									>
										Create Group
									</MenuItem>
									<MenuItem
										data={{ clicked: 'delete'}}
										onClick={this.handleAreaSelectContextMenu}
									>
										Delete Blocks
									</MenuItem>
								</ContextMenu>
							</React.Fragment>
						}
					>
					</GraphicalEditor>
				</Row>
			</Container>
		);
	}

	// modal that pops up when left clicking a block
	renderEditModal = () => {
		const {active, name, set} = this.state.modalBlockInfo;
		if(!name || !set)
			return null;
		const possibleChannels = active ? this.getPossibleChannels(name) : {};
		return (
			<EditModal
				data={this.props.data.contents[set].find(d => d.name === name)}
				active={active}
				toggle={() => this.closeEditModal()}
				blockNames={Object.keys(this.props.data.contents.representation.blocks) || []}
				deleteBlock={() => this.deleteBlocks([name])}
				possibleChannels={possibleChannels}
				updateBlockData={this.updateBlockData}
				createAlgorithmFromBlock={this.createAlgorithmFromBlock}
			/>
		);
	}

	renderInsertModal = () => {
		return (
			<InsertObjectModal
				toolchains={this.props.toolchains}
				protocols={this.props.protocols}
				sets={this.props.sets}
				normalAlgorithms={this.props.normalAlgorithms}
				analyzerAlgorithms={this.props.analyzerAlgorithms}
				active={this.state.insertModalActive}
				toggle={this.toggleInsertModal}
				addNewBlocks={(type, newBlocksData, newConnections) => {
					if(!this.svgContextMenuLocation)
						return;
					const [x, y] = this.svgContextMenuLocation;
					for(const b of newBlocksData){
						b[2] += x;
						b[3] += y;
					}

					this.addBlocks(newBlocksData, newConnections);
				}}
				usedNames={this.getUsedNames()}
			/>
		);
	}

	renderRenameGroupModal = () => {
		return (
			<RenameGroupModal
				active={this.state.renameGroupModalInfo !== undefined}
				toggle={this.toggleRenameGroupModal}
				usedNames={this.getUsedNames()}
				currentName={this.state.renameGroupModalInfo || ''}
				saveNewName={(name) => {
					const oldName = this.state.renameGroupModalInfo;
					if(!oldName)
						return;
					this.renameGroup(oldName, name);
				}}
			/>
		);
	}

	render = () => (
		<div className='toolchainEditor'>
			<div className='d-flex'>
				<Button
					className='mx-auto'
					outline
					color='secondary'
					onClick={() => this.props.saveFunc(this.props.data)}
				>
					Save Changes (Changes are <ValidSchemaBadge entity='toolchain' obj={this.props.data} />)
				</Button>
			</div>
			<Form onSubmit={(e) => e.preventDefault()}>
				<FormGroup tag='fieldset'>
					<FormGroup>
						<Label>Short Description</Label>
						<Input
							type='text'
							className='tcDescription'
							placeholder='Toolchain description...'
							value={this.props.data.contents['description']}
							onChange={e => this.setContents({ ...this.props.data.contents, 'description': e.target.value})}
						/>
					</FormGroup>
				</FormGroup>
				{ this.renderGraphicalEditor() }
				{ this.renderEditModal() }
				{ this.renderInsertModal() }
				{ this.renderRenameGroupModal() }
			</Form>
		</div>
	);
}

const mapStateToProps = (state, ownProps) => {
	const tcs = Selectors.toolchainGet(state);
	const obj = {
		toolchains: tcs,
		databases: Selectors.databaseGet(state),
		sets: Selectors.flattenedDatabases(state),
		protocols: Selectors.databaseProtocols(state),
		normalAlgorithms: Selectors.normalBlocks(state),
		analyzerAlgorithms: Selectors.analyzerBlocks(state),
		data: tcs[ownProps.index] || getValidObj()
	};
	return obj;
};

const mapDispatchToProps = (dispatch, ownProps) => ({
	// replace the obj in the Redux store with the new object
	updateFunc: (obj) => {
		console.log(`dispatching for ${ obj.name }`);
		dispatch(Actions[`toolchainUpdate`](obj.name, obj));
	},
	createAlgorithmFromBlock: async (data: NormalBlock | AnalyzerBlock, conns: ConnectionType[]) => {
		let newObj = getValidAlgorithmObj({ name: data.name, contents: {} });

		// generate the alg obj
		// first, separate into groups
		// look at all connections to block, each different channel is a different group
		// put the inputs in the corresponding groups as well
		const inputGroups = conns.reduce((groups, c) => {
			const input = c.to.split('.')[1];
			const channel: string = c.channel;
			if(Array.isArray(groups[channel])){
				groups[channel].push(input);
			} else {
				groups[channel] = [input];
			}
			return groups;
		}, {});

		// coverts a list of input names to the format needed for algorithm groups
		const groupListToObj = (iList: string[]): {[string]: any} =>
			iList.reduce((o, i) => ({...o, [i]: { type: '' }}), {});

		// generate group objs from the inputs split between groups
		newObj.contents.groups = Object.entries(inputGroups)
		.map(([gName, inputs], i) => ({ name: gName, inputs: groupListToObj(inputs) }) );

		// is normal block
		if(Array.isArray(data.outputs)){
			// all block outputs only go to first group
			if(Array.isArray(data.outputs) && newObj.contents.groups.length > 0){
				newObj.contents.groups[0].outputs = groupListToObj(data.outputs);
			}
		}
		// is analyzer block
		else {
			// make it an analyzer
			delete newObj.contents['splittable'];
			newObj.contents.results = {};
		}

		console.log(newObj);

		await dispatch(Actions.createObject('algorithm', newObj)());
	}
});

export default connect(mapStateToProps, mapDispatchToProps)(ToolchainEditor);
