// Internal Imports
import * as Configuration from './configuration';
import { Tools } from './tools';
import { Events, ScaleTypes, ColorClassNameTypes } from './interfaces';

// D3
import { map } from 'd3-collection';
import { scaleOrdinal } from 'd3-scale';
import { stack } from 'd3-shape';

/** The charting model layer which includes mainly the chart data and options,
 * as well as some misc. information to be shared among components */
export class ChartModel {
	protected services: any;

	// Internal Model state
	protected state: any = {
		options: {},
	};

	// Data labels
	/**
	 * A list of all the data groups that have existed within the lifetime of the chart
	 * @type string[]
	 */
	protected allDataGroups: string[];

	// Fill scales & fill related objects
	protected colorScale: any = {};

	protected colorClassNames: any = {};

	constructor(services: any) {
		this.services = services;
	}

	getAllDataFromDomain(groups?) {
		if (!this.getData()) {
			return null;
		}
		const options = this.getOptions();
		// Remove datasets that have been disabled
		let allData = this.getData();
		const dataGroups = this.getDataGroups();
		const { groupMapsTo } = Tools.getProperty(options, 'data');
		const axesOptions = Tools.getProperty(options, 'axes');

		// filter out the groups that are irrelevant to the component
		if (groups) {
			allData = allData.filter((item) => {
				return groups.includes(item.group);
			});
		}

		if (axesOptions) {
			Object.keys(axesOptions).forEach((axis) => {
				const mapsTo = axesOptions[axis].mapsTo;
				const scaleType = axesOptions[axis].scaleType;
				// make sure linear/log values are numbers
				if (
					scaleType === ScaleTypes.LINEAR ||
					scaleType === ScaleTypes.LOG
				) {
					allData = allData.map((datum) => {
						return { ...datum, [mapsTo]: Number(datum[mapsTo]) };
					});
				}

				// Check for custom domain
				if (mapsTo && axesOptions[axis].domain) {
					if (scaleType === ScaleTypes.LABELS) {
						allData = allData.filter((datum) =>
							axesOptions[axis].domain.includes(datum[mapsTo])
						);
					} else {
						const [start, end] = axesOptions[axis].domain;

						// Filter out data outside domain
						allData = allData.filter(
							(datum) =>
								datum[mapsTo] >= start && datum[mapsTo] <= end
						);
					}
				}
			});
		}

		return allData.filter((datum) => {
			return dataGroups.find(
				(group) => group.name === datum[groupMapsTo]
			);
		});
	}

	/**
	 * Charts that have group configs passed into them, only want to retrieve the display data relevant to that chart
	 * @param groups the included datasets for the particular chart
	 */
	getDisplayData(groups?) {
		if (!this.get('data')) {
			return null;
		}

		const { ACTIVE } = Configuration.legend.items.status;
		const dataGroups = this.getDataGroups(groups);
		const { groupMapsTo } = this.getOptions().data;
		const allDataFromDomain = this.getAllDataFromDomain(groups);

		return allDataFromDomain.filter((datum) => {
			return dataGroups.find(
				(dataGroup) =>
					dataGroup.name === datum[groupMapsTo] &&
					dataGroup.status === ACTIVE
			);
		});
	}

	getData() {
		return this.get('data');
	}

	isDataEmpty() {
		return !this.getData().length;
	}

	/**
	 *
	 * @param newData The new raw data to be set
	 */
	setData(newData) {
		const sanitizedData = this.sanitize(Tools.clone(newData));
		const dataGroups = this.generateDataGroups(sanitizedData);

		this.set({
			data: sanitizedData,
			dataGroups,
		});

		return sanitizedData;
	}

	getDataGroups(groups?) {
		const isDataLoading = Tools.getProperty(
			this.getOptions(),
			'data',
			'loading'
		);

		// No data should be displayed while data is still loading
		if (isDataLoading) {
			return [];
		}

		// if its a combo chart, the specific chart will pass the model the groups it needs
		if (groups) {
			return this.get('dataGroups').filter((dataGroup) =>
				groups.includes(dataGroup.name)
			);
		}
		return this.get('dataGroups');
	}

	getActiveDataGroups(groups?) {
		const { ACTIVE } = Configuration.legend.items.status;

		return this.getDataGroups(groups).filter(
			(dataGroup) => dataGroup.status === ACTIVE
		);
	}

	getDataGroupNames(groups?) {
		const dataGroups = this.getDataGroups(groups);
		return dataGroups.map((dataGroup) => dataGroup.name);
	}

	getActiveDataGroupNames(groups?) {
		const activeDataGroups = this.getActiveDataGroups(groups);
		return activeDataGroups.map((dataGroup) => dataGroup.name);
	}

	getGroupedData(groups?) {
		const displayData = this.getDisplayData(groups);
		const groupedData = {};
		const { groupMapsTo } = this.getOptions().data;

		displayData.map((datum) => {
			const group = datum[groupMapsTo];
			if (
				groupedData[group] !== null &&
				groupedData[group] !== undefined
			) {
				groupedData[group].push(datum);
			} else {
				groupedData[group] = [datum];
			}
		});

		return Object.keys(groupedData).map((groupName) => ({
			name: groupName,
			data: groupedData[groupName],
		}));
	}

	getDataValuesGroupedByKeys(groups?) {
		const options = this.getOptions();
		const { groupMapsTo } = options.data;
		const displayData = this.getDisplayData(groups);

		const stackKeys = map(displayData, (datum) => {
			const domainIdentifier = this.services.cartesianScales.getDomainIdentifier(
				datum
			);
			return datum[domainIdentifier];
		}).keys();

		const axisPosition = this.services.cartesianScales.domainAxisPosition;
		const scaleType = options.axes[axisPosition].scaleType;

		// Sort keys
		if (scaleType === ScaleTypes.TIME) {
			stackKeys.sort((a: any, b: any) => {
				const dateA: any = new Date(a);
				const dateB: any = new Date(b);
				return dateA - dateB;
			});
		} else if (
			scaleType === ScaleTypes.LOG ||
			scaleType === ScaleTypes.LINEAR
		) {
			stackKeys.sort((a: any, b: any) => a - b);
		}

		const dataGroupNames = this.getDataGroupNames();

		return stackKeys.map((key) => {
			const correspondingValues = { sharedStackKey: key };
			dataGroupNames.forEach((dataGroupName) => {
				const correspondingDatum = displayData.find((datum) => {
					const domainIdentifier = this.services.cartesianScales.getDomainIdentifier(
						datum
					);
					return (
						datum[groupMapsTo] === dataGroupName &&
						datum[domainIdentifier].toString() === key
					);
				});

				const rangeIdentifier = this.services.cartesianScales.getRangeIdentifier(
					correspondingValues
				);
				correspondingValues[dataGroupName] = correspondingDatum
					? correspondingDatum[rangeIdentifier]
					: null;
			});
			return correspondingValues;
		}) as any;
	}

	getStackedData({ percentage = false, groups = null }) {
		const options = this.getOptions();
		const { groupMapsTo } = options.data;

		const dataGroupNames = this.getDataGroupNames(groups);
		const dataValuesGroupedByKeys = this.getDataValuesGroupedByKeys(groups);

		if (percentage) {
			const maxByKey = Tools.fromPairs(
				dataValuesGroupedByKeys.map((d: any) => [d.sharedStackKey, 0])
			);

			dataValuesGroupedByKeys.forEach((d: any) => {
				dataGroupNames.forEach((name) => {
					maxByKey[d.sharedStackKey] += d[name];
				});
			});

			// cycle through data values to get percentage
			dataValuesGroupedByKeys.forEach((d: any) => {
				dataGroupNames.forEach((name) => {
					if (maxByKey[d.sharedStackKey]) {
						d[name] = (d[name] / maxByKey[d.sharedStackKey]) * 100;
					} else {
						d[name] = 0;
					}
				});
			});
		}

		return stack()
			.keys(dataGroupNames)(dataValuesGroupedByKeys)
			.map((series, i) => {
				// Add data group names to each series
				return Object.keys(series)
					.filter((key: any) => !isNaN(key))
					.map((key) => {
						const element = series[key];
						element[groupMapsTo] = dataGroupNames[i];

						return element;
					});
			});
	}

	/**
	 * @return {Object} The chart's options
	 */
	getOptions() {
		return this.state.options;
	}

	set(newState: any, configs?: any) {
		this.state = Object.assign({}, this.state, newState);
		const newConfig = Object.assign(
			{ skipUpdate: false, animate: true }, // default configs
			configs
		);
		if (!newConfig.skipUpdate) {
			this.update(newConfig.animate);
		}
	}

	get(property?: string) {
		if (property) {
			return this.state[property];
		} else {
			return this.state;
		}
	}

	/**
	 *
	 * @param newOptions New options to be set
	 */
	setOptions(newOptions) {
		this.set({
			options: Tools.merge(this.getOptions(), newOptions),
		});
	}

	/**
	 *
	 * Updates miscellanous information within the model
	 * such as the color scales, or the legend data labels
	 */
	update(animate = true) {
		if (!this.getDisplayData()) {
			return;
		}

		this.updateAllDataGroups();

		this.setCustomColorScale();
		this.setColorClassNames();
		this.services.events.dispatchEvent(Events.Model.UPDATE, { animate });
	}

	/*
	 * Data labels
	 */
	toggleDataLabel(changedLabel: string) {
		const { ACTIVE, DISABLED } = Configuration.legend.items.status;
		const dataGroups = this.getDataGroups();

		const hasDeactivatedItems = dataGroups.some(
			(group) => group.status === DISABLED
		);
		const activeItems = dataGroups.filter(
			(group) => group.status === ACTIVE
		);

		// If there are deactivated items, toggle "changedLabel"
		if (hasDeactivatedItems) {
			// If the only active item is being toggled
			// Activate all items
			if (
				activeItems.length === 1 &&
				activeItems[0].name === changedLabel
			) {
				// If every item is active, then enable "changedLabel" and disable all other items
				dataGroups.forEach((group, i) => {
					dataGroups[i].status = ACTIVE;
				});
			} else {
				const indexToChange = dataGroups.findIndex(
					(group) => group.name === changedLabel
				);
				dataGroups[indexToChange].status =
					dataGroups[indexToChange].status === DISABLED
						? ACTIVE
						: DISABLED;
			}
		} else {
			// If every item is active, then enable "changedLabel" and disable all other items
			dataGroups.forEach((group, i) => {
				dataGroups[i].status =
					group.name === changedLabel ? ACTIVE : DISABLED;
			});
		}

		// Updates selected groups
		const updatedActiveItems = dataGroups.filter(
			(group) => group.status === ACTIVE
		);
		const options = this.getOptions();

		const hasUpdatedDeactivatedItems = dataGroups.some(
			(group) => group.status === DISABLED
		);

		// If there are deactivated items, map the item name into selected groups
		if (hasUpdatedDeactivatedItems) {
			options.data.selectedGroups = updatedActiveItems.map(
				(activeItem) => activeItem.name
			);
		} else {
			// If every item is active, clear array
			options.data.selectedGroups = [];
		}

		// dispatch legend filtering event with the status of all the dataLabels
		this.services.events.dispatchEvent(Events.Legend.ITEMS_UPDATE, {
			dataGroups,
		});

		// Update model
		this.set({
			dataGroups,
		});
	}

	/**
	 * Should the data point be filled?
	 * @param group
	 * @param key
	 * @param value
	 * @param defaultFilled the default for this chart
	 */
	getIsFilled(group: any, key?: any, data?: any, defaultFilled?: boolean) {
		const options = this.getOptions();
		if (options.getIsFilled) {
			return options.getIsFilled(group, key, data, defaultFilled);
		} else {
			return defaultFilled;
		}
	}

	getFillColor(group: any, key?: any, data?: any) {
		const options = this.getOptions();
		const defaultFillColor = Tools.getProperty(this.colorScale, group);

		if (options.getFillColor) {
			return options.getFillColor(group, key, data, defaultFillColor);
		} else {
			return defaultFillColor;
		}
	}

	getStrokeColor(group: any, key?: any, data?: any) {
		const options = this.getOptions();
		const defaultStrokeColor = Tools.getProperty(this.colorScale, group);

		if (options.getStrokeColor) {
			return options.getStrokeColor(group, key, data, defaultStrokeColor);
		} else {
			return defaultStrokeColor;
		}
	}

	isUserProvidedColorScaleValid() {
		const userProvidedScale = Tools.getProperty(
			this.getOptions(),
			'color',
			'scale'
		);
		const dataGroups = this.getDataGroups();

		if (
			userProvidedScale == null ||
			Object.keys(userProvidedScale).length == 0
		) {
			return false;
		}

		return dataGroups.some((dataGroup) =>
			Object.keys(userProvidedScale).includes(dataGroup.name)
		);
	}

	getColorClassName(configs: {
		classNameTypes: ColorClassNameTypes[];
		dataGroupName: string;
		originalClassName?: string;
	}) {
		const colorPairingTag = this.colorClassNames(configs.dataGroupName);
		let className = configs.originalClassName;
		configs.classNameTypes.forEach(
			(type) =>
				(className = configs.originalClassName
					? `${className} ${type}-${colorPairingTag}`
					: `${type}-${colorPairingTag}`)
		);

		return className;
	}

	/**
	 * For charts that might hold an associated status for their dataset
	 */
	getStatus() {
		return null;
	}

	getAllDataGroupsNames() {
		return this.allDataGroups;
	}

	/**
	 * Converts data provided in the older format to tabular
	 *
	 */
	protected transformToTabularData(data) {
		console.warn(
			"We've updated the charting data format to be tabular by default. The current format you're using is deprecated and will be removed in v1.0, read more here https://carbon-design-system.github.io/carbon-charts/?path=/story/tutorials--tabular-data-format"
		);
		const tabularData = [];
		const { datasets, labels } = data;

		// Loop through all datasets
		datasets.forEach((dataset) => {
			// Update each data point to the new format
			dataset.data.forEach((datum, i) => {
				let group;

				const datasetLabel = Tools.getProperty(dataset, 'label');
				if (datasetLabel === null) {
					const correspondingLabel = Tools.getProperty(labels, i);
					if (correspondingLabel) {
						group = correspondingLabel;
					} else {
						group = 'Ungrouped';
					}
				} else {
					group = datasetLabel;
				}

				const updatedDatum = {
					group,
					key: labels[i],
				};

				if (isNaN(datum)) {
					updatedDatum['value'] = datum.value;
					updatedDatum['date'] = datum.date;
				} else {
					updatedDatum['value'] = datum;
				}

				tabularData.push(updatedDatum);
			});
		});

		return tabularData;
	}

	protected getTabularData(data) {
		// if data is not an array
		if (!Array.isArray(data)) {
			return this.transformToTabularData(data);
		}

		return data;
	}

	protected sanitize(data) {
		data = this.getTabularData(data);

		return data;
	}

	/*
	 * Data groups
	 */
	protected updateAllDataGroups() {
		// allDataGroups is used to generate a color scale that applies
		// to all the groups. Now when the data updates, you might remove a group,
		// and then bring it back in a newer data update, therefore
		// the order of the groups in allDataGroups matters so that you'd never
		// have an incorrect color assigned to a group.

		// Also, a new group should only be added to allDataGroups if
		// it doesn't currently exist

		if (!this.allDataGroups) {
			this.allDataGroups = this.getDataGroupNames();
		} else {
			// Loop through current data groups
			this.getDataGroupNames().forEach((dataGroupName) => {
				// If group name hasn't been stored yet, store it
				if (this.allDataGroups.indexOf(dataGroupName) === -1) {
					this.allDataGroups.push(dataGroupName);
				}
			});
		}
	}

	protected generateDataGroups(data) {
		const { groupMapsTo } = this.getOptions().data;
		const { ACTIVE, DISABLED } = Configuration.legend.items.status;
		const options = this.getOptions();

		const uniqueDataGroups = map(
			data,
			(datum) => datum[groupMapsTo]
		).keys();

		// check if selectedGroups can be applied to chart with current data groups
		if (options.data.selectedGroups.length) {
			const hasAllSelectedGroups = options.data.selectedGroups.every(
				(groupName) => uniqueDataGroups.includes(groupName)
			);
			if (!hasAllSelectedGroups) {
				options.data.selectedGroups = [];
			}
		}

		// Get group status based on items in selected groups
		const getStatus = (groupName) =>
			!options.data.selectedGroups.length ||
			options.data.selectedGroups.includes(groupName)
				? ACTIVE
				: DISABLED;

		return uniqueDataGroups.map((groupName) => ({
			name: groupName,
			status: getStatus(groupName),
		}));
	}

	/*
	 * Fill scales
	 */
	protected setCustomColorScale() {
		if (!this.isUserProvidedColorScaleValid()) {
			return;
		}

		const options = this.getOptions();
		const userProvidedScale = Tools.getProperty(options, 'color', 'scale');

		Object.keys(userProvidedScale).forEach((dataGroup) => {
			if (!this.allDataGroups.includes(dataGroup)) {
				console.warn(`"${dataGroup}" does not exist in data groups.`);
			}
		});

		/**
		 * Go through allDataGroups. If a data group has a color value provided
		 * by the user, add that to the color range
		 */
		const providedDataGroups = this.allDataGroups.filter(
			(dataGroup) => userProvidedScale[dataGroup]
		);

		providedDataGroups.forEach(
			(dataGroup) =>
				(this.colorScale[dataGroup] = userProvidedScale[dataGroup])
		);
	}

	/*
	 * Color palette
	 */
	protected setColorClassNames() {
		const colorPairingOptions = Tools.getProperty(
			this.getOptions(),
			'color',
			'pairing'
		);

		// Check if user has defined numberOfVariants (differ from given data)
		let numberOfVariants = Tools.getProperty(
			colorPairingOptions,
			'numberOfVariants'
		);
		if (!numberOfVariants || numberOfVariants < this.allDataGroups.length) {
			numberOfVariants = this.allDataGroups.length;
		}

		let pairingOption = Tools.getProperty(colorPairingOptions, 'option');
		const colorPairingCounts = Configuration.color.pairingOptions;

		// If number of dataGroups is greater than 5, user 14-color palette
		const numberOfColors = numberOfVariants > 5 ? 14 : numberOfVariants;

		// Use default palette if user choice is not in range
		pairingOption =
			pairingOption <= colorPairingCounts[`${numberOfColors}-color`]
				? pairingOption
				: 1;

		// Create color classes for graph, tooltip and stroke use
		const colorPairing = this.allDataGroups.map(
			(dataGroup, index) =>
				`${numberOfColors}-${pairingOption}-${(index % 14) + 1}`
		);

		// Create default color classnames
		this.colorClassNames = scaleOrdinal()
			.range(colorPairing)
			.domain(this.allDataGroups);
	}
}
