/**
 * @fileoverview Rule to flag numbers that will lose significant figure precision at runtime
 * @author Jacob Moore
 */

"use strict";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/** @type {import('../types').Rule.RuleModule} */
module.exports = {
	meta: {
		type: "problem",
		dialects: ["typescript", "javascript"],
		language: "javascript",

		docs: {
			description: "Disallow literal numbers that lose precision",
			recommended: true,
			url: "https://eslint.org/docs/latest/rules/no-loss-of-precision",
		},
		schema: [],
		messages: {
			noLossOfPrecision:
				"This number literal will lose precision at runtime.",
		},
	},

	create(context) {
		/**
		 * Returns whether the node is number literal
		 * @param {Node} node the node literal being evaluated
		 * @returns {boolean} true if the node is a number literal
		 */
		function isNumber(node) {
			return typeof node.value === "number";
		}

		/**
		 * Gets the source code of the given number literal. Removes `_` numeric separators from the result.
		 * @param {Node} node the number `Literal` node
		 * @returns {string} raw source code of the literal, without numeric separators
		 */
		function getRaw(node) {
			return node.raw.replace(/_/gu, "");
		}

		/**
		 * Checks whether the number is  base ten
		 * @param {ASTNode} node the node being evaluated
		 * @returns {boolean} true if the node is in base ten
		 */
		function isBaseTen(node) {
			const prefixes = ["0x", "0X", "0b", "0B", "0o", "0O"];

			return (
				prefixes.every(prefix => !node.raw.startsWith(prefix)) &&
				!/^0[0-7]+$/u.test(node.raw)
			);
		}

		/**
		 * Checks that the user-intended non-base ten number equals the actual number after is has been converted to the Number type
		 * @param {Node} node the node being evaluated
		 * @returns {boolean} true if they do not match
		 */
		function notBaseTenLosesPrecision(node) {
			const rawString = getRaw(node).toUpperCase();
			let base;

			if (rawString.startsWith("0B")) {
				base = 2;
			} else if (rawString.startsWith("0X")) {
				base = 16;
			} else {
				base = 8;
			}

			return !rawString.endsWith(node.value.toString(base).toUpperCase());
		}

		/**
		 * Adds a decimal point to the numeric string at index 1
		 * @param {string} stringNumber the numeric string without any decimal point
		 * @returns {string} the numeric string with a decimal point in the proper place
		 */
		function addDecimalPointToNumber(stringNumber) {
			return `${stringNumber[0]}.${stringNumber.slice(1)}`;
		}

		/**
		 * Returns the number stripped of leading zeros
		 * @param {string} numberAsString the string representation of the number
		 * @returns {string} the stripped string
		 */
		function removeLeadingZeros(numberAsString) {
			for (let i = 0; i < numberAsString.length; i++) {
				if (numberAsString[i] !== "0") {
					return numberAsString.slice(i);
				}
			}
			return numberAsString;
		}

		/**
		 * Returns the number stripped of trailing zeros
		 * @param {string} numberAsString the string representation of the number
		 * @returns {string} the stripped string
		 */
		function removeTrailingZeros(numberAsString) {
			for (let i = numberAsString.length - 1; i >= 0; i--) {
				if (numberAsString[i] !== "0") {
					return numberAsString.slice(0, i + 1);
				}
			}
			return numberAsString;
		}

		/**
		 * Converts an integer to an object containing the integer's coefficient and order of magnitude
		 * @param {string} stringInteger the string representation of the integer being converted
		 * @returns {Object} the object containing the integer's coefficient and order of magnitude
		 */
		function normalizeInteger(stringInteger) {
			const significantDigits = removeTrailingZeros(
				removeLeadingZeros(stringInteger),
			);

			return {
				magnitude: stringInteger.startsWith("0")
					? stringInteger.length - 2
					: stringInteger.length - 1,
				coefficient: addDecimalPointToNumber(significantDigits),
			};
		}

		/**
		 *
		 * Converts a float to an object containing the floats's coefficient and order of magnitude
		 * @param {string} stringFloat the string representation of the float being converted
		 * @returns {Object} the object containing the integer's coefficient and order of magnitude
		 */
		function normalizeFloat(stringFloat) {
			const trimmedFloat = removeLeadingZeros(stringFloat);

			if (trimmedFloat.startsWith(".")) {
				const decimalDigits = trimmedFloat.slice(1);
				const significantDigits = removeLeadingZeros(decimalDigits);

				return {
					magnitude:
						significantDigits.length - decimalDigits.length - 1,
					coefficient: addDecimalPointToNumber(significantDigits),
				};
			}
			return {
				magnitude: trimmedFloat.indexOf(".") - 1,
				coefficient: addDecimalPointToNumber(
					trimmedFloat.replace(".", ""),
				),
			};
		}

		/**
		 * Converts a base ten number to proper scientific notation
		 * @param {string} stringNumber the string representation of the base ten number to be converted
		 * @returns {string} the number converted to scientific notation
		 */
		function convertNumberToScientificNotation(stringNumber) {
			const splitNumber = stringNumber.replace("E", "e").split("e");
			const originalCoefficient = splitNumber[0];
			const normalizedNumber = stringNumber.includes(".")
				? normalizeFloat(originalCoefficient)
				: normalizeInteger(originalCoefficient);
			const normalizedCoefficient = normalizedNumber.coefficient;
			const magnitude =
				splitNumber.length > 1
					? parseInt(splitNumber[1], 10) + normalizedNumber.magnitude
					: normalizedNumber.magnitude;

			return `${normalizedCoefficient}e${magnitude}`;
		}

		/**
		 * Checks that the user-intended base ten number equals the actual number after is has been converted to the Number type
		 * @param {Node} node the node being evaluated
		 * @returns {boolean} true if they do not match
		 */
		function baseTenLosesPrecision(node) {
			const normalizedRawNumber = convertNumberToScientificNotation(
				getRaw(node),
			);
			const requestedPrecision = normalizedRawNumber
				.split("e")[0]
				.replace(".", "").length;

			if (requestedPrecision > 100) {
				return true;
			}
			const storedNumber = node.value.toPrecision(requestedPrecision);
			const normalizedStoredNumber =
				convertNumberToScientificNotation(storedNumber);

			return normalizedRawNumber !== normalizedStoredNumber;
		}

		/**
		 * Checks that the user-intended number equals the actual number after is has been converted to the Number type
		 * @param {Node} node the node being evaluated
		 * @returns {boolean} true if they do not match
		 */
		function losesPrecision(node) {
			return isBaseTen(node)
				? baseTenLosesPrecision(node)
				: notBaseTenLosesPrecision(node);
		}

		return {
			Literal(node) {
				if (node.value && isNumber(node) && losesPrecision(node)) {
					context.report({
						messageId: "noLossOfPrecision",
						node,
					});
				}
			},
		};
	},
};
