<?php
/**
 * Narrowing a campaign to part of the consenting audience.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Marketing;

defined( 'ABSPATH' ) || exit;

/**
 * Which consenting customers a campaign is for (spec §8).
 *
 * ### A kind, not a bag of checkboxes
 *
 * The obvious shape is independent filters — a recency box, a spend box, a
 * never-ordered box — and it is the wrong one, because it lets somebody build
 * "has never ordered **and** spent over 200". That segment is empty by
 * definition, and a form that lets a merchant assemble it is a form that wastes
 * their afternoon and then sends nothing.
 *
 * So a segment has a **kind**, and the numbers attached to it only mean
 * something within that kind.
 *
 * ### What "spent" measures
 *
 * The same window as the recency filter, not lifetime — "ordered in the last 90
 * days **and spent at least X in that time**". With no window set it is
 * lifetime, because then there is no window for it to mean anything else in.
 *
 * That distinction is worth being explicit about in a way it rarely is: on a
 * store two years old, lifetime spend and ninety-day spend differ by an order of
 * magnitude, and so does the size of the campaign somebody is about to pay for.
 *
 * ### Order count, category and country are qualifiers, not kinds
 *
 * The same rule that keeps the window and the spend floor out of `never` keeps
 * these three out of it as well: each is a property of an order, meaningless
 * without orders to have it, and every one of them narrows `buyers` further
 * rather than becoming a fourth kind next to it.
 */
final class Segment {

	/**
	 * Everyone whose consent is on record.
	 */
	public const KIND_ALL = 'all';

	/**
	 * People who have bought, optionally recently and optionally enough.
	 */
	public const KIND_BUYERS = 'buyers';

	/**
	 * People who agreed but have never ordered.
	 */
	public const KIND_NEVER = 'never';

	/**
	 * The widest window that can be asked for, in days.
	 *
	 * Two years. Past that the recency filter stops narrowing anything on most
	 * stores, and the order scan it drives gets expensive for no benefit.
	 */
	public const MAX_DAYS = 730;

	/**
	 * Which kind of segment this is.
	 */
	private string $kind;

	/**
	 * How far back an order counts, in days. Zero means any time.
	 */
	private int $days;

	/**
	 * The least somebody must have spent. Zero means any amount.
	 */
	private float $spend;

	/**
	 * A product they must have bought. Zero means anything.
	 */
	private int $product;

	/**
	 * How many qualifying orders they must have placed. Zero means any number.
	 */
	private int $min_orders;

	/**
	 * A product category they must have bought from. Zero means any.
	 */
	private int $category;

	/**
	 * A billing country they must have ordered from. Empty means any.
	 *
	 * Billing rather than shipping, to match every other place this plugin reads
	 * a country off an order — {@see \TextTango\Consent\Recipient::for_order()}
	 * and {@see \TextTango\Privacy\PersonalData::numbers_for()} both key off
	 * `get_billing_country()`, and a virtual-goods order may have no shipping
	 * address to read at all.
	 */
	private string $country;

	/**
	 * Constructor.
	 *
	 * @param string $kind       One of the KIND_* constants.
	 * @param int    $days       Recency window in days; 0 for any time.
	 * @param float  $spend      Minimum spend; 0 for any amount.
	 * @param int    $product    Product they must have bought; 0 for anything.
	 * @param int    $min_orders Qualifying orders required; 0 for any number.
	 * @param int    $category   Product category they must have bought from; 0 for any.
	 * @param string $country    Billing country (ISO 3166-1 alpha-2); '' for any.
	 */
	public function __construct(
		string $kind = self::KIND_ALL,
		int $days = 0,
		float $spend = 0.0,
		int $product = 0,
		int $min_orders = 0,
		int $category = 0,
		string $country = ''
	) {
		$this->kind       = self::normalise( $kind );
		$this->days       = max( 0, min( self::MAX_DAYS, $days ) );
		$this->spend      = max( 0.0, $spend );
		$this->product    = max( 0, $product );
		$this->min_orders = max( 0, $min_orders );
		$this->category   = max( 0, $category );
		$this->country    = strtoupper( trim( $country ) );
	}

	/**
	 * A kind this class recognises.
	 *
	 * Anything else becomes `all`, so a tampered or truncated field widens the
	 * audience to the one the merchant already reviewed rather than narrowing it
	 * to something they never chose.
	 *
	 * @param string $raw Whatever arrived.
	 */
	public static function normalise( string $raw ): string {
		$known = array( self::KIND_ALL, self::KIND_BUYERS, self::KIND_NEVER );

		return in_array( $raw, $known, true ) ? $raw : self::KIND_ALL;
	}

	/**
	 * Whether this segment narrows anything at all.
	 *
	 * When it does not, the audience is gathered exactly as it was before
	 * segments existed — no order scan, no exclusions, no explaining.
	 */
	public function is_everyone(): bool {
		return self::KIND_ALL === $this->kind;
	}

	/**
	 * The kind.
	 */
	public function kind(): string {
		return $this->kind;
	}

	/**
	 * The recency window in days, or 0 for any time.
	 */
	public function days(): int {
		return self::KIND_BUYERS === $this->kind ? $this->days : 0;
	}

	/**
	 * The minimum spend, or 0.0 for any amount.
	 */
	public function spend(): float {
		return self::KIND_BUYERS === $this->kind ? $this->spend : 0.0;
	}

	/**
	 * The product that must have been bought, or 0 for anything.
	 *
	 * Meaningful only for buyers, like the window and the floor — "has never
	 * ordered, and bought this" is the same contradiction the kinds exist to
	 * prevent.
	 */
	public function product(): int {
		return self::KIND_BUYERS === $this->kind ? $this->product : 0;
	}

	/**
	 * The qualifying orders required, or 0 for any number.
	 *
	 * Meaningful only for buyers, for the same reason the product is: "has never
	 * ordered, and ordered three times" is the same contradiction restated.
	 */
	public function min_orders(): int {
		return self::KIND_BUYERS === $this->kind ? $this->min_orders : 0;
	}

	/**
	 * The category that must have been bought from, or 0 for any.
	 */
	public function category(): int {
		return self::KIND_BUYERS === $this->kind ? $this->category : 0;
	}

	/**
	 * The billing country that must have been ordered from, or '' for any.
	 */
	public function country(): string {
		return self::KIND_BUYERS === $this->kind ? $this->country : '';
	}

	/**
	 * Whether one customer belongs in this segment.
	 *
	 * Pure. Takes the facts rather than a customer, so every awkward combination
	 * is testable without a store — and so the same function can judge a
	 * registered customer, a guest with orders, and somebody with no orders at
	 * all without caring which it is looking at.
	 *
	 * Category and country are not read here. They already decided which orders
	 * counted at all — {@see \TextTango\Marketing\Audience::order_history()}
	 * skips a non-matching order before it ever reaches `$facts` — so a customer
	 * whose only orders fall outside them simply has zero qualifying orders and
	 * is excluded by the same path a customer with no orders at all takes.
	 *
	 * @param array<string, mixed> $facts {
	 *     @type int   $orders How many qualifying orders they have placed.
	 *     @type float $spent  What they spent across those orders.
	 * }
	 */
	public function matches( array $facts ): bool {
		$orders = (int) ( $facts['orders'] ?? 0 );
		$spent  = (float) ( $facts['spent'] ?? 0.0 );

		if ( self::KIND_ALL === $this->kind ) {
			return true;
		}

		if ( self::KIND_NEVER === $this->kind ) {
			return 0 === $orders;
		}

		if ( 0 === $orders ) {
			return false;
		}

		/*
		 * Compared with `>=` rather than `>`, in both directions. "At least 200"
		 * and "at least 3 orders" are what the fields say, and somebody exactly on
		 * the line being quietly left out is the kind of off-by-one nobody notices
		 * until a customer asks why they were the only one who did not get the
		 * offer.
		 */
		return $spent >= $this->spend && $orders >= $this->min_orders;
	}

	/**
	 * The segment in one line, for the review screen.
	 *
	 * ### Why this is a composed list rather than the branching it used to be
	 *
	 * With two optional qualifiers — the window and the spend floor — every
	 * combination could be spelled out by hand, and it was: four `if` blocks,
	 * one per combination. Five qualifiers is twenty-six combinations, and
	 * writing that out is not a form of thoroughness, it is the shape a bug
	 * hides in — a combination nobody happened to write out correctly.
	 *
	 * So each active qualifier contributes one clause to a list, and the list is
	 * joined once. Nothing here decides what a *combination* means; each clause
	 * only has to be true on its own; joining them is later than the meanings.
	 */
	public function describe(): string {
		if ( self::KIND_NEVER === $this->kind ) {
			return __( 'Consenting customers who have never ordered', 'texttango' );
		}

		if ( self::KIND_ALL === $this->kind ) {
			return __( 'Everyone whose consent is on record', 'texttango' );
		}

		// Nothing set at all: the one case with its own fixed sentence, so a
		// plain "buyers" segment does not read as "customers who ordered" with
		// an invisible qualifier missing.
		if ( 0 === $this->product && 0 === $this->category && 0 === $this->days
			&& 0.0 === $this->spend && 0 === $this->min_orders && '' === $this->country
		) {
			return __( 'Customers who have ordered at least once', 'texttango' );
		}

		/*
		 * The window attaches straight onto the subject with no "and" — it is a
		 * prepositional phrase ("...in the last 90 days"), not a second verb —
		 * exactly as it always has, including for a product or category subject
		 * ("Customers who bought Widget in the last 90 days").
		 */
		$sentence = $this->subject();

		if ( $this->days > 0 ) {
			$sentence .= sprintf(
				/* translators: %d: number of days. */
				__( ' in the last %d days', 'texttango' ),
				$this->days
			);
		}

		/*
		 * Everything else is its own verb-phrase — "spent at least X", "placed
		 * at least N orders", "were billed to Ghana" — which is exactly why each
		 * needs its own "and" to attach to the subject rather than running on
		 * from "ordered" the way the window does. Joining them was the part the
		 * old branching got right only for the one case it was written for; this
		 * is that same joint, generalised to however many are active.
		 */
		$clauses = $this->clauses();

		if ( array() !== $clauses ) {
			$sentence .= ' ' . self::and_join( $clauses );
		}

		return $sentence;
	}

	/**
	 * Who the sentence is about, before the window or any clause is appended.
	 *
	 * A product or a category makes a better subject than a clause — "who
	 * bought Espresso Cups" reads naturally where "who ordered and bought
	 * Espresso Cups" does not — so whichever was chosen becomes the subject, and
	 * the other, if both are set, joins the clause list instead. Product wins
	 * when both are set: a category describes a whole aisle, and a product is
	 * the more specific of the two things somebody actually chose.
	 */
	private function subject(): string {
		if ( $this->product > 0 ) {
			return sprintf(
				/* translators: %s: a product name. */
				__( 'Customers who bought %s', 'texttango' ),
				self::product_name( $this->product )
			);
		}

		if ( $this->category > 0 ) {
			return sprintf(
				/* translators: %s: a product category. */
				__( 'Customers who bought from %s', 'texttango' ),
				self::category_name( $this->category )
			);
		}

		return __( 'Customers who ordered', 'texttango' );
	}

	/**
	 * Every qualifier that is not the window and did not win the subject, each
	 * as a complete verb-phrase so it reads correctly both on its own — "Customers
	 * who ordered and spent at least $200" — and stacked with others.
	 *
	 * @return string[]
	 */
	private function clauses(): array {
		$clauses = array();

		if ( $this->spend > 0.0 ) {
			/*
			 * "In that time" whenever a window is also set, because the
			 * difference between spending 200 ever and 200 within the window is
			 * the difference between two very different campaigns — and the only
			 * place that difference is visible is this sentence.
			 */
			$clauses[] = $this->days > 0
				? sprintf(
					/* translators: %s: an amount of money with its currency. */
					__( 'spent at least %s in that time', 'texttango' ),
					self::money( $this->spend )
				)
				: sprintf(
					/* translators: %s: an amount of money with its currency. */
					__( 'spent at least %s', 'texttango' ),
					self::money( $this->spend )
				);
		}

		if ( $this->min_orders > 0 ) {
			/*
			 * "Placed", not "ordered" — the subject already says "who ordered"
			 * when nothing else claimed it, and "ordered ... and ordered at
			 * least 3 times" repeats the one word that was supposed to carry the
			 * sentence.
			 */
			$clauses[] = sprintf(
				/* translators: %d: minimum number of orders. */
				_n( 'placed at least %d order', 'placed at least %d orders', $this->min_orders, 'texttango' ),
				$this->min_orders
			);
		}

		// Only when a product also won the subject — otherwise the category
		// already said this as the subject itself.
		if ( $this->category > 0 && $this->product > 0 ) {
			$clauses[] = sprintf(
				/* translators: %s: a product category. */
				__( 'also bought from the %s category', 'texttango' ),
				self::category_name( $this->category )
			);
		}

		if ( '' !== $this->country ) {
			$clauses[] = sprintf(
				/* translators: %s: a country name. */
				__( 'were billed to %s', 'texttango' ),
				self::country_name( $this->country )
			);
		}

		return $clauses;
	}

	/**
	 * Verb-phrase clauses joined with "and", ready to follow a subject.
	 *
	 * Every branch leads with its own "and" — that word is what connects the
	 * whole group back to the subject ("Customers who ordered **and** spent at
	 * least $200"), not only what separates the last clause from the rest. A
	 * first version of this only handled the second job, which read as
	 * "Customers who ordered in the last 90 days spent at least $200" with no
	 * conjunction at all — grammatical-looking enough on a single-clause segment
	 * that it was only caught by rendering three clauses together.
	 *
	 * @param string[] $clauses At least one clause.
	 */
	private static function and_join( array $clauses ): string {
		$count = count( $clauses );

		if ( 1 === $count ) {
			return 'and ' . $clauses[0];
		}

		$last = array_pop( $clauses );

		// Exactly two: "and A and B", no comma — an Oxford comma before "and"
		// reads as a mistake with only two items to separate.
		if ( 1 === count( $clauses ) ) {
			return 'and ' . $clauses[0] . ' and ' . $last;
		}

		/* translators: 1: comma-joined clauses, 2: the final clause. */
		return 'and ' . sprintf( __( '%1$s, and %2$s', 'texttango' ), implode( ', ', $clauses ), $last );
	}

	/**
	 * A product's name, or its id when it has been deleted since.
	 *
	 * @param int $product_id Product id.
	 */
	private static function product_name( int $product_id ): string {
		$product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null;

		if ( $product && method_exists( $product, 'get_name' ) ) {
			return (string) $product->get_name();
		}

		/* translators: %d: a product id whose product no longer exists. */
		return sprintf( __( 'product #%d', 'texttango' ), $product_id );
	}

	/**
	 * A category's name, or its id when it has been deleted since.
	 *
	 * @param int $term_id Term id in the `product_cat` taxonomy.
	 */
	private static function category_name( int $term_id ): string {
		$term = function_exists( 'get_term' ) ? get_term( $term_id, 'product_cat' ) : null;

		if ( $term instanceof \WP_Term ) {
			return $term->name;
		}

		/* translators: %d: a category id whose category no longer exists. */
		return sprintf( __( 'category #%d', 'texttango' ), $term_id );
	}

	/**
	 * A country's name, or its stored code when WooCommerce does not recognise
	 * it.
	 *
	 * Not validated at the point it was set — {@see self::__construct()} keeps
	 * whatever code arrived, the same trust `product` and `category` are given —
	 * so a code that once matched a WooCommerce country and no longer does still
	 * has something to show rather than nothing.
	 *
	 * @param string $code ISO 3166-1 alpha-2 country code.
	 */
	private static function country_name( string $code ): string {
		if ( function_exists( 'WC' ) && WC()->countries instanceof \WC_Countries ) {
			$countries = WC()->countries->get_countries();

			if ( isset( $countries[ $code ] ) ) {
				return (string) $countries[ $code ];
			}
		}

		return $code;
	}

	/**
	 * An amount with the store's currency.
	 *
	 * @param float $amount Amount.
	 */
	private static function money( float $amount ): string {
		return \TextTango\WooCommerce\Templates::format_amount(
			$amount,
			(string) get_option( 'woocommerce_currency', 'GHS' )
		);
	}
}
