/**
 * 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 com.fasterxml.jackson.databind.ObjectMapper;
import fr.cenra.rhomeo.api.Version;
import fr.cenra.rhomeo.core.data.UpdateInfo;
import fr.cenra.rhomeo.core.preferences.net.NetPreferences;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.GeneralSecurityException;
import java.text.NumberFormat;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;

import org.apache.sis.referencing.CRS;
import org.apache.sis.util.logging.Logging;
import org.geotoolkit.factory.Hints;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.opengis.util.FactoryException;
import fr.cenra.rhomeo.core.list.CodeValue;
import java.io.ObjectInput;
import java.io.ObjectOutput;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.spi.FileSystemProvider;
import java.text.DecimalFormat;
import java.text.ParseException;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javafx.scene.control.TableCell;
import javafx.util.Callback;
import javafx.util.StringConverter;


/**
 * Regroups generic methods / constants used by the whole application.
 * JavaFX features should be deported to fr.cenra.rhomeo.Rhomeo class,
 * in the desktop module, not here.
 *
 * @author Cédric Briançon (Geomatys)
 */
public class RhomeoCore {
    /**
     * Generic logger to use in the whole application.
     */
    public static final Logger LOGGER = Logging.getLogger("fr.cenra.rhomeo");

    /**
     * Title to display for all popups in the application.
     */
    public static final String APPLICATION_TITLE = "Calculette RhoMeo";

    /**
     * Application main folder, in the user home.
     */
    public static final String NAME = "rhomeo";

    /**
     * Key for the environment variable for the .rhomeo folder.
     */
    public static final String ENV_RHOMEO_PATH_KEY = "rhomeo.path";

    /**
     * Path for the configuration app main folder.
     */
    public static final Path CONFIGURATION_PATH;
    static {
        final String rhomeoPath = System.getProperty(ENV_RHOMEO_PATH_KEY) != null ? System.getProperty(ENV_RHOMEO_PATH_KEY) : NAME;
        Path tmpPath = Paths.get(System.getProperty("user.home"), "."+ rhomeoPath);

        if (!Files.isDirectory(tmpPath)) {
            try {
                Files.createDirectory(tmpPath);
            } catch (IOException ex) {
                try {
                    tmpPath = Files.createTempDirectory(NAME);
                } catch (IOException ex1) {
                    ex.addSuppressed(ex1);
                    throw new ExceptionInInitializerError(ex);
                }
            }
        }
        CONFIGURATION_PATH = tmpPath;
    }

    /**
     * System preference for the home directory of derby.
     */
    public static final String DERBY_HOME_KEY = "derby.system.home";

    /**
     * Spring application context path in the resources.
     */
    public static final String SPRING_CONTEXT_XML = "/fr/cenra/rhomeo/spring/spring-context.xml";

    /**
     * Path of the EPSG database.
     */
    public static final Path EPSG_PATH = CONFIGURATION_PATH.resolve("EPSG");

    /**
     * Path to the folder which will contain reference lists.
     */
    public static final Path REFERENCE_PATH = CONFIGURATION_PATH.resolve("references");

    /**
     * Path to the folder which will contain geographic reference datas.
     * Path content will look like :
     * ./site_id/dataname_year/*.shp
     */
    public static final Path REFERENCIELS_GEO_PATH = CONFIGURATION_PATH.resolve("referential_geo");

    /**
     * Sites folder and shp name.
     */
    public static final String SITES = "sites";

    /**
     * Path of the sites folder.
     */
    public static final Path SITES_PATH = CONFIGURATION_PATH.resolve(SITES);

    /**
     * Path of the sites folder.
     */
    public static final Path SITES_SHP_PATH = SITES_PATH.resolve(SITES +".shp");

    /**
     * Path of the CSV file for dashboard published results.
     */
    public static final Path DASHBOARD_PUBLISHED_RESULTS = CONFIGURATION_PATH.resolve("dashboard_results.csv");

    /**
     * Path of the JSON file for data contexts for sites / protocols.
     */
    public static final Path DATA_CONTEXT_SITE_PATH = CONFIGURATION_PATH.resolve("data_contexts.json");

    public static final Path RESULT_PATH = CONFIGURATION_PATH.resolve("results");
    /**
     * Path to the context restoration folder.
     */
    public static final Path RESTORATION_FOLDER = CONFIGURATION_PATH.resolve("state");

    /**
     * Number of millis for opening the connection before timeout.
     */
    public static final int CONNECTION_TIMEOUT = 5000;

    /**
     * Number of millis to wait after launching a timeout if no data has been transferred.
     */
    public static final int READ_TIMEOUT = 20000;

    private static CoordinateReferenceSystem SITES_CRS;
    private static final int SRID = 2154;

    /**
     * Check for word character at the start of a text.
     */
    public static final Pattern CODE_PATTERN = Pattern.compile("^[\\w\\.]+");

    public enum FT_PROPERTIES {
        the_geom, NAME, COUNTY, ODONATE, ORTHOPTERE, REFERENT, ORG, TYPE, REMARKS
    }

    /**
     * A regex to detect file extensions.
     */
    public static final Pattern EXT_PATTERN = Pattern.compile("\\.\\w+$");

    public static final DecimalFormat SIMPLE_DECIMAL_FORMAT = new DecimalFormat("0.##");
    public static final DecimalFormat SECOND_DECIMAL_FORMAT = new DecimalFormat("0.00");

    /**
     * Initializes the EPSG database, generating it if not yet present on the computer.
     *
     * @throws FactoryException
     * @throws IOException
     */
    public static void initEpsgDB() throws FactoryException, IOException {
        initEpsgDB(true);
    }

    /**
     * Initialize properties needed by EPSG database. If input boolean is true,
     * we'll fully load the database.
     *
     * @param checkInit Set to true if you want to ensure EPSG database is fully
     * initialized.
     *
     * @throws FactoryException
     * @throws IOException
     */
    public static void initEpsgDB(final boolean checkInit) throws FactoryException, IOException {
        // create a database in user directory
        Files.createDirectories(RhomeoCore.EPSG_PATH);

        // work in lazy mode, do your best for lenient datum shift
        Hints.putSystemDefault(Hints.LENIENT_DATUM_SHIFT, Boolean.TRUE);

        String derbyProp = null;
        try {
            derbyProp = System.getProperty(DERBY_HOME_KEY);
        } finally {
            if (derbyProp == null) {
                System.setProperty(DERBY_HOME_KEY, EPSG_PATH.toAbsolutePath().toString());
            }
        }

        // force loading epsg
        if (checkInit) {
            getSiteCRS();
        }
    }

    public static CoordinateReferenceSystem getSiteCRS() throws FactoryException {
        if (SITES_CRS == null) {
            SITES_CRS = CRS.forCode("EPSG:"+ SRID);
        }

        return SITES_CRS;
    }

    /**
     * Get current application version.
     *
     * @return The application version, may be empty or null if not found in the application packaging.
     */
    public static String getVersion() {
        return RhomeoCore.class.getPackage().getImplementationVersion();
    }

    /**
     * Verify the presence of a new update for the application.
     *
     * @return The new version to install if an update exists, {@code null} if no updates found
     *         or no need to update.
     */
    public static UpdateInfo existingUpdate() {
        final String version = getVersion();
        if (version == null || version.isEmpty()) {
            LOGGER.log(Level.INFO, "Invalid or not found version for this application");
            return null;
        }
        final Version localVersion = new Version(version);
        final NetPreferences netPrefs = new NetPreferences();
            final String updateUrl = netPrefs.getPreference(NetPreferences.UPDATE_URL);
        try (final InputStream stream = netPrefs.openConnection(netPrefs.getUpdateURL()).getInputStream()) {
            final UpdateInfo update = new ObjectMapper().readValue(stream, UpdateInfo.class);
            if (update.getVersion() != null && update.getVersion().compareTo(localVersion) > 0)
                return update;
        } catch (IOException|GeneralSecurityException e) {
            LOGGER.log(Level.WARNING, "Cannot access update service : ".concat(updateUrl), e);
        }

        return null;
    }

    /**
     * Convert byte number given in parameter in a human readable string. It tries
     * to fit the best unit. Ex : if you've got a number higher than a thousand,
     * input byte number will be expressed in kB. If you've got more than a million,
     * you've got it as MB
     * @param byteNumber Byte quantity to display
     * @return A string displaying byte number converted in fitting unit, along with unit symbol.
     */
    public static String toReadableSize(final long byteNumber) {
        final NumberFormat format = NumberFormat.getNumberInstance();
        format.setMinimumFractionDigits(2);
        format.setMaximumFractionDigits(2);
        if (byteNumber < 0) {
            return "inconnu";
        } else if (byteNumber < 1e3) {
            return "" + byteNumber + " octets";
        } else if (byteNumber < 1e6) {
            return "" + format.format(byteNumber / 1e3) + " ko";
        } else if (byteNumber < 1e9) {
            return "" + format.format(byteNumber / 1e6) + " Mo";
        } else if (byteNumber < 1e12) {
            return "" + format.format(byteNumber / 1e9) + " Go";
        } else {
            return "" + (byteNumber / 1e12) + " To";
        }
    }

    /**
     *
     * @param first First string to test.
     * @param second Second string to test.
     * @return True if both strings are null or blank. False if at least one of
     * them contains something else than spaces.
     */
    public static boolean bothEmpty(final String first, final String second) {
        return (first == null || first.trim().isEmpty()) && (second == null || second.trim().isEmpty());
    }

    /**
     *
     * @param first First string to test.
     * @param second Second string to test.
     * @return True if both strings are equal or blank. False if one of them is
     * not blank, or they're not equal.
     */
    public static boolean equivalent(final String first, final String second) {
        if (bothEmpty(first, second)) {
            return true;
        }

        return first == null || second == null? false : first.equals(second);
    }

    /**
     * Try to find an object of given type with the given code.
     * Note : Works only with enums.
     *
     * @param <T> Type of target coded list.
     * @param dataType Type of enum to search into
     * @param code Code of the object to return.
     * @return An object of the queried type with queried code, or nothing if we
     * cannot find any object matching.
     */
    public static <T extends CodeValue> Optional<T> getByCode(final Class<T> dataType, Object code) {
        if (dataType.isEnum() && code != null) {
            for (final T candid : dataType.getEnumConstants()) {
                if (candid.getCode().equals(code)) {
                    return Optional.of(candid);
                }
            }
        }

        return Optional.empty();
    }

    /**
     * Compute the quantile of a given data suite. For more details, see
     * https://en.wikipedia.org/wiki/Quantile
     *
     * Note : we differ from above wiki definition on even computing. Instead of
     * arbitrary ceiling computed position, we get the average of its two
     * ascending value in the data suite.
     *
     * @param data Data suite to find a quantile for. If input array is null or
     * empty, the method will miserably fail, so be careful.IMPORTANT : IT MUST
     * BE SORTED BY ASCENDING ORDER !
     * @param factor Quantile factor. It must be between 0 and 1 (otherwise, the
     * algorithm will end up very badly) representing which portion of the data
     * we want.
     * @return The queried quantile
     */
    public static float quantile(final float[] data, final float factor) {
        final float position = (data.length -1) * factor;
        final double floor = StrictMath.floor(position);
        final int floorInt = (int)floor;
        if (position == floor) {
            // We take directly the pointed value.
            return data[floorInt];
        }

        final double ratio = position - floor;
        final double extraValue = (data[floorInt + 1] - data[floorInt]) * ratio;

        return (float) (data[floorInt] + extraValue);
    }

    /**
     * Compute the quantile of a given data suite. For more details, see
     * https://en.wikipedia.org/wiki/Quantile
     *
     * Note : we differ from above wiki definition on even computing. Instead of
     * arbitrary ceiling computed position, we get the average of its two
     * ascending value in the data suite.
     *
     * @param data Data suite to find a quantile for. If input array is null or
     * empty, the method will miserably fail, so be careful.IMPORTANT : IT MUST
     * BE SORTED BY ASCENDING ORDER !
     * @param factor Quantile factor. It must be between 0 and 1 (otherwise, the
     * algorithm will end up very badly) representing which portion of the data
     * we want.
     * @return The queried quantile
     */
    public static double quantile(final double[] data, final float factor) {
        final float position = (data.length -1) * factor;
        final double floor = StrictMath.floor(position);
        final int floorInt = (int) floor;
        if (position == floor) {
            // We take directly the pointed value.
            return data[floorInt];
        }

        final double ratio = position - floor;
        final double extraValue = (data[floorInt + 1] - data[floorInt]) * ratio;

        return data[floorInt] + extraValue;
    }

    /**
     * Create a new file system, allowing to view the content of a zip file as
     * a file system.
     * /!\ Close the returned {@link FileSystem} once you're done with it !
     *
     * @param zipPath A path pointing on a zip file. If it does not exist, it
     * will be created.
     * @return  A file system pointing on given zip file.
     * @throws URISyntaxException If we cannot create a valid URI from given
     * path.
     * @throws IOException If the file occurs while accessing zip file.
     */
    public static FileSystem toZipFileSystem(final Path zipPath) throws URISyntaxException, IOException {
        // Don't call FileSystems.newFileSystems(URI, Map), because of a bug which
        // encode twice URIs in jar file system : https://bugs.openjdk.java.net/browse/JDK-8131067
        final List<FileSystemProvider> installedProviders = FileSystemProvider.installedProviders();
        for (final FileSystemProvider fsp : installedProviders) {
            if ("jar".equals(fsp.getScheme())) {
                Map<String, String> properties = new HashMap<>();
                properties.put("encoding", StandardCharsets.UTF_8.name());
                properties.put("create", "true");
                return fsp.newFileSystem(zipPath, properties);
            }
        }

        throw new IOException("No file system found for ZIP encoding.");
    }

    /**
     * Ensure input dates are either strictly equals, or represent the same moment.
     * @param first
     * @param second
     * @return True if the 2 dates are same reference, or respect {@link LocalDate#isEqual(java.time.chrono.ChronoLocalDate) }.
     */
    public static boolean checkEquality(final LocalDate first, final LocalDate second) {
        return first == second || first != null && second != null && first.isEqual(second);
    }

    /**
     * Write given local date into the specified object output. If input is null,
     * a special flag is written, so a null value will be returned by {@link #readDate(java.io.ObjectInput) }.
     * @param toWrite Local date to serialize.
     * @param destination Output to write into.
     * @throws IOException If an error occcurs while writing in output stream.
     */
    public static void writeDate(final LocalDate toWrite, final ObjectOutput destination) throws IOException {
        if (toWrite == null) {
            destination.writeBoolean(true);
        } else {
            destination.writeBoolean(false);
            destination.writeShort(toWrite.getYear());
            destination.write(toWrite.getMonthValue());
            destination.write(toWrite.getDayOfMonth());
        }
    }

    /**
     * Read date contained in given source at current position.
     * IMPORTANT : Only read dates written using {@link #writeDate(java.time.LocalDate, java.io.ObjectOutput) }
     * method.
     * @param source Object stream to read from.
     * @return Read date, or null.
     * @throws IOException
     */
    public static LocalDate readDate(final ObjectInput source) throws IOException {
        if (source.readBoolean())
            return null;
        return LocalDate.of(source.readShort(), source.readByte(), source.readByte());
    }

    public static final Callback ROUNDING_CELL_FACTORY = value -> {
        return new TableCell<Object, Number>() {
            @Override
            protected void updateItem(Number item, boolean empty) {
                super.updateItem(item, empty);
                if (empty || item == null) {
                    setText(null);
                } else {
                    setText(SIMPLE_DECIMAL_FORMAT.format(item));
                }
            }
        };
    };

    public static final StringConverter DOUBLE_CONVERTER = new StringConverter<Double>() {
        @Override
        public String toString(Double object) {
            if (object == null)
                return "";
            else
                return SECOND_DECIMAL_FORMAT.format(object);
        }

        @Override
        public Double fromString(String string) {
            if (string == null || (string = string.trim()).isEmpty()) {
                return 0d;
            } else
                try {
                    return SECOND_DECIMAL_FORMAT.parse(string).doubleValue();
                } catch (ParseException ex) {
                    try {
                        return Double.valueOf(string);
                    } catch (Exception ex2) {
                        ex.addSuppressed(ex2);
                        RhomeoCore.LOGGER.log(Level.FINE, "Unconvertible string.", ex);
                    }
                }

            return 0d;
        }
    };
}
