<?php
/**
 * Message templates and merge tags.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\WooCommerce;

defined( 'ABSPATH' ) || exit;

/**
 * Resolves merge tags and estimates what a message will cost to send (spec §6).
 *
 * The estimate is not decoration. SMS is billed per segment, and a single
 * accented character silently switches the whole message from GSM-7 to UCS-2,
 * cutting the per-segment budget from 160 characters to 70 — so a template that
 * looks like one message can quietly bill as three. Showing the count while the
 * template is being written is the only place that is cheap to notice.
 */
final class Templates {

	/**
	 * Characters encodable in a single GSM 03.38 septet.
	 *
	 * The `$` is escaped because PHP allows high bytes in identifiers, so an
	 * unescaped `$¥` is parsed as a variable rather than two characters.
	 */
	private const GSM7_BASIC = "@£\$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑÜ§¿abcdefghijklmnopqrstuvwxyzäöñüà";

	/**
	 * Characters that fit GSM 03.38 but cost two septets each.
	 */
	private const GSM7_EXTENDED = '^{}\\[~]|€';

	/**
	 * Segment sizes, in characters, for each encoding.
	 */
	private const LIMITS = array(
		'GSM-7' => array(
			'single' => 160,
			'multi'  => 153,
		),
		'UCS-2' => array(
			'single' => 70,
			'multi'  => 67,
		),
	);

	/**
	 * The alphabet and segment sizes, for the browser to reuse.
	 *
	 * The editor estimates segments live as a template is typed, and handing it
	 * these values keeps the two implementations from disagreeing about which
	 * characters are cheap — the part most likely to drift if it were duplicated
	 * in JavaScript by hand.
	 *
	 * @return array{basic: string, extended: string, limits: array<string, array<string, int>>}
	 */
	public static function charset_config(): array {
		return array(
			'basic'    => self::GSM7_BASIC,
			'extended' => self::GSM7_EXTENDED,
			'limits'   => self::LIMITS,
		);
	}

	/**
	 * Merge tags every template may use, with a short description.
	 *
	 * @return array<string, string>
	 */
	public static function tags(): array {
		return array(
			'{first_name}'      => __( 'Customer first name', 'texttango' ),
			'{last_name}'       => __( 'Customer last name', 'texttango' ),
			'{order_number}'    => __( 'Order number', 'texttango' ),
			'{order_total}'     => __( 'Order total with currency', 'texttango' ),
			'{order_status}'    => __( 'Order status', 'texttango' ),
			'{order_items}'     => __( 'Number of items', 'texttango' ),
			'{tracking_number}' => __( 'Shipment tracking number, if one is set', 'texttango' ),
			'{store_name}'      => __( 'Site name', 'texttango' ),
			'{site_url}'        => __( 'Site address', 'texttango' ),
			'{order_url}'       => __( 'Link to the order in the customer account', 'texttango' ),
		);
	}

	/**
	 * Merge tags one kind of event can resolve.
	 *
	 * @param string $set One of `order`, `account`, `stock`.
	 * @return array<string, string>
	 */
	public static function tags_for( string $set ): array {
		if ( 'account' === $set ) {
			return self::account_tags();
		}

		if ( 'stock' === $set ) {
			return self::stock_tags();
		}

		if ( 'reset' === $set ) {
			return self::reset_tags();
		}

		return self::tags();
	}

	/**
	 * Preview values for one kind of event.
	 *
	 * @param string $set One of `order`, `account`, `stock`.
	 * @return array<string, string>
	 */
	public static function sample_for( string $set ): array {
		if ( 'account' === $set ) {
			return self::account_sample_values();
		}

		if ( 'stock' === $set ) {
			return self::stock_sample_values();
		}

		if ( 'reset' === $set ) {
			return self::reset_sample_values();
		}

		return self::sample_values();
	}

	/**
	 * Merge tags a welcome message can resolve.
	 *
	 * Short on purpose. A new account has no order behind it, so the only things
	 * knowable are who they are and where they have just signed up.
	 *
	 * @return array<string, string>
	 */
	public static function account_tags(): array {
		return array(
			'{first_name}' => __( 'Customer first name', 'texttango' ),
			'{last_name}'  => __( 'Customer last name', 'texttango' ),
			'{store_name}' => __( 'Site name', 'texttango' ),
			'{site_url}'   => __( 'Site address', 'texttango' ),
		);
	}

	/**
	 * Merge-tag values for a newly registered customer.
	 *
	 * @param int $user_id Customer.
	 * @return array<string, string>
	 */
	public static function account_values( int $user_id ): array {
		$user = get_userdata( $user_id );

		return array(
			'{first_name}' => $user ? (string) $user->first_name : '',
			'{last_name}'  => $user ? (string) $user->last_name : '',
			'{store_name}' => self::store_name(),
			'{site_url}'   => home_url( '/' ),
		);
	}

	/**
	 * Plausible values for previewing a welcome message.
	 *
	 * @return array<string, string>
	 */
	public static function account_sample_values(): array {
		return array(
			'{first_name}' => 'Ama',
			'{last_name}'  => 'Mensah',
			'{store_name}' => self::store_name(),
			'{site_url}'   => home_url( '/' ),
		);
	}

	/**
	 * Merge tags a password reset can resolve.
	 *
	 * @return array<string, string>
	 */
	public static function reset_tags(): array {
		return array(
			'{reset_link}' => __( 'The link that lets them set a new password', 'texttango' ),
			'{first_name}' => __( 'Customer first name', 'texttango' ),
			'{last_name}'  => __( 'Customer last name', 'texttango' ),
			'{store_name}' => __( 'Site name', 'texttango' ),
			'{site_url}'   => __( 'Site address', 'texttango' ),
		);
	}

	/**
	 * Merge-tag values for a password reset.
	 *
	 * @param int    $user_id User resetting.
	 * @param string $link    The reset URL.
	 * @return array<string, string>
	 */
	public static function reset_values( int $user_id, string $link ): array {
		$user = get_userdata( $user_id );

		return array(
			'{reset_link}' => $link,
			'{first_name}' => $user ? (string) $user->first_name : '',
			'{last_name}'  => $user ? (string) $user->last_name : '',
			'{store_name}' => self::store_name(),
			'{site_url}'   => home_url( '/' ),
		);
	}

	/**
	 * Plausible values for previewing a reset message.
	 *
	 * The sample link is deliberately full length. A reset URL is most of the
	 * message's cost, and a short placeholder would show one segment in the
	 * editor where the real thing takes two.
	 *
	 * @return array<string, string>
	 */
	public static function reset_sample_values(): array {
		return array(
			'{reset_link}' => home_url( '/wp-login.php?action=rp&key=abc123def456ghi789jk&login=customer' ),
			'{first_name}' => 'Ama',
			'{last_name}'  => 'Mensah',
			'{store_name}' => self::store_name(),
			'{site_url}'   => home_url( '/' ),
		);
	}

	/**
	 * Merge tags a stock alert can resolve.
	 *
	 * @return array<string, string>
	 */
	public static function stock_tags(): array {
		return array(
			'{product_name}'   => __( 'Product name', 'texttango' ),
			'{sku}'            => __( 'Product SKU, or blank if it has none', 'texttango' ),
			'{stock_quantity}' => __( 'How many are left', 'texttango' ),
			'{product_url}'    => __( 'Link to the product', 'texttango' ),
			'{store_name}'     => __( 'Site name', 'texttango' ),
			'{site_url}'       => __( 'Site address', 'texttango' ),
		);
	}

	/**
	 * Merge-tag values for a product running low.
	 *
	 * Takes the product object WooCommerce hands the hook, so a variation
	 * reports its own name and stock rather than its parent's.
	 *
	 * @param \WC_Product $product Product that triggered the alert.
	 * @return array<string, string>
	 */
	public static function stock_values( \WC_Product $product ): array {
		return self::stock_fields(
			(string) $product->get_name(),
			(string) $product->get_sku(),
			$product->get_stock_quantity(),
			(string) $product->get_permalink()
		);
	}

	/**
	 * The same values, with the product already read.
	 *
	 * Split out so the awkward cases — a product with no SKU, one with stock
	 * management switched off — can be tested without a WooCommerce product to
	 * build, which is the same shape the rest of this plugin uses for rules that
	 * would otherwise need a store behind them.
	 *
	 * @param string   $name     Product name.
	 * @param string   $sku      SKU, or ''.
	 * @param int|null $quantity Stock level; null when stock is not counted.
	 * @param string   $url      Link to the product.
	 * @return array<string, string>
	 */
	public static function stock_fields( string $name, string $sku, ?int $quantity, string $url ): array {
		return array(
			'{product_name}'   => $name,
			'{sku}'            => $sku,
			// Null means stock is not being counted, which reads better as zero
			// than as an empty space in the middle of a sentence.
			'{stock_quantity}' => null === $quantity ? '0' : (string) $quantity,
			'{product_url}'    => $url,
			'{store_name}'     => self::store_name(),
			'{site_url}'       => home_url( '/' ),
		);
	}

	/**
	 * Plausible values for previewing a stock alert.
	 *
	 * @return array<string, string>
	 */
	public static function stock_sample_values(): array {
		return array(
			'{product_name}'   => 'Kente Tote Bag',
			'{sku}'            => 'KTB-001',
			'{stock_quantity}' => '2',
			'{product_url}'    => home_url( '/product/kente-tote-bag/' ),
			'{store_name}'     => self::store_name(),
			'{site_url}'       => home_url( '/' ),
		);
	}

	/**
	 * Merge tags a cart reminder may use.
	 *
	 * Deliberately its own list rather than an addition to {@see self::tags()}.
	 * An order template offering `{cart_total}` would be offering a tag that can
	 * only ever resolve to nothing, and a cart template offering `{order_number}`
	 * would be worse — it reads as though there is an order, and there is not.
	 *
	 * @return array<string, string>
	 */
	public static function cart_tags(): array {
		return array(
			'{first_name}' => __( 'Customer first name', 'texttango' ),
			'{last_name}'  => __( 'Customer last name', 'texttango' ),
			'{cart_items}' => __( 'Number of items left in the basket', 'texttango' ),
			'{cart_total}' => __( 'Basket total with currency', 'texttango' ),
			'{cart_url}'   => __( 'Link back to the basket', 'texttango' ),
			'{store_name}' => __( 'Site name', 'texttango' ),
			'{site_url}'   => __( 'Site address', 'texttango' ),
		);
	}

	/**
	 * Merge-tag values for a saved cart.
	 *
	 * The totals are summed from what WooCommerce stored rather than recalculated
	 * through a live cart. Rebuilding one for somebody who is not the current
	 * request's user means loading their session, their customer object and every
	 * product in it — inside a cron worker, for a figure that is decoration on the
	 * message rather than the point of it.
	 *
	 * @param int                  $user_id Customer.
	 * @param array<string, mixed> $cart    Saved cart contents.
	 * @return array<string, string>
	 */
	public static function cart_values( int $user_id, array $cart ): array {
		$user   = get_userdata( $user_id );
		$totals = self::cart_totals( $cart );

		return array(
			'{first_name}' => $user ? (string) $user->first_name : '',
			'{last_name}'  => $user ? (string) $user->last_name : '',
			'{cart_items}' => (string) $totals['items'],
			'{cart_total}' => self::format_amount( $totals['total'], get_option( 'woocommerce_currency', 'GHS' ) ),
			'{cart_url}'   => function_exists( 'wc_get_cart_url' ) ? wc_get_cart_url() : home_url( '/' ),
			'{store_name}' => self::store_name(),
			'{site_url}'   => home_url( '/' ),
		);
	}

	/**
	 * How many items are in a saved cart, and what they come to.
	 *
	 * Pure, so the summing can be tested against the shapes WooCommerce actually
	 * stores — including the ones where a line has no tax recorded at all.
	 *
	 * @param array<string, mixed> $cart Saved cart contents.
	 * @return array{items: int, total: float}
	 */
	public static function cart_totals( array $cart ): array {
		$items = 0;
		$total = 0.0;

		foreach ( $cart as $line ) {
			if ( ! is_array( $line ) ) {
				continue;
			}

			$items += (int) ( $line['quantity'] ?? 0 );
			$total += (float) ( $line['line_total'] ?? 0 ) + (float) ( $line['line_tax'] ?? 0 );
		}

		return array(
			'items' => $items,
			'total' => round( $total, 2 ),
		);
	}

	/**
	 * Plausible cart values, for previewing a reminder with no cart to hand.
	 *
	 * @return array<string, string>
	 */
	public static function cart_sample_values(): array {
		return array(
			'{first_name}' => 'Ama',
			'{last_name}'  => 'Mensah',
			'{cart_items}' => '2',
			'{cart_total}' => self::format_amount( 180.0, (string) get_option( 'woocommerce_currency', 'GHS' ) ),
			'{cart_url}'   => function_exists( 'wc_get_cart_url' ) ? wc_get_cart_url() : home_url( '/cart/' ),
			'{store_name}' => self::store_name(),
			'{site_url}'   => home_url( '/' ),
		);
	}

	/**
	 * Replace merge tags in a template.
	 *
	 * @param string                $template Raw template.
	 * @param array<string, string> $values   Tag values, keyed with braces.
	 */
	public static function render( string $template, array $values ): string {
		$known = array_keys( $values );

		/*
		 * Unresolved tags are dropped *before* substitution, not after. A customer
		 * should never receive the literal text "{tracking_number}", but stripping
		 * afterwards would also chew through any substituted value that happened to
		 * contain braces — a tracking number or order reference, say.
		 */
		$rendered = (string) preg_replace_callback(
			/*
			 * Digits included, and that is not cosmetic. Form fields are named by
			 * whoever built the form, and Contact Form 7 names them `tel-472` by
			 * default — which becomes the tag `{tel_472}`. Without digits here
			 * that tag matches nothing, so it is never recognised as unresolved,
			 * survives the strip below, and reaches the customer as literal
			 * braces in their message: the exact failure this replacement exists
			 * to prevent.
			 */
			'/\{[a-z0-9_]+\}/',
			static fn( array $matches ): string => in_array( $matches[0], $known, true ) ? $matches[0] : '',
			$template
		);

		$rendered = strtr( $rendered, $values );

		return trim( (string) preg_replace( '/[ \t]{2,}/', ' ', $rendered ) );
	}

	/**
	 * Merge-tag values for an order.
	 *
	 * Uses WooCommerce CRUD throughout, never post meta, so it works under
	 * High-Performance Order Storage (spec §16).
	 *
	 * @param \WC_Order $order Order the message is about.
	 * @return array<string, string>
	 */
	public static function order_values( \WC_Order $order ): array {
		return array(
			'{first_name}'      => $order->get_billing_first_name(),
			'{last_name}'       => $order->get_billing_last_name(),
			'{order_number}'    => $order->get_order_number(),
			'{order_total}'     => self::format_amount( (float) $order->get_total(), $order->get_currency() ),
			'{order_status}'    => wc_get_order_status_name( $order->get_status() ),
			'{order_items}'     => (string) $order->get_item_count(),
			'{tracking_number}' => self::tracking_for( $order ),
			'{store_name}'      => self::store_name(),
			'{site_url}'        => home_url( '/' ),
			'{order_url}'       => $order->get_view_order_url(),
		);
	}

	/**
	 * Plausible values for previewing a template with no real order to hand.
	 *
	 * @return array<string, string>
	 */
	public static function sample_values(): array {
		return array(
			'{first_name}'      => 'Ama',
			'{last_name}'       => 'Mensah',
			'{order_number}'    => '1042',
			'{order_total}'     => 'GHS 250.00',
			'{order_status}'    => 'Processing',
			'{order_items}'     => '3',
			'{tracking_number}' => 'TT123456789GH',
			'{store_name}'      => self::store_name(),
			'{site_url}'        => home_url( '/' ),
			'{order_url}'       => home_url( '/my-account/view-order/1042/' ),
		);
	}

	/**
	 * Estimate the encoding, length and segment count of a message.
	 *
	 * @param string $body Message as it would be sent.
	 * @return array{encoding: string, length: int, segments: int, limit: int}
	 */
	public static function estimate( string $body ): array {
		$encoding = self::is_gsm7( $body ) ? 'GSM-7' : 'UCS-2';
		$length   = 'GSM-7' === $encoding ? self::gsm7_length( $body ) : self::ucs2_length( $body );
		$limits   = self::LIMITS[ $encoding ];

		if ( 0 === $length ) {
			return array(
				'encoding' => $encoding,
				'length'   => 0,
				'segments' => 0,
				'limit'    => $limits['single'],
			);
		}

		if ( $length <= $limits['single'] ) {
			return array(
				'encoding' => $encoding,
				'length'   => $length,
				'segments' => 1,
				'limit'    => $limits['single'],
			);
		}

		return array(
			'encoding' => $encoding,
			'length'   => $length,
			'segments' => (int) ceil( $length / $limits['multi'] ),
			'limit'    => $limits['multi'],
		);
	}

	/**
	 * Whether every character fits the GSM 03.38 alphabet.
	 *
	 * @param string $body Message text.
	 */
	public static function is_gsm7( string $body ): bool {
		$basic    = self::characters( self::GSM7_BASIC );
		$extended = self::characters( self::GSM7_EXTENDED );

		foreach ( self::characters( $body ) as $character ) {
			if ( ! in_array( $character, $basic, true ) && ! in_array( $character, $extended, true ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Length of a GSM-7 message in septets, counting escaped characters twice.
	 *
	 * @param string $body Message text.
	 */
	private static function gsm7_length( string $body ): int {
		$extended = self::characters( self::GSM7_EXTENDED );
		$length   = 0;

		foreach ( self::characters( $body ) as $character ) {
			$length += in_array( $character, $extended, true ) ? 2 : 1;
		}

		return $length;
	}

	/**
	 * Length of a UCS-2 message in code units.
	 *
	 * Characters outside the Basic Multilingual Plane — emoji, most obviously —
	 * take two units each, so a single emoji costs two of the seventy available.
	 *
	 * @param string $body Message text.
	 */
	private static function ucs2_length( string $body ): int {
		$length = 0;

		foreach ( self::characters( $body ) as $character ) {
			$length += self::code_point( $character ) > 0xFFFF ? 2 : 1;
		}

		return $length;
	}

	/**
	 * Split a UTF-8 string into characters.
	 *
	 * @param string $value String to split.
	 * @return string[]
	 */
	private static function characters( string $value ): array {
		$characters = preg_split( '//u', $value, -1, PREG_SPLIT_NO_EMPTY );

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

	/**
	 * Unicode code point of a single character.
	 *
	 * @param string $character One UTF-8 character.
	 */
	private static function code_point( string $character ): int {
		$converted = unpack( 'N', mb_convert_encoding( $character, 'UCS-4BE', 'UTF-8' ) );

		return is_array( $converted ) ? (int) reset( $converted ) : 0;
	}

	/**
	 * Format a money amount for an SMS.
	 *
	 * Deliberately not `wc_price()`. That renders the currency *symbol*, and most
	 * symbols are outside GSM 03.38 — the Ghana cedi sign among them. One ₵ in a
	 * message drops the segment size from 160 characters to 70, so a single-part
	 * order update quietly becomes two, on every order, in the plugin's default
	 * currency. The ISO code costs three characters and keeps the cheap encoding,
	 * and is arguably clearer in a text message anyway.
	 *
	 * @param float  $amount   Amount to format.
	 * @param string $currency ISO currency code.
	 */
	public static function format_amount( float $amount, string $currency ): string {
		$code = strtoupper( preg_replace( '/[^A-Za-z]/', '', $currency ) ?? '' );

		if ( '' === $code ) {
			$code = 'GHS';
		}

		return $code . ' ' . number_format( $amount, 2, '.', ',' );
	}

	/**
	 * Site name, with entities decoded so "Ama&#039;s Shop" does not reach a handset.
	 */
	public static function store_name(): string {
		return wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES );
	}

	/**
	 * Tracking number for an order, if a shipping plugin recorded one.
	 *
	 * WooCommerce core has no tracking field, so the common meta keys are tried
	 * and the result is filterable for anything else.
	 *
	 * Public because the shipped notification triggers on the same answer this
	 * gives. Two lists of meta keys would drift, and the failure would be quiet:
	 * either a message that fires with an empty tracking number in it, or one
	 * that never fires at all.
	 *
	 * @param \WC_Order $order Order to inspect.
	 */
	public static function tracking_for( \WC_Order $order ): string {
		$number = '';

		foreach ( array( '_tracking_number', 'tracking_number', '_wc_shipment_tracking_items' ) as $key ) {
			$value = $order->get_meta( $key, true );

			if ( is_array( $value ) ) {
				$value = $value[0]['tracking_number'] ?? '';
			}

			if ( is_scalar( $value ) && '' !== (string) $value ) {
				$number = (string) $value;
				break;
			}
		}

		/**
		 * Filters the tracking number used in message templates.
		 *
		 * @param string    $number Tracking number, or an empty string.
		 * @param \WC_Order $order  Order being messaged about.
		 */
		return (string) apply_filters( 'texttango_order_tracking_number', $number, $order );
	}
}
