package ctbrec.ui.tabs;

import static ctbrec.Recording.State.*;
import static ctbrec.ui.UnicodeEmoji.*;

import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import ctbrec.Config;
import ctbrec.Model;
import ctbrec.Recording;
import ctbrec.StringUtil;
import ctbrec.recorder.Recorder;
import ctbrec.sites.Site;
import ctbrec.ui.AutosizeAlert;
import ctbrec.ui.DesktopIntegration;
import ctbrec.ui.JavaFxModel;
import ctbrec.ui.PreviewPopupHandler;
import ctbrec.ui.StreamSourceSelectionDialog;
import ctbrec.ui.action.CheckModelAccountAction;
import ctbrec.ui.action.EditNotesAction;
import ctbrec.ui.action.FollowAction;
import ctbrec.ui.action.IgnoreModelsAction;
import ctbrec.ui.action.OpenRecordingsDir;
import ctbrec.ui.action.PauseAction;
import ctbrec.ui.action.PlayAction;
import ctbrec.ui.action.RemoveTimeLimitAction;
import ctbrec.ui.action.ResumeAction;
import ctbrec.ui.action.SetStopDateAction;
import ctbrec.ui.action.StartRecordingAction;
import ctbrec.ui.action.StopRecordingAction;
import ctbrec.ui.action.ToggleRecordingAction;
import ctbrec.ui.controls.CustomMouseBehaviorContextMenu;
import ctbrec.ui.controls.DateTimeCellFactory;
import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.controls.SearchBox;
import ctbrec.ui.controls.autocomplete.AutoFillTextField;
import ctbrec.ui.controls.autocomplete.ObservableListSuggester;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringPropertyBase;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.concurrent.WorkerStateEvent;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableColumn.CellEditEvent;
import javafx.scene.control.TableColumn.SortType;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.Tooltip;
import javafx.scene.control.cell.CheckBoxTableCell;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.ContextMenuEvent;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import javafx.util.converter.NumberStringConverter;

public class RecordedModelsTab extends Tab implements TabSelectionListener {
    private static final Logger LOG = LoggerFactory.getLogger(RecordedModelsTab.class);

    private static final String STYLE_ALIGN_CENTER = "-fx-alignment: CENTER;";

    private ReentrantLock lock = new ReentrantLock();
    private ScheduledService<List<JavaFxModel>> updateService;
    private Recorder recorder;
    private List<Site> sites;
    private volatile boolean cellEditing = false;

    FlowPane grid = new FlowPane();
    ScrollPane scrollPane = new ScrollPane();
    TableView<JavaFxModel> table = new TableView<>();
    ObservableList<JavaFxModel> observableModels = FXCollections.observableArrayList();
    ObservableList<JavaFxModel> filteredModels = FXCollections.observableArrayList();
    ContextMenu popup;

    Label modelLabel = new Label("Model");
    AutoFillTextField model;
    Button addModelButton = new Button("Record");
    Button pauseAll = new Button("Pause All");
    Button resumeAll = new Button("Resume All");
    ToggleButton toggleRecording = new ToggleButton("Pause Recording");
    Button checkModelAccountExistance = new Button("Check URLs");
    TextField filter;

    public RecordedModelsTab(String title, Recorder recorder, List<Site> sites) {
        super(title);
        this.recorder = recorder;
        this.sites = sites;
        createGui();
        setClosable(false);
        initializeUpdateService();
    }

    @SuppressWarnings("unchecked")
    private void createGui() {
        grid.setPadding(new Insets(5));
        grid.setHgap(5);
        grid.setVgap(5);

        scrollPane.setContent(grid);
        scrollPane.setFitToHeight(true);
        scrollPane.setFitToWidth(true);
        BorderPane.setMargin(scrollPane, new Insets(5));


        table.setEditable(true);
        table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
        PreviewPopupHandler previewPopupHandler = new PreviewPopupHandler(table);
        table.setRowFactory(tableview -> {
            TableRow<JavaFxModel> row = new TableRow<>();
            row.addEventHandler(MouseEvent.ANY, previewPopupHandler);
            return row;
        });
        TableColumn<JavaFxModel, String> preview = new TableColumn<>("🎥");
        preview.setPrefWidth(35);
        preview.setCellValueFactory(cdf -> new SimpleStringProperty("  ▶  "));
        preview.setEditable(false);
        preview.setId("preview");
        if(!Config.getInstance().getSettings().livePreviews) {
            preview.setVisible(false);
        }
        TableColumn<JavaFxModel, String> name = new TableColumn<>("Model");
        name.setPrefWidth(200);
        name.setCellValueFactory(new PropertyValueFactory<>("displayName"));
        name.setCellFactory(new ClickableCellFactory<>());
        name.setEditable(false);
        name.setId("name");
        TableColumn<JavaFxModel, String> url = new TableColumn<>("URL");
        url.setCellValueFactory(new PropertyValueFactory<>("url"));
        url.setCellFactory(new ClickableCellFactory<>());
        url.setPrefWidth(400);
        url.setEditable(false);
        url.setId("url");
        TableColumn<JavaFxModel, String> online = new TableColumn<>("Online");
        online.setCellValueFactory(cdf -> cdf.getValue().getOnlineProperty());
        online.setPrefWidth(100);
        online.setEditable(false);
        online.setId("online");
        online.setStyle(STYLE_ALIGN_CENTER);
        TableColumn<JavaFxModel, String> recording = new TableColumn<>("Recording");
        recording.setCellValueFactory(cdf -> cdf.getValue().getRecordingProperty());
        recording.setCellFactory(tc -> new RecordingCell());
        recording.setPrefWidth(100);
        recording.setEditable(false);
        recording.setId("recording");
        recording.setStyle(STYLE_ALIGN_CENTER);
        TableColumn<JavaFxModel, Boolean> paused = new TableColumn<>("Paused");
        paused.setCellValueFactory(cdf -> cdf.getValue().getPausedProperty());
        paused.setCellFactory(CheckBoxTableCell.forTableColumn(paused));
        paused.setPrefWidth(100);
        paused.setEditable(true);
        paused.setId("paused");
        TableColumn<JavaFxModel, Number> priority = new TableColumn<>("Priority");
        priority.setCellValueFactory(param -> param.getValue().getPriorityProperty());
        priority.setCellFactory(new PriorityCellFactory());
        priority.setPrefWidth(90);
        priority.setEditable(true);
        priority.setOnEditStart(e -> cellEditing = true);
        priority.setOnEditCommit(this::onUpdatePriority);
        priority.setOnEditCancel(e -> cellEditing = false);
        priority.setId("priority");
        TableColumn<JavaFxModel, Instant> lastSeen = new TableColumn<>("last seen");
        lastSeen.setCellValueFactory(cdf -> cdf.getValue().lastSeenProperty());
        lastSeen.setCellFactory(new DateTimeCellFactory<>());
        lastSeen.setPrefWidth(150);
        lastSeen.setEditable(false);
        lastSeen.setId("lastSeen");
        TableColumn<JavaFxModel, Instant> lastRecorded = new TableColumn<>("last recorded");
        lastRecorded.setCellValueFactory(cdf -> cdf.getValue().lastRecordedProperty());
        lastRecorded.setCellFactory(new DateTimeCellFactory<>());
        lastRecorded.setPrefWidth(150);
        lastRecorded.setEditable(false);
        lastRecorded.setId("lastRecorded");
        TableColumn<JavaFxModel, String> notes = new TableColumn<>("Notes");
        notes.setCellValueFactory(cdf -> {
            JavaFxModel m = cdf.getValue();
            return new StringPropertyBase() {
                @Override
                public String getName() {
                    return "Model Notes";
                }

                @Override
                public Object getBean() {
                    return null;
                }

                @Override
                public String get() {
                    String modelNotes = Config.getInstance().getModelNotes(m);
                    return modelNotes;
                }
            };
        });
        notes.setPrefWidth(400);
        notes.setEditable(false);
        notes.setId("notes");
        table.getColumns().addAll(preview, name, url, online, recording, paused, priority, lastSeen, lastRecorded, notes);
        table.setItems(observableModels);
        table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
            popup = createContextMenu();
            if (popup != null) {
                popup.show(table, event.getScreenX(), event.getScreenY());
            }
            event.consume();
        });
        table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
            if (popup != null) {
                popup.hide();
            }
        });
        table.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
            List<JavaFxModel> selectedModels = table.getSelectionModel().getSelectedItems();
            if (event.getCode() == KeyCode.DELETE) {
                stopAction(selectedModels);
            } else {
                jumpToNextModel(event.getCode());
            }
        });

        scrollPane.setContent(table);

        HBox addModelBox = new HBox(5);
        modelLabel.setPadding(new Insets(5, 0, 0, 0));
        ObservableList<String> suggestions = FXCollections.observableArrayList();
        sites.forEach(site -> suggestions.add(site.getClass().getSimpleName()));
        model = new AutoFillTextField(new ObservableListSuggester(suggestions));
        model.minWidth(150);
        model.prefWidth(600);
        HBox.setHgrow(model, Priority.ALWAYS);
        model.setPromptText("e.g. MyFreeCams:ModelName or an URL like https://chaturbate.com/modelname/");
        model.onActionHandler(this::addModel);
        model.setTooltip(new Tooltip("To add a model enter SiteName:ModelName\n" +
                "press ENTER to confirm a suggested site name"));
        BorderPane.setMargin(addModelBox, new Insets(5));
        addModelButton.setOnAction(this::addModel);
        addModelButton.setPadding(new Insets(5));
        addModelBox.getChildren().addAll(modelLabel, model, addModelButton, pauseAll, resumeAll, toggleRecording, checkModelAccountExistance);
        HBox.setMargin(pauseAll, new Insets(0, 0, 0, 20));
        pauseAll.setOnAction(this::pauseAll);
        resumeAll.setOnAction(this::resumeAll);
        pauseAll.setPadding(new Insets(5));
        resumeAll.setPadding(new Insets(5));
        toggleRecording.setPadding(new Insets(5));
        toggleRecording.setOnAction(this::toggleRecording);
        HBox.setMargin(toggleRecording, new Insets(0, 0, 0, 20));
        checkModelAccountExistance.setPadding(new Insets(5));
        checkModelAccountExistance.setTooltip(new Tooltip("Go over all model URLs and check, if the account still exists"));
        HBox.setMargin(checkModelAccountExistance, new Insets(0, 0, 0, 20));
        checkModelAccountExistance.setOnAction(evt -> new CheckModelAccountAction(checkModelAccountExistance, recorder)
                .execute(Predicate.not(Model::isMarkedForLaterRecording)));

        HBox filterContainer = new HBox();
        filterContainer.setSpacing(0);
        filterContainer.setPadding(new Insets(0));
        filterContainer.setAlignment(Pos.CENTER_RIGHT);
        filterContainer.minWidth(100);
        filterContainer.prefWidth(150);
        HBox.setHgrow(filterContainer, Priority.ALWAYS);
        filter = new SearchBox(false);
        filter.minWidth(100);
        filter.prefWidth(150);
        filter.setPromptText("Filter");
        filter.textProperty().addListener( (observableValue, oldValue, newValue) -> {
            String q = filter.getText();
            lock.lock();
            try {
                filter(q);
            } finally {
                lock.unlock();
            }
        });
        filter.getStyleClass().remove("search-box-icon");
        filterContainer.getChildren().add(filter);
        addModelBox.getChildren().add(filterContainer);

        BorderPane root = new BorderPane();
        root.setPadding(new Insets(5));
        root.setTop(addModelBox);
        root.setCenter(scrollPane);
        setContent(root);

        restoreState();
    }

    private void jumpToNextModel(KeyCode code) {
        if (!table.getItems().isEmpty() && (code.isLetterKey() || code.isDigitKey())) {
            // determine where to start looking for the next model
            int startAt = 0;
            if (table.getSelectionModel().getSelectedIndex() >= 0) {
                startAt = table.getSelectionModel().getSelectedIndex() + 1;
                if (startAt >= table.getItems().size()) {
                    startAt = 0;
                }
            }

            String c = code.getChar().toLowerCase();
            int i = startAt;
            do {
                JavaFxModel current = table.getItems().get(i);
                if (current.getName().toLowerCase().replaceAll("[^0-9a-z]", "").startsWith(c)) {
                    table.getSelectionModel().clearAndSelect(i);
                    table.scrollTo(i);
                    break;
                }

                i++;
                if (i >= table.getItems().size()) {
                    i = 0;
                }
            } while (i != startAt);
        }
    }

    private void onUpdatePriority(CellEditEvent<JavaFxModel, Number> evt) {
        try {
            int prio = Optional.ofNullable(evt.getNewValue()).map(Number::intValue).orElse(-1);
            JavaFxModel m = evt.getRowValue();
            updatePriority(m, prio);
            table.refresh();
        } finally {
            cellEditing = false;
        }
    }

    private void updatePriority(JavaFxModel model, int priority) {
        try {
            if (priority < 0 || priority > 100) {
                String msg = "Priority has to be between 0 and 100";
                Dialogs.showError(table.getScene(), "Invalid value", msg, null);
            } else {
                model.setPriority(priority);
                recorder.priorityChanged(model.getDelegate());
            }
        } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) {
            Dialogs.showError(table.getScene(), "Couldn't update priority", e.getMessage(), e);
        }
    }

    private void addModel(ActionEvent e) {
        String input = model.getText();
        if (StringUtil.isBlank(input)) {
            return;
        }

        if (input.startsWith("http")) {
            addModelByUrl(input);
        } else {
            addModelByName(input);
        }
    }

    private void addModelByUrl(String url) {
        for (Site site : sites) {
            Model newModel = site.createModelFromUrl(url);
            if (newModel != null) {
                try {
                    recorder.addModel(newModel);
                } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) {
                    Dialogs.showError(getTabPane().getScene(), "Couldn't add model", "The model " + newModel.getName() + " could not be added: ", e1);
                }
                return;
            }
        }

        Dialogs.showError(getTabPane().getScene(), "Unknown URL format",
                "The URL you entered has an unknown format or the function does not support this site, yet", null);
    }

    private void addModelByName(String siteModelCombo) {
        String[] parts = siteModelCombo.trim().split(":");
        if (parts.length != 2) {
            Dialogs.showError(getTabPane().getScene(), "Wrong input format", "Use something like \"MyFreeCams:ModelName\"", null);
            return;
        }

        String siteName = parts[0];
        String modelName = parts[1];
        for (Site site : sites) {
            if (Objects.equals(siteName.toLowerCase(), site.getClass().getSimpleName().toLowerCase())) {
                try {
                    Model m = site.createModel(modelName);
                    recorder.addModel(m);
                } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) {
                    Dialogs.showError(getTabPane().getScene(), "Couldn't add model", "The model " + modelName + " could not be added:", e1);
                }
                return;
            }
        }

        Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getTabPane().getScene());
        alert.setTitle("Unknown site");
        alert.setHeaderText("Couldn't add model");
        alert.setContentText("The site you entered is unknown");
        alert.showAndWait();
    }

    private void pauseAll(ActionEvent evt) {
        boolean yes = Dialogs.showConfirmDialog("Pause all models", "", "Pause the recording of all models?", getTabPane().getScene());
        if (yes) {
            List<Model> models = recorder.getModels().stream().filter(Predicate.not(Model::isMarkedForLaterRecording)).collect(Collectors.toList());
            new PauseAction(getTabPane(), models, recorder).execute();
        }
    }

    private void resumeAll(ActionEvent evt) {
        boolean yes = Dialogs.showConfirmDialog("Resume all models", "", "Resume the recording of all models?", getTabPane().getScene());
        if (yes) {
            List<Model> models = recorder.getModels().stream().filter(Predicate.not(Model::isMarkedForLaterRecording)).collect(Collectors.toList());
            new ResumeAction(getTabPane(), models, recorder).execute();
        }
    }

    private void toggleRecording(ActionEvent evt) {
        new ToggleRecordingAction(toggleRecording, recorder).execute();
    }

    void initializeUpdateService() {
        updateService = createUpdateService();
        updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2)));
        updateService.setOnSucceeded(this::onUpdateSuccess);
        updateService.setOnFailed(event -> LOG.info("Couldn't get list of models from recorder", event.getSource().getException()));
    }

    private void onUpdateSuccess(WorkerStateEvent event) {
        if (cellEditing) {
            return;
        }

        List<JavaFxModel> updatedModels = updateService.getValue();
        if (updatedModels == null) {
            return;
        }

        lock.lock();
        try {
            addOrUpdateModels(updatedModels);

            // remove old ones, which are not in the list of updated models
            for (Iterator<JavaFxModel> iterator = observableModels.iterator(); iterator.hasNext();) {
                Model oldModel = iterator.next();
                if (!updatedModels.contains(oldModel)) {
                    iterator.remove();
                }
            }
        } finally {
            lock.unlock();
        }

        filteredModels.clear();
        filter(filter.getText());
        table.sort();
    }

    private void addOrUpdateModels(List<JavaFxModel> updatedModels) {
        for (JavaFxModel updatedModel : updatedModels) {
            int index = observableModels.indexOf(updatedModel);
            if (index == -1) {
                observableModels.add(updatedModel);
                updatedModel.getPausedProperty().addListener(createPauseListener(updatedModel));
            } else {
                // make sure to update the JavaFX online property, so that the table cell is updated
                JavaFxModel oldModel = observableModels.get(index);
                oldModel.setSuspended(updatedModel.isSuspended());
                oldModel.getOnlineProperty().set(updatedModel.getOnlineProperty().get());
                oldModel.getRecordingProperty().set(updatedModel.getRecordingProperty().get());
                oldModel.lastRecordedProperty().set(updatedModel.lastRecordedProperty().get());
                oldModel.lastSeenProperty().set(updatedModel.lastSeenProperty().get());
            }
        }
    }

    private ChangeListener<Boolean> createPauseListener(JavaFxModel updatedModel) {
        return (obs, oldV, newV) -> {
            if (newV.booleanValue()) {
                if(!recorder.isSuspended(updatedModel)) {
                    pauseRecording(Collections.singletonList(updatedModel));
                }
            } else {
                if(recorder.isSuspended(updatedModel)) {
                    resumeRecording(Collections.singletonList(updatedModel));
                }
            }
        };
    }

    private void filter(String filter) {
        lock.lock();
        try {
            if (StringUtil.isBlank(filter)) {
                observableModels.addAll(filteredModels);
                filteredModels.clear();
                return;
            }

            String[] tokens = filter.split(" ");
            observableModels.addAll(filteredModels);
            filteredModels.clear();
            for (int i = 0; i < table.getItems().size(); i++) {
                StringBuilder sb = new StringBuilder();
                for (TableColumn<JavaFxModel, ?> tc : table.getColumns()) {
                    Object cellData = tc.getCellData(i);
                    if (cellData != null) {
                        String content = cellData.toString();
                        sb.append(content).append(' ');
                    }
                }
                String searchText = sb.toString();

                boolean tokensMissing = false;
                for (String token : tokens) {
                    if (!searchText.toLowerCase().contains(token.toLowerCase())) {
                        tokensMissing = true;
                        break;
                    }
                }
                if (tokensMissing) {
                    JavaFxModel filteredModel = table.getItems().get(i);
                    filteredModels.add(filteredModel);
                }
            }
            observableModels.removeAll(filteredModels);
        } finally {
            lock.unlock();
        }
    }

    private ScheduledService<List<JavaFxModel>> createUpdateService() {
        ScheduledService<List<JavaFxModel>> modelUpdateService = new ScheduledService<List<JavaFxModel>>() {
            @Override
            protected Task<List<JavaFxModel>> createTask() {
                return new Task<List<JavaFxModel>>() {
                    @Override
                    public List<JavaFxModel> call() throws InvalidKeyException, NoSuchAlgorithmException, IOException {
                        LOG.trace("Updating recorded models");
                        List<Recording> recordings = recorder.getRecordings();
                        List<Model> onlineModels = recorder.getOnlineModels();
                        return recorder.getModels()
                                .stream()
                                .filter(Predicate.not(Model::isMarkedForLaterRecording))
                                .map(JavaFxModel::new)
                                .peek(fxm -> { // NOSONAR
                                    for (Recording recording : recordings) {
                                        if(recording.getStatus() == RECORDING && Objects.equals(recording.getModel(), fxm)){
                                            String recordingValue = HEAVY_CHECK_MARK;
                                            if(!Objects.equals(recording.getModel().getRecordUntil(), Instant.ofEpochMilli(Model.RECORD_INDEFINITELY))) {
                                                recordingValue += ' ' + CLOCK;
                                            }
                                            fxm.getRecordingProperty().set(recordingValue);
                                            break;
                                        }
                                    }

                                    for (Model onlineModel : onlineModels) {
                                        if(Objects.equals(onlineModel, fxm)) {
                                            fxm.getOnlineProperty().set(HEAVY_CHECK_MARK);
                                            break;
                                        }
                                    }
                                })
                                .collect(Collectors.toList());
                    }
                };
            }
        };
        ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
            Thread t = new Thread(r);
            t.setDaemon(true);
            t.setName("RecordedModelsTab UpdateService");
            return t;
        });
        modelUpdateService.setExecutor(executor);
        return modelUpdateService;
    }

    @Override
    public void selected() {
        if (updateService != null) {
            updateService.reset();
            updateService.restart();
        }
    }

    @Override
    public void deselected() {
        if (updateService != null) {
            updateService.cancel();
        }
    }

    private ContextMenu createContextMenu() {
        ObservableList<JavaFxModel> selectedModels = table.getSelectionModel().getSelectedItems();
        if (selectedModels.isEmpty()) {
            return null;
        }
        MenuItem stop = new MenuItem("Remove Model");
        stop.setOnAction(e -> stopAction(selectedModels));
        MenuItem recordLater = new MenuItem("Record Later");
        recordLater.setOnAction(e -> recordLater(selectedModels));

        MenuItem copyUrl = new MenuItem("Copy URL");
        copyUrl.setOnAction(e -> {
            Model selected = selectedModels.get(0);
            final Clipboard clipboard = Clipboard.getSystemClipboard();
            final ClipboardContent content = new ClipboardContent();
            content.putString(selected.getUrl());
            clipboard.setContent(content);
        });

        MenuItem pauseRecording = new MenuItem("Pause Recording");
        pauseRecording.setOnAction(e -> pauseRecording(selectedModels));
        MenuItem resumeRecording = new MenuItem("Resume Recording");
        resumeRecording.setOnAction(e -> resumeRecording(selectedModels));
        MenuItem stopRecordingAt = new MenuItem("Stop Recording at Date");
        stopRecordingAt.setOnAction(e -> setStopDate(selectedModels.get(0)));
        MenuItem removeTimeLimit = new MenuItem("Remove Time Limit");
        removeTimeLimit.setOnAction(e -> removeTimeLimit(selectedModels.get(0)));
        MenuItem openInBrowser = new MenuItem("Open in Browser");
        openInBrowser.setOnAction(e -> DesktopIntegration.open(selectedModels.get(0).getUrl()));
        MenuItem openInPlayer = new MenuItem("Open in Player");
        openInPlayer.setOnAction(e -> openInPlayer(selectedModels.get(0)));
        MenuItem switchStreamSource = new MenuItem("Switch resolution");
        switchStreamSource.setOnAction(e -> switchStreamSource(selectedModels.get(0)));
        MenuItem follow = new MenuItem("Follow");
        follow.setOnAction(e -> follow(selectedModels));
        follow.setDisable(!selectedModels.stream().allMatch(m -> m.getSite().supportsFollow() && m.getSite().credentialsAvailable()));
        MenuItem ignore = new MenuItem("Ignore");
        ignore.setOnAction(e -> ignore(selectedModels));
        MenuItem notes = new MenuItem("Notes");
        notes.setOnAction(e -> notes(selectedModels));
        MenuItem openRecDir = new MenuItem("Open recording directory");
        openRecDir.setOnAction(e -> new OpenRecordingsDir(table, selectedModels.get(0)).execute());

        ContextMenu menu = new CustomMouseBehaviorContextMenu(stop, recordLater);
        if (selectedModels.size() == 1) {
            menu.getItems().add(selectedModels.get(0).isSuspended() ? resumeRecording : pauseRecording);
            menu.getItems().add(stopRecordingAt);
            if (selectedModels.get(0).isRecordingTimeLimited()) {
                menu.getItems().add(removeTimeLimit);
            }
        } else {
            menu.getItems().addAll(resumeRecording, pauseRecording);
        }
        menu.getItems().addAll(copyUrl, openInPlayer, openInBrowser, openRecDir, switchStreamSource, follow, notes, ignore);

        if (selectedModels.size() > 1) {
            copyUrl.setDisable(true);
            openInPlayer.setDisable(true);
            openInBrowser.setDisable(true);
            switchStreamSource.setDisable(true);
            notes.setDisable(true);
        }

        return menu;
    }

    private void setStopDate(JavaFxModel model) {
        new SetStopDateAction(table, model.getDelegate(), recorder) //
        .execute() //
        .whenComplete((result, exception) -> table.refresh());
    }

    private void removeTimeLimit(JavaFxModel selectedModel) {
        new RemoveTimeLimitAction(table, selectedModel.getDelegate(), recorder) //
        .execute() //
        .whenComplete((result, exception) -> table.refresh());
    }

    private void ignore(ObservableList<JavaFxModel> selectedModels) {
        new IgnoreModelsAction(table, selectedModels, recorder, true).execute();
    }

    private void follow(ObservableList<JavaFxModel> selectedModels) {
        new FollowAction(getTabPane(), new ArrayList<>(selectedModels)).execute();
    }

    private void notes(ObservableList<JavaFxModel> selectedModels) {
        new EditNotesAction(getTabPane(), selectedModels.get(0), table).execute();
    }

    private void openInPlayer(JavaFxModel selectedModel) {
        new PlayAction(getTabPane(), selectedModel).execute();
    }

    private void switchStreamSource(JavaFxModel fxModel) {
        String couldntSwitchHeaderText = "Couldn't switch stream resolution";

        try {
            if (!fxModel.isOnline(true)) {
                Dialogs.showError(getTabPane().getScene(), couldntSwitchHeaderText, "The resolution can only be changed, when the model is online", null);
                return;
            }
        } catch (InterruptedException e1) {
            Thread.currentThread().interrupt();
            Dialogs.showError(getTabPane().getScene(), couldntSwitchHeaderText, "An error occured while checking, if the model is online", null);
            return;
        } catch (IOException | ExecutionException e1) {
            Dialogs.showError(getTabPane().getScene(), couldntSwitchHeaderText, "An error occured while checking, if the model is online", null);
            return;
        }

        Function<Model, Void> onSuccess = m -> {
            try {
                recorder.switchStreamSource(m);
            } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException | IOException e) {
                LOG.error(couldntSwitchHeaderText, e);
                showStreamSwitchErrorDialog(e);
            }
            return null;
        };
        Function<Throwable, Void> onFail = t -> {
            LOG.error(couldntSwitchHeaderText, t);
            showStreamSwitchErrorDialog(t);
            return null;
        };
        StreamSourceSelectionDialog.show(getTabPane().getScene(), fxModel.getDelegate(), onSuccess, onFail);
    }

    private void showStreamSwitchErrorDialog(Throwable throwable) {
        showErrorDialog(throwable, "Couldn't switch stream resolution", "Error while switching stream resolution");
    }

    private void showErrorDialog(Throwable throwable, String header, String msg) {
        Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getTabPane().getScene());
        alert.setTitle("Error");
        alert.setHeaderText(header);
        alert.setContentText(msg + ": " + throwable.getLocalizedMessage());
        alert.showAndWait();
    }

    private boolean stopAction(List<JavaFxModel> selectedModels) {
        boolean confirmed = true;
        if (Config.getInstance().getSettings().confirmationForDangerousActions) {
            int n = selectedModels.size();
            String plural = n > 1 ? "s" : "";
            String header = "This will stop the recording of " + n + " model" + plural;
            confirmed = Dialogs.showConfirmDialog("Stop Recording", "Continue?", header, table.getScene());
        }
        if (confirmed) {
            List<Model> models = selectedModels.stream().map(JavaFxModel::getDelegate).collect(Collectors.toList());
            new StopRecordingAction(getTabPane(), models, recorder).execute(m -> Platform.runLater(() -> {
                table.getSelectionModel().clearSelection(table.getItems().indexOf(m));
                table.getItems().remove(m);
            }));
        }
        return confirmed;
    }

    private void recordLater(List<JavaFxModel> selectedModels) {
        boolean confirmed = stopAction(selectedModels);
        if (confirmed) {
            List<Model> models = selectedModels.stream()
                    .map(JavaFxModel::getDelegate)
                    .map(m -> {
                        m.setMarkedForLaterRecording(true);
                        return m;
                    })
                    .collect(Collectors.toList());
            new StartRecordingAction(table, models, recorder).execute();
        }
    }

    private void pauseRecording(List<JavaFxModel> selectedModels) {
        List<Model> models = selectedModels.stream().map(JavaFxModel::getDelegate).collect(Collectors.toList());
        new PauseAction(getTabPane(), models, recorder).execute();
    }

    private void resumeRecording(List<JavaFxModel> selectedModels) {
        List<Model> models = selectedModels.stream().map(JavaFxModel::getDelegate).collect(Collectors.toList());
        new ResumeAction(getTabPane(), models, recorder).execute();
    }

    public void saveState() {
        if (!table.getSortOrder().isEmpty()) {
            TableColumn<JavaFxModel, ?> col = table.getSortOrder().get(0);
            Config.getInstance().getSettings().recordedModelsSortColumn = col.getText();
            Config.getInstance().getSettings().recordedModelsSortType = col.getSortType().toString();
        }
        int columns = table.getColumns().size();
        double[] columnWidths = new double[columns];
        String[] columnIds = new String[columns];
        for (int i = 0; i < columnWidths.length; i++) {
            columnWidths[i] = table.getColumns().get(i).getWidth();
            columnIds[i] = table.getColumns().get(i).getId();
        }
        Config.getInstance().getSettings().recordedModelsColumnWidths = columnWidths;
        Config.getInstance().getSettings().recordedModelsColumnIds = columnIds;
    }

    private void restoreState() {
        restoreColumnOrder();
        restoreColumnWidths();
        restoreSorting();
    }

    private void restoreSorting() {
        String sortCol = Config.getInstance().getSettings().recordedModelsSortColumn;
        if (StringUtil.isNotBlank(sortCol)) {
            for (TableColumn<JavaFxModel, ?> col : table.getColumns()) {
                if (Objects.equals(sortCol, col.getText())) {
                    col.setSortType(SortType.valueOf(Config.getInstance().getSettings().recordedModelsSortType));
                    table.getSortOrder().clear();
                    table.getSortOrder().add(col);
                    break;
                }
            }
        }
    }

    private void restoreColumnOrder() {
        String[] columnIds = Config.getInstance().getSettings().recordedModelsColumnIds;
        ObservableList<TableColumn<JavaFxModel,?>> columns = table.getColumns();
        for (int i = 0; i < columnIds.length; i++) {
            for (int j = 0; j < table.getColumns().size(); j++) {
                if(Objects.equals(columnIds[i], columns.get(j).getId())) {
                    TableColumn<JavaFxModel, ?> col = columns.get(j);
                    columns.remove(j); // NOSONAR
                    columns.add(i, col);
                }
            }
        }
    }

    private void restoreColumnWidths() {
        double[] columnWidths = Config.getInstance().getSettings().recordedModelsColumnWidths;
        if (columnWidths != null && columnWidths.length == table.getColumns().size()) {
            for (int i = 0; i < columnWidths.length; i++) {
                table.getColumns().get(i).setPrefWidth(columnWidths[i]);
            }
        }
    }

    private class ClickableCellFactory<S, T> implements Callback<TableColumn<S, T>, TableCell<S, T>> {
        @Override
        public TableCell<S, T> call(TableColumn<S, T> param) {
            TableCell<S, T> cell = new TableCell<>() {
                @Override
                protected void updateItem(Object item, boolean empty) {
                    setText(empty ? "" : Objects.toString(item));
                }
            };

            cell.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> {
                if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
                    JavaFxModel selectedModel = table.getSelectionModel().getSelectedItem();
                    if(selectedModel != null) {
                        new PlayAction(table, selectedModel).execute();
                    }
                }
            });
            return cell;
        }
    }

    private class PriorityCellFactory implements Callback<TableColumn<JavaFxModel, Number>, TableCell<JavaFxModel, Number>> {
        @Override
        public TableCell<JavaFxModel, Number> call(TableColumn<JavaFxModel, Number> param) {
            Callback<TableColumn<JavaFxModel, Number>, TableCell<JavaFxModel, Number>> callback = TextFieldTableCell
                    .<JavaFxModel, Number> forTableColumn((StringConverter<Number>) new NumberStringConverter());
            TableCell<JavaFxModel, Number> tableCell = callback.call(param);

            tableCell.setOnScroll(event -> {
                if(event.isControlDown()) {
                    event.consume();
                    JavaFxModel m = tableCell.getTableRow().getItem();
                    int prio = m.getPriority();
                    if(event.getDeltaY() < 0) {
                        prio--;
                    } else {
                        prio++;
                    }
                    prio = Math.min(Math.max(0, prio), 100);
                    m.setPriority(prio);
                    updatePriority(m, prio);
                }
            });

            return tableCell;
        }
    }

    private class RecordingCell extends TableCell<JavaFxModel, String> {
        @Override
        protected void updateItem(String value, boolean empty) {
            super.updateItem(value, empty);
            if (value == null) {
                setTooltip(null);
                setText(null);
            } else {
                Model m = getTableView().getItems().get(getTableRow().getIndex());
                if (m.isRecordingTimeLimited()) {
                    Tooltip tooltip = new Tooltip();
                    DateTimeFormatter dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
                    ZonedDateTime zonedDateTime = m.getRecordUntil().atZone(ZoneId.systemDefault());
                    tooltip.setText("Recording until " + dtf.format(zonedDateTime));
                    setTooltip(tooltip);
                }
                setText(value);
            }
        }
    }
}
