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

import com.vividsolutions.jts.geom.Geometry;
import fr.cenra.rhomeo.api.data.Site;
import fr.cenra.rhomeo.core.RhomeoCore;
import fr.cenra.rhomeo.core.preferences.net.NetPreferences;
import fr.cenra.rhomeo.core.util.GeometryUtils;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Level;
import java.util.stream.Collectors;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javax.xml.namespace.QName;
import javax.xml.stream.XMLStreamException;
import org.apache.sis.storage.DataStoreException;
import org.geotoolkit.data.FeatureReader;
import org.geotoolkit.data.FeatureStoreRuntimeException;
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.wfs.GetFeatureRequest;
import org.geotoolkit.data.wfs.WebFeatureClient;
import org.geotoolkit.data.wfs.WebFeatureException;
import org.geotoolkit.display2d.GO2Utilities;
import org.geotoolkit.feature.Feature;
import org.geotoolkit.feature.FeatureTypeBuilder;
import org.geotoolkit.feature.FeatureUtilities;
import org.geotoolkit.feature.type.AttributeDescriptor;
import org.geotoolkit.feature.type.FeatureType;
import org.geotoolkit.feature.type.PropertyDescriptor;
import org.geotoolkit.feature.xml.XmlFeatureReader;
import org.geotoolkit.feature.xml.jaxp.JAXPStreamFeatureReader;
import org.geotoolkit.filter.DefaultPropertyName;
import org.geotoolkit.map.FeatureMapLayer;
import org.geotoolkit.map.MapBuilder;
import org.geotoolkit.map.MapItem;
import org.geotoolkit.nio.IOUtilities;
import org.geotoolkit.wfs.xml.WFSVersion;
import org.opengis.filter.Filter;
import org.opengis.util.FactoryException;
import org.opengis.util.GenericName;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * Manage geographic referential.
 *
 * @author Johann Sorel (Geomatys)
 */
@Component
public class GeoReferentials {

    public static final String TYPE_ZONE_HYDRO      = "zonehydro";
    public static final String TYPE_RPG             = "rpg";
    public static final String TYPE_TACHE_ARTIF     = "tacheartif";
    public static final String TYPE_TACHE_URBAINE   = "tacheurbaine";
    private static final List<String> NAMES = Arrays.asList(TYPE_ZONE_HYDRO,TYPE_RPG,TYPE_TACHE_ARTIF,TYPE_TACHE_URBAINE);

    @Autowired
    private NetPreferences prefs;

    //cache WFS client
    private WebFeatureClient wfs;

    private synchronized WebFeatureClient getWFSClient() throws MalformedURLException, DataStoreException, WebFeatureException{
        if(wfs==null){
            String url = prefs.getPreference(NetPreferences.WFS_URL);
            if (url == null || (url = url.trim()).isEmpty())
                throw new IllegalStateException("Aucune URL disponible pour les référentiels géographiques");
            wfs = new WebFeatureClient(new URL(url), null, WFSVersion.v110, true);
            wfs.getNames();
        }
        return wfs;
    }

    /**
     * Combine local and server referentials.
     *
     * @param site
     * @return
     */
    public ObservableList<GeoReferential> getReferentials(Site site) throws IllegalArgumentException, IOException, DataStoreException{
        final ObservableList<GeoReferential> refs = getLocalReferentials(site);

        try {
            ObservableList<GeoReferential> wfsReferentials = getWFSReferentials();
            loop:
            for(GeoReferential gr : wfsReferentials){
                for(GeoReferential r : refs){
                    if(r.compareTo(gr)==0){
                        r.tacheArtifWFS().set(gr.tacheArtifWFS().get());
                        r.tacheUrbaineWFS().set(gr.tacheUrbaineWFS().get());
                        r.zoneHydroWFS().set(gr.zoneHydroWFS().get());
                        r.rpgWFS().set(gr.rpgWFS().get());
                        continue loop;
                    }
                }
                refs.add(gr);
            }
        } catch (Exception ex) {
            if(refs.isEmpty()){
                throw ex;
            }else{
                RhomeoCore.LOGGER.log(Level.WARNING, null, ex);
            }
        }

        //sort by year
        Collections.sort(refs);

        return refs;
    }

    /**
     * List available local referentials for given site.
     *
     * @param site
     * @param year
     * @return
     */
    public GeoReferential getLocalReferential(Site site, int year) throws IllegalArgumentException, IOException {
        Path folder = RhomeoCore.REFERENCIELS_GEO_PATH.resolve(GeometryUtils.getSha1(site.getGeometry()));
        if (!Files.isDirectory(folder)) {
            folder = RhomeoCore.REFERENCIELS_GEO_PATH.resolve(site.getName());
        }
        final String strYear = String.valueOf(year);
        for(Path p : IOUtilities.listChildren(folder)){
            if(Files.isDirectory(p)){
                if(p.getFileName().toString().equals(strYear)){
                    final GeoReferential ref = new GeoReferential(site, year);
                    for(Path tp : IOUtilities.listChildren(p)){
                        final String tName = tp.getFileName().toString().toLowerCase();
                        if(tName.startsWith(TYPE_ZONE_HYDRO)){
                            ref.zoneHydroLocal().set(true);
                        }else if(tName.startsWith(TYPE_TACHE_URBAINE)){
                            ref.tacheUrbaineLocal().set(true);
                        }else if(tName.startsWith(TYPE_TACHE_ARTIF)){
                            ref.tacheArtifLocal().set(true);
                        }else if(tName.startsWith(TYPE_RPG)){
                            ref.rpgLocal().set(true);
                        }
                    }
                    return ref;
                }
            }
        }
        throw new IOException("Referential for year "+year+" not found");
    }

    /**
     * List available local referentials for given site.
     *
     * @param site
     * @return
     */
    public ObservableList<GeoReferential> getLocalReferentials(Site site) throws IllegalArgumentException, IOException {
        final ObservableList<GeoReferential> lst = FXCollections.observableArrayList();
        Path folder = RhomeoCore.REFERENCIELS_GEO_PATH.resolve(GeometryUtils.getSha1(site.getGeometry()));
        if (!Files.isDirectory(folder)) {
            folder = RhomeoCore.REFERENCIELS_GEO_PATH.resolve(site.getName());
        }
        if(Files.isDirectory(folder)){
            for(Path p : IOUtilities.listChildren(folder)) {
                if (Files.isDirectory(p)) {
                    final int year;
                    // Ignore directories not matching year names.
                    try {
                        year =  Integer.valueOf(p.getFileName().toString());
                    } catch (NumberFormatException e) {
                        continue;
                    }

                    final GeoReferential ref = new GeoReferential(site,year);
                    for(Path tp : IOUtilities.listChildren(p)){
                        final String tName = tp.getFileName().toString().toLowerCase();
                        if(tName.startsWith(TYPE_ZONE_HYDRO)){
                            ref.zoneHydroLocal().set(true);
                        }else if(tName.startsWith(TYPE_TACHE_URBAINE)){
                            ref.tacheUrbaineLocal().set(true);
                        }else if(tName.startsWith(TYPE_TACHE_ARTIF)){
                            ref.tacheArtifLocal().set(true);
                        }else if(tName.startsWith(TYPE_RPG)){
                            ref.rpgLocal().set(true);
                        }
                    }
                    lst.add(ref);
                }
            }
        }
        return lst;
    }

    /**
     * List server referentials.
     *
     * @return
     * @throws DataStoreException
     * @throws MalformedURLException
     */
    public ObservableList<GeoReferential> getWFSReferentials() throws DataStoreException, MalformedURLException, WebFeatureException{

        final Map<String,GeoReferential> count = new HashMap<>();
        try (WebFeatureClient client = getWFSClient()) {
            final Set<GenericName> names = client.getNames();
            for(GenericName name : names){
                final String[] parts = name.tip().toString().toLowerCase().split("_");
                if(parts.length==2 && NAMES.contains(parts[0])){
                    GeoReferential ref = count.get(parts[1]);
                    if(ref==null) ref = new GeoReferential(null, Integer.valueOf(parts[1]));
                    switch(parts[0]){
                        case TYPE_ZONE_HYDRO    : ref.zoneHydroWFS().set(true); break;
                        case TYPE_RPG           : ref.rpgWFS().set(true); break;
                        case TYPE_TACHE_URBAINE : ref.tacheUrbaineWFS().set(true); break;
                        case TYPE_TACHE_ARTIF   : ref.tacheArtifWFS().set(true); break;
                    }
                    count.put(parts[1], ref);
                }
            }
        }

        return FXCollections.observableArrayList(count.values());
    }

    public MapItem getWFSLayers(final Optional<Filter> filter, final String... referentialNames) throws DataStoreException, MalformedURLException {
        MapItem result = MapBuilder.createItem();
        result.setName("WFS layers");
        try (WebFeatureClient client = getWFSClient()) {
            final Set<GenericName> names = client.getNames().stream()
                    .filter(name -> {
                        if (referentialNames == null || referentialNames.length < 1)
                            return true;
                        for (final String refName : referentialNames) {
                            if (name.tip().toString().toLowerCase().startsWith(refName.toLowerCase()))
                                return true;
                        }
                        return false;
                    })
                    .collect(Collectors.toSet());
            for (GenericName name : names) {
                Query q = filter
                        .map(f -> {
                            final QueryBuilder builder = new QueryBuilder(name);
                            builder.setFilter(f);
                            builder.setProperties(new String[]{"geom"});
                            return builder.buildQuery();
                        })
                        .orElseGet(() -> QueryBuilder.all(name));
                final FeatureMapLayer layer = MapBuilder.createFeatureLayer(
                        client.createSession(false).getFeatureCollection(q)
                );
                layer.setName(name.tip().toString());
                layer.setVisible(false);
                result.items().add(layer);
            }
        }
        return result;
    }

    /**
     * Download from WFS service the referential for specified year.
     *
     * @param site analyze site
     * @param year georeferential year
     * @throws IllegalArgumentException If we cannot find any hydrographic zone
     * intersecting site buffer.
     * @throws Exception Any other exception is a read/write error.
     */
    public void downloadReferential(Site site, int year) throws Exception {

        final Path localFolder = RhomeoCore.REFERENCIELS_GEO_PATH.resolve(GeometryUtils.getSha1(site.getGeometry())).resolve(String.valueOf(year));

        //extract only data on site envelope
        //not : this will be replace by hydro area after it has been downloaded
        final Geometry buffer = GeometryUtils.computeBuffer(site.getGeometry());

        //connect to WFS service
        try (WebFeatureClient client = getWFSClient()) {
            // First, we download hydrology data, because there's a special treatment over it.
            downloadReferential(TYPE_ZONE_HYDRO + '_' + year, localFolder, buffer, false, client);
            Geometry[] zhs = getLocalReferential(site, year).getFeatureCollection(TYPE_ZONE_HYDRO, null).stream()
                .map(feat -> feat.getDefaultGeometryProperty().getValue())
                .filter(geom -> geom instanceof Geometry)
                .toArray(size -> new Geometry[size]);
            Geometry zhUnion = GO2Utilities.JTS_FACTORY.createGeometryCollection(zhs).union();

            // Get other referentials
            final Set<String> names = new HashSet<>(NAMES);
            names.remove(TYPE_ZONE_HYDRO);
            for (String name : names) {
                //some layers may be missing, P08 and P09 use different refentials.
                try {
                    downloadReferential(name + '_' + year, localFolder, zhUnion, true, client);
                } catch (IllegalArgumentException e) {
                    RhomeoCore.LOGGER.log(Level.FINE, "Cannot download referential", e);
                }
            }
        } catch (Exception ex) {
            //erase local folder if an error occurs
            IOUtilities.deleteRecursively(localFolder);
            throw ex;
        }
    }

    /**
     * Download a single referential using given parameters.
     * @param refName Name of the layer to request.
     * @param targetDir Folder to put downloaded data into.
     * @param zone Geometry delimiting the area to download (intersection filter performed).
     * @param cut
     * @param client Client to use to query WFS service.
     * @throws DataStoreException Error on reading / writing.
     * @throws IOException Error on reading / writing.
     * @throws FactoryException Error on reading / writing.
     * @throws XMLStreamException Error on reading / writing.
     * @throws IllegalArgumentException If we cannot find given layer name on
     * the server, or no data intersects given zone.
     */
    protected void downloadReferential(final String refName, final Path targetDir, final Geometry zone, final boolean cut, final WebFeatureClient client)
            throws DataStoreException, IOException, FactoryException, XMLStreamException {
        //find feature type full name
        GenericName fullName = null;
        for (GenericName n : client.getNames()) {
            if (n.tip().toString().equalsIgnoreCase(refName)) {
                fullName = n;
                break;
            }
        }

        if (fullName == null) { // TODO : throw illegalArgumentException managed above.
            throw new IllegalArgumentException("No layer found for name ".concat(refName));
        }

        //prepare query
        final FeatureType featureType = client.getFeatureType(fullName);
        final FeatureTypeBuilder outputBuilder = new FeatureTypeBuilder();
        outputBuilder.setName(fullName);
        final QueryBuilder qb = new QueryBuilder(fullName);
        // We are only able to store simple features, so we remove complex
        // structure from WFS data.
        final List<String> props = new ArrayList<>();
        for (PropertyDescriptor desc : featureType.getDescriptors()) {
            if (isSimpleAttribute(desc)) {
                props.add(desc.getName().toString());
                outputBuilder.add(desc);
            }
        }

        qb.setProperties(props.toArray(new String[0]));
        qb.setCRS(RhomeoCore.getSiteCRS());
        //extract only data on site envelope
        qb.setFilter(GO2Utilities.FILTER_FACTORY.intersects(
                new DefaultPropertyName(featureType.getGeometryDescriptor().getName().tip().toString()),
                GO2Utilities.FILTER_FACTORY.literal(zone)
        ));

        //copy data to local folder
        Files.createDirectories(targetDir);
        try (final FeatureReader reader = getStreamingReader(client, qb.buildQuery());
                final ShapefileFeatureStore outStore = new ShapefileFeatureStore(targetDir.resolve(refName + ".shp").toUri())) {
            if (!reader.hasNext())
                throw new IllegalArgumentException("No data intersects given zone for layer ".concat(fullName.toString()));
            outStore.createFeatureType(fullName, outputBuilder.buildSimpleFeatureType());
            try (final FeatureWriter writer = outStore.getFeatureWriterAppend(fullName)) {
                if (cut) {
                    while (reader.hasNext()) {
                        final Feature next = reader.next();
                        Object value = next.getDefaultGeometryProperty().getValue();
                        if (value instanceof Geometry)
                            next.getDefaultGeometryProperty().setValue(zone.intersection((Geometry) value));
                        FeatureUtilities.copy(next, writer.next(), false);
                    }
                } else {
                    while (reader.hasNext()) {
                        FeatureUtilities.copy(reader.next(), writer.next(), false);
                    }
                }
            }
        }
    }

    private boolean isSimpleAttribute(final PropertyDescriptor property) {
        return property.getMinOccurs() == 1 &&
                property.getMaxOccurs() == 1 &&
                property instanceof AttributeDescriptor;
    }

    /**
     * Perform a getFeature on the distant server, browing its content lazily.
     * @param client The client to use for request.
     * @param query The query which contains reading parameters.
     * @return A reader to stream service response.
     * @throws DataStoreException
     * @throws IOException
     * @throws XMLStreamException
     */
    private FeatureReader getStreamingReader(final WebFeatureClient client, final Query query) throws DataStoreException, IOException, XMLStreamException {

        final GetFeatureRequest request = client.createGetFeature();
        request.setTypeName(new QName(query.getTypeName().tip().toString()));

        final Filter filter = query.getFilter();
        if (filter == null) {
            request.setFilter(Filter.INCLUDE);
        } else {
            request.setFilter(filter);
        }

        final Integer max = query.getMaxFeatures();
        if (max != null) {
            request.setMaxFeatures(max);
        }

        request.setPropertyNames(query.getPropertyNames());

        XmlFeatureReader reader = null;
        reader = new JAXPStreamFeatureReader(client.getFeatureType(query.getTypeName()));
        reader.getProperties().put(JAXPStreamFeatureReader.SKIP_UNEXPECTED_PROPERTY_TAGS, true);
        final InputStream stream;
        if (client.getUsePost()) {
            stream = request.getResponseStream();
        } else {
            final URL url = request.getURL();
            stream = url.openStream();
        }

        // If reader creation fails, stream isn't hanging on.
        try {
            return new StreamingFeatureReader(reader.readAsStream(stream), stream);
        } catch (Throwable t) {
            try {
                stream.close();
            } catch (Throwable t1) {
                t.addSuppressed(t1);
            }
            throw t;
        }
    }

    private static class StreamingFeatureReader implements FeatureReader {

        final FeatureReader source;
        final InputStream dataSource;

        public StreamingFeatureReader(final FeatureReader source, final InputStream dataSource) {
            this.source = source;
            this.dataSource = dataSource;
        }

        @Override
        public FeatureType getFeatureType() {
            return source.getFeatureType();
        }

        @Override
        public Feature next() throws FeatureStoreRuntimeException {
            return source.next();
        }

        @Override
        public boolean hasNext() throws FeatureStoreRuntimeException {
            return source.hasNext();
        }

        @Override
        public void close() {
            try {
                source.close();
            } finally {
                try {
                    dataSource.close();
                } catch (IOException ex) {
                    RhomeoCore.LOGGER.log(Level.WARNING, "An error occurred while closing data stream.", ex);
                }
            }
        }
    }
}
