/*-
 * ~~~~~~licensing~~~~~~
 * system-commons
 * ==========
 * Copyright (C) 2020 - 2021 EmDev LLC
 * ==========
 * 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.
 * ~~~~~~/licensing~~~~~~
 */
package ru.entaxy.esb.system.common.aggregation.repo;

import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.RuntimeCamelException;
import org.apache.camel.processor.aggregate.jdbc.DefaultJdbcOptimisticLockingExceptionMapper;
import org.apache.camel.processor.aggregate.jdbc.JdbcCamelCodec;
import org.apache.camel.processor.aggregate.jdbc.JdbcOptimisticLockingExceptionMapper;
import org.apache.camel.spi.OptimisticLockingAggregationRepository;
import org.apache.camel.spi.RecoverableAggregationRepository;
import org.apache.camel.support.service.ServiceSupport;
import org.apache.camel.util.ObjectHelper;
import org.apache.ignite.Ignite;
import org.apache.ignite.IgniteCache;
import org.apache.ignite.IgniteTransactions;
import org.apache.ignite.Ignition;
import org.apache.ignite.cache.CacheAtomicityMode;
import org.apache.ignite.cache.CacheMode;
import org.apache.ignite.cache.CacheRebalanceMode;
import org.apache.ignite.cache.query.ScanQuery;
import org.apache.ignite.configuration.CacheConfiguration;
import org.apache.ignite.configuration.DataStorageConfiguration;
import org.apache.ignite.configuration.IgniteConfiguration;
import org.apache.ignite.configuration.TransactionConfiguration;
import org.apache.ignite.logger.jcl.JclLogger;
import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi;
import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder;
import org.apache.ignite.transactions.Transaction;
import org.jetbrains.annotations.NotNull;
import org.osgi.framework.BundleContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.EmptyResultDataAccessException;

import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;

import static org.apache.ignite.IgniteSystemProperties.IGNITE_QUIET;

public class IgniteAggregationRepository extends ServiceSupport implements RecoverableAggregationRepository, OptimisticLockingAggregationRepository {

    private static final Logger LOG = LoggerFactory.getLogger(IgniteAggregationRepository.class);
    public static final String AGGREGATION = "aggregation";
    public static final String AGGREGATION_COMPLETED = "aggregationCompleted";
    public static final int MAX_ATTEMPT_COUNT = 3;

    private JdbcOptimisticLockingExceptionMapper jdbcOptimisticLockingExceptionMapper = new DefaultJdbcOptimisticLockingExceptionMapper();
    private boolean returnOldExchange;
    private final JdbcCamelCodec codec = new JdbcCamelCodec();
    private long recoveryInterval = 5000;
    private boolean useRecovery = true;
    private int maximumRedeliveries;
    private String deadLetterUri;
    private boolean allowSerializedHeaders;
    private int backups = 2;
    private String workDirectory;
    private String addresses = "127.0.0.1:47500,127.0.0.1:47501";

    private Ignite ignite;
    private IgniteTransactions transactions;

    private IgniteCache<String, byte[]> aggregationCache;
    private CacheConfiguration<String, byte[]> aggregationCfg;

    private IgniteCache<String, byte[]> aggregationCompleted;
    private CacheConfiguration<String, byte[]> aggregationCompletedCfg;

    private BundleContext bundleContext;

    /**
     * Creates an ignite aggregation repository
     */
    public IgniteAggregationRepository() {
    }

    /**
     * Creates an ignite aggregation repository with the two mandatory parameters
     */
    public IgniteAggregationRepository(BundleContext bundleContext, String workDirectory) {
        this.setBundleContext(bundleContext);
        this.setWorkDirectory(workDirectory);
    }

    @Override
    public Exchange add(final CamelContext camelContext, final String correlationId,
                        final Exchange oldExchange, final Exchange newExchange) throws OptimisticLockingException {

        try {
            return add(camelContext, correlationId, newExchange);
        } catch (Exception e) {
            if (jdbcOptimisticLockingExceptionMapper != null && jdbcOptimisticLockingExceptionMapper.isOptimisticLocking(e)) {
                throw new OptimisticLockingException();
            } else {
                throw RuntimeCamelException.wrapRuntimeCamelException(e);
            }
        }
    }

    @Override
    public Exchange add(final CamelContext camelContext, final String correlationId, final Exchange exchange) {
        Exchange result = null;
        final String key = correlationId;

        try {
            LOG.debug("Adding exchange with key: [{}]", key);

            boolean present = aggregationCache.get(key) != null;

            // Recover existing exchange with that ID
            if (isReturnOldExchange() && present) {
                result = get(key, camelContext);
            }

            final byte[] data = codec.marshallExchange(camelContext, exchange, allowSerializedHeaders);
            try (Transaction tx = transactions.txStart()) {
                aggregationCache.put(key, data);
                tx.commit();
            }

        } catch (Exception e) {
            throw new RuntimeException("Error adding with key " + key, e);
        }

        return result;
    }

    public Exchange insert(final CamelContext camelContext, final String correlationId, final Exchange exchange) throws IOException {
        Exchange result = null;
        LOG.debug("Adding exchange with key: [{}]", correlationId);

        final byte[] data = codec.marshallExchange(camelContext, exchange, allowSerializedHeaders);
        aggregationCompleted.put(correlationId, data);

        return result;
    }

    @Override
    public Exchange get(final CamelContext camelContext, final String correlationId) {
        Exchange result = get(correlationId, camelContext);

        LOG.debug("Getting key  [{}] -> {}", correlationId, result);

        return result;
    }

    private Exchange get(final String key, final CamelContext camelContext) {
        try {
            final byte[] data = aggregationCache.get(key);
            return codec.unmarshallExchange(camelContext, data);
        } catch (EmptyResultDataAccessException | NullPointerException ex) {
            return null;
        } catch (IOException | ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void remove(final CamelContext camelContext, final String correlationId, final Exchange exchange) {
        try (Transaction tx = transactions.txStart()) {
            insert(camelContext, correlationId, exchange);
            if (!aggregationCache.remove(correlationId)) {
                throw new RuntimeException("Error removing key " + correlationId + " from repository " + AGGREGATION);
            }
            tx.commit();
        } catch (Exception exception) {
            throw new RuntimeException("Error removing key " + correlationId + " from repository " + AGGREGATION, exception);
        }
    }

    @Override
    public void confirm(final CamelContext camelContext, final String exchangeId) {
        confirm(camelContext, exchangeId, 0);
    }

    private void confirm(final CamelContext camelContext, final String exchangeId, int attemptCount) {
        if (attemptCount <= MAX_ATTEMPT_COUNT) {
            try (Transaction tx = transactions.txStart()) {
                aggregationCompleted.remove(exchangeId);
                tx.commit();
            } catch (Exception exception) {
                confirm(camelContext, exchangeId, ++attemptCount);
            }
        }
    }

    @Override
    public Set<String> getKeys() {
        Set<String> keys = new HashSet<>();
        aggregationCache.query(new ScanQuery<>(null)).forEach(entry -> keys.add((String) entry.getKey()));
        return keys;
    }

    @Override
    public Set<String> scan(CamelContext camelContext) {
        Set<String> keys = new HashSet<>();
        aggregationCompleted.query(new ScanQuery<>(null)).forEach(entry -> keys.add((String) entry.getKey()));
        return keys;
    }

    @Override
    public Exchange recover(CamelContext camelContext, String exchangeId) {
        final byte[] data = aggregationCompleted.get(exchangeId);
        Exchange answer = null;
        try {
            answer = codec.unmarshallExchange(camelContext, data);
        } catch (IOException | ClassNotFoundException e) {
            LOG.error("Exception in recovering exchangeId {}", exchangeId, e);
        }

        LOG.debug("Recovering exchangeId [{}] -> {}", exchangeId, answer);

        return answer;
    }

    /**
     * If recovery is enabled then a background task is run every x'th time to scan for failed exchanges to recover
     * and resubmit. By default this interval is 5000 millis.
     *
     * @param interval the interval
     * @param timeUnit the time unit
     */
    public void setRecoveryInterval(long interval, TimeUnit timeUnit) {
        this.recoveryInterval = timeUnit.toMillis(interval);
    }

    public void setRecoveryInterval(long interval) {
        this.recoveryInterval = interval;
    }

    public long getRecoveryIntervalInMillis() {
        return recoveryInterval;
    }

    public boolean isUseRecovery() {
        return useRecovery;
    }

    /**
     * @param useRecovery Whether or not recovery is enabled. This option is by default true. When enabled the Camel
     *                    Aggregator automatic recover failed aggregated exchange and have them resubmittedd
     */
    public void setUseRecovery(boolean useRecovery) {
        this.useRecovery = useRecovery;
    }

    public int getMaximumRedeliveries() {
        return maximumRedeliveries;
    }

    public void setMaximumRedeliveries(int maximumRedeliveries) {
        this.maximumRedeliveries = maximumRedeliveries;
    }

    public String getDeadLetterUri() {
        return deadLetterUri;
    }

    /**
     * @param deadLetterUri An endpoint uri for a Dead Letter Channel where exhausted recovered Exchanges will be
     *                      moved. If this option is used then the maximumRedeliveries option must also be provided.
     *                      Important note : if the deadletter route throws an exception, it will be send again to DLQ
     *                      until it succeed !
     */
    public void setDeadLetterUri(String deadLetterUri) {
        this.deadLetterUri = deadLetterUri;
    }

    public boolean isReturnOldExchange() {
        return returnOldExchange;
    }

    /**
     * @param returnOldExchange Whether the get operation should return the old existing Exchange if any existed.
     *                          By default this option is false to optimize as we do not need the old exchange when
     *                          aggregating
     */
    public void setReturnOldExchange(boolean returnOldExchange) {
        this.returnOldExchange = returnOldExchange;
    }

    public boolean isAllowSerializedHeaders() {
        return allowSerializedHeaders;
    }

    public void setAllowSerializedHeaders(boolean allowSerializedHeaders) {
        this.allowSerializedHeaders = allowSerializedHeaders;
    }

    public JdbcOptimisticLockingExceptionMapper getJdbcOptimisticLockingExceptionMapper() {
        return jdbcOptimisticLockingExceptionMapper;
    }

    public void setJdbcOptimisticLockingExceptionMapper(JdbcOptimisticLockingExceptionMapper jdbcOptimisticLockingExceptionMapper) {
        this.jdbcOptimisticLockingExceptionMapper = jdbcOptimisticLockingExceptionMapper;
    }

    @Override
    protected void doStart() throws Exception {
        ObjectHelper.notNull(bundleContext, "BundleContext");
        ObjectHelper.notNull(workDirectory, "WorkDirectory");

        settingsIgnite();

        // log number of existing exchanges
        int current = getKeys().size();
        int completed = scan(null).size();

        if (current > 0) {
            LOG.info("On startup there are " + current + " aggregate exchanges (not completed) in repository: " + AGGREGATION);
        } else {
            LOG.info("On startup there are no existing aggregate exchanges (not completed) in repository: {}", AGGREGATION);
        }
        if (completed > 0) {
            LOG.warn("On startup there are " + completed + " completed exchanges to be recovered in repository: " + AGGREGATION_COMPLETED);
        } else {
            LOG.info("On startup there are no completed exchanges to be recovered in repository: {}", AGGREGATION_COMPLETED);
        }
    }

    @Override
    public void doStop() throws Exception {
        if (ignite != null) {
            ignite.close();
        }
    }

    private void settingsIgnite() {
        IgniteConfiguration cfg = new IgniteConfiguration();
        cfg.setDiscoverySpi(getTcpDiscoverySpi());
        cfg.setGridLogger(new JclLogger());
        cfg.setTransactionConfiguration(new TransactionConfiguration());

        settingsPersistence(cfg);

        aggregationCfg = getCacheConfiguration(AGGREGATION);
        cfg.setCacheConfiguration(aggregationCfg);

        aggregationCompletedCfg = getCacheConfiguration(AGGREGATION_COMPLETED);
        cfg.setCacheConfiguration(aggregationCompletedCfg);

        startIgnite(cfg);

        createCache();
    }

    private void settingsPersistence(IgniteConfiguration cfg) {
        DataStorageConfiguration storageCfg = new DataStorageConfiguration();
        storageCfg.getDefaultDataRegionConfiguration().setPersistenceEnabled(true);
        cfg.setWorkDirectory(workDirectory);
        cfg.setDataStorageConfiguration(storageCfg);
    }

    private void createCache() {
        transactions = ignite.transactions();
        aggregationCache = ignite.getOrCreateCache(aggregationCfg);
        aggregationCompleted = ignite.getOrCreateCache(aggregationCompletedCfg);

        // Set the baseline topology that is represented by these nodes.
        ignite.cluster().setBaselineTopology(ignite.cluster().localNode().order());
    }

    private void startIgnite(IgniteConfiguration cfg) {
        System.setProperty(IGNITE_QUIET, "false");
        ignite = Ignition.getOrStart(cfg);
        ignite.cluster().active(true);
    }

    @NotNull
    private CacheConfiguration<String, byte[]> getCacheConfiguration(String cacheName) {
        CacheConfiguration<String, byte[]> cacheCfg = new CacheConfiguration<String, byte[]>();
        cacheCfg.setName(cacheName);
        cacheCfg.setRebalanceMode(CacheRebalanceMode.SYNC);
        cacheCfg.setCacheMode(CacheMode.REPLICATED);
        cacheCfg.setAtomicityMode(CacheAtomicityMode.TRANSACTIONAL);
        cacheCfg.setBackups(backups);
        return cacheCfg;
    }

    @NotNull
    private TcpDiscoverySpi getTcpDiscoverySpi() {
        TcpDiscoverySpi spi = new TcpDiscoverySpi();
        spi.setIpFinder(new TcpDiscoveryVmIpFinder(false) {
            {
                setAddresses(Arrays.asList(addresses.split(",")));
            }
        });
        return spi;
    }

    public void setBundleContext(BundleContext bundleContext) {
        this.bundleContext = bundleContext;
    }

    public void setWorkDirectory(String workDirectory) {
        this.workDirectory = workDirectory;
    }

    public void setBackups(int backups) {
        this.backups = backups;
    }

    public void setAddresses(String addresses) {
        this.addresses = addresses;
    }
}
