import deindent from '../../utils/deindent';
import { stringify } from '../../utils/stringify';
import fixAttributeCasing from '../../utils/fixAttributeCasing';
import getExpressionPrecedence from '../../utils/getExpressionPrecedence';
import addToSet from '../../utils/addToSet';
import { DomGenerator } from '../dom/index';
import Node from './shared/Node';
import Element from './Element';
import Block from '../dom/Block';
import Expression from './shared/Expression';

export interface StyleProp {
	key: string;
	value: Node[];
}

export default class Attribute extends Node {
	type: 'Attribute';
	start: number;
	end: number;

	compiler: DomGenerator;
	parent: Element;
	name: string;
	isTrue: boolean;
	isDynamic: boolean;
	chunks: Node[];
	dependencies: Set<string>;
	expression: Node;

	constructor(compiler, parent, info) {
		super(compiler, parent, info);

		this.name = info.name;
		this.isTrue = info.value === true;

		this.dependencies = new Set();

		this.chunks = this.isTrue
			? []
			: info.value.map(node => {
				if (node.type === 'Text') return node;

				const expression = new Expression(compiler, this, node.expression);

				addToSet(this.dependencies, expression.dependencies);
				return expression;
			});

		this.isDynamic = this.dependencies.size > 0;
	}

	render(block: Block) {
		const node = this.parent;
		const name = fixAttributeCasing(this.name);

		if (name === 'style') {
			const styleProps = optimizeStyle(this.chunks);
			if (styleProps) {
				this.renderStyle(block, styleProps);
				return;
			}
		}

		let metadata = node.namespace ? null : attributeLookup[name];
		if (metadata && metadata.appliesTo && !~metadata.appliesTo.indexOf(node.name))
			metadata = null;

		const isIndirectlyBoundValue =
			name === 'value' &&
			(node.name === 'option' || // TODO check it's actually bound
				(node.name === 'input' &&
					node.attributes.find(
						(attribute: Attribute) =>
							attribute.type === 'Binding' && /checked|group/.test(attribute.name)
						)));

		const propertyName = isIndirectlyBoundValue
			? '__value'
			: metadata && metadata.propertyName;

		// xlink is a special case... we could maybe extend this to generic
		// namespaced attributes but I'm not sure that's applicable in
		// HTML5?
		const method = name.slice(0, 6) === 'xlink:'
			? '@setXlinkAttribute'
			: '@setAttribute';

		const isLegacyInputType = this.compiler.legacy && name === 'type' && this.parent.name === 'input';

		const isDataSet = /^data-/.test(name) && !this.compiler.legacy && !node.namespace;
		const camelCaseName = isDataSet ? name.replace('data-', '').replace(/(-\w)/g, function (m) {
			return m[1].toUpperCase();
		}) : name;

		if (this.isDynamic) {
			let value;

			const allDependencies = new Set();
			let shouldCache;
			let hasChangeableIndex;

			// TODO some of this code is repeated in Tag.ts — would be good to
			// DRY it out if that's possible without introducing crazy indirection
			if (this.chunks.length === 1) {
				// single {tag} — may be a non-string
				const expression = this.chunks[0];
				const { dependencies, snippet, indexes } = expression;

				value = snippet;
				dependencies.forEach(d => {
					allDependencies.add(d);
				});

				hasChangeableIndex = Array.from(indexes).some(index => block.changeableIndexes.get(index));

				shouldCache = (
					expression.type !== 'Identifier' ||
					block.contexts.has(expression.name) ||
					hasChangeableIndex
				);
			} else {
				// '{{foo}} {{bar}}' — treat as string concatenation
				value =
					(this.chunks[0].type === 'Text' ? '' : `"" + `) +
					this.chunks
						.map((chunk: Node) => {
							if (chunk.type === 'Text') {
								return stringify(chunk.data);
							} else {
								const { dependencies, snippet, indexes } = chunk;

								if (Array.from(indexes).some(index => block.changeableIndexes.get(index))) {
									hasChangeableIndex = true;
								}

								dependencies.forEach(d => {
									allDependencies.add(d);
								});

								return getExpressionPrecedence(chunk) <= 13 ? `(${snippet})` : snippet;
							}
						})
						.join(' + ');

				shouldCache = true;
			}

			const isSelectValueAttribute =
				name === 'value' && node.name === 'select';

			const last = (shouldCache || isSelectValueAttribute) && block.getUniqueName(
				`${node.var}_${name.replace(/[^a-zA-Z_$]/g, '_')}_value`
			);

			if (shouldCache || isSelectValueAttribute) block.addVariable(last);

			let updater;
			const init = shouldCache ? `${last} = ${value}` : value;

			if (isLegacyInputType) {
				block.builders.hydrate.addLine(
					`@setInputType(${node.var}, ${init});`
				);
				updater = `@setInputType(${node.var}, ${shouldCache ? last : value});`;
			} else if (isSelectValueAttribute) {
				// annoying special case
				const isMultipleSelect = node.getStaticAttributeValue('multiple');
				const i = block.getUniqueName('i');
				const option = block.getUniqueName('option');

				const ifStatement = isMultipleSelect
					? deindent`
						${option}.selected = ~${last}.indexOf(${option}.__value);`
					: deindent`
						if (${option}.__value === ${last}) {
							${option}.selected = true;
							break;
						}`;

				updater = deindent`
					for (var ${i} = 0; ${i} < ${node.var}.options.length; ${i} += 1) {
						var ${option} = ${node.var}.options[${i}];

						${ifStatement}
					}
				`;

				block.builders.hydrate.addBlock(deindent`
					${last} = ${value};
					${updater}
				`);

				block.builders.update.addLine(`${last} = ${value};`);
			} else if (propertyName) {
				block.builders.hydrate.addLine(
					`${node.var}.${propertyName} = ${init};`
				);
				updater = `${node.var}.${propertyName} = ${shouldCache ? last : value};`;
			} else if (isDataSet) {
				block.builders.hydrate.addLine(
					`${node.var}.dataset.${camelCaseName} = ${init};`
				);
				updater = `${node.var}.dataset.${camelCaseName} = ${shouldCache ? last : value};`;
			} else {
				block.builders.hydrate.addLine(
					`${method}(${node.var}, "${name}", ${init});`
				);
				updater = `${method}(${node.var}, "${name}", ${shouldCache ? last : value});`;
			}

			if (allDependencies.size || hasChangeableIndex || isSelectValueAttribute) {
				const dependencies = Array.from(allDependencies);
				const changedCheck = (
					( block.hasOutroMethod ? `#outroing || ` : '' ) +
					dependencies.map(dependency => `changed.${dependency}`).join(' || ')
				);

				const updateCachedValue = `${last} !== (${last} = ${value})`;

				const condition = shouldCache ?
					( dependencies.length ? `(${changedCheck}) && ${updateCachedValue}` : updateCachedValue ) :
					changedCheck;

				block.builders.update.addConditional(
					condition,
					updater
				);
			}
		} else {
			const value = this.isTrue
				? 'true'
				: this.chunks.length === 0 ? `""` : stringify(this.chunks[0].data);

			const statement = (
				isLegacyInputType
					? `@setInputType(${node.var}, ${value});`
					: propertyName
						? `${node.var}.${propertyName} = ${value};`
						: isDataSet
							? `${node.var}.dataset.${camelCaseName} = ${value};`
							: `${method}(${node.var}, "${name}", ${value});`
			);

			block.builders.hydrate.addLine(statement);

			// special case – autofocus. has to be handled in a bit of a weird way
			if (this.value === true && name === 'autofocus') {
				block.autofocus = node.var;
			}
		}

		if (isIndirectlyBoundValue) {
			const updateValue = `${node.var}.value = ${node.var}.__value;`;

			block.builders.hydrate.addLine(updateValue);
			if (this.isDynamic) block.builders.update.addLine(updateValue);
		}
	}

	renderStyle(
		block: Block,
		styleProps: StyleProp[]
	) {
		styleProps.forEach((prop: StyleProp) => {
			let value;

			if (isDynamic(prop.value)) {
				const allDependencies = new Set();
				let shouldCache;
				let hasChangeableIndex;

				value =
					((prop.value.length === 1 || prop.value[0].type === 'Text') ? '' : `"" + `) +
					prop.value
						.map((chunk: Node) => {
							if (chunk.type === 'Text') {
								return stringify(chunk.data);
							} else {
								const { dependencies, snippet, indexes } = chunk;

								if (Array.from(indexes).some(index => block.changeableIndexes.get(index))) {
									hasChangeableIndex = true;
								}

								dependencies.forEach(d => {
									allDependencies.add(d);
								});

								return getExpressionPrecedence(chunk.expression) <= 13 ? `( ${snippet} )` : snippet;
							}
						})
						.join(' + ');

				if (allDependencies.size || hasChangeableIndex) {
					const dependencies = Array.from(allDependencies);
					const condition = (
						( block.hasOutroMethod ? `#outroing || ` : '' ) +
						dependencies.map(dependency => `changed.${dependency}`).join(' || ')
					);

					block.builders.update.addConditional(
						condition,
						`@setStyle(${this.parent.var}, "${prop.key}", ${value});`
					);
				}
			} else {
				value = stringify(prop.value[0].data);
			}

			block.builders.hydrate.addLine(
				`@setStyle(${this.parent.var}, "${prop.key}", ${value});`
			);
		});
	}
}

// source: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes
const attributeLookup = {
	accept: { appliesTo: ['form', 'input'] },
	'accept-charset': { propertyName: 'acceptCharset', appliesTo: ['form'] },
	accesskey: { propertyName: 'accessKey' },
	action: { appliesTo: ['form'] },
	align: {
		appliesTo: [
			'applet',
			'caption',
			'col',
			'colgroup',
			'hr',
			'iframe',
			'img',
			'table',
			'tbody',
			'td',
			'tfoot',
			'th',
			'thead',
			'tr',
		],
	},
	allowfullscreen: { propertyName: 'allowFullscreen', appliesTo: ['iframe'] },
	alt: { appliesTo: ['applet', 'area', 'img', 'input'] },
	async: { appliesTo: ['script'] },
	autocomplete: { appliesTo: ['form', 'input'] },
	autofocus: { appliesTo: ['button', 'input', 'keygen', 'select', 'textarea'] },
	autoplay: { appliesTo: ['audio', 'video'] },
	autosave: { appliesTo: ['input'] },
	bgcolor: {
		propertyName: 'bgColor',
		appliesTo: [
			'body',
			'col',
			'colgroup',
			'marquee',
			'table',
			'tbody',
			'tfoot',
			'td',
			'th',
			'tr',
		],
	},
	border: { appliesTo: ['img', 'object', 'table'] },
	buffered: { appliesTo: ['audio', 'video'] },
	challenge: { appliesTo: ['keygen'] },
	charset: { appliesTo: ['meta', 'script'] },
	checked: { appliesTo: ['command', 'input'] },
	cite: { appliesTo: ['blockquote', 'del', 'ins', 'q'] },
	class: { propertyName: 'className' },
	code: { appliesTo: ['applet'] },
	codebase: { propertyName: 'codeBase', appliesTo: ['applet'] },
	color: { appliesTo: ['basefont', 'font', 'hr'] },
	cols: { appliesTo: ['textarea'] },
	colspan: { propertyName: 'colSpan', appliesTo: ['td', 'th'] },
	content: { appliesTo: ['meta'] },
	contenteditable: { propertyName: 'contentEditable' },
	contextmenu: {},
	controls: { appliesTo: ['audio', 'video'] },
	coords: { appliesTo: ['area'] },
	data: { appliesTo: ['object'] },
	datetime: { propertyName: 'dateTime', appliesTo: ['del', 'ins', 'time'] },
	default: { appliesTo: ['track'] },
	defer: { appliesTo: ['script'] },
	dir: {},
	dirname: { propertyName: 'dirName', appliesTo: ['input', 'textarea'] },
	disabled: {
		appliesTo: [
			'button',
			'command',
			'fieldset',
			'input',
			'keygen',
			'optgroup',
			'option',
			'select',
			'textarea',
		],
	},
	download: { appliesTo: ['a', 'area'] },
	draggable: {},
	dropzone: {},
	enctype: { appliesTo: ['form'] },
	for: { propertyName: 'htmlFor', appliesTo: ['label', 'output'] },
	form: {
		appliesTo: [
			'button',
			'fieldset',
			'input',
			'keygen',
			'label',
			'meter',
			'object',
			'output',
			'progress',
			'select',
			'textarea',
		],
	},
	formaction: { appliesTo: ['input', 'button'] },
	headers: { appliesTo: ['td', 'th'] },
	height: {
		appliesTo: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'],
	},
	hidden: {},
	high: { appliesTo: ['meter'] },
	href: { appliesTo: ['a', 'area', 'base', 'link'] },
	hreflang: { appliesTo: ['a', 'area', 'link'] },
	'http-equiv': { propertyName: 'httpEquiv', appliesTo: ['meta'] },
	icon: { appliesTo: ['command'] },
	id: {},
	indeterminate: { appliesTo: ['input'] },
	ismap: { propertyName: 'isMap', appliesTo: ['img'] },
	itemprop: {},
	keytype: { appliesTo: ['keygen'] },
	kind: { appliesTo: ['track'] },
	label: { appliesTo: ['track'] },
	lang: {},
	language: { appliesTo: ['script'] },
	loop: { appliesTo: ['audio', 'bgsound', 'marquee', 'video'] },
	low: { appliesTo: ['meter'] },
	manifest: { appliesTo: ['html'] },
	max: { appliesTo: ['input', 'meter', 'progress'] },
	maxlength: { propertyName: 'maxLength', appliesTo: ['input', 'textarea'] },
	media: { appliesTo: ['a', 'area', 'link', 'source', 'style'] },
	method: { appliesTo: ['form'] },
	min: { appliesTo: ['input', 'meter'] },
	multiple: { appliesTo: ['input', 'select'] },
	muted: { appliesTo: ['audio', 'video'] },
	name: {
		appliesTo: [
			'button',
			'form',
			'fieldset',
			'iframe',
			'input',
			'keygen',
			'object',
			'output',
			'select',
			'textarea',
			'map',
			'meta',
			'param',
		],
	},
	novalidate: { propertyName: 'noValidate', appliesTo: ['form'] },
	open: { appliesTo: ['details'] },
	optimum: { appliesTo: ['meter'] },
	pattern: { appliesTo: ['input'] },
	ping: { appliesTo: ['a', 'area'] },
	placeholder: { appliesTo: ['input', 'textarea'] },
	poster: { appliesTo: ['video'] },
	preload: { appliesTo: ['audio', 'video'] },
	radiogroup: { appliesTo: ['command'] },
	readonly: { propertyName: 'readOnly', appliesTo: ['input', 'textarea'] },
	rel: { appliesTo: ['a', 'area', 'link'] },
	required: { appliesTo: ['input', 'select', 'textarea'] },
	reversed: { appliesTo: ['ol'] },
	rows: { appliesTo: ['textarea'] },
	rowspan: { propertyName: 'rowSpan', appliesTo: ['td', 'th'] },
	sandbox: { appliesTo: ['iframe'] },
	scope: { appliesTo: ['th'] },
	scoped: { appliesTo: ['style'] },
	seamless: { appliesTo: ['iframe'] },
	selected: { appliesTo: ['option'] },
	shape: { appliesTo: ['a', 'area'] },
	size: { appliesTo: ['input', 'select'] },
	sizes: { appliesTo: ['link', 'img', 'source'] },
	span: { appliesTo: ['col', 'colgroup'] },
	spellcheck: {},
	src: {
		appliesTo: [
			'audio',
			'embed',
			'iframe',
			'img',
			'input',
			'script',
			'source',
			'track',
			'video',
		],
	},
	srcdoc: { appliesTo: ['iframe'] },
	srclang: { appliesTo: ['track'] },
	srcset: { appliesTo: ['img'] },
	start: { appliesTo: ['ol'] },
	step: { appliesTo: ['input'] },
	style: { propertyName: 'style.cssText' },
	summary: { appliesTo: ['table'] },
	tabindex: { propertyName: 'tabIndex' },
	target: { appliesTo: ['a', 'area', 'base', 'form'] },
	title: {},
	type: {
		appliesTo: [
			'button',
			'command',
			'embed',
			'object',
			'script',
			'source',
			'style',
			'menu',
		],
	},
	usemap: { propertyName: 'useMap', appliesTo: ['img', 'input', 'object'] },
	value: {
		appliesTo: [
			'button',
			'option',
			'input',
			'li',
			'meter',
			'progress',
			'param',
			'select',
			'textarea',
		],
	},
	volume: { appliesTo: ['audio', 'video'] },
	width: {
		appliesTo: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'],
	},
	wrap: { appliesTo: ['textarea'] },
};

Object.keys(attributeLookup).forEach(name => {
	const metadata = attributeLookup[name];
	if (!metadata.propertyName) metadata.propertyName = name;
});

function optimizeStyle(value: Node[]) {
	let expectingKey = true;
	let i = 0;

	const props: { key: string, value: Node[] }[] = [];
	let chunks = value.slice();

	while (chunks.length) {
		const chunk = chunks[0];

		if (chunk.type !== 'Text') return null;

		const keyMatch = /^\s*([\w-]+):\s*/.exec(chunk.data);
		if (!keyMatch) return null;

		const key = keyMatch[1];

		const offset = keyMatch.index + keyMatch[0].length;
		const remainingData = chunk.data.slice(offset);

		if (remainingData) {
			chunks[0] = {
				start: chunk.start + offset,
				end: chunk.end,
				type: 'Text',
				data: remainingData
			};
		} else {
			chunks.shift();
		}

		const result = getStyleValue(chunks);
		if (!result) return null;

		props.push({ key, value: result.value });
		chunks = result.chunks;
	}

	return props;
}

function getStyleValue(chunks: Node[]) {
	const value: Node[] = [];

	let inUrl = false;
	let quoteMark = null;
	let escaped = false;

	while (chunks.length) {
		const chunk = chunks.shift();

		if (chunk.type === 'Text') {
			let c = 0;
			while (c < chunk.data.length) {
				const char = chunk.data[c];

				if (escaped) {
					escaped = false;
				} else if (char === '\\') {
					escaped = true;
				} else if (char === quoteMark) {
					quoteMark === null;
				} else if (char === '"' || char === "'") {
					quoteMark = char;
				} else if (char === ')' && inUrl) {
					inUrl = false;
				} else if (char === 'u' && chunk.data.slice(c, c + 4) === 'url(') {
					inUrl = true;
				} else if (char === ';' && !inUrl && !quoteMark) {
					break;
				}

				c += 1;
			}

			if (c > 0) {
				value.push({
					type: 'Text',
					start: chunk.start,
					end: chunk.start + c,
					data: chunk.data.slice(0, c)
				});
			}

			while (/[;\s]/.test(chunk.data[c])) c += 1;
			const remainingData = chunk.data.slice(c);

			if (remainingData) {
				chunks.unshift({
					start: chunk.start + c,
					end: chunk.end,
					type: 'Text',
					data: remainingData
				});

				break;
			}
		}

		else {
			value.push(chunk);
		}
	}

	return {
		chunks,
		value
	};
}

function isDynamic(value: Node[]) {
	return value.length > 1 || value[0].type !== 'Text';
}