<?php

declare( strict_types=1 );

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

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

/**
 * Handles customer creation, lookup, and profile updates.
 * Customers are identified by phone number (unique).
 */
class Playard_Customer {

	// ── Core ─────────────────────────────────────────────────────────────────────

	/**
	 * Returns an existing customer matched by email (primary) then phone, or inserts a new row.
	 * Email is the primary account identifier. Phone is the fallback for legacy / guest bookings.
	 *
	 * @param  array<string,mixed> $data  Keys: name (required), email (primary), phone (fallback)
	 * @return object  Customer DB row (stdClass). Returns stub {id:0} on validation failure.
	 */
	public function get_or_create( array $data ): object {
		global $wpdb;

		$phone = $this->normalize_phone( (string) ( $data['phone'] ?? '' ) );
		$name  = sanitize_text_field( (string) ( $data['name']  ?? '' ) );
		$email = ! empty( $data['email'] ) ? sanitize_email( (string) $data['email'] ) : null;

		// ── Email-first: accounts are email-based ────────────────────────────────────
		if ( $email ) {
			$customer = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT * FROM {$wpdb->prefix}playard_customers WHERE email = %s LIMIT 1",
					$email
				)
			);

			if ( $customer ) {
				$updates = [];
				$formats = [];

				if ( $name && $name !== $customer->name ) {
					$updates['name'] = $name;
					$formats[]       = '%s';
				}

				if ( $phone && empty( $customer->phone ) ) {
					$updates['phone'] = $phone;
					$formats[]        = '%s';
				}

				if ( ! empty( $updates ) ) {
					$updates['updated_at'] = current_time( 'mysql' );
					$formats[]             = '%s';
					$wpdb->update(
						$wpdb->prefix . 'playard_customers',
						$updates,
						[ 'id' => (int) $customer->id ],
						$formats,
						[ '%d' ]
					);
					foreach ( $updates as $col => $val ) {
						$customer->$col = $val;
					}
				}

				return $customer;
			}
		}

		// ── Phone fallback ───────────────────────────────────────────────────────────
		if ( ! empty( $phone ) ) {
			$customer = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT * FROM {$wpdb->prefix}playard_customers WHERE phone = %s LIMIT 1",
					$phone
				)
			);

			if ( $customer ) {
				$updates = [];
				$formats = [];

				if ( $name && $name !== $customer->name ) {
					$updates['name'] = $name;
					$formats[]       = '%s';
				}

				if ( $email && empty( $customer->email ) ) {
					$updates['email'] = $email;
					$formats[]        = '%s';
				}

				if ( ! empty( $updates ) ) {
					$updates['updated_at'] = current_time( 'mysql' );
					$formats[]             = '%s';
					$wpdb->update(
						$wpdb->prefix . 'playard_customers',
						$updates,
						[ 'id' => (int) $customer->id ],
						$formats,
						[ '%d' ]
					);
					foreach ( $updates as $col => $val ) {
						$customer->$col = $val;
					}
				}

				return $customer;
			}
		}

		if ( ! $email && empty( $phone ) ) {
			return (object) [ 'id' => 0 ];
		}

		// ── New customer — silent creation ───────────────────────────────────────────
		$wpdb->insert(
			$wpdb->prefix . 'playard_customers',
			[
				'name'           => $name,
				'phone'          => $phone,
				'email'          => $email ?? '',
				'password_hash'  => '',
				'total_bookings' => 0,
				'total_spent'    => 0.00,
			],
			[ '%s', '%s', '%s', '%s', '%d', '%f' ]
		);

		$new_id = (int) $wpdb->insert_id;

		do_action( 'playard_customer_created', $new_id, $data );

		return $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}playard_customers WHERE id = %d LIMIT 1",
				$new_id
			)
		) ?? (object) [ 'id' => $new_id ];
	}

	// ── Registration ─────────────────────────────────────────────────────────────

	/**
	 * Creates a new customer account with name, email, and phone all required.
	 * Returns a JWT on success so the caller can auto-login the user.
	 *
	 * @param  array<string,mixed> $data  Keys: name, email, phone (all required)
	 * @return array<string,mixed>|WP_Error
	 */
	public function create_account( array $data ): array|WP_Error {
		global $wpdb;

		$name  = sanitize_text_field( trim( (string) ( $data['name']  ?? '' ) ) );
		$email = sanitize_email( trim( (string) ( $data['email'] ?? '' ) ) );
		$phone = $this->normalize_phone( (string) ( $data['phone'] ?? '' ) );

		if ( empty( $name ) ) {
			return new WP_Error( 'validation_error', __( 'Full name is required.', 'playard' ) );
		}
		if ( ! is_email( $email ) ) {
			return new WP_Error( 'validation_error', __( 'A valid email address is required.', 'playard' ) );
		}
		if ( empty( $phone ) ) {
			return new WP_Error( 'validation_error', __( 'Phone number is required.', 'playard' ) );
		}

		$email_exists = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$wpdb->prefix}playard_customers WHERE email = %s LIMIT 1",
				$email
			)
		);
		if ( $email_exists ) {
			return new WP_Error( 'email_exists', __( 'An account with this email already exists. Please sign in.', 'playard' ) );
		}

		$phone_exists = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$wpdb->prefix}playard_customers WHERE phone = %s LIMIT 1",
				$phone
			)
		);
		if ( $phone_exists ) {
			return new WP_Error( 'phone_exists', __( 'An account with this phone number already exists.', 'playard' ) );
		}

		$wpdb->insert(
			$wpdb->prefix . 'playard_customers',
			[
				'name'           => $name,
				'phone'          => $phone,
				'email'          => $email,
				'password_hash'  => '',
				'total_bookings' => 0,
				'total_spent'    => 0.00,
			],
			[ '%s', '%s', '%s', '%s', '%d', '%f' ]
		);

		$new_id = (int) $wpdb->insert_id;
		if ( ! $new_id ) {
			return new WP_Error( 'db_error', __( 'Could not create account. Please try again.', 'playard' ) );
		}

		do_action( 'playard_customer_registered', $new_id, $data );

		$token = $this->generate_jwt( $new_id );

		return [
			'token'      => $token,
			'expires_at' => gmdate( 'Y-m-d\TH:i:s\Z', time() + ( 7 * DAY_IN_SECONDS ) ),
			'customer'   => (object) [
				'id'         => $new_id,
				'name'       => $name,
				'phone'      => $phone,
				'email'      => $email,
				'avatar_url' => '',
			],
		];
	}

	// ── Phone ─────────────────────────────────────────────────────────────────────

	/**
	 * Strips everything except digits and leading +.
	 * "+880 171-234 5678" → "+8801712345678"
	 */
	public function normalize_phone( string $phone ): string {
		$phone = sanitize_text_field( $phone );
		return (string) preg_replace( '/[^\d+]/', '', $phone );
	}

	// ── Stats ─────────────────────────────────────────────────────────────────────

	/**
	 * Atomically increments total_bookings and total_spent after a confirmed booking.
	 *
	 * @param int   $customer_id
	 * @param float $amount
	 */
	public function increment_stats( int $customer_id, float $amount ): void {
		global $wpdb;

		if ( $customer_id <= 0 ) {
			return;
		}

		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->prefix}playard_customers
				 SET total_bookings = total_bookings + 1,
				     total_spent    = total_spent + %f,
				     updated_at     = %s
				 WHERE id = %d",
				$amount,
				current_time( 'mysql' ),
				$customer_id
			)
		);
	}

	/**
	 * Atomically decrements total_bookings and total_spent after a cancellation.
	 * Floors at zero to prevent negative values.
	 *
	 * @param int   $customer_id
	 * @param float $amount
	 */
	public function decrement_stats( int $customer_id, float $amount ): void {
		global $wpdb;

		if ( $customer_id <= 0 ) {
			return;
		}

		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->prefix}playard_customers
				 SET total_bookings = GREATEST( 0, total_bookings - 1 ),
				     total_spent    = GREATEST( 0, total_spent - %f ),
				     updated_at     = %s
				 WHERE id = %d",
				$amount,
				current_time( 'mysql' ),
				$customer_id
			)
		);
	}

	// ── Lookup ────────────────────────────────────────────────────────────────────

	/**
	 * Returns name + email only for returning-customer auto-fill.
	 * No sensitive fields are exposed.
	 *
	 * @return array{name:string,email:string}|null
	 */
	public function lookup_by_phone( string $phone ): ?array {
		global $wpdb;

		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT name, email FROM {$wpdb->prefix}playard_customers WHERE phone = %s LIMIT 1",
				$this->normalize_phone( $phone )
			)
		);

		if ( ! $row ) {
			return null;
		}

		// Return only masked data — this endpoint is public (unauthenticated).
		// Never expose the full name, full email, or phone number.
		$name  = (string) $row->name;
		$email = (string) ( $row->email ?? '' );

		$masked_name = '' !== $name
			? mb_substr( $name, 0, 2 ) . '***'
			: '';

		$masked_email = '';
		if ( '' !== $email && str_contains( $email, '@' ) ) {
			[ $local, $domain ] = explode( '@', $email, 2 );
			$masked_email = mb_substr( $local, 0, 2 ) . '***@' . $domain;
		}

		return [
			'name'  => $masked_name,
			'email' => $masked_email,
		];
	}

	/**
	 * Fetches a full customer row by ID.
	 */
	public function get( int $id ): ?object {
		global $wpdb;

		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}playard_customers WHERE id = %d LIMIT 1",
				$id
			)
		);

		return $row ?: null;
	}

	// ── Password ─────────────────────────────────────────────────────────────────

	/**
	 * Hashes and stores a password for the given customer.
	 *
	 * @return bool|WP_Error
	 */
	public function set_password( int $customer_id, string $password ): bool|WP_Error {
		global $wpdb;

		if ( strlen( $password ) < 8 ) {
			return new WP_Error(
				'validation_error',
				__( 'Password must be at least 8 characters.', 'playard' )
			);
		}

		$hash   = wp_hash_password( $password );
		$result = $wpdb->update(
			$wpdb->prefix . 'playard_customers',
			[ 'password_hash' => $hash ],
			[ 'id' => $customer_id ],
			[ '%s' ],
			[ '%d' ]
		);

		do_action( 'playard_customer_password_set', $customer_id );

		return $result !== false;
	}

	// ── Password login ────────────────────────────────────────────────────────────

	/**
	 * Authenticates a customer via phone or email + password.
	 * Type 'phone' or 'email'. Uses generic error to prevent enumeration.
	 *
	 * @return array<string,mixed>|WP_Error
	 */
	public function login_with_password( string $login, string $password, string $type = 'phone' ): array|WP_Error {
		global $wpdb;

		$lookup_val = $type === 'phone'
			? $this->normalize_phone( $login )
			: sanitize_email( $login );

		if ( ! $this->check_login_rate_limit( $lookup_val ) ) {
			return new WP_Error(
				'too_many_attempts',
				__( 'Too many attempts. Try again in 15 minutes.', 'playard' )
			);
		}

		// Use explicit column names (not interpolated) to satisfy PHPCS UnescapedDBParameter.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$customer = $type === 'phone'
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			? $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}playard_customers WHERE phone = %s LIMIT 1", $lookup_val ) )
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			: $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}playard_customers WHERE email = %s LIMIT 1", $lookup_val ) );

		if ( ! $customer || ! wp_check_password( $password, (string) $customer->password_hash ) ) {
			if ( empty( $customer->password_hash ?? '' ) && $customer ) {
				$this->record_failed_login( $lookup_val );
				return new WP_Error(
					'no_password',
					__( 'No password set. Please use code login.', 'playard' )
				);
			}

			$this->record_failed_login( $lookup_val );
			return new WP_Error( 'auth_invalid', __( 'Invalid credentials.', 'playard' ) );
		}

		delete_transient( 'playard_login_fail_' . md5( $lookup_val ) );

		// 2FA: if admin requires email OTP after password login (email accounts only)
		if ( $type === 'email' ) {
			$settings = (array) get_option( 'playard_settings', [] );
			if ( ! empty( $settings['auth']['require_pw_otp'] ) ) {
				$this->send_login_otp( $lookup_val );
				return [
					'requires_otp' => true,
					'email'        => $lookup_val,
				];
			}
		}

		$token = $this->generate_jwt( (int) $customer->id );

		do_action( 'playard_customer_logged_in', (int) $customer->id, $type );

		return [
			'token'      => $token,
			'expires_at' => gmdate( 'Y-m-d\TH:i:s\Z', time() + ( 7 * DAY_IN_SECONDS ) ),
			'customer'   => (object) [
				'id'           => (int) $customer->id,
				'name'         => (string) $customer->name,
				'phone'        => (string) $customer->phone,
				'email'        => (string) ( $customer->email    ?? '' ),
				'avatar_url'   => esc_url_raw( (string) ( $customer->avatar_url ?? '' ) ),
				'has_password' => ! empty( $customer->password_hash ),
			],
		];
	}

	/**
	 * Returns false when the identifier has 5+ failed attempts in the last 15 minutes.
	 */
	public function check_login_rate_limit( string $identifier ): bool {
		$count = (int) get_transient( 'playard_login_fail_' . md5( $identifier ) );
		return $count < 5;
	}

	/**
	 * Increments the failed-login counter for the given identifier.
	 */
	public function record_failed_login( string $identifier ): void {
		$key   = 'playard_login_fail_' . md5( $identifier );
		$count = (int) get_transient( $key );
		set_transient( $key, $count + 1, 15 * MINUTE_IN_SECONDS );
	}

	// ── Email OTP login ───────────────────────────────────────────────────────────

	/**
	 * Sends a 6-digit login OTP to the given email address.
	 * Always returns success to prevent email enumeration.
	 *
	 * @return array<string,mixed>|WP_Error
	 */
	public function send_login_otp( string $email ): array|WP_Error {
		$email = sanitize_email( $email );

		if ( ! is_email( $email ) ) {
			return new WP_Error( 'invalid_email', __( 'Invalid email address.', 'playard' ) );
		}

		if ( ! $this->check_login_rate_limit( $email ) ) {
			return new WP_Error(
				'too_many_attempts',
				__( 'Too many attempts. Please wait a few minutes before trying again.', 'playard' )
			);
		}

		$otp_code = str_pad( (string) wp_rand( 100000, 999999 ), 6, '0', STR_PAD_LEFT );
		set_transient( 'playard_loginotp_' . md5( $email ), $otp_code, 600 );

		global $wpdb;
		$customer = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT id, name, email FROM {$wpdb->prefix}playard_customers WHERE email = %s LIMIT 1",
				$email
			)
		);

		// Send email regardless of whether account exists (silent on not-found).
		if ( $customer ) {
			$facility  = playard_get_facility();
			$site_name = $facility ? sanitize_text_field( (string) $facility->name ) : (string) get_bloginfo( 'name' );

			$subject = sprintf(
				/* translators: %s: facility name */
				__( '%s — Your sign-in code', 'playard' ),
				$site_name
			);

			$body = $this->build_otp_email(
				(string) $customer->name,
				$otp_code,
				$site_name,
				__( 'sign in to your account', 'playard' )
			);

			wp_mail( $email, $subject, $body, [ 'Content-Type: text/html; charset=UTF-8' ] );
		}

		do_action( 'playard_login_otp_sent', $email );

		return [
			'sent'    => true,
			'message' => __( 'Verification code sent. Please check your inbox.', 'playard' ),
		];
	}

	/**
	 * Verifies a login OTP and issues a JWT on success.
	 *
	 * @return array<string,mixed>|WP_Error
	 */
	public function verify_login_otp( string $email, string $otp ): array|WP_Error {
		global $wpdb;

		$email = sanitize_email( $email );
		if ( ! is_email( $email ) ) {
			return new WP_Error( 'invalid_email', __( 'Invalid email address.', 'playard' ) );
		}

		$stored = (string) get_transient( 'playard_loginotp_' . md5( $email ) );

		if ( ! $stored || $stored !== $otp ) {
			$this->record_failed_login( $email );
			return new WP_Error( 'otp_invalid', __( 'Invalid or expired code. Please request a new one.', 'playard' ) );
		}

		delete_transient( 'playard_loginotp_' . md5( $email ) );

		$customer = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT id, name, phone, email, password_hash, avatar_url FROM {$wpdb->prefix}playard_customers WHERE email = %s LIMIT 1",
				$email
			)
		);

		if ( ! $customer ) {
			return new WP_Error(
				'not_found',
				__( 'No account found with this email. Book a session first to create your account.', 'playard' )
			);
		}

		$token = $this->generate_jwt( (int) $customer->id );

		do_action( 'playard_customer_logged_in', (int) $customer->id, 'email_otp' );

		return [
			'token'      => $token,
			'expires_at' => gmdate( 'Y-m-d\TH:i:s\Z', time() + ( 7 * DAY_IN_SECONDS ) ),
			'customer'   => (object) [
				'id'           => (int) $customer->id,
				'name'         => (string) $customer->name,
				'phone'        => (string) $customer->phone,
				'email'        => (string) ( $customer->email      ?? '' ),
				'avatar_url'   => esc_url_raw( (string) ( $customer->avatar_url ?? '' ) ),
				'has_password' => ! empty( $customer->password_hash ),
			],
		];
	}

	/**
	 * Builds a clean HTML OTP email body.
	 */
	private function build_otp_email( string $customer_name, string $otp_code, string $site_name, string $purpose ): string {
		/* translators: %s: customer name */
		$greeting = $customer_name ? sprintf( __( 'Hi %s,', 'playard' ), esc_html( $customer_name ) ) : __( 'Hi there,', 'playard' );

		return '<!DOCTYPE html>
<html>
<body style="margin:0;padding:20px;background:#F8FAFC;font-family:Arial,sans-serif;">
<div style="max-width:480px;margin:0 auto;background:#FFFFFF;border-radius:8px;padding:32px;color:#0F172A;line-height:1.6;">
  <h2 style="margin:0 0 8px;font-size:20px;">' . esc_html( $site_name ) . '</h2>
  <p style="margin:0 0 24px;color:#475569;font-size:14px;">' . $greeting . '<br>Here is your verification code to ' . esc_html( $purpose ) . ':</p>
  <div style="text-align:center;background:#F1F5F9;border-radius:8px;padding:20px;margin-bottom:24px;">
    <span style="font-size:36px;font-weight:700;letter-spacing:10px;color:#0F172A;font-family:monospace;">' . esc_html( $otp_code ) . '</span>
  </div>
  <p style="font-size:13px;color:#64748B;margin:0 0 8px;">This code expires in <strong>10 minutes</strong>.</p>
  <p style="font-size:12px;color:#94A3B8;margin:0;">If you did not request this code, please ignore this email.</p>
</div>
</body>
</html>';
	}

	// ── OTP (phone) ───────────────────────────────────────────────────────────────

	/**
	 * Generates and delivers a 6-digit OTP for the given phone number.
	 *
	 * @return array<string,mixed>|WP_Error
	 */
	public function send_otp( string $phone ): array|WP_Error {
		global $wpdb;

		$phone = $this->normalize_phone( $phone );

		if ( ! $this->check_otp_rate_limit( $phone ) ) {
			return new WP_Error(
				'otp_rate_limited',
				__( 'Too many OTP requests. Please wait before trying again.', 'playard' )
			);
		}

		$customer = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}playard_customers WHERE phone = %s LIMIT 1",
				$phone
			)
		);

		if ( ! $customer ) {
			return new WP_Error(
				'not_found',
				__( 'No account found with this phone number.', 'playard' )
			);
		}

		$otp_code   = str_pad( (string) wp_rand( 100000, 999999 ), 6, '0', STR_PAD_LEFT );
		$expires_at = gmdate( 'Y-m-d H:i:s', time() + 600 );

		$wpdb->delete(
			$wpdb->prefix . 'playard_otp',
			[ 'phone' => $phone ],
			[ '%s' ]
		);

		$wpdb->insert(
			$wpdb->prefix . 'playard_otp',
			[
				'phone'      => $phone,
				'otp_code'   => $otp_code,
				'expires_at' => $expires_at,
				'verified'   => 0,
			],
			[ '%s', '%s', '%s', '%d' ]
		);

		$method = $this->deliver_otp( $customer, $otp_code );

		do_action( 'playard_otp_sent', $phone, $method );

		return [
			'success' => true,
			'message' => __( 'OTP sent', 'playard' ),
			'method'  => $method,
		];
	}

	/**
	 * Verifies an OTP for password reset and returns a short-lived reset token.
	 *
	 * @return array<string,mixed>|WP_Error
	 */
	public function verify_otp_for_reset( string $phone, string $otp ): array|WP_Error {
		global $wpdb;

		$phone = $this->normalize_phone( $phone );

		$record = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}playard_otp
				 WHERE phone = %s AND otp_code = %s AND verified = 0 LIMIT 1",
				$phone,
				$otp
			)
		);

		if ( ! $record ) {
			return new WP_Error( 'otp_invalid', __( 'Invalid verification code.', 'playard' ) );
		}

		// Append ' UTC' so strtotime() interprets the stored gmdate() value correctly
		// regardless of the server's PHP timezone setting.
		if ( (int) strtotime( (string) $record->expires_at . ' UTC' ) < time() ) {
			return new WP_Error( 'otp_expired', __( 'Code expired. Please request a new one.', 'playard' ) );
		}

		$wpdb->update(
			$wpdb->prefix . 'playard_otp',
			[ 'verified' => 1 ],
			[ 'id' => (int) $record->id ],
			[ '%d' ],
			[ '%d' ]
		);

		$reset_token = 'rst_' . wp_generate_password( 24, false );
		set_transient( 'playard_reset_' . $reset_token, $phone, 600 );

		return [
			'success'    => true,
			'reset_token' => $reset_token,
			'expires_in' => 600,
		];
	}

	/**
	 * Resets the customer password using a one-time reset token from OTP verification.
	 *
	 * @return bool|WP_Error
	 */
	public function reset_password( string $phone, string $reset_token, string $new_password ): bool|WP_Error {
		global $wpdb;

		$phone = $this->normalize_phone( $phone );

		if ( strlen( $new_password ) < 8 ) {
			return new WP_Error( 'validation_error', __( 'Password must be at least 8 characters.', 'playard' ) );
		}

		$stored_phone = get_transient( 'playard_reset_' . $reset_token );

		if ( ! $stored_phone || $stored_phone !== $phone ) {
			return new WP_Error( 'token_invalid', __( 'Invalid or expired reset link.', 'playard' ) );
		}

		$customer = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT id FROM {$wpdb->prefix}playard_customers WHERE phone = %s LIMIT 1",
				$phone
			)
		);

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

		$wpdb->update(
			$wpdb->prefix . 'playard_customers',
			[ 'password_hash' => wp_hash_password( $new_password ) ],
			[ 'id' => (int) $customer->id ],
			[ '%s' ],
			[ '%d' ]
		);

		delete_transient( 'playard_reset_' . $reset_token );

		do_action( 'playard_customer_password_reset', (int) $customer->id );

		return true;
	}

	// ── Email-based forgot password ──────────────────────────────────────────────

	/**
	 * Generates a 6-digit OTP and emails it to the customer.
	 * Always returns success to prevent email enumeration.
	 *
	 * @return array<string,mixed>|WP_Error
	 */
	public function send_email_reset( string $email ): array|WP_Error {
		global $wpdb;

		$email = sanitize_email( $email );
		if ( ! is_email( $email ) ) {
			return new WP_Error( 'invalid_email', __( 'Invalid email address.', 'playard' ) );
		}

		$customer = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT id, name, email FROM {$wpdb->prefix}playard_customers WHERE email = %s LIMIT 1",
				$email
			)
		);

		// Silent success — do not reveal whether the email is registered.
		if ( ! $customer ) {
			return [ 'sent' => true ];
		}

		$otp_code = str_pad( (string) wp_rand( 100000, 999999 ), 6, '0', STR_PAD_LEFT );

		set_transient( 'playard_emailreset_' . md5( $email ), $otp_code, 600 );

		$facility  = playard_get_facility();
		$site_name = $facility ? sanitize_text_field( (string) $facility->name ) : get_bloginfo( 'name' );

		/* translators: %s: site name */
		$subject = sprintf( __( '[%s] Your password reset code', 'playard' ), $site_name );

		// Branded HTML email — consistent with all other plugin emails.
		$message = $this->build_otp_email( (string) $customer->name, $otp_code, $site_name, 'reset your password' );

		// Route through Playard_Notification::send() so the reset email appears in the admin log.
		( new Playard_Notification() )->send( $email, $subject, $message, 0, 'password_reset' );

		return [ 'sent' => true ];
	}

	/**
	 * Verifies the email reset OTP and issues a short-lived reset token.
	 *
	 * @return array<string,mixed>|WP_Error
	 */
	public function verify_email_reset( string $email, string $otp ): array|WP_Error {
		$email = sanitize_email( $email );

		$stored = get_transient( 'playard_emailreset_' . md5( $email ) );

		if ( ! $stored || $stored !== $otp ) {
			return new WP_Error( 'otp_invalid', __( 'Invalid or expired code.', 'playard' ) );
		}

		delete_transient( 'playard_emailreset_' . md5( $email ) );

		$reset_token = 'erst_' . wp_generate_password( 24, false );
		set_transient( 'playard_emailreset_token_' . $reset_token, $email, 600 );

		return [ 'reset_token' => $reset_token ];
	}

	/**
	 * Sets a new password after verifying the email reset token.
	 *
	 * @return bool|WP_Error
	 */
	public function reset_password_by_email( string $email, string $reset_token, string $new_password ): bool|WP_Error {
		global $wpdb;

		if ( strlen( $new_password ) < 8 ) {
			return new WP_Error( 'validation_error', __( 'Password must be at least 8 characters.', 'playard' ) );
		}

		$stored_email = get_transient( 'playard_emailreset_token_' . $reset_token );
		if ( ! $stored_email || sanitize_email( $stored_email ) !== sanitize_email( $email ) ) {
			return new WP_Error( 'token_invalid', __( 'Invalid or expired reset link.', 'playard' ) );
		}

		$customer = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT id FROM {$wpdb->prefix}playard_customers WHERE email = %s LIMIT 1",
				sanitize_email( $email )
			)
		);

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

		$wpdb->update(
			$wpdb->prefix . 'playard_customers',
			[ 'password_hash' => wp_hash_password( $new_password ) ],
			[ 'id' => (int) $customer->id ],
			[ '%s' ],
			[ '%d' ]
		);

		delete_transient( 'playard_emailreset_token_' . $reset_token );

		do_action( 'playard_customer_password_reset', (int) $customer->id );

		return true;
	}

	/**
	 * Verifies an OTP and returns a JWT token on success.
	 *
	 * @return array<string,mixed>|WP_Error
	 */
	public function verify_otp( string $phone, string $otp ): array|WP_Error {
		global $wpdb;

		$phone = $this->normalize_phone( $phone );

		$record = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}playard_otp
				 WHERE phone = %s AND otp_code = %s AND verified = 0 LIMIT 1",
				$phone,
				$otp
			)
		);

		if ( ! $record ) {
			return new WP_Error( 'otp_invalid', __( 'Invalid verification code.', 'playard' ) );
		}

		// Append ' UTC' so strtotime() interprets the stored gmdate() value correctly
		// regardless of the server's PHP timezone setting.
		if ( (int) strtotime( (string) $record->expires_at . ' UTC' ) < time() ) {
			return new WP_Error( 'otp_expired', __( 'Code expired. Please request a new one.', 'playard' ) );
		}

		$wpdb->update(
			$wpdb->prefix . 'playard_otp',
			[ 'verified' => 1 ],
			[ 'id' => (int) $record->id ],
			[ '%d' ],
			[ '%d' ]
		);

		$customer = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT id, name, phone, email FROM {$wpdb->prefix}playard_customers
				 WHERE phone = %s LIMIT 1",
				$phone
			)
		);

		if ( ! $customer ) {
			return new WP_Error( 'customer_not_found', __( 'Customer not found.', 'playard' ) );
		}

		$token = $this->generate_jwt( (int) $customer->id );

		do_action( 'playard_customer_logged_in', (int) $customer->id, 'otp' );

		return [
			'token'      => $token,
			'expires_at' => gmdate( 'Y-m-d\TH:i:s\Z', time() + ( 7 * DAY_IN_SECONDS ) ),
			'customer'   => $customer,
		];
	}

	/**
	 * Rate limiter: max 5 OTP requests per phone per hour.
	 */
	public function check_otp_rate_limit( string $phone ): bool {
		// Fixed hourly window: key includes the current UTC hour so the bucket resets on the clock,
		// not on a rolling window that can be exploited at window boundaries.
		$window = (int) gmdate( 'YmdH' );
		$key    = 'playard_otp_count_' . md5( $phone . $window );
		$count  = (int) get_transient( $key );

		if ( $count >= 5 ) {
			return false;
		}

		// TTL: seconds remaining in the current UTC hour + 60 s buffer.
		$seconds_left = ( 60 - (int) gmdate( 'i' ) ) * 60 - (int) gmdate( 's' ) + 60;
		set_transient( $key, $count + 1, $seconds_left );

		return true;
	}

	// ── JWT ───────────────────────────────────────────────────────────────────────

	/**
	 * Issues a signed 7-day JWT for the given customer ID.
	 */
	public function generate_jwt( int $customer_id ): string {
		$header  = $this->base64url( (string) wp_json_encode( [ 'alg' => 'HS256', 'typ' => 'JWT' ] ) );
		$payload = $this->base64url( (string) wp_json_encode( [
			'sub' => $customer_id,
			'iat' => time(),
			'exp' => time() + ( 7 * DAY_IN_SECONDS ),
			'iss' => 'playard',
		] ) );

		$signature = hash_hmac( 'sha256', "$header.$payload", AUTH_KEY );

		return "$header.$payload.$signature";
	}

	/**
	 * Validates a JWT token and returns the customer ID, or false if invalid / expired.
	 *
	 * @return int|false
	 */
	public function validate_jwt( string $token ): int|false {
		$parts = explode( '.', $token );

		if ( count( $parts ) !== 3 ) {
			return false;
		}

		[ $header, $payload, $signature ] = $parts;

		$expected = hash_hmac( 'sha256', "$header.$payload", AUTH_KEY );

		if ( ! hash_equals( $expected, $signature ) ) {
			return false;
		}

		$data = json_decode( base64_decode( strtr( $payload, '-_', '+/' ) ), true );

		if ( ! is_array( $data ) || empty( $data['exp'] ) || $data['exp'] < time() ) {
			return false;
		}

		return (int) $data['sub'];
	}

	/**
	 * Decodes a JWT token and returns the customer ID regardless of expiry.
	 * Used by refresh-token endpoint — signature is verified, expiry is ignored.
	 *
	 * @return int|false  Customer ID, or false if signature invalid / malformed.
	 */
	public function decode_jwt_ignore_expiry( string $token ): int|false {
		$parts = explode( '.', $token );

		if ( count( $parts ) !== 3 ) {
			return false;
		}

		[ $header, $payload, $signature ] = $parts;

		$expected = hash_hmac( 'sha256', "$header.$payload", AUTH_KEY );

		if ( ! hash_equals( $expected, $signature ) ) {
			return false;
		}

		$data = json_decode( base64_decode( strtr( $payload, '-_', '+/' ) ), true );

		if ( ! is_array( $data ) || empty( $data['sub'] ) || ( $data['iss'] ?? '' ) !== 'playard' ) {
			return false;
		}

		return (int) $data['sub'];
	}

	// ── Private helpers ───────────────────────────────────────────────────────────

	/**
	 * Delivers OTP to customer. Pro SMS/WhatsApp via filter; falls back to email, then admin.
	 */
	private function deliver_otp( object $customer, string $otp_code ): string {
		$sent_via_pro = apply_filters( 'playard_send_otp', false, $customer, $otp_code );

		if ( $sent_via_pro ) {
			return 'sms';
		}

		if ( ! empty( $customer->email ) ) {
			$facility  = playard_get_facility();
			$site_name = $facility ? sanitize_text_field( (string) $facility->name ) : (string) get_bloginfo( 'name' );

			$subject = sprintf(
				/* translators: %s: facility name */
				__( 'Your %s verification code', 'playard' ),
				$site_name
			);

			$message = $this->build_otp_email(
				(string) $customer->name,
				$otp_code,
				$site_name,
				'login'
			);

			// Route through Playard_Notification::send() so the email is logged in the admin log.
			( new Playard_Notification() )->send( (string) $customer->email, $subject, $message, 0, 'otp' );

			return 'email';
		}

		return 'admin';
	}

	// ── Admin customer management ─────────────────────────────────────────────────

	/**
	 * Returns a paginated, searchable list of customers for the admin dashboard.
	 * Validates sort column against a whitelist to prevent SQL injection.
	 *
	 * @param  array<string,mixed> $args  Keys: search, sort, order, page, per_page.
	 * @return array<string,mixed>
	 */
	public function get_customers( array $args ): array {
		global $wpdb;

		$where  = [ '1=1' ];
		$params = [];

		if ( ! empty( $args['search'] ) ) {
			$search   = '%' . $wpdb->esc_like( sanitize_text_field( (string) $args['search'] ) ) . '%';
			$where[]  = '(c.name LIKE %s OR c.phone LIKE %s OR c.email LIKE %s)';
			$params[] = $search;
			$params[] = $search;
			$params[] = $search;
		}

		$where_sql = implode( ' AND ', $where );

		// Sort by computed aggregates or customer columns
		$allowed_sorts = [ 'id', 'total_bookings', 'total_spent', 'last_booking_date', 'created_at', 'name' ];
		$sort  = in_array( $args['sort'] ?? '', $allowed_sorts, true ) ? $args['sort'] : 'created_at';
		$order = strtoupper( (string) ( $args['order'] ?? 'DESC' ) ) === 'ASC' ? 'ASC' : 'DESC';

		// Map sort keys to fully-qualified column references
		$sort_col_map = [
			'id'                => 'c.id',
			'total_bookings'    => 'b_agg.total_bookings',
			'total_spent'       => 'b_agg.total_spent',
			'last_booking_date' => 'b_agg.last_booking_date',
			'created_at'        => 'c.created_at',
			'name'              => 'c.name',
		];
		$sort_col = $sort_col_map[ $sort ] ?? 'c.created_at';

		$page     = max( 1, absint( $args['page'] ?? 1 ) );
		$per_page = min( 100, max( 1, absint( $args['per_page'] ?? 20 ) ) );
		$offset   = ( $page - 1 ) * $per_page;

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $where_sql/$sort_col built from whitelisted maps; safe
		$total = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->prefix}playard_customers c WHERE {$where_sql}",
				...$params
			)
		);

		// Compute total_bookings, total_spent, and last_booking_date from the bookings table in
		// real-time via a subquery so the values are always accurate regardless of stored counters.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT c.id, c.name, c.phone, c.email, c.avatar_url, c.created_at,
				        COALESCE( b_agg.total_bookings,    0  ) AS total_bookings,
				        COALESCE( b_agg.total_spent,       0  ) AS total_spent,
				        COALESCE( b_agg.last_booking_date, '' ) AS last_booking_date
				 FROM {$wpdb->prefix}playard_customers c
				 LEFT JOIN (
				     SELECT customer_id,
				            COUNT(*)           AS total_bookings,
				            SUM(amount_total)  AS total_spent,
				            MAX(booking_date)  AS last_booking_date
				     FROM {$wpdb->prefix}playard_bookings
				     WHERE booking_status NOT IN ('cancelled')
				     GROUP BY customer_id
				 ) b_agg ON b_agg.customer_id = c.id
				 WHERE {$where_sql}
				 ORDER BY {$sort_col} {$order}
				 LIMIT %d OFFSET %d",
				...array_merge( $params, [ $per_page, $offset ] )
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber

		return [
			'customers' => array_map( static function ( object $c ): array {
				return [
					'id'                => (int)    $c->id,
					'name'              => (string) $c->name,
					'phone'             => (string) $c->phone,
					'email'             => (string) ( $c->email      ?? '' ),
					'avatar_url'        => esc_url_raw( (string) ( $c->avatar_url ?? '' ) ),
					'total_bookings'    => (int)    $c->total_bookings,
					'total_spent'       => (float)  $c->total_spent,
					'last_booking_date' => (string) ( $c->last_booking_date ?? '' ),
					'created_at'        => (string) $c->created_at,
				];
			}, $rows ?: [] ),
			'total'    => $total,
			'page'     => $page,
			'per_page' => $per_page,
		];
	}

	/**
	 * Returns a single customer with their full booking history.
	 *
	 * @param  int $id  Customer ID.
	 * @return array<string,mixed>|WP_Error
	 */
	public function get_customer_detail( int $id ): array|WP_Error {
		global $wpdb;

		// Load customer + compute live stats from bookings (never rely on stale counters).
		$customer = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT c.id, c.name, c.phone, c.email, c.notes, c.avatar_url, c.created_at,
				        COALESCE( b_agg.total_bookings,    0  ) AS total_bookings,
				        COALESCE( b_agg.total_spent,       0  ) AS total_spent,
				        COALESCE( b_agg.last_booking_date, '' ) AS last_booking_date
				 FROM {$wpdb->prefix}playard_customers c
				 LEFT JOIN (
				     SELECT customer_id,
				            COUNT(*)          AS total_bookings,
				            SUM(amount_total) AS total_spent,
				            MAX(booking_date) AS last_booking_date
				     FROM {$wpdb->prefix}playard_bookings
				     WHERE booking_status NOT IN ('cancelled')
				     GROUP BY customer_id
				 ) b_agg ON b_agg.customer_id = c.id
				 WHERE c.id = %d
				 LIMIT 1",
				$id
			)
		);

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

		$bookings = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT b.booking_ref, b.booking_date, b.start_time, b.end_time,
				        b.sport, b.amount_total, b.amount_paid, b.booking_status,
				        b.payment_status, b.created_at, f.name AS field_name
				 FROM {$wpdb->prefix}playard_bookings b
				 LEFT JOIN {$wpdb->prefix}playard_fields f ON b.field_id = f.id
				 WHERE b.customer_id = %d
				 ORDER BY b.booking_date DESC, b.start_time DESC",
				$id
			)
		);

		$bookings_arr  = $bookings ?: [];
		$total_all     = count( $bookings_arr );
		$total_active  = count( array_filter( $bookings_arr, static fn( $b ) => $b->booking_status !== 'cancelled' ) );
		$total_cancelled = $total_all - $total_active;

		$stored_avatar = (string) ( $customer->avatar_url ?? '' );
		$avatar_url    = $stored_avatar
			? esc_url_raw( $stored_avatar )
			: esc_url_raw( (string) get_avatar_url( $customer->email ?: $customer->id, [ 'size' => 80, 'default' => 'mp' ] ) );

		return [
			'customer' => [
				'id'                => (int)    $customer->id,
				'name'              => (string) $customer->name,
				'phone'             => (string) $customer->phone,
				'email'             => (string) ( $customer->email    ?? '' ),
				'notes'             => (string) ( $customer->notes    ?? '' ),
				'avatar_url'        => $avatar_url,
				'total_bookings'    => (int)    $customer->total_bookings,
				'total_spent'       => (float)  $customer->total_spent,
				'last_booking_date' => (string) ( $customer->last_booking_date ?? '' ),
				'created_at'        => (string) $customer->created_at,
			],
			'bookings' => array_map( static function ( object $b ): array {
				return [
					'booking_ref'    => (string) $b->booking_ref,
					'field_name'     => (string) ( $b->field_name     ?? '' ),
					'booking_date'   => (string) $b->booking_date,
					'start_time'     => (string) $b->start_time,
					'end_time'       => (string) $b->end_time,
					'sport'          => (string) $b->sport,
					'amount_total'   => (float)  $b->amount_total,
					'amount_paid'    => (float)  $b->amount_paid,
					'booking_status' => (string) $b->booking_status,
					'payment_status' => (string) $b->payment_status,
					'created_at'     => (string) $b->created_at,
				];
			}, $bookings_arr ),
			'bookings_total'    => $total_all,
			'active_bookings'   => $total_active,
			'cancelled_bookings' => $total_cancelled,
		];
	}

	/**
	 * Saves admin-only private notes on a customer profile.
	 *
	 * @param  int    $id     Customer ID.
	 * @param  string $notes  Notes (max 1000 chars, sanitized).
	 * @return true|WP_Error
	 */
	public function update_customer_notes( int $id, string $notes ): bool|WP_Error {
		global $wpdb;

		$exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$wpdb->prefix}playard_customers WHERE id = %d LIMIT 1",
				$id
			)
		);

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

		$result = $wpdb->update(
			$wpdb->prefix . 'playard_customers',
			[
				'notes'      => sanitize_textarea_field( mb_substr( $notes, 0, 1000 ) ),
				'updated_at' => current_time( 'mysql' ),
			],
			[ 'id' => $id ],
			[ '%s', '%s' ],
			[ '%d' ]
		);

		return $result !== false;
	}

	/**
	 * URL-safe base64 encoding (base64url, no padding).
	 */
	private function base64url( string $data ): string {
		return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
	}
}
