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

"use strict";

//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------

/** Class representing a number in scientific notation. */
class ScientificNotation {
	/** @type {string} The digits of the coefficient. A decimal point is implied after the first digit. */
	coefficient;

	/** @type {number} The order of magnitude. */
	magnitude;

	constructor(coefficient, magnitude) {
		this.coefficient = coefficient;
		this.magnitude = magnitude;
	}

	/* c8 ignore start -- debug only */
	toString() {
		return `${this.coefficient[0]}${this.coefficient.length > 1 ? `.${this.coefficient.slice(1)}` : ""}e${this.magnitude}`;
	}
	/* c8 ignore stop */
}

/**
 * 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());
}

/**
 * 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 {ScientificNotation} the object containing the integer's coefficient and order of magnitude
 */
function normalizeInteger(stringInteger) {
	const trimmedInteger = removeLeadingZeros(stringInteger);
	const significantDigits = removeTrailingZeros(trimmedInteger);

	return new ScientificNotation(significantDigits, trimmedInteger.length - 1);
}

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

	switch (indexOfDecimalPoint) {
		case 0: {
			const significantDigits = removeLeadingZeros(trimmedFloat.slice(1));

			return new ScientificNotation(
				significantDigits,
				significantDigits.length - trimmedFloat.length,
			);
		}
		case -1:
			return new ScientificNotation(
				trimmedFloat,
				trimmedFloat.length - 1,
			);
		default:
			return new ScientificNotation(
				trimmedFloat.replace(".", ""),
				indexOfDecimalPoint - 1,
			);
	}
}

/**
 * Converts a base ten number to proper scientific notation
 * @param {string} stringNumber the string representation of the base ten number to be converted
 * @param {boolean} parseAsFloat if true, the coefficient will be always parsed as a float, regardless of whether a decimal point is present
 * @returns {ScientificNotation} the object containing the number's coefficient and order of magnitude
 */
function convertNumberToScientificNotation(stringNumber, parseAsFloat) {
	const splitNumber = stringNumber.split("e");
	const originalCoefficient = splitNumber[0];
	const normalizedNumber =
		parseAsFloat || stringNumber.includes(".")
			? normalizeFloat(originalCoefficient)
			: normalizeInteger(originalCoefficient);
	if (splitNumber.length > 1) {
		normalizedNumber.magnitude += parseInt(splitNumber[1], 10);
	}

	return normalizedNumber;
}

/**
 * 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 rawNumber = getRaw(node).toLowerCase().replace(/\.$/u, "");
	const normalizedRawNumber = convertNumberToScientificNotation(
		rawNumber,
		false,
	);

	if (node.value === 0) {
		return !/^0+$/u.test(normalizedRawNumber.coefficient);
	}

	const requestedPrecision = normalizedRawNumber.coefficient.length;

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

	return (
		normalizedRawNumber.magnitude !== normalizedStoredNumber.magnitude ||
		normalizedRawNumber.coefficient !== normalizedStoredNumber.coefficient
	);
}

/**
 * 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);
}

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

/** @type {import('../types').Rule.RuleModule} */
module.exports = {
	meta: {
		type: "problem",

		docs: {
			description: "Disallow literal numbers that lose precision",
			dialects: ["JavaScript", "TypeScript"],
			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) {
		return {
			Literal(node) {
				if (isNumber(node) && losesPrecision(node)) {
					context.report({
						messageId: "noLossOfPrecision",
						node,
					});
				}
			},
		};
	},
};
