/*
 * 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.zeppelin.resource;

import com.google.gson.Gson;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import com.google.gson.internal.Primitives;
import org.apache.zeppelin.common.JsonSerializable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;

/**
 * Information and reference to the resource
 */
public class Resource implements JsonSerializable, Serializable {
  private static final Logger LOGGER = LoggerFactory.getLogger(Resource.class);
  private static final Gson gson = new Gson();

  private final transient Object r;
  private final transient LocalResourcePool pool;
  private final boolean serializable;
  private final ResourceId resourceId;
  private final String className;


  /**
   * Create local resource
   *
   * @param resourceId
   * @param r          must not be null
   */
  Resource(LocalResourcePool pool, ResourceId resourceId, Object r) {
    this.r = r;
    this.pool = pool;
    this.resourceId = resourceId;
    this.serializable = r instanceof Serializable;
    this.className = r.getClass().getName();
  }

  /**
   * Create remote object
   *
   * @param resourceId
   */
  Resource(LocalResourcePool pool, ResourceId resourceId, boolean serializable, String className) {
    this.r = null;
    this.pool = pool;
    this.resourceId = resourceId;
    this.serializable = serializable;
    this.className = className;
  }

  public ResourceId getResourceId() {
    return resourceId;
  }

  public String getClassName() {
    return className;
  }

  /**
   * @return null when this is remote resource and not serializable.
   */
  public Object get() {
    if (isLocal() || isSerializable()) {
      return r;
    } else {
      return null;
    }
  }

  public <T> T get(Class<T> clazz) {
    return Primitives.wrap(clazz).cast(r);
  }

  public boolean isSerializable() {
    return serializable;
  }

  /**
   * if it is remote object
   *
   * @return
   */
  public boolean isRemote() {
    return !isLocal();
  }

  /**
   * Whether it is locally accessible or not
   *
   * @return
   */
  public boolean isLocal() {
    return true;
  }

  /**
   * Invoke a method without param
   * @param methodName
   * @return
   */
  public Object invokeMethod(String methodName) {
    return invokeMethod(methodName, (Class []) null, (Object []) null);
  }

  /**
   * Invoke a method and store result in ResourcePool
   * @param methodName
   * @param returnResourceName
   * @return
   */
  public Resource invokeMethod(String methodName, String returnResourceName) {
    return invokeMethod(methodName, (Class []) null, (Object []) null, returnResourceName);
  }

  /**
   * Invoke a method with automatic parameter type inference
   * @param methodName
   * @param params
   * @return
   * @throws ClassNotFoundException
   */
  public Object invokeMethod(String methodName, Object [] params)
          throws ClassNotFoundException {
    return invokeMethod(methodName, (Type[]) null, params);
  }

  /**
   * Invoke a method with automatic parameter type inference
   * @param methodName
   * @param params python interpreter convert python array '[]' to ArrayList through py4j
   * @return
   * @throws ClassNotFoundException
   */
  public Object invokeMethod(
          String methodName, ArrayList params)
          throws ClassNotFoundException {
    Object[] paramsArray = params.toArray(new Object[]{});
    return invokeMethod(methodName, paramsArray);
  }

  /**
   * Invoke a method with automatic parameter type inference and store result in ResourcePool
   * @param methodName
   * @param params
   * @param returnResourceName
   * @return
   * @throws ClassNotFoundException
   */
  public Resource invokeMethod(String methodName, Object [] params, String returnResourceName)
          throws ClassNotFoundException {
    return (Resource) invokeMethod(methodName, (Type[]) null, params, returnResourceName);
  }

  /**
   * Invoke a method with automatic parameter type inference and store result in ResourcePool
   * @param methodName
   * @param params python interpreter convert python array '[]' to ArrayList through py4j
   * @param returnResourceName
   * @return
   * @throws ClassNotFoundException
   */
  public Resource invokeMethod(
          String methodName, ArrayList params, String returnResourceName)
          throws ClassNotFoundException {
    Object[] paramsArray = params.toArray(new Object[]{});
    return invokeMethod(methodName, paramsArray, returnResourceName);
  }

  /**
   * Invoke a method with given parameter class names
   * @param methodName
   * @param paramTypes list of fully qualified class name
   * @param params
   * @return
   * @throws ClassNotFoundException
   */
  public Object invokeMethod(
          String methodName, String[] paramTypes, Object[] params)
          throws ClassNotFoundException {
    Type [] types = typeFromName(paramTypes);
    return invokeMethod(methodName, types, params);
  }

  /**
   * Invoke a method with given parameter class names
   * @param methodName
   * @param paramTypes list of fully qualified class name. python interpreter convert python array '[]' to ArrayList through py4j
   * @param params python interpreter convert python array '[]' to ArrayList through py4j
   * @return
   * @throws ClassNotFoundException
   */
  public Object invokeMethod(
          String methodName, ArrayList<String> paramTypes, ArrayList params)
          throws ClassNotFoundException {
    String[] paramTypesArray = paramTypes.toArray(new String[]{});
    Object[] paramsArray = params.toArray(new Object[]{});
    return invokeMethod(methodName, paramTypesArray, paramsArray);
  }

  /**
   * Invoke a method with given parameter class names and store result in ResourcePool
   * @param methodName
   * @param paramTypes
   * @param params
   * @param returnResourceName
   * @return
   * @throws ClassNotFoundException
   */
  public Resource invokeMethod(
          String methodName, String[] paramTypes, Object[] params, String returnResourceName)
          throws ClassNotFoundException {
    Type [] types = typeFromName(paramTypes);
    return (Resource) invokeMethod(methodName, types, params, returnResourceName);
  }


  public Resource invokeMethod(
          String methodName, ArrayList<String> paramTypes, ArrayList params, String returnResourceName)
          throws ClassNotFoundException {
    String[] paramTypesArray = paramTypes.toArray(new String[]{});
    Object[] paramsArray = params.toArray(new Object[]{});
    return invokeMethod(methodName, paramTypesArray, paramsArray, returnResourceName);
  }

  /**
   * Invoke a method with give parameter types
   * @param methodName
   * @param types
   * @param params
   * @return
   * @throws ClassNotFoundException
   */
  public Object invokeMethod(
          String methodName, Type[] types, Object[] params)
          throws ClassNotFoundException {
    return invokeMethod(methodName, types, params, null);
  }

  /**
   * Invoke a method with given parameter type and store result in ResourcePool
   * @param methodName
   * @param types
   * @param params
   * @param returnResourceName
   * @return
   * @throws ClassNotFoundException
   */
  public Object invokeMethod(
          String methodName, Type[] types, Object[] params, String returnResourceName) throws ClassNotFoundException {
    Object[] convertedParams = null;
    Class[] classes = null;

    if (types != null) {
      convertedParams = convertParams(types, params);
      classes = classFromType(types);
    } else {
      // inference method param types
      boolean found = false;
      Method[] methods = r.getClass().getDeclaredMethods();

      for (Method m : methods) {
        // try to find method by name
        if (!m.getName().equals(methodName)) {
          continue;
        }

        Type[] paramTypes = m.getGenericParameterTypes();
        if (paramTypes.length != params.length) {
          // parameter count doesn't match
          continue;
        } else {
          try {
            // try to convert parameters
            convertedParams = convertParams(paramTypes, params);
          } catch (Exception e) {
            LOGGER.info(
                String.format("The parameter types of method \'%s\' don't match with the arguments", m.getName()));
            continue;
          }
        }

        classes = classFromType(paramTypes);
        found = true;
        break;
      }

      if (!found) {
        throw new ClassNotFoundException("No method found for given parameters");
      }
    }

    if (returnResourceName == null) {
      return invokeMethod(methodName, classes, convertedParams);
    } else {
      return invokeMethod(methodName, classes, convertedParams, returnResourceName);
    }
  }

  /**
   * Call a method of the object that this resource holds
   *
   * @param methodName name of method to call
   * @param paramTypes method parameter types
   * @param params     method parameter values
   * @return return value of the method
   */
  public Object invokeMethod(
      String methodName, Class[] paramTypes, Object[] params) {
    if (r != null) {
      try {
        Method method = r.getClass().getMethod(
            methodName,
            paramTypes);
        method.setAccessible(true);
        Object ret = method.invoke(r, params);
        return ret;
      } catch (Exception e) {
        logException(e);
        return null;
      }
    } else {
      return null;
    }
  }

  /**
   * Call a method of the object that this resource holds and save return value as a resource
   *
   * @param methodName         name of method to call
   * @param paramTypes         method parameter types
   * @param params             method parameter values
   * @param returnResourceName name of resource that return value will be saved
   * @return Resource that holds return value
   */
  public Resource invokeMethod(
      String methodName, Class[] paramTypes, Object[] params, String returnResourceName) {
    if (r != null) {
      try {
        Method method = r.getClass().getMethod(
            methodName,
            paramTypes);
        Object ret = method.invoke(r, params);
        pool.put(
            resourceId.getNoteId(),
            resourceId.getParagraphId(),
            returnResourceName,
            ret
        );
        return pool.get(
            resourceId.getNoteId(),
            resourceId.getParagraphId(),
            returnResourceName);
      } catch (Exception e) {
        logException(e);
        return null;
      }
    } else {
      return null;
    }
  }

  public static ByteBuffer serializeObject(Object o) throws IOException {
    if (o == null || !(o instanceof Serializable)) {
      return null;
    }

    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try {
      ObjectOutputStream oos;
      oos = new ObjectOutputStream(out);
      oos.writeObject(o);
      oos.close();
      out.close();
    } catch (Exception e) {
      e.printStackTrace();
    }
    return ByteBuffer.wrap(out.toByteArray());
  }

  public static Object deserializeObject(ByteBuffer buf)
      throws IOException, ClassNotFoundException {
    if (buf == null) {
      return null;
    }
    InputStream ins = ByteBufferInputStream.get(buf);
    ObjectInputStream oin;
    Object object = null;

    oin = new ObjectInputStream(ins);
    object = oin.readObject();
    oin.close();
    ins.close();

    return object;
  }

  private void logException(Exception e) {
    Logger LOGGER = LoggerFactory.getLogger(Resource.class);
    LOGGER.error(e.getMessage(), e);
  }

  public String toJson() {
    return gson.toJson(this);
  }

  public static Resource fromJson(String json) {
    return gson.fromJson(json, Resource.class);
  }

  private ParameterizedType [] typeFromName(String [] classNames) throws ClassNotFoundException {
    if (classNames == null) {
      return null;
    }
    ParameterizedType[] types = new ParameterizedType[classNames.length];
    for (int i = 0; i < classNames.length; i++) {
      types[i] = typeFromName(classNames[i]);
    }
    return types;
  }

  private ParameterizedType typeFromName(String commaSeparatedClasses) throws ClassNotFoundException {
    String[] classNames = commaSeparatedClasses.split(",");
    Class [] arguments;

    if (classNames.length > 1) {
      arguments = new Class[classNames.length - 1];
      for (int i = 1; i < classNames.length; i++) {
        arguments[i - 1] = loadClass(classNames[i]);
      }
    } else {
      arguments = new Class[0];
    }

    Class rawType = loadClass(classNames[0]);

    return new ParameterizedType() {
      @Override
      public Type[] getActualTypeArguments() {
        return arguments;
      }

      @Override
      public Type getRawType() {
        return rawType;
      }

      @Override
      public Type getOwnerType() {
        return null;
      }
    };
  }

  private Class [] classFromType(Type[] types) throws ClassNotFoundException {
    Class[] cls = new Class[types.length];
    for (int i = 0; i < types.length; i++) {
      if (types[i] instanceof ParameterizedType) {
        String typeName = ((ParameterizedType) types[i]).getRawType().getTypeName();
        cls[i] = loadClass(typeName);
      } else {
        cls[i] = loadClass(types[i].getTypeName());
      }
    }
    return cls;
  }


  private Object [] convertParams(Type[] types, Object [] params) {
    Object [] converted = new Object[types.length];

    for (int i = 0; i < types.length; i++) {
      Type type = types[i];
      String typeName;
      if (type instanceof ParameterizedType) {
        typeName = ((ParameterizedType) type).getRawType().getTypeName();
      } else {
        typeName = type.getTypeName();
      }

      Object param = params[i];
      if (param == null) {
        converted[i] = null;
      } else if (param.getClass().getName().equals(typeName)) {
        converted[i] = param;
      } else {
        // try to convert param
        converted[i] = gson.fromJson(gson.toJson(param), type);
      }
    }

    return converted;
  }

  private Class loadClass(String className) throws ClassNotFoundException {
    switch(className) {
      case "byte":
        return byte.class;
      case "short":
        return short.class;
      case "int":
        return int.class;
      case "long":
        return long.class;
      case "float":
        return float.class;
      case "double":
        return double.class;
      case "boolean":
        return boolean.class;
      case "char":
        return char.class;
      default:
        return getClass().getClassLoader().loadClass(className);
    }
  }
}
