/*
	MIT License http://www.opensource.org/licenses/mit-license.php
	Author Ivan Kopeykin @vankop
*/

"use strict";

const { parseIdentifier } = require("./identifier");

/** @typedef {string | (string | ConditionalMapping)[]} DirectMapping */
/** @typedef {{ [k: string]: MappingValue }} ConditionalMapping */
/** @typedef {ConditionalMapping | DirectMapping | null} MappingValue */
/** @typedef {Record<string, MappingValue> | ConditionalMapping | DirectMapping} ExportsField */
/** @typedef {Record<string, MappingValue>} ImportsField */

/**
 * Processing exports/imports field
 * @callback FieldProcessor
 * @param {string} request request
 * @param {Set<string>} conditionNames condition names
 * @returns {[string[], string | null]} resolved paths with used field
 */

/*
Example exports field:
{
  ".": "./main.js",
  "./feature": {
    "browser": "./feature-browser.js",
    "default": "./feature.js"
  }
}
Terminology:

Enhanced-resolve name keys ("." and "./feature") as exports field keys.

If value is string or string[], mapping is called as a direct mapping
and value called as a direct export.

If value is key-value object, mapping is called as a conditional mapping
and value called as a conditional export.

Key in conditional mapping is called condition name.

Conditional mapping nested in another conditional mapping is called nested mapping.

----------

Example imports field:
{
  "#a": "./main.js",
  "#moment": {
    "browser": "./moment/index.js",
    "default": "moment"
  },
  "#moment/": {
    "browser": "./moment/",
    "default": "moment/"
  }
}
Terminology:

Enhanced-resolve name keys ("#a" and "#moment/", "#moment") as imports field keys.

If value is string or string[], mapping is called as a direct mapping
and value called as a direct export.

If value is key-value object, mapping is called as a conditional mapping
and value called as a conditional export.

Key in conditional mapping is called condition name.

Conditional mapping nested in another conditional mapping is called nested mapping.

*/

const slashCode = "/".charCodeAt(0);
const dotCode = ".".charCodeAt(0);
const hashCode = "#".charCodeAt(0);
const patternRegEx = /\*/g;

/** @typedef {Record<string, MappingValue>} RecordMapping */

/**
 * Cached `Object.keys()` for objects whose shape does not change after the
 * first observation — i.e. parsed `package.json` fields and the nested
 * conditional mappings inside them. `Object.keys` allocates a fresh array
 * on every call; since `findMatch` / `conditionalMapping` run on every
 * bare-specifier resolve, the allocation adds up quickly.
 * @type {WeakMap<RecordMapping, string[]>}
 */
const _keysCache = new WeakMap();

/**
 * @param {RecordMapping} obj object to read keys from
 * @returns {string[]} cached keys array (DO NOT mutate)
 */
function cachedKeys(obj) {
	let keys = _keysCache.get(obj);
	if (keys === undefined) {
		keys = Object.keys(obj);
		_keysCache.set(obj, keys);
	}
	return keys;
}

/**
 * Per-key precomputed info used by `findMatch`. Equivalent to what the
 * previous implementation recomputed inline on every resolve.
 * @typedef {object} FieldKeyInfo
 * @property {string} key the original key
 * @property {number} patternIndex position of the single "*" in the key, or -1 when absent
 * @property {string} wildcardPrefix substring before "*" (empty when patternIndex === -1)
 * @property {string} wildcardSuffix substring after "*" (empty when patternIndex === -1)
 * @property {boolean} isLegacySubpath true when key is a legacy `./foo/`-style folder key with no "*"
 * @property {boolean} isPattern true when key contains "*"
 * @property {boolean} isSubpathMapping true when key ends with "/"
 * @property {boolean} isValidPattern true when key has at most one "*"
 */

/**
 * Cached per-field key metadata, keyed by the exports/imports field
 * object. Computed lazily on first `findMatch` call and reused forever.
 * Safe because `package.json` fields are immutable JSON values.
 * @type {WeakMap<RecordMapping, FieldKeyInfo[]>}
 */
const _fieldKeyInfoCache = new WeakMap();

/**
 * @param {ExportsField | ImportsField} field field object
 * @returns {FieldKeyInfo[]} precomputed per-key info
 */
function getFieldKeyInfos(field) {
	const fieldKey = /** @type {RecordMapping} */ (field);
	let infos = _fieldKeyInfoCache.get(fieldKey);
	if (infos !== undefined) return infos;
	const keys = Object.getOwnPropertyNames(field);
	infos = Array.from({ length: keys.length });
	for (let i = 0; i < keys.length; i++) {
		const key = keys[i];
		const patternIndex = key.indexOf("*");
		const lastStar = patternIndex === -1 ? -1 : key.lastIndexOf("*");
		const keyLen = key.length;
		const endsWithSlash =
			keyLen > 0 && key.charCodeAt(keyLen - 1) === slashCode;
		infos[i] = {
			key,
			patternIndex,
			wildcardPrefix: patternIndex === -1 ? "" : key.slice(0, patternIndex),
			wildcardSuffix: patternIndex === -1 ? "" : key.slice(patternIndex + 1),
			isLegacySubpath: patternIndex === -1 && endsWithSlash,
			isPattern: patternIndex !== -1,
			isSubpathMapping: endsWithSlash,
			isValidPattern: patternIndex === -1 || lastStar === patternIndex,
		};
	}
	_fieldKeyInfoCache.set(fieldKey, infos);
	return infos;
}

/**
 * @param {string} a first string
 * @param {string} b second string
 * @returns {number} compare result
 */
function patternKeyCompare(a, b) {
	const aPatternIndex = a.indexOf("*");
	const bPatternIndex = b.indexOf("*");
	const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1;
	const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1;

	if (baseLenA > baseLenB) return -1;
	if (baseLenB > baseLenA) return 1;
	if (aPatternIndex === -1) return 1;
	if (bPatternIndex === -1) return -1;
	if (a.length > b.length) return -1;
	if (b.length > a.length) return 1;

	return 0;
}

/** @typedef {[MappingValue, string, boolean, boolean, string] | null} MatchTuple */

/**
 * Per-field memoization of `findMatch(request, field)`. For a given field
 * the result depends only on the `request` string (it does NOT depend on
 * `conditionNames` — that's applied separately by `conditionalMapping`),
 * so we can cache the tuple keyed by request.
 *
 * Typical build traffic runs the same request through the resolver
 * repeatedly (same import re-resolved from different source files, module
 * graph traversals that revisit a package, etc.), and every one of those
 * hits walks the same key list and allocates the same tuple. Caching the
 * tuple turns the second-and-onward call into a single Map lookup.
 *
 * Keyed on the field object via a module-level `WeakMap`, so the cache
 * is freed automatically when the owning description file is GC'd.
 * @type {WeakMap<RecordMapping, Map<string, MatchTuple>>}
 */
const _findMatchCache = new WeakMap();

/**
 * @param {string} request request
 * @param {ExportsField | ImportsField} field exports or import field
 * @returns {MatchTuple} match result (uncached)
 */
function computeFindMatch(request, field) {
	const requestLen = request.length;
	const requestEndsWithSlash =
		requestLen > 0 && request.charCodeAt(requestLen - 1) === slashCode;
	const requestHasStar = request.includes("*");

	if (
		!requestHasStar &&
		!requestEndsWithSlash &&
		Object.prototype.hasOwnProperty.call(field, request)
	) {
		const target = /** @type {{ [k: string]: MappingValue }} */ (field)[
			request
		];

		return [target, "", false, false, request];
	}

	/** @type {string} */
	let bestMatch = "";
	/** @type {FieldKeyInfo | null} */
	let bestMatchInfo = null;
	/** @type {string | undefined} */
	let bestMatchSubpath;

	const infos = getFieldKeyInfos(field);

	for (let i = 0; i < infos.length; i++) {
		const info = infos[i];
		const { key, patternIndex } = info;

		if (patternIndex !== -1) {
			if (
				!info.isValidPattern ||
				!request.startsWith(info.wildcardPrefix) ||
				requestLen < key.length ||
				!request.endsWith(info.wildcardSuffix) ||
				patternKeyCompare(bestMatch, key) !== 1
			) {
				continue;
			}
			bestMatch = key;
			bestMatchInfo = info;
			bestMatchSubpath = request.slice(
				patternIndex,
				requestLen - info.wildcardSuffix.length,
			);
		} else if (
			info.isLegacySubpath &&
			request.startsWith(key) &&
			patternKeyCompare(bestMatch, key) === 1
		) {
			bestMatch = key;
			bestMatchInfo = info;
			bestMatchSubpath = request.slice(key.length);
		}
	}

	if (bestMatch === "") return null;

	const target =
		/** @type {{ [k: string]: MappingValue }} */
		(field)[bestMatch];

	return [
		target,
		/** @type {string} */ (bestMatchSubpath),
		/** @type {FieldKeyInfo} */ (bestMatchInfo).isSubpathMapping,
		/** @type {FieldKeyInfo} */ (bestMatchInfo).isPattern,
		bestMatch,
	];
}

/**
 * Trying to match request to field
 * @param {string} request request
 * @param {ExportsField | ImportsField} field exports or import field
 * @returns {MatchTuple} match or null, number is negative and one less when it's a folder mapping, number is request.length + 1 for direct mappings
 */
function findMatch(request, field) {
	const fieldKey = /** @type {RecordMapping} */ (field);
	let perRequest = _findMatchCache.get(fieldKey);
	if (perRequest !== undefined) {
		const cached = perRequest.get(request);
		if (cached !== undefined) return cached;
		// `null` is a valid cached value (= "no match"), so a `get(...)`
		// that returns undefined could either mean "not cached yet" or
		// "cached null". Do the explicit `has` only in the undefined case.
		if (perRequest.has(request)) return null;
	} else {
		perRequest = new Map();
		_findMatchCache.set(fieldKey, perRequest);
	}

	const result = computeFindMatch(request, field);
	perRequest.set(request, result);
	return result;
}

/**
 * @param {ConditionalMapping | DirectMapping | null} mapping mapping
 * @returns {boolean} is conditional mapping
 */
function isConditionalMapping(mapping) {
	return (
		mapping !== null && typeof mapping === "object" && !Array.isArray(mapping)
	);
}

/**
 * Sentinel stored in the conditional-mapping cache for inputs whose walk
 * returns `null` ("no condition matched"). Using a non-null marker lets the
 * cache-hit path be a single `WeakMap.get()` — we distinguish
 * "cached null" from "not cached yet" without a second `has` call.
 */
const NULL_RESULT = Symbol("NULL_RESULT");

/**
 * Memoization of `conditionalMapping(mapping, conditionNames)`. The result
 * depends only on the mapping object (immutable — owned by a parsed
 * `package.json`) and the `conditionNames` Set (owned by the resolver's
 * options and stable for its lifetime), so it is safe to cache per (mapping,
 * conditionNames) pair.
 *
 * A conditional `exports` entry that appears inside a `directMapping` array
 * (the common `"browser": [...fallback list...]` shape, plus nested
 * conditions) gets walked on every resolve that traverses the parent entry.
 * Without this cache each of those walks re-reads `Object.keys` on the
 * mapping and re-visits every condition until one matches, even though the
 * inputs are identical.
 *
 * Outer key is the conditional mapping itself; inner key is the condition
 * Set. Both are object references, so WeakMap-of-WeakMap lets both levels
 * be collected automatically when the description file or resolver go away.
 * @type {WeakMap<ConditionalMapping, WeakMap<Set<string>, DirectMapping | typeof NULL_RESULT>>}
 */
const _conditionalMappingCache = new WeakMap();

/**
 * @param {ConditionalMapping} conditionalMapping_ conditional mapping
 * @param {Set<string>} conditionNames condition names
 * @returns {DirectMapping | null} direct mapping if found (uncached)
 */
function computeConditionalMapping(conditionalMapping_, conditionNames) {
	/** @type {[ConditionalMapping, string[], number][]} */
	const lookup = [[conditionalMapping_, cachedKeys(conditionalMapping_), 0]];

	loop: while (lookup.length > 0) {
		const [mapping, conditions, j] = lookup[lookup.length - 1];

		for (let i = j; i < conditions.length; i++) {
			const condition = conditions[i];

			if (condition === "default") {
				const innerMapping = mapping[condition];
				// is nested
				if (isConditionalMapping(innerMapping)) {
					const conditionalMapping = /** @type {ConditionalMapping} */ (
						innerMapping
					);
					lookup[lookup.length - 1][2] = i + 1;
					lookup.push([conditionalMapping, cachedKeys(conditionalMapping), 0]);
					continue loop;
				}

				return /** @type {DirectMapping} */ (innerMapping);
			}

			if (conditionNames.has(condition)) {
				const innerMapping = mapping[condition];
				// is nested
				if (isConditionalMapping(innerMapping)) {
					const conditionalMapping = /** @type {ConditionalMapping} */ (
						innerMapping
					);
					lookup[lookup.length - 1][2] = i + 1;
					lookup.push([conditionalMapping, cachedKeys(conditionalMapping), 0]);
					continue loop;
				}

				return /** @type {DirectMapping} */ (innerMapping);
			}
		}

		lookup.pop();
	}

	return null;
}

/**
 * @param {ConditionalMapping} conditionalMapping_ conditional mapping
 * @param {Set<string>} conditionNames condition names
 * @returns {DirectMapping | null} direct mapping if found
 */
function conditionalMapping(conditionalMapping_, conditionNames) {
	let perSet = _conditionalMappingCache.get(conditionalMapping_);
	if (perSet !== undefined) {
		const cached = perSet.get(conditionNames);
		if (cached !== undefined) {
			return cached === NULL_RESULT
				? null
				: /** @type {DirectMapping} */ (cached);
		}
	} else {
		perSet = new WeakMap();
		_conditionalMappingCache.set(conditionalMapping_, perSet);
	}
	const result = computeConditionalMapping(conditionalMapping_, conditionNames);
	perSet.set(conditionNames, result === null ? NULL_RESULT : result);
	return result;
}

/**
 * @param {string | undefined} remainingRequest remaining request when folder mapping, undefined for file mappings
 * @param {boolean} isPattern true, if mapping is a pattern (contains "*")
 * @param {boolean} isSubpathMapping true, for subpath mappings
 * @param {string} mappingTarget direct export
 * @param {(d: string, f: boolean) => void} assert asserting direct value
 * @returns {string} mapping result
 */
function targetMapping(
	remainingRequest,
	isPattern,
	isSubpathMapping,
	mappingTarget,
	assert,
) {
	if (remainingRequest === undefined) {
		assert(mappingTarget, false);

		return mappingTarget;
	}

	if (isSubpathMapping) {
		assert(mappingTarget, true);

		return mappingTarget + remainingRequest;
	}

	assert(mappingTarget, false);

	let result = mappingTarget;

	if (isPattern) {
		result = result.replace(
			patternRegEx,
			remainingRequest.replace(/\$/g, "$$"),
		);
	}

	return result;
}

/**
 * @param {string | undefined} remainingRequest remaining request when folder mapping, undefined for file mappings
 * @param {boolean} isPattern true, if mapping is a pattern (contains "*")
 * @param {boolean} isSubpathMapping true, for subpath mappings
 * @param {DirectMapping | null} mappingTarget direct export
 * @param {Set<string>} conditionNames condition names
 * @param {(d: string, f: boolean) => void} assert asserting direct value
 * @returns {string[]} mapping result
 */
function directMapping(
	remainingRequest,
	isPattern,
	isSubpathMapping,
	mappingTarget,
	conditionNames,
	assert,
) {
	if (mappingTarget === null) return [];

	if (typeof mappingTarget === "string") {
		return [
			targetMapping(
				remainingRequest,
				isPattern,
				isSubpathMapping,
				mappingTarget,
				assert,
			),
		];
	}

	/** @type {string[]} */
	const targets = [];

	for (const exp of mappingTarget) {
		if (typeof exp === "string") {
			targets.push(
				targetMapping(
					remainingRequest,
					isPattern,
					isSubpathMapping,
					exp,
					assert,
				),
			);
			continue;
		}

		const mapping = conditionalMapping(exp, conditionNames);
		if (!mapping) continue;
		const innerExports = directMapping(
			remainingRequest,
			isPattern,
			isSubpathMapping,
			mapping,
			conditionNames,
			assert,
		);
		for (const innerExport of innerExports) {
			targets.push(innerExport);
		}
	}

	return targets;
}

/**
 * @param {ExportsField | ImportsField} field root
 * @param {(s: string) => string} normalizeRequest Normalize request, for `imports` field it adds `#`, for `exports` field it adds `.` or `./`
 * @param {(s: string) => string} assertRequest assertRequest
 * @param {(s: string, f: boolean) => void} assertTarget assertTarget
 * @returns {FieldProcessor} field processor
 */
function createFieldProcessor(
	field,
	normalizeRequest,
	assertRequest,
	assertTarget,
) {
	return function fieldProcessor(request, conditionNames) {
		request = assertRequest(request);

		const match = findMatch(normalizeRequest(request), field);

		if (match === null) return [[], null];

		const [mapping, remainingRequest, isSubpathMapping, isPattern, usedField] =
			match;

		/** @type {DirectMapping | null} */
		let direct = null;

		if (isConditionalMapping(mapping)) {
			direct = conditionalMapping(
				/** @type {ConditionalMapping} */ (mapping),
				conditionNames,
			);

			// matching not found
			if (direct === null) return [[], null];
		} else {
			direct = /** @type {DirectMapping} */ (mapping);
		}

		return [
			directMapping(
				remainingRequest,
				isPattern,
				isSubpathMapping,
				direct,
				conditionNames,
				assertTarget,
			),
			usedField,
		];
	};
}

/**
 * @param {string} request request
 * @returns {string} updated request
 */
function assertExportsFieldRequest(request) {
	if (request.charCodeAt(0) !== dotCode) {
		throw new Error('Request should be relative path and start with "."');
	}
	if (request.length === 1) return "";
	if (request.charCodeAt(1) !== slashCode) {
		throw new Error('Request should be relative path and start with "./"');
	}
	if (request.charCodeAt(request.length - 1) === slashCode) {
		throw new Error("Only requesting file allowed");
	}

	return request.slice(2);
}

/**
 * @param {ExportsField} field exports field
 * @returns {ExportsField} normalized exports field
 */
function buildExportsField(field) {
	// handle syntax sugar, if exports field is direct mapping for "."
	if (typeof field === "string" || Array.isArray(field)) {
		return { ".": field };
	}

	const keys = Object.keys(field);

	for (let i = 0; i < keys.length; i++) {
		const key = keys[i];

		if (key.charCodeAt(0) !== dotCode) {
			// handle syntax sugar, if exports field is conditional mapping for "."
			if (i === 0) {
				while (i < keys.length) {
					const charCode = keys[i].charCodeAt(0);
					if (charCode === dotCode || charCode === slashCode) {
						throw new Error(
							`Exports field key should be relative path and start with "." (key: ${JSON.stringify(
								key,
							)})`,
						);
					}
					i++;
				}

				return { ".": field };
			}

			throw new Error(
				`Exports field key should be relative path and start with "." (key: ${JSON.stringify(
					key,
				)})`,
			);
		}

		if (key.length === 1) {
			continue;
		}

		if (key.charCodeAt(1) !== slashCode) {
			throw new Error(
				`Exports field key should be relative path and start with "./" (key: ${JSON.stringify(
					key,
				)})`,
			);
		}
	}

	return field;
}

/**
 * @param {string} exp export target
 * @param {boolean} expectFolder is folder expected
 */
function assertExportTarget(exp, expectFolder) {
	const parsedIdentifier = parseIdentifier(exp);

	if (!parsedIdentifier) {
		return;
	}

	const [relativePath] = parsedIdentifier;
	const isFolder =
		relativePath.charCodeAt(relativePath.length - 1) === slashCode;

	if (isFolder !== expectFolder) {
		throw new Error(
			expectFolder
				? `Expecting folder to folder mapping. ${JSON.stringify(
						exp,
					)} should end with "/"`
				: `Expecting file to file mapping. ${JSON.stringify(
						exp,
					)} should not end with "/"`,
		);
	}
}

/**
 * @param {ExportsField} exportsField the exports field
 * @returns {FieldProcessor} process callback
 */
module.exports.processExportsField = function processExportsField(
	exportsField,
) {
	return createFieldProcessor(
		buildExportsField(exportsField),
		(request) => (request.length === 0 ? "." : `./${request}`),
		assertExportsFieldRequest,
		assertExportTarget,
	);
};

/**
 * @param {string} request request
 * @returns {string} updated request
 */
function assertImportsFieldRequest(request) {
	if (request.charCodeAt(0) !== hashCode) {
		throw new Error('Request should start with "#"');
	}
	if (request.length === 1) {
		throw new Error("Request should have at least 2 characters");
	}
	// Note: #/ patterns are now allowed per Node.js PR #60864
	// https://github.com/nodejs/node/pull/60864
	if (request.charCodeAt(request.length - 1) === slashCode) {
		throw new Error("Only requesting file allowed");
	}

	return request.slice(1);
}

/**
 * @param {string} imp import target
 * @param {boolean} expectFolder is folder expected
 */
function assertImportTarget(imp, expectFolder) {
	const parsedIdentifier = parseIdentifier(imp);

	if (!parsedIdentifier) {
		return;
	}

	const [relativePath] = parsedIdentifier;
	const isFolder =
		relativePath.charCodeAt(relativePath.length - 1) === slashCode;

	if (isFolder !== expectFolder) {
		throw new Error(
			expectFolder
				? `Expecting folder to folder mapping. ${JSON.stringify(
						imp,
					)} should end with "/"`
				: `Expecting file to file mapping. ${JSON.stringify(
						imp,
					)} should not end with "/"`,
		);
	}
}

/**
 * @param {ImportsField} importsField the exports field
 * @returns {FieldProcessor} process callback
 */
module.exports.processImportsField = function processImportsField(
	importsField,
) {
	return createFieldProcessor(
		importsField,
		(request) => `#${request}`,
		assertImportsFieldRequest,
		assertImportTarget,
	);
};
