<?php
/**
 * Who a marketing message may go to.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Marketing;

use TextTango\Consent\Consent;
use TextTango\Consent\ConsentList;
use TextTango\Consent\Recipient;
use TextTango\Consent\Suppressions;
use TextTango\Support\Clock;
use WC_Order;

defined( 'ABSPATH' ) || exit;

/**
 * Gathers the numbers a campaign is allowed to reach (spec §8).
 *
 * ### Why the list is built here rather than handed to a contact group
 *
 * `POST /v2/campaigns` takes either `contact_group_ids` — letting the platform
 * fan out to whoever is in the group — or an explicit `to` list. Handing over a
 * group id is one line of code and the wrong choice: everything this plugin
 * knows about who may be messaged lives on **this** side. The opt-out list is
 * learned here, from refusals the platform gave us. Consent is recorded here,
 * with its timestamp and source. A number suppressed an hour ago has not
 * reached the group yet, because sync is event-driven and the reconciler runs
 * nightly.
 *
 * Building the list locally means a campaign is subject to exactly the same
 * rules as every other message the plugin sends, and the one that matters most
 * is the one a group cannot express: **somebody who opted out since the last
 * sync is excluded**, rather than messaged because a stale group still holds
 * their number.
 *
 * The cost is a bounded local query instead of one line. That is a good trade
 * for the difference between "we think these people agreed" and "these people
 * agreed, and none of them has since told us to stop".
 */
final class Audience {

	/**
	 * The platform's own ceiling on an explicit recipient list.
	 */
	public const MAX_RECIPIENTS = 10000;

	/**
	 * How many customers are examined per gather.
	 *
	 * Kept below the platform's ceiling so a store larger than one campaign can
	 * hold is reported honestly rather than silently truncated.
	 */
	public const BATCH = 5000;

	/**
	 * How many orders one segment scan reads.
	 *
	 * A segment asks a question about order history, and asking it of each
	 * consenting customer in turn is two queries a head — ten thousand of them
	 * on a store with five thousand customers. One bounded pass over orders
	 * answers it for everybody at once, and this is the bound.
	 */
	public const MAX_ORDERS = 20000;

	/**
	 * Everyone a marketing campaign may be sent to.
	 *
	 * @return array<string, mixed> {
	 *     @type string[] $recipients   E.164 numbers, deduplicated.
	 *     @type int      $consenting   Customers whose consent is on record.
	 *     @type int      $suppressed   Excluded for having opted out.
	 *     @type int      $unusable     Excluded for having no usable number.
	 *     @type bool     $truncated    Whether more exist than one campaign holds.
	 * }
	 */
	public static function gather( ?Segment $segment = null ): array {
		$roster = self::roster( $segment );

		$recipients = array();

		foreach ( $roster['rows'] as $row ) {
			if ( self::STATE_CONSENTING === $row['state'] ) {
				$recipients[] = $row['number'];
			}
		}

		return array(
			'recipients' => array_slice( $recipients, 0, self::MAX_RECIPIENTS ),
			'consenting' => $roster['consenting'],
			'listed'     => $roster['listed'],
			'segment'    => $roster['segment'],
			'narrowing'  => $roster['narrowing'],
			'outside'    => $roster['outside'],
			'suppressed' => $roster['suppressed'],
			'unusable'   => $roster['unusable'],
			'truncated'  => count( $recipients ) > self::MAX_RECIPIENTS,
		);
	}

	/**
	 * Somebody who may be messaged.
	 */
	public const STATE_CONSENTING = 'consenting';

	/**
	 * Somebody who has told us to stop.
	 */
	public const STATE_SUPPRESSED = 'suppressed';

	/**
	 * Somebody who agreed but left no number we can dial.
	 */
	public const STATE_UNUSABLE = 'unusable';

	/**
	 * Somebody who agreed but falls outside the segment being applied.
	 */
	public const STATE_OUTSIDE = 'outside';

	/**
	 * The audience as rows, one per person, rather than as a list of numbers.
	 *
	 * ### Why {@see self::gather()} is built on this rather than beside it
	 *
	 * The Audience screen has to show who a campaign will reach. If it walked the
	 * three sources itself, it would be a second implementation of the rule that
	 * decides who gets messaged — and the moment the two disagreed, the screen
	 * would become a claim checked against nothing while the sends went somewhere
	 * else. So there is one walk, and `gather()` is a view over it.
	 *
	 * ### The row key is not always the number
	 *
	 * A number is the natural key: it is what the plugin sends to, and it is the
	 * one thing all three sources share. But a customer who consented and left no
	 * usable number has no number to key on — and they are precisely the row the
	 * screen exists to explain, so they cannot be dropped. Those rows are keyed by
	 * account or order instead.
	 *
	 * ### First source wins
	 *
	 * Sources are walked richest first — account, then order, then the bare list —
	 * so a number that appears in more than one keeps the entry that knows a name.
	 * The counters still count every entry walked, which is what `gather()` has
	 * always reported and what the review screen's totals mean.
	 *
	 * @param Segment|null $segment Segment to apply, or null for everyone.
	 * @return array{rows: array<int, array<string, mixed>>, consenting: int, listed: int, segment: string, narrowing: bool, outside: int, suppressed: int, unusable: int}
	 */
	public static function roster( ?Segment $segment = null ): array {
		$segment    = $segment ?? new Segment();
		$narrowing  = ! $segment->is_everyone();
		$history    = $narrowing ? self::order_history( $segment ) : array();
		$found      = array();
		$suppressed = 0;
		$unusable   = 0;
		$consenting = 0;
		$outside    = 0;

		/*
		 * Read once, as a set. This used to be a `Suppressions::contains()` per
		 * recipient — one query each, up to twenty thousand of them at the bounds
		 * below, to prepare a single campaign.
		 */
		$blocked = Suppressions::numbers( self::MAX_RECIPIENTS );

		foreach ( self::consenting_users() as $user_id ) {
			++$consenting;

			$user  = get_userdata( (int) $user_id );
			$phone = Recipient::for_user( (int) $user_id );
			$row   = array(
				'name'    => $user instanceof \WP_User ? (string) $user->display_name : '',
				'user_id' => (int) $user_id,
				'source'  => (string) get_user_meta( (int) $user_id, Consent::META_OPTIN_SOURCE, true ),
				'at'      => (string) get_user_meta( (int) $user_id, Consent::META_OPTIN_AT, true ),
			);

			if ( '' === $phone ) {
				++$unusable;

				// Keyed by account, because there is no number to key on — and
				// this is the row somebody comes to this screen to find.
				self::place( $found, 'user:' . (int) $user_id, $row, '', self::STATE_UNUSABLE );

				continue;
			}

			if ( $narrowing && ! $segment->matches( $history[ 'user:' . (int) $user_id ] ?? array() ) ) {
				++$outside;

				self::place( $found, $phone, $row, $phone, self::STATE_OUTSIDE );

				continue;
			}

			if ( isset( $blocked[ $phone ] ) ) {
				++$suppressed;

				self::place( $found, $phone, $row, $phone, self::STATE_SUPPRESSED );

				continue;
			}

			self::place( $found, $phone, $row, $phone, self::STATE_CONSENTING );
		}

		foreach ( self::consenting_orders() as $order ) {
			++$consenting;

			$phone = Recipient::for_order( $order );
			$row   = array(
				'name'     => trim( (string) $order->get_billing_first_name() . ' ' . (string) $order->get_billing_last_name() ),
				'order_id' => (int) $order->get_id(),
				'source'   => (string) $order->get_meta( Consent::ORDER_META_OPTIN_SOURCE ),
				'at'       => (string) $order->get_meta( Consent::ORDER_META_OPTIN_AT ),
			);

			if ( '' === $phone ) {
				++$unusable;

				self::place( $found, 'order:' . (int) $order->get_id(), $row, '', self::STATE_UNUSABLE );

				continue;
			}

			// A guest has no account to key on, so their orders are gathered
			// under the number they gave — which is the only thing that ties
			// their orders together at all.
			if ( $narrowing && ! $segment->matches( $history[ 'phone:' . $phone ] ?? array() ) ) {
				++$outside;

				self::place( $found, $phone, $row, $phone, self::STATE_OUTSIDE );

				continue;
			}

			if ( isset( $blocked[ $phone ] ) ) {
				++$suppressed;

				self::place( $found, $phone, $row, $phone, self::STATE_SUPPRESSED );

				continue;
			}

			self::place( $found, $phone, $row, $phone, self::STATE_CONSENTING );
		}

		/*
		 * The third source, and the one with no customer behind it. These are
		 * numbers that agreed somewhere this site cannot see — imported from a
		 * plugin that kept a plain subscriber list — so there is no account to
		 * resolve and no order to read. The number is the whole record.
		 */
		$listed = 0;

		/*
		 * Skipped entirely when a segment is narrowing. A bare number has no
		 * order history and never can — it was imported from a system that kept
		 * only numbers — so it cannot satisfy "ordered in the last 90 days", and
		 * counting it as "outside the segment" would be a technicality dressed up
		 * as a judgement. The review says these people were set aside, because
		 * dropping them silently is the one thing worse than dropping them.
		 */
		if ( ! $narrowing ) {
			foreach ( ConsentList::numbers( self::MAX_RECIPIENTS ) as $number ) {
				++$consenting;
				++$listed;

				$row = array( 'source' => ConsentList::SOURCE_IMPORT );

				if ( isset( $blocked[ $number ] ) ) {
					++$suppressed;

					self::place( $found, $number, $row, $number, self::STATE_SUPPRESSED );

					continue;
				}

				self::place( $found, $number, $row, $number, self::STATE_CONSENTING );
			}
		} else {
			$listed = ConsentList::count();
		}

		return array(
			'rows'       => array_values( $found ),
			'consenting' => $consenting,
			'listed'     => $listed,
			'segment'    => $segment->describe(),
			'narrowing'  => $narrowing,
			'outside'    => $outside,
			'suppressed' => $suppressed,
			'unusable'   => $unusable,
		);
	}

	/**
	 * Put a row in the roster, unless a richer one is already there.
	 *
	 * Sources are walked account first, then order, then the bare list, so the
	 * first entry for a number is the one that knows the most about the person
	 * behind it. A later source finding the same number adds nothing but would
	 * overwrite a name with an empty string.
	 *
	 * Deliberately does not touch the counters. `gather()` has always counted
	 * every entry it walked rather than every distinct person — a customer who
	 * also checked out as a guest under the same number is two consents and one
	 * recipient — and the review screen's totals mean that.
	 *
	 * @param array<string, array<string, mixed>> $found  Roster so far, by key.
	 * @param string                              $key    Number, or `user:`/`order:` when there is none.
	 * @param array<string, mixed>                $row    What this source knows.
	 * @param string                              $number E.164, or empty when unusable.
	 * @param string                              $state  One of the STATE_* constants.
	 */
	private static function place( array &$found, string $key, array $row, string $number, string $state ): void {
		if ( isset( $found[ $key ] ) ) {
			return;
		}

		$found[ $key ] = array_merge(
			array(
				'number'   => '',
				'name'     => '',
				'user_id'  => 0,
				'order_id' => 0,
				'source'   => '',
				'at'       => '',
				'reason'   => '',
			),
			$row,
			array(
				'number' => $number,
				'state'  => $state,
			)
		);
	}

	/**
	 * What every customer has bought, in one pass.
	 *
	 * The inversion that makes a segment affordable. Asked customer by customer,
	 * "have they ordered recently and spent enough" is two queries a head; asked
	 * of the orders instead it is one query for the whole store, and the answer
	 * for each customer falls out of the same pass.
	 *
	 * Keyed by account where there is one and by number where there is not, so a
	 * guest's orders are gathered under the only thing that ties them together.
	 *
	 * Only paid orders count. A cancelled or failed order is not a purchase, and
	 * treating it as one would put somebody in a "recent buyers" segment on the
	 * strength of a payment that never went through.
	 *
	 * @param Segment $segment The segment being applied.
	 * @return array<string, array{orders: int, spent: float}>
	 */
	private static function order_history( Segment $segment ): array {
		if ( ! function_exists( 'wc_get_orders' ) ) {
			return array();
		}

		$args = array(
			'limit'   => self::MAX_ORDERS,
			'orderby' => 'date',
			'order'   => 'DESC',
			'return'  => 'objects',
			'status'  => function_exists( 'wc_get_is_paid_statuses' ) ? wc_get_is_paid_statuses() : array( 'processing', 'completed' ),
		);

		$days = $segment->days();

		if ( $days > 0 ) {
			$args['date_created'] = '>' . ( Clock::now() - ( $days * DAY_IN_SECONDS ) );
		}

		$history = array();
		$product = $segment->product();
		$bought  = $product > 0 ? self::orders_containing_any( array( $product ) ) : array();

		// A product nobody has ever bought narrows the audience to nobody, and
		// saying so by returning early is cheaper and clearer than scanning every
		// order to discover it.
		if ( $product > 0 && array() === $bought ) {
			return array();
		}

		/*
		 * A category is the same shortcut, one join earlier: first which
		 * products are in it, then which orders contain one of those — the same
		 * `orders_containing_any()` the product filter above just used, so
		 * "bought this product" and "bought from this category" cannot answer
		 * the question two different ways.
		 */
		$category    = $segment->category();
		$in_category = $category > 0 ? self::orders_containing_any( self::product_ids_in_category( $category ) ) : array();

		if ( $category > 0 && array() === $in_category ) {
			return array();
		}

		$country = $segment->country();

		foreach ( (array) wc_get_orders( $args ) as $order ) {
			if ( ! $order instanceof WC_Order ) {
				continue;
			}

			if ( $product > 0 && ! isset( $bought[ (int) $order->get_id() ] ) ) {
				continue;
			}

			if ( $category > 0 && ! isset( $in_category[ (int) $order->get_id() ] ) ) {
				continue;
			}

			/*
			 * Billing, not shipping — see {@see Segment::$country} for why: it
			 * is the field every other place in this plugin already reads a
			 * country off an order from, and a virtual-goods order may have no
			 * shipping address to compare against at all.
			 */
			if ( '' !== $country && strtoupper( (string) $order->get_billing_country() ) !== $country ) {
				continue;
			}

			$key = self::history_key( $order );

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

			if ( ! isset( $history[ $key ] ) ) {
				$history[ $key ] = array(
					'orders' => 0,
					'spent'  => 0.0,
				);
			}

			++$history[ $key ]['orders'];
			$history[ $key ]['spent'] += (float) $order->get_total();
		}

		return $history;
	}

	/**
	 * Which orders contain any of a set of products, as an id set.
	 *
	 * One query against the order-items tables, deliberately **not** against
	 * `wc_order_product_lookup`. That table is built by the Analytics importer,
	 * so it lags a fresh order and is empty wherever Analytics is switched off —
	 * a segment that quietly misses last week's buyers is worse than one that
	 * costs a join. The items tables are where line items actually live, are
	 * always present, and are untouched by High-Performance Order Storage, which
	 * moves orders and order meta but not their lines.
	 *
	 * Matches `_product_id` **or** `_variation_id`, so a parent product also
	 * catches everyone who bought a variation of it — a variation's line item
	 * carries the parent in `_product_id`, and "bought this product" is what a
	 * merchant means when they pick one that has variations. Passing more than
	 * one id is what makes this the same query for a category: the products in
	 * it, found first, then which orders contain any one of them.
	 *
	 * @param int[] $product_ids Product or variation ids.
	 * @return array<int, bool> Order ids as keys, for O(1) membership.
	 */
	private static function orders_containing_any( array $product_ids ): array {
		global $wpdb;

		$product_ids = array_values( array_unique( array_map( 'intval', $product_ids ) ) );

		if ( array() === $product_ids ) {
			return array();
		}

		$items        = $wpdb->prefix . 'woocommerce_order_items';
		$itemmeta     = $wpdb->prefix . 'woocommerce_order_itemmeta';
		$placeholders = implode( ', ', array_fill( 0, count( $product_ids ), '%d' ) );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the two table names are interpolated, both from $wpdb->prefix; the placeholders are counted from the argument.
		$sql = "SELECT DISTINCT i.order_id FROM {$items} AS i INNER JOIN {$itemmeta} AS m ON m.order_item_id = i.order_item_id WHERE i.order_item_type = 'line_item' AND m.meta_key IN ( '_product_id', '_variation_id' ) AND m.meta_value IN ( {$placeholders} ) LIMIT %d";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is the literal above; every value goes through prepare() as a placeholder.
		$ids = $wpdb->get_col( $wpdb->prepare( $sql, array_merge( $product_ids, array( self::MAX_ORDERS ) ) ) );

		$found = array();

		foreach ( (array) $ids as $id ) {
			$found[ (int) $id ] = true;
		}

		return $found;
	}

	/**
	 * The products filed under a category, as ids.
	 *
	 * `get_objects_in_term()` reads the term-relationships table WordPress
	 * already indexes for exactly this, so choosing a category costs one fast
	 * lookup before {@see self::orders_containing_any()} runs its own.
	 *
	 * Exact match only — a variable product's variations are covered because
	 * `orders_containing_any()` matches `_variation_id` against its parent's id,
	 * the same rule the product filter uses, but a **child category** is not
	 * covered by its parent. Widening that is a real question and not this
	 * one's to decide by default.
	 *
	 * @param int $term_id Term id in the `product_cat` taxonomy.
	 * @return int[]
	 */
	private static function product_ids_in_category( int $term_id ): array {
		if ( $term_id <= 0 ) {
			return array();
		}

		$ids = get_objects_in_term( $term_id, 'product_cat' );

		return is_wp_error( $ids ) ? array() : array_map( 'intval', $ids );
	}

	/**
	 * How one order is filed against a customer.
	 *
	 * @param WC_Order $order Order.
	 */
	private static function history_key( WC_Order $order ): string {
		$user_id = (int) $order->get_customer_id();

		if ( $user_id > 0 ) {
			return 'user:' . $user_id;
		}

		$phone = Recipient::for_order( $order );

		return '' === $phone ? '' : 'phone:' . $phone;
	}

	/**
	 * How many registered customers have agreed, and nothing more than that.
	 *
	 * Deliberately **not** how many are reachable. The reachable figure needs a
	 * suppression check per person — the cost kept off page load everywhere else
	 * — and showing it here only for the review to contradict it later would be
	 * the same class of bug as a balance that disagreed with itself.
	 *
	 * One query, counting rather than loading: `count_total` with a `number` of
	 * one returns the total without pulling a single user object.
	 */
	public static function consenting_count(): int {
		$query = new \WP_User_Query(
			array(
				'number'      => 1,
				'fields'      => 'ID',
				'count_total' => true,
				'meta_query'  => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- One counting query, on one screen, when it is empty.
					array(
						'key'   => Consent::META_OPTIN,
						'value' => Consent::granted_meta_value(),
					),
				),
			)
		);

		return (int) $query->get_total();
	}

	/**
	 * Registered customers whose marketing consent is on record.
	 *
	 * Reads the stored value directly rather than through the state machine,
	 * because a meta query cannot call a function — the value asked for is the
	 * one {@see Consent} writes for a confirmed grant, and a test holds the two
	 * together.
	 *
	 * @return array<int, int>
	 */
	private static function consenting_users(): array {
		$users = get_users(
			array(
				'number'     => self::BATCH,
				'fields'     => 'ID',
				'orderby'    => 'ID',
				'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded, and run only when a campaign is being prepared.
					array(
						'key'   => Consent::META_OPTIN,
						'value' => Consent::granted_meta_value(),
					),
				),
			)
		);

		return array_map( 'intval', (array) $users );
	}

	/**
	 * Guest orders carrying consent.
	 *
	 * Guests are most of the list on many stores, and consent they actively gave
	 * at checkout is no less real for their not having made an account.
	 *
	 * @return WC_Order[]
	 */
	private static function consenting_orders(): array {
		if ( ! function_exists( 'wc_get_orders' ) ) {
			return array();
		}

		$orders = wc_get_orders(
			array(
				'limit'       => self::BATCH,
				'customer_id' => 0,
				'orderby'     => 'ID',
				'order'       => 'DESC',
				'return'      => 'objects',
				'meta_query'  => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- As above.
					array(
						'key'   => Consent::ORDER_META_OPTIN,
						'value' => Consent::granted_order_meta_value(),
					),
				),
			)
		);

		return array_values(
			array_filter( (array) $orders, static fn( $order ): bool => $order instanceof WC_Order )
		);
	}
}
