<?php
/**
 * Record of donations that bypassed routing and reached GiveWP's default account.
 *
 * @since   1.2.0
 * @package DonationRouter
 */

declare( strict_types=1 );
namespace DonationRouter;

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

/**
 * Stores, per donation form, the last time routing fell back and why.
 *
 * Written from the payment path, so two properties matter more than accuracy:
 *
 *  1. It never throws. record() runs inside PayPal_Credential_Router's catch
 *     block; a recorder that could fail would turn a degraded donation into a
 *     broken one.
 *  2. It is throttled. A transient gate means a form misrouting every donation
 *     costs one write per hour, not one per donation.
 *
 * The throttle is why no event count is exposed: a recorder cannot count the
 * events it declined to write, and a count that really meant "hours in which at
 * least one fallback occurred" would be read as donations. What is measurable —
 * which form, which reason, when last seen — is what recent() returns.
 *
 * @since 1.2.0
 */
class Fallback_Log {

	/**
	 * Option holding the per-form entries. Not autoloaded.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	public const OPTION = 'ador_routing_fallbacks';

	/**
	 * No active routing rule matched the form.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	public const REASON_NO_RULE = 'no_rule';

	/**
	 * The routed account is deactivated.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	public const REASON_INACTIVE = 'inactive';

	/**
	 * The routed account's mode differs from the site's.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	public const REASON_MODE_MISMATCH = 'mode_mismatch';

	/**
	 * Anything else thrown while resolving credentials.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	public const REASON_ERROR = 'error';

	/**
	 * Transient prefix for the per-form write gate.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	private const GATE_PREFIX = 'ador_fallback_gate_';

	/**
	 * Record a fallback for a form, at most once per hour.
	 *
	 * Never throws.
	 *
	 * @since 1.2.0
	 *
	 * @param int    $form_id GiveWP donation form post ID.
	 * @param string $reason  One of the REASON_* constants.
	 *
	 * @return void
	 */
	public function record( int $form_id, string $reason ): void {
		try {
			if ( $form_id <= 0 ) {
				return;
			}

			$gate = self::GATE_PREFIX . $form_id;

			if ( false !== get_transient( $gate ) ) {
				return;
			}

			set_transient( $gate, '1', HOUR_IN_SECONDS );

			$entries             = $this->read();
			$entries[ $form_id ] = array(
				'last'   => time(),
				'reason' => $reason,
			);

			update_option( self::OPTION, $entries, false );
		} catch ( \Throwable $e ) {
			// Deliberately swallowed: this runs on the payment path, and the
			// worst acceptable outcome is no record.
			unset( $e );
		}
	}

	/**
	 * Entries from the last seven days, pruning anything older.
	 *
	 * Pruning happens here rather than in record() so the checkout path never
	 * pays for retention. The option is rewritten only when something changed.
	 *
	 * @since 1.2.0
	 *
	 * @return array<int, array{last:int, reason:string}> Keyed by form ID.
	 */
	public function recent(): array {
		$entries = $this->read();
		$cutoff  = time() - ( 7 * DAY_IN_SECONDS );
		$kept    = array();
		$changed = false;

		foreach ( $entries as $form_id => $entry ) {
			if ( ! is_array( $entry ) || ! isset( $entry['last'] ) || (int) $entry['last'] < $cutoff ) {
				$changed = true;
				continue;
			}

			$kept[ (int) $form_id ] = array(
				'last'   => (int) $entry['last'],
				'reason' => (string) ( $entry['reason'] ?? self::REASON_ERROR ),
			);
		}

		if ( $changed ) {
			update_option( self::OPTION, $kept, false );
		}

		return $kept;
	}

	/**
	 * The raw stored array, or an empty one when absent or corrupt.
	 *
	 * @since 1.2.0
	 *
	 * @return array<int|string, mixed>
	 */
	private function read(): array {
		$stored = get_option( self::OPTION, array() );

		return is_array( $stored ) ? $stored : array();
	}
}
