<?php
/**
 * Generic Recovery Email Job handler for ActionScheduler.
 *
 * Handles sending any recovery email in the sequence based on cart state.
 *
 * @package EDD\Pro\CartRecovery\Jobs
 * @copyright Copyright (c) 2026, Sandhills Development, LLC
 * @license https://opensource.org/licenses/GPL-2.0 GNU Public License
 * @since 3.6.5
 */

namespace EDD\Pro\CartRecovery\Jobs;

// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore

use EDD\Pro\CartRecovery\Config\Emails;
use EDD\Pro\CartRecovery\Cron\SingleEventScheduler;
use EDD\Pro\CartRecovery\Emails\Sequence;
use EDD\Pro\CartRecovery\Emails\Service;
use EDD\Pro\CartRecovery\Carts\Cart;
use EDD\Pro\CartRecovery\Carts\Repository;
use EDD\Pro\CartRecovery\Traits\Debug;
use EDD\Pro\CartRecovery\Database\Queries\RecoveryCart;

/**
 * Generic Recovery Email Job handler.
 *
 * Replaces the separate ImmediateRecovery, ValueReinforcement, and FinalAttempt jobs
 * with a unified approach that determines which email to send based on cart state.
 *
 * @since 3.6.5
 */
final class RecoveryEmail {
	use Debug;

	/**
	 * Email service.
	 *
	 * @since 3.6.5
	 * @var Service
	 */
	private $email_service;

	/**
	 * Cart repository.
	 *
	 * @since 3.6.5
	 * @var Repository
	 */
	private $cart_repo;

	/**
	 * Email sequence service.
	 *
	 * @since 3.6.5
	 * @var Sequence
	 */
	private $sequence;

	/**
	 * Constructor.
	 *
	 * @since 3.6.5
	 */
	public function __construct() {
		$this->email_service = new Service();
		$this->cart_repo     = new Repository();
		$this->sequence      = new Sequence();
	}

	/**
	 * Main execution workflow for the job with timing-based retry logic.
	 *
	 * Error Handling Strategy:
	 * This method implements a dual-layer safety net to prevent silent failures:
	 *
	 * 1. Immediate Protection (try-catch wrapper):
	 *    - Catches all exceptions and fatal errors during workflow execution
	 *    - Treats caught errors as send failures and schedules retries
	 *    - Prevents ActionScheduler from marking the job as failed without retry
	 *
	 * 2. Daily Safety Net (Maintenance Sweep):
	 *    - A daily cron job scans for abandoned carts that should have received emails
	 *    - Reschedules recovery emails for carts that fell through the cracks
	 *    - Provides defense-in-depth against edge cases and unforeseen failures
	 *
	 * @since 3.6.5
	 * @param int $cart_id The ID of the cart to process.
	 * @param int $attempt_number The current attempt number for this job stage.
	 */
	public function handle( int $cart_id, int $attempt_number = 1 ): void {
		if ( ! $cart_id ) {
			return;
		}

		try {
			$this->execute_recovery_workflow( $cart_id, $attempt_number );
		} catch ( \Throwable $e ) {
			// Catch any exception or fatal error and treat it as a send failure.
			$this->log(
				sprintf(
					'Fatal error during recovery workflow for cart %d (attempt #%d): %s in %s:%d',
					$cart_id,
					$attempt_number,
					$e->getMessage(),
					$e->getFile(),
					$e->getLine()
				)
			);

			// Attempt to schedule a retry for this cart.
			try {
				$query = new RecoveryCart();
				$cart  = $query->get_item( $cart_id );

				if ( $cart ) {
					$this->process_failure( $cart_id, $cart, $attempt_number );
				}
			} catch ( \Throwable $retry_error ) {
				$this->log(
					sprintf(
						'Failed to schedule retry for cart %d: %s',
						$cart_id,
						$retry_error->getMessage()
					)
				);
			}
		}
	}

	/**
	 * Execute the recovery workflow for a cart.
	 *
	 * @since 3.6.5
	 * @param int $cart_id The ID of the cart to process.
	 * @param int $attempt_number The current attempt number for this job stage.
	 * @return void
	 */
	private function execute_recovery_workflow( int $cart_id, int $attempt_number ): void {
		$query = new RecoveryCart();
		$cart  = $query->get_item( $cart_id );

		if ( ! $cart instanceof Cart ) {
			return;
		}

		// Don't process carts for customers who have unsubscribed.
		$date_unsubscribed = $cart->date_unsubscribed ?? null;
		if ( ! empty( $date_unsubscribed ) ) {
			$this->log( sprintf( 'Skipping cart %d: customer unsubscribed on %s', $cart_id, $date_unsubscribed ) );
			SingleEventScheduler::cancel_recovery_email( $cart_id );
			return;
		}

		// Don't process carts with no items.
		$items = $cart->get_items();
		if ( empty( $items ) ) {
			$this->log( sprintf( 'Skipping cart %d: no items in cart', $cart_id ) );
			// Mark cart as expired since it has no recoverable items.
			$this->cart_repo->update_status( $cart_id, 'expired' );
			SingleEventScheduler::cancel_recovery_email( $cart_id );
			return;
		}

		// Check if cart is ready (excluding timing), and exit if not.
		if ( ! $this->is_cart_ready_excluding_timing( $cart ) ) {
			return;
		}

		// Check timing separately so we can schedule a retry if needed.
		$next_email = $this->sequence->get_next_email_in_sequence( $cart );
		if ( ! $next_email ) {
			return;
		}

		$seconds_until_ready = $this->sequence->get_seconds_until_ready( $cart, $next_email );
		if ( $seconds_until_ready > 0 ) {
			// Too early, schedule a retry at the right time.
			$this->schedule_timing_retry( $cart_id, $seconds_until_ready );
			return;
		}

		// Final guard: re-read cart status to catch last-second recoveries.
		$fresh_cart = $query->get_item( $cart_id );
		if ( ! $fresh_cart || 'abandoned' !== ( $fresh_cart->status ?? '' ) || ! empty( $fresh_cart->date_recovered ) ) {
			SingleEventScheduler::cancel_recovery_email( $cart_id );
			return;
		}

		// Cart is ready, proceed with email sending workflow.
		$sent = $this->send_email( $cart_id );

		if ( $this->email_service->was_last_send_blocked() ) {
			return;
		}

		// Re-fetch cart to get updated state after email send attempt.
		$updated_cart = $query->get_item( $cart_id );
		if ( ! $updated_cart ) {
			$this->log( sprintf( 'Cart not found after send attempt: %d', $cart_id ) );
			return;
		}

		global $wpdb;
		if ( $sent ) {

			$wpdb->query( 'START TRANSACTION' );

			try {
				$this->process_success( $cart_id, $updated_cart );
				$wpdb->query( 'COMMIT' );
			} catch ( \Exception $e ) {
				$wpdb->query( 'ROLLBACK' );
				$this->log( sprintf( 'Success processing failed for cart %d: %s', $cart_id, $e->getMessage() ) );
			}
		} else {
			$this->log( sprintf( 'Email send failed for cart %d on attempt #%d', $cart_id, $attempt_number ) );

			$wpdb->query( 'START TRANSACTION' );

			try {
				$this->process_failure( $cart_id, $updated_cart, $attempt_number );
				$wpdb->query( 'COMMIT' );
			} catch ( \Exception $e ) {
				$wpdb->query( 'ROLLBACK' );
				$this->log( sprintf( 'Failure processing failed for cart %d: %s', $cart_id, $e->getMessage() ) );
			}
		}
	}

	/**
	 * Sends the recovery email.
	 *
	 * Determines which email template to send based on the cart's recovery_email_count.
	 *
	 * @since 3.6.5
	 * @param int $cart_id The cart ID.
	 * @return bool Whether the email was sent successfully.
	 */
	private function send_email( int $cart_id ): bool {
		// Get the cart to determine which email to send.
		$query = new RecoveryCart();
		$cart  = $query->get_item( $cart_id );

		if ( ! $cart ) {
			return false;
		}

		// Determine which email template to send.
		$next_email = $this->sequence->get_next_email_in_sequence( $cart );
		if ( ! $next_email ) {
			return false;
		}

		// Send the email using the template ID.
		return $this->email_service->send_recovery_email( $cart_id, $next_email->email_id );
	}

	/**
	 * Processes the success of the recovery email.
	 *
	 * Schedules the next email in the sequence if one exists.
	 *
	 * @since 3.6.5
	 * @param int    $cart_id    The cart ID.
	 * @param object $cart_state The cart state.
	 * @return void
	 */
	private function process_success( int $cart_id, object $cart_state ): void {
		// Cancel any pending retry attempts for this cart.
		$cancelled = SingleEventScheduler::cancel_recovery_email( $cart_id );
		$this->log( sprintf( 'Cancelled pending recovery emails for cart %d: %s', $cart_id, $cancelled ? 'yes' : 'no' ) );

		// Check if there's another email in the sequence.
		$next_email = $this->sequence->get_next_email_in_sequence( $cart_state );
		if ( $next_email ) {
			$scheduled = SingleEventScheduler::schedule_recovery_email( $cart_id );
			$this->log(
				sprintf(
					'Scheduling next recovery email for cart %d: %s',
					$cart_id,
					$scheduled ? 'SUCCESS' : 'FAILED'
				)
			);

			if ( ! $scheduled ) {
				$this->log( sprintf( 'WARNING: Failed to schedule next recovery email for cart %d', $cart_id ) );
			}
		} else {
			$this->log( sprintf( 'No next email in sequence for cart %d (sequence complete)', $cart_id ) );
		}
	}

	/**
	 * Processes the failure of the recovery email.
	 *
	 * Schedules a retry or marks the cart as expired if retry limit reached.
	 *
	 * @since 3.6.5
	 * @param int    $cart_id        The cart ID.
	 * @param object $cart_state     The cart state.
	 * @param int    $attempt_number The attempt number.
	 * @return bool True if successfully processed, false otherwise.
	 */
	private function process_failure( int $cart_id, object $cart_state, int $attempt_number ): bool {
		$failures       = (int) ( $cart_state->fail_count ?? 0 );
		$emails_sent    = (int) ( $cart_state->recovery_email_count ?? 0 );
		$total_attempts = $failures + $emails_sent;
		$attempt_cap    = Emails::get_max_recovery_attempts();

		// Hard maximum retry limit to prevent infinite loops (configurable via filter).
		$max_retries = (int) apply_filters( 'edd_acr_max_retry_attempts', 5 );

		// Check configurable attempt cap.
		if ( $attempt_cap > 0 && $total_attempts >= $attempt_cap ) {
			// Reached attempt limit, mark cart as expired.
			$this->log( sprintf( 'Cart %d reached attempt limit (%d attempts), marking as expired', $cart_id, $total_attempts ) );
			$this->cart_repo->update_status( $cart_id, 'expired' );
			return SingleEventScheduler::cancel_recovery_email( $cart_id );
		}

		// Check hard maximum retry limit as a safety net.
		if ( $failures >= $max_retries ) {
			$this->log( sprintf( 'Cart %d reached maximum retry limit (%d retries), marking as expired', $cart_id, $failures ) );
			$this->cart_repo->update_status( $cart_id, 'expired' );
			return SingleEventScheduler::cancel_recovery_email( $cart_id );
		}

		// Schedule a retry.
		$this->log( sprintf( 'Scheduling retry for cart %d (attempt #%d)', $cart_id, $attempt_number ) );
		$scheduled = SingleEventScheduler::schedule_recovery_email_retry( $cart_id, $attempt_number );
		$this->log(
			sprintf(
				'Retry scheduling for cart %d: %s',
				$cart_id,
				$scheduled ? 'SUCCESS' : 'FAILED'
			)
		);

		return $scheduled;
	}

	/**
	 * Checks if the cart is ready for the job execution (excluding timing check).
	 *
	 * @since 3.6.5
	 * @param Cart $cart The cart object.
	 * @return bool Whether the cart is ready.
	 */
	private function is_cart_ready_excluding_timing( Cart $cart ): bool {
		// Check basic cart status.
		$status = $cart->status ?? '';
		if ( ! in_array( $status, array( 'abandoned' ), true ) ) {
			return false;
		}

		$email = $cart->email ?? null;
		if ( empty( $email ) ) {
			return false;
		}

		// Check if cart was recovered or customer unsubscribed.
		$date_recovered    = $cart->date_recovered ?? null;
		$date_unsubscribed = $cart->date_unsubscribed ?? null;

		if ( ! empty( $date_recovered ) || ! empty( $date_unsubscribed ) ) {
			return false;
		}

		// Safety net: skip if the customer has completed an order since this cart was created.
		if ( $this->customer_has_completed_order_since( $cart ) ) {
			$cart_id = (int) $cart->id;
			$this->log( sprintf( 'Skipping cart %d: customer has a completed order after cart creation.', $cart_id ) );
			$this->cart_repo->update_status( $cart_id, 'expired' );
			SingleEventScheduler::cancel_recovery_email( $cart_id );

			return false;
		}

		return true;
	}

	/**
	 * Checks if the customer has a completed order created after this cart.
	 *
	 * This is a safety net to prevent recovery emails from being sent to customers
	 * who have already completed a purchase, even if the cart was not properly
	 * cleaned up during checkout completion.
	 *
	 * @since 3.6.7
	 * @param Cart $cart Cart object.
	 * @return bool True if a completed order exists after the cart was created.
	 */
	private function customer_has_completed_order_since( Cart $cart ): bool {
		$email = $cart->email ?? '';
		if ( empty( $email ) ) {
			return false;
		}

		$date_created = $cart->date_created ?? '';
		if ( empty( $date_created ) ) {
			return false;
		}

		$orders = edd_get_orders(
			array(
				'email'              => $email,
				'status'             => 'complete',
				'date_created_query' => array(
					'after'     => $date_created,
					'inclusive' => false,
				),
				'number'             => 1,
				'fields'             => 'ids',
			)
		);

		return ! empty( $orders );
	}

	/**
	 * Schedules a short retry when the job runs too early.
	 *
	 * @since 3.6.5
	 * @param int $cart_id Cart ID.
	 * @param int $delay_seconds How many seconds to wait before retry.
	 * @return void
	 */
	private function schedule_timing_retry( int $cart_id, int $delay_seconds ): void {
		// Add a small buffer to ensure we're past the scheduled time.
		$retry_delay = $delay_seconds + 10;

		// Cap the retry delay at a reasonable maximum.
		$retry_delay = min( $retry_delay, 300 ); // Max 5 minutes.

		// Fetch the cart to get the timestamp argument for proper scheduling.
		$query = new RecoveryCart();
		$cart  = $query->get_item( $cart_id );

		if ( ! $cart ) {
			return;
		}

		// Use the same argument pattern as SingleEventScheduler for consistency.
		$timestamp_arg = $cart->last_recovery_email ?? $cart->date_abandoned;
		$args          = array(
			$cart_id,
			$timestamp_arg,
		);

		$scheduler = \EDD\Cron\Schedulers\Handler::get_scheduler();
		$scheduler->schedule_single(
			'edd_acr_recovery_email',
			time() + $retry_delay,
			$args,
			'edd_acr'
		);

		$this->log(
			sprintf(
				'Timing retry scheduled for cart %d: %d seconds from now',
				$cart_id,
				$retry_delay
			)
		);
	}
}
