<?php
/**
 * Class WC_Payments_Subscriptions_Event_Handler
 *
 * @package WooCommerce\Payments
 */

use WCPay\Core\Server\Request\Get_Charge;
use WCPay\Exceptions\API_Exception;
use WCPay\Exceptions\Invalid_Webhook_Data_Exception;
use WCPay\Exceptions\Order_Not_Found_Exception;
use WCPay\Logger;

/**
 * Subscriptions Event/Webhook Handler class
 */
class WC_Payments_Subscriptions_Event_Handler {

	/**
	 * Maximum amount of payment retries to handle before cancelling the subscription.
	 *
	 * @var int
	 */
	const MAX_RETRIES = 4;

	/**
	 * Invoice Service.
	 *
	 * @var WC_Payments_Invoice_Service
	 */
	private $invoice_service;

	/**
	 * Subscription Service.
	 *
	 * @var WC_Payments_Subscription_Service
	 */
	private $subscription_service;

	/**
	 * Subscriptions event handler constructor.
	 *
	 * @param WC_Payments_Invoice_Service      $invoice_service      Invoice service.
	 * @param WC_Payments_Subscription_Service $subscription_service Subscription service.
	 */
	public function __construct( WC_Payments_Invoice_Service $invoice_service, WC_Payments_Subscription_Service $subscription_service ) {
		$this->invoice_service      = $invoice_service;
		$this->subscription_service = $subscription_service;
	}

	/**
	 * Validate and correct subscription status, date, and lines.
	 *
	 * @param array $body The event body that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception Required parameters not found.
	 */
	public function handle_invoice_upcoming( array $body ) {
		$event_object          = $this->get_event_property( $body, [ 'data', 'object' ] );
		$wcpay_subscription_id = $this->get_event_property( $event_object, 'subscription' );

		/**
		 * When a store is in staging mode, we don't want any webhook handling response to be sent to the server.
		 *
		 * Sending requests from staging sites can have unintended consequences for the live store. For example,
		 * Subscriptions which renew on the staging site will lead to paused subscriptions at Stripe and result in
		 * missed renewal payments.
		 */
		if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
			$this->log_skipped_webhook_due_to_staging( 'invoice.upcoming', $wcpay_subscription_id );
			return;
		}

		$wcpay_discounts = $this->get_event_property( $event_object, 'discounts' );
		$wcpay_lines     = $this->get_event_property( $event_object, [ 'lines', 'data' ] );
		$subscription    = WC_Payments_Subscription_Service::get_subscription_from_wcpay_subscription_id( $wcpay_subscription_id );

		if ( ! $subscription ) {
			throw new Invalid_Webhook_Data_Exception( __( 'Cannot find subscription to handle the "invoice.upcoming" event.', 'woocommerce-payments' ) );
		}

		$wcpay_subscription = $this->subscription_service->get_wcpay_subscription( $subscription );

		// Suspend or cancel subscription if we didn't expect a next payment.
		if ( 0 === $subscription->get_time( 'next_payment' ) ) {
			// TODO: Add error handling to these {cancel/suspend}_subscription calls i.e. add a subscription order note if the WCPay subscription wasn't cancelled.
			if ( ! $subscription->has_status( 'on-hold' ) && 0 !== $subscription->get_time( 'end' ) ) {
				$this->subscription_service->cancel_subscription( $subscription );
			} else {
				$this->subscription_service->suspend_subscription( $subscription );
				$subscription->add_order_note( __( 'Suspended WooPayments Subscription in invoice.upcoming webhook handler because subscription next_payment date is 0.', 'woocommerce-payments' ) );
				Logger::log(
					sprintf(
						'Suspended WooPayments Subscription in invoice.upcoming webhook handler because subscription next_payment date is 0. WC ID: %d; WooPayments ID: %s.',
						$subscription->get_id(),
						$wcpay_subscription_id
					)
				);
			}
		} else {
			// Translators: %s Scheduled/upcoming payment date in Y-m-d H:i:s format.
			$subscription->add_order_note( sprintf( __( 'Next automatic payment scheduled for %s.', 'woocommerce-payments' ), get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $wcpay_subscription['current_period_end'] ), wc_date_format() . ' ' . wc_time_format() ) ) );

			$this->subscription_service->update_dates_to_match_wcpay_subscription( $wcpay_subscription, $subscription );
			$this->invoice_service->validate_invoice( $wcpay_lines, $wcpay_discounts ? $wcpay_discounts : [], $subscription );
		}
	}

	/**
	 * Renews a subscription associated with paid invoice.
	 *
	 * @param array $body The event body that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception Required parameters not found.
	 * @throws Order_Not_Found_Exception
	 */
	public function handle_invoice_paid( array $body ) {
		$event_data            = $this->get_event_property( $body, 'data' );
		$event_object          = $this->get_event_property( $event_data, 'object' );
		$wcpay_subscription_id = $this->get_event_property( $event_object, 'subscription' );

		/**
		 * When a store is in staging mode, we don't want any webhook handling response to be sent to the server.
		 *
		 * Sending requests from staging sites can have unintended consequences for the live store. For example,
		 * Subscriptions which renew on the staging site will lead to paused subscriptions at Stripe and result in
		 * missed renewal payments.
		 */
		if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
			$this->log_skipped_webhook_due_to_staging( 'invoice.paid', $wcpay_subscription_id );
			return;
		}

		$wcpay_invoice_id = $this->get_event_property( $event_object, 'id' );
		$subscription     = WC_Payments_Subscription_Service::get_subscription_from_wcpay_subscription_id( $wcpay_subscription_id );

		if ( ! $subscription ) {
			throw new Invalid_Webhook_Data_Exception( __( 'Cannot find subscription for the incoming "invoice.paid" event.', 'woocommerce-payments' ) );
		}

		// This incoming invoice.paid event is linked to the subscription parent invoice and can be ignored.
		if ( WC_Payments_Invoice_Service::get_subscription_invoice_id( $subscription ) === $wcpay_invoice_id ) {
			return;
		}

		$order = wc_get_order( WC_Payments_Invoice_Service::get_order_id_by_invoice_id( $wcpay_invoice_id ) );

		if ( ! $order ) {
			$order = wcs_create_renewal_order( $subscription );

			if ( is_wp_error( $order ) ) {
				throw new Invalid_Webhook_Data_Exception( __( 'Unable to generate renewal order for subscription on the "invoice.paid" event.', 'woocommerce-payments' ) );
			} else {
				$order->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID );
				$this->invoice_service->set_order_invoice_id( $order, $wcpay_invoice_id );
			}
		}

		if ( $order->needs_payment() ) {
			/*
			 * Temporarily place the subscription on-hold to imitate the normal subscription renewal flow.
			 * This ensures the downstream effects take place, e.g. a payment status order note is added and the
			 * 'woocommerce_subscription_payment_complete' action is fired.
			 */
			remove_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );
			$subscription->update_status( 'on-hold' );
			add_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );

			/*
			 * Remove the reactivate_subscription callback that occurs when a subscription transitions from on-hold to active.
			 * The WCPay subscription will remain active throughout this process and does not need to be reactivated.
			 */
			remove_action( 'woocommerce_subscription_status_on-hold_to_active', [ $this->subscription_service, 'reactivate_subscription' ] );
			$order->payment_complete();
			add_action( 'woocommerce_subscription_status_on-hold_to_active', [ $this->subscription_service, 'reactivate_subscription' ] );

			/**
			 * Fetch a new instance of the subscription.
			 *
			 * After marking the order as paid, a parallel instance of the subscription would have been reactivated.
			 * To avoid race conditions and cache pollution, fetch a new instance to ensure our current instance doesn't override the active subscription status.
			 */
			$subscription = wcs_get_subscription( $subscription->get_id() );
		}

		if ( isset( $event_object['payment_intent'] ) ) {
			// Add the payment intent data to the order.
			$this->invoice_service->get_and_attach_intent_info_to_order( $order, $event_object['payment_intent'] );
		}

		// Remove pending invoice ID in case one was recorded for previous failed renewal attempts.
		$this->invoice_service->mark_pending_invoice_paid_for_subscription( $subscription );

		// Record the store's Stripe Billing environment context on the payment intent.
		$invoice = $this->invoice_service->record_subscription_payment_context( $wcpay_invoice_id );

		// Update charge and transaction metadata - add order id for Stripe Billing.
		$this->invoice_service->update_charge_details( $invoice, $order->get_id() );

		// Update transaction customer details for Stripe Billing.
		$this->invoice_service->update_transaction_details( $invoice, $order );
	}

	/**
	 * Marks a subscription payment associated with invoice as failed.
	 *
	 * @param array $body The event body that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception Required parameters not found.
	 */
	public function handle_invoice_payment_failed( array $body ) {
		$event_data            = $this->get_event_property( $body, 'data' );
		$event_object          = $this->get_event_property( $event_data, 'object' );
		$wcpay_subscription_id = $this->get_event_property( $event_object, 'subscription' );

		/**
		 * When a store is in staging mode, we don't want any webhook handling response to be sent to the server.
		 *
		 * Sending requests from staging sites can have unintended consequences for the live store. For example,
		 * Subscriptions which renew on the staging site will lead to paused subscriptions at Stripe and result in
		 * missed renewal payments.
		 */
		if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
			$this->log_skipped_webhook_due_to_staging( 'invoice.payment_failed', $wcpay_subscription_id );
			return;
		}

		$wcpay_invoice_id = $this->get_event_property( $event_object, 'id' );
		$attempts         = (int) $this->get_event_property( $event_object, 'attempt_count' );
		$subscription     = WC_Payments_Subscription_Service::get_subscription_from_wcpay_subscription_id( $wcpay_subscription_id );

		if ( ! $subscription ) {
			throw new Invalid_Webhook_Data_Exception( __( 'Cannot find subscription for the incoming "invoice.payment_failed" event.', 'woocommerce-payments' ) );
		}

		$charge = null;
		if ( isset( $event_object['charge'] ) ) {
			try {
				$charge = Get_Charge::create( $event_object['charge'] );
				$charge = $charge->send();
			} catch ( API_Exception $e ) {
				Logger::error( sprintf( 'Unable to retrieve charge data for invoice.payment_failed webhook. Charge ID: %s; Error: %s', $event_object['charge'], $e->getMessage() ) );
			}
		}
		$error_details = '';
		$error_code    = '';
		if ( $charge ) {
			if ( isset( $charge['outcome'] ) && isset( $charge['outcome']['seller_message'] ) ) {
				$error_details = $charge['outcome']['seller_message'];
				$error_code    = $charge['failure_code'];
			}
		}

		$order = wc_get_order( WC_Payments_Invoice_Service::get_order_id_by_invoice_id( $wcpay_invoice_id ) );

		if ( ! $order ) {
			$order = wcs_create_renewal_order( $subscription );

			if ( is_wp_error( $order ) ) {
				throw new Invalid_Webhook_Data_Exception( __( 'Unable to generate renewal order for subscription to record the incoming "invoice.payment_failed" event.', 'woocommerce-payments' ) );
			} else {
				$order->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID );
				$this->invoice_service->set_order_invoice_id( $order, $wcpay_invoice_id );
			}
		}

		if ( $error_details ) {
			$subscription->add_order_note(
				sprintf(
					// Translators: %1$d Number of failed renewal attempts. %2$s contains failure message, %3$s contains error code.
					_n(
						'WooPayments subscription renewal attempt %1$d failed with the following message "%2$s" and failure code <code>%3$s</code>',
						'WooPayments subscription renewal attempt %1$d failed with the following message "%2$s" and failure code <code>%3$s</code>',
						$attempts,
						'woocommerce-payments'
					),
					$attempts,
					$error_details,
					$error_code
				)
			);
			$order->add_order_note(
				sprintf(
					// Translators: %1$s contains failure message. %2$s contains error code.
					__(
						'Payment for the order failed with the following message: "%1$s" and failure code <code>%2$s</code>',
						'woocommerce-payments'
					),
					$error_details,
					$error_code
				)
			);
		} else {
			// Translators: %d Number of failed renewal attempts.
			$subscription->add_order_note( sprintf( _n( 'WooPayments subscription renewal attempt %d failed.', 'WooPayments subscription renewal attempt %d failed.', $attempts, 'woocommerce-payments' ), $attempts ) );
		}

		if ( self::MAX_RETRIES > $attempts ) {
			remove_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );
			$subscription->payment_failed();
			add_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );
		} else {
			$subscription->payment_failed( 'cancelled' );
		}

		// Record invoice ID so we can trigger repayment on payment method update.
		$this->invoice_service->mark_pending_invoice_for_subscription( $subscription, $wcpay_invoice_id );

		// Record the store's Stripe Billing environment context on the payment intent.
		$this->invoice_service->record_subscription_payment_context( $wcpay_invoice_id );
	}

	/**
	 * Gets the event data by property.
	 *
	 * @param array $event_data Event data.
	 * @param mixed $key        Requested key.
	 *
	 * @return mixed
	 *
	 * @throws Invalid_Webhook_Data_Exception Event data not found by key.
	 */
	private function get_event_property( array $event_data, $key ) {
		$keys = is_array( $key ) ? $key : [ $key ];
		$data = $event_data;

		foreach ( $keys as $k ) {
			if ( ! isset( $data[ $k ] ) ) {
				// Translators: %s Property name not found in event data array.
				throw new Invalid_Webhook_Data_Exception( sprintf( __( '%s not found in array', 'woocommerce-payments' ), $k ) );
			}

			$data = $data[ $k ];
		}

		return $data;
	}

	/**
	 * Creates a log entry noting that a subscription-related webhook has been skipped due to the current site being in staging mode.
	 *
	 * @param string $event                 The webhook event type. eg "invoice.paid".
	 * @param string $wcpay_subscription_id The WCPay subsciption ID.
	 */
	private function log_skipped_webhook_due_to_staging( string $event, string $wcpay_subscription_id ) {
		Logger::info(
			sprintf(
				// Example message: "invoice.paid webhook processing for sub_abc123defg456 was skipped. The current site (https://staging.example.com) is in staging mode. Live site is https://example.com.
				'%s webhook processing for %s was skipped. The current site (%s) is in staging mode. Live site is %s.',
				$event,
				$wcpay_subscription_id,
				WCS_Staging::get_site_url_from_source( 'current_wp_site' ),
				WCS_Staging::get_site_url_from_source( 'subscriptions_install' )
			)
		);
	}
}
