/*
 * 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.solr.api;


import java.io.Closeable;
import java.io.IOException;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.solr.client.solrj.SolrRequest;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.SpecProvider;
import org.apache.solr.common.util.*;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.response.SolrQueryResponse;
import org.apache.solr.security.AuthorizationContext;
import org.apache.solr.security.PermissionNameProvider;
import org.apache.solr.util.SolrJacksonAnnotationInspector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class implements an Api just from  an annotated java class
 * The class must have an annotation {@link EndPoint}
 * Each method must have an annotation {@link Command}
 * The methods that implement a command should have the first 2 parameters
 * {@link SolrQueryRequest} and {@link SolrQueryResponse} or it may optionally
 * have a third parameter which could be a java class annotated with jackson annotations.
 * The third parameter is only valid if it is using a json command payload
 */

public class AnnotatedApi extends Api implements PermissionNameProvider , Closeable {
  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

  public static final String ERR = "Error executing commands :";
  private EndPoint endPoint;
  private final Map<String, Cmd> commands ;
  private final Cmd singletonCommand;
  private final Api fallback;

  @Override
  public void close() throws IOException {
    for (Cmd value : commands.values()) {
      if (value.obj instanceof Closeable) {
        ((Closeable) value.obj).close();
      }
      break;// all objects are same so close only one
    }

  }

  public EndPoint getEndPoint() {
    return endPoint;
  }

  public static List<Api> getApis(Object obj) {
    return getApis(obj.getClass(), obj);
  }
  public static List<Api> getApis(Class<? extends Object> theClass , Object obj)  {
    Class<?> klas = null;
    try {
      klas = MethodHandles.publicLookup().accessClass(theClass);
    } catch (IllegalAccessException e) {
      throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Method may be non-public/inaccessible", e);
    }
    if (klas.getAnnotation(EndPoint.class) != null) {
      EndPoint endPoint = klas.getAnnotation(EndPoint.class);
      List<Method> methods = new ArrayList<>();
      Map<String, Cmd> commands = new HashMap<>();
      for (Method m : klas.getMethods()) {
        Command command = m.getAnnotation(Command.class);
        if (command != null) {
          methods.add(m);
          if (commands.containsKey(command.name())) {
            throw new RuntimeException("Duplicate commands " + command.name());
          }
          commands.put(command.name(), new Cmd(command.name(), obj, m));
        }
      }
      if (commands.isEmpty()) {
        throw new RuntimeException("No method with @Command in class: " + klas.getName());
      }
      SpecProvider specProvider = readSpec(endPoint, methods);
      return Collections.singletonList(new AnnotatedApi(specProvider, endPoint, commands, null));
    } else {
      List<Api> apis = new ArrayList<>();
      for (Method m : klas.getMethods()) {
        EndPoint endPoint = m.getAnnotation(EndPoint.class);
        if (endPoint == null) continue;
        Cmd cmd = new Cmd("", obj, m);
        SpecProvider specProvider = readSpec(endPoint, Collections.singletonList(m));
        apis.add(new AnnotatedApi(specProvider, endPoint, Collections.singletonMap("", cmd), null));
      }
      if (apis.isEmpty()) {
        throw new RuntimeException("Invalid Class : " + klas.getName() + " No @EndPoints");
      }

      return apis;
    }
  }


  private AnnotatedApi(SpecProvider specProvider, EndPoint endPoint, Map<String, Cmd> commands, Api fallback) {
    super(specProvider);
    this.endPoint = endPoint;
    this.fallback = fallback;
    this.commands = commands;
    this.singletonCommand = commands.get("");
  }

  @Override
  public Name getPermissionName(AuthorizationContext request) {
    return endPoint.permission();
  }

  @SuppressWarnings({"unchecked", "rawtypes"})
  private static SpecProvider readSpec(EndPoint endPoint, List<Method> m) {
    return () -> {
      Map map = new LinkedHashMap();
      List<String> methods = new ArrayList<>();
      for (SolrRequest.METHOD method : endPoint.method()) {
        methods.add(method.name());
      }
      map.put("methods", methods);
      map.put("url", new ValidatingJsonMap(Collections.singletonMap("paths", Arrays.asList(endPoint.path()))));
      Map<String, Object> cmds = new HashMap<>();

      for (Method method : m) {
        Command command = method.getAnnotation(Command.class);
        if (command != null && !command.name().isEmpty()) {
          cmds.put(command.name(), AnnotatedApi.createSchema(method));
        }
      }
      if (!cmds.isEmpty()) {
        map.put("commands", cmds);
      }
      return new ValidatingJsonMap(map);
    };

  }


  @Override
  public void call(SolrQueryRequest req, SolrQueryResponse rsp) {
    if (singletonCommand != null) {
      singletonCommand.invoke(req, rsp, null);
      return;
    }

    List<CommandOperation> cmds = req.getCommands(true);
    boolean allExists = true;
    for (CommandOperation cmd : cmds) {
      if (!commands.containsKey(cmd.name)) {
        cmd.addError("No such command supported: " + cmd.name);
        allExists = false;
      }
    }
    if (!allExists) {
      if (fallback != null) {
        fallback.call(req, rsp);
        return;
      } else {
        throw new ApiBag.ExceptionWithErrObject(SolrException.ErrorCode.BAD_REQUEST, "Error processing commands",
            CommandOperation.captureErrors(cmds));
      }
    }

    for (CommandOperation cmd : cmds) {
      commands.get(cmd.name).invoke(req, rsp, cmd);
    }

    @SuppressWarnings({"rawtypes"})
    List<Map> errs = CommandOperation.captureErrors(cmds);
    if (!errs.isEmpty()) {
      log.error("{}{}", ERR, Utils.toJSONString(errs));
      throw new ApiBag.ExceptionWithErrObject(SolrException.ErrorCode.BAD_REQUEST, ERR, errs);
    }

  }

  static class Cmd {
    final String command;
    final MethodHandle method;
    final Object obj;
    ObjectMapper mapper = SolrJacksonAnnotationInspector.createObjectMapper();
    int paramsCount;
    @SuppressWarnings({"rawtypes"})
    Class parameterClass;
    boolean isWrappedInPayloadObj = false;


    Cmd(String command, Object obj, Method method) {
      this.command = command;
      this.obj = obj;
      try {
        this.method = MethodHandles.publicLookup().unreflect(method);
      } catch (IllegalAccessException e) {
        throw new RuntimeException("Unable to access method, may be not public or accessible ", e);
      }
      Class<?>[] parameterTypes = method.getParameterTypes();
      paramsCount = parameterTypes.length;
      if (parameterTypes.length == 1) {
        readPayloadType(method.getGenericParameterTypes()[0]);
      } else if (parameterTypes.length == 3) {
        if (parameterTypes[0] != SolrQueryRequest.class || parameterTypes[1] != SolrQueryResponse.class) {
          throw new RuntimeException("Invalid params for method " + method);
        }
        Type t = method.getGenericParameterTypes()[2];
        readPayloadType(t);
      }
      if (parameterTypes.length > 3) {
        throw new RuntimeException("Invalid params count for method " + method);
      }
    }

    @SuppressWarnings("rawtypes")
    private void readPayloadType(Type t) {
      if (t instanceof ParameterizedType) {
        ParameterizedType typ = (ParameterizedType) t;
        if (typ.getRawType() == PayloadObj.class) {
          isWrappedInPayloadObj = true;
          if(typ.getActualTypeArguments().length == 0){
            //this is a raw type
            parameterClass = Map.class;
            return;
          }
          Type t1 = typ.getActualTypeArguments()[0];
          if (t1 instanceof ParameterizedType) {
            ParameterizedType parameterizedType = (ParameterizedType) t1;
            parameterClass = (Class) parameterizedType.getRawType();
          } else {
            parameterClass = (Class) typ.getActualTypeArguments()[0];
          }
        }
      } else {
        parameterClass = (Class) t;
      }
    }


    @SuppressWarnings({"unchecked"})
    void invoke(SolrQueryRequest req, SolrQueryResponse rsp, CommandOperation cmd) {
      try {
        Object o = null;
        String commandName = null;
        if(paramsCount == 1) {
          if(cmd == null) {
            if(parameterClass != null) {
              try {
                ContentStream stream = req.getContentStreams().iterator().next();
                o = mapper.readValue(stream.getStream(), parameterClass);
              } catch (IOException e) {
                throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "invalid payload", e);
              }
            }
          } else {
            commandName = cmd.name;
            o = cmd.getCommandData();
            if (o instanceof Map && parameterClass != null && parameterClass != Map.class) {
              o = mapper.readValue(Utils.toJSONString(o), parameterClass);
            }
          }
          PayloadObj<Object> payloadObj = new PayloadObj<>(commandName, o, o, req, rsp);
          cmd = payloadObj;
          method.invoke(obj, payloadObj);
          checkForErrorInPayload(cmd);
        } else if (paramsCount == 2) {
          method.invoke(obj, req, rsp);
        } else {
          o = cmd.getCommandData();
          if (o instanceof Map && parameterClass != null) {
            o = mapper.readValue(Utils.toJSONString(o), parameterClass);
          }
          if (isWrappedInPayloadObj) {
            PayloadObj<Object> payloadObj = new PayloadObj<>(cmd.name, cmd.getCommandData(), o, req, rsp);
            cmd = payloadObj;
            method.invoke(obj, req, rsp, payloadObj);
          } else {
            method.invoke(obj, req, rsp, o);
          }
          checkForErrorInPayload(cmd);
        }
      } catch (RuntimeException se) {
        log.error("Error executing command  ", se);
        throw se;
      } catch (Throwable e) {
        log.error("Error executing command : ", e);
        throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, e);
      }

    }

    private void checkForErrorInPayload(CommandOperation cmd) {
      if (cmd.hasError()) {
        throw new ApiBag.ExceptionWithErrObject(SolrException.ErrorCode.BAD_REQUEST, "Error executing command",
            CommandOperation.captureErrors(Collections.singletonList(cmd)));
      }
    }
  }

  public static Map<String, Object> createSchema(Method m) {
    Type[] types = m.getGenericParameterTypes();
    Type t = null;
    if (types.length == 3) t = types[2]; // (SolrQueryRequest req, SolrQueryResponse rsp, PayloadObj<PluginMeta>)
    if(types.length == 1) t = types[0];// (PayloadObj<PluginMeta>)
    if (t != null) {
      if (t instanceof ParameterizedType) {
        ParameterizedType typ = (ParameterizedType) t;
        if (typ.getRawType() == PayloadObj.class) {
          t = typ.getActualTypeArguments()[0];
        }
      }
      return JsonSchemaCreator.getSchema(t);

    }
    return null;
  }

}