<?php
/**
 * Abstract webhook class.
 */

declare(strict_types=1);
namespace FeData\FastEvents\Hooks\Webhooks;

defined( 'ABSPATH' ) || exit;

use FeData\FastEvents\Logging\LoggerFactory;
use FeData\FastEvents\Storage\PluginSettings;
use FeData\FastEvents\Utilities\JsonUtil;
use FeData\FastEvents\Utilities\PluginUtil;
use FeData\FastEvents\Utilities\TimeUtil;
use stdClass;
use WP_Error;

/**
 * Abstract webhook class.
 */
abstract class AbstractWebhook implements WebhookInterface {


	/**
	 * Retry scheme.
	 *
	 * @since 1.0
	 * @var array
	 */
	private array $retry_scheme = array();

	/**
	 * Retry position.
	 *
	 * @since 1.0
	 * @var int
	 */
	private int $retry_position = -1;

	/**
	 * Webhook argument.
	 *
	 * @since 1.0
	 * @var mixed
	 */
	private mixed $arg;

	/**
	 * Webhooks details
	 *
	 * @since 1.0
	 * @var stdClass
	 */
	protected stdClass $webhook;


	/**
	 * AbstractMailer constructor.
	 *
	 * @since 1.0
	 *
	 * @param PluginSettings $settings The settings.
	 * @param LoggerFactory  $logger   The logger.
	 */
	public function __construct(
		protected readonly PluginSettings $settings,
		protected readonly LoggerFactory $logger
	) {}
	/**
	 * Deliver the webhook.
	 *
	 * @since 1.0
	 * @since 2.0.3 Timeout set to 10 seconds
	 * @since 2.3.0 Use fast_events_json_encode.
	 *
	 * @param array $payload   Payload to send.
	 */
	protected function deliver( array $payload ): void {
		$start_time = microtime( true );

		// Setup request args.
		$body        = trim( JsonUtil::fast_events_json_encode( $payload ) );
		$delivery_id = wp_hash( $this->webhook->id . strtotime( 'now' ) );
		$http_args   = array(
			'method'     => 'POST',
			'timeout'    => 10,
			'user-agent' => PluginUtil::get_user_agent(),
			'body'       => $body,
			'headers'    => array(
				'Content-Type'             => 'application/json',
				'X-FE-Webhook-Source'      => home_url( '/' ),
				'X-FE-Webhook-Topic'       => $this->webhook->topic,
				'X-FE-Webhook-Resource'    => $this->get_resource(),
				'X-FE-Webhook-Event'       => $this->get_event(),
				'X-FE-Webhook-ID'          => (int) $this->webhook->id,
				'X-FE-Webhook-Delivery-ID' => $delivery_id,
				'X-FE-Webhook-Signature'   => $this->generate_signature( $body ),
			),
			'cookies'    => array(),
		);

		// Call webhook.
		$response = wp_safe_remote_request( $this->webhook->url, $http_args );

		// Get duration.
		$duration = round( ( microtime( true ) - $start_time ), 5 );

		// Logging and check for success or retry.
		$this->log_delivery( $delivery_id, $http_args, $response, $duration );
	}


	/**
	 * Get the resource for the webhook, e.g. `order`.
	 *
	 * @since  1.0
	 *
	 * @return string
	 */
	private function get_resource(): string {
		$topic = explode( '.', $this->webhook->topic );

		return $topic[0];
	}


	/**
	 * Get the event for the webhook, e.g. `created`.
	 *
	 * @since  1.0
	 *
	 * @return string
	 */
	private function get_event(): string {
		$topic = explode( '.', $this->webhook->topic );

		return $topic[1] ?? '';
	}

	/**
	 * Generate a base64-encoded HMAC-SHA256 signature of the payload body so the
	 * recipient can verify the authenticity of the webhook. Note that the signature
	 * is calculated after the body has already been encoded (JSON by default).
	 *
	 * @since  1.0
	 *
	 * @param  string $payload Payload data to hash.
	 *
	 * @return string
	 */
	private function generate_signature( string $payload ): string {

		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
		return base64_encode( hash_hmac( 'sha256', $payload, $this->webhook->secret, true ) );
	}


	/**
	 * Log the delivery request/response.
	 *
	 * @since 1.0
	 * @since 2.3.0 Use fast_events_json_encode.
	 *
	 * @param string         $delivery_id Previously created hash.
	 * @param array          $request     Request data.
	 * @param array|WP_Error $response    Response data.
	 * @param float          $duration    Request duration.
	 */
	private function log_delivery( string $delivery_id, array $request, WP_Error|array $response, float $duration ): void {

		$message = array(
			'Webhook Delivery' => array(
				'Delivery ID' => $delivery_id,
				'Date'        => gmdate( 'Y-m-d H:i:s' ) . ' GMT',
				'URL'         => $this->webhook->url,
				'Duration'    => $duration . ' seconds',
				'Request'     => array(
					'Method'  => $request['method'],
					'Headers' => array_merge(
						array(
							'User-Agent' => $request['user-agent'],
						),
						$request['headers']
					),
				),
				'Body'        => wp_slash( $request['body'] ),
			),
		);

		// Parse response.
		if ( is_wp_error( $response ) ) {
			$response_code    = $response->get_error_code();
			$response_message = $response->get_error_message();
			$response_headers = array();
			$response_body    = '';
		} else {
			$response_code    = wp_remote_retrieve_response_code( $response );
			$response_message = wp_remote_retrieve_response_message( $response );
			$response_headers = wp_remote_retrieve_headers( $response );
			$response_body    = wp_remote_retrieve_body( $response );
		}

		$message['Webhook Delivery']['Response'] = array(
			'Code'    => $response_code,
			'Message' => $response_message,
			'Headers' => $response_headers,
			'Body'    => $response_body,
		);

		if ( ! WP_DEBUG ) {
			$message['Webhook Delivery']['Body']             = 'Webhook body is not logged unless WP_DEBUG mode is turned on. This is to avoid the storing of personal data in the logs.';
			$message['Webhook Delivery']['Response']['Body'] = 'Webhook body is not logged unless WP_DEBUG mode is turned on. This is to avoid the storing of personal data in the logs.';
		}

		// Track success and failures.
		// Check for a success, which is a 2xx, 301 or 302 Response Code.
		if ( intval( $response_code ) >= 200 && intval( $response_code ) < 303 ) {
			$this->set_success_counters();
		} else {

			// Always log failures.
			$this->logger->alert(
				'Webhook delivery failed:',
				array(
					'type'  => 'Webhook',
					'debug' => $message,
				)
			);
			$this->failed_delivery();
		}
	}


	/**
	 * Decrease the 'pending' counter and increase the 'delivered' counter
	 *
	 * @since 1.0
	 * @since 2.3.0 Use fast_events_json_encode.
	 */
	private function set_success_counters(): void {
		global $wpdb;

		$result = $wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->prefix}fe_event_webhooks SET pending=GREATEST(0, pending-1), delivered=delivered+1, last_success=NOW() WHERE id=%d",
				(int) $this->webhook->id
			)
		);
		if ( false === $result || 0 === $result ) {
			$this->logger->error(
				'Failed to update webhook counters',
				array(
					'type'       => 'Webhook',
					'webhook-id' => (int) $this->webhook->id,
					'url'        => $this->webhook->url,
					'topic'      => $this->webhook->topic,
					'error'      => $wpdb->last_error,
				)
			);
		}
	}


	/**
	 * Delivery failed. Update counters and check if we need to retry.
	 *
	 * @since 1.0
	 * @since 2.3.0 Use fast_events_json_encode.
	 */
	private function failed_delivery(): void {
		global $wpdb;

		// Is this the first retry?
		if ( -1 === $this->retry_position ) {
			$this->retry_scheme = array_map( 'intval', explode( ',', $this->webhook->retry_scheme ) );
		}
		$cnt = count( $this->retry_scheme ) - 1;

		// Are we still not at the end of the retry scheme?
		if ( $this->retry_position < $cnt ) {
			$query = 'UPDATE ' . $wpdb->prefix . 'fe_event_webhooks SET retries=retries+1, last_retry=NOW()';
			++$this->retry_position;
			$new_retry_timestamp = TimeUtil::utc_time() + ( $this->retry_scheme[ $this->retry_position ] * MINUTE_IN_SECONDS );

			// Schedule a future action retrying to send the webhook.
			as_schedule_single_action(
				$new_retry_timestamp,
				'fast_events_action_webhook',
				array(
					'webhook_id'     => (int) $this->webhook->id,
					'arg'            => $this->arg,
					'retry_scheme'   => $this->retry_scheme,
					'retry_position' => $this->retry_position,
				),
				'fast-events-webhook-' . $this->webhook->topic
			);
		} else {
			$this->logger->alert(
				'** Delivery failed after ' . ( $this->retry_position + 1 ) . ' retries **',
				array(
					'type'       => 'Webhook',
					'webhook-id' => (int) $this->webhook->id,
					'name'       => $this->webhook->name,
					'url'        => $this->webhook->url,
					'topic'      => $this->webhook->topic,
				),
			);
			$query = 'UPDATE ' . $wpdb->prefix . 'fe_event_webhooks SET failed=failed+1, pending=GREATEST(0, pending-1), last_failure=NOW()';

			// Check if we reached the failure threshold.
			if ( ( (int) $this->webhook->failed + 1 ) > (int) $this->webhook->failure_threshold ) {
				$query .= ', status=0';
			}
		}

		// Update counters.
		$query .= ' WHERE id=' . $this->webhook->id;
		$result = $wpdb->query( $wpdb->prepare( $query ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		if ( false === $result || 0 === $result ) {
			$this->logger->warning(
				'Increase failure counter webhook error: ' . $wpdb->last_error,
				array(
					'type'       => 'Webhook',
					'webhook-id' => (int) $this->webhook->id,
					'name'       => $this->webhook->name,
					'url'        => $this->webhook->url,
					'topic'      => $this->webhook->topic,
				),
			);
		}
	}

	/**
	 * Set retry position.
	 *
	 * @since 3.0.0
	 *
	 * @param int $position Retry position.
	 */
	public function set_retry_position( int $position ): void {
		$this->retry_position = $position;
	}

	/**
	 * Set retry scheme.
	 *
	 * @since 3.0.0
	 *
	 * @param array $retry_scheme Retry position.
	 */
	public function set_retry_scheme( array $retry_scheme ): void {
		$this->retry_scheme = $retry_scheme;
	}

	/**
	 * Set arguments of the webhook action.
	 *
	 * @since 3.0.0
	 *
	 * @param mixed $arg Webhook arguments.
	 */
	public function set_arg( mixed $arg ): void {
		$this->arg = $arg;
	}
}
