/*
 * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
 * or more contributor license agreements. Licensed under the Elastic License
 * 2.0 and the Server Side Public License, v 1; you may not use this file except
 * in compliance with, at your election, the Elastic License 2.0 or the Server
 * Side Public License, v 1.
 */

package org.elasticsearch.cluster.routing.allocation;

import org.elasticsearch.cluster.node.DiscoveryNode;
import org.elasticsearch.cluster.routing.UnassignedInfo.AllocationStatus;
import org.elasticsearch.cluster.routing.allocation.decider.Decision;
import org.elasticsearch.cluster.routing.allocation.decider.Decision.Type;
import org.elasticsearch.common.Nullable;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.unit.TimeValue;
import org.elasticsearch.common.xcontent.XContentBuilder;

import java.io.IOException;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
 * Represents the allocation decision by an allocator for an unassigned shard.
 */
public class AllocateUnassignedDecision extends AbstractAllocationDecision {
    /** a constant representing a shard decision where no decision was taken */
    public static final AllocateUnassignedDecision NOT_TAKEN =
        new AllocateUnassignedDecision(AllocationStatus.NO_ATTEMPT, null, null, null, false, 0L, 0L);
    /**
     * a map of cached common no/throttle decisions that don't need explanations,
     * this helps prevent unnecessary object allocations for the non-explain API case
     */
    private static final Map<AllocationStatus, AllocateUnassignedDecision> CACHED_DECISIONS;
    static {
        Map<AllocationStatus, AllocateUnassignedDecision> cachedDecisions = new EnumMap<>(AllocationStatus.class);
        cachedDecisions.put(AllocationStatus.FETCHING_SHARD_DATA,
            new AllocateUnassignedDecision(AllocationStatus.FETCHING_SHARD_DATA, null, null, null, false, 0L, 0L));
        cachedDecisions.put(AllocationStatus.NO_VALID_SHARD_COPY,
            new AllocateUnassignedDecision(AllocationStatus.NO_VALID_SHARD_COPY, null, null, null, false, 0L, 0L));
        cachedDecisions.put(AllocationStatus.DECIDERS_NO,
            new AllocateUnassignedDecision(AllocationStatus.DECIDERS_NO, null, null, null, false, 0L, 0L));
        cachedDecisions.put(AllocationStatus.DECIDERS_THROTTLED,
            new AllocateUnassignedDecision(AllocationStatus.DECIDERS_THROTTLED, null, null, null, false, 0L, 0L));
        cachedDecisions.put(AllocationStatus.DELAYED_ALLOCATION,
            new AllocateUnassignedDecision(AllocationStatus.DELAYED_ALLOCATION, null, null, null, false, 0L, 0L));
        CACHED_DECISIONS = Collections.unmodifiableMap(cachedDecisions);
    }

    @Nullable
    private final AllocationStatus allocationStatus;
    @Nullable
    private final String allocationId;
    private final boolean reuseStore;
    private final long remainingDelayInMillis;
    private final long configuredDelayInMillis;

    private AllocateUnassignedDecision(AllocationStatus allocationStatus,
                                       DiscoveryNode assignedNode,
                                       String allocationId,
                                       List<NodeAllocationResult> nodeDecisions,
                                       boolean reuseStore,
                                       long remainingDelayInMillis,
                                       long configuredDelayInMillis) {
        super(assignedNode, nodeDecisions);
        assert assignedNode != null || allocationStatus != null :
            "a yes decision must have a node to assign the shard to";
        assert allocationId == null || assignedNode != null :
            "allocation id can only be null if the assigned node is null";
        this.allocationStatus = allocationStatus;
        this.allocationId = allocationId;
        this.reuseStore = reuseStore;
        this.remainingDelayInMillis = remainingDelayInMillis;
        this.configuredDelayInMillis = configuredDelayInMillis;
    }

    public AllocateUnassignedDecision(StreamInput in) throws IOException {
        super(in);
        allocationStatus = in.readOptionalWriteable(AllocationStatus::readFrom);
        allocationId = in.readOptionalString();
        reuseStore = in.readBoolean();
        remainingDelayInMillis = in.readVLong();
        configuredDelayInMillis = in.readVLong();
    }

    /**
     * Returns a NO decision with the given {@link AllocationStatus}, and the individual node-level
     * decisions that comprised the final NO decision if in explain mode.
     */
    public static AllocateUnassignedDecision no(AllocationStatus allocationStatus, @Nullable List<NodeAllocationResult> decisions) {
        return no(allocationStatus, decisions, false);
    }

    /**
     * Returns a NO decision for a delayed shard allocation on a replica shard, with the individual node-level
     * decisions that comprised the final NO decision, if in explain mode.  Instances created with this
     * method will return {@link AllocationStatus#DELAYED_ALLOCATION} for {@link #getAllocationStatus()}.
     */
    public static AllocateUnassignedDecision delayed(long remainingDelay, long totalDelay,
                                                     @Nullable List<NodeAllocationResult> decisions) {
        return no(AllocationStatus.DELAYED_ALLOCATION, decisions, false, remainingDelay, totalDelay);
    }

    /**
     * Returns a NO decision with the given {@link AllocationStatus}, and the individual node-level
     * decisions that comprised the final NO decision if in explain mode.
     */
    public static AllocateUnassignedDecision no(AllocationStatus allocationStatus, @Nullable List<NodeAllocationResult> decisions,
                                                boolean reuseStore) {
        return no(allocationStatus, decisions, reuseStore, 0L, 0L);
    }

    private static AllocateUnassignedDecision no(AllocationStatus allocationStatus, @Nullable List<NodeAllocationResult> decisions,
                                                 boolean reuseStore, long remainingDelay, long totalDelay) {
        if (decisions != null) {
            return new AllocateUnassignedDecision(allocationStatus, null, null, decisions, reuseStore, remainingDelay, totalDelay);
        } else {
            return getCachedDecision(allocationStatus);
        }
    }

    /**
     * Returns a THROTTLE decision, with the individual node-level decisions that
     * comprised the final THROTTLE decision if in explain mode.
     */
    public static AllocateUnassignedDecision throttle(@Nullable List<NodeAllocationResult> decisions) {
        if (decisions != null) {
            return new AllocateUnassignedDecision(AllocationStatus.DECIDERS_THROTTLED, null, null, decisions, false, 0L, 0L);
        } else {
            return getCachedDecision(AllocationStatus.DECIDERS_THROTTLED);
        }
    }

    /**
     * Creates a YES decision with the given individual node-level decisions that
     * comprised the final YES decision, along with the node id to which the shard is assigned and
     * the allocation id for the shard, if available.
     */
    public static AllocateUnassignedDecision yes(DiscoveryNode assignedNode, @Nullable String allocationId,
                                                 @Nullable List<NodeAllocationResult> decisions, boolean reuseStore) {
        return new AllocateUnassignedDecision(null, assignedNode, allocationId, decisions, reuseStore, 0L, 0L);
    }

    /**
     * Creates a {@link AllocateUnassignedDecision} from the given {@link Decision} and the assigned node, if any.
     */
    public static AllocateUnassignedDecision fromDecision(Decision decision, @Nullable DiscoveryNode assignedNode,
                                                          @Nullable List<NodeAllocationResult> nodeDecisions) {
        final Type decisionType = decision.type();
        AllocationStatus allocationStatus = decisionType != Type.YES ? AllocationStatus.fromDecision(decisionType) : null;
        return new AllocateUnassignedDecision(allocationStatus, assignedNode, null, nodeDecisions, false, 0L, 0L);
    }

    private static AllocateUnassignedDecision getCachedDecision(AllocationStatus allocationStatus) {
        AllocateUnassignedDecision decision = CACHED_DECISIONS.get(allocationStatus);
        return Objects.requireNonNull(decision, "precomputed decision not found for " + allocationStatus);
    }

    @Override
    public boolean isDecisionTaken() {
        return allocationStatus != AllocationStatus.NO_ATTEMPT;
    }

    /**
     * Returns the {@link AllocationDecision} denoting the result of an allocation attempt.
     * If {@link #isDecisionTaken()} returns {@code false}, then invoking this method will
     * throw an {@code IllegalStateException}.
     */
    public AllocationDecision getAllocationDecision() {
        checkDecisionState();
        return AllocationDecision.fromAllocationStatus(allocationStatus);
    }

    /**
     * Returns the status of an unsuccessful allocation attempt.  This value will be {@code null} if
     * no decision was taken or if the decision was {@link Decision.Type#YES}.  If {@link #isDecisionTaken()}
     * returns {@code false}, then invoking this method will throw an {@code IllegalStateException}.
     */
    @Nullable
    public AllocationStatus getAllocationStatus() {
        checkDecisionState();
        return allocationStatus;
    }

    /**
     * Gets the allocation id for the existing shard copy that the allocator is assigning the shard to.
     * This method returns a non-null value iff {@link #getTargetNode()} returns a non-null value
     * and the node on which the shard is assigned already has a shard copy with an in-sync allocation id
     * that we can re-use.  If {@link #isDecisionTaken()} returns {@code false}, then invoking this method
     * will throw an {@code IllegalStateException}.
     */
    @Nullable
    public String getAllocationId() {
        checkDecisionState();
        return allocationId;
    }

    /**
     * Gets the remaining delay for allocating the replica shard when a node holding the replica left
     * the cluster and the deciders are waiting to see if the node returns before allocating the replica
     * elsewhere.  Only returns a meaningful positive value if {@link #getAllocationStatus()} returns
     * {@link AllocationStatus#DELAYED_ALLOCATION}.  If {@link #isDecisionTaken()} returns {@code false},
     * then invoking this method will throw an {@code IllegalStateException}.
     */
    public long getRemainingDelayInMillis() {
        checkDecisionState();
        return remainingDelayInMillis;
    }

    /**
     * Gets the total configured delay for allocating the replica shard when a node holding the replica left
     * the cluster and the deciders are waiting to see if the node returns before allocating the replica
     * elsewhere.  Only returns a meaningful positive value if {@link #getAllocationStatus()} returns
     * {@link AllocationStatus#DELAYED_ALLOCATION}.  If {@link #isDecisionTaken()} returns {@code false},
     * then invoking this method will throw an {@code IllegalStateException}.
     */
    public long getConfiguredDelayInMillis() {
        checkDecisionState();
        return configuredDelayInMillis;
    }

    @Override
    public String getExplanation() {
        checkDecisionState();
        AllocationDecision allocationDecision = getAllocationDecision();
        if (allocationDecision == AllocationDecision.YES) {
            return "can allocate the shard";
        } else if (allocationDecision == AllocationDecision.THROTTLED) {
            return "allocation temporarily throttled";
        } else if (allocationDecision == AllocationDecision.AWAITING_INFO) {
            return "cannot allocate because information about existing shard data is still being retrieved from some of the nodes";
        } else if (allocationDecision == AllocationDecision.NO_VALID_SHARD_COPY) {
            if (hasNodeWithStaleOrCorruptShard()) {
                return "cannot allocate because all found copies of the shard are either stale or corrupt";
            } else {
                return "cannot allocate because a previous copy of the primary shard existed but can no longer be found on " +
                       "the nodes in the cluster";
            }
        } else if (allocationDecision == AllocationDecision.ALLOCATION_DELAYED) {
            return "cannot allocate because the cluster is still waiting " +
                              TimeValue.timeValueMillis(remainingDelayInMillis) +
                              " for the departed node holding a replica to rejoin" +
                              (atLeastOneNodeWithYesDecision() ?
                                   ", despite being allowed to allocate the shard to at least one other node" : "");
        } else {
            assert allocationDecision == AllocationDecision.NO;
            if (reuseStore) {
                return "cannot allocate because allocation is not permitted to any of the nodes that hold an in-sync shard copy";
            } else {
                return "cannot allocate because allocation is not permitted to any of the nodes";
            }
        }
    }

    private boolean hasNodeWithStaleOrCorruptShard() {
        return getNodeDecisions() != null && getNodeDecisions().stream().anyMatch(result ->
                result.getShardStoreInfo() != null
                    && (result.getShardStoreInfo().getAllocationId() != null
                            || result.getShardStoreInfo().getStoreException() != null));
    }

    @Override
    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
        checkDecisionState();
        builder.field("can_allocate", getAllocationDecision());
        builder.field("allocate_explanation", getExplanation());
        if (targetNode != null) {
            builder.startObject("target_node");
            discoveryNodeToXContent(targetNode, true, builder);
            builder.endObject();
        }
        if (allocationId != null) {
            builder.field("allocation_id", allocationId);
        }
        if (allocationStatus == AllocationStatus.DELAYED_ALLOCATION) {
            builder.humanReadableField("configured_delay_in_millis", "configured_delay",
                TimeValue.timeValueMillis(configuredDelayInMillis));
            builder.humanReadableField("remaining_delay_in_millis", "remaining_delay",
                TimeValue.timeValueMillis(remainingDelayInMillis));
        }
        nodeDecisionsToXContent(nodeDecisions, builder, params);
        return builder;
    }

    @Override
    public void writeTo(StreamOutput out) throws IOException {
        super.writeTo(out);
        out.writeOptionalWriteable(allocationStatus);
        out.writeOptionalString(allocationId);
        out.writeBoolean(reuseStore);
        out.writeVLong(remainingDelayInMillis);
        out.writeVLong(configuredDelayInMillis);
    }

    @Override
    public boolean equals(Object other) {
        if (super.equals(other) == false) {
            return false;
        }
        if (other instanceof AllocateUnassignedDecision == false) {
            return false;
        }
        AllocateUnassignedDecision that = (AllocateUnassignedDecision) other;
        return Objects.equals(allocationStatus, that.allocationStatus)
                   && Objects.equals(allocationId, that.allocationId)
                   && reuseStore == that.reuseStore
                   && configuredDelayInMillis == that.configuredDelayInMillis
                   && remainingDelayInMillis == that.remainingDelayInMillis;
    }

    @Override
    public int hashCode() {
        return 31 * super.hashCode() + Objects.hash(allocationStatus, allocationId, reuseStore,
            configuredDelayInMillis, remainingDelayInMillis);
    }

}
