<?php
/**
 * Marketing consent and hard opt-out.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Consent;

use WC_Order;

defined( 'ABSPATH' ) || exit;

/**
 * Who may be messaged, and on what basis (spec §7, §13).
 *
 * Two states, deliberately separate, because conflating them either silences
 * order updates nobody asked to stop or keeps texting somebody who did.
 *
 * **Marketing consent** is a preference: granted at checkout or in the account,
 * withdrawable at any time, recorded with a timestamp and a source so the trail
 * can be shown later. It gates marketing only. Order updates are service
 * messages about a transaction the customer chose to enter, and keep sending.
 *
 * **Suppression** is a hard stop. It means the platform refused the recipient —
 * they sent STOP, or were blacklisted — and nothing further may be sent to that
 * number, transactional included. It is keyed by the **number**, not the
 * account, because the person who opts out may have checked out as a guest and
 * have no account at all.
 *
 * Marketing consent itself has three states rather than two. **Pending** exists
 * because {@see DoubleOptIn} asks the number to confirm before anything is sent
 * to it: somebody who ticked the box but has not answered the confirmation has
 * asked for messages, which is worth recording, but has not yet proved they hold
 * the number — so they are not marketed to and their details do not leave the
 * site. Only `granted` counts as consent, and every read is written to be strict
 * about that.
 */
final class Consent {

	/**
	 * Consent given and, where required, confirmed.
	 */
	public const STATE_GRANTED = 'granted';

	/**
	 * Asked for, but not yet confirmed from the handset. Not consent.
	 */
	public const STATE_PENDING = 'pending';

	/**
	 * Declined, or withdrawn.
	 */
	public const STATE_DENIED = 'denied';

	/**
	 * Usermeta holding the marketing preference.
	 */
	public const META_OPTIN = 'texttango_sms_optin';

	/**
	 * Usermeta recording when the preference was last set.
	 */
	public const META_OPTIN_AT = 'texttango_sms_optin_at';

	/**
	 * Usermeta recording where the preference came from.
	 */
	public const META_OPTIN_SOURCE = 'texttango_sms_optin_source';

	/**
	 * Order meta mirroring the preference, so a guest order keeps its own record.
	 */
	public const ORDER_META_OPTIN = '_texttango_sms_optin';

	/**
	 * Order meta recording when that consent was captured.
	 */
	public const ORDER_META_OPTIN_AT = '_texttango_sms_optin_at';

	/**
	 * Order meta recording where it came from.
	 */
	public const ORDER_META_OPTIN_SOURCE = '_texttango_sms_optin_source';

	/**
	 * Events that send regardless of any preference or suppression.
	 *
	 * Each is either requested by the person receiving it in that moment, or
	 * addressed to the merchant's own staff. Blocking a login code because of a
	 * marketing preference would lock somebody out of their account.
	 *
	 * @var string[]
	 */
	public const ALWAYS_ALLOWED = array(
		'otp',
		'otp_2fa',
		'phone_verification',
		// Somebody mid-checkout who just clicked "send code" themselves. A
		// suppression or missing consent record must not be able to strand them
		// with no way to finish an order they are actively placing.
		'checkout_otp',
		// Somebody is standing at a login screen having just asked for this. A
		// suppression that swallows it locks them out of their own account, which
		// is a worse outcome than one more message to a number that opted out.
		'password_reset',
		'test',
		'new_order_admin',
		// Goes to the shop's own numbers, not a customer's. Asking a merchant to
		// consent to being told about their own form would be nonsense, and an
		// opt-out recorded against a staff number would silence the alerts they
		// switched on themselves.
		'form_submitted_admin',
	);

	/**
	 * Record what a user asked for.
	 *
	 * The policy layer, not the storage layer. When double opt-in is on, agreeing
	 * does not grant consent here — it starts a confirmation, and the state stays
	 * `pending` until the number answers. Putting that decision in this one method
	 * means no future caller can capture consent by a route that skips it, the same
	 * reasoning that keeps every send behind {@see \TextTango\Sending\Sender}.
	 *
	 * @param int    $user_id User id.
	 * @param bool   $granted Whether they agreed.
	 * @param string $source  Where it was captured, e.g. 'checkout'.
	 */
	public static function record_for_user( int $user_id, bool $granted, string $source ): void {
		if ( $user_id <= 0 ) {
			return;
		}

		if ( $granted && DoubleOptIn::is_enabled() ) {
			DoubleOptIn::start_for_user( $user_id, $source );

			return;
		}

		self::write_user( $user_id, $granted ? self::STATE_GRANTED : self::STATE_DENIED, $source );
	}

	/**
	 * Record what an order asked for.
	 *
	 * Kept on the order as well as the user so a guest checkout still carries its
	 * own consent artifact, and so the record reflects what was agreed at the time
	 * rather than whatever the account says today.
	 *
	 * @param WC_Order $order   Order the consent was given on.
	 * @param bool     $granted Whether they agreed.
	 * @param string   $source  Where it was captured.
	 */
	public static function record_for_order( WC_Order $order, bool $granted, string $source ): void {
		if ( $granted && DoubleOptIn::is_enabled() ) {
			// One confirmation covers the order and the account behind it, so this
			// does not fall through to the user path — that would text the customer
			// twice for a single tick of a single box.
			DoubleOptIn::start_for_order( $order, $source );

			return;
		}

		$state = $granted ? self::STATE_GRANTED : self::STATE_DENIED;

		self::write_order( $order, $state, $source );

		if ( $order->get_customer_id() > 0 ) {
			self::write_user( $order->get_customer_id(), $state, $source );
		}
	}

	/**
	 * Write a user's consent state, whatever decided it.
	 *
	 * The storage layer beneath {@see self::record_for_user()}. Callers that have
	 * already applied the policy — a confirmed double opt-in, say — come here
	 * directly so the decision is not reconsidered and the customer is not asked
	 * to confirm their own confirmation.
	 *
	 * @param int    $user_id User id.
	 * @param string $state   One of the STATE_* constants.
	 * @param string $source  Where it was captured.
	 */
	public static function write_user( int $user_id, string $state, string $source ): void {
		if ( $user_id <= 0 ) {
			return;
		}

		update_user_meta( $user_id, self::META_OPTIN, self::user_meta_value( $state ) );
		update_user_meta( $user_id, self::META_OPTIN_AT, gmdate( 'Y-m-d H:i:s' ) );
		update_user_meta( $user_id, self::META_OPTIN_SOURCE, sanitize_key( $source ) );

		/**
		 * Fires when a marketing preference is recorded.
		 *
		 * `$granted` is true only for confirmed consent, so a listener written
		 * before double opt-in existed still cannot mistake a pending request for
		 * permission. `$state` carries the fuller picture for anything that wants it.
		 *
		 * @param int    $user_id User id.
		 * @param bool   $granted Whether they may now be marketed to.
		 * @param string $source  Where it was captured.
		 * @param string $state   One of the STATE_* constants.
		 */
		do_action( 'texttango_consent_recorded', $user_id, self::STATE_GRANTED === $state, $source, $state );
	}

	/**
	 * Write an order's consent state, whatever decided it.
	 *
	 * @param WC_Order $order  Order the consent was given on.
	 * @param string   $state  One of the STATE_* constants.
	 * @param string   $source Where it was captured.
	 */
	public static function write_order( WC_Order $order, string $state, string $source ): void {
		$order->update_meta_data( self::ORDER_META_OPTIN, self::order_meta_value( $state ) );
		$order->update_meta_data( self::ORDER_META_OPTIN_AT, gmdate( 'Y-m-d H:i:s' ) );
		$order->update_meta_data( self::ORDER_META_OPTIN_SOURCE, sanitize_key( $source ) );
		$order->save();

		/**
		 * Fires when a preference is recorded against an order.
		 *
		 * Separate from the user hook because a guest has no account, and guests
		 * are most of the list on many stores — anything keyed only on users would
		 * silently ignore consent they actively gave.
		 *
		 * @param WC_Order $order   Order the consent was given on.
		 * @param bool     $granted Whether they may now be marketed to.
		 * @param string   $source  Where it was captured.
		 * @param string   $state   One of the STATE_* constants.
		 */
		do_action( 'texttango_order_consent_recorded', $order, self::STATE_GRANTED === $state, $source, $state );
	}

	/**
	 * Whether a user has agreed to marketing.
	 *
	 * Deliberately strict: only a confirmed grant passes, so a pending request
	 * reads as no permission everywhere this is asked.
	 *
	 * @param int $user_id User id.
	 */
	public static function user_has_consented( int $user_id ): bool {
		return self::STATE_GRANTED === self::state_for_user( $user_id );
	}

	/**
	 * A user's consent state.
	 *
	 * @param int $user_id User id.
	 */
	public static function state_for_user( int $user_id ): string {
		if ( $user_id <= 0 ) {
			return self::STATE_DENIED;
		}

		return self::read_state( (string) get_user_meta( $user_id, self::META_OPTIN, true ), '1' );
	}

	/**
	 * An order's consent state.
	 *
	 * @param WC_Order $order Order.
	 */
	public static function state_for_order( WC_Order $order ): string {
		return self::read_state( (string) $order->get_meta( self::ORDER_META_OPTIN, true ), 'yes' );
	}

	/**
	 * A state in words, for the profile screen and for a data-subject export.
	 *
	 * @param string $state One of the STATE_* constants.
	 */
	public static function state_label( string $state ): string {
		if ( self::STATE_GRANTED === $state ) {
			return __( 'Yes', 'texttango' );
		}

		if ( self::STATE_PENDING === $state ) {
			return __( 'Asked for, waiting for confirmation from the phone', 'texttango' );
		}

		return __( 'No', 'texttango' );
	}

	/**
	 * Read a stored value back into a state.
	 *
	 * Anything that is not recognisably a grant or a pending request is treated as
	 * no permission, so a value written by an older version, a half-finished import,
	 * or a hand edit can only ever fail closed.
	 *
	 * @param string $stored  Value as stored.
	 * @param string $granted The value that means yes for this store.
	 */
	private static function read_state( string $stored, string $granted ): string {
		if ( $granted === $stored ) {
			return self::STATE_GRANTED;
		}

		return self::STATE_PENDING === $stored ? self::STATE_PENDING : self::STATE_DENIED;
	}

	/**
	 * The stored value that means granted, for usermeta.
	 *
	 * Exposed because a `meta_query` compares against a literal and cannot call
	 * {@see self::state_for_user()}. Anything selecting consenting customers by
	 * query must ask for this rather than hard-coding `'1'`, so the two can
	 * never drift apart.
	 */
	public static function granted_meta_value(): string {
		return self::user_meta_value( self::STATE_GRANTED );
	}

	/**
	 * The stored value that means granted, for order meta.
	 */
	public static function granted_order_meta_value(): string {
		return self::order_meta_value( self::STATE_GRANTED );
	}

	/**
	 * How a state is stored in usermeta.
	 *
	 * `1` and `0` predate the pending state and are kept, so an install that
	 * upgrades does not silently lose every consent it had already collected.
	 *
	 * @param string $state One of the STATE_* constants.
	 */
	private static function user_meta_value( string $state ): string {
		if ( self::STATE_GRANTED === $state ) {
			return '1';
		}

		return self::STATE_PENDING === $state ? self::STATE_PENDING : '0';
	}

	/**
	 * How a state is stored in order meta.
	 *
	 * @param string $state One of the STATE_* constants.
	 */
	private static function order_meta_value( string $state ): string {
		if ( self::STATE_GRANTED === $state ) {
			return 'yes';
		}

		return self::STATE_PENDING === $state ? self::STATE_PENDING : 'no';
	}

	/**
	 * The consent artifact stored against a user, for display and export.
	 *
	 * @param int $user_id User id.
	 * @return array{granted: bool, state: string, at: string, source: string}
	 */
	public static function record_for( int $user_id ): array {
		$state = self::state_for_user( $user_id );

		return array(
			'granted' => self::STATE_GRANTED === $state,
			'state'   => $state,
			'at'      => (string) get_user_meta( $user_id, self::META_OPTIN_AT, true ),
			'source'  => (string) get_user_meta( $user_id, self::META_OPTIN_SOURCE, true ),
		);
	}

	/**
	 * Whether an event may be sent to a number.
	 *
	 * @param string   $event     Event id.
	 * @param string   $recipient E.164 recipient.
	 * @param int|null $user_id   Related user, when there is one.
	 * @return true|string True to send, or a short machine-readable reason not to.
	 */
	public static function decide( string $event, string $recipient, ?int $user_id = null ) {
		// Short-circuited before any lookup, so a login code never waits on a
		// suppression query. {@see self::evaluate()} checks this again, so the
		// rule stays complete when read on its own.
		if ( in_array( $event, self::ALWAYS_ALLOWED, true ) ) {
			return true;
		}

		$state = null !== $user_id ? self::state_for_user( $user_id ) : '';

		return self::evaluate(
			$event,
			Suppressions::contains( $recipient ),
			self::STATE_GRANTED === $state,
			ConsentList::contains( $recipient ),
			self::STATE_DENIED === $state
		);
	}

	/**
	 * The rule itself, with the lookups already done.
	 *
	 * Pure, so what may and may not be sent can be reasoned about — and tested —
	 * without a database behind it.
	 *
	 * The two later arguments default to false, so a caller that knows nothing
	 * about the consent list gets exactly the behaviour this had before it
	 * existed.
	 *
	 * @param string $event       Event id.
	 * @param bool   $suppressed  Whether the recipient is on the opt-out list.
	 * @param bool   $has_consent Whether marketing consent is on record for the account.
	 * @param bool   $listed      Whether the bare number is on the consent list.
	 * @param bool   $denied      Whether an account explicitly refused.
	 * @return true|string True to send, or a short machine-readable reason not to.
	 */
	public static function evaluate( string $event, bool $suppressed, bool $has_consent, bool $listed = false, bool $denied = false ) {
		if ( in_array( $event, self::ALWAYS_ALLOWED, true ) ) {
			return true;
		}

		// A hard stop outranks everything else, including service messages.
		if ( $suppressed ) {
			return 'suppressed';
		}

		if ( ! self::is_marketing( $event ) ) {
			// A service message about a transaction the customer entered into.
			return true;
		}

		if ( $has_consent ) {
			return true;
		}

		/*
		 * Checked before the list, and this ordering is the point of the whole
		 * rule. A row on the consent list came from somewhere else and nobody
		 * here watched it being given; somebody unticking the box on their own
		 * account did so in front of us. Without this, importing a stale
		 * subscriber list would start messaging the very people who had already
		 * said no in their account.
		 */
		if ( $denied ) {
			return 'no_marketing_consent';
		}

		if ( $listed ) {
			return true;
		}

		return 'no_marketing_consent';
	}

	/**
	 * Whether an event counts as marketing rather than a service message.
	 *
	 * Order lifecycle updates are service messages; anything promotional is not.
	 * Nothing marketing ships yet, so this exists to make the boundary explicit
	 * before Phase 4 starts adding to it.
	 *
	 * @param string $event Event id.
	 */
	public static function is_marketing( string $event ): bool {
		$marketing = str_starts_with( $event, 'marketing_' ) || str_starts_with( $event, 'campaign_' );

		/**
		 * Filters whether an event is marketing, and so requires opt-in.
		 *
		 * @param bool   $marketing Whether the event is marketing.
		 * @param string $event     Event id.
		 */
		return (bool) apply_filters( 'texttango_event_is_marketing', $marketing, $event );
	}
}
