<?php

use Random\RandomException;
use YahnisElsts\PluginUpdateChecker\v5\PucFactory;

class Backpack
{
    /**
     * The version of the software or application.
     *
     * This property holds the current version of the software, which can be
     * used for version tracking and compatibility checks.
     *
     * @var string
     */
    public string $version = "1.3.1";

    /**
     * The file path being operated on or referenced.
     *
     * This property is typically used to store the path to a specific file,
     * which may be used for various file operations or as a reference.
     *
     * @var string
     */
    public string $file;

    /**
     * The base file path used to determine the plugin's directory.
     *
     * This property is initialized in the constructor and represents the
     * base directory path for the plugin, derived from the provided file path.
     *
     * @var string
     */
    public string $baseFile;

    /**
     * The database version utilized by the plugin.
     *
     * This property stores the version number of the database
     * schema or structure associated with the plugin. It is
     * useful for handling migrations or schema updates.
     *
     * @var string
     */
    public string $dbVersion = "1.0.0";

    /**
     * Indicates whether the database version check should be skipped.
     *
     * When set to `true`, the plugin skips checking the stored database version
     * against the current version specified in `$dbVersion`. This can be useful
     * for debugging or in scenarios where database version checks are irrelevant.
     *
     * Default: `false`
     *
     * @var bool
     */
    public bool $skipDatabaseVersionCheck = false;

    /**
     * Constructor for initializing the class with a base file path.
     *
     * Accepts the path to a base file and sets it up for further usage within the class.
     *
     * @param string $baseFile The file path used to determine the base directory for the plugin.
     *
     * @return void
     */
    function __construct(string $baseFile)
    {
        $this->file     = $baseFile;
        $this->baseFile = plugin_dir_path($baseFile);
    }

    /**
     * Configures the necessary components for operation.
     *
     * This method sets up the environment by adding items to the sidebar menu
     * and registering required cron events.
     *
     * @return void
     */
    public function equip(): void
    {
        $this->addSidebarMenus();
        $this->registerCronEvents();
        $this->addEndpoints();
        $this->checkForUpdates();
    }

    /**
     * Callback method executed when the plugin is activated.
     *
     * This method is typically used for setting up initial options,
     * database tables, or any other installation-specific tasks
     * needed for the plugin to function properly.
     *
     * @return void
     * @throws Exception
     */
    public function install(): void
    {
        // Set path to mysqldump
        if (! get_option('mws_backpack_mysqldump_path')) {
            update_option('mws_backpack_mysqldump_path', '/usr/bin/mysqldump');
        }

        // Set a default backup path
        if (! get_option('mws_backpack_backup_path')) {
            $documentRoot      = $_SERVER['DOCUMENT_ROOT'];
            $defaultBackupPath = realpath($documentRoot . DIRECTORY_SEPARATOR . ".." . DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . "backups";
            update_option('mws_backpack_backup_path', $defaultBackupPath);
        }

        // Set an encryption key
        if (! get_option('mws_backpack_encryption_key')) {
            update_option('mws_backpack_encryption_key', $this->generateEncryptionKey());
        }

        // Create options for AWS access keys
        if (! get_option('mws_backpack_aws_access_key_id')) {
            update_option('mws_backpack_aws_access_key_id', '');
        }

        if (! get_option('mws_backpack_aws_secret_access_key')) {
            update_option('mws_backpack_aws_secret_access_key', '');
        }

        if (! get_option('mws_backpack_aws_bucket_name')) {
            update_option('mws_backpack_aws_bucket_name', '');
        }

        if (! get_option('mws_backpack_aws_bucket_region')) {
            update_option('mws_backpack_aws_bucket_region', 'eu-west-2');
        }

        if (! get_option('mws_backpack_aws_backup_prefix')) {
            update_option('mws_backpack_aws_backup_prefix', '');
        }

        // Install database tables
        $this->installDatabase();
    }

    /**
     * Performs a database upgrade if necessary.
     *
     * Checks if the database version needs to be updated or if the version check should be skipped. If either condition is met, it triggers the installation or upgrade of the database.
     *
     * @return void Does not return any value.
     */
    public function upgrade(): void
    {
        if ($this->skipDatabaseVersionCheck === true || get_option('mws_backpack_db_version') !== $this->dbVersion) {
            $this->installDatabase();
        }
    }

    /**
     * Installs or updates the database schema for the plugin.
     *
     * This method checks the current database version stored in the options table and compares it
     * with the required version. If they differ, it creates or updates the database table for logging
     * with the specified schema. The table includes fields for an auto-incrementing ID, type, and timestamp.
     * The database version is updated in the options table after the schema update.
     *
     * @return void
     */
    public function installDatabase(): void
    {
        global $wpdb;

        $charsetCollate = $wpdb->get_charset_collate();

        require_once(ABSPATH . 'wp-admin/includes/upgrade.php');

        // Log table
        $tableName = $wpdb->prefix . 'backpack_log';

        $sql = "CREATE TABLE $tableName (
                id bigint(9) NOT NULL AUTO_INCREMENT,
                type varchar(255) DEFAULT '' NOT NULL,
                message longtext NULL,
                created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
                updated_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
                PRIMARY KEY  (id)
            ) $charsetCollate;";

        dbDelta($sql);

        // Schedule table
        $tableName = $wpdb->prefix . 'backpack_schedule';

        $sql = "CREATE TABLE $tableName (
                id bigint(9) NOT NULL AUTO_INCREMENT,
                name varchar(500) DEFAULT '' NOT NULL,
                type varchar(255) DEFAULT '' NOT NULL,
                settings longtext DEFAULT NULL NULL,
                schedule varchar(255) DEFAULT '' NOT NULL,
                location varchar(255) DEFAULT '' NOT NULL,
                last_run_at datetime DEFAULT NULL NULL,
                next_due_at datetime DEFAULT NULL NULL,
                locked tinyint(1) DEFAULT 0 NULL,
                created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
                updated_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
                PRIMARY KEY  (id)
            ) $charsetCollate;";

        dbDelta($sql);

        // History table
        $tableName = $wpdb->prefix . 'backpack_history';

        $sql = "CREATE TABLE $tableName (
                id bigint(9) NOT NULL AUTO_INCREMENT,
                schedule_id bigint(9) NOT NULL,
                path varchar(500) DEFAULT '' NOT NULL,
                created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
                updated_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
                PRIMARY KEY  (id)
            ) $charsetCollate;";

        dbDelta($sql);

        update_option("mws_backpack_db_version", $this->dbVersion);
    }

    public function registerCronEvents(): void
    {
        // Add custom cron schedules
        add_filter('cron_schedules', function ($schedules) {
            $schedules['every_minute'] = [
                'interval' => MINUTE_IN_SECONDS,
                'display'  => __('Every Minute'),
            ];

            return $schedules;
        });

        // Schedule the process runner
        if (! wp_next_scheduled('mws_backpack_process')) {
            wp_schedule_event(time(), 'every_minute', 'mws_backpack_process');
        }

        // Add the action hook for the process runner
        add_action('mws_backpack_process', function () {
            // Process the queue
            global $backpack;

            $backpack->processQueue();
        });

        // Schedule the cleanup process
        if (! wp_next_scheduled('mws_backpack_cleanup')) {
            wp_schedule_event(time(), 'weekly', 'mws_backpack_cleanup');
        }

        // Add the action hook for the cleanup process
        add_action('mws_backpack_cleanup', function () {
            // Clean up the logs
            global $backpack;

            $backpack->empty();

            // Clean up older backups
            $backpack->tidy();
        });
    }

    /**
     * Determines whether normal WordPress cron jobs are enabled.
     *
     * Checks the `DISABLE_WP_CRON` constant to identify if cron jobs are disabled.
     * If the constant is defined and set to true, cron jobs are considered disabled.
     * Otherwise, they are enabled.
     *
     * @return bool Returns true if normal WordPress cron jobs are enabled, or false if they are disabled.
     */
    public function isCronEnabled(): bool
    {
        if (defined('DISABLE_WP_CRON')) {
            if (DISABLE_WP_CRON === true) {
                return false;
            }
        }

        return true;
    }

    /**
     * Adds a sidebar menu item in the WordPress admin panel and handles routing for its content.
     *
     * Registers a new menu item under the "Tools" menu in the WordPress admin dashboard.
     * When the menu page is accessed, it determines the active tab (defaulting to 'dashboard' if not specified),
     * processes the routing to load the appropriate content, and includes the template file for rendering.
     *
     * @return void This method does not return a value.
     */
    private function addSidebarMenus(): void
    {
        add_action('admin_menu', function () {
            add_management_page(
                'BackPack',                       // Page title
                'BackPack',                       // Menu title
                'manage_options',                 // Capability
                'mws-backpack',                  // Menu slug
                function () {                     // Callback function
                    if (empty($_GET['tab'])) {
                        $_GET['tab'] = 'dashboard';
                    }

                    $content = $this->route();

                    return include_once $this->baseFile . 'pages/template.php';
                }
            );
        });
    }


    /**
     * Handles routing based on the 'tab' parameter in the GET request.
     *
     * If a 'tab' parameter is present and not empty, it retrieves the corresponding tab content,
     * includes the appropriate PHP file, and returns its buffered output. If no 'tab' parameter
     * is provided, it returns false.
     *
     * @return bool|string Returns the buffered output of the included file as a string, or false if the 'tab' parameter is not present.
     */
    private function route(): bool|string
    {
        if (! empty($_GET['tab'])) {
            $tab = $this->getTab($_GET['tab']);

            // Use output buffering to display the tab content in the correct place
            ob_start();
            include_once $this->baseFile . "pages/{$tab}.php";
            return ob_get_clean();
        }

        return false;
    }


    /**
     * This method retrieves the sanitized name of the active tab
     * based on the provided tab or falls back to a default value.
     *
     * It ensures that the tab name is valid by checking against
     * an allowed list of tabs.
     *
     * If the provided tab is not valid, it defaults to "dashboard".
     *
     * @param string $tab The tab name to be validated and sanitized.
     *
     * @return string The sanitized and valid tab name.
     */
    private function getTab(string $tab): string
    {
        // Perform some basic sanitization first
        $tab = sanitize_text_field($tab);

        // Our allowed tabs list
        $allowedTabs = [
            "dashboard",
            "settings",
            "schedule",
            "history",
            "log",
        ];

        // Is the tab allowed? If not, forcibly override it to "dashboard"
        if (! in_array($tab, $allowedTabs)) {
            $tab = "dashboard";
        }

        return $tab;
    }

    /**
     * Deletes a task from the 'backpack_schedule' database table based on the given task ID.
     *
     * This function uses the global `$wpdb` object to execute a delete operation
     * on the specified database table, removing the record with the corresponding ID.
     *
     * @param int $taskID The ID of the task to be deleted.
     *
     * @return mysqli_result|bool|int|null The result of the delete operation. Returns a MySQLi result object on success (if applicable),
     * boolean false on failure, integer for the number of rows affected, or null if no rows matched the criteria.
     */
    public function deleteTask(int $taskID): mysqli_result|bool|int|null
    {
        global $wpdb;

        return $wpdb->delete($wpdb->prefix . 'backpack_schedule', ['id' => $taskID]);
    }

    /**
     * Executes a scheduled task immediately by updating its next due time to the current timestamp.
     *
     * Updates the next_due_at field of the task to the current time in the database, effectively marking it as due for immediate execution.
     *
     * @param int $taskID The unique identifier of the task to be executed.
     *
     * @return bool Returns true if the database update was successful, or false otherwise.
     */
    public function runTaskNow(int $taskID): bool
    {
        global $wpdb;

        $now = \Carbon\Carbon::now($this->getTimezone())->toDateTimeString();

        $update = $wpdb->query("UPDATE `{$wpdb->prefix}backpack_schedule` SET `next_due_at` = '{$now}' WHERE id = {$taskID}");

        return $update !== false;
    }

    /**
     * Unlocks a task based on its ID.
     *
     * Updates the record for the specified task in the `backpack_schedule` table, setting its `locked` status to 0.
     *
     * @param int $taskID The ID of the task to unlock.
     *
     * @return bool Returns true if the operation was successful, false otherwise.
     */
    public function unlockTask(int $taskID): bool
    {
        global $wpdb;

        $update = $wpdb->query("UPDATE `{$wpdb->prefix}backpack_schedule` SET `locked` = 0 WHERE id = {$taskID}");

        return $update !== false;
    }

    /**
     * Adds a new task to the schedule table in the database.
     *
     * This method inserts a new task with the provided name, type, and schedule into the database,
     * setting default values for other attributes such as location and timestamps. The method
     * then returns the ID of the newly created record.
     *
     * @param string $name     The name of the task.
     * @param string $type     The type or category of the task.
     * @param string $schedule The schedule associated with the task.
     *
     * @return int|false The ID of the newly inserted task in the database.
     */
    public function addTask(string $name, string $type, string $schedule): int|false
    {
        global $wpdb;

        if ($this->backupPathMatchesStoragePath()) {
            return false;
        }

        $settings = $this->createTaskSettings();

        if (Cron\CronExpression::isValidExpression($schedule) === true) {
            $nextRunDate = $this->getNextRunTime($schedule);
        } else {
            $nextRunDate = \Carbon\Carbon::now();
        }

        $wpdb->insert(
            "{$wpdb->prefix}backpack_schedule",
            [
                'name'        => $name,
                'type'        => $type,
                'schedule'    => $schedule,
                'settings'    => $settings,
                'next_due_at' => $nextRunDate->format('Y-m-d H:i:s'),
                'created_at'  => \Carbon\Carbon::now($this->getTimezone())->toDateTimeString(),
                'updated_at'  => \Carbon\Carbon::now($this->getTimezone())->toDateTimeString(),
            ],
            [
                '%s', // name
                '%s', // type
            ]
        );

        return $wpdb->insert_id;
    }

    /**
     * Updates a task in the 'backpack_schedule' database table based on the provided task ID.
     *
     * This method uses WordPress' $wpdb object to perform an update query. It modifies the name
     * and type of the task with the given task ID.
     *
     * @param int    $taskID   The ID of the task to update.
     * @param string $name     The new name for the task.
     * @param string $type     The new type for the task.
     * @param string $schedule The schedule associated with the task.
     *
     * @return mysqli_result|bool|int|null Returns the number of rows affected, true on success with no changes,
     *         false on failure, or a MySQLi result object which may depend on the $wpdb implementation.
     */
    public function updateTask(int $taskID, string $name, string $type, string $schedule): mysqli_result|bool|int|null
    {
        global $wpdb;

        if ($this->backupPathMatchesStoragePath()) {
            return false;
        }

        $settings = $this->createTaskSettings();

        if (Cron\CronExpression::isValidExpression($schedule) === true) {
            $nextRunDate = $this->getNextRunTime($schedule);
        } else {
            $nextRunDate = \Carbon\Carbon::now();
        }

        $update = $wpdb->update(
            "{$wpdb->prefix}backpack_schedule",
            [
                'name'        => $name,
                'type'        => $type,
                'schedule'    => $schedule,
                'settings'    => $settings,
                'next_due_at' => $nextRunDate->format('Y-m-d H:i:s'),
                'updated_at'  => \Carbon\Carbon::now($this->getTimezone())->toDateTimeString(),
            ],
            [
                'id' => $taskID,
            ],
            [
                '%s', // name
                '%s', // type
                '%s', // schedule
                '%s', // settings
                '%s', // next_due_at
                '%s', // updated_at
            ],
            [
                '%d', // id
            ]
        );

        $this->log("Task {$taskID} updated, next run date set to {$nextRunDate->format('Y-m-d H:i:s')}.");

        return $update;
    }

    /**
     * Retrieves task details from the database based on the provided task ID.
     *
     * This method queries the `backpack_schedule` table for a task's details using the given task ID.
     * It fetches columns including `id`, `name`, `type`, `location`, and `next_due_at`.
     *
     * @param int $taskID The ID of the task to retrieve.
     *
     * @return object|array|null Returns an associative array of task details if found, an object if specified otherwise, or null if no matching task exists.
     */
    public function getTask(int $taskID): object|array|null
    {
        global $wpdb;

        $task = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT * FROM `{$wpdb->prefix}backpack_schedule` WHERE `id` = %d",
                $taskID
            ),
            ARRAY_A,
        );

        $task = $this->decodeSettings($task);

        return $task;
    }

    /**
     * Determines the type of task based on the provided input.
     *
     * Matches the given task type parameter against predefined cases and returns the corresponding task type as a string. If no match is found, it defaults to "Full".
     *
     * @param mixed $type The input value representing the task type. Expected values are "full", "database", or "files".
     *
     * @return string Returns a string indicating the task type. Possible values are "Full", "Database", or "Files".
     */
    public function getTaskType(mixed $type): string
    {
        switch ($type) {
            case "full":
            default:
                return "Full";

            case "database":
                return "Database";

            case "files":
                return "Files";
        }
    }

    /**
     * Cleans up user input by removing magic quotes effects from GET, POST, and COOKIE arrays.
     *
     * This method checks if the GET, POST, or COOKIE superglobals are not empty and processes each of their
     * elements through the `stripslashes_deep` function to remove slashes added by magic quotes.
     *
     * @return void This method does not return a value.
     */
    public function fixGPC(): void
    {
        if (! empty($_GET)) {
            $_GET = array_map('stripslashes_deep', $_GET);
        }

        if (! empty($_POST)) {
            $_POST = array_map('stripslashes_deep', $_POST);
        }

        if (! empty($_COOKIE)) {
            $_COOKIE = array_map('stripslashes_deep', $_COOKIE);
        }
    }

    /**
     * Encrypts a string using OpenSSL.
     *
     * @param string $plaintext The string to encrypt.
     *
     * @return string The encrypted string, base64-encoded.
     * @throws RandomException If encryption fails.
     */
    public function encrypt(string $plaintext): string
    {
        $cipher = 'AES-256-CBC';
        $iv     = random_bytes(openssl_cipher_iv_length($cipher)); // Generate a random initialization vector.
        $key    = get_option('mws_backpack_encryption_key');

        $encryptedData = openssl_encrypt($plaintext, $cipher, $key, 0, $iv);

        if ($encryptedData === false) {
            throw new RuntimeException('Encryption failed.');
        }

        // Combine IV and encrypted data and return as base64-encoded string.
        return base64_encode($iv . $encryptedData);
    }

    /**
     * Decrypts a string encrypted using the encryptString() method.
     *
     * @param string $encryptedText The base64-encoded encrypted string.
     *
     * @return string The decrypted string.
     */
    public function decrypt(string $encryptedText): string
    {
        $cipher   = 'AES-256-CBC';
        $data     = base64_decode($encryptedText); // Decode the base64-encoded data.
        $ivLength = openssl_cipher_iv_length($cipher);

        // Extract the IV and encrypted data from the decoded string.
        $iv            = substr($data, 0, $ivLength);
        $encryptedData = substr($data, $ivLength);
        $key           = get_option('mws_backpack_encryption_key');

        $decryptedData = openssl_decrypt($encryptedData, $cipher, $key, 0, $iv);

        if ($decryptedData === false) {
            // If we can't decrypt the data, just return an empty screen
            return "";
        }

        return $decryptedData;
    }

    /**
     * Generates a random encryption key suitable for AES-256-CBC encryption.
     *
     * @return string The generated encryption key, base64-encoded for safe storage.
     * @throws Exception If the key generation fails.
     */
    public function generateEncryptionKey(): string
    {
        // AES-256-CBC requires a 256-bit (32-byte) key.
        $key = random_bytes(32);

        // Return the key as a base64-encoded string for safe storage.
        return base64_encode($key);
    }

    /**
     * Clears old log entries from the database.
     *
     * Deletes entries in the 'backpack_log' table older than the current time based on the specified condition.
     *
     * @return void Does not return any value.
     */
    public function empty(): void
    {
        global $wpdb;

        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$wpdb->prefix}backpack_log WHERE created_at < %s",
            current_time('mysql')
        ));
    }

    /**
     * Cleans up old backups by maintaining a limited number of backups per schedule.
     *
     * Iterates through all backup history, keeps a log of backups per schedule, and deletes backups when the number exceeds a defined limit (6 backups per schedule).
     *
     * @return void Does not return any value.
     */
    public function tidy(): void
    {
        global $wpdb;

        // Fetch all backup history
        $history = $this->getBackupHistory();

        // Keep a log of the backups - we want to keep 6 of each
        $log = [];

        foreach ($history as $backup) {
            $scheduleID = (int) $backup['schedule_ID'];

            // Start a count
            if (! array_key_exists($scheduleID, $log)) {
                $log[$scheduleID] = 0;
            }

            if ($log[$scheduleID] >= 6) {
                // Delete the backup
                (new Backpack_Export($this))->deleteBackup((int) $backup["ID"], $backup["path"]);
            }
        }
    }

    /**
     * Processes the job queue by executing scheduled tasks.
     *
     * Retrieves all tasks from the schedule where the next run date has passed and they are not locked. Locks these tasks to prevent duplication, and processes each task in the queue by executing their associated logic.
     *
     * @return void Does not return any value.
     */
    private function processQueue(): void
    {
        global $wpdb;

        // Unlimited some stuff as this could get intensive.
        // We only actually need to change the time limit when running cron normally as if it fires on the command line
        // it's already set to be unlimited.
        // I'm also fairly certain that both `set_time_limit(0)` and `ini_set('max_execution_time', 0)` do the exact
        // same thing, but I'll use both here just in case I'm wrong.
        set_time_limit(0);
        @ini_set('max_execution_time', 0);
        @ini_set('memory_limit', -1);

        // Process the job queue
        $this->log("Start queue processing");

        // Fetch all entries where next_run_date has passed and locked is 0
        $expiredSchedules = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$wpdb->prefix}backpack_schedule WHERE next_due_at <= %s AND locked = %d",
                current_time('mysql'),
                0
            ),
            ARRAY_A
        );

        if (count($expiredSchedules) === 0) {
            // $this->log("No expired schedules found");
            return;
        }

        $this->log("Total amount of scheduled tasks ready to run: " . count($expiredSchedules));

        $this->lockAllSchedules($expiredSchedules);

        foreach ($expiredSchedules as $schedule) {
            $this->runSchedule($schedule);
        }
    }

    /**
     * Creates task settings based on the input data from a POST request.
     *
     * This method checks for the presence of 'mws_backpack_task_settings' in the POST data.
     * If found, it encodes the corresponding data as a JSON string. If not found, it returns
     * an empty JSON array.
     *
     * @return string Returns a JSON-encoded string containing the task settings if present, or an empty JSON array if not.
     */
    private function createTaskSettings()
    {
        if (array_key_exists("mws_backpack_task_settings", $_POST)) {
            return json_encode($_POST["mws_backpack_task_settings"]);
        }

        return json_encode([]);
    }

    /**
     * Parses a cron string and calculates the next applicable run time.
     *
     * @param string        $cronString The cron string, in standard cron format (e.g., "0 12 * * *").
     * @param DateTime|null $fromDate   Optional start date to calculate the next run time. Defaults to the current time.
     *
     * @return DateTime|null The next applicable run time as a DateTime object, or null if the cron string is invalid.
     */
    public function getNextRunTime(string $cronString, ?DateTime $fromDate = null): ?DateTime
    {
        try {
            // Use the current time if no specific date is provided
            $fromDate = $fromDate ?? Carbon\Carbon::now($this->getTimezone());

            // Use the Cron package to parse the string and get the next run date
            $cron    = new \Cron\CronExpression($cronString);
            $nextRun = $cron->getNextRunDate($fromDate);

            return $nextRun;
        } catch (\Exception $e) {
            // Log the error or handle it if needed
            return null;
        }
    }

    /**
     * Logs a message with a specified type to the database.
     *
     * Inserts a log entry into the database containing the message, type, and timestamps for creation and update.
     *
     * @param string $message The log message to be stored.
     * @param string $type    The type of the log, defaults to "info".
     *
     * @return void Does not return any value.
     */
    public function log(string $message, string $type = "info"): void
    {
        global $wpdb;

        ray($type, $message);

        $wpdb->insert(
            "{$wpdb->prefix}backpack_log",
            [
                'type'       => $type,
                'message'    => $message,
                'created_at' => \Carbon\Carbon::now($this->getTimezone())->toDateTimeString(),
                'updated_at' => \Carbon\Carbon::now($this->getTimezone())->toDateTimeString(),
            ],
            [
                '%s', // type
                '%s', // message
            ]
        );
    }

    /**
     * Locks all expired schedules by updating their status in the database.
     *
     * Iterates through the provided expired schedules to collect their IDs
     * and updates the corresponding records in the database to mark them as locked.
     *
     * @param array|object|null $expiredSchedules A collection of expired schedules. Can be an array, object, or null.
     *
     * @return void Does not return any value.
     */
    private function lockAllSchedules(array|object|null $expiredSchedules): void
    {
        global $wpdb;

        $ids = [];

        foreach ($expiredSchedules as $expiredSchedule) {
            $ids[] = $expiredSchedule['id'];
        }

        $ids = implode(',', $ids);

        $wpdb->query("update `{$wpdb->prefix}backpack_schedule` set `locked` = 1 where `id` in ({$ids})");
    }

    /**
     * Executes a scheduled task.
     *
     * Processes a given schedule by decoding its settings, updating the run times,
     * and performing the appropriate backup task based on the schedule type. Once
     * completed, the schedule is marked as unlocked.
     *
     * @param mixed $schedule The schedule definition containing task details and timings.
     *
     * @return void Does not return any value.
     */
    private function runSchedule(mixed $schedule): void
    {
        $schedule = $this->decodeSettings($schedule);

        // Update last run at date
        $this->updateLastRunDate($schedule['id']);

        // Run the job
        switch ($schedule['type']) {
            case "database":
                $db = new Backpack_Database($this);
                $db->backup($schedule);
                break;

            case "files":
                $files = new Backpack_Files($this);
                $files->backup($schedule);
                break;

            case "full":
                $db             = new Backpack_Database($this);
                $databaseBackup = $db->backup($schedule, false);
                $files          = new Backpack_Files($this);
                $fileBackup     = $files->backup($schedule, false);

                // Compress both together
                $domain         = $this->getDomainName();
                $date           = date('Y-m-d');
                $file           = $file = sprintf("%s/%s_backup_%s_%s", get_option("mws_backpack_backup_path"), $domain, $this->slugify($schedule["name"]), Carbon\Carbon::now($this->getTimezone())->format('Y-m-d_H-i-s'));
                $compressor     = new Backpack_Compress($this);
                $compressedFile = $compressor->zip($file . '.zip', [$databaseBackup, $fileBackup]);
                $baseFilename   = basename($compressedFile);

                // Delete the old files now we have them compressed
                unlink($databaseBackup);
                unlink($fileBackup);

                // Export it to S3
                $export = new Backpack_Export($this);
                $export->toS3("{$domain}/{$date}/{$baseFilename}", $compressedFile, (int) $schedule["id"]);

                break;
        }

        // Update next due at
        $this->updateNextRunDate($schedule['id'], $this->getNextRunTime($schedule['schedule']));

        // Set as unlocked
        $this->unlock($schedule['id']);
    }

    /**
     * Unlocks a specified record in the database.
     *
     * Updates the `locked` status of a record in the `backpack_schedule` table to 0, based on the given identifier.
     *
     * @param mixed $id The identifier of the record to unlock.
     *
     * @return void Does not return any value.
     */
    private function unlock(mixed $id): void
    {
        global $wpdb;

        $wpdb->query("update `{$wpdb->prefix}backpack_schedule` set `locked` = 0 where `id` = {$id}");
    }

    /**
     * Updates the next run date for a scheduled task in the database.
     *
     * Sets the `next_due_at` field for a specific record in the `backpack_schedule` table to the provided date and time.
     *
     * @param mixed         $id             Identifies the record to update in the `backpack_schedule` table.
     * @param DateTime|null $getNextRunTime The upcoming date and time for the next scheduled run. Uses null if no date is provided.
     *
     * @return void Does not return any value.
     */
    private function updateNextRunDate(mixed $id, ?DateTime $getNextRunTime): void
    {
        global $wpdb;

        $wpdb->query("update `{$wpdb->prefix}backpack_schedule` set `next_due_at` = '{$getNextRunTime->format('Y-m-d H:i:s')}' where `id` = {$id}");
    }

    /**
     * Updates the last run date for a specific schedule entry in the database.
     *
     * Updates the `last_run_at` column for a record in the `backpack_schedule` table based on the given ID.
     *
     * @param mixed $id The identifier of the schedule entry to be updated.
     *
     * @return void Does not return any value.
     */
    private function updateLastRunDate(mixed $id): void
    {
        global $wpdb;

        $wpdb->query("update `{$wpdb->prefix}backpack_schedule` set `last_run_at` = '" . \Carbon\Carbon::now($this->getTimezone())->toDateTimeString() . "' where `id` = {$id}");
    }

    /**
     * Decodes the settings from a JSON string to an associative array.
     *
     * Converts the 'settings' key within the provided schedule array from a JSON-encoded string
     * to a PHP associative array.
     *
     * @param mixed $schedule The schedule data containing a 'settings' key to be decoded.
     *
     * @return mixed The modified schedule with the decoded 'settings' key.
     */
    private function decodeSettings(mixed $schedule): mixed
    {
        if (! array_key_exists('settings', $schedule) || empty($schedule['settings'])) {
            return $schedule;
        }

        $schedule['settings'] = json_decode($schedule['settings'], true);

        return $schedule;
    }

    /**
     * Verifies the backup path and ensures its existence.
     *
     * Checks if the specified backup path exists and attempts to create it if it does not.
     * If the directory creation fails, returns false. If successful or already existing,
     * returns the backup path.
     *
     * @return bool|string Returns the backup path as a string if verification is successful,
     *                     or false if the directory could not be created.
     */
    public function verifyBackupPath(): bool|string
    {
        // Check if the backup path exists
        $backupPath = get_option("mws_backpack_backup_path");

        $this->log("Backup path: {$backupPath}");

        if (! file_exists($backupPath)) {
            // Directory does not exist, attempt to create it
            if (! mkdir($backupPath, 0777, true) && ! is_dir($backupPath)) {
                return false;
            }
        }

        return $backupPath;
    }

    /**
     * Retrieves the domain name of the site.
     *
     * Removes the "https://" scheme from the site's URL and returns the resulting domain name.
     *
     * @return string The domain name of the site without the "https://" scheme.
     */
    public function getDomainName()
    {
        return str_replace("https://", "", get_site_url(scheme: "https"));
    }

    /**
     * Renders the output of a browser component and returns it as a string.
     *
     * Captures the output from the included browser.php file and returns the buffered content.
     *
     * @return string The HTML content generated by the browser component.
     */
    public function pathBrowser()
    {
        ob_start();

        include(__DIR__ . '/browser.php');

        return ob_get_clean();
    }

    /**
     * Retrieves the configured timezone setting.
     *
     * Fetches the timezone string from the stored options. If no timezone is set, it defaults to 'UTC'.
     *
     * @return string Returns the timezone string or 'UTC' if no timezone is configured.
     */
    public function getTimezone(): string
    {
        return get_option('timezone_string') ?: 'UTC';
    }

    /**
     * Displays success notices stored in a transient.
     *
     * Checks for the presence of a success message in a transient. If found, it outputs the success message in a dismissible admin notice and then deletes the transient to prevent repetitive display.
     *
     * @return void Does not return any value.
     */
    public function notices(): void
    {
        $success = get_transient('mws_backpack_success_message');

        if ($success) {
            echo '<div class="notice notice-success is-dismissible"><p>' . $success . '</p></div>';
            delete_transient('mws_backpack_success_message');
        }
    }

    /**
     * Retrieves the backup history from the database.
     *
     * Executes a query to fetch information about backups, including their ID, schedule name, and file path. The results are ordered by creation date and ID in descending order.
     *
     * @return array|object Returns an array of backup history records or an empty array if no records are found.
     */
    public function getBackupHistory(): array|object
    {
        global $wpdb;

        $table_name = $wpdb->prefix . 'backpack_history';

        $query   = "SELECT h.id as ID, s.id as schedule_ID, s.name, h.path, h.created_at FROM {$wpdb->prefix}backpack_history h LEFT JOIN {$wpdb->prefix}backpack_schedule s ON s.id = h.schedule_id ORDER BY created_at DESC, id DESC"; // Match field names to your table schema
        $results = $wpdb->get_results($query, ARRAY_A);

        return $results ? $results : [];
    }

    /**
     * Saves a backup history record in the database.
     *
     * Inserts a new row into the backup history table with the provided schedule ID and file path, along with the current timestamp.
     *
     * @param int    $scheduleID The ID of the backup schedule associated with this history entry.
     * @param string $path       The file path where the backup is stored.
     *
     * @return int The ID of the newly inserted backup history record.
     */
    public function saveBackupHistory(int $scheduleID, string $path): int
    {
        global $wpdb;

        $wpdb->insert(
            "{$wpdb->prefix}backpack_history",
            [
                'schedule_id' => $scheduleID,
                'path'        => $path,
                'created_at'  => \Carbon\Carbon::now($this->getTimezone())->toDateTimeString(),
                'updated_at'  => \Carbon\Carbon::now($this->getTimezone())->toDateTimeString(),
            ],
            [
                '%s', // schedule ID
                '%s', // path
            ]
        );

        return $wpdb->insert_id;
    }

    /**
     * Registers custom REST API endpoints.
     *
     * Initializes custom REST API routes and their associated callbacks within the WordPress REST API.
     * It defines permission checks for the endpoints to control access based on user capabilities.
     *
     * @return void Does not return any value.
     */
    private function addEndpoints(): void
    {
        add_action('rest_api_init', function () {
            $canUseRoute = current_user_can('manage_options');

            register_rest_route('backpack/v1', '/path-browser', [
                'methods'             => 'GET',
                'callback'            => [$this, 'pathBrowser'],
                'permission_callback' => function () use ($canUseRoute) {
                    return $canUseRoute;
                },
            ]);
        });
    }

    /**
     * Determines if the backup path matches or is a subdirectory of the storage path.
     *
     * Compares the absolute paths of the storage directory and the provided backup path
     * to check if the backup path is within the storage directory.
     *
     * @return bool Returns true if the backup path is a subdirectory of the storage path, false otherwise.
     */
    private function backupPathMatchesStoragePath(): bool
    {
        $storagePath = get_option('mws_backpack_backup_path');
        $backupPath  = $_POST["mws_backpack_task_settings"]["file"]["path"];

        // Check if $storagePath exists within $backupPath recursively as a directory
        $realStoragePath = realpath($storagePath);
        $realBackupPath  = realpath($backupPath);

        if ($realStoragePath && $realBackupPath && str_starts_with($realBackupPath, rtrim($realStoragePath, '/') . '/')) {
            return true;
        }

        return false;
    }

    /**
     * Generate a URL friendly "slug" from a given string.
     *
     * @param string $title
     * @param string $separator
     *
     * @return string
     */
    public function slugify(string $title, string $separator = '-'): string
    {
        // Convert all dashes/underscores into separator
        $flip = $separator === '-' ? '_' : '-';

        $title = preg_replace('![' . preg_quote($flip) . ']+!u', $separator, $title);

        // Replace @ with the word 'at'
        $title = str_replace('@', $separator . 'at' . $separator, $title);

        // Remove all characters that are not the separator, letters, numbers, or whitespace.
        $title = preg_replace('![^' . preg_quote($separator) . '\pL\pN\s]+!u', '', mb_strtolower($title, 'UTF-8'));

        // Replace all separator characters and whitespace by a single separator
        $title = preg_replace('![' . preg_quote($separator) . '\s]+!u', $separator, $title);

        return trim($title, $separator);
    }

    /**
     * Initializes the update checking mechanism for the plugin.
     *
     * Registers an action during the "plugins_loaded" hook to set up the update checker using a specified URL,
     * plugin file path, and slug. This ensures that the plugin can check for and handle updates dynamically.
     *
     * @return void Does not return any value.
     */
    private function checkForUpdates(): void
    {
        // Add update checker
        add_action("plugins_loaded", function () {
            PucFactory::buildUpdateChecker(
                'https://toybox.maxweb.cloud/api/v2/plugin/backpack/json',
                $this->file, //Full path to the main plugin file or functions.php.
                'backpack'
            );
        });
    }
}
