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

declare( strict_types=1 );

namespace TextTango\Admin;

use TextTango\Consent\Recipient;
use TextTango\Consent\Suppressions;
use TextTango\Install\Activator;
use TextTango\Marketing\Audience;
use TextTango\Marketing\AudienceFilter;

defined( 'ABSPATH' ) || exit;

/**
 * Who this site may message, and why somebody is not on the list (spec §11).
 *
 * ### The question this screen answers
 *
 * Before it existed, a merchant composing a campaign saw one number — "412
 * consenting" — and had to trust it. They could not check whether a particular
 * number was on the list, see why somebody was missing, or stop messaging one
 * person without waiting for that person to opt out. The Consent tab is
 * entirely settings: how to *ask* for consent, never who gave it.
 *
 * So the rows include people a campaign will **not** reach. Somebody who
 * consented and left no usable number is the row this screen exists for; hiding
 * them would leave the merchant with the same silence they had before.
 *
 * ### Why consent cannot be granted here
 *
 * Suppressing and restoring are the only two actions. Consent is a record of
 * what a person said, and a merchant typing it in on somebody's behalf is
 * exactly what the trail in §13 exists to make impossible. Suppressing is not
 * symmetrical with it: it only ever stops messages.
 */
final class AudiencePage {

	/**
	 * Slug of the screen.
	 */
	public const SLUG = 'texttango-audience';

	/**
	 * Nonce and `admin_post` action for stopping messages to a number.
	 */
	public const SUPPRESS_ACTION = 'texttango_audience_suppress';

	/**
	 * Nonce and `admin_post` action for allowing them again.
	 */
	public const RESTORE_ACTION = 'texttango_audience_restore';

	/**
	 * Where the screen's figures are cached.
	 */
	public const CACHE_KEY = 'texttango_roster';

	/**
	 * How long they are kept.
	 *
	 * The same short window {@see \TextTango\Analytics\Stats} uses, and for the
	 * same reason: long enough that paging through the list does not rebuild it
	 * each time, short enough that somebody who just changed something sees it.
	 */
	public const TTL = 5 * MINUTE_IN_SECONDS;

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'admin_post_' . self::SUPPRESS_ACTION, array( $this, 'handle_suppress' ) );
		add_action( 'admin_post_' . self::RESTORE_ACTION, array( $this, 'handle_restore' ) );
	}

	/**
	 * A link to this screen.
	 *
	 * @param array<string, string> $args Extra query arguments.
	 */
	public static function url( array $args = array() ): string {
		return add_query_arg(
			array_merge( array( 'page' => self::SLUG ), $args ),
			admin_url( 'admin.php' )
		);
	}

	/**
	 * The roster, cached.
	 *
	 * ### This cache is for the screen and nothing else
	 *
	 * {@see Audience::gather()} is called when a campaign is prepared and again
	 * when it is sent, and it deliberately does not come through here. A stale
	 * roster on a screen shows a number a few minutes out of date; a stale roster
	 * on the send path messages somebody who has since withdrawn their consent.
	 * Those are not the same mistake, so they do not share a cache.
	 *
	 * @return array<string, mixed>
	 */
	public static function roster(): array {
		$cached = get_transient( self::CACHE_KEY );

		if ( is_array( $cached ) ) {
			return $cached;
		}

		$roster       = Audience::roster();
		$roster['at'] = time();

		set_transient( self::CACHE_KEY, $roster, self::TTL );

		return $roster;
	}

	/**
	 * Throw the cached roster away.
	 *
	 * Called by the two actions rather than left to expire: somebody who has just
	 * pressed Stop and sees the row still saying "can be messaged" has no way to
	 * tell whether it worked.
	 */
	public static function flush(): void {
		delete_transient( self::CACHE_KEY );
	}

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

		$roster = self::roster();

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view state, validated by AudienceFilter.
		$filter = AudienceFilter::from_request( wp_unslash( $_GET ) );

		$matched = $filter->apply( (array) $roster['rows'] );
		$filter  = $filter->clamp( count( $matched ) );
		$paging  = $filter->paging();

		load_template(
			TEXTTANGO_PATH . 'templates/admin/audience.php',
			false,
			array(
				'notices'         => Notices::take(),
				'rows'            => array_slice( $matched, $paging->offset(), $paging->per_page() ),
				'filter'          => $filter,
				'paging'          => $paging,
				'total'           => count( $matched ),
				'roster'          => $roster,
				'states'          => AudienceFilter::states(),
				'url'             => self::url(),
				'suppress_action' => self::SUPPRESS_ACTION,
				'restore_action'  => self::RESTORE_ACTION,
				'ceiling'         => Audience::MAX_RECIPIENTS,
				'consent_url'     => Tabs::url( Tabs::CONSENT ),
			)
		);
	}

	/**
	 * Stop messaging a number.
	 */
	public function handle_suppress(): void {
		$number = $this->guard( self::SUPPRESS_ACTION );

		if ( '' === $number ) {
			$this->back( 'error', __( 'That is not a number this site can message, so there is nothing to stop.', 'texttango' ) );
		}

		Suppressions::add( $number, Suppressions::REASON_MANUAL, Suppressions::SOURCE_LOCAL );

		$this->back(
			'ok',
			sprintf(
				/* translators: %s: phone number. */
				__( 'No further messages will be sent to %s.', 'texttango' ),
				$number
			)
		);
	}

	/**
	 * Allow messages to a number again.
	 */
	public function handle_restore(): void {
		$number = $this->guard( self::RESTORE_ACTION );

		if ( '' === $number ) {
			$this->back( 'error', __( 'That is not a number this site can message.', 'texttango' ) );
		}

		Suppressions::remove( $number );

		/*
		 * Said carefully. Removing a suppression does not grant consent — it only
		 * clears the block — and whether this number is messaged still depends on
		 * a consent record somewhere. Promising more than that is how a merchant
		 * ends up messaging somebody who never agreed.
		 */
		$this->back(
			'ok',
			sprintf(
				/* translators: %s: phone number. */
				__( 'The block on %s is lifted. They will be messaged again only if their consent is still on record.', 'texttango' ),
				$number
			)
		);
	}

	/**
	 * Check the request and hand back the number it is about.
	 *
	 * Normalised through {@see Recipient::normalize()} rather than trusted, so a
	 * number suppressed from this screen is stored in the form every other part
	 * of the plugin compares against — a suppression under a different spelling
	 * of the same number blocks nothing at all.
	 *
	 * @param string $nonce_action Nonce action the form carries.
	 * @return string E.164, or empty when the number is unusable.
	 */
	private function guard( string $nonce_action ): string {
		if ( ! current_user_can( Activator::CAPABILITY ) ) {
			wp_die( esc_html__( 'You do not have permission to change the TextTango audience.', 'texttango' ) );
		}

		check_admin_referer( $nonce_action );

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified on the line above.
		$raw = isset( $_POST['number'] ) ? sanitize_text_field( wp_unslash( $_POST['number'] ) ) : '';

		return Recipient::normalize( $raw );
	}

	/**
	 * Say what happened and return to the list where it happened.
	 *
	 * The filter and page come back with it, so somebody who suppressed a number
	 * on page four of a search does not land back on an unfiltered page one.
	 *
	 * @param string $type    Notice type.
	 * @param string $message What to say.
	 */
	private function back( string $type, string $message ): void {
		Notices::add( $type, $message );
		self::flush();

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in guard(); this only rebuilds the address to return to.
		$filter = AudienceFilter::from_request( wp_unslash( $_POST ) );

		wp_safe_redirect( self::url( $filter->query_args() ) );
		exit;
	}
}
