<?php
/**
 * Sending one message to everybody who agreed to hear from you.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Marketing;

use TextTango\Api\Client;
use TextTango\Audit\AuditLog;
use TextTango\Consent\QuietHours;
use TextTango\Sending\BalanceWatch;
use TextTango\Sending\Budget;
use TextTango\Support\Clock;
use TextTango\Support\Sandbox;
use TextTango\Support\Settings;
use TextTango\WooCommerce\Templates;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Campaigns (spec §8).
 *
 * A campaign is the one thing this plugin does that cannot be undone and
 * cannot be done a little at a time. Every other send is one message to one
 * person, recoverable by apologising to them; this is thousands at once, and
 * the mistakes it makes — wrong list, wrong text, sent at 3am, sent twice —
 * are all mistakes of the moment before it goes.
 *
 * So the shape of this class is: everything that can be decided and shown
 * happens in {@see self::preview()}, and {@see self::send()} does the same work
 * again and then commits. Nothing is estimated once and trusted later.
 *
 * ### What it deliberately does not do
 *
 * **It does not retry.** {@see Client::post()} never retries a POST, and here
 * that rule earns its keep: a retried campaign is a second campaign, to
 * everybody. A timeout leaves a campaign whose fate is unknown, and the honest
 * response to that is to say so and let a human look, not to guess.
 *
 * **It does not receive delivery receipts.** The platform dispatches campaign
 * webhooks to a token's `campaign_callback_url`, which this plugin never sets —
 * it owns `single_sms_callback_url` and nothing else, so it can never be blamed
 * for breaking another integration's receipts. Campaign progress is therefore
 * polled, which is the right trade for something that reports one number a few
 * times rather than one event per recipient.
 */
final class Campaign {

	/**
	 * Event id.
	 *
	 * The `campaign_` prefix is load-bearing, not decorative. Both
	 * {@see Budget::tier()} and {@see \TextTango\Consent\Consent::is_marketing()}
	 * classify by prefix, so an event named plainly `campaign` would be treated
	 * as transactional — meaning quiet hours would not hold it and it would
	 * outrank order updates for what is left of the wallet. `CampaignTest` holds
	 * this constant against both classifiers so the next campaign type cannot
	 * quietly be named out of the marketing tier.
	 */
	public const EVENT = 'campaign_bulk';

	/**
	 * One request to the platform, which fans it out.
	 */
	public const DELIVERY_BULK = CampaignLog::DELIVERY_BULK;

	/**
	 * One message per recipient, each with its own coupon code.
	 */
	public const DELIVERY_PERSONAL = CampaignLog::DELIVERY_PERSONAL;

	/**
	 * A delivery mode, or the shared-code default.
	 *
	 * Anything unrecognised becomes bulk rather than erroring — the same rule
	 * {@see self::audience_mode()} follows. Bulk is the safe fallback of the two:
	 * it is what every campaign did before personal codes existed, and it cannot
	 * mint coupons a merchant did not ask for.
	 *
	 * @param string $delivery Raw value.
	 */
	public static function delivery_mode( string $delivery ): string {
		return self::DELIVERY_PERSONAL === $delivery ? self::DELIVERY_PERSONAL : self::DELIVERY_BULK;
	}

	/**
	 * Whether a personal-code campaign can be built from these choices.
	 *
	 * Pure, and refused rather than degraded. Without a coupon there is nothing
	 * to mint from, so every recipient would get a body with a literal
	 * `{coupon_code}` in it — and unlike a shared-code campaign, this one cannot
	 * fall back to sending the template's own code, because the whole point is
	 * that nobody shares one.
	 *
	 * @param string $delivery Delivery mode.
	 * @param string $coupon   Coupon code chosen, if any.
	 */
	public static function refuses_personal( string $delivery, string $coupon ): bool {
		return self::DELIVERY_PERSONAL === self::delivery_mode( $delivery ) && '' === trim( $coupon );
	}

	/**
	 * Send to everyone whose consent this site has on record.
	 */
	public const AUDIENCE_CUSTOMERS = 'customers';

	/**
	 * Send only to numbers the merchant typed in.
	 */
	public const AUDIENCE_SUPPLIED = 'supplied';

	/**
	 * Send to both, deduplicated.
	 */
	public const AUDIENCE_BOTH = 'both';

	/**
	 * How often in-flight campaigns are asked how they are doing.
	 */
	public const REFRESH_INTERVAL = 5 * MINUTE_IN_SECONDS;

	/**
	 * Action Scheduler hook for the poll.
	 */
	public const HOOK = 'texttango_refresh_campaigns';

	/**
	 * How many settled-but-unpriced campaigns one poll catches up on.
	 *
	 * Small, because this is a backlog that empties: a store with a hundred
	 * unpriced campaigns is caught up within a couple of hours of five-minute
	 * polls, and after that this finds nothing and costs one query.
	 */
	public const BACKFILL = 5;

	/**
	 * How long after dispatch a campaign is asked how it went.
	 *
	 * The recurring poll runs every five minutes, so without this a campaign's
	 * cost and delivered counts are up to five minutes late — which is exactly
	 * the window a merchant spends looking at the screen after pressing Send.
	 * This does not replace that poll; it removes the wait for the first one.
	 */
	public const FIRST_LOOK = 30;

	/**
	 * Wire up the poll.
	 */
	public function register(): void {
		add_action( self::HOOK, array( self::class, 'refresh_all' ) );
		add_action( 'init', array( self::class, 'ensure_scheduled' ) );
	}

	/**
	 * Keep the poll scheduled.
	 */
	public static function ensure_scheduled(): void {
		if ( ! function_exists( 'as_has_scheduled_action' ) || ! function_exists( 'as_schedule_recurring_action' ) ) {
			return;
		}

		if ( as_has_scheduled_action( self::HOOK, array(), 'texttango' ) ) {
			return;
		}

		as_schedule_recurring_action( time() + self::REFRESH_INTERVAL, self::REFRESH_INTERVAL, self::HOOK, array(), 'texttango' );
	}

	/**
	 * The audience a request is asking for.
	 *
	 * Pure, and it defaults rather than refuses — but the direction of that
	 * default is the whole point. Anything unrecognised falls back to the
	 * audience this plugin can *prove* agreed, never to the one it cannot. A
	 * tampered or truncated field therefore under-reaches rather than reaching
	 * people nobody vouched for.
	 *
	 * @param string $raw Whatever arrived in the request.
	 */
	public static function audience_mode( string $raw ): string {
		$known = array( self::AUDIENCE_CUSTOMERS, self::AUDIENCE_SUPPLIED, self::AUDIENCE_BOTH );

		return in_array( $raw, $known, true ) ? $raw : self::AUDIENCE_CUSTOMERS;
	}

	/**
	 * Whether a mode reaches the store's own consenting customers.
	 *
	 * @param string $mode One of the AUDIENCE_* constants.
	 */
	public static function uses_customers( string $mode ): bool {
		return self::AUDIENCE_SUPPLIED !== self::audience_mode( $mode );
	}

	/**
	 * Whether a mode reaches numbers the merchant supplied.
	 *
	 * @param string $mode One of the AUDIENCE_* constants.
	 */
	public static function uses_supplied( string $mode ): bool {
		return self::AUDIENCE_CUSTOMERS !== self::audience_mode( $mode );
	}

	/**
	 * Why there is nobody to send to, in the terms of the audience chosen.
	 *
	 * One message for all three modes would be actively wrong in two of them: a
	 * merchant who mistyped every number they pasted would be told their
	 * customers had not consented, and go looking in the wrong place.
	 *
	 * @param string $mode One of the AUDIENCE_* constants.
	 */
	public static function empty_text( string $mode ): string {
		$mode = self::audience_mode( $mode );

		if ( self::AUDIENCE_SUPPLIED === $mode ) {
			return __( 'None of the numbers you entered could be used. Check them for typos — anyone on your opt-out list is also excluded.', 'texttango' );
		}

		if ( self::AUDIENCE_BOTH === $mode ) {
			return __( 'There is nobody to send to: nobody on this store has agreed to marketing messages, and none of the numbers you entered could be used.', 'texttango' );
		}

		return __( 'Nobody on this store has agreed to marketing messages yet, so there is nobody to send to.', 'texttango' );
	}

	/**
	 * What a campaign would do, without doing it.
	 *
	 * The body comes back resolved, because that is the message that will
	 * actually arrive. A review showing `{coupon_code}` would be a review of
	 * something nobody receives — and a tag that resolves to nothing is exactly
	 * the mistake the review exists to catch.
	 *
	 * @param string $body     Message body, before merge tags.
	 * @param string $coupon   Coupon code to build the message around, if any.
	 * @param string $mode     One of the AUDIENCE_* constants.
	 * @param string $supplied_raw Numbers the merchant typed, as typed.
	 * @param bool   $asserted Whether they confirmed those people agreed.
	 * @return array<string, mixed> Audience figures merged with the estimate.
	 */
	public static function preview( string $body, string $coupon = '', string $mode = self::AUDIENCE_CUSTOMERS, string $supplied_raw = '', bool $asserted = false, int $when = 0, ?Segment $segment = null ): array {
		$mode = self::audience_mode( $mode );
		$now  = Clock::now();

		/*
		 * Gathered only when the mode asks for it. In supplied mode this walks
		 * neither the user table nor the orders, which is both the point of the
		 * mode and much the cheaper path.
		 */
		$audience = self::uses_customers( $mode ) ? Audience::gather( $segment ) : self::no_audience();
		$supplied = self::uses_supplied( $mode ) ? SuppliedNumbers::gather( $supplied_raw ) : self::nothing_supplied();
		$combined = self::combine( $audience['recipients'], $supplied['recipients'] );

		$facts    = '' === $coupon ? null : Coupons::facts( $coupon );
		$resolved = self::resolve( $body, $facts );
		$estimate = self::estimate( count( $combined ), $resolved );
		$refused  = '' === $coupon ? '' : Coupons::blocks( $facts, Clock::now() );

		if ( '' === $refused && self::uses_supplied( $mode ) ) {
			$refused = SuppliedNumbers::refuse( count( $supplied['recipients'] ), $asserted );
		}

		$delay = QuietHours::delay_for( self::EVENT );
		$sends = self::schedule_for( $when, $now, $delay );

		return array_merge(
			$audience,
			array(
				'mode'       => $mode,
				'recipients' => $combined,
				// Kept as its own figure rather than folded into the audience: the
				// merchant needs to see at a glance how much of this campaign rests
				// on consent this plugin can prove and how much on their word.
				'supplied'   => $supplied,
				'asserted'   => $asserted,
				'body'       => $resolved,
				'estimate'   => $estimate,
				'blocked'    => self::headroom( $estimate['messages'], self::usage() ),
				'delay'      => $delay,
				'when'       => $when,
				'sends_at'   => $sends > $now ? $sends : 0,
				// Said separately from `delay`, because "you asked for 3am and
				// quiet hours moved it" is a different sentence from "quiet hours
				// are holding this".
				'moved'      => $when > 0 && $sends > $when,
				'bad_time'   => self::refuse_time( $when, $now ),
				'balance'    => BalanceWatch::last_known(),
				'sandbox'    => Sandbox::is_active(),
				'coupon'     => $facts,
				'refused'    => $refused,
				'warnings'   => null === $facts || '' !== $refused
					? array()
					: Coupons::warnings( $facts, count( $combined ), Clock::now(), $body ),
			)
		);
	}

	/**
	 * The shape {@see Audience::gather()} returns when it was never asked.
	 *
	 * Returned rather than skipped so every reader downstream — the template
	 * especially — sees the same keys whichever mode ran, and cannot resolve a
	 * missing one to a misleading zero by accident.
	 *
	 * @return array<string, mixed>
	 */
	private static function no_audience(): array {
		return array(
			'recipients' => array(),
			'consenting' => 0,
			'listed'     => 0,
			'segment'    => '',
			'narrowing'  => false,
			'outside'    => 0,
			'suppressed' => 0,
			'unusable'   => 0,
			'truncated'  => false,
		);
	}

	/**
	 * The shape {@see SuppliedNumbers::gather()} returns when it was never asked.
	 *
	 * @return array<string, mixed>
	 */
	private static function nothing_supplied(): array {
		return array(
			'recipients' => array(),
			'unusable'   => array(),
			'duplicates' => 0,
			'suppressed' => 0,
			'truncated'  => false,
		);
	}

	/**
	 * The store's own audience and the supplied one, as a single list.
	 *
	 * Pure. Deduplicated across both, because a customer who consented here and
	 * also appears on a pasted list is one person, and messaging them twice is
	 * both a bad experience and a doubled bill.
	 *
	 * The store's own come first, so if the combined list has to be trimmed to
	 * the platform's ceiling it is the asserted numbers that go — the ones this
	 * site can prove agreed are the ones it keeps.
	 *
	 * @param string[] $gathered Numbers built from recorded consent.
	 * @param string[] $supplied Numbers the merchant provided.
	 * @return string[]
	 */
	public static function combine( array $gathered, array $supplied ): array {
		$combined = array();

		foreach ( array_merge( $gathered, $supplied ) as $recipient ) {
			$combined[ $recipient ] = true;
		}

		return array_slice( array_keys( $combined ), 0, Audience::MAX_RECIPIENTS );
	}

	/**
	 * Substitute a campaign's merge tags.
	 *
	 * Resolved here rather than handed to the platform, which has never heard of
	 * a WooCommerce coupon. With no coupon chosen the tags are still stripped,
	 * because a body written around one and then sent without it is the case
	 * that would otherwise put a literal `{coupon_code}` in front of everybody.
	 *
	 * @param string                    $body  Message body.
	 * @param array<string, mixed>|null $facts Coupon facts, or null for none.
	 */
	public static function resolve( string $body, ?array $facts ): string {
		return Templates::render(
			$body,
			null === $facts ? array() : Coupons::values( $facts )
		);
	}

	/**
	 * What a campaign costs.
	 *
	 * Pure. Two figures that are easy to confuse and expensive to confuse:
	 * `messages` is how many sends happen, which is what a spend cap counts;
	 * `credits` is how many segments are billed, which is what the wallet pays.
	 * A 200-character message to a thousand people is a thousand messages and
	 * two thousand credits.
	 *
	 * @param int    $recipients How many people.
	 * @param string $body       Message body.
	 * @return array{encoding: string, length: int, segments: int, limit: int, messages: int, credits: int}
	 */
	public static function estimate( int $recipients, string $body ): array {
		$segments = Templates::estimate( $body );

		return array_merge(
			$segments,
			array(
				'messages' => max( 0, $recipients ),
				'credits'  => max( 0, $recipients ) * (int) $segments['segments'],
			)
		);
	}

	/**
	 * Whether a campaign of this size fits under the ceilings.
	 *
	 * Pure, and asked of the whole campaign rather than of one message. The
	 * per-message check in {@see Budget} stops sending when a cap is *reached*,
	 * which for a campaign is the wrong question — five thousand messages
	 * against a cap with two hundred left does not send two hundred of them and
	 * stop, it sends five thousand. So the whole thing has to fit before any of
	 * it goes.
	 *
	 * @param int                $messages How many sends the campaign makes.
	 * @param array<string, int> $usage    Keys `today`, `daily_cap`, `month`, `monthly_cap`.
	 * @return string Empty when it fits, otherwise a machine-readable reason.
	 */
	public static function headroom( int $messages, array $usage ): string {
		$usage = array_merge(
			array(
				'today'       => 0,
				'daily_cap'   => 0,
				'month'       => 0,
				'monthly_cap' => 0,
			),
			$usage
		);

		if ( $usage['daily_cap'] > 0 && $usage['today'] + $messages > $usage['daily_cap'] ) {
			return 'daily_cap';
		}

		if ( $usage['monthly_cap'] > 0 && $usage['month'] + $messages > $usage['monthly_cap'] ) {
			return 'monthly_cap';
		}

		return '';
	}

	/**
	 * When a campaign should actually go out.
	 *
	 * Pure, because the interaction between a time somebody picked and a quiet
	 * window they configured earlier is exactly the sort of thing that gets got
	 * wrong once and then never looked at again.
	 *
	 * - Nothing requested → now, or the end of the quiet window if one is open.
	 *   That is what a campaign did before this control existed.
	 * - A time inside a quiet window → pushed to when the window opens. Choosing
	 *   3am must not defeat the setting whose entire purpose is to prevent 3am.
	 * - Anything else → exactly what was asked for.
	 *
	 * @param int $requested   Requested send time, or 0 for as soon as possible.
	 * @param int $now         Current timestamp.
	 * @param int $quiet_delay Seconds until the quiet window opens; 0 when none is.
	 * @return int The moment to send, or `$now` to send immediately.
	 */
	public static function schedule_for( int $requested, int $now, int $quiet_delay ): int {
		if ( $requested <= 0 ) {
			return $quiet_delay > 0 ? $now + $quiet_delay : $now;
		}

		if ( $quiet_delay > 0 && $requested < $now + $quiet_delay ) {
			return $now + $quiet_delay;
		}

		return $requested;
	}

	/**
	 * Whether a requested send time can be used.
	 *
	 * Pure. The platform validates `scheduled_at` with `after:now` and would
	 * reject a past time as a field error; refusing it here says so in words the
	 * merchant can act on.
	 *
	 * @param int $requested Requested send time, or 0 for as soon as possible.
	 * @param int $now       Current timestamp.
	 * @return string Empty when usable, otherwise a machine-readable reason.
	 */
	public static function refuse_time( int $requested, int $now ): string {
		if ( $requested > 0 && $requested <= $now ) {
			return 'past';
		}

		return '';
	}

	/**
	 * A refused send time in words.
	 *
	 * @param string $reason Machine-readable reason from {@see self::refuse_time()}.
	 */
	public static function refused_time_text( string $reason ): string {
		if ( 'past' === $reason ) {
			return __( 'That send time has already passed. Pick a time in the future, or send it now.', 'texttango' );
		}

		return '';
	}

	/**
	 * A readable explanation for a campaign a ceiling stopped.
	 *
	 * @param string $reason Machine-readable reason from {@see self::headroom()}.
	 */
	public static function blocked_text( string $reason ): string {
		if ( 'daily_cap' === $reason ) {
			return __( 'This campaign would take you past your daily message cap. Raise the cap under TextTango → Settings, or send it tomorrow.', 'texttango' );
		}

		if ( 'monthly_cap' === $reason ) {
			return __( 'This campaign would take you past your monthly message cap. Raise the cap under TextTango → Settings, or send it next month.', 'texttango' );
		}

		return '';
	}

	/**
	 * Send a campaign.
	 *
	 * @param string $name         Campaign name, for finding it again later.
	 * @param string $body         Message body, before merge tags.
	 * @param string $coupon       Coupon code the message is built around, if any.
	 * @param string $mode         One of the AUDIENCE_* constants.
	 * @param string $supplied_raw Numbers the merchant supplied, as typed.
	 * @param bool   $asserted     Whether they confirmed those people agreed.
	 * @param int    $when         Requested send time, or 0 for as soon as possible.
	 * @return array<string, mixed>|WP_Error The recorded campaign.
	 */
	public static function send( string $name, string $body, string $coupon = '', string $mode = self::AUDIENCE_CUSTOMERS, string $supplied_raw = '', bool $asserted = false, int $when = 0, ?Segment $segment = null, string $delivery = self::DELIVERY_BULK ) {
		$name   = trim( $name );
		$coupon = trim( $coupon );
		$mode   = self::audience_mode( $mode );
		$now    = Clock::now();

		$bad_time = self::refuse_time( $when, $now );

		if ( '' !== $bad_time ) {
			return new WP_Error( 'texttango_schedule_' . $bad_time, self::refused_time_text( $bad_time ) );
		}

		/*
		 * The coupon is read again rather than carried over from the review. It
		 * is a live WooCommerce object: it can be deleted, expire, or be used up
		 * between the review and the button, and each of those turns the message
		 * into a promise the store cannot keep.
		 */
		$facts = '' === $coupon ? null : Coupons::facts( $coupon );

		if ( '' !== $coupon ) {
			$refused = Coupons::blocks( $facts, Clock::now() );

			if ( '' !== $refused ) {
				return new WP_Error( 'texttango_coupon_' . $refused, Coupons::blocked_text( $refused ) );
			}
		}

		$body = trim( self::resolve( $body, $facts ) );

		if ( '' === $body ) {
			return new WP_Error( 'texttango_empty_campaign', __( 'A campaign needs a message.', 'texttango' ) );
		}

		$delivery = self::delivery_mode( $delivery );

		if ( self::refuses_personal( $delivery, $coupon ) ) {
			return new WP_Error(
				'texttango_personal_needs_coupon',
				__( 'A campaign giving everybody their own code needs a coupon to build those codes from.', 'texttango' )
			);
		}

		$from = (string) Settings::get( 'sender_id', '' );

		if ( '' === $from ) {
			return new WP_Error(
				'texttango_no_sender_id',
				__( 'Choose a sender ID under TextTango → Settings before sending a campaign.', 'texttango' )
			);
		}

		$client = Client::from_settings();

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

		/*
		 * Both lists are built again rather than carried over from the review:
		 * somebody may have opted out in between, and they are exactly the person
		 * a stale list would reach. The mode decides which are built at all — in
		 * supplied mode the store's own customers are never gathered, so they can
		 * never end up in a campaign that was not meant for them.
		 */
		$audience = self::uses_customers( $mode ) ? Audience::gather( $segment ) : self::no_audience();
		$supplied = self::uses_supplied( $mode ) ? SuppliedNumbers::gather( $supplied_raw ) : self::nothing_supplied();

		if ( self::uses_supplied( $mode ) ) {
			$refused = SuppliedNumbers::refuse( count( $supplied['recipients'] ), $asserted );

			if ( '' !== $refused ) {
				return new WP_Error( 'texttango_supplied_' . $refused, SuppliedNumbers::refusal_text( $refused ) );
			}
		}

		$recipients = self::combine( $audience['recipients'], $supplied['recipients'] );

		if ( array() === $recipients ) {
			return new WP_Error( 'texttango_no_audience', self::empty_text( $mode ) );
		}

		$sandboxed = Sandbox::is_active();

		if ( $sandboxed ) {
			$recipients = self::sandbox_recipients( $recipients );
		}

		$estimate = self::estimate( count( $recipients ), $body );
		$blocked  = $sandboxed ? '' : self::headroom( $estimate['messages'], self::usage() );

		if ( '' !== $blocked ) {
			return new WP_Error( 'texttango_' . $blocked, self::blocked_text( $blocked ) );
		}

		if ( array() === $recipients ) {
			// Sandbox is on in redirect mode with no test number set. Sending to
			// the real list is the one thing that must not happen here.
			return new WP_Error(
				'texttango_sandbox_no_number',
				__( 'Sandbox mode is on but no test number is set, so there is nowhere safe to send this. Set one under TextTango → Settings.', 'texttango' )
			);
		}

		$name    = '' === $name ? self::default_name() : $name;
		$sends   = self::schedule_for( $when, $now, QuietHours::delay_for( self::EVENT ) );
		$payload = array(
			'from'          => $from,
			'body'          => $body,
			'campaign_name' => $name,
			'to'            => array_values( $recipients ),
		);

		if ( $sends > $now ) {
			/*
			 * Handed to the platform to hold rather than kept in a local queue.
			 * The alternative is a scheduled action that has to survive hours of
			 * downtime, plugin updates and a merchant changing their mind, all to
			 * arrive at the same POST. The platform schedules campaigns natively;
			 * this is one of the few places where the thin-client rule and the
			 * simple implementation agree.
			 */
			$payload['is_scheduled'] = true;
			$payload['scheduled_at'] = self::iso8601( $sends );
		}

		$record = array(
			'uuid'         => '',
			'name'         => $name,
			'body'         => $body,
			'recipients'   => count( $recipients ),
			'segments'     => (int) $estimate['segments'],
			'credits'      => (int) $estimate['credits'],
			'status'       => $sends > $now ? 'scheduled' : 'queued',
			'scheduled_at' => $sends > $now ? $payload['scheduled_at'] : '',
			'coupon'       => null === $facts ? '' : (string) $facts['code'],
			'mode'         => $mode,
			'delivery'     => $delivery,
			/*
			 * The segment's **kind**, not its description.
			 *
			 * `$audience['segment']` is `Segment::describe()`, which is a sentence
			 * for the review screen — and the default one, "Everyone whose consent
			 * is on record", is 35 characters going into a `VARCHAR(32)`.
			 * `$wpdb->insert()` refuses a value too long for its column by
			 * refusing the **whole row**, so the campaign was sent and then never
			 * recorded: gone from history, and — because `messages_since()` reads
			 * this table — invisible to the spend caps that exist to notice it.
			 *
			 * A key is what every other column here stores, and the description is
			 * derivable from it whenever a screen wants one.
			 */
			'segment'      => self::uses_customers( $mode ) ? ( $segment instanceof Segment ? $segment->kind() : Segment::KIND_ALL ) : '',
			'supplied'     => count( $supplied['recipients'] ),
			'sandbox'      => $sandboxed,
			'created_at'   => gmdate( 'Y-m-d H:i:s', $now ),
			'excluded'     => array(
				'suppressed' => (int) $audience['suppressed'] + (int) $supplied['suppressed'],
				'unusable'   => (int) $audience['unusable'] + count( $supplied['unusable'] ),
			),
		);

		/*
		 * Checked before the sandbox short-circuit below, and deliberately.
		 *
		 * That short-circuit exists because a bulk campaign is one request to the
		 * platform, and the sandboxed version of making that request is not making
		 * it. A personal campaign makes no such request — it goes through
		 * `Sender::send()`, which sandboxes every message on its own and has done
		 * since long before campaigns existed.
		 *
		 * So a sandboxed personal campaign runs its fan-out for real: real rows in
		 * the delivery log marked sandbox, real codes generated so the message
		 * reads exactly as it will, no coupons created and nothing sent. That is a
		 * rehearsal somebody can actually learn from, which is the whole point of
		 * sandbox — short-circuiting here would leave the one campaign type that
		 * sends locally as the one type that cannot be tried out.
		 */
		if ( self::DELIVERY_PERSONAL === $delivery ) {
			$record['uuid']          = ( $sandboxed ? 'sandbox-' : 'personal-' ) . wp_generate_uuid4();
			$record['status']        = $sends > $now ? 'scheduled' : 'processing';
			$record['dispatched_at'] = $sends > $now ? '' : self::iso8601( $now );

			CampaignLog::record( $record );
			self::audit( $record );

			// Held campaigns start when their time comes; the rest start now.
			if ( $sends <= $now ) {
				PersonalCampaign::start( $record['uuid'], array_values( $recipients ) );
			}

			return $record;
		}

		if ( $sandboxed && Sandbox::BEHAVIOR_LOG_ONLY === Sandbox::behavior() ) {
			$record['uuid']   = 'sandbox-' . wp_generate_uuid4();
			$record['status'] = 'sandboxed';

			CampaignLog::record( $record );
			self::audit( $record );

			return $record;
		}

		$response = $client->create_campaign( $payload );

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

		$data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array();
		$uuid = (string) ( $data['id'] ?? '' );

		if ( '' === $uuid ) {
			// Accepted, but we cannot name what was accepted — so it can never be
			// polled, cancelled or shown. Said plainly rather than recorded as a
			// success nobody can act on.
			return new WP_Error(
				'texttango_campaign_unidentified',
				__( 'TextTango accepted the campaign but did not return an id for it. Check your campaigns on the TextTango dashboard before sending it again — it may already be going out.', 'texttango' )
			);
		}

		$record['uuid']   = $uuid;
		$record['status'] = (string) ( $data['attributes']['status'] ?? $record['status'] );

		CampaignLog::record( $record );
		self::audit( $record );
		self::look_soon( $uuid );

		return $record;
	}

	/**
	 * Ask about a campaign shortly after it goes, rather than on the next poll.
	 *
	 * Only for campaigns the platform actually received: a personal one is fanned
	 * out from here and has nothing there to ask about, and a sandboxed one never
	 * left the building.
	 *
	 * @param string $uuid Campaign UUID.
	 */
	private static function look_soon( string $uuid ): void {
		if ( '' === $uuid || ! function_exists( 'as_schedule_single_action' ) ) {
			return;
		}

		as_schedule_single_action(
			Clock::now() + self::FIRST_LOOK,
			self::HOOK,
			array(),
			'texttango'
		);
	}

	/**
	 * Stop a campaign that has not finished.
	 *
	 * @param string $uuid Campaign UUID.
	 * @return true|WP_Error
	 */
	public static function cancel( string $uuid ) {
		$record = CampaignLog::find( $uuid );

		if ( null === $record ) {
			return new WP_Error( 'texttango_unknown_campaign', __( 'That campaign is not one this site sent.', 'texttango' ) );
		}

		if ( ! empty( $record['sandbox'] ) ) {
			CampaignLog::update( $uuid, array( 'status' => 'cancelled' ) );

			return true;
		}

		/*
		 * A personal campaign was never sent to the platform, so there is nothing
		 * there to cancel — asking would be an error about a campaign it has
		 * never heard of. Stopping it means unscheduling the batches that have
		 * not run.
		 *
		 * Marked cancelled *before* unscheduling, not after: a batch already
		 * running cannot be unscheduled, and it checks this status before it
		 * sends. The other order leaves a window where one more batch goes out.
		 */
		if ( CampaignLog::is_personal( $record ) ) {
			CampaignLog::update( $uuid, array( 'status' => 'cancelled' ) );
			PersonalCampaign::stop( $uuid );

			AuditLog::record(
				AuditLog::ACTION_CAMPAIGN,
				(string) ( $record['name'] ?? $uuid ),
				array(
					'what' => 'cancelled',
					'uuid' => $uuid,
				)
			);

			return true;
		}

		$client = Client::from_settings();

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

		$response = $client->cancel_campaign( $uuid );

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

		CampaignLog::update( $uuid, array( 'status' => 'cancelled' ) );

		AuditLog::record(
			AuditLog::ACTION_CAMPAIGN,
			(string) ( $record['name'] ?? $uuid ),
			array(
				'what' => 'cancelled',
				'uuid' => $uuid,
			)
		);

		return true;
	}

	/**
	 * Ask the platform how every in-flight campaign is doing.
	 */
	public static function refresh_all(): void {
		/*
		 * In-flight campaigns, plus a few settled ones the platform never priced.
		 *
		 * The second group is a self-heal, the same shape as the missed-webhook
		 * one in §19. A campaign's cost arrives on the poll that sees it finish —
		 * but a campaign that finished before this site knew to ask for the cost
		 * is never polled again, because only in-flight campaigns are. Without
		 * this, every campaign sent before the cost column existed would stay
		 * unpriced forever and the spend total would stay short.
		 *
		 * Bounded per run, so it converges over a few polls and then costs
		 * nothing rather than sweeping the whole history every five minutes.
		 */
		$pending = array_merge( CampaignLog::in_flight(), CampaignLog::unpriced( self::BACKFILL ) );

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

		$client = Client::from_settings();

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

		$seen = array();

		foreach ( $pending as $row ) {
			$uuid = (string) $row['uuid'];

			if ( ! empty( $row['sandbox'] ) || isset( $seen[ $uuid ] ) ) {
				continue;
			}

			/*
			 * A personal campaign was never sent to the platform — it is fanned
			 * out from here — so there is nothing there to ask about. Polling one
			 * is a request for a campaign the platform has never heard of, every
			 * five minutes, for as long as the send takes. Its cost is the sum of
			 * its own message rows and is already counted there.
			 */
			if ( CampaignLog::is_personal( $row ) ) {
				continue;
			}

			$seen[ $uuid ] = true;

			self::refresh( $uuid, $client );
		}
	}

	/**
	 * Update one campaign from the platform.
	 *
	 * @param string      $uuid   Campaign UUID.
	 * @param Client|null $client Reused client, when refreshing several.
	 * @return bool Whether anything was learned.
	 */
	public static function refresh( string $uuid, ?Client $client = null ): bool {
		if ( null === $client ) {
			$client = Client::from_settings();

			if ( is_wp_error( $client ) ) {
				return false;
			}
		}

		$response = $client->campaign( $uuid );

		if ( is_wp_error( $response ) ) {
			return false;
		}

		$data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array();

		return CampaignLog::update( $uuid, self::progress( $data ) );
	}

	/**
	 * The fields worth keeping from a campaign response.
	 *
	 * Pure, so the shape of the platform's payload is asserted somewhere other
	 * than in a live call.
	 *
	 * @param array<string, mixed> $data A `data` object from the campaigns API.
	 * @return array<string, mixed>
	 */
	public static function progress( array $data ): array {
		$attributes = isset( $data['attributes'] ) && is_array( $data['attributes'] ) ? $data['attributes'] : array();
		$summary    = array();

		if ( isset( $data['analytics']['summary'] ) && is_array( $data['analytics']['summary'] ) ) {
			$summary = $data['analytics']['summary'];
		}

		$progress = array(
			'status'    => (string) ( $attributes['status'] ?? '' ),
			'delivered' => (int) ( $summary['delivered'] ?? 0 ),
			'failed'    => (int) ( $summary['failed'] ?? 0 ),
			'pending'   => (int) ( $summary['pending'] ?? 0 ),
			'sent'      => (int) ( $summary['total_sent'] ?? 0 ),
			'refreshed' => gmdate( 'Y-m-d H:i:s', Clock::now() ),
		);

		// The platform is the authority on how many it actually took, which can
		// differ from what was submitted once it has dropped invalid numbers.
		if ( isset( $attributes['total_recipients'] ) && (int) $attributes['total_recipients'] > 0 ) {
			$progress['accepted'] = (int) $attributes['total_recipients'];
		}

		if ( ! empty( $attributes['dispatched_at'] ) ) {
			$progress['dispatched_at'] = (string) $attributes['dispatched_at'];
		}

		/*
		 * What the campaign actually cost, in the same currency as a message's
		 * own `cost`. The platform has always reported this and nothing read it,
		 * so the analytics summed the message log alone and showed a "Spent"
		 * figure covering only part of what had been sent.
		 *
		 * Checked with `is_numeric` rather than `! empty`, because a campaign that
		 * genuinely cost nothing reports `'0'` — and treating that as "not
		 * reported" would leave it looking unpriced forever.
		 */
		if ( isset( $attributes['total_cost'] ) && is_numeric( $attributes['total_cost'] ) ) {
			$progress['cost'] = (float) $attributes['total_cost'];
		}

		/*
		 * Kept in step with the platform rather than trusted from when this site
		 * asked for it. A campaign rescheduled from the TextTango dashboard would
		 * otherwise keep showing the original time here — and a displayed date
		 * that quietly disagrees with reality is worse than showing none.
		 */
		if ( ! empty( $attributes['scheduled_at'] ) ) {
			$progress['scheduled_at'] = (string) $attributes['scheduled_at'];
		}

		return array_filter( $progress, static fn( $value ): bool => '' !== $value );
	}

	/**
	 * What the caps have seen so far, campaigns included.
	 *
	 * @return array<string, int>
	 */
	private static function usage(): array {
		return array(
			'today'       => Budget::billable_since( Budget::start_of_day() ),
			'daily_cap'   => (int) Settings::get( 'spend_cap_daily', 0 ),
			'month'       => Budget::billable_since( Budget::start_of_month() ),
			'monthly_cap' => (int) Settings::get( 'spend_cap_monthly', 0 ),
		);
	}

	/**
	 * Who a campaign goes to while sandbox mode is on.
	 *
	 * @param string[] $recipients The real audience.
	 * @return string[]
	 */
	private static function sandbox_recipients( array $recipients ): array {
		if ( Sandbox::BEHAVIOR_REDIRECT !== Sandbox::behavior() ) {
			return $recipients;
		}

		$test = Sandbox::test_number();

		return '' === $test ? array() : array( $test );
	}

	/**
	 * A name for a campaign the merchant did not name.
	 */
	private static function default_name(): string {
		return sprintf(
			/* translators: %s: date and time the campaign was sent. */
			__( 'Store campaign — %s', 'texttango' ),
			wp_date( 'j M Y, H:i', Clock::now() )
		);
	}

	/**
	 * An ISO 8601 timestamp the platform will parse unambiguously.
	 *
	 * UTC with an explicit offset, because `after:now` is evaluated on the
	 * platform's clock and a bare local time would be read in the platform's
	 * timezone rather than the store's.
	 *
	 * @param int $timestamp Unix timestamp.
	 */
	public static function iso8601( int $timestamp ): string {
		return gmdate( 'Y-m-d\TH:i:s\Z', $timestamp );
	}

	/**
	 * Record that a campaign went out.
	 *
	 * @param array<string, mixed> $record The campaign as stored.
	 */
	private static function audit( array $record ): void {
		$detail = array(
			'what'       => 'sent',
			'audience'   => (string) ( $record['mode'] ?? self::AUDIENCE_CUSTOMERS ),
			'uuid'       => $record['uuid'],
			'recipients' => $record['recipients'],
			'credits'    => $record['credits'],
			'sandbox'    => $record['sandbox'],
		);

		/*
		 * Recorded only when there were any, and recorded as its own line. This
		 * is the one part of a campaign resting on somebody's word rather than on
		 * a consent record, and `AuditLog` snapshots the actor's name — so when
		 * somebody later asks where a number came from, the answer has a person
		 * against it and a date, rather than a shrug.
		 */
		if ( (int) ( $record['supplied'] ?? 0 ) > 0 ) {
			$detail['supplied']         = (int) $record['supplied'];
			$detail['consent_asserted'] = true;
		}

		AuditLog::record( AuditLog::ACTION_CAMPAIGN, (string) $record['name'], $detail );
	}
}
