/*
 * 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.PrintWriter;
import java.io.StringWriter;
import java.lang.reflect.Method;
import java.text.ParseException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.apache.lucene.expressions.Expression;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.objectweb.asm.commons.GeneratorAdapter;

/** Tests customing the function map */
public class TestCustomFunctions extends CompilerTestCase {
  private static double DELTA = 0.0000001;

  /** empty list of methods */
  public void testEmpty() throws Exception {
    Map<String, Method> functions = Collections.emptyMap();
    ParseException expected =
        expectThrows(
            ParseException.class,
            () -> {
              compile("sqrt(20)", functions);
            });
    assertEquals(
        "Invalid expression 'sqrt(20)': Unrecognized function call (sqrt).", expected.getMessage());
    assertEquals(expected.getErrorOffset(), 0);
  }

  /** using the default map explicitly */
  public void testDefaultList() throws Exception {
    Map<String, Method> functions = JavascriptCompiler.DEFAULT_FUNCTIONS;
    Expression expr = compile("sqrt(20)", functions);
    assertEquals(Math.sqrt(20), expr.evaluate(null), DELTA);
  }

  public static double zeroArgMethod() {
    return 5;
  }

  /** tests a method with no arguments */
  public void testNoArgMethod() throws Exception {
    Map<String, Method> functions = new HashMap<>();
    functions.put("foo", getClass().getMethod("zeroArgMethod"));
    Expression expr = compile("foo()", functions);
    assertEquals(5, expr.evaluate(null), DELTA);
  }

  public static double oneArgMethod(double arg1) {
    return 3 + arg1;
  }

  /** tests a method with one arguments */
  public void testOneArgMethod() throws Exception {
    Map<String, Method> functions = new HashMap<>();
    functions.put("foo", getClass().getMethod("oneArgMethod", double.class));
    Expression expr = compile("foo(3)", functions);
    assertEquals(6, expr.evaluate(null), DELTA);
  }

  public static double threeArgMethod(double arg1, double arg2, double arg3) {
    return arg1 + arg2 + arg3;
  }

  /** tests a method with three arguments */
  public void testThreeArgMethod() throws Exception {
    Map<String, Method> functions = new HashMap<>();
    functions.put(
        "foo", getClass().getMethod("threeArgMethod", double.class, double.class, double.class));
    Expression expr = compile("foo(3, 4, 5)", functions);
    assertEquals(12, expr.evaluate(null), DELTA);
  }

  /** tests a map with 2 functions */
  public void testTwoMethods() throws Exception {
    Map<String, Method> functions = new HashMap<>();
    functions.put("foo", getClass().getMethod("zeroArgMethod"));
    functions.put("bar", getClass().getMethod("oneArgMethod", double.class));
    Expression expr = compile("foo() + bar(3)", functions);
    assertEquals(11, expr.evaluate(null), DELTA);
  }

  /** tests invalid methods that are not allowed to become variables to be mapped */
  public void testInvalidVariableMethods() {
    ParseException expected =
        expectThrows(
            ParseException.class,
            () -> {
              compile("method()");
            });
    assertEquals(
        "Invalid expression 'method()': Unrecognized function call (method).",
        expected.getMessage());
    assertEquals(0, expected.getErrorOffset());

    expected =
        expectThrows(
            ParseException.class,
            () -> {
              compile("method.method(1)");
            });
    assertEquals(
        "Invalid expression 'method.method(1)': Unrecognized function call (method.method).",
        expected.getMessage());
    assertEquals(0, expected.getErrorOffset());

    expected =
        expectThrows(
            ParseException.class,
            () -> {
              compile("1 + method()");
            });
    assertEquals(
        "Invalid expression '1 + method()': Unrecognized function call (method).",
        expected.getMessage());
    assertEquals(4, expected.getErrorOffset());
  }

  public static String bogusReturnType() {
    return "bogus!";
  }

  /** wrong return type: must be double */
  public void testWrongReturnType() throws Exception {
    Map<String, Method> functions = new HashMap<>();
    functions.put("foo", getClass().getMethod("bogusReturnType"));
    IllegalArgumentException expected =
        expectThrows(
            IllegalArgumentException.class,
            () -> {
              compile("foo()", functions);
            });
    assertTrue(expected.getMessage().contains("does not return a double"));
  }

  public static double bogusParameterType(String s) {
    return 0;
  }

  /** wrong param type: must be doubles */
  public void testWrongParameterType() throws Exception {
    Map<String, Method> functions = new HashMap<>();
    functions.put("foo", getClass().getMethod("bogusParameterType", String.class));
    IllegalArgumentException expected =
        expectThrows(
            IllegalArgumentException.class,
            () -> {
              compile("foo(2)", functions);
            });
    assertTrue(expected.getMessage().contains("must take only double parameters"));
  }

  public double nonStaticMethod() {
    return 0;
  }

  /** wrong modifiers: must be static */
  public void testWrongNotStatic() throws Exception {
    Map<String, Method> functions = new HashMap<>();
    functions.put("foo", getClass().getMethod("nonStaticMethod"));
    IllegalArgumentException expected =
        expectThrows(
            IllegalArgumentException.class,
            () -> {
              compile("foo()", functions);
            });
    assertTrue(expected.getMessage().contains("is not static"));
  }

  static double nonPublicMethod() {
    return 0;
  }

  /** wrong modifiers: must be public */
  public void testWrongNotPublic() throws Exception {
    Map<String, Method> functions = new HashMap<>();
    functions.put("foo", getClass().getDeclaredMethod("nonPublicMethod"));
    IllegalArgumentException expected =
        expectThrows(
            IllegalArgumentException.class,
            () -> {
              compile("foo()", functions);
            });
    assertTrue(expected.getMessage().contains("not public"));
  }

  static class NestedNotPublic {
    public static double method() {
      return 0;
    }
  }

  /** wrong class modifiers: class containing method is not public */
  public void testWrongNestedNotPublic() throws Exception {
    Map<String, Method> functions = new HashMap<>();
    functions.put("foo", NestedNotPublic.class.getMethod("method"));
    IllegalArgumentException expected =
        expectThrows(
            IllegalArgumentException.class,
            () -> {
              compile("foo()", functions);
            });
    assertTrue(expected.getMessage().contains("not public"));
  }

  /**
   * Classloader that can be used to create a fake static class that has one method returning a
   * static var
   */
  static final class Loader extends ClassLoader implements Opcodes {
    Loader(ClassLoader parent) {
      super(parent);
    }

    public Class<?> createFakeClass() {
      String className = TestCustomFunctions.class.getName() + "$Foo";
      ClassWriter classWriter =
          new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
      classWriter.visit(
          Opcodes.V1_5,
          ACC_PUBLIC | ACC_SUPER | ACC_FINAL | ACC_SYNTHETIC,
          className.replace('.', '/'),
          null,
          Type.getInternalName(Object.class),
          null);

      org.objectweb.asm.commons.Method m =
          org.objectweb.asm.commons.Method.getMethod("void <init>()");
      GeneratorAdapter constructor =
          new GeneratorAdapter(ACC_PRIVATE | ACC_SYNTHETIC, m, null, null, classWriter);
      constructor.loadThis();
      constructor.loadArgs();
      constructor.invokeConstructor(Type.getType(Object.class), m);
      constructor.returnValue();
      constructor.endMethod();

      GeneratorAdapter gen =
          new GeneratorAdapter(
              ACC_STATIC | ACC_PUBLIC | ACC_SYNTHETIC,
              org.objectweb.asm.commons.Method.getMethod("double bar()"),
              null,
              null,
              classWriter);
      gen.push(2.0);
      gen.returnValue();
      gen.endMethod();

      byte[] bc = classWriter.toByteArray();
      return defineClass(className, bc, 0, bc.length);
    }
  }

  /**
   * uses this test with a different classloader and tries to register it using the default
   * classloader, which should fail
   */
  public void testClassLoader() throws Exception {
    ClassLoader thisLoader = getClass().getClassLoader();
    Loader childLoader = new Loader(thisLoader);
    Class<?> fooClass = childLoader.createFakeClass();

    Method barMethod = fooClass.getMethod("bar");
    Map<String, Method> functions = Collections.singletonMap("bar", barMethod);
    assertNotSame(thisLoader, fooClass.getClassLoader());
    assertNotSame(thisLoader, barMethod.getDeclaringClass().getClassLoader());

    // this should pass:
    Expression expr = compile("bar()", functions, childLoader);
    assertEquals(2.0, expr.evaluate(null), DELTA);

    // use our classloader, not the foreign one, which should fail!
    IllegalArgumentException expected =
        expectThrows(
            IllegalArgumentException.class,
            () -> {
              compile("bar()", functions, thisLoader);
            });
    assertTrue(
        expected
            .getMessage()
            .contains(
                "is not declared by a class which is accessible by the given parent ClassLoader"));

    // mix foreign and default functions
    Map<String, Method> mixedFunctions = new HashMap<>(JavascriptCompiler.DEFAULT_FUNCTIONS);
    mixedFunctions.putAll(functions);
    expr = compile("bar()", mixedFunctions, childLoader);
    assertEquals(2.0, expr.evaluate(null), DELTA);
    expr = compile("sqrt(20)", mixedFunctions, childLoader);
    assertEquals(Math.sqrt(20), expr.evaluate(null), DELTA);

    // use our classloader, not the foreign one, which should fail!
    expected =
        expectThrows(
            IllegalArgumentException.class,
            () -> {
              compile("bar()", mixedFunctions, thisLoader);
            });
    assertTrue(
        expected
            .getMessage()
            .contains(
                "is not declared by a class which is accessible by the given parent ClassLoader"));
  }

  static String MESSAGE = "This should not happen but it happens";

  public static class StaticThrowingException {
    public static double method() {
      throw new ArithmeticException(MESSAGE);
    }
  }

  /**
   * the method throws an exception. We should check the stack trace that it contains the source
   * code of the expression as file name.
   */
  public void testThrowingException() throws Exception {
    Map<String, Method> functions = new HashMap<>();
    functions.put("foo", StaticThrowingException.class.getMethod("method"));
    String source = "3 * foo() / 5";
    Expression expr = compile(source, functions);
    ArithmeticException expected =
        expectThrows(
            ArithmeticException.class,
            () -> {
              expr.evaluate(null);
            });
    assertEquals(MESSAGE, expected.getMessage());
    StringWriter sw = new StringWriter();
    PrintWriter pw = new PrintWriter(sw);
    expected.printStackTrace(pw);
    pw.flush();
    assertTrue(
        sw.toString().contains("JavascriptCompiler$CompiledExpression.evaluate(" + source + ")"));
  }

  /** test that namespaces work with custom expressions. */
  public void testNamespaces() throws Exception {
    Map<String, Method> functions = new HashMap<>();
    functions.put("foo.bar", getClass().getMethod("zeroArgMethod"));
    String source = "foo.bar()";
    Expression expr = compile(source, functions);
    assertEquals(5, expr.evaluate(null), DELTA);
  }
}
