/*
 * 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
 * <p>
 * http://www.apache.org/licenses/LICENSE-2.0
 * <p>
 * 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.nutch.indexwriter.cloudsearch;

import java.lang.invoke.MethodHandles;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.AbstractMap;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.nutch.indexer.IndexWriter;
import org.apache.nutch.indexer.IndexWriterParams;
import org.apache.nutch.indexer.NutchDocument;
import org.apache.nutch.indexer.NutchField;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.amazonaws.regions.RegionUtils;
import com.amazonaws.services.cloudsearchdomain.AmazonCloudSearchDomainClient;
import com.amazonaws.services.cloudsearchdomain.model.ContentType;
import com.amazonaws.services.cloudsearchdomain.model.UploadDocumentsRequest;
import com.amazonaws.services.cloudsearchdomain.model.UploadDocumentsResult;
import com.amazonaws.services.cloudsearchv2.AmazonCloudSearchClient;
import com.amazonaws.services.cloudsearchv2.model.DescribeDomainsRequest;
import com.amazonaws.services.cloudsearchv2.model.DescribeDomainsResult;
import com.amazonaws.services.cloudsearchv2.model.DescribeIndexFieldsRequest;
import com.amazonaws.services.cloudsearchv2.model.DescribeIndexFieldsResult;
import com.amazonaws.services.cloudsearchv2.model.DomainStatus;
import com.amazonaws.services.cloudsearchv2.model.IndexFieldStatus;
import com.amazonaws.util.json.JSONException;
import com.amazonaws.util.json.JSONObject;

/**
 * Writes documents to CloudSearch.
 */
public class CloudSearchIndexWriter implements IndexWriter {
  private static final Logger LOG = LoggerFactory
      .getLogger(MethodHandles.lookup().lookupClass());

  private static final int MAX_SIZE_BATCH_BYTES = 5242880;
  private static final int MAX_SIZE_DOC_BYTES = 1048576;

  private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat(
      "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");

  private AmazonCloudSearchDomainClient client;

  private int maxDocsInBatch = -1;

  private StringBuffer buffer;

  private int numDocsInBatch = 0;

  private boolean dumpBatchFilesToTemp = false;

  private Configuration conf;

  private Map<String, String> csfields = new HashMap<String, String>();

  private String endpoint;
  private String regionName;

  @Override
  public void open(Configuration conf, String name) throws IOException {
    //Implementation not required
  }

  @Override
  public void open(IndexWriterParams parameters) throws IOException {
    //    LOG.debug("CloudSearchIndexWriter.open() name={} ", name);

    endpoint = parameters.get(CloudSearchConstants.ENDPOINT);
    dumpBatchFilesToTemp = parameters
        .getBoolean(CloudSearchConstants.BATCH_DUMP, false);
    this.regionName = parameters.get(CloudSearchConstants.REGION);

    if (StringUtils.isBlank(endpoint) && !dumpBatchFilesToTemp) {
      String message = "Missing CloudSearch endpoint. Should set it set via -D "
          + CloudSearchConstants.ENDPOINT + " or in nutch-site.xml";
      message += "\n" + describe();
      LOG.error(message);
      throw new RuntimeException(message);
    }

    maxDocsInBatch = parameters.getInt(CloudSearchConstants.MAX_DOCS_BATCH, -1);

    buffer = new StringBuffer(MAX_SIZE_BATCH_BYTES).append('[');

    if (dumpBatchFilesToTemp) {
      // only dumping to local file
      // no more config required
      return;
    }

    if (StringUtils.isBlank(endpoint)) {
      throw new RuntimeException("endpoint not set for CloudSearch");
    }

    AmazonCloudSearchClient cl = new AmazonCloudSearchClient();
    if (StringUtils.isNotBlank(regionName)) {
      cl.setRegion(RegionUtils.getRegion(regionName));
    }

    String domainName = null;

    // retrieve the domain name
    DescribeDomainsResult domains = cl
        .describeDomains(new DescribeDomainsRequest());

    Iterator<DomainStatus> dsiter = domains.getDomainStatusList().iterator();
    while (dsiter.hasNext()) {
      DomainStatus ds = dsiter.next();
      if (ds.getDocService().getEndpoint().equals(endpoint)) {
        domainName = ds.getDomainName();
        break;
      }
    }

    // check domain name
    if (StringUtils.isBlank(domainName)) {
      throw new RuntimeException(
          "No domain name found for CloudSearch endpoint");
    }

    DescribeIndexFieldsResult indexDescription = cl.describeIndexFields(
        new DescribeIndexFieldsRequest().withDomainName(domainName));
    for (IndexFieldStatus ifs : indexDescription.getIndexFields()) {
      String indexname = ifs.getOptions().getIndexFieldName();
      String indextype = ifs.getOptions().getIndexFieldType();
      LOG.info("CloudSearch index name {} of type {}", indexname, indextype);
      csfields.put(indexname, indextype);
    }

    client = new AmazonCloudSearchDomainClient();
    client.setEndpoint(endpoint);
  }

  @Override
  public void delete(String url) throws IOException {

    try {
      JSONObject doc_builder = new JSONObject();

      doc_builder.put("type", "delete");

      // generate the id from the url
      String ID = CloudSearchUtils.getID(url);
      doc_builder.put("id", ID);

      // add to the batch
      addToBatch(doc_builder.toString(2), url);

    } catch (JSONException e) {
      LOG.error("Exception caught while building JSON object", e);
    }

  }

  @Override
  public void update(NutchDocument doc) throws IOException {
    write(doc);
  }

  @Override
  public void write(NutchDocument doc) throws IOException {
    try {
      JSONObject doc_builder = new JSONObject();

      doc_builder.put("type", "add");

      String url = doc.getField("url").toString();

      // generate the id from the url
      String ID = CloudSearchUtils.getID(url);
      doc_builder.put("id", ID);

      JSONObject fields = new JSONObject();

      for (final Entry<String, NutchField> e : doc) {
        String fieldname = cleanFieldName(e.getKey());
        String type = csfields.get(fieldname);

        // undefined in index
        if (!dumpBatchFilesToTemp && type == null) {
          LOG.info(
              "Field {} not defined in CloudSearch domain for {} - skipping.",
              fieldname, url);
          continue;
        }

        List<Object> values = e.getValue().getValues();
        // write the values
        for (Object value : values) {
          // Convert dates to an integer
          if (value instanceof Date) {
            Date d = (Date) value;
            value = DATE_FORMAT.format(d);
          }
          // normalise strings
          else if (value instanceof String) {
            value = CloudSearchUtils.stripNonCharCodepoints((String) value);
          }

          fields.accumulate(fieldname, value);
        }
      }

      doc_builder.put("fields", fields);

      addToBatch(doc_builder.toString(2), url);

    } catch (JSONException e) {
      LOG.error("Exception caught while building JSON object", e);
    }
  }

  private void addToBatch(String currentDoc, String url) throws IOException {
    int currentDocLength = currentDoc.getBytes(StandardCharsets.UTF_8).length;

    // check that the doc is not too large -> skip it if it does
    if (currentDocLength > MAX_SIZE_DOC_BYTES) {
      LOG.error("Doc too large. currentDoc.length {} : {}", currentDocLength,
          url);
      return;
    }

    int currentBufferLength = buffer.toString()
        .getBytes(StandardCharsets.UTF_8).length;

    LOG.debug("currentDoc.length {}, buffer length {}", currentDocLength,
        currentBufferLength);

    // can add it to the buffer without overflowing?
    if (currentDocLength + 2 + currentBufferLength < MAX_SIZE_BATCH_BYTES) {
      if (numDocsInBatch != 0)
        buffer.append(',');
      buffer.append(currentDoc);
      numDocsInBatch++;
    }
    // flush the previous batch and create a new one with this doc
    else {
      commit();
      buffer.append(currentDoc);
      numDocsInBatch++;
    }

    // have we reached the max number of docs in a batch after adding
    // this doc?
    if (maxDocsInBatch > 0 && numDocsInBatch == maxDocsInBatch) {
      commit();
    }
  }

  @Override
  public void commit() throws IOException {

    // nothing to do
    if (numDocsInBatch == 0) {
      return;
    }

    // close the array
    buffer.append(']');

    LOG.info("Sending {} docs to CloudSearch", numDocsInBatch);

    byte[] bb = buffer.toString().getBytes(StandardCharsets.UTF_8);

    if (dumpBatchFilesToTemp) {
      try {
        File temp = File.createTempFile("CloudSearch_", ".json");
        FileUtils.writeByteArrayToFile(temp, bb);
        LOG.info("Wrote batch file {}", temp.getName());
      } catch (IOException e1) {
        LOG.error("Exception while generating batch file", e1);
      } finally {
        // reset buffer and doc counter
        buffer = new StringBuffer(MAX_SIZE_BATCH_BYTES).append('[');
        numDocsInBatch = 0;
      }
      return;
    }
    // not in debug mode
    try (InputStream inputStream = new ByteArrayInputStream(bb)) {
      UploadDocumentsRequest batch = new UploadDocumentsRequest();
      batch.setContentLength((long) bb.length);
      batch.setContentType(ContentType.Applicationjson);
      batch.setDocuments(inputStream);
      @SuppressWarnings("unused")
      UploadDocumentsResult result = client.uploadDocuments(batch);
    } catch (Exception e) {
      LOG.error("Exception while sending batch", e);
      LOG.error(buffer.toString());
    } finally {
      // reset buffer and doc counter
      buffer = new StringBuffer(MAX_SIZE_BATCH_BYTES).append('[');
      numDocsInBatch = 0;
    }
  }

  @Override
  public void close() throws IOException {
    // This will flush any unsent documents.
    commit();
    // close the client
    if (client != null) {
      client.shutdown();
    }
  }

  @Override
  public Configuration getConf() {
    return this.conf;
  }

  @Override
  public void setConf(Configuration conf) {
    this.conf = conf;
  }

  /**
   * Returns {@link Map} with the specific parameters the IndexWriter instance can take.
   *
   * @return The values of each row. It must have the form &#60;KEY,&#60;DESCRIPTION,VALUE&#62;&#62;.
   */
  @Override
  public Map<String, Entry<String, Object>> describe() {
    Map<String, Map.Entry<String, Object>> properties = new LinkedHashMap<>();

    properties.put(CloudSearchConstants.ENDPOINT, new AbstractMap.SimpleEntry<>(
        "Endpoint where service requests should be submitted.", this.endpoint));
    properties.put(CloudSearchConstants.REGION,
        new AbstractMap.SimpleEntry<>("Region name.", this.regionName));
    properties.put(CloudSearchConstants.BATCH_DUMP,
        new AbstractMap.SimpleEntry<>("true to send documents to a local file.",
            this.dumpBatchFilesToTemp));
    properties.put(CloudSearchConstants.MAX_DOCS_BATCH,
        new AbstractMap.SimpleEntry<>(
            "Maximum number of documents to send as a batch to CloudSearch.",
            this.maxDocsInBatch));

    return properties;
  }

  /**
   * Remove the non-cloudSearch-legal characters. Note that this might convert
   * two fields to the same name.
   *
   * @param name
   * @return
   */
  String cleanFieldName(String name) {
    String lowercase = name.toLowerCase();
    return lowercase.replaceAll("[^a-z_0-9]", "_");
  }

}
