<?php
/**
 * Agent Health Alerts
 *
 * Tells the owner by email when their agent has stopped answering visitors.
 *
 * This exists because a paused agent looks like a working one. Visitors still see
 * the contact form, so messages keep arriving and nothing appears broken — the
 * agent can sit paused for weeks while the owner reads the quiet as a slow month.
 * Today that state is visible only in wp-admin, which the client it matters most
 * for never opens.
 *
 * The two decision functions here are pure — a cache array in, a string out — so
 * the whole matrix is testable without WordPress. Only maybe_alert() touches
 * options or sends anything.
 *
 * @link       https://github.com/sitestaffr/sitestaffr-plugin
 * @since      1.40.0
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Decide when the owner needs to hear that their agent stopped answering.
 *
 * @since 1.40.0
 */
class SiteStaffr_Agent_Health_Alerts {

	/**
	 * Payment has failed or the subscription ended.
	 *
	 * @var string
	 */
	const STATE_PAYMENT = 'payment';

	/**
	 * The trial is over.
	 *
	 * @var string
	 */
	const STATE_TRIAL = 'trial';

	/**
	 * Subscription is fine; the minutes for this cycle are used up.
	 *
	 * @var string
	 */
	const STATE_MINUTES = 'minutes';

	/**
	 * The agent is answering.
	 *
	 * @var string
	 */
	const STATE_HEALTHY = 'healthy';

	/**
	 * The owner cancelled on purpose. The agent is stopped, but this is not a fault
	 * and needs no email — they know why it stopped and there is nothing to fix.
	 *
	 * Deliberately NOT mapped to STATE_HEALTHY: healthy would send a false "your agent
	 * is back" all-clear to anyone who went past_due and then cancelled.
	 *
	 * @since 1.40.3
	 * @var string
	 */
	const STATE_CANCELED = 'canceled';

	/**
	 * Variant sent when the agent starts answering again.
	 *
	 * @var string
	 */
	const VARIANT_ALL_CLEAR = 'all_clear';

	/**
	 * Option holding the cause we last emailed about, or '' when healthy.
	 *
	 * @var string
	 */
	const OPTION_LAST_CAUSE = 'sitestaffr_health_last_alerted_cause';

	/**
	 * Classify the billing cache into a health state.
	 *
	 * Mirrors the branch order of SiteStaffr_Billing_State::is_subscription_inactive()
	 * so this can never disagree with the dashboard about whether the agent is
	 * paused — it only splits that method's single "inactive" answer by cause,
	 * because the remedy differs per cause.
	 *
	 * Anything unreadable returns healthy. A false alert tells a paying customer
	 * their agent is down when it is not, which costs more than a late one.
	 *
	 * @since  1.40.0
	 * @param  array $cache The billing state cache.
	 * @return string       One of the STATE_* constants.
	 */
	public static function evaluate( $cache ) {
		if ( ! is_array( $cache ) ) {
			return self::STATE_HEALTHY;
		}

		$status = isset( $cache['subscription_status'] ) ? (string) $cache['subscription_status'] : '';

		// Exempt sites are never paused by billing: checkEntitlement() short-circuits on
		// exempt_from_usage_limits and returns allowed, whatever the subscription says. Telling
		// a comped customer their agent is down — when it is answering — is the worst kind of
		// false alert. Strict `true` only: a cache that serialized the flag as "0"/"false" must
		// not silence a REAL outage for a paying customer.
		if ( isset( $cache['exempt_from_usage_limits'] ) && true === $cache['exempt_from_usage_limits'] ) {
			return self::STATE_HEALTHY;
		}

		// A VOLUNTARY cancellation is not a payment failure. The middleware already draws this
		// distinction: routes/billing.js sets 'unpaid' when payment retries are exhausted (we
		// stopped them) and 'canceled' when the customer chose to leave. Only the former is a
		// problem the owner can fix, so only the former earns an email.
		if ( 'canceled' === $status ) {
			return self::STATE_CANCELED;
		}

		if ( in_array( $status, array( 'unpaid', 'past_due' ), true ) ) {
			return self::STATE_PAYMENT;
		}

		if ( 'trial_expired' === $status ) {
			return self::STATE_TRIAL;
		}

		// A trial that lapsed mid-cycle still reads 'trial_active' until something
		// refreshes it, so the date is authoritative here, not the status string.
		if ( 'trial_active' === $status && ! empty( $cache['trial_expires_at'] ) ) {
			$expires = strtotime( (string) $cache['trial_expires_at'] );
			if ( false !== $expires && time() > $expires ) {
				return self::STATE_TRIAL;
			}
		}

		if ( 'active' === $status ) {
			// -1 means "not reported". Absent seconds must never be read as zero,
			// or a cache that simply omits the field reports a false outage.
			$included = isset( $cache['included_seconds_remaining'] ) ? (int) $cache['included_seconds_remaining'] : -1;
			$addon    = isset( $cache['addon_seconds_remaining'] ) ? (int) $cache['addon_seconds_remaining'] : -1;
			if ( $included >= 0 && $addon >= 0 && 0 === ( $included + $addon ) ) {
				return self::STATE_MINUTES;
			}
		}

		return self::STATE_HEALTHY;
	}

	/**
	 * Decide which email to send, given the current state and the last one alerted.
	 *
	 * The "once" guarantee lives here: an unhealthy state that matches the stored
	 * cause returns '' and nothing is sent. A *different* cause does send, because
	 * past_due escalating to canceled is a different problem with a different fix —
	 * a deliberate exception to "once", not an oversight.
	 *
	 * @since  1.40.0
	 * @param  string $current The state from evaluate().
	 * @param  string $stored  The cause last alerted, or '' if none.
	 * @return string          Variant to send, or '' to stay silent.
	 */
	public static function decide( $current, $stored ) {
		$current = (string) $current;
		$stored  = (string) $stored;

		// A voluntary cancellation is silent from every prior state. It is not an all-clear
		// either — the agent really is stopped, it just stopped on purpose.
		if ( self::STATE_CANCELED === $current ) {
			return '';
		}

		if ( self::STATE_HEALTHY === $current ) {
			return ( '' !== $stored ) ? self::VARIANT_ALL_CLEAR : '';
		}

		return ( $current === $stored ) ? '' : $current;
	}

	/**
	 * Check the agent's health and email the owner if that has changed.
	 *
	 * The only impure method here. Hooked to `sitestaffr_billing_cache_refreshed`
	 * so detection rides the existing cache TTL rather than a new cron.
	 *
	 * @since  1.40.0
	 * @return void
	 */
	public static function maybe_alert() {
		if ( ! class_exists( 'SiteStaffr_Billing_State' ) || ! class_exists( 'SiteStaffr_Email_Notifications' ) ) {
			return;
		}

		$current = self::evaluate( SiteStaffr_Billing_State::get_cache() );
		$stored  = (string) get_option( self::OPTION_LAST_CAUSE, '' );
		$variant = self::decide( $current, $stored );

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

		$notifier = new SiteStaffr_Email_Notifications();
		$sent     = $notifier->send_health_alert( $variant );

		// Persist only after the send is accepted. A failed send retries on the
		// next refresh — the opposite of how notification-scope fails, because
		// here a missed alert is the entire cost of the feature. This is also
		// what makes the plugin safe to ship before the middleware route exists:
		// the call simply fails, nothing is recorded, and it tries again.
		if ( ! $sent ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Health: alert send failed - will retry on next billing refresh' );
			return;
		}

		if ( self::VARIANT_ALL_CLEAR === $variant ) {
			delete_option( self::OPTION_LAST_CAUSE );
		} else {
			update_option( self::OPTION_LAST_CAUSE, $current, false );
		}
	}
}
