<?php

namespace PaymentPage\RestAPI;

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

use PaymentPage\PaymentGateway as PaymentGateway;
use WP_Error;
use WP_REST_Server;
use WP_REST_Request;
use PaymentPage\Model\Payments as Model_Payments;

class Payment {

	/**
	 * (Audit S12) Per-IP rate limit for the unauthenticated /payment/sync-details endpoint.
	 * Caps row creation by anonymous callers; prevents DB flooding via repeated sync calls.
	 */
	const SYNC_DETAILS_RATE_LIMIT_PER_MINUTE = 12;

	public static function register_routes() {
		register_rest_route(
			PAYMENT_PAGE_REST_API_PREFIX . '/v1',
			'/payment/sync-details',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => '\PaymentPage\RestAPI\Payment::sync_details',
				'permission_callback' => '__return_true',
			)
		);
	}

	public static function sync_details( WP_REST_Request $request ) {
		foreach ( array( 'first_name', 'last_name', 'email_address', 'payment_gateway', 'payment_method', 'post_id', 'title' ) as $required_param ) {
			if ( ! $request->has_param( $required_param ) ) {
				return new WP_Error(
					'rest_error',
					/* translators: %s: request parameter name */
					esc_html( sprintf( __( 'Missing request param %s', 'payment-page' ), $required_param ) ),
					array(
						'status' => 400,
					)
				);
			}
		}

		// (Audit S12) Throttle anonymous row-creation by IP. Existing-row updates
		// (signed by id+secret) skip the throttle since they don't create new rows.
		$is_existing_row = $request->has_param( '_current_id' ) && $request->has_param( '_current_secret' );

		if ( ! $is_existing_row && ! self::_check_sync_details_rate_limit() ) {
			return new WP_Error(
				'rest_error',
				esc_html( __( 'Too many requests. Please try again in a minute.', 'payment-page' ) ),
				array(
					'status' => 429,
				)
			);
		}

		if ( $is_existing_row ) {
			if ( hash_equals( self::_compute_payment_secret( $request->get_param( '_current_id' ) ), (string) $request->get_param( '_current_secret' ) ) ) {
				try {
					$model = Model_Payments::findOrFail( array( 'id' => intval( $request->get_param( '_current_id' ) ) ) );
				} catch ( \Exception $e ) {
					$model = new Model_Payments( array() );
				}
			} else {
				return new WP_Error(
					'rest_error',
					esc_html( __( 'Invalid id & secret combination', 'payment-page' ) ),
					array(
						'status' => 400,
					)
				);
			}
		} else {
			$model = new Model_Payments( array() );
		}

		if ( $model->exists() && intval( $model->is_paid ) ) {
			return new WP_Error(
				'rest_error',
				/* translators: %s: payment details */
				esc_html( __( 'Already paid, cannot update details %s', 'payment-page' ) ),
				array(
					'status' => 400,
				)
			);
		}

		// (Audit S13 + F007) Sanitize identity fields on write. Without this, customer-
		// submitted name/email values flow unescaped into the wp_mail() body that admins
		// receive on payment success (where Webhook.php now esc_html()'s them — but
		// defense-in-depth: sanitize at the boundary AND escape on render).
		$current_user = wp_get_current_user();
		$model->populate(
			array(
				'post_id'         => intval( $request->get_param( 'post_id' ) ),
				'user_id'         => $current_user->ID,
				'wp_user_email'   => $current_user->ID !== 0 ? $current_user->user_email : '',
				'email_address'   => self::_sanitize_email_param( $request->get_param( 'email_address' ) ),
				'first_name'      => sanitize_text_field( wp_unslash( (string) $request->get_param( 'first_name' ) ) ),
				'last_name'       => sanitize_text_field( wp_unslash( (string) $request->get_param( 'last_name' ) ) ),
				'payment_gateway' => sanitize_key( (string) $request->get_param( 'payment_gateway' ) ),
				'payment_method'  => sanitize_key( (string) $request->get_param( 'payment_method' ) ),
				'metadata_json'   => _payment_page_rest_api_custom_fields( $request ),
				'amount'          => payment_page_format_price_as_non_decimal_int(
					floatval( $request->get_param( 'price' ) ),
					$request->get_param( 'currency' )
				),
				'currency'        => sanitize_text_field( wp_unslash( (string) $request->get_param( 'currency' ) ) ),
				'description'     => sanitize_text_field( wp_unslash( (string) $request->get_param( 'title' ) ) ),
				'is_live'         => ( PaymentGateway::get_integration_from_settings( $request->get_param( 'payment_gateway' ) )->is_live() ? 1 : 0 ),
			)
		);
		$model = $model->save();

		return rest_ensure_response(
			array(
				'id'     => $model->id,
				'secret' => self::_compute_payment_secret( $model->id ),
			)
		);
	}

	/**
	 * (Audit F005) Replaces the legacy md5(NONCE_SALT . $id) HMAC-by-concatenation
	 * with hash_hmac('sha256', $id, wp_salt('auth')). wp_salt('auth') returns
	 * AUTH_KEY + AUTH_SALT (canonical WP site-secret derivation); SHA-256 + proper
	 * HMAC removes the broken-md5 + length-extension footgun.
	 *
	 * If wp-config.php is missing salts entirely (the prior implementation used a
	 * hardcoded literal-string fallback — globally guessable on misconfigured sites),
	 * we now fail closed by returning an empty string. Any sync-details follow-up
	 * request will fail the hash_equals check at line 64. A Site Health warning is
	 * registered elsewhere to surface the misconfiguration to the admin.
	 */
	private static function _compute_payment_secret( $payment_id ) {
		$id = intval( $payment_id );

		if ( $id <= 0 ) {
			return '';
		}

		$salt = wp_salt( 'auth' );

		if ( $salt === '' ) {
			// (Audit F005) Fail closed instead of the prior literal-salt fallback that
			// made the secret globally guessable on misconfigured sites.
			return '';
		}

		return hash_hmac( 'sha256', (string) $id, $salt );
	}

	/**
	 * (Audit F007/S13) Sanitize an email address from the request payload. Returns
	 * empty string if invalid — caller treats empty email as "missing recipient"
	 * downstream (Webhook.php's wp_mail() to a empty address is a no-op).
	 */
	private static function _sanitize_email_param( $value ) {
		$email = sanitize_email( wp_unslash( (string) $value ) );

		if ( $email === '' || ! is_email( $email ) ) {
			return '';
		}

		return $email;
	}

	/**
	 * (Audit S12) Per-IP rate limiter for the unauthenticated POST /payment/sync-details
	 * endpoint. Caps anonymous row creation at SYNC_DETAILS_RATE_LIMIT_PER_MINUTE per
	 * 60 seconds. Uses a wp_options-backed transient bucket keyed by hashed IP.
	 *
	 * Returns true if the request is within budget, false to reject with 429.
	 */
	private static function _check_sync_details_rate_limit() {
		$ip = function_exists( 'payment_page_http_ip_address' )
			? (string) payment_page_http_ip_address()
			: ( isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : '' );

		if ( $ip === '' ) {
			// No identifiable client — allow (fail-open so trusted-proxy edge cases
			// where REMOTE_ADDR is stripped don't accidentally 429 everyone).
			return true;
		}

		$bucket_key = 'payment_page_sync_rate_' . md5( $ip );
		$count      = (int) get_transient( $bucket_key );

		if ( $count >= self::SYNC_DETAILS_RATE_LIMIT_PER_MINUTE ) {
			// (Phase 45 P3) Observability hook — counts rate-limit drops.
			// Tag: hashed IP bucket (8 chars — enough to correlate dropped
			// requests without leaking client IP). Production: zero listeners
			// → zero overhead. Test suite asserts drops fire on the 13th
			// request from the same IP per minute.
			do_action( 'pts_increment_counter', 'pts.rate_limit.dropped', array(
				'ip_bucket' => substr( md5( $ip ), 0, 8 ),
				'limit'     => (string) self::SYNC_DETAILS_RATE_LIMIT_PER_MINUTE,
			) );
			return false;
		}

		set_transient( $bucket_key, $count + 1, MINUTE_IN_SECONDS );

		return true;
	}

}
