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

import fr.cenra.rhomeo.core.RhomeoCore;
import java.beans.BeanInfo;
import java.beans.Introspector;
import java.beans.MethodDescriptor;
import java.lang.reflect.Method;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.logging.Level;
import javafx.beans.Observable;
import javafx.beans.property.Property;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.ObservableSet;
import javafx.collections.SetChangeListener;
import javafx.collections.transformation.SortedList;
import javafx.util.Callback;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import org.apache.sis.util.ArgumentChecks;

/**
 * Data filled or imported in application.
 *
 * IMPORTANT : Avoid modifying {@link #getItems() } list outside of FX thread,
 * because UI could use filtered / sorted lists based on it.
 *
 * @author Alexis Manin (Geomatys)
 * @param <T> Type of statements pointed by this dataset.
 */
public class Dataset<T extends Statement> {

    private final Protocol protocol;

    /**
     * Keep a set of computed tracking points to speed up research and updates.
     *
     * TODO : replace with a map whose key is the tracking point, and value is the count of attached statements.
     */
    private ObservableSet<TrackingPoint> internalPoints;
    /**
     * List of tracking points exposed to user.
     */
    private ObservableList<TrackingPoint> points;
    /**
     * Seized statements.
     */
    private final ObservableList<T> items;

    /**
     * When an item date change, this listener update available tracking point list.
     */
    private final ChangeListener<LocalDate> dateListener;
    /**
     * When an item name change, this listener update available tracking point list.
     */
    private final ChangeListener<String> nameListener;
    /**
     * On a change in {@link #getItems() }, this listener check added/removed
     * objects to update tracking point list.
     */
    private final TrackingPointListener tPointListener;
    private SetToListListener mirroring;

    public Dataset(final Protocol target) {
        ArgumentChecks.ensureNonNull("Target protocol", target);
        protocol = target;

            final Callback<T, Observable[]> extractor = createCallback();
            if (extractor == null) {
                items = FXCollections.observableArrayList();
            } else {
                items = FXCollections.observableArrayList(extractor);
            }

        dateListener = (obs, oldDate, newDate) -> {
            if (obs instanceof Property) {
                Object bean = ((Property)obs).getBean();
                if (bean instanceof Statement) {
                    final String name = ((Statement)bean).getTrackingPoint();
                    if (name != null) {
                        if (oldDate != null) {
                            final TrackingPoint p = protocol.createTrackingPoint(name, oldDate);
                            if (getSubSet(p).isEmpty()) {
                                internalPoints.remove(p);
                            }
                        }
                        if (newDate != null) {
                            internalPoints.add(protocol.createTrackingPoint(name, newDate));
                        }
                    }
                }
            }
        };

        nameListener = (obs, oldName, newName) -> {
            if (obs instanceof Property) {
                Object bean = ((Property) obs).getBean();
                if (bean instanceof Statement) {
                    final LocalDate date = ((Statement) bean).getDate();
                    if (date != null) {
                        if (oldName != null) {
                            final TrackingPoint p = protocol.createTrackingPoint(oldName, date);
                            if (getSubSet(p).isEmpty()) {
                                internalPoints.remove(p);
                            }
                        }
                        if (newName != null) {
                            internalPoints.add(protocol.createTrackingPoint(newName, date));
                        }
                    }
                }
            }
        };

        tPointListener = new TrackingPointListener();
    }

    /**
     *
     * @return List of associated data. Can be empty, but never null.
     */
    @NotNull
    @Valid
    public synchronized ObservableList<T> getItems() {
        return items;
    }

    @Valid
    public Protocol getProtocol() {
        return protocol;
    }

    private Callback<T, Observable[]> createCallback() {
        final ArrayList<Method> extractors = new ArrayList<>();
        try {
        final BeanInfo info = Introspector.getBeanInfo(protocol.getDataType(), Object.class);
        MethodDescriptor[] descs = info.getMethodDescriptors();
        for (final MethodDescriptor desc : descs) {
            final Method method = desc.getMethod();
            if (method.getParameterCount() < 1 && Observable.class.isAssignableFrom(method.getReturnType())) {
                method.setAccessible(true);
                extractors.add(method);
            }
        }
        } catch (Exception e) {
            RhomeoCore.LOGGER.log(Level.WARNING, "Cannot analyze datatype for protocol ".concat(protocol.getName()), e);
        }

        if (extractors.isEmpty()) {
            return null;
        }

        return input -> {
            final ArrayList<Object> result = new ArrayList<>(extractors.size());
            for (int i = 0 ; i < extractors.size() ; i++) {
                try {
                    result.add((Observable) extractors.get(i).invoke(input));
                } catch (ReflectiveOperationException e) {
                    RhomeoCore.LOGGER.log(Level.WARNING, "Cannot extract a property !", e);
                }
            }

            return result.toArray(new Observable[result.size()]);
        };
    }

    @NotNull
    public synchronized ObservableList<TrackingPoint> getTrackingPoints() {
        if(points==null) {
            final HashSet<TrackingPoint> tmpSet = new HashSet<>();
            if (items != null) {
                String name;
                LocalDate date;
                for (final Statement statement : items) {
                    name = statement.getTrackingPoint();
                    date = statement.getDate();
                    if (name != null && statement.getDate() != null) {
                        final TrackingPoint p = protocol.createTrackingPoint(name, date);
                        p.setRemarks(statement.getRemarks());
                        tmpSet.add(p);
                    }
                }
            }

            internalPoints = FXCollections.synchronizedObservableSet(FXCollections.observableSet(tmpSet));
            points = FXCollections.observableArrayList(tmpSet);
            removeListeners();
            addListeners();
        }
        return FXCollections.unmodifiableObservableList(points);
    }

    /**
     * Query an unmodifiable view of the items contained in this dataset matching
     * the given tracking point. All modifications in {@link #getItems() } will
     * be mirrored in the returned view.
     *
     * If the given point is null, a list of all statements which are not bound
     * to any point is returned.
     *
     * @param filter The tracking point to get data for.
     * @return items matching a given tracking point (contained in it).
     */
    @NotNull
    public synchronized ObservableList<T> getSubSet(final TrackingPoint... filter) {
        return getSubSet(filter == null || (filter.length == 1 && filter[0] == null)? null : Arrays.asList(filter));
    }

    public synchronized ObservableList<T> getSubSet(Collection<TrackingPoint> filter) {
        final Predicate<T> predicate;
        if (filter == null || filter.isEmpty()) {
            predicate = input -> {
                return input.getTrackingPoint() == null || input.getDate() == null;
            };
        } else if (filter.size() == 1) {
            final TrackingPoint tp = filter.iterator().next();
            final String filterName = tp.getName();
            final LocalDate filterStart = tp.getYearStart().minusDays(1);
            final LocalDate filterEnd = tp.getYearEnd().plusDays(1);
            predicate = input -> {
                return filterName.equals(input.getTrackingPoint())
                        && input.getDate() != null
                        && input.getDate().isAfter(filterStart)
                        && input.getDate().isBefore(filterEnd);
            };
        } else {
            // Remove doublons
            if (!(filter instanceof Set)) {
                filter = new HashSet(filter);
            }

            // Multiple tracking point filter. For each name, we build a set of
            // date range, for each tracking with the same name but different year range.
            final HashMap<String, List<LocalDate[]>> filters = new HashMap<>(filter.size());
            for (final TrackingPoint tp : filter) {
                filters.computeIfAbsent(tp.getName(), str -> new ArrayList())
                        .add(new LocalDate[]{tp.getYearStart().minusDays(1), tp.getYearEnd().plusDays(1)});
            }

            predicate = input -> {
                if (input.getTrackingPoint() == null || input.getDate() == null)
                    return false;

                final List<LocalDate[]> dates = filters.get(input.getTrackingPoint());
                if (dates == null)
                    return false;

                for (final LocalDate[] range : dates) {
                    if (input.getDate().isAfter(range[0]) &&
                            input.getDate().isBefore(range[1]))
                        return true;
                }

                return false;
            };
        }
        return getItems().filtered(predicate);
    }

    public boolean deletePoints(final Collection<TrackingPoint> points) {
        final ArrayList<TrackingPoint> defCopy = new ArrayList<>(points);
        return items.removeIf(item -> {
            for (final TrackingPoint tp : defCopy) {
                if (tp.getName().equals(item.getTrackingPoint()) &&
                        item.getDate() != null &&
                        (item.getDate().isEqual(tp.getYearStart()) || item.getDate().isAfter(tp.getYearStart())) &&
                        (item.getDate().isEqual(tp.getYearEnd()) || item.getDate().isBefore(tp.getYearEnd()))) {
                    return true;
                }
            }
            return false;
        });
    }

    /**
     * Remove listeners in charge of updating tracking point list.
     */
    private void removeListeners() {
        if (mirroring != null && internalPoints != null) {
            internalPoints.removeListener(mirroring);
        }

        if (items != null) {
            items.removeListener(tPointListener);
            items.forEach(item -> {
                item.dateProperty().removeListener(dateListener);
                item.trackingPointProperty().removeListener(nameListener);
            });
        }
    }

    /**
     * Add listeners on items and tracking points. The aim is to check statement
     * changes to update tracking point list accordingly.
     */
    private void addListeners() {
        if (items != null && points != null) {
            mirroring = new SetToListListener(points);
            internalPoints.addListener(mirroring);
            items.forEach(item -> {
                item.dateProperty().addListener(dateListener);
                item.trackingPointProperty().addListener(nameListener);
            });
            items.addListener(tPointListener);
        }
    }

    /**
     * Check changes on statement list, to update available tracking points
     * according to dataset content.
     */
    private class TrackingPointListener implements ListChangeListener<Statement> {

        @Override
        public void onChanged(Change<? extends Statement> c) {
            final HashSet<TrackingPoint> toRemove = new HashSet<>();
            final HashSet<TrackingPoint> toAdd = new HashSet<>();

            TrackingPoint point;
            while (c.next()) {
                if (c.wasRemoved()) {
                    for (Statement remitem : c.getRemoved()) {
                        remitem.dateProperty().removeListener(dateListener);
                        remitem.trackingPointProperty().removeListener(nameListener);

                        point = createTrackingPoint(remitem);
                        if (point != null) {
                            toRemove.add(point);
                        }
                    }
                }

                if (c.wasAdded()) {
                    for (Statement additem : c.getAddedSubList()) {
                        additem.dateProperty().addListener(dateListener);
                        additem.trackingPointProperty().addListener(nameListener);

                        point = createTrackingPoint(additem);
                        if (point != null) {
                            toRemove.remove(point);
                            toAdd.add(point);
                        }
                    }
                }
            }

            // If there's still statements referencing removed statement tracking points, we keep them.
            final Iterator<TrackingPoint> it = toRemove.iterator();
            while (it.hasNext()) {
                point = it.next();
                if (!getSubSet(point).isEmpty())
                    it.remove();
            }

            internalPoints.removeAll(toRemove);
            internalPoints.addAll(toAdd);
        }

        private TrackingPoint createTrackingPoint(final Statement statement) {
            final String name = statement.getTrackingPoint();
            final LocalDate date = statement.getDate();
            if (name != null && date != null) {
                final TrackingPoint p = protocol.createTrackingPoint(name, date);
                p.setRemarks(statement.getRemarks());
                return p;
            } else {
                return null;
            }
        }
    }

    /**
     * Mirrors changes happening in a set to the parameterised list.
     */
    private class SetToListListener implements SetChangeListener<TrackingPoint> {

        private final List destination;

        public SetToListListener(final List destination) {
            this.destination = destination;
        }

        @Override
        public void onChanged(Change<? extends TrackingPoint> c) {
            if (c.wasRemoved()) {
                destination.remove(c.getElementRemoved());
            } else if (c.wasAdded()) {
                destination.add(c.getElementAdded());
            }
        }
    }

    /**
     * Search for the tracking point attached to given statement.
     * @param data The statement to find parent tracking point for.
     * @return A tracking point matching given parameter, or nothing.
     */
    public Optional<TrackingPoint> findPoint(final Statement data) {
        if (data == null)
            return Optional.empty();
        return findPoint(data.getTrackingPoint(), data.getDate());
    }

    /**
     * Search for a tracking point whose name is equal to the given one, and validity
     * period contains given date.
     * @param name The name to get a tracking point for.
     * @param date The date to get a tracking point for.
     * @return A tracking point matching given parameters, or nothing.
     */
    public Optional<TrackingPoint> findPoint(String name, final LocalDate date) {
        if (!items.isEmpty() && date != null && name != null && !(name = name.trim()).isEmpty()) {
            final ObservableList<TrackingPoint> tps = getTrackingPoints();
            if (!tps.isEmpty()) {
                for (final TrackingPoint tp : tps) {
                    if (tp.getName().equals(name) &&
                            (date.isAfter(tp.getYearStart()) || date.isEqual(tp.getYearStart())) &&
                            (date.isBefore(tp.getYearEnd()) || date.isEqual(tp.getYearEnd()))) {
                        return Optional.of(tp);
                    }
                }
            }
        }

        return Optional.empty();
    }

    public synchronized Map<TrackingPoint, List<T>> groupByPoint() {
        final List<TrackingPoint> trackingPoints = getTrackingPoints().sorted((TrackingPoint o1, TrackingPoint o2) -> {
            if (o1 == null) {
                return o2 == null ? 0 : 1;
            } else if (o2 == null) {
                return -1;
            }

            final int nameOrder = o1.getName().compareTo(o2.getName());
            if (nameOrder != 0) {
                return nameOrder;
            }

            return o1.getYear() - o2.getYear();
        });

        final Map<TrackingPoint, List<T>> statements = new HashMap<>();

        final SortedList<T> sortedSet = getItems().sorted((Comparator) ORDER_BY_POINT);
        final Iterator<TrackingPoint> tit = trackingPoints.iterator();
        final Iterator<T> sit = sortedSet.iterator();
        if (tit.hasNext()) {
            TrackingPoint tp = tit.next();
            Predicate predicate = new BelongsToTrackingPoint(tp);
            ArrayList<T> ptList = new ArrayList<>();
            statements.put(tp, ptList);
            while (sit.hasNext()) {
                final T st = sit.next();
                if (!predicate.test(st)) {
                    if (tit.hasNext()) {
                        tp = tit.next();
                        predicate = new BelongsToTrackingPoint(tp);
                        ptList = new ArrayList<>();
                        statements.put(tp, ptList);
                    } else {
                        break;
                    }
                }

                ptList.add(st);
            }
        }

        return statements;
    }

    /**
     * Check if the given statement belongs to given tracking point.
     * @param point The point to test
     * @param statement The statement to test against input point.
     * @return True if given statement belongs to given tracking point, false otherwise.
     */
    public static boolean belongToPoint(final TrackingPoint point, final Statement statement) {
        final LocalDate date = statement.getDate();
        if (date == null || statement.getTrackingPoint() == null) {
            return false;
        }

        return (point.getName().equals(statement.getTrackingPoint())
                && (date.isAfter(point.getYearStart()) || date.isEqual(point.getYearStart()))
                && (date.isBefore(point.getYearEnd()) || date.isEqual(point.getYearEnd())));
    }

    private static class BelongsToTrackingPoint implements Predicate<Statement> {
        final String name;
        final LocalDate filterStart;
        final LocalDate filterEnd;

        private BelongsToTrackingPoint(final TrackingPoint tp) {
            name = tp.getName();
            filterStart = tp.getYearStart().minusDays(1);
            filterEnd = tp.getYearEnd().plusDays(1);
        }

        @Override
        public boolean test(Statement t) {
            return name.equals(t.getTrackingPoint())
                    && t.getDate() != null
                    && t.getDate().isAfter(filterStart)
                    && t.getDate().isBefore(filterEnd);
        }
    }

    private static Comparator<? extends Statement> ORDER_BY_POINT = (s1, s2) -> {
        final String t1 = s1.getTrackingPoint();
        final String t2 = s2.getTrackingPoint();

        int comp;
        if (t1 == t2)
            comp = 0;
        else if (t1 != null)
            comp = t1.compareTo(t2);
        else
            comp = 1;

        if (comp == 0) {
            final LocalDate d1 = s1.getDate();
            final LocalDate d2 = s2.getDate();
            if (d1 != d2) {
                if (d1 == null)
                    comp = 1;
                else
                    comp = d1.compareTo(d2);
            }
        }

        return comp;
    };
}
