
package org.sleuthkit.autopsy.timeline.ui;

import java.net.URL;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.ResourceBundle;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.geometry.Insets;
import javafx.geometry.Rectangle2D;
import javafx.scene.SnapshotParameters;
import javafx.scene.control.*;
import javafx.scene.effect.Lighting;
import javafx.scene.image.WritableImage;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import static javafx.scene.layout.Region.USE_PREF_SIZE;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javax.annotation.concurrent.GuardedBy;
import jfxtras.scene.control.LocalDateTimeTextField;
import org.controlsfx.control.RangeSlider;
import org.controlsfx.control.action.Action;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.Interval;
import org.openide.util.NbBundle;
import org.sleuthkit.autopsy.coreutils.LoggedTask;
import org.sleuthkit.autopsy.coreutils.Logger;
import org.sleuthkit.autopsy.timeline.FXMLConstructor;
import org.sleuthkit.autopsy.timeline.TimeLineController;
import org.sleuthkit.autopsy.timeline.TimeLineView;
import org.sleuthkit.autopsy.timeline.VisualizationMode;
import org.sleuthkit.autopsy.timeline.actions.DefaultFilters;
import org.sleuthkit.autopsy.timeline.actions.SaveSnapshot;
import org.sleuthkit.autopsy.timeline.actions.ZoomOut;
import org.sleuthkit.autopsy.timeline.events.FilteredEventsModel;
import org.sleuthkit.autopsy.timeline.ui.countsview.CountsViewPane;
import org.sleuthkit.autopsy.timeline.ui.detailview.DetailViewPane;
import org.sleuthkit.autopsy.timeline.ui.detailview.tree.NavPanel;
import org.sleuthkit.autopsy.timeline.utils.RangeDivisionInfo;

/** A Container for an {@link AbstractVisualization}, has a toolbar on top to
 * hold settings widgets supplied by contained {@link AbstractVisualization},
 * and the histogram / timeselection on bottom. Also supplies containers for
 * replacement axis to contained {@link AbstractVisualization}
 *
 * TODO: refactor common code out of histogram and CountsView? -jm
 */
public class VisualizationPanel extends BorderPane implements TimeLineView {

    @GuardedBy("this")
    private LoggedTask<Void> histogramTask;

    private static final Logger LOGGER = Logger.getLogger(VisualizationPanel.class.getName());

    private final NavPanel navPanel;

    private AbstractVisualization<?, ?, ?, ?> visualization;

    @FXML // ResourceBundle that was given to the FXMLLoader
    private ResourceBundle resources;

    @FXML // URL location of the FXML file that was given to the FXMLLoader
    private URL location;

    //// range slider and histogram componenets
    @FXML // fx:id="histogramBox"
    protected HBox histogramBox; // Value injected by FXMLLoader

    @FXML // fx:id="rangeHistogramStack"
    protected StackPane rangeHistogramStack; // Value injected by FXMLLoader

    private final RangeSlider rangeSlider = new RangeSlider(0, 1.0, .25, .75);

    //// time range selection components
    @FXML
    protected MenuButton zoomMenuButton;

    @FXML
    private Separator rightSeperator;

    @FXML
    private Separator leftSeperator;

    @FXML
    protected Button zoomOutButton;

    @FXML
    protected Button zoomInButton;

    @FXML
    protected LocalDateTimeTextField startPicker;

    @FXML
    protected LocalDateTimeTextField endPicker;

    //// replacemetn axis label componenets
    @FXML
    protected Pane partPane;

    @FXML
    protected Pane contextPane;

    @FXML
    protected Region spacer;

    //// header toolbar componenets
    @FXML
    private ToolBar toolBar;

    @FXML
    private ToggleButton countsToggle;

    @FXML
    private ToggleButton detailsToggle;

    @FXML
    private Button snapShotButton;

    private double preDragPos;

    protected TimeLineController controller;

    protected FilteredEventsModel filteredEvents;

    private final ChangeListener<Object> rangeSliderListener
            = (observable1, oldValue, newValue) -> {
                if (rangeSlider.isHighValueChanging() == false && rangeSlider.isLowValueChanging() == false) {
                    Long minTime = filteredEvents.getMinTime() * 1000;
                    controller.pushTimeRange(new Interval(
                                    new Double(rangeSlider.getLowValue() + minTime).longValue(),
                                    new Double(rangeSlider.getHighValue() + minTime).longValue(),
                                    DateTimeZone.UTC));
                }
            };

    private final InvalidationListener endListener = (Observable observable) -> {
        if (endPicker.getLocalDateTime() != null) {
            controller.pushTimeRange(VisualizationPanel.this.filteredEvents.timeRange().get().withEndMillis(
                    ZonedDateTime.of(endPicker.getLocalDateTime(), TimeLineController.getTimeZoneID()).toInstant().toEpochMilli()));
        }
    };

    private final InvalidationListener startListener = (Observable observable) -> {
        if (startPicker.getLocalDateTime() != null) {
            controller.pushTimeRange(VisualizationPanel.this.filteredEvents.timeRange().get().withStartMillis(
                    ZonedDateTime.of(startPicker.getLocalDateTime(), TimeLineController.getTimeZoneID()).toInstant().toEpochMilli()));
        }
    };

    static private final Background background = new Background(new BackgroundFill(Color.GREY, CornerRadii.EMPTY, Insets.EMPTY));

    static private final Lighting lighting = new Lighting();

    public VisualizationPanel(NavPanel navPanel) {
        this.navPanel = navPanel;
        FXMLConstructor.construct(this, "VisualizationPanel.fxml"); // NON-NLS
    }

    @FXML // This method is called by the FXMLLoader when initialization is complete
    protected void initialize() {
        assert endPicker != null : "fx:id=\"endPicker\" was not injected: check your FXML file 'ViewWrapper.fxml'."; // NON-NLS
        assert histogramBox != null : "fx:id=\"histogramBox\" was not injected: check your FXML file 'ViewWrapper.fxml'."; // NON-NLS
        assert startPicker != null : "fx:id=\"startPicker\" was not injected: check your FXML file 'ViewWrapper.fxml'."; // NON-NLS
        assert rangeHistogramStack != null : "fx:id=\"rangeHistogramStack\" was not injected: check your FXML file 'ViewWrapper.fxml'."; // NON-NLS
        assert countsToggle != null : "fx:id=\"countsToggle\" was not injected: check your FXML file 'VisToggle.fxml'."; // NON-NLS
        assert detailsToggle != null : "fx:id=\"eventsToggle\" was not injected: check your FXML file 'VisToggle.fxml'."; // NON-NLS

        HBox.setHgrow(leftSeperator, Priority.ALWAYS);
        HBox.setHgrow(rightSeperator, Priority.ALWAYS);
        ChangeListener<Toggle> toggleListener = (ObservableValue<? extends Toggle> observable,
                                                 Toggle oldValue,
                                                 Toggle newValue) -> {
                    if (newValue == null) {
                        countsToggle.getToggleGroup().selectToggle(oldValue != null ? oldValue : countsToggle);
                    } else if (newValue == countsToggle && oldValue != null) {
                        controller.setViewMode(VisualizationMode.COUNTS);
                    } else if (newValue == detailsToggle && oldValue != null) {
                        controller.setViewMode(VisualizationMode.DETAIL);
                    }
                };

        if (countsToggle.getToggleGroup() != null) {
            countsToggle.getToggleGroup().selectedToggleProperty().addListener(toggleListener);
        } else {
            countsToggle.toggleGroupProperty().addListener((Observable observable) -> {
                countsToggle.getToggleGroup().selectedToggleProperty().addListener(toggleListener);
            });
        }
        countsToggle.setText(NbBundle.getMessage(this.getClass(), "VisualizationPanel.countsToggle.text"));
        detailsToggle.setText(NbBundle.getMessage(this.getClass(), "VisualizationPanel.detailsToggle.text"));

        //setup rangeslider
        rangeSlider.setOpacity(.7);
        rangeSlider.setMin(0);

//        /** this is still needed to not get swamped by low/high value changes.
//         * https://bitbucket.org/controlsfx/controlsfx/issue/241/rangeslider-high-low-properties
//         * TODO: committ an appropriate version of this fix to the ControlsFX
//         * repo on bitbucket, remove this after next release -jm */
//        Skin<?> skin = rangeSlider.getSkin();
//        if (skin != null) {
//            attachDragListener((RangeSliderSkin) skin);
//        } else {
//            rangeSlider.skinProperty().addListener((Observable observable) -> {
//                RangeSliderSkin skin1 = (RangeSliderSkin) rangeSlider.getSkin();
//                attachDragListener(skin1);
//            });
//        }

        rangeSlider.setBlockIncrement(1);

        rangeHistogramStack.getChildren().add(rangeSlider);

        /* this padding attempts to compensates for the fact that the
         * rangeslider track doesn't extend to edge of node,and so the
         * histrogram doesn't quite line up with the rangeslider */
        histogramBox.setStyle("   -fx-padding: 0,0.5em,0,.5em; "); // NON-NLS

        zoomMenuButton.getItems().clear();
        for (ZoomRanges b : ZoomRanges.values()) {

            MenuItem menuItem = new MenuItem(b.getDisplayName());
            menuItem.setOnAction((event) -> {
                if (b != ZoomRanges.ALL) {
                    controller.pushPeriod(b.getPeriod());
                } else {
                    controller.showFullRange();
                }
            });
            zoomMenuButton.getItems().add(menuItem);
        }
        zoomMenuButton.setText(NbBundle.getMessage(this.getClass(), "VisualizationPanel.zoomMenuButton.text"));

        zoomOutButton.setOnAction(e -> {
            controller.pushZoomOutTime();
        });
        zoomInButton.setOnAction(e -> {
            controller.pushZoomInTime();
        });

        snapShotButton.setOnAction((ActionEvent event) -> {
            //take snapshot
            final SnapshotParameters snapshotParameters = new SnapshotParameters();
            snapshotParameters.setViewport(new Rectangle2D(visualization.getBoundsInParent().getMinX(), visualization.getBoundsInParent().getMinY(),
                                                           visualization.getBoundsInParent().getWidth(),
                                                           contextPane.getLayoutBounds().getHeight() + visualization.getLayoutBounds().getHeight() + partPane.getLayoutBounds().getHeight()
            ));
            WritableImage snapshot = this.snapshot(snapshotParameters, null);
            //pass snapshot to save action
            new SaveSnapshot(controller, snapshot).handle(event);
        });

        snapShotButton.setText(NbBundle.getMessage(this.getClass(), "VisualizationPanel.snapShotButton.text"));
    }

//    /**
//     * TODO: committed an appropriate version of this fix to the ControlsFX repo
//     * on bitbucket, remove this after next release -jm
//     *
//     * @param skin
//     */
//    private void attachDragListener(RangeSliderSkin skin) {
//        if (skin != null) {
//            for (Node n : skin.getChildren()) {
//                if (n.getStyleClass().contains("track")) {
//                    n.setOpacity(.3);
//                }
//                if (n.getStyleClass().contains("range-bar")) {
//                    StackPane rangeBar = (StackPane) n;
//                    rangeBar.setOnMousePressed((MouseEvent e) -> {
//                        rangeBar.requestFocus();
//                        preDragPos = e.getX();
//                    });
//
//                    //don't mark as not changing until mouse is released
//                    rangeBar.setOnMouseReleased((MouseEvent event) -> {
//                        rangeSlider.setLowValueChanging(false);
//                        rangeSlider.setHighValueChanging(false);
//                    });
//                    rangeBar.setOnMouseDragged((MouseEvent event) -> {
//                        final double min = rangeSlider.getMin();
//                        final double max = rangeSlider.getMax();
//
//                        ///!!!  compensate for range and width so that rangebar actualy stays with the slider
//                        double delta = (event.getX() - preDragPos) * (max - min) / rangeSlider.
//                                getWidth();
//                        ////////////////////////////////////////////////////
//
//                        final double lowValue = rangeSlider.getLowValue();
//                        final double newLowValue = Math.min(Math.max(min, lowValue + delta),
//                                                            max);
//                        final double highValue = rangeSlider.getHighValue();
//                        final double newHighValue = Math.min(Math.max(min, highValue + delta),
//                                                             max);
//
//                        if (newLowValue <= min || newHighValue >= max) {
//                            return;
//                        }
//
//                        rangeSlider.setLowValueChanging(true);
//                        rangeSlider.setHighValueChanging(true);
//                        rangeSlider.setLowValue(newLowValue);
//                        rangeSlider.setHighValue(newHighValue);
//                    });
//                }
//            }
//        }
//    }

    @Override
    public synchronized void setController(TimeLineController controller) {
        this.controller = controller;
        setModel(controller.getEventsModel());

        setViewMode(controller.getViewMode().get());

        controller.getNeedsHistogramRebuild().addListener((ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) -> {
            if (newValue) {
                refreshHistorgram();
            }
        });

        controller.getViewMode().addListener((ObservableValue<? extends VisualizationMode> ov, VisualizationMode t, VisualizationMode t1) -> {
            setViewMode(t1);
        });
        refreshHistorgram();
    }

    private void setViewMode(VisualizationMode visualizationMode) {
        switch (visualizationMode) {
            case COUNTS:
                setVisualization(new CountsViewPane(partPane, contextPane, spacer));
                countsToggle.setSelected(true);
                break;
            case DETAIL:
                setVisualization(new DetailViewPane(partPane, contextPane, spacer));
                detailsToggle.setSelected(true);
                break;
        }
    }

    synchronized void setVisualization(final AbstractVisualization<?, ?, ?, ?> newViz) {
        Platform.runLater(() -> {
            synchronized (VisualizationPanel.this) {
                if (visualization != null) {
                    toolBar.getItems().removeAll(visualization.getSettingsNodes());
                    visualization.dispose();
                }

                visualization = newViz;
                toolBar.getItems().addAll(newViz.getSettingsNodes());

                visualization.setController(controller);
                setCenter(visualization);
                if (visualization instanceof DetailViewPane) {
                    navPanel.setChart((DetailViewPane) visualization);
                }
                visualization.hasEvents.addListener((ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) -> {
                    if (newValue == false) {

                        setCenter(new StackPane(visualization, new Region() {
                            {
                                setBackground(new Background(new BackgroundFill(Color.GREY, CornerRadii.EMPTY, Insets.EMPTY)));
                                setOpacity(.3);
                            }
                        }, new NoEventsDialog(() -> {
                            setCenter(visualization);
                        })));
                    } else {
                        setCenter(visualization);
                    }
                });
            }
        });
    }

    synchronized private void refreshHistorgram() {

        if (histogramTask != null) {
            histogramTask.cancel(true);
        }

        histogramTask = new LoggedTask<Void>(
                NbBundle.getMessage(this.getClass(), "VisualizationPanel.histogramTask.title"), true) {

            @Override
            protected Void call() throws Exception {

                updateMessage(NbBundle.getMessage(this.getClass(), "VisualizationPanel.histogramTask.preparing"));

                long max = 0;
                final RangeDivisionInfo rangeInfo = RangeDivisionInfo.getRangeDivisionInfo(filteredEvents.getSpanningInterval());
                final long lowerBound = rangeInfo.getLowerBound();
                final long upperBound = rangeInfo.getUpperBound();
                Interval timeRange = new Interval(new DateTime(lowerBound, TimeLineController.getJodaTimeZone()), new DateTime(upperBound, TimeLineController.getJodaTimeZone()));

                //extend range to block bounderies (ie day, month, year)
                int p = 0; // progress counter

                //clear old data, and reset ranges and series
                Platform.runLater(() -> {
                    updateMessage(NbBundle.getMessage(this.getClass(), "VisualizationPanel.histogramTask.resetUI"));

                });

                ArrayList<Long> bins = new ArrayList<>();

                DateTime start = timeRange.getStart();
                while (timeRange.contains(start)) {
                    if (isCancelled()) {
                        return null;
                    }
                    DateTime end = start.plus(rangeInfo.getPeriodSize().getPeriod());
                    final Interval interval = new Interval(start, end);
                    //increment for next iteration

                    start = end;

                    updateMessage(NbBundle.getMessage(this.getClass(), "VisualizationPanel.histogramTask.queryDb"));
                    //query for current range
                    long count = filteredEvents.getEventCounts(interval).values().stream().mapToLong(Long::valueOf).sum();
                    bins.add(count);

                    max = Math.max(count, max);

                    final double fMax = Math.log(max);
                    final ArrayList<Long> fbins = new ArrayList<>(bins);
                    Platform.runLater(() -> {
                        updateMessage(NbBundle.getMessage(this.getClass(), "VisualizationPanel.histogramTask.updateUI2"));

                        histogramBox.getChildren().clear();

                        for (Long bin : fbins) {
                            if (isCancelled()) {
                                break;
                            }
                            Region bar = new Region();
                            //scale them to fit in histogram height
                            bar.prefHeightProperty().bind(histogramBox.heightProperty().multiply(Math.log(bin)).divide(fMax));
                            bar.setMaxHeight(USE_PREF_SIZE);
                            bar.setMinHeight(USE_PREF_SIZE);
                            bar.setBackground(background);
                            bar.setOnMouseEntered((MouseEvent event) -> {
                                Tooltip.install(bar, new Tooltip(bin.toString()));
                            });
                            bar.setEffect(lighting);
                            //they each get equal width to fill the histogram horizontally
                            HBox.setHgrow(bar, Priority.ALWAYS);
                            histogramBox.getChildren().add(bar);
                        }
                    });
                }
                return null;
            }

        };
        new Thread(histogramTask).start();
        controller.monitorTask(histogramTask);
    }

    @Override
    public void setModel(FilteredEventsModel filteredEvents) {
        this.filteredEvents = filteredEvents;

        refreshTimeUI(filteredEvents.timeRange().get());
        this.filteredEvents.timeRange().addListener((Observable observable) -> {
            refreshTimeUI(filteredEvents.timeRange().get());
        });
        TimeLineController.getTimeZone().addListener((Observable observable) -> {
            refreshTimeUI(filteredEvents.timeRange().get());
        });
    }

    private void refreshTimeUI(Interval interval) {
        RangeDivisionInfo rangeDivisionInfo = RangeDivisionInfo.getRangeDivisionInfo(filteredEvents.getSpanningInterval());

        final Long minTime = rangeDivisionInfo.getLowerBound();
        final long maxTime = rangeDivisionInfo.getUpperBound();

        if (minTime > 0 && maxTime > minTime) {

            Platform.runLater(() -> {
                startPicker.localDateTimeProperty().removeListener(startListener);
                endPicker.localDateTimeProperty().removeListener(endListener);
                rangeSlider.highValueChangingProperty().removeListener(rangeSliderListener);
                rangeSlider.lowValueChangingProperty().removeListener(rangeSliderListener);

                rangeSlider.setMax((Long) (maxTime - minTime));
                rangeSlider.setHighValue(interval.getEndMillis() - minTime);
                rangeSlider.setLowValue(interval.getStartMillis() - minTime);
                endPicker.setLocalDateTime(LocalDateTime.ofInstant(Instant.ofEpochMilli(interval.getEndMillis()), TimeLineController.getTimeZoneID()));
                startPicker.setLocalDateTime(LocalDateTime.ofInstant(Instant.ofEpochMilli(interval.getStartMillis()), TimeLineController.getTimeZoneID()));

                rangeSlider.highValueChangingProperty().addListener(rangeSliderListener);
                rangeSlider.lowValueChangingProperty().addListener(rangeSliderListener);
                startPicker.localDateTimeProperty().addListener(startListener);
                endPicker.localDateTimeProperty().addListener(endListener);
            });
        }
    }

    private class NoEventsDialog extends TitledPane {

        private final Runnable closeCallback;

        @FXML // ResourceBundle that was given to the FXMLLoader
        private ResourceBundle resources;

        @FXML // URL location of the FXML file that was given to the FXMLLoader
        private URL location;

        @FXML
        private Button resetFiltersButton;

        @FXML
        private Button dismissButton;

        @FXML
        private Button zoomButton;

        @FXML
        private Label visualizationModeLabel;

        @FXML
        private Label noEventsDialogLabel;

        @FXML
        private Label startLabel;

        @FXML
        private Label endLabel;

        public NoEventsDialog(Runnable closeCallback) {
            this.closeCallback = closeCallback;
            FXMLConstructor.construct(this, "NoEventsDialog.fxml"); // NON-NLS

        }

        @FXML
        void initialize() {
            assert resetFiltersButton != null : "fx:id=\"resetFiltersButton\" was not injected: check your FXML file 'NoEventsDialog.fxml'."; // NON-NLS
            assert dismissButton != null : "fx:id=\"dismissButton\" was not injected: check your FXML file 'NoEventsDialog.fxml'."; // NON-NLS
            assert zoomButton != null : "fx:id=\"zoomButton\" was not injected: check your FXML file 'NoEventsDialog.fxml'."; // NON-NLS

            visualizationModeLabel.setText(
                    NbBundle.getMessage(this.getClass(), "VisualizationPanel.visualizationModeLabel.text"));
            noEventsDialogLabel.setText(
                    NbBundle.getMessage(this.getClass(), "VisualizationPanel.noEventsDialogLabel.text"));
            zoomButton.setText(NbBundle.getMessage(this.getClass(), "VisualizationPanel.zoomButton.text"));
            startLabel.setText(NbBundle.getMessage(this.getClass(), "VisualizationPanel.startLabel.text"));
            endLabel.setText(NbBundle.getMessage(this.getClass(), "VisualizationPanel.endLabel.text"));

            Action zoomOutAction = new ZoomOut(controller);
            zoomButton.setOnAction(zoomOutAction);
            zoomButton.disableProperty().bind(zoomOutAction.disabledProperty());

            dismissButton.setOnAction(e -> {
                closeCallback.run();
            });
            Action defaultFiltersAction = new DefaultFilters(controller);
            resetFiltersButton.setOnAction(defaultFiltersAction);
            resetFiltersButton.disableProperty().bind(defaultFiltersAction.disabledProperty());
            resetFiltersButton.setText(
                    NbBundle.getMessage(this.getClass(), "VisualizationPanel.resetFiltersButton.text"));
        }
    }
}
