import path from 'node:path';
import fs, { promises, readdir } from 'node:fs';
import { createRequire } from 'module';

const POSIX_SEP_RE = new RegExp('\\' + path.posix.sep, 'g');
const NATIVE_SEP_RE = new RegExp('\\' + path.sep, 'g');
/** @type {Map<string,RegExp>}*/
const PATTERN_REGEX_CACHE = new Map();
const GLOB_ALL_PATTERN = `**/*`;
const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts'];
const JS_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'];
const TSJS_EXTENSIONS = TS_EXTENSIONS.concat(JS_EXTENSIONS);
const TS_EXTENSIONS_RE_GROUP = `\\.(?:${TS_EXTENSIONS.map((ext) => ext.substring(1)).join('|')})`;
const TSJS_EXTENSIONS_RE_GROUP = `\\.(?:${TSJS_EXTENSIONS.map((ext) => ext.substring(1)).join(
	'|'
)})`;
const IS_POSIX = path.posix.sep === path.sep;

/**
 * @template T
 * @returns {{resolve:(result:T)=>void, reject:(error:any)=>void, promise: Promise<T>}}
 */
function makePromise() {
	let resolve, reject;
	const promise = new Promise((res, rej) => {
		resolve = res;
		reject = rej;
	});
	return { promise, resolve, reject };
}

/**
 * loads typescript async to avoid direct dependency
 * @returns {Promise<any>}
 */
async function loadTS() {
	try {
		return import('./typescript.js').then(function (n) { return n.t; }).then((m) => m.default);
	} catch (e) {
		console.error('typescript must be installed to use "native" functions');
		throw e;
	}
}

/**
 * @param {string} filename
 * @param {import('./cache.js').TSConfckCache} [cache]
 * @returns {Promise<string|void>}
 */
async function resolveTSConfigJson(filename, cache) {
	if (path.extname(filename) !== '.json') {
		return; // ignore files that are not json
	}
	const tsconfig = path.resolve(filename);
	if (cache && (cache.hasParseResult(tsconfig) || cache.hasParseResult(filename))) {
		return tsconfig;
	}
	return promises.stat(tsconfig).then((stat) => {
		if (stat.isFile() || stat.isFIFO()) {
			return tsconfig;
		} else {
			throw new Error(`${filename} exists but is not a regular file.`);
		}
	});
}

/**
 *
 * @param {string} dir an absolute directory path
 * @returns {boolean}  if dir path includes a node_modules segment
 */
const isInNodeModules = IS_POSIX
	? (dir) => dir.includes('/node_modules/')
	: (dir) => dir.match(/[/\\]node_modules[/\\]/);

/**
 * convert posix separator to native separator
 *
 * eg.
 * windows: C:/foo/bar -> c:\foo\bar
 * linux: /foo/bar -> /foo/bar
 *
 * @param {string} filename with posix separators
 * @returns {string} filename with native separators
 */
const posix2native = IS_POSIX
	? (filename) => filename
	: (filename) => filename.replace(POSIX_SEP_RE, path.sep);

/**
 * convert native separator to posix separator
 *
 * eg.
 * windows: C:\foo\bar -> c:/foo/bar
 * linux: /foo/bar -> /foo/bar
 *
 * @param {string} filename - filename with native separators
 * @returns {string} filename with posix separators
 */
const native2posix = IS_POSIX
	? (filename) => filename
	: (filename) => filename.replace(NATIVE_SEP_RE, path.posix.sep);

/**
 * converts params to native separator, resolves path and converts native back to posix
 *
 * needed on windows to handle posix paths in tsconfig
 *
 * @param dir {string|null} directory to resolve from
 * @param filename {string} filename or pattern to resolve
 * @returns string
 */
const resolve2posix = IS_POSIX
	? (dir, filename) => (dir ? path.resolve(dir, filename) : path.resolve(filename))
	: (dir, filename) =>
			native2posix(
				dir
					? path.resolve(posix2native(dir), posix2native(filename))
					: path.resolve(posix2native(filename))
			);

/**
 *
 * @param {import('./public.d.ts').TSConfckParseResult} result
 * @param {import('./public.d.ts').TSConfckParseOptions} [options]
 * @returns {string[]}
 */
function resolveReferencedTSConfigFiles(result, options) {
	const dir = path.dirname(result.tsconfigFile);
	return result.tsconfig.references.map((ref) => {
		const refPath = ref.path.endsWith('.json')
			? ref.path
			: path.join(ref.path, options?.configName ?? 'tsconfig.json');
		return resolve2posix(dir, refPath);
	});
}

/**
 * @param {string} filename
 * @param {import('./public.d.ts').TSConfckParseResult} result
 * @returns {import('./public.d.ts').TSConfckParseResult}
 */
function resolveSolutionTSConfig(filename, result) {
	const allowJs = result.tsconfig.compilerOptions?.allowJs;
	const extensions = allowJs ? TSJS_EXTENSIONS : TS_EXTENSIONS;
	if (
		result.referenced &&
		extensions.some((ext) => filename.endsWith(ext)) &&
		!isIncluded(filename, result)
	) {
		const solutionTSConfig = result.referenced.find((referenced) =>
			isIncluded(filename, referenced)
		);
		if (solutionTSConfig) {
			return solutionTSConfig;
		}
	}
	return result;
}

/**
 *
 * @param {string} filename
 * @param {import('./public.d.ts').TSConfckParseResult} result
 * @returns {boolean}
 */
function isIncluded(filename, result) {
	const dir = native2posix(path.dirname(result.tsconfigFile));
	const files = (result.tsconfig.files || []).map((file) => resolve2posix(dir, file));
	const absoluteFilename = resolve2posix(null, filename);
	if (files.includes(filename)) {
		return true;
	}
	const allowJs = result.tsconfig.compilerOptions?.allowJs;
	const isIncluded = isGlobMatch(
		absoluteFilename,
		dir,
		result.tsconfig.include || (result.tsconfig.files ? [] : [GLOB_ALL_PATTERN]),
		allowJs
	);
	if (isIncluded) {
		const isExcluded = isGlobMatch(absoluteFilename, dir, result.tsconfig.exclude || [], allowJs);
		return !isExcluded;
	}
	return false;
}

/**
 * test filenames agains glob patterns in tsconfig
 *
 * @param filename {string} posix style abolute path to filename to test
 * @param dir {string} posix style absolute path to directory of tsconfig containing patterns
 * @param patterns {string[]} glob patterns to match against
 * @param allowJs {boolean} allowJs setting in tsconfig to include js extensions in checks
 * @returns {boolean} true when at least one pattern matches filename
 */
function isGlobMatch(filename, dir, patterns, allowJs) {
	const extensions = allowJs ? TSJS_EXTENSIONS : TS_EXTENSIONS;
	return patterns.some((pattern) => {
		// filename must end with part of pattern that comes after last wildcard
		let lastWildcardIndex = pattern.length;
		let hasWildcard = false;
		let hasExtension = false;
		let hasSlash = false;
		let lastSlashIndex = -1;
		for (let i = pattern.length - 1; i > -1; i--) {
			const c = pattern[i];
			if (!hasWildcard) {
				if (c === '*' || c === '?') {
					lastWildcardIndex = i;
					hasWildcard = true;
				}
			}
			if (!hasSlash) {
				if (c === '.') {
					hasExtension = true;
				} else if (c === '/') {
					lastSlashIndex = i;
					hasSlash = true;
				}
			}
			if (hasWildcard && hasSlash) {
				break;
			}
		}
		if (!hasExtension && (!hasWildcard || lastWildcardIndex < lastSlashIndex)) {
			// add implicit glob
			pattern += `${pattern.endsWith('/') ? '' : '/'}${GLOB_ALL_PATTERN}`;
			lastWildcardIndex = pattern.length - 1;
			hasWildcard = true;
		}

		// if pattern does not end with wildcard, filename must end with pattern after last wildcard
		if (
			lastWildcardIndex < pattern.length - 1 &&
			!filename.endsWith(pattern.slice(lastWildcardIndex + 1))
		) {
			return false;
		}

		// if pattern ends with *, filename must end with a default extension
		if (pattern.endsWith('*') && !extensions.some((ext) => filename.endsWith(ext))) {
			return false;
		}

		// for **/* , filename must start with the dir
		if (pattern === GLOB_ALL_PATTERN) {
			return filename.startsWith(`${dir}/`);
		}

		const resolvedPattern = resolve2posix(dir, pattern);

		// filename must start with part of pattern that comes before first wildcard
		let firstWildcardIndex = -1;
		for (let i = 0; i < resolvedPattern.length; i++) {
			if (resolvedPattern[i] === '*' || resolvedPattern[i] === '?') {
				firstWildcardIndex = i;
				hasWildcard = true;
				break;
			}
		}
		if (
			firstWildcardIndex > 1 &&
			!filename.startsWith(resolvedPattern.slice(0, firstWildcardIndex - 1))
		) {
			return false;
		}

		if (!hasWildcard) {
			//  no wildcard in pattern, filename must be equal to resolved pattern
			return filename === resolvedPattern;
		} else if (
			firstWildcardIndex + GLOB_ALL_PATTERN.length ===
				resolvedPattern.length - (pattern.length - 1 - lastWildcardIndex) &&
			resolvedPattern.slice(firstWildcardIndex, firstWildcardIndex + GLOB_ALL_PATTERN.length) ===
				GLOB_ALL_PATTERN
		) {
			// singular glob-all pattern and we already validated prefix and suffix matches
			return true;
		}
		// complex pattern, use regex to check it
		if (PATTERN_REGEX_CACHE.has(resolvedPattern)) {
			return PATTERN_REGEX_CACHE.get(resolvedPattern).test(filename);
		}
		const regex = pattern2regex(resolvedPattern, allowJs);
		PATTERN_REGEX_CACHE.set(resolvedPattern, regex);
		return regex.test(filename);
	});
}

/**
 * @param {string} resolvedPattern
 * @param {boolean} allowJs
 * @returns {RegExp}
 */
function pattern2regex(resolvedPattern, allowJs) {
	let regexStr = '^';
	for (let i = 0; i < resolvedPattern.length; i++) {
		const char = resolvedPattern[i];
		if (char === '?') {
			regexStr += '[^\\/]';
			continue;
		}
		if (char === '*') {
			if (resolvedPattern[i + 1] === '*' && resolvedPattern[i + 2] === '/') {
				i += 2;
				regexStr += '(?:[^\\/]*\\/)*'; // zero or more path segments
				continue;
			}
			regexStr += '[^\\/]*';
			continue;
		}
		if ('/.+^${}()|[]\\'.includes(char)) {
			regexStr += `\\`;
		}
		regexStr += char;
	}

	// add known file endings if pattern ends on *
	if (resolvedPattern.endsWith('*')) {
		regexStr += allowJs ? TSJS_EXTENSIONS_RE_GROUP : TS_EXTENSIONS_RE_GROUP;
	}
	regexStr += '$';

	return new RegExp(regexStr);
}

/**
 * replace tokens like ${configDir}
 * @param {import('./public.d.ts').TSConfckParseResult} result
 */
function replaceTokens(result) {
	if (result.tsconfig) {
		result.tsconfig = JSON.parse(
			JSON.stringify(result.tsconfig)
				// replace ${configDir}
				.replaceAll(/"\${configDir}/g, `"${native2posix(path.dirname(result.tsconfigFile))}`)
		);
	}
}

/**
 * find the closest tsconfig.json file
 *
 * @param {string} filename - path to file to find tsconfig for (absolute or relative to cwd)
 * @param {import('./public.d.ts').TSConfckFindOptions} [options] - options
 * @returns {Promise<string|null>} absolute path to closest tsconfig.json or null if not found
 */
async function find(filename, options) {
	let dir = path.dirname(path.resolve(filename));
	if (options?.ignoreNodeModules && isInNodeModules(dir)) {
		return null;
	}
	const cache = options?.cache;
	const configName = options?.configName ?? 'tsconfig.json';
	if (cache?.hasConfigPath(dir, configName)) {
		return cache.getConfigPath(dir, configName);
	}
	const { /** @type {Promise<string|null>} */ promise, resolve, reject } = makePromise();
	if (options?.root && !path.isAbsolute(options.root)) {
		options.root = path.resolve(options.root);
	}
	findUp(dir, { promise, resolve, reject }, options);
	return promise;
}

/**
 *
 * @param {string} dir
 * @param {{promise:Promise<string|null>,resolve:(result:string|null)=>void,reject:(err:any)=>void}} madePromise
 * @param {import('./public.d.ts').TSConfckFindOptions} [options] - options
 */
function findUp(dir, { resolve, reject, promise }, options) {
	const { cache, root, configName } = options ?? {};
	if (cache) {
		if (cache.hasConfigPath(dir, configName)) {
			let cached;
			try {
				cached = cache.getConfigPath(dir, configName);
			} catch (e) {
				reject(e);
				return;
			}
			if (cached?.then) {
				cached.then(resolve).catch(reject);
			} else {
				resolve(cached);
			}
		} else {
			cache.setConfigPath(dir, promise, configName);
		}
	}
	const tsconfig = path.join(dir, options?.configName ?? 'tsconfig.json');
	fs.stat(tsconfig, (err, stats) => {
		if (stats && (stats.isFile() || stats.isFIFO())) {
			resolve(tsconfig);
		} else if (err?.code !== 'ENOENT') {
			reject(err);
		} else {
			let parent;
			if (root === dir || (parent = path.dirname(dir)) === dir) {
				resolve(null);
			} else {
				findUp(parent, { promise, resolve, reject }, options);
			}
		}
	});
}

/**
 * @typedef WalkState
 * @interface
 * @property {string[]} files - files
 * @property {number} calls - number of ongoing calls
 * @property {(dir: string)=>boolean} skip - function to skip dirs
 * @property {boolean} err - error flag
 * @property {string[]} configNames - config file names
 */

const sep = path.sep;

/**
 * find all tsconfig.json files in dir
 *
 * @param {string} dir - path to dir (absolute or relative to cwd)
 * @param {import('./public.d.ts').TSConfckFindAllOptions} [options] - options
 * @returns {Promise<string[]>} list of absolute paths to all found tsconfig.json files
 */
async function findAll(dir, options) {
	/** @type WalkState */
	const state = {
		files: [],
		calls: 0,
		skip: options?.skip,
		err: false,
		configNames: options?.configNames ?? ['tsconfig.json']
	};
	return new Promise((resolve, reject) => {
		walk(path.resolve(dir), state, (err, files) => (err ? reject(err) : resolve(files)));
	});
}

/**
 *
 * @param {string} dir
 * @param {WalkState} state
 * @param {(err: NodeJS.ErrnoException | null, files?: string[]) => void} done
 */
function walk(dir, state, done) {
	if (state.err) {
		return;
	}
	state.calls++;
	readdir(dir, { withFileTypes: true }, (err, entries = []) => {
		if (state.err) {
			return;
		}
		// skip deleted or inaccessible directories
		if (err && !(err.code === 'ENOENT' || err.code === 'EACCES' || err.code === 'EPERM')) {
			state.err = true;
			done(err);
		} else {
			for (const ent of entries) {
				if (ent.isDirectory() && !state.skip?.(ent.name)) {
					walk(`${dir}${sep}${ent.name}`, state, done);
				} else if (ent.isFile() && state.configNames.includes(ent.name)) {
					state.files.push(`${dir}${sep}${ent.name}`);
				}
			}
			if (--state.calls === 0) {
				if (!state.err) {
					done(null, state.files);
				}
			}
		}
	});
}

/*
 this file contains code from strip-bom and strip-json-comments by Sindre Sorhus
 https://github.com/sindresorhus/strip-json-comments/blob/v4.0.0/index.js
 https://github.com/sindresorhus/strip-bom/blob/v5.0.0/index.js
 licensed under MIT, see ../LICENSE
*/

/**
 * convert content of tsconfig.json to regular json
 *
 * @param {string} tsconfigJson - content of tsconfig.json
 * @returns {string} content as regular json, comments and dangling commas have been replaced with whitespace
 */
function toJson(tsconfigJson) {
	const stripped = stripDanglingComma(stripJsonComments(stripBom(tsconfigJson)));
	if (stripped.trim() === '') {
		// only whitespace left after stripping, return empty object so that JSON.parse still works
		return '{}';
	} else {
		return stripped;
	}
}

/**
 * replace dangling commas from pseudo-json string with single space
 * implementation heavily inspired by strip-json-comments
 *
 * @param {string} pseudoJson
 * @returns {string}
 */
function stripDanglingComma(pseudoJson) {
	let insideString = false;
	let offset = 0;
	let result = '';
	let danglingCommaPos = null;
	for (let i = 0; i < pseudoJson.length; i++) {
		const currentCharacter = pseudoJson[i];
		if (currentCharacter === '"') {
			const escaped = isEscaped(pseudoJson, i);
			if (!escaped) {
				insideString = !insideString;
			}
		}
		if (insideString) {
			danglingCommaPos = null;
			continue;
		}
		if (currentCharacter === ',') {
			danglingCommaPos = i;
			continue;
		}
		if (danglingCommaPos) {
			if (currentCharacter === '}' || currentCharacter === ']') {
				result += pseudoJson.slice(offset, danglingCommaPos) + ' ';
				offset = danglingCommaPos + 1;
				danglingCommaPos = null;
			} else if (!currentCharacter.match(/\s/)) {
				danglingCommaPos = null;
			}
		}
	}
	return result + pseudoJson.substring(offset);
}

// start strip-json-comments
/**
 *
 * @param {string} jsonString
 * @param {number} quotePosition
 * @returns {boolean}
 */
function isEscaped(jsonString, quotePosition) {
	let index = quotePosition - 1;
	let backslashCount = 0;

	while (jsonString[index] === '\\') {
		index -= 1;
		backslashCount += 1;
	}

	return Boolean(backslashCount % 2);
}

/**
 *
 * @param {string} string
 * @param {number?} start
 * @param {number?} end
 */
function strip(string, start, end) {
	return string.slice(start, end).replace(/\S/g, ' ');
}

const singleComment = Symbol('singleComment');
const multiComment = Symbol('multiComment');

/**
 * @param {string} jsonString
 * @returns {string}
 */
function stripJsonComments(jsonString) {
	let isInsideString = false;
	/** @type {false | symbol} */
	let isInsideComment = false;
	let offset = 0;
	let result = '';

	for (let index = 0; index < jsonString.length; index++) {
		const currentCharacter = jsonString[index];
		const nextCharacter = jsonString[index + 1];

		if (!isInsideComment && currentCharacter === '"') {
			const escaped = isEscaped(jsonString, index);
			if (!escaped) {
				isInsideString = !isInsideString;
			}
		}

		if (isInsideString) {
			continue;
		}

		if (!isInsideComment && currentCharacter + nextCharacter === '//') {
			result += jsonString.slice(offset, index);
			offset = index;
			isInsideComment = singleComment;
			index++;
		} else if (isInsideComment === singleComment && currentCharacter + nextCharacter === '\r\n') {
			index++;
			isInsideComment = false;
			result += strip(jsonString, offset, index);
			offset = index;
		} else if (isInsideComment === singleComment && currentCharacter === '\n') {
			isInsideComment = false;
			result += strip(jsonString, offset, index);
			offset = index;
		} else if (!isInsideComment && currentCharacter + nextCharacter === '/*') {
			result += jsonString.slice(offset, index);
			offset = index;
			isInsideComment = multiComment;
			index++;
		} else if (isInsideComment === multiComment && currentCharacter + nextCharacter === '*/') {
			index++;
			isInsideComment = false;
			result += strip(jsonString, offset, index + 1);
			offset = index + 1;
		}
	}

	return result + (isInsideComment ? strip(jsonString.slice(offset)) : jsonString.slice(offset));
}
// end strip-json-comments

// start strip-bom
/**
 * @param {string} string
 * @returns {string}
 */
function stripBom(string) {
	// Catches EFBBBF (UTF-8 BOM) because the buffer-to-string
	// conversion translates it to FEFF (UTF-16 BOM).
	if (string.charCodeAt(0) === 0xfeff) {
		return string.slice(1);
	}
	return string;
}
// end strip-bom

const not_found_result = {
	tsconfigFile: null,
	tsconfig: {}
};

/**
 * parse the closest tsconfig.json file
 *
 * @param {string} filename - path to a tsconfig .json or a source file or directory (absolute or relative to cwd)
 * @param {import('./public.d.ts').TSConfckParseOptions} [options] - options
 * @returns {Promise<import('./public.d.ts').TSConfckParseResult>}
 * @throws {TSConfckParseError}
 */
async function parse(filename, options) {
	/** @type {import('./cache.js').TSConfckCache} */
	const cache = options?.cache;
	if (cache?.hasParseResult(filename)) {
		return getParsedDeep(filename, cache, options);
	}
	const {
		resolve,
		reject,
		/** @type {Promise<import('./public.d.ts').TSConfckParseResult>}*/
		promise
	} = makePromise();
	cache?.setParseResult(filename, promise, true);
	try {
		let tsconfigFile =
			(await resolveTSConfigJson(filename, cache)) || (await find(filename, options));
		if (!tsconfigFile) {
			resolve(not_found_result);
			return promise;
		}
		let result;
		if (filename !== tsconfigFile && cache?.hasParseResult(tsconfigFile)) {
			result = await getParsedDeep(tsconfigFile, cache, options);
		} else {
			result = await parseFile$1(tsconfigFile, cache, filename === tsconfigFile);
			await Promise.all([parseExtends(result, cache), parseReferences$1(result, options)]);
		}
		replaceTokens(result);
		resolve(resolveSolutionTSConfig(filename, result));
	} catch (e) {
		reject(e);
	}
	return promise;
}

/**
 * ensure extends and references are parsed
 *
 * @param {string} filename - cached file
 * @param {import('./cache.js').TSConfckCache} cache - cache
 * @param {import('./public.d.ts').TSConfckParseOptions} options - options
 */
async function getParsedDeep(filename, cache, options) {
	const result = await cache.getParseResult(filename);
	if (
		(result.tsconfig.extends && !result.extended) ||
		(result.tsconfig.references && !result.referenced)
	) {
		const promise = Promise.all([
			parseExtends(result, cache),
			parseReferences$1(result, options)
		]).then(() => result);
		cache.setParseResult(filename, promise, true);
		return promise;
	}
	return result;
}

/**
 *
 * @param {string} tsconfigFile - path to tsconfig file
 * @param {import('./cache.js').TSConfckCache} [cache] - cache
 * @param {boolean} [skipCache] - skip cache
 * @returns {Promise<import('./public.d.ts').TSConfckParseResult>}
 */
async function parseFile$1(tsconfigFile, cache, skipCache) {
	if (
		!skipCache &&
		cache?.hasParseResult(tsconfigFile) &&
		!cache.getParseResult(tsconfigFile)._isRootFile_
	) {
		return cache.getParseResult(tsconfigFile);
	}
	const promise = promises
		.readFile(tsconfigFile, 'utf-8')
		.then(toJson)
		.then((json) => {
			const parsed = JSON.parse(json);
			applyDefaults(parsed, tsconfigFile);
			return {
				tsconfigFile,
				tsconfig: normalizeTSConfig(parsed, path.dirname(tsconfigFile))
			};
		})
		.catch((e) => {
			throw new TSConfckParseError(
				`parsing ${tsconfigFile} failed: ${e}`,
				'PARSE_FILE',
				tsconfigFile,
				e
			);
		});
	if (
		!skipCache &&
		(!cache?.hasParseResult(tsconfigFile) || !cache.getParseResult(tsconfigFile)._isRootFile_)
	) {
		cache?.setParseResult(tsconfigFile, promise);
	}
	return promise;
}

/**
 * normalize to match the output of ts.parseJsonConfigFileContent
 *
 * @param {any} tsconfig - typescript tsconfig output
 * @param {string} dir - directory
 */
function normalizeTSConfig(tsconfig, dir) {
	// set baseUrl to absolute path
	const baseUrl = tsconfig.compilerOptions?.baseUrl;
	if (baseUrl && !baseUrl.startsWith('${') && !path.isAbsolute(baseUrl)) {
		tsconfig.compilerOptions.baseUrl = resolve2posix(dir, baseUrl);
	}
	return tsconfig;
}

/**
 *
 * @param {import('./public.d.ts').TSConfckParseResult} result
 * @param {import('./public.d.ts').TSConfckParseOptions} [options]
 * @returns {Promise<void>}
 */
async function parseReferences$1(result, options) {
	if (!result.tsconfig.references) {
		return;
	}
	const referencedFiles = resolveReferencedTSConfigFiles(result, options);
	const referenced = await Promise.all(
		referencedFiles.map((file) => parseFile$1(file, options?.cache))
	);
	await Promise.all(referenced.map((ref) => parseExtends(ref, options?.cache)));
	referenced.forEach((ref) => {
		ref.solution = result;
		replaceTokens(ref);
	});
	result.referenced = referenced;
}

/**
 * @param {import('./public.d.ts').TSConfckParseResult} result
 * @param {import('./cache.js').TSConfckCache}[cache]
 * @returns {Promise<void>}
 */
async function parseExtends(result, cache) {
	if (!result.tsconfig.extends) {
		return;
	}
	// use result as first element in extended
	// but dereference tsconfig so that mergeExtended can modify the original without affecting extended[0]
	/** @type {import('./public.d.ts').TSConfckParseResult[]} */
	const extended = [
		{ tsconfigFile: result.tsconfigFile, tsconfig: JSON.parse(JSON.stringify(result.tsconfig)) }
	];

	// flatten extends graph into extended
	let pos = 0;
	/** @type {string[]} */
	const extendsPath = [];
	let currentBranchDepth = 0;
	while (pos < extended.length) {
		const extending = extended[pos];
		extendsPath.push(extending.tsconfigFile);
		if (extending.tsconfig.extends) {
			// keep following this branch
			currentBranchDepth += 1;
			/** @type {string[]} */
			let resolvedExtends;
			if (!Array.isArray(extending.tsconfig.extends)) {
				resolvedExtends = [resolveExtends(extending.tsconfig.extends, extending.tsconfigFile)];
			} else {
				// reverse because typescript 5.0 treats ['a','b','c'] as c extends b extends a
				resolvedExtends = extending.tsconfig.extends
					.reverse()
					.map((ex) => resolveExtends(ex, extending.tsconfigFile));
			}

			const circularExtends = resolvedExtends.find((tsconfigFile) =>
				extendsPath.includes(tsconfigFile)
			);
			if (circularExtends) {
				const circle = extendsPath.concat([circularExtends]).join(' -> ');
				throw new TSConfckParseError(
					`Circular dependency in "extends": ${circle}`,
					'EXTENDS_CIRCULAR',
					result.tsconfigFile
				);
			}
			// add new extends to the list directly after current
			extended.splice(
				pos + 1,
				0,
				...(await Promise.all(resolvedExtends.map((file) => parseFile$1(file, cache))))
			);
		} else {
			// reached a leaf, backtrack to the last branching point and continue
			extendsPath.splice(-currentBranchDepth);
			currentBranchDepth = 0;
		}
		pos = pos + 1;
	}
	result.extended = extended;
	// skip first as it is the original config
	for (const ext of result.extended.slice(1)) {
		extendTSConfig(result, ext);
	}
}

/**
 *
 * @param {string} extended
 * @param {string} from
 * @returns {string}
 */
function resolveExtends(extended, from) {
	// see #149 and #220
	if (['.', '..'].includes(extended)) {
		extended = extended + '/tsconfig.json';
	}
	const req = createRequire(from);
	let error;
	try {
		return req.resolve(extended);
	} catch (e) {
		error = e;
	}
	if (extended[0] !== '.' && !path.isAbsolute(extended)) {
		try {
			return req.resolve(`${extended}/tsconfig.json`);
		} catch (e) {
			error = e;
		}
	}

	throw new TSConfckParseError(
		`failed to resolve "extends":"${extended}" in ${from}`,
		'EXTENDS_RESOLVE',
		from,
		error
	);
}

// references, extends and custom keys are not carried over
const EXTENDABLE_KEYS = [
	'compilerOptions',
	'files',
	'include',
	'exclude',
	'watchOptions',
	'compileOnSave',
	'typeAcquisition',
	'buildOptions'
];

/**
 *
 * @param {import('./public.d.ts').TSConfckParseResult} extending
 * @param {import('./public.d.ts').TSConfckParseResult} extended
 * @returns void
 */
function extendTSConfig(extending, extended) {
	const extendingConfig = extending.tsconfig;
	const extendedConfig = extended.tsconfig;
	const relativePath = native2posix(
		path.relative(path.dirname(extending.tsconfigFile), path.dirname(extended.tsconfigFile))
	);
	for (const key of Object.keys(extendedConfig).filter((key) => EXTENDABLE_KEYS.includes(key))) {
		if (key === 'compilerOptions') {
			if (!extendingConfig.compilerOptions) {
				extendingConfig.compilerOptions = {};
			}
			for (const option of Object.keys(extendedConfig.compilerOptions)) {
				if (Object.prototype.hasOwnProperty.call(extendingConfig.compilerOptions, option)) {
					continue; // already set
				}
				extendingConfig.compilerOptions[option] = rebaseRelative(
					option,
					extendedConfig.compilerOptions[option],
					relativePath
				);
			}
		} else if (extendingConfig[key] === undefined) {
			if (key === 'watchOptions') {
				extendingConfig.watchOptions = {};
				for (const option of Object.keys(extendedConfig.watchOptions)) {
					extendingConfig.watchOptions[option] = rebaseRelative(
						option,
						extendedConfig.watchOptions[option],
						relativePath
					);
				}
			} else {
				extendingConfig[key] = rebaseRelative(key, extendedConfig[key], relativePath);
			}
		}
	}
}

const REBASE_KEYS = [
	// root
	'files',
	'include',
	'exclude',
	// compilerOptions
	'baseUrl',
	'rootDir',
	'rootDirs',
	'typeRoots',
	'outDir',
	'outFile',
	'declarationDir',
	// watchOptions
	'excludeDirectories',
	'excludeFiles'
];

/** @typedef {string | string[]} PathValue */

/**
 *
 * @param {string} key
 * @param {PathValue} value
 * @param {string} prependPath
 * @returns {PathValue}
 */
function rebaseRelative(key, value, prependPath) {
	if (!REBASE_KEYS.includes(key)) {
		return value;
	}
	if (Array.isArray(value)) {
		return value.map((x) => rebasePath(x, prependPath));
	} else {
		return rebasePath(value, prependPath);
	}
}

/**
 *
 * @param {string} value
 * @param {string} prependPath
 * @returns {string}
 */
function rebasePath(value, prependPath) {
	if (path.isAbsolute(value) || value.startsWith('${configDir}')) {
		return value;
	} else {
		// relative paths use posix syntax in tsconfig
		return path.posix.normalize(path.posix.join(prependPath, value));
	}
}

class TSConfckParseError extends Error {
	/**
	 * error code
	 * @type {string}
	 */
	code;
	/**
	 * error cause
	 * @type { Error | undefined}
	 */
	cause;

	/**
	 * absolute path of tsconfig file where the error happened
	 * @type {string}
	 */
	tsconfigFile;
	/**
	 *
	 * @param {string} message - error message
	 * @param {string} code - error code
	 * @param {string} tsconfigFile - path to tsconfig file
	 * @param {Error?} cause - cause of this error
	 */
	constructor(message, code, tsconfigFile, cause) {
		super(message);
		// Set the prototype explicitly.
		Object.setPrototypeOf(this, TSConfckParseError.prototype);
		this.name = TSConfckParseError.name;
		this.code = code;
		this.cause = cause;
		this.tsconfigFile = tsconfigFile;
	}
}

/**
 *
 * @param {any} tsconfig
 * @param {string} tsconfigFile
 */
function applyDefaults(tsconfig, tsconfigFile) {
	if (isJSConfig(tsconfigFile)) {
		tsconfig.compilerOptions = {
			...DEFAULT_JSCONFIG_COMPILER_OPTIONS,
			...tsconfig.compilerOptions
		};
	}
}

const DEFAULT_JSCONFIG_COMPILER_OPTIONS = {
	allowJs: true,
	maxNodeModuleJsDepth: 2,
	allowSyntheticDefaultImports: true,
	skipLibCheck: true,
	noEmit: true
};

/**
 * @param {string} configFileName
 */
function isJSConfig(configFileName) {
	return path.basename(configFileName) === 'jsconfig.json';
}

/**
 * find the closest tsconfig.json file using native ts.findConfigFile
 *
 * You must have `typescript` installed to use this
 *
 * @param {string} filename - path to file to find tsconfig for (absolute or relative to cwd)
 * @param {import('./public.d.ts').TSConfckFindOptions} [options] - options
 * @returns {Promise<string>} absolute path to closest tsconfig.json
 */
async function findNative(filename, options) {
	let dir = native2posix(path.dirname(path.resolve(filename)));
	if (options?.ignoreNodeModules && isInNodeModules(dir)) {
		return null;
	}
	const cache = options?.cache;
	const root = options?.root ? native2posix(path.resolve(options.root)) : undefined;
	const configName = options?.configName ?? 'tsconfig.json';
	if (cache?.hasConfigPath(dir, configName)) {
		return cache.getConfigPath(dir, configName);
	}
	const ts = await loadTS();
	const { findConfigFile, sys } = ts;
	let tsconfigFile = findConfigFile(dir, sys.fileExists, configName);
	if (!tsconfigFile || is_out_of_root(tsconfigFile, root)) {
		tsconfigFile = null;
	}
	if (cache) {
		cache_result(tsconfigFile, dir, cache, root, configName);
	}
	return tsconfigFile;
}

/**
 *
 * @param {string} tsconfigFile
 * @param {string} root
 */
function is_out_of_root(tsconfigFile, root) {
	return root && !tsconfigFile.startsWith(root);
}

/**
 * add all intermediate directories between fileDir and tsconfigFile to cache
 * if no tsconfig was found, go up until root
 * @param {string|null} tsconfigFile
 * @param {string} fileDir
 * @param {import('./cache.js').TSConfckCache} cache
 * @param {string|undefined} root
 * @param {string} configName
 */
function cache_result(tsconfigFile, fileDir, cache, root, configName) {
	const tsconfigDir = tsconfigFile ? path.dirname(tsconfigFile) : root;
	const directories = [];
	let dir = fileDir;
	while (dir) {
		directories.push(dir);
		const parent = path.dirname(dir);
		if (tsconfigDir === dir || parent === dir) {
			break;
		} else {
			dir = parent;
		}
	}
	directories.forEach((d) => {
		cache.setConfigPath(d, Promise.resolve(tsconfigFile), configName);
	});
}

const notFoundResult = {
	tsconfigFile: null,
	tsconfig: {},
	result: null
};

/**
 * parse the closest tsconfig.json file with typescript native functions
 *
 * You need to have `typescript` installed to use this
 *
 * @param {string} filename - path to a tsconfig .json or a source file (absolute or relative to cwd)
 * @param {import('./public.d.ts').TSConfckParseNativeOptions} [options] - options
 * @returns {Promise<import('./public.d.ts').TSConfckParseNativeResult>}
 * @throws {TSConfckParseNativeError}
 */
async function parseNative(filename, options) {
	/** @type {import('./cache.js').TSConfckCache} */
	const cache = options?.cache;
	if (cache?.hasParseResult(filename)) {
		return cache.getParseResult(filename);
	}
	const {
		resolve,
		reject,
		/** @type {Promise<import('./public.d.ts').TSConfckParseNativeResult>}*/
		promise
	} = makePromise();
	cache?.setParseResult(filename, promise);
	try {
		const tsconfigFile =
			(await resolveTSConfigJson(filename, cache)) || (await findNative(filename, options));
		if (!tsconfigFile) {
			resolve(notFoundResult);
			return promise;
		}
		/** @type {import('./public.d.ts').TSConfckParseNativeResult} */
		let result;
		if (filename !== tsconfigFile && cache?.hasParseResult(tsconfigFile)) {
			result = await cache.getParseResult(tsconfigFile);
		} else {
			const ts = await loadTS();
			result = await parseFile(tsconfigFile, ts, options, filename === tsconfigFile);
			await parseReferences(result, ts, options);
			cache?.setParseResult(tsconfigFile, Promise.resolve(result));
		}

		resolve(resolveSolutionTSConfig(filename, result));
		return promise;
	} catch (e) {
		reject(e);
		return promise;
	}
}

/**
 *
 * @param {string} tsconfigFile
 * @param {any} ts
 * @param {import('./public.d.ts').TSConfckParseNativeOptions} [options]
 * @param {boolean} [skipCache]
 * @returns {import('./public.d.ts').TSConfckParseNativeResult}
 */
function parseFile(tsconfigFile, ts, options, skipCache) {
	const cache = options?.cache;
	if (!skipCache && cache?.hasParseResult(tsconfigFile)) {
		return cache.getParseResult(tsconfigFile);
	}
	const posixTSConfigFile = native2posix(tsconfigFile);
	const { parseJsonConfigFileContent, readConfigFile, sys } = ts;
	const { config, error } = readConfigFile(posixTSConfigFile, sys.readFile);
	if (error) {
		throw new TSConfckParseNativeError(error, tsconfigFile, null);
	}

	const host = {
		useCaseSensitiveFileNames: false,
		readDirectory: sys.readDirectory,
		fileExists: sys.fileExists,
		readFile: sys.readFile
	};

	if (options?.ignoreSourceFiles) {
		config.files = [];
		config.include = [];
	}
	const nativeResult = parseJsonConfigFileContent(
		config,
		host,
		path.dirname(posixTSConfigFile),
		undefined,
		posixTSConfigFile
	);

	checkErrors(nativeResult, tsconfigFile);

	/** @type {import('./public.d.ts').TSConfckParseNativeResult} */
	const result = {
		tsconfigFile,
		tsconfig: result2tsconfig(nativeResult, ts, tsconfigFile),
		result: nativeResult
	};

	if (!skipCache) {
		cache?.setParseResult(tsconfigFile, Promise.resolve(result));
	}

	return result;
}

/**
 *
 * @param {import('./public.d.ts').TSConfckParseNativeResult} result
 * @param {any} ts
 * @param {import('./public.d.ts').TSConfckParseNativeOptions} [options]
 */
async function parseReferences(result, ts, options) {
	if (!result.tsconfig.references) {
		return;
	}
	const referencedFiles = resolveReferencedTSConfigFiles(result, options);
	result.referenced = await Promise.all(
		referencedFiles.map((file) => parseFile(file, ts, options))
	);
	result.referenced.forEach((ref) => (ref.solution = result));
}

/**
 * check errors reported by parseJsonConfigFileContent
 *
 * ignores errors related to missing input files as these may happen regularly in programmatic use
 * and do not affect the config itself
 *
 * @param {any} nativeResult - native typescript parse result to check for errors
 * @param {string} tsconfigFile
 * @throws {TSConfckParseNativeError} for critical error
 */
function checkErrors(nativeResult, tsconfigFile) {
	const ignoredErrorCodes = [
		// see https://github.com/microsoft/TypeScript/blob/main/src/compiler/diagnosticMessages.json
		18002, // empty files list
		18003 // no inputs
	];
	const criticalError = nativeResult.errors?.find(
		(error) => error.category === 1 && !ignoredErrorCodes.includes(error.code)
	);
	if (criticalError) {
		throw new TSConfckParseNativeError(criticalError, tsconfigFile, nativeResult);
	}
}

/**
 * convert the result of `parseJsonConfigFileContent` to a tsconfig that can be parsed again
 *
 * - use merged compilerOptions
 * - strip prefix and postfix of compilerOptions.lib
 * - convert enum values back to string
 *
 * @param {any} result
 * @param {any} ts typescript
 * @param {string} tsconfigFile
 * @returns {object} tsconfig with merged compilerOptions and enums restored to their string form
 */
function result2tsconfig(result, ts, tsconfigFile) {
	// dereference result.raw so changes below don't modify original
	const tsconfig = JSON.parse(JSON.stringify(result.raw));
	// for some reason the extended compilerOptions are not available in result.raw but only in result.options
	// and contain an extra fields 'configFilePath' and 'pathsBasePath'. Use everything but those 2
	const ignoredOptions = ['configFilePath', 'pathsBasePath'];
	if (result.options && Object.keys(result.options).some((o) => !ignoredOptions.includes(o))) {
		tsconfig.compilerOptions = {
			...result.options
		};
		for (const ignored of ignoredOptions) {
			delete tsconfig.compilerOptions[ignored];
		}
	}

	const compilerOptions = tsconfig.compilerOptions;
	if (compilerOptions) {
		if (compilerOptions.lib != null) {
			// remove lib. and .dts from lib.es2019.d.ts etc
			compilerOptions.lib = compilerOptions.lib.map((x) =>
				x.replace(/^lib\./, '').replace(/\.d\.ts$/, '')
			);
		}
		const enumProperties = [
			{ name: 'importsNotUsedAsValues', enumeration: ts.ImportsNotUsedAsValues },
			{ name: 'module', enumeration: ts.ModuleKind },
			{
				name: 'moduleResolution',
				enumeration: {
					...ts.ModuleResolutionKind,
					2: 'node' /*ts.ModuleResolutionKind uses "Node10" but in tsconfig it is just node"*/
				}
			},
			{
				name: 'newLine',
				enumeration: { 0: 'crlf', 1: 'lf' } /*ts.NewLineKind uses different names*/
			},
			{ name: 'target', enumeration: ts.ScriptTarget }
		];
		for (const prop of enumProperties) {
			if (compilerOptions[prop.name] != null && typeof compilerOptions[prop.name] === 'number') {
				compilerOptions[prop.name] = prop.enumeration[compilerOptions[prop.name]].toLowerCase();
			}
		}
		if (compilerOptions.target === 'latest') {
			compilerOptions.target = 'esnext'; // why ts, why?
		}
	}

	// merged watchOptions
	if (result.watchOptions) {
		tsconfig.watchOptions = {
			...result.watchOptions
		};
	}

	const watchOptions = tsconfig.watchOptions;
	if (watchOptions) {
		const enumProperties = [
			{ name: 'watchFile', enumeration: ts.WatchFileKind },
			{ name: 'watchDirectory', enumeration: ts.WatchDirectoryKind },
			{ name: 'fallbackPolling', enumeration: ts.PollingWatchKind }
		];
		for (const prop of enumProperties) {
			if (watchOptions[prop.name] != null && typeof watchOptions[prop.name] === 'number') {
				const enumVal = prop.enumeration[watchOptions[prop.name]];
				watchOptions[prop.name] = enumVal.charAt(0).toLowerCase() + enumVal.slice(1);
			}
		}
	}
	if (tsconfig.compileOnSave === false) {
		// ts adds this property even if it isn't present in the actual config
		// delete if it is false to match content of tsconfig
		delete tsconfig.compileOnSave;
	}
	// ts itself has not replaced all tokens at this point, make sure they are
	const parseResult = { tsconfig, tsconfigFile };
	replaceTokens(parseResult);
	return parseResult.tsconfig;
}

class TSConfckParseNativeError extends Error {
	/**
	 *
	 * @param {TSDiagnosticError} diagnostic - diagnostics of ts
	 * @param {string} tsconfigFile - file that errored
	 * @param {any?} result  - parsed result, if any
	 */
	constructor(diagnostic, tsconfigFile, result) {
		super(diagnostic.messageText);
		// Set the prototype explicitly.
		Object.setPrototypeOf(this, TSConfckParseNativeError.prototype);
		this.name = TSConfckParseNativeError.name;
		this.code = `TS ${diagnostic.code}`;
		this.diagnostic = diagnostic;
		this.result = result;
		this.tsconfigFile = tsconfigFile;
	}

	/**
	 * code of typescript diagnostic, prefixed with "TS "
	 * @type {string}
	 */
	code;

	/**
	 * full ts diagnostic that caused this error
	 * @type {TSDiagnosticError}
	 */
	diagnostic;

	/**
	 * absolute path of tsconfig file where the error happened
	 * @type {string}
	 */
	tsconfigFile;

	/**
	 * native result if present, contains all errors in result.errors
	 * @type {any|undefined}
	 */
	result;
}

/** @typedef TSDiagnosticError {
	code: number;
	category: number;
	messageText: string;
	start?: number;
} TSDiagnosticError */

/** @template T */
class TSConfckCache {
	/**
	 * clear cache, use this if you have a long running process and tsconfig files have been added,changed or deleted
	 */
	clear() {
		this.#configPaths.clear();
		this.#parsed.clear();
	}

	/**
	 * has cached closest config for files in dir
	 * @param {string} dir
	 * @param {string} [configName=tsconfig.json]
	 * @returns {boolean}
	 */
	hasConfigPath(dir, configName = 'tsconfig.json') {
		return this.#configPaths.has(`${dir}/${configName}`);
	}

	/**
	 * get cached closest tsconfig for files in dir
	 * @param {string} dir
	 * @param {string} [configName=tsconfig.json]
	 * @returns {Promise<string|null>|string|null}
	 * @throws {unknown} if cached value is an error
	 */
	getConfigPath(dir, configName = 'tsconfig.json') {
		const key = `${dir}/${configName}`;
		const value = this.#configPaths.get(key);
		if (value == null || value.length || value.then) {
			return value;
		} else {
			throw value;
		}
	}

	/**
	 * has parsed tsconfig for file
	 * @param {string} file
	 * @returns {boolean}
	 */
	hasParseResult(file) {
		return this.#parsed.has(file);
	}

	/**
	 * get parsed tsconfig for file
	 * @param {string} file
	 * @returns {Promise<T>|T}
	 * @throws {unknown} if cached value is an error
	 */
	getParseResult(file) {
		const value = this.#parsed.get(file);
		if (value.then || value.tsconfig) {
			return value;
		} else {
			throw value; // cached error, rethrow
		}
	}

	/**
	 * @internal
	 * @private
	 * @param file
	 * @param {boolean} isRootFile a flag to check if current file which involking the parse() api, used to distinguish the normal cache which only parsed by parseFile()
	 * @param {Promise<T>} result
	 */
	setParseResult(file, result, isRootFile = false) {
		// _isRootFile_ is a temporary property for Promise result, used to prevent deadlock with cache
		Object.defineProperty(result, '_isRootFile_', {
			value: isRootFile,
			writable: false,
			enumerable: false,
			configurable: false
		});
		this.#parsed.set(file, result);
		result
			.then((parsed) => {
				if (this.#parsed.get(file) === result) {
					this.#parsed.set(file, parsed);
				}
			})
			.catch((e) => {
				if (this.#parsed.get(file) === result) {
					this.#parsed.set(file, e);
				}
			});
	}

	/**
	 * @internal
	 * @private
	 * @param {string} dir
	 * @param {Promise<string|null>} configPath
	 * @param {string} [configName=tsconfig.json]
	 */
	setConfigPath(dir, configPath, configName = 'tsconfig.json') {
		const key = `${dir}/${configName}`;
		this.#configPaths.set(key, configPath);
		configPath
			.then((path) => {
				if (this.#configPaths.get(key) === configPath) {
					this.#configPaths.set(key, path);
				}
			})
			.catch((e) => {
				if (this.#configPaths.get(key) === configPath) {
					this.#configPaths.set(key, e);
				}
			});
	}

	/**
	 * map directories to their closest tsconfig.json
	 * @internal
	 * @private
	 * @type{Map<string,(Promise<string|null>|string|null)>}
	 */
	#configPaths = new Map();

	/**
	 * map files to their parsed tsconfig result
	 * @internal
	 * @private
	 * @type {Map<string,(Promise<T>|T)> }
	 */
	#parsed = new Map();
}

export { TSConfckCache, TSConfckParseError, TSConfckParseNativeError, find, findAll, findNative, parse, parseNative, toJson };
//# sourceMappingURL=index2.js.map
