package eu.reply.sce.backend.core.sequence;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.persistence.EntityManager;
import javax.persistence.metamodel.EntityType;
import javax.persistence.metamodel.Metamodel;
import javax.transaction.Transactional;
import javax.transaction.Transactional.TxType;

import org.apache.commons.lang3.StringUtils;
import org.codehaus.groovy.control.CompilationFailedException;
import org.slf4j.Logger;
import org.slf4j.helpers.NOPLogger;

import eu.reply.sce.backend.core.jee.Context;
import eu.reply.sce.backend.core.jee.configuration.Configuration.Cache;
import eu.reply.sce.backend.core.jee.configuration.Configuration.Cached;
import eu.reply.sce.backend.core.jee.configuration.Configuration.PerTenant;
import eu.reply.sce.backend.core.jee.configuration.Configuration.Provider;
import eu.reply.sce.backend.core.jee.configuration.ModuleConfiguration;
import eu.reply.sce.backend.core.jee.configuration.ModuleConfiguration.Module;
import eu.reply.sce.backend.core.jee.configuration.entity.AbstractModuleConfiguration;
import eu.reply.sce.backend.core.sequence.businesskey.BusinessKeySequence;
import eu.reply.sce.backend.core.sequence.exceptions.MissingSequenceException;
import eu.reply.sce.backend.core.sequence.exceptions.SequenceHandlerConfigurationException;
import groovy.lang.Binding;
import groovy.lang.GroovyShell;
import groovy.lang.Script;

@ApplicationScoped
public class SequenceHandler {

    static class Configuration extends HashMap<String, BusinessSequenceConfiguration> {
        private static final long serialVersionUID = 4933273561678991933L;
    }

    public static final long TEN = 10;

    @ApplicationScoped
    static class SequenceInstantiator {
        private final GroovyShell groovyShell = new GroovyShell();

        @Inject
        @Module(Module.CURRENT)
        private EntityManager em;

        @Inject
        @Module(Module.CURRENT)
        private ModuleConfiguration currentModule;

        @Inject
        private Logger log;


        void doCreate(final BusinessSequenceConfiguration configuration, final Object payload) {
            log.info("Creating BusinessSequence entity from {}", configuration);
            BusinessSequence businessSequence;

            String type = configuration.getType();
            String code = evaluateCode(configuration, payload);
            String format = configuration.getFormat();
            Integer incrementBy = configuration.getIncrementBy();
            Integer allocationSize = configuration.getAllocationSize();
            boolean cyclic = configuration.getCyclic();
            boolean strictlyProgressive = configuration.getStrictlyProgressive();

            businessSequence =
                    new BusinessSequence(type, code, format, incrementBy, allocationSize, cyclic, strictlyProgressive,
                            configuration.getCheckDigitClass());
            addRange(businessSequence, configuration);

            try {
                BusinessSequence existing = BusinessSequence.findByTypeAndCode(em, type, code);
                log.warn("Sequence with type {} and code {} already exists, nothing to do. Existing sequence is: ",
                        type, code, existing);
                return;
            } catch (MissingSequenceException e) {
                // sequence doesn't exist. Create it.
            }

            em.persist(businessSequence);
            log.info("Created sequence {}", businessSequence);

            // This should be done by BusinessSequenceProcessor. IT are not
            // doing it
            if (businessSequence.getSequenceCounter() == null) {
                log.warn("Activating first range for {}", businessSequence);
                BusinessSequence.activateNextRange(businessSequence, em, currentModule);
            }
        }

        private void addRange(final BusinessSequence businessSequence,
                              final BusinessSequenceConfiguration configuration) {
            List<SequenceRange> ranges = new ArrayList<>();

            if (configuration.getLength() != null) {
                int nDigits = configuration.getLength();
                // 10^nDigits - 1
                Long maxVal = (long) Math.pow(TEN, nDigits) - 1;
                Long minVal = configuration.getMinimumValue() == null ? 0 : configuration.getMinimumValue();
                ranges.add(new SequenceRange(minVal, maxVal));
            } else {
                ranges.add(new SequenceRange(configuration.getMinimumValue(),
                        Objects.requireNonNull(configuration.getMaximumValue(), "maxValue")));
            }
            businessSequence.setSequenceRanges(ranges);
        }


        private String evaluateCode(final BusinessSequenceConfiguration configuration, final Object payload) {
            if (configuration.getCode() != null) {
                return configuration.getCode();
            }
            try {
                String codeMethod = Objects.requireNonNull(configuration.getInitCodeMethod(), "initCodeMethod");
                Script initCodeScript = groovyShell.parse(codeMethod);
                Binding binding = new Binding();
                binding.setVariable("payload", payload);
                initCodeScript.setBinding(binding);
                Object result = initCodeScript.run();
                return result.toString();
            } catch (NullPointerException | CompilationFailedException e) {
                throw new SequenceHandlerConfigurationException(configuration.getType(), e);
            }
        }


    }

    private GroovyShell groovyShell = new GroovyShell();

    @Inject
    private Logger log;

    @Inject
    @Module(Module.CURRENT)
    private EntityManager em;

    @Inject
    private Context context;

    @Inject
    private SequenceInstantiator instantiatior;

    @Inject
    private SequenceNumberGenerator generator;

    @Inject
    @Cached
    @PerTenant
    private Provider<Configuration> sequenceConfigurationProvider;

    @Inject
    @PerTenant
    private Cache<BusinessSequenceConfiguration> sequenceConfigurationCache;

    private static SequenceHandler singleton;

    /**
     * Use only for the <b>unit</b> tests to retrieve {@link SequenceHandler} instance.
     *
     * @param em the entity manager
     * @return {@link SequenceHandler} static instance
     */

    public static SequenceHandler createSingletonForUnitTests(final EntityManager em) {
        if (singleton == null) {
            singleton = new SequenceHandler();
        }
        if (singleton.em != em) {
            singleton.log = NOPLogger.NOP_LOGGER;
            singleton.em = Objects.requireNonNull(em, "em");
            singleton.generator = SequenceNumberGenerator.createSingletonForUnitTests(em);
            singleton.instantiatior = new SequenceInstantiator();
            singleton.instantiatior.em = em;
            singleton.instantiatior.log = NOPLogger.NOP_LOGGER;
            singleton.instantiatior.currentModule = new AbstractModuleConfiguration() {
                @Override
                public EntityManager getEntityManager() {
                    return em;
                }

                @Override
                public String getModuleName() {
                    return "test";
                }
            };

            singleton.sequenceConfigurationCache = new Cache<>() {
                private final Map<String, BusinessSequenceConfiguration> map = new HashMap<>();

                @Override
                public BusinessSequenceConfiguration get(final String key) {
                    return map.getOrDefault(key, null);
                }

                @Override
                public void put(final String key, final BusinessSequenceConfiguration value) {
                    map.put(key, value);
                }

                @Override
                public boolean containsKey(final String key) {
                    return map.containsKey(key);
                }
            };
        }
        return singleton;
    }

    public void create(final String sequence, final Object payload) {
        log.info("creating sequence {} for {}", Objects.requireNonNull(sequence, "sequence"), payload);
        instantiatior.doCreate(getConfiguration(sequence), payload);
    }

    // untested yet
    public void create(final BusinessSequenceConfiguration configuration, final Object payload) {
        log.info("creating sequence with anonymous configuration {} for {}", configuration, payload);
        instantiatior.doCreate(configuration, payload);
    }

    public void create(final Class<?> clazz, final BusinessKeySequence annotation, final Object payload) {
        log.info("creating sequence {} from annotation for {}", Objects.requireNonNull(clazz, "clazz").getName(), payload);
        instantiatior.doCreate(getConfigurationFromAnnotation(clazz, Objects.requireNonNull(annotation, "annotation")), payload);
    }

    /**
     * Inspects all entities of the current module trying to find
     * BusinessKeySequence annotations and instantiates the sequences with their
     * default value. Schedule this method at module starup if you want to use
     * default sequences.
     */
    @Transactional(TxType.REQUIRES_NEW)
    public void initBusinessKeySequences() {

        Metamodel metamodel = em.getMetamodel();
        for (EntityType<?> entityType : metamodel.getEntities()) {
            BusinessKeySequence bksAnnotation = entityType.getJavaType().getAnnotation(BusinessKeySequence.class);
            if (bksAnnotation != null) {
                BusinessSequenceConfiguration configuration =
                        getConfigurationFromAnnotation(entityType.getJavaType(), bksAnnotation);
                if (configuration.getCode() != null) {
                    try {
                        BusinessSequence.findByTypeAndCode(em, configuration.getType(), configuration.getCode());
                    } catch (MissingSequenceException e) {
                        log.info("Sequence not found for {}, creating one", configuration);
                        try {
                            instantiatior.doCreate(configuration, null);
                        } catch (RuntimeException createException) {
                            log.error("Error instantiating sequence {}", configuration, createException);
                        }
                    }
                }
            }
        }
    }

    /**
     * Builds the sequence configuration object starting from a
     * BusinessKeyAnnotation (backward-compatibility).
     */
    private BusinessSequenceConfiguration getConfigurationFromAnnotation(final Class<?> clazz,
                                                                         final BusinessKeySequence bksAnnotation) {
        String sequenceName = clazz.getCanonicalName();
        BusinessSequenceConfiguration sequenceConfiguration = null;
        try {
            sequenceConfiguration = getConfiguration(sequenceName);
        } catch (SequenceHandlerConfigurationException e) {
            // instantiate sequence if not exists
            sequenceConfiguration =
                    new BusinessSequenceConfiguration(clazz, bksAnnotation.prefix(), bksAnnotation.length(),
                            bksAnnotation.allocationSize());
            sequenceConfigurationCache.put(sequenceName, sequenceConfiguration);
        }
        return sequenceConfiguration;
    }

    /**
     * Generates and returns the next code in the sequence. If a updateField is
     * specified in the sequence configuration, it is also set in the configured
     * field using a groovy assignment expression
     */
    public String generateCode(final String sequence, final Object payload) {
        BusinessSequenceConfiguration sequenceConfiguration = getConfiguration(sequence);
        if (sequenceConfiguration.getUpdateField() != null) {
            return this.setNextCode(sequenceConfiguration, payload);
        }
        return this.nextCode(sequenceConfiguration, payload);
    }

    /**
     * Generates and returns the next code in the sequence created with
     * {@link BusinessKeySequence} annotation. This method doesn't support {@code updateField} for
     * backward-compatibility. If prefix is populated, it generates new {@link BusinessSequence}
     * with overridden prefix.
     *
     * @param payload       the object
     * @param clazz         the object class
     * @param prefix        overridden prefix
     * @param bksAnnotation the annotation used to retrieve configuration
     * @return generated code
     */
    public String generateCode(final Object payload, final Class<?> clazz, final String prefix,
                               final BusinessKeySequence bksAnnotation) {
        BusinessSequenceConfiguration sequenceConfiguration = getConfigurationFromAnnotation(clazz, bksAnnotation);
        sequenceConfiguration = getConfigurationWithPrefix(sequenceConfiguration, prefix);
        return nextCode(sequenceConfiguration, payload);
    }

    /**
     * Generates and returns the next code in the sequence created with
     * {@link BusinessKeySequence} annotation. This method doesn't support {@code updateField} for
     * backward-compatibility.
     *
     * @param payload       the object
     * @param clazz         the object class
     * @param bksAnnotation the annotation used to retrieve configuration
     * @return generated code
     */
    public String generateCode(final Object payload, final Class<?> clazz, final BusinessKeySequence bksAnnotation) {
        BusinessSequenceConfiguration sequenceConfiguration = getConfigurationFromAnnotation(clazz, bksAnnotation);
        return nextCode(sequenceConfiguration, payload);
    }


    private String nextCode(final BusinessSequenceConfiguration sequenceConfiguration, final Object payload) {
        String type = sequenceConfiguration.getType();
        String code = this.evaluateCode(sequenceConfiguration, payload);
        return generator.nextCode(type, code);
    }

    private String setNextCode(final BusinessSequenceConfiguration sequenceConfiguration, final Object payload) {
        String generatedCode = this.nextCode(sequenceConfiguration, payload);
        try {
            Script updateScript = groovyShell
                    .parse("payload." + Objects.requireNonNull(sequenceConfiguration.getUpdateField(), "updateField") +
                            " = generatedCode");
            Binding binding = new Binding();
            binding.setVariable("generatedCode", generatedCode);
            binding.setVariable("payload", payload);
            updateScript.setBinding(binding);
            updateScript.run();
        } catch (NullPointerException | CompilationFailedException e) {
            throw new SequenceHandlerConfigurationException(sequenceConfiguration.getType(), e);
        }
        return generatedCode;
    }

    /**
     * If a fixed code is provided in configuration, this code is returned.
     * Otherwise this method evaluates the vaule provided in genCodeMethod as a
     * groovy script. This allows to retrieve a sequence based on data from
     * another entity: i.e. use a different sequence for each parcel depening on
     * the site it is created into / company its items belong to.
     */
    private String evaluateCode(final BusinessSequenceConfiguration configuration, final Object payload) {
        if (configuration.getCode() != null) {
            return configuration.getCode();
        }
        try {
            String codeMethod = Objects.requireNonNull(configuration.getGenCodeMethod(), "genCodeMethod");
            Script initCodeScript = groovyShell.parse(codeMethod);
            Binding binding = new Binding();
            binding.setVariable("payload", payload);
            initCodeScript.setBinding(binding);
            Object result = initCodeScript.run();
            return result.toString();
        } catch (NullPointerException | CompilationFailedException e) {
            throw new SequenceHandlerConfigurationException(configuration.getType(), e);
        }
    }


    /**
     * Retrieve configuration for sequence from cache or try to load it.
     */
    private BusinessSequenceConfiguration getConfiguration(final String sequence) {
        if (sequenceConfigurationCache.containsKey(sequence)) {
            return sequenceConfigurationCache.get(sequence);
        }
        BusinessSequenceConfiguration configuration = this.loadConfiguration(sequence);
        sequenceConfigurationCache.put(sequence, configuration);
        return configuration;
    }

    /**
     * Load sequence configuration from sequenceHandler file.
     */
    private BusinessSequenceConfiguration loadConfiguration(final String sequence) {
        if (context == null || sequenceConfigurationProvider == null) {
            throw new SequenceHandlerConfigurationException(sequence, "missing context");
        }
        Configuration sequenceConfiguration = sequenceConfigurationProvider.getPerModuleItem("sequence-handler");
        if (sequenceConfiguration == null) {
            throw new SequenceHandlerConfigurationException(sequence, "missing configuration file");
        }
        BusinessSequenceConfiguration configuration = sequenceConfiguration.get(sequence);
        if (configuration == null) {
            throw new SequenceHandlerConfigurationException(sequence, "missing configuration");
        }
        if (configuration.getType() == null) {
            configuration.setType(sequence);
        }
        return configuration;
    }

    private BusinessSequenceConfiguration getConfigurationWithPrefix(final BusinessSequenceConfiguration configuration,
                                                                     final String prefix) {
        if (StringUtils.isBlank(prefix) || Objects.equals(prefix, configuration.getCode())) {
            return configuration;
        }
        BusinessSequenceConfiguration sequenceConfiguration = configuration.cloneWithPrefix(prefix);
        try {
            BusinessSequence.findByTypeAndCode(em, sequenceConfiguration.getType(),
                    sequenceConfiguration.getCode());
        } catch (MissingSequenceException e) {
            instantiatior.doCreate(sequenceConfiguration, null);
        }
        return sequenceConfiguration;
    }
}