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

"use strict";

const Source = require("./Source");
const { getMap, getSourceAndMap } = require("./helpers/getFromStreamChunks");
const splitIntoLines = require("./helpers/splitIntoLines");
const streamChunks = require("./helpers/streamChunks");

/** @typedef {import("./Source").HashLike} HashLike */
/** @typedef {import("./Source").MapOptions} MapOptions */
/** @typedef {import("./Source").RawSourceMap} RawSourceMap */
/** @typedef {import("./Source").SourceAndMap} SourceAndMap */
/** @typedef {import("./Source").SourceValue} SourceValue */
/** @typedef {import("./helpers/getGeneratedSourceInfo").GeneratedSourceInfo} GeneratedSourceInfo */
/** @typedef {import("./helpers/streamChunks").OnChunk} OnChunk */
/** @typedef {import("./helpers/streamChunks").OnName} OnName */
/** @typedef {import("./helpers/streamChunks").OnSource} OnSource */
/** @typedef {import("./helpers/streamChunks").Options} Options */

// since v8 7.0, Array.prototype.sort is stable
const hasStableSort =
	typeof process === "object" &&
	process.versions &&
	typeof process.versions.v8 === "string" &&
	!/^[0-6]\./.test(process.versions.v8);

// This is larger than max string length
const MAX_SOURCE_POSITION = 0x20000000;

class Replacement {
	/**
	 * @param {number} start start
	 * @param {number} end end
	 * @param {string} content content
	 * @param {string=} name name
	 */
	constructor(start, end, content, name) {
		this.start = start;
		this.end = end;
		this.content = content;
		this.name = name;
		if (!hasStableSort) {
			this.index = -1;
		}
	}
}

class ReplaceSource extends Source {
	/**
	 * @param {Source} source source
	 * @param {string=} name name
	 */
	constructor(source, name) {
		super();
		this._source = source;
		this._name = name;
		/** @type {Replacement[]} */
		this._replacements = [];
		this._isSorted = true;
	}

	getName() {
		return this._name;
	}

	getReplacements() {
		this._sortReplacements();
		return this._replacements;
	}

	/**
	 * @param {number} start start
	 * @param {number} end end
	 * @param {string} newValue new value
	 * @param {string=} name name
	 * @returns {void}
	 */
	replace(start, end, newValue, name) {
		if (typeof newValue !== "string") {
			throw new Error(
				`insertion must be a string, but is a ${typeof newValue}`,
			);
		}
		this._replacements.push(new Replacement(start, end, newValue, name));
		this._isSorted = false;
	}

	/**
	 * @param {number} pos pos
	 * @param {string} newValue new value
	 * @param {string=} name name
	 * @returns {void}
	 */
	insert(pos, newValue, name) {
		if (typeof newValue !== "string") {
			throw new Error(
				`insertion must be a string, but is a ${typeof newValue}: ${newValue}`,
			);
		}
		this._replacements.push(new Replacement(pos, pos - 1, newValue, name));
		this._isSorted = false;
	}

	/**
	 * @returns {SourceValue} source
	 */
	source() {
		if (this._replacements.length === 0) {
			return this._source.source();
		}
		let current = this._source.source();
		let pos = 0;
		const result = [];

		this._sortReplacements();
		for (const replacement of this._replacements) {
			const start = Math.floor(replacement.start);
			const end = Math.floor(replacement.end + 1);
			if (pos < start) {
				const offset = start - pos;
				result.push(current.slice(0, offset));
				current = current.slice(offset);
				pos = start;
			}
			result.push(replacement.content);
			if (pos < end) {
				const offset = end - pos;
				current = current.slice(offset);
				pos = end;
			}
		}
		result.push(current);
		return result.join("");
	}

	/**
	 * @param {MapOptions=} options map options
	 * @returns {RawSourceMap | null} map
	 */
	map(options) {
		if (this._replacements.length === 0) {
			return this._source.map(options);
		}
		return getMap(this, options);
	}

	/**
	 * @param {MapOptions=} options map options
	 * @returns {SourceAndMap} source and map
	 */
	sourceAndMap(options) {
		if (this._replacements.length === 0) {
			return this._source.sourceAndMap(options);
		}
		return getSourceAndMap(this, options);
	}

	original() {
		return this._source;
	}

	_sortReplacements() {
		if (this._isSorted) return;
		if (hasStableSort) {
			this._replacements.sort((a, b) => {
				const diff1 = a.start - b.start;
				if (diff1 !== 0) return diff1;
				const diff2 = a.end - b.end;
				if (diff2 !== 0) return diff2;
				return 0;
			});
		} else {
			for (const [i, repl] of this._replacements.entries()) repl.index = i;
			this._replacements.sort((a, b) => {
				const diff1 = a.start - b.start;
				if (diff1 !== 0) return diff1;
				const diff2 = a.end - b.end;
				if (diff2 !== 0) return diff2;
				return (
					/** @type {number} */ (a.index) - /** @type {number} */ (b.index)
				);
			});
		}
		this._isSorted = true;
	}

	/**
	 * @param {Options} options options
	 * @param {OnChunk} onChunk called for each chunk of code
	 * @param {OnSource} onSource called for each source
	 * @param {OnName} onName called for each name
	 * @returns {GeneratedSourceInfo} generated source info
	 */
	streamChunks(options, onChunk, onSource, onName) {
		this._sortReplacements();
		const replacements = this._replacements;
		let pos = 0;
		let i = 0;
		let replacementEnd = -1;
		let nextReplacement =
			i < replacements.length
				? Math.floor(replacements[i].start)
				: MAX_SOURCE_POSITION;
		let generatedLineOffset = 0;
		let generatedColumnOffset = 0;
		let generatedColumnOffsetLine = 0;
		/** @type {(string | string[] | undefined)[]} */
		const sourceContents = [];
		/** @type {Map<string, number>} */
		const nameMapping = new Map();
		/** @type {number[]} */
		const nameIndexMapping = [];
		/**
		 * @param {number} sourceIndex source index
		 * @param {number} line line
		 * @param {number} column column
		 * @param {string} expectedChunk expected chunk
		 * @returns {boolean} result
		 */
		const checkOriginalContent = (sourceIndex, line, column, expectedChunk) => {
			/** @type {undefined | string | string[]} */
			let content =
				sourceIndex < sourceContents.length
					? sourceContents[sourceIndex]
					: undefined;
			if (content === undefined) return false;
			if (typeof content === "string") {
				content = splitIntoLines(content);
				sourceContents[sourceIndex] = content;
			}
			const contentLine = line <= content.length ? content[line - 1] : null;
			if (contentLine === null) return false;
			return (
				contentLine.slice(column, column + expectedChunk.length) ===
				expectedChunk
			);
		};
		const { generatedLine, generatedColumn } = streamChunks(
			this._source,
			{ ...options, finalSource: false },
			(
				_chunk,
				generatedLine,
				generatedColumn,
				sourceIndex,
				originalLine,
				originalColumn,
				nameIndex,
			) => {
				let chunkPos = 0;
				const chunk = /** @type {string} */ (_chunk);
				const endPos = pos + chunk.length;

				// Skip over when it has been replaced
				if (replacementEnd > pos) {
					// Skip over the whole chunk
					if (replacementEnd >= endPos) {
						const line = generatedLine + generatedLineOffset;
						if (chunk.endsWith("\n")) {
							generatedLineOffset--;
							if (generatedColumnOffsetLine === line) {
								// undo exiting corrections form the current line
								generatedColumnOffset += generatedColumn;
							}
						} else if (generatedColumnOffsetLine === line) {
							generatedColumnOffset -= chunk.length;
						} else {
							generatedColumnOffset = -chunk.length;
							generatedColumnOffsetLine = line;
						}
						pos = endPos;
						return;
					}

					// Partially skip over chunk
					chunkPos = replacementEnd - pos;
					if (
						checkOriginalContent(
							sourceIndex,
							originalLine,
							originalColumn,
							chunk.slice(0, chunkPos),
						)
					) {
						originalColumn += chunkPos;
					}
					pos += chunkPos;
					const line = generatedLine + generatedLineOffset;
					if (generatedColumnOffsetLine === line) {
						generatedColumnOffset -= chunkPos;
					} else {
						generatedColumnOffset = -chunkPos;
						generatedColumnOffsetLine = line;
					}
					generatedColumn += chunkPos;
				}

				// Is a replacement in the chunk?
				if (nextReplacement < endPos) {
					do {
						let line = generatedLine + generatedLineOffset;
						if (nextReplacement > pos) {
							// Emit chunk until replacement
							const offset = nextReplacement - pos;
							const chunkSlice = chunk.slice(chunkPos, chunkPos + offset);
							onChunk(
								chunkSlice,
								line,
								generatedColumn +
									(line === generatedColumnOffsetLine
										? generatedColumnOffset
										: 0),
								sourceIndex,
								originalLine,
								originalColumn,
								nameIndex < 0 || nameIndex >= nameIndexMapping.length
									? -1
									: nameIndexMapping[nameIndex],
							);
							generatedColumn += offset;
							chunkPos += offset;
							pos = nextReplacement;
							if (
								checkOriginalContent(
									sourceIndex,
									originalLine,
									originalColumn,
									chunkSlice,
								)
							) {
								originalColumn += chunkSlice.length;
							}
						}

						// Insert replacement content splitted into chunks by lines
						const { content, name } = replacements[i];
						const matches = splitIntoLines(content);
						let replacementNameIndex = nameIndex;
						if (sourceIndex >= 0 && name) {
							let globalIndex = nameMapping.get(name);
							if (globalIndex === undefined) {
								globalIndex = nameMapping.size;
								nameMapping.set(name, globalIndex);
								onName(globalIndex, name);
							}
							replacementNameIndex = globalIndex;
						}
						for (let m = 0; m < matches.length; m++) {
							const contentLine = matches[m];
							onChunk(
								contentLine,
								line,
								generatedColumn +
									(line === generatedColumnOffsetLine
										? generatedColumnOffset
										: 0),
								sourceIndex,
								originalLine,
								originalColumn,
								replacementNameIndex,
							);

							// Only the first chunk has name assigned
							replacementNameIndex = -1;

							if (m === matches.length - 1 && !contentLine.endsWith("\n")) {
								if (generatedColumnOffsetLine === line) {
									generatedColumnOffset += contentLine.length;
								} else {
									generatedColumnOffset = contentLine.length;
									generatedColumnOffsetLine = line;
								}
							} else {
								generatedLineOffset++;
								line++;
								generatedColumnOffset = -generatedColumn;
								generatedColumnOffsetLine = line;
							}
						}

						// Remove replaced content by settings this variable
						replacementEnd = Math.max(
							replacementEnd,
							Math.floor(replacements[i].end + 1),
						);

						// Move to next replacement
						i++;
						nextReplacement =
							i < replacements.length
								? Math.floor(replacements[i].start)
								: MAX_SOURCE_POSITION;

						// Skip over when it has been replaced
						const offset = chunk.length - endPos + replacementEnd - chunkPos;
						if (offset > 0) {
							// Skip over whole chunk
							if (replacementEnd >= endPos) {
								const line = generatedLine + generatedLineOffset;
								if (chunk.endsWith("\n")) {
									generatedLineOffset--;
									if (generatedColumnOffsetLine === line) {
										// undo exiting corrections form the current line
										generatedColumnOffset += generatedColumn;
									}
								} else if (generatedColumnOffsetLine === line) {
									generatedColumnOffset -= chunk.length - chunkPos;
								} else {
									generatedColumnOffset = chunkPos - chunk.length;
									generatedColumnOffsetLine = line;
								}
								pos = endPos;
								return;
							}

							// Partially skip over chunk
							const line = generatedLine + generatedLineOffset;
							if (
								checkOriginalContent(
									sourceIndex,
									originalLine,
									originalColumn,
									chunk.slice(chunkPos, chunkPos + offset),
								)
							) {
								originalColumn += offset;
							}
							chunkPos += offset;
							pos += offset;
							if (generatedColumnOffsetLine === line) {
								generatedColumnOffset -= offset;
							} else {
								generatedColumnOffset = -offset;
								generatedColumnOffsetLine = line;
							}
							generatedColumn += offset;
						}
					} while (nextReplacement < endPos);
				}

				// Emit remaining chunk
				if (chunkPos < chunk.length) {
					const chunkSlice = chunkPos === 0 ? chunk : chunk.slice(chunkPos);
					const line = generatedLine + generatedLineOffset;
					onChunk(
						chunkSlice,
						line,
						generatedColumn +
							(line === generatedColumnOffsetLine ? generatedColumnOffset : 0),
						sourceIndex,
						originalLine,
						originalColumn,
						nameIndex < 0 ? -1 : nameIndexMapping[nameIndex],
					);
				}
				pos = endPos;
			},
			(sourceIndex, source, sourceContent) => {
				while (sourceContents.length < sourceIndex) {
					sourceContents.push(undefined);
				}
				sourceContents[sourceIndex] = sourceContent;
				onSource(sourceIndex, source, sourceContent);
			},
			(nameIndex, name) => {
				let globalIndex = nameMapping.get(name);
				if (globalIndex === undefined) {
					globalIndex = nameMapping.size;
					nameMapping.set(name, globalIndex);
					onName(globalIndex, name);
				}
				nameIndexMapping[nameIndex] = globalIndex;
			},
		);

		// Handle remaining replacements
		let remainer = "";
		for (; i < replacements.length; i++) {
			remainer += replacements[i].content;
		}

		// Insert remaining replacements content splitted into chunks by lines
		let line = /** @type {number} */ (generatedLine) + generatedLineOffset;
		const matches = splitIntoLines(remainer);
		for (let m = 0; m < matches.length; m++) {
			const contentLine = matches[m];
			onChunk(
				contentLine,
				line,
				/** @type {number} */
				(generatedColumn) +
					(line === generatedColumnOffsetLine ? generatedColumnOffset : 0),
				-1,
				-1,
				-1,
				-1,
			);

			if (m === matches.length - 1 && !contentLine.endsWith("\n")) {
				if (generatedColumnOffsetLine === line) {
					generatedColumnOffset += contentLine.length;
				} else {
					generatedColumnOffset = contentLine.length;
					generatedColumnOffsetLine = line;
				}
			} else {
				generatedLineOffset++;
				line++;
				generatedColumnOffset = -(/** @type {number} */ (generatedColumn));
				generatedColumnOffsetLine = line;
			}
		}

		return {
			generatedLine: line,
			generatedColumn:
				/** @type {number} */
				(generatedColumn) +
				(line === generatedColumnOffsetLine ? generatedColumnOffset : 0),
		};
	}

	/**
	 * @param {HashLike} hash hash
	 * @returns {void}
	 */
	updateHash(hash) {
		this._sortReplacements();
		hash.update("ReplaceSource");
		this._source.updateHash(hash);
		hash.update(this._name || "");
		for (const repl of this._replacements) {
			hash.update(
				`${repl.start}${repl.end}${repl.content}${repl.name ? repl.name : ""}`,
			);
		}
	}
}

module.exports = ReplaceSource;
module.exports.Replacement = Replacement;
