/**
 * 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.data.site;

import com.vividsolutions.jts.geom.MultiPolygon;
import fr.cenra.rhomeo.api.data.Site;
import fr.cenra.rhomeo.core.RhomeoCore;
import static fr.cenra.rhomeo.core.RhomeoCore.SITES;
import static fr.cenra.rhomeo.core.RhomeoCore.SITES_SHP_PATH;
import fr.cenra.rhomeo.core.RhomeoRuntimeException;
import fr.cenra.rhomeo.core.data.site.SiteImpl;
import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javax.annotation.PreDestroy;
import org.apache.sis.storage.DataStoreException;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.collection.Cache;
import org.apache.sis.util.iso.Names;
import org.geotoolkit.data.FeatureCollection;
import org.geotoolkit.data.FeatureIterator;
import org.geotoolkit.data.FeatureReader;
import org.geotoolkit.data.FeatureWriter;
import org.geotoolkit.data.query.Query;
import org.geotoolkit.data.query.QueryBuilder;
import org.geotoolkit.data.shapefile.ShapefileFeatureStore;
import org.geotoolkit.data.shapefile.ShapefileFeatureStoreFactory;
import org.geotoolkit.factory.FactoryFinder;
import org.geotoolkit.factory.Hints;
import org.geotoolkit.feature.Feature;
import org.geotoolkit.feature.FeatureBuilder;
import org.geotoolkit.feature.FeatureTypeBuilder;
import org.geotoolkit.feature.type.FeatureType;
import org.geotoolkit.parameter.Parameters;
import org.opengis.filter.Filter;
import org.opengis.filter.FilterFactory2;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.util.FactoryException;
import org.opengis.util.LocalName;
import org.springframework.stereotype.Repository;
import org.springframework.validation.annotation.Validated;


/**
 * Access point to sites stored in the file system.
 * An {@linkplain ObservableList observable list} can be followed for listening
 * changes via the {@link #findAll()} method.
 *
 * @author Cédric Briançon (Geomatys)
 */
@Repository
@Validated
public class SiteRepositoryImpl implements SiteRepository {

    private static final LocalName NAME = Names.createLocalName(null, ":", SITES);

    private static FeatureType SITE_TYPE;

    private final FilterFactory2 ff;

    /**
     * Cache result of {@link #findNames() ()} method, to avoid reading the
     * shapefile each time a site is updated, created or deleted.
     */
    private WeakReference<ObservableList<String>> allNames;

    /**
     * Cache loaded sites, to ensure their uniqueness in memory.
     */
    private final Cache<String, Site> cache;

    /**
     * Feature store to use.
     */
    private final ShapefileFeatureStore store;

    /**
     * Get or create the {@linkplain #store feature store}, creating all folders needed
     * if necessary.
     * @throws java.lang.Exception If an error occurs while initializing shapefile.
     */
    public SiteRepositoryImpl() throws Exception {
        Files.createDirectories(RhomeoCore.SITES_PATH);

        final ParameterValueGroup parameters = ShapefileFeatureStoreFactory.PARAMETERS_DESCRIPTOR.createValue();
        Parameters.getOrCreate(ShapefileFeatureStoreFactory.PATH, parameters)
                .setValue(SITES_SHP_PATH.toUri());
        Parameters.getOrCreate(ShapefileFeatureStoreFactory.NAMESPACE, parameters)
                .setValue("no namespace");

        final ShapefileFeatureStoreFactory fact = new ShapefileFeatureStoreFactory();

        if (Files.exists(SITES_SHP_PATH)) {
            store = (ShapefileFeatureStore) fact.open(parameters);
        } else {
            store = (ShapefileFeatureStore) fact.create(parameters);
            store.createFeatureType(NAME, getSiteType());
        }

        // Empty or corrupted file
        if (store.getName() == null || !store.getName().equals(NAME)) {
            store.createFeatureType(NAME, getSiteType());
        }

        final Hints hints = new Hints();
        hints.put(Hints.FILTER_FACTORY, FilterFactory2.class);
        ff = (FilterFactory2) FactoryFinder.getFilterFactory(hints);

        cache = new Cache(4, 0, false);
    }

    /**
     *
     * @return Cached data, or null if cache has not been initialized yet.
     */
    private synchronized ObservableList<String> getCachedNames() {
        return allNames == null ? null : allNames.get();
    }

    /**
     * Create a new filter which keeps only sites whose name is equal to given one.
     * @param nameValue The name to filter against.
     * @return A new filter.
     */
    private Filter newNameFilter(final String nameValue) {
        return ff.equals(ff.property(RhomeoCore.FT_PROPERTIES.NAME.name()), ff.literal(nameValue));
    }

    /**
     *
     * @param name The name to filter against.
     * @return A collection of sites whose names are equal to given one.
     */
    private FeatureCollection filteredByName(final String name) {
        ArgumentChecks.ensureNonEmpty("Name to search", name.trim());
        return store.createSession(false).getFeatureCollection(
                QueryBuilder.filtered(this.NAME, newNameFilter(name))
        );
    }

    /**
     *
     * @param siteName A name to find sites for.
     * @return True if at least one site with the same name already exists.
     */
    private synchronized boolean exists(final String siteName) {
        ArgumentChecks.ensureNonEmpty("Name to search", siteName.trim());
        // Do not use cache here, because if an existing site name has been modified,
        // it would appear in cache.
        return !filteredByName(siteName).isEmpty();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public synchronized Site findOne(final String name) {
        if (name == null || name.isEmpty())
            return null;

        try {
            return cache.getOrCreate(name, () -> {
                final FeatureCollection col = filteredByName(name);
                if (col.isEmpty()) {
                    return null;
                }

                try (final FeatureIterator it = col.iterator()) {
                    return toSite(it.next());
                }
            });
        } catch (Exception e) {
            throw new RhomeoRuntimeException("Cannot load site named ".concat(name), e);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public synchronized void create(final Site site) throws DuplicatedKeyException {
        ArgumentChecks.ensureNonNull("Site", site);

        if (exists(site.getName())) {
            throw new DuplicatedKeyException();
        }

        try {
            final Feature toAdd = toFeature(site);
            store.addFeatures(NAME, Collections.singleton(toAdd));
        } catch (DataStoreException e) {
            throw new RhomeoRuntimeException(e);
        }

        ObservableList<String> cached = getCachedNames();
        if (cached != null) {
            cached.add(site.getName());
            Collections.sort(cached);
        }

        cache.put(site.getName(), site);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public synchronized ObservableList<Site> findAll() {
        throw new UnsupportedOperationException("Cannot load full site list in memory !");
    }

    public synchronized ObservableList<String> findNames() {
        ObservableList<String> result = getCachedNames();
        if (result == null) {
            final List<String> sites = new ArrayList<>();
            final Query query;
            try {
                final QueryBuilder builder = new QueryBuilder(store.getName());
                builder.setProperties(new String[]{RhomeoCore.FT_PROPERTIES.NAME.name()});
                query = builder.buildQuery();
                try (final FeatureReader reader = store.getFeatureReader(query)) {
                    while (reader.hasNext()) {
                        sites.add(reader.next().getPropertyValue(RhomeoCore.FT_PROPERTIES.NAME.name()).toString());
                    }
                }
            } catch (DataStoreException ex) {
                throw new RhomeoRuntimeException(ex);
            }
            Collections.sort(sites);
            result = FXCollections.observableList(sites);
            allNames = new WeakReference<>(result);
        }

        return FXCollections.unmodifiableObservableList(result);
    }

    /**
     * Update the first encountered feature matching given filter.
     *
     * @param filter
     * @param site
     * @throws DataStoreException
     */
    private synchronized void updateFeature(final Filter filter, final Site site) throws DataStoreException {
        // There is a bug in the shp method getFeatureWriter(name, filter), it never uses the filter parameter!
        // Consequently we have to test each filter afterwards, via filter.evaluate(feature)
        boolean found = false;
        try(final FeatureWriter writer = store.getFeatureWriter(NAME, Filter.INCLUDE)) {
            while (writer.hasNext()) {
                final Feature f = writer.next();
                if (filter.evaluate(f)) {
                    toFeature(f, site);
                    writer.write();
                    found = true;
                }
            }
        }

        if (!found) {
            // If we've got until here, no element has been found for given filter.
            throw new IllegalArgumentException("Cannot update unexisting site !");
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void update(final Site site) {
        try {
            update(site, null);
        } catch (DuplicatedKeyException ex) {
            throw new RhomeoRuntimeException(ex); // should not happen, as key is not modified.
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public synchronized void update(final Site site, String oldName) throws DuplicatedKeyException {
        ArgumentChecks.ensureNonNull("Site", site);

        // Defines if we need to sort the sites list in cache or not.
        // If user updated the site name, then it is required to ensure keeping this list sorted.
        boolean needsToSort = false;
        if (oldName == null || oldName.trim().isEmpty() || oldName.equals(site.getName())) {
            oldName = site.getName();
        } else {
            // Here we want to rename the current site name, and its position in the list might be impacted.
            needsToSort = true;
        }

        ArgumentChecks.ensureNonNull("Name to update", oldName);
        if (!oldName.equals(site.getName()) && exists(site.getName())) {
            throw new DuplicatedKeyException("Cannot change site name because another one already has the same name !");
        }

        // Persist change
        try {
            // Ok not already used key, we can update the matching feature
            updateFeature(newNameFilter(oldName), site);
        } catch (Exception e) {
            throw new RhomeoRuntimeException(e);
        }

        // update caches
        if (needsToSort) {
            final ObservableList<String> cached = getCachedNames();
            if (cached != null)
                cached.set(cached.indexOf(oldName), site.getName());
            Collections.sort(cached);
        }

        if (needsToSort) {
            cache.remove(oldName);
            cache.put(site.getName(), site);
        } else {
            cache.replace(oldName, site);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public synchronized void delete(final String name) {
        ArgumentChecks.ensureNonEmpty("Site name", name.trim());
        // There is a bug in the shp method getFeatureWriter(name, filter), it never uses the filter parameter!
        // Consequently we have to test each filter afterwards, via filter.evaluate(feature)
        final Filter filter = newNameFilter(name);
        try (final FeatureWriter writer = store.getFeatureWriter(this.NAME, Filter.INCLUDE)) {
            while (writer.hasNext()) {
                final Feature f = writer.next();
                if (filter.evaluate(f)) {
                    writer.remove();
                }
            }
        } catch (DataStoreException e) {
            throw new RhomeoRuntimeException(e);
        }

        ObservableList<String> cached = getCachedNames();
        if (cached != null) {
            cached.removeAll(Collections.singleton(name));
        }

        cache.remove(name);
    }

    /**
     * {@inheritDoc}
     */
    @PreDestroy
    @Override
    public synchronized void close() throws Exception {
        allNames = null;
        store.close();
    }

    public static final FeatureType getSiteType() {
        if (SITE_TYPE == null) {
            final FeatureTypeBuilder ftb = new FeatureTypeBuilder();
            ftb.setName(NAME);
            try {
                ftb.add(RhomeoCore.FT_PROPERTIES.the_geom.name(), MultiPolygon.class, RhomeoCore.getSiteCRS());
            } catch (FactoryException ex) {
                throw new RhomeoRuntimeException(ex);
            }
            ftb.add(RhomeoCore.FT_PROPERTIES.NAME.name(), String.class);
            ftb.add(RhomeoCore.FT_PROPERTIES.REMARKS.name(), String.class);
            ftb.add(RhomeoCore.FT_PROPERTIES.COUNTY.name(), String.class);
            ftb.add(RhomeoCore.FT_PROPERTIES.REFERENT.name(), String.class);
            ftb.add(RhomeoCore.FT_PROPERTIES.ORG.name(), String.class);
            ftb.add(RhomeoCore.FT_PROPERTIES.TYPE.name(), String.class);
            ftb.add(RhomeoCore.FT_PROPERTIES.ODONATE.name(), String.class);
            ftb.add(RhomeoCore.FT_PROPERTIES.ORTHOPTERE.name(), String.class);
            ftb.setDefaultGeometry(RhomeoCore.FT_PROPERTIES.the_geom.name());
            SITE_TYPE = ftb.buildFeatureType();
        }

        return SITE_TYPE;
    }

    /**
     * Converts a {@link Site} into a {@link Feature}.
     *
     * @param site Site to convert.
     * @return A new feature matching this site, never {@code null}
     * @throws DataStoreException
     */
    public static Feature toFeature(final Site site) throws DataStoreException {
        final FeatureBuilder sfb = new FeatureBuilder(getSiteType());
        sfb.setPropertyValue(RhomeoCore.FT_PROPERTIES.the_geom.name(), site.getGeometry());
        sfb.setPropertyValue(RhomeoCore.FT_PROPERTIES.COUNTY.name(), site.getCountyCode());
        sfb.setPropertyValue(RhomeoCore.FT_PROPERTIES.NAME.name(), site.getName());
        sfb.setPropertyValue(RhomeoCore.FT_PROPERTIES.ODONATE.name(), site.getOdonateType());
        sfb.setPropertyValue(RhomeoCore.FT_PROPERTIES.ORTHOPTERE.name(), site.getOrthoptereType());
        sfb.setPropertyValue(RhomeoCore.FT_PROPERTIES.ORG.name(), site.getOrganization());
        sfb.setPropertyValue(RhomeoCore.FT_PROPERTIES.REFERENT.name(), site.getReferent());
        sfb.setPropertyValue(RhomeoCore.FT_PROPERTIES.REMARKS.name(), site.getRemarks());
        sfb.setPropertyValue(RhomeoCore.FT_PROPERTIES.TYPE.name(), site.getZoneType());
        return sfb.buildFeature(site.getName());
    }

    /**
     * Apply site values into an existing feature.
     *
     * @param feature existing feature to update
     * @param site new values to extract
     * @throws DataStoreException
     */
    public static void toFeature(final Feature feature, final Site site) throws DataStoreException {
        feature.setPropertyValue(RhomeoCore.FT_PROPERTIES.the_geom.name(), site.getGeometry());
        feature.setPropertyValue(RhomeoCore.FT_PROPERTIES.COUNTY.name(), site.getCountyCode());
        feature.setPropertyValue(RhomeoCore.FT_PROPERTIES.NAME.name(), site.getName());
        feature.setPropertyValue(RhomeoCore.FT_PROPERTIES.ODONATE.name(), site.getOdonateType());
        feature.setPropertyValue(RhomeoCore.FT_PROPERTIES.ORTHOPTERE.name(), site.getOrthoptereType());
        feature.setPropertyValue(RhomeoCore.FT_PROPERTIES.ORG.name(), site.getOrganization());
        feature.setPropertyValue(RhomeoCore.FT_PROPERTIES.REFERENT.name(), site.getReferent());
        feature.setPropertyValue(RhomeoCore.FT_PROPERTIES.REMARKS.name(), site.getRemarks());
        feature.setPropertyValue(RhomeoCore.FT_PROPERTIES.TYPE.name(), site.getZoneType());
    }

    /**
     * Converts a {@link Feature} into a {@link Site}.
     *
     * @param feature Feature to convert into a site.
     * @return A new instance of site, never {@code null}
     */
    public static Site toSite(final Feature feature) {
        final SiteImpl newSite = new SiteImpl((MultiPolygon)feature.getProperty(RhomeoCore.FT_PROPERTIES.the_geom.name()).getValue());
        newSite.setCountyCode(feature.getProperty(RhomeoCore.FT_PROPERTIES.COUNTY.name()).getValue().toString());
        newSite.setName(feature.getProperty(RhomeoCore.FT_PROPERTIES.NAME.name()).getValue().toString());
        newSite.setOdonateType(feature.getProperty(RhomeoCore.FT_PROPERTIES.ODONATE.name()).getValue().toString());
        newSite.setOrthoptereType(feature.getProperty(RhomeoCore.FT_PROPERTIES.ORTHOPTERE.name()).getValue().toString());
        newSite.setOrganization(feature.getProperty(RhomeoCore.FT_PROPERTIES.ORG.name()).getValue().toString());
        newSite.setReferent(feature.getProperty(RhomeoCore.FT_PROPERTIES.REFERENT.name()).getValue().toString());
        newSite.setRemarks(feature.getProperty(RhomeoCore.FT_PROPERTIES.REMARKS.name()).getValue().toString());
        newSite.setZoneType(feature.getProperty(RhomeoCore.FT_PROPERTIES.TYPE.name()).getValue().toString());
        return newSite;
    }
}
