/*
 * Licensed to Elasticsearch under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch 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.elasticsearch.cluster.metadata;

import org.elasticsearch.cluster.Diff;
import org.elasticsearch.cluster.NamedDiff;
import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.io.stream.Writeable;
import org.elasticsearch.common.joda.Joda;
import org.elasticsearch.common.settings.Setting;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.ContextParser;
import org.elasticsearch.common.xcontent.ObjectParser;
import org.elasticsearch.common.xcontent.ToXContent;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.index.Index;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * A collection of tombstones for explicitly marking indices as deleted in the cluster state.
 *
 * The cluster state contains a list of index tombstones for indices that have been
 * deleted in the cluster.  Because cluster states are processed asynchronously by
 * nodes and a node could be removed from the cluster for a period of time, the
 * tombstones remain in the cluster state for a fixed period of time, after which
 * they are purged.
 */
public final class IndexGraveyard implements MetaData.Custom {

    /**
     * Setting for the maximum tombstones allowed in the cluster state;
     * prevents the cluster state size from exploding too large, but it opens the
     * very unlikely risk that if there are greater than MAX_TOMBSTONES index
     * deletions while a node was offline, when it comes back online, it will have
     * missed index deletions that it may need to process.
     */
    public static final Setting<Integer> SETTING_MAX_TOMBSTONES = Setting.intSetting("cluster.indices.tombstones.size",
                                                                                     500, // the default maximum number of tombstones
                                                                                     Setting.Property.NodeScope);

    public static final String TYPE = "index-graveyard";
    private static final ParseField TOMBSTONES_FIELD = new ParseField("tombstones");
    private static final ObjectParser<List<Tombstone>, Void> GRAVEYARD_PARSER;
    static {
        GRAVEYARD_PARSER = new ObjectParser<>("index_graveyard", ArrayList::new);
        GRAVEYARD_PARSER.declareObjectArray(List::addAll, Tombstone.getParser(), TOMBSTONES_FIELD);
    }

    private final List<Tombstone> tombstones;

    private IndexGraveyard(final List<Tombstone> list) {
        assert list != null;
        tombstones = Collections.unmodifiableList(list);
    }

    public IndexGraveyard(final StreamInput in) throws IOException {
        final int queueSize = in.readVInt();
        List<Tombstone> tombstones = new ArrayList<>(queueSize);
        for (int i = 0; i < queueSize; i++) {
            tombstones.add(new Tombstone(in));
        }
        this.tombstones = Collections.unmodifiableList(tombstones);
    }

    @Override
    public String getWriteableName() {
        return TYPE;
    }

    @Override
    public EnumSet<MetaData.XContentContext> context() {
        return MetaData.API_AND_GATEWAY;
    }

    @Override
    public boolean equals(Object obj) {
        return (obj instanceof IndexGraveyard) && Objects.equals(tombstones, ((IndexGraveyard)obj).tombstones);
    }

    @Override
    public int hashCode() {
        return tombstones.hashCode();
    }

    /**
     * Get the current unmodifiable index tombstone list.
     */
    public List<Tombstone> getTombstones() {
        return tombstones;
    }

    /**
     * Returns true if the graveyard contains a tombstone for the given index.
     */
    public boolean containsIndex(final Index index) {
        for (Tombstone tombstone : tombstones) {
            if (tombstone.getIndex().equals(index)) {
                return true;
            }
        }
        return false;
    }

    @Override
    public XContentBuilder toXContent(final XContentBuilder builder, final Params params) throws IOException {
        builder.startArray(TOMBSTONES_FIELD.getPreferredName());
        for (Tombstone tombstone : tombstones) {
            tombstone.toXContent(builder, params);
        }
        return builder.endArray();
    }

    public static IndexGraveyard fromXContent(final XContentParser parser) throws IOException {
        return new IndexGraveyard(GRAVEYARD_PARSER.parse(parser, null));
    }

    @Override
    public String toString() {
        return "IndexGraveyard[" + tombstones + "]";
    }

    @Override
    public void writeTo(final StreamOutput out) throws IOException {
        out.writeVInt(tombstones.size());
        for (Tombstone tombstone : tombstones) {
            tombstone.writeTo(out);
        }
    }

    @Override
    @SuppressWarnings("unchecked")
    public Diff<MetaData.Custom> diff(final MetaData.Custom previous) {
        return new IndexGraveyardDiff((IndexGraveyard) previous, this);
    }

    public static NamedDiff<MetaData.Custom> readDiffFrom(final StreamInput in) throws IOException {
        return new IndexGraveyardDiff(in);
    }

    public static IndexGraveyard.Builder builder() {
        return new IndexGraveyard.Builder();
    }

    public static IndexGraveyard.Builder builder(final IndexGraveyard graveyard) {
        return new IndexGraveyard.Builder(graveyard);
    }

    /**
     * A class to build an IndexGraveyard.
     */
    public static final class Builder {
        private List<Tombstone> tombstones;
        private int numPurged = -1;
        private final long currentTime = System.currentTimeMillis();

        private Builder() {
            tombstones = new ArrayList<>();
        }

        private Builder(IndexGraveyard that) {
            tombstones = new ArrayList<>(that.getTombstones());
        }

        /**
         * A copy of the current tombstones in the builder.
         */
        public List<Tombstone> tombstones() {
            return Collections.unmodifiableList(tombstones);
        }

        /**
         * Add a deleted index to the list of tombstones in the cluster state.
         */
        public Builder addTombstone(final Index index) {
            tombstones.add(new Tombstone(index, currentTime));
            return this;
        }

        /**
         * Add a set of deleted indexes to the list of tombstones in the cluster state.
         */
        public Builder addTombstones(final Collection<Index> indices) {
            for (Index index : indices) {
                addTombstone(index);
            }
            return this;
        }

        /**
         * Add a list of tombstones to the graveyard.
         */
        Builder addBuiltTombstones(final List<Tombstone> tombstones) {
            this.tombstones.addAll(tombstones);
            return this;
        }

        /**
         * Get the number of tombstones that were purged.  This should *only* be called
         * after build() has been called.
         */
        public int getNumPurged() {
            assert numPurged != -1;
            return numPurged;
        }

        /**
         * Purge tombstone entries.  Returns the number of entries that were purged.
         *
         * Tombstones are purged if the number of tombstones in the list
         * is greater than the input parameter of maximum allowed tombstones.
         * Tombstones are purged until the list is equal to the maximum allowed.
         */
        private int purge(final int maxTombstones) {
            int count = tombstones().size() - maxTombstones;
            if (count <= 0) {
                return 0;
            }
            tombstones = tombstones.subList(count, tombstones.size());
            return count;
        }

        public IndexGraveyard build() {
            return build(Settings.EMPTY);
        }

        public IndexGraveyard build(final Settings settings) {
            // first, purge the necessary amount of entries
            numPurged = purge(SETTING_MAX_TOMBSTONES.get(settings));
            return new IndexGraveyard(tombstones);
        }
    }

    /**
     * A class representing a diff of two IndexGraveyard objects.
     */
    public static final class IndexGraveyardDiff implements NamedDiff<MetaData.Custom> {

        private final List<Tombstone> added;
        private final int removedCount;

        IndexGraveyardDiff(final StreamInput in) throws IOException {
            added = Collections.unmodifiableList(in.readList((streamInput) -> new Tombstone(streamInput)));
            removedCount = in.readVInt();
        }

        IndexGraveyardDiff(final IndexGraveyard previous, final IndexGraveyard current) {
            final List<Tombstone> previousTombstones = previous.tombstones;
            final List<Tombstone> currentTombstones = current.tombstones;
            final List<Tombstone> added;
            final int removed;
            if (previousTombstones.isEmpty()) {
                // nothing will have been removed, and all entries in current are new
                added = new ArrayList<>(currentTombstones);
                removed = 0;
            } else if (currentTombstones.isEmpty()) {
                // nothing will have been added, and all entries in previous are removed
                added = Collections.emptyList();
                removed = previousTombstones.size();
            } else {
                // look through the back, starting from the end, for added tombstones
                final Tombstone lastAddedTombstone = previousTombstones.get(previousTombstones.size() - 1);
                final int addedIndex = currentTombstones.lastIndexOf(lastAddedTombstone);
                if (addedIndex < currentTombstones.size()) {
                    added = currentTombstones.subList(addedIndex + 1, currentTombstones.size());
                } else {
                    added = Collections.emptyList();
                }
                // look from the front for the removed tombstones
                final Tombstone firstTombstone = currentTombstones.get(0);
                int idx = previousTombstones.indexOf(firstTombstone);
                if (idx < 0) {
                    // the first tombstone in the current list wasn't found in the previous list,
                    // which means all tombstones from the previous list have been deleted.
                    assert added.equals(currentTombstones); // all previous are removed, so the current list must be the same as the added
                    idx = previousTombstones.size();
                }
                removed = idx;
            }
            this.added = Collections.unmodifiableList(added);
            this.removedCount = removed;
        }

        @Override
        public void writeTo(final StreamOutput out) throws IOException {
            out.writeList(added);
            out.writeVInt(removedCount);
        }

        @Override
        public IndexGraveyard apply(final MetaData.Custom previous) {
            @SuppressWarnings("unchecked") final IndexGraveyard old = (IndexGraveyard) previous;
            if (removedCount > old.tombstones.size()) {
                throw new IllegalStateException("IndexGraveyardDiff cannot remove [" + removedCount + "] entries from [" +
                                                old.tombstones.size() + "] tombstones.");
            }
            final List<Tombstone> newTombstones = new ArrayList<>(old.tombstones.subList(removedCount, old.tombstones.size()));
            for (Tombstone tombstone : added) {
                newTombstones.add(tombstone);
            }
            return new IndexGraveyard.Builder().addBuiltTombstones(newTombstones).build();
        }

        /** The index tombstones that were added between two states */
        public List<Tombstone> getAdded() {
            return added;
        }

        /** The number of index tombstones that were removed between two states */
        public int getRemovedCount() {
            return removedCount;
        }

        @Override
        public String getWriteableName() {
            return TYPE;
        }
    }

    /**
     * An individual tombstone entry for representing a deleted index.
     */
    public static final class Tombstone implements ToXContent, Writeable {

        private static final String INDEX_KEY = "index";
        private static final String DELETE_DATE_IN_MILLIS_KEY = "delete_date_in_millis";
        private static final String DELETE_DATE_KEY = "delete_date";
        private static final ObjectParser<Tombstone.Builder, Void> TOMBSTONE_PARSER;
        static {
            TOMBSTONE_PARSER = new ObjectParser<>("tombstoneEntry", Tombstone.Builder::new);
            TOMBSTONE_PARSER.declareObject(Tombstone.Builder::index, (parser, context) -> Index.fromXContent(parser),
                    new ParseField(INDEX_KEY));
            TOMBSTONE_PARSER.declareLong(Tombstone.Builder::deleteDateInMillis, new ParseField(DELETE_DATE_IN_MILLIS_KEY));
            TOMBSTONE_PARSER.declareString((b, s) -> {}, new ParseField(DELETE_DATE_KEY));
        }

        static ContextParser<Void, Tombstone> getParser() {
            return (parser, context) -> TOMBSTONE_PARSER.apply(parser, null).build();
        }

        private final Index index;
        private final long deleteDateInMillis;

        private Tombstone(final Index index, final long deleteDateInMillis) {
            Objects.requireNonNull(index);
            if (deleteDateInMillis < 0L) {
                throw new IllegalArgumentException("invalid deleteDateInMillis [" + deleteDateInMillis + "]");
            }
            this.index = index;
            this.deleteDateInMillis = deleteDateInMillis;
        }

        // create from stream
        private Tombstone(StreamInput in) throws IOException {
            index = new Index(in);
            deleteDateInMillis = in.readLong();
        }

        /**
         * The deleted index.
         */
        public Index getIndex() {
            return index;
        }

        /**
         * The date in milliseconds that the index deletion event occurred, used for logging/debugging.
         */
        public long getDeleteDateInMillis() {
            return deleteDateInMillis;
        }

        @Override
        public void writeTo(final StreamOutput out) throws IOException {
            index.writeTo(out);
            out.writeLong(deleteDateInMillis);
        }

        @Override
        public boolean equals(final Object other) {
            if (this == other) {
                return true;
            }
            if (other == null || getClass() != other.getClass()) {
                return false;
            }
            @SuppressWarnings("unchecked") Tombstone that = (Tombstone) other;
            return index.equals(that.index) && deleteDateInMillis == that.deleteDateInMillis;
        }

        @Override
        public int hashCode() {
            int result = index.hashCode();
            result = 31 * result + Long.hashCode(deleteDateInMillis);
            return result;
        }

        @Override
        public String toString() {
            return "[index=" + index + ", deleteDate=" + Joda.getStrictStandardDateFormatter().printer().print(deleteDateInMillis) + "]";
        }

        @Override
        public XContentBuilder toXContent(final XContentBuilder builder, final Params params) throws IOException {
            builder.startObject();
            builder.field(INDEX_KEY);
            index.toXContent(builder, params);
            builder.timeValueField(DELETE_DATE_IN_MILLIS_KEY, DELETE_DATE_KEY, deleteDateInMillis, TimeUnit.MILLISECONDS);
            return builder.endObject();
        }

        public static Tombstone fromXContent(final XContentParser parser) throws IOException {
            return TOMBSTONE_PARSER.parse(parser, null).build();
        }

        /**
         * A builder for building tombstone entries.
         */
        private static final class Builder {
            private Index index;
            private long deleteDateInMillis = -1L;

            public void index(final Index index) {
                this.index = index;
            }

            public void deleteDateInMillis(final long deleteDate) {
                this.deleteDateInMillis = deleteDate;
            }

            public Tombstone build() {
                assert index != null;
                assert deleteDateInMillis > -1L;
                return new Tombstone(index, deleteDateInMillis);
            }
        }
    }

}
