package leigh.elements.javafx.type1;

import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyStringWrapper;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.VBox;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.util.Duration;
import leigh.conflux.nodegraph.AttributeValueNode;
import leigh.elements.ParentElement;
import leigh.elements.javafx.ThemeJFX;
import leigh.elements.sdk.AppDataFrame;
import leigh.elements.sdk.AppScreenInstance;
import leigh.elements.sdk.ElementsColumnConfig;
import leigh.elements.sdk.type1.FormLayout;
import leigh.elements.sdk.type1.TableElement;
import leigh.eo.EO;
import leigh.eo.EOListener;
import leigh.eo.EOLoop;
import leigh.eo.EditEventGenerator;
import leigh.mecha.javafx.JavaFXConstants;
import leigh.mecha.javafx.NodeRuler;
import leigh.mecha.log.MechaLogger;
import leigh.mecha.log.MechaLoggerFactory;
import leigh.mecha.math.IntInterval;
import leigh.mecha.math.IntIntervals;
import leigh.mecha.util.Snavig;
import org.apache.commons.lang.StringUtils;

import java.io.Serializable;
import java.util.List;
import java.util.Objects;
import java.util.UUID;

/**
 * Implementation of the Elements {@link TableElement} for JavaFX. This implementation provides an interactive
 * UI.
 * <p>
 * This implementation no longer requires that the {@link AppDataFrame} <code>getView()</code> method return
 * an {@link ObservableList}, and in fact the practice is to be avoided.
 * <p>
 * The grid will monitor the <code>GridElement.KEY_TIMESTAMP</code> value and if this changes will refresh its view
 * of the data.
 * <p>
 * The grid will set its selection state into <code>GridElement.KEY_SELECTION</code>. Right now it only supports
 * single-selections, although that will change in the future. The value of the selection attribute will be the
 * numeric index which is currently selected, and corresponds to <code>getView()</code> on the dataframe.
 * <p>
 * The TableElementJFX currently supports sorting through the natural support provided in the underlying
 * {@link javafx.scene.control.TableView} implementation. Therefore, it does not call sort mechanics on the
 * {@link leigh.conflux.DataFrame} objects at this time. It is possible to preload a sort configuration
 * by specifying the SORT_COL and SORT_ORDER parameters in the configuration. Currently, only sorting a single
 * column at a time is supported. For the relevant values, see {@link TableElement}.
 *
 * @author Alex Leigh
 */

public class TableElementJFX extends ParentElement implements ElementJFX, EOListener {
    public final static String ICON_ROW_GREY = "row_grey";
    private static final MechaLogger logger = MechaLoggerFactory.getLogger(TableElementJFX.class);
    private static final String LABEL_ADD = "";
    private static final String LABEL_DELETE = "";
    private static final String LABEL_FILTER = "";
    private static final String LABEL_CLEARFILTER = "";
    private final static String ICON_CREATE = "grid_create";
    private final static String ICON_DELETE = "grid_delete";
    private final static String ICON_FILTER = "grid_filter";
    private final static String ICON_CLEAR_FILTER = "grid_filter_clear";
    // This stores the objects which the grid is actually showing on the screen.
    private final ObservableList<EO> obsList = FXCollections.observableArrayList();
    private final VBox root = new VBox();
    private final javafx.scene.control.TableView tableView = new ElementsTableView(getFrame());
    private final ThemeJFX theme;
    private boolean eventsArmed = true;
    private boolean selectionEventArmed = true;

    protected TableElementJFX(EO cfg, AppScreenInstance screen, ThemeJFX theme) {
        super(cfg, screen);
        this.theme = theme;

        logger.debug("Instancing new Table. [cfg: {}]", cfg);
        boolean isReadOnly = cfg.getValueBoolean(TableElement.KEY_ISREADONLY);

        // TODO Shouldn't this be based on the above?
        tableView.setEditable(true);

        // We set this really high to cause it to generally fill its container.
        tableView.setPrefHeight(JavaFXConstants.REALLY_TALL_HEIGHT);
        tableView.setColumnResizePolicy(javafx.scene.control.TableView.UNCONSTRAINED_RESIZE_POLICY);

        // Get the frame from the screen. This presumes that it already exists, but it should at this point.
        UUID dataFrameId = cfg.getValueUUID(TableElement.KEY_DATASOURCE);
        AppDataFrame frame = screen.getDataFrame(dataFrameId);
        assert (frame != null);
        obsList.addAll(frame.getView());

        logger.debug("Found dataframe: [id: {}] [frame: {}]", dataFrameId, frame);
        ToolBar tb = new ToolBar();
        if (!isReadOnly) {
            // CREATE mechanic
            Button create = new Button(LABEL_ADD);
            create.setGraphic(new ImageView(theme.getIcons().getImage(ICON_CREATE)));
            create.setOnAction(new EventHandler<ActionEvent>() {
                @Override
                public void handle(ActionEvent event) {
                    // We do it this way to ensure an ID is generated.
                    Platform.runLater(() -> {
                        EO foreignObject = screen.createNewRecord();
                        frame.add(foreignObject);
                        logger.debug("Created new object. [viewSize: {}]", frame.getView().size());
                        try {
                            screen.update();
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                        tableView.getSelectionModel().select(foreignObject);
                    });
                }
            });
            tb.getItems().add(create);

            // DELETE mechanic
            Button delete = new Button(LABEL_DELETE);
            delete.setGraphic(new ImageView(theme.getIcons().getImage(ICON_DELETE)));
            delete.setOnAction(event -> {
                logger.info("DELETE button clicked. [sel: {}]", getFrame().getSelected());
                frame.deleteSelected();

                try {
                    screen.update();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
            tb.getItems().add(delete);
        }

        // FILTER mechanic
        Button filter = new Button(LABEL_FILTER);
        filter.setGraphic(new ImageView(theme.getIcons().getImage(ICON_FILTER)));
        filter.setOnAction(event -> {
            final Stage dialog = new Stage();
            dialog.initModality(Modality.APPLICATION_MODAL);
            VBox dialogVbox = new VBox(20);

            GridPane gridPane = new GridPane();
            gridPane.setAlignment(Pos.CENTER);
            gridPane.setHgap(10);
            gridPane.setVgap(10);
            gridPane.setPadding(new Insets(25, 25, 25, 25));

            buildChildren(FormLayout.KEY_FIELDS);

            int row = 0;

            for (ElementsColumnConfig col : frame.getColumnConfigs()) {
                String label = col.getLabel();
                Label l = new Label(label + ":");
                TextField tf = new TextField();

                PauseTransition pause = new PauseTransition(Duration.seconds(1));
                tf.textProperty().addListener((observable, oldValue, newValue) -> {
                    pause.setOnFinished(new EventHandler<ActionEvent>() {
                        @Override
                        public void handle(ActionEvent event) {
                            logger.debug("Filter value changing. [old: {}] [new: {}]", oldValue, newValue);
                            if (StringUtils.isNotEmpty(newValue)) {
                                frame.setQuickFilter(col.getName(), newValue);
                            } else {
                                frame.setQuickFilter(col.getName(), null);
                            }
                            try {
                                frame.applyFilters();
                            } catch (Exception e) {
                                e.printStackTrace();
                            }
                        }
                    });
                    pause.playFromStart();
                });

                gridPane.add(l, 0, row);
                gridPane.add(tf, 1, row);

                row++;
            }

            dialogVbox.getChildren().add(gridPane);

            Button ok = new Button("OK");
            ok.setOnAction(event1 -> dialog.close());

            gridPane.add(ok, 0, row);

            Scene dialogScene = new Scene(dialogVbox, 300, 200);
            dialog.setScene(dialogScene);
            dialog.show();
        });
        tb.getItems().add(filter);
        Button clearFilter = new Button(LABEL_CLEARFILTER);
        clearFilter.setOnAction(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent event) {
                try {
                    frame.clearFilters();
                    frame.applyFilters();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });
        clearFilter.setGraphic(new ImageView(theme.getIcons().getImage(ICON_CLEAR_FILTER)));
        tb.getItems().add(clearFilter);

        root.getChildren().add(tb);

        // We have to set the list into the TableView before we fiddle with sorting, or the sort cfg will
        // be reset in the object.
        tableView.setItems(obsList);

        logger.debug("Initial load of items. [sz: {}]", obsList.size());

        List<ElementsColumnConfig> colCfgs = frame.getColumnConfigs();

        // TODO: Raise an error if the column configuration was null

        tableView.getColumns().add(makeEditStatusColumn());

        String sortCol = cfg.getValueString(TableElement.KEY_SORT_COL);
        String sortOrder = cfg.getValueString(TableElement.KEY_SORT_ORDER);

        logger.debug("Sort configuration: [col: {}] [order: {}]", sortCol, sortOrder);

        if (colCfgs != null) {
            for (ElementsColumnConfig col : colCfgs) {
                logger.debug("Adding column: {}", col);
                TableColumn tableCol = makeDataColumn(col.getName(), col.getLabel(),
                        col.getWidth(), col.getAlignment(), col.isReadOnly());

                tableView.getColumns().add(tableCol);

                if (Objects.equals(sortCol, col.getName())) {
                    logger.debug("Matched sort: [col: {}]", sortCol);
                    switch (sortOrder) {
                        case TableElement.VALUE_SORT_DESCENDING:
                            tableCol.setSortType(TableColumn.SortType.DESCENDING);
                            break;
                        default:
                            tableCol.setSortType(TableColumn.SortType.ASCENDING);
                    }

                    // This is okay because we already added the column to the TableView
                    tableView.getSortOrder().setAll(tableCol);
                    logger.debug("New sort order: {}", tableView.getSortOrder());
                }
            }

            // We have to sort it the first time. We assume that we setup a sort configuration. If not, this
            // should just NOP.
            if (sortCol != null) {
                logger.debug("Forcing sort on tableview");
                /*
                 * This is basically a hack. Simply calling tableView.sort() on the TV is not, in fact, sorting
                 * it even though the data is already present at this point.
                 */
                frameChangeEventHandler();
            }
        }

        /*
         * Event listener for when the end-user changes the selection by clicking in the table. The TableView will
         * have already updated its internal selection model, so all we have to do is take its current state
         * and write that into the cfg object.
         */
        tableView.getSelectionModel().getSelectedIndices().addListener((ListChangeListener) c -> {
            logger.info("Table ListChangeListener called");
            syncSelectionToFrame();
        });

        TableElementJFX me = this;

        // We register this event handler so that if the end-user clicks back into the table, but re-selects a row
        // that was already selected, that we recognize that focus has been gained, and redraw the screen. This
        // results in the correct east panel being drawn in this edge-case.
        tableView.addEventFilter(MouseEvent.MOUSE_CLICKED, new EventHandler<Event>() {
            @Override
            public void handle(Event event) {
                logger.info("Click event.");

                // QOS-11 We check this because the user might be editing a cell. If so, we don't want to mess with
                // the selection, or we'll de-select it out from under them.
                if (tableView.getEditingCell() == null)
                    syncSelectionToFrame();
            }
        });

        root.getChildren().add(tableView);

        EditEventGenerator tracker = (EditEventGenerator) cfg.getEditTracker();
        if (tracker != null) {
            tracker.addListener(cfg, this);
        }
    }

    /**
     * Return the {@link AppDataFrame} associated with this object.
     */
    public AppDataFrame getFrame() {
        UUID dataFrameId = getCfg().getValueUUID(TableElement.KEY_DATASOURCE);
        return getScreen().getDataFrame(dataFrameId);
    }

    /**
     * Call this method when the data frame has mutated in some way. This includes changes to the data contents,
     * the sorting, the selection, or the filtering, etc.
     */
    private void frameChangeEventHandler() {
        if (eventsArmed) {
            eventsArmed = false;
            selectionEventArmed = false;

            logger.info("frameChangeEventHandler() called. Current sort order: {}", tableView.getSortOrder());

            Platform.runLater(() -> {
                logger.debug("Update thread started");
                UUID dataFrameId = getCfg().getValueUUID(TableElement.KEY_DATASOURCE);
                AppDataFrame frame = getScreen().getDataFrame(dataFrameId);

                // 3 cases: lists are the same size, smaller, bigger
                List<EO> view = frame.getView();

                logger.debug("refreshData() called. [obsList: {}]", obsList.getClass());

                if (obsList.size() > view.size()) {
                    // Do we have extra objects because the view got smaller?
                    // We cannot use a normal technique here because we must avoid replacing the list / calling clear()
                    logger.debug("obsList was larger than view... Removing extra entries.");
                    int vsz = view.size();
                    int cnt = obsList.size() - vsz;

                    for (int i = 0; i < cnt; i++) {
                        obsList.remove(vsz);
                    }
                } else if (view.size() > obsList.size()) {
                    logger.debug("obsList was smaller than view... Adding extra entries.");
                    for (int i = obsList.size(); i < view.size(); i++) {
                        obsList.add(view.get(i));
                    }
                }

                // Now that obsList.size() == view.size(), see if they are the same objects.

                // In some cases, tampering with the list will drop the selection on the table. This is a problem
                // for us.
                for (int i = 0; i < obsList.size(); i++) {
                    EO vo = view.get(i);
                    EO go = obsList.get(i);

                    // Only replace the object if the reference actually changed. We therefore don't care about the
                    // contents.
                    if (vo != go) {
                        logger.debug(
                                "Replacing object in view because eo reference changed... [idx: {}] [obsList: {}]",
                                i, obsList);
                        obsList.set(i, view.get(i));
                    } else {
                        logger.debug("Not replacing object because EO reference did not change. [idx: {}]", i);
                    }
                }

                logger.debug("refreshData() [view_cnt: {}] [obs_cnt: {}]", frame.getView().size(), obsList.size());

                tableView.refresh();
                selectionEventArmed = true;
                eventsArmed = true;
                syncSelectionFromFrame();
            });
        }
    }

    @Override
    public Node getJavaFXNode() {
        return root;
    }

    /**
     * Return the current selection on the TableView in the format of a {@link IntIntervals} object.
     */
    public IntIntervals getCurrentSelection() {
        List<Integer> lst = tableView.getSelectionModel().getSelectedIndices();

        IntIntervals sel = new IntIntervals();
        for (Integer x : lst) {
            sel.add(x, x);
        }
        sel.compact();
        return sel;
    }

    /**
     * Return a callback to the caller which reads the given parameter from an EO. This will return a simple
     * callback which provides a {@link ReadOnlyStringWrapper} for the value stored in the EO provided to the
     * callback, e.g., the EO from the dataframe view.
     *
     * @param propertyName The key in the EO.
     * @return The callback.
     */
    public Callback<javafx.scene.control.TableColumn.CellDataFeatures<EO, String>, ObservableValue<String>>
    makeCallback(String propertyName) {
        return param -> {
            // TODO: The performance of indexOf() here is likely terrible
            EO obj = param.getValue();
            String displayValue = Snavig.convertToString(obj.getMetaValue(propertyName,
                    AttributeValueNode.KEY_INPUT_DISPLAY_VALUE));
            if (displayValue != null) return new ReadOnlyStringWrapper(displayValue);

            // QV-35 It is always possible that this is a loop, and we do not want to display loops as values since we
            // will get the toString() implementation
            Serializable val = obj.getValue(propertyName);
            if (val instanceof EOLoop) {
                logger.warn("Value was unexpectedly a loop. [key: {}]", propertyName);
                return new ReadOnlyStringWrapper(null);
            }
            return new ReadOnlyStringWrapper(Snavig.convertToString(val));
        };
    }

    /**
     * Make a new {@link javafx.scene.control.TableColumn} object for accessing the dataframe.
     * The underlying implementation for this mechanic is the {@link VisionTableColumn}.
     */
    private javafx.scene.control.TableColumn makeDataColumn(String propertyName, String label, int widthChars,
                                                            String alignment, boolean isReadOnly) {
        VisionTableColumn col = new VisionTableColumn(label, propertyName);
        logger.debug("Col width: {} {}", propertyName, widthChars);
        if (widthChars != 0) {
            col.setPrefWidth(NodeRuler.measureTextWidth(widthChars));
        }

        if (alignment != null) {
            switch (alignment) {
                case "left":
                    col.getStyleClass().add("vds-table-col-align-left");
                    break;
                case "centre":
                case "center":
                    col.getStyleClass().add("vds-table-col-align-center");
                    break;
                case "right":
                    col.getStyleClass().add("vds-table-col-align-right");
                    break;
                default:
                    logger.warn("Unknown table col alignment: {}", alignment);
            }
        }

        if (isReadOnly) {
            col.setEditable(false);
        }

        col.setOnEditCommit(event -> {
            event.getTableView().getItems().get(
                    event.getTablePosition().getRow()).setValue(propertyName, event.getNewValue());
            logger.debug("EDIT COMMIT");
            try {
                getScreen().update();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        Callback<TableColumn<EO, String>, TableCell<EO, String>> cell = TextFieldTableCell.forTableColumn();
        col.setCellFactory(cell);
        col.setCellValueFactory(makeCallback(propertyName));
        return col;
    }

    /**
     * Generate the column configuration for the edit status column. We include this for obvious benefits,
     * not least of which is newly created rows would otherwise be entirely empty, causing rendering and human
     * factor problems.
     */
    private javafx.scene.control.TableColumn makeEditStatusColumn() {
        javafx.scene.control.TableColumn tableColumnType = new javafx.scene.control.TableColumn();
        tableColumnType.setCellFactory(param -> {
            //Set up the ImageView
            final ImageView imageview = new ImageView();
            imageview.setFitHeight(10);
            imageview.setFitWidth(10);
            ///imageview.setImage(imageComputer); //uncommenting this places the image on all cells, even empty ones
            //Set up the Table
            TableCell<String, Image> cell = new TableCell<String, Image>() {
                @Override
                public void updateItem(Image item, boolean empty) {
                    //  logger.debug("updateItem() called. [item: {}] [empty: {}]", item, empty);
                    if (!empty) {  // choice of image is based on values from item, but it doesn't matter now
                        imageview.setImage(theme.getIcons().getImage(ICON_ROW_GREY));
                    }
                }
            };

            // Attach the imageview to the cell
            cell.setGraphic(imageview);

            return cell;
        });
        return tableColumnType;
    }

    /**
     * Synchronize the selection state from the data frame into the internal representation of the table. Call this
     * method when the selection in the frame has changed due to a foreign actor.
     */
    private void syncSelectionFromFrame() {
        IntIntervals newSelect = getFrame().getSelectedIntervals();
        IntIntervals oldSelection = getCurrentSelection();
        logger.debug("syncSelectionFromFrame() called: [new: {}] [old: {}]", newSelect, oldSelection);

        // Only update the selection if we actually have a change.
        if (!IntIntervals.equals(oldSelection, newSelect)) {
            logger.debug("===== NEW SELECTION - WOULD UPDATE");
            if (newSelect == null) {
                // NOP
            } else {
                TableView.TableViewSelectionModel selectionModel = tableView.getSelectionModel();
                selectionModel.clearSelection();
                for (IntInterval ivl : newSelect.getIntervals()) {
                    for (int i = ivl.getStartIndex(); i <= ivl.getStopIndex(); i++) {
                        logger.debug("********* Selecting: [index: {}]", i);
                        selectionModel.select(i);
                    }
                }
            }
        } else {
            logger.debug("syncSelectionFromFrame() - CALLED, BUT NO CHANGE");
        }
    }

    /**
     * Synchronize the current TableView selection state to the associated frame.
     * <p>
     * MT-Safety: Safe.
     */
    private void syncSelectionToFrame() {
        if (selectionEventArmed) {
            IntIntervals sel = getCurrentSelection();
            AppDataFrame frame = getFrame();
            frame.clearAndSelect(sel);
            logger.info("syncSelectionToFrame() [tableSelection: {}] [frameSelection: {}]", sel, frame.getSelected());
        } else {
            logger.debug("syncSelectionToFrame() called, but selection events disarmed.");
        }
    }

    @Override
    /**
     * If the frame is updated outside of our control, the KEY_TIMESTAMP in the configuration will be updated. This
     * is our cue to execute the <code>frameChangeEventHandler()</code> method to update our representation of the
     * frame.
     */
    public void valueUpdated(EO eo, String key, Serializable oldValue, Serializable newValue) {
        logger.info("=============== valueUpdated() called. [key: {}] [oldValue: {}] [newValue: {}]",
                key, oldValue, newValue);
        if (key.equals(TableElement.KEY_TIMESTAMP)) {
            // This event indicates that the frame changed somehow (filtering, add/delete object, sorting).
            // The implication is that our view state is no longer valid and needs to be refreshed.
            frameChangeEventHandler();
        }
    }
}