/*
 * 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.skywalking.oap.server.storage.plugin.elasticsearch.base;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.EqualsAndHashCode;
import org.apache.skywalking.library.elasticsearch.response.Mappings;
import org.apache.skywalking.oap.server.core.Const;
import org.apache.skywalking.oap.server.library.client.elasticsearch.ElasticSearchClient;
import org.apache.skywalking.oap.server.library.util.CollectionUtils;

public class IndexStructures {
    private final Map<String, Fields> mappingStructures;
    private final Map<String, UpdatableIndexSettings> indexSettingStructures;

    public IndexStructures() {
        this.mappingStructures = new HashMap<>();
        this.indexSettingStructures = new HashMap<>();
    }

    public Mappings getMapping(String tableName) {
        Map<String, Object> properties =
            mappingStructures.containsKey(tableName) ?
                mappingStructures.get(tableName).properties : new HashMap<>();
        Mappings.Source source =
                    mappingStructures.containsKey(tableName) ?
                        mappingStructures.get(tableName).source : new Mappings.Source();
        return Mappings.builder()
                       .type(ElasticSearchClient.TYPE)
                       .properties(properties)
                       .source(source)
                       .build();
    }

    /**
     * Add or append mapping/settings when the current structures don't contain the input structure or having
     * new fields in it.
     */
    public void putStructure(String tableName, Mappings mapping, Map<String, Object> settings) {
        if (CollectionUtils.isNotEmpty(settings) && Objects.nonNull(settings.get("index"))) {
            this.indexSettingStructures.putIfAbsent(tableName, new UpdatableIndexSettings(
                (Map<String, Object>) settings.get("index")));
        }
        if (Objects.isNull(mapping)
            || Objects.isNull(mapping.getProperties())
            || mapping.getProperties().isEmpty()) {
            return;
        }
        Fields fields = new Fields(mapping);
        if (mappingStructures.containsKey(tableName)) {
            mappingStructures.get(tableName).appendNewFields(fields);
        } else {
            mappingStructures.put(tableName, fields);
        }
    }

    /**
     * Returns mappings with fields that not exist in the input mappings.
     * The input mappings should be history mapping from current index.
     * Do not return _source config to avoid current index update conflict.
     */
    public Mappings diffMappings(String tableName, Mappings mappings) {
        if (!mappingStructures.containsKey(tableName)) {
            return new Mappings();
        }
        Map<String, Object> diffProperties =
            mappingStructures.get(tableName).diffFields(new Fields(mappings));
        return Mappings.builder()
                       .type(ElasticSearchClient.TYPE)
                       .properties(diffProperties)
                       .build();
    }

    /**
     * Returns true when the current structures already contains the properties of the input
     * mappings.
     */
    public boolean containsMapping(String tableName, Mappings mappings) {
        if (Objects.isNull(mappings) ||
            CollectionUtils.isEmpty(mappings.getProperties())) {
            return true;
        }

        return mappingStructures.containsKey(tableName)
            && mappingStructures.get(tableName)
                                .containsAllFields(new Fields(mappings));
    }

    /**
     * Check whether all the fields are already defined regardless of the field types.
     * When OAP runs in no-init mode, it doesn't care about the field types, it just
     * cares about whether the data can be ingested without reporting error.
     * OAP running in init mode should take care of the field types.
     */
    public boolean containsFieldNames(String tableName, Mappings mappings) {
        if (Objects.isNull(mappings) ||
            CollectionUtils.isEmpty(mappings.getProperties())) {
            return true;
        }

        return mappingStructures.containsKey(tableName) &&
                mappingStructures.get(tableName)
                        .containsAllFieldNames(new Fields(mappings));
    }

    /**
     * Returns true when the current index setting equals the input.
     */
    public boolean compareIndexSetting(String tableName, Map<String, Object> settings) {
        if ((CollectionUtils.isEmpty(settings) || CollectionUtils.isEmpty((Map) settings.get("index")))
            && Objects.isNull(indexSettingStructures.get(tableName))) {
            return true;
        }

        return indexSettingStructures.containsKey(tableName)
            && indexSettingStructures.get(tableName).
                                     equals(new UpdatableIndexSettings((Map<String, Object>) settings.get("index")));
    }

    /**
     * The mapping properties of the template or index.
     */
    public static class Fields {
        private final Map<String, Object> properties;
        private final Mappings.Source source;

        private Fields(Mappings mapping) {
            this.properties = mapping.getProperties();
            this.source = mapping.getSource();
        }

        /**
         * Returns ture when the input fields have already been stored in the properties.
         */
        private boolean containsAllFields(Fields fields) {
            if (this.properties.size() < fields.properties.size()) {
                return false;
            }
            boolean isContains = fields.properties.entrySet().stream()
                    .allMatch(item -> Objects.equals(properties.get(item.getKey()), item.getValue()));
            if (!isContains) {
                return false;
            }
            Set<String> inputExcludes = fields.source.getExcludes();
            Set<String> excludes = source.getExcludes();
            //need to add new excludes
            if (!excludes.containsAll(inputExcludes)) {
                return false;
            }
            //need to delete existing excludes
            for (String p : fields.properties.keySet()) {
                if (!inputExcludes.contains(p) && excludes.contains(p)) {
                    return false;
                }
            }
            return true;
        }

        private boolean containsAllFieldNames(Fields fields) {
            if (this.properties.size() < fields.properties.size()) {
                return false;
            }
            return this.properties.keySet().containsAll(fields.properties.keySet());
        }

        /**
         * Append new fields and update.
         * Properties combine input and exist, update property's attribute, won't remove old one.
         * If the existed `excludes` contains a not existing property, the excluded field would be removed.
         */
        private void appendNewFields(Fields fields) {
            properties.putAll(fields.properties);
            Set<String> inputExcludes = fields.source.getExcludes();
            Set<String> excludes = source.getExcludes();
            excludes.addAll(inputExcludes);
            fields.properties.keySet().forEach(p -> {
                if (!inputExcludes.contains(p) && excludes.contains(p)) {
                    excludes.remove(p);
                }
            });
        }

        /**
         * Returns the properties that not exist in the input fields.
         */
        private Map<String, Object> diffFields(Fields fields) {
            return this.properties.entrySet().stream()
                                  .filter(e -> !fields.properties.containsKey(e.getKey()))
                                  .collect(Collectors.toMap(
                                      Map.Entry::getKey,
                                      Map.Entry::getValue
                                  ));
        }
    }

    /**
     * The index settings structure which only includes needs to compare for update fields
     */
    @EqualsAndHashCode
    public static class UpdatableIndexSettings {
        private final String replicas;
        private final String shards;

        public UpdatableIndexSettings(Map<String, Object> indexSettings) {
            this.replicas = String.valueOf(indexSettings.getOrDefault("number_of_replicas", Const.EMPTY_STRING));
            this.shards = String.valueOf(indexSettings.getOrDefault("number_of_shards", Const.EMPTY_STRING));
        }
    }
}
