/**
 * 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 java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.apache.sis.util.ArgumentChecks;

/**
 * TODO : Create a builder, to manage encoder / decoder creation.
 *
 * @author Alexis Manin (Geomatys)
 * @param <T> Type of java bean to map to the CSV file.
 */
public abstract class CSVMapper<T> {

    public static final char COMMENT_CHAR = '#';
    public static final char DEFAULT_SEPARATOR = ';';

    /**
     * Expected objects in input or output of the file.
     */
    protected final Class<T> dataType;

    /**
     * Information about {@link #dataType}.
     */
    protected final BeanInfo beanInfo;

    /**
     * Separator between columns.
     */
    protected final char separator;

    /**
     * Charset to use with given file.
     */
    protected final Charset charset;

    /**
     * Path to the CSV file to read or write.
     */
    protected final Path target;

    /**
     * For each column of the file, give the property index corresponding in
     * {@link #beanInfo}.
     */
    protected int[] propertyOrder;

    /**
     * List properties that can be missing in the read / written file.
     */
    private final Set<String> ignorable;

    protected CSVMapper(Path target, Class<T> beanClass, Character separator, Charset fileEncoding) throws IOException, IntrospectionException {
        this(target, beanClass, separator, fileEncoding, null);
    }

    protected CSVMapper(Path target, Class<T> beanClass, Character separator, Charset fileEncoding, Set<String> ignorableProperties) throws IOException, IntrospectionException {
        ArgumentChecks.ensureNonNull("Output path", target);
        ArgumentChecks.ensureNonNull("Data type", beanClass);

        if (ignorableProperties != null) {
            ignorable = Collections.unmodifiableSet(ignorableProperties);
        } else {
            ignorable = Collections.EMPTY_SET;
        }

        // Check global information
        beanInfo = Introspector.getBeanInfo(beanClass, Object.class);
        this.dataType = beanClass;

        this.separator = separator == null ? DEFAULT_SEPARATOR : separator;

        charset = fileEncoding == null ? Charset.defaultCharset() : fileEncoding;

        // If input file exists, we check its header is compatible with given data type.
        String firstLine;
        if (Files.isReadable(target)) {
            try (final BufferedReader reader = Files.newBufferedReader(target, charset)) {
                // We read lines until we find the first not empty / commented line.
                firstLine = reader.readLine();
                while (firstLine != null && ((firstLine = firstLine.trim()).isEmpty() || firstLine.charAt(0) == COMMENT_CHAR))
                    firstLine = reader.readLine();

                if (firstLine != null && !isValidHeader(firstLine)) {
                    throw new IllegalArgumentException("L'entête du fichier n'est pas compatible avec le type de donnée associé.");
                }
            } catch (CharacterCodingException e) {
                throw new IllegalArgumentException("Le jeu de caractères du fichier est invalide. Attendu : ".concat(charset.displayName()));
            }
        } else if (Files.exists(target)) {
            throw new IllegalArgumentException("Le fichier source est illisible !");
        } else {
            firstLine = null;
        }

        updatePropertyOrder(firstLine);

        this.target = target;
    }

    public Class<T> getDataType() {
        return dataType;
    }

    /**
     * Create a string displaying names of all properties (except the transient
     * and {@link Object} inherited ones) defined by given bean information.
     *
     * @return A string which is the list of found properties, splitted by a
     * given separator.
     */
    public String buildHeader() {
        final StringBuilder builder = new StringBuilder();
        final PropertyDescriptor[] pDs = beanInfo.getPropertyDescriptors();

        for (int i = 0; i < propertyOrder.length; i++) {
            final PropertyDescriptor p = pDs[propertyOrder[i]];
            // Ignore properties inherited from object.
            if (p.getReadMethod() == null || p.getReadMethod().getDeclaringClass().equals(Object.class))
                continue;
            builder.append(p.getName()).append(separator);
        }

        return builder.substring(0, builder.length() - 1);
    }

    /**
     * Check if given header lists all properties defined in given bean type.
     *
     * @param firstLine The header to check.
     * @return True if the file header describes the current reference type,
     * false otherwise.
     */
    public final boolean isValidHeader(final String firstLine) {
        return isValidHeader(firstLine, separator, beanInfo, ignorable);
    }

    /**
     * Check if given header lists all properties defined in given bean type.
     *
     * @param firstLine The header to check.
     * @param separator The character which is used as separator between
     * columns.
     * @param beanInfo Bean information to check header against.
     * @return True if the file header describes the current reference type,
     * false otherwise.
     */
    public static boolean isValidHeader(final String firstLine, final char separator, final BeanInfo beanInfo) {
        return isValidHeader(firstLine, separator, beanInfo, null);
    }

    public static boolean isValidHeader(final String firstLine, final char separator, final BeanInfo beanInfo, final Set<String> ignorable) {

        final String[] splitted = firstLine.split(new StringBuilder("\\s*").append(separator).append("\\s*").toString());
        final Set<String> headerNames = new HashSet<>(Arrays.asList(splitted));
        if (ignorable != null)
            headerNames.addAll(ignorable);

        for (final PropertyDescriptor p : beanInfo.getPropertyDescriptors()) {
            // Ignore properties unreadable or inherited from object.
            if (p.getReadMethod() == null || p.getReadMethod().getDeclaringClass().equals(Object.class))
                continue;

            if (!headerNames.remove(p.getName())) {
                return false;
            }
        }

        /* If we arrive here, it means all mandatory properties have been found
         * in input header. If we're in read-only mode, we set it as valid,
         * because for columns not present in java model, we can ignore them.
         * On the contrary, if we must write into the file, we cannot allow
         * losing data contained in it, so we ensure each column of the header
         * can be bound to the java model.
         */
        return true;
    }

    /**
     * Index property columns.
     *
     * @param header the header of the file.
     */
    protected final void updatePropertyOrder(String header) {
        // No header. Natural order initialized.
        if (header == null) {
            propertyOrder = new int[beanInfo.getPropertyDescriptors().length];
            for (int i = 0; i < propertyOrder.length; i++) {
                propertyOrder[i] = i;
            }
        } else {
            final String[] splitted = header.split(new StringBuilder("\\s*").append(separator).append("\\s*").toString());
            propertyOrder = new int[splitted.length];
            Arrays.fill(propertyOrder, -1);
            final Map<String, Integer> columns = new HashMap<>(splitted.length);
            int i = 0;
            for (final String columnName : splitted) {
                columns.put(columnName, i++);
            }

            i = 0;
            for (final PropertyDescriptor p : beanInfo.getPropertyDescriptors()) {
                final Integer colIndex = columns.get(p.getName());
                if (colIndex != null)
                    propertyOrder[colIndex] = i;
                i++;
            }
        }
    }

    /**
     * Throw a new IOException whose message describe location (line, column) of
     * the error.
     *
     * @param line Number of the line where error happened. Must be > 0.
     * @param column Index of the field (not character column) on which error occurred. Must be > 0.
     * @param token The field value we failed against.
     * @param cause Original exception raised.
     * @throws IOException In all cases.
     */
    protected static void throwWithDescription(final int line, int column, Object token, final Exception cause) throws IOException {
        final StringBuilder msg = new StringBuilder("Erreur : ");
        if (line > 0) {
            msg.append("ligne ").append(line).append(", ");
        }

        if (column > 0) {
            msg.append("colonne ").append(column).append(", ");
        }
        msg.append("valeur ").append(token);
        throw new IOException(msg.toString(), cause);
    }
}
