Source: number_parse_util.js

/**
 * Utility class for parsing numbers with flexible thousands and decimal separators.
 * Mirrors the Java NumberParseUtil implementation to ensure consistency
 * between front-end and back-end.
 *
 * This class handles various number formats including:
 * - US format: "123,456.78"
 * - European format: "123.456,78"
 * - Mixed formats with proper precedence rules
 * - Detection of ambiguous formats like "123,456" vs "123.456"
 */

/**
 * Result class for number parsing operations that can succeed or fail.
 */
class NumberParseResult {
  /**
   * Create a NumberParseResult.
   *
   * @param {number|null} number - The parsed number, or null if parsing failed
   * @param {string|null} error - The error message, or null if parsing succeeded
   */
  constructor(number, error) {
    const self = this;
    self._number = number;
    self._error = error;
  }

  /**
   * Get the parsed number.
   *
   * @returns {number|null} The parsed number, or null if parsing failed
   */
  getNumber() {
    const self = this;
    return self._number;
  }

  /**
   * Get the error message.
   *
   * @returns {string|null} The error message, or null if parsing succeeded
   */
  getError() {
    const self = this;
    return self._error;
  }

  /**
   * Check if parsing was successful.
   *
   * @returns {boolean} True if parsing succeeded, false otherwise
   */
  isSuccess() {
    const self = this;
    return self._error === null;
  }

  /**
   * Create a successful result.
   *
   * @param {number} number - The parsed number
   * @returns {NumberParseResult} A successful result
   */
  static success(number) {
    return new NumberParseResult(number, null);
  }

  /**
   * Create a failed result.
   *
   * @param {string} error - The error message
   * @returns {NumberParseResult} A failed result
   */
  static error(error) {
    return new NumberParseResult(null, error);
  }
}
class NumberParseUtil {
  /**
   * Parse a number string with flexible thousands/decimal separator handling.
   *
   * @param {string} numberString - The number string to parse
   * @returns {NumberParseResult} The parsing result containing the number or error
   */
  parseFlexibleNumber(numberString) {
    const self = this;
    if (!numberString || typeof numberString !== "string") {
      return NumberParseResult.error("Number string cannot be null or empty");
    }

    const trimmed = numberString.trim();
    if (trimmed === "") {
      return NumberParseResult.error("Number string cannot be empty");
    }

    // Handle sign
    const isNegative = trimmed.startsWith("-");
    const isPositive = trimmed.startsWith("+");
    const numberPart = isNegative || isPositive ? trimmed.substring(1) : trimmed;

    // If no separators, parse directly
    const fullyNumeric = !numberPart.includes(",") && !numberPart.includes(".");
    if (fullyNumeric) {
      const result = parseFloat(numberPart);
      if (isNaN(result)) {
        return NumberParseResult.error(`Invalid number format: '${numberString}'`);
      }
      return NumberParseResult.success(isNegative ? -result : result);
    }

    // Handle cases with separators
    const separatorResult = self._parseWithSeparators(numberPart, numberString);
    if (!separatorResult.isSuccess()) {
      return separatorResult;
    }
    const finalNumber = isNegative ? -separatorResult.getNumber() : separatorResult.getNumber();
    return NumberParseResult.success(finalNumber);
  }

  /**
   * Check if a number string format is ambiguous.
   *
   * @param {string} numberString - The number string to check
   * @returns {boolean} True if the number format is ambiguous
   */
  isAmbiguous(numberString) {
    const self = this;
    if (!numberString || typeof numberString !== "string") {
      return false;
    }

    const result = self.parseFlexibleNumber(numberString);
    if (result.isSuccess()) {
      return false;
    }
    return result.getError().includes("Ambiguous number format");
  }

  /**
   * Get disambiguation suggestions for an ambiguous number.
   *
   * @param {string} numberString - The ambiguous number string
   * @returns {string} Suggestion message for resolving ambiguity
   */
  getDisambiguationSuggestion(numberString) {
    const self = this;
    if (!self.isAmbiguous(numberString)) {
      return "Number format is not ambiguous";
    }

    const trimmed = numberString.trim();
    const separator = trimmed.includes(",") ? "," : ".";
    const other = separator === "," ? "." : ",";

    return `Use '${trimmed}${separator}0' or '${trimmed}${other}0' to disambiguate`;
  }

  /**
   * Parse number string with comma and/or period separators.
   *
   * @param {string} numberPart - Number part without sign
   * @param {string} originalString - Original string for error messages
   * @returns {NumberParseResult} Parsed number result
   * @private
   */
  _parseWithSeparators(numberPart, originalString) {
    const self = this;
    const hasCommas = self._countOccurrences(numberPart, ",") > 0;
    const hasPeriods = self._countOccurrences(numberPart, ".") > 0;
    const bothSeparatorsPresent = hasCommas && hasPeriods;

    if (bothSeparatorsPresent) {
      return self._parseMixedSeparators(numberPart, originalString);
    } else if (hasCommas) {
      return self._parseSingleSeparatorType(numberPart, ",", originalString);
    } else if (hasPeriods) {
      return self._parseSingleSeparatorType(numberPart, ".", originalString);
    } else {
      // Shouldn't reach here based on calling conditions
      return NumberParseResult.error(`Unexpected parsing state for: '${originalString}'`);
    }
  }

  /**
   * Handle numbers with both comma and period separators.
   *
   * Validates that we have a valid mixed separator pattern:
   * - US format: One or more commas as thousands, one period as decimal
   *   (period must be last)
   * - European format: One or more periods as thousands, one comma as decimal
   *   (comma must be last)
   *
   * @param {string} numberPart - Number part to parse
   * @param {string} originalString - Original string for error messages
   * @returns {NumberParseResult} Parsed number result
   * @private
   */
  _parseMixedSeparators(numberPart, originalString) {
    const self = this;
    const commaCount = self._countOccurrences(numberPart, ",");
    const periodCount = self._countOccurrences(numberPart, ".");
    const lastComma = numberPart.lastIndexOf(",");
    const lastPeriod = numberPart.lastIndexOf(".");

    const usesUsFormat = lastPeriod > lastComma;
    const multiplePeriods = periodCount !== 1;
    const periodsBeforeComma = numberPart.indexOf(".") < lastComma;

    if (usesUsFormat) {
      if (multiplePeriods) {
        return NumberParseResult.error(
          `Invalid number format: '${originalString}' - multiple decimal separators not allowed`,
        );
      }

      if (periodsBeforeComma) {
        return NumberParseResult.error(
          `Invalid number format: '${originalString}' - periods cannot ` +
            "appear before commas in US format",
        );
      }

      const withoutThousands = numberPart.replace(/,/g, "");
      const result = parseFloat(withoutThousands);
      if (isNaN(result)) {
        return NumberParseResult.error(`Invalid number format: '${originalString}'`);
      }
      return NumberParseResult.success(result);
    } else {
      const ukSuggestion = self._convertEuropeanToUkFormat(numberPart);
      return NumberParseResult.error(
        `Unsupported number format: '${originalString}'. ` +
        `Please use: '${ukSuggestion}'. ` +
        "Kigali Sim requires comma for thousands separator and period for decimal point.",
      );
    }
  }

  /**
   * Handle numbers with only one type of separator (all commas or all periods).
   *
   * @param {string} numberPart - Number part to parse
   * @param {string} separator - The separator character (',' or '.')
   * @param {string} originalString - Original string for error messages
   * @returns {NumberParseResult} Parsed number result
   * @private
   */
  _parseSingleSeparatorType(numberPart, separator, originalString) {
    const self = this;
    const separatorCount = self._countOccurrences(numberPart, separator);

    if (separatorCount > 1) {
      return self._parseSingleSeparatorMultipleOccurrences(
        numberPart,
        separator,
        originalString,
      );
    }

    const separatorIndex = numberPart.indexOf(separator);
    const digitsBefore = separatorIndex;
    const digitsAfter = numberPart.length - separatorIndex - 1;

    if (digitsAfter !== 3) {
      return self._parseSingleSeparatorSingleOccurrence(
        numberPart,
        separator,
        digitsBefore,
        digitsAfter,
        originalString,
      );
    }

    if (digitsBefore === 0) {
      return self._parseStartsWithSeparator(numberPart, separator, originalString);
    }

    if (digitsBefore >= 4) {
      return self._parseUnambiguousPriorDigits(numberPart, separator, originalString);
    }

    if (numberPart.startsWith("0" + separator)) {
      return self._parseWithLeadingZero(numberPart, separator, originalString);
    }

    return self._interpretAmbiguous(numberPart, separator, originalString);
  }

  /**
   * Parse single separator with multiple occurrences.
   *
   * @param {string} numberPart - Number part to parse
   * @param {string} separator - The separator character (',' or '.')
   * @param {string} originalString - Original string for error messages
   * @returns {NumberParseResult} Parsed number result
   * @private
   */
  _parseSingleSeparatorMultipleOccurrences(numberPart, separator, originalString) {
    const self = this;
    if (separator === ".") {
      const ukSuggestion = numberPart.replace(/\./g, ",");
      return NumberParseResult.error(
        self._generateEuropeanFormatError(originalString, ukSuggestion),
      );
    } else {
      const cleaned = numberPart.replace(new RegExp(`\\${separator}`, "g"), "");
      const result = parseFloat(cleaned);
      if (isNaN(result)) {
        return NumberParseResult.error(`Invalid number format: '${originalString}'`);
      }
      return NumberParseResult.success(result);
    }
  }

  /**
   * Parse single separator with single occurrence and ambiguous digit count.
   *
   * @param {string} numberPart - Number part to parse
   * @param {string} separator - The separator character (',' or '.')
   * @param {number} digitsBefore - Number of digits before separator
   * @param {number} digitsAfter - Number of digits after separator
   * @param {string} originalString - Original string for error messages
   * @returns {NumberParseResult} Parsed number result
   * @private
   */
  _parseSingleSeparatorSingleOccurrence(
    numberPart,
    separator,
    digitsBefore,
    digitsAfter,
    originalString,
  ) {
    const self = this;
    if (separator === ",") {
      if (digitsAfter <= 2 && digitsBefore > 0) {
        const ukSuggestion = numberPart.replace(",", ".");
        return NumberParseResult.error(
          self._generateEuropeanFormatError(originalString, ukSuggestion),
        );
      } else {
        const result = parseFloat(numberPart.replace(/,/g, ""));
        if (isNaN(result)) {
          return NumberParseResult.error(`Invalid number format: '${originalString}'`);
        }
        return NumberParseResult.success(result);
      }
    } else {
      const result = parseFloat(numberPart);
      if (isNaN(result)) {
        return NumberParseResult.error(`Invalid number format: '${originalString}'`);
      }
      return NumberParseResult.success(result);
    }
  }

  /**
   * Parse number that starts with separator.
   *
   * @param {string} numberPart - Number part to parse
   * @param {string} separator - The separator character (',' or '.')
   * @param {string} originalString - Original string for error messages
   * @returns {NumberParseResult} Parsed number result
   * @private
   */
  _parseStartsWithSeparator(numberPart, separator, originalString) {
    const self = this;
    if (separator === ",") {
      const ukSuggestion = numberPart.replace(",", ".");
      return NumberParseResult.error(
        self._generateEuropeanFormatError(originalString, ukSuggestion),
      );
    } else {
      const result = parseFloat(numberPart);
      if (isNaN(result)) {
        return NumberParseResult.error(`Invalid number format: '${originalString}'`);
      }
      return NumberParseResult.success(result);
    }
  }

  /**
   * Parse number with 4 or more digits before separator (unambiguous decimal).
   *
   * @param {string} numberPart - Number part to parse
   * @param {string} separator - The separator character (',' or '.')
   * @param {string} originalString - Original string for error messages
   * @returns {NumberParseResult} Parsed number result
   * @private
   */
  _parseUnambiguousPriorDigits(numberPart, separator, originalString) {
    const self = this;
    if (separator === ",") {
      const ukSuggestion = numberPart.replace(",", ".");
      return NumberParseResult.error(
        self._generateEuropeanFormatError(originalString, ukSuggestion),
      );
    } else {
      const result = parseFloat(numberPart);
      if (isNaN(result)) {
        return NumberParseResult.error(`Invalid number format: '${originalString}'`);
      }
      return NumberParseResult.success(result);
    }
  }

  /**
   * Parse number starting with zero and separator (clearly a decimal).
   *
   * @param {string} numberPart - Number part to parse
   * @param {string} separator - The separator character (',' or '.')
   * @param {string} originalString - Original string for error messages
   * @returns {NumberParseResult} Parsed number result
   * @private
   */
  _parseWithLeadingZero(numberPart, separator, originalString) {
    const self = this;
    if (separator === ",") {
      const ukSuggestion = numberPart.replace(",", ".");
      return NumberParseResult.error(
        self._generateEuropeanFormatError(originalString, ukSuggestion),
      );
    } else {
      const result = parseFloat(numberPart);
      if (isNaN(result)) {
        return NumberParseResult.error(`Invalid number format: '${originalString}'`);
      }
      return NumberParseResult.success(result);
    }
  }

  /**
   * Interpret ambiguous format with 3 digits after separator (like 123,456).
   *
   * @param {string} numberPart - Number part to parse
   * @param {string} separator - The separator character (',' or '.')
   * @param {string} originalString - Original string for error messages
   * @returns {NumberParseResult} Parsed number result
   * @private
   */
  _interpretAmbiguous(numberPart, separator, originalString) {
    const self = this;
    if (separator === ",") {
      const result = parseFloat(numberPart.replace(/,/g, ""));
      if (isNaN(result)) {
        return NumberParseResult.error(`Invalid number format: '${originalString}'`);
      }
      return NumberParseResult.success(result);
    } else {
      const result = parseFloat(numberPart);
      if (isNaN(result)) {
        return NumberParseResult.error(`Invalid number format: '${originalString}'`);
      }
      return NumberParseResult.success(result);
    }
  }

  /**
   * Check if a separator pattern looks like a thousands separator.
   *
   * @param {string} numberStr - The number string
   * @param {number} separatorIndex - Index of the separator
   * @returns {boolean} True if it looks like a thousands separator
   * @private
   */
  _isLikelyThousandsSeparator(numberStr, separatorIndex) {
    const self = this;
    const beforeSeparator = numberStr.substring(0, separatorIndex);

    // Use length-based check as suggested in feedback, but adjusted to handle common cases
    return beforeSeparator.length >= 1;
  }

  /**
   * Count occurrences of a character in a string.
   *
   * @param {string} str - The string to search
   * @param {string} char - The character to count
   * @returns {number} Number of occurrences
   * @private
   */
  _countOccurrences(str, char) {
    const self = this;
    return str.length - str.replaceAll(char, "").length;
  }

  /**
   * Convert European format number to UK format suggestion.
   *
   * Handle different European patterns:
   * - "1.234,56" to "1,234.56"
   * - "123,45" to "123.45"
   * - "1.234.567,89" to "1,234,567.89"
   *
   * @param {string} numberPart - European format number part
   * @returns {string} UK format equivalent
   * @private
   */
  _convertEuropeanToUkFormat(numberPart) {
    const lastCommaIndex = numberPart.lastIndexOf(",");
    const lastPeriodIndex = numberPart.lastIndexOf(".");

    const europeanMixed = lastCommaIndex > lastPeriodIndex && lastCommaIndex !== -1;
    const singleComma = lastCommaIndex !== -1 && lastPeriodIndex === -1;

    if (europeanMixed) {
      return numberPart.replace(/\./g, ",").replace(/,([^,]*)$/, ".$1");
    } else if (singleComma) {
      return numberPart.replace(",", ".");
    }

    return numberPart;
  }

  /**
   * Generate standardized error message for European format detection.
   *
   * @param {string} originalInput - The original user input
   * @param {string} ukSuggestion - The UK format suggestion
   * @returns {string} Formatted error message
   * @private
   */
  _generateEuropeanFormatError(originalInput, ukSuggestion) {
    return `Unsupported number format: '${originalInput}'. ` +
           `Please use: '${ukSuggestion}'. ` +
           "Kigali Sim requires comma for thousands separator and period for decimal point.";
  }
}

export {NumberParseUtil, NumberParseResult};