<?php
/**
 * Turns WooCommerce order events into queued SMS.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\WooCommerce;

use TextTango\Api\PhoneNumber;
use TextTango\Messages\MessageLog;
use TextTango\Sending\Sender;
use TextTango\Support\Settings;
use WC_Order;

defined( 'ABSPATH' ) || exit;

/**
 * Binds order lifecycle hooks to queued transactional sends (spec §6, §14, §16).
 *
 * Two rules shape everything here.
 *
 * The recipient is validated to E.164 **before** anything is claimed or queued,
 * so an unusable number costs nothing: it is skipped with an order note rather
 * than sent, rejected by the API, and billed for the attempt (spec §15).
 *
 * The dedupe key is `event + order_id + status`. WooCommerce fires status
 * transitions more than once in ordinary use — a plugin re-saving an order, a
 * payment gateway confirming twice, a retried webhook — and with no idempotency
 * key on the send endpoint (spec §2) this key is the only thing standing between
 * that and a customer receiving, and the merchant paying for, the same message
 * twice.
 */
final class Notifications {

	/**
	 * Event fired when a tracking number appears.
	 */
	public const TRACKING_EVENT = 'order_shipped';

	/**
	 * Order meta holding the tracking number the customer was already told.
	 */
	public const META_TRACKING_SENT = '_texttango_tracking_sent';

	/**
	 * Register hooks.
	 */
	public function register(): void {
		if ( ! self::woocommerce_active() ) {
			return;
		}

		add_action( 'woocommerce_order_status_changed', array( $this, 'on_status_changed' ), 10, 4 );
		add_action( 'woocommerce_new_order', array( $this, 'on_new_order' ), 10, 1 );

		/*
		 * Tracking is watched on every order save rather than on a tracking
		 * plugin's own hook. WooCommerce core has no tracking field, so the
		 * number could come from Shipment Tracking, Advanced Shipment Tracking,
		 * a courier integration, or somebody typing it into a custom field —
		 * each with a different hook, or none. Watching the saved order catches
		 * all of them, and works the same under HPOS as under post meta.
		 */
		add_action( 'woocommerce_after_order_object_save', array( $this, 'on_order_saved' ), 10, 1 );
	}

	/**
	 * Whether WooCommerce is loaded.
	 */
	public static function woocommerce_active(): bool {
		return function_exists( 'wc_get_order' ) && class_exists( 'WC_Order' );
	}

	/**
	 * Send whatever the new status calls for.
	 *
	 * @param int            $order_id Order id.
	 * @param string         $from     Previous status.
	 * @param string         $to       New status.
	 * @param WC_Order|mixed $order    Order object, as WooCommerce passes it.
	 */
	public function on_status_changed( $order_id, $from, $to, $order = null ): void {
		$order = $order instanceof WC_Order ? $order : wc_get_order( $order_id );

		if ( ! $order instanceof WC_Order ) {
			return;
		}

		foreach ( Events::for_status( (string) $to ) as $event ) {
			self::maybe_send( $event, $order, (string) $to );
		}
	}

	/**
	 * Alert staff that an order arrived.
	 *
	 * @param int $order_id Order id.
	 */
	public function on_new_order( $order_id ): void {
		$order = wc_get_order( $order_id );

		if ( $order instanceof WC_Order ) {
			self::maybe_send( 'new_order_admin', $order, 'new' );
		}
	}

	/**
	 * An order was saved. Tell the customer if a tracking number just appeared.
	 *
	 * @param WC_Order|mixed $order Order, as WooCommerce passes it.
	 */
	public function on_order_saved( $order ): void {
		if ( ! $order instanceof WC_Order || ! Events::is_enabled( self::TRACKING_EVENT ) ) {
			return;
		}

		$tracking = Templates::tracking_for( $order );

		if ( '' === $tracking || $tracking === (string) $order->get_meta( self::META_TRACKING_SENT, true ) ) {
			return;
		}

		/*
		 * Recorded before sending. This runs inside a save, and the send path
		 * saves the order again to leave its note — so a stamp written afterwards
		 * would race with a second save firing this hook again. The dedupe key
		 * covers the same ground on the sending side; this stops the second
		 * round trip happening at all.
		 */
		$order->update_meta_data( self::META_TRACKING_SENT, $tracking );
		$order->save_meta_data();

		self::maybe_send( self::TRACKING_EVENT, $order, 'tracking:' . $tracking );
	}

	/**
	 * Queue an event's message, unless something says not to.
	 *
	 * @param string   $event  Event id.
	 * @param WC_Order $order  Order the message is about.
	 * @param string   $status Status that triggered it, used in the dedupe key.
	 */
	public static function maybe_send( string $event, WC_Order $order, string $status ): void {
		$definition = Events::get( $event );

		if ( null === $definition || ! Events::is_enabled( $event ) ) {
			return;
		}

		/*
		 * `texttango_should_send` used to be applied here. It now lives at the send
		 * choke point in Sender, where it also covers codes, the test send and
		 * anything added later — a filter meant to stop a message should not be
		 * defeatable by a caller that took a different route.
		 */

		$body = Templates::render( Events::template( $event ), Templates::order_values( $order ) );

		/**
		 * Filters the rendered body of a WooCommerce notification (spec §18).
		 *
		 * Applied after merge tags resolve, so `$body` is the finished message.
		 * Whatever is returned is what sends and what the log keeps — including
		 * its length, so a longer string can turn one billable segment into two.
		 *
		 * @since 0.2.0
		 *
		 * @param string   $body  Rendered message.
		 * @param string   $event Event id, e.g. `order_completed`.
		 * @param WC_Order $order Order being messaged about.
		 */
		$body = (string) apply_filters( 'texttango_message_template', $body, $event, $order );

		if ( '' === trim( $body ) ) {
			return;
		}

		if ( Events::AUDIENCE_ADMIN === $definition['audience'] ) {
			self::send_to_staff( $event, $order, $status, $body );

			return;
		}

		self::send_to_customer( $event, $order, $status, $body );
	}

	/**
	 * Queue the customer's copy.
	 *
	 * @param string   $event  Event id.
	 * @param WC_Order $order  Order the message is about.
	 * @param string   $status Status that triggered it.
	 * @param string   $body   Rendered message.
	 */
	private static function send_to_customer( string $event, WC_Order $order, string $status, string $body ): void {
		/**
		 * Filters the number a customer notification is addressed to (spec §18).
		 *
		 * For stores that keep the mobile somewhere other than billing — a custom
		 * field, a membership record, a CRM. Returned as typed rather than in
		 * E.164: normalization still runs afterwards, so a local format is fine
		 * and a bad one is still refused rather than sent.
		 *
		 * Returning an empty string skips the message with no log row, which is
		 * the right way to say "not this customer" without leaving a failure.
		 *
		 * @since 0.2.0
		 *
		 * @param string   $raw   Billing phone as stored on the order.
		 * @param WC_Order $order Order being messaged about.
		 * @param string   $event Event id.
		 */
		$raw = trim(
			(string) apply_filters(
				'texttango_resolve_recipient_phone',
				(string) $order->get_billing_phone(),
				$order,
				$event
			)
		);

		if ( '' === $raw ) {
			return;
		}

		$recipient = PhoneNumber::normalize( $raw, $order->get_billing_country() );

		if ( is_wp_error( $recipient ) ) {
			// Skipped rather than attempted: an unusable number would be rejected
			// by the API and still counted as a send.
			self::note(
				$order,
				sprintf(
					/* translators: 1: event label, 2: reason the number was rejected. */
					__( 'TextTango: %1$s SMS not sent — %2$s', 'texttango' ),
					(string) ( Events::get( $event )['label'] ?? $event ),
					$recipient->get_error_message()
				)
			);

			return;
		}

		$result = Sender::queue(
			array(
				'to'         => $recipient,
				'message'    => $body,
				'event'      => $event,
				'dedupe_key' => self::dedupe_key( $event, $order->get_id(), $status ),
				'order_id'   => $order->get_id(),
				'wp_user_id' => $order->get_customer_id() > 0 ? $order->get_customer_id() : null,
			)
		);

		self::record_outcome( $order, $event, $recipient, $result );
	}

	/**
	 * Queue the staff alert, one message per configured number.
	 *
	 * @param string   $event  Event id.
	 * @param WC_Order $order  Order the message is about.
	 * @param string   $status Status that triggered it.
	 * @param string   $body   Rendered message.
	 */
	private static function send_to_staff( string $event, WC_Order $order, string $status, string $body ): void {
		// Already normalized and made distinct by admin_recipients(), so one
		// handset written two ways cannot become two messages here.
		foreach ( Events::admin_recipients() as $index => $recipient ) {
			Sender::queue(
				array(
					'to'         => $recipient,
					'message'    => $body,
					'event'      => $event,
					// The index keeps each staff number's copy distinct, so three
					// recipients do not collide on one key and lose two messages.
					'dedupe_key' => self::dedupe_key( $event, $order->get_id(), $status . ':' . $index ),
					'order_id'   => $order->get_id(),
				)
			);
		}
	}

	/**
	 * Note what happened on the order, unless it was a duplicate.
	 *
	 * @param WC_Order                      $order     Order to annotate.
	 * @param string                        $event     Event id.
	 * @param string                        $recipient E.164 recipient.
	 * @param array<string, mixed>|\WP_Error $result   Outcome from the sender.
	 */
	private static function record_outcome( WC_Order $order, string $event, string $recipient, $result ): void {
		$label = (string) ( Events::get( $event )['label'] ?? $event );

		if ( is_wp_error( $result ) ) {
			// A duplicate is the guard working, not a problem worth an order note.
			if ( 'texttango_duplicate' === $result->get_error_code() ) {
				return;
			}

			self::note(
				$order,
				sprintf(
					/* translators: 1: event label, 2: error message. */
					__( 'TextTango: %1$s SMS failed — %2$s', 'texttango' ),
					$label,
					$result->get_error_message()
				)
			);

			return;
		}

		$masked = PhoneNumber::mask( $recipient );

		if ( ! empty( $result['sandboxed'] ) ) {
			self::note(
				$order,
				sprintf(
					/* translators: 1: event label, 2: masked recipient number. */
					__( 'TextTango: %1$s SMS logged for %2$s but not sent — sandbox mode is on.', 'texttango' ),
					$label,
					$masked
				)
			);

			return;
		}

		self::note(
			$order,
			sprintf(
				/* translators: 1: event label, 2: masked recipient number. */
				__( 'TextTango: %1$s SMS queued for %2$s.', 'texttango' ),
				$label,
				$masked
			)
		);
	}

	/**
	 * The idempotency key for one event on one order.
	 *
	 * @param string $event    Event id.
	 * @param int    $order_id Order id.
	 * @param string $status   Status that triggered it.
	 */
	public static function dedupe_key( string $event, int $order_id, string $status ): string {
		return MessageLog::build_dedupe_key( $event, (string) $order_id, $status );
	}

	/**
	 * Add a private note to an order.
	 *
	 * @param WC_Order $order Order to annotate.
	 * @param string   $note  Already-translated note.
	 */
	private static function note( WC_Order $order, string $note ): void {
		if ( ! Settings::get( 'order_notes_enabled', true ) ) {
			return;
		}

		$order->add_order_note( $note );
	}
}
