<?php
/**
 * Abandoned cart recovery email service.
 *
 * @package EDD\Pro\CartRecovery
 * @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\Emails;

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

use EDD\Pro\CartRecovery\Config\Emails;
use EDD\Pro\CartRecovery\Emails\Sequence;
use EDD\Pro\CartRecovery\Carts\Cart;
use EDD\Pro\CartRecovery\Database\Queries\RecoveryCart as Query;
use EDD\Pro\CartRecovery\Database\Queries\RecoveryEmail as EmailQuery;
use EDD\Pro\CartRecovery\Journey\EventLogger;

/**
 * Sends recovery emails for abandoned carts.
 *
 * @since 3.6.5
 */
final class Service {

	/**
	 * Tracks whether the most recent send attempt was blocked by a lock.
	 *
	 * @var bool
	 */
	private $last_send_blocked = false;

	/**
	 * Event logger instance.
	 *
	 * @var EventLogger
	 */
	private $event_logger;

	/**
	 * Constructor.
	 *
	 * @since 3.6.5
	 */
	public function __construct() {
		$this->event_logger = new EventLogger();
	}

	/**
	 * Dispatches a recovery email for a cart.
	 *
	 * @since 3.6.5
	 * @param int    $cart_id    Cart identifier.
	 * @param string $template_id Optional. Email template ID to send. If not provided, determines based on sequence.
	 * @return bool Whether the message was sent.
	 */
	public function send_recovery_email( int $cart_id, string $template_id = '' ): bool {
		// Get cart.
		$cart = $this->get_cart( $cart_id );
		if ( ! $cart || empty( $cart->email ) ) {
			return false;
		}

		// If template_id is not provided, determine which email to send based on cart state.
		if ( empty( $template_id ) ) {
			$sequence   = new Sequence();
			$next_email = $sequence->get_next_email_in_sequence( $cart );

			if ( ! $next_email ) {
				return false;
			}

			$template_id = $next_email->email_id;
		}

		// Get the email object.
		$email_obj = edd_get_email( $template_id );
		if ( ! $email_obj || ! $email_obj->is_enabled() ) {
			return false;
		}

		// Handle locking for first email only.
		$emails_sent = isset( $cart->recovery_email_count ) ? (int) $cart->recovery_email_count : 0;
		$lock_key    = null;
		if ( 0 === $emails_sent ) {
			$this->last_send_blocked = false;
			$lock_key                = $this->acquire_send_lock( $cart_id );
			if ( is_null( $lock_key ) ) {
				$this->last_send_blocked = true;
				return false;
			}
		}

		// Validate cart payload.
		if ( ! $this->validate_cart_payload( (int) $cart->id, $cart ) ) {
			$this->cleanup_on_failure( $lock_key );
			return false;
		}

		// Issue recovery token.
		$token = $this->issue_recovery_token( (int) $cart->id, $cart->email );
		if ( empty( $token['token'] ) ) {
			$this->cleanup_on_failure( $lock_key );
			return false;
		}

		try {
			// Get the email type instance using the Registry.
			$email_type = \EDD\Emails\Registry::get( $template_id, array( $cart, $token, $template_id ) );
		} catch ( \Throwable $e ) {
			edd_debug_log( sprintf( '[EDD ACR] Failed to get email type instance for template %s: %s', $template_id, $e->getMessage() ) );
			$this->cleanup_on_failure( $lock_key );
			return false;
		}

		// Pre-log the email attempt.
		$log_args = array(
			'cart'        => $cart,
			'token'       => $token['token'],
			'subject'     => $email_obj->subject,
			'date_sent'   => edd_get_utc_date_string(),
			'expires_at'  => $token['expires_at'],
			'template_id' => $template_id,
		);

		$email_id = $this->pre_log_email_attempt( $log_args );

		// Fire hook for email tracking.
		if ( $email_id ) {
			do_action( 'edd_acr_email_before_send', $email_id );
		}

		// Send the email.
		$sent = $email_type->send();

		// Handle abort.
		if ( $email_type->did_abort_send() ) {
			$this->finalize_send_failure( $email_id, $log_args );
			$this->cleanup_on_failure( $lock_key );
			return false;
		}

		// Handle success/failure.
		if ( $sent ) {
			$this->finalize_send_success( $email_id, $log_args );
			$this->cleanup_on_success( $lock_key );
			return true;
		}

		$this->finalize_send_failure( $email_id, $log_args );
		$this->cleanup_on_failure( $lock_key );

		return false;
	}

	/**
	 * Indicates whether the last send attempt was skipped due to an active lock.
	 *
	 * @since 3.6.5
	 * @return bool Whether the last send attempt was blocked by a lock.
	 */
	public function was_last_send_blocked(): bool {
		return $this->last_send_blocked;
	}

	/**
	 * Marks a recovery token as used with a custom delivery status.
	 *
	 * @since 3.6.5
	 * @param string $token The recovery token.
	 * @param string $status The delivery status.
	 * @return void
	 */
	public function mark_token_used( string $token, string $status = 'converted' ): void {
		if ( empty( $token ) ) {
			return;
		}

		$email_query = new EmailQuery();
		$email       = $email_query->get_item_by( 'recovery_token', $token );
		if ( ! $email || empty( $email->id ) ) {
			return;
		}

		$email_query->update_item(
			(int) $email->id,
			array(
				'date_used'       => edd_get_utc_date_string(),
				'delivery_status' => $status,
			)
		);

		self::log_token_consumed( $token, (int) $email->cart_id, $status );
	}

	/**
	 * Cleans up resources on email send failure.
	 *
	 * @since 3.6.5
	 * @param string|null $lock_key Optional lock key to release.
	 * @return void
	 */
	private function cleanup_on_failure( ?string $lock_key ): void {
		if ( $lock_key ) {
			$this->release_send_lock( $lock_key );
		}
	}

	/**
	 * Cleans up resources on email send success.
	 *
	 * @since 3.6.5
	 * @param string|null $lock_key Optional lock key to release.
	 * @return void
	 */
	private function cleanup_on_success( ?string $lock_key ): void {
		if ( $lock_key ) {
			$this->release_send_lock( $lock_key );
		}
	}

	/**
	 * Returns the cart object.
	 *
	 * @since 3.6.5
	 * @param int $cart_id The cart ID.
	 * @return Cart|false The cart object.
	 */
	private function get_cart( $cart_id ) {
		$query = new Query();

		return $query->get_item( $cart_id );
	}

	/**
	 * Attempts to acquire a per-cart send lock using atomic option insertion.
	 *
	 * @since 3.6.5
	 * @param int $cart_id Cart identifier.
	 * @return string|null The lock reference or null if the lock could not be acquired.
	 */
	private function acquire_send_lock( int $cart_id ): ?string {
		if ( $cart_id <= 0 ) {
			return null;
		}

		$lock_token  = wp_generate_uuid4();
		$option_key  = 'edd_acr_send_lock_' . $cart_id;
		$timeout_key = $option_key . '_timeout';

		// Clean up expired lock if it exists.
		$this->cleanup_expired_lock( $option_key, $timeout_key );

		// Atomic lock acquisition using add_option (fails if option already exists).
		$locked = add_option( $option_key, $lock_token, '', 'no' );
		if ( ! $locked ) {
			return null;
		}

		// Set timeout companion for lock expiration.
		set_transient( $timeout_key, $lock_token, 2 * MINUTE_IN_SECONDS );

		return $option_key . '|' . $lock_token;
	}

	/**
	 * Releases a previously acquired send lock with token verification.
	 *
	 * @since 3.6.5
	 * @param string $lock_reference The lock reference.
	 * @return void
	 */
	private function release_send_lock( string $lock_reference ): void {
		if ( empty( $lock_reference ) ) {
			return;
		}

		list( $option_key, $expected_token ) = array_pad( explode( '|', $lock_reference, 2 ), 2, '' );
		if ( '' === $option_key || '' === $expected_token ) {
			return;
		}

		$current_token = get_option( $option_key );
		if ( false === $current_token ) {
			return;
		}

		// Verify token matches before releasing to prevent accidental deletion.
		if ( hash_equals( (string) $current_token, $expected_token ) ) {
			delete_option( $option_key );
			// Clean up timeout companion.
			delete_transient( $option_key . '_timeout' );
		}
	}

	/**
	 * Validates that a cart still has a usable payload before attempting to send.
	 *
	 * @since 3.6.5
	 * @param int  $cart_id Cart identifier.
	 * @param Cart $cart    Cart row object.
	 * @return bool Whether the cart payload is safe to use.
	 */
	private function validate_cart_payload( int $cart_id, $cart ): bool {
		if ( ! $cart instanceof Cart ) {
			$this->log_cart_validation_failure( $cart_id, 'cart record missing' );
			return false;
		}

		// Cart must still be abandoned.
		if ( 'abandoned' !== ( $cart->status ?? '' ) ) {
			$this->log_cart_validation_failure( $cart_id, 'cart is no longer abandoned (status: ' . ( $cart->status ?? 'unknown' ) . ')' );
			return false;
		}

		// Cart must not have been recovered.
		if ( ! empty( $cart->date_recovered ) ) {
			$this->log_cart_validation_failure( $cart_id, 'cart was already recovered' );
			return false;
		}

		$items = $cart->get_items();
		if ( empty( $items ) ) {
			$this->log_cart_validation_failure( $cart_id, 'cart contains no purchasable items' );
			return false;
		}

		return true;
	}

	/**
	 * Logs cart validation failures in a consistent format.
	 *
	 * @since 3.6.5
	 * @param int    $cart_id Cart identifier.
	 * @param string $reason  The reason for the validation failure.
	 * @return void
	 */
	private function log_cart_validation_failure( int $cart_id, string $reason ): void {
		edd_debug_log( sprintf( '[EDD ACR] EmailService aborted: %s for cart %d', $reason, $cart_id ), true );
	}

	/**
	 * Generates a recovery token.
	 *
	 * @since 3.6.5
	 * @param int    $cart_id Cart identifier.
	 * @param string $email   The email address.
	 * @return array {
	 *     Array of token and expiration information.
	 *
	 *     @type string $token      The recovery token.
	 */
	private function issue_recovery_token( int $cart_id, string $email ): array {
		// Mix cart id, canonicalised email, timer tick, and randomness to avoid token reuse.
		$payload = $cart_id . '|' . strtolower( $email ) . '|' . microtime( true ) . '|' . wp_rand();
		$salt    = wp_salt( 'edd_acr_recovery' );

		$token = hash_hmac( 'sha256', $payload, $salt );

		$default_ttl  = Emails::get_token_ttl();
		$filtered_ttl = (int) apply_filters( 'edd_acr_recovery_token_ttl', $default_ttl );

		if ( $filtered_ttl <= 0 ) {
			edd_debug_log( sprintf( '[EDD ACR] Invalid recovery token TTL override (%d); reverting to default.', $filtered_ttl ) );
			$filtered_ttl = $default_ttl;
		}

		// Enforce a minimum lifetime so downstream flows have enough time to run.
		$ttl        = max( $filtered_ttl, Emails::get_token_min_ttl() );
		$expires_at = gmdate( 'Y-m-d H:i:s', time() + $ttl );

		$token_value = $token ? $token : '';
		self::log_token_issued( $token_value, $cart_id, $expires_at );

		return array(
			'token'      => $token ? $token : '',
			'expires_at' => $expires_at,
		);
	}

	/**
	 * Writes a debug entry when tokens are issued.
	 *
	 * @since 3.6.5
	 * @param string $token      The recovery token.
	 * @param int    $cart_id    Cart identifier.
	 * @param string $expires_at The date the token expires.
	 * @return void
	 */
	private static function log_token_issued( string $token, int $cart_id, string $expires_at ): void {
		if ( empty( $token ) ) {
			return;
		}

		edd_debug_log( sprintf( '[EDD ACR] Token issued for cart %d (expires %s): %s', $cart_id, $expires_at, $token ) );
	}

	/**
	 * Writes a debug entry when tokens are consumed.
	 *
	 * @since 3.6.5
	 * @param string $token      The recovery token.
	 * @param int    $cart_id    Cart identifier.
	 * @param string $status     The delivery status.
	 * @return void
	 */
	private static function log_token_consumed( string $token, int $cart_id, string $status ): void {
		if ( empty( $token ) ) {
			return;
		}

		edd_debug_log( sprintf( '[EDD ACR] Token consumed for cart %d with status "%s": %s', $cart_id, $status, $token ) );
	}

	/**
	 * Pre-logs an email attempt to the database to get an email ID for tracking.
	 *
	 * @since 3.6.5
	 * @param array $args Email attempt arguments.
	 * @return int|false The email ID if successful, false on failure.
	 */
	private function pre_log_email_attempt( array $args ) {
		$email_query = new EmailQuery();
		$email_hash  = '';
		if ( ! empty( $args['cart']->email ) ) {
			$email_hash = hash( 'sha256', strtolower( (string) $args['cart']->email ) );
		}

		$email_data = array(
			'cart_id'         => (int) $args['cart']->id,
			'template_id'     => $args['template_id'],
			'email_subject'   => $args['subject'],
			'date_sent'       => $args['date_sent'],
			'recovery_token'  => $args['token'],
			'email_hash'      => $email_hash,
			'delivery_status' => 'pending',
			'date_expired'    => $args['expires_at'],
		);

		$result = $email_query->add_item( $email_data );

		if ( false === $result ) {
			global $wpdb;
			$db_error = ! empty( $wpdb->last_error ) ? $wpdb->last_error : 'none';
			edd_debug_log(
				sprintf(
					'[EDD ACR] FAILED to pre-log email for cart %d. Last DB error: %s',
					$args['cart']->id,
					$db_error
				),
				true
			);
		}

		return $result;
	}

	/**
	 * Finalizes a successful send and updates cart statistics.
	 *
	 * @since 3.6.5
	 * @param int|false $email_id The email ID (false if pre-log failed).
	 * @param array     $args     Email attempt arguments.
	 * @return void
	 */
	private function finalize_send_success( $email_id, array $args ): void {
		// Update email status to sent.
		if ( $email_id ) {
			$email_query = new EmailQuery();
			$email_query->update_item(
				$email_id,
				array(
					'delivery_status' => 'sent',
				)
			);

			// Log email_sent journey event.
			$event_data = array(
				'template_id' => ! empty( $args['template_id'] ) ? $args['template_id'] : '',
				'subject'     => ! empty( $args['subject'] ) ? $args['subject'] : '',
			);
			$this->event_logger->log_email_event(
				'email_sent',
				(int) $args['cart']->id,
				$email_id,
				$event_data
			);
		}

		$query   = new Query();
		$updated = $query->update_item(
			$args['cart']->id,
			array(
				'recovery_email_count' => (int) $args['cart']->recovery_email_count + 1,
				'last_recovery_email'  => $args['date_sent'],
				'fail_count'           => 0,
			)
		);

		if ( false === $updated ) {
			edd_debug_log( sprintf( '[EDD ACR] Critical: Failed to update cart %d stats after successful send', (int) $args['cart']->id ), true );
		}
	}

	/**
	 * Finalizes a failed send and increments failure counters.
	 *
	 * @since 3.6.5
	 * @param int|false $email_id The email ID (false if pre-log failed).
	 * @param array     $args     Email attempt arguments.
	 * @return void
	 */
	private function finalize_send_failure( $email_id, array $args ): void {
		// Update email status to failed.
		if ( $email_id ) {
			$email_query = new EmailQuery();
			$email_query->update_item(
				$email_id,
				array(
					'delivery_status' => 'failed',
					'bounce_reason'   => __( 'Email could not be sent.', 'easy-digital-downloads' ),
				)
			);

			// Log email_failed journey event.
			$event_data = array(
				'template_id' => ! empty( $args['template_id'] ) ? $args['template_id'] : '',
			);
			$this->event_logger->log_email_event(
				'email_failed',
				(int) $args['cart']->id,
				$email_id,
				$event_data
			);
		}

		$query   = new Query();
		$updated = $query->update_item(
			$args['cart']->id,
			array(
				'fail_count'          => (int) $args['cart']->fail_count + 1,
				'last_recovery_email' => $args['date_sent'],
			)
		);

		if ( false === $updated ) {
			edd_debug_log( sprintf( '[EDD ACR] Error: Failed to update cart %d failure stats after send attempt', (int) $args['cart']->id ), true );
		}
	}

	/**
	 * Cleans up expired locks by checking timeout companion transients.
	 *
	 * @since 3.6.5
	 * @param string $option_key   The lock option key.
	 * @param string $timeout_key  The timeout transient key.
	 */
	private function cleanup_expired_lock( string $option_key, string $timeout_key ): void {
		$existing_token = get_option( $option_key );
		if ( false === $existing_token ) {
			return;
		}

		$timeout_token = get_transient( $timeout_key );
		if ( false === $timeout_token ) {
			delete_option( $option_key );
		}
	}
}
