Source: core/propertyUtils/equationConversion.js

/*
 * Copyright 2026 The Ray Optics Simulation authors and contributors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import * as math from 'mathjs';

const DOUBLE_BACKTICK_RE = /^``([\s\S]*)``$/;

/** LaTeX math-mode command (no leading \) → math.js symbol name */
const GREEK_SYMBOL = {
  // lowercase
  alpha: 'alpha',
  beta: 'beta',
  gamma: 'gamma',
  delta: 'delta',
  epsilon: 'epsilon',
  varepsilon: 'varepsilon',
  zeta: 'zeta',
  eta: 'eta',
  theta: 'theta',
  vartheta: 'vartheta',
  iota: 'iota',
  kappa: 'kappa',
  lambda: 'lambda',
  mu: 'mu',
  nu: 'nu',
  xi: 'xi',
  pi: 'pi',
  varpi: 'varpi',
  rho: 'rho',
  varrho: 'varrho',
  sigma: 'sigma',
  varsigma: 'varsigma',
  tau: 'tau',
  upsilon: 'upsilon',
  phi: 'phi',
  varphi: 'varphi',
  chi: 'chi',
  psi: 'psi',
  omega: 'omega',
  // uppercase (\Pi etc. — product symbol not distinguished here)
  Gamma: 'Gamma',
  Delta: 'Delta',
  Theta: 'Theta',
  Lambda: 'Lambda',
  Xi: 'Xi',
  Pi: 'Pi',
  Sigma: 'Sigma',
  Upsilon: 'Upsilon',
  Phi: 'Phi',
  Psi: 'Psi',
  Omega: 'Omega'
};

/** One-argument functions: LaTeX command → math.js name */
const FN1 = {
  sin: 'sin',
  cos: 'cos',
  tan: 'tan',
  sec: 'sec',
  csc: 'csc',
  cot: 'cot',
  sinh: 'sinh',
  cosh: 'cosh',
  tanh: 'tanh',
  arcsin: 'asin',
  arccos: 'acos',
  arctan: 'atan',
  arcsinh: 'asinh',
  arccosh: 'acosh',
  arctanh: 'atanh',
  asinh: 'asinh',
  acosh: 'acosh',
  atanh: 'atanh',
  ln: 'log',
  log: 'log10',
  exp: 'exp',
  sqrt: 'sqrt',
  floor: 'floor',
  ceil: 'ceil',
  round: 'round',
  sign: 'sign',
  sgn: 'sign',
  trunc: 'trunc'
};

const FN2 = new Set(['max', 'min']);

/**
 * Split a TeX fragment on commas that separate arguments at the current fence level:
 * `{…}` / `()` / `\left…\right` nesting increments/decrements; commas only split when all depths are 0.
 * @param {string} inner
 * @returns {string[]}
 */
function splitTexCommaArgs(inner) {
  const parts = [];
  let segStart = 0;
  let i = 0;
  let brace = 0;
  let rawParen = 0;
  let lr = 0;

  const skipSpaces = (j) => {
    let k = j;
    while (k < inner.length && /\s/.test(inner[k])) k++;
    return k;
  };

  while (i < inner.length) {
    const ch = inner[i];
    if (ch === '{') {
      brace++;
      i++;
      continue;
    }
    if (ch === '}') {
      brace--;
      i++;
      continue;
    }
    if (ch === '(') {
      rawParen++;
      i++;
      continue;
    }
    if (ch === ')') {
      rawParen--;
      i++;
      continue;
    }
    if (inner.startsWith('\\left', i)) {
      lr++;
      i += 5;
      i = skipSpaces(i);
      if (i < inner.length) i++;
      continue;
    }
    if (inner.startsWith('\\right', i)) {
      lr--;
      i += 6;
      i = skipSpaces(i);
      if (i < inner.length) i++;
      continue;
    }
    if (ch === ',' && brace === 0 && rawParen === 0 && lr === 0) {
      parts.push(inner.slice(segStart, i).trim());
      segStart = i + 1;
    }
    i++;
  }
  parts.push(inner.slice(segStart).trim());
  return parts;
}

class TexExprParser {
  /**
   * @param {string} s
   * @param {number} [start]
   * @param {number} [end]
   */
  constructor(s, start = 0, end = s.length) {
    this.s = s;
    this.i = start;
    this.end = end;
  }

  throwUnexpected() {
    throw new Error('Invalid or unsupported LaTeX in equation');
  }

  skipSpace() {
    while (this.i < this.end && /\s/.test(this.s[this.i])) {
      this.i++;
    }
  }

  atEnd() {
    this.skipSpace();
    return this.i >= this.end;
  }

  peek() {
    this.skipSpace();
    return this.i < this.end ? this.s[this.i] : '';
  }

  consumeExpected(c) {
    this.skipSpace();
    if (this.i >= this.end || this.s[this.i] !== c) this.throwUnexpected();
    this.i++;
  }

  startsWithWord(word) {
    return (
      this.s.startsWith(word, this.i) &&
      (this.i + word.length >= this.end || !/[a-zA-Z]/.test(this.s[this.i + word.length]))
    );
  }

  readBraceGroupRaw() {
    this.skipSpace();
    this.consumeExpected('{');
    let depth = 1;
    const outStart = this.i;
    while (this.i < this.end && depth > 0) {
      const ch = this.s[this.i];
      if (ch === '{') depth++;
      else if (ch === '}') depth--;
      this.i++;
    }
    if (depth !== 0) this.throwUnexpected();
    return this.s.slice(outStart, this.i - 1);
  }

  /** Parse one delimiter character after \left / \right (e.g. ( ) [ ] | .) */
  readOneDelim() {
    this.skipSpace();
    if (this.i >= this.end) this.throwUnexpected();
    return this.s[this.i++];
  }

  /**
   * After the `\\left` keyword has been consumed (`\\` + name), read the opening delimiter
   * and the body up to the matching `\\right`.
   * @returns {{ inner: string, open: string }}
   */
  readLeftRightBodyAfterLeftKeyword() {
    const open = this.readOneDelim();
    const innerStart = this.i;
    let depth = 1;
    while (depth > 0 && this.i < this.end) {
      if (this.startsWithWord('\\left')) {
        depth++;
        this.i += 5;
        this.readOneDelim();
        continue;
      }
      if (this.startsWithWord('\\right')) {
        depth--;
        if (depth === 0) {
          const inner = this.s.slice(innerStart, this.i).trim();
          this.i += 6;
          this.readOneDelim();
          return { inner, open };
        }
        this.i += 6;
        this.readOneDelim();
        continue;
      }
      this.i++;
    }
    this.throwUnexpected();
  }

  /** @returns {string} math.js subexpression */
  parseTop() {
    return this.parseAddSub();
  }

  parseAddSub() {
    let left = this.parseMulDiv();
    while (true) {
      this.skipSpace();
      if (this.i >= this.end) break;
      const op = this.s[this.i];
      if (op !== '+' && op !== '-') break;
      this.i++;
      const right = this.parseMulDiv();
      left = `${left} ${op} ${right}`;
    }
    return left;
  }

  parseMulDiv() {
    let left = this.parsePow();
    while (true) {
      this.skipSpace();
      if (this.i < this.end && this.startsWithWord('\\cdot')) {
        this.i += 5;
        left = `${left} * ${this.parsePow()}`;
        continue;
      }
      if (this.i < this.end && this.startsWithWord('\\times')) {
        this.i += 6;
        left = `${left} * ${this.parsePow()}`;
        continue;
      }
      if (this.i < this.end && this.s[this.i] === '*') {
        this.i++;
        left = `${left} * ${this.parsePow()}`;
        continue;
      }
      if (this.i < this.end && this.s[this.i] === '/') {
        this.i++;
        left = `${left} / ${this.parsePow()}`;
        continue;
      }
      if (this.implicitMultFollows()) {
        left = `${left} * ${this.parsePow()}`;
        continue;
      }
      break;
    }
    return left;
  }

  implicitMultFollows() {
    if (this.i >= this.end) return false;
    const c = this.s[this.i];
    if (c === '+' || c === '-' || c === ')' || c === '}' || c === ',' || c === '^' || c === '_') {
      return false;
    }
    if (c === '\\') {
      if (this.startsWithWord('\\cdot') || this.startsWithWord('\\times')) return false;
      return true;
    }
    return /[0-9(.]/.test(c) || /[a-zA-Z]/.test(c);
  }

  parsePow() {
    let left = this.parseUnary();
    this.skipSpace();
    if (this.i < this.end && this.s[this.i] === '^') {
      this.i++;
      let exp;
      this.skipSpace();
      if (this.peek() === '{') {
        const inner = this.readBraceGroupRaw();
        exp = new TexExprParser(inner, 0, inner.length).parseTop();
      } else if (this.i < this.end) {
        exp = this.parseUnary();
      } else this.throwUnexpected();
      left = `${left} ^ ${exp}`;
    }
    return left;
  }

  parseUnary() {
    this.skipSpace();
    let neg = false;
    while (this.i < this.end && (this.s[this.i] === '+' || this.s[this.i] === '-')) {
      if (this.s[this.i] === '-') neg = !neg;
      this.i++;
      this.skipSpace();
    }
    const v = this.parsePostfixScripts();
    return neg ? `- ${v}` : v;
  }

  parsePostfixScripts() {
    let v = this.parsePrimary();
    while (true) {
      this.skipSpace();
      if (this.i < this.end && this.s[this.i] === '_') {
        this.i++;
        this.skipSpace();
        let sub;
        if (this.peek() === '{') {
          const raw = this.readBraceGroupRaw();
          sub = new TexExprParser(raw, 0, raw.length).parseTop();
        } else if (this.i < this.end && /[a-zA-Z0-9]/.test(this.s[this.i])) {
          sub = this.s[this.i++];
        } else this.throwUnexpected();
        v = `${v}_${sub}`;
        continue;
      }
      break;
    }
    return v;
  }

  parsePrimary() {
    this.skipSpace();
    if (this.i >= this.end) this.throwUnexpected();

    if (this.s[this.i] === '(') {
      this.i++;
      const inner = this.parseTop();
      this.consumeExpected(')');
      return `(${inner})`;
    }

    if (this.s[this.i] === '{') {
      const raw = this.readBraceGroupRaw();
      return new TexExprParser(raw, 0, raw.length).parseTop();
    }

    if (/[0-9.]/.test(this.s[this.i])) return this.readNumber();

    if (/[a-zA-Z]/.test(this.s[this.i])) return this.readAsciiIdentifier();

    if (this.s[this.i] === '\\') return this.parseBackslash();

    this.throwUnexpected();
  }

  readNumber() {
    const start = this.i;
    while (this.i < this.end && /[0-9.]/.test(this.s[this.i])) this.i++;
    return this.s.slice(start, this.i);
  }

  readAsciiIdentifier() {
    const start = this.i;
    while (this.i < this.end && /[a-zA-Z0-9]/.test(this.s[this.i])) this.i++;
    return this.s.slice(start, this.i);
  }

  readCommandName() {
    if (this.s[this.i] !== '\\') this.throwUnexpected();
    this.i++;
    const start = this.i;
    while (this.i < this.end && /[a-zA-Z]/.test(this.s[this.i])) this.i++;
    if (start === this.i) this.throwUnexpected();
    return this.s.slice(start, this.i);
  }

  parseBackslash() {
    const cmd = this.readCommandName();

    if (cmd === 'left') {
      const { inner, open } = this.readLeftRightBodyAfterLeftKeyword();
      const p = new TexExprParser(inner, 0, inner.length);
      const body = p.parseTop();
      if (open === '|') return `abs(${body})`;
      return `(${body})`;
    }

    if (cmd === 'frac') {
      const a = this.readBraceGroupRaw();
      const b = this.readBraceGroupRaw();
      const ea = new TexExprParser(a, 0, a.length).parseTop();
      const eb = new TexExprParser(b, 0, b.length).parseTop();
      return `(${ea}) / (${eb})`;
    }

    if (cmd === 'sqrt') {
      this.skipSpace();
      if (this.peek() === '[') {
        this.i++;
        let idx = '';
        while (this.i < this.end && this.s[this.i] !== ']') idx += this.s[this.i++];
        this.consumeExpected(']');
        const raw = this.readBraceGroupRaw();
        const arg = new TexExprParser(raw, 0, raw.length).parseTop();
        return `nthRoot(${idx}, ${arg})`;
      }
      const raw = this.readBraceGroupRaw();
      const arg = new TexExprParser(raw, 0, raw.length).parseTop();
      return `sqrt(${arg})`;
    }

    if (cmd === 'cdot' || cmd === 'times') this.throwUnexpected();

    if (cmd === 'pi') return 'pi';

    if (cmd === 'operatorname') {
      const raw = this.readBraceGroupRaw().trim();
      if (FN1[raw]) {
        return this.parseFnCall(FN1[raw], raw);
      }
      if (FN2.has(raw)) {
        return this.parseFnCall(raw, raw);
      }
      this.throwUnexpected();
    }

    if (cmd === 'mathrm') {
      const raw = this.readBraceGroupRaw().trim();
      if (raw !== 'trunc') this.throwUnexpected();
      return this.parseFnCall('trunc', 'trunc');
    }

    if (Object.prototype.hasOwnProperty.call(GREEK_SYMBOL, cmd)) {
      return GREEK_SYMBOL[cmd];
    }

    if (FN2.has(cmd)) {
      return this.parseFnCall(cmd, cmd);
    }

    if (FN1[cmd]) {
      return this.parseFnCall(FN1[cmd], cmd);
    }

    this.throwUnexpected();
  }

  /**
   * Parse `\name` + arguments (brace or parens or \left...\right).
   * @param {string} jsFn
   * @param {string} texName
   */
  parseFnCall(jsFn, texName) {
    this.skipSpace();
    let innerList;
    if (this.peek() === '{') {
      const raw = this.readBraceGroupRaw();
      innerList = [raw.trim()];
    } else if (this.peek() === '(') {
      this.i++;
      const start = this.i;
      let depth = 1;
      while (this.i < this.end && depth > 0) {
        if (this.s[this.i] === '(') depth++;
        else if (this.s[this.i] === ')') depth--;
        this.i++;
      }
      if (depth !== 0) this.throwUnexpected();
      const inner = this.s.slice(start, this.i - 1);
      innerList = splitTexCommaArgs(inner);
    } else if (this.startsWithWord('\\left')) {
      this.i += 5;
      const { inner } = this.readLeftRightBodyAfterLeftKeyword();
      innerList = splitTexCommaArgs(inner);
    } else {
      const atom = this.parsePostfixScripts();
      innerList = [atom];
    }

    if (FN2.has(texName)) {
      if (innerList.length < 2) this.throwUnexpected();
      return `${jsFn}(${innerList.map((t) => new TexExprParser(t, 0, t.length).parseTop()).join(', ')})`;
    }
    if (innerList.length !== 1) this.throwUnexpected();
    const arg = new TexExprParser(innerList[0], 0, innerList[0].length).parseTop();
    return `${jsFn}(${arg})`;
  }
}

/**
 * Convert a LaTeX equation string to a math.js expression string.
 * @param {string} latex - A LaTeX equation string.
 * @returns {string} The equivalent math.js expression string.
 * @throws If the LaTeX cannot be parsed.
 */
export function latexToMathJS(latex) {
  const s = String(latex).trim();
  if (!s) {
    throw new Error('Invalid or unsupported LaTeX in equation');
  }
  const p = new TexExprParser(s, 0, s.length);
  const expr = p.parseTop();
  p.skipSpace();
  if (!p.atEnd()) {
    throw new Error('Invalid or unsupported LaTeX in equation');
  }
  return math.parse(expr).toString();
}

/**
 * Convert a math.js expression string to a LaTeX equation string.
 * Uses the same toTex handler as ModuleObj.expandEquation().
 * @param {string} mathJSStr - A math.js expression string.
 * @returns {string} The equivalent LaTeX equation string.
 * @throws If the expression cannot be parsed or converted.
 */
export function mathJSToLatex(mathJSStr) {
  const expr = math.parse(mathJSStr);
  return expr.toTex({
    handler: function (node, options) {
      if (node.type === 'SymbolNode') {
        const name = node.name;
        if (name.includes('_')) {
          const parts = name.split('_');
          const base = parts[0];
          const subscript = parts.slice(1).join('_');
          const formattedBase = base.length === 1 ? ' ' + base : '\\' + base;
          return formattedBase + '_{' + subscript + '}';
        }
        return name.length === 1 ? ' ' + name : '\\' + name;
      }

      if (node.type === 'FunctionNode') {
        const name = node.fn.name || node.fn;
        const args = node.args || [];
        switch (name) {
          case 'log':
            return '\\log\\left(' + args[0].toTex(options) + '\\right)';
          case 'asin':
            return '\\arcsin\\left(' + args[0].toTex(options) + '\\right)';
          case 'acos':
            return '\\arccos\\left(' + args[0].toTex(options) + '\\right)';
          case 'atan':
            return '\\arctan\\left(' + args[0].toTex(options) + '\\right)';
          case 'asinh':
            return '\\operatorname{asinh}\\left(' + args[0].toTex(options) + '\\right)';
          case 'acosh':
            return '\\operatorname{acosh}\\left(' + args[0].toTex(options) + '\\right)';
          case 'atanh':
            return '\\operatorname{atanh}\\left(' + args[0].toTex(options) + '\\right)';
          case 'floor':
            return '\\operatorname{floor}\\left(' + args[0].toTex(options) + '\\right)';
          case 'ceil':
            return '\\operatorname{ceil}\\left(' + args[0].toTex(options) + '\\right)';
          case 'round':
            return '\\operatorname{round}\\left(' + args[0].toTex(options) + '\\right)';
          case 'sign':
            return '\\operatorname{sign}\\left(' + args[0].toTex(options) + '\\right)';
        }
        return undefined;
      }

      return undefined;
    }
  });
}

/**
 * Check whether a stored equation value is supported by the visual editor
 * and extract the math.js display string.
 *
 * Supported formats:
 *  - A string without backticks: interpreted as LaTeX, converted to math.js.
 *  - A string that is a single double-backtick block (``expr``): the inner
 *    part is already math.js.
 *
 * @param {*} value - The stored equation value.
 * @returns {{ supported: true, display: string } | { supported: false }}
 */
export function equationValueToDisplay(value) {
  if (value === undefined || value === null) {
    return { supported: true, display: '' };
  }
  if (typeof value !== 'string') {
    return { supported: false };
  }
  if (!value.includes('`')) {
    try {
      return { supported: true, display: latexToMathJS(value) };
    } catch {
      return { supported: false };
    }
  }
  const m = value.match(DOUBLE_BACKTICK_RE);
  if (m) {
    return { supported: true, display: m[1] };
  }
  return { supported: false };
}

/**
 * Plain-text equation for object list descriptions (matches EquationPropertyControl /
 * equationValueToDisplay): LaTeX → math.js string; ``expr`` → inner expr; unsupported → raw stored value.
 * @param {*} value - The stored equation value.
 * @returns {string}
 */
export function equationValueForListDisplay(value) {
  const r = equationValueToDisplay(value);
  if (r.supported) {
    return r.display;
  }
  if (value === undefined || value === null) {
    return '';
  }
  return typeof value === 'string' ? value : String(value);
}

/**
 * Convert a display string (user-entered math.js text) back to a stored
 * equation value.
 *
 * In template mode the value is wrapped in double backticks.
 * In non-template mode the value is converted to LaTeX.
 *
 * @param {string} text - The user-entered math.js expression.
 * @param {boolean} isTemplate - Whether the current context is a template.
 * @returns {string|undefined} The stored value, or undefined for empty input.
 * @throws If non-template conversion to LaTeX fails.
 */
export function equationDisplayToValue(text, isTemplate) {
  if (text === '') return undefined;
  if (isTemplate) {
    return '``' + text + '``';
  }
  return mathJSToLatex(text);
}