/**
 * Copyright © CENRA (2016)
 *
 * remi.clement@espaces-naturels.fr
 *
 * This software is a multi-platform and portable application based on
 * the scientifical toolbox to monitor wetlands. With this toolbox it’s
 * possible to calculate indicators «RhoMéO» to monitor quality,
 * fonctionalities and urban and agricultural pressures of wetlands.
 * Indicators are calculated using naturalist data entered and/or imported,
 * and geographic data downloaded from a geographic server.
 *
 * This software is governed by the CeCILL license under French law and
 * abiding by the rules of distribution of free software.  You can  use,
 * modify and/ or redistribute the software under the terms of the CeCILL
 * license as circulated by CEA, CNRS and INRIA at the following URL
 * "http://www.cecill.info".
 *
 * As a counterpart to the access to the source code and  rights to copy,
 * modify and redistribute granted by the license, users are provided only
 * with a limited warranty  and the software's author,  the holder of the
 * economic rights,  and the successive licensors  have only  limited
 * liability.
 *
 * In this respect, the user's attention is drawn to the risks associated
 * with loading,  using,  modifying and/or developing or reproducing the
 * software by the user in light of its specific status of free software,
 * that may mean  that it is complicated to manipulate,  and  that  also
 * therefore means  that it is reserved for developers  and  experienced
 * professionals having in-depth computer knowledge. Users are therefore
 * encouraged to load and test the software's suitability as regards their
 * requirements in conditions enabling the security of their systems and/or
 * data to be ensured and,  more generally, to use and operate it in the
 * same conditions as regards security.
 *
 * The fact that you are presently reading this means that you have had
 * knowledge of the CeCILL license and that you accept its terms.
*/
package fr.cenra.rhomeo.core;

import fr.cenra.rhomeo.api.Version;
import fr.cenra.rhomeo.api.data.DataContext;
import fr.cenra.rhomeo.api.data.Dataset;
import fr.cenra.rhomeo.api.data.ObjectWrapper;
import fr.cenra.rhomeo.api.data.Protocol;
import fr.cenra.rhomeo.api.data.Reference;
import fr.cenra.rhomeo.api.data.ReferenceDescription;
import fr.cenra.rhomeo.api.data.Site;
import fr.cenra.rhomeo.api.data.Statement;
import fr.cenra.rhomeo.api.process.ProcessContext;
import fr.cenra.rhomeo.api.result.Index;
import fr.cenra.rhomeo.api.ui.StatementEditor;
import fr.cenra.rhomeo.api.process.Indicator;
import fr.cenra.rhomeo.api.result.AdditionalValue;
import fr.cenra.rhomeo.api.ui.StatementEditorSpi;
import fr.cenra.rhomeo.core.data.DashboardResultsManager;
import fr.cenra.rhomeo.core.data.DataContextManager;
import fr.cenra.rhomeo.core.data.ReferenceManager;
import fr.cenra.rhomeo.core.preferences.ftp.FTPPreferences;

import java.beans.IntrospectionException;
import java.io.IOException;
import java.net.UnknownHostException;
import java.security.GeneralSecurityException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import fr.cenra.rhomeo.core.util.SerializableDataContext;
import java.util.HashMap;
import java.util.HashSet;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javax.validation.constraints.NotNull;

import org.apache.commons.net.ftp.FTPClient;
import org.apache.sis.util.ArgumentChecks;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.support.AbstractApplicationContext;
import org.springframework.stereotype.Component;

/**
 * Represents the application session for a user.
 *
 * @author Alexis Manin (Geomatys)
 */
@Component
public class Session implements ApplicationContextAware {

    private static Session INSTANCE;
    private ApplicationContext ctx;

    private Dataset dataset;
    private DataContext dataContext;
    private ProcessContext processContext;
    private Set<Index> results;
    private final Set<AdditionalValue> additionalValues = new HashSet<>();

    @Autowired
    private List<Protocol> protocols;

    @Autowired
    private List<Indicator> indicators;

    @Autowired
    private DataContextManager contextManager;

    @Autowired
    private DashboardResultsManager resultsManager;

    private final ReadOnlyObjectWrapper<WorkflowStep> workflowStep = new ReadOnlyObjectWrapper<>();

    public static Session getInstance() throws IllegalStateException {
        if (INSTANCE == null) {
            throw new IllegalStateException("Application context not initialized !");
        }

        return INSTANCE;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        if (applicationContext != null) {
            ctx = applicationContext;
            INSTANCE = this;
        } else {
            INSTANCE = null;
        }
    }

    /**
     * Close the session, releasing all resources.
     */
    public void close() {
        if (ctx instanceof AbstractApplicationContext) {
            ((AbstractApplicationContext)ctx).close();
        }

        INSTANCE = null;
    }

    /**
     * Try to create a new instance for given bean name.
     *
     * @param <T> Type of the wanted bean.
     * @param qualifier Name of the wanted prototype.
     * @param beanClass The class to use to create Spring prototype.
     * @return A new bean corresponding to the prototype registered for given qualifier.
     * @throws IllegalArgumentException If given bean definition is not a prototype.
     */
    public <T> T createPrototype(final String qualifier, final Class<T> beanClass) throws IllegalArgumentException {
        if (ctx.getAutowireCapableBeanFactory().isPrototype(qualifier)) {
            return ctx.getBean(qualifier, beanClass);
        } else {
            throw new IllegalArgumentException("Given qualifier does not represent a prototype object !");
        }
    }

    /**
     * Try to create a new instance for given bean name.
     *
     * @param qualifier Name of the wanted prototype.
     * @return A new bean corresponding to the prototype registered for given qualifier.
     * @throws IllegalArgumentException If given bean definition is not a prototype.
     */
    public Object createPrototype(final String qualifier) throws IllegalArgumentException {
        if (ctx.getAutowireCapableBeanFactory().isPrototype(qualifier)) {
            return ctx.getBean(qualifier);
        } else {
            throw new IllegalArgumentException("Given qualifier does not represent a prototype object !");
        }
    }

    /**
     * Try to create a new instance of given type.
     *
     * @param <T> Type of the wanted bean.
     * @param beanClass The class to use to find Spring prototype definition.
     * @param constructorArgs Possible list of constructor arguments for the requiered prototype.
     * @return  A new bean of given type.
     * @throws IllegalArgumentException If given bean definition is not a prototype.
     */
    public <T> T createPrototype(final Class<T> beanClass, final Object... constructorArgs) throws BeansException {
        return ctx.getBean(beanClass, constructorArgs);
    }

    /**
     * Create a new connection over the FTP service providing {@link Reference}
     * data. The provided {@link FTPClient} must be closed using {@link FTPClient#disconnect() }
     * once you've finished your work.
     *
     * @return A client to exchange data with FTP service which contains reference files.
     *
     * @throws IOException If we cannot connect to distant service.
     * @throws java.security.GeneralSecurityException If an authentication is
     * required, but we cannot access to password.
     */
    @NotNull
    public FTPClient connectReferenceFTP() throws IOException, GeneralSecurityException {
        return ctx.getBean(FTPPreferences.class).getAccess(FTPPreferences.ACCESS_POINTS.REFERENCES).createClient();
    }

    /**
     * Create a new connection over the FTP service providing result data.
     * data. The provided {@link FTPClient} must be closed using {@link FTPClient#disconnect() }
     * once you've finished your work.
     *
     * @return A client to exchange data with FTP service which contains result files.
     *
     * @throws UnknownHostException If we cannot connect to distant service.
     * @throws java.security.GeneralSecurityException If an authentication is
     * required, but we cannot access to password.
     */
    @NotNull
    public FTPClient connectResultFTP() throws IOException, GeneralSecurityException {
        return ctx.getBean(FTPPreferences.class).getAccess(FTPPreferences.ACCESS_POINTS.RESULTS).createClient();
    }

    /**
     * Try to find a singleton or prototype for the given class.
     *
     * @param <T> Type of the wanted bean.
     * @param beanClass The class to use to find Spring prototype definition.
     * @return A new bean of given type.
     */
    public <T> T getBean(final Class<T> beanClass) {
        return ctx.getBean(beanClass);
    }
    /**
     * Try to find a singleton or prototype for the given class.
     *
     * @param beanName The name of a bean to find Spring prototype definition.
     * @return A new bean of given type.
     */
    public Object getBean(final String beanName) {
        return ctx.getBean(beanName);
    }


    public DataContext getDataContext() {
        return dataContext;
    }

    public Dataset getDataset() {
        return dataset;
    }

    /**
     * Request a new edition context for given site and protocol.
     *
     * /!\ Use with care ! Any call to this method will reset previously seized
     * data.
     *
     * @param site The site to start edition for.
     * @param protocol The protocol defining data type to seize.
     */
    public void startEdition(final Site site, final Protocol protocol) {
        ArgumentChecks.ensureNonNull("Site", site);
        ArgumentChecks.ensureNonNull("Protocol", protocol);
        if (!protocol.isCompatible(site)) {
            throw new IllegalArgumentException("Target site is not compatible with chosen protocol !");
        }

        final SerializableDataContext srDataContext;
        try {
            srDataContext = contextManager.loadContext(site, protocol);
        } catch (IOException ex) {
            throw new RhomeoRuntimeException(ex);
        }

        if (srDataContext != null) {
            final DataContext context = srDataContext.toDataContext(this);
            if (context != null) {
                this.dataContext = context;
            } else {
                this.dataContext = new DataContext(site, protocol);
            }
        } else {
            this.dataContext = new DataContext(site, protocol);
        }

        // Now that we've got the data context, we'll check that its reference
        // versions are installed. If not, we'll remove them from data context.
        checkDataContext(dataContext);

        this.dataset = new Dataset(protocol);
    }

    private static void checkDataContext(final DataContext ctx) {
        for (final ReferenceDescription description : ctx.getProtocol().getReferenceTypes()) {
            final ReferenceManager manager;
            try {
                manager = ReferenceManager.getOrCreate(description);
            } catch (IntrospectionException ex) {
                throw new RhomeoRuntimeException(ex);
            }
            if (!manager.getInstalledVersions().contains(ctx.getReferences().get(description.getReferenceType()))) {
                ctx.getReferences().remove(description.getReferenceType());
            }
        }
    }

    /**
     * A site name has been updated so update this old name in all serialized files
     * working on the old name.
     *
     * @param site New site containing new name.
     * @param oldName Old site name.
     */
    public void serializeUpdateSiteName(Site site, String oldName) throws IOException, IntrospectionException, ReflectiveOperationException {
        resultsManager.updateDashboardResultsSiteName(site, oldName);
        contextManager.updateReferencesSiteName(oldName, site.getName());
    }

    /**
     * A site has been deleted so delete all references of this site name in previously
     * serialized files for this site.
     *
     * @param siteName
     */
    public void serializeDeleteSite(final String siteName) throws IOException, IntrospectionException, ReflectiveOperationException {
        resultsManager.removeDashboardResultsForSite(siteName);
        contextManager.removeReferencesForSite(siteName);
    }

    /**
     * Get results for the current {@linkplain DataContext data context}.
     *
     * @return A set of results, may be {@code null} or empty.
     */
    public Set<Index> getResults() {
        return results;
    }

    /**
     * Set results for the current {@linkplain DataContext data context}.
     *
     * @param results a set of results.
     */
    public void setResults(final Set<Index> results) {
        this.results = results;
    }

    public Set<AdditionalValue> getAdditionalValues() {
        return additionalValues;
    }

    /**
     * Get the process context.
     *
     * @return process context, may be {@code null}.
     */
    public ProcessContext getProcessContext() {
        return processContext;
    }

    public void setProcessContext(ProcessContext processContext) {
        this.processContext = processContext;
    }

    /**
     *
     * @return Observable property describing current application state.
     */
    public ReadOnlyObjectProperty<WorkflowStep> workflowStepProperty() {
        return workflowStep.getReadOnlyProperty();
    }

    /**
     *
     * @return application current working step. If null, it means application is
     * waiting for the user to choose a protocol to operate on.
     */
    public WorkflowStep getWorkflowStep() {
        return workflowStep.get();
    }

    /**
     * Request application to focus on given workflow step. This method will
     * first ensure that all needed data can be found. If not, the step is left
     * unchanged.
     *
     * @param newStep The step we want to go to.
     * @return True if session succeeded to update the application mode. False
     * if it cannot perform queried operation (due to missing data).
     */
    public boolean requestWorkflowStep(final WorkflowStep newStep) {
        if (newStep == null) {
            clearWorkflow();

        } else {
            switch (newStep) {
                case DATASET:
                    if (dataContext != null)
                        workflowStep.set(newStep);
                    break;
                case PROCESS:
                    if (processContext != null && dataContext != null) {
                        checkDataContext(dataContext);
                        if (dataContext.getReferences().size() >= dataContext.getProtocol().getReferenceTypes().size()) {
                            workflowStep.set(newStep);
                        }
                    }
                    break;
                case RESULT:
                case FINALIZATION:
                    if (results != null)
                        workflowStep.set(newStep);
            }
        }

        return newStep == workflowStep.get();
    }

    private void clearWorkflow() {
        dataContext = null;
        dataset = null;
        processContext = null;
        results = null;
        additionalValues.clear();

        workflowStep.set(null);
    }

    /**
     * Get all protocols found.
     *
     * @return All protocols.
     */
    public List<Protocol> getProtocols() {
        return protocols;
    }

    /**
     * Get all indicators found.
     *
     * @return All indicators.
     */
    public List<Indicator> getIndicators() {
        return indicators;
    }

    /**
     * Return the description object corresponding to given {@link Reference} class.
     * @param <T> Type of reference to get a description for.
     * @param refType Type of reference to get a description for.
     * @return Description object for requested reference class.
     */
    public <T extends Reference> ReferenceDescription<T> getDescription(final Class<T> refType) {
        final Map<String, ReferenceDescription> beans = ctx.getBeansOfType(ReferenceDescription.class);
        for (final ReferenceDescription desc : beans.values()) {
            if (desc.getReferenceType().equals(refType)) {
                return desc;
            }
        }

        return null;
    }

    /**
     * Analyze the given object, and inject autowired fields according to the
     * current application context content.
     * @param o The object to inject dependencies into.
     */
    public void injectDependencies(Object o) {
        ctx.getAutowireCapableBeanFactory().autowireBean(o);
    }

    public Optional<StatementEditor> findEditor(Class<? extends Statement> dataType) {
        ArgumentChecks.ensureNonNull("Data type", dataType);
        final Map<String, StatementEditorSpi> beans = ctx.getBeansOfType(StatementEditorSpi.class);
        StatementEditorSpi fallback = null;
        Class<? extends Statement> editorType;
        for (final StatementEditorSpi spi : beans.values()) {
            editorType = spi.getDataType();
            if (dataType.equals(editorType)) {
                return Optional.of(spi.createEditor());
            } else if (editorType.isAssignableFrom(dataType)) {
                if (fallback == null || fallback.getDataType().isAssignableFrom(editorType)) {
                    fallback = spi;
                }
            }
        }

        return fallback == null? Optional.empty() : Optional.of(fallback.createEditor());
    }

    /**
     * Find and return the first found wrapper for given data type, if any.
     *
     * @param <T> Data type to wrap.
     * @param dataType The type we want to export.
     * @return First found wrapper for input class, or an empty optional if no
     * wrapper can be found.
     */
    public <T> Optional<ObjectWrapper<T, ?>> getWrapper(final Class<T> dataType) {
        if (dataType == null)
            return Optional.empty();
        final Map<String, ObjectWrapper> beans = ctx.getBeansOfType(ObjectWrapper.class);
        for (final ObjectWrapper wrapper : beans.values()) {
            if (wrapper.getInputType().equals(dataType))
                return Optional.of(wrapper);
        }

        return Optional.empty();
    }

        /**
     * Create a new map whose keys are description for given reference type.
     * Values are not changed.
     *
     * We need this to convert reference versions provided by data context into
     * something usable for metadata export.
     *
     * @param input The map to get values and keys to transform from.
     * @return Copy of input map, with transformed keys.
     */
    public Map<ReferenceDescription, Version> transform(final Map<Class<Reference>, Version> input) {
        if (input == null || input.isEmpty())
            return (Map)input;

        final Map<ReferenceDescription, Version> result = new HashMap<>(input.size());
        for (final Map.Entry<Class<Reference>, Version> entry : ((Map<Class<Reference>, Version>) input).entrySet()) {
            result.put(getDescription(entry.getKey()), entry.getValue());
        }

        return result;
    }
}