<?php
/**
 * Code block migrator and SQL query tool AJAX handlers.
 *
 * @package CloudScale_DevTools
 */

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

class CSDT_Code_Migrator {

    public static function recursive_str_replace( string $from, string $to, $data ) {
        if ( is_array( $data ) ) {
            foreach ( $data as $k => $v ) {
                $data[ $k ] = self::recursive_str_replace( $from, $to, $v );
            }
            return $data;
        }
        if ( ! is_string( $data ) ) {
            return $data;
        }
        // allowed_classes=false: prevents PHP object-injection / POP-chain
        // execution if any DB cell contains attacker-supplied serialized data.
        // Objects become __PHP_Incomplete_Class; arrays and scalars deserialise
        // normally so URL rewriting still works.
        $unserialized = @unserialize( $data, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
        if ( $unserialized !== false && $data !== 'b:0;' ) {
            $replaced = self::recursive_str_replace( $from, $to, $unserialized );
            return serialize( $replaced ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
        }
        return str_replace( $from, $to, $data );
    }

    /*
    ==================================================================
        6a. Settings AJAX save
        ================================================================== */

    /**
     * AJAX handler: saves the colour theme and default mode settings.
     *
     * @since  1.6.0
     * @return void Sends JSON response and exits.
     */
    public static function ajax_save_theme_setting(): void {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( 'Forbidden', 403 );
        }
        if ( ! check_ajax_referer( 'csdt_devtools_code_settings_inline', 'nonce', false ) ) {
            wp_send_json_error( 'Bad nonce', 403 );
        }

        $theme = isset( $_POST['theme'] ) ? sanitize_text_field( wp_unslash( $_POST['theme'] ) ) : 'dark';
        if ( ! in_array( $theme, array( 'dark', 'light' ), true ) ) {
            $theme = 'dark';
        }
        update_option( 'csdt_devtools_code_default_theme', $theme );

        $valid_pairs = array_keys( CloudScale_DevTools::get_theme_registry() );
        $pair        = isset( $_POST['theme_pair'] ) ? sanitize_text_field( wp_unslash( $_POST['theme_pair'] ) ) : 'atom-one';
        if ( ! in_array( $pair, $valid_pairs, true ) ) {
            $pair = 'atom-one';
        }
        update_option( 'csdt_devtools_code_theme_pair', $pair );

        // Only touch the perf-monitor flag when the caller explicitly sent it.
        // The Re-apply panel saves theme settings without that checkbox in scope
        // and must not silently turn perf monitoring off.
        if ( array_key_exists( 'csdt_devtools_perf_monitor_enabled', $_POST ) ) {
            $perf_enabled = '1' === $_POST['csdt_devtools_perf_monitor_enabled'] ? '1' : '0'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            update_option( 'csdt_devtools_perf_monitor_enabled', $perf_enabled );
        } else {
            $perf_enabled = (string) get_option( 'csdt_devtools_perf_monitor_enabled', '1' );
        }

        if ( array_key_exists( 'powered_by', $_POST ) ) {
            $powered_by = '1' === sanitize_text_field( wp_unslash( $_POST['powered_by'] ) ) ? '1' : '0';
            update_option( 'csdt_show_powered_by', $powered_by );
        }

        wp_send_json_success(
            array(
				'theme'        => $theme,
				'theme_pair'   => $pair,
				'perf_enabled' => $perf_enabled,
            )
        );
    }

    public static function ajax_save_perf_monitor(): void {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( 'Forbidden', 403 );
        }
        if ( ! check_ajax_referer( CloudScale_DevTools::PERF_NONCE, 'nonce', false ) ) {
            wp_send_json_error( 'Bad nonce', 403 );
        }
        $enabled = isset( $_POST['enabled'] ) && '1' === $_POST['enabled'] ? '1' : '0'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        update_option( 'csdt_devtools_perf_monitor_enabled', $enabled );
        wp_send_json_success( array( 'perf_enabled' => $enabled ) );
    }

    // ── Object cache toggle ───────────────────────────────────────────────────

    public static function get_object_cache_status(): array {
        $drop_in = WP_CONTENT_DIR . '/object-cache.php';
        $apcu_ok = extension_loaded( 'apcu' );

        if ( ! file_exists( $drop_in ) ) {
            return array(
				'type'    => 'none',
				'label'   => 'Not installed',
				'apcu_ok' => $apcu_ok,
			);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents -- reading drop-in header at known local path
        $head = (string) file_get_contents( $drop_in, false, null, 0, 1024 );

        if ( false !== strpos( $head, 'CSDT_APCU_AVAILABLE' ) ) {
            return array(
				'type'    => 'ours',
				'label'   => 'APCu (CloudScale)',
				'apcu_ok' => $apcu_ok,
			);
        }
        if ( false !== stripos( $head, 'redis' ) ) {
            return array(
				'type'    => 'redis',
				'label'   => 'Redis',
				'apcu_ok' => $apcu_ok,
			);
        }
        if ( false !== stripos( $head, 'memcached' ) ) {
            return array(
				'type'    => 'memcached',
				'label'   => 'Memcached',
				'apcu_ok' => $apcu_ok,
			);
        }
        return array(
			'type'    => 'other',
			'label'   => 'Third-party drop-in',
			'apcu_ok' => $apcu_ok,
		);
    }

    public static function ajax_object_cache_toggle(): void {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( 'Forbidden', 403 );
        }
        if ( ! check_ajax_referer( 'csdt_object_cache_nonce', 'nonce', false ) ) {
            wp_send_json_error( 'Bad nonce', 403 );
        }

        $action   = sanitize_key( wp_unslash( $_POST['action_type'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        $drop_in  = WP_CONTENT_DIR . '/object-cache.php';
        $src_file = __DIR__ . '/../lib/object-cache.php';

        if ( 'install' === $action ) {
            if ( ! file_exists( $src_file ) ) {
                wp_send_json_error( 'Source file missing from plugin lib/ directory.' );
            }
            if ( file_exists( $drop_in ) ) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents -- reading drop-in header at known local path
                $head = (string) file_get_contents( $drop_in, false, null, 0, 1024 );
                if ( false === strpos( $head, 'CSDT_APCU_AVAILABLE' ) ) {
                    wp_send_json_error( 'A third-party object cache is already installed. Remove it before installing ours.' );
                }
            }
            // Copies the bundled APCu object-cache drop-in to the canonical WordPress
            // drop-in location (wp-content/object-cache.php). This mirrors the approach
            // used by redis-cache and other approved WP.org object-cache plugins.
            // Only executes on explicit admin action (nonce-protected, manage_options).
            if ( ! @copy( $src_file, $drop_in ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy, PluginCheck.CodeAnalysis.WriteFile.PluginDirectoryWrite, WordPress.PHP.NoSilencedErrors.Discouraged -- canonical object-cache drop-in; identical pattern to redis-cache plugin
                wp_send_json_error( 'Could not write wp-content/object-cache.php, check directory permissions.' );
            }
            wp_send_json_success( self::get_object_cache_status() );
        }

        if ( 'uninstall' === $action ) {
            if ( ! file_exists( $drop_in ) ) {
                wp_send_json_success( self::get_object_cache_status() );
            }
            $head = (string) file_get_contents( $drop_in, false, null, 0, 1024 );
            if ( false === strpos( $head, 'CSDT_APCU_AVAILABLE' ) ) {
                wp_send_json_error( 'Not a CloudScale object cache, remove it manually if needed.' );
            }
            if ( ! @unlink( $drop_in ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- removing our own drop-in on uninstall
                wp_send_json_error( 'Could not remove object-cache.php, check directory permissions.' );
            }
            wp_send_json_success( self::get_object_cache_status() );
        }

        wp_send_json_error( 'Unknown action.' );
    }

    public static function ajax_install_apcu(): void {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( 'Forbidden', 403 );
        }
        if ( ! check_ajax_referer( 'csdt_object_cache_nonce', 'nonce', false ) ) {
            wp_send_json_error( 'Bad nonce', 403 );
        }

        $manual_cmd = 'docker exec pi_wordpress sh -c "apk add --no-cache php-apcu && kill -USR2 1"';
        // Automatic installation requires shell access which is unavailable in this environment.
        // Signal PHP-FPM directly if APCu is already installed and only a reload is needed.
        $reloaded  = false;
        $pid_files = array_merge(
            glob( '/var/run/php/php*-fpm.pid' ) ?: array(),
            array( '/run/php-fpm.pid', '/var/run/php-fpm.pid', '/tmp/php-fpm.pid' )
        );
        foreach ( $pid_files as $pf ) {
            if ( ! file_exists( $pf ) ) {
				continue; }
            $pid = (int) trim( (string) file_get_contents( $pf ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading local OS PID file
            if ( $pid > 0 && function_exists( 'posix_kill' ) ) {
                $reloaded = posix_kill( $pid, SIGUSR2 );
                break;
            }
        }

        if ( $reloaded ) {
            wp_send_json_success(
                array(
					'reloaded' => true,
					'message'  => 'PHP-FPM reloaded, if APCu was already installed it is now active.',
					'status'   => self::get_object_cache_status(),
                )
            );
        }

        wp_send_json_error(
            array(
				'message'    => 'Run this command in your terminal to install APCu: ' . $manual_cmd,
				'manual_cmd' => $manual_cmd,
            )
        );
    }

    /*
    ==================================================================
        7. MIGRATION TOOL

        ================================================================== */

    /*
    ==================================================================
        7a. Migration: Block conversion logic
        ================================================================== */

    /**
     * Returns the regex pattern that matches legacy wp:code blocks.
     *
     * @since  1.5.0
     * @return string PCRE pattern string.
     */
    private static function get_code_pattern() {
        return '#<!-- wp:(code-syntax-block/code|code)\s*(\{[^}]*\})?\s*-->\s*'
            . '<pre[^>]*class="[^"]*wp-block-code[^"]*"[^>]*>\s*'
            . '<code([^>]*)>(.*?)</code>\s*'
            . '</pre>\s*'
            . '<!-- /wp:\1\s*-->#s';
    }

    /**
     * Returns the regex pattern that matches legacy wp:preformatted blocks.
     *
     * @since  1.5.0
     * @return string PCRE pattern string.
     */
    private static function get_preformatted_pattern() {
        return '#<!-- wp:preformatted\s*(\{[^}]*\})?\s*-->\s*'
            . '<pre[^>]*class="[^"]*wp-block-preformatted[^"]*"[^>]*>(.*?)</pre>\s*'
            . '<!-- /wp:preformatted\s*-->#s';
    }

    /**
     * Converts a matched legacy wp:code block into a CloudScale block comment.
     *
     * @since  1.5.0
     * @param  array $matches preg_replace_callback match array.
     * @return string New block comment markup.
     */
    private static function convert_code_block( $matches ) {
        $block_json   = $matches[2] ?? '';
        $code_attrs   = $matches[3] ?? '';
        $code_content = $matches[4] ?? '';

        $code = html_entity_decode( $code_content, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
        $code = rtrim( $code, "\n" );

        $lang = '';

        if ( ! empty( $block_json ) ) {
            $json = json_decode( $block_json, true );
            if ( isset( $json['language'] ) ) {
                $lang = $json['language'];
            }
        }

        if ( empty( $lang ) && preg_match( '/lang=["\']([^"\']+)["\']/', $code_attrs, $lm ) ) {
            $lang = $lm[1];
        }

        if ( empty( $lang ) && preg_match( '/class=["\'][^"\']*language-([a-zA-Z0-9+#._-]+)/', $code_attrs, $lm ) ) {
            $lang = $lm[1];
        }

        return self::build_migrate_block( $code, $lang );
    }

    /**
     * Converts a matched legacy wp:preformatted block into a CloudScale block comment.
     *
     * @since  1.5.0
     * @param  array $matches preg_replace_callback match array.
     * @return string New block comment markup.
     */
    private static function convert_preformatted_block( $matches ) {
        $code_content = $matches[2] ?? '';

        $code = str_ireplace( array( '<br>', '<br/>', '<br />' ), "\n", $code_content );
        $code = wp_strip_all_tags( $code );
        $code = html_entity_decode( $code, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
        $code = rtrim( $code, "\n" );

        return self::build_migrate_block( $code, '' );
    }

    /**
     * Builds a CloudScale block comment from code content and an optional language slug.
     *
     * @since  1.5.0
     * @param  string $code Code content.
     * @param  string $lang Language identifier, or empty string for auto-detect.
     * @return string Block comment markup.
     */
    private static function build_migrate_block( $code, $lang ) {
        $attrs = array( 'content' => $code );
        if ( ! empty( $lang ) ) {
            $attrs['language'] = $lang;
        }

        $attrs_json = wp_json_encode( $attrs );

        return '<!-- wp:cloudscale/code-block ' . $attrs_json . ' /-->';
    }

    /**
     * Counts the total number of legacy code blocks in post content.
     *
     * @since  1.5.0
     * @param  string $content Post content.
     * @return int Number of legacy code blocks found.
     */
    private static function count_migrate_blocks( $content ) {
        $count  = preg_match_all( self::get_code_pattern(), $content, $m );
        $count += preg_match_all( self::get_preformatted_pattern(), $content, $m );
        return $count;
    }

    /**
     * Converts all legacy code and preformatted blocks in post content to CloudScale blocks.
     *
     * @since  1.5.0
     * @param  string $content Post content.
     * @return string Post content with legacy blocks replaced.
     */
    private static function convert_content( $content ) {
        $content = preg_replace_callback( self::get_code_pattern(), array( __CLASS__, 'convert_code_block' ), $content );
        $content = preg_replace_callback( self::get_preformatted_pattern(), array( __CLASS__, 'convert_preformatted_block' ), $content );
        return $content;
    }

    /**
     * Truncates a string to a maximum byte length, appending an ellipsis when cut.
     *
     * @since  1.5.0
     * @param  string $str String to truncate.
     * @param  int    $max Maximum byte length.
     * @return string Truncated string.
     */
    private static function truncate_block( $str, $max ) {
        if ( strlen( $str ) <= $max ) {
            return $str;
        }
        return substr( $str, 0, $max ) . "\n... [truncated]";
    }

    /**
     * Builds a before/after preview array for all legacy blocks in post content.
     *
     * @since  1.5.0
     * @param  string $content Post content.
     * @return array<int, array<string, mixed>> Preview data for each block found.
     */
    private static function get_migration_preview( $content ) {
        $blocks = array();

        preg_match_all( self::get_code_pattern(), $content, $matches, PREG_SET_ORDER );
        foreach ( $matches as $match ) {
            $original  = $match[0];
            $converted = self::convert_code_block( $match );

            $lang = '';
            if ( preg_match( '/"language":"([^"]+)"/', $converted, $lm ) ) {
                $lang = $lm[1];
            }

            $code_preview = html_entity_decode( $match[4], ENT_QUOTES | ENT_HTML5, 'UTF-8' );
            $first_line   = strtok( $code_preview, "\n" );
            if ( strlen( $first_line ) > 80 ) {
                $first_line = substr( $first_line, 0, 80 ) . '...';
            }

            $blocks[] = array(
                'index'      => count( $blocks ) + 1,
                'type'       => 'wp:code',
                'language'   => $lang ?: '(auto detect)',
                'first_line' => $first_line,
                'original'   => htmlspecialchars( self::truncate_block( $original, 500 ) ),
                'converted'  => htmlspecialchars( self::truncate_block( $converted, 500 ) ),
            );
        }

        preg_match_all( self::get_preformatted_pattern(), $content, $matches, PREG_SET_ORDER );
        foreach ( $matches as $match ) {
            $original  = $match[0];
            $converted = self::convert_preformatted_block( $match );

            $code_raw   = str_ireplace( array( '<br>', '<br/>', '<br />' ), "\n", $match[2] );
            $code_raw   = wp_strip_all_tags( $code_raw );
            $code_raw   = html_entity_decode( $code_raw, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
            $first_line = strtok( $code_raw, "\n" );
            if ( strlen( $first_line ) > 80 ) {
                $first_line = substr( $first_line, 0, 80 ) . '...';
            }

            $blocks[] = array(
                'index'      => count( $blocks ) + 1,
                'type'       => 'wp:preformatted',
                'language'   => '(auto detect)',
                'first_line' => $first_line,
                'original'   => htmlspecialchars( self::truncate_block( $original, 500 ) ),
                'converted'  => htmlspecialchars( self::truncate_block( $converted, 500 ) ),
            );
        }

        return $blocks;
    }

    /*
    ==================================================================
        7b. Migration: AJAX handlers
        ================================================================== */

    /**
     * AJAX handler: scans all posts for legacy code blocks and returns a list.
     *
     * @since  1.5.0
     * @return void Sends JSON response and exits.
     */
    public static function ajax_scan() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( 'Forbidden', 403 );
        }

        if ( ! check_ajax_referer( CloudScale_DevTools::MIGRATE_NONCE, 'nonce', false ) ) {
            wp_send_json_error( 'Bad nonce', 403 );
        }

        global $wpdb;

        // Static query, no user data; $wpdb->posts is a trusted WP core property.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
        $posts = $wpdb->get_results(
            "SELECT ID, post_title, post_status, post_date, post_content
             FROM {$wpdb->posts}
             WHERE post_type IN ('post', 'page')
               AND post_status != 'trash'
               AND (
                   post_content LIKE '%<!-- wp:code %'
                OR post_content LIKE '%<!-- wp:code-->%'
                OR post_content LIKE '%<!-- wp:code-syntax-block/code%'
                OR post_content LIKE '%<!-- wp:preformatted%'
               )
             ORDER BY post_date DESC"
        );

        if ( $posts === null ) {
            wp_send_json_error( 'Database error: ' . ( $wpdb->last_error ?: 'could not query posts' ) );
        }

        $results = array();
        foreach ( $posts as $post ) {
            $count = self::count_migrate_blocks( $post->post_content );
            if ( $count > 0 ) {
                $results[] = array(
                    'id'          => (int) $post->ID,
                    'title'       => $post->post_title,
                    'status'      => $post->post_status,
                    'date'        => wp_date( 'd M Y', strtotime( $post->post_date ) ),
                    'block_count' => $count,
                    'edit_url'    => get_edit_post_link( $post->ID, 'raw' ),
                    'view_url'    => get_permalink( $post->ID ),
                );
            }
        }

        wp_send_json_success(
            array(
				'posts'        => $results,
				'total_posts'  => count( $results ),
				'total_blocks' => array_sum( array_column( $results, 'block_count' ) ),
            )
        );
    }

    /**
     * AJAX handler: returns a before/after preview of the migration for a single post.
     *
     * @since  1.5.0
     * @return void Sends JSON response and exits.
     */
    public static function ajax_preview() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( 'Forbidden', 403 );
        }

        if ( ! check_ajax_referer( CloudScale_DevTools::MIGRATE_NONCE, 'nonce', false ) ) {
            wp_send_json_error( 'Bad nonce', 403 );
        }

        $post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) );
        $post    = get_post( $post_id );

        if ( ! $post ) {
            wp_send_json_error( 'Post not found.' );
        }

        $blocks = self::get_migration_preview( $post->post_content );

        wp_send_json_success(
            array(
				'post_id'     => $post_id,
				'title'       => $post->post_title,
				'block_count' => count( $blocks ),
				'blocks'      => $blocks,
            )
        );
    }

    /**
     * AJAX handler: migrates all legacy code blocks in a single post.
     *
     * @since  1.5.0
     * @return void Sends JSON response and exits.
     */
    public static function ajax_migrate_single() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( 'Forbidden', 403 );
        }

        if ( ! check_ajax_referer( CloudScale_DevTools::MIGRATE_NONCE, 'nonce', false ) ) {
            wp_send_json_error( 'Bad nonce', 403 );
        }

        $post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) );
        $post    = get_post( $post_id );

        if ( ! $post ) {
            wp_send_json_error( 'Post not found.' );
        }

        $count       = self::count_migrate_blocks( $post->post_content );
        $new_content = self::convert_content( $post->post_content );

        if ( $new_content === $post->post_content ) {
            wp_send_json_error( 'No legacy code blocks found in this post.' );
        }

        global $wpdb;
        $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- code migration custom table
            $wpdb->posts,
            array( 'post_content' => $new_content ),
            array( 'ID' => $post_id ),
            array( '%s' ),
            array( '%d' )
        );
        clean_post_cache( $post_id );

        wp_send_json_success(
            array(
				'post_id'         => $post_id,
				'blocks_migrated' => $count,
				'message'         => 'Migrated ' . $count . ' block(s) in "' . esc_html( $post->post_title ) . '".',
            )
        );
    }

    /**
     * AJAX handler: migrates all legacy code blocks across all matching posts.
     *
     * @since  1.5.0
     * @return void Sends JSON response and exits.
     */
    public static function ajax_migrate_all() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( 'Forbidden', 403 );
        }

        if ( ! check_ajax_referer( CloudScale_DevTools::MIGRATE_NONCE, 'nonce', false ) ) {
            wp_send_json_error( 'Bad nonce', 403 );
        }

        global $wpdb;

        // Static query, no user data; $wpdb->posts is a trusted WP core property.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
        $posts = $wpdb->get_results(
            "SELECT ID, post_title, post_content
             FROM {$wpdb->posts}
             WHERE post_type IN ('post', 'page')
               AND post_status != 'trash'
               AND (
                   post_content LIKE '%<!-- wp:code %'
                OR post_content LIKE '%<!-- wp:code-->%'
                OR post_content LIKE '%<!-- wp:code-syntax-block/code%'
                OR post_content LIKE '%<!-- wp:preformatted%'
               )
             ORDER BY ID ASC"
        );

        $migrated_posts  = 0;
        $migrated_blocks = 0;
        $details         = array();

        foreach ( $posts as $post ) {
            $count = self::count_migrate_blocks( $post->post_content );
            if ( $count === 0 ) {
                continue;
            }

            $new_content = self::convert_content( $post->post_content );

            if ( $new_content !== $post->post_content ) {
                $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- code migration custom table
                    $wpdb->posts,
                    array( 'post_content' => $new_content ),
                    array( 'ID' => $post->ID ),
                    array( '%s' ),
                    array( '%d' )
                );
                clean_post_cache( $post->ID );

                ++$migrated_posts;
                $migrated_blocks += $count;
                $details[]        = '#' . $post->ID . ': ' . esc_html( $post->post_title ) . ' (' . $count . ' blocks)';
            }
        }

        wp_send_json_success(
            array(
				'migrated_posts'  => $migrated_posts,
				'migrated_blocks' => $migrated_blocks,
				'details'         => $details,
            )
        );
    }

    /**
     * Walks parsed blocks recursively, clearing the `theme` attribute on every
     * cloudscale/code-block. Other attributes are left untouched.
     *
     * @param array $blocks  Parsed blocks (by reference for mutation).
     * @param int   $cleared Counter incremented per block where theme was reset.
     * @return void
     */
    private static function clear_theme_on_cs_blocks( array &$blocks, int &$cleared ): void {
        foreach ( $blocks as &$b ) {
            if ( ! is_array( $b ) ) {
				continue; }
            if ( ( $b['blockName'] ?? '' ) === 'cloudscale/code-block' ) {
                $attrs = $b['attrs'] ?? array();
                if ( ! empty( $attrs['theme'] ) ) {
                    $attrs['theme'] = '';
                    $b['attrs']     = $attrs;
                    ++$cleared;
                }
            }
            if ( ! empty( $b['innerBlocks'] ) && is_array( $b['innerBlocks'] ) ) {
                self::clear_theme_on_cs_blocks( $b['innerBlocks'], $cleared );
            }
        }
        unset( $b );
    }

    /**
     * AJAX handler: clears the per-block `theme` override on every
     * cloudscale/code-block across all posts so they inherit the site-wide
     * default. Other attributes (language, title, GitHub source, etc.) are
     * never touched.
     *
     * @since  1.9.0
     * @return void Sends JSON response and exits.
     */
    public static function ajax_reset_theme_all(): void {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( 'Forbidden', 403 );
        }
        if ( ! check_ajax_referer( CloudScale_DevTools::MIGRATE_NONCE, 'nonce', false ) ) {
            wp_send_json_error( 'Bad nonce', 403 );
        }

        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
        $posts = $wpdb->get_results(
            "SELECT ID, post_title, post_content
             FROM {$wpdb->posts}
             WHERE post_type NOT IN ('revision', 'auto-draft')
               AND post_status NOT IN ('trash', 'auto-draft')
               AND post_content LIKE '%<!-- wp:cloudscale/code-block%'
             ORDER BY ID ASC"
        );

        $posts_scanned = 0;
        $posts_changed = 0;
        $blocks_reset  = 0;
        $details       = array();

        foreach ( $posts as $post ) {
            ++$posts_scanned;
            $blocks   = parse_blocks( $post->post_content );
            $per_post = 0;
            self::clear_theme_on_cs_blocks( $blocks, $per_post );
            if ( $per_post === 0 ) {
				continue; }

            $new_content = serialize_blocks( $blocks );
            if ( $new_content === $post->post_content ) {
				continue; }

            // $wpdb->update writes the post_content verbatim (no wp_unslash dance), so
            // the JSON-escaped attrs from serialize_blocks survive intact.
            $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- code migration custom table
                $wpdb->posts,
                array( 'post_content' => $new_content ),
                array( 'ID' => $post->ID ),
                array( '%s' ),
                array( '%d' )
            );
            clean_post_cache( $post->ID );

            ++$posts_changed;
            $blocks_reset += $per_post;
            $details[]     = '#' . $post->ID . ': ' . esc_html( $post->post_title ) . ' (' . $per_post . ' blocks)';
        }

        wp_send_json_success(
            array(
				'posts_scanned' => $posts_scanned,
				'posts_changed' => $posts_changed,
				'blocks_reset'  => $blocks_reset,
				'details'       => $details,
            )
        );
    }
}
