<?php

namespace PaymentPage\RestAPI;

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

use WP_REST_Server;
use WP_REST_Request;
use PaymentPage\PaymentGateway as PP_PaymentGateway;
use PaymentPage\ThirdPartyIntegration\Freemius as PP_Freemius;
use PaymentPage\Model\Log as PP_Model_Log;
use PaymentPage\Model\Payments as PP_Model_Payments;
use PaymentPage\Email\ConfirmationEmail;

class Webhook {

	public static function register_routes() {
		// (Audit S17) POST only — was previously ALLMETHODS which accepted GET/HEAD too.
		// Stripe only ever sends POST; restricting tightens the surface and produces
		// proper 405s for noisy probes.
		register_rest_route(
			PAYMENT_PAGE_REST_API_PREFIX . '/v1',
			'/webhook/stripe-callback/(?P<mode>[\w-]+)',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => '\PaymentPage\RestAPI\Webhook::stripe_callback',
				'permission_callback' => function() {
					return true;
				},
			)
		);

		register_rest_route(
			PAYMENT_PAGE_REST_API_PREFIX . '/v1',
			'/webhook/paypal-callback/(?P<mode>[\w-]+)',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => '\PaymentPage\RestAPI\Webhook::paypal_callback',
				'permission_callback' => function() {
					return true;
				},
			)
		);
	}

	public static function stripe_callback( WP_REST_Request $request ) {
		if ( ! isset( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ) {
			http_response_code( 400 );

			return rest_ensure_response(
				array(
					/* translators: %s: HTTP header name */
				'message' => sprintf( __( '%s not found', 'payment-page' ), 'HTTP_STRIPE_SIGNATURE' ),
				)
			);
		}

		$mode = $request->get_param( 'mode' );

		if ( ! class_exists( '\Stripe\Stripe' ) ) {
			require_once PAYMENT_PAGE_BASE_PATH . '/lib/stripe/init.php';
		}

		$payload = @file_get_contents( 'php://input' );

		$sig_header = sanitize_text_field( wp_unslash( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) );

		try {
			$event = \Stripe\Webhook::constructEvent(
				$payload,
				$sig_header,
				payment_page_setting_get( 'stripe_' . $mode . '_webhook_secret' )
			);
		} catch ( \Stripe\Exception\SignatureVerificationException $e ) {
			// (Audit S1) Bad signature — 400 so Stripe stops retrying on this delivery.
			// Without this explicit catch the SDK threw an uncaught exception that surfaced
			// as a 500, which Stripe interprets as a transient failure and keeps retrying —
			// filling debug.log and burning retry budget.
			http_response_code( 400 );

			return rest_ensure_response(
				array(
					'message' => __( 'Invalid Signature', 'payment-page' ),
				)
			);
		} catch ( \UnexpectedValueException $e ) {
			// Invalid payload
			http_response_code( 400 );

			return rest_ensure_response(
				array(
					'message' => __( 'Invalid Payload', 'payment-page' ),
				)
			);
		}

		if ( ! isset( $event->data->object->metadata->payment_page_payment_id ) ) {
			http_response_code( 200 );

			return rest_ensure_response(
				array(
					/* translators: %s: metadata key name */
				'message' => sprintf( __( 'Event not tracked, %s metadata not attached', 'payment-page' ), 'payment_page_payment_id' ),
				)
			);
		}

		if ( ! isset( $event->data->object->metadata->domain_name ) || $event->data->object->metadata->domain_name !== payment_page_domain_name() ) {
			http_response_code( 200 );

			return rest_ensure_response(
				array(
					/* translators: %s: metadata key name */
				'message' => sprintf( __( 'Event not tracked, %s metadata does not match', 'payment-page' ), 'domain_name' ),
				)
			);
		}

		// (Audit S5) Tracked event types — 1.5.0 expands beyond the legacy succeeded-only
		// set so we can record failed payments and surface async ACH 'processing' state.
		$tracked_event_types = array(
			'payment_intent.succeeded',
			'setup_intent.succeeded',
			'payment_intent.payment_failed',
			'payment_intent.processing',
		);

		if ( ! in_array( $event->type, $tracked_event_types, true ) ) {
			http_response_code( 200 );

			return rest_ensure_response(
				array(
					'message' => __( 'Event not tracked', 'payment-page' ),
				)
			);
		}

		// (Audit S2) Idempotency — Stripe redelivers events on any non-2xx or timeout.
		// Without dedupe, the side effects (customer/admin emails, submit-action HTTP
		// webhooks) fire every time. Use a 7-day transient set keyed by event id to
		// short-circuit duplicates.
		if ( self::_stripe_event_already_processed( $event->id ) ) {
			http_response_code( 200 );

			return rest_ensure_response(
				array(
					'message' => __( 'Duplicate event ignored (already processed)', 'payment-page' ),
				)
			);
		}

		$modelPayment = PP_Model_Payments::findOrFail( array( 'id' => intval( $event->data->object->metadata->payment_page_payment_id ) ) );

		// (Audit S5) payment_intent.payment_failed — record failure, no side-effect dispatch.
		if ( $event->type === 'payment_intent.payment_failed' ) {
			self::_mark_stripe_event_processed( $event->id );

			$log = new PP_Model_Log(
				array(
					'post_id'   => $modelPayment->post_id,
					'namespace' => 'webhook',
					'action'    => 'payment_intent.payment_failed',
					'content'   => array(
						'payment_id' => $modelPayment->id,
						'intent_id'  => isset( $event->data->object->id ) ? $event->data->object->id : '',
						'last_error' => isset( $event->data->object->last_payment_error )
							? $event->data->object->last_payment_error
							: null,
					),
				)
			);
			$log->save();

			http_response_code( 200 );

			return rest_ensure_response(
				array(
					'message' => __( 'Payment failure recorded', 'payment-page' ),
				)
			);
		}

		// (Audit S5) payment_intent.processing — async settlement-pending state (ACH, SEPA).
		// Log it; the eventual payment_intent.succeeded webhook will flip is_paid=1 and fire
		// the side effects. Critical for ACH which can take 2-4 business days to settle.
		if ( $event->type === 'payment_intent.processing' ) {
			self::_mark_stripe_event_processed( $event->id );

			$log = new PP_Model_Log(
				array(
					'post_id'   => $modelPayment->post_id,
					'namespace' => 'webhook',
					'action'    => 'payment_intent.processing',
					'content'   => array(
						'payment_id' => $modelPayment->id,
						'intent_id'  => isset( $event->data->object->id ) ? $event->data->object->id : '',
					),
				)
			);
			$log->save();

			http_response_code( 200 );

			return rest_ensure_response(
				array(
					'message' => __( 'Payment processing (async settlement pending)', 'payment-page' ),
				)
			);
		}

		// payment_intent.succeeded / setup_intent.succeeded — terminal success.
		// (Audit S2) Belt-and-suspenders dedupe: even if the transient check above missed
		// (e.g. transient storage was flushed), short-circuit if is_paid is already set so
		// we don't re-fire emails / submit-action HTTP POSTs.
		if ( intval( $modelPayment->is_paid ) === 1 ) {
			self::_mark_stripe_event_processed( $event->id );

			http_response_code( 200 );

			return rest_ensure_response(
				array(
					'message' => __( 'Payment already marked as paid (idempotent skip)', 'payment-page' ),
				)
			);
		}

		$modelPayment->amount_received = intval( $event->data->object->amount_received );
		$modelPayment->is_paid         = 1;
		$modelPayment->save();

		self::_mark_stripe_event_processed( $event->id );

		self::_onPaymentSuccess( $modelPayment, $event );

		http_response_code( 201 );

		return rest_ensure_response(
			array(
				'message' => __( 'Handled Callback', 'payment-page' ),
			)
		);
	}

	/**
	 * Idempotency guard for Stripe webhook events. Returns true if the given event id has
	 * been processed in the last 7 days. (Audit S2)
	 *
	 * (Phase 45 P3) Emits `pts_increment_counter` on hit/miss so the
	 * payments-testing-suite can count idempotency-replay behavior.
	 * Production has zero listeners attached → zero overhead.
	 */
	private static function _stripe_event_already_processed( $event_id ) {
		if ( ! is_string( $event_id ) || $event_id === '' ) {
			return false;
		}

		$transient_key = 'payment_page_stripe_webhook_evt_' . md5( $event_id );
		$hit = get_transient( $transient_key ) !== false;

		do_action(
			'pts_increment_counter',
			$hit ? 'pts.webhook.idempotency.hit' : 'pts.webhook.idempotency.miss',
			array( 'event_id_hash' => substr( md5( $event_id ), 0, 8 ) )
		);

		return $hit;
	}

	private static function _mark_stripe_event_processed( $event_id ) {
		if ( ! is_string( $event_id ) || $event_id === '' ) {
			return;
		}

		$transient_key = 'payment_page_stripe_webhook_evt_' . md5( $event_id );

		// 7 days — Stripe's max retry window is 3 days; 7 gives a comfortable margin
		// without exhausting wp_options bloat (each transient is ~120 bytes).
		set_transient( $transient_key, 1, 7 * DAY_IN_SECONDS );
	}

	public static function paypal_callback( WP_REST_Request $request ) {

		// I hate paypal.
		require_once PAYMENT_PAGE_BASE_PATH . '/lib/paypal-sdk/autoload.php';
		require_once PAYMENT_PAGE_BASE_PATH . '/lib/paypal-http/autoload.php';
		require_once PAYMENT_PAGE_BASE_PATH . '/lib/paypal-checkout-sdk/autoload.php';

		$requestBody = file_get_contents( 'php://input' );

		if ( empty( $requestBody ) ) {
			exit;
		}

		$headers = getallheaders();
		$headers = array_change_key_case( $headers, CASE_UPPER );
		if (
		( ! array_key_exists( 'PAYPAL-AUTH-ALGO', $headers ) ) ||
		( ! array_key_exists( 'PAYPAL-TRANSMISSION-ID', $headers ) ) ||
		( ! array_key_exists( 'PAYPAL-CERT-URL', $headers ) ) ||
		( ! array_key_exists( 'PAYPAL-TRANSMISSION-SIG', $headers ) ) ||
		( ! array_key_exists( 'PAYPAL-TRANSMISSION-TIME', $headers ) )
		) {
			exit;
		}

		$webhookID = payment_page_setting_get( 'paypal_' . $request->get_param( 'mode' ) . '_webhook_id' );

		$signatureVerification = new \PayPal\Api\VerifyWebhookSignature();
		$signatureVerification->setAuthAlgo( $headers['PAYPAL-AUTH-ALGO'] );
		$signatureVerification->setTransmissionId( $headers['PAYPAL-TRANSMISSION-ID'] );
		$signatureVerification->setCertUrl( $headers['PAYPAL-CERT-URL'] );
		$signatureVerification->setWebhookId( $webhookID );
		$signatureVerification->setTransmissionSig( $headers['PAYPAL-TRANSMISSION-SIG'] );
		$signatureVerification->setTransmissionTime( $headers['PAYPAL-TRANSMISSION-TIME'] );

		$signatureVerification->setRequestBody( $requestBody );

		try {
			$apiContext = new \PayPal\Rest\ApiContext(
				new \PayPal\Auth\OAuthTokenCredential(
					payment_page_setting_get( 'paypal_' . $request->get_param( 'mode' ) . '_client_id' ),
					payment_page_setting_get( 'paypal_' . $request->get_param( 'mode' ) . '_secret' )
				)
			);

			$apiContext->setConfig(
				array(
					'mode'           => ( $request->get_param( 'mode' ) === 'test' ? 'sandbox' : 'live' ),
					'log.LogEnabled' => false,
					'cache.enabled'  => false,
				)
			);

			$output = $signatureVerification->post( $apiContext );

		} catch ( \Exception $ex ) {
			// (Audit S14) Don't silently swallow — log so failed PayPal verifications
			// don't vanish into the void. Still exit (PayPal expects 200 on dropped
			// verifications, and surfacing 5xx would just trigger PayPal retries).
			$log = new PP_Model_Log(
				array(
					'post_id'   => 0,
					'namespace' => 'webhook',
					'action'    => 'paypal.verify_signature_failed',
					'content'   => array(
						'mode'    => $request->get_param( 'mode' ),
						'message' => $ex->getMessage(),
					),
				)
			);
			$log->save();
			exit;
		}

		$sigVerificationResult = $output->getVerificationStatus();

		if ( $sigVerificationResult == 'SUCCESS' ) {
			$requestBodyDecode = json_decode( $requestBody, ARRAY_A );

			// (Audit S14) Guard the nested array path — malformed PayPal payloads or
			// non-order webhook event types would throw an "undefined array key" PHP 8
			// warning that under failOnWarning PHPUnit / production WP_DEBUG flips to fatal.
			if ( ! is_array( $requestBodyDecode )
				|| ! isset( $requestBodyDecode['resource']['supplementary_data']['related_ids']['order_id'] )
				|| ! isset( $requestBodyDecode['resource']['id'] ) ) {
				$log = new PP_Model_Log(
					array(
						'post_id'   => 0,
						'namespace' => 'webhook',
						'action'    => 'paypal.malformed_payload',
						'content'   => array(
							'mode' => $request->get_param( 'mode' ),
						),
					)
				);
				$log->save();
				exit;
			}

			$order_id       = $requestBodyDecode['resource']['supplementary_data']['related_ids']['order_id'];
			$transaction_id = $requestBodyDecode['resource']['id'];

			if ( $request->get_param( 'mode' ) === 'test' ) {
				$environment = new \PayPalCheckoutSdk\Core\SandboxEnvironment(
					payment_page_setting_get( 'paypal_' . $request->get_param( 'mode' ) . '_client_id' ),
					payment_page_setting_get( 'paypal_' . $request->get_param( 'mode' ) . '_secret' )
				);
			} else {
				$environment = new \PayPalCheckoutSdk\Core\ProductionEnvironment(
					payment_page_setting_get( 'paypal_' . $request->get_param( 'mode' ) . '_client_id' ),
					payment_page_setting_get( 'paypal_' . $request->get_param( 'mode' ) . '_secret' )
				);
			}

			$client = new \PayPalCheckoutSdk\Core\PayPalHttpClient( $environment );

			try {
				$order_information = $client->execute( new \PayPalCheckoutSdk\Orders\OrdersGetRequest( $order_id ) );

				$description_raw = isset( $order_information->result->purchase_units[0]->description )
					? (string) $order_information->result->purchase_units[0]->description
					: '';
				$description     = explode( ':', $description_raw );

				// (Audit S14) Guard array indices — a malformed description (or a PayPal
				// order that doesn't follow PP's "<domain>:<payment_id>" convention) was
				// previously a PHP-8 "undefined array key" warning that flipped to fatal
				// under WP_DEBUG.
				if ( ! isset( $description[0] ) || ! isset( $description[1] ) ) {
					$log = new PP_Model_Log(
						array(
							'post_id'   => 0,
							'namespace' => 'webhook',
							'action'    => 'paypal.unrecognized_description',
							'content'   => array(
								'mode'        => $request->get_param( 'mode' ),
								'description' => $description_raw,
							),
						)
					);
					$log->save();
					exit;
				}

				if ( strpos( $description[0], payment_page_domain_name() ) === false ) {
					exit;
				}

				$modelPayment = PP_Model_Payments::findOrFail( array( 'id' => intval( $description[1] ) ) );

				// (Audit S2 parity for PayPal) — skip dispatch if already paid.
				if ( intval( $modelPayment->is_paid ) === 1 ) {
					exit;
				}

				$modelPayment->amount_received = payment_page_format_price_as_non_decimal_int(
					$order_information->result->purchase_units[0]->amount->value,
					$modelPayment->currency
				);

				$modelPayment->is_paid       = 1;
				$modelPayment->paypal_txn_id = $transaction_id;
				$modelPayment->save();

				self::_onPaymentSuccess( $modelPayment, $order_information );

				/**
				$capture_id = $order_information->result->purchase_units[0]->payments->captures[0]->id;
				$capture_information = $client->execute( new \PayPalCheckoutSdk\Payments\CapturesGetRequest( $capture_id ) );
				*/
			} catch ( \Exception $e ) {
				// (Audit S14) Surface the failure to the PP log table instead of swallowing.
				$log = new PP_Model_Log(
					array(
						'post_id'   => 0,
						'namespace' => 'webhook',
						'action'    => 'paypal.order_capture_failed',
						'content'   => array(
							'mode'           => $request->get_param( 'mode' ),
							'order_id'       => $order_id,
							'transaction_id' => $transaction_id,
							'message'        => $e->getMessage(),
						),
					)
				);
				$log->save();
			}
		}
	}

  public static function _onPaymentSuccess( PP_Model_Payments $modelPayment, $gateway_args ) {
    // (Phase 45 P3) Observability hook. Tags include the gateway + event
    // type so the test suite can assert success-path coverage by type.
    do_action( 'pts_increment_counter', 'pts.webhook.payment_success', array(
      'gateway'    => (string) $modelPayment->payment_gateway,
      'event_type' => is_object( $gateway_args ) && isset( $gateway_args->type ) ? (string) $gateway_args->type : 'unknown',
      'mode'       => $modelPayment->is_live ? 'live' : 'test',
    ));

    $request_data = array(
      'gateway'         => $modelPayment->payment_gateway,
      'method'          => $modelPayment->payment_method,
      'mode'            => $modelPayment->is_live ? 'live' : 'test',
      'name'            => $modelPayment->first_name . ' ' . $modelPayment->last_name,
      'email'           => $modelPayment->email_address,
      'amount'          => $modelPayment->amount,
      'amount_received' => $modelPayment->amount_received,
      'currency'        => $modelPayment->currency,
    );

    $request_data += _payment_page_payment_identification_fields( $modelPayment->post_id, $modelPayment->id );

    foreach ( $modelPayment->metadata_json as $k => $v ) {
      $request_data[ $k ] = $v;
    }

    if ( $modelPayment->payment_gateway === 'stripe' ) {
      if ( $gateway_args->type === 'payment_intent.succeeded' ) {
        do_action( 'payment_page_payment_received', $request_data, array( 'stripe' => $gateway_args->data ) );
      }

      if ( $gateway_args->type === 'setup_intent.succeeded' ) {
        do_action( 'payment_page_subscription_created', $request_data, array( 'stripe' => $gateway_args->data ) );
      }
    } else {
      do_action( 'payment_page_payment_received', $request_data, array( 'paypal' => $gateway_args->result ) );
    }

    $page_settings = get_post_meta( $modelPayment->post_id, '_elementor_data', true );

    if ( !empty( $page_settings ) ) {
      $page_settings = json_decode( $page_settings, true );

      if ( !empty( $page_settings ) ) {
        $paymentPageElements = self::_extractPaymentPageElements( $page_settings );

        foreach ( $paymentPageElements as $paymentPageElement ) {
          self::_onPaymentSuccessWithPageSettings( $modelPayment, $gateway_args, $paymentPageElement, $request_data );
        }
      }
    }

    $page_settings = get_post_meta( $modelPayment->post_id, 'payment_page_settings', true );

    if(!empty($page_settings)) {
      self::_onPaymentSuccessWithPageSettings( $modelPayment, $gateway_args, $page_settings, $request_data );
    }

  }



	/**
	 * Not Private, can be used to debug if wanted.
	 *
	 * @param PP_Model_Payments $modelPayment
	 * @param $gateway_args
	 * @return void
	 * @throws \Exception
	 */
  private static function _onPaymentSuccessWithPageSettings( PP_Model_Payments $modelPayment, $gateway_args, $page_settings, $request_data ) {
    if ( ! isset( $page_settings['submit_actions'] ) || empty( $page_settings['submit_actions'] ) ) {
      return;
    }

    if ( in_array( 'http_request', $page_settings['submit_actions'] ) && isset( $page_settings['http_request_url'] ) && ! empty( $page_settings['http_request_url'] ) ) {
      $url = $page_settings['http_request_url'];

      if( is_array( $url ) && isset( $url['url'] ) )
        $url = $url['url'];

      if(!empty($url)) {
        $log = new PP_Model_Log(
          array(
            'post_id'   => $modelPayment->post_id,
            'namespace' => 'form',
            'action'    => 'http_request',
            'content'   => array(
              'request_response' => null,
              'request_data'     => $request_data,
            ),
          )
        );

        $log->save();

        $log->content['request_response'] = wp_remote_post(
          $url,
          array(
            'body' => $request_data,
          )
        );

        $log->save();
      }
    }

    if ( in_array( 'email', $page_settings['submit_actions'] )
        && isset( $page_settings['email_to'] )
        && isset( $page_settings['email_subject_payer'] )
        && isset( $page_settings['email_subject_admin'] ) ) {

      $headers = array( 'Content-Type: text/html; charset=UTF-8' );

      $from_header = ConfirmationEmail::build_from_header( $page_settings );
      if ( $from_header !== '' ) {
        $headers[] = $from_header;
      }

      $admin_body = ConfirmationEmail::build_body( 'admin', $page_settings, $modelPayment, $request_data );
      $payer_body = ConfirmationEmail::build_body( 'payer', $page_settings, $modelPayment, $request_data );

      foreach ( explode( ',', $page_settings['email_to'] ) as $email ) {
        $email = trim( $email );
        if ( $email === '' ) {
          continue;
        }
        ConfirmationEmail::with_plaintext_alt(
          $admin_body,
          static function () use ( $email, $page_settings, $headers, $admin_body ) {
            return wp_mail(
              $email,
              $page_settings['email_subject_admin'],
              $admin_body,
              $headers
            );
          }
        );
      }

      ConfirmationEmail::with_plaintext_alt(
        $payer_body,
        static function () use ( $modelPayment, $page_settings, $headers, $payer_body ) {
          return wp_mail(
            $modelPayment->email_address,
            $page_settings['email_subject_payer'],
            $payer_body,
            $headers
          );
        }
      );
    }
	}

	private static function _extractPaymentPageElements( $page_settings ) {
		$response = array();

		foreach ( $page_settings as $page_setting ) {
			if ( isset( $page_setting['elements'] ) && ! empty( $page_setting['elements'] ) ) {
				$response += self::_extractPaymentPageElements( $page_setting['elements'] );

				continue;
			}

			if ( isset( $page_setting['widgetType'] ) && $page_setting['widgetType'] === 'payment-form' ) {
				$response[ $page_setting['id'] ] = $page_setting['settings'];
			}
		}

		return $response;
	}

}
