<?php

/**
 * Stripe Connect Class
 *
 * @package     AffiliateWP Stripe Payouts
 * @subpackage  Classes/Stripe Connect
 * @copyright   Copyright (c) 2025, AffiliateWP, LLC
 * @license     GPL-2.0+
 * @since       2.29.0
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * AffiliateWP_Stripe_Connect Class
 *
 * @since 2.29.0
 */
class AffiliateWP_Stripe_Connect {


	/**
	 * Singleton instance
	 *
	 * @since 2.29.0
	 * @var AffiliateWP_Stripe_Connect
	 */
	private static $instance;

	/**
	 * Main AffiliateWP_Stripe_Connect Instance
	 *
	 * Ensures only one instance of AffiliateWP_Stripe_Connect exists at any one time.
	 *
	 * @since 2.29.0
	 * @static
	 * @return AffiliateWP_Stripe_Connect The one true AffiliateWP_Stripe_Connect
	 */
	public static function instance() {
		if ( ! isset( self::$instance ) && ! ( self::$instance instanceof AffiliateWP_Stripe_Connect ) ) {
			self::$instance = new AffiliateWP_Stripe_Connect();
			self::$instance->init();
		}

		return self::$instance;
	}

	/**
	 * Initialize the class
	 *
	 * @since 2.29.0
	 * @return void
	 */
	public function init() {
		// Admin.
		add_action( 'admin_init', [ $this, 'process_admin_connect' ] );
		add_action( 'admin_init', [ $this, 'process_admin_disconnect' ] );

		// Frontend.
		add_action( 'init', [ $this, 'process_affiliate_connect' ] );
		add_action( 'template_redirect', [ $this, 'process_affiliate_disconnect' ] );
	}

	/**
	 * Process admin connection to Stripe
	 *
	 * @since 2.29.0
	 * @return void
	 */
	public function process_admin_connect() {
		// Check if we're on the settings page.
		if ( ! isset( $_GET['page'] ) || $_GET['page'] !== 'affiliate-wp-settings' ) {
			return;
		}

		// Check for OAuth errors from Stripe first
		if ( isset( $_GET['error'] ) ) {
			$error_validation = affwp_stripe_payouts_validate_stripe_error( $_GET['error'] );
			if ( is_wp_error( $error_validation ) ) {
				$error             = 'invalid_request';
				$error_description = $error_validation->get_error_message();
			} else {
				$error             = $error_validation;
				$error_description = isset( $_GET['error_description'] ) ?
					affwp_stripe_payouts_sanitize_error_message( $_GET['error_description'] ) : '';
			}

			// Log the error for debugging
			affwp_stripe_payouts_log_error( 'OAuth error from Stripe: ' . $error . ' - ' . $error_description );

			// Redirect with sanitized error message
			wp_redirect(
				add_query_arg(
					[
						'stripe_error' => urlencode(
							sprintf(
								__( 'Stripe connection failed: %s', 'affiliate-wp' ),
								$error_description ?: $error
							)
						),
					],
					admin_url( 'admin.php?page=affiliate-wp-settings&tab=stripe_payouts' )
				)
			);
			exit;
		}

		// Check if this is an OAuth callback from Stripe (either format).
		if ( isset( $_GET['code'] ) && ! empty( $_GET['code'] ) ) {
			// Validate the authorization code
			$code = affwp_stripe_payouts_validate_oauth_code( $_GET['code'] );
			if ( is_wp_error( $code ) ) {
				wp_safe_redirect(
					add_query_arg(
						[
							'stripe_error' => urlencode( $code->get_error_message() ),
						],
						admin_url( 'admin.php?page=affiliate-wp-settings&tab=stripe_payouts' )
					)
				);
				exit;
			}

			// Initialize Stripe API.
			affwp_stripe_payouts_init_api();

			try {
				// Exchange the authorization code for an access token.
				$stripe_connect_url = 'https://connect.stripe.com/oauth/token';

				$args = [
					'method'      => 'POST',
					'timeout'     => 45,
					'redirection' => 5,
					'httpversion' => '1.0',
					'blocking'    => true,
					'headers'     => [],
					'body'        => [
						'client_secret' => affwp_stripe_payouts_get_secret_key(),
						'code'          => $code,
						'grant_type'    => 'authorization_code',
					],
				];

				$response = wp_remote_post( $stripe_connect_url, $args );

				if ( is_wp_error( $response ) ) {
					throw new Exception( $response->get_error_message() );
				}

				$body = json_decode( wp_remote_retrieve_body( $response ), true );

				if ( isset( $body['error'] ) ) {
					throw new Exception( $body['error_description'] );
				}

				// Store the Stripe account ID.
				if ( isset( $body['stripe_user_id'] ) ) {
					update_option( 'affwp_stripe_payouts_account_id', sanitize_text_field( $body['stripe_user_id'] ) );

					// Redirect to settings page.
					wp_safe_redirect( admin_url( 'admin.php?page=affiliate-wp-settings&tab=stripe_payouts&stripe_connected=1' ) );
					exit;
				} else {
					throw new Exception( __( 'Stripe account ID not found in the response.', 'affiliate-wp' ) );
				}
			} catch ( Exception $e ) {
				affwp_stripe_payouts_log_error( 'Error connecting admin to Stripe: ' . $e->getMessage() );

				// Redirect with error
				wp_safe_redirect( admin_url( 'admin.php?page=affiliate-wp-settings&tab=stripe_payouts&stripe_error=' . urlencode( $e->getMessage() ) ) );
				exit;
			}
		}

		// Check if this is a disconnect request.
		if ( isset( $_GET['disconnect'] ) && $_GET['disconnect'] == '1' && isset( $_GET['_wpnonce'] ) && wp_verify_nonce( $_GET['_wpnonce'], 'affwp_stripe_payouts_disconnect' ) ) {
			delete_option( 'affwp_stripe_payouts_account_id' );

			// Redirect to settings page
			wp_safe_redirect( admin_url( 'admin.php?page=affiliate-wp-settings&tab=stripe_payouts&stripe_disconnected=1' ) );
			exit;
		}
	}

	/**
	 * Process admin disconnection from Stripe
	 *
	 * @since 2.29.0
	 * @return void
	 */
	public function process_admin_disconnect() {
		// Check if this is the admin disconnect action and verify nonce.
		if (
			isset( $_GET['affwp_action'] ) &&
			$_GET['affwp_action'] === 'stripe_payouts_admin_disconnect' &&
			isset( $_GET['_wpnonce'] ) &&
			wp_verify_nonce( $_GET['_wpnonce'], 'affwp_stripe_payouts_admin_disconnect' )
		) {
			// Check user permissions.
			if ( ! current_user_can( 'manage_affiliate_options' ) ) {
				wp_die( __( 'You do not have permission to disconnect Stripe.', 'affiliate-wp' ) );
			}

			// Delete the stored account ID.
			delete_option( 'affwp_stripe_payouts_account_id' );

			// Redirect back to settings page with disconnected notice.
			wp_safe_redirect( admin_url( 'admin.php?page=affiliate-wp-settings&tab=stripe_payouts&stripe_disconnected=1' ) );
			exit;
		}
	}

	/**
	 * Process affiliate connection to Stripe
	 *
	 * @since 2.29.0
	 * @return void
	 */
	public function process_affiliate_connect() {
		// Check if this is an affiliate connect request.
		if ( isset( $_GET['affwp_action'] ) && $_GET['affwp_action'] == 'stripe_payouts_connect' ) {

			// Validate affiliate ID
			if ( ! isset( $_GET['affiliate_id'] ) ) {
				wp_die( __( 'Affiliate ID is required.', 'affiliate-wp' ) );
			}

			$affiliate_id = affwp_stripe_payouts_validate_affiliate_id( $_GET['affiliate_id'] );
			if ( is_wp_error( $affiliate_id ) ) {
				wp_die( $affiliate_id->get_error_message() );
			}

			// Check user permissions
			$permission_check = affwp_stripe_payouts_validate_user_permissions( $affiliate_id, 'connect' );
			if ( is_wp_error( $permission_check ) ) {
				wp_die( $permission_check->get_error_message() );
			}

			$user_id = affwp_get_affiliate_user_id( $affiliate_id );

			// Check if Stripe is configured.
			if ( ! affwp_stripe_payouts_is_configured() ) {
				wp_die( __( 'Stripe is not configured. Please contact the site administrator.', 'affiliate-wp' ) );
			}

			// Check for OAuth errors from Stripe first
			if ( isset( $_GET['error'] ) ) {
				$error_validation = affwp_stripe_payouts_validate_stripe_error( $_GET['error'] );
				if ( is_wp_error( $error_validation ) ) {
					$error             = 'invalid_request';
					$error_description = $error_validation->get_error_message();
				} else {
					$error             = $error_validation;
					$error_description = isset( $_GET['error_description'] ) ?
						affwp_stripe_payouts_sanitize_error_message( $_GET['error_description'] ) : '';
				}

				// Log the error for debugging
				affwp_stripe_payouts_log_error( 'OAuth error from Stripe (affiliate): ' . $error . ' - ' . $error_description );

				affwp_stripe_payouts_set_flash_notice(
					sprintf(
						__( 'Stripe connection failed: %s', 'affiliate-wp' ),
						$error_description ?: $error
					),
					'error'
				);
				wp_safe_redirect( affwp_stripe_payouts_get_settings_url() );
				exit;
			}

			// Check if this is a callback from Stripe
			if ( isset( $_GET['code'] ) && ! empty( $_GET['code'] ) ) {
				// Validate the authorization code
				$code = affwp_stripe_payouts_validate_oauth_code( $_GET['code'] );
				if ( is_wp_error( $code ) ) {
					affwp_stripe_payouts_set_flash_notice( $code->get_error_message(), 'error' );
					wp_safe_redirect( affwp_stripe_payouts_get_settings_url() );
					exit;
				}

				// Process the OAuth callback
				$this->process_affiliate_oauth_callback();
				return;
			}

			try {
				// Initialize Stripe API.
				affwp_stripe_payouts_init_api();

				// Get user info for the account.
				$user_info = get_userdata( $user_id );

				/*
				 * Resume an onboarding that is already in progress instead of creating a duplicate
				 * account. This also handles Stripe's refresh_url (stripe_refresh=1), which fires when
				 * an onboarding link expires or the affiliate leaves the hosted flow without finishing.
				 */
				$pending_account_id = get_user_meta( $user_id, 'affwp_stripe_payouts_pending_account_id', true );

				if ( ! empty( $pending_account_id ) ) {
					$pending_account = \Stripe\Account::retrieve( $pending_account_id );

					// Onboarding finished while we weren't looking (e.g. the return URL never fired).
					if ( $pending_account->details_submitted ) {
						$this->complete_affiliate_connection( $affiliate_id, $user_id, $pending_account_id );
					}

					// Still incomplete and the affiliate left the hosted flow (refresh_url): return
					// them to settings rather than looping straight back into Stripe.
					if ( isset( $_GET['stripe_refresh'] ) ) {
						$this->redirect_incomplete_onboarding();
					}

					/*
					 * Active reconnect: resume the same account so we don't orphan a half-finished
					 * one, but only when the affiliate isn't switching countries (Stripe can't change
					 * an account's country after creation). On a switch, drop the stale account and
					 * fall through to create a fresh one below.
					 */
					$requested_country = isset( $_GET['affwp_stripe_country'] )
						? strtoupper( sanitize_text_field( wp_unslash( $_GET['affwp_stripe_country'] ) ) )
						: '';

					if ( '' === $requested_country || $requested_country === strtoupper( (string) $pending_account->country ) ) {
						$this->redirect_to_onboarding( $pending_account_id, $affiliate_id );
					}

					delete_user_meta( $user_id, 'affwp_stripe_payouts_pending_account_id' );
				}

				// A refresh with nothing to resume just returns to settings.
				if ( isset( $_GET['stripe_refresh'] ) ) {
					$this->redirect_incomplete_onboarding();
				}

				/*
				 * Resolve and validate the affiliate's country (always collected at connect). It drives
				 * the service agreement and the 1099 capability. The affiliate declares it in the connect
				 * form; if none is provided we fall back to the platform's own country (domestic connect).
				 */
				$platform_country  = affwp_stripe_payouts_get_platform_country();
				$affiliate_country = isset( $_GET['affwp_stripe_country'] )
					? strtoupper( sanitize_text_field( wp_unslash( $_GET['affwp_stripe_country'] ) ) )
					: '';

				if ( '' === $affiliate_country ) {
					/*
					 * No declared country (only reachable when the connect-form param is missing). Fall back
					 * to the platform's own country (domestic connect). If we can't even determine that, do
					 * NOT guess 'US' — that could create a US account and request the US 1099 capability on a
					 * non-US platform. Redirect back with the same notice instead; their effective method is
					 * the site default.
					 */
					if ( is_wp_error( $platform_country ) ) {
						affwp_stripe_payouts_set_flash_notice( __( 'We could not set up Stripe payouts right now, so you will be paid another way.', 'affiliate-wp' ), 'info' );
						wp_safe_redirect( affwp_stripe_payouts_get_settings_url() );
						exit;
					}

					$affiliate_country = $platform_country;
				}

				// Persist the affiliate's declared country (even when unserviceable) so it drives routing,
				// 1099 handling, and payout-method visibility — including hiding Stripe from their picker.
				if ( $affiliate_id ) {
					affwp_update_affiliate_meta( $affiliate_id, 'affwp_stripe_country', $affiliate_country );
				}

				/*
				 * Stripe can only pay domestic affiliates, or — when cross-border is enabled on a US/UK
				 * platform — affiliates in supported countries. Anything else can't be served. This is a
				 * safety net: the affiliate normally never reaches here (the picker hides Stripe and their
				 * effective method already falls back to the site default), so redirect back with a notice
				 * rather than a hard error — their effective method is the site default.
				 */
				if ( ! affwp_stripe_payouts_is_country_serviceable( $affiliate_country ) ) {
					affwp_stripe_payouts_set_flash_notice( __( 'Stripe is not available in your country, so you will be paid another way.', 'affiliate-wp' ), 'info' );
					wp_safe_redirect( affwp_stripe_payouts_get_settings_url() );
					exit;
				}

				// Create the Express account. Every connected account uses Stripe's full service
				// agreement, including cross-border payouts.
				$account_data = [
					'type'         => 'express',
					'country'      => $affiliate_country,
					'capabilities' => [
						'transfers'     => [ 'requested' => true ],
						'card_payments' => [ 'requested' => true ],
					],
				];

				/*
				 * Request the 1099 tax-reporting capability so Stripe collects the W-9/TIN and can issue
				 * the affiliate's form. The capability is `tax_reporting_us_1099_misc` (there is no `_nec`
				 * capability); the 1099-NEC *form* is selected at filing time in the Stripe tax-reporting
				 * dashboard.
				 *
				 * Gated on BOTH the affiliate AND the platform being US: only a US platform has a US 1099
				 * filing role, so a UK store paying a US affiliate must NOT request it (Stripe would leave
				 * the capability unfulfillable). $platform_country may be a WP_Error here when the affiliate
				 * declared their own country, so it compares false against 'US' — we never guess 'US'.
				 */
				if ( 'US' === $affiliate_country && 'US' === $platform_country ) {
					$account_data['capabilities']['tax_reporting_us_1099_misc'] = [ 'requested' => true ];
				}

				// Add email if available.
				if ( $user_info && $user_info->user_email ) {
					$account_data['email'] = $user_info->user_email;
				}

				// Add business profile.
				$account_data['business_profile'] = [
					'product_description' => sprintf(
						__( 'Affiliate for %s', 'affiliate-wp' ),
						get_bloginfo( 'name' )
					),
				];

				// Create the account.
				$account = \Stripe\Account::create( $account_data );

				// Store the account ID temporarily.
				update_user_meta( $user_id, 'affwp_stripe_payouts_pending_account_id', $account->id );

				$this->redirect_to_onboarding( $account->id, $affiliate_id );
			} catch ( Exception $e ) {
				$error_message = $e->getMessage();

				// For errors, show a generic message
				wp_die(
					sprintf(
						__( 'Error connecting to Stripe: %s', 'affiliate-wp' ),
						esc_html( $error_message )
					)
				);
			}
		}
	}

	/**
	 * Process OAuth callback for affiliate
	 *
	 * @since 2.29.0
	 * @return void
	 */
	private function process_affiliate_oauth_callback() {
		// Validate affiliate ID
		if ( ! isset( $_GET['affiliate_id'] ) ) {
			wp_die( __( 'Affiliate ID is required.', 'affiliate-wp' ) );
		}

		$affiliate_id = affwp_stripe_payouts_validate_affiliate_id( $_GET['affiliate_id'] );
		if ( is_wp_error( $affiliate_id ) ) {
			wp_die( $affiliate_id->get_error_message() );
		}

		$user_id = affwp_get_affiliate_user_id( $affiliate_id );

		// Get the pending account ID.
		$account_id = get_user_meta( $user_id, 'affwp_stripe_payouts_pending_account_id', true );

		if ( empty( $account_id ) ) {
			wp_die( __( 'No pending Stripe account found.', 'affiliate-wp' ) );
		}

		try {
			// Initialize Stripe API.
			affwp_stripe_payouts_init_api();

			// Retrieve the account to check its status
			$account = \Stripe\Account::retrieve( $account_id );

			if ( $account->details_submitted ) {
				$this->complete_affiliate_connection( $affiliate_id, $user_id, $account_id );
			}

			// The affiliate left Stripe without finishing (e.g. clicked "Return to
			// AffiliateWP"). Return them to settings with a resume notice rather than
			// regenerating a link and looping them straight back into onboarding.
			$this->redirect_incomplete_onboarding();
		} catch ( Exception $e ) {
			affwp_stripe_payouts_log_error( 'Error connecting affiliate to Stripe: ' . $e->getMessage() );

			// Delete the pending account ID
			delete_user_meta( $user_id, 'affwp_stripe_payouts_pending_account_id' );

			affwp_stripe_payouts_set_flash_notice(
				sprintf( __( 'Stripe connection error: %s', 'affiliate-wp' ), $e->getMessage() ),
				'error'
			);
			wp_safe_redirect( affwp_stripe_payouts_get_settings_url() );
			exit;
		}
	}

	/**
	 * Persist a completed Stripe connection and return the affiliate to their settings.
	 *
	 * @since 2.34.0
	 *
	 * @param int    $affiliate_id Affiliate ID.
	 * @param int    $user_id      WordPress user ID.
	 * @param string $account_id   Connected Stripe account ID.
	 * @return void
	 */
	private function complete_affiliate_connection( $affiliate_id, $user_id, $account_id ) {
		update_user_meta( $user_id, 'affwp_stripe_payouts_account_id', $account_id );
		delete_user_meta( $user_id, 'affwp_stripe_payouts_pending_account_id' );

		if ( $affiliate_id ) {
			affwp_stripe_payouts_store_affiliate_platform( $affiliate_id );

			// Completing onboarding is a clear signal of intent — switch the affiliate's
			// payout method to Stripe so the dashboard reflects it.
			affwp_update_affiliate_meta( $affiliate_id, 'payout_method', 'stripe' );
		}

		wp_safe_redirect(
			add_query_arg(
				[
					'stripe_connected' => '1',
				],
				affwp_stripe_payouts_get_settings_url()
			)
		);
		exit;
	}

	/**
	 * Build a fresh onboarding link for an account and send the affiliate into Stripe.
	 *
	 * @since 2.34.0
	 *
	 * @param string $account_id   Stripe account ID to onboard.
	 * @param int    $affiliate_id Affiliate ID.
	 * @return void
	 */
	private function redirect_to_onboarding( $account_id, $affiliate_id ) {
		$settings_url = affwp_stripe_payouts_get_settings_url();

		// Persist the affiliate's intent to be paid via Stripe the moment they enter onboarding, so
		// the choice survives the round-trip to Stripe (the picker has no AJAX save; without this,
		// leaving for Stripe and returning would revert the dropdown to their previous method). This
		// is the single chokepoint for every onboarding-entry path — fresh create, resume, and
		// country switch all reach it — so it covers resume, which bypasses account creation.
		// Onboarding may not finish: payouts for a not-yet-connected affiliate are held with a
		// "complete your Stripe setup" reason (Failure_Class::AFFILIATE_ACTION_REQUIRED), never
		// silently rerouted. complete_affiliate_connection() re-affirms this on success.
		if ( $affiliate_id ) {
			affwp_update_affiliate_meta( $affiliate_id, 'payout_method', 'stripe' );
		}

		$account_link = \Stripe\AccountLink::create(
			[
				'account'     => $account_id,
				'refresh_url' => add_query_arg(
					[
						'affwp_action'   => 'stripe_payouts_connect',
						'affiliate_id'   => $affiliate_id,
						'stripe_refresh' => '1',
					],
					$settings_url
				),
				'return_url'  => add_query_arg(
					[
						'affwp_action' => 'stripe_payouts_connect',
						'affiliate_id' => $affiliate_id,
						'code'         => 'completed',
					],
					$settings_url
				),
				'type'        => 'account_onboarding',
			]
		);

		wp_redirect( $account_link->url );
		exit;
	}

	/**
	 * Return the affiliate to their settings with a resume notice when they leave Stripe
	 * onboarding without finishing. Never bounces them back into the hosted flow.
	 *
	 * @since 2.34.0
	 *
	 * @return void
	 */
	private function redirect_incomplete_onboarding() {
		affwp_stripe_payouts_set_flash_notice( __( 'Your Stripe payout setup is not finished yet. You can resume it any time from your payout settings.', 'affiliate-wp' ), 'info' );
		wp_safe_redirect( affwp_stripe_payouts_get_settings_url() );
		exit;
	}

	/**
	 * Process affiliate disconnection from Stripe
	 *
	 * @since 2.29.0
	 * @return void
	 */
	public function process_affiliate_disconnect() {
		if ( ! isset( $_GET['affwp_action'] ) || $_GET['affwp_action'] != 'stripe_payouts_disconnect' ) {
			return;
		}

		// Validate affiliate ID
		if ( ! isset( $_GET['affiliate_id'] ) ) {
			wp_die( __( 'Affiliate ID is required.', 'affiliate-wp' ) );
		}

		$affiliate_id = affwp_stripe_payouts_validate_affiliate_id( $_GET['affiliate_id'] );
		if ( is_wp_error( $affiliate_id ) ) {
			wp_die( $affiliate_id->get_error_message() );
		}

		// Check user permissions
		$permission_check = affwp_stripe_payouts_validate_user_permissions( $affiliate_id, 'disconnect' );
		if ( is_wp_error( $permission_check ) ) {
			wp_die( $permission_check->get_error_message() );
		}

		$user_id = affwp_get_affiliate_user_id( $affiliate_id );

		// Verify nonce
		if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( $_GET['_wpnonce'], 'affwp_stripe_payouts_disconnect_' . $affiliate_id ) ) {
			wp_die( __( 'Security check failed.', 'affiliate-wp' ) );
		}

		// Delete the Stripe account ID, including any onboarding still in progress, so the affiliate
		// returns to a clean slate and can pick a different country (a Stripe account's country is
		// fixed at creation, so switching means abandoning the half-finished account).
		delete_user_meta( $user_id, 'affwp_stripe_payouts_account_id' );
		delete_user_meta( $user_id, 'affwp_stripe_payouts_pending_account_id' );

		// Redirect to affiliate area
		if ( current_user_can( 'manage_affiliates' ) ) {
			// Admin is disconnecting an affiliate
			wp_safe_redirect( admin_url( 'admin.php?page=affiliate-wp-affiliates&action=edit&affiliate_id=' . $affiliate_id . '&stripe_disconnected=1' ) );
		} else {
			// Affiliate is disconnecting themselves
			wp_safe_redirect(
				add_query_arg(
					[
						'stripe_disconnected' => '1',
					],
					affwp_stripe_payouts_get_settings_url()
				)
			);
		}
		exit;
	}
}

// Instantiate the class
AffiliateWP_Stripe_Connect::instance();
