<?php
/**
 * Billing State Cache Manager.
 *
 * Phase 5: Maintains WordPress-side billing cache synced from middleware.
 *
 * @package SiteStaffr
 * @since 0.14.10
 */

if ( ! defined( 'WPINC' ) ) {
	die;
}

// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin table names are internally derived from $wpdb->prefix and values are placeholder-prepared.
class SiteStaffr_Billing_State {

	/**
	 * Cache option key.
	 */
	const CACHE_OPTION = 'sitestaffr_billing_cache';

	/**
	 * Default cache TTL in seconds.
	 */
	const DEFAULT_TTL = 300;

	/**
	 * Determine if billing cutover is enabled.
	 *
	 * @since 0.14.10
	 * @return bool
	 */
	public static function is_cutover_enabled() {
		return defined( 'SITESTAFFR_BILLING_CUTOVER' ) && SITESTAFFR_BILLING_CUTOVER;
	}

	/**
	 * Return billing cache (best effort).
	 *
	 * @since 0.14.10
	 * @return array
	 */
	public static function get_cache() {
		$cache = get_option( self::CACHE_OPTION, array() );
		return is_array( $cache ) ? $cache : array();
	}

	/**
	 * Check if subscription status indicates an inactive plan.
	 *
	 * @since 1.13.11
	 * @return bool True if plan is inactive (trial expired, canceled, unpaid, past due).
	 */
	public static function is_subscription_inactive() {
		$cache = self::get_cache();
		$status = isset( $cache['subscription_status'] ) ? $cache['subscription_status'] : '';

		$inactive_statuses = array( 'trial_expired', 'canceled', 'unpaid', 'past_due' );
		if ( in_array( $status, $inactive_statuses, true ) ) {
			return true;
		}

		if ( 'trial_active' === $status && ! empty( $cache['trial_expires_at'] ) ) {
			$expires = strtotime( $cache['trial_expires_at'] );
			if ( false !== $expires && time() > $expires ) {
				return true;
			}
		}

		if ( 'active' === $status ) {
			$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 && ( $included + $addon ) === 0 ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Refresh cache from middleware if stale (or forced).
	 *
	 * @since 0.14.10
	 * @param bool $force Force refresh regardless of age.
	 * @return bool True when cache is fresh after call.
	 */
	public static function maybe_refresh_cache( $force = false ) {
		// Allow forced refresh for admin display parity even when cutover is disabled.
		if ( ! self::is_cutover_enabled() && ! $force ) {
			return false;
		}

		$cache = self::get_cache();
		$ttl   = isset( $cache['cache_ttl'] ) ? absint( $cache['cache_ttl'] ) : self::DEFAULT_TTL;
		if ( $ttl <= 0 ) {
			$ttl = self::DEFAULT_TTL;
		}

		$last_synced = isset( $cache['last_synced_at'] ) ? strtotime( $cache['last_synced_at'] ) : 0;
		$is_stale    = ( time() - $last_synced ) > $ttl;

		if ( ! $force && ! $is_stale ) {
			return true;
		}

		$account_id      = get_option( 'sitestaffr_account_id', '' );
		$installation_id = get_option( 'sitestaffr_installation_id', '' );
		$site_url        = get_site_url();
		$api_secret      = self::get_api_secret();

		if ( empty( $api_secret ) || ( empty( $account_id ) && empty( $installation_id ) ) ) {
			return false;
		}

		$payload = array(
			'account_id'      => sanitize_text_field( $account_id ),
			'installation_id' => sanitize_text_field( $installation_id ),
			'site_url'        => esc_url_raw( $site_url ),
		);
		$payload_json  = wp_json_encode( $payload );
		$timestamp     = (string) time();
		$payload_to_sign = $timestamp . '.' . $payload_json;
		$signature_raw = hash_hmac( 'sha256', $payload_to_sign, $api_secret, true );
		$signature_b64 = base64_encode( $signature_raw );

		$response = wp_remote_post(
			rtrim( SITESTAFFR_MIDDLEWARE_URL, '/' ) . '/api/billing/account-state',
			array(
				'timeout' => 8,
				'headers' => array(
					'Content-Type'          => 'application/json',
					'X-SiteStaffr-Signature' => $signature_b64,
					'X-SiteStaffr-Timestamp' => $timestamp,
				),
				'body'    => $payload_json,
			)
		);

		if ( is_wp_error( $response ) ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Billing Cache: middleware refresh failed - ' . $response->get_error_message() );
			return false;
		}

		$code = wp_remote_retrieve_response_code( $response );
		if ( 200 !== $code ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Billing Cache: middleware refresh non-200 - ' . $code );
			return false;
		}

		$body = wp_remote_retrieve_body( $response );
		$data = json_decode( $body, true );
		if ( ! is_array( $data ) ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Billing Cache: middleware refresh invalid response' );
			return false;
		}

		$payload = $data;
		if ( isset( $data['success'] ) ) {
			if ( empty( $data['success'] ) ) {
				SiteStaffr_Logger::legacy( 'SiteStaffr Billing Cache: middleware refresh unsuccessful response' );
				return false;
			}
			if ( isset( $data['data'] ) && is_array( $data['data'] ) ) {
				$payload = $data['data'];
			}
		}

		self::update_cache_from_payload( $payload, 'middleware_pull' );
		self::maybe_send_depletion_email();

		/**
		 * Fires after the billing cache is successfully refreshed from the middleware.
		 *
		 * Agent-health alerts piggyback this rather than adding a cron: detection
		 * lands inside the existing TTL, which is ample for something a human takes
		 * hours to act on.
		 *
		 * @since 1.40.0
		 */
		do_action( 'sitestaffr_billing_cache_refreshed' );

		return true;
	}

	/**
	 * Update billing cache from middleware payload.
	 *
	 * @since 0.14.10
	 * @param array  $payload Middleware payload.
	 * @param string $source  Sync source label.
	 * @return void
	 */
	public static function update_cache_from_payload( $payload, $source = 'middleware_push' ) {
		if ( isset( $payload['data'] ) && is_array( $payload['data'] ) ) {
			$payload = $payload['data'];
		}

		self::maybe_sync_billing_identifiers( $payload );

		$has_included_seconds = isset( $payload['included_seconds_remaining'] );
		$has_addon_seconds    = isset( $payload['addon_seconds_remaining'] );
		$seconds_authoritative = $has_included_seconds || $has_addon_seconds;

		$included_seconds = $has_included_seconds ? (int) $payload['included_seconds_remaining'] : 0;
		$addon_seconds    = $has_addon_seconds ? (int) $payload['addon_seconds_remaining'] : 0;
		$included_minutes = isset( $payload['included_minutes_remaining'] ) ? (int) $payload['included_minutes_remaining'] : (int) floor( $included_seconds / 60 );
		$addon_minutes    = isset( $payload['addon_minutes_remaining'] ) ? (int) $payload['addon_minutes_remaining'] : (int) floor( $addon_seconds / 60 );

		// When second-level values are available from middleware, derive minute display
		// from seconds so UI does not overstate remaining balance near exhaustion.
		if ( $seconds_authoritative ) {
			$included_minutes = (int) floor( max( 0, $included_seconds ) / 60 );
			$addon_minutes    = (int) floor( max( 0, $addon_seconds ) / 60 );
		}

		$cache = array(
			'subscription_status'             => isset( $payload['subscription_status'] ) ? sanitize_text_field( $payload['subscription_status'] ) : 'trial_active',
			'plan_name'                       => isset( $payload['plan_name'] ) ? sanitize_text_field( $payload['plan_name'] ) : 'trial',
			'included_seconds_remaining'      => $included_seconds,
			'addon_seconds_remaining'         => $addon_seconds,
			'included_minutes_remaining'      => $included_minutes,
			'addon_minutes_remaining'         => $addon_minutes,
			'seconds_authoritative'           => $seconds_authoritative ? 1 : 0,
			'subscription_current_period_start' => isset( $payload['subscription_current_period_start'] ) ? sanitize_text_field( $payload['subscription_current_period_start'] ) : null,
			'subscription_current_period_end' => isset( $payload['subscription_current_period_end'] ) ? sanitize_text_field( $payload['subscription_current_period_end'] ) : null,
			'trial_started_at'                => isset( $payload['trial_started_at'] ) ? sanitize_text_field( $payload['trial_started_at'] ) : null,
			'trial_expires_at'                => isset( $payload['trial_expires_at'] ) ? sanitize_text_field( $payload['trial_expires_at'] ) : null,
			'scheduled_downgrade_plan'        => isset( $payload['scheduled_downgrade_plan'] ) ? sanitize_key( $payload['scheduled_downgrade_plan'] ) : null,
			// This cache is an explicit whitelist, so a field the middleware sends is DROPPED
			// unless it is listed here. Exempt sites are never paused by billing (the middleware's
			// checkEntitlement short-circuits on this flag), and without it the agent-health check
			// reports a false outage — which staging.sitestaffr.com did on 2026-08-11.
			// Strict `true` matches the middleware contract: a missing or non-boolean value means
			// NOT exempt, because failing open would silence real alerts for paying customers.
			'exempt_from_usage_limits'        => ( isset( $payload['exempt_from_usage_limits'] ) && true === $payload['exempt_from_usage_limits'] ),
			'last_synced_at'                  => gmdate( 'c' ),
			'cache_ttl'                       => self::DEFAULT_TTL,
			'source'                          => sanitize_text_field( $source ),
		);

		update_option( self::CACHE_OPTION, $cache, false );
		self::maybe_record_cycle_snapshot( $cache );
	}

	/**
	 * Return a balance array compatible with legacy Usage Manager output.
	 *
	 * @since 0.14.10
	 * @return array|null Null when unavailable.
	 */
	public static function get_minutes_balance() {
		if ( ! self::is_cutover_enabled() ) {
			return null;
		}

		self::maybe_refresh_cache( false );
		$cache = self::get_cache();
		if ( empty( $cache ) ) {
			return null;
		}

		$seconds_authoritative = ! empty( $cache['seconds_authoritative'] );
		if ( $seconds_authoritative ) {
			$included = isset( $cache['included_seconds_remaining'] ) ? (int) floor( max( 0, (int) $cache['included_seconds_remaining'] ) / 60 ) : 0;
			$addon    = isset( $cache['addon_seconds_remaining'] ) ? (int) floor( max( 0, (int) $cache['addon_seconds_remaining'] ) / 60 ) : 0;
		} else {
			$included = isset( $cache['included_minutes_remaining'] ) ? (int) $cache['included_minutes_remaining'] : 0;
			$addon    = isset( $cache['addon_minutes_remaining'] ) ? (int) $cache['addon_minutes_remaining'] : 0;
		}

		return array(
			'included'  => $included,
			'purchased' => $addon,
			'total'     => $included + $addon,
		);
	}

	/**
	 * Send a one-time email when voice minutes hit zero.
	 *
	 * Sets a flag to prevent re-sending. Clears the flag when minutes
	 * become available again (plan renewal, addon purchase).
	 *
	 * @since 1.13.8
	 */
	private static function maybe_send_depletion_email() {
		$balance = self::get_minutes_balance();
		if ( ! is_array( $balance ) ) {
			return;
		}

		$is_depleted = 0 === (int) $balance['total'];
		$email_sent  = (bool) get_option( 'sitestaffr_minutes_exhausted_email_sent', false );

		// Minutes replenished — clear the flag so it can fire again next cycle.
		if ( ! $is_depleted && $email_sent ) {
			delete_option( 'sitestaffr_minutes_exhausted_email_sent' );
			return;
		}

		// Not depleted or already sent — nothing to do.
		if ( ! $is_depleted || $email_sent ) {
			return;
		}

		// Determine recipient.
		$settings = get_option( 'sitestaffr_settings', array() );
		$to = ! empty( $settings['notification_email'] ) ? sanitize_email( $settings['notification_email'] ) : get_option( 'admin_email' );
		if ( empty( $to ) ) {
			return;
		}

		// Plan-aware messaging.
		$cache     = self::get_cache();
		$plan_key  = isset( $cache['plan_name'] ) ? sanitize_key( $cache['plan_name'] ) : 'starter';
		$site_name = get_bloginfo( 'name' );

		if ( 'pro' === $plan_key ) {
			$cta_line = 'Purchase add-on minutes from your Usage & Billing page to restore voice.';
		} else {
			$cta_line = 'Upgrade your plan from the Usage & Billing page to get more voice minutes.';
		}

		$subject = sprintf( '[%s] Voice AI minutes depleted', $site_name );
		$body    = sprintf(
			"Your voice AI minutes on %s have been used up.\n\n" .
			"Visitors are now seeing text-only chat instead of the voice widget.\n\n" .
			"%s\n\n" .
			"Usage & Billing: %s",
			$site_name,
			$cta_line,
			admin_url( 'admin.php?page=sitestaffr-usage' )
		);

		wp_mail( $to, $subject, $body );
		update_option( 'sitestaffr_minutes_exhausted_email_sent', true, false );
	}

	/**
	 * Resolve API secret used for middleware HMAC signing.
	 *
	 * @since 0.14.10
	 * @return string
	 */
	private static function get_api_secret() {
		$site_api_secret = get_option( 'sitestaffr_site_api_secret' );
		if ( ! empty( $site_api_secret ) ) {
			return $site_api_secret;
		}

		$global_api_secret = get_option( 'sitestaffr_api_secret' );
		if ( ! empty( $global_api_secret ) ) {
			return $global_api_secret;
		}

		return '';
	}

	/**
	 * Keep locally stored billing identifiers aligned with middleware-authoritative payloads.
	 *
	 * @since 0.14.327
	 * @param array $payload Middleware billing payload.
	 * @return void
	 */
	private static function maybe_sync_billing_identifiers( $payload ) {
		if ( ! is_array( $payload ) ) {
			return;
		}

		$incoming_account_id      = isset( $payload['account_id'] ) ? sanitize_text_field( $payload['account_id'] ) : '';
		$incoming_installation_id = isset( $payload['installation_id'] ) ? sanitize_text_field( $payload['installation_id'] ) : '';

		if ( ! empty( $incoming_account_id ) && self::is_valid_uuid_v4( $incoming_account_id ) ) {
			$existing_account_id = sanitize_text_field( get_option( 'sitestaffr_account_id', '' ) );
			if ( $existing_account_id !== $incoming_account_id ) {
				update_option( 'sitestaffr_account_id', $incoming_account_id, false );
				SiteStaffr_Logger::legacy(
					sprintf(
						'SiteStaffr Billing Cache: Updated local account_id from %s to %s based on middleware payload',
						$existing_account_id ? $existing_account_id : '(empty)',
						$incoming_account_id
					)
				);
			}
		}

		if ( ! empty( $incoming_installation_id ) && self::is_valid_uuid_v4( $incoming_installation_id ) ) {
			$existing_installation_id = sanitize_text_field( get_option( 'sitestaffr_installation_id', '' ) );
			if ( $existing_installation_id !== $incoming_installation_id ) {
				update_option( 'sitestaffr_installation_id', $incoming_installation_id, false );
				SiteStaffr_Logger::legacy(
					sprintf(
						'SiteStaffr Billing Cache: Updated local installation_id from %s to %s based on middleware payload',
						$existing_installation_id ? $existing_installation_id : '(empty)',
						$incoming_installation_id
					)
				);
			}
		}
	}

	/**
	 * Validate UUID v4 format.
	 *
	 * @since 0.14.327
	 * @param string $uuid UUID candidate.
	 * @return bool
	 */
	private static function is_valid_uuid_v4( $uuid ) {
		return is_string( $uuid ) && (bool) preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid );
	}

	/**
	 * Persist a billing cycle snapshot for historical analytics.
	 *
	 * @since 0.14.97
	 * @param array $cache Normalized billing cache payload.
	 * @return void
	 */
	private static function maybe_record_cycle_snapshot( $cache ) {
		if ( ! is_array( $cache ) ) {
			return;
		}

		$period_start = isset( $cache['subscription_current_period_start'] ) ? sanitize_text_field( $cache['subscription_current_period_start'] ) : '';
		$period_end   = isset( $cache['subscription_current_period_end'] ) ? sanitize_text_field( $cache['subscription_current_period_end'] ) : '';
		if ( empty( $period_start ) || empty( $period_end ) ) {
			return;
		}

		$period_start_ts = strtotime( $period_start );
		$period_end_ts   = strtotime( $period_end );
		if ( false === $period_start_ts || false === $period_end_ts || $period_end_ts < $period_start_ts ) {
			return;
		}

		global $wpdb;
		$table_name = $wpdb->prefix . 'phoneease_billing_cycle_snapshots';

		$table_exists = $wpdb->get_var(
			$wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) )
		);
		if ( $table_exists !== $table_name ) {
			return;
		}

		$latest_snapshot = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT plan_name, subscription_status, included_minutes_remaining, addon_minutes_remaining,
						included_seconds_remaining, addon_seconds_remaining, captured_at
				 FROM {$table_name}
				 WHERE period_start = %s AND period_end = %s
				 ORDER BY captured_at DESC
				 LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted plugin table name.
				gmdate( 'Y-m-d H:i:s', $period_start_ts ),
				gmdate( 'Y-m-d H:i:s', $period_end_ts )
			),
			ARRAY_A
		);

		$new_values = array(
			'plan_name'                  => isset( $cache['plan_name'] ) ? sanitize_key( $cache['plan_name'] ) : 'trial',
			'subscription_status'        => isset( $cache['subscription_status'] ) ? sanitize_key( $cache['subscription_status'] ) : 'trial_active',
			'included_minutes_remaining' => isset( $cache['included_minutes_remaining'] ) ? (int) $cache['included_minutes_remaining'] : 0,
			'addon_minutes_remaining'    => isset( $cache['addon_minutes_remaining'] ) ? (int) $cache['addon_minutes_remaining'] : 0,
			'included_seconds_remaining' => isset( $cache['included_seconds_remaining'] ) ? (int) $cache['included_seconds_remaining'] : 0,
			'addon_seconds_remaining'    => isset( $cache['addon_seconds_remaining'] ) ? (int) $cache['addon_seconds_remaining'] : 0,
		);

		if ( ! empty( $latest_snapshot ) ) {
			$latest_captured_ts = isset( $latest_snapshot['captured_at'] ) ? strtotime( $latest_snapshot['captured_at'] ) : false;
			$is_same_state = (
				isset( $latest_snapshot['plan_name'], $latest_snapshot['subscription_status'], $latest_snapshot['included_minutes_remaining'], $latest_snapshot['addon_minutes_remaining'], $latest_snapshot['included_seconds_remaining'], $latest_snapshot['addon_seconds_remaining'] ) &&
				sanitize_key( $latest_snapshot['plan_name'] ) === $new_values['plan_name'] &&
				sanitize_key( $latest_snapshot['subscription_status'] ) === $new_values['subscription_status'] &&
				(int) $latest_snapshot['included_minutes_remaining'] === $new_values['included_minutes_remaining'] &&
				(int) $latest_snapshot['addon_minutes_remaining'] === $new_values['addon_minutes_remaining'] &&
				(int) $latest_snapshot['included_seconds_remaining'] === $new_values['included_seconds_remaining'] &&
				(int) $latest_snapshot['addon_seconds_remaining'] === $new_values['addon_seconds_remaining']
			);
			// Skip noisy duplicate snapshots when nothing changed recently.
			if ( $is_same_state && false !== $latest_captured_ts && ( time() - $latest_captured_ts ) < 3600 ) {
				return;
			}
		}

		$wpdb->insert(
			$table_name,
			array(
				'period_start'               => gmdate( 'Y-m-d H:i:s', $period_start_ts ),
				'period_end'                 => gmdate( 'Y-m-d H:i:s', $period_end_ts ),
				'plan_name'                  => $new_values['plan_name'],
				'subscription_status'        => $new_values['subscription_status'],
				'included_seconds_remaining' => $new_values['included_seconds_remaining'],
				'addon_seconds_remaining'    => $new_values['addon_seconds_remaining'],
				'included_minutes_remaining' => $new_values['included_minutes_remaining'],
				'addon_minutes_remaining'    => $new_values['addon_minutes_remaining'],
				'source'                     => isset( $cache['source'] ) ? sanitize_text_field( $cache['source'] ) : 'middleware_sync',
				'captured_at'                => current_time( 'mysql' ),
			),
			array(
				'%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s',
			)
		);
	}
}
// phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
