<?php

declare(strict_types=1);

/**
 * CommandReceiver - REST endpoints for Console command delivery and status probes.
 *
 * Console pushes commands to POST /wp-json/securwp/v1/command. The plugin
 * persists accepted commands locally, returns an immediate acceptance response,
 * and executes them via the background queue.
 */

namespace SecurWP\Modules\Scanner\API;

use SecurWP\Includes\RequestVerifier;


use SecurWP\Modules\Cloud\Service\CloudSettings;
use SecurWP\Modules\Cloud\Services\CommandExecutor;
use SecurWP\Modules\Cloud\Services\CommandQueue;

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

	class CommandReceiver {

		const NAMESPACE = 'securwp/v1';

	/**
	 * Register REST API routes.
	 */
	public static function register() {
		add_action( 'rest_api_init', [ __CLASS__, 'register_routes' ] );
		CommandQueue::register_hooks();
	}

	/**
	 * Register the command endpoint.
	 */
	public static function register_routes() {
		register_rest_route( self::NAMESPACE, '/command', [
			'methods'             => 'POST',
			'callback'            => [ __CLASS__, 'handle_command' ],
			'permission_callback' => [ __CLASS__, 'verify_command_signature' ],
		] );

			register_rest_route( self::NAMESPACE, '/command/status', [
				'methods'             => 'POST',
				'callback'            => [ __CLASS__, 'handle_command_status' ],
				'permission_callback' => [ __CLASS__, 'verify_command_status_signature' ],
			] );

		register_rest_route( self::NAMESPACE, '/scan/status', [
			'methods'             => 'GET',
			'callback'            => [ __CLASS__, 'get_scan_status' ],
			'permission_callback' => [ __CLASS__, 'verify_scan_status_signature' ],
		] );
	}

	/**
	 * Per-endpoint rate limits (requests per IP per minute).
	 *
	 * /command: low - console sends at most a few commands per minute per site.
		 * /command/status: higher - Console may reconcile multiple accepted commands.
		 * /scan/status: higher - console may poll frequently during active scans.
		 */
		private const COMMAND_RATE_LIMIT         = 20;
		private const COMMAND_STATUS_RATE_LIMIT  = 120;
		private const SCAN_STATUS_RATE_LIMIT     = 60;
		private const MAX_COMMAND_BODY_BYTES     = 65536;
		private const MAX_COMMAND_PARAMS_BYTES   = 16384;

	/**
	 * Permission callback for POST /command.
	 *
	 * @param \WP_REST_Request $request
	 * @return bool|\WP_Error
	 */
		public static function verify_command_signature( $request ) {
			$rate_error = self::check_rate_limit( 'cmd', self::COMMAND_RATE_LIMIT );
			if ( $rate_error !== null ) {
				return $rate_error;
			}
			return self::verify_signature( $request );
		}

		/**
		 * Permission callback for POST /command/status.
		 *
		 * @param \WP_REST_Request $request
		 * @return bool|\WP_Error
		 */
		public static function verify_command_status_signature( $request ) {
			$rate_error = self::check_rate_limit( 'cmd_status', self::COMMAND_STATUS_RATE_LIMIT );
			if ( $rate_error !== null ) {
				return $rate_error;
			}
			return self::verify_signature( $request );
		}

	/**
	 * Permission callback for GET /scan/status.
	 *
	 * @param \WP_REST_Request $request
	 * @return bool|\WP_Error
	 */
	public static function verify_scan_status_signature( $request ) {
		$rate_error = self::check_rate_limit( 'scan', self::SCAN_STATUS_RATE_LIMIT );
		if ( $rate_error !== null ) {
			return $rate_error;
		}
		return self::verify_signature( $request );
	}

	/**
	 * Verify the request signature (HMAC + replay protection).
	 *
	 * @param \WP_REST_Request $request
	 * @return bool|\WP_Error
	 */
	public static function verify_signature( $request ) {
		$settings = CloudSettings::get_all();
		$site_token = sanitize_text_field( (string) ( $settings['site_token'] ?? '' ) );
		$site_secret = sanitize_text_field( (string) ( $settings['site_secret'] ?? '' ) );

		$raw = $request->get_headers();
		$headers = [];
		foreach ( $raw as $key => $values ) {
			// WP REST API returns underscored keys (x_site_token); RequestVerifier expects dashes (x-site-token).
			$k = str_replace( '_', '-', strtolower( (string) $key ) );
			$headers[ $k ] = is_array( $values ) ? (string) ( $values[0] ?? '' ) : (string) $values;
		}

			$method = (string) $request->get_method();
			$uri = sanitize_text_field( (string) wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
			$body = (string) $request->get_body();

		return RequestVerifier::verify( $site_token, $site_secret, $method, $uri, $body, $headers );
	}

	/**
	 * Handle incoming command.
	 *
	 * Flow:
	 *  1. Validate payload & idempotency.
	 *  2. Persist the command locally for background execution.
	 *  3. Return an immediate accepted response.
	 *  4. Terminal-tracked commands send running/succeeded/failed ACKs separately.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public static function handle_command( $request ) {
		$raw_body = (string) $request->get_body();
		if ( strlen( $raw_body ) > self::MAX_COMMAND_BODY_BYTES ) {
			return new \WP_REST_Response(
				[ 'ok' => false, 'error' => 'payload_too_large', 'error_code' => 'CMD_PAYLOAD_TOO_LARGE' ],
				413
			);
		}

		$payload = $request->get_json_params();
		if ( ! is_array( $payload ) ) {
			return new \WP_REST_Response( [ 'ok' => false, 'error' => 'invalid_json' ], 400 );
		}

		$id = sanitize_text_field( (string) ( $payload['id'] ?? '' ) );
		$command = sanitize_text_field( (string) ( $payload['command'] ?? '' ) );
		if ( array_key_exists( 'params', $payload ) && ! is_array( $payload['params'] ) ) {
			return new \WP_REST_Response( [ 'ok' => false, 'error' => 'invalid_params', 'error_code' => 'CMD_PARAM_INVALID' ], 400 );
		}
		$params = isset( $payload['params'] ) ? $payload['params'] : [];
		$ack_mode = strtolower( sanitize_text_field( (string) ( $payload['ack_mode'] ?? 'terminal' ) ) );

		if ( $id === '' || $command === '' ) {
			return new \WP_REST_Response( [ 'ok' => false, 'error' => 'missing_fields' ], 400 );
		}
		if ( ! self::is_uuid( $id ) ) {
			return new \WP_REST_Response( [ 'ok' => false, 'error' => 'invalid_command_id', 'error_code' => 'CMD_ID_INVALID' ], 400 );
		}
		if ( ! in_array( $ack_mode, [ 'accepted', 'terminal' ], true ) ) {
			return new \WP_REST_Response( [ 'ok' => false, 'error' => 'invalid_ack_mode', 'error_code' => 'CMD_ACK_MODE_INVALID' ], 400 );
		}
		if ( ! CommandExecutor::is_supported_command( $command ) ) {
			return new \WP_REST_Response( [ 'ok' => false, 'error' => 'unsupported_command', 'error_code' => 'CMD_UNSUPPORTED' ], 400 );
		}
		if ( ! self::params_within_limit( $params ) ) {
			return new \WP_REST_Response( [ 'ok' => false, 'error' => 'params_too_large', 'error_code' => 'CMD_PARAMS_TOO_LARGE' ], 413 );
		}

		$validator = new CommandExecutor();
		$validation = $validator->validate_for_acceptance( $command, $params );
		if ( is_wp_error( $validation ) ) {
			return new \WP_REST_Response(
				[
					'ok'         => false,
					'error'      => sanitize_text_field( $validation->get_error_message() ),
					'error_code' => 'CMD_PARAM_INVALID',
				],
				400
			);
		}

		if ( ! self::acquire_command_lock( $id ) ) {
			$existing = CommandQueue::get_status_payload( $id );
			if ( is_array( $existing ) ) {
				return new \WP_REST_Response( $existing, 200 );
			}

			return new \WP_REST_Response(
				[
					'ok'             => false,
					'error'          => 'command_accept_in_progress',
					'error_code'     => 'COMMAND_ACCEPT_IN_PROGRESS',
					'command_status' => 'accepting',
					'id'             => $id,
					'command'        => $command,
				],
				409
			);
		}

		try {
			$existing_status = CommandQueue::get_status_payload( $id );
			if ( is_array( $existing_status ) ) {
				return new \WP_REST_Response( $existing_status, 200 );
			}

			// Gate: console must be enabled for all commands except a few that always bypass.
			// console.enable / console.disable: needed for remote toggle.
			$settings = CloudSettings::get_all();
			$cmd_lower = strtolower( $command );
			$bypass_gate = in_array( $cmd_lower, [ 'console.enable', 'console.disable' ], true );
			if ( empty( $settings['console_enabled'] ) && ! $bypass_gate ) {
				return new \WP_REST_Response(
					[ 'ok' => false, 'error' => 'console_disabled', 'error_code' => 'CONSOLE_DISABLED' ],
					403
				);
			}

			try {
				$accepted = CommandQueue::enqueue( $id, $command, $params, $ack_mode );
			} catch ( \Throwable $e ) {
				return new \WP_REST_Response(
					[
						'ok'             => false,
						'error'          => 'command_persist_failed',
						'error_code'     => 'COMMAND_PERSIST_FAILED',
						'command_status' => 'unknown',
						'id'             => $id,
						'command'        => $command,
					],
					503
				);
			}

			return new \WP_REST_Response(
				self::accepted_response(
					$id,
					$command,
					(string) ( $accepted['status'] ?? 'accepted' )
				),
				200
			);
		} finally {
			self::release_command_lock( $id );
		}
	}

		/**
		 * Build the canonical acceptance payload for command push responses.
		 *
		 * @param string $command_id Command UUID.
		 * @param string $command    Command name.
		 * @param string $status     Current local command status.
		 * @return array<string,mixed>
		 */
	private static function accepted_response( string $command_id, string $command, string $status = 'accepted' ): array {
		return [
			'ok'             => true,
			'accepted'       => true,
			'command_status' => $status,
			'id'             => $command_id,
			'command'        => $command,
		];
	}

	/**
	 * @param string $value
	 */
	private static function is_uuid( string $value ): bool {
		return 1 === preg_match(
			'/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
			$value
		);
	}

	/**
	 * @param array<string,mixed> $params
	 */
	private static function params_within_limit( array $params ): bool {
		$encoded = wp_json_encode( $params );
		if ( false === $encoded ) {
			return false;
		}
		return strlen( $encoded ) <= self::MAX_COMMAND_PARAMS_BYTES;
	}

	/**
	 * Return the locally persisted status for one command.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public static function handle_command_status( $request ) {
		$payload = $request->get_json_params();
		$command_id = '';
		if ( is_array( $payload ) ) {
			$command_id = sanitize_text_field( (string) ( $payload['id'] ?? '' ) );
		}
		if ( $command_id === '' ) {
			$command_id = sanitize_text_field( (string) $request->get_param( 'id' ) );
		}
		if ( $command_id === '' ) {
			return new \WP_REST_Response( [ 'ok' => false, 'error' => 'missing_command_id' ], 400 );
		}
		if ( ! self::is_uuid( $command_id ) ) {
			return new \WP_REST_Response( [ 'ok' => false, 'error' => 'invalid_command_id', 'error_code' => 'CMD_ID_INVALID' ], 400 );
		}

		$status = CommandQueue::get_status_payload( $command_id );
		if ( ! is_array( $status ) ) {
			return new \WP_REST_Response( [ 'ok' => false, 'error' => 'command_not_found', 'command_status' => 'unknown' ], 404 );
		}

		return new \WP_REST_Response( $status, 200 );
	}

	/**
	 * Get current scan status.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public static function get_scan_status( $request ) {
		if ( ! class_exists( '\\SecurWP\\Modules\\Scanner\\Queue\\ScanQueue' ) ) {
			return new \WP_REST_Response( [ 'active' => false, 'ok' => false, 'error' => 'scanner_unavailable' ], 200 );
		}
		$queue = \SecurWP\Modules\Scanner\Queue\ScanQueue::get();
		if ( ! $queue ) {
			return new \WP_REST_Response( [ 'active' => false, 'ok' => true ], 200 );
		}
		return new \WP_REST_Response( [
			'active'   => \SecurWP\Modules\Scanner\Queue\ScanQueue::is_active(),
			'ok'       => true,
			'scan_id'  => $queue['id'] ?? '',
			'status'   => $queue['status'] ?? '',
			'progress' => \SecurWP\Modules\Scanner\Queue\ScanQueue::get_progress(),
		], 200 );
	}

	/**
	 * Per-IP, per-endpoint rate limit using atomic MySQL counter.
	 *
	 * Uses INSERT ... ON DUPLICATE KEY UPDATE against the shared securwp_rate_limits
	 * table so the counter survives object cache eviction.
	 *
	 * @param string $endpoint Short label ('cmd', 'scan') to keep counters separate.
	 * @param int    $limit    Max requests allowed per IP per minute.
	 * @return \WP_Error|null WP_Error if rate limited, null otherwise.
	 */
	private static function check_rate_limit( string $endpoint, int $limit ): ?\WP_Error {
		global $wpdb;

		$ip            = \SecurWP\Includes\IpDetector::get_ip();
		$ip_hash       = hash( 'sha256', $ip );
		$hit_type      = 'console_' . $endpoint;
		$minute_bucket = (int) floor( time() / 60 );
			$table         = $wpdb->prefix . 'securwp_rate_limits';

				try {
					if ( $table === '' || strlen( $table ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) {
						throw new \InvalidArgumentException( 'Invalid table identifier.' );
					}
					$table = '`' . str_replace( '`', '``', $table ) . '`';
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->query(
					$wpdb->prepare(
						"INSERT INTO {$table} (ip_hash, hit_type, minute_bucket, hits)
					 VALUES (%s, %s, %d, 1)
					 ON DUPLICATE KEY UPDATE hits = hits + 1",
					$ip_hash,
					$hit_type,
						$minute_bucket
					)
				);
				// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$hits = (int) $wpdb->get_var(
					$wpdb->prepare(
						"SELECT hits FROM {$table} WHERE ip_hash = %s AND hit_type = %s AND minute_bucket = %d",
					$ip_hash,
					$hit_type,
						$minute_bucket
					)
				);
				// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		} catch ( \Throwable $e ) {
			// Allow the request when the rate-limit table is unavailable.
			return null;
		}

		if ( $hits > $limit ) {
			return new \WP_Error(
				'rate_limited',
				'Too many requests.',
				[ 'status' => 429 ]
			);
		}

		return null;
	}

	/**
	 * Acquire a short-lived command lock by command ID.
	 * Prevents duplicate execution under concurrent retries.
	 *
	 * Uses MySQL GET_LOCK() for an atomic, session-scoped mutex. The
	 * timeout=0 means "don't wait" - return immediately if another
	 * connection holds the lock. The lock is automatically released if
	 * the DB connection closes (e.g. PHP process crash), preventing
	 * permanent deadlocks. Max lock name length in MySQL is 64 bytes;
	 * 'securwp_cmd_' (13) + 32-char hash = 45 bytes.
	 *
	 * @param string $command_id
	 * @return bool
	 */
	private static function acquire_command_lock( string $command_id ): bool {
		global $wpdb;
		$lock_name = 'securwp_cmd_' . substr( hash( 'sha256', $command_id ), 0, 32 );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$result = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock_name ) );
		return $result === '1' || $result === 1;
	}

	/**
	 * Release command lock.
	 *
	 * @param string $command_id
	 * @return void
	 */
	private static function release_command_lock( string $command_id ): void {
		global $wpdb;
		$lock_name = 'securwp_cmd_' . substr( hash( 'sha256', $command_id ), 0, 32 );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
	}
}
