<?php
/**
 * Migrates the order actions date to a new column, from the existing order meta.
 *
 * @since 3.2.0
 *
 * @package EDD
 * @subpackage Upgrades
 * @category Orders
 */

namespace EDD\Upgrades\Orders;

use EDD\Utils\Date;
use EDD\EventManagement\SubscriberInterface;
use EDD\Upgrades\Utilities\MigrationCheck;
use EDD\Cron\Traits\NextScheduled;
use EDD\Cron\Events\SingleEvent;

/**
 * Class MigrateAfterActionsDate
 *
 * @since 3.2.0
 */
class MigrateAfterActionsDate implements SubscriberInterface {
	use NextScheduled;

	/**
	 * The name of the upgrade.
	 *
	 * @var string
	 */
	protected $upgrade_name = 'migrate_order_actions_date';

	/**
	 * The name of the cron action.
	 *
	 * @var string
	 */
	protected $cron_action = 'edd_migrate_order_actions_date';

	/**
	 * The name of the option that stores the total count of rows to process.
	 *
	 * @var string
	 */
	protected $total_count_option = 'edd_migrate_order_actions_date_total';

	/**
	 * The remote ID of the notification that the migration is in progress.
	 *
	 * @var string
	 */
	protected $in_progress_remote_id = 'action-time-running';

	/**
	 * The remote ID of the notification that the migration is complete.
	 *
	 * @var string
	 */
	protected $complete_remote_id = 'action-time-done';

	/**
	 * Hook into actions and filters.
	 *
	 * @since  3.2
	 */
	public static function get_subscribed_events() {
		// If the upgrade has already been run, don't hook anything.
		if ( edd_has_upgrade_completed( 'migrate_order_actions_date' ) ) {
			return array();
		}

		// If the initial EDD 3.x migration hasn't completed, don't hook anything.
		if ( ! MigrationCheck::is_v30_migration_complete() ) {
			return array();
		}

		// Always hook the migration hook.
		$hooks = array(
			'edd_migrate_order_actions_date' => 'process_step',
		);

		// When Action Scheduler is active, we cannot reliably call next_scheduled() here because
		// this method runs during plugins_loaded — before Action Scheduler has initialized. Calling
		// it at this stage logs a "called too early" debug notice on every page load.
		//
		// Instead, we hook onto action_scheduler_init (which fires once AS is fully ready) and do
		// the check there, adding the shutdown hook only when no event is already pending.
		if ( \EDD\Cron\Schedulers\ActionScheduler::is_available() ) {
			$hooks['action_scheduler_init'] = 'maybe_add_shutdown_hook';
		} else {
			// WP-Cron fallback: wp_next_scheduled() is safe to call at plugins_loaded, so the
			// original early check works fine and avoids an unnecessary shutdown callback.
			if ( ! self::next_scheduled( 'edd_migrate_order_actions_date' ) ) {
				$hooks['shutdown'] = array( 'maybe_schedule_background_update', 99 );
			}
		}

		return $hooks;
	}

	/**
	 * Conditionally registers the shutdown hook for the background update when Action Scheduler is active.
	 *
	 * This method is hooked onto action_scheduler_init, which fires after Action Scheduler has fully
	 * initialized. At that point next_scheduled() is reliable, so we can safely skip adding the
	 * shutdown callback when the cron event is already pending — avoiding unnecessary work on every
	 * page's shutdown for the duration of the migration.
	 *
	 * @since 3.6.7
	 *
	 * @return void
	 */
	public function maybe_add_shutdown_hook() {
		if ( ! self::next_scheduled( $this->cron_action ) ) {
			add_action( 'shutdown', array( $this, 'maybe_schedule_background_update' ), 99 );
		}
	}

	/**
	 * Maybe schedules the background update.
	 *
	 * We're running this on shutdown, so we can be sure that the Orders table has been created.
	 *
	 * @since 3.2.0
	 *
	 * @return void
	 */
	public function maybe_schedule_background_update() {
		$orders_table      = new \EDD\Database\Tables\Orders();
		$table_version_key = $orders_table->db_version_key;
		$table_version     = get_option( $table_version_key, 0 );

		// If our table version is earlier than the one we need, return.
		if ( version_compare( '202307111', $table_version, '<' ) ) {
			return;
		}

		// This is the right table, and the right version, so let's schedule the background update.
		$this->register_first_background_event();
	}

	/**
	 * Registers the first background event to start the migration.
	 *
	 * @since 3.2.0
	 *
	 * @return void
	 */
	private function register_first_background_event() {
		// If we've already scheduled the cleanup, no need to schedule it again.
		if ( self::next_scheduled( $this->cron_action ) ) {
			return;
		}

		// See if we have any order meta with the key of _edd_complete_actions_run.
		global $wpdb;
		$has_action_date_meta = $wpdb->get_var( "SELECT COUNT(meta_id) FROM {$this->get_meta_table_name()} WHERE meta_key = '_edd_complete_actions_run'" );

		if ( empty( $has_action_date_meta ) ) {
			$this->mark_complete( false );
			return;
		}

		// Only update the total count option if it doesn't exist. This prevents it from being overridden in some edge cases.
		if ( empty( get_option( $this->total_count_option ) ) ) {
			// Set the total amount in a transient so we can use it later.
			update_option( $this->total_count_option, $has_action_date_meta, false );
		}

		$this->add_or_update_initial_notification();

		// ...And schedule a single event a minute from now to start the processing of this data.
		SingleEvent::add(
			time() + MINUTE_IN_SECONDS,
			$this->cron_action
		);
	}

	/**
	 * Processes a single step of the migration.
	 *
	 * If there are more items to process, it will schedule another single event to run in a minute.
	 * If there are no more items to process, it will mark the migration as complete.
	 *
	 * @since 3.2.0
	 *
	 * @return void
	 */
	public function process_step() {
		// Since this hooks on an action, don't let it run if we're not in a cron.
		if ( ! edd_doing_cron() && ! did_action( 'action_scheduler_before_execute' ) ) {
			return;
		}

		global $wpdb;
		$meta_rows = $wpdb->get_results( "SELECT meta_id, edd_order_id, meta_value FROM {$this->get_meta_table_name()} WHERE meta_key = '_edd_complete_actions_run' ORDER BY edd_order_id DESC LIMIT 500" );

		edd_debug_log( 'Found records to migrate (max 500): ' . count( $meta_rows ) );

		// If we don't have anymore items to process, mark it as complete.
		if ( empty( $meta_rows ) ) {
			$this->mark_complete();
			return;
		}

		// Iterate through the meta rows and update the order.
		$migrated_meta_ids = array();
		$migrated_note_ids = array();

		// We don't need to schedule a recalculation here.
		add_filter( 'edd_recalculate_bypass_cron', '__return_true' );

		foreach ( $meta_rows as $row ) {
			// Check that the order exists.
			$order = edd_get_order( $row->edd_order_id );
			if ( ! $order ) {
				edd_debug_log( 'Order ' . $row->edd_order_id . ' no longer exists. Removing orphaned meta row.' );
				$migrated_meta_ids[] = $row->meta_id;
				continue;
			}

			// Validate that meta_value is numeric before using it as a timestamp.
			if ( ! is_numeric( $row->meta_value ) ) {
				edd_debug_log( 'Order ' . $row->edd_order_id . ' has corrupted timestamp. Removing invalid meta row.' );
				$migrated_meta_ids[] = $row->meta_id;
				continue;
			}

			// Convert the timestamp to a DateTime object.
			$date = new Date();
			$date->setTimestamp( $row->meta_value )->setTimezone( new \DateTimeZone( 'UTC' ) );

			// Update the order.
			$updated = edd_update_order( $row->edd_order_id, array( 'date_actions_run' => $date->format( 'mysql' ) ) );

			if ( $updated ) {
				// Store the meta ids we can remove.
				$migrated_meta_ids[] = $row->meta_id;

				// Store the note ids we can remove.
				$note_content = __( 'After payment actions processed.', 'easy-digital-downloads' );
				$note_id      = $wpdb->get_var( "SELECT id FROM {$this->get_notes_table_name()} WHERE object_id = {$row->edd_order_id} AND object_type = 'order' AND content = '{$note_content}'" );
				if ( $note_id ) {
					$migrated_note_ids[] = $note_id;
				}
			}
		}

		// Remove our filter for bypassing the cron.
		remove_filter( 'edd_recalculate_bypass_cron', '__return_true' );

		$migrated_meta_ids = array_filter( $migrated_meta_ids );
		// If we have any migrated meta IDs, delete them.
		if ( ! empty( $migrated_meta_ids ) ) {
			// Delete the meta rows we just migrated.
			$wpdb->query( "DELETE FROM {$this->get_meta_table_name()} WHERE meta_id IN (" . implode( ',', $migrated_meta_ids ) . ')' );
		}

		$migrated_note_ids = array_filter( $migrated_note_ids );
		// If we found notes to delete, delete them.
		if ( ! empty( $migrated_note_ids ) ) {
			$wpdb->query( "DELETE FROM {$this->get_notes_table_name()} WHERE id IN (" . implode( ',', $migrated_note_ids ) . ')' );
		}

		$this->add_or_update_initial_notification();

		$percent_complete = $this->get_percentage_complete();

		edd_debug_log( 'Processed step of order actions date migration. Percentage Complete: ' . $percent_complete . '%' );

		// ...And schedule another single event so we can process the next batch.
		SingleEvent::add(
			time() + MINUTE_IN_SECONDS,
			$this->cron_action
		);
	}

	/**
	 * Marks the migration as complete.
	 *
	 * @since 3.2.0
	 *
	 * @param bool $add_notification If we should add a notification that the migration is complete.
	 *
	 * @return void
	 */
	private function mark_complete( $add_notification = true ) {
		// Set the upgrade as complete.
		edd_set_upgrade_complete( 'migrate_order_actions_date' );

		// Delete the total count option. It may not exist, but we should delete it anyway.
		delete_option( $this->total_count_option );

		if ( false === $add_notification ) {
			return;
		}

		$initial_notification = $this->get_initial_notification();
		if ( ! empty( $initial_notification ) ) {
			EDD()->notifications->update( $initial_notification->id, array( 'dismissed' => 1 ) );
		}

		EDD()->notifications->maybe_add_local_notification(
			array(
				'remote_id'  => $this->complete_remote_id,
				'buttons'    => '',
				'conditions' => '',
				'type'       => 'success',
				'title'      => __( 'Order Table Optimization Complete!', 'easy-digital-downloads' ),
				'content'    => __( 'Easy Digital Downloads has finished updating your orders database! Thank you for your patience.', 'easy-digital-downloads' ),
			)
		);
	}

	/**
	 * Gets the initial notification about the migration.
	 *
	 * @since 3.2.0
	 *
	 * @return object
	 */
	private function get_initial_notification() {
		return EDD()->notifications->get_item_by( 'remote_id', $this->in_progress_remote_id );
	}

	/**
	 * Adds or updates the initial notification about the migration.
	 *
	 * @since 3.2.0
	 *
	 * @return void
	 */
	private function add_or_update_initial_notification() {
		$initial_notification = $this->get_initial_notification();
		$percent_complete     = $this->get_percentage_complete();

		/* translators: %s: % complete. */
		$notification_title = sprintf( __( 'Optimizing Orders Table ( %d%% )', 'easy-digital-downloads' ), $percent_complete );

		if ( ! empty( $initial_notification ) ) {
			$date = new Date();
			$date->setTimestamp( time() )->setTimezone( new \DateTimeZone( 'UTC' ) );

			// Update the notification.
			EDD()->notifications->update(
				$initial_notification->id,
				array(
					'title'        => $notification_title,
					'date_created' => $date->format( 'mysql' ),
				)
			);
		} else {
			// Add the notification.
			EDD()->notifications->maybe_add_local_notification(
				array(
					'remote_id'  => $this->in_progress_remote_id,
					'buttons'    => '',
					'conditions' => '',
					'type'       => 'info',
					'title'      => $notification_title,
					'content'    => __( 'Easy Digital Downloads is updating the Orders and Order Meta table in the background. This process may take a while to complete depending on the number of orders you have. We\'ll let you know when the process is complete.', 'easy-digital-downloads' ),
				)
			);
		}
	}

	/**
	 * Gets the name of the order meta table.
	 *
	 * @since 3.2.0
	 *
	 * @return string
	 */
	private function get_meta_table_name() {
		$order_meta_table = new \EDD\Database\Tables\Order_Meta();
		return $order_meta_table->table_name;
	}

	/**
	 * Gets the name of the notes table.
	 *
	 * @since 3.2.0
	 *
	 * @return string
	 */
	private function get_notes_table_name() {
		$notes_table = new \EDD\Database\Tables\Notes();
		return $notes_table->table_name;
	}

	/**
	 * Gets the percentage complete.
	 *
	 * @since 3.2.0
	 *
	 * @return int
	 */
	private function get_percentage_complete() {
		static $percent_complete;

		if ( ! is_null( $percent_complete ) ) {
			return $percent_complete;
		}

		global $wpdb;

		// Get the total amount of rows remaining.
		$total_rows_remaining = $wpdb->get_var( "SELECT COUNT(*) FROM {$this->get_meta_table_name()} WHERE meta_key = '_edd_complete_actions_run'" );

		// Get the total we started with.
		$total_rows_start = get_option( $this->total_count_option );

		// Format a % complete without decimals.
		$percent_complete = number_format( ( ( $total_rows_start - $total_rows_remaining ) / $total_rows_start ) * 100, 0 );

		// Just in case we end up over 100%, somehow...make it 100%.
		if ( $percent_complete > 100 ) {
			$percent_complete = 100;
		}

		return $percent_complete;
	}
}
