<?php

declare( strict_types=1 );

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

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

/**
 * Handles payment configuration and payment record operations.
 */
class Playard_Payment {

	/**
	 * Validates and saves Stripe API keys.
	 *
	 * @param array<string,mixed> $data
	 */
	public function save_stripe_keys( array $data ): bool {
		$publishable = sanitize_text_field( (string) ( $data['stripe_publishable_key'] ?? '' ) );
		$secret      = sanitize_text_field( (string) ( $data['stripe_secret_key'] ?? '' ) );
		$test_mode   = (bool) ( $data['test_mode'] ?? false );

		if ( empty( $publishable ) || empty( $secret ) ) {
			return false;
		}

		// Validate key format matches selected mode
		if ( $test_mode ) {
			if ( ! str_starts_with( $publishable, 'pk_test_' ) ) return false;
			if ( ! str_starts_with( $secret, 'sk_test_' ) )      return false;
		} else {
			if ( ! str_starts_with( $publishable, 'pk_live_' ) ) return false;
			if ( ! str_starts_with( $secret, 'sk_live_' ) )      return false;
		}

		update_option( 'playard_stripe_publishable_key', $publishable, 'no' );
		update_option( 'playard_stripe_secret_key', $this->encrypt( $secret ), 'no' );
		update_option( 'playard_stripe_test_mode', $test_mode, 'no' );
		update_option( 'playard_payment_method', 'stripe', 'no' );

		// Mirror into playard_settings so the Settings > Payments page displays the keys correctly.
		$settings                              = (array) get_option( 'playard_settings', [] );
		$mode_key                              = $test_mode ? 'test' : 'live';
		$existing                              = (array) ( $settings['payments']['stripe'] ?? [] );
		$settings['payments']['stripe']        = array_merge( $existing, [
			'enabled'                            => true,
			'mode'                               => $test_mode ? 'test' : 'live',
			$mode_key . '_publishable_key'       => $publishable,
			$mode_key . '_secret_key'            => $secret,
			'checkout_mode'                      => $existing['checkout_mode'] ?? 'embedded',
		] );
		update_option( 'playard_settings', $settings, 'no' );

		do_action( 'playard_stripe_keys_saved', $test_mode );

		return true;
	}

	public function save_cash_only(): void {
		update_option( 'playard_payment_method', 'cash', 'no' );
		do_action( 'playard_cash_payment_set' );
	}

	/**
	 * Returns the active payment method ('stripe', 'cash', or '').
	 */
	public function get_active_method(): string {
		return (string) get_option( 'playard_payment_method', '' );
	}

	/**
	 * Returns the Stripe secret key from settings (test or live based on mode).
	 * Falls back to the legacy encrypted option for existing installations.
	 * Never output this to any frontend.
	 */
	public function get_stripe_secret_key(): string {
		$settings = (array) get_option( 'playard_settings', [] );
		$stripe   = $settings['payments']['stripe'] ?? [];
		$mode     = $stripe['mode'] ?? 'test';
		$key      = $mode === 'live'
			? (string) ( $stripe['live_secret_key'] ?? '' )
			: (string) ( $stripe['test_secret_key'] ?? '' );

		if ( ! empty( $key ) ) {
			return $key;
		}

		// Legacy: key was stored encrypted in a separate option.
		$encrypted = (string) get_option( 'playard_stripe_secret_key', '' );
		return $encrypted ? $this->decrypt( $encrypted ) : '';
	}

	/**
	 * Returns the Stripe publishable key from settings (test or live based on mode).
	 * Falls back to the legacy option for existing installations.
	 */
	public function get_stripe_publishable_key(): string {
		$settings = (array) get_option( 'playard_settings', [] );
		$stripe   = $settings['payments']['stripe'] ?? [];
		$mode     = $stripe['mode'] ?? 'test';
		$key      = $mode === 'live'
			? (string) ( $stripe['live_publishable_key'] ?? '' )
			: (string) ( $stripe['test_publishable_key'] ?? '' );

		if ( ! empty( $key ) ) {
			return $key;
		}

		return (string) get_option( 'playard_stripe_publishable_key', '' );
	}

	public function is_test_mode(): bool {
		return (bool) get_option( 'playard_stripe_test_mode', false );
	}

	/**
	 * Records a payment transaction row.
	 *
	 * @param array<string,mixed> $data
	 * @return int|false
	 */
	public function record_transaction( array $data ): int|false {
		global $wpdb;

		$result = $wpdb->insert(
			$wpdb->prefix . 'playard_payments',
			[
				'booking_id'  => absint( $data['booking_id'] ),
				'amount'      => round( (float) $data['amount'], 2 ),
				'gateway'     => sanitize_text_field( $data['gateway'] ),
				'gateway_ref' => sanitize_text_field( $data['gateway_ref'] ?? '' ),
				'type'        => in_array( $data['type'], [ 'payment', 'refund' ], true ) ? $data['type'] : 'payment',
				'status'      => in_array( $data['status'], [ 'pending', 'success', 'failed' ], true ) ? $data['status'] : 'pending',
			],
			[ '%d', '%f', '%s', '%s', '%s', '%s' ]
		);

		if ( false === $result ) {
			return false;
		}

		$payment_id = (int) $wpdb->insert_id;
		do_action( 'playard_payment_recorded', $payment_id, $data );

		return $payment_id;
	}

	// ── Payment history ───────────────────────────────────────────────────────────

	/**
	 * Returns all payment records for a booking, ordered oldest-first.
	 * Each row: id, booking_id, amount, gateway, gateway_ref, type, status, created_at.
	 *
	 * @return object[]
	 */
	public function get_booking_payments( int $booking_id ): array {
		global $wpdb;

		return (array) $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, booking_id, amount, gateway, gateway_ref, type, status, created_at
				 FROM {$wpdb->prefix}playard_payments
				 WHERE booking_id = %d
				 ORDER BY created_at ASC",
				$booking_id
			)
		);
	}

	// ── Stripe core ──────────────────────────────────────────────────────────────

	/**
	 * Returns true when both Stripe keys are present.
	 */
	public function is_stripe_configured(): bool {
		$settings = (array) get_option( 'playard_settings', [] );
		$stripe   = $settings['payments']['stripe'] ?? [];
		if ( empty( $stripe['enabled'] ) ) {
			return false;
		}
		return ! empty( $this->get_stripe_publishable_key() )
			&& ! empty( $this->get_stripe_secret_key() );
	}

	/**
	 * Returns the available payment gateways for the current facility.
	 * Pro plugin can add PayPal, bKash, Razorpay etc. via the filter.
	 *
	 * @return array<string,array<string,string>>
	 */
	public function get_available_gateways(): array {
		$gateways = [];

		if ( $this->is_stripe_configured() ) {
			$gateways['stripe'] = [
				'id'    => 'stripe',
				'label' => __( 'Pay with card', 'playard' ),
				'icon'  => 'credit-card',
			];
		}

		$gateways['cash'] = [
			'id'    => 'cash',
			'label' => __( 'Pay on arrival', 'playard' ),
			'icon'  => 'banknotes',
		];

		return (array) apply_filters( 'playard_payment_gateways', $gateways );
	}

	// ── Stripe Payment Intent ─────────────────────────────────────────────────

	/**
	 * Creates a Stripe Payment Intent for a pending booking.
	 * Reads amount from the pending booking row — no lock table needed.
	 *
	 * @param  string $booking_ref  The pending booking reference.
	 * @param  string $coupon_code  Optional coupon (passed through filter).
	 * @return array<string,mixed>|WP_Error
	 */
	public function create_stripe_intent( string $booking_ref, string $coupon_code = '' ): array|WP_Error {
		global $wpdb;

		$booking = $wpdb->get_row( $wpdb->prepare(
			"SELECT * FROM {$wpdb->prefix}playard_bookings
			 WHERE booking_ref = %s AND booking_status = 'pending' LIMIT 1",
			sanitize_text_field( $booking_ref )
		) );

		if ( ! $booking ) {
			return new WP_Error( 'booking_not_found', __( 'Booking not found or already confirmed.', 'playard' ) );
		}

		$secret_key = $this->get_stripe_secret_key();
		if ( ! $secret_key ) {
			return new WP_Error( 'payment_error', __( 'Payment not configured.', 'playard' ) );
		}

		$amount   = round( (float) $booking->amount_total, 2 );
		$amount   = (float) apply_filters( 'playard_stripe_intent_amount', $amount, $booking_ref, $coupon_code );
		$amount   = round( $amount, 2 );
		$currency = playard_get_currency();

		$response = wp_remote_post(
			'https://api.stripe.com/v1/payment_intents',
			[
				'headers' => [
					'Authorization'  => 'Bearer ' . $secret_key,
					'Content-Type'   => 'application/x-www-form-urlencoded',
					'Stripe-Version' => '2024-06-20',
				],
				'body' => [
					'amount'                     => $this->to_smallest_unit( $amount, $currency ),
					'currency'                   => $currency,
					'payment_method_types[]'     => 'card',
					'metadata[plugin]'           => 'playard',
					'metadata[booking_ref]'      => $booking_ref,
					'metadata[field_id]'         => (string) $booking->field_id,
				],
				'timeout' => 30,
			]
		);

		if ( is_wp_error( $response ) ) {
			return new WP_Error( 'payment_error', __( 'Payment service unavailable.', 'playard' ) );
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = json_decode( (string) wp_remote_retrieve_body( $response ), true );

		if ( $code !== 200 || isset( $body['error'] ) ) {
			return new WP_Error(
				'payment_error',
				(string) ( $body['error']['message'] ?? __( 'Payment failed.', 'playard' ) )
			);
		}

		// Store the intent ID on the pending booking row so the webhook can find it.
		$wpdb->update(
			$wpdb->prefix . 'playard_bookings',
			[ 'payment_intent_id' => sanitize_text_field( $body['id'] ) ],
			[ 'id' => (int) $booking->id ],
			[ '%s' ],
			[ '%d' ]
		);

		return [
			'client_secret'     => $body['client_secret'],
			'payment_intent_id' => $body['id'],
			'amount'            => $amount,
			'currency'          => $currency,
		];
	}

	/**
	 * Verifies a Stripe Payment Intent is in 'succeeded' status.
	 * Called server-side before confirming a booking.
	 */
	public function verify_stripe_intent( string $intent_id ): bool {
		$secret_key = $this->get_stripe_secret_key();

		if ( ! $secret_key || ! $intent_id ) {
			return false;
		}

		$response = wp_remote_get(
			'https://api.stripe.com/v1/payment_intents/' . rawurlencode( $intent_id ),
			[
				'headers' => [
					'Authorization'  => 'Bearer ' . $secret_key,
					'Stripe-Version' => '2024-06-20',
				],
				'timeout' => 15,
			]
		);

		if ( is_wp_error( $response ) ) {
			return false;
		}

		$body = json_decode( (string) wp_remote_retrieve_body( $response ), true );

		return isset( $body['status'] ) && $body['status'] === 'succeeded';
	}

	/**
	 * Creates a Stripe Payment Intent for the outstanding balance on an existing booking.
	 * Used by the customer portal "Pay Online" flow for cash (pay-on-arrival) bookings.
	 *
	 * @param  object $booking  DB row from playard_bookings.
	 * @return array{ client_secret: string, payment_intent_id: string }|WP_Error
	 */
	public function create_booking_payment_intent( object $booking ): array|WP_Error {
		$amount_due = round( (float) $booking->amount_due, 2 );

		if ( $amount_due <= 0 ) {
			return new WP_Error( 'no_amount', __( 'No outstanding balance on this booking.', 'playard' ) );
		}

		$secret_key = $this->get_stripe_secret_key();

		if ( ! $secret_key ) {
			return new WP_Error( 'payment_error', __( 'Online payment is not configured.', 'playard' ) );
		}

		$currency = playard_get_currency();

		$response = wp_remote_post(
			'https://api.stripe.com/v1/payment_intents',
			[
				'headers' => [
					'Authorization'  => 'Bearer ' . $secret_key,
					'Content-Type'   => 'application/x-www-form-urlencoded',
					'Stripe-Version' => '2024-06-20',
				],
				'body'    => [
					'amount'                 => $this->to_smallest_unit( $amount_due, $currency ),
					'currency'               => $currency,
					'payment_method_types[]' => 'card',
					'metadata[plugin]'       => 'playard',
					'metadata[booking_ref]'  => (string) $booking->booking_ref,
					'metadata[type]'         => 'booking_payment',
				],
				'timeout' => 30,
			]
		);

		if ( is_wp_error( $response ) ) {
			return new WP_Error( 'payment_error', __( 'Payment service unavailable.', 'playard' ) );
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = json_decode( (string) wp_remote_retrieve_body( $response ), true );

		if ( $code !== 200 || isset( $body['error'] ) ) {
			return new WP_Error(
				'payment_error',
				(string) ( $body['error']['message'] ?? __( 'Payment failed.', 'playard' ) )
			);
		}

		return [
			'client_secret'     => (string) $body['client_secret'],
			'payment_intent_id' => (string) $body['id'],
		];
	}

	/**
	 * Converts a decimal amount to the smallest currency unit (e.g. cents).
	 * Zero-decimal currencies (JPY, KRW, etc.) are returned as-is.
	 */
	private function to_smallest_unit( float $amount, string $currency ): int {
		$zero_decimal = [
			'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga',
			'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
		];

		if ( in_array( strtolower( $currency ), $zero_decimal, true ) ) {
			return (int) $amount;
		}

		return (int) round( $amount * 100 );
	}

	/**
	 * Handles an incoming Stripe webhook.
	 * Validates the Stripe-Signature header, dispatches to internal handlers,
	 * and fires do_action hooks for Pro extensibility.
	 * Webhook secret is required — returns WP_Error if not configured.
	 */
	public function handle_webhook( string $payload, string $sig_header ): bool|WP_Error {
		$webhook_secret = (string) get_option( 'playard_stripe_webhook_secret', '' );

		if ( empty( $webhook_secret ) ) {
			return new WP_Error( 'webhook_not_configured', __( 'Webhook signing secret not configured.', 'playard' ) );
		}

		if ( ! $this->verify_webhook_signature( $payload, $sig_header, $webhook_secret ) ) {
			return new WP_Error( 'invalid_signature', __( 'Webhook signature verification failed.', 'playard' ) );
		}

		$event = json_decode( $payload, true );

		if ( ! isset( $event['type'] ) ) {
			return new WP_Error( 'invalid_event', __( 'Invalid Stripe event.', 'playard' ) );
		}

		$type = (string) $event['type'];
		$obj  = (array) ( $event['data']['object'] ?? [] );

		switch ( $type ) {
			case 'payment_intent.succeeded':
				$this->on_payment_succeeded( $obj );
				break;
			case 'payment_intent.payment_failed':
				$this->on_payment_failed( $obj );
				break;
			case 'charge.refunded':
				$this->on_charge_refunded( $obj );
				break;
			case 'charge.dispute.created':
				$this->on_dispute_created( $obj );
				break;
		}

		do_action( 'playard_stripe_webhook_' . str_replace( '.', '_', $type ), $event );
		do_action( 'playard_stripe_webhook', $event );

		return true;
	}

	/**
	 * payment_intent.succeeded — create booking if not already confirmed.
	 * Idempotent: skipped when the frontend already created the booking.
	 *
	 * @param array<string,mixed> $intent  Payment Intent object from Stripe event.
	 */
	private function on_payment_succeeded( array $intent ): void {
		global $wpdb;

		$intent_id = (string) ( $intent['id'] ?? '' );

		if ( ! $intent_id ) {
			return;
		}

		// Walk-in booking awaiting Stripe payment: mark as paid.
		$walkin = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}playard_bookings
				 WHERE payment_intent_id = %s
				   AND booking_status    = 'confirmed'
				   AND payment_status    = 'pending'
				   AND payment_gateway   = 'stripe'
				 LIMIT 1",
				$intent_id
			)
		);

		if ( $walkin ) {
			$wpdb->update(
				$wpdb->prefix . 'playard_bookings',
				[
					'payment_status' => 'paid',
					'amount_paid'    => (float) $walkin->amount_total,
					'amount_due'     => 0.00,
				],
				[ 'id' => (int) $walkin->id ],
				[ '%s', '%f', '%f' ],
				[ '%d' ]
			);

			$this->record_transaction( [
				'booking_id'  => (int) $walkin->id,
				'amount'      => (float) $walkin->amount_total,
				'gateway'     => 'stripe',
				'gateway_ref' => $intent_id,
				'type'        => 'payment',
				'status'      => 'success',
			] );

			do_action( 'playard_stripe_payment_received', (int) $walkin->id, (float) $walkin->amount_total );
			// Fire the standard payment-received hook so the receipt email and customer stats are updated.
			do_action( 'playard_payment_received', (int) $walkin->id, (float) $walkin->amount_total );
			return;
		}

		// Skip if booking already confirmed via frontend.
		$already_confirmed = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$wpdb->prefix}playard_bookings
				 WHERE payment_intent_id = %s AND booking_status = 'confirmed'
				 LIMIT 1",
				$intent_id
			)
		);

		if ( $already_confirmed ) {
			return;
		}

		// Fallback: confirm a pending booking that still has our intent_id
		// (browser crash / network error before frontend called confirm-payment).
		$pending = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}playard_bookings
				 WHERE payment_intent_id = %s AND booking_status = 'pending'
				 LIMIT 1",
				$intent_id
			)
		);

		if ( $pending ) {
			$total = (float) $pending->amount_total;

			$wpdb->update(
				$wpdb->prefix . 'playard_bookings',
				[
					'booking_status' => 'confirmed',
					'payment_status' => 'paid',
					'amount_paid'    => $total,
					'amount_due'     => 0.00,
				],
				[ 'id' => (int) $pending->id ],
				[ '%s', '%s', '%f', '%f' ],
				[ '%d' ]
			);

			$this->record_transaction( [
				'booking_id'  => (int) $pending->id,
				'amount'      => $total,
				'gateway'     => 'stripe',
				'gateway_ref' => $intent_id,
				'type'        => 'payment',
				'status'      => 'success',
			] );

			do_action( 'playard_booking_confirmed', (int) $pending->id );
			do_action( 'playard_payment_received',  (int) $pending->id, $total );
		}
	}

	/**
	 * Creates a Stripe Checkout Session (hosted payment page) for a walk-in booking.
	 * Returns the checkout URL the admin can share with the customer.
	 *
	 * @param  object $booking     DB row from playard_bookings.
	 * @param  string $success_url URL to redirect after successful payment.
	 * @return array{ checkout_url: string, payment_intent_id: string, session_id: string }|WP_Error
	 */
	public function create_stripe_checkout_session( object $booking, string $success_url ): array|WP_Error {
		$amount     = round( (float) $booking->amount_total, 2 );
		$secret_key = $this->get_stripe_secret_key();

		if ( ! $secret_key ) {
			return new WP_Error( 'payment_error', __( 'Stripe is not configured.', 'playard' ) );
		}

		$currency = playard_get_currency();
		$field    = playard_get_field( (int) $booking->field_id );
		$label    = $field ? esc_html( $field->name ) : __( 'Booking', 'playard' );

		$response = wp_remote_post(
			'https://api.stripe.com/v1/checkout/sessions',
			[
				'headers' => [
					'Authorization'  => 'Bearer ' . $secret_key,
					'Content-Type'   => 'application/x-www-form-urlencoded',
					'Stripe-Version' => '2024-06-20',
				],
				'body'    => [
					'mode'                                                  => 'payment',
					'line_items[0][price_data][currency]'                   => strtolower( $currency ),
					'line_items[0][price_data][unit_amount]'                => (string) $this->to_smallest_unit( $amount, $currency ),
					'line_items[0][price_data][product_data][name]'         => $label,
					'line_items[0][price_data][product_data][description]'  => sprintf( '#%s · %s', $booking->booking_ref, $booking->booking_date ),
					'line_items[0][quantity]'                               => '1',
					'success_url'                                           => esc_url_raw( $success_url ),
					'cancel_url'                                            => esc_url_raw( home_url( '/' ) ),
					'metadata[plugin]'                                      => 'playard',
					'metadata[booking_ref]'                                 => (string) $booking->booking_ref,
					'metadata[booking_id]'                                  => (string) $booking->id,
					'metadata[type]'                                        => 'walkin_stripe',
				],
				'timeout' => 30,
			]
		);

		if ( is_wp_error( $response ) ) {
			return new WP_Error( 'payment_error', __( 'Payment service unavailable.', 'playard' ) );
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = json_decode( (string) wp_remote_retrieve_body( $response ), true );

		if ( $code !== 200 || isset( $body['error'] ) ) {
			return new WP_Error(
				'payment_error',
				(string) ( $body['error']['message'] ?? __( 'Could not create payment link.', 'playard' ) )
			);
		}

		return [
			'checkout_url'      => (string) $body['url'],
			'payment_intent_id' => (string) ( $body['payment_intent'] ?? '' ),
			'session_id'        => (string) $body['id'],
		];
	}

	/**
	 * payment_intent.payment_failed — fires hook so notifications can run.
	 *
	 * @param array<string,mixed> $intent
	 */
	private function on_payment_failed( array $intent ): void {
		do_action( 'playard_stripe_payment_failed', $intent );
	}

	/**
	 * charge.refunded — marks the booking's payment_status as 'refunded'
	 * and records a refund transaction.
	 *
	 * @param array<string,mixed> $charge
	 */
	private function on_charge_refunded( array $charge ): void {
		global $wpdb;

		$intent_id     = (string) ( $charge['payment_intent'] ?? '' );
		$amount_refunded = round( (float) ( $charge['amount_refunded'] ?? 0 ) / 100, 2 );

		if ( ! $intent_id || $amount_refunded <= 0 ) {
			return;
		}

		$booking = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}playard_bookings WHERE payment_intent_id = %s LIMIT 1",
				$intent_id
			)
		);

		if ( ! $booking ) {
			return;
		}

		$wpdb->update(
			$wpdb->prefix . 'playard_bookings',
			[ 'payment_status' => 'refunded' ],
			[ 'id' => (int) $booking->id ],
			[ '%s' ],
			[ '%d' ]
		);

		$this->record_transaction( [
			'booking_id'  => (int) $booking->id,
			'amount'      => $amount_refunded,
			'gateway'     => 'stripe',
			'gateway_ref' => $intent_id,
			'type'        => 'refund',
			'status'      => 'success',
		] );

		do_action( 'playard_stripe_charge_refunded', (int) $booking->id, $amount_refunded );
	}

	/**
	 * charge.dispute.created — fires hook; Pro can notify owner or flag booking.
	 *
	 * @param array<string,mixed> $dispute
	 */
	private function on_dispute_created( array $dispute ): void {
		do_action( 'playard_stripe_dispute_created', $dispute );
	}

	/**
	 * Verifies Stripe webhook signature using HMAC-SHA256.
	 * Rejects events older than 5 minutes to prevent replay attacks.
	 */
	private function verify_webhook_signature( string $payload, string $sig_header, string $secret ): bool {
		$parts = [];
		foreach ( explode( ',', $sig_header ) as $part ) {
			[ $k, $v ] = array_pad( explode( '=', $part, 2 ), 2, '' );
			$parts[ trim( $k ) ][] = trim( $v );
		}

		$timestamp  = (int) ( $parts['t'][0] ?? 0 );
		$signatures = $parts['v1'] ?? [];

		if ( ! $timestamp || empty( $signatures ) ) {
			return false;
		}

		// Reject events older than 5 minutes (300 seconds).
		if ( abs( time() - $timestamp ) > 300 ) {
			return false;
		}

		$signed   = $timestamp . '.' . $payload;
		$expected = hash_hmac( 'sha256', $signed, $secret );

		foreach ( $signatures as $sig ) {
			if ( hash_equals( $expected, $sig ) ) {
				return true;
			}
		}

		return false;
	}

	// -------------------------------------------------------------------------
	// Encryption — XOR with AUTH_KEY + base64
	// Secret key is obfuscated at rest, never returned to frontend.
	// -------------------------------------------------------------------------

	private function encrypt( string $value ): string {
		if ( empty( $value ) ) {
			return '';
		}

		$key    = defined( 'AUTH_KEY' ) && AUTH_KEY ? AUTH_KEY : wp_generate_password( 64, true, true );
		$repeat = str_repeat( $key, (int) ceil( strlen( $value ) / strlen( $key ) ) );
		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
		return base64_encode( $value ^ $repeat );
	}

	private function decrypt( string $encrypted ): string {
		if ( empty( $encrypted ) ) {
			return '';
		}

		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		$value  = base64_decode( $encrypted, true );
		if ( false === $value ) {
			return '';
		}

		$key    = defined( 'AUTH_KEY' ) && AUTH_KEY ? AUTH_KEY : '';
		if ( empty( $key ) ) {
			return '';
		}

		$repeat = str_repeat( $key, (int) ceil( strlen( $value ) / strlen( $key ) ) );
		return $value ^ $repeat;
	}

	// ── Stripe refund ────────────────────────────────────────────────────────────

	/**
	 * Issues a refund via the Stripe Refunds API.
	 * Refunds against the original Payment Intent, records the transaction.
	 *
	 * @param  string $payment_intent_id  Stripe pi_xxx ID stored on the booking.
	 * @param  float  $amount             Decimal amount to refund (e.g. 50.00).
	 * @param  int    $booking_id         Internal booking row ID (for transaction record).
	 * @return array{ refund_id: string, amount: float, status: string }|WP_Error
	 */
	public function create_stripe_refund( string $payment_intent_id, float $amount, int $booking_id ): array|WP_Error {
		$secret_key = $this->get_stripe_secret_key();

		if ( ! $secret_key ) {
			return new WP_Error( 'payment_error', __( 'Payment not configured.', 'playard' ) );
		}

		if ( empty( $payment_intent_id ) || $amount <= 0 ) {
			return new WP_Error( 'invalid_request', __( 'Invalid refund parameters.', 'playard' ) );
		}

		$currency = playard_get_currency();

		$response = wp_remote_post(
			'https://api.stripe.com/v1/refunds',
			[
				'headers' => [
					'Authorization'  => 'Bearer ' . $secret_key,
					'Content-Type'   => 'application/x-www-form-urlencoded',
					'Stripe-Version' => '2024-06-20',
				],
				'body'    => [
					'payment_intent' => $payment_intent_id,
					'amount'         => $this->to_smallest_unit( $amount, $currency ),
				],
				'timeout' => 30,
			]
		);

		if ( is_wp_error( $response ) ) {
			return new WP_Error( 'refund_error', __( 'Refund service unavailable.', 'playard' ) );
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = json_decode( (string) wp_remote_retrieve_body( $response ), true );

		if ( $code !== 200 || isset( $body['error'] ) ) {
			return new WP_Error(
				'refund_error',
				(string) ( $body['error']['message'] ?? __( 'Refund failed.', 'playard' ) )
			);
		}

		$this->record_transaction( [
			'booking_id'  => $booking_id,
			'amount'      => $amount,
			'gateway'     => 'stripe',
			'gateway_ref' => (string) ( $body['id'] ?? '' ),
			'type'        => 'refund',
			'status'      => 'success',
		] );

		do_action( 'playard_stripe_refund_created', $booking_id, $amount, (string) ( $body['id'] ?? '' ) );

		return [
			'refund_id' => (string) ( $body['id'] ?? '' ),
			'amount'    => $amount,
			'status'    => (string) ( $body['status'] ?? 'succeeded' ),
		];
	}

	// ── Manual refund (free plan / cash) ─────────────────────────────────────────

	/**
	 * Records a manual refund that the owner has already processed externally
	 * (e.g. via Stripe dashboard) or gave back as cash.
	 *
	 * @param  string $ref   Booking reference.
	 * @param  array  $data  { amount, method, notes }.
	 * @return array|WP_Error
	 */
	public function record_manual_refund( string $ref, array $data ): array|WP_Error {
		global $wpdb;

		$booking = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}playard_bookings WHERE booking_ref = %s LIMIT 1",
				sanitize_text_field( $ref )
			)
		);

		if ( ! $booking ) {
			return new WP_Error( 'not_found', __( 'Booking not found.', 'playard' ) );
		}

		if ( $booking->booking_status !== 'cancelled' ) {
			return new WP_Error( 'not_cancelled', __( 'Can only refund cancelled bookings.', 'playard' ) );
		}

		if ( (float) $booking->amount_paid <= 0 ) {
			return new WP_Error( 'nothing_to_refund', __( 'No payment to refund.', 'playard' ) );
		}

		$refund_amount = min(
			floatval( $data['amount'] ?? $booking->amount_paid ),
			(float) $booking->amount_paid
		);

		if ( $refund_amount <= 0 ) {
			return new WP_Error( 'invalid_amount', __( 'Invalid refund amount.', 'playard' ) );
		}

		$method = in_array( $data['method'] ?? '', [ 'stripe_manual', 'cash' ], true )
		          ? $data['method'] : 'cash';

		$wpdb->insert(
			$wpdb->prefix . 'playard_payments',
			[
				'booking_id'  => (int) $booking->id,
				'amount'      => $refund_amount,
				'gateway'     => $method,
				'gateway_ref' => sanitize_text_field( $data['notes'] ?? '' ),
				'type'        => 'refund',
				'status'      => 'success',
			],
			[ '%d', '%f', '%s', '%s', '%s', '%s' ]
		);

		$wpdb->update(
			$wpdb->prefix . 'playard_bookings',
			[
				'amount_paid'    => max( 0.0, (float) $booking->amount_paid - $refund_amount ),
				'amount_due'     => 0.0,
				'payment_status' => 'refunded',
			],
			[ 'id' => (int) $booking->id ],
			[ '%f', '%f', '%s' ],
			[ '%d' ]
		);

		do_action( 'playard_refund_recorded', (int) $booking->id, $refund_amount, $method );

		return [
			'booking_ref'    => $booking->booking_ref,
			'refund_amount'  => $refund_amount,
			'payment_status' => 'refunded',
			'remaining_due'  => 0.0,
		];
	}
}
