<?php
/**
 * Spend ceilings and what gets shed when one is reached.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Sending;

use TextTango\Install\Schema;
use TextTango\Marketing\CampaignLog;
use TextTango\Messages\MessageLog;
use TextTango\Support\Clock;
use TextTango\Support\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Stops a site spending more than it meant to (spec §19).
 *
 * Two things this guards against, and they are not the same. One is a merchant
 * running out of credit mid-week. The other is a **runaway loop** — a hook
 * firing on every save, an import re-triggering order statuses, an integration
 * retrying in a circle. The second is the expensive one, because it can spend a
 * month's budget in an afternoon and nothing in the ordinary flow notices.
 *
 * ### Not everything is equally droppable
 *
 * When something has to give, it gives from the bottom up. A sign-in code is
 * somebody standing at a login form right now; dropping it locks them out of
 * their own account, and no cost ceiling is worth that. A marketing blast can
 * wait for tomorrow and nobody is harmed. Order updates sit between: worth
 * protecting, but not worth an unbounded bill.
 *
 * So critical messages are **never** subject to a cap or a low balance. That is
 * a deliberate hole in the ceiling, and it is the right one: the failure it
 * prevents is a person locked out, and the failure it allows is a handful of
 * codes beyond a limit that was approximate anyway.
 *
 * ### What a cap does when it bites
 *
 * The message is **skipped, not queued**. A cap is a safety net against spending
 * that should not be happening, and holding thousands of messages to release at
 * midnight would turn one bad afternoon into a bad night. Every skipped message
 * is recorded with the reason, so a merchant can see exactly what a cap cost
 * them and raise it if it was set too low.
 */
final class Budget {

	/**
	 * Somebody is waiting for this right now. Never held back.
	 */
	public const TIER_CRITICAL = 'critical';

	/**
	 * A service message about something the customer is already involved in.
	 */
	public const TIER_TRANSACTIONAL = 'transactional';

	/**
	 * Promotional. The first thing to go.
	 */
	public const TIER_MARKETING = 'marketing';

	/**
	 * Events nothing may hold back.
	 *
	 * @var string[]
	 */
	private const CRITICAL_EVENTS = array(
		'otp',
		'otp_2fa',
		'phone_verification',
		'optin_confirm',
		// Same reasoning as a sign-in code: a ceiling that silently swallows the
		// one code standing between a customer and finishing an order they are
		// actively placing is worse than the ceiling being crossed by one message.
		'checkout_otp',
		// Same reasoning as a sign-in code: a ceiling that silently swallows a
		// reset locks somebody out of their account.
		'password_reset',
		'test',
	);

	/**
	 * How urgent an event is.
	 *
	 * Pure, so the ordering everything else depends on can be read in one place
	 * and tested without a database.
	 *
	 * @param string $event Event id.
	 */
	public static function tier( string $event ): string {
		if ( in_array( $event, self::CRITICAL_EVENTS, true ) ) {
			return self::TIER_CRITICAL;
		}

		if ( str_starts_with( $event, 'marketing_' ) || str_starts_with( $event, 'campaign_' ) ) {
			return self::TIER_MARKETING;
		}

		return self::TIER_TRANSACTIONAL;
	}

	/**
	 * Whether a message may go, given what has already been spent.
	 *
	 * Pure. Every count and the balance are passed in, so the whole policy —
	 * which tier survives which pressure — is one readable function rather than
	 * something spread across queries.
	 *
	 * @param string             $tier  One of the TIER_* constants.
	 * @param array<string, int> $usage {
	 *     What has been sent, and the limits in force. A limit of 0 means none.
	 *
	 *     @type int $today            Messages sent today.
	 *     @type int $daily_cap        Daily ceiling.
	 *     @type int $month            Messages sent this calendar month.
	 *     @type int $monthly_cap      Monthly ceiling.
	 *     @type int $to_recipient     Messages sent to this number today.
	 *     @type int $recipient_cap    Per-number daily ceiling.
	 * }
	 * @param float              $balance      Last known account balance.
	 * @param float              $pause_below  Balance under which marketing pauses. 0 disables.
	 * @return true|string True to send, or a short machine-readable reason not to.
	 */
	public static function decide( string $tier, array $usage, float $balance = -1.0, float $pause_below = 0.0 ) {
		// The deliberate hole in the ceiling. See the class docblock: a code that
		// does not arrive locks somebody out, and that is worse than overspending
		// by a few messages against a limit that was always approximate.
		if ( self::TIER_CRITICAL === $tier ) {
			return true;
		}

		$usage = array_merge(
			array(
				'today'         => 0,
				'daily_cap'     => 0,
				'month'         => 0,
				'monthly_cap'   => 0,
				'to_recipient'  => 0,
				'recipient_cap' => 0,
			),
			$usage
		);

		/*
		 * Checked first, and applied to every non-critical tier, because this is
		 * the runaway-loop guard rather than a budget. A number receiving its
		 * twentieth order update today is not a busy shop, it is a bug.
		 */
		if ( $usage['recipient_cap'] > 0 && $usage['to_recipient'] >= $usage['recipient_cap'] ) {
			return 'recipient_cap';
		}

		if ( $usage['daily_cap'] > 0 && $usage['today'] >= $usage['daily_cap'] ) {
			return 'daily_cap';
		}

		if ( $usage['monthly_cap'] > 0 && $usage['month'] >= $usage['monthly_cap'] ) {
			return 'monthly_cap';
		}

		// Marketing goes first when the wallet runs low, so what credit is left
		// pays for order updates rather than promotions. A balance of -1 means
		// unknown, and an unknown balance pauses nothing.
		if ( self::TIER_MARKETING === $tier && $pause_below > 0.0 && $balance >= 0.0 && $balance < $pause_below ) {
			return 'low_balance';
		}

		return true;
	}

	/**
	 * Whether a message may go. The lookups, then {@see self::decide()}.
	 *
	 * @param string $event     Event id.
	 * @param string $recipient E.164 recipient.
	 * @return true|string
	 */
	public static function check( string $event, string $recipient ) {
		$tier = self::tier( $event );

		// Short-circuited before any query: a login code must never wait on three
		// counts of a table that may hold a million rows.
		if ( self::TIER_CRITICAL === $tier ) {
			return true;
		}

		$daily     = (int) Settings::get( 'spend_cap_daily', 0 );
		$monthly   = (int) Settings::get( 'spend_cap_monthly', 0 );
		$per_to    = (int) Settings::get( 'spend_cap_recipient', 0 );
		$pause_at  = (float) Settings::get( 'balance_pause_below', 0 );
		$has_limit = $daily > 0 || $monthly > 0 || $per_to > 0;

		if ( ! $has_limit && $pause_at <= 0.0 ) {
			return true;
		}

		return self::decide(
			$tier,
			array(
				'today'         => $daily > 0 ? self::billable_since( self::start_of_day() ) : 0,
				'daily_cap'     => $daily,
				'month'         => $monthly > 0 ? self::billable_since( self::start_of_month() ) : 0,
				'monthly_cap'   => $monthly,
				'to_recipient'  => $per_to > 0 ? self::billable_to( $recipient, self::start_of_day() ) : 0,
				'recipient_cap' => $per_to,
			),
			BalanceWatch::last_known(),
			$pause_at
		);
	}

	/**
	 * A readable explanation for a message a ceiling stopped.
	 *
	 * @param string $reason Machine-readable reason.
	 */
	public static function reason_text( string $reason ): string {
		$reasons = array(
			'recipient_cap' => __( 'This number has already had its daily limit of messages from this site.', 'texttango' ),
			'daily_cap'     => __( 'The daily message cap has been reached.', 'texttango' ),
			'monthly_cap'   => __( 'The monthly message cap has been reached.', 'texttango' ),
			'low_balance'   => __( 'Marketing is paused because the TextTango balance is low. Order updates and sign-in codes still send.', 'texttango' ),
		);

		return $reasons[ $reason ] ?? __( 'A sending limit stopped this message.', 'texttango' );
	}

	/**
	 * Whether a reason belongs to this module.
	 *
	 * @param string $reason Machine-readable reason.
	 */
	public static function owns_reason( string $reason ): bool {
		return in_array( $reason, array( 'recipient_cap', 'daily_cap', 'monthly_cap', 'low_balance' ), true );
	}

	/**
	 * How many messages have been billed since a moment.
	 *
	 * Counts what was actually handed to the platform. Skipped and sandboxed rows
	 * cost nothing, and counting them would let a cap be exhausted by messages
	 * that were never sent — which on a site with an aggressive opt-out list
	 * would silently stop everything.
	 *
	 * @param string $since UTC datetime.
	 */
	public static function billable_since( string $since ): int {
		global $wpdb;

		$table    = Schema::table_name();
		$statuses = self::billable_statuses();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table.
		$logged = (int) $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table name is interpolated, from $wpdb->prefix; the five placeholders match self::billable_statuses().
				"SELECT COUNT(*) FROM {$table} WHERE created_at >= %s AND sandbox = 0 AND status IN (%s, %s, %s, %s, %s)",
				$since,
				$statuses[0],
				$statuses[1],
				$statuses[2],
				$statuses[3],
				$statuses[4]
			)
		);

		/*
		 * Campaigns leave no rows in that table — the platform fans them out, so
		 * the individual sends never pass through this site. Counting them here
		 * is what stops a five thousand message blast being invisible to a five
		 * hundred a day cap, which would leave order updates flowing long after
		 * the budget they were meant to share had been spent.
		 */
		return $logged + CampaignLog::messages_since( $since );
	}

	/**
	 * How many messages one number has been billed since a moment.
	 *
	 * @param string $recipient E.164 recipient.
	 * @param string $since     UTC datetime.
	 */
	public static function billable_to( string $recipient, string $since ): int {
		global $wpdb;

		$table    = Schema::table_name();
		$statuses = self::billable_statuses();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table.
		return (int) $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table name is interpolated, from $wpdb->prefix; the five placeholders match self::billable_statuses().
				"SELECT COUNT(*) FROM {$table} WHERE recipient = %s AND created_at >= %s AND sandbox = 0 AND status IN (%s, %s, %s, %s, %s)",
				$recipient,
				$since,
				$statuses[0],
				$statuses[1],
				$statuses[2],
				$statuses[3],
				$statuses[4]
			)
		);
	}

	/**
	 * Midnight today, in the site's timezone, as a UTC datetime.
	 *
	 * The site's day, not UTC's, because a merchant who sets "500 a day" means
	 * their day. On a site far from UTC a cap that reset at the wrong hour would
	 * appear to bite in the middle of the afternoon.
	 */
	public static function start_of_day(): string {
		return self::to_utc( self::local_now()->setTime( 0, 0, 0 ) );
	}

	/**
	 * The first moment of this calendar month, in the site's timezone.
	 */
	public static function start_of_month(): string {
		$local = self::local_now();

		return self::to_utc(
			$local->setDate( (int) $local->format( 'Y' ), (int) $local->format( 'n' ), 1 )->setTime( 0, 0, 0 )
		);
	}

	/**
	 * Now, in the site's timezone.
	 *
	 * Read through {@see Clock} rather than from the system clock, so a test can
	 * move time across a day or month boundary without waiting for one.
	 */
	private static function local_now(): \DateTimeImmutable {
		return ( new \DateTimeImmutable( '@' . Clock::now() ) )->setTimezone( wp_timezone() );
	}

	/**
	 * Format a moment the way the log stores its timestamps.
	 *
	 * @param \DateTimeImmutable $moment Moment to convert.
	 */
	private static function to_utc( \DateTimeImmutable $moment ): string {
		return gmdate( 'Y-m-d H:i:s', $moment->getTimestamp() );
	}

	/**
	 * What has been spent, for the settings screen.
	 *
	 * @return array{today: int, month: int, daily_cap: int, monthly_cap: int}
	 */
	public static function usage(): array {
		return array(
			'today'       => self::billable_since( self::start_of_day() ),
			'month'       => self::billable_since( self::start_of_month() ),
			'daily_cap'   => (int) Settings::get( 'spend_cap_daily', 0 ),
			'monthly_cap' => (int) Settings::get( 'spend_cap_monthly', 0 ),
		);
	}

	/**
	 * Whether any ceiling is currently reached.
	 *
	 * Used by the admin notice, so a merchant finds out from the screen rather
	 * than from a customer asking why they were never told their order shipped.
	 */
	public static function paused_reason(): string {
		$verdict = self::decide(
			self::TIER_TRANSACTIONAL,
			array(
				'today'       => self::billable_since( self::start_of_day() ),
				'daily_cap'   => (int) Settings::get( 'spend_cap_daily', 0 ),
				'month'       => self::billable_since( self::start_of_month() ),
				'monthly_cap' => (int) Settings::get( 'spend_cap_monthly', 0 ),
			)
		);

		return true === $verdict ? '' : (string) $verdict;
	}

	/**
	 * Statuses that mean the platform was asked to send something.
	 *
	 * @return string[]
	 */
	public static function billable_statuses(): array {
		return array(
			MessageLog::STATUS_SENDING,
			MessageLog::STATUS_SENT,
			MessageLog::STATUS_DELIVERED,
			MessageLog::STATUS_FAILED,
			MessageLog::STATUS_TIMEOUT,
		);
	}
}
