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

import java.io.IOException;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.lang.StringUtils;
import org.apache.zeppelin.interpreter.Interpreter;
import org.apache.zeppelin.interpreter.InterpreterContext;
import org.apache.zeppelin.interpreter.InterpreterPropertyBuilder;
import org.apache.zeppelin.interpreter.InterpreterResult;
import org.apache.zeppelin.interpreter.thrift.InterpreterCompletion;
import org.elasticsearch.action.delete.DeleteResponse;
import org.elasticsearch.action.get.GetResponse;
import org.elasticsearch.action.index.IndexResponse;
import org.elasticsearch.action.search.SearchAction;
import org.elasticsearch.action.search.SearchRequestBuilder;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.Client;
import org.elasticsearch.client.transport.TransportClient;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.transport.InetSocketTransportAddress;
import org.elasticsearch.common.xcontent.XContentHelper;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHitField;
import org.elasticsearch.search.aggregations.Aggregation;
import org.elasticsearch.search.aggregations.Aggregations;
import org.elasticsearch.search.aggregations.InternalMultiBucketAggregation;
import org.elasticsearch.search.aggregations.bucket.InternalSingleBucketAggregation;
import org.elasticsearch.search.aggregations.bucket.MultiBucketsAggregation;
import org.elasticsearch.search.aggregations.metrics.InternalMetricsAggregation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.github.wnameless.json.flattener.JsonFlattener;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;


/**
 * Elasticsearch Interpreter for Zeppelin.
 */
public class ElasticsearchInterpreter extends Interpreter {

  private static Logger logger = LoggerFactory.getLogger(ElasticsearchInterpreter.class);

  private static final String HELP = "Elasticsearch interpreter:\n"
    + "General format: <command> /<indices>/<types>/<id> <option> <JSON>\n"
    + "  - indices: list of indices separated by commas (depends on the command)\n"
    + "  - types: list of document types separated by commas (depends on the command)\n"
    + "Commands:\n"
    + "  - search /indices/types <query>\n"
    + "    . indices and types can be omitted (at least, you have to provide '/')\n"
    + "    . a query is either a JSON-formatted query, nor a lucene query\n"
    + "  - size <value>\n"
    + "    . defines the size of the result set (default value is in the config)\n"
    + "    . if used, this command must be declared before a search command\n"
    + "  - count /indices/types <query>\n"
    + "    . same comments as for the search\n"
    + "  - get /index/type/id\n"
    + "  - delete /index/type/id\n"
    + "  - index /ndex/type/id <json-formatted document>\n"
    + "    . the id can be omitted, elasticsearch will generate one";

  private static final List<String> COMMANDS = Arrays.asList(
    "count", "delete", "get", "help", "index", "search");

  private static final Pattern FIELD_NAME_PATTERN = Pattern.compile("\\[\\\\\"(.+)\\\\\"\\](.*)");


  public static final String ELASTICSEARCH_HOST = "elasticsearch.host";
  public static final String ELASTICSEARCH_PORT = "elasticsearch.port";
  public static final String ELASTICSEARCH_CLUSTER_NAME = "elasticsearch.cluster.name";
  public static final String ELASTICSEARCH_RESULT_SIZE = "elasticsearch.result.size";

  static {
    Interpreter.register(
      "elasticsearch",
      "elasticsearch",
      ElasticsearchInterpreter.class.getName(),
        new InterpreterPropertyBuilder()
          .add(ELASTICSEARCH_HOST, "localhost", "The host for Elasticsearch")
          .add(ELASTICSEARCH_PORT, "9300", "The port for Elasticsearch")
          .add(ELASTICSEARCH_CLUSTER_NAME, "elasticsearch", "The cluster name for Elasticsearch")
          .add(ELASTICSEARCH_RESULT_SIZE, "10", "The size of the result set of a search query")
          .build());
  }

  private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
  private Client client;
  private String host = "localhost";
  private int port = 9300;
  private String clusterName = "elasticsearch";
  private int resultSize = 10;

  public ElasticsearchInterpreter(Properties property) {
    super(property);
    this.host = getProperty(ELASTICSEARCH_HOST);
    this.port = Integer.parseInt(getProperty(ELASTICSEARCH_PORT));
    this.clusterName = getProperty(ELASTICSEARCH_CLUSTER_NAME);
    this.resultSize = Integer.parseInt(getProperty(ELASTICSEARCH_RESULT_SIZE));
  }

  @Override
  public void open() {
    try {
      logger.info("prop={}", getProperty());
      final Settings settings = Settings.settingsBuilder()
        .put("cluster.name", clusterName)
        .put(getProperty())
        .build();
      client = TransportClient.builder().settings(settings).build()
        .addTransportAddress(new InetSocketTransportAddress(InetAddress.getByName(host), port));
    }
    catch (IOException e) {
      logger.error("Open connection with Elasticsearch", e);
    }
  }

  @Override
  public void close() {
    if (client != null) {
      client.close();
    }
  }

  @Override
  public InterpreterResult interpret(String cmd, InterpreterContext interpreterContext) {
    logger.info("Run Elasticsearch command '" + cmd + "'");

    if (StringUtils.isEmpty(cmd) || StringUtils.isEmpty(cmd.trim())) {
      return new InterpreterResult(InterpreterResult.Code.SUCCESS);
    }

    int currentResultSize = resultSize;

    if (client == null) {
      return new InterpreterResult(InterpreterResult.Code.ERROR,
        "Problem with the Elasticsearch client, please check your configuration (host, port,...)");
    }

    String[] items = StringUtils.split(cmd.trim(), " ", 3);

    // Process some specific commands (help, size, ...)
    if ("help".equalsIgnoreCase(items[0])) {
      return processHelp(InterpreterResult.Code.SUCCESS, null);
    }

    if ("size".equalsIgnoreCase(items[0])) {
      // In this case, the line with size must be followed by a search,
      // so we will continue with the next lines
      final String[] lines = StringUtils.split(cmd.trim(), "\n", 2);

      if (lines.length < 2) {
        return processHelp(InterpreterResult.Code.ERROR,
                           "Size cmd must be followed by a search");
      }

      final String[] sizeLine = StringUtils.split(lines[0], " ", 2);
      if (sizeLine.length != 2) {
        return processHelp(InterpreterResult.Code.ERROR, "Right format is : size <value>");
      }
      currentResultSize = Integer.parseInt(sizeLine[1]);

      items = StringUtils.split(lines[1].trim(), " ", 3);
    }

    if (items.length < 2) {
      return processHelp(InterpreterResult.Code.ERROR, "Arguments missing");
    }

    final String method = items[0];
    final String url = items[1];
    final String data = items.length > 2 ? items[2].trim() : null;

    final String[] urlItems = StringUtils.split(url.trim(), "/");

    try {
      if ("get".equalsIgnoreCase(method)) {
        return processGet(urlItems);
      }
      else if ("count".equalsIgnoreCase(method)) {
        return processCount(urlItems, data);
      }
      else if ("search".equalsIgnoreCase(method)) {
        return processSearch(urlItems, data, currentResultSize);
      }
      else if ("index".equalsIgnoreCase(method)) {
        return processIndex(urlItems, data);
      }
      else if ("delete".equalsIgnoreCase(method)) {
        return processDelete(urlItems);
      }

      return processHelp(InterpreterResult.Code.ERROR, "Unknown command");
    }
    catch (Exception e) {
      return new InterpreterResult(InterpreterResult.Code.ERROR, "Error : " + e.getMessage());
    }
  }

  @Override
  public void cancel(InterpreterContext interpreterContext) {
    // Nothing to do
  }

  @Override
  public FormType getFormType() {
    return FormType.SIMPLE;
  }

  @Override
  public int getProgress(InterpreterContext interpreterContext) {
    return 0;
  }

  @Override
  public List<InterpreterCompletion> completion(String s, int i) {
    final List suggestions = new ArrayList<>();

    if (StringUtils.isEmpty(s)) {
      suggestions.addAll(COMMANDS);
    }
    else {
      for (String cmd : COMMANDS) {
        if (cmd.toLowerCase().contains(s)) {
          suggestions.add(cmd);
        }
      }
    }

    return suggestions;
  }

  private InterpreterResult processHelp(InterpreterResult.Code code, String additionalMessage) {
    final StringBuffer buffer = new StringBuffer();

    if (additionalMessage != null) {
      buffer.append(additionalMessage).append("\n");
    }

    buffer.append(HELP).append("\n");

    return new InterpreterResult(code, InterpreterResult.Type.TEXT, buffer.toString());
  }

  /**
   * Processes a "get" request.
   *
   * @param urlItems Items of the URL
   * @return Result of the get request, it contains a JSON-formatted string
   */
  private InterpreterResult processGet(String[] urlItems) {

    if (urlItems.length != 3
        || StringUtils.isEmpty(urlItems[0])
        || StringUtils.isEmpty(urlItems[1])
        || StringUtils.isEmpty(urlItems[2])) {
      return new InterpreterResult(InterpreterResult.Code.ERROR,
                                   "Bad URL (it should be /index/type/id)");
    }

    final GetResponse response = client
      .prepareGet(urlItems[0], urlItems[1], urlItems[2])
      .get();
    if (response.isExists()) {
      final String json = gson.toJson(response.getSource());

      return new InterpreterResult(
                    InterpreterResult.Code.SUCCESS,
                    InterpreterResult.Type.TEXT,
                    json);
    }

    return new InterpreterResult(InterpreterResult.Code.ERROR, "Document not found");
  }

  /**
   * Processes a "count" request.
   *
   * @param urlItems Items of the URL
   * @param data May contains the JSON of the request
   * @return Result of the count request, it contains the total hits
   */
  private InterpreterResult processCount(String[] urlItems, String data) {

    if (urlItems.length > 2) {
      return new InterpreterResult(InterpreterResult.Code.ERROR,
                                   "Bad URL (it should be /index1,index2,.../type1,type2,...)");
    }

    final SearchResponse response = searchData(urlItems, data, 0);

    return new InterpreterResult(
      InterpreterResult.Code.SUCCESS,
      InterpreterResult.Type.TEXT,
      "" + response.getHits().getTotalHits());
  }

  /**
   * Processes a "search" request.
   *
   * @param urlItems Items of the URL
   * @param data May contains the JSON of the request
   * @param size Limit of result set
   * @return Result of the search request, it contains a tab-formatted string of the matching hits
   */
  private InterpreterResult processSearch(String[] urlItems, String data, int size) {

    if (urlItems.length > 2) {
      return new InterpreterResult(InterpreterResult.Code.ERROR,
                                   "Bad URL (it should be /index1,index2,.../type1,type2,...)");
    }

    final SearchResponse response = searchData(urlItems, data, size);

    return buildResponseMessage(response);
  }

  /**
   * Processes a "index" request.
   *
   * @param urlItems Items of the URL
   * @param data JSON to be indexed
   * @return Result of the index request, it contains the id of the document
   */
  private InterpreterResult processIndex(String[] urlItems, String data) {

    if (urlItems.length < 2 || urlItems.length > 3) {
      return new InterpreterResult(InterpreterResult.Code.ERROR,
                                   "Bad URL (it should be /index/type or /index/type/id)");
    }

    final IndexResponse response = client
      .prepareIndex(urlItems[0], urlItems[1], urlItems.length == 2 ? null : urlItems[2])
      .setSource(data)
      .get();

    return new InterpreterResult(
      InterpreterResult.Code.SUCCESS,
      InterpreterResult.Type.TEXT,
      response.getId());
  }

  /**
   * Processes a "delete" request.
   *
   * @param urlItems Items of the URL
   * @return Result of the delete request, it contains the id of the deleted document
   */
  private InterpreterResult processDelete(String[] urlItems) {

    if (urlItems.length != 3
        || StringUtils.isEmpty(urlItems[0])
        || StringUtils.isEmpty(urlItems[1])
        || StringUtils.isEmpty(urlItems[2])) {
      return new InterpreterResult(InterpreterResult.Code.ERROR,
                                   "Bad URL (it should be /index/type/id)");
    }

    final DeleteResponse response = client
      .prepareDelete(urlItems[0], urlItems[1], urlItems[2])
      .get();

    if (response.isFound()) {
      return new InterpreterResult(
        InterpreterResult.Code.SUCCESS,
        InterpreterResult.Type.TEXT,
        response.getId());
    }

    return new InterpreterResult(InterpreterResult.Code.ERROR, "Document not found");
  }

  private SearchResponse searchData(String[] urlItems, String query, int size) {

    final SearchRequestBuilder reqBuilder = new SearchRequestBuilder(
      client, SearchAction.INSTANCE);
    reqBuilder.setIndices();

    if (urlItems.length >= 1) {
      reqBuilder.setIndices(StringUtils.split(urlItems[0], ","));
    }
    if (urlItems.length > 1) {
      reqBuilder.setTypes(StringUtils.split(urlItems[1], ","));
    }

    if (!StringUtils.isEmpty(query)) {
      // The query can be either JSON-formatted, nor a Lucene query
      // So, try to parse as a JSON => if there is an error, consider the query a Lucene one
      try {
        final Map source = gson.fromJson(query, Map.class);
        reqBuilder.setExtraSource(source);
      }
      catch (JsonParseException e) {
        // This is not a JSON (or maybe not well formatted...)
        reqBuilder.setQuery(QueryBuilders.queryStringQuery(query).analyzeWildcard(true));
      }
    }

    reqBuilder.setSize(size);

    final SearchResponse response = reqBuilder.get();

    return response;
  }

  private InterpreterResult buildAggResponseMessage(Aggregations aggregations) {

    // Only the result of the first aggregation is returned
    //
    final Aggregation agg = aggregations.asList().get(0);
    InterpreterResult.Type resType = InterpreterResult.Type.TEXT;
    String resMsg = "";

    if (agg instanceof InternalMetricsAggregation) {
      resMsg = XContentHelper.toString((InternalMetricsAggregation) agg).toString();
    }
    else if (agg instanceof InternalSingleBucketAggregation) {
      resMsg = XContentHelper.toString((InternalSingleBucketAggregation) agg).toString();
    }
    else if (agg instanceof InternalMultiBucketAggregation) {
      final StringBuffer buffer = new StringBuffer("key\tdoc_count");

      final InternalMultiBucketAggregation multiBucketAgg = (InternalMultiBucketAggregation) agg;
      for (MultiBucketsAggregation.Bucket bucket : multiBucketAgg.getBuckets()) {
        buffer.append("\n")
          .append(bucket.getKeyAsString())
          .append("\t")
          .append(bucket.getDocCount());
      }

      resType = InterpreterResult.Type.TABLE;
      resMsg = buffer.toString();
    }

    return new InterpreterResult(InterpreterResult.Code.SUCCESS, resType, resMsg);
  }

  private String buildSearchHitsResponseMessage(SearchHit[] hits) {

    if (hits == null || hits.length == 0) {
      return "";
    }

    //First : get all the keys in order to build an ordered list of the values for each hit
    //
    final Map<String, Object> hitFields = new HashMap<>();
    final List<Map<String, Object>> flattenHits = new LinkedList<>();
    final Set<String> keys = new TreeSet<>();
    for (SearchHit hit : hits) {
      // Fields can be found either in _source, or in fields (it depends on the query)
      //
      String json = hit.getSourceAsString();
      if (json == null) {
        hitFields.clear();
        for (SearchHitField hitField : hit.getFields().values()) {
          hitFields.put(hitField.getName(), hitField.getValues());
        }
        json = gson.toJson(hitFields);
      }

      final Map<String, Object> flattenJsonMap = JsonFlattener.flattenAsMap(json);
      final Map<String, Object> flattenMap = new HashMap<>();
      for (Iterator<String> iter = flattenJsonMap.keySet().iterator(); iter.hasNext(); ) {
        // Replace keys that match a format like that : [\"keyname\"][0]
        final String fieldName = iter.next();
        final Matcher fieldNameMatcher = FIELD_NAME_PATTERN.matcher(fieldName);
        if (fieldNameMatcher.matches()) {
          flattenMap.put(fieldNameMatcher.group(1) + fieldNameMatcher.group(2),
            flattenJsonMap.get(fieldName));
        }
        else {
          flattenMap.put(fieldName, flattenJsonMap.get(fieldName));
        }
      }
      flattenHits.add(flattenMap);

      for (String key : flattenMap.keySet()) {
        keys.add(key);
      }
    }

    // Next : build the header of the table
    //
    final StringBuffer buffer = new StringBuffer();
    for (String key : keys) {
      buffer.append(key).append('\t');
    }
    buffer.replace(buffer.lastIndexOf("\t"), buffer.lastIndexOf("\t") + 1, "\n");

    // Finally : build the result by using the key set
    //
    for (Map<String, Object> hit : flattenHits) {
      for (String key : keys) {
        final Object val = hit.get(key);
        if (val != null) {
          buffer.append(val);
        }
        buffer.append('\t');
      }
      buffer.replace(buffer.lastIndexOf("\t"), buffer.lastIndexOf("\t") + 1, "\n");
    }

    return buffer.toString();
  }

  private InterpreterResult buildResponseMessage(SearchResponse response) {

    final Aggregations aggregations = response.getAggregations();

    if (aggregations != null && aggregations.asList().size() > 0) {
      return buildAggResponseMessage(aggregations);
    }

    return new InterpreterResult(
      InterpreterResult.Code.SUCCESS,
      InterpreterResult.Type.TABLE,
      buildSearchHitsResponseMessage(response.getHits().getHits()));
  }
}
