/**
 * 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.atlas.repository.graphdb.janus;

import com.google.common.annotations.VisibleForTesting;
import org.apache.atlas.exception.AtlasBaseException;
import org.apache.atlas.model.discovery.AtlasAggregationEntry;
import org.apache.atlas.repository.Constants;
import org.apache.atlas.repository.graphdb.AggregationContext;
import org.apache.atlas.repository.graphdb.AtlasGraphIndexClient;
import org.apache.atlas.repository.graphdb.AtlasGraphManagement;
import org.apache.atlas.type.AtlasStructType.AtlasAttribute;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.collections.MapUtils;
import org.apache.commons.configuration.Configuration;
import org.apache.commons.lang.StringUtils;
import org.apache.solr.client.solrj.SolrClient;
import org.apache.solr.client.solrj.SolrQuery;
import org.apache.solr.client.solrj.SolrRequest;
import org.apache.solr.client.solrj.SolrResponse;
import org.apache.solr.client.solrj.SolrServerException;
import org.apache.solr.client.solrj.request.GenericSolrRequest;
import org.apache.solr.client.solrj.request.V2Request;
import org.apache.solr.client.solrj.response.FacetField;
import org.apache.solr.client.solrj.response.QueryResponse;
import org.apache.solr.client.solrj.response.TermsResponse;
import org.apache.solr.client.solrj.util.ClientUtils;
import org.apache.solr.common.params.CommonParams;
import org.apache.solr.common.util.ContentStream;
import org.apache.solr.common.util.NamedList;
import org.janusgraph.diskstorage.solr.Solr6Index;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.*;

import static org.apache.atlas.repository.Constants.FREETEXT_REQUEST_HANDLER;
import static org.apache.atlas.repository.Constants.VERTEX_INDEX;

public class AtlasJanusGraphIndexClient implements AtlasGraphIndexClient {
    private static final Logger LOG = LoggerFactory.getLogger(AtlasJanusGraphIndexClient.class);

    private static final FreqComparator FREQ_COMPARATOR          = new FreqComparator();
    private static final int            DEFAULT_SUGGESTION_COUNT = 5;
    private static final int            MIN_FACET_COUNT_REQUIRED = 1;
    private static final String         TERMS_PREFIX             = "terms.prefix";
    private static final String         TERMS_FIELD              = "terms.fl";

    private final Configuration configuration;


    public AtlasJanusGraphIndexClient(Configuration configuration) {
        this.configuration = configuration;
    }

    @Override
    public void applySearchWeight(String collectionName, Map<String, Integer> indexFieldName2SearchWeightMap) {
        SolrClient solrClient = null;

        try {
            solrClient = Solr6Index.getSolrClient(); // get solr client using same settings as that of Janus Graph

            if (solrClient == null) {
                LOG.warn("AtlasJanusGraphIndexClient.applySearchWeight(): Non SOLR index stores are not supported yet.");

                return;
            }

            Solr6Index.Mode solrMode = Solr6Index.getSolrMode();

            //1) try updating request handler
            //2) if update fails, try creating request handler

            int       maxAttempts         = configuration != null ? configuration.getInt("index.client.apply.search.weight.max.attempts", 3) : 3;
            int       retryWaitIntervalMs = configuration != null ? configuration.getInt("index.client.apply.search.weight.retry.interval.ms", 1000) : 1000;
            Throwable lastExcp            = null;

            for (int i = 0; i < maxAttempts; i++) {
                if (i > 0) {
                    LOG.warn("Attempt #{} failed! Waiting for {}ms before retry", i, retryWaitIntervalMs);

                    try {
                        Thread.sleep(retryWaitIntervalMs);
                    } catch (Exception excp) {
                        // ignore
                    }
                }

                try {
                    LOG.info("Attempting to update free text request handler {} for collection {}", FREETEXT_REQUEST_HANDLER, collectionName);

                    updateFreeTextRequestHandler(solrClient, collectionName, indexFieldName2SearchWeightMap, solrMode);

                    LOG.info("Successfully updated free text request handler {} for collection {}..", FREETEXT_REQUEST_HANDLER, collectionName);

                    return;
                } catch (Throwable t) {
                    lastExcp = t;

                    LOG.warn("Error encountered in updating request handler {} for collection {}. Will attempt to create one", FREETEXT_REQUEST_HANDLER, collectionName);
                }

                try {
                    LOG.info("Attempting to create free text request handler {} for collection {}", FREETEXT_REQUEST_HANDLER, collectionName);

                    createFreeTextRequestHandler(solrClient, collectionName, indexFieldName2SearchWeightMap, solrMode);
                    LOG.info("Successfully created free text request handler {} for collection {}", FREETEXT_REQUEST_HANDLER, collectionName);

                    return;
                } catch (Throwable t) {
                    lastExcp = t;

                    LOG.warn("Error encountered in creating request handler {} for collection {}", FREETEXT_REQUEST_HANDLER, collectionName, t);
                }
            }

            String msg = String.format("Error encountered in creating/updating request handler %s for collection %s", FREETEXT_REQUEST_HANDLER, collectionName);

            throw lastExcp != null ? new RuntimeException(msg, lastExcp) : new RuntimeException(msg);
        } finally {
            LOG.debug("Releasing the solr client from usage.");

            Solr6Index.releaseSolrClient(solrClient);
        }
    }


    @Override
    public Map<String, List<AtlasAggregationEntry>> getAggregatedMetrics(AggregationContext aggregationContext) {
        SolrClient solrClient = null;

        try {
            solrClient = Solr6Index.getSolrClient(); // get solr client using same settings as that of Janus Graph

            if (solrClient == null) {
                LOG.warn("The indexing system is not solr based. Will return empty Aggregation metrics.");

                return Collections.EMPTY_MAP;
            }

            Set<String>         aggregationCommonFields = aggregationContext.getAggregationFieldNames();
            Set<AtlasAttribute> aggregationAttributes   = aggregationContext.getAggregationAttributes();
            Map<String, String> indexFieldNameCache     = aggregationContext.getIndexFieldNameCache();

            if (CollectionUtils.isEmpty(aggregationCommonFields)) {
                LOG.warn("There are no fields provided for aggregation purpose.");

                if (CollectionUtils.isEmpty(aggregationAttributes)) {
                    LOG.warn("There are no aggregation fields or attributes are provided. Will return empty metrics.");

                    return Collections.EMPTY_MAP;
                }
            }

            if (CollectionUtils.isEmpty(aggregationAttributes)) {
                LOG.warn("There no attributes provided for aggregation purpose.");
            }

            Map<String, String>   indexFieldName2PropertyKeyNameMap = new HashMap<>();
            AtlasSolrQueryBuilder solrQueryBuilder                  = new AtlasSolrQueryBuilder();

            solrQueryBuilder.withEntityTypes(aggregationContext.getSearchForEntityTypes())
                            .withQueryString(aggregationContext.getQueryString())
                            .withCriteria(aggregationContext.getFilterCriteria())
                            .withExcludedDeletedEntities(aggregationContext.isExcludeDeletedEntities())
                            .withIncludeSubTypes(aggregationContext.isIncludeSubTypes())
                            .withCommonIndexFieldNames(indexFieldNameCache);


            SolrQuery solrQuery      = new SolrQuery();
            String    finalSolrQuery = solrQueryBuilder.build();

            if(LOG.isDebugEnabled()) {
                LOG.debug("Final query string prepared is {}", finalSolrQuery);
            }

            solrQuery.setQuery(finalSolrQuery);
            solrQuery.setRequestHandler(FREETEXT_REQUEST_HANDLER);

            if (CollectionUtils.isNotEmpty(aggregationCommonFields)) {
                for (String propertyName : aggregationCommonFields) {
                    // resolve index field names for aggregation fields.
                    String indexFieldName = indexFieldNameCache.get(propertyName);

                    indexFieldName2PropertyKeyNameMap.put(indexFieldName, propertyName);

                    solrQuery.addFacetField(indexFieldName);
                }
            }

            if (CollectionUtils.isNotEmpty(aggregationAttributes)) {
                for (AtlasAttribute attribute : aggregationAttributes) {
                    String indexFieldName = attribute.getIndexFieldName();

                    indexFieldName2PropertyKeyNameMap.put(indexFieldName, attribute.getQualifiedName());

                    solrQuery.addFacetField(indexFieldName);
                }
            }

            solrQuery.setFacetMinCount(MIN_FACET_COUNT_REQUIRED);

            QueryResponse    queryResponse = solrClient.query(VERTEX_INDEX, solrQuery, SolrRequest.METHOD.POST);
            List<FacetField> facetFields   = queryResponse == null ? null : queryResponse.getFacetFields();

            if (CollectionUtils.isNotEmpty(facetFields)) {
                Map<String, List<AtlasAggregationEntry>> ret = new HashMap<>();

                for (FacetField facetField : facetFields) {
                    String                      indexFieldName = facetField.getName();
                    List<AtlasAggregationEntry> entries        = new ArrayList<>(facetField.getValueCount());
                    List<FacetField.Count>      values         = facetField.getValues();

                    for (FacetField.Count count : values) {
                        entries.add(new AtlasAggregationEntry(count.getName(), count.getCount()));
                    }

                    //get the original propertyName from the index field name.
                    String propertyKeyName = indexFieldName2PropertyKeyNameMap.get(indexFieldName);

                    ret.put(propertyKeyName, entries);
                }

                return ret;
            }
        } catch (Exception e) {
            LOG.error("Error encountered in getting the aggregation metrics. Will return empty aggregation.", e);
        } finally {
            Solr6Index.releaseSolrClient(solrClient);
        }

        return Collections.EMPTY_MAP;
    }

    @Override
    public void applySuggestionFields(String collectionName, List<String> suggestionProperties) {
        SolrClient solrClient = null;

        try {
            solrClient = Solr6Index.getSolrClient(); // get solr client using same settings as that of Janus Graph

            if (solrClient == null) {
                LOG.warn("The indexing system is not solr based. Suggestions feature will not be available.");

                return;
            }

            Solr6Index.Mode solrMode = Solr6Index.getSolrMode();

            //update the request handler
            performRequestHandlerAction(collectionName,
                                        solrClient,
                                        generatePayLoadForSuggestions(generateSuggestionsString(suggestionProperties)),
                                        solrMode);
        } catch (Throwable t) {
            String msg = String.format("Error encountered in creating the request handler '%s' for collection '%s'", Constants.TERMS_REQUEST_HANDLER, collectionName);

            LOG.error(msg, t);
        } finally {
            Solr6Index.releaseSolrClient(solrClient);
        }

        LOG.info("Applied suggestion fields request handler for collection {}.", collectionName);
    }

    @Override
    public List<String> getSuggestions(String prefixString, String indexFieldName) {
        SolrClient solrClient = null;

        try {
            solrClient = Solr6Index.getSolrClient(); // get solr client using same settings as that of Janus Graph

            if (solrClient == null) {
                LOG.warn("The indexing system is not solr based. Suggestions feature will not be available.");

                return Collections.EMPTY_LIST;
            }

            SolrQuery solrQuery = new SolrQuery();

            solrQuery.setRequestHandler(Constants.TERMS_REQUEST_HANDLER)
                     .setParam(TERMS_PREFIX, prefixString)
                     .setParam(CommonParams.OMIT_HEADER, true);

            if (StringUtils.isNotEmpty(indexFieldName)) {
                solrQuery.setParam(TERMS_FIELD, indexFieldName);
            }

            QueryResponse queryResponse = solrClient.query(VERTEX_INDEX, solrQuery);
            TermsResponse termsResponse = queryResponse == null? null: queryResponse.getTermsResponse();

            if(termsResponse == null) {
                LOG.info("Received null for terms response. Will return no suggestions.");

                return Collections.EMPTY_LIST;
            }

            Map<String, TermFreq> termsMap = new HashMap<>();

            for (List<TermsResponse.Term> fieldTerms : termsResponse.getTermMap().values()) {
                for (TermsResponse.Term fieldTerm : fieldTerms) {
                    TermFreq term = termsMap.get(fieldTerm.getTerm());

                    if (term == null) {
                        term = new TermFreq(fieldTerm.getTerm(), fieldTerm.getFrequency());

                        termsMap.put(term.getTerm(), term);
                    } else {
                        term.addFreq(fieldTerm.getFrequency());
                    }
                }
            }

            return getTopTerms(termsMap);
        } catch (SolrServerException | IOException e) {
            String msg = String.format("Error encountered in generating the suggestions. Ignoring the error", e);

            LOG.error(msg);
        } finally {
            Solr6Index.releaseSolrClient(solrClient);
        }

        return Collections.EMPTY_LIST;
    }

    private void graphManagementCommit(AtlasGraphManagement management) {
        try {
            management.commit();
        } catch (Exception ex) {
            LOG.warn("Graph transaction management commit failed; attempting rollback: {}", ex);

            graphManagementRollback(management);
        }
    }

    private void graphManagementRollback(AtlasGraphManagement management) {
        try {
            management.rollback();
        } catch (Exception ex) {
            LOG.warn("Graph transaction management rollback failed: {}", ex);
        }
    }

    @VisibleForTesting
    static List<String> getTopTerms(Map<String, TermFreq> termsMap) {
        final List<String> ret;

        if (MapUtils.isNotEmpty(termsMap)) {
            // Collect top high frequency terms.
            PriorityQueue<TermFreq> termsQueue = new PriorityQueue(termsMap.size(), FREQ_COMPARATOR);

            for (TermFreq term : termsMap.values()) {
                termsQueue.add(term);
            }

            ret = new ArrayList<>(DEFAULT_SUGGESTION_COUNT);

            while (!termsQueue.isEmpty()) {
                ret.add(termsQueue.poll().getTerm());

                if (ret.size() >= DEFAULT_SUGGESTION_COUNT) {
                    break;
                }
            }
        } else {
            ret = Collections.EMPTY_LIST;
        }

        return ret;
    }

    @VisibleForTesting
    static String generatePayLoadForFreeText(String action, String qfValue) {
        return String.format("{" +
                " %s :  { " +
                "       'name' : '%s', " +
                "       'class': 'solr.SearchHandler' , " +
                "       'defaults': " + "{" +
                "          'defType': 'edismax' , " +
                "          'rows':    100 , " +
                "          'lowercaseOperators': true , " +
                "          'qf': '%s' , " +
                "          'hl.fl': '*' , " +
                "          'hl.requireFieldMatch': true , " +
                "          'lowercaseOperators': true , " +
                "         }" +
                "    }" +
                "}", action, FREETEXT_REQUEST_HANDLER, qfValue);
    }

    @VisibleForTesting
    String generatePayLoadForSuggestions(String suggestionFieldsString) {
        return String.format("{\n" +
                " update-requesthandler :  { \n" +
                "       'name' :    '%s', \n" +
                "       'class':    'solr.SearchHandler' , \n" +
                "       'startup':  'lazy' ,\n" +
                "       'defaults': " + "{ \n" +
                "          'terms':       true , \n" +
                "          'distrib':     false , \n" +
                "          'terms.limit': 5 , \n" +
                "           'terms.fl'  : \n" +
                "              [\n" +
                "              %s \n" +
                "           ] \n" +
                "         }\n" +
                "       'components': " + "[ \n" +
                "           'terms' \n" +
                "        ] \n" +
                "    } \n" +
                "}", Constants.TERMS_REQUEST_HANDLER, suggestionFieldsString);

    }

    @VisibleForTesting
    protected static String generateSearchWeightString(Map<String, Integer> indexFieldName2SearchWeightMap) {
        StringBuilder searchWeightBuilder = new StringBuilder();

        for (Map.Entry<String, Integer> entry : indexFieldName2SearchWeightMap.entrySet()) {
            searchWeightBuilder.append(" ")
                               .append(entry.getKey())
                               .append("^")
                               .append(entry.getValue().intValue());
        }

        if (LOG.isDebugEnabled()) {
            LOG.debug("generateSearchWeightString(fieldsCount={}): ret={}", indexFieldName2SearchWeightMap.size(), searchWeightBuilder.toString());
        }

        return searchWeightBuilder.toString();
    }

    @VisibleForTesting
    protected static String generateSuggestionsString(List<String> suggestionIndexFieldNames) {
        StringBuilder    ret      = new StringBuilder();
        Iterator<String> iterator = suggestionIndexFieldNames.iterator();

        while(iterator.hasNext()) {
            ret.append("'").append(iterator.next()).append("'");

            if(iterator.hasNext()) {
                ret.append(", ");
            }
        }

        if (LOG.isDebugEnabled()) {
            LOG.debug("generateSuggestionsString(fieldsCount={}): ret={}", suggestionIndexFieldNames.size(), ret.toString());
        }

        return ret.toString();
    }

    private SolrResponse updateFreeTextRequestHandler(SolrClient solrClient, String collectionName,
                                                    Map<String, Integer> indexFieldName2SearchWeightMap,
                                                    Solr6Index.Mode mode) throws IOException, SolrServerException, AtlasBaseException {
        String searchWeightString = generateSearchWeightString(indexFieldName2SearchWeightMap);
        String payLoadString      = generatePayLoadForFreeText("update-requesthandler", searchWeightString);

        return performRequestHandlerAction(collectionName, solrClient, payLoadString, mode);
    }

    private SolrResponse createFreeTextRequestHandler(SolrClient solrClient, String collectionName,
                                                    Map<String, Integer> indexFieldName2SearchWeightMap,
                                                    Solr6Index.Mode mode) throws IOException, SolrServerException, AtlasBaseException {
        String searchWeightString = generateSearchWeightString(indexFieldName2SearchWeightMap);
        String payLoadString      = generatePayLoadForFreeText("create-requesthandler", searchWeightString);

        return performRequestHandlerAction(collectionName, solrClient, payLoadString, mode);
    }

    private SolrResponse performRequestHandlerAction(String collectionName,
                                                   SolrClient solrClient,
                                                   String actionPayLoad,
                                                   Solr6Index.Mode mode) throws IOException, SolrServerException, AtlasBaseException {
        switch (mode) {
            case CLOUD:
                V2Request v2request = new V2Request.Builder(String.format("/collections/%s/config", collectionName))
                                                            .withMethod(SolrRequest.METHOD.POST)
                                                            .withPayload(actionPayLoad)
                                                            .build();

                return validateResponseForSuccess(v2request.process(solrClient));

            case HTTP:
                Collection<ContentStream> contentStreams = ClientUtils.toContentStreams(actionPayLoad, "application/json; charset=UTF-8");
                GenericSolrRequest        request        = new GenericSolrRequest(SolrRequest.METHOD.POST, String.format("/%s/config", collectionName), null);

                request.setContentStreams(contentStreams);
                request.setUseV2(false);

                return validateResponseForSuccess(request.process(solrClient));

            default:
                throw new IllegalArgumentException("Unsupported Solr operation mode: " + mode);
        }
    }

    private SolrResponse validateResponseForSuccess(SolrResponse solrResponse) throws AtlasBaseException {
        if(solrResponse == null) {
            String msg = "Received null response .";

            LOG.error(msg);

            throw new AtlasBaseException(msg);
        }

        if (LOG.isDebugEnabled()) {
            LOG.debug("V2 Response is {}", solrResponse.toString());
        }

        NamedList<Object> response = solrResponse.getResponse();

        if(response != null) {
            Object errorMessages = response.get("errorMessages");

            if(errorMessages != null) {
                LOG.error("Error encountered in performing request handler create/update");

                List<Object>        errorObjects = (List<Object>) errorMessages;
                Map<Object, Object> errObject    = (Map<Object, Object>) errorObjects.get(0);
                List<String>        msgs         = (List<String>) errObject.get("errorMessages");
                StringBuilder       sb           = new StringBuilder();

                for(String msg: msgs) {
                    sb.append(msg);
                }

                String errors = sb.toString();
                String msg    = String.format("Error encountered in performing response handler action. %s.", errors);

                LOG.error(msg);

                throw new AtlasBaseException(msg);
            } else {
                if(LOG.isDebugEnabled()) {
                    LOG.debug("Successfully performed response handler action. V2 Response is {}", solrResponse.toString());
                }
            }

        } else {
            if(LOG.isDebugEnabled()) {
                LOG.debug("Did not receive any response from SOLR.");
            }
        }

        return solrResponse;
    }

        static final class TermFreq {
        private final String term;
        private       long   freq;

        public TermFreq(String term, long freq) {
            this.term = term;
            this.freq = freq;
        }

        public final String getTerm() { return term; }

        public final long getFreq() { return freq; }

        public final void addFreq(long val) { freq += val; }
   }

    static class FreqComparator implements Comparator<TermFreq> {
        @Override
        public int compare(TermFreq lhs, TermFreq rhs) {
            return Long.compare(rhs.getFreq(), lhs.getFreq());
        }
    }
}