<?php
/**
 * Putting a discount code in a campaign.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Marketing;

use TextTango\Support\Clock;
use TextTango\WooCommerce\Templates;
use WC_Coupon;

defined( 'ABSPATH' ) || exit;

/**
 * Coupon blasts (spec §8).
 *
 * ### One code, or one each
 *
 * `POST /v2/campaigns` sends one body to a list. There is no per-recipient
 * substitution unless the campaign is built from a platform template against a
 * contact group, and this plugin deliberately does not hand over contact groups
 * — see {@see Audience} for why. So a **shared-code** campaign resolves one code
 * into the message before it leaves this site, and goes out as one request.
 *
 * A **personal-code** campaign cannot use that endpoint at all, because every
 * body differs. It is fanned out from here instead, one `Sender::send()` per
 * recipient, with {@see self::mint()} creating that person's own single-use
 * coupon as their message goes. See {@see PersonalCampaign} for the dispatch and
 * for why its recipients are excluded from every campaign-side total.
 *
 * The difference is a real price difference, not a preference: a thousand
 * recipients is one request against a thousand sends, and the spend cap counts
 * the second properly only because the first is counted separately.
 *
 * ### Why picking beats typing
 *
 * The merchant could type the code into the body themselves, and this class
 * would not need to exist. What it adds is the two things typing cannot tell
 * them, both of which are otherwise learned from complaints:
 *
 * - **The code is real and still live.** A campaign quoting a coupon that
 *   expired last week is worse than no campaign.
 * - **There is enough of it to go round.** A hundred-use coupon sent to three
 *   thousand people is two thousand nine hundred people who were promised a
 *   discount and got an error at checkout.
 */
final class Coupons {

	/**
	 * Merge tags a coupon campaign may use, with a short description.
	 *
	 * @return array<string, string>
	 */
	public static function tags(): array {
		return array(
			'{coupon_code}'    => __( 'The discount code', 'texttango' ),
			'{coupon_amount}'  => __( 'What it takes off', 'texttango' ),
			'{coupon_expires}' => __( 'When it runs out, or blank if it does not', 'texttango' ),
			'{store_name}'     => __( 'Site name', 'texttango' ),
			'{site_url}'       => __( 'Site address', 'texttango' ),
		);
	}

	/**
	 * Coupons a campaign can be built around, newest first.
	 *
	 * Published only. A draft coupon does not work at checkout, so offering one
	 * here would be offering a way to promise a discount that cannot be redeemed.
	 *
	 * @param int $limit How many to offer.
	 * @return array<string, string> Code mapped to a readable label.
	 */
	public static function selectable( int $limit = 100 ): array {
		if ( ! class_exists( WC_Coupon::class ) ) {
			return array();
		}

		$posts = get_posts(
			array(
				'post_type'        => 'shop_coupon',
				'post_status'      => 'publish',
				'numberposts'      => $limit,
				'orderby'          => 'date',
				'order'            => 'DESC',
				'suppress_filters' => false,
			)
		);

		$coupons = array();
		$now     = Clock::now();

		foreach ( $posts as $post ) {
			$facts = self::facts_from( new WC_Coupon( $post->ID ) );

			if ( '' === $facts['code'] ) {
				continue;
			}

			$coupons[ $facts['code'] ] = self::label( $facts, $now );
		}

		return $coupons;
	}

	/**
	 * Everything worth knowing about one coupon.
	 *
	 * @param string $code Coupon code.
	 * @return array<string, mixed>|null Null when no such coupon exists.
	 */
	public static function facts( string $code ): ?array {
		$code = trim( $code );

		if ( '' === $code || ! class_exists( WC_Coupon::class ) ) {
			return null;
		}

		$coupon = new WC_Coupon( $code );

		// WC_Coupon happily constructs from a code that does not exist and gives
		// back an object with no id, so that is the check rather than the code.
		if ( 0 === (int) $coupon->get_id() ) {
			return null;
		}

		return self::facts_from( $coupon );
	}

	/**
	 * Read a coupon into a plain array.
	 *
	 * Everything the rules need, and nothing that needs WooCommerce to inspect,
	 * so {@see self::warnings()} and {@see self::blocks()} can be tested against
	 * every awkward combination without a store behind them.
	 *
	 * @param WC_Coupon $coupon Coupon.
	 * @return array<string, mixed>
	 */
	private static function facts_from( WC_Coupon $coupon ): array {
		$expires = $coupon->get_date_expires();

		return array(
			'code'           => (string) $coupon->get_code(),
			'type'           => (string) $coupon->get_discount_type(),
			'amount'         => (float) $coupon->get_amount(),
			'expires'        => $expires ? (int) $expires->getTimestamp() : 0,
			'usage_limit'    => (int) $coupon->get_usage_limit(),
			'usage_count'    => (int) $coupon->get_usage_count(),
			'limit_per_user' => (int) $coupon->get_usage_limit_per_user(),
			'minimum'        => (float) $coupon->get_minimum_amount(),
			'free_shipping'  => (bool) $coupon->get_free_shipping(),
		);
	}

	/**
	 * A coupon in one line, for a dropdown.
	 *
	 * Takes the moment rather than reading the clock, like the two rules it
	 * calls. Everything in this class that depends on time takes it as an
	 * argument, so all of it can be tested at the boundaries that matter.
	 *
	 * @param array<string, mixed> $facts Coupon facts.
	 * @param int                  $now   Current timestamp.
	 */
	public static function label( array $facts, int $now ): string {
		$parts = array( strtoupper( (string) $facts['code'] ), self::amount( $facts ) );

		$remaining = self::remaining( $facts );

		if ( null !== $remaining ) {
			/* translators: %s: number of redemptions left. */
			$parts[] = sprintf( __( '%s left', 'texttango' ), number_format_i18n( $remaining ) );
		}

		/*
		 * A dead coupon still appears in the list — hiding it would leave a
		 * merchant hunting for a code they can see in WooCommerce and not here —
		 * but it says so, because a picker that offers something unusable without
		 * saying so is a trap. Choosing one is refused at review either way.
		 */
		$refused = self::blocks( $facts, $now );

		if ( 'expired' === $refused ) {
			$parts[] = __( 'expired', 'texttango' );
		} elseif ( 'used_up' === $refused ) {
			$parts[] = __( 'all used', 'texttango' );
		}

		return implode( ' · ', array_filter( $parts ) );
	}

	/**
	 * What a coupon takes off, written out.
	 *
	 * @param array<string, mixed> $facts Coupon facts.
	 */
	public static function amount( array $facts ): string {
		$amount = (float) $facts['amount'];

		if ( 'percent' === (string) $facts['type'] ) {
			/* translators: %s: a percentage, without the sign. */
			return sprintf( __( '%s%% off', 'texttango' ), number_format_i18n( $amount, 0 ) );
		}

		if ( $amount <= 0.0 && ! empty( $facts['free_shipping'] ) ) {
			return __( 'free shipping', 'texttango' );
		}

		return sprintf(
			/* translators: %s: a money amount with its currency. */
			__( '%s off', 'texttango' ),
			Templates::format_amount( $amount, (string) get_option( 'woocommerce_currency', 'GHS' ) )
		);
	}

	/**
	 * How many redemptions are left, or null when there is no limit.
	 *
	 * @param array<string, mixed> $facts Coupon facts.
	 */
	public static function remaining( array $facts ): ?int {
		$limit = (int) $facts['usage_limit'];

		if ( $limit <= 0 ) {
			return null;
		}

		return max( 0, $limit - (int) $facts['usage_count'] );
	}

	/**
	 * Merge-tag values for a coupon.
	 *
	 * @param array<string, mixed> $facts Coupon facts.
	 * @return array<string, string>
	 */
	public static function values( array $facts ): array {
		$expires = (int) $facts['expires'];

		return array(
			'{coupon_code}'    => strtoupper( (string) $facts['code'] ),
			'{coupon_amount}'  => self::amount( $facts ),
			'{coupon_expires}' => $expires > 0 ? wp_date( 'j M', $expires ) : '',
			'{store_name}'     => Templates::store_name(),
			'{site_url}'       => home_url( '/' ),
		);
	}

	/**
	 * Plausible values, for previewing a coupon message with none chosen.
	 *
	 * @return array<string, string>
	 */
	public static function sample_values(): array {
		return array(
			'{coupon_code}'    => 'WEEKEND20',
			'{coupon_amount}'  => __( '20% off', 'texttango' ),
			'{coupon_expires}' => wp_date( 'j M', Clock::now() + WEEK_IN_SECONDS ),
			'{store_name}'     => Templates::store_name(),
			'{site_url}'       => home_url( '/' ),
		);
	}

	/**
	 * A reason this coupon must not be sent at all.
	 *
	 * Pure. Separate from {@see self::warnings()} because these are not advice:
	 * a campaign quoting a code that cannot be redeemed is worse than no
	 * campaign, so the send is refused rather than merely flagged.
	 *
	 * @param array<string, mixed>|null $facts Coupon facts, or null if unknown.
	 * @param int                       $now   Current timestamp.
	 * @return string Empty when it may be sent, otherwise a machine-readable reason.
	 */
	public static function blocks( ?array $facts, int $now ): string {
		if ( null === $facts ) {
			return 'unknown';
		}

		$expires = (int) ( $facts['expires'] ?? 0 );

		/*
		 * WooCommerce expires a coupon at the *end* of its expiry date, so a
		 * coupon dated today is still good until midnight. Comparing the raw
		 * timestamp would refuse it all day.
		 */
		if ( $expires > 0 && $now > $expires + DAY_IN_SECONDS ) {
			return 'expired';
		}

		if ( 0 === self::remaining( $facts ) ) {
			return 'used_up';
		}

		return '';
	}

	/**
	 * A refusal in words.
	 *
	 * @param string $reason Machine-readable reason from {@see self::blocks()}.
	 */
	public static function blocked_text( string $reason ): string {
		if ( 'unknown' === $reason ) {
			return __( 'That coupon no longer exists. Choose another one.', 'texttango' );
		}

		if ( 'expired' === $reason ) {
			return __( 'That coupon has expired, so everyone who tried to use it would get an error at checkout. Extend its expiry date or choose another one.', 'texttango' );
		}

		if ( 'used_up' === $reason ) {
			return __( 'That coupon has already been used its full number of times. Raise its usage limit or choose another one.', 'texttango' );
		}

		return '';
	}

	/**
	 * Things worth saying before a coupon campaign goes.
	 *
	 * Pure. Each of these is something a merchant otherwise finds out from a
	 * customer, which is both later and more expensive.
	 *
	 * @param array<string, mixed> $facts      Coupon facts.
	 * @param int                  $recipients How many people will receive it.
	 * @param int                  $now        Current timestamp.
	 * @param string               $body       The message, for tags that can resolve to nothing.
	 * @return string[] Readable warnings, in the order they matter.
	 */
	public static function warnings( array $facts, int $recipients, int $now, string $body = '' ): array {
		$warnings = array();

		$remaining = self::remaining( $facts );

		// The one that turns a promotion into a complaint queue.
		if ( null !== $remaining && $recipients > $remaining ) {
			$warnings[] = sprintf(
				/* translators: 1: redemptions left on the coupon, 2: number of recipients, 3: how many would miss out. */
				__( 'This coupon has %1$s uses left but is going to %2$s people. The %3$s who try it after the rest have claimed it will get an error at checkout.', 'texttango' ),
				number_format_i18n( $remaining ),
				number_format_i18n( $recipients ),
				number_format_i18n( $recipients - $remaining )
			);
		}

		$expires = (int) ( $facts['expires'] ?? 0 );

		// WooCommerce keeps a coupon good until the end of its expiry date.
		if ( $expires > 0 && ( $expires + DAY_IN_SECONDS ) - $now < 2 * DAY_IN_SECONDS ) {
			$warnings[] = sprintf(
				/* translators: %s: local date and time the coupon stops working. */
				__( 'This coupon stops working on %s, which leaves very little time for anyone to act on the message.', 'texttango' ),
				wp_date( 'j M', $expires )
			);
		}

		if ( (float) ( $facts['minimum'] ?? 0 ) > 0.0 ) {
			$warnings[] = sprintf(
				/* translators: %s: minimum spend with currency. */
				__( 'This coupon needs a minimum spend of %s. Say so in the message, or people will find out at checkout.', 'texttango' ),
				Templates::format_amount( (float) $facts['minimum'], (string) get_option( 'woocommerce_currency', 'GHS' ) )
			);
		}

		/*
		 * An unresolved tag is stripped rather than left in the message, which is
		 * right — nobody should receive the literal `{coupon_expires}`. But a
		 * sentence written around it becomes "Ends ." instead, and that is only
		 * obvious to somebody reading the resolved body carefully.
		 */
		if ( 0 === $expires && str_contains( $body, '{coupon_expires}' ) ) {
			$warnings[] = __( 'The message mentions when the coupon runs out, but this one has no expiry date. That part of the sentence will arrive empty.', 'texttango' );
		}

		return $warnings;
	}

	/**
	 * Characters a personal code may contain.
	 *
	 * `0`/`O` and `1`/`I`/`L` are missing on purpose. This code is read off a
	 * phone screen and typed into a checkout by hand, and a discount that fails
	 * because somebody read a zero as an O is worse than no discount — they do
	 * not conclude they mistyped it, they conclude the shop lied.
	 */
	public const ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';

	/**
	 * How many characters follow the template's code.
	 *
	 * Six from a thirty-one character alphabet is about 887 million
	 * combinations, which makes guessing a stranger's code pointless while
	 * keeping the whole thing short enough to read aloud.
	 */
	public const SUFFIX_LENGTH = 6;

	/**
	 * Meta naming the campaign a coupon was minted for.
	 */
	public const META_CAMPAIGN = '_texttango_campaign';

	/**
	 * Meta naming the number it was minted for.
	 */
	public const META_RECIPIENT = '_texttango_recipient';

	/**
	 * A personal code built from a template code and a suffix.
	 *
	 * Pure, and separate from the minting so the shape can be checked without a
	 * store. WooCommerce upper-cases coupon codes when it stores them, so this
	 * does too rather than letting the stored code differ from the one texted.
	 *
	 * @param string $template Code of the coupon being cloned.
	 * @param string $suffix   The random part.
	 */
	public static function personal_code( string $template, string $suffix ): string {
		$template = strtoupper( trim( $template ) );
		$suffix   = strtoupper( trim( $suffix ) );

		// A template code long enough to push the result past what a customer
		// will retype is trimmed rather than sent. The suffix is the part that
		// has to survive — it is what makes the code theirs.
		$template = substr( $template, 0, 24 );

		return '' === $template ? $suffix : $template . '-' . $suffix;
	}

	/**
	 * A random suffix.
	 *
	 * @param int $length How many characters.
	 */
	public static function suffix( int $length = self::SUFFIX_LENGTH ): string {
		$alphabet = self::ALPHABET;
		$last     = strlen( $alphabet ) - 1;
		$wanted   = max( 1, $length );
		$out      = '';

		for ( $i = 0; $i < $wanted; $i++ ) {
			$out .= $alphabet[ wp_rand( 0, $last ) ];
		}

		return $out;
	}

	/**
	 * Create one person's own coupon, cloned from a template.
	 *
	 * ### Single use, and that is the whole point
	 *
	 * `usage_limit` and `usage_limit_per_user` are both 1. A personal code that
	 * can be used twice is a shared code with extra steps — the first person to
	 * post it publicly turns everybody else's private discount into a race.
	 *
	 * Everything else is copied from the template so the merchant configures one
	 * coupon and the clones inherit it: the discount and its type, the expiry,
	 * the minimum spend and free shipping. **Expiry especially** — the review
	 * screen promises the codes expire with the template, and a clone with no
	 * expiry would be a discount the shop can never withdraw.
	 *
	 * ### Sandbox mints nothing
	 *
	 * A sandboxed campaign is a rehearsal, and a rehearsal that leaves five
	 * hundred live coupons in the shop is not one. The code is generated so the
	 * message reads exactly as it will in production, but no coupon is created —
	 * nothing redeemable exists, and the catalogue is not left to be tidied up.
	 *
	 * @param string $template  Code of the coupon to clone.
	 * @param string $campaign  Campaign uuid, recorded on the clone.
	 * @param string $recipient Number it is for, recorded on the clone.
	 * @param bool   $sandbox   Whether to generate a code without creating it.
	 * @return string The code, or empty when the template is gone.
	 */
	public static function mint( string $template, string $campaign, string $recipient, bool $sandbox = false ): string {
		$facts = self::facts( $template );

		// The template was deleted between composing and sending. Minting from
		// nothing would produce a code that discounts nothing, so the caller is
		// told rather than the customer.
		if ( null === $facts ) {
			return '';
		}

		$code = self::unique_code( (string) $facts['code'] );

		if ( '' === $code || $sandbox ) {
			return $code;
		}

		$coupon = new WC_Coupon();

		$coupon->set_code( $code );
		$coupon->set_discount_type( (string) $facts['type'] );
		$coupon->set_amount( (float) $facts['amount'] );
		$coupon->set_usage_limit( 1 );
		$coupon->set_usage_limit_per_user( 1 );
		$coupon->set_individual_use( true );

		if ( (float) $facts['minimum'] > 0 ) {
			$coupon->set_minimum_amount( (float) $facts['minimum'] );
		}

		if ( ! empty( $facts['free_shipping'] ) ) {
			$coupon->set_free_shipping( true );
		}

		if ( (int) $facts['expires'] > 0 ) {
			$coupon->set_date_expires( (int) $facts['expires'] );
		}

		$coupon->add_meta_data( self::META_CAMPAIGN, $campaign, true );
		$coupon->add_meta_data( self::META_RECIPIENT, $recipient, true );

		return 0 === (int) $coupon->save() ? '' : $code;
	}

	/**
	 * A code from this template that nothing else is using.
	 *
	 * Checked rather than trusted to chance. Saving a coupon whose code already
	 * exists gives two coupons answering to one code, and WooCommerce resolves
	 * that by picking one — so a customer could redeem somebody else's.
	 *
	 * @param string $template Template code.
	 */
	private static function unique_code( string $template ): string {
		for ( $attempt = 0; $attempt < 8; $attempt++ ) {
			$code = self::personal_code( $template, self::suffix() );

			if ( ! function_exists( 'wc_get_coupon_id_by_code' ) ) {
				return $code;
			}

			if ( 0 === (int) wc_get_coupon_id_by_code( $code ) ) {
				return $code;
			}
		}

		// Eight collisions against 887 million combinations is not bad luck, it
		// is something wrong — an exhausted random source, or a filter rewriting
		// codes. Sending nothing beats sending a code somebody else holds.
		return '';
	}
}
