<?php
/**
 * Numbers the merchant supplies to a campaign.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Marketing;

use TextTango\Api\PhoneNumber;
use TextTango\Consent\Suppressions;
use TextTango\Support\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * A campaign's supplied recipients (spec §8).
 *
 * ### Why this exists at all
 *
 * Everywhere else, this plugin builds the audience itself from consent it can
 * prove. A box you can paste numbers into is the opposite of that, and it is
 * the one feature that could turn a plugin which *demonstrates* consent into
 * one that merely *asserts* it. A campaign can be addressed to these numbers
 * alone, so this is not a footnote to a decided audience — it is one of the
 * three answers to who a campaign goes to.
 *
 * It exists because the honest case is real: consent recorded on a signup
 * sheet, in a system being migrated from, at a market stall. Refusing to carry
 * those numbers does not stop them being messaged — it sends the merchant to a
 * different tool that asks no questions at all.
 *
 * ### So it is built to keep the record honest rather than to look permissive
 *
 * - **The opt-out list still applies.** Somebody who told this store to stop
 *   cannot be reached by pasting their number back in. This is the rule that
 *   matters most, and it is the one a paste box would normally destroy.
 * - **They are counted separately.** The review says how many came from your
 *   customers and how many you supplied, so the two are never conflated in the
 *   merchant's head or in the log.
 * - **Somebody has to say they have consent,** and the audit trail records who
 *   said it and when. That does not make it true. It does mean that when
 *   somebody asks where a number came from, there is a name against the answer
 *   rather than a shrug.
 * - **Unreadable numbers are shown back, not dropped.** A silently discarded
 *   number is a customer the merchant thinks they messaged.
 */
final class SuppliedNumbers {

	/**
	 * How many supplied numbers one campaign will take.
	 *
	 * The platform's own ceiling for a single campaign, and the same figure
	 * {@see Audience::MAX_RECIPIENTS} uses. This was deliberately lower while
	 * these numbers were an addition to an audience already decided; now that
	 * they can *be* the audience, a cap below what the platform accepts would
	 * only stop somebody doing a thing that works.
	 *
	 * A list this size still wants {@see \TextTango\Install\Importer} where one
	 * exists, because that records where the consent came from instead of asking
	 * somebody to promise.
	 */
	public const MAX = 10000;

	/**
	 * Split, normalize and sort a pasted list.
	 *
	 * Pure. Takes the default country rather than reading it, so every awkward
	 * shape — local formats, a spreadsheet paste full of tabs, the same number
	 * twice in two different notations — is testable without a store.
	 *
	 * @param string $raw     What the merchant typed or pasted.
	 * @param string $country Two-letter default country for local formats.
	 * @return array<string, mixed> {
	 *     @type string[] $recipients E.164 numbers, deduplicated, in input order.
	 *     @type string[] $unusable   Raw entries that could not be read.
	 *     @type int      $duplicates Entries dropped for repeating one already seen.
	 *     @type bool     $truncated  Whether more were supplied than will be taken.
	 * }
	 */
	public static function parse( string $raw, string $country = '' ): array {
		/*
		 * Split on line breaks, commas, semicolons and tabs — but never on a plain
		 * space. People write phone numbers with spaces in them: "+233 50 123
		 * 4567" is how it appears on a signup sheet, and splitting there turns one
		 * number into four unreadable fragments. The normalizer handles spaces,
		 * dashes and brackets inside an entry perfectly well, so leaving them
		 * together is both simpler and right.
		 */
		$entries    = preg_split( '/[\r\n,;\t]+/', trim( $raw ) );
		$recipients = array();
		$unusable   = array();
		$duplicates = 0;

		foreach ( (array) $entries as $entry ) {
			$entry = trim( (string) $entry );

			if ( '' === $entry ) {
				continue;
			}

			$normalized = PhoneNumber::normalize( $entry, '' === $country ? null : $country );

			if ( is_wp_error( $normalized ) ) {
				// Kept as typed, so the merchant can see which one to fix rather
				// than being told a count and left to find it.
				$unusable[] = $entry;

				continue;
			}

			// Deduplicated after normalizing, so 0548828183 and +233548828183 are
			// recognised as one person rather than messaged — and billed — twice.
			if ( in_array( $normalized, $recipients, true ) ) {
				++$duplicates;

				continue;
			}

			$recipients[] = $normalized;
		}

		return array(
			'recipients' => array_slice( $recipients, 0, self::MAX ),
			'unusable'   => $unusable,
			'duplicates' => $duplicates,
			'truncated'  => count( $recipients ) > self::MAX,
		);
	}

	/**
	 * Parse a pasted list and take the opt-out list out of it.
	 *
	 * @param string $raw What the merchant typed or pasted.
	 * @return array<string, mixed> The parse, plus a `suppressed` count.
	 */
	public static function gather( string $raw ): array {
		$parsed = self::parse( $raw, (string) Settings::get( 'default_country', '' ) );
		$kept   = array();
		$out    = 0;

		foreach ( $parsed['recipients'] as $recipient ) {
			/*
			 * The rule this whole class is built around. A number reaches this
			 * point because somebody pasted it and ticked a box; it leaves only if
			 * this store has no record of that person asking to be left alone.
			 */
			if ( Suppressions::contains( $recipient ) ) {
				++$out;

				continue;
			}

			$kept[] = $recipient;
		}

		$parsed['recipients'] = $kept;
		$parsed['suppressed'] = $out;

		return $parsed;
	}

	/**
	 * Why a supplied list cannot be sent to, if it cannot.
	 *
	 * Pure.
	 *
	 * @param int  $supplied How many usable numbers were supplied.
	 * @param bool $asserted Whether consent was asserted for them.
	 * @return string Empty when it may be sent, otherwise a machine-readable reason.
	 */
	public static function refuse( int $supplied, bool $asserted ): string {
		if ( $supplied > 0 && ! $asserted ) {
			return 'not_asserted';
		}

		return '';
	}

	/**
	 * A refusal in words.
	 *
	 * @param string $reason Machine-readable reason from {@see self::refuse()}.
	 */
	public static function refusal_text( string $reason ): string {
		if ( 'not_asserted' === $reason ) {
			return __( 'Confirm that these people agreed to marketing messages from this store before sending to them.', 'texttango' );
		}

		return '';
	}
}
