<?php

/**
 * Action Scheduler - Bulletproof Scheduling System
 *
 * A centralized, standardized system for scheduling actions using WooCommerce Action Scheduler.
 * Provides duplicate prevention, consistent argument formatting, and comprehensive logging.
 *
 * @package ArraySubs\Supports
 * @since 1.0.0
 */

namespace ArraySubs\Supports;

defined('ABSPATH') || exit;

/**
 * Action Scheduler wrapper class
 *
 * This class provides a bulletproof scheduling system with:
 * - Duplicate prevention (no duplicate scheduled actions)
 * - Consistent argument formatting (indexed arrays only)
 * - Consistent group naming (arraysubs-{group})
 * - Lock mechanism to prevent concurrent execution
 * - Comprehensive logging for debugging
 * - Meta tracking for scheduled actions
 *
 * USAGE RULES:
 * 1. Always use indexed arrays for arguments: [$id] not ['id' => $id]
 * 2. Always use the predefined groups from ActionScheduler::GROUP_*
 * 3. Always use the predefined hooks from ActionScheduler::HOOK_*
 * 4. For subscription-specific actions, pass subscription_id as first arg
 * 5. For global/recurring jobs, use empty array [] for args
 *
 * @example Single action for a subscription:
 *   ActionScheduler::scheduleSingle(
 *       ActionScheduler::HOOK_PROCESS_RENEWAL,
 *       $timestamp,
 *       [$subscription_id],
 *       ActionScheduler::GROUP_RENEWALS
 *   );
 *
 * @example Recurring global job:
 *   ActionScheduler::scheduleRecurring(
 *       ActionScheduler::HOOK_CHECK_OVERDUE,
 *       $start_time,
 *       HOUR_IN_SECONDS,
 *       [],
 *       ActionScheduler::GROUP_BILLING
 *   );
 */
class ActionScheduler
{
    // =========================================================================
    // HOOK CONSTANTS - All scheduled action hooks must be defined here
    // =========================================================================

    /**
     * Process a single subscription renewal
     * Args: [$subscription_id]
     */
    const HOOK_PROCESS_RENEWAL = 'arraysubs_process_renewal';

    /**
     * Process trial to paid conversion
     * Args: [$subscription_id] for single, [] for batch job
     */
    const HOOK_PROCESS_TRIAL_CONVERSION = 'arraysubs_process_trial_conversion';

    /**
     * Process trial conversions (batch job)
     * Args: []
     */
    const HOOK_PROCESS_TRIAL_CONVERSIONS = 'arraysubs_process_trial_conversions';

    /**
     * Generate upcoming renewal invoices (batch job)
     * Args: []
     */
    const HOOK_GENERATE_UPCOMING_RENEWALS = 'arraysubs_generate_upcoming_renewals';

    /**
     * Generate a renewal invoice for one subscription
     * Args: [$subscription_id]
     */
    const HOOK_GENERATE_RENEWAL_INVOICE = 'arraysubs_generate_renewal_invoice';

    /**
     * Check and handle overdue renewals (batch job)
     * Args: []
     */
    const HOOK_CHECK_OVERDUE_RENEWALS = 'arraysubs_check_overdue_renewals';

    /**
     * Cancel a subscription due to non-payment
     * Args: [$subscription_id]
     */
    const HOOK_CANCEL_SUBSCRIPTION = 'arraysubs_cancel_subscription';

    /**
     * Put subscription on hold
     * Args: [$subscription_id]
     */
    const HOOK_HOLD_SUBSCRIPTION = 'arraysubs_hold_subscription';

    /**
     * Expire a subscription (end of term)
     * Args: [$subscription_id]
     */
    const HOOK_EXPIRE_SUBSCRIPTION = 'arraysubs_expire_subscription';

    /**
     * Send renewal reminder email
     * Args: [$subscription_id, $days_before]
     */
    const HOOK_SEND_RENEWAL_REMINDER = 'arraysubs_send_renewal_reminder';

    /**
     * Send payment failed notification
     * Args: [$subscription_id, $order_id]
     */
    const HOOK_SEND_PAYMENT_FAILED = 'arraysubs_send_payment_failed';

    /**
     * Send expiring soon notification
     * Args: [$subscription_id]
     */
    const HOOK_SEND_EXPIRING_SOON = 'arraysubs_send_expiring_soon';

    /**
     * Clean up old data (batch job)
     * Args: []
     */
    const HOOK_CLEANUP_OLD_DATA = 'arraysubs_cleanup_old_data';

    /**
     * Resume a paused subscription (from retention pause offer)
     * Args: [$subscription_id]
     */
    const HOOK_RESUME_SUBSCRIPTION = 'arraysubs_resume_subscription';

    /**
     * Process a skipped renewal cycle (advance dates, decrement counter)
     * Args: [$subscription_id]
     */
    const HOOK_PROCESS_SKIPPED_CYCLE = 'arraysubs_process_skipped_cycle';

    /**
     * Expire store credits (batch job)
     * Args: []
     */
    const HOOK_EXPIRE_STORE_CREDITS = 'arraysubs_expire_store_credits';

    /**
     * Send credit expiring soon notification
     * Args: [$customer_id, $credit_log_id]
     */
    const HOOK_SEND_CREDIT_EXPIRING = 'arraysubs_send_credit_expiring';

    /**
     * Clean up old webhook events from the idempotency table (batch job)
     * Args: []
     */
    const HOOK_CLEANUP_WEBHOOK_EVENTS = 'arraysubs_cleanup_webhook_events';

    /**
     * Reconcile gateway status for subscriptions (batch job)
     * Args: []
     */
    const HOOK_GATEWAY_RECONCILE = 'arraysubs_gateway_reconcile';

    /**
     * Backfill retention logs from existing cancelled subscription meta
     * Args: [$offset]
     */
    const HOOK_BACKFILL_RETENTION_LOGS = 'arraysubs_backfill_retention_logs';

    /**
     * Subscription mutation lock namespace.
     * Args: [$subscription_id]
     */
    const HOOK_SUBSCRIPTION_MUTATION = 'arraysubs_subscription_mutation';

    // =========================================================================
    // GROUP CONSTANTS - All groups must be defined here
    // =========================================================================

    /**
     * Renewal-related actions (process renewals)
     */
    const GROUP_RENEWALS = 'arraysubs-renewals';

    /**
     * Billing-related batch jobs (trial conversions, overdue checks)
     */
    const GROUP_BILLING = 'arraysubs-billing';

    /**
     * Email notifications
     */
    const GROUP_EMAILS = 'arraysubs-emails';

    /**
     * Subscription status changes (cancel, hold, expire)
     */
    const GROUP_STATUS = 'arraysubs-status';

    /**
     * Maintenance and cleanup jobs
     */
    const GROUP_MAINTENANCE = 'arraysubs-maintenance';

    /**
     * Gateway-related actions (webhook cleanup, reconciliation)
     */
    const GROUP_GATEWAY = 'arraysubs-gateway';

    // =========================================================================
    // LOCK CONSTANTS
    // =========================================================================

    /**
     * Lock expiration time in seconds (5 minutes)
     */
    const LOCK_EXPIRATION = 300;

    /**
     * Transient prefix for execution locks
     */
    const LOCK_PREFIX = 'arraysubs_action_lock_';

    /**
     * Transient prefix for lock timeout tracking
     */
    const LOCK_TIMEOUT_PREFIX = 'arraysubs_lock_timeout_';

    // =========================================================================
    // PUBLIC METHODS - SCHEDULING
    // =========================================================================

    /**
     * Schedule a single action
     *
     * Automatically prevents duplicates by unscheduling existing actions first.
     *
     * @param string $hook      Hook name (use self::HOOK_* constants)
     * @param int    $timestamp Unix timestamp when to run
     * @param array  $args      Arguments (MUST be indexed array)
     * @param string $group     Group name (use self::GROUP_* constants)
     * @return int|false        Action ID on success, false on failure
     */
    public static function scheduleSingle(string $hook, int $timestamp, array $args = [], string $group = self::GROUP_BILLING)
    {
        if (!self::isAvailable()) {
            self::log('Action Scheduler not available', 'error');
            return false;
        }

        // Validate arguments are indexed array
        if (!self::validateArgs($args)) {
            self::log("Invalid args format for {$hook} - must be indexed array", 'error');
            return false;
        }

        // Validate hook is one of our defined hooks
        if (!self::isValidHook($hook)) {
            self::log("Unknown hook: {$hook} - should use ActionScheduler::HOOK_* constants", 'warning');
        }

        // Validate group is one of our defined groups
        if (!self::isValidGroup($group)) {
            self::log("Unknown group: {$group} - should use ActionScheduler::GROUP_* constants", 'warning');
        }

        // Prevent duplicates: unschedule any existing actions with same hook+args+group
        self::unscheduleAll($hook, $args, $group);

        // Schedule the action
        $action_id = as_schedule_single_action($timestamp, $hook, $args, $group);

        if ($action_id) {
            // Store meta for subscription-specific actions
            if (!empty($args) && is_numeric($args[0])) {
                $meta_key = self::getMetaKey($hook);
                if ($meta_key) {
                    update_post_meta((int) $args[0], $meta_key, $action_id);
                }
            }

            self::log(sprintf(
                'Scheduled: %s at %s | Args: %s | Group: %s | Action ID: %d',
                $hook,
                gmdate('Y-m-d H:i:s', $timestamp),
                \wp_json_encode($args),
                $group,
                $action_id
            ));
        }

        return $action_id;
    }

    /**
     * Schedule a recurring action
     *
     * Only schedules if not already scheduled (prevents duplicates).
     *
     * @param string $hook      Hook name (use self::HOOK_* constants)
     * @param int    $timestamp First run timestamp
     * @param int    $interval  Interval in seconds between runs
     * @param array  $args      Arguments (MUST be indexed array)
     * @param string $group     Group name (use self::GROUP_* constants)
     * @return int|false        Action ID on success, false on failure or already exists
     */
    public static function scheduleRecurring(string $hook, int $timestamp, int $interval, array $args = [], string $group = self::GROUP_BILLING)
    {
        if (!self::isAvailable()) {
            self::log('Action Scheduler not available', 'error');
            return false;
        }

        // Validate arguments are indexed array
        if (!self::validateArgs($args)) {
            self::log("Invalid args format for {$hook} - must be indexed array", 'error');
            return false;
        }

        // Check if already scheduled - don't create duplicates
        if (self::isScheduled($hook, $args, $group)) {
            self::log(sprintf(
                'Skipped scheduling recurring: %s already scheduled | Args: %s | Group: %s',
                $hook,
                \wp_json_encode($args),
                $group
            ), 'debug');
            return false;
        }

        // Schedule the recurring action
        $action_id = as_schedule_recurring_action($timestamp, $interval, $hook, $args, $group);

        if ($action_id) {
            self::log(sprintf(
                'Scheduled recurring: %s starting %s every %d seconds | Args: %s | Group: %s | Action ID: %d',
                $hook,
                gmdate('Y-m-d H:i:s', $timestamp),
                $interval,
                \wp_json_encode($args),
                $group,
                $action_id
            ));
        }

        return $action_id;
    }

    /**
     * Schedule an action if not already scheduled
     *
     * Unlike scheduleSingle(), this does NOT unschedule existing - just skips if exists.
     * Use this when you want to ensure an action runs but don't want to reset the timer.
     *
     * @param string $hook      Hook name
     * @param int    $timestamp Unix timestamp
     * @param array  $args      Arguments
     * @param string $group     Group name
     * @return int|false        Action ID, or false if already scheduled or error
     */
    public static function scheduleIfNotExists(string $hook, int $timestamp, array $args = [], string $group = self::GROUP_BILLING)
    {
        if (!self::isAvailable()) {
            return false;
        }

        if (!self::validateArgs($args)) {
            self::log("Invalid args format for {$hook}", 'error');
            return false;
        }

        if (self::isScheduled($hook, $args, $group)) {
            self::log(sprintf(
                'Already scheduled: %s | Args: %s | Group: %s',
                $hook,
                \wp_json_encode($args),
                $group
            ), 'debug');
            return false;
        }

        return as_schedule_single_action($timestamp, $hook, $args, $group);
    }

    // =========================================================================
    // PUBLIC METHODS - UNSCHEDULING
    // =========================================================================

    /**
     * Unschedule all actions matching hook, args, and group
     *
     * @param string $hook  Hook name
     * @param array  $args  Arguments (must match exactly)
     * @param string $group Group name
     * @return void
     */
    public static function unscheduleAll(string $hook, array $args = [], string $group = self::GROUP_BILLING): void
    {
        if (!self::isAvailable() || !function_exists('as_unschedule_all_actions')) {
            return;
        }

        as_unschedule_all_actions($hook, $args, $group);

        // Clean up meta for subscription-specific actions
        if (!empty($args) && is_numeric($args[0])) {
            $meta_key = self::getMetaKey($hook);
            if ($meta_key) {
                delete_post_meta((int) $args[0], $meta_key);
            }
        }

        self::log(sprintf(
            'Unscheduled all: %s | Args: %s | Group: %s',
            $hook,
            \wp_json_encode($args),
            $group
        ), 'debug');
    }

    /**
     * Unschedule actions whose first argument is a subscription ID.
     *
     * Some hooks include extra arguments after the subscription ID, such as
     * renewal reminders: [$subscription_id, $days_before].
     *
     * @param string $hook            Hook name.
     * @param int    $subscription_id Subscription ID.
     * @param string $group           Action Scheduler group.
     * @return void
     */
    public static function unscheduleAllForSubscriptionArg(string $hook, int $subscription_id, string $group = self::GROUP_BILLING): void
    {
        self::unscheduleAll($hook, [$subscription_id], $group);

        if (
            !self::isAvailable()
            || !function_exists('as_get_scheduled_actions')
            || !function_exists('as_unschedule_action')
            || !class_exists('\ActionScheduler')
            || !class_exists('\ActionScheduler_Store')
        ) {
            return;
        }

        $action_ids = as_get_scheduled_actions(
            [
                'hook'     => $hook,
                'group'    => $group,
                'status'   => \ActionScheduler_Store::STATUS_PENDING,
                'per_page' => -1,
            ],
            'ids'
        );

        foreach ($action_ids as $action_id) {
            try {
                $action = \ActionScheduler::store()->fetch_action((int) $action_id);
            } catch (\Throwable $exception) {
                self::log(
                    sprintf('Could not inspect action #%d while unscheduling %s: %s', (int) $action_id, $hook, $exception->getMessage()),
                    'warning'
                );
                continue;
            }

            $args = $action->get_args();

            if ((int) ($args[0] ?? 0) !== $subscription_id) {
                continue;
            }

            as_unschedule_action($hook, $args, $group);
        }

        $meta_key = self::getMetaKey($hook);
        if ($meta_key) {
            delete_post_meta($subscription_id, $meta_key);
        }
    }

    /**
     * Unschedule all actions for a subscription
     *
     * Useful when cancelling or deleting a subscription.
     *
     * @param int $subscription_id Subscription ID
     * @return void
     */
    public static function unscheduleAllForSubscription(int $subscription_id): void
    {
        $hooks = [
            self::HOOK_PROCESS_RENEWAL => self::GROUP_RENEWALS,
            self::HOOK_GENERATE_RENEWAL_INVOICE => self::GROUP_BILLING,
            self::HOOK_PROCESS_TRIAL_CONVERSION => self::GROUP_BILLING,
            self::HOOK_CANCEL_SUBSCRIPTION => self::GROUP_STATUS,
            self::HOOK_HOLD_SUBSCRIPTION => self::GROUP_STATUS,
            self::HOOK_EXPIRE_SUBSCRIPTION => self::GROUP_STATUS,
            self::HOOK_RESUME_SUBSCRIPTION => self::GROUP_STATUS,
            self::HOOK_PROCESS_SKIPPED_CYCLE => self::GROUP_BILLING,
            self::HOOK_SEND_RENEWAL_REMINDER => self::GROUP_EMAILS,
            self::HOOK_SEND_EXPIRING_SOON => self::GROUP_EMAILS,
        ];

        foreach ($hooks as $hook => $group) {
            self::unscheduleAllForSubscriptionArg($hook, $subscription_id, $group);
        }

        self::log(sprintf(
            'Unscheduled all actions for subscription #%d',
            $subscription_id
        ));
    }

    // =========================================================================
    // PUBLIC METHODS - QUERY
    // =========================================================================

    /**
     * Check if an action is scheduled
     *
     * @param string $hook  Hook name
     * @param array  $args  Arguments
     * @param string $group Group name
     * @return bool
     */
    public static function isScheduled(string $hook, array $args = [], string $group = self::GROUP_BILLING): bool
    {
        if (!self::isAvailable() || !function_exists('as_has_scheduled_action')) {
            return false;
        }

        return as_has_scheduled_action($hook, $args, $group);
    }

    /**
     * Get next scheduled time for an action
     *
     * @param string $hook  Hook name
     * @param array  $args  Arguments
     * @param string $group Group name
     * @return int|false    Timestamp or false
     */
    public static function getNextScheduledTime(string $hook, array $args = [], string $group = self::GROUP_BILLING)
    {
        if (!self::isAvailable() || !function_exists('as_next_scheduled_action')) {
            return false;
        }

        return as_next_scheduled_action($hook, $args, $group);
    }

    /**
     * Get all scheduled actions for a subscription
     *
     * @param int $subscription_id Subscription ID
     * @return array Array of scheduled action info
     */
    public static function getScheduledForSubscription(int $subscription_id): array
    {
        if (!self::isAvailable() || !function_exists('as_get_scheduled_actions')) {
            return [];
        }

        $scheduled = [];
        $hooks = [
            self::HOOK_PROCESS_RENEWAL,
            self::HOOK_GENERATE_RENEWAL_INVOICE,
            self::HOOK_PROCESS_TRIAL_CONVERSION,
            self::HOOK_CANCEL_SUBSCRIPTION,
            self::HOOK_HOLD_SUBSCRIPTION,
            self::HOOK_EXPIRE_SUBSCRIPTION,
            self::HOOK_SEND_RENEWAL_REMINDER,
            self::HOOK_SEND_EXPIRING_SOON,
        ];

        foreach ($hooks as $hook) {
            $next_time = self::getNextScheduledTime($hook, [$subscription_id], self::getGroupForHook($hook));
            if ($next_time) {
                $scheduled[$hook] = [
                    'timestamp' => $next_time,
                    'datetime' => gmdate('Y-m-d H:i:s', $next_time),
                ];
            }
        }

        return $scheduled;
    }

    // =========================================================================
    // PUBLIC METHODS - EXECUTION LOCKS (Prevent concurrent execution)
    // =========================================================================

    /**
     * Acquire a lock before processing an action
     *
     * Uses ATOMIC database operation to prevent race conditions.
     * This is bulletproof against:
     * - Multiple simultaneous calls from Action Scheduler
     * - 3rd party plugins triggering the same hook
     * - Concurrent cron requests
     *
     * @param string $hook Hook name
     * @param array  $args Arguments
     * @return bool        True if lock acquired, false if already locked
     */
    public static function acquireLock(string $hook, array $args = []): bool
    {
        global $wpdb;

        // Validate wpdb is available
        if (!$wpdb || !($wpdb instanceof \wpdb)) {
            self::log(sprintf(
                'Lock acquisition failed for %s - wpdb not available',
                $hook
            ), 'error');
            return false;
        }

        $lock_key = self::getLockKey($hook, $args);
        $expiration = time() + self::LOCK_EXPIRATION;
        $transient_key = '_transient_' . $lock_key;

        // ATOMIC OPERATION: Try to insert lock, fail if exists and not expired
        // This prevents race conditions between check and set

        // First, clean up any expired lock (with retry for race conditions)
        $current_time = time();
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic lock cleanup requires direct query
        $deleted = $wpdb->query($wpdb->prepare(
            'DELETE FROM ' . $wpdb->options . ' WHERE option_name = %s AND CAST(option_value AS UNSIGNED) > 0 AND CAST(option_value AS UNSIGNED) < %d',
            $transient_key,
            $current_time
        ));

        // Check for database errors during cleanup
        if ($deleted === false && $wpdb->last_error) {
            self::log(sprintf(
                'Lock cleanup failed for %s | Error: %s',
                $hook,
                $wpdb->last_error
            ), 'error');
            // Continue anyway - might still succeed
        }

        // Now try to insert atomically - this will fail if lock exists
        // Using INSERT IGNORE ensures atomicity - either we get it or we don't
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic lock acquisition requires direct query
        $result = $wpdb->query($wpdb->prepare(
            'INSERT IGNORE INTO ' . $wpdb->options . ' (option_name, option_value, autoload) VALUES (%s, %d, %s)',
            $transient_key,
            $expiration,
            'no'
        ));

        // Check for database errors
        if ($result === false || $wpdb->last_error) {
            self::log(sprintf(
                'Lock acquisition query failed for %s | Error: %s',
                $hook,
                $wpdb->last_error ?: 'Unknown error'
            ), 'error');
            return false;
        }

        // If insert succeeded (1 row affected), we got the lock
        if ($result === 1) {
            // Also set expiration transient for automatic cleanup
            // Transient failure is non-critical since lock is already in DB
            $timeout_key    = self::LOCK_TIMEOUT_PREFIX . self::computeArgsHash($hook, $args);
            $transient_set  = set_transient($timeout_key, $expiration, self::LOCK_EXPIRATION);
            if (!$transient_set) {
                self::log(sprintf('Timeout transient set failed for %s (non-critical)', $hook), 'debug');
            }

            self::log(sprintf(
                'Lock acquired for %s | Args: %s | Expires: %s (%d seconds)',
                $hook,
                wp_json_encode($args),
                gmdate('Y-m-d H:i:s', $expiration),
                self::LOCK_EXPIRATION
            ), 'debug');

            return true;
        }

        // Lock exists and is not expired (result was 0 - no rows inserted)
        self::log(sprintf(
            'Lock already exists for %s | Args: %s | Another process is handling this',
            $hook,
            wp_json_encode($args)
        ), 'warning');

        return false;
    }

    /**
     * Release a lock after processing an action
     *
     * Always call this after your action handler completes (success or failure).
     * Use in a finally block to ensure release even on exceptions.
     *
     * @param string $hook Hook name
     * @param array  $args Arguments
     * @return void
     */
    public static function releaseLock(string $hook, array $args = []): void
    {
        global $wpdb;

        // Validate wpdb is available
        if (!$wpdb || !($wpdb instanceof \wpdb)) {
            self::log(sprintf(
                'Lock release failed for %s - wpdb not available',
                $hook
            ), 'error');
            return;
        }

        $lock_key = self::getLockKey($hook, $args);
        $transient_key = '_transient_' . $lock_key;

        // Delete directly from options table for atomic release
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic lock release requires direct query
        $result = $wpdb->delete(
            $wpdb->options,
            ['option_name' => $transient_key],
            ['%s']
        );

        // Check for database errors
        if ($result === false && $wpdb->last_error) {
            self::log(sprintf(
                'Lock release query failed for %s | Error: %s',
                $hook,
                $wpdb->last_error
            ), 'error');
        }

        // Also clean up timeout transient (non-critical if it fails)
        $timeout_key = self::LOCK_TIMEOUT_PREFIX . self::computeArgsHash($hook, $args);
        delete_transient($timeout_key);

        self::log(sprintf(
            'Lock released for %s | Args: %s | Rows deleted: %d',
            $hook,
            wp_json_encode($args),
            $result !== false ? $result : 0
        ), 'debug');
    }

    /**
     * Check if an action is currently locked
     *
     * @param string $hook Hook name
     * @param array  $args Arguments
     * @return bool
     */
    public static function isLocked(string $hook, array $args = []): bool
    {
        global $wpdb;

        // Validate wpdb is available
        if (!$wpdb || !($wpdb instanceof \wpdb)) {
            self::log(sprintf(
                'Lock check failed for %s - wpdb not available',
                $hook
            ), 'error');
            return false; // Assume unlocked if we can't check
        }

        $lock_key = self::getLockKey($hook, $args);
        $transient_key = '_transient_' . $lock_key;

        // Check if lock exists and is not expired
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lock status check requires direct query
        $lock_expiration = $wpdb->get_var($wpdb->prepare(
            'SELECT option_value FROM ' . $wpdb->options . ' WHERE option_name = %s',
            $transient_key
        ));

        // Check for database errors
        if ($lock_expiration === null && $wpdb->last_error) {
            self::log(sprintf(
                'Lock check query failed for %s | Error: %s',
                $hook,
                $wpdb->last_error
            ), 'error');
            return false; // Assume unlocked on error
        }

        if ($lock_expiration === null) {
            return false;
        }

        // Validate expiration is numeric
        if (!is_numeric($lock_expiration)) {
            self::log(sprintf(
                'Invalid lock expiration value for %s: %s',
                $hook,
                $lock_expiration
            ), 'error');
            // Clean up invalid lock
            self::releaseLock($hook, $args);
            return false;
        }

        $expiration_time = (int) $lock_expiration;
        $current_time = time();

        // Check if expired
        if ($expiration_time < $current_time) {
            // Clean up expired lock
            self::releaseLock($hook, $args);
            return false;
        }

        return true;
    }

    /**
     * Force release a lock (use with caution - admin tools only)
     *
     * @param string $hook Hook name
     * @param array  $args Arguments
     * @return void
     */
    public static function forceReleaseLock(string $hook, array $args = []): void
    {
        self::releaseLock($hook, $args);
        self::log(sprintf(
            'Lock FORCE released for %s | Args: %s',
            $hook,
            wp_json_encode($args)
        ), 'warning');
    }

    /**
     * Get lock status with details
     *
     * @param string $hook Hook name
     * @param array  $args Arguments
     * @return array{locked: bool, expires_at: int|null, expires_in: int|null}
     */
    public static function getLockStatus(string $hook, array $args = []): array
    {
        global $wpdb;

        // Default unlocked response
        $default_response = [
            'locked' => false,
            'expires_at' => null,
            'expires_in' => null,
        ];

        // Validate wpdb is available
        if (!$wpdb || !($wpdb instanceof \wpdb)) {
            self::log(sprintf(
                'Lock status check failed for %s - wpdb not available',
                $hook
            ), 'error');
            return $default_response;
        }

        $lock_key = self::getLockKey($hook, $args);
        $transient_key = '_transient_' . $lock_key;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lock status check requires direct query
        $lock_expiration = $wpdb->get_var($wpdb->prepare(
            'SELECT option_value FROM ' . $wpdb->options . ' WHERE option_name = %s',
            $transient_key
        ));

        // Check for database errors
        if ($lock_expiration === null && $wpdb->last_error) {
            self::log(sprintf(
                'Lock status query failed for %s | Error: %s',
                $hook,
                $wpdb->last_error
            ), 'error');
            return $default_response;
        }

        if ($lock_expiration === null || !is_numeric($lock_expiration)) {
            return $default_response;
        }

        $expiration_time = (int) $lock_expiration;
        $current_time = time();

        if ($expiration_time < $current_time) {
            return $default_response;
        }

        return [
            'locked' => true,
            'expires_at' => $expiration_time,
            'expires_in' => $expiration_time - $current_time,
        ];
    }

    /**
     * Clean up all expired locks across the system
     *
     * This should be called periodically (e.g., via cron) to clean up
     * locks that weren't properly released due to fatal errors, crashes, etc.
     *
     * @return int Number of locks cleaned up
     */
    public static function cleanupExpiredLocks(): int
    {
        global $wpdb;

        if (!$wpdb || !($wpdb instanceof \wpdb)) {
            return 0;
        }

        $current_time = time();
        $lock_pattern = '_transient_' . self::LOCK_PREFIX . '%';

        // Delete all expired locks in one query
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Batch lock cleanup requires direct query
        $deleted = $wpdb->query($wpdb->prepare(
            'DELETE FROM ' . $wpdb->options . '
             WHERE option_name LIKE %s
             AND CAST(option_value AS UNSIGNED) > 0
             AND CAST(option_value AS UNSIGNED) < %d',
            $lock_pattern,
            $current_time
        ));

        if ($deleted && $deleted > 0) {
            self::log(sprintf(
                'Cleaned up %d expired lock(s)',
                $deleted
            ), 'info');
        }

        return $deleted !== false ? (int) $deleted : 0;
    }

    /**
     * Get all active locks (for debugging/monitoring)
     *
     * @return array Array of active locks with their details
     */
    public static function getAllActiveLocks(): array
    {
        global $wpdb;

        if (!$wpdb || !($wpdb instanceof \wpdb)) {
            return [];
        }

        $current_time = time();
        $lock_pattern = '_transient_' . self::LOCK_PREFIX . '%';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lock listing requires direct query
        $results = $wpdb->get_results($wpdb->prepare(
            'SELECT option_name, option_value
             FROM ' . $wpdb->options . '
             WHERE option_name LIKE %s
             AND CAST(option_value AS UNSIGNED) > 0
             AND CAST(option_value AS UNSIGNED) >= %d
             ORDER BY option_value ASC',
            $lock_pattern,
            $current_time
        ), ARRAY_A);

        if (!$results) {
            return [];
        }

        $locks = [];
        foreach ($results as $row) {
            $lock_name = str_replace('_transient_' . self::LOCK_PREFIX, '', $row['option_name']);
            $expiration = (int) $row['option_value'];

            $locks[] = [
                'key' => $lock_name,
                'expires_at' => $expiration,
                'expires_in' => $expiration - $current_time,
                'expires_at_formatted' => gmdate('Y-m-d H:i:s', $expiration),
            ];
        }

        return $locks;
    }

    // =========================================================================
    // PUBLIC METHODS - UTILITIES
    // =========================================================================

    /**
     * Check if Action Scheduler is available
     *
     * @return bool
     */
    public static function isAvailable(): bool
    {
        return self::hasApiFunctions() && self::isInitialized();
    }

    /**
     * Check whether Action Scheduler API functions are loaded.
     *
     * @return bool
     */
    public static function hasApiFunctions(): bool
    {
        return function_exists('as_schedule_single_action');
    }

    /**
     * Check whether the Action Scheduler data store has been initialized.
     *
     * @return bool
     */
    public static function isInitialized(): bool
    {
        return \did_action('action_scheduler_init') > 0;
    }

    /**
     * Get all defined hooks
     *
     * @return array
     */
    public static function getDefinedHooks(): array
    {
        return [
            'HOOK_PROCESS_RENEWAL' => self::HOOK_PROCESS_RENEWAL,
            'HOOK_PROCESS_TRIAL_CONVERSION' => self::HOOK_PROCESS_TRIAL_CONVERSION,
            'HOOK_PROCESS_TRIAL_CONVERSIONS' => self::HOOK_PROCESS_TRIAL_CONVERSIONS,
            'HOOK_GENERATE_UPCOMING_RENEWALS' => self::HOOK_GENERATE_UPCOMING_RENEWALS,
            'HOOK_GENERATE_RENEWAL_INVOICE' => self::HOOK_GENERATE_RENEWAL_INVOICE,
            'HOOK_CHECK_OVERDUE_RENEWALS' => self::HOOK_CHECK_OVERDUE_RENEWALS,
            'HOOK_CANCEL_SUBSCRIPTION' => self::HOOK_CANCEL_SUBSCRIPTION,
            'HOOK_HOLD_SUBSCRIPTION' => self::HOOK_HOLD_SUBSCRIPTION,
            'HOOK_EXPIRE_SUBSCRIPTION' => self::HOOK_EXPIRE_SUBSCRIPTION,
            'HOOK_SEND_RENEWAL_REMINDER' => self::HOOK_SEND_RENEWAL_REMINDER,
            'HOOK_SEND_PAYMENT_FAILED' => self::HOOK_SEND_PAYMENT_FAILED,
            'HOOK_SEND_EXPIRING_SOON' => self::HOOK_SEND_EXPIRING_SOON,
            'HOOK_CLEANUP_OLD_DATA' => self::HOOK_CLEANUP_OLD_DATA,
            'HOOK_RESUME_SUBSCRIPTION' => self::HOOK_RESUME_SUBSCRIPTION,
            'HOOK_PROCESS_SKIPPED_CYCLE' => self::HOOK_PROCESS_SKIPPED_CYCLE,
            'HOOK_EXPIRE_STORE_CREDITS' => self::HOOK_EXPIRE_STORE_CREDITS,
            'HOOK_SEND_CREDIT_EXPIRING' => self::HOOK_SEND_CREDIT_EXPIRING,
            'HOOK_CLEANUP_WEBHOOK_EVENTS' => self::HOOK_CLEANUP_WEBHOOK_EVENTS,
            'HOOK_GATEWAY_RECONCILE' => self::HOOK_GATEWAY_RECONCILE,
            'HOOK_BACKFILL_RETENTION_LOGS' => self::HOOK_BACKFILL_RETENTION_LOGS,
        ];
    }

    /**
     * Get all defined groups
     *
     * @return array
     */
    public static function getDefinedGroups(): array
    {
        return [
            'GROUP_RENEWALS' => self::GROUP_RENEWALS,
            'GROUP_BILLING' => self::GROUP_BILLING,
            'GROUP_EMAILS' => self::GROUP_EMAILS,
            'GROUP_STATUS' => self::GROUP_STATUS,
            'GROUP_MAINTENANCE' => self::GROUP_MAINTENANCE,
            'GROUP_GATEWAY' => self::GROUP_GATEWAY,
        ];
    }

    // =========================================================================
    // PRIVATE METHODS
    // =========================================================================

    /**
     * Validate that args is an indexed array (not associative)
     *
     * @param array $args Arguments to validate.
     * @return bool
     */
    private static function validateArgs(array $args): bool
    {
        if (empty($args)) {
            return true;
        }

        // Check if keys are sequential integers starting from 0
        return array_keys($args) === range(0, count($args) - 1);
    }

    /**
     * Check if hook is a defined constant
     *
     * @param string $hook Hook name.
     * @return bool
     */
    private static function isValidHook(string $hook): bool
    {
        return in_array($hook, self::getDefinedHooks(), true);
    }

    /**
     * Check if group is a defined constant
     *
     * @param string $group Group name.
     * @return bool
     */
    private static function isValidGroup(string $group): bool
    {
        return in_array($group, self::getDefinedGroups(), true);
    }

    /**
     * Get the meta key for storing action ID
     *
     * @param string $hook Hook name.
     * @return string|null
     */
    private static function getMetaKey(string $hook): ?string
    {
        $map = [
            self::HOOK_PROCESS_RENEWAL => '_renewal_action_id',
            self::HOOK_GENERATE_RENEWAL_INVOICE => '_renewal_invoice_action_id',
            self::HOOK_PROCESS_TRIAL_CONVERSION => '_trial_conversion_action_id',
            self::HOOK_CANCEL_SUBSCRIPTION => '_cancel_action_id',
            self::HOOK_HOLD_SUBSCRIPTION => '_hold_action_id',
            self::HOOK_EXPIRE_SUBSCRIPTION => '_expire_action_id',
            self::HOOK_SEND_RENEWAL_REMINDER => '_renewal_reminder_action_id',
            self::HOOK_SEND_EXPIRING_SOON => '_expiring_soon_action_id',
            self::HOOK_RESUME_SUBSCRIPTION => '_resume_subscription_action_id',
            self::HOOK_PROCESS_SKIPPED_CYCLE => '_skip_cycle_action_id',
        ];

        return $map[$hook] ?? null;
    }

    /**
     * Get the group for a hook
     *
     * @param string $hook Hook name.
     * @return string
     */
    private static function getGroupForHook(string $hook): string
    {
        $map = [
            self::HOOK_PROCESS_RENEWAL => self::GROUP_RENEWALS,
            self::HOOK_PROCESS_TRIAL_CONVERSION => self::GROUP_BILLING,
            self::HOOK_PROCESS_TRIAL_CONVERSIONS => self::GROUP_BILLING,
            self::HOOK_GENERATE_UPCOMING_RENEWALS => self::GROUP_BILLING,
            self::HOOK_GENERATE_RENEWAL_INVOICE => self::GROUP_BILLING,
            self::HOOK_CHECK_OVERDUE_RENEWALS => self::GROUP_BILLING,
            self::HOOK_CANCEL_SUBSCRIPTION => self::GROUP_STATUS,
            self::HOOK_HOLD_SUBSCRIPTION => self::GROUP_STATUS,
            self::HOOK_EXPIRE_SUBSCRIPTION => self::GROUP_STATUS,
            self::HOOK_SEND_RENEWAL_REMINDER => self::GROUP_EMAILS,
            self::HOOK_SEND_PAYMENT_FAILED => self::GROUP_EMAILS,
            self::HOOK_SEND_EXPIRING_SOON => self::GROUP_EMAILS,
            self::HOOK_CLEANUP_OLD_DATA => self::GROUP_MAINTENANCE,
            self::HOOK_RESUME_SUBSCRIPTION => self::GROUP_STATUS,
            self::HOOK_PROCESS_SKIPPED_CYCLE => self::GROUP_BILLING,
            self::HOOK_EXPIRE_STORE_CREDITS => self::GROUP_MAINTENANCE,
            self::HOOK_SEND_CREDIT_EXPIRING => self::GROUP_EMAILS,
            self::HOOK_CLEANUP_WEBHOOK_EVENTS => self::GROUP_GATEWAY,
            self::HOOK_GATEWAY_RECONCILE => self::GROUP_GATEWAY,
            self::HOOK_BACKFILL_RETENTION_LOGS => self::GROUP_MAINTENANCE,
        ];

        return $map[$hook] ?? self::GROUP_BILLING;
    }

    /**
     * Get lock key for an action
     *
     * Generates a unique, deterministic key for locking.
     * Uses JSON encoding for better consistency than serialize().
     *
     * @param string $hook Hook name.
     * @param array  $args Action arguments.
     * @return string
     */
    private static function getLockKey(string $hook, array $args = []): string
    {
        return self::LOCK_PREFIX . self::computeArgsHash($hook, $args);
    }

    /**
     * Compute a deterministic hash from hook name and arguments
     *
     * Used to generate unique suffixes for lock and timeout transient keys.
     *
     * @param string $hook Hook name.
     * @param array  $args Action arguments.
     * @return string SHA1 hash string.
     */
    private static function computeArgsHash(string $hook, array $args = []): string
    {
        // Use JSON encoding for more predictable serialization
        // This avoids issues with serialize() and different PHP versions/settings
        $args_string = wp_json_encode($args, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        // Fallback if JSON encoding fails (shouldn't happen with indexed arrays)
        if ($args_string === false) {
            $args_string = implode('|', array_map('strval', $args));
        }

        // Create hash from hook name + args
        // Using sha1 instead of md5 for better collision resistance (minimal performance impact)
        return sha1($hook . '|' . $args_string);
    }

    /**
     * Log a message
     *
     * @param string $message Log message.
     * @param string $level   Log level: 'debug', 'info', 'warning', or 'error'.
     * @return void
     */
    private static function log(string $message, string $level = 'info'): void
    {
        // Only log debug messages when WP_DEBUG is enabled
        if ($level === 'debug' && (!defined('WP_DEBUG') || true !== (bool) constant('WP_DEBUG'))) {
            return;
        }

        $prefix = match ($level) {
            'error' => 'ERROR',
            'warning' => 'WARNING',
            'debug' => 'DEBUG',
            default => 'INFO',
        };
    }
}
