HEX
Server: Apache/2.4.65 (Debian)
System: Linux 88f31f35b0b8 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64
User: www-data (33)
PHP: 8.2.29
Disabled: NONE
Upload Files
File: /var/www/html/wp-content/plugins/ai-engine/classes/modules/tasks.php
<?php

class Meow_MWAI_Modules_Tasks {
  private $wpdb = null;
  private $core = null;
  public $table_tasks = null;
  public $table_tasklogs = null;
  private $db_check = false;
  private $namespace = 'mwai/v1';
  private $max_tasks_per_tick = 5;
  private $max_retries = 3;

  public function __construct( $core ) {
    global $wpdb;
    $this->wpdb = $wpdb;
    $this->core = $core;
    $this->table_tasks = $wpdb->prefix . 'mwai_tasks';
    $this->table_tasklogs = $wpdb->prefix . 'mwai_tasklogs';

    // Initialize database
    $this->check_db();

    // Register REST API
    add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );

    // Custom cron schedules - MUST be registered before using them
    add_filter( 'cron_schedules', [ $this, 'custom_cron_schedule' ], 5 );

    // Always register the action hooks
    add_action( 'mwai_tasks_internal_run', [ $this, 'tick' ] );
    add_action( 'mwai_tasks_internal_dev_run', [ $this, 'tick' ] );

    // Register cleanup tasks handler
    add_filter( 'mwai_task_cleanup_tasks', [ $this, 'handle_cleanup_tasks' ], 10, 2 );

    if ( is_admin() ) {
      add_action( 'init', [ $this, 'ensure_system_tasks' ], 20 );
      add_action( 'admin_init', [ $this, 'fix_overdue_cron' ] );
    }

    // Schedule crons on init (after custom schedules are registered)
    add_action( 'init', [ $this, 'ensure_cron_scheduled' ], 15 );

    // Load the Tasks Examples module (includes test task functionality)
    require_once( __DIR__ . '/tasks-examples.php' );
    new Meow_MWAI_Modules_Tasks_Examples( $core );
  }

  /**
   * Ensure cron is scheduled properly
   */
  public function ensure_cron_scheduled() {
    $dev_mode = $this->core->get_option( 'dev_mode' );
    $hook = $dev_mode ? 'mwai_tasks_internal_dev_run' : 'mwai_tasks_internal_run';
    $opposite_hook = $dev_mode ? 'mwai_tasks_internal_run' : 'mwai_tasks_internal_dev_run';

    // Clear opposite hook
    wp_clear_scheduled_hook( $opposite_hook );

    // Check if current hook is scheduled and not overdue
    $next = wp_next_scheduled( $hook );

    // If not scheduled or overdue by more than 5 minutes, reschedule
    if ( !$next || $next < ( time() - 300 ) ) {
      wp_clear_scheduled_hook( $hook );

      if ( $dev_mode ) {
        wp_schedule_event( time() + 5, 'five_seconds', $hook );
      }
      else {
        wp_schedule_event( time() + 60, 'one_minute', $hook );
      }
    }
  }

  /**
   * Fix overdue cron events
   */
  public function fix_overdue_cron() {
    $dev_mode = $this->core->get_option( 'dev_mode' );

    if ( $dev_mode ) {
      // Clear production cron if it exists
      wp_clear_scheduled_hook( 'mwai_tasks_internal_run' );

      // Ensure dev cron is scheduled
      if ( !wp_next_scheduled( 'mwai_tasks_internal_dev_run' ) ) {
        wp_schedule_event( time() + 5, 'five_seconds', 'mwai_tasks_internal_dev_run' );
      }
    }
    else {
      // Clear dev cron if it exists
      wp_clear_scheduled_hook( 'mwai_tasks_internal_dev_run' );

      // Ensure production cron is scheduled
      if ( !wp_next_scheduled( 'mwai_tasks_internal_run' ) ) {
        wp_schedule_event( time() + 60, 'one_minute', 'mwai_tasks_internal_run' );
      }
    }
  }

  public function custom_cron_schedule( $schedules ) {
    $schedules['one_minute'] = [ 'display' => __( 'Every Minute' ), 'interval' => 60 ];
    $schedules['five_seconds'] = [ 'display' => __( 'Every 5 Seconds' ), 'interval' => 5 ];
    return $schedules;
  }

  public function rest_api_init() {
    register_rest_route( $this->namespace, '/helpers/tasks_list', [
      'methods' => 'GET',
      'callback' => [ $this, 'rest_tasks_list' ],
      'permission_callback' => [ $this->core, 'can_access_settings' ],
    ] );

    register_rest_route( $this->namespace, '/helpers/task_run', [
      'methods' => 'POST',
      'callback' => [ $this, 'rest_task_run' ],
      'permission_callback' => [ $this->core, 'can_access_settings' ],
    ] );

    register_rest_route( $this->namespace, '/helpers/task_pause', [
      'methods' => 'POST',
      'callback' => [ $this, 'rest_task_pause' ],
      'permission_callback' => [ $this->core, 'can_access_settings' ],
    ] );

    register_rest_route( $this->namespace, '/helpers/task_resume', [
      'methods' => 'POST',
      'callback' => [ $this, 'rest_task_resume' ],
      'permission_callback' => [ $this->core, 'can_access_settings' ],
    ] );

    register_rest_route( $this->namespace, '/helpers/task_delete', [
      'methods' => 'POST',
      'callback' => [ $this, 'rest_task_delete' ],
      'permission_callback' => [ $this->core, 'can_access_settings' ],
    ] );

    register_rest_route( $this->namespace, '/helpers/task_logs', [
      'methods' => 'GET',
      'callback' => [ $this, 'rest_task_logs' ],
      'permission_callback' => [ $this->core, 'can_access_settings' ],
    ] );

    register_rest_route( $this->namespace, '/helpers/task_logs_delete', [
      'methods' => 'POST',
      'callback' => [ $this, 'rest_task_logs_delete' ],
      'permission_callback' => [ $this->core, 'can_access_settings' ],
    ] );

    register_rest_route( $this->namespace, '/helpers/tasks_reset', [
      'methods' => 'POST',
      'callback' => [ $this, 'rest_tasks_reset' ],
      'permission_callback' => [ $this->core, 'can_access_settings' ],
    ] );
  }

  /**
   * Ensure a task exists or update its configuration
   */
  public function ensure( $args ) {
    $defaults = [
      'name' => '',
      'description' => '',
      'category' => 'general',
      'schedule' => 'once',
      'next_run' => null,  // Allow specifying when a one-time task should run
      'is_multistep' => 0,
      'expires_at' => null,
      'auto_delete' => 0,
      'deletable' => 1,
      'data' => null,
      'step_name' => null,
    ];

    $args = wp_parse_args( $args, $defaults );

    if ( empty( $args['name'] ) ) {
      return new WP_Error( 'invalid_name', 'Task name is required' );
    }

    // Check if task exists
    $existing = $this->wpdb->get_row( $this->wpdb->prepare(
      "SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
      $args['name']
    ) );

    $now = gmdate( 'Y-m-d H:i:s' );

    if ( $existing ) {
      // Update existing task
      $update_data = [
        'description' => $args['description'],
        'category' => $args['category'],
        'updated' => $now,
      ];

      // Only update these if they've changed
      if ( $args['schedule'] !== $existing->schedule ) {
        $update_data['schedule'] = $args['schedule'];
        $update_data['next_run'] = $this->calculate_next_run( $args['schedule'] );
      }

      if ( $args['expires_at'] !== $existing->expires_at ) {
        $update_data['expires_at'] = $args['expires_at'];
      }

      if ( $args['data'] !== null ) {
        $existing_data = json_decode( $existing->data, true ) ?: [];
        $merged_data = array_merge( $existing_data, $args['data'] );
        $update_data['data'] = json_encode( $merged_data );
      }

      if ( $args['step_name'] !== null ) {
        $update_data['step_name'] = $args['step_name'];
      }

      $result = $this->wpdb->update(
        $this->table_tasks,
        $update_data,
        [ 'task_name' => $args['name'] ]
      );

      return $result !== false;
    }
    else {
      // Create new task
      // Use provided next_run for one-time tasks, otherwise calculate from schedule
      if ( $args['schedule'] === 'once' && $args['next_run'] ) {
        $next_run = $args['next_run'];
      }
      else {
        $next_run = $this->calculate_next_run( $args['schedule'] );
      }

      $insert_data = [
        'task_name' => $args['name'],
        'description' => $args['description'],
        'category' => $args['category'],
        'schedule' => $args['schedule'],
        'status' => 'pending',
        'next_run' => $next_run,
        'expires_at' => $args['expires_at'],
        'step' => 0,
        'step_name' => $args['step_name'],
        'step_data' => isset( $args['step_data'] ) ? json_encode( $args['step_data'] ) : null,
        'data' => json_encode( $args['data'] ?: [] ),
        'meta' => json_encode( [] ),
        'error_count' => 0,
        'max_retries' => $this->max_retries,
        'created' => $now,
        'updated' => $now,
      ];

      $result = $this->wpdb->insert( $this->table_tasks, $insert_data );

      return $result !== false;
    }
  }

  /**
   * Get a specific task by name
   */
  public function get_task( $task_name ) {
    return $this->wpdb->get_row( $this->wpdb->prepare(
      "SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
      $task_name
    ) );
  }

  /**
   * Create a new task directly
   */
  public function create_task( $task_data ) {
    $defaults = [
      'category' => 'general',
      'status' => 'pending', // Changed from 'active' to 'pending' to match tick() selection
      'next_run' => null,
      'expires_at' => null,
      'step' => 0,
      'step_name' => null,
      'step_data' => null,
      'data' => [],
      'meta' => [],
      'error_count' => 0,
      'max_retries' => 3,
      'description' => null
    ];

    $task_data = array_merge( $defaults, $task_data );
    $now = gmdate( 'Y-m-d H:i:s' );

    // Calculate next run if schedule provided and next_run not set
    if ( !empty( $task_data['schedule'] ) && empty( $task_data['next_run'] ) ) {
      $task_data['next_run'] = $this->calculate_next_run( $task_data['schedule'] );
    }

    // Ensure next_run is in proper datetime format if it's a timestamp
    if ( !empty( $task_data['next_run'] ) && is_numeric( $task_data['next_run'] ) ) {
      $task_data['next_run'] = gmdate( 'Y-m-d H:i:s', $task_data['next_run'] );
    }

    // If still no next_run, set to now
    if ( empty( $task_data['next_run'] ) ) {
      $task_data['next_run'] = $now;
    }

    $insert_data = [
      'task_name' => $task_data['task_name'],
      'description' => $task_data['description'],
      'category' => $task_data['category'],
      'schedule' => $task_data['schedule'],
      'status' => $task_data['status'],
      'next_run' => $task_data['next_run'],
      'expires_at' => $task_data['expires_at'],
      'step' => $task_data['step'],
      'step_name' => $task_data['step_name'],
      'step_data' => is_array( $task_data['step_data'] ) ? json_encode( $task_data['step_data'] ) : $task_data['step_data'],
      'data' => is_array( $task_data['data'] ) ? json_encode( $task_data['data'] ) : $task_data['data'],
      'meta' => is_array( $task_data['meta'] ) ? json_encode( $task_data['meta'] ) : $task_data['meta'],
      'error_count' => $task_data['error_count'],
      'max_retries' => $task_data['max_retries'],
      'created' => $now,
      'updated' => $now,
    ];

    return $this->wpdb->insert( $this->table_tasks, $insert_data ) !== false;
  }

  /**
   * Update a task by name
   */
  public function update_task( $task_name, $fields ) {
    $update_data = [ 'updated' => gmdate( 'Y-m-d H:i:s' ) ];

    foreach ( $fields as $key => $value ) {
      if ( in_array( $key, [ 'data', 'meta', 'step_data' ] ) && is_array( $value ) ) {
        $update_data[$key] = json_encode( $value );
      }
      else {
        $update_data[$key] = $value;
      }
    }

    return $this->wpdb->update(
      $this->table_tasks,
      $update_data,
      [ 'task_name' => $task_name ]
    ) !== false;
  }

  /**
   * Update task fields
   */
  public function update( $task_name, $fields ) {
    $allowed_fields = [ 'schedule', 'description', 'data', 'expires_at', 'step', 'step_name', 'step_data' ];
    $update_data = [ 'updated' => gmdate( 'Y-m-d H:i:s' ) ];

    foreach ( $fields as $key => $value ) {
      if ( in_array( $key, $allowed_fields ) ) {
        if ( $key === 'data' || $key === 'step_data' ) {
          $update_data[$key] = json_encode( $value );
        }
        else if ( $key === 'schedule' ) {
          $update_data[$key] = $value;
          $update_data['next_run'] = $this->calculate_next_run( $value );
        }
        else {
          $update_data[$key] = $value;
        }
      }
    }

    $result = $this->wpdb->update(
      $this->table_tasks,
      $update_data,
      [ 'task_name' => $task_name ]
    );

    return $result !== false;
  }

  /**
   * Remove a task
   */
  public function remove( $task_name, $opts = [] ) {
    $delete_logs = isset( $opts['delete_logs'] ) ? $opts['delete_logs'] : false;

    // Get task ID for logs deletion
    if ( $delete_logs ) {
      $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
        "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
        $task_name
      ) );

      if ( $task_id ) {
        $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task_id ] );
      }
    }

    $result = $this->wpdb->delete( $this->table_tasks, [ 'task_name' => $task_name ] );

    return $result !== false;
  }

  /**
   * Pause a task
   */
  public function pause( $task_name ) {
    $result = $this->wpdb->update(
      $this->table_tasks,
      [ 'status' => 'paused', 'updated' => gmdate( 'Y-m-d H:i:s' ) ],
      [ 'task_name' => $task_name ]
    );

    return $result !== false;
  }

  /**
   * Resume a task
   */
  public function resume( $task_name ) {
    // Get the task to determine schedule
    $task = $this->wpdb->get_row( $this->wpdb->prepare(
      "SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
      $task_name
    ) );

    if ( !$task ) {
      return false;
    }

    $next_run = $this->calculate_next_run( $task->schedule );

    $result = $this->wpdb->update(
      $this->table_tasks,
      [
        'status' => 'pending',
        'next_run' => $next_run,
        'updated' => gmdate( 'Y-m-d H:i:s' )
      ],
      [ 'task_name' => $task_name ]
    );

    return $result !== false;
  }

  /**
   * Run a task immediately
   */
  public function run_now( $task_name ) {
    // First check if task is stuck and reset it
    $task = $this->wpdb->get_row( $this->wpdb->prepare(
      "SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
      $task_name
    ) );

    if ( $task && $task->status === 'running' ) {
      // Reset stuck task - be more aggressive (1 minute instead of 10)
      $this->reset_stuck_tasks( 1 );
    }

    $result = $this->wpdb->update(
      $this->table_tasks,
      [
        'status' => 'pending',
        'next_run' => gmdate( 'Y-m-d H:i:s' ),
        'updated' => gmdate( 'Y-m-d H:i:s' )
      ],
      [ 'task_name' => $task_name ]
    );

    if ( $result !== false ) {
      // Optionally run tick once (but keep it light)
      $this->tick();
      return true;
    }

    return false;
  }

  /**
   * Reset tasks that are stuck in running state
   */
  public function reset_stuck_tasks( $minutes_threshold = 10 ) {
    $now = gmdate( 'Y-m-d H:i:s' );
    $stuck_cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$minutes_threshold} minutes" ) );

    $count = $this->wpdb->query( $this->wpdb->prepare(
      "UPDATE {$this->table_tasks} 
       SET status = 'pending', 
           error_count = error_count + 1,
           updated = %s
       WHERE status = 'running' 
       AND updated < %s",
      $now,
      $stuck_cutoff
    ) );

    return $count;
  }

  /**
   * Main execution loop - called by cron
   */
  public function tick() {
    // Track cron execution for proper "last run" display
    // Determine which hook is actually running
    $dev_mode = $this->core->get_option( 'dev_mode' );
    $hook_name = $dev_mode ? 'mwai_tasks_internal_dev_run' : 'mwai_tasks_internal_run';
    $this->core->track_cron_start( $hook_name );

    // Use UTC consistently
    $now = gmdate( 'Y-m-d H:i:s' );

    // First, reset any stuck tasks (running for more than 10 minutes)
    $this->reset_stuck_tasks();

    // Get due tasks
    $tasks = $this->wpdb->get_results( $this->wpdb->prepare(
      "SELECT * FROM {$this->table_tasks} 
       WHERE status IN ('pending', 'error') 
       AND next_run <= %s 
       AND (expires_at IS NULL OR expires_at > %s)
       ORDER BY next_run ASC
       LIMIT %d",
      $now,
      $now,
      $this->max_tasks_per_tick
    ) );

    foreach ( $tasks as $task ) {
      $this->execute_task( $task );
    }

    // Track cron completion
    $this->core->track_cron_end( $hook_name );
  }

  /**
   * Execute a single task
   */
  private function execute_task( $task ) {
    // Atomically claim the task
    $claimed = $this->wpdb->update(
      $this->table_tasks,
      [ 'status' => 'running', 'updated' => gmdate( 'Y-m-d H:i:s' ) ],
      [
        'id' => $task->id,
        'status' => $task->status // Ensure it hasn't changed
      ]
    );

    if ( !$claimed ) {
      return; // Another process got it
    }

    // Start logging
    $log_id = $this->log_start( $task->id );
    $start_time = microtime( true );

    // Build job array
    $job = [
      'name' => $task->task_name,
      'schedule' => $task->schedule,
      'step' => $task->step,
      'step_name' => $task->step_name,
      'data' => json_decode( $task->data, true ) ?: [],
      'meta' => json_decode( $task->meta, true ) ?: [],
    ];

    // Call the filter with error handling
    try {
      $result = apply_filters( "mwai_task_{$task->task_name}", null, $job );

      // Fallback to generic filter if specific one returns null
      if ( $result === null ) {
        $result = apply_filters( 'mwai_task_run', null, $job );
      }

      // Default result if nothing handles it
      if ( $result === null ) {
        $result = [
          'ok' => true,
          'message' => "No handler for '{$task->task_name}'",
        ];
      }
    }
    catch ( Exception $e ) {
      $result = [
        'ok' => false,
        'message' => 'Exception: ' . $e->getMessage(),
      ];
    }
    catch ( Error $e ) {
      $result = [
        'ok' => false,
        'message' => 'Fatal error: ' . $e->getMessage(),
      ];
    }

    // Normalize result
    $result = $this->normalize_result( $result );

    // Log the end
    $time_taken = microtime( true ) - $start_time;
    $this->log_end( $log_id, $result, $time_taken );

    // Update task based on result
    $this->finish_from_result( $task, $result );
  }

  /**
   * Normalize task result
   */
  private function normalize_result( $result ) {
    if ( !is_array( $result ) ) {
      return [
        'ok' => false,
        'message' => 'Invalid result format',
      ];
    }

    $defaults = [
      'ok' => false,
      'done' => true,
      'message' => '',
      'step' => null,
      'step_name' => null,
      'data' => null,
      'meta' => null,
      'next_run_delay' => null,
    ];

    return wp_parse_args( $result, $defaults );
  }

  /**
   * Update task after execution
   */
  private function finish_from_result( $task, $result ) {
    $now_ts = time();
    $now = gmdate( 'Y-m-d H:i:s', $now_ts );

    // Merge data and meta if provided
    $data = json_decode( $task->data, true ) ?: [];
    $meta = json_decode( $task->meta, true ) ?: [];

    if ( $result['data'] !== null && is_array( $result['data'] ) ) {
      $data = array_merge( $data, $result['data'] );
    }

    if ( $result['meta'] !== null && is_array( $result['meta'] ) ) {
      $meta = array_merge( $meta, $result['meta'] );
    }

    $update_data = [
      'data' => json_encode( $data ),
      'meta' => json_encode( $meta ),
      'last_run' => $now,
      'updated' => $now,
    ];

    // Update step if provided
    if ( $result['step'] !== null ) {
      $update_data['step'] = $result['step'];
    }
    if ( $result['step_name'] !== null ) {
      $update_data['step_name'] = $result['step_name'];
    }

    if ( $result['ok'] ) {
      // Success path
      $update_data['error_count'] = 0;

      if ( !$result['done'] ) {
        // Multi-step task not finished - continue quickly
        $update_data['status'] = 'pending';
        $delay = isset( $result['next_run_delay'] ) ? (int) $result['next_run_delay'] : 10;
        if ( $delay < 1 ) {
          $delay = 1;
        }
        $update_data['next_run'] = gmdate( 'Y-m-d H:i:s', $now_ts + $delay );
      }
      else if ( $task->schedule === 'once' ) {
        // One-off task completed
        $update_data['status'] = 'done';
        $update_data['next_run'] = null;
      }
      else {
        // Recurring task completed this cycle
        $update_data['status'] = 'pending';
        $update_data['next_run'] = $this->calculate_next_run( $task->schedule, $now_ts );
        $update_data['step'] = 0;
        $update_data['step_name'] = null;
      }
    }
    else {
      // Error path
      $update_data['error_count'] = $task->error_count + 1;

      if ( $update_data['error_count'] >= $task->max_retries ) {
        // Max retries reached or exceeded
        $update_data['status'] = 'error';
        $update_data['next_run'] = null;
      }
      else {
        // Retry with backoff
        $update_data['status'] = 'pending';
        $update_data['next_run'] = gmdate( 'Y-m-d H:i:s', $now_ts + 300 ); // 5 minutes
      }
    }

    // Check expiration
    if ( $task->expires_at && strtotime( $task->expires_at ) <= $now_ts ) {
      $update_data['status'] = 'expired';
      $update_data['next_run'] = null;
    }

    // Update the task
    $this->wpdb->update(
      $this->table_tasks,
      $update_data,
      [ 'id' => $task->id ]
    );

    // Auto-delete expired tasks that have an expiration date
    if ( $task->expires_at && $update_data['status'] === 'expired' ) {
      $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
      // Also delete logs for expired tasks
      $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
    }
  }

  /**
   * Handle cleanup tasks - remove old logs and failed tasks
   */
  public function handle_cleanup_tasks( $result, $job ) {
    try {
      $now = gmdate( 'Y-m-d H:i:s' );
      $stats = [
        'logs_deleted' => 0,
        'failed_tasks_deleted' => 0,
        'expired_tasks_deleted' => 0,
      ];

      // 1. Delete task logs older than 7 days
      $week_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );
      $logs_deleted = $this->wpdb->query( $this->wpdb->prepare(
        "DELETE FROM {$this->table_tasklogs} WHERE created < %s",
        $week_ago
      ) );
      $stats['logs_deleted'] = $logs_deleted ? $logs_deleted : 0;

      // 2. Delete failed tasks that have been in error state for over 30 days
      $month_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) );
      $failed_tasks = $this->wpdb->get_results( $this->wpdb->prepare(
        "SELECT id, task_name FROM {$this->table_tasks} 
         WHERE status = 'error' AND updated < %s",
        $month_ago
      ) );

      foreach ( $failed_tasks as $task ) {
        // Delete the task and its logs
        $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
        $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
        $stats['failed_tasks_deleted']++;
      }

      // 3. Delete expired tasks that have been expired for over 7 days
      $expired_tasks = $this->wpdb->get_results( $this->wpdb->prepare(
        "SELECT id, task_name FROM {$this->table_tasks} 
         WHERE status = 'expired' AND updated < %s",
        $week_ago
      ) );

      foreach ( $expired_tasks as $task ) {
        // Delete the task and its logs
        $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
        $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
        $stats['expired_tasks_deleted']++;
      }

      // 4. Clean up orphaned logs (logs without corresponding tasks)
      $orphaned_logs = $this->wpdb->query(
        "DELETE tl FROM {$this->table_tasklogs} tl
         LEFT JOIN {$this->table_tasks} t ON tl.task_id = t.id
         WHERE t.id IS NULL"
      );

      $message = sprintf(
        'Cleaned: %d logs, %d failed tasks, %d expired tasks',
        $stats['logs_deleted'],
        $stats['failed_tasks_deleted'],
        $stats['expired_tasks_deleted']
      );

      return [
        'ok' => true,
        'message' => $message,
        'data' => $stats
      ];

    }
    catch ( Exception $e ) {
      return [
        'ok' => false,
        'message' => 'Cleanup failed: ' . $e->getMessage()
      ];
    }
  }

  /**
   * Calculate next run time
   */
  private function calculate_next_run( $schedule, $after_ts = null ) {
    if ( $schedule === 'once' ) {
      // For one-time tasks without a specific time, run immediately
      return gmdate( 'Y-m-d H:i:s' );
    }

    if ( $after_ts === null ) {
      $after_ts = time();
    }

    $next_ts = $this->cron_next( $schedule, $after_ts );
    return gmdate( 'Y-m-d H:i:s', $next_ts );
  }

  /**
   * Parse cron expression and get next run time
   */
  private function cron_next( $expr, $after_ts ) {
    $parts = $this->parse_cron( $expr );
    if ( !$parts ) {
      // Invalid expression, return next hour
      return $after_ts + 3600;
    }

    // Start from the next minute
    $check_ts = $after_ts - ( $after_ts % 60 ) + 60;

    // Check up to 2 years in the future (should be more than enough)
    $max_ts = $after_ts + ( 2 * 365 * 24 * 60 * 60 );

    while ( $check_ts < $max_ts ) {
      $time = getdate( $check_ts );

      if ( $this->cron_matches( $parts, $time ) ) {
        return $check_ts;
      }

      // Move to next minute
      $check_ts += 60;
    }

    // Fallback to next hour if no match found
    return $after_ts + 3600;
  }

  /**
   * Parse cron expression into parts
   */
  private function parse_cron( $expr ) {
    if ( empty( $expr ) ) {
      return false;
    }

    $fields = preg_split( '/\s+/', trim( $expr ) );
    if ( count( $fields ) !== 5 ) {
      return false;
    }

    return [
      'minute' => $this->parse_cron_field( $fields[0], 0, 59 ),
      'hour' => $this->parse_cron_field( $fields[1], 0, 23 ),
      'dom' => $this->parse_cron_field( $fields[2], 1, 31 ),
      'month' => $this->parse_cron_field( $fields[3], 1, 12 ),
      'dow' => $this->parse_cron_field( $fields[4], 0, 7 ),
    ];
  }

  /**
   * Parse a single cron field
   */
  private function parse_cron_field( $field, $min, $max ) {
    if ( $field === '*' ) {
      return range( $min, $max );
    }

    $values = [];

    // Handle step values (*/N)
    if ( strpos( $field, '/' ) !== false ) {
      list( $range, $step ) = explode( '/', $field );
      $step = (int) $step;

      if ( $range === '*' ) {
        for ( $i = $min; $i <= $max; $i += $step ) {
          $values[] = $i;
        }
      }
      else if ( strpos( $range, '-' ) !== false ) {
        list( $start, $end ) = explode( '-', $range );
        $start = (int) $start;
        $end = (int) $end;
        for ( $i = $start; $i <= $end && $i <= $max; $i += $step ) {
          $values[] = $i;
        }
      }
      return $values;
    }

    // Handle ranges (N-M)
    if ( strpos( $field, '-' ) !== false ) {
      list( $start, $end ) = explode( '-', $field );
      return range( (int) $start, min( (int) $end, $max ) );
    }

    // Handle lists (N,M,...)
    if ( strpos( $field, ',' ) !== false ) {
      $parts = explode( ',', $field );
      foreach ( $parts as $part ) {
        $values[] = (int) $part;
      }
      return $values;
    }

    // Single value
    return [ (int) $field ];
  }

  /**
   * Check if time matches cron expression
   */
  private function cron_matches( $parts, $time ) {
    // Check minute
    if ( !in_array( (int) $time['minutes'], $parts['minute'] ) ) {
      return false;
    }

    // Check hour
    if ( !in_array( (int) $time['hours'], $parts['hour'] ) ) {
      return false;
    }

    // Check month
    if ( !in_array( (int) $time['mon'], $parts['month'] ) ) {
      return false;
    }

    // Check day of month OR day of week (standard cron behavior)
    $dom_match = in_array( (int) $time['mday'], $parts['dom'] );
    $dow_match = in_array( (int) $time['wday'], $parts['dow'] );

    // Handle Sunday as both 0 and 7
    if ( in_array( 7, $parts['dow'] ) && $time['wday'] == 0 ) {
      $dow_match = true;
    }

    // If both dom and dow are restricted (*not* wildcards), either can match
    // If one is wildcard, only the restricted one needs to match
    $dom_restricted = !( count( $parts['dom'] ) === 31 );
    $dow_restricted = !( count( $parts['dow'] ) === 8 || count( $parts['dow'] ) === 7 );

    if ( $dom_restricted && $dow_restricted ) {
      return $dom_match || $dow_match;
    }
    else if ( $dom_restricted ) {
      return $dom_match;
    }
    else if ( $dow_restricted ) {
      return $dow_match;
    }

    return true;
  }

  /**
   * Log task start
   */
  private function log_start( $task_id ) {
    $this->wpdb->insert(
      $this->table_tasklogs,
      [
        'task_id' => $task_id,
        'started' => gmdate( 'Y-m-d H:i:s' ),
        'status' => 'running',
        'created' => gmdate( 'Y-m-d H:i:s' ),
      ]
    );

    return $this->wpdb->insert_id;
  }

  /**
   * Log task end
   */
  private function log_end( $log_id, $result, $time_taken = null ) {
    $status = 'error';
    if ( $result['ok'] && $result['done'] ) {
      $status = 'success';
    }
    else if ( $result['ok'] && !$result['done'] ) {
      $status = 'partial';
    }

    $this->wpdb->update(
      $this->table_tasklogs,
      [
        'ended' => gmdate( 'Y-m-d H:i:s' ),
        'status' => $status,
        'message' => substr( $result['message'], 0, 255 ),
        'time_taken' => $time_taken,
        'memory_peak' => memory_get_peak_usage(),
        'step' => $result['step'],
      ],
      [ 'id' => $log_id ]
    );
  }

  public function ensure_system_tasks() {
    $this->ensure( [
      'name' => 'cleanup_discussions',
      'description' => 'Remove old discussions beyond retention period.',
      'category' => 'system',
      'schedule' => '0 3 * * *', // Daily at 3 AM UTC
    ] );

    $this->ensure( [
      'name' => 'cleanup_statistics',
      'description' => 'Remove old query logs beyond Insights retention period.',
      'category' => 'system',
      'schedule' => '0 2 * * *', // Daily at 2 AM UTC
    ] );

    // Ensure cleanup_files task exists
    $this->ensure( [
      'name' => 'cleanup_files',
      'category' => 'system',
      'description' => 'Delete expired files based on expiration dates.',
      'schedule' => '0 4 * * *', // Daily at 4 AM UTC
    ] );

    // Ensure cleanup_tasks exists
    $this->ensure( [
      'name' => 'cleanup_tasks',
      'description' => 'Clean old task logs and failed tasks.',
      'category' => 'system',
      'schedule' => '0 13 * * *', // Daily at 1 PM UTC
      'deletable' => 0, // System task, not deletable
    ] );
  }

  /**
   * REST: List tasks
   */
  public function rest_tasks_list( $request ) {
    // Make sure table exists
    $this->check_db();

    $tasks = $this->wpdb->get_results(
      "SELECT * FROM {$this->table_tasks} ORDER BY task_name ASC"
    );

    if ( $tasks === false ) {
      return new WP_REST_Response( [ 'success' => false, 'message' => 'Database error', 'tasks' => [] ], 500 );
    }

    if ( empty( $tasks ) ) {
      $tasks = [];
    }

    // Add computed fields
    foreach ( $tasks as &$task ) {
      $task->data = json_decode( $task->data, true );
      $task->meta = json_decode( $task->meta, true );
      $task->step_data = $task->step_data ? json_decode( $task->step_data, true ) : null;

      // Ensure integers are properly cast
      $task->step = (int) $task->step;
      $task->error_count = (int) $task->error_count;
      $task->max_retries = (int) $task->max_retries;

      // Fix tasks that should be in error status but aren't
      if ( $task->error_count >= $task->max_retries && $task->status === 'pending' ) {
        $task->status = 'error';
        $task->next_run = null;
      }

      // Determine if task is deletable (system tasks cannot be deleted)
      $task->deletable = !in_array( $task->task_name, ['cleanup_discussions', 'cleanup_files'] ) ? 1 : 0;

      // Get last message from most recent log
      $last_log = $this->wpdb->get_row( $this->wpdb->prepare(
        "SELECT message FROM {$this->table_tasklogs} WHERE task_id = %d ORDER BY started DESC LIMIT 1",
        $task->id
      ) );
      $task->last_message = $last_log ? $last_log->message : null;

      // Get log count for this task
      $log_count = $this->wpdb->get_var( $this->wpdb->prepare(
        "SELECT COUNT(*) FROM {$this->table_tasklogs} WHERE task_id = %d",
        $task->id
      ) );
      $task->log_count = (int) $log_count;

      // Calculate next 3 run times for preview
      if ( $task->schedule !== 'once' && $task->status === 'pending' ) {
        $next_runs = [];
        $check_ts = $task->next_run ? strtotime( $task->next_run ) : time();

        for ( $i = 0; $i < 3; $i++ ) {
          $check_ts = $this->cron_next( $task->schedule, $check_ts );
          $next_runs[] = gmdate( 'Y-m-d H:i:s', $check_ts );
        }

        $task->next_runs_preview = $next_runs;
      }
      else {
        $task->next_runs_preview = [];
      }

      // Format times for display
      if ( $task->last_run ) {
        $task->last_run_human = $this->human_time_diff( strtotime( $task->last_run ) );
      }
      else {
        $task->last_run_human = 'Never';
      }

      // Only show next_run for tasks that are actually scheduled to run
      // Don't show next_run for error, done, or expired tasks
      if ( $task->next_run && in_array( $task->status, ['pending', 'running', 'paused'] ) ) {
        $task->next_run_human = $this->human_time_diff( strtotime( $task->next_run ) );
      }
      else {
        $task->next_run_human = null;
        $task->next_run = null; // Clear it so frontend doesn't try to display it
      }
    }

    return new WP_REST_Response( [ 'success' => true, 'tasks' => $tasks ], 200 );
  }

  /**
   * REST: Run task now
   */
  public function rest_task_run( $request ) {
    $params = $request->get_json_params();
    $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;

    if ( !$task_name ) {
      return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
    }

    $result = $this->run_now( $task_name );

    if ( $result ) {
      return new WP_REST_Response( [ 'success' => true, 'message' => 'Task scheduled to run' ], 200 );
    }

    return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to run task' ], 500 );
  }

  /**
   * REST: Pause task
   */
  public function rest_task_pause( $request ) {
    $params = $request->get_json_params();
    $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;

    if ( !$task_name ) {
      return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
    }

    $result = $this->pause( $task_name );

    if ( $result ) {
      return new WP_REST_Response( [ 'success' => true, 'message' => 'Task paused' ], 200 );
    }

    return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to pause task' ], 500 );
  }

  /**
   * REST: Resume task
   */
  public function rest_task_resume( $request ) {
    $params = $request->get_json_params();
    $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;

    if ( !$task_name ) {
      return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
    }

    $result = $this->resume( $task_name );

    if ( $result ) {
      return new WP_REST_Response( [ 'success' => true, 'message' => 'Task resumed' ], 200 );
    }

    return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to resume task' ], 500 );
  }

  /**
   * REST: Delete task
   */
  public function rest_task_delete( $request ) {
    $params = $request->get_json_params();
    $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
    $delete_logs = isset( $params['delete_logs'] ) ? $params['delete_logs'] : true;

    if ( !$task_name ) {
      return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
    }

    $result = $this->remove( $task_name, [ 'delete_logs' => $delete_logs ] );

    if ( $result ) {
      return new WP_REST_Response( [ 'success' => true, 'message' => 'Task deleted' ], 200 );
    }

    return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete task' ], 500 );
  }

  /**
   * REST: Get task logs
   */
  public function rest_task_logs( $request ) {
    $task_name = $request->get_param( 'task_name' );

    if ( !$task_name ) {
      return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
    }

    // Get task ID
    $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
      "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
      $task_name
    ) );

    if ( !$task_id ) {
      return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
    }

    // Get logs
    $logs = $this->wpdb->get_results( $this->wpdb->prepare(
      "SELECT * FROM {$this->table_tasklogs} 
       WHERE task_id = %d 
       ORDER BY started DESC 
       LIMIT 50",
      $task_id
    ) );

    return new WP_REST_Response( [ 'success' => true, 'logs' => $logs ], 200 );
  }

  /**
   * REST: Delete task logs
   */
  public function rest_task_logs_delete( $request ) {
    $params = $request->get_json_params();
    $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;

    if ( !$task_name ) {
      return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
    }

    // Get task ID
    $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
      "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
      $task_name
    ) );

    if ( !$task_id ) {
      return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
    }

    // Delete logs for this task
    $result = $this->wpdb->delete(
      $this->table_tasklogs,
      [ 'task_id' => $task_id ],
      [ '%d' ]
    );

    if ( $result !== false ) {
      return new WP_REST_Response( [ 'success' => true, 'message' => 'Logs deleted successfully' ], 200 );
    }

    return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete logs' ], 500 );
  }

  /**
   * REST: Reset all tasks
   */
  public function rest_tasks_reset( $request ) {
    // Clear all WordPress cron jobs related to tasks
    wp_clear_scheduled_hook( 'mwai_tasks_internal_run' );
    wp_clear_scheduled_hook( 'mwai_tasks_internal_dev_run' );

    // Clear all transients
    delete_transient( 'mwai_cron_last_run' );
    delete_transient( 'mwai_cron_running_mwai_tasks_internal_run' );
    delete_transient( 'mwai_cron_running_mwai_tasks_internal_dev_run' );

    // Truncate task logs table
    $this->wpdb->query( "TRUNCATE TABLE {$this->table_tasklogs}" );

    // Delete all tasks
    $this->wpdb->query( "TRUNCATE TABLE {$this->table_tasks}" );

    // Re-initialize the Tasks Runner cron
    $dev_mode = $this->core->get_option( 'dev_mode' );
    if ( $dev_mode ) {
      wp_schedule_event( time() + 5, 'five_seconds', 'mwai_tasks_internal_dev_run' );
    }
    else {
      wp_schedule_event( time() + 60, 'one_minute', 'mwai_tasks_internal_run' );
    }

    // Re-create system tasks
    $this->ensure_system_tasks();

    return new WP_REST_Response( [
      'success' => true,
      'message' => 'Tasks system has been reset. All tasks and logs have been cleared, and system tasks have been re-created.'
    ], 200 );
  }

  /**
   * Helper: Human-readable time difference (abbreviated)
   */
  private function human_time_diff( $timestamp ) {
    // Use current time consistently
    $now = time();
    $diff = $timestamp - $now;

    if ( $diff < 0 ) {
      // Past
      $diff = abs( $diff );
      $suffix = ' ago';
    }
    else {
      // Future
      $suffix = '';
    }

    // Use abbreviated format
    if ( $diff < 60 ) {
      return $diff . 's' . $suffix;
    }
    else if ( $diff < 3600 ) {
      $minutes = round( $diff / 60 );
      return $minutes . 'm' . $suffix;
    }
    else if ( $diff < 86400 ) {
      $hours = round( $diff / 3600 );
      return $hours . 'h' . $suffix;
    }
    else {
      $days = round( $diff / 86400 );
      return $days . 'd' . $suffix;
    }
  }

  /**
   * Check and create database tables
   */
  public function check_db() {
    // Don't run multiple times
    if ( $this->db_check ) {
      return true;
    }

    // Per-module version check: skip SHOW TABLES if already verified for this version.
    if ( get_option( 'mwai_db_version_tasks' ) === MWAI_VERSION ) {
      $this->db_check = true;
      return true;
    }

    // Check if tables exist
    $tasks_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasks'" );
    $logs_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasklogs'" );

    if ( !$tasks_exists || !$logs_exists ) {
      $this->create_db();
      $tasks_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasks'" );
      $logs_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasklogs'" );
    }

    $this->db_check = $tasks_exists && $logs_exists;

    if ( $this->db_check ) {
      // Check for database upgrades
      $this->upgrade_db();
      update_option( 'mwai_db_version_tasks', MWAI_VERSION, true );
    }

    return $this->db_check;
  }

  /**
   * Upgrade database schema if needed
   */
  private function upgrade_db() {
    // Add category column if it doesn't exist
    $category_exists = $this->wpdb->get_var(
      "SHOW COLUMNS FROM {$this->table_tasks} LIKE 'category'"
    );

    if ( !$category_exists ) {
      $this->wpdb->query(
        "ALTER TABLE {$this->table_tasks}
         ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'general' AFTER description"
      );
    }

    // Remove deprecated columns if they exist
    $columns_to_remove = ['auto_delete', 'deletable', 'is_multistep', 'last_message'];

    foreach ( $columns_to_remove as $column ) {
      $column_exists = $this->wpdb->get_var(
        "SHOW COLUMNS FROM {$this->table_tasks} LIKE '$column'"
      );

      if ( $column_exists ) {
        $this->wpdb->query( "ALTER TABLE {$this->table_tasks} DROP COLUMN $column" );
      }
    }

    // Add step_data column if it doesn't exist
    $step_data_exists = $this->wpdb->get_var(
      "SHOW COLUMNS FROM {$this->table_tasks} LIKE 'step_data'"
    );

    if ( !$step_data_exists ) {
      $this->wpdb->query(
        "ALTER TABLE {$this->table_tasks}
         ADD COLUMN step_data LONGTEXT NULL AFTER step_name"
      );
    }
  }

  /**
   * Create database tables
   */
  public function create_db() {
    $charset_collate = $this->wpdb->get_charset_collate();

    $sql_tasks = "CREATE TABLE $this->table_tasks (
      id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
      task_name VARCHAR(100) NOT NULL,
      description TEXT NULL,
      category VARCHAR(32) NOT NULL DEFAULT 'general',
      schedule VARCHAR(128) NOT NULL,
      status VARCHAR(16) NOT NULL DEFAULT 'pending',
      next_run DATETIME NULL,
      last_run DATETIME NULL,
      expires_at DATETIME NULL,
      step INT NOT NULL DEFAULT 0,
      step_name VARCHAR(64) NULL,
      step_data LONGTEXT NULL,
      data LONGTEXT NULL,
      meta LONGTEXT NULL,
      error_count INT NOT NULL DEFAULT 0,
      max_retries INT NOT NULL DEFAULT 3,
      created DATETIME NOT NULL,
      updated DATETIME NOT NULL,
      PRIMARY KEY (id),
      UNIQUE KEY task_name (task_name),
      KEY status_next (status, next_run),
      KEY category (category),
      KEY expires (expires_at)
    ) $charset_collate;";

    $sql_logs = "CREATE TABLE $this->table_tasklogs (
      id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
      task_id BIGINT UNSIGNED NOT NULL,
      started DATETIME NOT NULL,
      ended DATETIME NULL,
      status VARCHAR(16) NOT NULL,
      message TEXT NULL,
      time_taken FLOAT NULL,
      memory_peak BIGINT NULL,
      step INT NULL,
      created DATETIME NOT NULL,
      PRIMARY KEY (id),
      KEY task_id_started (task_id, started)
    ) $charset_collate;";

    require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
    dbDelta( $sql_tasks );
    dbDelta( $sql_logs );
  }
}