# mainwp/6.2.2/includes/rest-api/controller/version2/class-mainwp-rest-sites-controller.php

MainWP Dashboard: Self-hosted WordPress Management for Agencies, version 6.2.2. 3,418 lines.

- Page: https://pluginprobe.com/plugins/mainwp/6.2.2/code/includes/rest-api/controller/version2/class-mainwp-rest-sites-controller.php
- Raw: https://pluginprobe.com/plugins/mainwp/6.2.2/raw/includes/rest-api/controller/version2/class-mainwp-rest-sites-controller.php
- Modified: 2026-09-30T15:48:00+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/mainwp/6.2.2/code/includes/rest-api/controller/version2/class-mainwp-rest-sites-controller.php#L10-L20`.

```php
<?php
/**
 * MainWP REST Controller
 *
 * This class handles the REST API
 *
 * @package MainWP\Dashboard
 */

use MainWP\Dashboard\MainWP_Sync;
use MainWP\Dashboard\MainWP_DB;
use MainWP\Dashboard\MainWP_Manage_Sites_View;
use MainWP\Dashboard\MainWP_Manage_Sites_Handler;
use MainWP\Dashboard\MainWP_Connect;
use MainWP\Dashboard\MainWP_Monitoring_Handler;
use MainWP\Dashboard\MainWP_Extra_Exception;
use MainWP\Dashboard\MainWP_DB_Client;
use MainWP\Dashboard\Module\CostTracker\Cost_Tracker_DB;
use MainWP\Dashboard\Module\CostTracker\Cost_Tracker_Rest_Api_Handle_V1;
use MainWP\Dashboard\MainWP_DB_Common;
use MainWP\Dashboard\MainWP_Utility;
use MainWP\Dashboard\MainWP_DB_Site_Actions;
use MainWP\Dashboard\MainWP_Logger;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}


/**
 * Class MainWP_Rest_Sites_Controller
 *
 * @package MainWP\Dashboard
 */
class MainWP_Rest_Sites_Controller extends MainWP_REST_Controller{ //phpcs:ignore -- NOSONAR - multi methods.

    // phpcs:disable Generic.Metrics.CyclomaticComplexity -- complexity.

    /**
     * Protected static variable to hold the single instance of the class.
     *
     * @var mixed Default null
     */
    private static $instance = null;

    /**
     * Endpoint namespace.
     *
     * @var string
     */
    protected $namespace = 'mainwp/v2';

    /**
     * Route base.
     *
     * @var string
     */
    protected $rest_base = 'sites';


    /**
     * Constructor.
     */
    public function __construct() {
        add_filter( 'mainwp_rest_routes_sites_controller_filter_allowed_fields_by_context', array( $this, 'hook_filter_allowed_fields_by_context' ), 10, 3 );
        add_filter( 'mainwp_rest_routes_sites_controller_get_allowed_fields_by_context', array( $this, 'hook_get_allowed_fields_by_context' ), 10, 1 );
        add_filter( 'rest_request_after_callbacks', array( $this, 'hook_preserve_enveloped_fields_response' ), 10, 3 );
    }

    /**
     * Method instance()
     *
     * Create public static instance.
     *
     * @static
     * @return static::$instance
     */
    public static function instance() {
        if ( null === static::$instance ) {
            static::$instance = new self();
        }
        return static::$instance;
    }


    /**
     * Get filter sites.
     *
     * @param object $item item data.
     * @param string $context context.
     * @param mixed  $request The rest request.
     *
     * @return object item in context.
     */
    public function hook_filter_allowed_fields_by_context( $item, $context = 'view', $request = array() ) {
        return $this->prepare_site_item_for_response_context( $item, $request, $context );
    }

    /**
     * Get filter sites.
     *
     * @param string $context context.
     *
     * @return object item in context.
     */
    public function hook_get_allowed_fields_by_context( $context = 'view' ) {
        return $this->get_allowed_fields_by_context( $context );
    }

    /**
     * Site fields that must never appear in any v2 response, regardless of
     * context or _fields. The schema declares http_pass / http_user / uniqueId
     * as edit-only (MWP-1541), but check_permissions() in the auth layer only
     * distinguishes GET vs write methods, so a read-only key can still request
     * `?context=edit`. The _fields short-circuit below also skips the schema
     * filter entirely. This list provides belt-and-suspenders defence so a
     * read-only caller cannot recover credential fields via either bypass.
     *
     * @var string[]
     */
    protected static $never_in_response_fields = array(
        'privkey',
        'pubkey',
        'http_user',
        'http_pass',
        'adminname',
        'securekey',
        'uniqueId',
        // Projected onto the row for the health_score fast-path (not a schema field);
        // reserve it so ?custom_fields=health_site_status cannot surface the raw value.
        'health_site_status',
    );

    /**
     * Strip credential-shaped fields from a prepared response item in place.
     * Operates on either an associative array or an object.
     *
     * @param mixed $prepared Prepared item (array or object).
     * @return mixed The same item with credential fields removed.
     */
    private function strip_never_in_response_fields( $prepared ) {
        if ( is_array( $prepared ) ) {
            foreach ( static::$never_in_response_fields as $field ) {
                unset( $prepared[ $field ] );
            }
        } elseif ( is_object( $prepared ) ) {
            foreach ( static::$never_in_response_fields as $field ) {
                unset( $prepared->{$field} );
            }
        }
        return $prepared;
    }

    /**
     * Prepare a site response item while preserving `_fields` projections.
     *
     * `prepare_item_for_response()` already narrows the payload when `_fields`
     * is present. Running the result back through
     * `filter_response_data_by_allowed_fields()` expands it again to all schema
     * fields with empty-string placeholders. This helper avoids that behavior.
     *
     * @param object          $item            Site item.
     * @param WP_REST_Request $request         Request object.
     * @param string          $context         Response context.
     * @param array           $addition_fields Extra fields to include beyond the schema projection.
     *
     * @return array
     */
    protected function prepare_site_item_for_response_context( $item, $request, $context = 'view', $addition_fields = array() ) {
        $prepared = $this->prepare_item_for_response( $item, $request );

        if ( ! ( $request instanceof WP_REST_Request && ! empty( $request['_fields'] ) ) ) {
            $prepared = $this->filter_response_data_by_allowed_fields( $prepared, $context, $addition_fields );
        }

        // MWP-1541: strip credential fields unconditionally on read.
        //
        // The v2 schema labels http_pass / http_user / uniqueId as edit-only,
        // but every site-response code path in this controller passes the
        // context arg as 'view' (or 'simple_view') -- there is no caller that
        // forwards the request's `?context=edit` query through to the
        // schema-filter step, so the edit-context response shape is
        // effectively unreachable from the v2 sites routes today. That
        // makes this strip equivalent to "strip on every read response",
        // which is what we want for the security fix anyway: REST consumers
        // never need to read these fields back, and writes accept them via
        // the request body regardless.
        //
        // Re-enabling edit-context reads in the future would mean plumbing
        // the requested context through the existing call sites and then
        // narrowing this strip to view only. Out of scope for MWP-1541.
        return $this->strip_never_in_response_fields( $prepared );
    }

    /**
     * Preserve MainWP's custom response envelopes when `_fields` is used.
     *
     * WordPress core applies `_fields` to the top-level response object. MainWP's
     * sites routes return custom envelopes such as {success,total,data}, so core
     * would otherwise strip the envelope and serialize an empty array for requests
     * like `_fields=id`. The controller already applies `_fields` to each site
     * item via prepare_item_for_response(), so we clear the request parameter after
     * callbacks for enveloped sites responses only.
     *
     * @param mixed           $response Response to replace the requested version with.
     * @param array           $handler  Route handler used for the request.
     * @param WP_REST_Request $request  Request used to generate the response.
     *
     * @return mixed
     */
    public function hook_preserve_enveloped_fields_response( $response, $handler, $request ) { // phpcs:ignore -- NOSONAR - hook signature.
        if ( empty( $request['_fields'] ) ) {
            return $response;
        }

        $route_prefix = '/' . $this->namespace . '/' . $this->rest_base;
        $route        = $request->get_route();
        if ( 0 !== strpos( $route, $route_prefix ) || is_wp_error( $response ) ) {
            return $response;
        }

        $response = rest_ensure_response( $response );
        $data     = $response->get_data();

        if ( ! is_array( $data ) || ( ! array_key_exists( 'data', $data ) && ! array_key_exists( 'site', $data ) ) ) {
            return $response;
        }

        $request->set_param( '_fields', null );

        return $response;
    }

    /**
     * Method register_routes()
     *
     * Creates the necessary endpoints for the api.
     * Note, for a request to be successful the URL query parameters consumer_key and consumer_secret need to be set and correct.
     */
    public function register_routes() { // phpcs:ignore -- NOSONAR - complex.

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_items' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/basic',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_basic_items' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/count',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'count_items' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/sync',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'sync_items' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/reconnect',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'reconnect_items' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/disconnect',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'disconnect_sites' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/check',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'check_items' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[\d]+)',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_item' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => array(
                        'id_domain' => array(
                            'description' => __( 'Site ID or domain.', 'mainwp' ),
                            'type'        => 'string',
                        ),
                    ),
                ),
                'schema' => array( $this, 'get_public_item_schema' ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[A-Za-z0-9-\.]*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,6}$)',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_item' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => array(
                        'id_domain' => array(
                            'description' => __( 'Site ID or domain.', 'mainwp' ),
                            'type'        => 'string',
                        ),
                    ),
                ),
                'schema' => array( $this, 'get_public_item_schema' ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/add',
            array(
                array(
                    'methods'             => WP_REST_Server::CREATABLE,
                    'callback'            => array( $this, 'create_item' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_add_site_args(),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/edit',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'update_item' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_edit_site_args(),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/sync',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'sync_item' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/security',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'security_item' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/plugins',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_site_plugins' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/plugins/activate',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'activate_plugins_of_site' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/plugins/deactivate',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'deactivate_plugins_of_site' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/plugins/delete',
            array(
                array(
                    'methods'             => WP_REST_Server::DELETABLE,
                    'callback'            => array( $this, 'delete_plugins_of_site' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/plugins/abandoned',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_abandoned_plugins_of_site' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/themes',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_site_themes' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/themes/activate',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'activate_themes_of_site' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/themes/delete',
            array(
                array(
                    'methods'             => WP_REST_Server::DELETABLE,
                    'callback'            => array( $this, 'delete_themes_of_site' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/themes/abandoned',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_abandoned_themes_of_site' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/non-mainwp-changes',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_non_mainwp_changes_of_site' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/reconnect',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'reconnect_item' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/disconnect',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'disconnect_site' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/suspend',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'suspend_item' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/unsuspend',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'unsuspend_item' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/check',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'check_item' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/remove',
            array(
                array(
                    'methods'             => WP_REST_Server::DELETABLE,
                    'callback'            => array( $this, 'delete_item' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
            )
        );

        // Retrieves client Object for the site by site ID or Domain.
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/client',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_client_site' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
            )
        );

        // Retrieves costs for the site by ID or Domain.
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/costs',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_costs_site' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/batch',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'batch_items' ),
                    'permission_callback' => array( $this, 'get_rest_permissions_check' ),
                    'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
                ),
                'schema' => array( $this, 'get_public_batch_schema' ),
            )
        );
    }


    /**
     * Read the ?custom_fields argument as a list of wp_options names.
     *
     * These names travel into extra_view and reach the option-view builders in
     * identifier position (column aliases), where escaping is not a defense, so
     * anything outside [A-Za-z0-9_] is discarded rather than escaped. The DB layer
     * screens them again; this keeps a bad name from being queried or echoed back
     * by prepare_item_for_response() in the first place.
     *
     * health_site_status is dropped as well: every caller adds it explicitly for the
     * health_score fast-path, and $never_in_response_fields reserves it out of the
     * response, so accepting it here would only duplicate the entry.
     *
     * @param WP_REST_Request $request Request object.
     *
     * @return array Custom field names safe to query.
     */
    protected function get_requested_custom_fields( $request ) {
        if ( ! isset( $request['custom_fields'] ) ) {
            return array();
        }

        $fields = array_map( 'trim', wp_parse_list( $request['custom_fields'] ) );

        $fields = array_filter(
            $fields,
            function ( $name ) {
                return 'health_site_status' !== $name && preg_match( '/^[A-Za-z0-9_]+$/', $name );
            }
        );

        return array_values( array_unique( $fields ) );
    }

    /**
     * Get item.
     *
     * @param  WP_REST_Request $request Request object.
     *
     * @return array|mixed Response data, ready for insertion into collection data.
     */
    public function get_item( $request ) { // phpcs:ignore -- NOSONAR - complex method.

        $value = isset( $request['id_domain'] ) ? $request['id_domain'] : '';
        $by    = 'domain';

        if ( is_numeric( $value ) ) {
            $by = 'id';
        } else {
            $value = urldecode( $value );
        }

        // Extract with_tags flag early so both paths use the same value.
        // Default to true for consistency with get_items() endpoint.
        $with_tags     = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
        $custom_fields = $this->get_requested_custom_fields( $request );

        // Try abilities-first approach (with fallback to legacy logic).
        $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/get-site-v1' ) : null;

        if ( null !== $ability ) {
            // Build ability input: site_id_or_domain accepts either ID or domain.
            $ability_input = array(
                'site_id_or_domain' => $value,
                'include_stats'     => false, // REST doesn't request stats by default.
            );

            $result = $ability->execute( $ability_input );

            if ( is_wp_error( $result ) ) {
                $error_data = $result->get_error_data();
                $status     = isset( $error_data['status'] ) ? $error_data['status'] : 500;
                return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
            }

            // DESIGN NOTE: The ability is used for ID discovery and access verification only.
            // The ability returns a simplified schema optimized for AI/automation consumers,
            // but REST API backward compatibility requires the full response format from
            // prepare_item_for_response() + filter_response_data_by_allowed_fields().
            // We re-fetch from DB to apply REST-specific options (with_tags, fields) that
            // the ability schema does not support. This ensures consistent response format
            // between ability and legacy paths.

            $site_data = ! empty( $result ) && is_array( $result ) ? $result : array();
            $site_id   = isset( $site_data['id'] ) ? (int) $site_data['id'] : 0;

            if ( $site_id > 0 ) {
                $params = array(
                    'full_data'    => true,
                    'selectgroups' => $with_tags,
                    'include'      => array( $site_id ),
                    'extra_view'   => array_merge( array( 'health_site_status' ), $custom_fields ), // Custom fields plus health_site_status, so prepare_item_for_response() reads the health data off the row instead of an extra per-site query.
                    // health_site_status must be in fields too: get_websites_for_current_user() maps rows through map_site(), which keeps only fields; extra_view alone would be dropped before prepare_item_for_response() sees it.
                    'fields'       => array_merge( array( 'health_site_status' ), $this->get_fields_for_response( $request ) ),
                );

                // Required to ensure custom fields are included in the DB query for abilities path since ability schema does not support fields parameter, but legacy path does.
                if ( ! empty( $custom_fields ) ) {
                    $params['fields'] = array_merge( $params['fields'], $custom_fields );
                }

                $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
                $data     = $websites ? current( $websites ) : array();
                if ( ! empty( $data ) ) {
                    $site_data = $this->prepare_site_item_for_response_context( $data, $request, 'view', $custom_fields );
                }
            }

            return rest_ensure_response( array( 'data' => $site_data ) );
        }

        // Fallback: Legacy logic when Abilities API is not available.
        $prepared_args = array(
            'with_tags' => $with_tags,
        );

        $item = $this->get_site_by( $by, $value, $prepared_args );

        if ( is_wp_error( $item ) ) {
            return $item;
        }

        $params = array(
            'full_data'    => true,
            'selectgroups' => $with_tags,
            'include'      => array( $item->id ),
            'extra_view'   => array_merge( array( 'health_site_status' ), $custom_fields ), // Custom fields plus health_site_status, so prepare_item_for_response() reads the health data off the row instead of an extra per-site query.
            // health_site_status must be in fields too: get_websites_for_current_user() maps rows through map_site(), which keeps only fields; extra_view alone would be dropped before prepare_item_for_response() sees it.
            'fields'       => array_merge( array( 'health_site_status' ), $this->get_fields_for_response( $request ) ),
        );

        // Required to ensure custom fields are included in the DB query for abilities path since ability schema does not support fields parameter, but legacy path does.
        if ( ! empty( $custom_fields ) ) {
            $params['fields'] = array_merge( $params['fields'], $custom_fields );
        }

        $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
        $data     = $websites ? current( $websites ) : array();
        if ( ! empty( $data ) ) {
            $data = $this->prepare_site_item_for_response_context( $data, $request, 'view', $custom_fields );
        }
        return rest_ensure_response( array( 'data' => $data ) );
    }



    /**
     * Get all sites.
     *
     * @param WP_REST_Request $request Full details about the request.
     * @return WP_Error|WP_REST_Response
     */
    public function get_items( $request ) { // phpcs:ignore -- NOSONAR - complex method.

        $args = $this->prepare_objects_query( $request );
        $args = $this->validate_rest_args( $args, $this->get_validate_args_params( 'get_sites' ) );
        if ( is_wp_error( $args ) ) {
            return $args;
        }

        // Check REST-specific flags that affect response format.
        // These are processed in the legacy path but not in the ability schema.
        $with_tags     = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
        $full_data     = isset( $request['full_data'] ) ? mainwp_string_to_bool( $request['full_data'] ) : true;
        $custom_fields = $this->get_requested_custom_fields( $request );

        // Try abilities-first approach (with fallback to legacy logic).
        // Skip abilities when with_tags=false, full_data=false, or include/exclude
        // are set (ability returns paginated results that would corrupt metadata).
        $ability = null;
        if ( empty( $args['include'] ) && empty( $args['exclude'] ) && $with_tags && $full_data ) {
            $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/list-sites-v1' ) : null;
        }

        if ( null !== $ability ) {
            // Build ability input from REST parameters.
            $ability_input = array(
                'page'      => isset( $args['paged'] ) ? (int) $args['paged'] : 1,
                'per_page'  => isset( $args['items_per_page'] ) ? (int) $args['items_per_page'] : 20,
                'status'    => isset( $args['status'] ) ? $args['status'] : 'any',
                'search'    => isset( $args['s'] ) ? $args['s'] : '',
                'tag_id'    => isset( $args['group_id'] ) ? (int) $args['group_id'] : null,
                'client_id' => isset( $args['client_id'] ) ? (int) $args['client_id'] : null,
            );

            // Remove null values to avoid passing unneeded parameters.
            $ability_input = array_filter( $ability_input, fn( $v ) => null !== $v );

            // Remove empty string values as well to avoid passing empty search params.
            $ability_input = array_filter( $ability_input, fn( $v ) => '' !== $v );

            $result = $ability->execute( $ability_input );

            if ( is_wp_error( $result ) ) {
                $error_data = $result->get_error_data();
                $status     = isset( $error_data['status'] ) ? $error_data['status'] : 500;
                return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
            }

            // DESIGN NOTE: The ability is used for filtering, pagination, and access verification.
            // The ability returns a simplified schema optimized for AI/automation consumers,
            // but REST API backward compatibility requires the full response format from
            // prepare_item_for_response() + filter_response_data_by_allowed_fields().
            // We re-fetch from DB using the IDs returned by the ability to apply REST-specific
            // options (with_tags, fields) that the ability schema does not support.
            // This ensures consistent response format between ability and legacy paths.
            $normalized_items = array();
            if ( ! empty( $result['items'] ) ) {
                // Get site IDs from ability results.
                $site_ids = array_map( fn( $item ) => isset( $item['id'] ) ? (int) $item['id'] : 0, $result['items'] );
                $site_ids = array_filter( $site_ids );

                if ( ! empty( $site_ids ) ) {
                    // Fetch full site data to normalize through REST filters.
                    $params = array(
                        'full_data'    => true,
                        'selectgroups' => true,
                        'include'      => $site_ids,
                        'extra_view'   => array_merge( array( 'health_site_status' ), $custom_fields ), // Custom fields plus health_site_status, so prepare_item_for_response() reads the health data off the row instead of an extra per-site query.
                        // health_site_status must be in fields too: get_websites_for_current_user() maps rows through map_site(), which keeps only fields; extra_view alone would be dropped before prepare_item_for_response() sees it.
                        'fields'       => array_merge( array( 'health_site_status' ), $this->get_fields_for_response( $request ) ),
                    );

                    // Required to ensure custom fields are included in the DB query for abilities path since ability schema does not support fields parameter, but legacy path does.
                    if ( ! empty( $custom_fields ) ) {
                        $params['fields'] = array_merge( $params['fields'], $custom_fields );
                    }

                    $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );

                    if ( $websites ) {
                        foreach ( $websites as $site ) {
                            $normalized_items[] = $this->prepare_site_item_for_response_context( $site, $request, 'view', $custom_fields );
                        }
                    }
                }
            }

            return rest_ensure_response(
                array(
                    'success' => 1,
                    'total'   => isset( $result['total'] ) ? (int) $result['total'] : count( $normalized_items ),
                    'data'    => $normalized_items,
                )
            );
        }

        // Fallback: Legacy logic when Abilities API is not available.
        $args['selectgroups'] = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
        $args['full_data']    = isset( $request['full_data'] ) ? mainwp_string_to_bool( $request['full_data'] ) : true;
        // Fetch health_site_status onto the rows (extra_view = SQL column, fields =
        // what map_site() keeps) so prepare_item_for_response() reads the health data
        // off the object instead of a per-site query.
        $args['extra_view'] = array_merge( array( 'health_site_status' ), $custom_fields );
        $args_fields        = isset( $args['fields'] ) && is_array( $args['fields'] ) ? $args['fields'] : array();
        $args['fields']     = array_merge( array( 'health_site_status' ), $args_fields );

        // get data.
        $websites = MainWP_DB::instance()->get_websites_for_current_user( $args );

        $data = array();

        if ( $websites ) {
            foreach ( $websites as $site ) {
                $data[] = $this->prepare_site_item_for_response_context( $site, $request, 'view', $custom_fields );
            }
        }
        return rest_ensure_response(
            array(
                'success' => 1,
                'total'   => count( $data ),
                'data'    => $data,
            )
        );
    }

    /**
     * Get all sites.
     *
     * @param WP_REST_Request $request Full details about the request.
     * @return WP_Error|WP_REST_Response
     */
    public function get_basic_items( $request ) {

        $args = $this->prepare_objects_query( $request );
        $args = $this->validate_rest_args( $args, $this->get_validate_args_params( 'get_sites' ) );

        if ( is_wp_error( $args ) ) {
            return $args;
        }

        $args['selectgroups'] = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : false;
        $args['full_data']    = isset( $request['full_data'] ) ? mainwp_string_to_bool( $request['full_data'] ) : false;
        // health_score has context 'view', so get_fields_for_response() still lists it here
        // (the request context defaults to 'view') and prepare_item_for_response() computes it
        // even though the simple_view filter later strips it. Project health_site_status onto the
        // rows so that computation reads off the object instead of a per-site option query.
        $args['extra_view'] = array( 'health_site_status' );
        $args_fields        = isset( $args['fields'] ) && is_array( $args['fields'] ) ? $args['fields'] : array();
        $args['fields']     = array_merge( array( 'health_site_status' ), $args_fields );

        // get data.
        $websites = MainWP_DB::instance()->get_websites_for_current_user( $args );

        $data = array();

        if ( $websites ) {
            foreach ( $websites as $site ) {
                $data[] = $this->prepare_site_item_for_response_context( $site, $request, 'simple_view' );
            }
        }
        return rest_ensure_response(
            array(
                'success' => 1,
                'total'   => count( $data ),
                'data'    => $data,
            )
        );
    }


    /**
     * Get the query params for collections.
     *
     * @return array
     */
    public function get_collection_params() {
        $params                       = array();
        $params['context']['default'] = 'view';
        return $params;
    }

    /**
     * Count all sites.
     *
     * @param  WP_REST_Request $request Request object.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function count_items( $request ) {
        $args = $this->prepare_objects_query( $request );

        $args = $this->validate_rest_args( $args, $this->get_validate_args_params( 'get_sites' ) );
        if ( is_wp_error( $args ) ) {
            return $args;
        }

        $params = array(
            'exclude' => isset( $args['exclude'] ) ? $args['exclude'] : array(),
            'include' => isset( $args['include'] ) ? $args['include'] : array(),
            'status'  => isset( $args['status'] ) ? $args['status'] : array(),
        );

        // get data.
        $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
        $data     = array(
            'count' => count( $websites ),
        );
        return rest_ensure_response( $data );
    }

    /**
     * Sync sites.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function sync_item( $request ) { // phpcs:ignore -- NOSONAR - complex.

        $website = $this->get_request_item( $request );

        if ( is_wp_error( $website ) ) {
            return $website;
        }

        // Try abilities-first approach (with fallback to legacy logic).
        $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/sync-sites-v1' ) : null;

        if ( null !== $ability ) {
            // Build ability input: single site as array.
            $ability_input = array(
                'site_ids' => array( (int) $website->id ),
            );

            $result = $ability->execute( $ability_input );

            if ( is_wp_error( $result ) ) {
                $error_data = $result->get_error_data();
                $status     = isset( $error_data['status'] ) ? $error_data['status'] : 500;
                return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
            }

            // Transform ability output to REST response format.
            // The sync-sites-v1 ability returns 'synced' and 'errors' arrays.
            // A single-site sync is successful if the site appears in 'synced' and not in 'errors'.
            $synced    = isset( $result['synced'] ) && is_array( $result['synced'] ) ? $result['synced'] : array();
            $errors    = isset( $result['errors'] ) && is_array( $result['errors'] ) ? $result['errors'] : array();
            $success   = ! empty( $synced ) && empty( $errors );
            $site_data = array();

            // Normalize site data to REST format for backward compatibility.
            if ( $success ) {
                $params   = array(
                    'full_data'    => true,
                    'selectgroups' => true,
                    'include'      => array( $website->id ),
                    'fields'       => $this->get_fields_for_response( $request ),
                );
                $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
                $data     = $websites ? current( $websites ) : array();
                if ( ! empty( $data ) ) {
                    $site_data = $this->prepare_site_item_for_response_context( $data, $request, 'view' );
                }
            }

            return rest_ensure_response(
                array(
                    'success' => $success ? 1 : 0,
                    'data'    => $site_data,
                )
            );
        }

        // Fallback: Legacy logic when Abilities API is not available.
        try {
            $success = MainWP_Sync::sync_site( $website );
        } catch ( \Exception $e ) {
            return new WP_Error( 'mainwp_rest_sync_site_error', $e->getMessage(), array( 'status' => 404 ) );
        }

        $data = array();

        if ( $success ) {
            $params   = array(
                'full_data'    => true,
                'selectgroups' => true,
                'include'      => array( $website->id ),
                'fields'       => $this->get_fields_for_response( $request ),
            );
            $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
            $data     = $websites ? current( $websites ) : array();
            $data     = $this->prepare_site_item_for_response_context( $data, $request, 'view' );
        }

        return rest_ensure_response(
            array(
                'success' => $success ? 1 : 0,
                'data'    => $data,
            )
        );
    }


    /**
     * Secure site info.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function security_item( $request ) {

        $website = $this->get_request_item( $request );

        if ( is_wp_error( $website ) ) {
            return $website;
        }

        try {
            $result = MainWP_Connect::fetch_url_authed( $website, 'security' );
        } catch ( \Exception $e ) {
            return new WP_Error( 'mainwp_rest_get_secure_site_error', $e->getMessage(), array( 'status' => 404 ) );
        }

        $data = array(
            'data' => $result,
            'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );

        return rest_ensure_response( $data );
    }

    /**
     * Get plugins of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function get_site_plugins( $request ) { //phpcs:ignore -- NOSONAR - complex.

        $website = $this->get_request_item( $request );

        if ( is_wp_error( $website ) ) {
            return $website;
        }

        $args = $this->prepare_objects_query( $request );
        $args = $this->validate_rest_args( $args, $this->get_validate_args_params( 'site_plugins' ) );

        if ( is_wp_error( $args ) ) {
            return $args;
        }

        // Try abilities-first approach (with fallback to legacy logic).
        // Skip abilities if unsupported parameters are present.
        // The ability schema doesn't support search, must_use, or pagination.
        // Note: Pagination only applies when BOTH paged AND items_per_page are set (matches legacy).
        $ability                = null;
        $has_unsupported_params = ! empty( $args['s'] ) || ! empty( $args['must_use'] )
            || ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) );

        if ( ! $has_unsupported_params ) {
            $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/get-site-plugins-v1' ) : null;
        }

        if ( null !== $ability ) {
            // Build ability input matching the ability's JSON Schema.
            $ability_input = array(
                'site_id_or_domain' => (int) $website->id,
            );

            // Status must be a string matching the ability schema enum ['all', 'active', 'inactive'].
            if ( isset( $args['status'] ) && in_array( $args['status'], array( 'all', 'active', 'inactive' ), true ) ) {
                $ability_input['status'] = $args['status'];
            } else {
                $ability_input['status'] = 'all';
            }

            // Pass has_update filter if provided.
            if ( isset( $args['has_update'] ) ) {
                $ability_input['has_update'] = (bool) $args['has_update'];
            }

            $result = $ability->execute( $ability_input );

            if ( is_wp_error( $result ) ) {
                $error_data = $result->get_error_data();
                $status     = isset( $error_data['status'] ) ? $error_data['status'] : 500;
                return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
            }

            // Transform ability output to REST response format.
            // Ability returns 'plugins' array and 'total', REST includes site info.
            $plugins = isset( $result['plugins'] ) && is_array( $result['plugins'] ) ? $result['plugins'] : array();
            return rest_ensure_response(
                array(
                    'success' => 1,
                    'total'   => isset( $result['total'] ) ? $result['total'] : count( $plugins ),
                    'data'    => $plugins,
                    'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
                )
            );
        }

        // Fallback: Legacy logic when Abilities API is not available.
        $data = json_decode( $website->plugins, 1 );

        if ( is_array( $data ) ) {
            if ( ! empty( $args['must_use'] ) ) {
                $data = MainWP_Utility::get_sub_array_having( $data, 'mu', 1 );
            }
            if ( ! empty( $args['s'] ) ) {
                $s = trim( $args['s'] );
                if ( ! empty( $s ) ) {
                    $data = mainwp_search_in_array(
                        $data,
                        $s,
                        array(
                            'search_in_key'    => false,
                            'in_sub_fields'    => array( 'name', 'description', 'version', 'slug' ),
                            'use_index_result' => false,
                        )
                    );
                }
            }

            if ( ! empty( $args['status'] ) && is_array( $args['status'] ) && ! in_array( 'any', $args['status'] ) ) {

                $status_act   = in_array( 'active', $args['status'] ) ? 1 : 0;
                $status_inact = in_array( 'inactive', $args['status'] ) ? 1 : 0;

                $status_val = false;
                if ( $status_act && $status_inact ) {
                    $status_val = false;
                } elseif ( $status_act ) {
                    $status_val = 1;
                } elseif ( $status_inact ) {
                    $status_val = 0;
                }

                if ( false !== $status_val ) {
                    $data = MainWP_Utility::get_sub_array_having( $data, 'active', $status_val );
                }
            }
            if ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) ) {
                $pag  = (int) $args['paged'];
                $per  = (int) $args['items_per_page'];
                $data = MainWP_Utility::get_sub_array_with_limit( $data, $per * ( $pag - 1 ), $per );
            }
        }

        $resp_data = array(
            'success' => 1,
            'total'   => is_array( $data ) ? count( $data ) : 0,
            'data'    => $data,
            'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );
        return rest_ensure_response( $resp_data );
    }


    /**
     * Handle plugins actions of site.
     *
     * @param object $website object.
     * @param string $action action.
     * @param string $slugs plugins slugs.
     *
     * @return WP_Error|array
     */
    public function handle_plugins_actions_of_site( $website, $action, $slugs ) {
        try {
            $information = MainWP_Connect::fetch_url_authed(
                $website,
                'plugin_action',
                array(
                    'action' => $action,
                    'plugin' => implode( '||', $slugs ),
                )
            );

            $success = false;
            $data    = array();
            if ( is_array( $information ) && isset( $information['other_data']['plugin_action_data'] ) ) {
                $action_data = $information['other_data']['plugin_action_data'];
                foreach ( $action_data as $item ) {
                    $success = true;
                    $item    = MainWP_Utility::instance()->sanitize_data( $item );
                    if ( ! empty( $item ) && isset( $item['name'] ) ) {
                        $data[] = array(
                            'name'    => $item['name'],
                            'version' => $item['version'],
                            'slug'    => $item['slug'],
                        );
                    }
                }
            }

            return array(
                'success' => $success ? 1 : 0,
                'data'    => $data,
            );

        } catch ( \Exception $e ) {
            return new WP_Error( "mainwp_rest_{$action}_plugins_action", $e->getMessage() );
        }
    }


    /**
     * Activate plugins of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function activate_plugins_of_site( $request ) {
        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }

        $slugs = isset( $request['slug'] ) ? array_map( 'trim', wp_parse_list( $request['slug'] ) ) : array();
        if ( empty( $slugs ) ) {
            return new WP_Error( 'mainwp_rest_invalid_plugins_slugs', __( 'Plugins slugs are empty or invalid.', 'mainwp' ), 400 );
        }
        $result = $this->handle_plugins_actions_of_site( $website, 'activate', $slugs );

        if ( is_wp_error( $result ) ) {
            return $result;
        }

        $resp_data = array(
            'success' => ! empty( $result['success'] ) ? 1 : 0,
            'data'    => ! empty( $result['data'] ) ? $result['data'] : array(),
            'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );

        return rest_ensure_response( $resp_data );
    }

    /**
     * Deactivate plugins of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function deactivate_plugins_of_site( $request ) {
        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }

        $slugs = isset( $request['slug'] ) ? array_map( 'trim', wp_parse_list( $request['slug'] ) ) : array();
        if ( empty( $slugs ) ) {
            return new WP_Error( 'mainwp_rest_invalid_plugins_slugs', __( 'Plugins slug is empty or invalid.', 'mainwp' ), 400 );
        }
        $result = $this->handle_plugins_actions_of_site( $website, 'deactivate', $slugs );

        if ( is_wp_error( $result ) ) {
            return $result;
        }
        $resp_data = array(
            'success' => ! empty( $result['success'] ) ? 1 : 0,
            'data'    => ! empty( $result['data'] ) ? $result['data'] : array(),
            'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );

        return rest_ensure_response( $resp_data );
    }

    /**
     * Delete plugins of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function delete_plugins_of_site( $request ) {
        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }
        $slugs = isset( $request['slug'] ) ? array_map( 'trim', wp_parse_list( $request['slug'] ) ) : array();
        if ( empty( $slugs ) ) {
            return new WP_Error( 'mainwp_rest_invalid_plugins_slugs', __( 'Plugins slugs is empty or invalid.', 'mainwp' ), 400 );
        }
        $result = $this->handle_plugins_actions_of_site( $website, 'delete', $slugs );

        if ( is_wp_error( $result ) ) {
            return $result;
        }
        $resp_data = array(
            'success' => ! empty( $result['success'] ) ? 1 : 0,
            'data'    => ! empty( $result['data'] ) ? $result['data'] : array(),
            'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );

        return rest_ensure_response( $resp_data );
    }

    /**
     * Get abandoned plugins of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function get_abandoned_plugins_of_site( $request ) {

        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }

        // get data.
        $data = MainWP_DB::instance()->get_website_option( $website, 'plugins_outdate_info' );
        $data = ! empty( $data ) ? json_decode( $data, true ) : array();

        $resp_data = array(
            'success' => 1,
            'total'   => is_array( $data ) ? count( $data ) : 0,
            'data'    => $data,
            'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );

        return rest_ensure_response( $resp_data );
    }


    /**
     * Get themes of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function get_site_themes( $request ) { //phpcs:ignore - NOSONAR - complex.
        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }

        $args = $this->prepare_objects_query( $request );

        $args = $this->validate_rest_args( $args, $this->get_validate_args_params( 'site_themes' ) );
        if ( is_wp_error( $args ) ) {
            return $args;
        }

        if ( isset( $args['status'] ) ) {
            $args['status'] = array_unique( wp_parse_list( $args['status'] ) );
        }

        // Try abilities-first approach (with fallback to legacy logic).
        // Skip abilities if unsupported parameters are present.
        // The ability schema doesn't support search or pagination.
        // Note: Pagination only applies when BOTH paged AND items_per_page are set (matches legacy).
        $ability                = null;
        $has_unsupported_params = ! empty( $args['s'] )
            || ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) );

        if ( ! $has_unsupported_params ) {
            $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/get-site-themes-v1' ) : null;
        }

        if ( null !== $ability ) {
            // Build ability input matching the ability's JSON Schema.
            $ability_input = array(
                'site_id_or_domain' => (int) $website->id,
            );

            // Status must be a string matching the ability schema enum ['all', 'active', 'inactive'].
            if ( isset( $args['status'] ) && in_array( $args['status'], array( 'all', 'active', 'inactive' ), true ) ) {
                $ability_input['status'] = $args['status'];
            } else {
                $ability_input['status'] = 'all';
            }

            // Pass has_update filter if provided.
            if ( isset( $args['has_update'] ) ) {
                $ability_input['has_update'] = (bool) $args['has_update'];
            }

            $result = $ability->execute( $ability_input );

            if ( is_wp_error( $result ) ) {
                $error_data = $result->get_error_data();
                $status     = isset( $error_data['status'] ) ? $error_data['status'] : 500;
                return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
            }

            // Transform ability output to REST response format.
            // Ability returns 'themes' array and 'total', REST includes site info.
            $themes = isset( $result['themes'] ) && is_array( $result['themes'] ) ? $result['themes'] : array();
            return rest_ensure_response(
                array(
                    'success' => 1,
                    'total'   => isset( $result['total'] ) ? $result['total'] : count( $themes ),
                    'data'    => $themes,
                    'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
                )
            );
        }

        // Fallback: Legacy logic when Abilities API is not available.
        $data = json_decode( $website->themes, 1 );
        if ( is_array( $data ) ) {
            if ( ! empty( $args['s'] ) ) {
                $s = trim( $args['s'] );
                if ( ! empty( $s ) ) {
                    $data = mainwp_search_in_array(
                        $data,
                        $s,
                        array(
                            'search_in_key'    => false,
                            'in_sub_fields'    => array( 'name', 'description', 'version', 'slug', 'title' ),
                            'use_index_result' => false,
                        )
                    );
                }
            }

            if ( ! empty( $args['status'] ) ) {

                $status_act   = in_array( 'active', $args['status'] ) ? 1 : 0;
                $status_inact = in_array( 'inactive', $args['status'] ) ? 1 : 0;

                $status_val = false;
                if ( $status_act && $status_inact ) {
                    $status_val = false;
                } elseif ( $status_act ) {
                    $status_val = 1;
                } elseif ( $status_inact ) {
                    $status_val = 0;
                }

                if ( false !== $status_val ) {
                    $data = MainWP_Utility::get_sub_array_having( $data, 'active', $status_val );
                }
            }

            if ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) ) {
                $pag  = (int) $args['paged'];
                $per  = (int) $args['items_per_page'];
                $data = MainWP_Utility::get_sub_array_with_limit( $data, $per * ( $pag - 1 ), $per );
            }
        }

        $resp_data = array(
            'success' => 1,
            'total'   => is_array( $data ) ? count( $data ) : 0,
            'data'    => $data,
            'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );
        return rest_ensure_response( $resp_data );
    }


    /**
     * Handle plugins actions of site.
     *
     * @param object $website object.
     * @param string $action action.
     * @param string $slugs themes slugs.
     *
     * @return WP_Error|array
     */
    public function handle_theme_actions_of_site( $website, $action, $slugs ) { //phpcs:ignore -- NOSONAR - complex.
        try {
            $information = MainWP_Connect::fetch_url_authed(
                $website,
                'theme_action',
                array(
                    'action' => $action,
                    'theme'  => $slugs,
                )
            );

            if ( ! is_array( $information ) ) {
                $information = array();
            }

            $success = 0;
            $data    = array();

            if ( 'delete' === $action && is_array( $information ) && ! empty( $information['error']['is_activated_theme'] ) ) {
                /* translators: %s: Theme name */
                $data['error'] = sprintf( esc_html__( 'The theme %s is active.', 'mainwp' ), esc_html( $information['error']['is_activated_theme'] ) );
            }

            if ( 'activate' === $action && isset( $information['other_data']['theme_deactivate_data'] ) ) {
                $success           = 1;
                $act_slug          = 'deactivate';
                $data[ $act_slug ] = array();
                $action_data       = $information['other_data']['theme_deactivate_data'];
                foreach ( $action_data as $item ) {
                    $success = true;
                    $item    = MainWP_Utility::instance()->sanitize_data( $item );
                    if ( ! empty( $item ) && isset( $item['name'] ) ) {
                        $data[ $act_slug ] = array(
                            'name'    => $item['name'],
                            'version' => $item['version'],
                            'slug'    => $item['slug'],
                        );
                    }
                }
            }

            if ( in_array( $action, array( 'activate', 'delete' ) ) && isset( $information['other_data']['theme_action_data'] ) ) {
                $success = 1;
                if ( 'activate' === $action ) {
                    $act_slug = 'activate';
                } else {
                    $act_slug = 'delete';
                }
                $data[ $act_slug ] = array();
                $action_data       = $information['other_data']['theme_action_data'];
                foreach ( $action_data as $item ) {
                    $success = true;
                    $item    = MainWP_Utility::instance()->sanitize_data( $item );
                    if ( ! empty( $item ) && isset( $item['name'] ) ) {
                        $data[ $act_slug ] = array(
                            'name'    => $item['name'],
                            'version' => $item['version'],
                            'slug'    => $item['slug'],
                        );
                    }
                }
            }

            return array(
                'success' => $success,
                'data'    => $data,
            );

        } catch ( \Exception $e ) {
            return new WP_Error( "mainwp_rest_{$action}_plugins_action", $e->getMessage() );
        }
    }


    /**
     * Activate themes of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function activate_themes_of_site( $request ) {
        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }

        $slugs = isset( $request['slug'] ) && is_string( $request['slug'] ) ? trim( $request['slug'] ) : '';
        if ( empty( $slugs ) ) {
            return new WP_Error( 'mainwp_rest_invalid_themes_slugs', __( 'Theme slug is empty or invalid.', 'mainwp' ), 400 );
        }

        $result = $this->handle_theme_actions_of_site( $website, 'activate', $slugs );

        if ( is_wp_error( $result ) ) {
            return $result;
        }
        $resp_data = array(
            'success' => ! empty( $result['success'] ) ? 1 : 0,
            'data'    => ! empty( $result['data'] ) ? $result['data'] : array(),
            'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );

        return rest_ensure_response( $resp_data );
    }


    /**
     * Delete themes of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function delete_themes_of_site( $request ) {

        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }

        $slugs = isset( $request['slug'] ) ? array_map( 'trim', wp_parse_list( $request['slug'] ) ) : array();
        if ( empty( $slugs ) ) {
            return new WP_Error( 'mainwp_rest_invalid_themes_slugs', __( 'Theme slug is empty or invalid.', 'mainwp' ), 400 );
        }

        $result = $this->handle_theme_actions_of_site( $website, 'delete', implode( '||', $slugs ) );

        if ( is_wp_error( $result ) ) {
            return $result;
        }
        $resp_data = array(
            'success' => ! empty( $result['success'] ) ? 1 : 0,
            'data'    => ! empty( $result['data'] ) ? $result['data'] : array(),
            'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );

        return rest_ensure_response( $resp_data );
    }


    /**
     * Get abandoned themes of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function get_abandoned_themes_of_site( $request ) {

        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }

        // get data.
        $data = MainWP_DB::instance()->get_website_option( $website, 'themes_outdate_info' );
        $data = ! empty( $data ) ? json_decode( $data, true ) : array();

        $resp_data = array(
            'success' => 1,
            'total'   => is_array( $data ) ? count( $data ) : 0,
            'data'    => $data,
            'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );

        return rest_ensure_response( $resp_data );
    }

    /**
     * Get non-mainwp changes of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function get_non_mainwp_changes_of_site( $request ) { //phpcs:ignore -- NOSONAR -complex.

        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }

        $args = $this->prepare_objects_query( $request );

        $params = array(
            'wpid'        => $website->id,
            'where_extra' => ' AND dismiss = 0 ',
            'order'       => ! empty( $args['order'] ) ? sanitize_text_field( $args['order'] ) : '',
            'order_by'    => ! empty( $args['orderby'] ) ? sanitize_text_field( $args['orderby'] ) : '',
            'source'      => ! empty( $request['source'] ) ? sanitize_text_field( $request['source'] ) : 'wpadmin',
        );

        if ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) ) {
            $params['rowcount'] = intval( $args['items_per_page'] );
            $params['offset']   = ( (int) ( $args['paged'] ) - 1 ) * $params['rowcount'];
        } else {
            // support compatible api param.
            $limit              = ! empty( $args['limit'] ) ? (int) ( $args['limit'] ) : 200;
            $params['rowcount'] = $limit;
            $params['offset']   = 0;
        }

        $params['actions']            = ! empty( $request['actions'] ) ? sanitize_text_field( $request['actions'] ) : '';
        $params['contexts']           = ! empty( $request['contexts'] ) ? sanitize_text_field( $request['contexts'] ) : '';
        $params['total_count']        = ! empty( $request['total_count'] ) ? mainwp_string_to_bool( $request['total_count'] ) : false;
        $params['optimize_view']      = ! empty( $request['optimize_view'] ) ? mainwp_string_to_bool( $request['optimize_view'] ) : false;
        $params['optimize_with_meta'] = ! empty( $request['optimize_with_meta'] ) ? mainwp_string_to_bool( $request['optimize_with_meta'] ) : false;

        $data = MainWP_DB_Site_Actions::instance()->get_wp_actions( $params, $website );

        if ( $params['total_count'] ) {
            $total = $data;
            $data  = array( 'total' => $total );
        } else {
            $total = is_array( $data ) ? count( $data ) : 0;
        }

        $resp_data = array(
            'success' => 1,
            'total'   => $total,
            'data'    => $data,
            'site'    => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );
        return rest_ensure_response( $resp_data );
    }

    /**
     * Sync all sites.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function sync_items( $request ) { //phpcs:ignore -- NOSONAR - complex.

        $args   = $this->prepare_objects_query( $request );
        $params = array(
            'exclude' => isset( $args['exclude'] ) && ! empty( $args['exclude'] ) ? $args['exclude'] : array(),
            'include' => isset( $args['include'] ) && ! empty( $args['include'] ) ? $args['include'] : array(),
        );

        // Try abilities-first approach (with fallback to legacy logic).
        $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/sync-sites-v1' ) : null;

        if ( null !== $ability ) {
            // Build ability input.
            $ability_input = array();
            if ( ! empty( $params['include'] ) ) {
                $raw_include = is_array( $params['include'] ) ? $params['include'] : wp_parse_list( $params['include'] );
                $include_ids = array_values( array_unique( array_filter( array_map( 'absint', $raw_include ) ) ) );

                if ( empty( $include_ids ) ) {
                    return new WP_Error(
                        'mainwp_invalid_input',
                        __( 'Invalid include parameter. Expected one or more positive site IDs.', 'mainwp' ),
                        array( 'status' => 400 )
                    );
                }
                $ability_input['site_ids'] = $include_ids;
            }
            if ( ! empty( $params['exclude'] ) ) {
                $raw_exclude = is_array( $params['exclude'] ) ? $params['exclude'] : wp_parse_list( $params['exclude'] );
                $exclude_ids = array_values( array_unique( array_filter( array_map( 'absint', $raw_exclude ) ) ) );
                if ( empty( $exclude_ids ) ) {
                    return new WP_Error(
                        'mainwp_invalid_input',
                        __( 'Invalid exclude parameter. Expected one or more positive site IDs.', 'mainwp' ),
                        array( 'status' => 400 )
                    );
                }
                $ability_input['exclude_ids'] = $exclude_ids;
            }

            $result = $ability->execute( $ability_input );

            if ( is_wp_error( $result ) ) {
                $error_data = $result->get_error_data();
                $status     = isset( $error_data['status'] ) ? $error_data['status'] : 500;
                return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
            }

            // Check if sync was queued for background processing.
            if ( isset( $result['job_id'] ) ) {
                return rest_ensure_response(
                    array(
                        'success' => 1,
                        'message' => esc_html__( 'Sync queued for background processing.', 'mainwp' ),
                        'job_id'  => $result['job_id'],
                        'total'   => isset( $result['sites_queued'] ) ? $result['sites_queued'] : 0,
                    )
                );
            }

            // Transform ability output: map synced/errors arrays to REST format.
            // The sync-sites-v1 ability returns 'synced' and 'errors' arrays.
            // REST expects site ID as key with 'result' field indicating success/failure.
            $transformed_data = array();
            $synced           = isset( $result['synced'] ) && is_array( $result['synced'] ) ? $result['synced'] : array();
            $errors           = isset( $result['errors'] ) && is_array( $result['errors'] ) ? $result['errors'] : array();

            // Process successfully synced sites.
            foreach ( $synced as $site_info ) {
                $site_id = isset( $site_info['id'] ) ? (int) $site_info['id'] : 0;
                if ( $site_id > 0 ) {
                    // Re-fetch full site data for REST response format.
                    $site_params = array(
                        'full_data'    => true,
                        'selectgroups' => true,
                        'include'      => array( $site_id ),
                        'fields'       => $this->get_fields_for_response( $request ),
                    );
                    $websites    = MainWP_DB::instance()->get_websites_for_current_user( $site_params );
                    $site_data   = $websites ? current( $websites ) : array();
                    if ( ! empty( $site_data ) ) {
                        $site_data = $this->filter_response_data_by_allowed_fields(
                            $this->prepare_item_for_response( $site_data, $request ),
                            'view'
                        );
                    }
                    $transformed_data[ $site_id ] = array_merge( array( 'result' => 'success' ), $site_data );
                }
            }

            // Process failed sites.
            foreach ( $errors as $error_info ) {
                $site_id = isset( $error_info['identifier'] ) ? (int) $error_info['identifier'] : 0;
                if ( $site_id > 0 ) {
                    $transformed_data[ $site_id ] = array( 'result' => 'failed' );
                }
            }

            update_option( 'mainwp_last_synced_all_sites', time() );
            return rest_ensure_response(
                array(
                    'total' => count( $transformed_data ),
                    'data'  => $transformed_data,
                )
            );
        }

        // Fallback: Legacy logic when Abilities API is not available.
        $data     = array();
        $websites = MainWP_DB::instance()->query( MainWP_DB::instance()->get_sql_websites_for_current_user( false, null, 'wp.url', false, false, null, false, array( 'favi_icon' ), 'no', $params ) );
        while ( $websites && ( $website  = MainWP_DB::fetch_object( $websites ) ) ) {
            try {
                $ret = MainWP_Sync::sync_site( $website );
            } catch ( \Exception $e ) {
                $ret = false;
            }
            $item                 = $this->prepare_site_item_for_response_context( $website, $request, 'view' );
            $data[ $website->id ] = array_merge( array( 'result' => $ret ? 'success' : 'failed' ), $item );
        }
        MainWP_DB::free_result( $websites );
        update_option( 'mainwp_last_synced_all_sites', time() );
        return rest_ensure_response(
            array(
                'total' => count( $data ),
                'data'  => $data,
            )
        );
    }

    /**
     * Reconnect site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function reconnect_item( $request ) {
        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }
        $resp_data = array();
        try {
            $ret                  = MainWP_Manage_Sites_View::m_reconnect_site( $website );
            $resp_data['success'] = $ret ? 1 : 0;
        } catch ( \Exception $e ) {
            // failed.
            /* translators: %d: Site ID */
            return new \WP_Error( 'mainwp_rest_reconnect_site_error', sprintf( esc_html__( 'Reconnect Site "%d" error:', 'mainwp' ), $website->id ) . ': ' . $e->getMessage() );
        }
        $website           = $this->get_site_by( 'id', $website->id );
        $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $website, 'simple_view' );
        return rest_ensure_response( $resp_data );
    }

    /**
     * Reconnect sites.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function reconnect_items( $request ) {

        $args   = $this->prepare_objects_query( $request );
        $params = array(
            'exclude' => isset( $args['exclude'] ) && ! empty( $args['exclude'] ) ? $args['exclude'] : array(),
            'include' => isset( $args['include'] ) && ! empty( $args['include'] ) ? $args['include'] : array(),
        );

        $data     = array();
        $websites = MainWP_DB::instance()->query( MainWP_DB::instance()->get_sql_websites_for_current_user( false, null, 'wp.url', false, false, null, false, array( 'favi_icon' ), 'no', $params ) );
        while ( $websites && ( $website  = MainWP_DB::fetch_object( $websites ) ) && '' !== $website->sync_errors ) {
            $ret = false;
            try {
                $ret = MainWP_Manage_Sites_View::m_reconnect_site( $website );
            } catch ( \Exception $e ) {
                // failed.
            }
            $data[ $website->id ] = $ret ? 'success' : 'failed';
        }
        MainWP_DB::free_result( $websites );
        return rest_ensure_response(
            array(
                'total' => count( $data ),
                'data'  => $data,
            )
        );
    }


    /**
     * Disconnect site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return array|mixed Response data, ready for insertion into collection data.
     */
    public function disconnect_site( $request ) {

        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }

        $data = $this->filter_response_data_by_allowed_fields( $website, 'simple_view' );

        $success = 0;
        $error   = '';
        try {
            $info    = MainWP_Connect::fetch_url_authed( $website, 'disconnect' );
            $success = is_array( $info ) && isset( $info['result'] ) && 'success' === $info['result'] ? 1 : 0;
            $error   = is_array( $info ) && ! empty( $info['error'] ) ? $info['error'] : '';
        } catch ( \Exception $e ) {
            /* translators: %d: Site ID */
            return new \WP_Error( 'mainwp_rest_disconnect_site_error', sprintf( esc_html__( 'Disconnect Site "%d" error:', 'mainwp' ), $website->id ) . ': ' . $e->getMessage() );
        }
        $resp_data = array(
            'success' => $success,
            'data'    => $data,
        );
        if ( ! empty( $error ) ) {
            $resp_data['error'] = $error;
        }
        return rest_ensure_response( $resp_data );
    }


    /**
     * Suspend site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return array|mixed Response data, ready for insertion into collection data.
     */
    public function suspend_item( $request ) {
        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }
        $suspended = 1;
        if ( $website && ! $website->suspended ) { //phpcs:ignore -- to valid.
            $newValues = array(
                'suspended' => $suspended,
            );

            MainWP_DB::instance()->update_website_values( $website->id, $newValues );
            /**
             * Fires immediately after website suspended/unsuspend.
             *
             * @since 4.5.2
             *
             * @param object $website  website data.
             * @param int $suspended The new suspended value.
             */
            do_action( 'mainwp_site_suspended', $website, $suspended );
        }

        $resp_data = array(
            'success'   => 1,
            'suspended' => $suspended,
            'data'      => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );

        return rest_ensure_response( $resp_data );
    }


    /**
     * Unsuspend site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return array|mixed Response data, ready for insertion into collection data.
     */
    public function unsuspend_item( $request ) {
        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }
        $suspended = 0;
        if ( $website && $website->suspended ) { //phpcs:ignore -- to valid.
            $newValues = array(
                'suspended' => $suspended,
            );

            MainWP_DB::instance()->update_website_values( $website->id, $newValues );
            /**
             * Fires immediately after website suspended/unsuspend.
             *
             * @since 4.5.2
             *
             * @param object $website  website data.
             * @param int $suspended The new suspended value.
             */
            do_action( 'mainwp_site_suspended', $website, $suspended );
        }
        $resp_data = array(
            'success'   => 1,
            'suspended' => $suspended,
            'data'      => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
        );
        return rest_ensure_response( $resp_data );
    }

    /**
     * Disconnect sites.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function disconnect_sites( $request ) {

        $args     = $this->prepare_objects_query( $request );
        $params   = array(
            'view'    => 'base_view',
            'exclude' => isset( $args['exclude'] ) && ! empty( $args['exclude'] ) ? $args['exclude'] : array(),
            'include' => isset( $args['include'] ) && ! empty( $args['include'] ) ? $args['include'] : array(),
        );
        $data     = array();
        $websites = MainWP_DB::instance()->query( MainWP_DB::instance()->get_sql_websites_for_current_user_by_params( $params ) );
        while ( $websites && ( $website  = MainWP_DB::fetch_object( $websites ) ) ) {
            $info = false;
            try {
                $info = MainWP_Connect::fetch_url_authed( $website, 'disconnect' );
            } catch ( \Exception $e ) {
                // ok.
            }
            $success = 0;
            if ( is_array( $info ) ) {
                $success = isset( $info['result'] ) ? 1 : 0;
            }
            $data[ $website->id ] = $success ? 'success' : 'failed';
        }
        MainWP_DB::free_result( $websites );
        return rest_ensure_response(
            array(
                'total' => count( $data ),
                'data'  => $data,
            )
        );
    }

    /**
     * Check site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function check_item( $request ) {

        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }

        $error = '';
        try {
            $ret = MainWP_Monitoring_Handler::handle_check_website( $website );
        } catch ( \Exception $e ) {
            $ret   = false;
            $error = $e->getMessage();
        }

        $resp_data = array( 'success' => $ret ? 1 : 0 );
        $new_code  = is_array( $ret ) && isset( $ret['httpCode'] ) ? $ret['httpCode'] : false;

        if ( ! empty( $error ) ) {
            $resp_data ['error'] = esc_html( $result );
        }

        $data = $this->filter_response_data_by_allowed_fields( $website, 'simple_view' );

        if ( false !== $new_code ) {
            if ( is_array( $result ) && isset( $result['new_uptime_status'] ) ) {
                $data['status'] = $result['new_uptime_status'];
            } else {
                $data['status'] = MainWP_Monitoring_Handler::get_site_checking_status( $new_code );
            }
            $data['http_code'] = $new_code;
        }

        $resp_data['data'] = $data;

        return rest_ensure_response( $resp_data );
    }

    /**
     * Check sites.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function check_items( $request ) {
        $args     = $this->prepare_objects_query( $request );
        $params   = array(
            'exclude' => isset( $args['exclude'] ) && ! empty( $args['exclude'] ) ? $args['exclude'] : array(),
            'include' => isset( $args['include'] ) && ! empty( $args['include'] ) ? $args['include'] : array(),
        );
        $data     = array();
        $websites = MainWP_DB::instance()->query( MainWP_DB::instance()->get_sql_websites_for_current_user( false, null, 'wp.url', false, false, null, false, array( 'favi_icon' ), 'no', $params ) );
        while ( $websites && ( $website  = MainWP_DB::fetch_object( $websites ) ) ) {
            try {
                $ret = MainWP_Monitoring_Handler::handle_check_website( $website );
            } catch ( \Exception $e ) {
                $ret = false;
            }
            $item                 = $this->prepare_site_item_for_response_context( $website, $request, 'view' );
            $data[ $website->id ] = array_merge( array( 'result' => $ret ? 'success' : 'failed' ), $item );
        }
        MainWP_DB::free_result( $websites );
        return rest_ensure_response(
            array(
                'total' => count( $data ),
                'data'  => $data,
            )
        );
    }

    /**
     * Remove site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function delete_item( $request ) {

        $website = $this->get_request_item( $request );

        if ( is_wp_error( $website ) ) {
            return $website;
        }

        $error  = '';
        $result = false;

        try {
            $result = MainWP_Manage_Sites_Handler::remove_wp_site( $website->id );
        } catch ( MainWP_Extra_Exception $e ) {
            $error = $e->getMessage();
        }

        $success = ( is_array( $result ) && isset( $result['result'] ) && ( 'success' === $result['result'] || 'removed' === $result['result'] ) ) ? 1 : 0;

        $resp_data = array(
            'success' => $success,
        );

        if ( is_array( $result ) && ! empty( $result['result'] ) ) {
            if ( 'success' === $result['result'] ) {
                $message = esc_html__( 'The site has been removed and the MainWP Child plugin has been disabled.', 'mainwp' );
            } else {
                $message = esc_html__( 'The site has been removed. Please make sure that the MainWP Child plugin has been deactivated properly.', 'mainwp' );
            }
            $resp_data['message'] = $message;
        }

        if ( ! empty( $error ) ) {
            $resp_data['error'] = $error;
        }

        $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $website, 'simple_view' );

        return rest_ensure_response( $resp_data );
    }

    /**
     * Get client of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function get_client_site( $request ) {
        $website = $this->get_request_item( $request );

        if ( is_wp_error( $website ) ) {
            return $website;
        }

        $client = false;
        if ( $website->client_id ) {
            $client = MainWP_DB_Client::instance()->get_wp_client_by( 'client_id', $website->client_id );
        }

        $resp_data = array(
            'success' => $client ? 1 : 0,
        );

        if ( empty( $client ) ) {
            $resp_data['error'] = esc_html__( 'Client is not set or not found.', 'mainwp' );
        } else {
            $resp_data['data'] = $client;
        }

        return rest_ensure_response( $resp_data );
    }

    /**
     * Get costs of site.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function get_costs_site( $request ) {
        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }
        $resp_data = array(
            'success' => 1,
        );
        $costs     = Cost_Tracker_DB::get_instance()->get_cost_tracker_by( 'site_id', $website->id );
        if ( $costs ) {
            $data               = Cost_Tracker_Rest_Api_Handle_V1::instance()->prepare_api_costs_data( $costs );
            $resp_data['total'] = is_array( $data ) ? count( $data ) : 0;
            $resp_data['data']  = $data;
        } else {
            $resp_data['message'] = esc_html__( 'Cost not found.', 'mainwp' );
            $resp_data['data']    = array();
        }
        return rest_ensure_response( $resp_data );
    }

    /**
     * Get site by.
     *
     * @param WP_REST_Request $request Full details about the request.
     *
     * @return WP_Error|Object Item.
     */
    public function get_request_item( $request ) {
        return $this->get_site_item( $request );
    }


    /**
     * Get the location schema, conforming to JSON Schema.
     *
     * @since  3.5.0
     * @return array
     */
    public function get_item_schema() { //phpcs:ignore -- NOSONAR - long function.
        return array(
            '$schema'    => 'http://json-schema.org/draft-04/schema#',
            'title'      => 'sites',
            'type'       => 'object',
            'properties' => array(
                'id'                     => array(
                    'type'        => 'integer',
                    'description' => __( 'Site ID.', 'mainwp' ),
                    'context'     => array( 'view', 'edit', 'simple_view' ),
                    'arg_options' => array(
                        'sanitize_callback' => 'wp_parse_id_list',
                    ),
                ),
                'name'                   => array(
                    'type'        => 'string',
                    'description' => __( 'Site name.', 'mainwp' ),
                    'context'     => array( 'view', 'edit', 'simple_view' ),
                    'arg_options' => array(
                        'sanitize_callback' => 'sanitize_text_field',
                    ),
                ),
                'url'                    => array(
                    'type'        => 'string',
                    'description' => __( 'Site url.', 'mainwp' ),
                    'context'     => array( 'view', 'edit', 'simple_view' ),
                    'arg_options' => array(
                        'sanitize_callback' => 'sanitize_text_field',
                    ),
                ),
                'tags'                   => array(
                    'type'        => 'string',
                    'description' => __( 'Site tags.', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'note'                   => array(
                    'type'        => 'string',
                    'description' => __( 'Site note.', 'mainwp' ),
                    'context'     => array( 'view', 'edit' ),
                ),
                'ip'                     => array(
                    'type'        => 'string',
                    'description' => __( 'Site IP.', 'mainwp' ),
                    'context'     => array( 'view' ),
                    'arg_options' => array(
                        'sanitize_callback' => 'sanitize_text_field',
                    ),
                ),
                'client_id'              => array(
                    'type'        => 'integer',
                    'default'     => 0,
                    'description' => __( 'Client id', 'mainwp' ),
                    'context'     => array( 'view', 'edit', 'simple_view' ),
                ),
                'is_staging'             => array(
                    'type'        => 'integer',
                    'default'     => 0,
                    'description' => __( 'is staging', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'wp_version'             => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'wp version', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'php_version'            => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'php version', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'child_version'          => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'child version', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'mysql_version'          => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'mysql version', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'memory_limit'           => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'memory limit', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'database_size'          => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Database size', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'active_theme'           => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Active theme', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'status'                 => array(
                    'type'        => array( 'string', 'array' ),
                    'default'     => '',
                    'description' => __( 'Status', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'sync_errors'            => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Sync errors', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'http_status'            => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Http status', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'health_status'          => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Health status', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'health_score'           => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Health score', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'icon'                   => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Icon', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'last_sync'              => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Last sync', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'plugins'                => array(
                    'type'        => array( 'string', 'array' ),
                    'default'     => '',
                    'description' => __( 'Plugins', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'themes'                 => array(
                    'type'        => array( 'string', 'array' ),
                    'default'     => '',
                    'description' => __( 'Themes', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'last_post_time'         => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Last post time', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'recent_posts'           => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Recent posts', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'recent_pages'           => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Recent pages', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'recent_comments'        => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Recent comments', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'categories'             => array(
                    'type'        => 'string',
                    'default'     => '',
                    'description' => __( 'Categories', 'mainwp' ),
                    'context'     => array( 'view' ),
                ),
                'wp_upgrades'            => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'plugin_upgrades'        => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'premium_upgrades'       => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'theme_upgrades'         => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'translation_upgrades'   => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'ignored_plugins'        => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'ignored_themes'         => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'automatic_update'       => array(
                    'type'        => 'integer',
                    'default'     => 0,
                    'description' => __( 'Automatic update', 'mainwp' ),
                    'context'     => array( 'edit' ),
                    'enum'        => array( 0, 1 ),
                    'arg_options' => array(
                        'sanitize_callback' => 'absint',
                    ),
                ),
                // MWP-1541: Basic Auth credentials were exposed in the default
                // 'view' context, leaking http_pass to any v2 read-key holder.
                // Narrowed to 'edit'-only as the formal contract; the actual
                // protection on read responses is provided by
                // strip_never_in_response_fields() in
                // prepare_site_item_for_response_context(), which strips these
                // fields from every site response regardless of context or
                // _fields. check_permissions() in the auth layer does not
                // currently gate ?context=edit to actual edit-permission keys,
                // and no caller forwards request context to the schema-filter
                // step, so the schema label alone is not the effective guard.
                'http_user'              => array(
                    'type'        => 'string',
                    'description' => __( 'HTTP user', 'mainwp' ),
                    'context'     => array( 'edit' ),
                    'arg_options' => array(
                        'sanitize_callback' => 'sanitize_text_field',
                    ),
                ),
                'http_pass'              => array(
                    'type'        => 'string',
                    'description' => __( 'HTTP password', 'mainwp' ),
                    'context'     => array( 'edit' ),
                ),
                // uniqueId is the per-site secure-mode signing identifier; same
                // narrowing as http_pass / http_user. Unlike them, it was already
                // view-only (never accepted as input) so this change strictly
                // tightens the surface.
                'uniqueId'               => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'edit' ),
                ),
                'disable_health_check'   => array(
                    'type'        => 'integer',
                    'default'     => 0,
                    'description' => __( 'Disable health check', 'mainwp' ),
                    'context'     => array( 'edit' ),
                    'enum'        => array( 0, 1 ),
                    'arg_options' => array(
                        'sanitize_callback' => 'absint',
                    ),
                ),
                'health_threshold'       => array(
                    'type'        => 'integer',
                    'default'     => 0,
                    'description' => __( 'Health threshold', 'mainwp' ),
                    'context'     => array( 'edit' ),
                    'arg_options' => array(
                        'sanitize_callback' => 'absint',
                    ),
                ),
                'is_ignoreCoreUpdates'   => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'is_ignorePluginUpdates' => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'is_ignoreThemeUpdates'  => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'verify_certificate'     => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'force_use_ipv4'         => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view', 'edit' ),
                ),
                'ssl_version'            => array(
                    'type'    => 'string',
                    'default' => '',
                    'context' => array( 'view' ),
                ),
                'suspended'              => array(
                    'type'        => 'integer',
                    'default'     => 0,
                    'description' => __( 'Suspended', 'mainwp' ),
                    'context'     => array( 'view', 'edit' ),
                    'enum'        => array( 0, 1 ),
                    'arg_options' => array(
                        'sanitize_callback' => 'absint',
                    ),
                ),
                // groupids is validated and sanitized in the database save function
                // before updating the group associations.
                'groupids'               => array(
                    'type'        => 'string',
                    'description' => __( 'Site tag/group IDs as a comma-separated string.', 'mainwp' ),
                    'context'     => array( 'edit' ),
                ),

            ),
        );
    }


    /**
     * Arguments registered by the add route.
     *
     * @return array
     */
    protected function get_add_site_args() {
        return array_merge( $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), $this->get_add_site_extra_args() );
    }

    /**
     * Get the args a batch create item is validated against.
     *
     * The add route registers the editable schema args plus the add-only extras,
     * so a batch create item has to be held to that same set.
     *
     * @return array
     */
    public function get_batch_create_args() {
        return $this->get_add_site_args();
    }

    /**
     * Get the args a batch update item is validated against.
     *
     * @return array
     */
    public function get_batch_update_args() {
        $args       = $this->get_edit_site_args();
        $args['id'] = $this->get_batch_item_id_arg();
        return $args;
    }

    /**
     * Arguments registered by the edit route.
     *
     * The schema's id arg is the collection filter (wp_parse_id_list) and the edit
     * route identifies the site by its URL segment, so id is dropped here. Schema
     * defaults are dropped too: core injects arg defaults as request params, and
     * prepare_object_for_update() treats every present field as a change, so a
     * name-only edit would otherwise also write suspended = 0 and the update flags.
     *
     * @return array
     */
    protected function get_edit_site_args() {
        $args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE );
        unset( $args['id'] );
        foreach ( $args as &$arg ) {
            unset( $arg['default'] );
        }
        unset( $arg );
        return array_merge( $args, $this->get_edit_site_extra_args() );
    }

    /**
     * Extra arguments accepted by the edit route.
     *
     * These are the documented update-only input spellings that prepare_object_for_update()
     * reads. They are absent from the item schema, so core never type-checked them.
     *
     * @return array
     */
    protected function get_edit_site_extra_args() {
        $args = array(
            'adminname'             => array(
                'description' => __( 'Site administrator username.', 'mainwp' ),
                'type'        => 'string',
            ),
            'admin'                 => array(
                'description' => __( 'Site administrator username, accepted alias of adminname.', 'mainwp' ),
                'type'        => 'string',
            ),
            'uniqueid'              => array(
                'description' => __( 'Unique security ID.', 'mainwp' ),
                'type'        => 'string',
            ),
            'groupids'              => array(
                'description' => __( 'Comma-separated tag IDs.', 'mainwp' ),
                'type'        => 'string',
            ),
            'protocol'              => array(
                'description' => __( 'Site protocol.', 'mainwp' ),
                'type'        => 'string',
                'enum'        => array( 'http', 'https' ),
            ),
            'sslversion'            => array(
                'description' => __( 'SSL version.', 'mainwp' ),
                'type'        => 'string',
            ),
            'disablehealthchecking' => array(
                'description' => __( 'Disable health check.', 'mainwp' ),
                'type'        => 'integer',
                'enum'        => array( 0, 1 ),
            ),
            'healththreshold'       => array(
                'description' => __( 'Health threshold.', 'mainwp' ),
                'type'        => 'integer',
            ),
            'backup_before_upgrade' => array(
                'description' => __( 'Backup before upgrade.', 'mainwp' ),
                'type'        => 'integer',
                'enum'        => array( 0, 1 ),
            ),
            'ignore_core_updates'   => array(
                'description' => __( 'Ignore WordPress core updates.', 'mainwp' ),
                'type'        => 'integer',
                'enum'        => array( 0, 1 ),
            ),
            'ignore_plugin_updates' => array(
                'description' => __( 'Ignore plugin updates.', 'mainwp' ),
                'type'        => 'integer',
                'enum'        => array( 0, 1 ),
            ),
            'ignore_theme_updates'  => array(
                'description' => __( 'Ignore theme updates.', 'mainwp' ),
                'type'        => 'integer',
                'enum'        => array( 0, 1 ),
            ),
            'monitoring_emails'     => array(
                'description' => __( 'Monitoring notification email addresses.', 'mainwp' ),
                'type'        => 'string',
            ),
            'force_use_ipv4'        => $this->get_force_use_ipv4_arg(),
        );

        // The item schema types the is_ignore* columns as view strings, and the DB layer
        // tests them for truthiness, so left as strings "false" would enable ignoring.
        $aliases = array(
            'is_ignoreCoreUpdates'   => 'ignore_core_updates',
            'is_ignorePluginUpdates' => 'ignore_plugin_updates',
            'is_ignoreThemeUpdates'  => 'ignore_theme_updates',
        );
        foreach ( $aliases as $alias => $documented ) {
            $args[ $alias ] = array(
                'description' => $args[ $documented ]['description'],
                'type'        => 'integer',
                'enum'        => array( 0, 1 ),
            );
        }

        return $args;
    }

    /**
     * Extra arguments accepted by the add route.
     *
     * These are consumed by prepare_object_for_database() but absent from
     * the item schema, so core never type-checked them and an array value
     * reached the string sanitizers and fataled the request. Registering them
     * here types them without touching the published item schema.
     *
     * @return array
     */
    protected function get_add_site_extra_args() {
        return array(
            'admin'          => array(
                'description' => __( 'Site administrator username.', 'mainwp' ),
                'type'        => 'string',
            ),
            'adminpassword'  => array(
                'description' => __( 'Site administrator password.', 'mainwp' ),
                'type'        => 'string',
            ),
            'uniqueid'       => array(
                'description' => __( 'Unique security ID.', 'mainwp' ),
                'type'        => 'string',
            ),
            'groupids'       => array(
                'description' => __( 'Comma-separated tag IDs.', 'mainwp' ),
                'type'        => 'string',
            ),
            'ssl_verify'     => array(
                'description'       => __( 'Verify the SSL certificate of the child site.', 'mainwp' ),
                'type'              => 'boolean',
                // rest_parse_request_arg validates before it sanitizes, so an explicit
                // JSON null is a 400 instead of being coerced to false past the validator.
                'sanitize_callback' => 'rest_parse_request_arg',
                'validate_callback' => 'rest_validate_request_arg',
            ),
            'force_use_ipv4' => $this->get_force_use_ipv4_arg(),
        );
    }

    /**
     * The force_use_ipv4 argument the two input routes accept.
     *
     * The item schema types it as a view string, so a JSON boolean was refused even
     * though OpenAPI documents one. The type order decides how core coerces the value:
     * true / "1" / "0" match boolean first, while 2 and "2" fall through to integer and
     * keep the "use the global setting" value the DB layer stores.
     *
     * @return array
     */
    protected function get_force_use_ipv4_arg() {
        return array(
            'description'       => __( 'Force IPv4: 0 to disable, 1 to force IPv4, 2 to use the global setting.', 'mainwp' ),
            'type'              => array( 'boolean', 'integer', 'string' ),
            // The DB layer only clamps values above 2, so without the enum -1 would be
            // stored as sent and "yes" would silently become 0. Core compares the enum
            // against the coerced value, so "1" and "true" still match true.
            'enum'              => array( false, true, 2 ),
            // Both callbacks re-word an enum rejection: sanitize_params() runs the
            // sanitizer over every parameter bag while has_valid_params() only checks the
            // highest-priority one, so a valid body value alongside an invalid query value
            // is refused by the sanitizer and would otherwise carry core's wording.
            // rest_validate_request_arg() does not call validate_callback itself, so
            // neither closure recurses.
            'validate_callback' => function ( $value, $request, $param ) {
                return $this->force_use_ipv4_enum_error( rest_validate_request_arg( $value, $request, $param ), $param );
            },
            'sanitize_callback' => function ( $value, $request, $param ) {
                $valid = $this->force_use_ipv4_enum_error( rest_validate_request_arg( $value, $request, $param ), $param );
                if ( is_wp_error( $valid ) ) {
                    return $valid;
                }

                return rest_sanitize_request_arg( $value, $request, $param );
            },
        );
    }

    /**
     * Re-word a force_use_ipv4 enum rejection, passing anything else through.
     *
     * Core lists the enum with %l, which prints its false member as an empty string
     * ("is not one of , 1, and 2"), so the rejection is worded in the values the route
     * documents instead.
     *
     * @param true|WP_Error $result Result of validating the value.
     * @param string        $param  Parameter name.
     * @return true|WP_Error
     */
    private function force_use_ipv4_enum_error( $result, $param ) {
        if ( is_wp_error( $result ) && 'rest_not_in_enum' === $result->get_error_code() ) {
            return new WP_Error(
                'rest_not_in_enum',
                /* translators: %s: parameter name */
                sprintf( __( '%s is not one of 0, 1, or 2.', 'mainwp' ), $param ),
                array( 'status' => 400 )
            );
        }

        return $result;
    }

    /**
     * Prepare a single order for create or update.
     *
     * @throws MainWP_Rest_Data_Exception When fails to set any item.
     * @param  WP_REST_Request $request Request object.
     * @return array
     */
    protected function prepare_object_for_database( $request ) {
        $item_fields               = array();
        $item_fields['url']        = isset( $request['url'] ) ? sanitize_text_field( $request['url'] ) : '';
        $item_fields['name']       = isset( $request['name'] ) ? sanitize_text_field( $request['name'] ) : '';
        $item_fields['wpadmin']    = isset( $request['admin'] ) ? sanitize_text_field( $request['admin'] ) : '';
        $item_fields['adminpwd']   = isset( $request['adminpassword'] ) ? (string) $request['adminpassword'] : ''; // Cast only; sanitize_text_field() strips characters a password may legitimately hold.
        $item_fields['unique_id']  = sanitize_text_field( $request['uniqueid'] ?? $request['uniqueId'] ?? '' ); // uniqueid is the documented input spelling, uniqueId is what the item schema registers on this route.
        $item_fields['ssl_verify'] = empty( $request['ssl_verify'] ) ? false : intval( $request['ssl_verify'] );
        // Omitted stays null: fetch_url() then reads the global mainwp_forceUseIPv4 option
        // for the handshake, the same as the UI and the v1 add handler send, and
        // add_website() leaves the column at its default.
        $item_fields['force_use_ipv4'] = null;
        if ( isset( $request['force_use_ipv4'] ) ) {
            // The add route accepts 2 (use the global setting) and mainwp_string_to_bool()
            // would collapse it to 0, so keep it before the boolean conversion.
            if ( 2 === (int) $request['force_use_ipv4'] ) {
                $item_fields['force_use_ipv4'] = 2;
            } else {
                $item_fields['force_use_ipv4'] = mainwp_string_to_bool( $request['force_use_ipv4'] ) ? 1 : 0;
            }
        }
        $item_fields['http_user'] = isset( $request['http_user'] ) ? sanitize_text_field( $request['http_user'] ) : '';
        $item_fields['http_pass'] = isset( $request['http_pass'] ) ? (string) $request['http_pass'] : ''; // Cast only, same reason as adminpwd.
        $item_fields['groupids']  = isset( $request['groupids'] ) && ! empty( $request['groupids'] ) ? explode( ',', sanitize_text_field( $request['groupids'] ) ) : array();
        $item_fields['clientid']  = isset( $request['client_id'] ) && ! empty( $request['client_id'] ) ? intval( $request['client_id'] ) : 0;

        /**
         * Filters an object before it is inserted via the REST API.
         *
         * @since 5.2
         *
         * @param array         $item_fields    Site data.
         * @param WP_REST_Request $request  Request object.
         */
        return apply_filters( 'mainwp_rest_pre_insert_site_item', $item_fields, $request );
    }

    /**
     * Prepare a single order for create or update.
     *
     * @param  WP_REST_Request $request Request object.
     * @return array
     */
    protected function prepare_object_for_update( $request ) {

        $map_fields_update = array(
            'http_user'              => 'http_user',
            'http_pass'              => 'http_pass',
            'name'                   => 'name',
            'adminname'              => 'admin',
            'ssl_version'            => 'sslversion',
            'uniqueId'               => 'uniqueid',
            'protocol'               => 'protocol',
            'disable_health_check'   => 'disablehealthchecking',
            'health_threshold'       => 'healththreshold',
            'suspended'              => 'suspended',
            'groupids'               => 'groupids',
            'automatic_update'       => 'automatic_update',
            'backup_before_upgrade'  => 'backup_before_upgrade',
            'force_use_ipv4'         => 'force_use_ipv4',
            'is_ignoreCoreUpdates'   => 'ignore_core_updates',
            'is_ignorePluginUpdates' => 'ignore_plugin_updates',
            'is_ignoreThemeUpdates'  => 'ignore_theme_updates',
            'monitoring_emails'      => 'monitoring_emails',
        );

        $data = array();
        // The value side is the documented input spelling; the item-schema spelling on
        // the key side is accepted as a fallback, the same way prepare_object_for_database()
        // accepts uniqueId.
        foreach ( $map_fields_update as $schema_field => $field ) {
            if ( isset( $request[ $field ] ) ) {
                $data[ $field ] = $request[ $field ];
            } elseif ( isset( $request[ $schema_field ] ) ) {
                $data[ $field ] = $request[ $schema_field ];
            }
        }

        // rest_api_update_website() writes client_id whenever the key is set, so an
        // unconditional entry here would unassign the client on every name-only edit.
        // A sent 0 stays the explicit unassign.
        if ( isset( $request['client_id'] ) ) {
            $data['client_id'] = intval( $request['client_id'] );
        }

        /**
         * Filters an object before it is inserted via the REST API.
         *
         * @since 5.2
         *
         * @param array         $item_fields    Site data.
         * @param WP_REST_Request $request  Request object.
         */
        return apply_filters( 'mainwp_rest_pre_update_site_item', $data, $request );
    }

    /**
     * Get formatted item object.
     *
     * @since  5.2
     * @param  object $site_object Site object.
     * @param  array  $args Request args.
     *
     * @return array
     */
    protected function get_pre_formatted_item_data( $site_object, $args = array() ) {

        $fields = false;
        // Determine if the response fields were specified.
        if ( ! empty( $this->request['_fields'] ) ) {
            $fields = wp_parse_list( $this->request['_fields'] );

            if ( 0 === count( $fields ) ) {
                $fields = false;
            } else {
                $fields = array_map( 'trim', $fields );
            }
        }

        $extra_fields = array();
        $extra_fields = false === $fields ? $extra_fields : array_intersect( $extra_fields, $fields );

        $formatted_data = array(
            'id'        => $site_object->id,
            'url'       => $site_object->url,
            'name'      => $site_object->name,
            'client_id' => $site_object->client_id,
        );

        if ( ! empty( $args['selectgroups'] ) ) {
            $formatted_data['wpgroups'] = $site_object->wpgroups;
        }

        if ( ! empty( $args['full_data'] ) ) {
            $formatted_extra = array(
                'id'                   => $site_object->id,
                'url'                  => $site_object->url,
                'name'                 => $site_object->name,
                'offline_checks_last'  => $site_object->offline_checks_last,
                'offline_check_result' => $site_object->offline_check_result, // 1 - online, -1 offline.
                'http_response_code'   => $site_object->http_response_code,
                'disable_health_check' => $site_object->disable_health_check,
                'health_threshold'     => $site_object->health_threshold,
                'note'                 => $site_object->note,
                'plugin_upgrades'      => $site_object->plugin_upgrades,
                'theme_upgrades'       => $site_object->theme_upgrades,
                'translation_upgrades' => $site_object->translation_upgrades,
                'securityIssues'       => $site_object->securityIssues,
                'themes'               => $site_object->themes,
                'plugins'              => $site_object->plugins,
                'automatic_update'     => ! empty( $site_object->automatic_update ) ? $site_object->automatic_update : 0,
                'sync_errors'          => $site_object->sync_errors,
                'last_post_gmt'        => $site_object->last_post_gmt,
                'health_value'         => $site_object->health_value,
                'phpversion'           => $site_object->phpversion,
                'wp_upgrades'          => $site_object->wp_upgrades,
                'security_stats'       => $site_object->security_stats,
                'client_id'            => $site_object->client_id,
                'adminname'            => $site_object->adminname,
                'http_user'            => $site_object->http_user,
                'http_pass'            => $site_object->http_pass,
                'ssl_version'          => $site_object->ssl_version,
                'signature_algo'       => $site_object->signature_algo,
                'verify_method'        => $site_object->verify_method,
                'verify_certificate'   => $site_object->verify_certificate,
            );

            $format_date = array( 'dtsAutomaticSync', 'dtsAutomaticSyncStart', 'dtsSync', 'dtsSyncStart' );
            // Format date values.
            foreach ( $format_date as $key ) {
                // Date created is stored UTC, date modified is stored WP local time.
                $datetime                = get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $site_object->$key ) );
                $formatted_extra[ $key ] = mainwp_rest_prepare_date_response( $datetime, false );
            }

            $formatted_data = array_merge( $formatted_data, $formatted_extra );
        }

        return $formatted_data;
    }

    /**
     * Prepare a single product review output for response.
     *
     * @param object          $item site object.
     * @param WP_REST_Request $request Request object.
     * @param array           $args args.
     *
     * @return WP_REST_Response $response Response data.
     */
    public function prepare_item_for_response( $item, $request, $args = array() ) { //phpcs:ignore -- NOSONAR - complex.

        if ( ! is_object( $item ) ) {
            $item = new \stdClass();
        }

        $fields = $this->get_fields_for_response( $request );

        if ( ! is_array( $fields ) ) {
            $fields = array();
        }

        $context = ! empty( $request['context'] ) ? $request['context'] : 'view';

        $data = $this->filter_response_by_context( $item, $context );

        if ( is_object( $data ) ) {
            $_data = array();
            foreach ( $data as $field => $value ) {
                $_data[ $field ] = $value;
            }
            $data = $_data;
        }

        if ( in_array( 'tags', $fields ) && property_exists( $item, 'wpgroups' ) && property_exists( $item, 'wpgroupids' ) ) {
            $wpgroupids = ! empty( $item->wpgroupids ) ? explode( ',', $item->wpgroupids ) : array();
            $wpgroups   = ! empty( $item->wpgroups ) ? explode( ',', $item->wpgroups ) : array();

            $tags = array();
            if ( is_array( $wpgroupids ) ) {
                foreach ( $wpgroupids as $gidx => $groupid ) {
                    if ( $groupid && ! isset( $tags[ $groupid ] ) ) {
                        $tags[ $groupid ] = isset( $wpgroups[ $gidx ] ) ? $wpgroups[ $gidx ] : '';
                    }
                }
            }
            $data['tags'] = $tags;
        }

        $map_fields = array(
            'database_size'  => 'dbsize',
            'http_status'    => 'http_response_code',
            'icon'           => 'cust_site_icon_info',
            'last_sync'      => 'dtsSync',
            'last_post_time' => 'last_post_gmt',
        );

        if ( is_array( $data ) ) {
            foreach ( $map_fields as $field1 => $field2 ) {
                if ( in_array( $field1, $fields ) && property_exists( $item, $field2 ) ) {
                    $data[ $field1 ] = $item->{$field2};
                }
            }

            if ( in_array( 'health_score', $fields, true ) ) {
                // health_value is a sortable composite (score - critical * 100) that goes
                // negative on critical issues; report the label v1 and WP-CLI derive from
                // the stored Site Health issue counts, not the raw column.
                // Passing the $item object (not $item->id) with json_format=true uses the
                // property fast-path when health_site_status is already on the row, and
                // guards scalars to array() so a malformed value cannot fatal.
                $health_status        = MainWP_DB::instance()->get_website_option( $item, 'health_site_status', array(), true );
                $hstatus              = MainWP_Utility::get_site_health( $health_status );
                $data['health_score'] = ( 80 <= $hstatus['val'] && empty( $hstatus['critical'] ) ) ? 'Good' : 'Should be improved';
            }
        }

        $website_info = MainWP_DB::instance()->get_website_option( $item->id, 'site_info' );
        $website_info = ! empty( $website_info ) ? json_decode( $website_info, true ) : array();

        if ( is_array( $website_info ) && property_exists( $item, 'http_response_code' ) ) {
            $code        = $item->http_response_code;
            $code_string = MainWP_Utility::get_http_codes( $code );
            if ( ! empty( $code_string ) ) {
                $code .= ' - ' . $code_string;
            }
            $website_info['http_status'] = $code;
        }

        $map_meta_fields = array(
            'wp_version'    => 'wpversion',
            'php_version'   => 'phpversion',
            'child_version' => 'child_version',
            'mysql_version' => 'mysql_version',
            'memory_limit'  => 'memory_limit',
            'http_status'   => 'http_status',
        );

        foreach ( $map_meta_fields as $_field1 => $_field2 ) {
            if ( in_array( $_field1, $fields ) ) {
                $data[ $_field1 ] = isset( $website_info[ $_field2 ] ) ? $website_info[ $_field2 ] : '';
            }
        }

        if ( in_array( 'active_theme', $fields ) ) {
            $active_theme = array();
            if ( ! empty( $item->themes ) ) {
                $themes = json_decode( $item->themes, 1 );
                if ( is_array( $themes ) && ! empty( $themes ) ) {
                    foreach ( $themes as $theme ) {
                        if ( ! empty( $theme['active'] ) ) {
                            $active_theme = $theme;
                            break;
                        }
                    }
                }
            }
            $data['active_theme'] = is_array( $active_theme ) && ! empty( $active_theme['name'] ) ? esc_html( $active_theme['name'] . ' ' . $active_theme['version'] ) : '';
        }

        if ( in_array( 'status', $fields ) ) {
            $data['status'] = ! empty( $item->sync_errors ) ? 'disconnected' : 'connected';
            if ( $item->suspended ) {
                $data['status'] = 'suspended';
            }
        }

        if ( is_array( $fields ) && ! empty( $fields ) ) {
            $_data = array();
            foreach ( $data as $field => $value ) {
                if ( in_array( $field, $fields ) ) {
                    $_data[ $field ] = $value;
                }
            }
            $data = $_data;
        }

        if ( isset( $data['last_sync'] ) ) {
            $data['last_sync'] = mainwp_rest_prepare_date_response( $data['last_sync'] );
        }

        $custom_fields = $this->get_requested_custom_fields( $request );

        if ( is_array( $custom_fields ) && ! empty( $custom_fields ) ) {
            foreach ( $custom_fields as $field ) {
                if ( property_exists( $item, $field ) && ! isset( $data[ $field ] ) ) {
                    $data[ $field ] = $item->{$field};
                }
            }
        }

        /**
         * Filterobject returned from the REST API.
         *
         * @param array $data The object.
         * @param mixed       $item   The object used to create response.
         * @param WP_REST_Request  $request  Request object.
         */
        return apply_filters( 'mainwp_rest_prepare_site', $data, $item, $request );
    }

    /**
     * Add Site.
     *
     * @since  5.2
     * @throws Exception But all errors are validated before returning any data.
     *
     * @param  WP_REST_Request $request  Full details about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function create_item( $request ) {  //phpcs:ignore -- NOSONAR - complex.
        $resp_data            = array();
        $resp_data['success'] = 0;
        $site_id              = 0;
        $data                 = array(); //phpcs:ignore -- NOSONAR - not used data.
        $found_id             = 0;
        try {

            if ( $request->has_param( 'groupids' ) && ! empty( $request['groupids'] ) && false === MainWP_DB_Common::instance()->validate_site_group_ids( $request['groupids'] ) ) {
                return new \WP_Error(
                    'rest_invalid_param',
                    __( 'The groupids parameter must be a comma-separated string of positive integers.', 'mainwp' ),
                    array(
                        'status' => 400,
                    )
                );
            }

            $item = $this->prepare_object_for_database( $request );

            if ( empty( $item['name'] ) && ! empty( $item['url'] ) ) {
                $item['name'] = MainWP_Utility::remove_http_prefix( $item['url'], true );
                $item['name'] = rtrim( $item['name'], '/' );
            }

            $website                                      = MainWP_DB::instance()->get_websites_by_url( $item['url'] );
            list( $message, $error, $site_id, $found_id ) = MainWP_Manage_Sites_View::add_wp_site( $website, $item );

            if ( ! empty( $site_id ) ) {
                $site                 = MainWP_DB::instance()->get_website_by_id( $site_id );
                $resp_data['success'] = 1;
                $resp_data['data']    = $this->filter_response_data_by_allowed_fields( $site, 'simple_view' );

                if ( ! empty( $message ) ) {
                    $resp_data['message'] = wp_strip_all_tags( $message );
                }
            } elseif ( ! empty( $error ) ) {
                $resp_data['error'] = wp_strip_all_tags( $error );
            } else {
                $resp_data['error'] = esc_html__( 'Add site failed. Please try again.', 'mainwp' );
            }
        } catch ( Exception $e ) {
            $resp_data['error'] = wp_strip_all_tags( $e->getMessage() );
        }

        if ( ! empty( $found_id ) ) {
            $resp_data['found_id'] = $found_id;
        }

        return rest_ensure_response( $resp_data );
    }

    /**
     * Update site.
     *
     * @param  WP_REST_Request $request Request object.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function update_item( $request ) {
        $website = $this->get_request_item( $request );
        if ( is_wp_error( $website ) ) {
            return $website;
        }

        if ( $request->has_param( 'tags' ) ) {
            return new \WP_Error(
                'rest_invalid_param',
                __( 'The "tags" parameter is not supported for this endpoint.', 'mainwp' ),
                array(
                    'status' => 400,
                )
            );
        }

        if ( $request->has_param( 'groupids' ) && ! is_string( $request['groupids'] ) ) {
            return new \WP_Error(
                'rest_invalid_param',
                __( 'The groupids parameter must be a string.', 'mainwp' ),
                array(
                    'status' => 400,
                )
            );
        }

        $resp_data            = array();
        $resp_data['success'] = 0;
        try {
            $item   = $this->prepare_object_for_update( $request );
            $result = MainWP_DB_Common::instance()->rest_api_update_website( $website->id, $item );
            if ( is_array( $result ) ) {
                foreach ( array( 'group_updated', 'group_message', 'message' ) as $field ) {
                    if ( array_key_exists( $field, $result ) ) {
                        $resp_data[ $field ] = $result[ $field ];
                    }
                }
            }
            if ( is_array( $result ) && ! empty( $result['success'] ) ) {
                    $resp_data['success'] = 1;
                    $params               = array(
                        'full_data'    => true,
                        'selectgroups' => true,
                        'include'      => array( $website->id ),
                        'fields'       => $this->get_fields_for_response( $request ),
                    );
                    $websites             = MainWP_DB::instance()->get_websites_for_current_user( $params );
                    $data                 = $websites ? current( $websites ) : array();
                    $resp_data['data']    = $this->prepare_site_item_for_response_context( $data, $request, 'view' );
            } elseif ( empty( $resp_data['group_message'] ) && empty( $resp_data['message'] ) ) {
                $resp_data['error'] = esc_html__( 'Update site failed. Please try again.', 'mainwp' );
            }
        } catch ( \Exception $e ) {
            $resp_data['error'] = $e->getMessage();
        }
        return rest_ensure_response( $resp_data );
    }
}

```
