/*
 * Auto-CreamAPI
 * Copyright (C) 2020 Jeddunk
 *
 * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public
 * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later
 * version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
 * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with this program. If not, see
 * <https://www.gnu.org/licenses/>.
 */

package xyz.jeddunk.autocreamapi;

import de.jensd.fx.glyphs.fontawesome.FontAwesomeIconView;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.event.Event;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.paint.Color;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.stage.*;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.configuration2.ex.ConfigurationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import xyz.jeddunk.autocreamapi.pojo.App;
import xyz.jeddunk.autocreamapi.util.CreamApiConfig;
import xyz.jeddunk.autocreamapi.util.CreamApiDllHandler;
import xyz.jeddunk.autocreamapi.util.SteamAppsListCache;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.stream.Stream;

public class Controller implements Initializable {

    final Logger logger = LoggerFactory.getLogger(Controller.class);

    private static final String GLYPH_FAILURE = "TIMES";
    private static final String GLYPH_SUCCESS = "CHECK";
    private static final Color COLOR_FAILURE = Color.web("#E53935");
    private static final Color COLOR_SUCCESS = Color.web("#43A047");
    private static final String REGEX = "(?<steamApiDll>steam_api(?:64)?.dll)$";

    private CreamApiDllHandler handler = null;
    private final CreamApiConfig config = CreamApiConfig.getInstance();
    private final SteamAppsListCache cache = new SteamAppsListCache();
    private boolean is64Bit;
    private boolean isSameFile;


    @FXML
    public Label creamApiDllApplied;
    @FXML
    public Label creamApiConfigExists;
    @FXML
    public FontAwesomeIconView creamApiDllAppliedIcon;
    @FXML
    public FontAwesomeIconView creamApiConfigExistsIcon;
    @FXML
    public Label state_label;
    @FXML
    public TextField path_textfield;
    @FXML
    public TextField appId_textfield;
    @FXML
    public TextField game_name_textfield;
    @FXML
    public ComboBox<String> language_combobox;
    @FXML
    public TextArea dlc_textarea;
    @FXML
    public CheckBox extra_protection_checkbox;
    @FXML
    public CheckBox offline_checkbox;
    @FXML
    public CheckBox unlock_all_checkbox;
    @FXML
    public CheckBox steamdb_dlc_checkbox;
    @FXML
    public Button reset_button;
    @FXML
    public Button save_button;
    @FXML
    public Button getAppId_button;
    @FXML
    public Button path_button;
    @FXML
    public Button getDlcList_button;
    private String steamApiPathString;

    {
        try {
            handler = CreamApiDllHandler.getInstance();
        } catch (FileNotFoundException e) {
            Alert alert = new Alert(Alert.AlertType.ERROR);
            alert.setTitle("CreamAPI DLLs missing!");
            alert.setHeaderText("CreamAPI DLL files can't be found!");
            alert.setContentText("Please download CreamAPI and extract the non-log " +
                    "version in the same folder as Auto-CreamAPI!\nThe program will now close.");
            alert.showAndWait();
            System.exit(2);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public Controller() {
    }

    @FXML
    @Override
    public void initialize(URL location, ResourceBundle resources) {
        steamdb_dlc_checkbox.setSelected(true);
        appId_textfield.textProperty().addListener(appIdChangesGameName());
        game_name_textfield.setOnKeyReleased(event -> {
            KeyCode code = event.getCode();
            if (code.equals(KeyCode.ENTER)) {
                getAppId();
            }
        });
        language_combobox.setItems(FXCollections.observableArrayList(config.getLanguages()));
        language_combobox.setTooltip(new Tooltip());
        language_combobox.setEditable(false);
        new ComboBoxAutoComplete<>(language_combobox);
        dlc_textarea.setPromptText("List of DLC...\r0000 = DLC Name");
        generate_tooltips();
        reset(true);
        state_label.setText("Ready.");
    }

    private void generate_tooltips() {
        Tooltip extra_protection_tooltip = new Tooltip("\"extra protection\"");
        extra_protection_checkbox.setTooltip(extra_protection_tooltip);
        Tooltip.install(extra_protection_checkbox, extra_protection_tooltip);

        Tooltip steamdb_dlc_tooltip = new Tooltip("Additionally get DLC data from SteamDB.\n" +
                "Especially useful for DLC that is not listed on the Steam Store.\n" +
                "This sometimes doesn't work because SteamDB may block access for scrapers.");
        steamdb_dlc_checkbox.setTooltip(steamdb_dlc_tooltip);
        Tooltip.install(steamdb_dlc_checkbox, steamdb_dlc_tooltip);
    }

    private void read() {
        language_combobox.getSelectionModel().select(config.getLanguage());
        appId_textfield.setText(config.getAppId().toString());
        dlc_textarea.setText(config.getDlcListAsString());
        game_name_textfield.setText(cache.getGame(config.getAppId()).getName());
        extra_protection_checkbox.setSelected(config.getExtraProtection());
        offline_checkbox.setSelected(config.getForceOffline());
        unlock_all_checkbox.setSelected(config.getUnlockAll());
        unlockAll_disableDlcTextArea();
    }

    private void emptyFields() {
        /*setIndicator(creamApiDllApplied, creamApiDllAppliedIcon, COLOR_FAILURE, GLYPH_FAILURE);
        setIndicator(creamApiConfigExists, creamApiConfigExistsIcon, COLOR_FAILURE, GLYPH_FAILURE);*/
        appId_textfield.setText("");
        dlc_textarea.setText("");
        game_name_textfield.setText("");
        extra_protection_checkbox.setSelected(false);
        offline_checkbox.setSelected(false);
        unlock_all_checkbox.setSelected(false);
        unlockAll_disableDlcTextArea();
    }

    private ChangeListener<String> appIdChangesGameName() {
        return (observable, oldValue, newValue) -> {
            int appId;
            try {
                appId = Integer.parseInt(newValue);
                final App game = cache.getGame(appId);
                if (game != null) {
                    game_name_textfield.setText(game.getName());
                } else {
                    game_name_textfield.setText("");
                }
            } catch (NumberFormatException e) {
                game_name_textfield.setText("");
            }
        };
    }

    private void reset(boolean silent) {
        if (silent) {
            state_label.setText("");
        } else {
            state_label.setText("Resetting fields...");
        }
        try {
            config.read();
            read();
            if (!silent) {
                state_label.setText("Successfully reset all fields!");
            }
        } catch (NoSuchElementException e) {
            logger.warn("Error reading cream_api.ini! " +
                    "This can be ignored if setting up CreamAPI for the first time.");
        } catch (NullPointerException e) {
            logger.warn("Can't fill out fields, no configuration file set!");
            if (!silent) {
                state_label.setText("Could not reset fields, no configuration file set!");
            }
        }
        updateIndicators();
    }

    private void updateIndicators() {
        try {
            is64Bit = false;
            if (!steamApiPathString.isEmpty()) {
                Path dllPath = Paths.get(steamApiPathString);
                if (dllPath.endsWith("steam_api64.dll")) {
                    is64Bit = true;
                }
                InputStream is = Files.newInputStream(dllPath);
                String md5 = DigestUtils.md5Hex(is);
                isSameFile = Objects.equals(md5, handler.getDllMd5(is64Bit));
                if (isSameFile) {
                    setIndicator(creamApiDllApplied, creamApiDllAppliedIcon, COLOR_SUCCESS, GLYPH_SUCCESS);
                } else {
                    setIndicator(creamApiDllApplied, creamApiDllAppliedIcon, COLOR_FAILURE, GLYPH_FAILURE);
                }
                Path configPath = Paths.get(config.getPath());
                if (Files.exists(configPath) && Files.size(configPath) > 0L) {
                    setIndicator(creamApiConfigExists, creamApiConfigExistsIcon, COLOR_SUCCESS, GLYPH_SUCCESS);
                } else {
                    setIndicator(creamApiConfigExists, creamApiConfigExistsIcon, COLOR_FAILURE, GLYPH_FAILURE);
                }
            }
        } catch (Exception e) {
            logger.warn("Resetting visual indicators!");
            setIndicator(creamApiDllApplied, creamApiDllAppliedIcon, COLOR_FAILURE, GLYPH_FAILURE);
            setIndicator(creamApiConfigExists, creamApiConfigExistsIcon, COLOR_FAILURE, GLYPH_FAILURE);
        }
    }

    private void setIndicator(Label label, FontAwesomeIconView icon, Color color, String glyphName) {
        label.setTextFill(color);
        icon.setFill(color);
        icon.setGlyphName(glyphName);
    }

    public void save() {
        saveService().start();
    }

    public void getAppId() {
        final List<App> games = cache.findListOfGames(game_name_textfield.getText());
        if (games.isEmpty()) {
            logger.info("No game name was given, setting AppID to \"-1\"!");
            appId_textfield.setText("-1");
        } else if (games.size() == 1) {
            App game = games.get(0);
            game_name_textfield.setText(game.getName());
            appId_textfield.setText(String.valueOf(game.getAppId()));
        } else {
            logger.info("Multiple results found, opening search result window!");
            try {
                URL resource = ClassLoader.getSystemResource("xyz/jeddunk/autocreamapi/searchResultWindow.fxml");
                FXMLLoader fxmlLoader = new FXMLLoader(resource);
                Parent root1 = fxmlLoader.load();

                SearchResultWindowController c = fxmlLoader.getController();
                c.initMe(games);
                c.setParentController(this);

                Stage stage = new Stage();
                stage.initModality(Modality.APPLICATION_MODAL);
                stage.setResizable(false);
                stage.setTitle("Choose game...");
                stage.setScene(new Scene(root1));
                stage.show();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public void unlockAll_disableDlcTextArea() {
        dlc_textarea.setDisable(unlock_all_checkbox.isSelected());
        getDlcList_button.setDisable(unlock_all_checkbox.isSelected());
    }

    /**
     * Gets DLC from both the Steam Store and SteamDB, since the latter has a (weird) limit of 64(?) DLCs. SteamDB
     * also lists DLC not available for purchase.
     */
    public void getDlcList() {
        getDlcListService().start();
    }

    public void openFileChooser() {
        FileChooser chooser = new FileChooser();
        chooser.setTitle("Choose steam_api(64).dll...");
        FileChooser.ExtensionFilter filter =
                new FileChooser.ExtensionFilter("Steam API DLL",
                        "steam_api.dll", "steam_api64.dll");
        chooser.getExtensionFilters().add(filter);
        File file = chooser.showOpenDialog(path_button.getScene().getWindow());
        try {
            config.setConfig(file.getParent() + "\\cream_api.ini");
            path_textfield.setText(file.getParent());
            steamApiPathString = file.getAbsolutePath();
        } catch (ConfigurationException | IOException e) {
            e.printStackTrace();
        } catch (NullPointerException e) {
            logger.warn("Could not set config file location! Did you cancel the file chooser?");
        }
        emptyFields();
        reset(true);
        logger.info("File chosen: " + steamApiPathString);
        logger.info("Ready.");
        state_label.setText("Ready.");
    }

    /**
     * Check if CreamAPI version of DLL is there, if not, rename original to steam_api(64)_o.dll and copy
     * CreamAPI DLL to the path. Also looks for alternative Steam DLL (which means it patches both 64 and 32 bit
     * DLLs at once if found)
     *
     * @throws IOException If file is missing or not accessible/modifiable.
     */
    private void setUpCreamApi() throws IOException {
        logger.info("Setting up DLL...");
        Path path = Paths.get(steamApiPathString);
        copyDllFile(path, isSameFile);
        findAdditionalDll(path);
        logger.info("Set up DLL successfully!");
    }

    private void findAdditionalDll(Path path) throws IOException {
        String aDllString = is64Bit ? "\\steam_api.dll" : "\\steam_api64.dll";
        Path dir = path.getParent();
        Path aDll = Paths.get(dir.toString() + aDllString);
        if (Files.exists(aDll)) {
            logger.info("Additional DLL found: (" + aDll.toString() + ")!");
            InputStream is = Files.newInputStream(aDll);
            String md5 = DigestUtils.md5Hex(is);
            boolean aIsSameFile = Objects.equals(md5, handler.getDllMd5(!is64Bit));
            copyDllFile(aDll, aIsSameFile);
        }
    }

    private void copyDllFile(Path path, boolean isSameFile) throws IOException {
        if (!isSameFile) {
            String pathOrigString = steamApiPathString;
            String replacement = is64Bit ? "steam_api64_o.dll" : "steam_api_o.dll";
            pathOrigString =
                    pathOrigString
                            .replaceFirst(REGEX, replacement);
            Path pathOrig = Paths.get(pathOrigString);
            if (!Files.exists(pathOrig)) {
                logger.info("Renaming " + path.toString() + " to " + replacement + "...");
                Files.move(path, pathOrig);
            } else {
                logger.info(pathOrigString + " already exists!");
                String pathBakString = steamApiPathString;
                pathBakString =
                        pathBakString
                                .replaceFirst(REGEX, is64Bit ? "steam_api64.dll.backup" : "steam_api.dll.backup");
                Path pathBak = Paths.get(pathBakString);
                Files.move(path, pathBak, StandardCopyOption.REPLACE_EXISTING);
            }
            logger.info("Copying CreamAPI DLL to " + path.toString() + " ...");
            Files.copy(handler.getDllPath(is64Bit), path);
        }
    }

    private void setDisableAllButtons(boolean b) {
        reset_button.setDisable(b);
        save_button.setDisable(b);
        getAppId_button.setDisable(b);
        path_button.setDisable(b);
        getDlcList_button.setDisable(b);
    }

    public void resetFromButton() {
        reset(false);
    }

    private Service<Void> saveService() {
        Service<Void> s = new Service<Void>() {
            @Override
            protected Task<Void> createTask() {
                return new Task<Void>() {
                    @Override
                    protected Void call() {
                        try {
                            setUpCreamApi();
                            config.setDlcListFromString(dlc_textarea.getText());
                            config.setAppId(Integer.parseInt(appId_textfield.getText()));
                            config.setExtraProtection(extra_protection_checkbox.isSelected());
                            config.setForceOffline(offline_checkbox.isSelected());
                            config.setUnlockAll(unlock_all_checkbox.isSelected());
                            config.setLanguage(language_combobox.getValue());
                            config.sync();
                        } catch (IOException | ConfigurationException e) {
                            e.printStackTrace();
                            cancel();
                        } catch (NullPointerException e) {
                            logger.warn("No configuration file set!");
                            cancel();
                        }
                        updateIndicators();
                        return null;
                    }
                };
            }
        };
        s.setOnRunning(event -> {
            setDisableAllButtons(true);
            state_label.setText("Saving...");
        });
        s.setOnSucceeded(event -> {
            setDisableAllButtons(false);
            state_label.setText("Saved successfully!");
        });
        s.setOnCancelled(event -> {
            setDisableAllButtons(false);
            state_label.setText("Could not save configuration file!");
        });
        return s;
    }

    private Service<Void> getDlcListService() {
        Service<Void> s = new Service<Void>() {
            @Override
            protected Task<Void> createTask() {
                return new Task<Void>() {
                    @Override
                    protected Void call() {
                        Map<Integer, String> collect =
                                cache.getDlcMap(appId_textfield.getText(), steamdb_dlc_checkbox.isSelected());
                        StringBuilder sb = new StringBuilder();
                        collect.forEach((k, v) -> sb.append(k).append("=").append(v).append("\n"));
                        dlc_textarea.setText(sb.toString());
                        return null;
                    }
                };
            }
        };
        s.setOnRunning(event -> {
            setDisableAllButtons(true);
            state_label.setText("Getting list of DLCs...");
        });
        s.setOnSucceeded(event -> {
            setDisableAllButtons(false);
            state_label.setText("Got list of DLCs successfully!");
        });
        s.setOnCancelled(event -> {
            setDisableAllButtons(false);
            state_label.setText("Could not get list of DLCs!");
        });
        return s;
    }

    static class ComboBoxAutoComplete<T> {

        private ComboBox<T> cmb;
        String filter = "";
        private ObservableList<T> originalItems;

        public ComboBoxAutoComplete(ComboBox<T> cmb) {
            this.cmb = cmb;
            originalItems = FXCollections.observableArrayList(cmb.getItems());
            cmb.setTooltip(new Tooltip());
            cmb.setOnKeyPressed(this::handleOnKeyPressed);
            cmb.setOnHidden(this::handleOnHiding);
        }

        public void handleOnKeyPressed(KeyEvent e) {
            SingleSelectionModel<T> sm = this.cmb.getSelectionModel();
            ObservableList<T> filteredList = FXCollections.observableArrayList();
            KeyCode code = e.getCode();

            if (code.isLetterKey()) {
                filter += e.getText();
            }
            if (code == KeyCode.BACK_SPACE && filter.length() > 0) {
                filter = filter.substring(0, filter.length() - 1);
                cmb.getItems().setAll(originalItems);
            }
            if (code == KeyCode.ESCAPE) {
                filter = "";
            }
            if (filter.length() == 0) {
                filteredList = originalItems;
                cmb.getTooltip().hide();
            } else {
                Stream<T> items = cmb.getItems().stream();
                String txtUsr = filter.toLowerCase();
                items.filter(el -> el.toString().toLowerCase().contains(txtUsr)).forEach(filteredList::add);
                cmb.getTooltip().setText(txtUsr);
                Window stage = cmb.getScene().getWindow();
                double posX = stage.getX() + cmb.getBoundsInParent().getMinX();
                double posY = stage.getY() + cmb.getBoundsInParent().getMinY();
                cmb.getTooltip().show(stage, posX, posY);
                cmb.show();
            }
            cmb.getItems().setAll(filteredList);
            sm.selectFirst();
        }

        public void handleOnHiding(Event e) {
            filter = "";
            cmb.getTooltip().hide();
            T s = cmb.getSelectionModel().getSelectedItem();
            cmb.getItems().setAll(originalItems);
            cmb.getSelectionModel().select(s);
        }

    }

}
