package org.cryptomator.ui.mainwindow;

import org.apache.commons.lang3.SystemUtils;
import org.cryptomator.common.recovery.RecoveryActionType;
import org.cryptomator.common.recovery.VaultPreparator;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.common.vaults.NotAVaultDirectoryException;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.common.vaults.VaultComponent;
import org.cryptomator.common.vaults.VaultListManager;
import org.cryptomator.cryptofs.CryptoFileSystemProvider;
import org.cryptomator.cryptofs.DirStructure;
import org.cryptomator.cryptofs.common.Constants;
import org.cryptomator.integrations.mount.MountService;
import org.cryptomator.ui.addvaultwizard.AddVaultWizardComponent;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.common.VaultService;
import org.cryptomator.ui.dialogs.Dialogs;
import org.cryptomator.ui.fxapp.FxApplicationWindows;
import org.cryptomator.ui.fxapp.FxFSEventList;
import org.cryptomator.ui.preferences.SelectedPreferencesTab;
import org.cryptomator.ui.recoverykey.RecoveryKeyComponent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Inject;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.geometry.Side;
import javafx.scene.control.Button;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.ListView;
import javafx.scene.input.ContextMenuEvent;
import javafx.scene.input.DragEvent;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.StackPane;
import javafx.stage.DirectoryChooser;
import javafx.stage.Stage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.stream.Collectors;

import static org.cryptomator.common.Constants.CRYPTOMATOR_FILENAME_EXT;
import static org.cryptomator.common.Constants.MASTERKEY_FILENAME;
import static org.cryptomator.common.Constants.VAULTCONFIG_FILENAME;
import static org.cryptomator.common.vaults.VaultState.Value.ALL_MISSING;
import static org.cryptomator.common.vaults.VaultState.Value.ERROR;
import static org.cryptomator.common.vaults.VaultState.Value.LOCKED;
import static org.cryptomator.common.vaults.VaultState.Value.MISSING;
import static org.cryptomator.common.vaults.VaultState.Value.NEEDS_MIGRATION;
import static org.cryptomator.common.vaults.VaultState.Value.VAULT_CONFIG_MISSING;

@MainWindowScoped
public class VaultListController implements FxController {

	private static final Logger LOG = LoggerFactory.getLogger(VaultListController.class);

	private final Stage mainWindow;
	private final ObservableList<Vault> vaults;
	private final VaultService vaultService;
	private final ObjectProperty<Vault> selectedVault;
	private final VaultListCellFactory cellFactory;
	private final AddVaultWizardComponent.Builder addVaultWizard;
	private final BooleanBinding emptyVaultList;
	private final BooleanProperty unreadEvents;
	private final VaultListManager vaultListManager;
	private final BooleanProperty draggingVaultOver = new SimpleBooleanProperty();
	private final ResourceBundle resourceBundle;
	private final FxApplicationWindows appWindows;
	private final ObservableValue<Double> cellSize;
	private final Dialogs dialogs;

	private final VaultComponent.Factory vaultComponentFactory;
	private final RecoveryKeyComponent.Factory recoveryKeyWindow;
	private final List<MountService> mountServices;
	private final ExecutorService executor;

	public ListView<Vault> vaultList;
	public StackPane root;
	@FXML
	private Button addVaultButton;
	@FXML
	private ContextMenu addVaultContextMenu;

	@Inject
	VaultListController(@MainWindow Stage mainWindow, //
						ObservableList<Vault> vaults, //
						ObjectProperty<Vault> selectedVault, //
						VaultListCellFactory cellFactory, //
						VaultService vaultService, //
						AddVaultWizardComponent.Builder addVaultWizard, //
						VaultListManager vaultListManager, //
						ResourceBundle resourceBundle, //
						FxApplicationWindows appWindows, //
						Settings settings, //
						Dialogs dialogs, //
						RecoveryKeyComponent.Factory recoveryKeyWindow, //
						VaultComponent.Factory vaultComponentFactory, //
						List<MountService> mountServices, //
						FxFSEventList fxFSEventList, //
						ExecutorService executor) {
		this.mainWindow = mainWindow;
		this.vaults = vaults;
		this.selectedVault = selectedVault;
		this.cellFactory = cellFactory;
		this.vaultService = vaultService;
		this.addVaultWizard = addVaultWizard;
		this.vaultListManager = vaultListManager;
		this.resourceBundle = resourceBundle;
		this.appWindows = appWindows;
		this.dialogs = dialogs;
		this.recoveryKeyWindow = recoveryKeyWindow;
		this.vaultComponentFactory = vaultComponentFactory;
		this.mountServices = mountServices;
		this.executor = executor;

		this.emptyVaultList = Bindings.isEmpty(vaults);
		this.unreadEvents = fxFSEventList.unreadEventsProperty();

		selectedVault.addListener(this::selectedVaultDidChange);
		cellSize = settings.compactMode.map(compact -> compact ? 30.0 : 60.0);
	}

	public void initialize() {
		vaultList.setItems(vaults);
		vaultList.setCellFactory(cellFactory);

		vaultList.prefHeightProperty().bind( //
				vaultList.fixedCellSizeProperty().multiply(Bindings.size(vaultList.getItems())) //
		);

		selectedVault.bind(vaultList.getSelectionModel().selectedItemProperty());
		vaults.addListener((ListChangeListener.Change<? extends Vault> c) -> {
			while (c.next()) {
				if (c.wasAdded()) {
					Vault anyAddedVault = c.getAddedSubList().get(0);
					vaultList.getSelectionModel().select(anyAddedVault);
				}
			}
		});
		vaultList.addEventFilter(MouseEvent.MOUSE_RELEASED, this::deselect);

		//unlock vault on double click
		vaultList.addEventFilter(MouseEvent.MOUSE_CLICKED, click -> {
			if (click.getClickCount() >= 2) {
				Optional.ofNullable(selectedVault.get()) //
						.filter(Vault::isLocked) //
						.ifPresent(vault -> appWindows.startUnlockWorkflow(vault, mainWindow));
				Optional.ofNullable(selectedVault.get()) //
						.filter(Vault::isUnlocked) //
						.ifPresent(vaultService::reveal);
			}
		});

		//don't show context menu when no vault selected
		vaultList.addEventFilter(ContextMenuEvent.CONTEXT_MENU_REQUESTED, request -> {
			if (selectedVault.get() == null) {
				request.consume();
			}
		});

		//show removeVaultDialog on certain key press
		vaultList.addEventFilter(KeyEvent.KEY_PRESSED, keyEvent -> {
			if (keyEvent.getCode() == KeyCode.DELETE) {
				pressedShortcutToRemoveVault();
				keyEvent.consume();
			}
		});
		if (SystemUtils.IS_OS_MAC) {
			vaultList.addEventFilter(KeyEvent.KEY_PRESSED, keyEvent -> {
				if (keyEvent.getCode() == KeyCode.BACK_SPACE) {
					pressedShortcutToRemoveVault();
					keyEvent.consume();
				}
			});
		}

		//register vault selection shortcut to the main window
		mainWindow.addEventFilter(KeyEvent.KEY_RELEASED, keyEvent -> {
			if (keyEvent.isShortcutDown() && keyEvent.getCode().isDigitKey()) {
				vaultList.getSelectionModel().select(Integer.parseInt(keyEvent.getText()) - 1);
				keyEvent.consume();
			}
		});

		root.setOnDragEntered(this::handleDragEvent);
		root.setOnDragOver(this::handleDragEvent);
		root.setOnDragDropped(this::handleDragEvent);
		root.setOnDragExited(this::handleDragEvent);
	}

	@FXML
	private void toggleMenu() {
		if (addVaultContextMenu.isShowing()) {
			addVaultContextMenu.hide();
		} else {
			addVaultContextMenu.show(addVaultButton, Side.BOTTOM, 0.0, 0.0);
		}
	}

	private void deselect(MouseEvent released) {
		if (released.getY() > (vaultList.getItems().size() * vaultList.fixedCellSizeProperty().get())) {
			vaultList.getSelectionModel().clearSelection();
			released.consume();
		}
	}

	private void selectedVaultDidChange(@SuppressWarnings("unused") ObservableValue<? extends Vault> observableValue, @SuppressWarnings("unused") Vault oldValue, Vault newValue) {
		if (newValue == null) {
			return;
		}
		VaultListManager.redetermineVaultState(newValue);
	}

	private Optional<Path> chooseValidVaultDirectory() {
		DirectoryChooser directoryChooser = new DirectoryChooser();
		File selectedDirectory;

		do {
			selectedDirectory = directoryChooser.showDialog(mainWindow);
			if (selectedDirectory == null) {
				return Optional.empty();
			}

			Path selectedPath = selectedDirectory.toPath();
			if (!Files.isDirectory(selectedPath.resolve(Constants.DATA_DIR_NAME))) {
				dialogs.prepareNoDDirectorySelectedDialog(mainWindow).build().showAndWait();
				selectedDirectory = null;
			}
		} while (selectedDirectory == null);

		return Optional.of(selectedDirectory.toPath());
	}

	@FXML
	public void didClickAddNewVault() {
		addVaultWizard.build().showAddNewVaultWizard(resourceBundle);
	}

	@FXML
	public void didClickAddExistingVault() {
		addVaultWizard.build().showAddExistingVaultWizard(resourceBundle);
	}

	@FXML
	public void didClickRecoverExistingVault() {
		Optional<Path> selectedDirectory = chooseValidVaultDirectory();
		if (selectedDirectory.isEmpty()) {
			return;
		}

		Path path = selectedDirectory.get();
		Optional<Vault> matchingVaultListEntry = vaultListManager.get(path);
		if (matchingVaultListEntry.isPresent()) {
			dialogs.prepareRecoveryVaultAlreadyExists(mainWindow, matchingVaultListEntry.get().getDisplayName()) //
					.setOkAction(Stage::close) //
					.build().showAndWait();
			return;
		}

		Vault preparedVault = VaultPreparator.prepareVault(path, vaultComponentFactory, mountServices, resourceBundle);
		VaultListManager.redetermineVaultState(preparedVault);

		switch (preparedVault.getState()) {
			case VAULT_CONFIG_MISSING -> recoveryKeyWindow.create(preparedVault, mainWindow, new SimpleObjectProperty<>(RecoveryActionType.RESTORE_VAULT_CONFIG)).showOnboardingDialogWindow();
			case ALL_MISSING -> recoveryKeyWindow.create(preparedVault, mainWindow, new SimpleObjectProperty<>(RecoveryActionType.RESTORE_ALL)).showOnboardingDialogWindow();
			case LOCKED, NEEDS_MIGRATION -> {
				vaultListManager.addVault(preparedVault);
				dialogs.prepareRecoveryVaultAdded(mainWindow, preparedVault.getDisplayName()).setOkAction(Stage::close).build().showAndWait();
			}
			default -> LOG.warn("Unhandled vault state during recovery: {}", preparedVault.getState());
		}

	}

	private void pressedShortcutToRemoveVault() {
		final var vault = selectedVault.get();
		if (vault != null && EnumSet.of(LOCKED, MISSING, ERROR, NEEDS_MIGRATION, ALL_MISSING, VAULT_CONFIG_MISSING).contains(vault.getState())) {
			dialogs.prepareRemoveVaultDialog(mainWindow, vault, vaults).build().showAndWait();
		}
	}

	private void handleDragEvent(DragEvent event) {
		if (DragEvent.DRAG_OVER.equals(event.getEventType()) && event.getGestureSource() == null && event.getDragboard().hasFiles()) {
			draggingVaultOver.set(event.getDragboard().getFiles().stream().map(File::toPath).anyMatch(this::containsVault));
			if (draggingVaultOver.get()) {
				event.acceptTransferModes(TransferMode.ANY);
			}
		} else if (DragEvent.DRAG_DROPPED.equals(event.getEventType()) && event.getGestureSource() == null && event.getDragboard().hasFiles()) {
			Set<Path> vaultPaths = event.getDragboard().getFiles().stream().map(File::toPath).filter(this::containsVault).collect(Collectors.toSet());
			if (!vaultPaths.isEmpty()) {
				vaultPaths.forEach(this::addVault);
			}
			event.setDropCompleted(!vaultPaths.isEmpty());
			event.consume();
		} else if (DragEvent.DRAG_EXITED.equals(event.getEventType())) {
			draggingVaultOver.set(false);
		}
	}

	private boolean containsVault(Path path) {
		try {
			if (path.getFileName().toString().endsWith(CRYPTOMATOR_FILENAME_EXT)) {
				path = path.getParent();
			}
			return CryptoFileSystemProvider.checkDirStructureForVault(path, VAULTCONFIG_FILENAME, MASTERKEY_FILENAME) != DirStructure.UNRELATED;
		} catch (IOException e) {
			return false;
		}
	}

	private void addVault(Path pathToVault) {
		Path target = pathToVault.getFileName().toString().endsWith(CRYPTOMATOR_FILENAME_EXT) ? pathToVault.getParent() : pathToVault;
		executor.execute(() -> {
			try {
				vaultListManager.add(target);
			} catch (NotAVaultDirectoryException e) {
				LOG.warn("Cannot add {}: {}", target, e.getMessage());
				Platform.runLater(() -> dialogs.prepareNotAVaultDirectoryDialog(mainWindow, e).build().showAndWait());
			} catch (IOException e) {
				LOG.warn("Failed to add vault {}", target, e);
				Platform.runLater(() -> appWindows.showErrorWindow(e, mainWindow, null));
			}
		});
	}

	@FXML
	public void showPreferences() {
		appWindows.showPreferencesWindow(SelectedPreferencesTab.ANY);
	}

	@FXML
	public void showEventViewer() {
		appWindows.showEventViewer();
	}
	// Getter and Setter

	public BooleanBinding emptyVaultListProperty() {
		return emptyVaultList;
	}

	public boolean isEmptyVaultList() {
		return emptyVaultList.get();
	}

	public BooleanProperty draggingVaultOverProperty() {
		return draggingVaultOver;
	}

	public boolean isDraggingVaultOver() {
		return draggingVaultOver.get();
	}

	public ObservableValue<Double> cellSizeProperty() {
		return cellSize;
	}

	public Double getCellSize() {
		return cellSize.getValue();
	}

	public ObservableValue<Boolean> unreadEventsPresentProperty() {
		return unreadEvents;
	}

	public boolean getUnreadEventsPresent() {
		return unreadEvents.getValue();
	}
}
