<?php
/**
 * Main plugin orchestrator.
 *
 * @since   1.0.0
 * @package DonationRouter
 */

declare( strict_types=1 );
namespace DonationRouter;

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

/**
 * Bootstraps the plugin: registers hooks and service providers.
 *
 * @since 1.0.0
 */
class Plugin {

	use Singleton;

	/**
	 * Minimum GiveWP version the routing integration is built against.
	 *
	 * Below this, GiveWP lacks the PayPal Commerce container bindings, email-tag
	 * hooks, and REST plumbing the plugin depends on, so it refuses to boot.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	private const MIN_GIVE_VERSION = '3.0';

	/**
	 * Highest GiveWP major version the plugin has been verified against.
	 *
	 * Verified against GiveWP 4.16.1, so major 4 is the tested ceiling. A major
	 * above this is "untested": GiveWP may have changed the untyped
	 * PayPal/MerchantDetails contract the plugin extends (the class of change
	 * that has caused load-time fatals before), so the guard warns loudly.
	 *
	 * @since 1.0.0
	 *
	 * @var int
	 */
	private const TESTED_UP_TO_GIVE_MAJOR = 4;

	/**
	 * Kick off plugin internals.
	 *
	 * @since 1.0.0
	 */
	private function __construct() {}

	/**
	 * Register core hooks.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function boot(): void {
		register_activation_hook( ADOR_FILE, array( self::class, 'on_activation' ) );
		register_deactivation_hook( ADOR_FILE, array( self::class, 'on_deactivation' ) );

		add_action( 'plugins_loaded', array( $this, 'register_components' ), 20 );
	}

	/**
	 * Verify GiveWP is active and bootstrap shared, admin, and frontend components.
	 *
	 * Runs on the plugins_loaded hook — GIVE_VERSION is only guaranteed to be
	 * defined once every active plugin's main file has run, and this is the
	 * earliest point where that's true regardless of plugin load order (the
	 * array order of the active_plugins option, which WordPress does not sort
	 * by dependency).
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function register_components(): void {
		$version = defined( 'GIVE_VERSION' ) ? (string) GIVE_VERSION : null;
		$status  = self::give_support_status( $version );

		// GiveWP absent or older than the minimum: refuse to boot. Donations
		// fall through to GiveWP's own gateway handling.
		if ( 'missing' === $status || 'too_old' === $status ) {
			add_action( 'admin_notices', array( self::class, 'notice_missing_givewp' ) );
			return;
		}

		// GiveWP newer than the tested ceiling: fail loud but keep routing
		// active, so a compatible major is not silently disabled (which would
		// misroute every donation to the default account). Site owners who
		// prefer to hard-disable routing until they have re-verified can opt in.
		if ( 'untested' === $status ) {
			add_action( 'admin_notices', array( self::class, 'notice_untested_givewp' ) );

			/**
			 * Filter whether to refuse booting routing on an untested GiveWP major.
			 *
			 * Defaults to false (fail-open: warn but keep multi-account routing
			 * active). Return true to fail closed — routing is not registered and
			 * every donation falls back to GiveWP's default PayPal account until
			 * the plugin is confirmed compatible.
			 *
			 * @since 1.1.0
			 *
			 * @param bool   $block   Whether to skip booting routing. Default false.
			 * @param string $version The detected GiveWP version.
			 */
			if ( (bool) apply_filters( 'ador_block_on_untested_givewp', false, (string) $version ) ) {
				return;
			}
		}

		// Apply any pending schema migration when the plugin was updated in place
		// — a file replace, `wp plugin update`, or a bulk update never fires the
		// activation hook, so the DB would otherwise stay on the old schema until
		// a manual deactivate/reactivate. Registered here (not in boot_admin) so
		// the hook is wired in every context; the callback itself is admin-only.
		add_action( 'admin_init', array( self::class, 'maybe_upgrade_schema' ) );

		require_once ADOR_PATH . 'includes/functions/functions-icons.php';

		$this->boot_shared();

		if ( is_admin() ) {
			$this->boot_admin();
		} else {
			$this->boot_frontend();
		}
	}

	/**
	 * Classify a GiveWP version against the plugin's supported range.
	 *
	 * Pure decision function (no WordPress calls, version passed in) so the
	 * compatibility policy is unit-testable without redefining the GIVE_VERSION
	 * constant.
	 *
	 * @since 1.1.0
	 *
	 * @param string|null $version The GiveWP version string, or null when GiveWP is inactive.
	 *
	 * @return string One of: `missing` (GiveWP inactive), `too_old` (below the
	 *                minimum), `untested` (a major above the tested ceiling), or
	 *                `supported`.
	 */
	public static function give_support_status( ?string $version ): string {
		if ( null === $version || '' === $version ) {
			return 'missing';
		}

		if ( version_compare( $version, self::MIN_GIVE_VERSION, '<' ) ) {
			return 'too_old';
		}

		if ( (int) $version > self::TESTED_UP_TO_GIVE_MAJOR ) {
			return 'untested';
		}

		return 'supported';
	}

	/**
	 * Initialise components required in every request context (admin and frontend).
	 *
	 * GiveWP's email tags fire on `init` at priority -999, so their hooks must
	 * be registered before that point — i.e. during plugin initialisation.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	private function boot_shared(): void {
		global $wpdb;

		$account_repo       = new Account_Repository( $wpdb );
		$routing_repo       = new Country_Routing_Repository( $wpdb );
		$numbering          = new Receipt_Numbering();
		$receipt_customizer = new Receipt_Customizer( $account_repo, $numbering );

		$receipt_customizer->register_hooks();

		// Posts are saved from REST and WP-CLI as well as wp-admin, so the
		// shortcode-placement cache is invalidated here rather than in the
		// admin-only boot path.
		add_action( 'save_post', array( Setup_Status::class, 'flush_shortcode_cache' ) );
		add_action( 'deleted_post', array( Setup_Status::class, 'flush_shortcode_cache' ) );

		/*
		 * Runtime donation-routing components MUST register in every request
		 * context. GiveWP dispatches its PayPal Commerce create-order and
		 * approve-order actions over admin-ajax (wp_ajax_*), where is_admin()
		 * is always true, and delivers webhooks over the REST API. Gating any
		 * of these behind is_admin()===false (i.e. the old boot_frontend path)
		 * silently disabled multi-account routing: every donation fell through
		 * to GiveWP's default PayPal account regardless of the donor's country.
		 */
		$credential_router = new PayPal_Credential_Router( $routing_repo, $account_repo );
		$form_renderer     = new Form_Renderer( $routing_repo );
		$verifier          = new PayPal_Webhook_Signature_Verifier();
		$webhook_router    = new Webhook_Router( $account_repo, $verifier, $credential_router );

		$credential_router->register_hooks();
		$form_renderer->register_hooks();
		$webhook_router->register_hooks();

		// Boot PDF handler only when the feature is enabled and dompdf is installed.
		if ( get_option( 'ador_pdf_receipts_enabled', false ) && class_exists( \Dompdf\Dompdf::class ) ) {
			try {
				$pdf_generator = new Receipt_PDF_Generator( $account_repo );
				$pdf_handler   = new Receipt_PDF_Handler( $pdf_generator );
				$pdf_handler->register_hooks();
			} catch ( \RuntimeException $e ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
				error_log( '[ADOR] PDF receipts disabled: ' . $e->getMessage() );
			}
		}
	}

	/**
	 * Initialise frontend-only components and register their hooks.
	 *
	 * Only the donor-facing country-selector modal lives here — it renders via
	 * shortcode on public donation pages. All money-path routing is registered
	 * in boot_shared() so it also runs on admin-ajax and REST requests.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	private function boot_frontend(): void {
		global $wpdb;

		$routing_repo = new Country_Routing_Repository( $wpdb );
		$geo          = new Geolocation_Hint();
		$selector     = new Country_Selector( $routing_repo, $geo );

		$selector->register_hooks();
	}

	/**
	 * Initialise admin-only components and register their hooks.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	private function boot_admin(): void {
		global $wpdb;

		$account_repo    = new Account_Repository( $wpdb );
		$routing_repo    = new Country_Routing_Repository( $wpdb );
		$onboarding      = new Onboarding_Handler( $account_repo );
		$routing_handler = new Country_Routing_Handler( $routing_repo, $account_repo );
		$admin_settings  = new Admin_Settings( $account_repo, $onboarding, $routing_repo );
		$connect_handler = new PayPal_Connect_Handler( $account_repo );

		$admin_settings->register_hooks();
		$onboarding->register_hooks();
		$routing_handler->register_hooks();
		$connect_handler->register_hooks();
		( new PayPal_Connect_Redirect_Handler( $connect_handler ) )->register_hooks();

		add_filter( 'plugin_action_links_' . ADOR_BASENAME, array( $this, 'add_action_links' ) );
		add_filter( 'plugin_row_meta', array( $this, 'add_row_meta' ), 10, 2 );
	}

	/**
	 * Append a Settings link to the plugin's action links on the Plugins screen.
	 *
	 * @since 1.0.0
	 *
	 * @param array<int|string, string> $links Existing action links.
	 *
	 * @return array<int|string, string>
	 */
	public function add_action_links( array $links ): array {
		$settings_url = add_query_arg(
			array(
				'post_type' => 'give_forms',
				'page'      => Admin_Settings::MENU_SLUG,
			),
			admin_url( 'edit.php' )
		);

		array_unshift(
			$links,
			sprintf(
				'<a href="%s">%s</a>',
				esc_url( $settings_url ),
				esc_html__( 'Settings', 'alamin-donation-router' )
			)
		);

		return $links;
	}

	/**
	 * Append documentation and support links to the plugin's row meta.
	 *
	 * @since 1.0.0
	 *
	 * @param array<int|string, string> $links       Existing row meta links.
	 * @param string                    $plugin_file Plugin basename being rendered.
	 *
	 * @return array<int|string, string>
	 */
	public function add_row_meta( array $links, string $plugin_file ): array {
		if ( ADOR_BASENAME !== $plugin_file ) {
			return $links;
		}

		$links[] = sprintf(
			'<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
			esc_url( 'https://github.com/mralaminahamed/donation-router-for-givewp-paypal-donations/wiki' ),
			esc_html__( 'Documentation', 'alamin-donation-router' )
		);

		$links[] = sprintf(
			'<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
			esc_url( 'https://github.com/mralaminahamed/donation-router-for-givewp-paypal-donations/issues' ),
			esc_html__( 'Support', 'alamin-donation-router' )
		);

		return $links;
	}

	/**
	 * Handle plugin activation: install DB schema and flush rewrite rules.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public static function on_activation(): void {
		Database_Installer::install();
		flush_rewrite_rules();
	}

	/**
	 * Run the schema migration after an in-place plugin update.
	 *
	 * Hooked on `admin_init`. Updating the plugin by replacing its files
	 * (`wp plugin update`, the bulk updater, or an SFTP overwrite) does not fire
	 * `register_activation_hook`, so the activation-time migration never runs and
	 * the tables can lag behind the shipped schema. Database_Installer::install()
	 * short-circuits on a `version_compare` when the stored schema version is
	 * already current, so this is a cheap check on every admin request and
	 * performs the one-time DDL only on the first admin page load after an update.
	 *
	 * @since 1.1.0
	 *
	 * @return void
	 */
	public static function maybe_upgrade_schema(): void {
		Database_Installer::install();
	}

	/**
	 * Handle plugin deactivation.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public static function on_deactivation(): void {
		flush_rewrite_rules();
	}

	/**
	 * Handle plugin uninstall (called from uninstall.php).
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public static function on_uninstall(): void {
		Database_Installer::uninstall();
	}

	/**
	 * Show an admin notice when GiveWP is missing or too old.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public static function notice_missing_givewp(): void {
		echo '<div class="notice notice-error"><p>' .
			esc_html__( 'Donation Router for GiveWP & PayPal requires GiveWP 3.0 or higher.', 'alamin-donation-router' ) .
			'</p></div>';
	}

	/**
	 * Show an admin notice when the active GiveWP major is newer than the tested
	 * ceiling. Routing stays active (unless disabled via the
	 * `ador_block_on_untested_givewp` filter); the notice asks the admin to
	 * verify donations and update the plugin.
	 *
	 * @since 1.1.0
	 *
	 * @return void
	 */
	public static function notice_untested_givewp(): void {
		$version = defined( 'GIVE_VERSION' ) ? (string) GIVE_VERSION : '';

		echo '<div class="notice notice-warning"><p>' .
			sprintf(
				/* translators: 1: detected GiveWP version, 2: highest tested GiveWP major version. */
				esc_html__( 'Donation Router for GiveWP & PayPal has been tested up to GiveWP %2$s.x but detected GiveWP %1$s. Multi-account routing is still active — please verify donations and receipts, and update the plugin if you encounter issues.', 'alamin-donation-router' ),
				esc_html( $version ),
				esc_html( (string) self::TESTED_UP_TO_GIVE_MAJOR )
			) .
			'</p></div>';
	}
}
