/*-
 * ~~~~~~licensing~~~~~~
 * system-commons
 * ==========
 * Copyright (C) 2020 - 2025 EmDev LLC
 * ==========
 * You may not use this file except in accordance with the License Terms of the Copyright
 * Holder located at: https://entaxy.ru/eula . All copyrights, all intellectual property
 * rights to the Software and any copies are the property of the Copyright Holder. Unless
 * it is explicitly allowed the Copyright Holder, the User is prohibited from using the
 * Software for commercial purposes to provide services to third parties.
 * 
 * The Copyright Holder hereby declares that the Software is provided on an "AS IS".
 * Under no circumstances does the Copyright Holder guarantee or promise that the
 * Software provided by him will be suitable or not suitable for the specific purposes
 * of the User, that the Software will meet all commercial and personal subjective
 * expectations of the User, that the Software will work properly, without technical
 * errors, quickly and uninterruptedly.
 * 
 * Under no circumstances shall the Copyright Holder or its Affiliates is not liable
 * to the User for any direct or indirect losses of the User, his expenses or actual
 * damage, including, downtime; loss of bussines; lost profit; lost earnings; loss
 * or damage to data, property, etc.
 * ~~~~~~/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.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.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.spi.discovery.tcp.TcpDiscoverySpi;
import org.apache.ignite.spi.discovery.tcp.ipfinder.multicast.TcpDiscoveryMulticastIpFinder;
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 ru.entaxy.esb.system.common.aggregation.JdbcCamelCodec;

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


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 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, Ignite ignite) {
        this.setBundleContext(bundleContext);
        this.setIgnite(ignite);
    }

    @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) {
        final String confirmKey = exchange.getExchangeId();
        try (Transaction tx = transactions.txStart()) {
            insert(camelContext, confirmKey, 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(ignite, "Ignite");

        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);
        }
    }

    private void settingsIgnite() {
        aggregationCfg = getCacheConfiguration(AGGREGATION);
        aggregationCompletedCfg = getCacheConfiguration(AGGREGATION_COMPLETED);
        
        transactions = ignite.transactions();
        
        aggregationCache = ignite.getOrCreateCache(aggregationCfg);
        aggregationCompleted = ignite.getOrCreateCache(aggregationCompletedCfg);
    }

    @NotNull
    private CacheConfiguration<String, byte[]> getCacheConfiguration(String cacheName) {
        CacheConfiguration<String, byte[]> cacheCfg = new CacheConfiguration<>();
        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 TcpDiscoveryMulticastIpFinder());
        return spi;
    }

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

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

	public void setIgnite(Ignite ignite) {
		this.ignite = ignite;
	}
    
}
