<?php
/**
 * Numbers that must not be messaged.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Consent;

use TextTango\Install\Schema;
use TextTango\Support\Paging;

defined( 'ABSPATH' ) || exit;

/**
 * The local record of recipients the platform has refused (spec §7, §15).
 *
 * TextTango exposes no inbound-SMS webhook to customer tokens and no SMS
 * suppression endpoint, so a STOP cannot reach this site directly. The one
 * signal available is a `RECIPIENT_SUPPRESSED` refusal on the next send —
 * which means opt-out is discovered rather than announced, at a cost of one
 * rejected send per recipient.
 *
 * Recording it locally is what stops that cost repeating: every later message
 * to that number is skipped here, without an API call.
 *
 * Keyed by number rather than by user, because whoever opts out may have
 * checked out as a guest and have no account to hang a flag on.
 */
final class Suppressions {

	/**
	 * The recipient refused the message, e.g. by texting STOP.
	 */
	public const REASON_OPTED_OUT = 'opted_out';

	/**
	 * A person switched it off in their account or the admin did.
	 */
	public const REASON_MANUAL = 'manual';

	/**
	 * Learned from the API refusing a send.
	 */
	public const SOURCE_API = 'api';

	/**
	 * Set on this site.
	 */
	public const SOURCE_LOCAL = 'local';

	/**
	 * Whether a number is suppressed.
	 *
	 * @param string $recipient E.164 recipient.
	 */
	public static function contains( string $recipient ): bool {
		return null !== self::find( $recipient );
	}

	/**
	 * The suppression record for a number, if there is one.
	 *
	 * @param string $recipient E.164 recipient.
	 * @return array<string, mixed>|null
	 */
	public static function find( string $recipient ): ?array {
		global $wpdb;

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

		$table = Schema::suppressions_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name is built from $wpdb->prefix and cannot be parameterised.
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE recipient = %s", $recipient ), ARRAY_A );

		return is_array( $row ) ? $row : null;
	}

	/**
	 * Every suppressed number, as a set to test membership against.
	 *
	 * ### Why this exists next to `contains()`
	 *
	 * `contains()` is one uncached `SELECT`, which is right when there is one
	 * number to check. {@see \TextTango\Marketing\Audience} has thousands, and
	 * asking one at a time made preparing a campaign cost a query per recipient —
	 * up to twenty thousand of them at the bounds that class already sets.
	 * Invisible on a small store, and ruinous on exactly the stores this is for.
	 *
	 * Keyed by number with a `true` value rather than returned as a list, because
	 * the caller is testing membership and `isset()` on a hash is the operation
	 * that costs nothing. Bounded for the same reason `ConsentList::numbers()`
	 * is: this feeds an audience that has a ceiling of its own.
	 *
	 * @param int $limit Maximum numbers.
	 * @return array<string, true>
	 */
	public static function numbers( int $limit = 10000 ): array {
		global $wpdb;

		$table = Schema::suppressions_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name is built from $wpdb->prefix and cannot be parameterised.
		$rows = $wpdb->get_col(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table name is interpolated, from $wpdb->prefix.
				"SELECT recipient FROM {$table} ORDER BY id ASC LIMIT %d",
				max( 1, $limit )
			)
		);

		$set = array();

		foreach ( (array) $rows as $recipient ) {
			if ( '' !== (string) $recipient ) {
				$set[ (string) $recipient ] = true;
			}
		}

		return $set;
	}

	/**
	 * Stop messaging a number.
	 *
	 * @param string $recipient E.164 recipient.
	 * @param string $reason    Why, one of the REASON_* constants.
	 * @param string $source    Where it was learned, one of the SOURCE_* constants.
	 */
	public static function add( string $recipient, string $reason = self::REASON_OPTED_OUT, string $source = self::SOURCE_API ): bool {
		global $wpdb;

		if ( '' === $recipient ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; REPLACE makes re-learning the same opt-out idempotent.
		$suppressed = $wpdb->replace(
			Schema::suppressions_table_name(),
			array(
				'recipient'  => $recipient,
				'reason'     => $reason,
				'source'     => $source,
				'created_at' => gmdate( 'Y-m-d H:i:s' ),
			),
			array( '%s', '%s', '%s', '%s' )
		);

		if ( false === $suppressed ) {
			return false;
		}

		/**
		 * Fires when a number is added to the local suppression list.
		 *
		 * @param string $recipient E.164 recipient.
		 * @param string $reason    Why.
		 * @param string $source    Where it was learned.
		 */
		do_action( 'texttango_recipient_suppressed', $recipient, $reason, $source );

		return true;
	}

	/**
	 * Start messaging a number again.
	 *
	 * Only meaningful for a suppression this site added. One learned from the API
	 * will simply be re-learned on the next send, because the platform still holds
	 * its own record and this site cannot clear that.
	 *
	 * @param string $recipient E.164 recipient.
	 */
	public static function remove( string $recipient ): bool {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table.
		return false !== $wpdb->delete( Schema::suppressions_table_name(), array( 'recipient' => $recipient ), array( '%s' ) );
	}

	/**
	 * How many numbers are suppressed.
	 */
	public static function count(): int {
		global $wpdb;

		$table = Schema::suppressions_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name is built from $wpdb->prefix and cannot be parameterised.
		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
	}

	/**
	 * The most recent suppressions, for the admin screen.
	 *
	 * @param int $limit Maximum rows.
	 * @return array<int, array<string, mixed>>
	 */
	public static function recent( int $limit = 20 ): array {
		global $wpdb;

		$table = Schema::suppressions_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name is built from $wpdb->prefix and cannot be parameterised.
		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d", $limit ), ARRAY_A );

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

	/**
	 * One page of rows, newest first.
	 *
	 * Numbers this site must not message used to be capped at a fixed number with no way to reach
	 * the row after it. This is a record somebody consults when they need an
	 * answer about one person or one change, so "the newest N" is not enough.
	 *
	 * @param Paging $paging Which page.
	 * @return array<int, array<string, mixed>>
	 */
	public static function paged( Paging $paging ): array {
		global $wpdb;

		$table = Schema::suppressions_table_name();

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name is built from $wpdb->prefix and cannot be parameterised.
		$sql = "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d";

		// 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( $wpdb->prepare( $sql, $paging->per_page(), $paging->offset() ), ARRAY_A );

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

	/**
	 * Whether an API error code means the recipient has opted out.
	 *
	 * @param string $error_code Error code from the client.
	 */
	public static function is_suppression_error( string $error_code ): bool {
		return in_array(
			$error_code,
			array( 'texttango_recipient_suppressed', 'texttango_recipient_opted_out' ),
			true
		);
	}
}
