<?php
/**
 * Numbers that agreed, with no account behind them.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Consent;

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

defined( 'ABSPATH' ) || exit;

/**
 * The opt-in list, keyed by number (spec §7).
 *
 * ### Why this exists
 *
 * It is the mirror image of {@see Suppressions}, and that class's own docblock
 * makes the argument for it:
 *
 * > Suppression is keyed by number, not by user. A customer who opts out may
 * > have checked out as a guest and have no account at all […] so a usermeta
 * > flag would miss exactly the people it most needs to catch.
 *
 * Every word of that applies to opting *in*, and until this existed the plugin
 * only half-believed it: an opt-out could be recorded against a bare number, an
 * opt-in could not. So somebody imported from a system that kept a plain
 * subscriber list — no account, no order — had nowhere to be recorded at all,
 * and {@see \TextTango\Install\Importer} could only count them and say so.
 *
 * ### Why there is no state column
 *
 * Presence means consent, exactly as presence in the suppression table means
 * opted out. A denial needs no row because absence is already the answer, and a
 * `state` column would invite a third value nobody has defined the meaning of.
 *
 * ### What it does not outrank
 *
 * A row here is the weakest kind of consent this plugin holds — it came from
 * somewhere else, and nobody on this site watched it being given. The opt-out
 * list beats it, and so does an explicit refusal on an account. That ordering
 * lives in {@see Consent::evaluate()}, where every other rule about who may be
 * messaged already lives.
 */
final class ConsentList {

	/**
	 * Brought over from another plugin's subscriber list.
	 */
	public const SOURCE_IMPORT = 'import';

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

	/**
	 * The 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::consents_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;
	}

	/**
	 * Record that a number agreed.
	 *
	 * @param string $recipient E.164 recipient.
	 * @param string $source    Where the consent came from.
	 */
	public static function add( string $recipient, string $source = self::SOURCE_IMPORT ): bool {
		global $wpdb;

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

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

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

		/**
		 * Fires when a number is added to the local consent list.
		 *
		 * @since 0.7.0
		 *
		 * @param string $recipient E.164 recipient.
		 * @param string $source    Where the consent came from.
		 */
		do_action( 'texttango_recipient_consented', $recipient, $source );

		return true;
	}

	/**
	 * Take a number off the list.
	 *
	 * @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::consents_table_name(), array( 'recipient' => $recipient ), array( '%s' ) );
	}

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

		$table = Schema::consents_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table.
		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
	}

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

		$table = Schema::consents_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_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table name is interpolated, from $wpdb->prefix.
				"SELECT * FROM {$table} ORDER BY created_at DESC, id DESC LIMIT %d",
				max( 1, $limit )
			),
			ARRAY_A
		);

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

	/**
	 * One page of rows, newest first.
	 *
	 * Numbers that agreed without an account 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::consents_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 created_at DESC, 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();
	}

	/**
	 * Every number on the list.
	 *
	 * Bounded, because this feeds a campaign audience and a campaign has a
	 * ceiling of its own — reading more than can ever be sent to is work nobody
	 * benefits from.
	 *
	 * @param int $limit Maximum numbers.
	 * @return string[]
	 */
	public static function numbers( int $limit = 10000 ): array {
		global $wpdb;

		$table = Schema::consents_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 )
			)
		);

		return array_values( array_filter( array_map( 'strval', (array) $rows ) ) );
	}
}
