<?php
declare(strict_types=1);

namespace DMRS\MBS\Api\License;

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

/**
 * File Path: includes/api/license/class-mbs-license-request-log.php
 *
 * License Request Log Manager
 *
 * Logs all license API requests for debugging and tamper detection. Keeps the
 * 50 most recent entries in wp_options (single row, not a custom table).
 *
 * Canonical write path: MBS_License_Request_Log::log_and_return() is the
 * static helper called from every API flow (activate, deactivate, register,
 * validate, update_check) AFTER the wp_remote_post call. It handles both
 * the WP_Error and success branches and strips sensitive fields from both
 * the request params (license_key, api_secret) and the response (api_secret,
 * token) before persisting the entry.
 *
 * Direct calls to log_api_request() are still valid for callers that have
 * already unpacked the response — the static helper is a convenience wrapper,
 * not a replacement.
 *
 * @package MyBookShowroom
 * @since 1.0.0
 */

/** Class MBS_License_Request_Log */
final class MBS_License_Request_Log {

	private const OPTION_KEY  = 'mbs_license_request_log';
	private const MAX_ENTRIES = 50;

	// -------------------------------------------------------------------------
	// Public API
	// -------------------------------------------------------------------------

	/**
	 * Log the outcome of a wp_remote_post call, handling both success and error.
	 *
	 * Accepts the raw wp_remote_post return value (WP_Error or array), the
	 * canonical action name, the outbound request body, and measured duration.
	 * Internally handles sanitization — license_key + api_secret stripped from
	 * params; api_secret + token stripped from response.
	 *
	 * Call this AFTER wp_remote_post; does not modify the response passed in.
	 *
	 * @param string         $action       Canonical action name (activate, deactivate, register, validate, update_check).
	 * @param array|\WP_Error $response     Raw wp_remote_post return value.
	 * @param float          $duration     Time in seconds, from microtime(true) diff.
	 */
	public static function log_and_return(
		string $action,
		array $request_body,
		$response,
		float $duration
	): void {
		$logger = new self();

		if ( is_wp_error( $response ) ) {
			$logger->log_api_request( $action, $request_body, array(
				'http_code' => 0,
				'error'     => $response->get_error_message(),
			), $duration );
			return;
		}

		$http_code = (int) wp_remote_retrieve_response_code( $response );
		$body      = wp_remote_retrieve_body( $response );
		$data      = json_decode( $body, true );

		$log_response              = is_array( $data ) ? $data : array();
		$log_response['http_code'] = $http_code;

		$logger->log_api_request( $action, $request_body, $log_response, $duration );
	}

	/**
	 * Log an API request (low-level; prefer log_and_return for wp_remote_post
	 * flows).
	 *
	 * @param string $action   Action name (register, activate, validate, etc.).
	 * @param array  $params   Request parameters — license_key + api_secret stripped before logging.
	 * @param array  $response API response — api_secret + token stripped before logging.
	 * @param float  $duration Request duration in seconds.
	 */
	public function log_api_request( string $action, array $params, array $response, float $duration = 0.0 ): bool {
		$log_entry = array(
			'timestamp' => current_time( 'mysql' ),
			'action'    => sanitize_key( $action ),
			'params'    => $this->sanitize_log_params( $params ),
			'response'  => $this->sanitize_log_response( $response ),
			'duration'  => round( $duration, 4 ),
			'ip_hash'   => wp_hash( $this->get_client_ip() ),
		);

		$logs = $this->get_logs( self::MAX_ENTRIES );
		array_unshift( $logs, $log_entry );

		if ( count( $logs ) > self::MAX_ENTRIES ) {
			$logs = array_slice( $logs, 0, self::MAX_ENTRIES );
		}

		return (bool) update_option( self::OPTION_KEY, $logs, false );
	}

	/**
	 * Retrieve log entries.
	 *
	 * @param int $limit Maximum number of entries to return.
	 * @return array
	 */
	public function get_logs( int $limit = 20 ): array {
		$logs = get_option( self::OPTION_KEY, array() );
		return is_array( $logs ) ? array_slice( $logs, 0, $limit ) : array();
	}

	/** Clear all log entries. */
	public function clear(): bool {
		return delete_option( self::OPTION_KEY );
	}

	// -------------------------------------------------------------------------
	// Private helpers
	// -------------------------------------------------------------------------

	/**
	 * Resolve the real client IP, checking X-Forwarded-For first.
	 *
	 * The value is hashed before storage so no plaintext IP is persisted.
	 *
	 * @return string IP address string (not yet hashed).
	 */
	private function get_client_ip(): string {
		// phpcs:disable WordPress.Security.ValidatedSanitizedInput
		if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
			// X-Forwarded-For can be a comma-separated list: client, proxy1, proxy2
			$forwarded = explode( ',', wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
			$ip        = trim( $forwarded[0] );
			if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
				return $ip;
			}
		}

		return isset( $_SERVER['REMOTE_ADDR'] )
			? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
			: 'unknown';
		// phpcs:enable
	}

	/**
	 * Sanitize parameters before logging. Never log the raw license key.
	 *
	 * Applies the same recursive array guard as sanitize_log_response().
	 * The flat array_map('sanitize_text_field') was fatal when
	 * file_hashes — an array value — was present in the params array.
	 *
	 * @return array
	 */
	private function sanitize_log_params( array $params ): array {
		unset( $params['license_key'] );
		unset( $params['api_secret'] );
		unset( $params['hmac'] );
		return array_map(
			function( $value ) {
				return is_array( $value )
					? array_map( 'sanitize_text_field', $value )
					: sanitize_text_field( (string) $value );
			},
			$params
		);
	}

	/**
	 * Sanitize response before logging.
	 *
	 * @return array
	 */
	private function sanitize_log_response( array $response ): array {
		// Strip any secret values that could appear in responses.
		unset( $response['api_secret'] );
		unset( $response['token'] );
		// CQ-06: Guard against nested arrays — array_map('sanitize_text_field')
		// throws a fatal error when a value is itself an array.
		return array_map(
			function( $value ) {
				return is_array( $value )
					? array_map( 'sanitize_text_field', $value )
					: sanitize_text_field( (string) $value );
			},
			$response
		);
	}
}
