<?php

declare(strict_types=1);

namespace SiteFort\Modules\Notifications;

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

use SiteFort\Includes\Interfaces\Module;
use SiteFort\Modules\Firewall\FirewallLogger;
use SiteFort\Modules\Hardening\Hardening;
use SiteFort\Modules\Hardening\LockoutUnlockLink;

/**
 * Notifications Module - event notifications for site-level security events.
 *
 * The plugin dispatches local email delivery and webhook delivery and forwards
 * notification events to Console for centralized in-app visibility.
 */
class Notifications implements Module {

	private string $plugin_name;
	private string $version;
	private NotificationSettings $settings;
	private NotificationService $service;
	private WebhookDelivery $webhook;

	public function __construct( string $plugin_name, string $version ) {
		$this->plugin_name = $plugin_name;
		$this->version     = $version;
		$this->settings    = new NotificationSettings();
		$this->service     = new NotificationService( $this->settings );
		$this->webhook     = new WebhookDelivery( $this->settings );
	}

	public function initialize( \SiteFort\Includes\Module_Loader $loader ): void {
		add_filter( 'cron_schedules', [ $this, 'register_cron_schedules' ] );
		$loader->add_action( 'init', $this, 'maybe_schedule_fw_digest', 5, 0 );
		$loader->add_action( 'sitefort_fw_block_digest_send', $this, 'send_firewall_block_digest', 10, 0 );
		$loader->add_action( 'sitefort_push_notification_event', $this, 'on_push_notification_event', 10, 2 );
		$loader->add_action( 'sitefort_scan_completed', $this, 'on_scan_completed', 10, 1 );
		$loader->add_action( 'sitefort_vulns_found_batch', $this, 'on_vulns_found_batch', 10, 1 );
		$loader->add_action( 'sitefort_lockout_activated', $this, 'on_login_lockout', 10, 2 );
		$loader->add_action( 'sitefort_2fa_enrolled', $this, 'on_2fa_enrolled', 10, 2 );
		$loader->add_action( 'sitefort_2fa_disabled', $this, 'on_2fa_disabled', 10, 2 );
		$loader->add_action( 'sitefort_scan_failure_alert', $this, 'on_scan_failure', 10, 3 );
		$loader->add_action( 'sitefort_tools_action_completed', $this, 'on_tools_action', 10, 1 );
		$loader->add_action( 'wp_login', $this, 'on_admin_login', 10, 2 );
		$loader->add_action( 'sitefort_plugin_deactivating', $this, 'on_plugin_deactivating', 10, 0 );
		$loader->add_action( 'sitefort_login_url_changed', $this, 'on_login_url_changed', 10, 1 );
		$loader->add_action( 'sitefort_webhook_retry', $this->webhook, 'retry_send', 10, 2 );
		$loader->add_action( 'rest_api_init', $this, 'register_rest_routes', 10, 0 );
		// Backward-compatible AJAX handlers (will be removed when frontend switches to REST).
		$loader->add_action( 'wp_ajax_sitefort_get_notification_settings', $this, 'handle_get_settings' );
		$loader->add_action( 'wp_ajax_sitefort_save_notification_settings', $this, 'handle_save_settings' );
	}

	public function on_scan_completed( array $data ): void {
		$issues_count = (int) ( $data['issues_count'] ?? 0 );

		if ( $issues_count === 0 ) {
			return; // Clean scans don't trigger notifications.
		}

		$this->service->send( 'security.scan_completed', $data );
	}

	public function on_vulns_found_batch( array $vulns ): void {
		if ( empty( $vulns ) ) {
			return;
		}

		$severity_rank = [ 'low' => 0, 'medium' => 1, 'high' => 2, 'critical' => 3 ];
		$max_severity = 'low';
		foreach ( $vulns as $vuln ) {
			$sev = strtolower( (string) ( $vuln['severity'] ?? 'low' ) );
			if ( ( $severity_rank[ $sev ] ?? -1 ) > ( $severity_rank[ $max_severity ] ?? -1 ) ) {
				$max_severity = $sev;
			}
		}

		$this->service->send( 'security.vuln_detected', [
			'count'           => count( $vulns ),
			'severity'        => $max_severity,
			'vulnerabilities' => $vulns,
		] );
	}

	/**
	 * Register weekly and monthly cron schedules not available in WP core.
	 */
	public function register_cron_schedules( array $schedules ): array {
		if ( ! isset( $schedules['weekly'] ) ) {
			$schedules['weekly'] = [ 'interval' => 604800, 'display' => 'Once Weekly' ];
		}
		if ( ! isset( $schedules['monthly'] ) ) {
			$schedules['monthly'] = [ 'interval' => 2592000, 'display' => 'Once Monthly' ];
		}
		return $schedules;
	}

	/**
	 * Ensure the firewall block digest cron is scheduled at the correct frequency.
	 * Called on init; handles enable/disable and schedule changes automatically.
	 */
	public function maybe_schedule_fw_digest(): void {
		$hook = 'sitefort_fw_block_digest_send';

		if ( ! $this->settings->is_event_enabled( 'security.firewall_block' ) ) {
			wp_clear_scheduled_hook( $hook );
			return;
		}

		$desired   = $this->settings->get_digest_schedule( 'security.firewall_block' );
		$scheduled = wp_get_scheduled_event( $hook );

		if ( $scheduled && $scheduled->schedule === $desired ) {
			return; // Already scheduled at the right frequency.
		}

		// Clear any existing schedule and reschedule at the new frequency.
		wp_clear_scheduled_hook( $hook );
		wp_schedule_event( time() + 60, $desired, $hook );
	}

	/**
	 * Send the firewall block digest email from recorded firewall events.
	 * Invoked by the sitefort_fw_block_digest_send cron hook.
	 */
	public function send_firewall_block_digest(): void {
		if ( ! $this->settings->is_event_enabled( 'security.firewall_block' ) ) {
			return;
		}

		if ( ! FirewallLogger::is_schema_current() ) {
			return;
		}

		$lock_key = 'sitefort_fw_digest_lock';
		if ( get_transient( $lock_key ) ) {
			return;
		}
		set_transient( $lock_key, '1', 5 * MINUTE_IN_SECONDS );

		try {
			global $wpdb;
			$table           = self::table_identifier( $wpdb->prefix . 'sitefort_firewall_events' );
			$current_blog_id = get_current_blog_id();

			$schedule = $this->settings->get_digest_schedule( 'security.firewall_block' );
			$period_seconds = [
				'daily'   => DAY_IN_SECONDS,
				'weekly'  => 7 * DAY_IN_SECONDS,
				'monthly' => 30 * DAY_IN_SECONDS,
			][ $schedule ] ?? DAY_IN_SECONDS;

			$since = time() - $period_seconds;

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
				$type_rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT attack_type, COUNT(*) AS cnt
						FROM {$table}
						WHERE event_time >= %d AND blog_id = %d
						GROUP BY attack_type
						ORDER BY cnt DESC
						LIMIT 20",
						$since,
						$current_blog_id
					),
					ARRAY_A
				);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

			if ( ! is_array( $type_rows ) || empty( $type_rows ) ) {
				return;
			}

			$count = 0;
			$by_type = [];
			foreach ( $type_rows as $row ) {
				$type = (string) ( $row['attack_type'] ?? '' );
				$type = '' !== $type ? $type : 'Unknown';
				$cnt  = (int) ( $row['cnt'] ?? 0 );
				if ( $cnt <= 0 ) {
					continue;
				}
				$by_type[ $type ] = $cnt;
				$count += $cnt;
			}

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

			$period    = [
				'daily'   => 'past 24 hours',
				'weekly'  => 'past 7 days',
				'monthly' => 'past 30 days',
			][ $schedule ] ?? $schedule;
			$site_name = get_bloginfo( 'name' ) ?: (string) wp_parse_url( home_url(), PHP_URL_HOST );
			$digest_data = $this->build_firewall_digest_payload( $site_name, $period, $schedule, $count, $by_type );

			$this->service->send( 'security.firewall_block', $digest_data, [ 'respect_cooldown' => false ] );
		} finally {
			delete_transient( $lock_key );
		}
	}

	/**
	 * Build the normalized firewall digest payload shared by email and push/webhook delivery.
	 *
	 * @param array<string,int> $by_type
	 * @return array<string,mixed>
	 */
	private function build_firewall_digest_payload( string $site_name, string $period, string $schedule, int $count, array $by_type ): array {
		$top_types = [];
		foreach ( array_slice( $by_type, 0, 5, true ) as $type => $type_count ) {
			$top_types[] = sprintf( '%s (%d)', $type, (int) $type_count );
		}

		$details = [
			'Digest Period' => $period,
			'Total Blocks'  => (string) $count,
		];
		if ( ! empty( $top_types ) ) {
			$details['Top Attack Types'] = implode( ', ', $top_types );
		}

		return [
			'message'         => sprintf( 'SiteFort blocked %d request(s) on %s during the %s.', $count, $site_name, $period ),
			'blocked_count'   => $count,
			'digest_period'   => $period,
			'digest_schedule' => $schedule,
			'by_type'         => $by_type,
			'detected_at'     => gmdate( 'Y-m-d H:i:s' ) . ' UTC',
			'details'         => $details,
		];
	}

	/**
	 * Escape a plugin-owned table identifier for direct interpolation.
	 */
	private static function table_identifier( string $table ): string {
		if ( $table === '' || strlen( $table ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) {
			throw new \InvalidArgumentException( 'Invalid table identifier.' );
		}

		return '`' . str_replace( '`', '``', $table ) . '`';
	}

	public function on_login_lockout( string $identifier, string $type ): void {
		$hardening = Hardening::get_saved_settings();
		$threshold = 'ip' === $type
			? (int) ( $hardening['limit_login_attempts_max'] ?? 0 )
			: (int) ( $hardening['limit_login_attempts_max_user'] ?? 0 );
		$window = (int) ( $hardening['limit_login_attempts_window'] ?? 0 );
		$duration = (int) ( $hardening['limit_login_attempts_lockout'] ?? 0 );

		$this->service->send( 'security.login_lockout', [
			'identifier' => $identifier,
			'type'       => $type, // 'ip' or 'user'
			'message'    => 'ip' === $type
				? "IP address {$identifier} has been locked out due to too many failed login attempts."
				: "User \"{$identifier}\" has been locked out due to too many failed login attempts.",
			'threshold' => $threshold > 0
				? sprintf(
					/* translators: %d: number of failed logins before lockout. */
					_n( '%d failed login attempt', '%d failed login attempts', $threshold, 'sitefort' ),
					$threshold
				)
				: '',
			'detection_window' => $window > 0 ? human_time_diff( time(), time() + $window ) : '',
			'lockout_duration' => $duration > 0 ? human_time_diff( time(), time() + $duration ) : '',
			'detected_at'      => gmdate( 'Y-m-d H:i:s' ) . ' UTC',
			'details'    => [
				'Lockout type' => $type,
				'Identifier'   => $identifier,
			],
		], [
			// Bearer credential; must never ride $data into webhook/cloud push.
			'email_only_data' => [
				'unlock_url'       => LockoutUnlockLink::build_url( $type, $identifier ),
				'unlock_valid_for' => human_time_diff( time(), time() + LockoutUnlockLink::validity_seconds() ),
			],
		] );
	}

	public function on_2fa_enrolled( int $user_id, array $context = [] ): void {
		$user = get_userdata( $user_id );
		$name = $user ? $user->user_login : "ID {$user_id}";
		$method = (string) ( $context['method'] ?? '' );
		$remaining_codes = isset( $context['recovery_codes_remaining'] ) ? (int) $context['recovery_codes_remaining'] : null;

		$message = "Two-factor authentication was enabled for user \"{$name}\".";
		if ( $method !== '' ) {
			$message .= ' Method: ' . strtoupper( $method ) . '.';
		}

		$details = [
			'User'   => $name,
			'Action' => 'Enrolled',
		];

		if ( $method !== '' ) {
			$details['Method'] = strtoupper( $method );
		}

		if ( null !== $remaining_codes ) {
			$details['Recovery codes remaining'] = (string) $remaining_codes;
		}

		$this->service->send( 'security.2fa_change', [
			'user_id'  => $user_id,
			'username' => $name,
			'action'   => 'enrolled',
			'message'  => $message,
			'details'  => $details,
		] );
	}

	public function on_2fa_disabled( int $user_id, array $context = [] ): void {
		$user = get_userdata( $user_id );
		$name = $user ? $user->user_login : "ID {$user_id}";
		$previous_method = (string) ( $context['previous_method'] ?? '' );
		$recovery_codes_cleared = ! empty( $context['recovery_codes_cleared'] );

		$message = "Two-factor authentication was disabled for user \"{$name}\".";
		if ( $previous_method !== '' ) {
			$message .= ' Previous method: ' . strtoupper( $previous_method ) . '.';
		}

		$details = [
			'User'   => $name,
			'Action' => 'Disabled',
		];

		if ( $previous_method !== '' ) {
			$details['Previous method'] = strtoupper( $previous_method );
		}

		if ( $recovery_codes_cleared ) {
			$details['Recovery codes'] = 'Cleared';
		}

		$this->service->send( 'security.2fa_change', [
			'user_id'  => $user_id,
			'username' => $name,
			'action'   => 'disabled',
			'message'  => $message,
			'details'  => $details,
		] );
	}

	public function on_scan_failure( string $type, string $message, array $context ): void {
		$this->service->send( 'security.scan_failure', [
			'type'    => $type,
			'message' => $message,
			'scan_id' => $context['scan_id'] ?? '',
		] );
	}

	public function on_tools_action( string $action_name ): void {
		$labels = [
			'regenerate_salts'       => 'Security Salts Regenerated',
			'change_prefix'          => 'Database Prefix Changed',
			'change_admin_id'        => 'Admin User ID Changed',
			'change_admin_username'  => 'Admin Username Changed',
			'rotate_encryption_key'  => 'Encryption Key Rotated',
		];
		$label = $labels[ $action_name ] ?? ucwords( str_replace( '_', ' ', $action_name ) );
		$this->service->send( 'security.tools_action', [
			'action'  => $action_name,
			'message' => "Security tool action completed: {$label}.",
			'details' => [ 'Action' => $label ],
		] );
	}

	public function on_admin_login( string $user_login, \WP_User $user ): void {
		if ( ! \SiteFort\Includes\Capabilities::user_can_manage_plugin( $user ) ) {
			return;
		}

		$ip = \SiteFort\Includes\IpDetector::get_ip();
		$ua = filter_input(
			INPUT_SERVER,
			'HTTP_USER_AGENT',
			FILTER_CALLBACK,
			[
				'options' => 'sanitize_text_field',
				'flags'   => FILTER_REQUIRE_SCALAR,
			]
		);
		$ua = is_string( $ua ) ? $ua : '';
		$login_time = current_time( 'mysql' );

		$this->service->send( 'security.admin_login', [
			'user_id'    => $user->ID,
			'username'   => $user_login,
			'ip_address' => $ip,
			'browser'    => $ua,
			'login_time' => $login_time,
			'message'    => "Administrator \"{$user_login}\" logged in from {$ip}.",
			'details'    => [
				'User'       => $user_login,
				'IP Address' => $ip,
				'Browser'    => $ua,
				'Time'       => $login_time,
			],
		] );
	}

	public function on_plugin_deactivating(): void {
		$user     = wp_get_current_user();
		$username = $user->exists() ? $user->user_login : 'Unknown';
		$ip       = \SiteFort\Includes\IpDetector::get_ip();

		$this->service->send( 'security.plugin_deactivated', [
			'user_id'  => $user->ID ?? 0,
			'username' => $username,
			'message'  => "SiteFort was deactivated by \"{$username}\" from {$ip}.",
			'details'  => [
				'User'       => $username,
				'IP Address' => $ip,
				'Time'       => current_time( 'mysql' ),
			],
		] );
	}

	public function on_login_url_changed( string $new_login_url ): void {
		$new_login_url = esc_url_raw( $new_login_url );
		if ( '' === $new_login_url ) {
			return;
		}

		$this->service->send( 'security.login_url_changed', [
			'new_login_url' => $new_login_url,
			'message'       => "The WordPress login URL was changed to {$new_login_url}.",
			'details'       => [
				'New Login URL' => $new_login_url,
				'Time'          => current_time( 'mysql' ),
			],
		] );
	}

	/**
	 * Forward notification events to webhook delivery.
	 *
	 * @param string $event_type Event identifier.
	 * @param array  $data       Event data.
	 */
	public function on_push_notification_event( string $event_type, array $data ): void {
		if ( $this->webhook->is_enabled() ) {
			$this->webhook->send( $event_type, $data );
		}
	}

	public function register_rest_routes(): void {
		$ns = 'sitefort/v1';

		register_rest_route( $ns, '/notifications/settings', [
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ $this, 'rest_get_settings' ],
				'permission_callback' => [ $this, 'check_rest_permission' ],
			],
			[
				'methods'             => \WP_REST_Server::EDITABLE,
				'callback'            => [ $this, 'rest_save_settings' ],
				'permission_callback' => [ $this, 'check_rest_permission' ],
			],
		] );

		register_rest_route( $ns, '/notifications/test-webhook', [
			'methods'             => \WP_REST_Server::EDITABLE,
			'callback'            => [ $this, 'rest_test_webhook' ],
			'permission_callback' => [ $this, 'check_rest_permission' ],
		] );
	}

	public function check_rest_permission(): bool {
		return \SiteFort\Includes\Capabilities::can_manage_plugin();
	}

	public function rest_get_settings( \WP_REST_Request $request ): \WP_REST_Response {
		$include_contacts = $this->should_include_admin_contacts( $request->get_param( 'include_contacts' ), true );

		return new \WP_REST_Response( $this->build_settings_response( $include_contacts ), 200 );
	}

	public function rest_save_settings( \WP_REST_Request $request ): \WP_REST_Response {
		$input = $request->get_json_params();
		try {
			$this->settings->save( is_array( $input ) ? $input : [] );
		} catch ( \InvalidArgumentException $e ) {
			return new \WP_REST_Response( [
				'message' => $e->getMessage(),
			], 400 );
		}

		$include_contacts = $this->should_include_admin_contacts( $request->get_param( 'include_contacts' ), true );

		return new \WP_REST_Response( [
			'message'  => 'Notification settings saved.',
			'settings' => $this->build_settings_response( $include_contacts ),
		], 200 );
	}

	public function rest_test_webhook( \WP_REST_Request $request ): \WP_REST_Response {
		$params   = $request->get_json_params();
		$active   = $this->settings->resolve_active_webhook();
		$url      = esc_url_raw( (string) ( $params['url'] ?? '' ) );
		$provider = sanitize_key( (string) ( $params['provider'] ?? ( $active['provider'] ?? 'slack' ) ) );
		if ( ! in_array( $provider, [ 'slack', 'discord', 'generic' ], true ) ) {
			$provider = 'slack';
		}

		if ( empty( $url ) ) {
			$url = $this->settings->get_provider_webhook_url( $provider );
		}

		if ( empty( $url ) || ! WebhookDelivery::validate_webhook_url( $provider, $url ) ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => sprintf( 'Invalid %s webhook URL.', ucfirst( $provider ) ),
			], 400 );
		}

		$test_delivery = new WebhookDelivery( [
			'webhook_enabled'         => true,
			'webhook_active_provider' => $provider,
			'webhook_provider_urls'   => [
				$provider => $url,
			],
		] );

		$success = $test_delivery->send( 'test.webhook', [ 'message' => 'This is a test notification from SiteFort.' ] );

		return new \WP_REST_Response( [
			'success' => $success,
			'message' => $success ? 'Test webhook sent successfully.' : 'Failed to deliver test webhook.',
		] );
	}

	public function handle_get_settings(): void {
		check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
		if ( ! \SiteFort\Includes\Capabilities::can_manage_plugin() ) {
			wp_send_json_error( [ 'message' => 'Unauthorized' ] );
			return;
		}

		$include_contacts = $this->should_include_admin_contacts(
			$this->get_ajax_include_contacts_param(),
			true
		);

		wp_send_json_success( $this->build_settings_response( $include_contacts ) );
	}

	public function handle_save_settings(): void {
		check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
		if ( ! \SiteFort\Includes\Capabilities::can_manage_plugin() ) {
			wp_send_json_error( [ 'message' => 'Unauthorized' ] );
			return;
		}

		$raw   = $this->get_ajax_json_payload( 'settings', '{}' );
		$input = $this->decode_ajax_json_array( $raw );

		if ( null === $input ) {
			wp_send_json_error( [ 'message' => 'Invalid settings' ] );
			return;
		}

		try {
			$this->settings->save( $input );
		} catch ( \InvalidArgumentException $e ) {
			wp_send_json_error( [ 'message' => $e->getMessage() ] );
			return;
		}
		$include_contacts = $this->should_include_admin_contacts(
			$this->get_ajax_include_contacts_param(),
			true
		);

		wp_send_json_success( [
			'message'  => 'Notification settings saved.',
			'settings' => $this->build_settings_response( $include_contacts ),
		] );
	}

	/**
	 * Build the admin settings payload used by AJAX and REST UI surfaces.
	 *
	 * Keeps UI-only recipient metadata out of redacted state used by cloud sync.
	 *
	 * @return array<string,mixed>
	 */
	private function build_settings_response( bool $include_admin_contacts = true ): array {
		$response = $this->settings->get_redacted( true );
		$response['fallback_recipient_email'] = $this->settings->get_fallback_recipient_email();

		if ( $include_admin_contacts ) {
			$response['available_admin_contacts'] = $this->settings->get_available_admin_contacts();
		}

		return $response;
	}

	/**
	 * @param mixed $value
	 */
	private function should_include_admin_contacts( $value, bool $default ): bool {
		if ( null === $value ) {
			return $default;
		}

		return rest_sanitize_boolean( $value );
	}

	/**
	 * Read the AJAX include_contacts flag through a single sanitization boundary.
	 *
	 * @return string|null
	 */
	private function get_ajax_include_contacts_param(): ?string {
		$value = filter_input(
			INPUT_POST,
			'include_contacts',
			FILTER_CALLBACK,
			[
				'options' => [ self::class, 'sanitize_ajax_scalar_payload' ],
				'flags'   => FILTER_REQUIRE_SCALAR,
			]
		);
		if ( null === $value || false === $value ) {
			return null;
		}

		return $value;
	}

	private function get_ajax_json_payload( string $key, string $default ): string {
		if ( ! filter_has_var( INPUT_POST, $key ) ) {
			return $default;
		}

		$value = filter_input(
			INPUT_POST,
			$key,
			FILTER_CALLBACK,
			[
				'options' => [ self::class, 'sanitize_ajax_json_payload' ],
				'flags'   => FILTER_REQUIRE_SCALAR,
			]
		);

		return is_string( $value ) ? $value : '';
	}

	/**
	 * @param mixed $value
	 */
	public static function sanitize_ajax_scalar_payload( $value ): string {
		return is_scalar( $value ) ? sanitize_text_field( (string) $value ) : '';
	}

	/**
	 * JSON is decoded and setting-normalized after this read.
	 *
	 * @param mixed $value
	 */
	public static function sanitize_ajax_json_payload( $value ): string {
		return is_scalar( $value ) ? str_replace( "\0", '', (string) $value ) : '';
	}

	/**
	 * Decode and structurally sanitize an AJAX JSON settings payload before the
	 * notification settings service applies field-specific validation.
	 *
	 * @return array<mixed>|null
	 */
	private function decode_ajax_json_array( string $raw ): ?array {
		$decoded = json_decode( $raw, true );
		if ( ! is_array( $decoded ) ) {
			return null;
		}

		return self::sanitize_decoded_json_payload( $decoded );
	}

	/**
	 * @param mixed $value
	 * @return mixed
	 */
	private static function sanitize_decoded_json_payload( $value ) {
		if ( is_array( $value ) ) {
			$sanitized = [];
			foreach ( $value as $key => $item ) {
				$clean_key = is_int( $key )
					? $key
					: (string) preg_replace( '/[\x00-\x1F\x7F]/', '', sanitize_text_field( wp_check_invalid_utf8( (string) $key ) ) );
				if ( ! is_int( $key ) && '' === $clean_key ) {
					continue;
				}

				$sanitized[ $clean_key ] = self::sanitize_decoded_json_payload( $item );
			}

			return $sanitized;
		}

		if ( is_string( $value ) ) {
			return str_replace( "\0", '', wp_check_invalid_utf8( $value ) );
		}

		return is_scalar( $value ) || null === $value ? $value : null;
	}
}
