/*
 * ModeShape (http://www.modeshape.org)
 *
 * Licensed 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.modeshape.jcr.journal;

import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.modeshape.schematic.annotation.ThreadSafe;
import org.modeshape.common.logging.Logger;
import org.modeshape.common.util.CheckArg;
import org.modeshape.common.util.DateTimeUtil;
import org.modeshape.jcr.JcrI18n;
import org.modeshape.jcr.cache.NodeKey;
import org.modeshape.jcr.cache.change.ChangeSet;
import org.modeshape.jcr.clustering.ClusteringService;
import org.modeshape.jcr.clustering.MessageConsumer;

/**
 * A {@link ChangeJournal} implementation which runs in a cluster and which attempts to reconcile with other members of the cluster
 * on startup in order to retrieve missed/lost records.
 *
 * @author Horia Chiorean (hchiorea@redhat.com)
 */
@ThreadSafe
public class ClusteredJournal extends MessageConsumer<ClusteredJournal.DeltaMessage> implements ChangeJournal {

    private final static Logger LOGGER  = Logger.getLogger(ClusteredJournal.class);
    private final static int MAX_MINUTES_TO_WAIT_FOR_RECONCILIATION = 2;

    private final LocalJournal localJournal;
    private final ClusteringService clusteringService;
    private final int reconciliationMaxWaitTimeMinutes;
    
    private CountDownLatch reconciliationLatch = null;

    /**
     * Creates a new clustered journal
     *
     * @param localJournal the local {@link ChangeJournal} which will
     * @param clusteringService an {@link ClusteringService} instance.
     */
    public ClusteredJournal( LocalJournal localJournal,
                             ClusteringService clusteringService ) {
        this(localJournal, clusteringService, MAX_MINUTES_TO_WAIT_FOR_RECONCILIATION);
    }

    protected ClusteredJournal(LocalJournal localJournal,
                               ClusteringService clusteringService,
                               int reconciliationMaxWaitTime) {
        super(DeltaMessage.class);

        CheckArg.isNotNull(localJournal, "localJournal");
        CheckArg.isNotNull(clusteringService, "clusteringService");

        this.clusteringService = clusteringService;
        this.localJournal = localJournal.withSearchTimeDelta(clusteringService.getMaxAllowedClockDelayMillis());
        this.reconciliationMaxWaitTimeMinutes = reconciliationMaxWaitTime;
    }

    @Override
    public void notify( ChangeSet changeSet ) {
        localJournal.notify(changeSet);
    }

    @Override
    public void start() throws Exception {
        // make sure the clustering service is open
        if (!clusteringService.isOpen())  {
            throw new IllegalStateException("The clustering service has not been started");
        }

        localJournal.start();

        //make sure this process can always process delta messages
        clusteringService.addConsumer(this);
        
        if (!clusteringService.multipleMembersInCluster()) {
            // this is the first node of the cluster, nothing to do
            return;
        }

        // we require just 1 response before unblocking for a couple of reasons:
        // a) partition tolerance is NOT SUPPORTED
        // b) each member of the cluster has a full view of all the changes throughout that cluster, thanks to remote events
        // we'll process all responses eventually, but we only block for the first one
        int numberOfRequiredResponses = 1;
        this.reconciliationLatch = new CountDownLatch(numberOfRequiredResponses);
        
        // send the request
        JournalRecord lastRecord = lastRecord();
        Long lastChangeSetTimeMillis = lastRecord != null ? lastRecord.getChangeTimeMillis() : null;
        DeltaMessage request = DeltaMessage.request(journalId(), lastChangeSetTimeMillis);
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("Sending delta request: {0}", request);
        }
        this.clusteringService.sendMessage(request);
        waitForReconciliationToComplete();
    }

    private void waitForReconciliationToComplete() throws InterruptedException {
        try {
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("{0} waiting until it receives {1} responses from cluster {2}", journalId(), 
                             reconciliationLatch.getCount(),
                             clusterName());
            }
            if (!reconciliationLatch.await(reconciliationMaxWaitTimeMinutes, TimeUnit.MINUTES)) {
                LOGGER.warn(JcrI18n.journalHasNotCompletedReconciliation,journalId(), clusterName(), reconciliationMaxWaitTimeMinutes);
                reconciliationLatch.countDown();
            } else if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("{0} successfully completed reconciliation", journalId());
            }
        } catch (InterruptedException e) {
            LOGGER.warn(JcrI18n.journalHasNotCompletedReconciliation, journalId(), clusterName(),
                        MAX_MINUTES_TO_WAIT_FOR_RECONCILIATION);
            if (Thread.interrupted()) {
                throw e;
            }
        }
    }

    @Override
    public void shutdown() {
        localJournal.shutdown();
    }

    @Override
    public void removeOldRecords() {
        localJournal.removeOldRecords();
    }

    @Override
    public Records allRecords( boolean descendingOrder ) {
        return localJournal.allRecords(descendingOrder);
    }

    @Override
    public JournalRecord lastRecord() {
        return localJournal.lastRecord();
    }

    @Override
    public Records recordsNewerThan( LocalDateTime changeSetTime,
                                     boolean inclusive,
                                     boolean descendingOrder ) {
        return localJournal.recordsNewerThan(changeSetTime, inclusive, descendingOrder);
    }

    @Override
    public Iterator<NodeKey> changedNodesSince( long timestamp ) {
        return localJournal.changedNodesSince(timestamp);
    }

    @Override
    public void addRecords( JournalRecord... records ) {
        localJournal.addRecords(records);
    }

    @Override
    public String journalId() {
        return localJournal.journalId();
    }

    @Override
    public boolean started() {
        return localJournal.started() && reconciliationCompleted();
    }

    @Override
    public void consume( ClusteredJournal.DeltaMessage message ) {
        if (!localJournal.started()) {
            return;
        }
        if (message.isResponse()) {
            processDeltaResponse(message);
        } else {
            processDeltaRequest(message);
        }
    }
    
    protected boolean reconciliationCompleted() {
        return reconciliationLatch == null || reconciliationLatch.getCount() == 0;
    }

    private void processDeltaRequest(DeltaMessage request) {
        String requestorId = request.getRequestorId();
        String journalId = journalId();
        if (requestorId.equals(journalId)) {
            //we MUST discard own messages, because JGroups will broadcast these as well
            LOGGER.debug("{0} discarding delta request from itself", journalId);
            return;
        }

        if (!reconciliationCompleted()) {
            //if this clustered journal has not completed reconciling itself, it cannot send anything
            LOGGER.debug("{0} is still reconciling, cannot send delta to journal {1}", journalId, requestorId);
            return;
        }

        Long requestorLastChangeSetTime = request.getRequestorLastChangeSetTime();
        LocalDateTime lastChangeSetTime = requestorLastChangeSetTime != null ? 
                                          DateTimeUtil.localDateTimeUTC(requestorLastChangeSetTime) :
                                          null;
        Records delta = recordsNewerThan(lastChangeSetTime, false, false);
        List<JournalRecord> deltaList = new ArrayList<>(delta.size());
        for (JournalRecord record : delta) {
            deltaList.add(record);
        }
        
        DeltaMessage response = DeltaMessage.response(request, journalId, deltaList);
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("Sending delta response {0} to journal {1}", response, requestorId);
        }
        clusteringService.sendMessage(response);
    }

    private void processDeltaResponse(DeltaMessage message) {
        String journalId = journalId();
        if (!journalId.equals(message.getRequestorId())) {
            // only process a response if the message is a response to our request (in a cluster everything will be broadcasted to everyone)
            return;
        }
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("{0} received delta response {1}", journalId, message);
        }
        List<JournalRecord> records = message.getRespondentRecords();
        if (!records.isEmpty()) {
            //make sure that a new timestamp is not generated for those records and whatever comes in the response is used.
            localJournal.addRecords(records.toArray(new JournalRecord[0]));
        }
        reconciliationLatch.countDown();     
    }

    protected ClusteringService clusteringService() {
        return ClusteredJournal.this.clusteringService;
    }

    protected LocalJournal localJournal() {
        return localJournal;
    }

    protected String clusterName() {
        return clusteringService().clusterName();
    }
    
    protected static class DeltaMessage implements Serializable {
        private static final long serialVersionUID = 1L;
        
        private final String requestorId;
        private final Long requestorLastChangeSetTime;
        private final String respondentId;
        private final List<JournalRecord> respondentRecords;

        private DeltaMessage( String requestorId,
                              Long requestorLastChangeSetTime,
                              String respondentId,
                              List<JournalRecord> respondentRecords ) {
            this.requestorId = requestorId;
            this.requestorLastChangeSetTime = requestorLastChangeSetTime;
            this.respondentId = respondentId;
            this.respondentRecords = respondentRecords;
        }
        
        protected boolean isResponse() {
            return this.respondentId != null;
        }

        protected String getRequestorId() {
            return requestorId;
        }

        protected Long getRequestorLastChangeSetTime() {
            return requestorLastChangeSetTime;
        }

        protected String getRespondentId() {
            return respondentId;
        }

        protected List<JournalRecord> getRespondentRecords() {
            return respondentRecords;
        }

        protected static DeltaMessage request(String requestorId, Long requestorLastChangeSetTime) {
            return new DeltaMessage(requestorId, requestorLastChangeSetTime, null, null);
        }

        protected static DeltaMessage response(DeltaMessage request, String repondentId, List<JournalRecord> respondentRecords) {
            return new DeltaMessage(request.requestorId, request.requestorLastChangeSetTime, repondentId, respondentRecords);
        }

        @Override
        public String toString() {
            StringBuilder sb = null;
            if (isResponse()) {
                sb = new StringBuilder("response[");
                sb.append("requestorId='").append(requestorId).append('\'');
                sb.append(", requestorLastChangeSetTime=").append(requestorLastChangeSetTime);
                sb.append(", repondentId='").append(respondentId).append('\'');
                sb.append(", respondentRecords=").append(respondentRecords);
            } else {
                sb = new StringBuilder("request[");
                sb.append("requestorId='").append(requestorId).append('\'');
                sb.append(", requestorLastChangeSetTime=").append(requestorLastChangeSetTime);                
            }
            sb.append(']');
            return sb.toString();
        }
    }
}