<?php
/**
 * Computes whether the plugin is actually configured to route donations.
 *
 * @since   1.2.0
 * @package DonationRouter
 */

declare( strict_types=1 );
namespace DonationRouter;

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

/**
 * Derives the readiness panel's contents from real state.
 *
 * Every check reads the accounts table, the routing table, WordPress options or
 * the fallback log — nothing is remembered from a setup wizard. That is the
 * point: the failures worth catching (an account deactivated after setup, a
 * form unpublished, GiveWP flipped into test mode) all happen long after
 * anybody would have clicked through an onboarding flow.
 *
 * @since 1.2.0
 */
class Setup_Status {

	/**
	 * Account data source.
	 *
	 * @since 1.2.0
	 *
	 * @var Account_Repository_Interface
	 */
	private Account_Repository_Interface $accounts;

	/**
	 * Routing data source.
	 *
	 * @since 1.2.0
	 *
	 * @var Country_Routing_Repository_Interface
	 */
	private Country_Routing_Repository_Interface $routings;

	/**
	 * Fallback record.
	 *
	 * @since 1.2.0
	 *
	 * @var Fallback_Log
	 */
	private Fallback_Log $fallback_log;

	/**
	 * Memoised result of checks(), computed at most once per request.
	 *
	 * `checks()` is called up to four times per Accounts page load (directly,
	 * then again from is_ready() and twice from count_of()). Each run repeats
	 * an AES-GCM decrypt per account, a get_post_status() and a find_by_id()
	 * per routing rule, and Fallback_Log::recent() — including its pruning
	 * update_option(). Caching the first result avoids paying for that work
	 * repeatedly within a single request.
	 *
	 * @since 1.2.0
	 *
	 * @var Setup_Check[]|null
	 */
	private ?array $cached_checks = null;

	/**
	 * Transient caching whether the selector shortcode is placed anywhere.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	private const SHORTCODE_TRANSIENT = 'ador_selector_placed';

	/**
	 * Initialise with the data sources every check reads.
	 *
	 * @since 1.2.0
	 *
	 * @param Account_Repository_Interface         $accounts     Account data source.
	 * @param Country_Routing_Repository_Interface $routings     Routing data source.
	 * @param Fallback_Log|null                    $fallback_log Fallback record; defaults to a real one.
	 */
	public function __construct(
		Account_Repository_Interface $accounts,
		Country_Routing_Repository_Interface $routings,
		?Fallback_Log $fallback_log = null
	) {
		$this->accounts     = $accounts;
		$this->routings     = $routings;
		$this->fallback_log = $fallback_log ?? new Fallback_Log();
	}

	/**
	 * Every check, in setup order.
	 *
	 * Memoised: the result is computed at most once per request and reused on
	 * every subsequent call (see {@see Setup_Status::$cached_checks}).
	 *
	 * `find_all_active()` hydrates every account through decryption. If the
	 * site's AUTH_KEY or SECURE_AUTH_SALT has been rotated since the
	 * credentials were stored, that decrypt throws and would otherwise fatal
	 * this method before the panel can render at all — precisely the scenario
	 * {@see Setup_Status::check_account_credentials()} exists to explain. Both
	 * repository reads are therefore guarded; a failure degrades to a single
	 * explanatory FAIL rather than an unhandled exception.
	 *
	 * @since 1.2.0
	 *
	 * @return Setup_Check[]
	 */
	public function checks(): array {
		if ( null !== $this->cached_checks ) {
			return $this->cached_checks;
		}

		try {
			$accounts = $this->accounts->find_all_active();
			$rules    = $this->routings->find_all_active();
		} catch ( \Throwable $e ) {
			unset( $e );

			$this->cached_checks = array( $this->check_credentials_unreadable() );

			return $this->cached_checks;
		}

		$this->cached_checks = array(
			$this->check_has_account( $accounts ),
			$this->check_account_credentials( $accounts ),
			$this->check_account_mode( $accounts ),
			$this->check_has_routing( $rules ),
			$this->check_rules_resolve( $rules ),
			$this->check_rule_forms( $rules ),
			$this->check_webhook_registered( $accounts ),
			$this->check_selector_placed(),
			$this->check_legal_ack(),
			$this->check_recent_fallbacks(),
		);

		return $this->cached_checks;
	}

	/**
	 * Whether nothing is failing.
	 *
	 * @since 1.2.0
	 *
	 * @return bool
	 */
	public function is_ready(): bool {
		return 0 === $this->count_of( Setup_Check::FAIL );
	}

	/**
	 * How many checks are in a given state.
	 *
	 * @since 1.2.0
	 *
	 * @param string $state One of the Setup_Check constants.
	 *
	 * @return int
	 */
	public function count_of( string $state ): int {
		return count(
			array_filter(
				$this->checks(),
				static fn( Setup_Check $check ): bool => $state === $check->state
			)
		);
	}

	/**
	 * The plugin's admin URL, optionally on a specific tab and view.
	 *
	 * @since 1.2.0
	 *
	 * @param array<string, string|int> $args Extra query arguments.
	 *
	 * @return string
	 */
	private function url( array $args = array() ): string {
		return add_query_arg(
			array_merge(
				array(
					'post_type' => 'give_forms',
					'page'      => Admin_Settings::MENU_SLUG,
				),
				$args
			),
			admin_url( 'edit.php' )
		);
	}

	/**
	 * Check 1: at least one active PayPal account exists.
	 *
	 * @since 1.2.0
	 *
	 * @param PayPal_Account[] $accounts Active accounts.
	 *
	 * @return Setup_Check
	 */
	private function check_has_account( array $accounts ): Setup_Check {
		if ( array() === $accounts ) {
			return new Setup_Check(
				'has_account',
				Setup_Check::FAIL,
				__( 'PayPal account', 'alamin-donation-router' ),
				__( 'No active PayPal account is configured, so nothing can be routed.', 'alamin-donation-router' ),
				$this->url(
					array(
						'tab'  => Admin_Settings::TAB_ACCOUNTS,
						'view' => 'add',
					)
				),
				__( 'Add an account', 'alamin-donation-router' )
			);
		}

		return new Setup_Check(
			'has_account',
			Setup_Check::PASS,
			__( 'PayPal account', 'alamin-donation-router' ),
			sprintf(
				/* translators: %d: number of active PayPal accounts. */
				_n( '%d active account.', '%d active accounts.', count( $accounts ), 'alamin-donation-router' ),
				count( $accounts )
			)
		);
	}

	/**
	 * Check 2: every active account has usable credentials.
	 *
	 * An account whose stored credentials no longer decrypt — after a WordPress
	 * salt rotation, for instance — is currently discoverable only when a donor
	 * fails checkout.
	 *
	 * @since 1.2.0
	 *
	 * @param PayPal_Account[] $accounts Active accounts.
	 *
	 * @return Setup_Check
	 */
	private function check_account_credentials( array $accounts ): Setup_Check {
		$broken = array();

		foreach ( $accounts as $account ) {
			$credentials = $account->get_credentials();

			if ( empty( $credentials['clientId'] ) || empty( $credentials['clientSecret'] ) ) {
				$broken[] = $account;
			}
		}

		if ( array() === $broken ) {
			return new Setup_Check(
				'account_credentials',
				Setup_Check::PASS,
				__( 'Credentials', 'alamin-donation-router' ),
				__( 'Every active account has a Client ID and Secret.', 'alamin-donation-router' )
			);
		}

		$first = $broken[0];

		return new Setup_Check(
			'account_credentials',
			Setup_Check::FAIL,
			__( 'Credentials', 'alamin-donation-router' ),
			sprintf(
				/* translators: %s: account label. */
				__( '%s has no usable Client ID or Secret. If the site\'s AUTH_KEY or SECURE_AUTH_SALT changed, the stored credentials can no longer be decrypted and must be re-entered.', 'alamin-donation-router' ),
				$first->account_label
			),
			$this->url(
				array(
					'tab'        => Admin_Settings::TAB_ACCOUNTS,
					'view'       => 'edit',
					'account_id' => (int) $first->id,
				)
			),
			__( 'Edit account', 'alamin-donation-router' )
		);
	}

	/**
	 * Fallback for check 2 when accounts could not even be loaded.
	 *
	 * `find_all_active()` decrypts every stored credential. If the site's
	 * AUTH_KEY or SECURE_AUTH_SALT has changed since the credentials were
	 * saved, that decrypt throws before any check can run at all — the exact
	 * scenario {@see Setup_Status::check_account_credentials()} describes, just
	 * arriving as a fatal instead of a broken account. This produces the same
	 * FAIL under id `account_credentials` so the panel still tells the
	 * operator what happened instead of rendering nothing.
	 *
	 * @since 1.2.0
	 *
	 * @return Setup_Check
	 */
	private function check_credentials_unreadable(): Setup_Check {
		return new Setup_Check(
			'account_credentials',
			Setup_Check::FAIL,
			__( 'Credentials', 'alamin-donation-router' ),
			__( 'Stored PayPal credentials could not be decrypted. If the site\'s AUTH_KEY or SECURE_AUTH_SALT changed, the stored credentials can no longer be decrypted and must be re-entered.', 'alamin-donation-router' ),
			$this->url( array( 'tab' => Admin_Settings::TAB_ACCOUNTS ) ),
			__( 'Review accounts', 'alamin-donation-router' )
		);
	}

	/**
	 * Check 3: every active account matches the site's PayPal environment.
	 *
	 * @since 1.2.0
	 *
	 * @param PayPal_Account[] $accounts Active accounts.
	 *
	 * @return Setup_Check
	 */
	private function check_account_mode( array $accounts ): Setup_Check {
		$site_mode  = give_is_test_mode() ? 'sandbox' : 'live';
		$mismatched = array_values(
			array_filter(
				$accounts,
				static fn( PayPal_Account $account ): bool => $account->mode !== $site_mode
			)
		);

		if ( array() === $mismatched ) {
			return new Setup_Check(
				'account_mode',
				Setup_Check::PASS,
				__( 'Environment', 'alamin-donation-router' ),
				sprintf(
					/* translators: %s: the PayPal environment name (Live or Sandbox). */
					__( 'All active accounts are %s, matching GiveWP.', 'alamin-donation-router' ),
					$this->mode_label( $site_mode )
				)
			);
		}

		$first = $mismatched[0];

		return new Setup_Check(
			'account_mode',
			Setup_Check::FAIL,
			__( 'Environment', 'alamin-donation-router' ),
			sprintf(
				/* translators: 1: account label, 2: the account's PayPal environment name, 3: the site's PayPal environment name. */
				__( '%1$s is a %2$s account but GiveWP is in %3$s mode, so it is skipped and its donations reach GiveWP\'s default account instead.', 'alamin-donation-router' ),
				$first->account_label,
				$this->mode_label( $first->mode ),
				$this->mode_label( $site_mode )
			),
			$this->url(
				array(
					'tab'        => Admin_Settings::TAB_ACCOUNTS,
					'view'       => 'edit',
					'account_id' => (int) $first->id,
				)
			),
			__( 'Edit account', 'alamin-donation-router' )
		);
	}

	/**
	 * Translatable label for a PayPal environment mode.
	 *
	 * Interpolating the raw 'live'/'sandbox' storage value straight into a
	 * translated sentence leaves that word untranslated no matter how the rest
	 * of the sentence is rendered. This maps the stored value to a string a
	 * translator can render in place.
	 *
	 * @since 1.2.0
	 *
	 * @param string $mode 'live' or 'sandbox'.
	 *
	 * @return string
	 */
	private function mode_label( string $mode ): string {
		return 'sandbox' === $mode
			? __( 'Sandbox', 'alamin-donation-router' )
			: __( 'Live', 'alamin-donation-router' );
	}

	/**
	 * Check 4: at least one active routing rule exists.
	 *
	 * @since 1.2.0
	 *
	 * @param Country_Routing[] $rules Active rules.
	 *
	 * @return Setup_Check
	 */
	private function check_has_routing( array $rules ): Setup_Check {
		if ( array() === $rules ) {
			return new Setup_Check(
				'has_routing',
				Setup_Check::FAIL,
				__( 'Country routing', 'alamin-donation-router' ),
				__( 'No active routing rule exists, so every donation falls through to GiveWP\'s default PayPal account.', 'alamin-donation-router' ),
				$this->url(
					array(
						'tab'  => Admin_Settings::TAB_ROUTING,
						'view' => 'add',
					)
				),
				__( 'Add a routing rule', 'alamin-donation-router' )
			);
		}

		return new Setup_Check(
			'has_routing',
			Setup_Check::PASS,
			__( 'Country routing', 'alamin-donation-router' ),
			sprintf(
				/* translators: %d: number of active routing rules. */
				_n( '%d country routed.', '%d countries routed.', count( $rules ), 'alamin-donation-router' ),
				count( $rules )
			)
		);
	}

	/**
	 * Check 5: every active rule points at an account that can receive money.
	 *
	 * Deactivating an account is one click and does not touch the rules aimed
	 * at it; the rule keeps reporting itself as active.
	 *
	 * @since 1.2.0
	 *
	 * @param Country_Routing[] $rules Active rules.
	 *
	 * @return Setup_Check
	 */
	private function check_rules_resolve( array $rules ): Setup_Check {
		$broken = array();

		foreach ( $rules as $rule ) {
			try {
				$account = $this->accounts->find_by_id( $rule->account_id );

				if ( ! $account->is_active ) {
					$broken[] = $rule;
				}
			} catch ( \Throwable $e ) {
				unset( $e );
				$broken[] = $rule;
			}
		}

		if ( array() === $broken ) {
			return new Setup_Check(
				'rules_resolve',
				Setup_Check::PASS,
				__( 'Rule targets', 'alamin-donation-router' ),
				__( 'Every routing rule points at an active account.', 'alamin-donation-router' )
			);
		}

		$first = $broken[0];

		return new Setup_Check(
			'rules_resolve',
			Setup_Check::FAIL,
			__( 'Rule targets', 'alamin-donation-router' ),
			sprintf(
				/* translators: %s: ISO country code. */
				__( 'The rule for %s points at an account that is missing or deactivated, so those donations reach GiveWP\'s default account.', 'alamin-donation-router' ),
				$first->country_code
			),
			$this->url(
				array(
					'tab'        => Admin_Settings::TAB_ROUTING,
					'view'       => 'edit',
					'routing_id' => (int) $first->id,
				)
			),
			__( 'Edit rule', 'alamin-donation-router' )
		);
	}

	/**
	 * Check 6: every active rule points at a published donation form.
	 *
	 * @since 1.2.0
	 *
	 * @param Country_Routing[] $rules Active rules.
	 *
	 * @return Setup_Check
	 */
	private function check_rule_forms( array $rules ): Setup_Check {
		$broken = array_values(
			array_filter(
				$rules,
				static fn( Country_Routing $rule ): bool => 'publish' !== get_post_status( $rule->form_id )
			)
		);

		if ( array() === $broken ) {
			return new Setup_Check(
				'rule_forms',
				Setup_Check::PASS,
				__( 'Rule forms', 'alamin-donation-router' ),
				__( 'Every routing rule points at a published donation form.', 'alamin-donation-router' )
			);
		}

		$first = $broken[0];

		return new Setup_Check(
			'rule_forms',
			Setup_Check::FAIL,
			__( 'Rule forms', 'alamin-donation-router' ),
			sprintf(
				/* translators: %s: ISO country code. */
				__( 'The donation form for the %s rule is deleted or unpublished, so GiveWP has nothing to render for those donors.', 'alamin-donation-router' ),
				$first->country_code
			),
			$this->url(
				array(
					'tab'        => Admin_Settings::TAB_ROUTING,
					'view'       => 'edit',
					'routing_id' => (int) $first->id,
				)
			),
			__( 'Edit rule', 'alamin-donation-router' )
		);
	}

	/**
	 * Drop the cached shortcode-placement answer.
	 *
	 * Hooked to save_post and deleted_post in Plugin::boot_shared(): posts are
	 * saved from REST and WP-CLI as well as wp-admin, so an admin-only hook
	 * would keep reporting the shortcode missing after somebody added it.
	 *
	 * @since 1.2.0
	 *
	 * @return void
	 */
	public static function flush_shortcode_cache(): void {
		delete_transient( self::SHORTCODE_TRANSIENT );
	}

	/**
	 * Check 7: every active account has a webhook ID.
	 *
	 * @since 1.2.0
	 *
	 * @param PayPal_Account[] $accounts Active accounts.
	 *
	 * @return Setup_Check
	 */
	private function check_webhook_registered( array $accounts ): Setup_Check {
		$missing = array_values(
			array_filter(
				$accounts,
				static fn( PayPal_Account $account ): bool => null === $account->webhook_id || '' === $account->webhook_id
			)
		);

		if ( array() === $missing ) {
			return new Setup_Check(
				'webhook_registered',
				array() === $accounts ? Setup_Check::WARN : Setup_Check::PASS,
				__( 'Webhooks', 'alamin-donation-router' ),
				array() === $accounts
					? __( 'No accounts to register webhooks for yet.', 'alamin-donation-router' )
					: __( 'Every active account has a webhook ID.', 'alamin-donation-router' )
			);
		}

		$first = $missing[0];

		return new Setup_Check(
			'webhook_registered',
			Setup_Check::WARN,
			__( 'Webhooks', 'alamin-donation-router' ),
			sprintf(
				/* translators: %s: account label. */
				__( '%s has no webhook ID, so PayPal capture and refund events for it are never recorded.', 'alamin-donation-router' ),
				$first->account_label
			),
			$this->url(
				array(
					'tab'        => Admin_Settings::TAB_ACCOUNTS,
					'view'       => 'webhook',
					'account_id' => (int) $first->id,
				)
			),
			__( 'Webhook setup', 'alamin-donation-router' )
		);
	}

	/**
	 * Check 8: the country selector shortcode appears on a published page.
	 *
	 * Cached, because this is a LIKE scan over post content and the panel
	 * renders on every Accounts page load.
	 *
	 * @since 1.2.0
	 *
	 * @return Setup_Check
	 */
	private function check_selector_placed(): Setup_Check {
		$placed = get_transient( self::SHORTCODE_TRANSIENT );

		if ( false === $placed ) {
			global $wpdb;

			$like = '%' . $wpdb->esc_like( '[' . Country_Selector::SHORTCODE ) . '%';

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$found = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(1) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ('post', 'page') AND post_content LIKE %s",
					$like
				)
			);

			$placed = $found > 0 ? '1' : '0';

			set_transient( self::SHORTCODE_TRANSIENT, $placed, DAY_IN_SECONDS );
		}

		if ( '1' === $placed ) {
			return new Setup_Check(
				'selector_placed',
				Setup_Check::PASS,
				__( 'Country selector', 'alamin-donation-router' ),
				__( 'The selector shortcode is on a published page.', 'alamin-donation-router' )
			);
		}

		return new Setup_Check(
			'selector_placed',
			Setup_Check::WARN,
			__( 'Country selector', 'alamin-donation-router' ),
			sprintf(
				/* translators: %s: the shortcode. */
				__( 'No published post or page contains %s, so donors are never asked which country they are giving from.', 'alamin-donation-router' ),
				'[' . Country_Selector::SHORTCODE . ']'
			),
			$this->url( array( 'tab' => Admin_Settings::TAB_ROUTING ) ),
			__( 'Get the shortcode', 'alamin-donation-router' )
		);
	}

	/**
	 * Check 9: the legal review acknowledgement has been given.
	 *
	 * Advisory. Nothing in the payment or receipt path reads this option; it is
	 * surfaced here so the claim on the Settings tab is visible rather than
	 * enforced.
	 *
	 * @since 1.2.0
	 *
	 * @return Setup_Check
	 */
	private function check_legal_ack(): Setup_Check {
		if ( (bool) get_option( 'ador_receipt_legal_review_acknowledged', false ) ) {
			return new Setup_Check(
				'legal_ack',
				Setup_Check::PASS,
				__( 'Legal review', 'alamin-donation-router' ),
				__( 'Receipt templates and entity details are marked as reviewed.', 'alamin-donation-router' )
			);
		}

		return new Setup_Check(
			'legal_ack',
			Setup_Check::WARN,
			__( 'Legal review', 'alamin-donation-router' ),
			__( 'Nobody has confirmed that the receipt templates and tax statements were reviewed by a qualified adviser.', 'alamin-donation-router' ),
			$this->url( array( 'tab' => Admin_Settings::TAB_SETTINGS ) ),
			__( 'Review settings', 'alamin-donation-router' )
		);
	}

	/**
	 * Check 10: no donation has bypassed routing recently.
	 *
	 * @since 1.2.0
	 *
	 * @return Setup_Check
	 */
	private function check_recent_fallbacks(): Setup_Check {
		$recent = $this->fallback_log->recent();

		if ( array() === $recent ) {
			return new Setup_Check(
				'recent_fallbacks',
				Setup_Check::PASS,
				__( 'Live routing', 'alamin-donation-router' ),
				__( 'No donation has bypassed routing in the last seven days.', 'alamin-donation-router' )
			);
		}

		// array_key_first() would report the first-inserted entry, not the most
		// recent one; with several forms failing that can surface a days-old
		// event while a newer one exists. Sort by last-seen descending instead.
		uasort(
			$recent,
			static fn( array $a, array $b ): int => $b['last'] <=> $a['last']
		);

		$form_id = (int) array_key_first( $recent );
		$title   = (string) get_the_title( $form_id );

		return new Setup_Check(
			'recent_fallbacks',
			Setup_Check::WARN,
			__( 'Live routing', 'alamin-donation-router' ),
			sprintf(
				/* translators: 1: donation form title, 2: reason, 3: human-readable time difference. */
				__( 'Donations on "%1$s" reached GiveWP\'s default PayPal account (%2$s). Last seen %3$s ago.', 'alamin-donation-router' ),
				'' !== $title ? $title : (string) $form_id,
				$this->reason_label( $recent[ $form_id ]['reason'] ),
				human_time_diff( $recent[ $form_id ]['last'] )
			),
			$this->url( array( 'tab' => Admin_Settings::TAB_ROUTING ) ),
			__( 'Check routing', 'alamin-donation-router' )
		);
	}

	/**
	 * Human-readable text for a Fallback_Log reason code.
	 *
	 * @since 1.2.0
	 *
	 * @param string $reason One of the Fallback_Log REASON_* constants.
	 *
	 * @return string
	 */
	private function reason_label( string $reason ): string {
		switch ( $reason ) {
			case Fallback_Log::REASON_NO_RULE:
				return __( 'no active routing rule', 'alamin-donation-router' );
			case Fallback_Log::REASON_INACTIVE:
				return __( 'the routed account is deactivated', 'alamin-donation-router' );
			case Fallback_Log::REASON_MODE_MISMATCH:
				return __( 'the routed account is in the wrong environment', 'alamin-donation-router' );
			default:
				return __( 'credentials could not be resolved', 'alamin-donation-router' );
		}
	}
}
