<?php
/**
 * The campaigns screen.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Admin;

use TextTango\Install\Activator;
use TextTango\Consent\ConsentList;
use TextTango\Marketing\Audience;
use TextTango\Marketing\Campaign;
use TextTango\Marketing\CampaignLog;
use TextTango\Marketing\Coupons;
use TextTango\Marketing\PersonalCampaign;
use TextTango\Marketing\Segment;
use TextTango\Support\Paging;
use TextTango\Marketing\SuppliedNumbers;
use TextTango\Sending\BalanceWatch;
use TextTango\Support\Sandbox;
use TextTango\Support\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Compose, review, send and follow campaigns (spec §8).
 *
 * ### Why there is a review step
 *
 * Every other form in this plugin saves a setting, and a setting saved wrongly
 * is a setting saved again. This one spends money and reaches thousands of
 * people at once, and neither is recoverable. So the compose form does not
 * send: it produces a review, and only the review sends.
 *
 * The draft lives in a transient between the two rather than in a hidden field,
 * so the figures shown on the review are ones the server worked out, not ones
 * the browser handed back. It also means a review left open in a tab overnight
 * expires rather than sending yesterday's message to today's list.
 */
final class CampaignsPage {

	/**
	 * Menu slug.
	 */
	public const SLUG = 'texttango-campaigns';

	/**
	 * `admin_post` action that turns a draft into a review.
	 */
	public const REVIEW_ACTION = 'texttango_campaign_review';

	/**
	 * `admin_post` action that actually sends.
	 */
	public const SEND_ACTION = 'texttango_campaign_send';

	/**
	 * `admin_post` action that stops one already accepted.
	 */
	public const CANCEL_ACTION = 'texttango_campaign_cancel';

	/**
	 * `admin_post` action that throws a review away unsent.
	 */
	public const DISCARD_ACTION = 'texttango_campaign_discard';

	/**
	 * How long a review stays valid.
	 *
	 * Long enough to read carefully and check the wording with somebody; short
	 * enough that a forgotten tab cannot send a stale message to a list that has
	 * moved on.
	 */
	public const DRAFT_TTL = 30 * MINUTE_IN_SECONDS;

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'admin_post_' . self::REVIEW_ACTION, array( $this, 'handle_review' ) );
		add_action( 'admin_post_' . self::SEND_ACTION, array( $this, 'handle_send' ) );
		add_action( 'admin_post_' . self::CANCEL_ACTION, array( $this, 'handle_cancel' ) );
		add_action( 'admin_post_' . self::DISCARD_ACTION, array( $this, 'handle_discard' ) );
	}

	/**
	 * View showing the compose form, and any review of it.
	 */
	public const VIEW_NEW = 'new';

	/**
	 * View showing what has already gone out.
	 */
	public const VIEW_HISTORY = 'history';

	/**
	 * Query argument naming the view.
	 */
	public const VIEW_ARG = 'view';

	/**
	 * Query argument naming the history page.
	 */
	public const HISTORY_PAGE = 'history_page';

	/**
	 * Query argument naming the campaign whose recipients are being read.
	 *
	 * A state of History rather than a view of its own: a per-recipient list with
	 * no campaign chosen means nothing, so it cannot be a tab somebody clicks.
	 */
	public const CAMPAIGN_ARG = 'campaign';

	/**
	 * A link to one campaign's recipients.
	 *
	 * @param string $uuid Campaign uuid.
	 */
	public static function recipients_url( string $uuid ): string {
		return add_query_arg( self::CAMPAIGN_ARG, $uuid, self::url( self::VIEW_HISTORY ) );
	}

	/**
	 * Query argument naming the page within that list.
	 */
	public const RECIPIENT_PAGE = 'recipient_page';

	/**
	 * The screen's views, in display order.
	 *
	 * Creating a campaign and checking on one already sent are two jobs, and they
	 * used to be one scroll: the review, then a long compose form, then the
	 * history — so seeing whether last night's send landed meant scrolling past
	 * the whole form.
	 *
	 * @return array<string, string>
	 */
	public static function views(): array {
		return array(
			self::VIEW_NEW     => __( 'New campaign', 'texttango' ),
			self::VIEW_HISTORY => __( 'History', 'texttango' ),
		);
	}

	/**
	 * Each view's icon.
	 *
	 * Beside {@see self::views()} for the same reason the settings tabs keep
	 * theirs beside their labels: two lists keyed alike drift once they live
	 * apart, and a test can only check both while both are reachable.
	 *
	 * @return array<string, string>
	 */
	public static function view_icons(): array {
		return array(
			self::VIEW_NEW     => 'send',
			self::VIEW_HISTORY => 'history',
		);
	}

	/**
	 * Which view to render.
	 *
	 * Pure, because both rules here are silent when wrong: an unrecognised view
	 * would render a page with nothing on it, and a review arriving while the
	 * merchant happens to be on History would look like the Review button did
	 * nothing at all.
	 *
	 * @param string $requested  What the query string asked for.
	 * @param bool   $reviewing  Whether there is a draft waiting to be looked at.
	 */
	public static function view_for( string $requested, bool $reviewing = false ): string {
		if ( $reviewing ) {
			return self::VIEW_NEW;
		}

		return isset( self::views()[ $requested ] ) ? $requested : self::VIEW_NEW;
	}

	/**
	 * Where the screen lives.
	 *
	 * @param string $view View to open, or the default when omitted.
	 */
	public static function url( string $view = '' ): string {
		$url = admin_url( 'admin.php?page=' . self::SLUG );

		// The default view is left out of the URL, so the screen has one address
		// rather than two that render the same thing.
		if ( '' === $view || self::VIEW_NEW === $view ) {
			return $url;
		}

		return add_query_arg( self::VIEW_ARG, $view, $url );
	}

	/**
	 * The views, resolved for the shared tab bar.
	 *
	 * @param string $current The view being rendered.
	 * @return array<int, array{label: string, icon: string, url: string, active: bool}>
	 */
	public static function view_rows( string $current ): array {
		$icons = self::view_icons();
		$rows  = array();

		foreach ( self::views() as $slug => $label ) {
			$rows[] = array(
				'label'  => $label,
				'icon'   => $icons[ $slug ] ?? '',
				'url'    => self::url( $slug ),
				'active' => $slug === $current,
			);
		}

		return $rows;
	}

	/**
	 * The same screen, showing the draft back in the compose form.
	 */
	public static function edit_url(): string {
		return add_query_arg( 'edit', '1', self::url() );
	}

	/**
	 * Whether the merchant asked to go back and change a campaign.
	 *
	 * Read-only: it decides which card renders and nothing else, destroys
	 * nothing, and leaves the draft exactly where it was. That is why it is a
	 * plain link with no nonce, while Discard — which does destroy something —
	 * keeps its form and its nonce.
	 */
	public static function is_editing(): bool {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Chooses which card to draw; changes nothing.
		return isset( $_GET['edit'] );
	}

	/**
	 * Which of the two cards this request should show.
	 *
	 * Pure, because the awkward case is the one nobody thinks to try: an `edit=1`
	 * somebody bookmarked, arriving when there is no draft to edit. That has to
	 * fall through to an empty compose form rather than to a review of nothing.
	 *
	 * @param bool $has_draft Whether a campaign is waiting.
	 * @param bool $editing   Whether the merchant asked to change it.
	 * @return bool True to show the review, false to show the compose form.
	 */
	public static function shows_review( bool $has_draft, bool $editing ): bool {
		return $has_draft && ! $editing;
	}

	/**
	 * Output the screen.
	 */
	public function render(): void {
		if ( ! current_user_can( Activator::CAPABILITY ) ) {
			wp_die( esc_html__( 'You do not have permission to send TextTango campaigns.', 'texttango' ) );
		}

		$draft     = self::draft();
		$editing   = self::is_editing();
		$review    = array();
		$reviewing = self::shows_review( null !== $draft, $editing );

		if ( $reviewing ) {
			$review = Campaign::preview(
				(string) $draft['body'],
				(string) $draft['coupon'],
				(string) $draft['mode'],
				(string) $draft['supplied'],
				! empty( $draft['asserted'] ),
				(int) $draft['when'],
				self::segment_from( $draft )
			);
		}

		/*
		 * Counted, then one page read — history is a table now and a shop that
		 * sends weekly will outgrow any single read of it.
		 */
		$history_total = CampaignLog::count();

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view state; Paging validates it to a positive integer.
		$history_paging = Paging::from_request( wp_unslash( $_GET ), self::HISTORY_PAGE )->clamp( $history_total );
		$history        = CampaignLog::paged( $history_paging );

		/*
		 * The per-recipient list, when a campaign is named and is one that has
		 * recipients to show. A bulk campaign has none — the platform fanned it
		 * out — so asking for one is answered with the plain history rather than
		 * an empty table implying the send reached nobody.
		 */
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view state; matched against a campaign this site recorded.
		$opened     = isset( $_GET[ self::CAMPAIGN_ARG ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::CAMPAIGN_ARG ] ) ) : '';
		$campaign   = '' === $opened ? null : CampaignLog::find( $opened );
		$recipients = array();
		$rec_paging = null;
		$rec_total  = 0;

		if ( null !== $campaign && CampaignLog::is_personal( $campaign ) ) {
			$rec_total = PersonalCampaign::progress( $opened )['total'];

			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view state; Paging validates it to a positive integer.
			$rec_paging = Paging::from_request( wp_unslash( $_GET ), self::RECIPIENT_PAGE )->clamp( $rec_total );
			$recipients = PersonalCampaign::paged( $opened, $rec_paging );
		} else {
			$campaign = null;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Chooses which read-only panel to render; validated against the registry by view_for().
		$requested = isset( $_GET[ self::VIEW_ARG ] ) ? sanitize_key( wp_unslash( $_GET[ self::VIEW_ARG ] ) ) : '';
		$current   = self::view_for( $requested, $reviewing );

		$view = array(
			'notices'            => Notices::take(),
			'reviewing'          => $reviewing,
			'view'               => $current,
			'view_rows'          => self::view_rows( $current ),
			// A draft being edited is not a new campaign, and the heading on the
			// compose card is the only thing that can say so.
			'editing'            => $editing && null !== $draft,
			'edit_url'           => self::edit_url(),
			'draft'              => $draft ?? array(
				'name'       => '',
				'body'       => '',
				'coupon'     => '',
				'delivery'   => Campaign::DELIVERY_BULK,
				'mode'       => Campaign::AUDIENCE_CUSTOMERS,
				'supplied'   => '',
				'asserted'   => false,
				'when'       => 0,
				'segment'    => Segment::KIND_ALL,
				'days'       => 0,
				'spend'      => 0.0,
				'product'    => 0,
				'min_orders' => 0,
				'category'   => 0,
				'country'    => '',
			),
			'review'             => $review,
			'history'            => $history,
			'history_paging'     => $history_paging,
			'opened'             => $campaign,
			'recipients'         => $recipients,
			'recipient_paging'   => $rec_paging,
			'recipient_total'    => $rec_total,
			'history_total'      => $history_total,
			// Counted only when there is nothing to show, so an established store
			// with campaigns behind it never pays for the query.
			'consenting_count'   => 0 === $history_total ? Audience::consenting_count() : 0,
			'consent_url'        => Tabs::url( Tabs::CONSENT ),
			'review_action'      => self::REVIEW_ACTION,
			'send_action'        => self::SEND_ACTION,
			'cancel_action'      => self::CANCEL_ACTION,
			'discard_action'     => self::DISCARD_ACTION,
			'connected'          => '' !== Settings::get_api_key(),
			'sender_id'          => (string) Settings::get( 'sender_id', '' ),
			'balance'            => BalanceWatch::formatted(),
			'sandbox_active'     => Sandbox::is_active(),
			'sandbox_note'       => Sandbox::describe(),
			'in_flight'          => CampaignLog::IN_FLIGHT,
			'settings_url'       => Tabs::url( Tabs::CONNECTION ),
			'timezone'           => wp_timezone_string(),
			'segments'           => self::segments(),
			'currency'           => (string) get_option( 'woocommerce_currency', '' ),
			'consent_list_count' => ConsentList::count(),
			'coupons'            => Coupons::selectable(),
			'coupon_tags'        => Coupons::tags(),
			'supplied_max'       => SuppliedNumbers::MAX,
			'audiences'          => self::audiences(),
		);

		load_template( TEXTTANGO_PATH . 'templates/admin/campaigns.php', false, $view );
	}

	/**
	 * Turn a composed message into a review.
	 */
	public function handle_review(): void {
		$this->guard( self::REVIEW_ACTION );

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in guard(), above.
		$name       = isset( $_POST['campaign_name'] ) ? sanitize_text_field( wp_unslash( $_POST['campaign_name'] ) ) : '';
		$body       = isset( $_POST['campaign_body'] ) ? sanitize_textarea_field( wp_unslash( $_POST['campaign_body'] ) ) : '';
		$coupon     = isset( $_POST['campaign_coupon'] ) ? sanitize_text_field( wp_unslash( $_POST['campaign_coupon'] ) ) : '';
		$supplied   = isset( $_POST['campaign_supplied'] ) ? sanitize_textarea_field( wp_unslash( $_POST['campaign_supplied'] ) ) : '';
		$asserted   = isset( $_POST['campaign_asserted'] );
		$mode       = Campaign::audience_mode(
			isset( $_POST['campaign_audience'] ) ? sanitize_key( wp_unslash( $_POST['campaign_audience'] ) ) : ''
		);
		$delivery   = Campaign::delivery_mode(
			isset( $_POST['campaign_delivery'] ) ? sanitize_key( wp_unslash( $_POST['campaign_delivery'] ) ) : ''
		);
		$segment    = Segment::normalise(
			isset( $_POST['campaign_segment'] ) ? sanitize_key( wp_unslash( $_POST['campaign_segment'] ) ) : ''
		);
		$days       = isset( $_POST['campaign_days'] ) ? absint( wp_unslash( $_POST['campaign_days'] ) ) : 0;
		$spend      = isset( $_POST['campaign_spend'] ) ? (float) sanitize_text_field( wp_unslash( $_POST['campaign_spend'] ) ) : 0.0;
		$product    = isset( $_POST['campaign_product'] ) ? absint( wp_unslash( $_POST['campaign_product'] ) ) : 0;
		$min_orders = isset( $_POST['campaign_min_orders'] ) ? absint( wp_unslash( $_POST['campaign_min_orders'] ) ) : 0;
		$category   = isset( $_POST['campaign_category'] ) ? absint( wp_unslash( $_POST['campaign_category'] ) ) : 0;
		$country    = self::country_from_request(
			isset( $_POST['campaign_country'] ) ? sanitize_text_field( wp_unslash( $_POST['campaign_country'] ) ) : ''
		);
		$when       = self::requested_time(
			isset( $_POST['campaign_timing'] ) ? sanitize_key( wp_unslash( $_POST['campaign_timing'] ) ) : '',
			isset( $_POST['campaign_when'] ) ? sanitize_text_field( wp_unslash( $_POST['campaign_when'] ) ) : ''
		);
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		if ( '' === trim( $body ) ) {
			Notices::add( 'error', __( 'Write the message you want to send.', 'texttango' ) );

			wp_safe_redirect( self::url() );
			exit;
		}

		/*
		 * Caught here rather than at the send, so nobody reaches a review screen
		 * that cannot be acted on. Campaign::send() refuses it as well — this is
		 * the message, that is the guarantee.
		 */
		if ( Campaign::refuses_personal( $delivery, $coupon ) ) {
			Notices::add( 'error', __( 'Choose a coupon to build the codes from, or send everybody the same code instead.', 'texttango' ) );

			wp_safe_redirect( self::url() );
			exit;
		}

		set_transient(
			self::draft_key(),
			compact( 'name', 'body', 'coupon', 'mode', 'delivery', 'supplied', 'asserted', 'when', 'segment', 'days', 'spend', 'product', 'min_orders', 'category', 'country' ),
			self::DRAFT_TTL
		);

		wp_safe_redirect( self::url() );
		exit;
	}

	/**
	 * Send the campaign under review.
	 */
	public function handle_send(): void {
		$this->guard( self::SEND_ACTION );

		$draft = self::draft();

		if ( null === $draft ) {
			// The review expired, or was already sent and this is a reloaded
			// confirmation. Either way the safe reading is "do not send again".
			Notices::add( 'error', __( 'That campaign is no longer waiting to be sent. Write it again if you still want to send it.', 'texttango' ) );

			wp_safe_redirect( self::url() );
			exit;
		}

		/*
		 * Cleared before the send, not after. If the request dies part way — a
		 * timeout, a fatal, the browser giving up and the admin pressing Send
		 * again — the draft must already be gone, so the worst case is a campaign
		 * whose fate has to be checked on the dashboard rather than a second
		 * campaign to the same ten thousand people.
		 */
		delete_transient( self::draft_key() );

		$result = Campaign::send(
			(string) $draft['name'],
			(string) $draft['body'],
			(string) $draft['coupon'],
			(string) $draft['mode'],
			(string) $draft['supplied'],
			! empty( $draft['asserted'] ),
			(int) $draft['when'],
			self::segment_from( $draft ),
			(string) ( $draft['delivery'] ?? Campaign::DELIVERY_BULK )
		);

		if ( is_wp_error( $result ) ) {
			Notices::add( 'error', $result->get_error_message() );

			wp_safe_redirect( self::url() );
			exit;
		}

		Notices::add( 'ok', self::sent_message( $result ) );

		// To History, where the campaign that was just sent is the top row and its
		// progress is visible. The compose form has nothing left to say.
		wp_safe_redirect( self::url( self::VIEW_HISTORY ) );
		exit;
	}

	/**
	 * Throw away a review without sending it.
	 */
	public function handle_discard(): void {
		$this->guard( self::DISCARD_ACTION );

		delete_transient( self::draft_key() );

		wp_safe_redirect( self::url() );
		exit;
	}

	/**
	 * Stop a campaign that has not finished.
	 */
	public function handle_cancel(): void {
		$this->guard( self::CANCEL_ACTION );

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in guard(), above.
		$uuid   = isset( $_POST['uuid'] ) ? sanitize_text_field( wp_unslash( $_POST['uuid'] ) ) : '';
		$result = Campaign::cancel( $uuid );

		if ( is_wp_error( $result ) ) {
			Notices::add( 'error', $result->get_error_message() );
		} else {
			Notices::add( 'ok', __( 'Campaign stopped. Anything already handed to the network will still arrive.', 'texttango' ) );
		}

		// Back to History: Stop is only ever pressed from there, and dropping
		// somebody onto the compose form afterwards loses their place.
		wp_safe_redirect( self::url( self::VIEW_HISTORY ) );
		exit;
	}

	/**
	 * What to tell the admin about a campaign that went.
	 *
	 * @param array<string, mixed> $record The recorded campaign.
	 */
	public static function sent_message( array $record ): string {
		$mode     = Campaign::audience_mode( (string) ( $record['mode'] ?? '' ) );
		$live     = 'scheduled' !== (string) $record['status'] && empty( $record['sandbox'] );
		$supplied = (int) ( $record['supplied'] ?? 0 );
		$people   = sprintf(
			/* translators: %d: number of recipients. */
			_n( '%d person', '%d people', (int) $record['recipients'], 'texttango' ),
			(int) $record['recipients']
		);

		if ( $live && Campaign::AUDIENCE_SUPPLIED === $mode ) {
			return sprintf(
				/* translators: %s: number of recipients, already written out. */
				__( 'Campaign sent to the %s you supplied. Your customers were not included.', 'texttango' ),
				$people
			);
		}

		if ( $live && $supplied > 0 ) {
			return sprintf(
				/* translators: 1: total recipients, already written out, 2: how many the merchant supplied. */
				__( 'Campaign sent to %1$s, %2$s of whom you supplied.', 'texttango' ),
				$people,
				number_format_i18n( $supplied )
			);
		}

		if ( ! empty( $record['sandbox'] ) && 'sandboxed' === (string) $record['status'] ) {
			return sprintf(
				/* translators: %d: number of recipients. */
				_n(
					'Sandbox mode is on, so nothing was sent. %d person would have received this.',
					'Sandbox mode is on, so nothing was sent. %d people would have received this.',
					(int) $record['recipients'],
					'texttango'
				),
				(int) $record['recipients']
			);
		}

		if ( 'scheduled' === (string) $record['status'] ) {
			return sprintf(
				/* translators: 1: number of recipients, 2: local time the campaign will go out. */
				__( 'Scheduled for %2$s — quiet hours are on, so TextTango is holding it until then. %1$s will receive it.', 'texttango' ),
				sprintf(
					/* translators: %d: number of recipients. */
					_n( '%d person', '%d people', (int) $record['recipients'], 'texttango' ),
					(int) $record['recipients']
				),
				wp_date( 'j M, H:i', (int) strtotime( (string) $record['scheduled_at'] ) )
			);
		}

		return sprintf(
			/* translators: %d: number of recipients. */
			_n(
				'Campaign sent to %d person.',
				'Campaign sent to %d people.',
				(int) $record['recipients'],
				'texttango'
			),
			(int) $record['recipients']
		);
	}

	/**
	 * The draft awaiting review, if there is one.
	 *
	 * @return array{name: string, body: string, coupon: string, mode: string, supplied: string, asserted: bool}|null
	 */
	private static function draft(): ?array {
		$draft = get_transient( self::draft_key() );

		if ( ! is_array( $draft ) || ! isset( $draft['body'] ) ) {
			return null;
		}

		return array(
			'name'       => (string) ( $draft['name'] ?? '' ),
			'body'       => (string) $draft['body'],
			'coupon'     => (string) ( $draft['coupon'] ?? '' ),
			'delivery'   => (string) ( $draft['delivery'] ?? Campaign::DELIVERY_BULK ),
			// Passed through the validator on the way out as well as on the way
			// in: a draft written by an older version of this screen has no mode,
			// and the safe reading of that is the audience we can prove.
			'mode'       => Campaign::audience_mode( (string) ( $draft['mode'] ?? '' ) ),
			'supplied'   => (string) ( $draft['supplied'] ?? '' ),
			'asserted'   => ! empty( $draft['asserted'] ),
			'when'       => (int) ( $draft['when'] ?? 0 ),
			'segment'    => Segment::normalise( (string) ( $draft['segment'] ?? '' ) ),
			'days'       => (int) ( $draft['days'] ?? 0 ),
			'spend'      => (float) ( $draft['spend'] ?? 0.0 ),
			'product'    => (int) ( $draft['product'] ?? 0 ),
			'min_orders' => (int) ( $draft['min_orders'] ?? 0 ),
			'category'   => (int) ( $draft['category'] ?? 0 ),
			'country'    => self::country_from_request( (string) ( $draft['country'] ?? '' ) ),
		);
	}

	/**
	 * A country code from a request, validated against WooCommerce's own list.
	 *
	 * Not merely uppercased and trusted: `Segment` accepts whatever string it is
	 * given, the same latitude it already gives a product or category id, but a
	 * draft is round-tripped through a transient and read back into a `<select>`
	 * — and a code that select does not offer would render as nothing selected
	 * while the segment still silently carried it. Checked here, once, against
	 * the exact list the picker was built from, so what is stored is always
	 * something the form can show back.
	 *
	 * @param string $raw Whatever arrived.
	 */
	private static function country_from_request( string $raw ): string {
		$code = strtoupper( trim( $raw ) );

		if ( '' === $code || ! function_exists( 'WC' ) || ! ( WC()->countries instanceof \WC_Countries ) ) {
			return '';
		}

		return array_key_exists( $code, WC()->countries->get_countries() ) ? $code : '';
	}

	/**
	 * The segment a draft is asking for.
	 *
	 * @param array<string, mixed> $draft The stored draft.
	 */
	private static function segment_from( array $draft ): Segment {
		return new Segment(
			(string) ( $draft['segment'] ?? Segment::KIND_ALL ),
			(int) ( $draft['days'] ?? 0 ),
			(float) ( $draft['spend'] ?? 0.0 ),
			(int) ( $draft['product'] ?? 0 ),
			(int) ( $draft['min_orders'] ?? 0 ),
			(int) ( $draft['category'] ?? 0 ),
			self::country_from_request( (string) ( $draft['country'] ?? '' ) )
		);
	}

	/**
	 * The send time a request is asking for, as a timestamp.
	 *
	 * Pure. A `datetime-local` field submits wall-clock time with no zone, so it
	 * has to be read in the site's timezone — read as UTC it would silently move
	 * by hours, which on a scheduled campaign means the wrong time of day for
	 * every recipient.
	 *
	 * @param string $timing One of `now` or `later`.
	 * @param string $value  The `datetime-local` value, e.g. `2026-08-03T09:00`.
	 * @return int Timestamp, or 0 for as soon as possible.
	 */
	public static function requested_time( string $timing, string $value ): int {
		if ( 'later' !== $timing || '' === trim( $value ) ) {
			return 0;
		}

		$stamp = strtotime( $value . ' ' . wp_timezone_string() );

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

	/**
	 * The segments offered under "my consenting customers".
	 *
	 * @return array<string, string>
	 */
	public static function segments(): array {
		return array(
			Segment::KIND_ALL    => __( 'Everyone who agreed', 'texttango' ),
			Segment::KIND_BUYERS => __( 'Only people who have ordered', 'texttango' ),
			Segment::KIND_NEVER  => __( 'Only people who have never ordered', 'texttango' ),
		);
	}

	/**
	 * The audiences a campaign can be addressed to, in the order they are offered.
	 *
	 * @return array<string, array{label: string, hint: string}>
	 */
	public static function audiences(): array {
		return array(
			Campaign::AUDIENCE_CUSTOMERS => array(
				'label' => __( 'My consenting customers', 'texttango' ),
				'hint'  => __( 'Everyone on this store who agreed to marketing messages, minus your opt-out list.', 'texttango' ),
			),
			Campaign::AUDIENCE_SUPPLIED  => array(
				'label' => __( 'Numbers I supply', 'texttango' ),
				'hint'  => __( 'Only the numbers you enter below. Your customers are not included.', 'texttango' ),
			),
			Campaign::AUDIENCE_BOTH      => array(
				'label' => __( 'Both', 'texttango' ),
				'hint'  => __( 'Your consenting customers and the numbers you enter, with anybody in both counted once.', 'texttango' ),
			),
		);
	}

	/**
	 * Transient key for the current admin's draft.
	 *
	 * Per user, so two people preparing campaigns do not overwrite each other's
	 * review and send one another's message.
	 */
	private static function draft_key(): string {
		return 'texttango_campaign_draft_' . get_current_user_id();
	}

	/**
	 * Permission and nonce, together, because neither is ever wanted alone.
	 *
	 * @param string $nonce_action Nonce action to verify.
	 */
	private function guard( string $nonce_action ): void {
		if ( ! current_user_can( Activator::CAPABILITY ) ) {
			wp_die( esc_html__( 'You do not have permission to send TextTango campaigns.', 'texttango' ) );
		}

		check_admin_referer( $nonce_action );
	}
}
