/*
	MIT License http://www.opensource.org/licenses/mit-license.php
	Author Tobias Koppers @sokra
*/

"use strict";

const Parser = require("../Parser");
const ConstDependency = require("../dependencies/ConstDependency");
const CssExportDependency = require("../dependencies/CssExportDependency");
const CssImportDependency = require("../dependencies/CssImportDependency");
const CssLocalIdentifierDependency = require("../dependencies/CssLocalIdentifierDependency");
const CssSelfLocalIdentifierDependency = require("../dependencies/CssSelfLocalIdentifierDependency");
const CssUrlDependency = require("../dependencies/CssUrlDependency");
const StaticExportsDependency = require("../dependencies/StaticExportsDependency");
const walkCssTokens = require("./walkCssTokens");

/** @typedef {import("../Parser").ParserState} ParserState */
/** @typedef {import("../Parser").PreparsedAst} PreparsedAst */

const CC_LEFT_CURLY = "{".charCodeAt(0);
const CC_RIGHT_CURLY = "}".charCodeAt(0);
const CC_COLON = ":".charCodeAt(0);
const CC_SLASH = "/".charCodeAt(0);
const CC_SEMICOLON = ";".charCodeAt(0);

const cssUnescape = str => {
	return str.replace(/\\([0-9a-fA-F]{1,6}[ \t\n\r\f]?|[\s\S])/g, match => {
		if (match.length > 2) {
			return String.fromCharCode(parseInt(match.slice(1).trim(), 16));
		} else {
			return match[1];
		}
	});
};

class LocConverter {
	constructor(input) {
		this._input = input;
		this.line = 1;
		this.column = 0;
		this.pos = 0;
	}

	get(pos) {
		if (this.pos !== pos) {
			if (this.pos < pos) {
				const str = this._input.slice(this.pos, pos);
				let i = str.lastIndexOf("\n");
				if (i === -1) {
					this.column += str.length;
				} else {
					this.column = str.length - i - 1;
					this.line++;
					while (i > 0 && (i = str.lastIndexOf("\n", i - 1)) !== -1)
						this.line++;
				}
			} else {
				let i = this._input.lastIndexOf("\n", this.pos);
				while (i >= pos) {
					this.line--;
					i = i > 0 ? this._input.lastIndexOf("\n", i - 1) : -1;
				}
				this.column = pos - i;
			}
			this.pos = pos;
		}
		return this;
	}
}

const CSS_MODE_TOP_LEVEL = 0;
const CSS_MODE_IN_RULE = 1;
const CSS_MODE_IN_LOCAL_RULE = 2;
const CSS_MODE_AT_IMPORT_EXPECT_URL = 3;
// TODO implement layer and supports for @import
const CSS_MODE_AT_IMPORT_EXPECT_SUPPORTS = 4;
const CSS_MODE_AT_IMPORT_EXPECT_MEDIA = 5;
const CSS_MODE_AT_OTHER = 6;

const explainMode = mode => {
	switch (mode) {
		case CSS_MODE_TOP_LEVEL:
			return "parsing top level css";
		case CSS_MODE_IN_RULE:
			return "parsing css rule content (global)";
		case CSS_MODE_IN_LOCAL_RULE:
			return "parsing css rule content (local)";
		case CSS_MODE_AT_IMPORT_EXPECT_URL:
			return "parsing @import (expecting url)";
		case CSS_MODE_AT_IMPORT_EXPECT_SUPPORTS:
			return "parsing @import (expecting optionally supports or media query)";
		case CSS_MODE_AT_IMPORT_EXPECT_MEDIA:
			return "parsing @import (expecting optionally media query)";
		case CSS_MODE_AT_OTHER:
			return "parsing at-rule";
		default:
			return mode;
	}
};

class CssParser extends Parser {
	constructor({
		allowPseudoBlocks = true,
		allowModeSwitch = true,
		defaultMode = "global"
	} = {}) {
		super();
		this.allowPseudoBlocks = allowPseudoBlocks;
		this.allowModeSwitch = allowModeSwitch;
		this.defaultMode = defaultMode;
	}

	/**
	 * @param {string | Buffer | PreparsedAst} source the source to parse
	 * @param {ParserState} state the parser state
	 * @returns {ParserState} the parser state
	 */
	parse(source, state) {
		if (Buffer.isBuffer(source)) {
			source = source.toString("utf-8");
		} else if (typeof source === "object") {
			throw new Error("webpackAst is unexpected for the CssParser");
		}
		if (source[0] === "\ufeff") {
			source = source.slice(1);
		}

		const module = state.module;

		const declaredCssVariables = new Set();

		const locConverter = new LocConverter(source);
		let mode = CSS_MODE_TOP_LEVEL;
		let modePos = 0;
		let modeNestingLevel = 0;
		let modeData = undefined;
		let singleClassSelector = undefined;
		let lastIdentifier = undefined;
		const modeStack = [];
		const isTopLevelLocal = () =>
			modeData === "local" ||
			(this.defaultMode === "local" && modeData === undefined);
		const eatWhiteLine = (input, pos) => {
			for (;;) {
				const cc = input.charCodeAt(pos);
				if (cc === 32 || cc === 9) {
					pos++;
					continue;
				}
				if (cc === 10) pos++;
				break;
			}
			return pos;
		};
		const eatUntil = chars => {
			const charCodes = Array.from({ length: chars.length }, (_, i) =>
				chars.charCodeAt(i)
			);
			const arr = Array.from(
				{ length: charCodes.reduce((a, b) => Math.max(a, b), 0) + 1 },
				() => false
			);
			charCodes.forEach(cc => (arr[cc] = true));
			return (input, pos) => {
				for (;;) {
					const cc = input.charCodeAt(pos);
					if (cc < arr.length && arr[cc]) {
						return pos;
					}
					pos++;
					if (pos === input.length) return pos;
				}
			};
		};
		const eatText = (input, pos, eater) => {
			let text = "";
			for (;;) {
				if (input.charCodeAt(pos) === CC_SLASH) {
					const newPos = walkCssTokens.eatComments(input, pos);
					if (pos !== newPos) {
						pos = newPos;
						if (pos === input.length) break;
					} else {
						text += "/";
						pos++;
						if (pos === input.length) break;
					}
				}
				const newPos = eater(input, pos);
				if (pos !== newPos) {
					text += input.slice(pos, newPos);
					pos = newPos;
				} else {
					break;
				}
				if (pos === input.length) break;
			}
			return [pos, text.trimEnd()];
		};
		const eatExportName = eatUntil(":};/");
		const eatExportValue = eatUntil("};/");
		const parseExports = (input, pos) => {
			pos = walkCssTokens.eatWhitespaceAndComments(input, pos);
			const cc = input.charCodeAt(pos);
			if (cc !== CC_LEFT_CURLY)
				throw new Error(
					`Unexpected ${input[pos]} at ${pos} during parsing of ':export' (expected '{')`
				);
			pos++;
			pos = walkCssTokens.eatWhitespaceAndComments(input, pos);
			for (;;) {
				if (input.charCodeAt(pos) === CC_RIGHT_CURLY) break;
				pos = walkCssTokens.eatWhitespaceAndComments(input, pos);
				if (pos === input.length) return pos;
				let start = pos;
				let name;
				[pos, name] = eatText(input, pos, eatExportName);
				if (pos === input.length) return pos;
				if (input.charCodeAt(pos) !== CC_COLON) {
					throw new Error(
						`Unexpected ${input[pos]} at ${pos} during parsing of export name in ':export' (expected ':')`
					);
				}
				pos++;
				if (pos === input.length) return pos;
				pos = walkCssTokens.eatWhitespaceAndComments(input, pos);
				if (pos === input.length) return pos;
				let value;
				[pos, value] = eatText(input, pos, eatExportValue);
				if (pos === input.length) return pos;
				const cc = input.charCodeAt(pos);
				if (cc === CC_SEMICOLON) {
					pos++;
					if (pos === input.length) return pos;
					pos = walkCssTokens.eatWhitespaceAndComments(input, pos);
					if (pos === input.length) return pos;
				} else if (cc !== CC_RIGHT_CURLY) {
					throw new Error(
						`Unexpected ${input[pos]} at ${pos} during parsing of export value in ':export' (expected ';' or '}')`
					);
				}
				const dep = new CssExportDependency(name, value);
				const { line: sl, column: sc } = locConverter.get(start);
				const { line: el, column: ec } = locConverter.get(pos);
				dep.setLoc(sl, sc, el, ec);
				module.addDependency(dep);
			}
			pos++;
			if (pos === input.length) return pos;
			pos = eatWhiteLine(input, pos);
			return pos;
		};
		const eatPropertyName = eatUntil(":{};");
		const processLocalDeclaration = (input, pos) => {
			modeData = undefined;
			const start = pos;
			pos = walkCssTokens.eatWhitespaceAndComments(input, pos);
			const propertyNameStart = pos;
			const [propertyNameEnd, propertyName] = eatText(
				input,
				pos,
				eatPropertyName
			);
			if (input.charCodeAt(propertyNameEnd) !== CC_COLON) return start;
			pos = propertyNameEnd + 1;
			if (propertyName.startsWith("--")) {
				// CSS Variable
				const { line: sl, column: sc } = locConverter.get(propertyNameStart);
				const { line: el, column: ec } = locConverter.get(propertyNameEnd);
				const name = propertyName.slice(2);
				const dep = new CssLocalIdentifierDependency(
					name,
					[propertyNameStart, propertyNameEnd],
					"--"
				);
				dep.setLoc(sl, sc, el, ec);
				module.addDependency(dep);
				declaredCssVariables.add(name);
			} else if (
				propertyName === "animation-name" ||
				propertyName === "animation"
			) {
				modeData = "animation";
				lastIdentifier = undefined;
			}
			return pos;
		};
		const processDeclarationValueDone = (input, pos) => {
			if (modeData === "animation" && lastIdentifier) {
				const { line: sl, column: sc } = locConverter.get(lastIdentifier[0]);
				const { line: el, column: ec } = locConverter.get(lastIdentifier[1]);
				const name = input.slice(lastIdentifier[0], lastIdentifier[1]);
				const dep = new CssSelfLocalIdentifierDependency(name, lastIdentifier);
				dep.setLoc(sl, sc, el, ec);
				module.addDependency(dep);
			}
		};
		const eatKeyframes = eatUntil("{};/");
		const eatNameInVar = eatUntil(",)};/");
		walkCssTokens(source, {
			isSelector: () => {
				return mode !== CSS_MODE_IN_RULE && mode !== CSS_MODE_IN_LOCAL_RULE;
			},
			url: (input, start, end, contentStart, contentEnd) => {
				const value = cssUnescape(input.slice(contentStart, contentEnd));
				switch (mode) {
					case CSS_MODE_AT_IMPORT_EXPECT_URL: {
						modeData.url = value;
						mode = CSS_MODE_AT_IMPORT_EXPECT_SUPPORTS;
						break;
					}
					case CSS_MODE_AT_IMPORT_EXPECT_SUPPORTS:
					case CSS_MODE_AT_IMPORT_EXPECT_MEDIA:
						throw new Error(
							`Unexpected ${input.slice(
								start,
								end
							)} at ${start} during ${explainMode(mode)}`
						);
					default: {
						const dep = new CssUrlDependency(value, [start, end], "url");
						const { line: sl, column: sc } = locConverter.get(start);
						const { line: el, column: ec } = locConverter.get(end);
						dep.setLoc(sl, sc, el, ec);
						module.addDependency(dep);
						module.addCodeGenerationDependency(dep);
						break;
					}
				}
				return end;
			},
			string: (input, start, end) => {
				switch (mode) {
					case CSS_MODE_AT_IMPORT_EXPECT_URL: {
						modeData.url = cssUnescape(input.slice(start + 1, end - 1));
						mode = CSS_MODE_AT_IMPORT_EXPECT_SUPPORTS;
						break;
					}
				}
				return end;
			},
			atKeyword: (input, start, end) => {
				const name = input.slice(start, end);
				if (name === "@namespace") {
					throw new Error("@namespace is not supported in bundled CSS");
				}
				if (name === "@import") {
					if (mode !== CSS_MODE_TOP_LEVEL) {
						throw new Error(
							`Unexpected @import at ${start} during ${explainMode(mode)}`
						);
					}
					mode = CSS_MODE_AT_IMPORT_EXPECT_URL;
					modePos = end;
					modeData = {
						start: start,
						url: undefined,
						supports: undefined
					};
				}
				if (name === "@keyframes") {
					let pos = end;
					pos = walkCssTokens.eatWhitespaceAndComments(input, pos);
					if (pos === input.length) return pos;
					const [newPos, name] = eatText(input, pos, eatKeyframes);
					const { line: sl, column: sc } = locConverter.get(pos);
					const { line: el, column: ec } = locConverter.get(newPos);
					const dep = new CssLocalIdentifierDependency(name, [pos, newPos]);
					dep.setLoc(sl, sc, el, ec);
					module.addDependency(dep);
					pos = newPos;
					if (pos === input.length) return pos;
					if (input.charCodeAt(pos) !== CC_LEFT_CURLY) {
						throw new Error(
							`Unexpected ${input[pos]} at ${pos} during parsing of @keyframes (expected '{')`
						);
					}
					mode = CSS_MODE_IN_LOCAL_RULE;
					modeNestingLevel = 1;
					return pos + 1;
				}
				return end;
			},
			semicolon: (input, start, end) => {
				switch (mode) {
					case CSS_MODE_AT_IMPORT_EXPECT_URL:
						throw new Error(`Expected URL for @import at ${start}`);
					case CSS_MODE_AT_IMPORT_EXPECT_MEDIA:
					case CSS_MODE_AT_IMPORT_EXPECT_SUPPORTS: {
						const { line: sl, column: sc } = locConverter.get(modeData.start);
						const { line: el, column: ec } = locConverter.get(end);
						end = eatWhiteLine(input, end);
						const media = input.slice(modePos, start).trim();
						const dep = new CssImportDependency(
							modeData.url,
							[modeData.start, end],
							modeData.supports,
							media
						);
						dep.setLoc(sl, sc, el, ec);
						module.addDependency(dep);
						break;
					}
					case CSS_MODE_IN_LOCAL_RULE: {
						processDeclarationValueDone(input, start);
						return processLocalDeclaration(input, end);
					}
					case CSS_MODE_IN_RULE: {
						return end;
					}
				}
				mode = CSS_MODE_TOP_LEVEL;
				modeData = undefined;
				singleClassSelector = undefined;
				return end;
			},
			leftCurlyBracket: (input, start, end) => {
				switch (mode) {
					case CSS_MODE_TOP_LEVEL:
						mode = isTopLevelLocal()
							? CSS_MODE_IN_LOCAL_RULE
							: CSS_MODE_IN_RULE;
						modeNestingLevel = 1;
						if (mode === CSS_MODE_IN_LOCAL_RULE)
							return processLocalDeclaration(input, end);
						break;
					case CSS_MODE_IN_RULE:
					case CSS_MODE_IN_LOCAL_RULE:
						modeNestingLevel++;
						break;
				}
				return end;
			},
			rightCurlyBracket: (input, start, end) => {
				switch (mode) {
					case CSS_MODE_IN_LOCAL_RULE:
						processDeclarationValueDone(input, start);
					/* falls through */
					case CSS_MODE_IN_RULE:
						if (--modeNestingLevel === 0) {
							mode = CSS_MODE_TOP_LEVEL;
							modeData = undefined;
							singleClassSelector = undefined;
						}
						break;
				}
				return end;
			},
			id: (input, start, end) => {
				singleClassSelector = false;
				switch (mode) {
					case CSS_MODE_TOP_LEVEL:
						if (isTopLevelLocal()) {
							const name = input.slice(start + 1, end);
							const dep = new CssLocalIdentifierDependency(name, [
								start + 1,
								end
							]);
							const { line: sl, column: sc } = locConverter.get(start);
							const { line: el, column: ec } = locConverter.get(end);
							dep.setLoc(sl, sc, el, ec);
							module.addDependency(dep);
						}
						break;
				}
				return end;
			},
			identifier: (input, start, end) => {
				singleClassSelector = false;
				switch (mode) {
					case CSS_MODE_IN_LOCAL_RULE:
						if (modeData === "animation") {
							lastIdentifier = [start, end];
						}
						break;
				}
				return end;
			},
			class: (input, start, end) => {
				switch (mode) {
					case CSS_MODE_TOP_LEVEL: {
						if (isTopLevelLocal()) {
							const name = input.slice(start + 1, end);
							const dep = new CssLocalIdentifierDependency(name, [
								start + 1,
								end
							]);
							const { line: sl, column: sc } = locConverter.get(start);
							const { line: el, column: ec } = locConverter.get(end);
							dep.setLoc(sl, sc, el, ec);
							module.addDependency(dep);
							if (singleClassSelector === undefined) singleClassSelector = name;
						} else {
							singleClassSelector = false;
						}
						break;
					}
				}
				return end;
			},
			leftParenthesis: (input, start, end) => {
				switch (mode) {
					case CSS_MODE_TOP_LEVEL: {
						modeStack.push(false);
						break;
					}
				}
				return end;
			},
			rightParenthesis: (input, start, end) => {
				switch (mode) {
					case CSS_MODE_TOP_LEVEL: {
						const newModeData = modeStack.pop();
						if (newModeData !== false) {
							modeData = newModeData;
							const dep = new ConstDependency("", [start, end]);
							module.addPresentationalDependency(dep);
						}
						break;
					}
				}
				return end;
			},
			pseudoClass: (input, start, end) => {
				singleClassSelector = false;
				switch (mode) {
					case CSS_MODE_TOP_LEVEL: {
						const name = input.slice(start, end);
						if (this.allowModeSwitch && name === ":global") {
							modeData = "global";
							const dep = new ConstDependency("", [start, end]);
							module.addPresentationalDependency(dep);
						} else if (this.allowModeSwitch && name === ":local") {
							modeData = "local";
							const dep = new ConstDependency("", [start, end]);
							module.addPresentationalDependency(dep);
						} else if (this.allowPseudoBlocks && name === ":export") {
							const pos = parseExports(input, end);
							const dep = new ConstDependency("", [start, pos]);
							module.addPresentationalDependency(dep);
							return pos;
						}
						break;
					}
				}
				return end;
			},
			pseudoFunction: (input, start, end) => {
				switch (mode) {
					case CSS_MODE_TOP_LEVEL: {
						const name = input.slice(start, end - 1);
						if (this.allowModeSwitch && name === ":global") {
							modeStack.push(modeData);
							modeData = "global";
							const dep = new ConstDependency("", [start, end]);
							module.addPresentationalDependency(dep);
						} else if (this.allowModeSwitch && name === ":local") {
							modeStack.push(modeData);
							modeData = "local";
							const dep = new ConstDependency("", [start, end]);
							module.addPresentationalDependency(dep);
						} else {
							modeStack.push(false);
						}
						break;
					}
				}
				return end;
			},
			function: (input, start, end) => {
				switch (mode) {
					case CSS_MODE_IN_LOCAL_RULE: {
						const name = input.slice(start, end - 1);
						if (name === "var") {
							let pos = walkCssTokens.eatWhitespaceAndComments(input, end);
							if (pos === input.length) return pos;
							const [newPos, name] = eatText(input, pos, eatNameInVar);
							if (!name.startsWith("--")) return end;
							const { line: sl, column: sc } = locConverter.get(pos);
							const { line: el, column: ec } = locConverter.get(newPos);
							const dep = new CssSelfLocalIdentifierDependency(
								name.slice(2),
								[pos, newPos],
								"--",
								declaredCssVariables
							);
							dep.setLoc(sl, sc, el, ec);
							module.addDependency(dep);
							return newPos;
						}
						break;
					}
				}
				return end;
			},
			comma: (input, start, end) => {
				switch (mode) {
					case CSS_MODE_TOP_LEVEL:
						modeData = undefined;
						modeStack.length = 0;
						break;
					case CSS_MODE_IN_LOCAL_RULE:
						processDeclarationValueDone(input, start);
						break;
				}
				return end;
			}
		});

		module.buildInfo.strict = true;
		module.buildMeta.exportsType = "namespace";
		module.addDependency(new StaticExportsDependency([], true));
		return state;
	}
}

module.exports = CssParser;
