/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.
 */
package org.apache.lucene.expressions.js;

import java.io.IOException;
import java.io.Reader;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import org.antlr.v4.runtime.ANTLRInputStream;
import org.antlr.v4.runtime.BaseErrorListener;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.DiagnosticErrorListener;
import org.antlr.v4.runtime.RecognitionException;
import org.antlr.v4.runtime.Recognizer;
import org.antlr.v4.runtime.atn.PredictionMode;
import org.antlr.v4.runtime.tree.ParseTree;
import org.apache.lucene.expressions.Expression;
import org.apache.lucene.expressions.js.JavascriptParser.ExpressionContext;
import org.apache.lucene.search.DoubleValues;
import org.apache.lucene.util.IOUtils;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Label;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.objectweb.asm.commons.GeneratorAdapter;

/**
 * An expression compiler for javascript expressions.
 *
 * <p>Example:
 *
 * <pre class="prettyprint">
 *   Expression foo = JavascriptCompiler.compile("((0.3*popularity)/10.0)+(0.7*score)");
 * </pre>
 *
 * <p>See the {@link org.apache.lucene.expressions.js package documentation} for the supported
 * syntax and default functions.
 *
 * <p>You can compile with an alternate set of functions via {@link #compile(String, Map,
 * ClassLoader)}. For example:
 *
 * <pre class="prettyprint">
 *   Map&lt;String,Method&gt; functions = new HashMap&lt;&gt;();
 *   // add all the default functions
 *   functions.putAll(JavascriptCompiler.DEFAULT_FUNCTIONS);
 *   // add cbrt()
 *   functions.put("cbrt", Math.class.getMethod("cbrt", double.class));
 *   // call compile with customized function map
 *   Expression foo = JavascriptCompiler.compile("cbrt(score)+ln(popularity)",
 *                                               functions,
 *                                               getClass().getClassLoader());
 * </pre>
 *
 * @lucene.experimental
 */
public final class JavascriptCompiler {
  static final class Loader extends ClassLoader {
    Loader(ClassLoader parent) {
      super(parent);
    }

    public Class<? extends Expression> define(String className, byte[] bytecode) {
      return defineClass(className, bytecode, 0, bytecode.length).asSubclass(Expression.class);
    }
  }

  private static final int CLASSFILE_VERSION = Opcodes.V1_8;

  // We use the same class name for all generated classes as they all have their own class loader.
  // The source code is displayed as "source file name" in stack trace.
  private static final String COMPILED_EXPRESSION_CLASS =
      JavascriptCompiler.class.getName() + "$CompiledExpression";
  private static final String COMPILED_EXPRESSION_INTERNAL =
      COMPILED_EXPRESSION_CLASS.replace('.', '/');

  static final Type EXPRESSION_TYPE = Type.getType(Expression.class);
  static final Type FUNCTION_VALUES_TYPE = Type.getType(DoubleValues.class);

  private static final org.objectweb.asm.commons.Method
      EXPRESSION_CTOR = getAsmMethod(void.class, "<init>", String.class, String[].class),
      EVALUATE_METHOD = getAsmMethod(double.class, "evaluate", DoubleValues[].class);

  static final org.objectweb.asm.commons.Method DOUBLE_VAL_METHOD =
      getAsmMethod(double.class, "doubleValue");

  /** create an ASM Method object from return type, method name, and parameters. */
  private static org.objectweb.asm.commons.Method getAsmMethod(
      Class<?> rtype, String name, Class<?>... ptypes) {
    return new org.objectweb.asm.commons.Method(
        name, MethodType.methodType(rtype, ptypes).toMethodDescriptorString());
  }

  // This maximum length is theoretically 65535 bytes, but as it's CESU-8 encoded we dont know how
  // large it is in bytes, so be safe
  // rcmuir: "If your ranking function is that large you need to check yourself into a mental
  // institution!"
  private static final int MAX_SOURCE_LENGTH = 16384;

  final String sourceText;
  final Map<String, Method> functions;
  final boolean picky;

  /**
   * Compiles the given expression using default compiler settings.
   *
   * @param sourceText The expression to compile
   * @return A new compiled expression
   * @throws ParseException on failure to compile
   */
  public static Expression compile(String sourceText) throws ParseException {
    return compile(sourceText, DEFAULT_FUNCTIONS, JavascriptCompiler.class.getClassLoader());
  }

  /**
   * Compiles the given expression with the supplied custom functions using default compiler
   * settings.
   *
   * <p>Functions must be {@code public static}, return {@code double} and can take from zero to 256
   * {@code double} parameters.
   *
   * @param sourceText The expression to compile
   * @param functions map of String names to functions
   * @param parent a {@code ClassLoader} that should be used as the parent of the loaded class. It
   *     must contain all classes referred to by the given {@code functions}.
   * @return A new compiled expression
   * @throws ParseException on failure to compile
   */
  public static Expression compile(
      String sourceText, Map<String, Method> functions, ClassLoader parent) throws ParseException {
    return compile(sourceText, functions, parent, false);
  }

  /**
   * Compiles the given expression with the supplied custom functions.
   *
   * <p>Functions must be {@code public static}, return {@code double} and can take from zero to 256
   * {@code double} parameters.
   *
   * @param sourceText The expression to compile
   * @param functions map of String names to functions
   * @param parent a {@code ClassLoader} that should be used as the parent of the loaded class. It
   *     must contain all classes referred to by the given {@code functions}.
   * @param picky whether to throw exception on ambiguity or other internal parsing issues (this
   *     option makes things slower too, it is only for debugging).
   * @return A new compiled expression
   * @throws ParseException on failure to compile
   */
  static Expression compile(
      String sourceText, Map<String, Method> functions, ClassLoader parent, boolean picky)
      throws ParseException {
    if (parent == null) {
      throw new NullPointerException("A parent ClassLoader must be given.");
    }
    for (Method m : functions.values()) {
      checkFunctionClassLoader(m, parent);
      checkFunction(m);
    }
    return new JavascriptCompiler(sourceText, functions, picky).compileExpression(parent);
  }

  /**
   * This method is unused, it is just here to make sure that the function signatures don't change.
   * If this method fails to compile, you also have to change the byte code generator to correctly
   * use the FunctionValues class.
   */
  @SuppressWarnings({"unused", "null"})
  private static void unusedTestCompile() throws IOException {
    DoubleValues f = null;
    f.doubleValue();
  }

  /**
   * Constructs a compiler for expressions with specific set of functions
   *
   * @param sourceText The expression to compile
   */
  private JavascriptCompiler(String sourceText, Map<String, Method> functions, boolean picky) {
    if (sourceText == null) {
      throw new NullPointerException();
    }
    this.sourceText = sourceText;
    this.functions = functions;
    this.picky = picky;
  }

  /**
   * Compiles the given expression with the specified parent classloader
   *
   * @return A new compiled expression
   * @throws ParseException on failure to compile
   */
  private Expression compileExpression(ClassLoader parent) throws ParseException {
    final Map<String, Integer> externalsMap = new LinkedHashMap<>();
    final ClassWriter classWriter =
        new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);

    try {
      generateClass(getAntlrParseTree(), classWriter, externalsMap);

      final Class<? extends Expression> evaluatorClass =
          new Loader(parent).define(COMPILED_EXPRESSION_CLASS, classWriter.toByteArray());
      final Constructor<? extends Expression> constructor =
          evaluatorClass.getConstructor(String.class, String[].class);

      return constructor.newInstance(
          sourceText, externalsMap.keySet().toArray(new String[externalsMap.size()]));
    } catch (RuntimeException re) {
      if (re.getCause() instanceof ParseException) {
        throw (ParseException) re.getCause();
      }
      throw re;
    } catch (ReflectiveOperationException exception) {
      throw new IllegalStateException(
          "An internal error occurred attempting to compile the expression (" + sourceText + ").",
          exception);
    }
  }

  /**
   * Parses the sourceText into an ANTLR 4 parse tree
   *
   * @return The ANTLR parse tree
   * @throws ParseException on failure to parse
   */
  private ParseTree getAntlrParseTree() throws ParseException {
    final ANTLRInputStream antlrInputStream = new ANTLRInputStream(sourceText);
    final JavascriptErrorHandlingLexer javascriptLexer =
        new JavascriptErrorHandlingLexer(antlrInputStream);
    javascriptLexer.removeErrorListeners();
    final JavascriptParser javascriptParser =
        new JavascriptParser(new CommonTokenStream(javascriptLexer));
    javascriptParser.removeErrorListeners();
    if (picky) {
      setupPicky(javascriptParser);
    }
    javascriptParser.setErrorHandler(new JavascriptParserErrorStrategy());
    return javascriptParser.compile();
  }

  private void setupPicky(JavascriptParser parser) {
    // Diagnostic listener invokes syntaxError on other listeners for ambiguity issues
    parser.addErrorListener(new DiagnosticErrorListener(true));
    // a second listener to fail the test when the above happens.
    parser.addErrorListener(
        new BaseErrorListener() {
          @Override
          public void syntaxError(
              final Recognizer<?, ?> recognizer,
              final Object offendingSymbol,
              final int line,
              final int charPositionInLine,
              final String msg,
              final RecognitionException e) {
            throw new RuntimeException(
                new ParseException(
                    "line ("
                        + line
                        + "), offset ("
                        + charPositionInLine
                        + "), symbol ("
                        + offendingSymbol
                        + ") "
                        + msg,
                    charPositionInLine));
          }
        });

    // Enable exact ambiguity detection (costly). we enable exact since its the default for
    // DiagnosticErrorListener, life is too short to think about what 'inexact ambiguity' might
    // mean.
    parser.getInterpreter().setPredictionMode(PredictionMode.LL_EXACT_AMBIG_DETECTION);
  }

  /** Sends the bytecode of class file to {@link ClassWriter}. */
  private void generateClass(
      final ParseTree parseTree,
      final ClassWriter classWriter,
      final Map<String, Integer> externalsMap)
      throws ParseException {
    classWriter.visit(
        CLASSFILE_VERSION,
        Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER | Opcodes.ACC_FINAL,
        COMPILED_EXPRESSION_INTERNAL,
        null,
        EXPRESSION_TYPE.getInternalName(),
        null);
    final String clippedSourceText =
        (sourceText.length() <= MAX_SOURCE_LENGTH)
            ? sourceText
            : (sourceText.substring(0, MAX_SOURCE_LENGTH - 3) + "...");
    classWriter.visitSource(clippedSourceText, null);

    final GeneratorAdapter constructor =
        new GeneratorAdapter(Opcodes.ACC_PUBLIC, EXPRESSION_CTOR, null, null, classWriter);
    constructor.loadThis();
    constructor.loadArgs();
    constructor.invokeConstructor(EXPRESSION_TYPE, EXPRESSION_CTOR);
    constructor.returnValue();
    constructor.endMethod();

    final GeneratorAdapter gen =
        new GeneratorAdapter(Opcodes.ACC_PUBLIC, EVALUATE_METHOD, null, null, classWriter);

    // to completely hide the ANTLR visitor we use an anonymous impl:
    new JavascriptBaseVisitor<Void>() {
      private final Deque<Type> typeStack = new ArrayDeque<>();

      @Override
      public Void visitCompile(JavascriptParser.CompileContext ctx) {
        typeStack.push(Type.DOUBLE_TYPE);
        visit(ctx.expression());
        typeStack.pop();

        return null;
      }

      @Override
      public Void visitPrecedence(JavascriptParser.PrecedenceContext ctx) {
        visit(ctx.expression());

        return null;
      }

      @Override
      public Void visitNumeric(JavascriptParser.NumericContext ctx) {
        if (ctx.HEX() != null) {
          pushLong(Long.parseLong(ctx.HEX().getText().substring(2), 16));
        } else if (ctx.OCTAL() != null) {
          pushLong(Long.parseLong(ctx.OCTAL().getText().substring(1), 8));
        } else if (ctx.DECIMAL() != null) {
          gen.push(Double.parseDouble(ctx.DECIMAL().getText()));
          gen.cast(Type.DOUBLE_TYPE, typeStack.peek());
        } else {
          throw new IllegalStateException("Unknown operation specified: " + ctx.getText());
        }

        return null;
      }

      @Override
      public Void visitExternal(JavascriptParser.ExternalContext ctx) {
        String text = ctx.VARIABLE().getText();
        int arguments = ctx.expression().size();
        boolean parens = ctx.LP() != null && ctx.RP() != null;
        Method method = parens ? functions.get(text) : null;

        try {
          if (method != null) {
            int arity = method.getParameterTypes().length;

            if (arguments != arity) {
              throw new ParseException(
                  "Invalid expression '"
                      + sourceText
                      + "': Expected ("
                      + arity
                      + ") arguments for function call ("
                      + text
                      + "), but found ("
                      + arguments
                      + ").",
                  ctx.start.getStartIndex());
            }

            typeStack.push(Type.DOUBLE_TYPE);

            for (int argument = 0; argument < arguments; ++argument) {
              visit(ctx.expression(argument));
            }

            typeStack.pop();

            gen.invokeStatic(
                Type.getType(method.getDeclaringClass()),
                org.objectweb.asm.commons.Method.getMethod(method));

            gen.cast(Type.DOUBLE_TYPE, typeStack.peek());
          } else if (!parens || arguments == 0 && text.contains(".")) {
            int index;

            text = normalizeQuotes(ctx.getText());

            if (externalsMap.containsKey(text)) {
              index = externalsMap.get(text);
            } else {
              index = externalsMap.size();
              externalsMap.put(text, index);
            }

            gen.loadArg(0);
            gen.push(index);
            gen.arrayLoad(FUNCTION_VALUES_TYPE);
            gen.invokeVirtual(FUNCTION_VALUES_TYPE, DOUBLE_VAL_METHOD);
            gen.cast(Type.DOUBLE_TYPE, typeStack.peek());
          } else {
            throw new ParseException(
                "Invalid expression '"
                    + sourceText
                    + "': Unrecognized function call ("
                    + text
                    + ").",
                ctx.start.getStartIndex());
          }
          return null;
        } catch (ParseException e) {
          // The API doesn't allow checked exceptions here, so propagate up the stack. This is
          // unwrapped
          // in getAntlrParseTree.
          throw new RuntimeException(e);
        }
      }

      @Override
      public Void visitUnary(JavascriptParser.UnaryContext ctx) {
        if (ctx.BOOLNOT() != null) {
          Label labelNotTrue = new Label();
          Label labelNotReturn = new Label();

          typeStack.push(Type.INT_TYPE);
          visit(ctx.expression());
          typeStack.pop();
          gen.visitJumpInsn(Opcodes.IFEQ, labelNotTrue);
          pushBoolean(false);
          gen.goTo(labelNotReturn);
          gen.visitLabel(labelNotTrue);
          pushBoolean(true);
          gen.visitLabel(labelNotReturn);

        } else if (ctx.BWNOT() != null) {
          typeStack.push(Type.LONG_TYPE);
          visit(ctx.expression());
          typeStack.pop();
          gen.push(-1L);
          gen.visitInsn(Opcodes.LXOR);
          gen.cast(Type.LONG_TYPE, typeStack.peek());

        } else if (ctx.ADD() != null) {
          visit(ctx.expression());

        } else if (ctx.SUB() != null) {
          typeStack.push(Type.DOUBLE_TYPE);
          visit(ctx.expression());
          typeStack.pop();
          gen.visitInsn(Opcodes.DNEG);
          gen.cast(Type.DOUBLE_TYPE, typeStack.peek());

        } else {
          throw new IllegalStateException("Unknown operation specified: " + ctx.getText());
        }

        return null;
      }

      @Override
      public Void visitMuldiv(JavascriptParser.MuldivContext ctx) {
        int opcode;

        if (ctx.MUL() != null) {
          opcode = Opcodes.DMUL;
        } else if (ctx.DIV() != null) {
          opcode = Opcodes.DDIV;
        } else if (ctx.REM() != null) {
          opcode = Opcodes.DREM;
        } else {
          throw new IllegalStateException("Unknown operation specified: " + ctx.getText());
        }

        pushArith(opcode, ctx.expression(0), ctx.expression(1));

        return null;
      }

      @Override
      public Void visitAddsub(JavascriptParser.AddsubContext ctx) {
        int opcode;

        if (ctx.ADD() != null) {
          opcode = Opcodes.DADD;
        } else if (ctx.SUB() != null) {
          opcode = Opcodes.DSUB;
        } else {
          throw new IllegalStateException("Unknown operation specified: " + ctx.getText());
        }

        pushArith(opcode, ctx.expression(0), ctx.expression(1));

        return null;
      }

      @Override
      public Void visitBwshift(JavascriptParser.BwshiftContext ctx) {
        int opcode;

        if (ctx.LSH() != null) {
          opcode = Opcodes.LSHL;
        } else if (ctx.RSH() != null) {
          opcode = Opcodes.LSHR;
        } else if (ctx.USH() != null) {
          opcode = Opcodes.LUSHR;
        } else {
          throw new IllegalStateException("Unknown operation specified: " + ctx.getText());
        }

        pushShift(opcode, ctx.expression(0), ctx.expression(1));

        return null;
      }

      @Override
      public Void visitBoolcomp(JavascriptParser.BoolcompContext ctx) {
        int opcode;

        if (ctx.LT() != null) {
          opcode = GeneratorAdapter.LT;
        } else if (ctx.LTE() != null) {
          opcode = GeneratorAdapter.LE;
        } else if (ctx.GT() != null) {
          opcode = GeneratorAdapter.GT;
        } else if (ctx.GTE() != null) {
          opcode = GeneratorAdapter.GE;
        } else {
          throw new IllegalStateException("Unknown operation specified: " + ctx.getText());
        }

        pushCond(opcode, ctx.expression(0), ctx.expression(1));

        return null;
      }

      @Override
      public Void visitBooleqne(JavascriptParser.BooleqneContext ctx) {
        int opcode;

        if (ctx.EQ() != null) {
          opcode = GeneratorAdapter.EQ;
        } else if (ctx.NE() != null) {
          opcode = GeneratorAdapter.NE;
        } else {
          throw new IllegalStateException("Unknown operation specified: " + ctx.getText());
        }

        pushCond(opcode, ctx.expression(0), ctx.expression(1));

        return null;
      }

      @Override
      public Void visitBwand(JavascriptParser.BwandContext ctx) {
        pushBitwise(Opcodes.LAND, ctx.expression(0), ctx.expression(1));

        return null;
      }

      @Override
      public Void visitBwxor(JavascriptParser.BwxorContext ctx) {
        pushBitwise(Opcodes.LXOR, ctx.expression(0), ctx.expression(1));

        return null;
      }

      @Override
      public Void visitBwor(JavascriptParser.BworContext ctx) {
        pushBitwise(Opcodes.LOR, ctx.expression(0), ctx.expression(1));

        return null;
      }

      @Override
      public Void visitBooland(JavascriptParser.BoolandContext ctx) {
        Label andFalse = new Label();
        Label andEnd = new Label();

        typeStack.push(Type.INT_TYPE);
        visit(ctx.expression(0));
        gen.visitJumpInsn(Opcodes.IFEQ, andFalse);
        visit(ctx.expression(1));
        gen.visitJumpInsn(Opcodes.IFEQ, andFalse);
        typeStack.pop();
        pushBoolean(true);
        gen.goTo(andEnd);
        gen.visitLabel(andFalse);
        pushBoolean(false);
        gen.visitLabel(andEnd);

        return null;
      }

      @Override
      public Void visitBoolor(JavascriptParser.BoolorContext ctx) {
        Label orTrue = new Label();
        Label orEnd = new Label();

        typeStack.push(Type.INT_TYPE);
        visit(ctx.expression(0));
        gen.visitJumpInsn(Opcodes.IFNE, orTrue);
        visit(ctx.expression(1));
        gen.visitJumpInsn(Opcodes.IFNE, orTrue);
        typeStack.pop();
        pushBoolean(false);
        gen.goTo(orEnd);
        gen.visitLabel(orTrue);
        pushBoolean(true);
        gen.visitLabel(orEnd);

        return null;
      }

      @Override
      public Void visitConditional(JavascriptParser.ConditionalContext ctx) {
        Label condFalse = new Label();
        Label condEnd = new Label();

        typeStack.push(Type.INT_TYPE);
        visit(ctx.expression(0));
        typeStack.pop();
        gen.visitJumpInsn(Opcodes.IFEQ, condFalse);
        visit(ctx.expression(1));
        gen.goTo(condEnd);
        gen.visitLabel(condFalse);
        visit(ctx.expression(2));
        gen.visitLabel(condEnd);

        return null;
      }

      private void pushArith(int operator, ExpressionContext left, ExpressionContext right) {
        pushBinaryOp(operator, left, right, Type.DOUBLE_TYPE, Type.DOUBLE_TYPE, Type.DOUBLE_TYPE);
      }

      private void pushShift(int operator, ExpressionContext left, ExpressionContext right) {
        pushBinaryOp(operator, left, right, Type.LONG_TYPE, Type.INT_TYPE, Type.LONG_TYPE);
      }

      private void pushBitwise(int operator, ExpressionContext left, ExpressionContext right) {
        pushBinaryOp(operator, left, right, Type.LONG_TYPE, Type.LONG_TYPE, Type.LONG_TYPE);
      }

      private void pushBinaryOp(
          int operator,
          ExpressionContext left,
          ExpressionContext right,
          Type leftType,
          Type rightType,
          Type returnType) {
        typeStack.push(leftType);
        visit(left);
        typeStack.pop();
        typeStack.push(rightType);
        visit(right);
        typeStack.pop();
        gen.visitInsn(operator);
        gen.cast(returnType, typeStack.peek());
      }

      private void pushCond(int operator, ExpressionContext left, ExpressionContext right) {
        Label labelTrue = new Label();
        Label labelReturn = new Label();

        typeStack.push(Type.DOUBLE_TYPE);
        visit(left);
        visit(right);
        typeStack.pop();

        gen.ifCmp(Type.DOUBLE_TYPE, operator, labelTrue);
        pushBoolean(false);
        gen.goTo(labelReturn);
        gen.visitLabel(labelTrue);
        pushBoolean(true);
        gen.visitLabel(labelReturn);
      }

      private void pushBoolean(boolean truth) {
        switch (typeStack.peek().getSort()) {
          case Type.INT:
            gen.push(truth);
            break;
          case Type.LONG:
            gen.push(truth ? 1L : 0L);
            break;
          case Type.DOUBLE:
            gen.push(truth ? 1. : 0.);
            break;
          default:
            throw new IllegalStateException("Invalid expected type: " + typeStack.peek());
        }
      }

      private void pushLong(long i) {
        switch (typeStack.peek().getSort()) {
          case Type.INT:
            gen.push((int) i);
            break;
          case Type.LONG:
            gen.push(i);
            break;
          case Type.DOUBLE:
            gen.push((double) i);
            break;
          default:
            throw new IllegalStateException("Invalid expected type: " + typeStack.peek());
        }
      }
    }.visit(parseTree);

    gen.returnValue();
    gen.endMethod();

    classWriter.visitEnd();
  }

  static String normalizeQuotes(String text) {
    StringBuilder out = new StringBuilder(text.length());
    boolean inDoubleQuotes = false;
    for (int i = 0; i < text.length(); ++i) {
      char c = text.charAt(i);
      if (c == '\\') {
        c = text.charAt(++i);
        if (c == '\\') {
          out.append('\\'); // re-escape the backslash
        }
        // no escape for double quote
      } else if (c == '\'') {
        if (inDoubleQuotes) {
          // escape in output
          out.append('\\');
        } else {
          int j = findSingleQuoteStringEnd(text, i);
          out.append(text, i, j); // copy up to end quote (leave end for append below)
          i = j;
        }
      } else if (c == '"') {
        c = '\''; // change beginning/ending doubles to singles
        inDoubleQuotes = !inDoubleQuotes;
      }
      out.append(c);
    }
    return out.toString();
  }

  static int findSingleQuoteStringEnd(String text, int start) {
    ++start; // skip beginning
    while (text.charAt(start) != '\'') {
      if (text.charAt(start) == '\\') {
        ++start; // blindly consume escape value
      }
      ++start;
    }
    return start;
  }

  /**
   * The default set of functions available to expressions.
   *
   * <p>See the {@link org.apache.lucene.expressions.js package documentation} for a list.
   */
  public static final Map<String, Method> DEFAULT_FUNCTIONS;

  static {
    Map<String, Method> map = new HashMap<>();
    try {
      final Properties props = new Properties();
      var name = JavascriptCompiler.class.getSimpleName() + ".properties";
      try (Reader in =
          IOUtils.getDecodingReader(
              IOUtils.requireResourceNonNull(
                  JavascriptCompiler.class.getResourceAsStream(name), name),
              StandardCharsets.UTF_8)) {
        props.load(in);
      }
      for (final String call : props.stringPropertyNames()) {
        final String[] vals = props.getProperty(call).split(",");
        if (vals.length != 3) {
          throw new Error("Syntax error while reading Javascript functions from resource");
        }
        final Class<?> clazz = Class.forName(vals[0].trim());
        final String methodName = vals[1].trim();
        final int arity = Integer.parseInt(vals[2].trim());
        @SuppressWarnings({"rawtypes", "unchecked"})
        Class[] args = new Class[arity];
        Arrays.fill(args, double.class);
        Method method = clazz.getMethod(methodName, args);
        checkFunction(method);
        map.put(call, method);
      }
    } catch (ReflectiveOperationException | IOException e) {
      throw new Error("Cannot resolve function", e);
    }
    DEFAULT_FUNCTIONS = Collections.unmodifiableMap(map);
  }

  /** Check Method signature for compatibility. */
  private static void checkFunction(Method method) {
    // check that the Method is public in some public reachable class:
    final MethodType type;
    try {
      type = MethodHandles.publicLookup().unreflect(method).type();
    } catch (IllegalAccessException iae) {
      throw new IllegalArgumentException(
          method + " is not accessible (declaring class or method not public).", iae);
    }
    // do some checks if the signature is "compatible":
    if (!Modifier.isStatic(method.getModifiers())) {
      throw new IllegalArgumentException(method + " is not static.");
    }
    for (int arg = 0, arity = type.parameterCount(); arg < arity; arg++) {
      if (type.parameterType(arg) != double.class) {
        throw new IllegalArgumentException(method + " must take only double parameters.");
      }
    }
    if (type.returnType() != double.class) {
      throw new IllegalArgumentException(method + " does not return a double.");
    }
  }

  /**
   * Cross check if declaring class of given method is the same as returned by the given parent
   * {@link ClassLoader} on string lookup. This prevents {@link NoClassDefFoundError}.
   */
  private static void checkFunctionClassLoader(Method method, ClassLoader parent) {
    boolean ok = false;
    try {
      final Class<?> clazz = method.getDeclaringClass();
      ok = Class.forName(clazz.getName(), false, parent) == clazz;
    } catch (
        @SuppressWarnings("unused")
        ClassNotFoundException e) {
      ok = false;
    }
    if (!ok) {
      throw new IllegalArgumentException(
          method
              + " is not declared by a class which is accessible by the given parent ClassLoader.");
    }
  }
}
