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

import { connect } from 'react-redux';

import cn from 'classnames';

import lev from 'fast-levenshtein';

import './ExperimentEditor.css';
import { getValidExperimentObj as getValidObj, expGetDefaultParameterValue as getDefaultParameterValue } from '@helpers/beat';
import type { BeatObject, ParameterValue, BeatEnvironment } from '@helpers/beat';
import { changeObjFieldName, sortObject, copyObj } from '@helpers';

import * as Selectors from '@store/selectors.js';
import * as Actions from '@store/actions.js';

import ValidSchemaBadge from '../ValidSchemaBadge.jsx';
import CacheInput from '../CacheInput.jsx';
import DeleteInputBtn from '../DeleteInputBtn.jsx';
import TypedField from '../TypedField.jsx';
import InfoTooltip from '../InfoTooltip.jsx';
import ParameterConsume from '../ParameterConsume.jsx';

import GraphicalEditor from '../toolchain/GraphicalEditor.jsx';
import type { BlockSet } from '../toolchain/types.js';
import { connectionToId } from '../toolchain/ToolchainConnection.jsx';

type Props = {
	data: BeatObject,
	experiments: BeatObject[],
	normalBlocks: BeatObject[],
	analyzerBlocks: BeatObject[],
	datasets: any[],
	toolchain: BeatObject,
	environments: { [string]: BeatEnvironment },
	saveFunc: (BeatObject) => any,
	updateFunc: (BeatObject) => any,
};

type State = {
	/* LockMap: map of which blocks are used in the type inference
	 * This is a per-block mapping of which blocks are used for type inference.
	 * There are more references (including some commented-out buttons & such) to
	 * block-level control of the lock map. Currently this is managed behind-the-scenes,
	 * where once a dataset/block has a valid dataset/algorithm assigned to it,
	 * its lock map value is true. The user can only influence this by disabling
	 * the entire lock map via "disable type inference" button.
	 */
	lockMap: LockMap,
	// info for the modal for editing blocks
	activeBlockInfo: {
		set: ?BlockSet,
		name: ?string,
	},
	// is type inference disabled globally?
	disableTypeInference: boolean,
};

type LockMap = {
	[string]: boolean,
};

// parses a string resulting from the "datasetFieldToString" function into the original database, protocol, and set
const rxStringToDataset = /^(\S+)\/(\S+)\s\((\S+\/\d+)\)/;
// parses a string resulting from the "datasetProtocolFieldToString" function into the original database and protocol
const rxStringToDatasetProtocol = /^(\S+)\s\((\S+\/\d+)\)/;
// given a dataset obj (with database, protocol, and set fields), return a user-readable string representing this dataset
const datasetFieldToString = (dataset: any) => `${ dataset.protocol }/${ dataset.set } (${ dataset.database })`;
// given a dataset obj (with database, protocol, and set fields), return a user-readable string representing this dataset's protocol
const datasetProtocolFieldToString = (dataset: any) => `${ dataset.protocol } (${ dataset.database })`;
// given an algorithm, returns maps of the inputs and outputs of the algorithm (the i/o name mapped to its type)
const algIOs = (alg: any) => {
	const inputs = alg.contents.groups.map(g => {
		if(!g.inputs)
			return [];
		return Object.entries(g.inputs)
		.map(([iName, tObj]) => ({[iName]: tObj.type}))
		.reduce((o, iObj) => ({...o, ...iObj}), {})
		;
	})
	.reduce((o, is) => ({...o, ...is}), {});

	const outputs = alg.contents.groups.map(g => {
		if(!g.outputs)
			return [];
		return Object.entries(g.outputs)
		.map(([iName, tObj]) => ({[iName]: tObj.type}))
		.reduce((o, iObj) => ({...o, ...iObj}), {})
		;
	})
	.reduce((o, is) => ({...o, ...is}), {});

	return [
		inputs,
		outputs,
	];
};

// given a block and the available algorithms,
// remap the chosen algorithm type data onto the block i/o names
const getBlockIOTypes = (block: any, algs: any[]) => {
	const alg = algs.find(alg => alg.name === block.algorithm);
	const [ais, aos] = alg ? algIOs(alg) : [{}, {}];
	const bis = Object.entries(block.inputs).reduce((o, [name, bi]) => ({...o, [bi]: ais[name] || ''}), {});
	const bos = Object.entries(block.outputs || {}).reduce((o, [name, bo]) => ({...o, [bo]: aos[name] || ''}), {});

	return [bis, bos];
};

// generate a new lock map for the given toolchain
const genLockMap = (toolchain: any): LockMap => [...toolchain.contents.datasets, ...toolchain.contents.blocks, ...toolchain.contents.analyzers]
.map(b => b.name)
.reduce((o, name) => ({...o, [name]: false}), {});

// checks if the given object is valid for the current entity given the i/o types and inferred types map
const isValidEntity = (
	entityName: string,
	blockTcEntity: {inputs?: [], outputs?: []} = {},
	possibleObj: {inputs: any, outputs: any} = {inputs:{},outputs:{}},
	inferredTypes = {}
): boolean => {
	const hasInputs = Array.isArray(blockTcEntity.inputs);
	const hasOutputs = Array.isArray(blockTcEntity.outputs);
	if(!hasInputs && Object.keys(possibleObj.inputs).length > 0)
		return false;
	if(!hasOutputs && Object.keys(possibleObj.outputs).length > 0)
		return false;
	if(hasInputs && blockTcEntity.inputs.length !== Object.keys(possibleObj.inputs).length)
		return false;
	if(hasInputs && hasOutputs &&
		blockTcEntity.outputs.length !== Object.keys(possibleObj.outputs).length
	)
		return false;

	if(inferredTypes.hasOwnProperty(entityName)){
		if(inferredTypes[entityName][0] !== undefined){
			const remainingEntityInputTypes = Object.values(possibleObj.inputs);
			const remainingInferredInputTypes = Object.values(inferredTypes[entityName][0]).filter(t => t !== '');
			while(remainingInferredInputTypes.length > 0){
				const it = remainingInferredInputTypes.pop();
				if(!remainingEntityInputTypes.includes(it))
					return false;
				remainingEntityInputTypes.splice(remainingEntityInputTypes.indexOf(it), 1);
			}
		}

		if(inferredTypes[entityName][1] !== undefined){
			const remainingEntityOutputTypes = Object.values(possibleObj.outputs);
			const remainingInferredOutputTypes = Object.values(inferredTypes[entityName][1]).filter(t => t !== '');
			while(remainingInferredOutputTypes.length > 0){
				const it = remainingInferredOutputTypes.pop();
				if(!remainingEntityOutputTypes.includes(it))
					return false;
				remainingEntityOutputTypes.splice(remainingEntityOutputTypes.indexOf(it), 1);
			}
		}
	}

	return true;
};

const dbSetIsValidForTcDataset = (dbSet, tcDs) => {
	if(Object.keys(dbSet.outputs).length < tcDs.outputs.length)
		return false;
	if(!tcDs.outputs.reduce((b, output) => b && dbSet.outputs.hasOwnProperty(output), true))
		return false;
	return true;
};

// maps key strings to value strings via levenshtein distances
// must have the same # of keys as vals
const levMapStrings = (keyArr: string[], valArr: string[]): { [string]: string } => {
	if(keyArr.length !== valArr.length){
		throw new Error(`Cannot lev map arrays of different lengths!`);
	}

	const keys = [...keyArr];
	const vals = [...valArr];
	const map = {};

	//console.log(keys);
	//console.log(vals);

	while(keys.length > 0){
		const scores = {};
		const matches = {};
		for(const k of keys){
			scores[k] = 10000;
			matches[k] = 'notfound';
			for(const v of vals){
				const score = lev.get(k, v);
				if(score < scores[k]){
					scores[k] = score;
					matches[k] = v;
				}
			}
		}

		let chosenKey = keys[0];
		let bestScore = 10000;
		for(const k of keys){
			if(scores[k] < bestScore){
				chosenKey = k;
				bestScore = scores[k];
			}
		}

		map[chosenKey] = matches[chosenKey];
		keys.splice(keys.indexOf(chosenKey), 1);
		vals.splice(vals.indexOf(matches[chosenKey]), 1);
		//console.log(map);
	}

	const orderedMap = {};
	for(const k of keyArr){
		orderedMap[k] = map[k];
	}

	//console.log(orderedMap);
	return orderedMap;
};

type EnvironmentConfigProps = {
	envInfo: {
		name: string,
		version: string,
	},
	queue: string,
	availableEnvs: { [string]: BeatEnvironment },
	updateEnvInfo: (name: string, version: string) => any,
	updateQueue: (queue: string) => any,
	disabled?: boolean,
};

const formatEnv = (name, version) => `${ name } (${ version })`;
const EnvironmentConfig = ({ envInfo, queue, availableEnvs, updateEnvInfo, updateQueue, disabled = false }: EnvironmentConfigProps) => {
	const eObjs = Object.values(availableEnvs).map(e => ({[formatEnv(e.name, e.version)]: Object.keys(e.queues)}));
	const envQueues = Object.assign({}, ...eObjs);
	const currEnv = formatEnv(envInfo.name, envInfo.version);
	const queues = envQueues[currEnv];
	return (
		<FormGroup row>
			<Col>
				<Label>
					<InfoTooltip
						id='envTooltip'
						info={`When executing an experiment in a docker-ized execution system (on BEAT web or in beat.cmdline using the "--docker" flag), there are certain Python environments available to run the experiment in. To choose a non-default environment, select the environment (and queue) here.`}
					>
						Environment Name
					</InfoTooltip>
				</Label>
				<Input
					type='select'
					className='env custom-select'
					value={currEnv}
					onChange={e => {
						// deconstruct selected env
						const rx = /^(.*) \((.*)\)$/;
						const res = rx.exec(e.target.value);
						if(!res)
							return;
						const [n, v] = res.slice(1, 3);
						updateEnvInfo(n, v);
					}}
					placeholder='Environment'
					disabled={disabled}
				>
					<option value={''}>Environment...</option>
					{
						Object.keys(envQueues).map((name, i) =>
							<option key={i} value={name}>{ name }</option>
						)
					}
				</Input>
			</Col>
			{ Array.isArray(queues) && queues.length > 0 &&
					<Col>
						<Label>
							<InfoTooltip
								id='queueTooltip'
								info={`The "queue" refers to the job queue to put the experiment in once a user decides to run it. This setting currently is only used on the BEAT web platform, and has no effect if ran through beat.cmdline.`}
							>
								Queue
							</InfoTooltip>
						</Label>
						<Input
							type='select'
							className='queue custom-select'
							value={queue}
							onChange={e => updateQueue(e.target.value)}
							placeholder='Queue'
							disabled={disabled}
						>
							{
								(queues).map((queue, i) =>
									<option key={i} value={queue}>{ queue }</option>
								)
							}
						</Input>
					</Col>
			}
		</FormGroup>
	);
};

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

	state = {
		lockMap: genLockMap(this.props.toolchain),
		activeBlockInfo: {
			name: undefined,
			set: undefined,
		},
		disableTypeInference: false,
	}

	setContents = (newContents: any) => {
		this.props.updateFunc({
			...this.props.data,
			contents: {
				'description': this.props.data.contents['description'],
				...newContents,
			}
		});
	}

	// handles left clicking on a block
	handleBlockClick = (blockName: string, set: BlockSet) => {
		const newMBI = {
			set,
			name: blockName
		};
		this.setState({
			activeBlockInfo: newMBI,
		});

		setTimeout(() => {
			const elForm = document.querySelector(`.${ set === 'datasets' ? 'dataset' : 'block' }_${ blockName }`);
			if(!elForm){
				return;
			}
			elForm.scrollIntoView(true);
		}, 50);
	}

	setLockMap = (name: string, value: boolean) => {
		this.setState((prevState, props) => ({
			lockMap: {
				...prevState.lockMap,
				[name]: value,
			},
		}));
	}

	// gets parameter data for all the blocks with selected algorithms
	getParameterObjs = (): any[] => {
		const algs = [...this.props.normalBlocks, ...this.props.analyzerBlocks];
		return Object.entries({...this.props.data.contents.blocks, ...this.props.data.contents.analyzers})
		.map(([bName, block]) => algs.find(a => a.name === block.algorithm))
		.filter(a => a !== null && a !== undefined && a.contents.parameters !== undefined && Object.keys(a.contents.parameters).length > 0)
		.map(a => [a.name, a.contents.parameters])
		;
	}

	// tries to infer the types of inputs & outputs given the structure of the toolchain,
	// the algorithms currently chosen for blocks,
	// and the database/protocol/set currently chosen for datasets.
	// Does forward (from datasets to analyzers) and backward (from analyzers to datasets).
	getConnectionInferredTypes = (): any => {
		const isLocked = (name) => !this.state.disableTypeInference && this.state.lockMap[name];

		const dsTypes = Object.entries(this.props.data.contents.datasets)
		.reduce((o, [name, ds]) => {
			const d = this.props.datasets.find(pds => datasetFieldToString(pds) === datasetFieldToString(ds));
			const outputs = d ? d.outputs : {};
			return {
				...o,
				[name]: [{}, outputs]
			};
		}, {});

		const nTypes = Object.entries(this.props.data.contents.blocks)
		.filter(([name, block]) => isLocked(name))
		.reduce((o, [name, block]) => ({
			...o,
			[name]: getBlockIOTypes(block, this.props.normalBlocks),
		}), {});

		const aTypes = Object.entries(this.props.data.contents.analyzers)
		.filter(([name, block]) => isLocked(name))
		.reduce((o, [name, block]) => ({
			...o,
			[name]: getBlockIOTypes(block, this.props.analyzerBlocks),
		}), {});

		const missingDatasets = [...this.props.toolchain.contents.datasets]
		.filter(ds => !dsTypes.hasOwnProperty(ds.name))
		.reduce((o, ds) => ({
			...o,
			[ds.name]: [{}, ds.outputs.reduce((o, out) => ({...o, [out]: ''}), {})]
		}), {});

		const missingBlocks = Object.entries({
			...this.props.data.contents.blocks,
			...this.props.data.contents.analyzers
		})
		.filter(([name, block]) => !nTypes.hasOwnProperty(name) && !aTypes.hasOwnProperty(name))
		.reduce((o, [name, block]) => ({...o, [name]: getBlockIOTypes(block, [...this.props.normalBlocks, ...this.props.analyzerBlocks])}), {});

		const inferredCache = Object.keys({...missingDatasets, ...missingBlocks}).reduce((o, name) => ({...o, [name]: []}), {});

		const getSetTypes = (entityName: string) => {
			if(dsTypes.hasOwnProperty(entityName))
				return dsTypes[entityName];
			if(nTypes.hasOwnProperty(entityName))
				return nTypes[entityName];
			if(aTypes.hasOwnProperty(entityName))
				return aTypes[entityName];
			return [{}, {}];
		};

		// false === forwards, inferring input types
		// true === backwards, inferring output types
		const calcInferredType = (entityName: string, forwardsOrBackwards: boolean): [any, any][] => {
			//forwards
			if(!forwardsOrBackwards) {
				const connectionsTo = this.props.toolchain.contents.connections.filter(c => c.to.split('.')[0] === entityName);
				const types = connectionsTo.map(c => {
					const [bName, oName] = c.from.split('.');
					const iName = c.to.split('.')[1];
					const fromTypes = getSetTypes(bName)[1];
					return [iName, fromTypes[oName] || ''];
				})
				.reduce((o, [iName, type]) => ({...o, [iName]: o.hasOwnProperty(iName) ? (o[iName] === '' ? type : o[iName]) : type}), {});
				inferredCache[entityName] = [types, inferredCache[entityName][1]];
			} else {
				const connectionsFrom = this.props.toolchain.contents.connections.filter(c => c.from.split('.')[0] === entityName);
				const types = connectionsFrom.map(c => {
					const [bName, iName] = c.to.split('.');
					const oName = c.from.split('.')[1];
					const toTypes = getSetTypes(bName)[0];
					return [oName, toTypes[iName] || ''];
				})
				.reduce((o, [oName, type]) => ({...o, [oName]: o.hasOwnProperty(oName) ? (o[oName] === '' ? type : o[oName]) : type}), {});
				inferredCache[entityName] = [inferredCache[entityName][0], types];
			}

			return inferredCache[entityName];
		};


		Object.keys({...missingBlocks, ...missingDatasets})
		.forEach(name => {
			calcInferredType(name, false);
			calcInferredType(name, true);
		});

		return inferredCache;
	};

	// finds any issues/errors/misconfiguration throughout the experiment. things it checks:
	// - whether a block doesnt have a dataset/algorithm assigned yet
	// - whether a block with an algorithm doesnt have an input/output assigned yet
	// - whether a connection of a block has different types at the output and input
	getErrorMap = (): {
		dataSourceMissing: string[],
		algorithmMissing: string[],
		ioMissing: string[],
	} => {
		const dataSourceMissing = Object.entries(this.props.data.contents.datasets)
		.filter(([name, ds]) => ds.set === '')
		.map(([name, b]) => name)
		;

		const algorithmMissing = [
			...Object.entries(this.props.data.contents.blocks),
			...Object.entries(this.props.data.contents.analyzers),
		].filter(([name, b]) => b.algorithm === '')
		.map(([name, b]) => name)
		;

		const ioMissing = [
			...Object.entries(this.props.data.contents.blocks),
			...Object.entries(this.props.data.contents.analyzers),
		].filter(([name, b]) => {
			if(b.algorithm === '')
				return false;
			const inputs = Object.values(b.inputs);
			if(inputs.includes('') || Array.from(new Set(inputs)).length !== inputs.length)
				return true;
			if(b.hasOwnProperty('outputs')){
				const outputs = Object.values(b.outputs);
				if(outputs.includes('') || Array.from(new Set(outputs)).length !== outputs.length)
					return true;
			}
			return false;
		})
		.map(([name, b]) => name)
		;

		const dsTypes = Object.entries(this.props.data.contents.datasets)
		.reduce((o, [name, ds]) => {
			const d = this.props.datasets.find(pds => datasetFieldToString(pds) === datasetFieldToString(ds));
			const outputs = d ? d.outputs : {};
			return {
				...o,
				[name]: [{}, outputs]
			};
		}, {});

		const nTypes = Object.entries(this.props.data.contents.blocks)
		.reduce((o, [name, block]) => ({
			...o,
			[name]: getBlockIOTypes(block, this.props.normalBlocks),
		}), {});

		const aTypes = Object.entries(this.props.data.contents.analyzers)
		.reduce((o, [name, block]) => ({
			...o,
			[name]: getBlockIOTypes(block, this.props.analyzerBlocks),
		}), {});
		// returns the connection ids instead of block names
		const incompatibleConnectionTypes = this.props.toolchain.contents.hasOwnProperty('connections') ?
			this.props.toolchain.contents.connections.map(c => {
				const [fromBlock, fromOutput] = c.from.split('.');
				const [toBlock, toInput] = c.to.split('.');
				const fromTypes = dsTypes[fromBlock] || nTypes[fromBlock];
				if(!fromTypes)
					return false;
				const fromType = fromTypes[1][fromOutput];
				const toTypes = dsTypes[toBlock] || nTypes[toBlock];
				if(!toTypes)
					return false;
				const toType = toTypes[0][toInput];
				if(!fromType || !toType)
					return false;
				if(fromType === toType)
					return false;
				return connectionToId(c);
			}).filter(id => id) :
			[]
		;

		return {
			dataSourceMissing,
			algorithmMissing,
			ioMissing,
			incompatibleConnectionTypes,
		};
	};

	renderDatasets = () => (
		<FormGroup className='datasets'>
			<FormGroup className='d-flex'>
				<h3 className='mr-2'>
					<InfoTooltip
						id='datasetsTooltip'
						info={`Assign sets from database protocols to your dataset blocks. Or, assign all your dataset blocks at once using a compatible database protocol.`}
					>
						Datasets
					</InfoTooltip>
				</h3>
				<Input
					type='select'
					className='custom-select'
					value={JSON.stringify(sortObject(this.props.data.contents.datasets))}
					onChange={e => {
						const str = e.target.value;

						const newDs = JSON.parse(str);
						//console.log(newDs);
						this.setContents({...this.props.data.contents, datasets: newDs});
						/*
						for(const dataset in this.props.data.contents.datasets){
							this.setLockMap(dataset, true);
						}
						*/
					}}
				>
					<option value=''>Protocol...</option>
					{
						Object.entries(
							this.props.datasets
							.reduce((o, ds) => {
								const key = datasetProtocolFieldToString(ds);
								return {
									...o,
									[key]: [
										...(o[key] || []),
										ds
									]
								};
							}, {})
						)
						.map(([dbProtStr, sets]) => {
							const enoughSets = sets.length == Object.keys(this.props.data.contents.datasets).length;
							if(!enoughSets)
								return [dbProtStr, false];

							// least to greatest outputs len
							const tcDss = [...this.props.toolchain.contents.datasets]
							.sort((a, b) => a.outputs.length - b.outputs.length);

							const newDataset = {};
							// start at greatest go to least
							while(tcDss.length > 0){
								const tcDs = tcDss.pop();
								let setsIdx = sets.findIndex(set => set.set === tcDs.name && dbSetIsValidForTcDataset(set, tcDs));
								if(setsIdx === -1){
									const validSets = sets.filter(set => dbSetIsValidForTcDataset(set, tcDs));
									let bestScore = 10000;
									let bestMatch = 'notfound';
									for(const s of validSets){
										const score = lev.get(tcDs.name, s.set);
										if(score < bestScore){
											bestScore = score;
											bestMatch = s;
										}
									}
									setsIdx = sets.findIndex(set => set == bestMatch);
								}
								if(setsIdx === -1)
									return [dbProtStr, false];

								const dbSet = sets.splice(setsIdx, 1)[0];
								newDataset[tcDs.name] = {
									set: dbSet.set,
									protocol: dbSet.protocol,
									database: dbSet.database,
								};
							}
							return [dbProtStr, newDataset];
						})
						.filter(([dbProtStr, dss]) => dss !== false)
						.map(([dbProtStr, dss], i) => <option key={i} value={JSON.stringify(sortObject(dss))}>{dbProtStr}</option>)
					}
				</Input>
			</FormGroup>
			{
				(Object.entries(this.props.data.contents.datasets): [string, any][])
				.sort(([n1, ds1], [n2, ds2]) => n1 > n2 ? 1 : -1)
				.map(([name, dataset], i, dEntries) => {
					const inferredTypes = this.getConnectionInferredTypes();
					const tcDataset = this.props.toolchain.contents.datasets.find(d => d.name === name);
					const dsString = datasetFieldToString(dataset);
					return (
						<FormGroup row key={i} className={`dataset${ i } dataset_${ name }`}>
							<Label sm={4}>
								{ name }
								{' '}
								<Badge
									title={`This dataset provides the "${ name }" synchronization channel.`}
									color='info'
								>
									{ name }
								</Badge>
								{/*
								{' '}
								<Button
									color={this.state.lockMap[name] ? 'primary' : 'secondary'}
									title={`When unlocked, connected blocks will not restrict their available algorithms to those compatible with this dataset's output types. When locked, connected blocks will take into account this dataset's output types.`}
									onClick={() => {
										this.setLockMap(name, !this.state.lockMap[name] && dataset.set !== '');
									}}
								>
									{ this.state.lockMap[name] ? 'Locked' : 'Unlocked' }
								</Button>
								*/}
							</Label>
							<Col sm={8}>
								<Input
									type='select'
									className='custom-select'
									valid={dsString !== datasetFieldToString({database:'',protocol:'',set:''})}
									value={dsString}
									onChange={e => {
										const str = e.target.value;
										const res = rxStringToDataset.exec(str) || [];
										const ds = {
											set: res[2] || '',
											protocol: res[1] || '',
											database: res[3] || '',
										};

										const newDs = {...this.props.data.contents.datasets, [name]: ds};
										this.setContents({...this.props.data.contents, datasets: newDs});
										//this.setLockMap(name, true);
									}}
								>
									<option value=''>Dataset...</option>
									{
										this.props.datasets
										.filter(ds => dbSetIsValidForTcDataset(ds, tcDataset))
										.filter(ds => isValidEntity(
											name,
											tcDataset,
											{outputs: ds.outputs, inputs: {}},
											inferredTypes
										))
										.map(ds => datasetFieldToString(ds))
										.map((str, i) =>
											<option key={i} value={str}>{ str }</option>
										)
									}
								</Input>
							</Col>
						</FormGroup>
					);
				})
			}
		</FormGroup>
	);

	// update a block's data (and the globals object, if it changed)
	updateBlock = (blockName: string, isAnalyzer: boolean, newBlock: any, globals: any = this.props.data.contents.globals) => {
		if(isAnalyzer)
			delete newBlock.outputs;

		const bKey = isAnalyzer ? 'analyzers' : 'blocks';
		const newBlocks = copyObj(this.props.data.contents[bKey]);
		newBlocks[blockName] = newBlock;
		this.setContents({
			...this.props.data.contents,
			[bKey]: newBlocks,
			globals,
		});
	}

	// renders the currently selected block to edit, if any
	renderBlock = (blockName: string, block: any, isAnalyzer: boolean, key: number) => {
		// gets the block info from the tc
		const tcBlock = isAnalyzer ? this.props.toolchain.contents.analyzers.find(b => b.name === blockName) : this.props.toolchain.contents.blocks.find(b => b.name === blockName);
		// gets the possible algorithms
		const algorithms = isAnalyzer ? this.props.analyzerBlocks : this.props.normalBlocks;

		// finds the current algorithm, if any
		const alg = algorithms.find(nb => nb.name === block.algorithm) || {contents: {groups: []}};
		// gets the input & output info of the current alg
		const [ inputs, outputs ] = algIOs(alg);

		// calculates the inferred types throughout the experiment
		const inferredTypes = this.getConnectionInferredTypes();

		// filters through the algs based on shape (input/output count) and type inferrence
		// to get the list of possible algs for this block
		const possibleAlgorithms = algorithms.filter(pa => {
			const [iAlg, oAlg] = algIOs(pa);
			return isValidEntity(blockName, tcBlock, {inputs: iAlg, outputs: oAlg}, inferredTypes);
		});

		// func to update the exp's block info
		const updateBlock = (newBlock: any, globals: any = this.props.data.contents.globals) =>
			this.updateBlock(blockName, isAnalyzer, newBlock, globals);

		// get info that could be from the block or from the global settings
		const envInfo = block.environment || this.props.data.contents.globals.environment;
		const queue = block.queue || this.props.data.contents.globals.queue;
		const envDisabled = !block.hasOwnProperty('environment');

		const getClearedGlobals = () => {
			const globals = copyObj(this.props.data.contents.globals);
			// if the old algorithm had parameters and no other block is using that alg,
			// delete the global param defaults for the old alg
			if(globals.hasOwnProperty(block.algorithm) &&
				[...this.props.toolchain.contents.analyzers, ...this.props.toolchain.contents.blocks]
				.filter(b => b.algorithm === block.algorithm).length <= 1)
				delete globals[block.algorithm];
			return globals;
		};

		return (
			<FormGroup key={key} className={`block${ key } block_${ blockName }`}>
				<h4>
					<InfoTooltip
						id='blockTooltip'
						info={`Choose a compatible algorithm for this block to run when execution reaches this place in the toolchain. When you choose an algorithm, inputs & outputs in the algorithm are automatically mapped to the inputs & outputs of the block - but make sure to check this mapping and manually fix incorrect assignments.`}
					>
						Block <pre className='preInline'>{ blockName }</pre>
					</InfoTooltip>
					{' '}
					<Badge
						title={`Block is synchronized to the "${ tcBlock.synchronized_channel }" channel`}
						color='info'
					>{ tcBlock.synchronized_channel }</Badge>
				</h4>
				<FormGroup row>
					<Col sm='2'>
						<Label>Execution Info</Label>
						<Button
							color={envDisabled ? 'secondary' : 'warning'}
							onClick={() => {
								if(envDisabled)
									updateBlock({
										...block,
										environment: {
											name: envInfo.name,
											version: envInfo.version,
										},
										queue: queue,
									});
								else {
									const newBlock = copyObj(block);
									delete newBlock.environment;
									delete newBlock.queue;
									updateBlock(newBlock);
								}
							}}
							title={`Toggle whether or not the block is using the global default value for execution information, or is using a custom setting for this specific block.`}
						>
							{ envDisabled ? 'Using global defaults' : 'Using block value' }
						</Button>
					</Col>
					<Col>
						<EnvironmentConfig
							envInfo={envInfo}
							queue={queue}
							availableEnvs={this.props.environments}
							disabled={envDisabled}
							updateEnvInfo={(name, version) => {
								const env = Object.values(this.props.environments).find(e => e.name === name && e.version === version);
								if(!env)
									return;
								const queue = Object.keys(env.queues)[0];
								updateBlock({
									...block,
									environment: {
										name,
										version,
									},
									queue,
								});
							}}
							updateQueue={queue => updateBlock({
								...block,
								queue,
							})}
						/>
					</Col>
				</FormGroup>
				<FormGroup className='algorithm'>
					<Label>
						Algorithm
					</Label>
					<FormGroup row>
						<Col>
							<Input
								type='select'
								className='custom-select'
								valid={block.algorithm !== ''}
								value={block.algorithm}
								onChange={e => {
									// user selected a different algorithm
									const str = e.target.value;
									// find the alg or use a dummy algorithm instead
									const alg = algorithms.find(nb => nb.name === str) || {name: '', contents: {groups: []}};
									const [ inputs, outputs ] = algIOs(alg);
									// try to map the IOs from the algorithm to the block via names
									const findCloseIOMatch = (s: string, arr: string[]): string => {
										if(arr.length === 1)
											return arr[0];
										const findRes = arr.find(str => str.includes(s));
										if(findRes !== undefined)
											return findRes;
										return '';
									};

									// create the new block obj with the new alg
									const thisBlock = {
										...block,
										algorithm: str,
									};
									// assign new inputs
									if(tcBlock.inputs)
										thisBlock.inputs = levMapStrings(Object.keys(inputs), tcBlock.inputs);
									// assign new outputs
									if(tcBlock.outputs)
										thisBlock.outputs = levMapStrings(Object.keys(outputs), tcBlock.outputs);

									// setup the parameters (erase & create stuff)
									const globals = getClearedGlobals();

									// if the new alg has parameters, gen the global defaults for the params
									if(alg.contents.parameters && Object.keys(alg.contents.parameters).length > 0)
										globals[alg.name] = {
											...Object.entries(alg.contents.parameters || {})
											.map(([pName, param]) => ({[pName]: getDefaultParameterValue(param)}))
											.reduce((o, p) => ({...o, ...p}), {}),
											...this.props.data.contents.globals[alg.name],
										};

									updateBlock(thisBlock, globals);
									//this.setLockMap(blockName, true);
								}}
							>
								<option value=''>Algorithm...</option>
								{
									possibleAlgorithms
									.map(nb => nb.name)
									.map((str, i) =>
										<option key={i} value={str}>{ str }</option>
									)
								}
							</Input>
						</Col>
						<Col sm='auto'>
							{ !isAnalyzer &&
									<React.Fragment>
										<Button
											color='primary'
											disabled={block.algorithm === ''}
											title={`Copies the algorithm & IO mappings to all unconfigured blocks with the same inputs & outputs`}
											onClick={() => {
												// copy the algorithm and input/output mappings to blocks
												// that are the same except for the block name and dont have an algorithm assigned already
												const targetBlocks = this.props.toolchain.contents.blocks
												// not the block being copied from
												.filter(b => b.name !== blockName)
												// same IO
												.filter(b => JSON.stringify(b.inputs) === JSON.stringify(tcBlock.inputs))
												.filter(b => JSON.stringify(b.outputs) === JSON.stringify(tcBlock.outputs))
												.map(b => b.name)
												;
												//console.log(targetBlocks);

												// because the target blocks have the exact same inputs/outputs as the current block
												// we dont need to re-compute the block object that we will copy!
												// instead, just reuse the current block object and assign it to all
												// the target blocks.
												// also, because all these blocks are the same algorithm as the current block
												// we dont need to mess with the globals.

												const newBlocks = copyObj(this.props.data.contents.blocks);
												targetBlocks.forEach(bName => { newBlocks[bName] = copyObj(block); });
												this.setContents({
													...this.props.data.contents,
													blocks: newBlocks,
												});
											}}
										>
											Copy to similar blocks
										</Button>
										{' '}
									</React.Fragment>
							}
							<Button
								color='secondary'
								title={`Clears the block data, resetting the selected block.`}
								onClick={() => {
									const newBlock = copyObj(block);
									const newGlobals = getClearedGlobals();
									newBlock.algorithm = '';
									if(newBlock.inputs)
										newBlock.inputs = {};
									if(newBlock.outputs)
										newBlock.outputs = {};
									if(newBlock.parameters)
										newBlock.parameters = {};

									updateBlock(newBlock, newGlobals);
								}}
							>
								Reset
							</Button>
						</Col>
					</FormGroup>
				</FormGroup>
				{ Object.entries(alg.contents.parameters || {}).length > 0 && <h5>Parameters</h5> }
				{
					Object.entries(alg.contents.parameters || {}).map(([pName, paramObj], i) => {
						const isTethered = !block.parameters.hasOwnProperty(pName);
						const globalParams = this.props.data.contents.globals[alg.name];
						const currVal = block.parameters[pName] || globalParams[pName];

						const deleteParam = () => {
							const thisBlock = {...block,
							};
							delete block.parameters[pName];
							updateBlock(thisBlock);
						};
						const updateParam = (val: ParameterValue) => {
							const thisBlock = {...block,
								parameters: {...block.parameters,
									[pName]: val
								}
							};
							updateBlock(thisBlock);
						};

						return (
							<FormGroup key={i} className={`parameter${ i }`}>
								<Label>
									{ pName }
								</Label>
								<FormGroup row>
									<Col sm='auto'>
										<Button
											color={isTethered ? 'secondary' : 'warning'}
											onClick={() => {
												if(isTethered){
													updateParam(currVal);
												} else {
													deleteParam();
												}
											}}
											title={`Toggle whether or not the block is using the global default value for this parameter, or is using a custom value for this specific block.`}
										>
											{ isTethered ? 'Using global defaults' : 'Using block value' }
										</Button>
									</Col>
									<Col>
										<ParameterConsume
											parameter={paramObj}
											currVal={currVal}
											updateFunc={updateParam}
										/>
									</Col>
								</FormGroup>
							</FormGroup>
						);
					})
				}
				<h5>Inputs</h5>
				{
					Object.entries(inputs).map(([iName, type], i, inputs) => {
						const iLabel = `${ iName } (${ type })`;
						const value = block.inputs[iName] || '';
						const foundCount = Object.values(block.inputs).filter(s => s === value).length;
						return (
							<FormGroup row key={i} className={`input${ i }`}>
								<Label sm={6}>{ iLabel }</Label>
								<Col sm={6}>
									<Input
										type='select'
										className='custom-select'
										valid={value !== '' && foundCount === 1}
										value={value}
										onChange={e => {
											const str = e.target.value;
											const thisBlock = {
												...block,
												inputs: {...block.inputs, [iName]: str}
											};
											for(const key in block.inputs){
												if(block.inputs[key] === str)
													thisBlock.inputs[key] = value;
											}
											updateBlock(thisBlock);
										}}
									>
										<option value=''>Input...</option>
										{
											tcBlock.inputs
											.map((str, i) =>
												<option key={i} value={str}>{ str }</option>
											)
										}
									</Input>
								</Col>
							</FormGroup>
						);
					})
				}
				{ Object.keys(outputs).length > 0 && <h5>Outputs</h5> }
				{
					Object.entries(outputs).map(([oName, type], i) => {
						const oLabel = `${ oName } (${ type })`;
						const value = block.outputs[oName] || '';
						const foundCount = Object.values(block.outputs).filter(s => s === value).length;
						return (
							<FormGroup row key={i} className={`output${ i }`}>
								<Label sm={6}>{ oLabel }</Label>
								<Col sm={6}>
									<Input
										type='select'
										className='custom-select'
										valid={value !== '' && foundCount === 1}
										value={value}
										onChange={e => {
											const str = e.target.value;
											const thisBlock = {
												...block,
												outputs: {...block.outputs, [oName]: str}
											};
											for(const key in block.outputs){
												if(block.outputs[key] === str)
													thisBlock.outputs[key] = value;
											}
											updateBlock(thisBlock);
										}}
									>
										<option value=''>Output...</option>
										{
											tcBlock.outputs
											.map((str, i) =>
												<option key={i} value={str}>{ str }</option>
											)
										}
									</Input>
								</Col>
							</FormGroup>
						);
					})
				}

			</FormGroup>
		);
	}

	renderBlocks = () => this.state.activeBlockInfo.set === 'blocks' && (
		<FormGroup>
			{
				(Object.entries(this.props.data.contents.blocks): [string, any][])
				.filter(([n, b]) => n === this.state.activeBlockInfo.name)
				.sort(([n1, ds1], [n2, ds2]) => n1 > n2 ? 1 : -1)
				.map(([name, blk], i) =>
					this.renderBlock(name, blk, false, i)
				)
			}
		</FormGroup>
	);

	renderAnalyzers = () => this.state.activeBlockInfo.set === 'analyzers' && (
		<FormGroup>
			<h3>Analyzers</h3>
			{
				(Object.entries(this.props.data.contents.analyzers): [string, any][])
				.filter(([n, b]) => n === this.state.activeBlockInfo.name)
				.sort(([n1, ds1], [n2, ds2]) => n1 > n2 ? 1 : -1)
				.map(([name, blk], i) =>
					this.renderBlock(name, blk, true, i)
				)
			}
		</FormGroup>
	)

	renderGlobals = () => {
		const paramObjs = this.getParameterObjs();
		return (
			<FormGroup className='globals'>
				<h3>
					<InfoTooltip
						id='globalSettingsTooltip'
						info={`These settings are for the entire experiment, not for a specific block.`}
					>
						Global Settings
					</InfoTooltip>
				</h3>
				<EnvironmentConfig
					envInfo={this.props.data.contents.globals.environment}
					queue={this.props.data.contents.globals.queue}
					availableEnvs={this.props.environments}
					updateEnvInfo={(name, version) => this.setContents({
						...this.props.data.contents,
						globals: {
							...this.props.data.contents.globals,
							environment: {
								...this.props.data.contents.globals.environment,
								name,
								version,
							},
							queue: '',
						}
					})}
					updateQueue={queue => this.setContents({
						...this.props.data.contents,
						globals: {
							...this.props.data.contents.globals,
							queue,
						}
					})}
				/>
				{ paramObjs.length > 0 &&
						<h3>
							Algorithm Parameter Defaults
						</h3>
				}
				{
					paramObjs.map(([algName, ps], i) => (
						<FormGroup key={i} className={`globalParameter${ i }`}>
							<h4>{ algName }</h4>
							{
								Object.entries(ps).map(([pName, paramObj], j) => (
									<FormGroup key={j}>
										<Label for={`${ algName }/${ pName }`}>{ pName }</Label>
										<ParameterConsume
											name={`${ algName }/${ pName }`}
											parameter={paramObj}
											currVal={this.props.data.contents.globals[algName][pName]}
											updateFunc={(val: ParameterValue) => {
												const globals = {
													...this.props.data.contents.globals,
													[algName]: {
														...this.props.data.contents.globals[algName],
														[pName]: val
													}
												};
												this.setContents({
													...this.props.data.contents,
													globals
												});
											}}
										/>
									</FormGroup>
								))
							}
						</FormGroup>
					))
				}
			</FormGroup>
		);
	}

	renderGraphicalEditor = () => {
		const tc = this.props.toolchain;
		const {representation, blocks, datasets, analyzers, connections} = tc.contents;
		const groups = tc.extraContents ? tc.extraContents.groups : [];
		const expData = [
			...Object.entries(this.props.data.contents.datasets)
			.map(([name, ds]) => ({ [name]: datasetFieldToString(ds) })),
			...Object.entries(this.props.data.contents.blocks)
			.map(([name, b]) => ({ [name]: b.algorithm })),
			...Object.entries(this.props.data.contents.analyzers)
			.map(([name, a]) => ({ [name]: a.algorithm })),
		].reduce((o, data) => ({ ...o, ...data }), {});
		const errorMap = this.getErrorMap();
		const errorStrings: { [string]: string[] } = ((Object.entries(errorMap): any): [ string, string[] ][])
		.map(([ error, names ]) => names.reduce((o, n) => {
			let errorMessage = '';
			// messages need to be short enough to not be clipped (up to 1 char longer than the ioMissing message)
			switch(error){
				case 'dataSourceMissing':
					errorMessage = 'Data source not assigned!';
					break;
				case 'algorithmMissing':
					errorMessage = 'Algorithm not assigned!';
					break;
				case 'ioMissing':
					errorMessage = 'Input/output not assigned!';
					break;
				case 'incompatibleConnectionTypes':
					errorMessage = '. Connection has incompatible start/end types!';
					break;
				default:
					errorMessage = error;
					break;
			}
			return {...o, [n]: errorMessage};
		}, {}))
		.reduce((o, errors) => {
			Object.entries(errors).forEach(([name, errorString]) => {
				if(o.hasOwnProperty(name))
					o[name].push(errorString);
				else
					o[name] = [errorString];
			});
			return o;
		}, {});

		const scrollToGlobalSettings = (e) => {
			const elSettings = document.querySelector('.globals');
			if(!elSettings){
				return;
			}
			elSettings.scrollIntoView(true);
		};

		return (
			<FormGroup row>
				<Col sm={12}>
					<GraphicalEditor
						repData={representation || {blocks: {}, connections: {}, channel_colors: {}}}
						blocks={blocks || []}
						datasets={datasets || []}
						analyzers={analyzers || []}
						connections={connections || []}
						groups={groups}
						experimentData={expData}
						handleBlockClick={this.handleBlockClick}
						errorBlocks={errorStrings}
						focusBlocks={[this.state.activeBlockInfo.name || '']}
						handleBackgroundClick={scrollToGlobalSettings}
					/>
				</Col>
			</FormGroup>
		);
	}

	render = () => (
		<div className='experimentEditor'>
			<div className='d-flex'>
				<Button
					className='mx-auto'
					outline
					color='secondary'
					onClick={() => this.props.saveFunc(this.props.data)}
				>
					Save Changes (Changes are <ValidSchemaBadge entity='experiment' obj={this.props.data} />)
				</Button>
			</div>
			<Form onSubmit={(e) => e.preventDefault()}>
				<FormGroup tag='fieldset'>
					<FormGroup>
						<Label>Short Description</Label>
						<Input
							type='text'
							className='expDescription'
							placeholder='Experiment description...'
							value={this.props.data.contents['description']}
							onChange={e => this.setContents({ ...this.props.data.contents, 'description': e.target.value})}
						/>
					</FormGroup>
				</FormGroup>
				{/*
				<pre>{JSON.stringify(this.getConnectionInferredTypes(), null, 4)}</pre>
				*/}
				{ this.renderGraphicalEditor() }
				<FormGroup>
					<Button block outline
						onClick={e => {
							this.setState({ disableTypeInference: !this.state.disableTypeInference });
						}}
						title={`When assigning algorithms & datasets to blocks, the platform tries to infer types throughout the toolchain to filter the possibilities. If you don't want any filtering, you may turn this feature off to be able to select any compatible algorithm/dataset. You may re-enable filtering at any time by clicking the button again.`}
					>
						{ this.state.disableTypeInference ? 'Enable' : 'Disable' } Type Inference
					</Button>
				</FormGroup>
				{ this.state.activeBlockInfo.set === 'blocks' && this.renderBlocks() }
				{ this.state.activeBlockInfo.set === 'analyzers' && this.renderAnalyzers() }
				{ this.state.activeBlockInfo.set === 'datasets' && this.renderDatasets() }
				{ this.renderGlobals() }
			</Form>
		</div>
	);
}

const mapStateToProps = (state, ownProps) => {
	const exps = Selectors.experimentGet(state);
	const data = exps[ownProps.index] || undefined;
	const tcName = data ? data.name.split('/').slice(1, 4).join('/') : '';
	const tc = Selectors.toolchainGet(state).find(t => t.name === tcName) || {
		name: '',
		contents: {
			blocks: [],
			analyzers: [],
			datasets: [],
		}
	};
	const normalBlocks = Selectors.normalBlocks(state);
	const analyzerBlocks = Selectors.analyzerBlocks(state);

	const obj = {
		experiments: exps,
		data: data || getValidObj({name: '', contents: {}}, tc, [normalBlocks, ...analyzerBlocks]),
		// algorithms for normal blocks
		normalBlocks,
		// algorithms for analyzer blocks
		analyzerBlocks,
		datasets: Selectors.flattenedDatabases(state),
		toolchain: tc,
		environments: Selectors.environmentsGet(state),
	};
	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[`experimentUpdate`](obj.name, obj));
	},
});

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