<?php
/**
 * Sending a campaign that gives everybody their own code.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Marketing;

use TextTango\Install\Schema;
use TextTango\Messages\MessageLog;
use TextTango\Sending\Sender;
use TextTango\Support\Clock;
use TextTango\Support\Paging;

defined( 'ABSPATH' ) || exit;

/**
 * The fan-out behind a personal-code campaign (spec §8).
 *
 * ### Why this is not a campaign the platform sends
 *
 * `POST /v2/campaigns` takes one body for a whole list. A personal-code campaign
 * has a different body for every recipient — that is what makes the code
 * personal — so there is nothing to hand over. It is sent from here, one
 * `Sender::send()` per person.
 *
 * ### Sending is not reimplemented here
 *
 * Every message goes through {@see Sender::send()}, which is the only reason
 * quiet hours, spend caps, the circuit breaker, the opt-out list, sandbox, the
 * dedupe key and the delivery log apply to these messages at all. A second send
 * path would be a second copy of those rules, and the one it would drift from
 * first is the one that stops a staging clone texting real customers.
 *
 * ### Why the recipient list is a table
 *
 * It has to be **frozen** when the campaign starts. Re-deriving the audience per
 * batch would shift the moment somebody opted out, and an offset into a list
 * that shrank underneath it skips whoever moved up — so a batch would silently
 * miss people while reporting success.
 *
 * It also has to survive a restart: Action Scheduler guarantees a job runs at
 * least once, not exactly once, so a batch may run twice and must not mint a
 * second coupon for somebody who already has one. The unique key on
 * `(campaign_uuid, recipient)` is what makes that safe rather than careful.
 *
 * And it is the only place that can answer what a merchant will certainly ask:
 * which code did this person get, and did they use it.
 */
final class PersonalCampaign {

	/**
	 * Action Scheduler hook for one batch.
	 */
	public const HOOK = 'texttango_campaign_batch';

	/**
	 * Action Scheduler group, shared with the message queue.
	 */
	public const GROUP = 'texttango';

	/**
	 * The event these messages are logged under.
	 *
	 * Its own name, not `campaign_bulk`. They are genuinely different things —
	 * one writes a row per recipient and the other writes none — and the message
	 * log's event filter is where somebody goes to tell them apart.
	 */
	public const EVENT = 'campaign_personal';

	/**
	 * How many recipients one batch handles.
	 *
	 * Small enough that a batch finishes well inside any request timeout, since
	 * each one mints a coupon and makes an HTTP call. A campaign of five thousand
	 * is two hundred batches, which Action Scheduler paces on its own.
	 */
	public const BATCH = 25;

	/**
	 * How many recipient rows are written per INSERT when a campaign starts.
	 *
	 * Two hundred keeps the statement well inside `max_allowed_packet` while
	 * turning a ten thousand recipient campaign into fifty queries rather than
	 * ten thousand — and that write happens in the request where somebody
	 * pressed Send.
	 */
	public const INSERT_CHUNK = 200;

	/**
	 * Waiting to be sent.
	 */
	public const PENDING = 'pending';

	/**
	 * Handed to the sender.
	 */
	public const SENT = 'sent';

	/**
	 * Refused, with a reason.
	 */
	public const FAILED = 'failed';

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( self::HOOK, array( self::class, 'run' ), 10, 1 );
	}

	/**
	 * Freeze the recipient list for a campaign and start it.
	 *
	 * @param string   $uuid       Campaign uuid.
	 * @param string[] $recipients E.164 numbers.
	 * @return int How many were queued.
	 */
	public static function start( string $uuid, array $recipients ): int {
		global $wpdb;

		$table = Schema::campaign_recipients_table_name();
		$now   = gmdate( 'Y-m-d H:i:s', Clock::now() );
		$saved = 0;

		$wanted = array_values( array_filter( array_map( 'strval', $recipients ) ) );

		/*
		 * Written in chunks rather than one row at a time. This runs inside the
		 * request where somebody pressed Send, and a campaign of ten thousand
		 * would otherwise be ten thousand round trips before the page came back —
		 * long enough to hit a timeout, on the one action that must not be
		 * repeated because the merchant is unsure whether it worked.
		 */
		foreach ( array_chunk( $wanted, self::INSERT_CHUNK ) as $chunk ) {
			$values       = array();
			$placeholders = array();

			foreach ( $chunk as $recipient ) {
				$placeholders[] = '( %s, %s, %s, %s )';

				array_push( $values, $uuid, $recipient, self::PENDING, $now );
			}

			/*
			 * Ignored rather than replaced on a duplicate: the unique key means a
			 * second start() for one campaign — a double-submitted form, a retried
			 * request — adds nobody twice and re-mints for nobody.
			 */
			$sql = "INSERT IGNORE INTO {$table} ( campaign_uuid, recipient, status, created_at ) VALUES "
				. implode( ', ', $placeholders );

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table from $wpdb->prefix; every value is a placeholder bound below.
			$inserted = $wpdb->query( $wpdb->prepare( $sql, $values ) );

			$saved += (int) $inserted;
		}

		if ( $saved > 0 ) {
			self::schedule( $uuid, 0 );
		}

		return $saved;
	}

	/**
	 * Ask for the next batch.
	 *
	 * @param string $uuid  Campaign uuid.
	 * @param int    $delay Seconds to wait.
	 */
	private static function schedule( string $uuid, int $delay = 0 ): void {
		if ( ! function_exists( 'as_schedule_single_action' ) ) {
			return;
		}

		as_schedule_single_action(
			Clock::now() + max( 0, $delay ),
			self::HOOK,
			array( array( 'uuid' => $uuid ) ),
			self::GROUP
		);
	}

	/**
	 * Send one batch, then ask for the next.
	 *
	 * @param array<string, mixed>|string $args Scheduled arguments.
	 */
	public static function run( $args ): void {
		$uuid = is_array( $args ) ? (string) ( $args['uuid'] ?? '' ) : (string) $args;

		if ( '' === $uuid ) {
			return;
		}

		$campaign = CampaignLog::find( $uuid );

		if ( null === $campaign ) {
			return;
		}

		/*
		 * Stopped between batches. Checked here rather than only when Stop is
		 * pressed, because the batch already scheduled cannot be unscheduled from
		 * inside itself — and a cancelled campaign that sends one more batch has
		 * spent money the merchant asked it not to.
		 */
		if ( 'cancelled' === (string) $campaign['status'] ) {
			return;
		}

		$pending = self::pending( $uuid, self::BATCH );

		if ( array() === $pending ) {
			self::finish( $uuid );

			return;
		}

		foreach ( $pending as $row ) {
			self::deliver( $campaign, $row );
		}

		// Straight on to the next: Action Scheduler paces its own queue, and a
		// delay here would only make a long campaign longer.
		self::schedule( $uuid, 0 );
	}

	/**
	 * Mint one code and send one message.
	 *
	 * @param array<string, mixed> $campaign The campaign record.
	 * @param array<string, mixed> $row      The recipient row.
	 */
	private static function deliver( array $campaign, array $row ): void {
		$uuid      = (string) $campaign['uuid'];
		$recipient = (string) $row['recipient'];
		$sandbox   = ! empty( $campaign['sandbox'] );

		$code = Coupons::mint( (string) $campaign['coupon'], $uuid, $recipient, $sandbox );

		if ( '' === $code ) {
			/*
			 * The template coupon was deleted after the campaign started. Sending
			 * anyway would text a discount code that discounts nothing, so this
			 * person is marked and the campaign carries on — one missing coupon is
			 * not a reason to strand everybody still waiting.
			 */
			self::settle( (int) $row['id'], self::FAILED, 0, '', __( 'The coupon this campaign was built around no longer exists.', 'texttango' ) );

			return;
		}

		$facts = Coupons::facts( (string) $campaign['coupon'] );
		$body  = Campaign::resolve(
			(string) $campaign['body'],
			null === $facts ? null : array_merge( $facts, array( 'code' => $code ) )
		);

		$result = Sender::send(
			array(
				'to'         => $recipient,
				'message'    => $body,
				'event'      => self::EVENT,
				// Keyed on the campaign and the person, so a batch that runs twice
				// is refused by the log rather than charged twice.
				'dedupe_key' => 'campaign:' . $uuid . ':' . $recipient,
			)
		);

		if ( is_wp_error( $result ) ) {
			self::settle( (int) $row['id'], self::FAILED, 0, $code, (string) $result->get_error_message() );

			return;
		}

		self::settle( (int) $row['id'], self::SENT, (int) ( $result['message_id'] ?? 0 ), $code, '' );
	}

	/**
	 * Record what happened to one recipient.
	 *
	 * @param int    $id         Row id.
	 * @param string $status     One of PENDING, SENT, FAILED.
	 * @param int    $message_id Delivery-log row, when there is one.
	 * @param string $code       The code minted, when one was.
	 * @param string $reason     Why it failed, when it did.
	 */
	private static function settle( int $id, string $status, int $message_id, string $code, string $reason ): void {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table.
		$wpdb->update(
			Schema::campaign_recipients_table_name(),
			array(
				'status'       => $status,
				'message_id'   => $message_id,
				'coupon'       => $code,
				'error_reason' => substr( $reason, 0, 191 ),
				'sent_at'      => gmdate( 'Y-m-d H:i:s', Clock::now() ),
			),
			array( 'id' => $id ),
			array( '%s', '%d', '%s', '%s', '%s' ),
			array( '%d' )
		);
	}

	/**
	 * The next few people waiting.
	 *
	 * @param string $uuid  Campaign uuid.
	 * @param int    $limit How many.
	 * @return array<int, array<string, mixed>>
	 */
	private static function pending( string $uuid, int $limit ): array {
		global $wpdb;

		$table = Schema::campaign_recipients_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name comes from $wpdb->prefix.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table name is interpolated.
				"SELECT * FROM {$table} WHERE campaign_uuid = %s AND status = %s ORDER BY id ASC LIMIT %d",
				$uuid,
				self::PENDING,
				max( 1, $limit )
			),
			ARRAY_A
		);

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Mark a campaign finished, with what actually went.
	 *
	 * @param string $uuid Campaign uuid.
	 */
	private static function finish( string $uuid ): void {
		$counts = self::progress( $uuid );

		CampaignLog::update(
			$uuid,
			array(
				'status'    => 'completed',
				'sent'      => $counts['sent'],
				'failed'    => $counts['failed'],
				'refreshed' => gmdate( 'c', Clock::now() ),
			)
		);
	}

	/**
	 * How far a campaign has got.
	 *
	 * @param string $uuid Campaign uuid.
	 * @return array{total: int, sent: int, failed: int, pending: int}
	 */
	public static function progress( string $uuid ): array {
		global $wpdb;

		$table = Schema::campaign_recipients_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name comes from $wpdb->prefix.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table name is interpolated.
				"SELECT status, COUNT(*) AS n FROM {$table} WHERE campaign_uuid = %s GROUP BY status",
				$uuid
			),
			ARRAY_A
		);

		$counts = array(
			'total'   => 0,
			'sent'    => 0,
			'failed'  => 0,
			'pending' => 0,
		);

		foreach ( (array) $rows as $row ) {
			$status = (string) $row['status'];
			$n      = (int) $row['n'];

			$counts['total'] += $n;

			if ( isset( $counts[ $status ] ) ) {
				$counts[ $status ] += $n;
			}
		}

		return $counts;
	}

	/**
	 * Stop a campaign that has not finished.
	 *
	 * Unschedules what has not run and leaves what has. A message already handed
	 * to the network cannot be recalled, and a coupon already texted is a promise
	 * — so those stay, and the table records exactly where it stopped.
	 *
	 * @param string $uuid Campaign uuid.
	 */
	public static function stop( string $uuid ): void {
		if ( function_exists( 'as_unschedule_all_actions' ) ) {
			as_unschedule_all_actions( self::HOOK, array( array( 'uuid' => $uuid ) ), self::GROUP );
		}
	}

	/**
	 * One page of a campaign's recipients, newest first.
	 *
	 * @param string $uuid   Campaign uuid.
	 * @param Paging $paging Page to read.
	 * @return array<int, array<string, mixed>>
	 */
	public static function paged( string $uuid, Paging $paging ): array {
		global $wpdb;

		$table = Schema::campaign_recipients_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name comes from $wpdb->prefix.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table name is interpolated.
				"SELECT * FROM {$table} WHERE campaign_uuid = %s ORDER BY id ASC LIMIT %d OFFSET %d",
				$uuid,
				$paging->per_page(),
				$paging->offset()
			),
			ARRAY_A
		);

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Whether a person's code has been redeemed.
	 *
	 * Read off the coupon rather than stored here, because the redemption
	 * happens at a checkout this class never sees and a copy would go stale the
	 * moment it was written.
	 *
	 * @param string $code Coupon code.
	 */
	public static function redeemed( string $code ): bool {
		if ( '' === $code ) {
			return false;
		}

		$facts = Coupons::facts( $code );

		return null !== $facts && (int) $facts['usage_count'] > 0;
	}

	/**
	 * Forget a campaign's recipients.
	 *
	 * @param string $uuid Campaign uuid.
	 */
	public static function forget( string $uuid ): void {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table.
		$wpdb->delete( Schema::campaign_recipients_table_name(), array( 'campaign_uuid' => $uuid ), array( '%s' ) );
	}

	/**
	 * Everything issued to a set of numbers, newest first.
	 *
	 * The lookup behind the privacy exporter and eraser. This table holds a
	 * phone number and the discount code given to whoever answers it, which is
	 * personal data by any reading — so WordPress's own data-subject tooling has
	 * to be able to find it, and until now could not.
	 *
	 * Takes numbers rather than a user id or an email, because that is the only
	 * thing this table knows: a personal campaign may reach somebody who has no
	 * account and has never ordered. Deciding *which* numbers belong to a person
	 * stays with {@see \TextTango\Privacy\PersonalData::numbers_for()}, which
	 * already answers that for the message log — two answers to that question
	 * would eventually disagree, and a data-subject request is the worst place to
	 * find out.
	 *
	 * @param string[] $numbers E.164 numbers.
	 * @param int      $limit   Most rows to return.
	 * @return array<int, array<string, mixed>>
	 */
	public static function for_numbers( array $numbers, int $limit = 500 ): array {
		global $wpdb;

		$numbers = array_values( array_filter( array_map( 'strval', $numbers ) ) );

		if ( array() === $numbers ) {
			return array();
		}

		$table        = Schema::campaign_recipients_table_name();
		$placeholders = implode( ', ', array_fill( 0, count( $numbers ), '%s' ) );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table from $wpdb->prefix; the placeholders are counted from the argument.
		$sql = "SELECT * FROM {$table} WHERE recipient IN ( {$placeholders} ) ORDER BY id DESC LIMIT %d";

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is the literal above; every value below is a placeholder.
		$query = $wpdb->prepare( $sql, array_merge( $numbers, array( max( 1, $limit ) ) ) );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; prepared on the line above.
		$rows = $wpdb->get_results( $query, ARRAY_A );

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Remove what was issued to a set of numbers, and unlink their coupons.
	 *
	 * ### Why the coupon survives
	 *
	 * A minted coupon carries `Coupons::META_RECIPIENT` — the number it was made
	 * for — and that meta is personal data sitting in `wp_postmeta`, so it goes.
	 * The coupon itself does not: a redeemed code is attached to a real order,
	 * and deleting it would alter that order's record and WooCommerce's own
	 * reporting. An unredeemed one, once unlinked, is a code that identifies
	 * nobody.
	 *
	 * `META_CAMPAIGN` stays. A campaign uuid says nothing about a person.
	 *
	 * @param string[] $numbers E.164 numbers.
	 * @return int How many rows were removed.
	 */
	public static function erase_for( array $numbers ): int {
		global $wpdb;

		$rows = self::for_numbers( $numbers );

		if ( array() === $rows ) {
			return 0;
		}

		$table   = Schema::campaign_recipients_table_name();
		$removed = 0;

		foreach ( $rows as $row ) {
			$code = (string) ( $row['coupon'] ?? '' );

			if ( '' !== $code && function_exists( 'wc_get_coupon_id_by_code' ) ) {
				$coupon_id = (int) wc_get_coupon_id_by_code( $code );

				if ( $coupon_id > 0 ) {
					delete_post_meta( $coupon_id, Coupons::META_RECIPIENT );
				}
			}

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table.
			$wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) );

			++$removed;
		}

		return $removed;
	}

	/**
	 * Drop recipient rows older than a cutoff.
	 *
	 * The numbers here are personal data, so a retention window has to reach
	 * them the way it reaches the message log. It did not, which meant a
	 * merchant who asked the plugin to stop keeping numbers after ninety days
	 * kept these ones forever.
	 *
	 * @param string $cutoff UTC datetime, `Y-m-d H:i:s`.
	 * @param int    $limit  Most rows to remove in one pass.
	 * @return int How many rows were removed.
	 */
	public static function prune( string $cutoff, int $limit ): int {
		global $wpdb;

		$table = Schema::campaign_recipients_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; values are placeholders.
		$deleted = $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table name is interpolated, from $wpdb->prefix.
				"DELETE FROM {$table} WHERE created_at < %s LIMIT %d",
				$cutoff,
				max( 1, $limit )
			)
		);

		return false === $deleted ? 0 : (int) $deleted;
	}
}
