/*
 * 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.nifi.cluster.coordination.flow;

import org.apache.commons.codec.digest.DigestUtils;
import org.apache.nifi.cluster.protocol.DataFlow;
import org.apache.nifi.cluster.protocol.NodeIdentifier;
import org.apache.nifi.controller.serialization.VersionedFlowSynchronizer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

/**
 * <p>
 * An implementation of {@link FlowElection} that waits until either a maximum amount of time has elapsed
 * or a maximum number of Data Flows have entered the running to be elected, and then elects the 'winner'
 * based on the number of 'votes' that a particular DataFlow has received. This implementation considers
 * two Flows with the same fingerprint to be the same Flow. If there is a tie in the number of votes for
 * a particular DataFlow, one will be chosen in a non-deterministic manner. If multiple DataFlows are
 * presented with the same fingerprint but different Flows (for instance, the position of a component has
 * changed), one of the Flows with that fingerprint will be chosen in a non-deterministic manner.
 * </p>
 */
public class PopularVoteFlowElection implements FlowElection {
    private static final Logger logger = LoggerFactory.getLogger(PopularVoteFlowElection.class);

    private final long maxWaitNanos;
    private final Integer maxNodes;

    private volatile Long startNanos = null;
    private volatile DataFlow electedDataFlow = null;

    private final Map<String, FlowCandidate> candidateByFingerprint = new HashMap<>();

    public PopularVoteFlowElection(final long maxWait, final TimeUnit maxWaitPeriod, final Integer maxNodes) {
        this.maxWaitNanos = maxWaitPeriod.toNanos(maxWait);
        if (maxWaitNanos < 1) {
            throw new IllegalArgumentException("Maximum wait time to elect Cluster Flow cannot be less than 1 nanosecond");
        }

        this.maxNodes = maxNodes;
        if (maxNodes != null && maxNodes < 1) {
            throw new IllegalArgumentException("Maximum number of nodes to wait on before electing Cluster Flow cannot be less than 1");
        }
    }

    @Override
    public synchronized boolean isElectionComplete() {
        if (electedDataFlow != null) {
            return true;
        }

        if (startNanos == null) {
            return false;
        }

        final long nanosSinceStart = System.nanoTime() - startNanos;
        if (nanosSinceStart > maxWaitNanos) {
            final FlowCandidate elected = performElection();
            logger.info("Election is complete because the maximum allowed time has elapsed. "
                + "The elected dataflow is held by the following nodes: {}", elected.getNodes());

            return true;
        } else if (maxNodes != null) {
            final int numVotes = getVoteCount();
            if (numVotes >= maxNodes) {
                final FlowCandidate elected = performElection();
                logger.info("Election is complete because the required number of nodes ({}) have voted. "
                    + "The elected dataflow is held by the following nodes: {}", maxNodes, elected.getNodes());

                return true;
            }
        }

        return false;
    }

    @Override
    public boolean isVoteCounted(final NodeIdentifier nodeIdentifier) {
        return candidateByFingerprint.values().stream()
            .anyMatch(candidate -> candidate.getNodes().contains(nodeIdentifier));
    }

    private synchronized int getVoteCount() {
        return candidateByFingerprint.values().stream().mapToInt(candidate -> candidate.getVotes()).sum();
    }

    @Override
    public synchronized DataFlow castVote(final DataFlow candidate, final NodeIdentifier nodeId) {
        if (candidate == null || isElectionComplete()) {
            return getElectedDataFlow();
        }

        final String fingerprint = fingerprint(candidate);
        final FlowCandidate flowCandidate = candidateByFingerprint.computeIfAbsent(fingerprint, key -> new FlowCandidate(candidate));
        final boolean voteCast = flowCandidate.vote(nodeId);

        if (startNanos == null) {
            startNanos = System.nanoTime();
        }

        if (voteCast) {
            logger.info("Vote cast by {}; this flow now has {} votes", nodeId, flowCandidate.getVotes());
        }

        if (isElectionComplete()) {
            return getElectedDataFlow();
        }

        return null; // no elected candidate so return null
    }

    private String fingerprint(final DataFlow dataFlow) {
        final String flowFingerprint = DigestUtils.sha256Hex(dataFlow.getFlow());
        final String authFingerprint = dataFlow.getAuthorizerFingerprint() == null ? "" : new String(dataFlow.getAuthorizerFingerprint(), StandardCharsets.UTF_8);
        final String candidateFingerprint = flowFingerprint + authFingerprint;

        return candidateFingerprint;
    }

    @Override
    public DataFlow getElectedDataFlow() {
        return electedDataFlow;
    }

    private FlowCandidate performElection() {
        if (candidateByFingerprint.isEmpty()) {
            return null;
        }

        final List<FlowCandidate> nonEmptyCandidates = candidateByFingerprint.values().stream()
            .filter(candidate -> !candidate.isFlowEmpty())
            .collect(Collectors.toList());

        if (nonEmptyCandidates.isEmpty()) {
            // All flow candidates are empty flows. Just use one of them.
            final FlowCandidate electedCandidate = candidateByFingerprint.values().iterator().next();
            this.electedDataFlow = electedCandidate.getDataFlow();
            return electedCandidate;
        }

        final FlowCandidate elected;
        if (nonEmptyCandidates.size() == 1) {
            // Only one flow is non-empty. Use that one.
            elected = nonEmptyCandidates.iterator().next();
        } else {
            // Choose the non-empty flow that got the most votes.
            elected = nonEmptyCandidates.stream()
                .max((candidate1, candidate2) -> Integer.compare(candidate1.getVotes(), candidate2.getVotes()))
                .get();
        }

        this.electedDataFlow = elected.getDataFlow();
        return elected;
    }

    @Override
    public synchronized String getStatusDescription() {
        if (startNanos == null) {
            return "No votes have yet been cast.";
        }

        final StringBuilder descriptionBuilder = new StringBuilder("Election will complete in ");
        final long nanosElapsed = System.nanoTime() - startNanos;
        final long nanosLeft = maxWaitNanos - nanosElapsed;
        final long secsLeft = TimeUnit.NANOSECONDS.toSeconds(nanosLeft);
        if (secsLeft < 1) {
            descriptionBuilder.append("less than 1 second");
        } else {
            descriptionBuilder.append(secsLeft).append(" seconds");
        }

        if (maxNodes != null) {
            final int votesNeeded = maxNodes.intValue() - getVoteCount();
            descriptionBuilder.append(" or after ").append(votesNeeded).append(" more vote");
            descriptionBuilder.append(votesNeeded == 1 ? " is " : "s are ");
            descriptionBuilder.append("cast, whichever occurs first.");
        }

        return descriptionBuilder.toString();
    }

    private static class FlowCandidate {
        private final DataFlow dataFlow;
        private final AtomicInteger voteCount = new AtomicInteger(0);
        private final Set<NodeIdentifier> nodeIds = Collections.synchronizedSet(new HashSet<>());

        public FlowCandidate(final DataFlow dataFlow) {
            this.dataFlow = dataFlow;
        }

        /**
         * Casts a vote for this candidate for the given node identifier, if a vote has not already
         * been cast for this node identifier
         *
         * @param nodeId the node id that is casting the vote
         * @return <code>true</code> if the vote was case, <code>false</code> if this node id has already cast its vote
         */
        public boolean vote(final NodeIdentifier nodeId) {
            if (nodeIds.add(nodeId)) {
                voteCount.incrementAndGet();
                return true;
            }

            return false;
        }

        public int getVotes() {
            return voteCount.get();
        }

        public DataFlow getDataFlow() {
            return dataFlow;
        }

        public boolean isFlowEmpty() {
            return VersionedFlowSynchronizer.isFlowEmpty(dataFlow);
        }

        public Set<NodeIdentifier> getNodes() {
            return nodeIds;
        }
    }
}
