<?php

if (!defined('ABSPATH')) {
    die('You are not allowed to call this page directly.');
}

class MeprDbCtrl extends MeprBaseCtrl
{
    /**
     * Option name for the atomic database migration lock.
     *
     * @var string
     */
    const MIGRATION_LOCK_OPTION = 'mepr_migration_lock';

    /**
     * Result of upgrade(): no migration was needed.
     *
     * @var string
     */
    const UPGRADE_NOT_NEEDED = 'not_needed';

    /**
     * Result of upgrade(): another process holds the migration lock.
     *
     * @var string
     */
    const UPGRADE_LOCKED = 'locked';

    /**
     * Result of upgrade(): this process acquired the lock and ran the migrations.
     *
     * @var string
     */
    const UPGRADE_COMPLETED = 'completed';

    /**
     * Loads the hooks.
     *
     * @return void
     */
    public function load_hooks()
    {
        // DB upgrades will happen here, as a non-blocking process hopefully.
        add_action('init', [$this,'upgrade_needed'], 1);
        add_action('wp_ajax_mepr_db_upgrade', [$this,'ajax_db_upgrade']);
        add_action('wp_ajax_mepr_db_upgrade_success', [$this,'ajax_db_upgrade_success']);
        add_action('wp_ajax_mepr_db_upgrade_not_needed', [$this,'ajax_db_upgrade_not_needed']);
        add_action('wp_ajax_mepr_db_upgrade_in_progress', [$this,'ajax_db_upgrade_in_progress']);
        add_action('wp_ajax_mepr_db_upgrade_error', [$this,'ajax_db_upgrade_error']);

        add_filter('cron_schedules', [$this,'intervals']);
        add_action('mepr_migrate_members_table_015', 'MeprDbMigrations::populate_inactive_memberships_col_015');

        // Cleanup soft db migrate for now
        // TODO: Remove soon.
        $timestamp = wp_next_scheduled('mepr_migration_worker');
        if ($timestamp) {
            wp_unschedule_event($timestamp, 'mepr_migration_worker');
        }
    }

    /**
     * Intervals.
     *
     * @param  array $schedules The schedules.
     * @return array
     */
    public function intervals($schedules)
    {
        $schedules['mepr_migrate_members_table_015_interval'] = [
            'interval' => MeprUtils::minutes(10), // Run every 10 minutes.
            'display'  => 'MemberPress Member Data Migrate Interval',
        ];

        return $schedules;
    }

    /**
     * Upgrade needed.
     *
     * @return void
     */
    public function upgrade_needed()
    {
        if (defined('DOING_AJAX')) {
            return;
        }

        if (isset($_GET['mepraction']) && $_GET['mepraction'] === 'cancel-migration') {
            // This is a state-mutating escape hatch (clears the lock/transients and marks
            // the DB current). It runs on `init` for every request, so require an
            // authenticated admin and a valid nonce before touching anything.
            $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '';
            if (!MeprUtils::is_mepr_admin() || !wp_verify_nonce($nonce, 'mepr_cancel_migration')) {
                return;
            }

            delete_transient('mepr_migrating');
            delete_transient('mepr_migration_error');
            delete_transient('mepr_current_migration');
            delete_option(self::MIGRATION_LOCK_OPTION);
            update_option('mepr_db_version', MEPR_VERSION);

            return;
        }

        $mepr_db = new MeprDb();

        if (is_admin() && MeprUtils::is_mepr_admin() && $mepr_db->show_upgrade_ui()) {
            MeprView::render('admin/db/upgrade_needed');
            exit;
        } else {
            try {
                delete_transient('mepr_migration_error'); // Reset migration error transient.
                $this->upgrade();
            } catch (MeprDbMigrationException $e) {
                // We just log the error?
            }
        }
    }

    /**
     * Ajax db upgrade.
     *
     * @return void
     */
    public function ajax_db_upgrade()
    {
        check_ajax_referer('db_upgrade', 'mepr_db_upgrade_nonce');

        // Network super admin and non-network admins will be the only ones dealing with upgrades.
        if (!MeprUtils::is_mepr_admin()) {
            header('HTTP/1.1 403 Forbidden');
            exit(json_encode(['error' => __('You\'re unauthorized to access this resource.', 'memberpress')]));
        }

        $mepr_db = MeprDb::fetch();

        header('Content-Type: application/json');

        if ($mepr_db->do_upgrade()) {
            try {
                delete_transient('mepr_migration_error'); // Reset migration error transient.
                $result = $this->upgrade();

                exit(json_encode($this->upgrade_result_response($result)));
            } catch (MeprDbMigrationException $e) {
                header('HTTP/1.1 500 Internal Error');
                exit(json_encode([
                    'status'  => 'error',
                    'message' => $e->getMessage(),
                ]));
            }
        }

        exit(json_encode([
            'status'  => 'already_migrated',
            'message' => __('No need to upgrade your database', 'memberpress'),
        ]));
    }

    /**
     * Maps an upgrade() result constant to the ajax_db_upgrade() response payload.
     *
     * @param  string $result One of the UPGRADE_* constants returned by upgrade().
     * @return array
     */
    protected function upgrade_result_response($result)
    {
        if ($result === self::UPGRADE_LOCKED) {
            // Another process is already migrating: tell the UI to poll the
            // in-progress endpoint instead of reporting a (false) completion.
            return [
                'status'  => 'in_progress',
                'message' => __('A database upgrade is already in progress', 'memberpress'),
            ];
        }

        if ($result === self::UPGRADE_NOT_NEEDED) {
            // Another process finished the upgrade between our do_upgrade() check and
            // acquiring the lock: report it as already migrated rather than complete.
            return [
                'status'  => 'already_migrated',
                'message' => __('No need to upgrade your database', 'memberpress'),
            ];
        }

        return [
            'status'  => 'complete',
            'message' => __('Your Upgrade has completed successfully', 'memberpress'),
        ];
    }

    /**
     * Ajax db upgrade in progress.
     *
     * @return void
     */
    public function ajax_db_upgrade_in_progress()
    {
        check_ajax_referer('db_upgrade_in_progress', 'mepr_db_upgrade_nonce');

        $mig = new MeprDbMigrations();

        // Network super admin and non-network admins will be the only ones dealing with upgrades.
        if (!MeprUtils::is_mepr_admin()) {
            header('HTTP/1.1 403 Forbidden');
            exit(json_encode(['error' => __('You\'re unauthorized to access this resource.', 'memberpress')]));
        }

        $version = get_transient('mepr_migrating');

        if (!empty($version)) {
            $current_migration = get_transient('mepr_current_migration');

            if (!isset($current_migration['check']) || (bool) $current_migration['check'] === false) {
                $check = [
                    'completed' => 0,
                    'total'     => 0,
                    'progress'  => 0,
                ];
            } else {
                $check = call_user_func([$mig, $current_migration['check']]);
            }

            $message = ((!isset($current_migration['message']) || empty($current_migration['message'])) ? __('MemberPress is currently upgrading your database', 'memberpress') : $current_migration['message']);

            extract($check);

            header('HTTP/1.1 200 OK');
            $status = 'in_progress';
            exit(
                json_encode(
                    compact('status', 'version', 'progress', 'message', 'completed', 'total')
                )
            );
        } else {
            $error = get_transient('mepr_migration_error');
            if ($error) {
                header('HTTP/1.1 500 Internal Error');
                exit(json_encode(['error' => $error]));
            } else {
                header('HTTP/1.1 200 OK');
                exit(json_encode([
                    'status'  => 'not_in_progress',
                    'message' => __('No MemberPress database upgrade is in progress', 'memberpress'),
                ]));
            }
        }

        exit;
    }

    /**
     * Ajax db upgrade success.
     *
     * @return void
     */
    public function ajax_db_upgrade_success()
    {
        check_ajax_referer('db_upgrade_success', 'mepr_db_upgrade_nonce');

        $error = get_transient('mepr_migration_error');
        if ($error) {
            MeprView::render('admin/db/upgrade_error', compact('error'));
        } else {
            MeprView::render('admin/db/upgrade_success');
        }
        exit;
    }

    /**
     * Ajax db upgrade not needed.
     *
     * @return void
     */
    public function ajax_db_upgrade_not_needed()
    {
        check_ajax_referer('db_upgrade_not_needed', 'mepr_db_upgrade_nonce');

        MeprView::render('admin/db/upgrade_not_needed');
        exit;
    }

    /**
     * Ajax db upgrade error.
     *
     * @return void
     */
    public function ajax_db_upgrade_error()
    {
        check_ajax_referer('db_upgrade_error', 'mepr_db_upgrade_nonce');

        $error = get_transient('mepr_migration_error');
        MeprView::render('admin/db/upgrade_error', compact('error'));
        exit;
    }

    /**
     * INSTALL PLUGIN
     * Handled in the same way wp-cron does it ...
     * fast, non-blocking post with an ignore_user_abort
     */

    /**
     * Upgrade.
     *
     * @return string One of the self::UPGRADE_* constants describing the outcome.
     */
    public function upgrade()
    {
        $mepr_db = MeprDb::fetch();

        // Nothing to migrate: never touch the lock.
        if (!$mepr_db->do_upgrade()) {
            return self::UPGRADE_NOT_NEEDED;
        }

        $token = $this->acquire_migration_lock();

        // A non-expired lock is held by another process (or ownership could not be
        // confirmed): bail without migrating.
        if ($token === null) {
            return self::UPGRADE_LOCKED;
        }

        // Tracks whether migrations finished normally; consulted by the shutdown handler.
        $completed = false;

        // Cleanup callback: releases only our own lock and clears the progress marker.
        $release = function () use ($token) {
            $this->release_migration_lock($token);
            delete_transient('mepr_migrating');
        };

        // Arm the shutdown safety net before publishing the progress marker so the brief
        // window between owning the lock and setting the marker is covered too. `finally`
        // does not run on fatal errors (e.g. OOM) or hard timeouts, so this is the only
        // cleanup that survives them. If the run died before completing, record a migration
        // error so the UI does not mistake the cleared progress marker for success (the
        // poller treats "no progress" as completion). The lock's encoded expiry remains the
        // ultimate backstop if even the shutdown handler cannot run.
        register_shutdown_function(function () use (&$completed, $release) {
            if (!$completed && !get_transient('mepr_migration_error')) {
                set_transient(
                    'mepr_migration_error',
                    __('The database upgrade did not complete. Please try again.', 'memberpress'),
                    MeprUtils::hours(4)
                );
            }
            $release();
        });

        // Publish the progress marker now that the safety net is armed so the in-progress
        // AJAX endpoint reflects an active migration, keeping it consistent with the lock.
        set_transient('mepr_migrating', MEPR_VERSION, MeprUtils::hours(4));

        if (function_exists('ignore_user_abort')) {
            ignore_user_abort(true);
        }
        if (function_exists('set_time_limit')) {
            set_time_limit(0); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
        }

        try {
            // Leave transactions up to the individual migrations now.
            if (is_multisite() && (int) $GLOBALS['blog_id'] === 1) {
                // On the root blog, upgrade every site on the network.
                $mepr_db->upgrade_multisite();
            } else {
                $mepr_db->upgrade();
            }
            $completed = true;
        } finally {
            $release();
        }

        return self::UPGRADE_COMPLETED;
    }

    /**
     * Atomically acquire the database migration lock.
     *
     * The lock lives in a single `wp_options` row whose value is "<expiry_ts>:<token>".
     * The expiry lets a crashed run self-heal (the next caller takes over once it has
     * passed), and the token lets us release only our own lock. Because `option_name`
     * carries a UNIQUE key, MySQL serializes concurrent acquirers on this statement.
     *
     * @throws MeprDbMigrationException When the acquire query hits a SQL/connection error.
     *
     * @return string|null The lock token on success (fresh lock or expired-lock takeover),
     *                      or null when a non-expired lock is held by another process.
     */
    protected function acquire_migration_lock()
    {
        global $wpdb;

        $now        = time();
        $expires_at = $now + MeprUtils::hours(4);
        $token      = $expires_at . ':' . wp_generate_uuid4();

        // The expiry is compared against the same PHP clock that generated it (passed as a
        // bound integer) rather than MySQL's UNIX_TIMESTAMP(), for consistency across hosts.
        // A malformed/legacy value with no ":" prefix casts to 0, which is always < $now, so
        // it is intentionally treated as expired and taken over (self-healing).
        $wpdb->query(
            $wpdb->prepare(
                "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)
                 VALUES (%s, %s, 'no')
                 ON DUPLICATE KEY UPDATE
                     option_value = IF(
                         CAST(SUBSTRING_INDEX(option_value, ':', 1) AS UNSIGNED) < %d,
                         %s,
                         option_value
                     )",
                self::MIGRATION_LOCK_OPTION,
                $token,
                $now,
                $token
            )
        );

        // A SQL/connection failure must not be mistaken for lock contention (which would
        // silently yield UPGRADE_LOCKED); surface it as a real migration error instead.
        if (!empty($wpdb->last_error)) {
            throw new MeprDbMigrationException(
                sprintf(
                    // Translators: %s: the database error message.
                    esc_html__('Could not acquire the database migration lock: %s', 'memberpress'),
                    esc_html($wpdb->last_error)
                )
            );
        }

        // The acquire writes via raw SQL, so clear the options cache to stay coherent.
        wp_cache_delete(self::MIGRATION_LOCK_OPTION, 'options');

        // Authoritative ownership check (defense in depth): read the row back and confirm it
        // holds our token. This does not rely on rows_affected semantics, so it stays correct
        // even if the connection were ever configured with CLIENT_FOUND_ROWS.
        $stored = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
                self::MIGRATION_LOCK_OPTION
            )
        );

        return $stored === $token ? $token : null;
    }

    /**
     * Release the database migration lock, but only if this process still owns it.
     *
     * The DELETE is keyed on the exact token so a late release (e.g. from the shutdown
     * handler of a run that overran its expiry) can never clear a lock that another
     * process has since legitimately taken over. Safe to call more than once.
     *
     * @param  string $token The token returned by acquire_migration_lock().
     * @return void
     */
    protected function release_migration_lock($token)
    {
        global $wpdb;

        $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
                self::MIGRATION_LOCK_OPTION,
                $token
            )
        );

        // The acquire path writes via raw SQL, so clear the options cache to stay coherent.
        wp_cache_delete(self::MIGRATION_LOCK_OPTION, 'options');
    }
}
