Source: ui_editor_util.js

/**
 * Utility functions for UI-based authoring experience.
 *
 * @license BSD
 */
import {EngineNumber} from "engine_number";
import {NumberParseUtil} from "number_parse_util";
import {STREAM_TARGET_SELECTORS, VALID_YEAR_KEYWORDS} from "ui_editor_const";

/**
 * Invalid patterns for numeric input validation.
 * @constant {Array<RegExp>}
 */
const NUMERIC_INPUT_INVALID_PATTERNS = [
  /[a-zA-Z\s]/, // Alphabetical characters or spaces
  /[^\d\s\-+.,]/, // Non-numeric symbols except digits, spaces, signs, periods, and commas
];

/**
 * Determines if an input element is a duration field.
 *
 * @param {HTMLElement} input - The input element to check.
 * @returns {boolean} True if the input is a duration field.
 */
function getIsDurationField(input) {
  if (input.classList.contains("duration-start")) {
    return true;
  } else if (input.classList.contains("duration-end")) {
    return true;
  } else {
    return false;
  }
}

/**
 * Gets suggestion and description for numeric input validation.
 *
 * @param {string} fieldDescription - The field description from aria-label.
 * @param {string} value - The input value being validated.
 * @param {boolean} isAmbiguous - Whether the value has an ambiguous format.
 * @param {boolean} isParseError - Whether the value failed to parse.
 * @param {Object} parseResult - The parse result object (if parse error).
 * @param {NumberParseUtil} numberParser - The number parser utility.
 * @returns {Object} Object with suggestion and description fields.
 */
function getNumericInputSuggestionAndDescription(
  fieldDescription,
  value,
  isAmbiguous,
  isParseError,
  parseResult,
  numberParser,
) {
  let description = fieldDescription;
  let suggestion = "";

  if (isAmbiguous) {
    description = `${fieldDescription} (ambiguous number format)`;
    suggestion = numberParser.getDisambiguationSuggestion(value);
  } else if (isParseError) {
    const errorMessage = parseResult.getError();
    // Extract suggestion from error message if it contains "Please use:"
    if (errorMessage.includes("Please use:")) {
      const match = errorMessage.match(/Please use: '([^']+)'/);
      if (match) {
        suggestion = `Use '${match[1]}' instead (comma for thousands, period for decimal)`;
      }
    }
    description = `${fieldDescription} (unsupported number format)`;
  }

  return {suggestion, description};
}

/**
 * Updates the visibility of selector elements based on selected duration type.
 *
 * @param {HTMLElement} dateSelector - The date selector element to update.
 */
function updateDurationSelector(dateSelector) {
  const makeVisibilityCallback = (showStart, showEnd) => {
    return () => {
      const startElement = dateSelector.querySelector(".duration-start");
      startElement.style.display = showStart ? "inline-block" : "none";

      const endElement = dateSelector.querySelector(".duration-end");
      endElement.style.display = showEnd ? "inline-block" : "none";

      const toElement = dateSelector.querySelector(".duration-to");
      const showTo = showStart && showEnd;
      toElement.style.display = showTo ? "inline-block" : "none";
    };
  };

  const strategies = {
    "in year": makeVisibilityCallback(true, false),
    "during all years": makeVisibilityCallback(false, false),
    "starting in year": makeVisibilityCallback(true, false),
    "ending in year": makeVisibilityCallback(false, true),
    "during years": makeVisibilityCallback(true, true),
  };

  const refreshVisibility = (dateSelector) => {
    const currentValue = dateSelector.querySelector(".duration-type-input").value;
    const strategy = strategies[currentValue];
    strategy();
  };

  refreshVisibility(dateSelector);
}

/**
 * Initializes a duration selector.
 *
 * @param {HTMLElement} newDiv - The new element to set up duration selector for.
 */
function setupDurationSelector(newDiv) {
  const dateSelectors = Array.of(...newDiv.querySelectorAll(".duration-subcomponent"));
  dateSelectors.forEach((dateSelector) => {
    dateSelector.addEventListener("change", (event) => {
      updateDurationSelector(dateSelector);
    });

    updateDurationSelector(dateSelector);
  });
}

/**
 * Build a function which sets up a list button with add/delete functionality.
 *
 * @param {Function} postCallback - Function to call after each list item UI is
 *     initialized or removed. If not given, will use a no-op.
 */
function buildSetupListButton(postCallback) {
  /**
   * Function which exeuctes on adding a new lits element.
   *
   * @param {HTMLElement} button - Button element to set up.
   * @param {HTMLElement} targetList - List element to add items to.
   * @param {string} templateId - ID of template to use for new items.
   * @param {Function} initUiCallback - Callback to invoke when item added.
   * @param {string} context - Context for stream detection ('consumption' or 'policy').
   */
  return (button, targetList, templateId, initUiCallback, context) => {
    button.addEventListener("click", (event) => {
      event.preventDefault();

      const newDiv = document.createElement("div");
      newDiv.innerHTML = document.getElementById(templateId).innerHTML;
      newDiv.classList.add("dialog-list-item");
      targetList.appendChild(newDiv);

      const deleteLink = newDiv.querySelector(".delete-command-link");
      deleteLink.addEventListener("click", (event) => {
        event.preventDefault();
        newDiv.remove();
        postCallback();
      });

      initUiCallback(null, newDiv, context);

      setupDurationSelector(newDiv);

      if (postCallback !== undefined) {
        postCallback();
      }
    });
  };
}

/**
 * Sets a form field value with fallback to default.
 *
 * @param {HTMLElement} selection - Form field element.
 * @param {Object} source - Source object to get value from.
 * @param {*} defaultValue - Default value if source is null.
 * @param {Function} strategy - Function to extract value from source.
 */
function setFieldValue(selection, source, defaultValue, strategy) {
  const newValue = source === null ? null : strategy(source);
  const valueOrDefault = newValue === null ? defaultValue : newValue;
  selection.value = valueOrDefault;
}

/**
 * Gets raw value from a form field.
 *
 * @param {HTMLElement} selection - Form field to get value from.
 * @returns {string} The field's value.
 */
function getFieldValue(selection) {
  return selection.value;
}

/**
 * Gets sanitized value from a form field, removing quotes and commas.
 *
 * @param {HTMLElement} selection - Form field to get sanitized value from.
 * @returns {string} Sanitized field value.
 */
function getSanitizedFieldValue(selection) {
  const valueRaw = getFieldValue(selection);
  const clean = valueRaw.replaceAll('"', "").replaceAll(",", "");
  const trimmed = clean.trim();
  const guarded = trimmed === "" ? "Unnamed" : trimmed;
  return guarded;
}

/**
 * Sets up a list input with template-based items.
 *
 * @param {HTMLElement} listSelection - Container element for list.
 * @param {string} itemTemplate - HTML template for list items.
 * @param {Array} items - Array of items to populate list.
 * @param {Function} uiInit - Callback to initialize each item's UI.
 * @param {Function} removeCallback - Callback to invoke if item removed.
 */
function setListInput(listSelection, itemTemplate, items, uiInit, removeCallback) {
  listSelection.innerHTML = "";
  const addItem = (item) => {
    const newDiv = document.createElement("div");
    newDiv.innerHTML = itemTemplate;
    newDiv.classList.add("dialog-list-item");
    listSelection.appendChild(newDiv);
    uiInit(item, newDiv);

    const deleteLink = newDiv.querySelector(".delete-command-link");
    deleteLink.addEventListener("click", (event) => {
      event.preventDefault();
      newDiv.remove();
      removeCallback(item);
    });
  };
  items.forEach(addItem);
}

/**
 * Read the current items in a list.
 *
 * @param {HTMLElement} selection - The HTML element containing list items.
 * @param {Function} itemReadStrategy - A function to process each list item.
 * @returns {Array} An array of processed items returned by the strategy.
 */
function getListInput(selection, itemReadStrategy) {
  const dialogListItems = Array.of(...selection.querySelectorAll(".dialog-list-item"));
  return dialogListItems.map(itemReadStrategy);
}

/**
 * Sets a value/units pair for engine number inputs.
 *
 * @param {HTMLElement} valSelection - Value input element.
 * @param {HTMLElement} unitsSelection - Units select element.
 * @param {Object} source - Source object for values.
 * @param {EngineNumber} defaultValue - Default engine number.
 * @param {Function} strategy - Function to extract engine number from source.
 */
function setEngineNumberValue(valSelection, unitsSelection, source, defaultValue, strategy) {
  const newValue = source === null ? null : strategy(source);
  const valueOrDefault = newValue === null ? defaultValue : newValue;

  // Use original string for value field if available, otherwise use numeric value
  if (valueOrDefault.hasOriginalString()) {
    valSelection.value = valueOrDefault.getOriginalString();
  } else {
    const numericValue = valueOrDefault.getValue();
    const isStr = typeof numericValue === "string" || numericValue instanceof String;
    const isNan = isNaN(numericValue);
    const useBlank = !isStr && isNan;
    valSelection.value = useBlank ? "" : numericValue;
  }
  unitsSelection.value = valueOrDefault.getUnits();
}

/**
 * Gets an engine number from value/units form fields.
 *
 * @param {HTMLElement} valSelection - Value input element.
 * @param {HTMLElement} unitsSelection - Units select element.
 * @returns {EngineNumber} Combined engine number object.
 */
function getEngineNumberValue(valSelection, unitsSelection) {
  const valueString = valSelection.value;
  const units = unitsSelection.value;

  // Parse the value to get the numeric value, but preserve original string formatting
  const numericValue = parseFloat(valueString);

  // If parsing results in NaN, default to 0 but preserve original string if not empty
  const finalValue = isNaN(numericValue) ? 0 : numericValue;
  const originalString = valueString.trim() === "" ? null : valueString.trim();

  return new EngineNumber(finalValue, units, originalString);
}

/**
 * Inverts the sign of a number string while preserving formatting.
 *
 * @param {string} numberString - The number string to invert
 * @returns {string} The number string with inverted sign
 */
function invertNumberString(numberString) {
  const trimmed = numberString.trim();
  if (trimmed.startsWith("-")) {
    // Remove the minus sign (or replace with plus for explicit positive)
    return "+" + trimmed.substring(1);
  } else if (trimmed.startsWith("+")) {
    // Replace plus with minus
    return "-" + trimmed.substring(1);
  } else {
    // No sign present, add minus
    return "-" + trimmed;
  }
}

/**
 * Validates numeric inputs within a dialog and prompts user for potentially
 * invalid values.
 *
 * @param {HTMLElement} dialog - The dialog element containing numeric inputs
 * @param {string} dialogType - Type of dialog for error message context
 *     ("substance", "policy", "simulation")
 * @returns {boolean} True if user confirms to proceed, false if user cancels
 */
function validateNumericInputs(dialog, dialogType) {
  const numericInputs = dialog.querySelectorAll(".numeric-input");
  const potentiallyInvalid = [];
  const numberParser = new NumberParseUtil();

  // Check each numeric input
  numericInputs.forEach((input) => {
    const value = input.value.trim();
    if (value === "") {
      return; // Skip empty values (may be optional)
    }

    // Allow valid QubecTalk year keywords for duration fields
    const isDurationField = getIsDurationField(input);
    const isValidYearKeyword = isDurationField && VALID_YEAR_KEYWORDS.includes(value.toLowerCase());

    if (isValidYearKeyword) {
      return; // Skip validation for valid year keywords
    }

    // Check against invalid patterns
    const isLikelyInvalid = NUMERIC_INPUT_INVALID_PATTERNS.some((pattern) => pattern.test(value));

    // Check for ambiguous number formats
    const isAmbiguous = numberParser.isAmbiguous(value);

    // Check if the number fails to parse (e.g., European format)
    const parseResult = numberParser.parseFlexibleNumber(value);
    const isParseError = !parseResult.isSuccess();

    if (isLikelyInvalid || isAmbiguous || isParseError) {
      // Get field description from aria-label
      const fieldDescription = input.getAttribute("aria-label") || "Unknown field";

      const {suggestion, description} = getNumericInputSuggestionAndDescription(
        fieldDescription,
        value,
        isAmbiguous,
        isParseError,
        parseResult,
        numberParser,
      );

      potentiallyInvalid.push({
        element: input,
        value: value,
        description: description,
        suggestion: suggestion,
      });
    }
  });

  // If no potentially invalid values found, proceed
  if (potentiallyInvalid.length === 0) {
    return true;
  }

  // Build user-friendly error message
  const fieldList = potentiallyInvalid.map((item) => {
    if (item.suggestion) {
      return `• ${item.description}: "${item.value}"\n  Suggestion: ${item.suggestion}`;
    } else {
      return `• ${item.description}: "${item.value}"`;
    }
  }).join("\n\n");

  const message = "The following numeric fields contain potentially " +
    `invalid values:\n\n${fieldList}\n\n` +
    "These values may cause simulation errors. You can:\n" +
    "• Click \"Continue\" if these are intentional (equations, etc.)\n" +
    "• Click \"Cancel\" to review and correct the values";

  // Prompt user for confirmation
  return confirm(message);
}

/**
 * Validates simulation duration to warn about very long simulations.
 *
 * @param {HTMLElement} dialog - Dialog element containing start and end inputs.
 * @returns {boolean} True if user confirms or duration is reasonable, false if cancelled.
 * @private
 */
function validateSimulationDuration(dialog) {
  const startInput = dialog.querySelector(".edit-simulation-start-input");
  const endInput = dialog.querySelector(".edit-simulation-end-input");

  if (!startInput || !endInput) {
    return true; // If inputs not found, proceed
  }

  const startValue = startInput.value.trim();
  const endValue = endInput.value.trim();

  // Only check if both values are simple integers (no equations, etc.)
  const isSimpleInteger = (value) => /^\d+$/.test(value);

  if (!isSimpleInteger(startValue) || !isSimpleInteger(endValue)) {
    return true; // Skip validation for non-simple integers
  }

  const startYear = parseInt(startValue, 10);
  const endYear = parseInt(endValue, 10);
  const duration = endYear - startYear;

  if (duration > 1000) {
    const message = `This simulation spans ${duration} years (${startYear} to ${endYear}), ` +
      "which is over 1000 years.\n\n" +
      "Do you want to continue with this duration?";

    return confirm(message);
  }

  return true;
}

/**
 * Sets the state of a duration selection UI widget.
 *
 * Set the duration for shown within a duration selection UI widget to match
 * that of a given command. If the command is null, it uses the default value.
 *
 * @param {HTMLElement} selection - The selection element containing duration-related inputs.
 * @param {Object} command - The command object from which the duration is extracted.
 * @param {YearMatcher} defaultVal - The default duration value if the command is null.
 * @param {boolean} initListeners - Flag indicating if new event listeners for
 *     element visibility should be added in response to changing duration type.
 */
function setDuring(selection, command, defaultVal, initListeners) {
  const effectiveVal = command === null ? defaultVal : command.getDuration();
  const durationTypeInput = selection.querySelector(".duration-type-input");

  const setElements = () => {
    if (effectiveVal === null) {
      durationTypeInput.value = "during all years";
      return;
    }

    const durationStartInput = selection.querySelector(".duration-start");
    const durationEndInput = selection.querySelector(".duration-end");
    const durationStart = effectiveVal.getStart();
    const noStart = durationStart === null;
    const durationEnd = effectiveVal.getEnd();
    const noEnd = durationEnd === null;

    // Helper function to safely set year values, preserving original user input
    const setYearValue = (input, yearValue) => {
      if (yearValue === null || yearValue === undefined) {
        input.value = "";
      } else {
        // Use ParsedYear's getYearStr() method for proper display
        input.value = yearValue.getYearStr();
      }
    };

    if (noStart && noEnd) {
      durationTypeInput.value = "during all years";
    } else if (noStart) {
      durationTypeInput.value = "ending in year";
      setYearValue(durationEndInput, durationEnd);
    } else if (noEnd) {
      durationTypeInput.value = "starting in year";
      setYearValue(durationStartInput, durationStart);
    } else if (durationStart && durationEnd && durationStart.equals(durationEnd)) {
      durationTypeInput.value = "in year";
      setYearValue(durationStartInput, durationStart);
    } else {
      durationTypeInput.value = "during years";
      setYearValue(durationStartInput, durationStart);
      setYearValue(durationEndInput, durationEnd);
    }
  };

  setElements();
  updateDurationSelector(selection);

  if (initListeners) {
    durationTypeInput.addEventListener("change", (event) => {
      updateDurationSelector(selection);
    });
  }
}

/**
 * Build a function which updates displays of command counts.
 *
 * @param dialog - Selection over the dialog in which the command count
 *     displays should be updated.
 * @returns Funciton which takes a string list selector and a string display
 *     selector. That function will put the count of commands found in the
 *     list selector into the display selector.
 */
function buildUpdateCount(dialog) {
  return (listSelector, displaySelector) => {
    const listSelection = dialog.querySelector(listSelector);
    const displaySelection = dialog.querySelector(displaySelector);

    const listItems = Array.of(...listSelection.querySelectorAll(".dialog-list-item"));
    const listCount = listItems.map((x) => 1).reduce((a, b) => a + b, 0);

    displaySelection.innerHTML = listCount;
  };
}

/**
 * Sets up event listeners for internal dialog links.
 *
 * This function adds click event listeners to dialog internal links that, when
 * clicked, will toggle the corresponding tab based on the link's href.
 *
 * @param {HTMLElement} root - The root element containing dialog.
 * @param {Object} tabs - Tabby object for managing tab toggling.
 */
function setupDialogInternalLinks(root, tabs) {
  const internalLinks = root.querySelectorAll(".dialog-internal-link");
  internalLinks.forEach((link) => {
    link.addEventListener("click", (event) => {
      event.preventDefault();
      const anchor = link.hash;
      tabs.toggle(anchor);
    });
  });
}

export {
  buildSetupListButton,
  buildUpdateCount,
  getEngineNumberValue,
  getFieldValue,
  getListInput,
  getSanitizedFieldValue,
  invertNumberString,
  setDuring,
  setEngineNumberValue,
  setFieldValue,
  setListInput,
  setupDialogInternalLinks,
  setupDurationSelector,
  updateDurationSelector,
  validateNumericInputs,
  validateSimulationDuration,
};