<?php
/**
 * Reminding somebody about a cart they walked away from.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Marketing;

use TextTango\Consent\Recipient;
use TextTango\Sending\Queue;
use TextTango\Sending\Sender;
use TextTango\Support\Clock;
use TextTango\Support\Settings;
use TextTango\WooCommerce\Templates;

defined( 'ABSPATH' ) || exit;

/**
 * Abandoned cart recovery (spec §8).
 *
 * ### Who this can reach, and who it cannot
 *
 * **Signed-in customers only.** This is the limitation worth stating first,
 * because it is the difference between what the feature is and what the phrase
 * usually promises. A guest who fills a cart and leaves has given this site
 * nothing to send to and nothing that says they wanted to be sent to. Their
 * phone number may be sitting in a checkout session, and using it would be
 * marketing to somebody who never agreed — the one thing the rest of this
 * plugin exists to prevent. So the audience is people with an account, marketing
 * consent on record, and a usable number, and everybody else is left alone.
 *
 * ### Where the cart comes from
 *
 * WooCommerce already keeps a signed-in customer's cart in user meta so it
 * survives them closing the browser. That is the copy this reads. Keeping a
 * second one would mean a second thing to expire, a second thing for the privacy
 * eraser to find, and two records that disagree the first time one write fails.
 *
 * ### How it avoids becoming a nuisance
 *
 * Three separate rules, because each catches a case the others do not:
 *
 * - **Rescheduling.** Every cart change cancels the pending reminder and starts
 *   the clock again, so somebody browsing for an hour is not interrupted mid-shop.
 * - **A dedupe key over the cart's contents.** The same cart is reminded about
 *   once per stage, however many times the timer is restarted.
 * - **A floor between sequences.** Adding and removing one item repeatedly would
 *   otherwise mint a new cart, and so a new reminder, as often as somebody cared
 *   to click. {@see self::COOLDOWN} is not configurable for the same reason the
 *   per-recipient cap is not: it is a runaway guard rather than a preference.
 */
final class AbandonedCart {

	/**
	 * Event id.
	 *
	 * A cart reminder is promotional, not a service message about a transaction
	 * somebody entered into — they deliberately did not enter into it. The
	 * `marketing_` prefix is what makes consent mandatory, quiet hours apply, and
	 * the message yield to order updates when the balance runs low.
	 */
	public const EVENT = 'marketing_abandoned_cart';

	/**
	 * Action Scheduler hook for a reminder.
	 */
	public const HOOK = 'texttango_abandoned_cart';

	/**
	 * The shortest time between one customer's reminder sequences.
	 *
	 * Not a setting. A merchant tuning this down to nothing would be tuning away
	 * the protection rather than a preference.
	 */
	public const COOLDOWN = DAY_IN_SECONDS;

	/**
	 * First reminder.
	 */
	public const STAGE_FIRST = 1;

	/**
	 * Follow-up, when one is configured.
	 */
	public const STAGE_FOLLOWUP = 2;

	/**
	 * User meta recording the last reminder sent: `hash:stage:timestamp`.
	 */
	public const META_REMINDED = 'texttango_cart_reminded';

	/**
	 * The actions WooCommerce saves the cart on.
	 *
	 * Taken from `WC_Cart_Session::init()` rather than chosen: this class reads
	 * the copy those handlers write, so listening anywhere else means reading a
	 * cart from before the change that prompted the read.
	 *
	 * `woocommerce_after_cart_item_quantity_update` is the tempting one to use
	 * for a quantity change and the wrong one — it fires several lines earlier
	 * than `woocommerce_cart_item_set_quantity`, which is where the saving
	 * actually happens.
	 *
	 * @var string[]
	 */
	public const CART_HOOKS = array(
		'woocommerce_add_to_cart',
		'woocommerce_cart_item_removed',
		'woocommerce_cart_item_restored',
		'woocommerce_cart_item_set_quantity',
	);

	/**
	 * Priority the cart hooks are bound at.
	 *
	 * Must be later than WooCommerce's own, which saves at the default 10. Bound
	 * at 10 this class registers first, runs first, and reads the previous
	 * cart — so the very first add to an empty basket looks like no basket at
	 * all and no reminder is ever scheduled. The feature would appear to be off.
	 */
	public const PRIORITY = 20;

	/**
	 * Register hooks.
	 */
	public function register(): void {
		// Every way a cart's contents can change, so the timer restarts on all of
		// them rather than on the one that was thought of first.
		foreach ( self::CART_HOOKS as $hook ) {
			add_action( $hook, array( self::class, 'touch' ), self::PRIORITY );
		}

		/*
		 * Emptying is the one case that does not need the late priority, and gets
		 * it anyway for consistency: `empty_cart()` deletes the saved cart before
		 * firing this, so the read finds nothing and the reminder is cancelled.
		 */
		add_action( 'woocommerce_cart_emptied', array( self::class, 'touch' ), self::PRIORITY );

		// And the ways a basket stops being abandoned because it was bought.
		add_action( 'woocommerce_checkout_order_processed', array( self::class, 'bought' ), 10, 3 );
		add_action( 'woocommerce_store_api_checkout_order_processed', array( self::class, 'bought_via_blocks' ) );

		add_action( self::HOOK, array( self::class, 'remind' ), 10, 2 );
	}

	/**
	 * Whether the feature is on.
	 */
	public static function is_enabled(): bool {
		return (bool) Settings::get( 'abandoned_cart_enabled', false );
	}

	/**
	 * How long a cart must sit still before the first reminder, in seconds.
	 */
	public static function delay(): int {
		return max( 1, (int) Settings::get( 'abandoned_cart_delay', 60 ) ) * MINUTE_IN_SECONDS;
	}

	/**
	 * How long after the first reminder the follow-up goes. Zero means none.
	 */
	public static function followup_delay(): int {
		return max( 0, (int) Settings::get( 'abandoned_cart_followup_hours', 0 ) ) * HOUR_IN_SECONDS;
	}

	/**
	 * A cart changed. Restart the clock.
	 */
	public static function touch(): void {
		$user_id = get_current_user_id();

		if ( $user_id <= 0 || ! self::is_enabled() ) {
			return;
		}

		self::unschedule( $user_id );

		// An empty cart is not an abandoned one, and neither is a cart belonging
		// to somebody there is no way to message.
		if ( self::is_cart_empty( self::cart( $user_id ) ) || '' === Recipient::for_user( $user_id ) ) {
			return;
		}

		self::schedule( $user_id, self::STAGE_FIRST, self::delay() );
	}

	/**
	 * An order came in through the classic checkout.
	 *
	 * @param int                  $order_id Order id.
	 * @param array<string, mixed> $posted   Posted checkout data. Unused.
	 * @param mixed                $order    Order object.
	 */
	public static function bought( $order_id, $posted = array(), $order = null ): void {
		unset( $posted, $order );

		self::unschedule( get_current_user_id() );
	}

	/**
	 * An order came in through the block checkout.
	 *
	 * @param mixed $order Order object.
	 */
	public static function bought_via_blocks( $order = null ): void {
		$user_id = 0;

		if ( is_object( $order ) && method_exists( $order, 'get_customer_id' ) ) {
			$user_id = (int) $order->get_customer_id();
		}

		self::unschedule( $user_id > 0 ? $user_id : get_current_user_id() );
	}

	/**
	 * Send a reminder, if it is still the right thing to do.
	 *
	 * Everything is re-checked here rather than trusted from when the reminder
	 * was scheduled. An hour is long enough to check out, empty the cart, opt
	 * out, or for the merchant to switch the feature off.
	 *
	 * @param int $user_id Customer.
	 * @param int $stage   Which reminder in the sequence.
	 */
	public static function remind( $user_id, $stage = self::STAGE_FIRST ): void {
		$user_id = (int) $user_id;
		$stage   = (int) $stage;

		if ( $user_id <= 0 ) {
			return;
		}

		$cart      = self::cart( $user_id );
		$recipient = Recipient::for_user( $user_id );
		$hash      = self::hash( $cart );

		$verdict = self::decide(
			array(
				'enabled'    => self::is_enabled(),
				'cart_empty' => self::is_cart_empty( $cart ),
				'has_number' => '' !== $recipient,
				'template'   => '' !== trim( self::template( $stage ) ),
				'last'       => (string) get_user_meta( $user_id, self::META_REMINDED, true ),
				'hash'       => $hash,
				'stage'      => $stage,
				'now'        => Clock::now(),
			)
		);

		if ( '' !== $verdict ) {
			return;
		}

		$body = Templates::render( self::template( $stage ), Templates::cart_values( $user_id, $cart ) );

		/*
		 * Consent and the opt-out list are checked inside the sender, against the
		 * `marketing_` prefix on the event, rather than again here. One gate that
		 * everything passes through is worth more than a second copy of the rule
		 * that can fall out of step with it.
		 */
		$result = Sender::queue(
			array(
				'to'         => $recipient,
				'message'    => $body,
				'event'      => self::EVENT,
				'dedupe_key' => sprintf( 'cart:%d:%d:%s', $user_id, $stage, $hash ),
				'wp_user_id' => $user_id,
			)
		);

		if ( is_wp_error( $result ) ) {
			return;
		}

		update_user_meta( $user_id, self::META_REMINDED, sprintf( '%s:%d:%d', $hash, $stage, Clock::now() ) );

		$followup = self::followup_delay();

		if ( self::STAGE_FIRST === $stage && $followup > 0 ) {
			self::schedule( $user_id, self::STAGE_FOLLOWUP, $followup );
		}
	}

	/**
	 * Whether a reminder should go, and if not, why not.
	 *
	 * Pure. Every fact is passed in, so the whole rule — including the two ways
	 * it refuses to repeat itself — reads as one function rather than as
	 * conditions scattered between a scheduler and a worker.
	 *
	 * @param array<string, mixed> $facts {
	 *     @type bool   $enabled    Whether the feature is on.
	 *     @type bool   $cart_empty Whether the cart is now empty.
	 *     @type bool   $has_number Whether there is a number to send to.
	 *     @type bool   $template   Whether this stage has a message.
	 *     @type string $last       Last reminder, as `hash:stage:timestamp`.
	 *     @type string $hash       Current cart's fingerprint.
	 *     @type int    $stage      Which reminder in the sequence.
	 *     @type int    $now        Current timestamp.
	 * }
	 * @return string Empty to send, otherwise a machine-readable reason not to.
	 */
	public static function decide( array $facts ): string {
		if ( empty( $facts['enabled'] ) ) {
			return 'disabled';
		}

		// Checked before anything about repetition: a cart that is now empty was
		// either bought or abandoned deliberately, and neither wants a nudge.
		if ( ! empty( $facts['cart_empty'] ) ) {
			return 'cart_empty';
		}

		if ( empty( $facts['has_number'] ) ) {
			return 'no_number';
		}

		if ( empty( $facts['template'] ) ) {
			return 'no_template';
		}

		$last = self::parse_reminder( (string) ( $facts['last'] ?? '' ) );

		if ( null === $last ) {
			return '';
		}

		// The same cart, at the same point in the sequence, has already been
		// reminded about. Restarting the timer must not restart the sequence.
		if ( $last['hash'] === (string) $facts['hash'] && $last['stage'] >= (int) $facts['stage'] ) {
			return 'already_sent';
		}

		/*
		 * A different cart, but too soon. Without this, emptying a cart and
		 * refilling it produces a new fingerprint and so a new sequence, as often
		 * as somebody cares to click — which is a reminder feature turned into a
		 * way to text yourself on demand, billed to the merchant.
		 */
		if ( $last['hash'] !== (string) $facts['hash'] && (int) $facts['now'] - $last['at'] < self::COOLDOWN ) {
			return 'too_soon';
		}

		return '';
	}

	/**
	 * Read back what {@see self::remind()} stored.
	 *
	 * @param string $stored Value of the reminder meta.
	 * @return array{hash: string, stage: int, at: int}|null
	 */
	public static function parse_reminder( string $stored ): ?array {
		$parts = explode( ':', $stored );

		if ( 3 !== count( $parts ) || '' === $parts[0] ) {
			return null;
		}

		return array(
			'hash'  => $parts[0],
			'stage' => (int) $parts[1],
			'at'    => (int) $parts[2],
		);
	}

	/**
	 * The message for one stage of the sequence.
	 *
	 * @param int $stage Which reminder.
	 */
	public static function template( int $stage ): string {
		$key = self::STAGE_FOLLOWUP === $stage
			? 'abandoned_cart_followup_template'
			: 'abandoned_cart_template';

		$stored = trim( (string) Settings::get( $key, '' ) );

		return '' === $stored ? self::default_template( $stage ) : $stored;
	}

	/**
	 * What a stage says when the merchant has not written anything.
	 *
	 * @param int $stage Which reminder.
	 */
	public static function default_template( int $stage ): string {
		if ( self::STAGE_FOLLOWUP === $stage ) {
			return __( 'Hi {first_name}, your {store_name} basket is still waiting — {cart_items} item(s), {cart_total}. Finish up here: {cart_url}', 'texttango' );
		}

		return __( 'Hi {first_name}, you left {cart_items} item(s) in your {store_name} basket ({cart_total}). Pick up where you left off: {cart_url}', 'texttango' );
	}

	/**
	 * A signed-in customer's saved cart.
	 *
	 * @param int $user_id Customer.
	 * @return array<string, mixed>
	 */
	public static function cart( int $user_id ): array {
		$stored = get_user_meta( $user_id, '_woocommerce_persistent_cart_' . get_current_blog_id(), true );

		if ( ! is_array( $stored ) || ! isset( $stored['cart'] ) || ! is_array( $stored['cart'] ) ) {
			return array();
		}

		return $stored['cart'];
	}

	/**
	 * Whether there is anything in a cart.
	 *
	 * @param array<string, mixed> $cart Cart contents.
	 */
	public static function is_cart_empty( array $cart ): bool {
		return array() === $cart;
	}

	/**
	 * A fingerprint of what is in a cart.
	 *
	 * Products and quantities only. Prices move for reasons that have nothing to
	 * do with the customer — a sale starting overnight would otherwise look like
	 * a brand new cart and earn them a second reminder for the same items.
	 *
	 * @param array<string, mixed> $cart Cart contents.
	 */
	public static function hash( array $cart ): string {
		$items = array();

		foreach ( $cart as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$items[] = sprintf(
				'%d/%d/%d',
				(int) ( $item['product_id'] ?? 0 ),
				(int) ( $item['variation_id'] ?? 0 ),
				(int) ( $item['quantity'] ?? 0 )
			);
		}

		sort( $items );

		return substr( md5( implode( ',', $items ) ), 0, 12 );
	}

	/**
	 * Book a reminder.
	 *
	 * @param int $user_id Customer.
	 * @param int $stage   Which reminder.
	 * @param int $delay   Seconds from now.
	 */
	private static function schedule( int $user_id, int $stage, int $delay ): void {
		if ( ! function_exists( 'as_schedule_single_action' ) ) {
			return;
		}

		as_schedule_single_action(
			Clock::now() + $delay,
			self::HOOK,
			array(
				'user_id' => $user_id,
				'stage'   => $stage,
			),
			Queue::GROUP
		);
	}

	/**
	 * Cancel every pending reminder for one customer.
	 *
	 * Both stages, because a follow-up booked an hour ago is exactly the message
	 * that must not arrive after somebody has already bought.
	 *
	 * @param int $user_id Customer.
	 */
	private static function unschedule( int $user_id ): void {
		if ( $user_id <= 0 || ! function_exists( 'as_unschedule_all_actions' ) ) {
			return;
		}

		foreach ( array( self::STAGE_FIRST, self::STAGE_FOLLOWUP ) as $stage ) {
			as_unschedule_all_actions(
				self::HOOK,
				array(
					'user_id' => $user_id,
					'stage'   => $stage,
				),
				Queue::GROUP
			);
		}
	}
}
