<?php

declare(strict_types=1);

/**
 * CommandReceiver - REST endpoints for Console command delivery and status probes.
 *
 * Console pushes commands to POST /wp-json/sitefort/v1/command. The plugin
 * persists accepted commands locally. Fast commands run inline and the
 * response carries the terminal result; the rest run via the background queue.
 */

namespace SiteFort\Modules\Scanner\API;

use SiteFort\Includes\RequestVerifier;
use SiteFort\Includes\RestRateLimiter;
use SiteFort\Modules\Cloud\Service\CloudSettings;
use SiteFort\Modules\Cloud\Services\CommandExecutor;
use SiteFort\Modules\Cloud\Services\CommandQueue;

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

if ( ! class_exists( RestRateLimiter::class ) ) {
	require_once SITEFORT_PLUGIN_DIR . 'includes/RestRateLimiter.php';
}

class CommandReceiver {

	const NAMESPACE = 'sitefort/v1';

	/**
	 * Register REST API routes.
	 */
	public static function register() {
		RestRateLimiter::register_hooks();
		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 ) {
		$verified = self::verify_signature( $request, self::MAX_COMMAND_BODY_BYTES );
		if ( true !== $verified ) {
			return $verified;
		}

		$rate_error = self::check_rate_limit( 'cmd', self::COMMAND_RATE_LIMIT );
		if ( $rate_error !== null ) {
			return $rate_error;
		}

		return true;
	}

	/**
	 * Permission callback for POST /command/status.
	 *
	 * @param \WP_REST_Request $request
	 * @return bool|\WP_Error
	 */
	public static function verify_command_status_signature( $request ) {
		$verified = self::verify_signature( $request, self::MAX_COMMAND_BODY_BYTES );
		if ( true !== $verified ) {
			return $verified;
		}

		$rate_error = self::check_rate_limit( 'cmd_status', self::COMMAND_STATUS_RATE_LIMIT );
		if ( $rate_error !== null ) {
			return $rate_error;
		}

		return true;
	}

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

		$rate_error = self::check_rate_limit( 'scan', self::SCAN_STATUS_RATE_LIMIT );
		if ( $rate_error !== null ) {
			return $rate_error;
		}

		return true;
	}

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

		return RequestVerifier::verify_rest_request( $site_token, $site_secret, $request, $max_body_bytes );
	}

	/**
	 * Handle incoming command.
	 *
	 * Flow:
	 *  1. Validate payload & idempotency.
	 *  2. Persist the command locally.
	 *  3. Fast commands run inline; the response carries the terminal result.
	 *  4. The rest return an accepted response and send running/succeeded/failed
	 *     ACKs from the background worker.
	 *
	 * @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 ) {
				$gate = \SiteFort\Modules\Cloud\Service\ConsoleGate::status();
				$error_code = $gate['gate_allowed'] ? 'CONSOLE_DISABLED' : 'CONSOLE_GATE_CLOSED';
				return new \WP_REST_Response(
					[
						'ok'                      => false,
						'error'                   => 'console_disabled',
						'error_code'              => $error_code,
						'console_gate_allowed'    => $gate['gate_allowed'],
						'console_pending_request' => $gate['pending_request'],
					],
					403
				);
			}

			$inline = CommandExecutor::runs_inline( $command );
			try {
				$accepted = CommandQueue::enqueue( $id, $command, $params, $ack_mode, ! $inline );
			} 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
				);
			}

			if ( $inline ) {
				$status_payload = CommandQueue::run_now( $id );
				if ( is_array( $status_payload ) ) {
					return new \WP_REST_Response( $status_payload, 200 );
				}
			}

			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( '\\SiteFort\\Modules\\Scanner\\Queue\\ScanQueue' ) ) {
			return new \WP_REST_Response( [ 'active' => false, 'ok' => false, 'error' => 'scanner_unavailable' ], 200 );
		}
		$queue = \SiteFort\Modules\Scanner\Queue\ScanQueue::get();
		if ( ! $queue ) {
			return new \WP_REST_Response( [ 'active' => false, 'ok' => true ], 200 );
		}
		return new \WP_REST_Response( [
			'active'   => \SiteFort\Modules\Scanner\Queue\ScanQueue::is_active(),
			'ok'       => true,
			'scan_id'  => $queue['id'] ?? '',
			'status'   => $queue['status'] ?? '',
			'progress' => \SiteFort\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 sitefort_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 {
		return RestRateLimiter::check( 'console_' . $endpoint, $limit );
	}

	/**
	 * 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;
	 * 'sitefort_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 = 'sitefort_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 = 'sitefort_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 ) );
	}
}
