package org.limine.snapper.processes;

import org.limine.snapper.formats.json1.ImageDetail;
import org.limine.snapper.formats.json1.Manifest;
import org.limine.snapper.formats.json1.SnapperEntry;
import org.limine.snapper.formats.json1.SnapshotEntry;
import org.limine.snapper.formats.snapper.SnapperProperty;
import org.limine.snapper.objects.*;

import java.io.File;
import java.util.*;
import java.util.stream.Collectors;

public class SnapshotManager {

    private final Config config;
    private boolean isUpdateNeeded;
    private boolean isAddingSnapshotNeeded;

    public SnapshotManager(Config config) {
        this.config = config;
        isUpdateNeeded = false;
        isAddingSnapshotNeeded = false;
    }

    /**
     * Check whether the last Snapper entry is the newest and is not yet in the Limine entries.
     * If so, add it.
     * Clean outdated Limine snapshot entries if they are not present in the Snapper list.
     */
    public void sync(Manifest manifest, SnapperReader snapper) {

        String lastUTCTime = manifest.getLastUTCTime();
        if (Utility.isTimeFormatNotValid(lastUTCTime)) {
            String errMessage = "Stop the tool due to a mismatch in the UTC time format: " + lastUTCTime;
            Utility.exitWithError(errMessage);
            return;
        }

        // Delete outdated snapshot-entries from the manifest if they have already been deleted from the Snapper list.
        // Update snapshot entries if their description has been changed in the Snapper list.
        this.update(manifest, snapper.getSnapperEntries());

        SnapperEntry lastSnapperEntry = snapper.getLastSnapperEntry();
        if (snapper.getSnapperEntries().isEmpty() || lastSnapperEntry == null) {
            isAddingSnapshotNeeded = false;
            return;
        }

        int result = Utility.compareTimestamps(lastUTCTime, lastSnapperEntry.utcTime());
        if (result < 0) {
            // lastUTCTime is before lastSnapperEntry.utcTime
            isAddingSnapshotNeeded = true;
        } else if (result > 0) {
            // lastUTCTime is after lastSnapperEntry.utcTime
            isAddingSnapshotNeeded = false;
        } else {
            // lastUTCTime is equal to lastSnapperEntry.utcTime, then check both IDs if they are the same.
            isAddingSnapshotNeeded = manifest.getLastSnapshotID() != lastSnapperEntry.snapshotID();
        }

        // Snapshot type filter
        String lastSnapshotType = lastSnapperEntry.properties().get(SnapperProperty.TYPE.replace);
        if (lastSnapshotType != null && Config.EXCLUDE_SNAPSHOT_TYPES.contains(lastSnapshotType)) {
            isAddingSnapshotNeeded = false;
        }

        int max = Config.MAX_SNAPSHOT_ENTRIES;
        if (isAddingSnapshotNeeded && max > 0) {
            max = max - 1;
        }
        // Prune snapshot entries according to the maximum number specified in the configuration.
        this.pruneSnapshotEntries(manifest, max, snapper.getSnapperEntries().size());
        // Auto-prune older snapshot boot entries according to LIMIT_USAGE_PERCENT.
        this.pruneSnapshotEntries(manifest, null);
    }

    public PartitionUsage pruneSnapshotEntries(Manifest manifest, PartitionUsage bootUsage) {
        if (Config.MAX_SNAPSHOT_ENTRIES != Config.AUTO_SNAPSHOT_LIMIT) {
            return bootUsage;
        }
        if (bootUsage == null) {
            bootUsage = Utility.calculatePartitionUsage(Config.LIMIT_USAGE_PERCENT, config.espPath(), 1);
        }
        if (bootUsage != null && bootUsage.exceedsLimit() && HistoryWriter.cleanupUnusedFiles(manifest, config)) {
            bootUsage = Utility.calculatePartitionUsage(Config.LIMIT_USAGE_PERCENT, config.espPath(), bootUsage.deltaBytes());
        }
        while (bootUsage != null && !manifest.getSnapshotEntries().isEmpty() && bootUsage.exceedsLimit()) {
            HashSet<ImageDetail> imageFiles = manifest.getSnapshotEntries().getFirst().allImages();
            manifest.getSnapshotEntries().removeFirst();
            deleteImageFiles(manifest.getSnapshotEntries(), imageFiles);
            isUpdateNeeded = true;
            bootUsage = Utility.calculatePartitionUsage(Config.LIMIT_USAGE_PERCENT, config.espPath(), bootUsage.deltaBytes());
        }
        return bootUsage;
    }

    private void pruneSnapshotEntries(Manifest manifest, int max, int total) {
        if (Config.MAX_SNAPSHOT_ENTRIES < 0) {
            return;
        }

        boolean warningVisible = (max < total) && (Config.DEFAULT_SNAPSHOT_LIMIT == max);
        if (warningVisible) {
            Utility.warnMessage("Snapshot limit mismatch:", total + " Snapper snapshots exceed the default Limine menu limit (" + Config.DEFAULT_SNAPSHOT_LIMIT + ")");
            Utility.infoMessage("Remove this warning by changing MAX_SNAPSHOT_ENTRIES from the default or lowering the Snapper snapshot limit.");
        }

        int index = manifest.getSnapshotEntries().size() - max;
        HashSet<ImageDetail> outdatedImageFiles = new HashSet<>();
        boolean shouldBeDeleted = index > 0;

        if (shouldBeDeleted && warningVisible) {
            Utility.warnMessage("Snapshot limit reached:", "Time to prune older snapshots based on the configured MAX_SNAPSHOT_ENTRIES (" + max + ")");
        }

        while (index > 0) {
            SnapshotEntry limineSnapshot = manifest.getSnapshotEntries().getFirst();
            outdatedImageFiles.addAll(limineSnapshot.allImages());
            manifest.getSnapshotEntries().removeFirst();
            index--;
        }
        if (shouldBeDeleted) {
            this.isUpdateNeeded = true;
            this.deleteImageFiles(manifest.getSnapshotEntries(), outdatedImageFiles);
        }
    }

    private void update(Manifest manifest, HashMap<String, SnapperEntry> snapperList) {
        Iterator<SnapshotEntry> iterator = manifest.getSnapshotEntries().iterator();
        HashSet<ImageDetail> outdatedImageFiles = new HashSet<>();
        boolean shouldFilesBeDeleted = false;
        while (iterator.hasNext()) {
            SnapshotEntry limineSnapshot = iterator.next();
            SnapperEntry snapperEntry = snapperList.get(limineSnapshot.snapperEntry().timeId());
            if (snapperEntry == null) {
                outdatedImageFiles.addAll(limineSnapshot.allImages());
                iterator.remove();
                this.isUpdateNeeded = true;
                shouldFilesBeDeleted = true;
            } else {
                // Update the Limine snapshot if the description of the Snapper entry has been changed.
                String oldDescription = limineSnapshot.snapperEntry().properties().get(SnapperProperty.DESCRIPTION.replace);
                String newDescription = snapperEntry.properties().get(SnapperProperty.DESCRIPTION.replace);
                if (!Objects.equals(oldDescription, newDescription)) {
                    limineSnapshot.snapperEntry().properties().put(SnapperProperty.DESCRIPTION.replace, newDescription);
                    this.isUpdateNeeded = true;
                }
            }
        }
        if (shouldFilesBeDeleted) {
            this.deleteImageFiles(manifest.getSnapshotEntries(), outdatedImageFiles);
        }
    }

    public HashSet<FileDetail> remove(Manifest manifest, HashSet<Integer> ids) {
        HashSet<ImageDetail> outdatedFiles = new HashSet<>();
        Iterator<SnapshotEntry> iterator = manifest.getSnapshotEntries().iterator();

        while (iterator.hasNext()) {
            SnapshotEntry snapshotEntry = iterator.next();
            Integer id = snapshotEntry.snapperEntry().snapshotID();
            if (id != null && ids.contains(id)) {
                outdatedFiles.addAll(snapshotEntry.allImages());
                iterator.remove();
            }
        }
        if (outdatedFiles.isEmpty()) {
            return new HashSet<>();
        } else {
            // The manifest should be updated after this change!
            isUpdateNeeded = true;
            return deleteImageFiles(manifest.getSnapshotEntries(), outdatedFiles, false);
        }
    }

    private void deleteImageFiles(List<SnapshotEntry> limineSnapshots, HashSet<ImageDetail> outdatedImageFiles) {
        deleteImageFiles(limineSnapshots, outdatedImageFiles, true);
    }

    private HashSet<FileDetail> deleteImageFiles(List<SnapshotEntry> limineSnapshots, HashSet<ImageDetail> outdatedImageFiles, boolean showCommand) {
        HashSet<FileDetail> deletedFiles = new LinkedHashSet<>();
        // Collect all files from all current snapshots after you have deleted some snapshots.
        HashSet<String> allImageFiles = this.getCurrentImageFiles(limineSnapshots);
        // Deletion of outdated files if they are not used by all snapshots.
        String basePath = Utility.buildPath(config.espPath(), config.machineID(), Config.HISTORY_FOLDER) + File.separator;
        for (ImageDetail imageDetail : outdatedImageFiles) {
            if (!allImageFiles.contains(imageDetail.fileHashName())) {
                // Run a command to delete /<esp>/<machine-id>/limine_history/<some file>
                String targetHashFile = Utility.buildPath(basePath, imageDetail.fileHashName());
                FileDetail fileDetail = FileDetail.create(imageDetail.fileHashName(), basePath);
                if (fileDetail != null) {
                    deletedFiles.add(fileDetail);
                }
                Utility.deleteFile(targetHashFile, true, showCommand);
            }
        }
        return deletedFiles;
    }

    private HashSet<String> getCurrentImageFiles(List<SnapshotEntry> limineSnapshots) {
        HashSet<String> allImageFiles = new HashSet<>();
        for (SnapshotEntry snapshotEntry : limineSnapshots) {
            for (ImageDetail imageDetail : snapshotEntry.allImages()) {
                allImageFiles.add(imageDetail.fileHashName());
            }
        }
        return allImageFiles;
    }

    /**
     * Copy files to the history folder with a deduplication function.
     * The manifest adds the last snapshot.
     */
    public void historyAddSnapshot(Manifest manifest, SnapshotEntry lastSnapshotEntry) {
        if (lastSnapshotEntry.kernelEntries().isEmpty()) {
            if (!Config.IS_TIME_FOR_RESTORE) {
                // The current snapshot entry has no kernel entry, it should not be added to the Limine menu.
                String errMessage = "The Limine snapshot entry cannot be created because it has no kernel.";
                Utility.errorMessage(errMessage);
            }
            return;
        }

        // Copy or move any image files to the Limine history directory.
        for (ImageDetail imageDetail : lastSnapshotEntry.allImages()) {
            if (Config.IS_TIME_FOR_RESTORE && imageDetail.fileHashName().endsWith(Config.NO_FILE_NAME)) {
                // There is no image file. No need to back up this file during the restore.
                continue;
            }
            String sourceFilePath = Utility.buildPath(config.espPath(), imageDetail.dirPath(), imageDetail.fileName());
            String targetFilePath = Utility.buildPath(config.espPath(), config.machineID(), Config.HISTORY_FOLDER, imageDetail.fileHashName());
            if (Utility.isFilePresent(targetFilePath)) {
                this.repairIfCorrupted(sourceFilePath, targetFilePath);
                if (Config.IS_TIME_FOR_RESTORE && Utility.isFilePresent(sourceFilePath)) {
                    // Delete the image file as it already exists in the Limine history directory
                    Utility.deleteFile(sourceFilePath);
                }
                continue;
            }
            isUpdateNeeded = true;
            if (Config.IS_TIME_FOR_RESTORE) {
                // Move the image file to the Limine history directory
                Utility.moveFile(sourceFilePath, targetFilePath, true);
            } else {
                // Copy the image file to the Limine history directory
                Utility.copyFile(sourceFilePath, targetFilePath, true);
            }
        }
        manifest.getSnapshotEntries().add(lastSnapshotEntry);
        this.updateManifest(manifest, lastSnapshotEntry.snapperEntry());
    }

    public void updateManifest(Manifest manifest, SnapperEntry lastSnapperEntry) {
        manifest.setLastLocalTime(lastSnapperEntry.localTime());
        manifest.setLastUTCTime(lastSnapperEntry.utcTime());
        manifest.setLastSnapshotID(lastSnapperEntry.snapshotID());
    }

    private SnapshotEntry getSnapshotEntry(Manifest manifest, int snapshotID) {
        SnapshotEntry snapshotEntry = null;
        for (SnapshotEntry entry : manifest.getSnapshotEntries()) {
            if (entry.snapperEntry().snapshotID() == snapshotID) {
                snapshotEntry = entry;
                break;
            }
        }
        return snapshotEntry;
    }

    /**
     * User is prompted to confirm the snapshot ID or select a different snapshot ID for restoring.
     */
    private SnapshotEntry confirmOrSelectSnapshot(Manifest manifest) {
        int snapshotID = Utility.getSnapshotIdFromKernelCmdline();
        // Check the snapshot ID if it is present in the manifest.
        SnapshotEntry snapshotEntry = getSnapshotEntry(manifest, snapshotID);
        if (snapshotEntry == null) {
            snapshotID = -1;
        } else {
            System.out.println(ConsoleColor.GREEN_BOLD + "Snapshot ID    : " + ConsoleColor.RESET + snapshotID);
            System.out.println(ConsoleColor.GREEN_BOLD + "Date           : " + ConsoleColor.RESET + snapshotEntry.snapperEntry().localTime());
            System.out.println(ConsoleColor.GREEN_BOLD + "Description    : " + ConsoleColor.RESET + snapshotEntry.snapperEntry().properties().get(SnapperProperty.DESCRIPTION.replace));
            System.out.println(ConsoleColor.GREEN_BOLD + "Restore method : " + ConsoleColor.RESET + Config.RESTORE_METHOD.name().toLowerCase());
        }

        if (snapshotID > 0) {
            // Ask a user to confirm to restore this snapshot?
            boolean isConfirmed = Utility.askUser(
                    "\n Confirm restore of snapshot " + snapshotID + " using the \"" + Config.RESTORE_METHOD.name().toLowerCase() + "\" method?\n"
                            + " Type [y]es to restore, [l]ist to display all snapshots, or [c]ancel to abort.\n",
                    "yes", "list", "cancel"
            );

            if (!isConfirmed) {
                snapshotID = this.selectSnapshot(manifest);
            }
        } else {
            // Ask the user to select which snapshot ID for restore.
            snapshotID = this.selectSnapshot(manifest);
        }
        snapshotEntry = getSnapshotEntry(manifest, snapshotID);
        if (snapshotEntry == null) {
            String errMessage = "No snapshot " + snapshotID + " is found";
            Utility.errorMessage(errMessage);
            return null;
        }
        return snapshotEntry;
    }

    /**
     * Check custom selected snapshot ID for restoring.
     */
    private SnapshotEntry checkCustomSnapshotID(Manifest manifest, String customID) {
        int snapshotID;
        try {
            snapshotID = Integer.parseInt(customID);
        } catch (NumberFormatException e) {
            Utility.errorMessage("Invalid ID '" + customID + "': must be numeric");
            return null;
        }
        // Check the snapshot ID if it is present in the manifest.
        SnapshotEntry snapshotEntry = getSnapshotEntry(manifest, snapshotID);

        if (snapshotEntry == null) {
            Utility.errorMessage("Snapshot ID '" + customID + "' not found in Limine snapshots");
            return null;
        } else {
            return snapshotEntry;
        }
    }


    public RestoreDetail preRestoreKernels(Manifest manifest, LimineReader limine, String customID) {
        SnapshotEntry snapshotEntry = checkCustomSnapshotID(manifest, customID);
        if (snapshotEntry == null) {
            return null;
        } else {
            SnapshotEntry targetEntry = limine.createCurrentLimineEntry();
            // Predict if there is enough partition space when adding new files?
            PartitionUsage usage = predictSpaceUsage(targetEntry, snapshotEntry, false);
            if (usage.exceedsLimit()) {
                Utility.errorMessage("Stopping restore:", "Not enough space on boot partition, usage will increase by " + usage.deltaMiB() + " from "
                        + usage.usedPercent() + " to " + usage.newPercent() + " after the restore and will break restored boot files.");
                return null;
            }
            verifySnapshotFiles(snapshotEntry, true, List.of());
            return new RestoreDetail(snapshotEntry, targetEntry, "", true);
        }
    }

    public RestoreDetail preRestore(Manifest manifest, LimineReader limine) {
        SnapshotEntry snapshotEntry = confirmOrSelectSnapshot(manifest);
        if (snapshotEntry == null) {
            return null;
        }
        SnapshotEntry targetEntry = limine.createCurrentLimineEntry();
        // Predict if there is enough partition space when adding new files?
        PartitionUsage usage = predictSpaceUsage(targetEntry, snapshotEntry, true);
        if (usage.exceedsLimit()) {
            Utility.errorMessage("Stopping restore:", "Not enough space on boot partition, usage will increase by " + usage.deltaMiB() + " from "
                    + usage.usedPercent() + " to " + usage.newPercent() + " after the restore and will break restored boot files.");
            return null;
        }

        if (!verifySnapshotFiles(snapshotEntry, false, manifest.getSnapshotEntries())) {
            if (Utility.askUser("""
                    \s
                     Do you want to select a different snapshot ID? [y|r|c]
                     Type [y]es to choose another, [r]estore to continue, or [c]ancel to abort.
                    \s""", "yes", "restore", "cancel")
            ) {
                return preRestore(manifest, limine);
            }
        }

        if (Config.USE_OPENSUSE_LAYOUT) {
            // Checking if OpenSUSE-style layout is valid and compatible with snapper rollback
            return new RestoreDetail(snapshotEntry, targetEntry, "", Utility.checkOpenSuseLayout());

        } else {

            if (RestoreMethod.RSYNC == Config.RESTORE_METHOD) {
                if (!Utility.isCommandPresent("rsync")) {
                    String errMessage = "Command 'rsync' is not installed";
                    Utility.errorMessage(errMessage);
                    return null;
                }
            }
            String subvolumePath = Utility.removeSurroundingSlashes(config.rootSubvolumePath());
            if (subvolumePath.isEmpty() && RestoreMethod.REPLACE == Config.RESTORE_METHOD) {
                String errMessage = """
                        \s
                        Error: No root subvolume detected. You are using the entire volume as the root partition.
                         The "replace" restore method can not support your layout without a root subvolume.
                        
                         Possible solution:
                         1. Install 'rsync'
                         2. Set 'RESTORE_METHOD=rsync' in /etc/default/limine or /tmp/limine-snapper-sync.conf
                         3. Retry the restore, this will use 'rsync' instead.
                        \s
                        """;
                Utility.errorMessage(errMessage);
                return null;
            }

            String backupDescription = Utility.userInput("Enter snapshot description for subvolume " + subvolumePath + ": ");
            if (backupDescription.isEmpty()) {
                backupDescription = "backup before restore #" + snapshotEntry.snapperEntry().snapshotID();
            }
            return new RestoreDetail(snapshotEntry, targetEntry, backupDescription, true);
        }
    }

    /**
     * Restoration process
     */
    public SnapshotEntry restore(Manifest manifest, LimineReader limineReader, RestoreDetail restoreDetail, boolean restoreKernelsOnly) {
        boolean areYouInSnapshot = Utility.areYouInSnapshot();
        SnapshotEntry limineEntry = limineReader.createCurrentLimineEntry();
        int snapshotID = restoreDetail.snapshotEntry().snapperEntry().snapshotID();
        SnapshotEntry snapshotEntry = restoreDetail.snapshotEntry();
        boolean isBackupNeeded = !restoreKernelsOnly;

        String subvolumePath = Utility.removeSurroundingSlashes(config.rootSubvolumePath());
        String snapshotPath = Utility.removeSurroundingSlashes(config.rootSnapshotsPath());

        String mountDir = "/run/lss/";
        String timestamp = Utility.getCurrentIsoFormattedTime();
        String backupSubvolume = subvolumePath + "_backup_" + timestamp;
        String newSubvolume = subvolumePath + "_lss_" + timestamp;

        // Skip if you want to restore kernels only
        if (!restoreKernelsOnly) {
            String uuid;
            if (Config.FS_UUID != null) {
                uuid = Config.FS_UUID;
            } else {
                uuid = manifest.getUuid();
            }
            if (uuid == null || uuid.isEmpty()) {
                Utility.errorMessage("No UUID is set, please set 'UUID=...' in " + Config.DEFAULT_CONFIG_FILE_PATH + " or " + Config.TMP_CONFIG_FILE_PATH + "!");
                return null;
            }

            // mount /run/lss
            List<String> commands = List.of(
                    "mount -t btrfs -o subvol=/ /dev/disk/by-uuid/" + uuid + " " + mountDir
            );

            // Time to start mount.
            Utility.infoMessage("\nTime to start mounting Btrfs.");
            if (!Utility.ensureDirectoryExists(mountDir)) {
                return null;
            }
            if (!Utility.runCommands(commands, true, true, true)) {
                String errMessage = "Failed to mount " + mountDir;
                Utility.errorMessage(errMessage);
                Utility.runCommand("umount " + mountDir, false, false);
                return null;
            }

            // Find all child subvolumes of the parent subvolume. This is important because rsync should exclude them.
            // For Btrfs restore, they should be moved back to the parent subvolume after the restore.
            List<String> childSubvolumePaths = new ArrayList<>();
            // Get all child subvolume-paths
            String btrfsCommand = "btrfs subvolume list -o '" + Utility.buildPath(mountDir, subvolumePath) + "'";
            Output subvolumePathsOutput = Utility.getTextFromCommand(btrfsCommand, true, null);
            if (subvolumePathsOutput.isSuccess()) {
                for (String fullLine : subvolumePathsOutput.text()) {
                    String path = "";
                    int index = fullLine.indexOf(subvolumePath + File.separator);
                    if (index != -1) {
                        path = fullLine.substring(index + subvolumePath.length() + 1);
                    }
                    if (!path.isEmpty()) {
                        childSubvolumePaths.add(path);
                    } else if (!fullLine.isBlank()) {
                        String errMessage = "The output format of '" + btrfsCommand + "' was changed. Please report the issue.";
                        Utility.errorMessage(errMessage);
                        return null;
                    }
                }
            }

            boolean isSnapshot = Utility.isDirectoryPresent(Utility.buildPath(mountDir, snapshotPath, snapshotID + "/snapshot/"));

            if (!isSnapshot) {
                String errMessage = "It does not support your Btrfs layout without Snapper.";
                Utility.errorMessage(errMessage);
                return null;
            }

            if (Config.RESTORE_METHOD == RestoreMethod.RSYNC) {
                // Rsync instead Btrfs restore
                StringBuilder excludes = new StringBuilder();
                if (!childSubvolumePaths.isEmpty()) {
                    excludes.append(" --exclude={");
                    for (int i = 0; i < childSubvolumePaths.size(); i++) {
                        excludes.append("'/").append(childSubvolumePaths.get(i)).append("'");
                        if (i < childSubvolumePaths.size() - 1) {
                            excludes.append(",");
                        }
                    }
                    excludes.append("}");
                }
                String snapshotDirPath = Utility.buildPath(mountDir, snapshotPath, snapshotID + "/snapshot") + File.separator;
                String subvolumeDirPath = Utility.buildPath(mountDir, subvolumePath) + File.separator;
                String rsyncCmdline = Config.RSYNC_CMDLINE + excludes + " '" + snapshotDirPath + "' '" + subvolumeDirPath + "'";
                commands = List.of(
                        // Bugfix: Do not use the "-r" read-only option as it denies moving the snapshot to the Snapper list.
                        "btrfs subvolume snapshot '" + Utility.buildPath(mountDir, subvolumePath) + "' '" + Utility.buildPath(mountDir, backupSubvolume) + "'",
                        rsyncCmdline
                );

                // Check if the subvolume is read-only
                if (isSubvolumeReadOnly(subvolumeDirPath)) {
                    Utility.warnMessage("WARNING:", "The subvolume '" + subvolumePath + "' is read-only. Rsync must be stopped.");
                    return null;
                }

            } else {
                // Restore the snapshot in any (default or custom) Snapper layout.
                String snapshotDirPath = Utility.buildPath(mountDir, snapshotPath, snapshotID + "/snapshot");
                String newSubvolumeDirPath = Utility.buildPath(mountDir, newSubvolume);
                String subvolumeDirPath = Utility.buildPath(mountDir, subvolumePath);
                String backupSubvolumeDirPath = Utility.buildPath(mountDir, backupSubvolume);

                commands = List.of(
                        "btrfs subvolume snapshot '" + snapshotDirPath + "' '" + newSubvolumeDirPath + "'",
                        "mv '" + subvolumeDirPath + "' '" + backupSubvolumeDirPath + "'",
                        "mv '" + newSubvolumeDirPath + "' '" + subvolumeDirPath + "'"
                );

                // Set the subvolume to writable before restoration.
                if (isSubvolumeReadOnly(subvolumeDirPath)) {
                    Utility.warnMessage("WARNING:", "The subvolume '" + subvolumePath + "' is read-only. It will be unlocked so restoration can continue.");
                    Utility.setSubvolumeProperty(subvolumeDirPath, false);
                }
            }

            // Time to start to restore.
            Utility.infoMessage("Time to start restoring snapshot ID: " + snapshotID);
            if (!Utility.runCommands(commands, true, true, true)) {
                if (RestoreMethod.REPLACE == Config.RESTORE_METHOD) {
                    // The restore fails, then "backup" subvolume should be moved back to the correct location.
                    if (Utility.isDirectoryPresent(Utility.buildPath(mountDir, backupSubvolume))) {
                        List<String> revert;
                        if (Utility.isDirectoryPresent(Utility.buildPath(mountDir, subvolumePath))) {

                            String subvolumeDirPath = Utility.buildPath(mountDir, subvolumePath);
                            String newSubvolumeDirPath = Utility.buildPath(mountDir, newSubvolume);
                            String backupSubvolumeDirPath = Utility.buildPath(mountDir, backupSubvolume);

                            revert = List.of(
                                    "mv '" + subvolumeDirPath + "' '" + newSubvolumeDirPath + "'",
                                    "mv '" + backupSubvolumeDirPath + "' '" + subvolumeDirPath + "'"
                            );
                        } else {
                            revert = List.of(
                                    "mv '" + Utility.buildPath(mountDir, backupSubvolume) + "' '" + Utility.buildPath(mountDir, subvolumePath) + "'"
                            );
                        }
                        Utility.runCommands(revert, true, true, true);
                    }
                }
                String errMessage = "Failed to restore.";
                Utility.errorMessage(errMessage);
                return null;
            }

            if (RestoreMethod.REPLACE == Config.RESTORE_METHOD) {
                if (Config.SET_SNAPSHOT_AS_DEFAULT) {
                    String setDefaultSubvolumeCommand = "btrfs subvolume set-default '" + Utility.buildPath(mountDir, subvolumePath) + "'";
                    Utility.runCommand(setDefaultSubvolumeCommand, true, true);
                }
                // If the parent backup-subvolume has multiple child subvolumes,
                // they should be moved back to the right subvolume.
                List<String> movedChildSubvolumeCommands = new ArrayList<>();
                for (String childSubvolumePath : childSubvolumePaths) {
                    String sourceDirPath = Utility.buildPath(mountDir, backupSubvolume, childSubvolumePath);
                    String extractDirectoryPath = Utility.extractParentDirectoryPath(childSubvolumePath);
                    String targetDirPath = Utility.buildPath(mountDir, subvolumePath, extractDirectoryPath) + File.separator;
                    movedChildSubvolumeCommands.add("mv '" + sourceDirPath + "' '" + targetDirPath + "'");
                }

                if (!movedChildSubvolumeCommands.isEmpty()) {
                    // Time to move all child subvolumes back.
                    Utility.infoMessage("Time to move all child subvolumes back to the parent subvolume: " + subvolumePath);
                    if (!Utility.runCommands(movedChildSubvolumeCommands, false, true, true)) {
                        String errMessage = "Failed to moved the child subvolumes.";
                        Utility.errorMessage(errMessage);
                    }
                }
            }
        }

        // If restoreKernelsOnly is true (isBackupNeeded is false), then do not create a "backup" boot entry.
        // Move the backup subvolume to the Snapper list, move its kernel files to the Limine history, and then update the manifest.
        if (isBackupNeeded) {
            Utility.infoMessage("Time to add the \"" + restoreDetail.backupDescription() + "\" entry to the Snapper list and Limine snapshot list.");
            int latestSnapshotID = Utility.findLatestSnapperNumber(Utility.buildPath(mountDir, snapshotPath));
            if (latestSnapshotID < 1) {
                String errMessage = "Oops, but after the restore there is no snapshot in the Snapper list. That is strange.";
                Utility.errorMessage(errMessage);
            } else {
                if (areYouInSnapshot) {
                    // You are in the snapshot without the conflict with the Snapper.
                    latestSnapshotID = latestSnapshotID + 1;
                } else {
                    // You are not in the snapshot.
                    // If the tool and the Snapper create the same ID at the same time, this leads to a conflict.
                    // Solution: Another higher ID is created, probably to avoid the conflict with the Snapper.
                    latestSnapshotID = latestSnapshotID + 2;
                }

                String backupSubvolumeDirPath = Utility.buildPath(mountDir, backupSubvolume);
                String newSnapshotDirPath = Utility.buildPath(mountDir, snapshotPath, String.valueOf(latestSnapshotID));
                Utility.ensureDirectoryExists(newSnapshotDirPath);

                List<String> moveSubvolumeToSnapper = List.of(
                        "mv '" + backupSubvolumeDirPath + "' '" + newSnapshotDirPath + "/snapshot'"
                );
                if (Utility.runCommands(moveSubvolumeToSnapper, false, true, true)) {
                    Utility.setSubvolumeProperty("'" + newSnapshotDirPath + "/snapshot'", !Config.SNAPSHOT_WRITABLE);

                    // Create a new info.xml for this snapshot in the Snapper list.
                    String utcTime = Utility.getCurrentUTCTime();
                    SnapperWriter.writeInfoXml(latestSnapshotID, utcTime, restoreDetail.backupDescription(), newSnapshotDirPath + "/info.xml");

                    // Create a new backup (snapshot) entry with the "broken" kernel versions
                    String localTime = Utility.convertUTCtoLocal(utcTime);
                    SnapperEntry snapperEntry = new SnapperEntry(latestSnapshotID, utcTime, localTime, null);
                    snapperEntry.properties().put(SnapperProperty.DESCRIPTION.replace, restoreDetail.backupDescription());
                    SnapshotEntry backupEntry = limineReader.createCurrentLimineEntry(snapperEntry);

                    // Move the "broken" kernel files to the Limine history directory.
                    // snapshots.json (the manifest) adds the backup entry.
                    this.historyAddSnapshot(manifest, backupEntry);

                } else {
                    String errMessage = "Failed to move the \"backup\" snapshot to the Snapper list.";
                    Utility.errorMessage(errMessage);
                }
            }
        } else {
            // Delete the "broken" kernel files without a backup.
            for (ImageDetail imageDetail : limineEntry.allImages()) {
                if (!imageDetail.fileHashName().endsWith(Config.NO_FILE_NAME)) {
                    String sourceFilePath = Utility.buildPath(config.espPath(), imageDetail.dirPath(), imageDetail.fileName());
                    if (Utility.isFilePresent(sourceFilePath)) {
                        String targetFilePath = Utility.buildPath(config.espPath(), config.machineID(), Config.HISTORY_FOLDER, imageDetail.fileHashName());
                        this.repairIfCorrupted(sourceFilePath, targetFilePath);
                        Utility.deleteFile(sourceFilePath);
                    }
                }
            }
        }

        // Time to copy the matching kernel-files of this snapshot to their right place.
        Utility.infoMessage("Time to restore matching kernel versions.");
        for (ImageDetail imageDetail : snapshotEntry.allImages()) {
            if (!imageDetail.fileHashName().endsWith(Config.NO_FILE_NAME)) {
                String sourceFilePath = Utility.buildPath(config.espPath(), config.machineID(), Config.HISTORY_FOLDER, imageDetail.fileHashName());
                String targetDirPath = Utility.buildPath(config.espPath(), imageDetail.dirPath()) + File.separator;
                // Check if the directory does not exist, then create it.
                Utility.ensureDirectoryExists(targetDirPath);
                String targetFilePath = targetDirPath + imageDetail.fileName();

                // Check if the file exists
                if (Utility.isFilePresent(targetFilePath)) {
                    String hashCode = Utility.calculateHash(targetFilePath);
                    // Check if the file is the same as it from the snapshot.
                    if (!imageDetail.fileHashName().endsWith(hashCode)) {
                        // Both files are different,
                        // The file should be replaced by the other file from the snapshot.
                        Utility.copyFile(sourceFilePath, targetFilePath, true);
                    }
                } else {
                    Utility.copyFile(sourceFilePath, targetFilePath, true);
                }
            }
        }
        // Overwrite the restored kernel entries in the current Limine entry.
        limineEntry.kernelEntries().clear();
        limineEntry.kernelEntries().addAll(snapshotEntry.kernelEntries());
        return limineEntry;
    }

    private void repairIfCorrupted(String sourceFilePath, String targetFilePath) {
        if (Utility.isFilePresent(targetFilePath)) {
            // Check if the target file in any of the snapshots is corrupted.
            String hashCode = Utility.calculateHash(targetFilePath);
            if (!targetFilePath.endsWith(hashCode)) {
                // The snapshot file is corrupted.
                // Replace the corrupted file with a healthy one.
                Utility.copyFile(sourceFilePath, targetFilePath, true);
                String errMessage = "A corrupted file was detected at: '" + targetFilePath + "' and has been replaced with a healthy one.";
                Utility.errorMessage(errMessage);
            }
        }
    }

    private int selectSnapshot(Manifest manifest) {
        LinkedHashSet<String> IDs = HistoryReader.displaySnapshotList(manifest);
        boolean unicode = Utility.isUnicodeSupported();
        String upOrDown = unicode ? "↑/↓" : "Up/Down keys";
        int selectedID;
        while (true) {
            String input = Utility.userSelect(
                    "\n Which snapshot ID do you want to restore using the \"" + Config.RESTORE_METHOD.name().toLowerCase() + "\" method?"
                            + "\n Use " + upOrDown + " to select a snapshot ID, or type [c]ancel to abort.\n", new ArrayList<>(IDs)
            );

            if (IDs.contains(input.trim())) {
                selectedID = Integer.parseInt(input.trim());
                break;
            } else {
                System.err.println(ConsoleColor.RED + "Selected ID is invalid or not available: " + ConsoleColor.RESET + input);
            }
        }
        return selectedID;
    }

    private boolean verifySnapshotFiles(SnapshotEntry snapshotEntry, boolean showError, List<SnapshotEntry> snapshotEntryList) {
        boolean allFilesValid = true;
        LinkedHashSet<SnapshotEntry> affectedSnapshots = new LinkedHashSet<>();
        for (ImageDetail image : snapshotEntry.allImages()) {
            String hashedFilePath = Utility.buildPath(config.espPath(), config.machineID(), Config.HISTORY_FOLDER, image.fileHashName());
            boolean fileOk = true;
            // Check if the file exists
            if (!Utility.isFilePresent(hashedFilePath)) {
                String message = "Missing file detected:";
                if (showError) {
                    Utility.errorMessage(message, hashedFilePath);
                } else {
                    Utility.warnMessage(message, hashedFilePath);
                }
                fileOk = false;
            }
            // Check if the file is corrupted
            if (Hash.hasCorruption(hashedFilePath)) {
                String message = "Corrupted file detected:";
                if (showError) {
                    Utility.errorMessage(message, hashedFilePath);
                } else {
                    Utility.warnMessage(message, hashedFilePath);
                }
                fileOk = false;
            }
            if (!fileOk) {
                allFilesValid = false;
                // Collect snapshots affected by this missing or corrupted file
                for (SnapshotEntry otherSnapshot : snapshotEntryList) {
                    for (ImageDetail otherImage : otherSnapshot.allImages()) {
                        if (Objects.equals(otherImage.fileHashName(), image.fileHashName())) {
                            affectedSnapshots.add(otherSnapshot);
                        }
                    }
                }
            }
        }
        if (!affectedSnapshots.isEmpty()) {
            // Convert affectedSnapshots to comma-separated list of IDs
            String ids = affectedSnapshots.stream()
                    .map(s -> String.valueOf(s.snapperEntry().snapshotID()))
                    .collect(Collectors.joining(", "));
            Utility.warnMessage("Snapshots affected:", ids);
        }
        return allFilesValid;
    }

    public PartitionUsage predictSpaceUsage(SnapshotEntry currentEntry) {
        HashSet<String> addedFilePaths = new HashSet<>();
        for (ImageDetail imageDetail : currentEntry.allImages()) {
            String snapshotFilePath = Utility.buildPath(config.espPath(), config.machineID(), Config.HISTORY_FOLDER, imageDetail.fileHashName());
            if (!Utility.isFilePresent(snapshotFilePath)) {
                String filePath = Utility.buildPath(config.espPath(), imageDetail.dirPath(), imageDetail.fileName());
                addedFilePaths.add(filePath);
            }
        }
        return Utility.checkPartitionUsage(Config.LIMIT_USAGE_PERCENT, config.espPath(), addedFilePaths, new HashSet<>());
    }

    private PartitionUsage predictSpaceUsage(SnapshotEntry currentEntry, SnapshotEntry snapshotEntry, boolean isBackupNeeded) {
        HashSet<String> addedFilePaths = new HashSet<>();
        HashSet<String> deletedFilePaths = new HashSet<>();
        for (ImageDetail imageDetail : currentEntry.allImages()) {
            String filePath = Utility.buildPath(config.espPath(), imageDetail.dirPath(), imageDetail.fileName());
            if (isBackupNeeded) {
                String snapshotFilePath = Utility.buildPath(config.espPath(), config.machineID(), Config.HISTORY_FOLDER, imageDetail.fileHashName());
                if (Utility.isFilePresent(filePath) && Utility.isFilePresent(snapshotFilePath)) {
                    deletedFilePaths.add(filePath);
                }
            } else {
                if (Utility.isFilePresent(filePath)) {
                    deletedFilePaths.add(filePath);
                }
            }
        }
        for (ImageDetail imageDetail : snapshotEntry.allImages()) {
            String snapshotFilePath = Utility.buildPath(config.espPath(), config.machineID(), Config.HISTORY_FOLDER, imageDetail.fileHashName());
            if (Utility.isFilePresent(snapshotFilePath)) {
                addedFilePaths.add(snapshotFilePath);
            }
        }
        return Utility.checkPartitionUsage(100.0, config.espPath(), addedFilePaths, deletedFilePaths);
    }

    private boolean isSubvolumeReadOnly(String subvolumePath) {
        Output output = Utility.getTextFromCommand("btrfs property get '" + subvolumePath + "' ro", true, null);
        if (output.isSuccess() && !output.text().isEmpty()) {
            return output.text().getFirst().contains("true");
        } else {
            return false;
        }
    }

    public boolean checkUpdate() {
        return isUpdateNeeded || isAddingSnapshotNeeded;
    }

    public boolean isAllowedToAddSnapshot() {
        return isAddingSnapshotNeeded;
    }
}
