<?php
/**
 * BitForgeNet Guest Tickets - Security helpers: capabilities, nonces, rate limiting, tokens, hardening.
 *
 * @package BitForgeNet_Guest_Tickets
 * @since   1.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Centralised security surface for the plugin.
 */
class BFNET_Security {

	/**
	 * Register hardening hooks.
	 */
	public function __construct() {
		add_action( 'admin_init', array( $this, 'block_unauthorised_ajax_probe' ), 1 );
		add_filter( 'wp_headers', array( $this, 'security_headers' ) );
	}

	/**
	 * Add a couple of conservative headers on plugin-owned front-end routes.
	 *
	 * @param array $headers Existing headers.
	 * @return array
	 */
	public function security_headers( $headers ) {
		if ( ! empty( $GLOBALS['bfnet_is_plugin_route'] ) ) {
			$headers['X-Content-Type-Options'] = 'nosniff';
			$headers['Referrer-Policy']        = 'strict-origin-when-cross-origin';
			$headers['X-Frame-Options']        = 'SAMEORIGIN';
		}
		return $headers;
	}

	/**
	 * Reject stray admin-ajax probing of plugin actions from logged-out users.
	 *
	 * @return void
	 */
	public function block_unauthorised_ajax_probe() {
		if ( ! wp_doing_ajax() || is_user_logged_in() ) {
			return;
		}
		$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
		$public = array( 'bfnet_register', 'bfnet_check_availability' );
		if ( 0 === strpos( $action, 'bfnet_' ) && ! in_array( $action, $public, true ) ) {
			wp_send_json_error( array( 'message' => __( 'Not allowed.', 'bitforgenet-guest-tickets' ) ), 403 );
		}
	}

	/* --------------------------------------------------------------------- */
	/* Capabilities                                                          */
	/* --------------------------------------------------------------------- */

	/**
	 * Check a capability, allowing administrators through by default.
	 *
	 * @param string $cap Capability name.
	 * @return bool
	 */
	public static function can( $cap ) {
		return current_user_can( $cap ) || current_user_can( 'manage_options' );
	}

	/**
	 * Abort the request unless the user holds the capability.
	 *
	 * @param string $cap Capability name.
	 * @return void
	 */
	public static function require_cap( $cap ) {
		if ( ! self::can( $cap ) ) {
			wp_die(
				esc_html__( 'You do not have permission to access this BitForgeNet Guest Tickets screen.', 'bitforgenet-guest-tickets' ),
				esc_html__( 'Permission denied', 'bitforgenet-guest-tickets' ),
				array( 'response' => 403 )
			);
		}
	}

	/**
	 * Verify an admin nonce and capability in one call, dying on failure.
	 *
	 * @param string $action Nonce action.
	 * @param string $cap    Required capability.
	 * @param string $field  Request field holding the nonce.
	 * @return void
	 */
	public static function verify_admin_request( $action, $cap, $field = '_bfnet_nonce' ) {
		self::require_cap( $cap );

		$nonce = isset( $_REQUEST[ $field ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $field ] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, $action ) ) {
			wp_die(
				esc_html__( 'Security check failed. Please reload the page and try again.', 'bitforgenet-guest-tickets' ),
				esc_html__( 'Invalid request', 'bitforgenet-guest-tickets' ),
				array( 'response' => 403 )
			);
		}
	}

	/* --------------------------------------------------------------------- */
	/* Tokens                                                                */
	/* --------------------------------------------------------------------- */

	/**
	 * Generate a cryptographically secure random token.
	 *
	 * @param int $bytes Entropy in bytes.
	 * @return string Lowercase hex string.
	 */
	public static function random_token( $bytes = 24 ) {
		try {
			return bin2hex( random_bytes( $bytes ) );
		} catch ( Exception $e ) {
			return bin2hex( wp_generate_password( $bytes, false, false ) );
		}
	}

	/**
	 * Hash a ticket token for storage and lookup.
	 *
	 * Uses the site's AUTH_SALT so a leaked database alone cannot forge codes
	 * without also leaking wp-config.php.
	 *
	 * @param string $token Raw token.
	 * @return string 64-char hex digest.
	 */
	public static function hash_token( $token ) {
		$salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'bitforgenet-guest-tickets-fallback-salt';
		return hash_hmac( 'sha256', (string) $token, $salt );
	}

	/**
	 * Constant-time string comparison.
	 *
	 * @param string $known Expected value.
	 * @param string $given Supplied value.
	 * @return bool
	 */
	public static function equals( $known, $given ) {
		return hash_equals( (string) $known, (string) $given );
	}

	/**
	 * Build a signed, expiring URL signature.
	 *
	 * @param string $payload Data to sign.
	 * @param int    $expires Unix timestamp.
	 * @return string
	 */
	public static function sign( $payload, $expires ) {
		$salt = defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : 'bitforgenet-guest-tickets-fallback-salt-2';
		return hash_hmac( 'sha256', $payload . '|' . $expires, $salt );
	}

	/**
	 * Validate a signature produced by sign().
	 *
	 * @param string $payload   Signed data.
	 * @param int    $expires   Claimed expiry.
	 * @param string $signature Provided signature.
	 * @return bool
	 */
	public static function verify_signature( $payload, $expires, $signature ) {
		if ( (int) $expires < time() ) {
			return false;
		}
		return self::equals( self::sign( $payload, (int) $expires ), (string) $signature );
	}

	/* --------------------------------------------------------------------- */
	/* Client fingerprint and rate limiting                                  */
	/* --------------------------------------------------------------------- */

	/**
	 * Best-effort client IP, honouring common proxy headers safely.
	 *
	 * @return string
	 */
	public static function client_ip() {
		$candidates = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR' );
		foreach ( $candidates as $key ) {
			if ( empty( $_SERVER[ $key ] ) ) {
				continue;
			}
			$raw  = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
			$list = explode( ',', $raw );
			$ip   = trim( $list[0] );
			if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
				return $ip;
			}
		}
		return '0.0.0.0';
	}

	/**
	 * Pseudonymised IP for GDPR-friendly logging.
	 *
	 * @param string|null $ip Optional explicit IP.
	 * @return string
	 */
	public static function ip_hash( $ip = null ) {
		$ip   = null === $ip ? self::client_ip() : $ip;
		$salt = defined( 'NONCE_SALT' ) ? NONCE_SALT : 'bitforgenet-guest-tickets-fallback-salt-3';
		return hash_hmac( 'sha256', $ip, $salt );
	}

	/**
	 * Truncated user agent for logging.
	 *
	 * @return string
	 */
	public static function user_agent() {
		$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		return substr( $ua, 0, 250 );
	}

	/**
	 * Sliding-window rate limiter backed by transients.
	 *
	 * @param string $bucket  Logical bucket name.
	 * @param int    $limit   Maximum hits per window.
	 * @param int    $window  Window length in seconds.
	 * @param string $subject Optional explicit subject; defaults to the IP hash.
	 * @return bool True when the request is allowed.
	 */
	public static function rate_limit( $bucket, $limit, $window = HOUR_IN_SECONDS, $subject = '' ) {
		$limit = (int) $limit;
		if ( $limit <= 0 ) {
			return true;
		}

		$subject = $subject ? $subject : self::ip_hash();
		$key     = 'bfnet_rl_' . substr( md5( $bucket . '|' . $subject ), 0, 24 );
		$hits    = get_transient( $key );

		if ( false === $hits ) {
			set_transient( $key, 1, $window );
			return true;
		}

		$hits = (int) $hits;
		if ( $hits >= $limit ) {
			return false;
		}

		set_transient( $key, $hits + 1, $window );
		return true;
	}

	/* --------------------------------------------------------------------- */
	/* Input sanitising                                                      */
	/* --------------------------------------------------------------------- */

	/**
	 * Sanitise a person's name.
	 *
	 * @param string $value Raw input.
	 * @return string
	 */
	public static function clean_name( $value ) {
		$value = sanitize_text_field( (string) $value );
		$value = preg_replace( "/[^\p{L}\p{M}\s'\-\.]/u", '', $value );
		$value = preg_replace( '/\s+/u', ' ', (string) $value );
		return trim( mb_substr( (string) $value, 0, 80 ) );
	}

	/**
	 * Sanitise a phone number to digits and a leading plus.
	 *
	 * @param string $value Raw input.
	 * @return string
	 */
	public static function clean_phone( $value ) {
		$value = preg_replace( '/[^\d\+\s\-\(\)]/', '', (string) $value );
		return trim( substr( (string) $value, 0, 40 ) );
	}

	/**
	 * Validate an email address, optionally rejecting known disposable domains.
	 *
	 * @param string $value Raw input.
	 * @return string Empty string when invalid.
	 */
	public static function clean_email( $value ) {
		$email = sanitize_email( trim( (string) $value ) );
		if ( ! $email || ! is_email( $email ) ) {
			return '';
		}

		if ( (int) BFNET_Settings::get( 'block_disposable_email', 0 ) ) {
			$domain     = strtolower( substr( strrchr( $email, '@' ), 1 ) );
			$disposable = apply_filters(
				'bfnet_disposable_email_domains',
				array( 'mailinator.com', 'yopmail.com', 'guerrillamail.com', 'tempmail.com', '10minutemail.com', 'trashmail.com', 'sharklasers.com' )
			);
			if ( in_array( $domain, $disposable, true ) ) {
				return '';
			}
		}

		return $email;
	}

	/**
	 * Sanitise a Google Maps URL, allowing only known map hosts.
	 *
	 * @param string $value Raw URL.
	 * @return string
	 */
	public static function clean_map_url( $value ) {
		$url = esc_url_raw( trim( (string) $value ) );
		if ( ! $url ) {
			return '';
		}
		$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
		$ok   = array( 'maps.google.com', 'www.google.com', 'google.com', 'goo.gl', 'maps.app.goo.gl', 'www.openstreetmap.org', 'openstreetmap.org', 'maps.apple.com' );
		foreach ( $ok as $allowed ) {
			if ( $host === $allowed || substr( $host, -( strlen( $allowed ) + 1 ) ) === '.' . $allowed ) {
				return $url;
			}
		}
		return '';
	}
}
