<?php

namespace GOS\GravityOps\Core\SuiteCore\Admin;

use Automatic_Upgrader_Skin;
use GFCommon;
use GOS\GravityOps\Core\SuiteCore\SuiteCore;
use Plugin_Upgrader;

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

/**
 * Class SuiteMenu
 *
 * Provides functionalities to manage a custom GravityOps menu in the WordPress admin area.
 * Includes methods to ensure the menu exists, render its content, and retrieve its icon.
 */
class SuiteMenu {
    /**
     * SuiteCore instance.
     *
     * @var SuiteCore
     */
    private SuiteCore $core;

    /**
     * A boolean indicating whether the assets have been enqueued.
     *
     * @var bool
     */
    private bool $did_enqueue_hook = false;
    /**
     * A boolean indicating whether the admin notices have been registered.
     *
     * @var bool
     */
    private bool $did_notice_hook = false;
    /**
     * A boolean indicating whether the AJAX handlers have been registered.
     *
     * @var bool
     */
    private bool $did_ajax_hook = false;

    /**
     * Track whether the parent GravityOps menu has already been registered.
     * Prevents duplicate add_menu_page() calls and ensures a single owner of the parent page.
     *
     * @var bool
     */
    private bool $did_parent_menu = false;

    /**
     * The resolved capability used for the GravityOps parent menu.
     * Child pages should inherit this automatically.
     *
     * @var string
     */
    private string $parent_capability = '';

    /**
     * Cached plugin state by file.
     *
     * @var array<string,array{0:bool,1:bool,2:string,3:bool,4:string}>
     */
    private array $state_cache = [];

    /**
     * Constructor for initializing the class with required dependencies.
     *
     * This method sets up the necessary dependency for the object by injecting the SuiteCore instance.
     *
     * @param SuiteCore $core The core instance of the suite used to initialize the class.
     *
     * @return void
     */
    public function __construct( SuiteCore $core ) {
        $this->core = $core;
        $this->register_ajax_handlers();
    }

    /**
     * Build a single tile's HTML markup.
     *
     * @param array $item Catalog item with state applied.
     * @return string
     */
    private function build_tile_html( array $item ): string {
        ob_start();
        $this->render_tile( $item );
        return (string) ob_get_clean();
    }

    /**
     * Render a single tile's HTML markup.
     *
     * @param array $item Catalog item with state applied.
     * @return void
     */
    private function render_tile( array $item ): void {
        $slug         = (string) ( $item['slug'] ?? '' );
        $is_active    = ! empty( $item['is_active'] );
        $is_installed = ! empty( $item['is_installed'] );
        $has_update   = ! empty( $item['has_update'] );
        $version      = ! empty( $item['version'] ) ? $item['version'] : '';

        // Determine tile URL/target by state.
        $tile_target    = '';
        $has_shell_page = false;
        if ( ! $is_installed ) {
            $tile_url    = ! empty( $item['marketing_url'] ) ? $item['marketing_url'] . '#pricing' : '#';
            $tile_target = '_blank';
        } elseif ( $is_active ) {
            $has_shell_page = $this->core->shell()->has_page( $slug );
            $tile_url       = $has_shell_page ? admin_url( 'admin.php?page=' . $slug . '&src=gops-dashboard' ) : '#';
        } else {
            $tile_url = '#';
        }

        $tile_url      = esc_url( $tile_url );
        $state         = $is_active ? 'active' : ( $is_installed ? 'inactive' : 'not-installed' );
        $needs_upgrade = ( $is_active && empty( $has_shell_page ) );
        $icon_url      = $this->resolve_plugin_icon_url( $item );
        $plugin_name   = $item['name'] ?? $slug;
        $has_package   = ! empty( $item['has_package'] );

        $update_file    = ! empty( $item['update_file'] ) ? $item['update_file'] : ( $item['plugin_file_action'] ?? '' );
        $toggle_action  = $is_active ? 'deactivate' : 'activate';
        $toggle_file    = $is_active
            ? ( $item['plugin_file_active'] ?: ( $item['plugin_file_action'] ?? '' ) )
            : ( $item['plugin_file_action'] ?? '' );
        $changelog_file = $item['plugin_file_active'] ?: ( $item['plugin_file_action'] ?? '' );
        ?>
        <div class="gops-tile" data-gops-tile="<?php echo esc_attr( $slug ); ?>" data-gops-state="<?php echo esc_attr( $state ); ?>" data-gops-marketing-url="<?php echo esc_attr( $item['marketing_url'] ?? '' ); ?>" data-gops-needs-upgrade="<?php echo $needs_upgrade ? '1' : '0'; ?>" data-gops-plugin-name="<?php echo esc_attr( $plugin_name ); ?>">
            <div class="gops-tile__head">
                <?php if ( $icon_url ) : ?>
                    <img class="gops-tile__iconimg" width="48" height="48" src="<?php echo esc_url( $icon_url ); ?>" alt=""/>
                <?php else : ?>
                    <div class="gops-tile__icon" aria-hidden="true"><?php echo isset( $item['icon_html'] ) ? wp_kses_post( $item['icon_html'] ) : '🔧'; ?></div>
                <?php endif; ?>
                <h3 class="gops-tile__title"><a class="gops-tile__link" <?php echo $tile_target ? 'target="' . esc_attr( $tile_target ) . '" rel="noopener"' : ''; ?> href="<?php echo esc_url( $tile_url ); ?>"><?php echo esc_html( $plugin_name ); ?></a></h3>
            </div>
            <p class="gops-tile__desc"><?php echo esc_html( $item['description'] ); ?></p>
            <?php echo wp_kses_post( $this->core->shell()->maybe_render_dashboard_coupon( '', $item ) ); ?>
            <div class="gops-tile__meta">
                <?php if ( ! empty( $item['uses_free'] ) ) : ?>
                    <span class="gops-badge">Free</span>
                <?php endif; ?>
                <?php if ( $has_update ) : ?>
                    <span class="gops-badge gops-badge--warn">Update available<?php echo ! empty( $item['new_version'] ) ? ': v' . esc_html( $item['new_version'] ) : ''; ?></span>
                <?php endif; ?>
            </div>

            <div class="gops-tile__actions">
                <div class="gops-tile__action-group">
                    <?php if ( $version ) : ?>
                        <span class="gops-tile__version">v<?php echo esc_html( $version ); ?></span>
                    <?php endif; ?>
                    <span class="gops-badge<?php echo $is_active ? ' gops-badge--ok' : ' gops-badge--warn'; ?>">
                        <?php echo $is_active ? 'Active' : ( $is_installed ? 'Inactive' : 'Not Installed' ); ?>
                    </span>

                    <?php if ( $is_installed ) : ?>
                        <?php if ( $has_update && current_user_can( 'update_plugins' ) && ! empty( $update_file ) ) : ?>
                            <?php if ( $has_package ) : ?>
                                <button class="button button-primary gops-action-button" type="button" data-gops-action="update" data-gops-plugin-file="<?php echo esc_attr( $update_file ); ?>" data-gops-slug="<?php echo esc_attr( $slug ); ?>">Update Now</button>
                            <?php elseif ( ! $is_active && ! empty( $item['premium_file'] ) && $item['premium_file'] === $update_file ) : ?>
                                <span class="gops-update-notice">Activate to update</span>
                            <?php endif; ?>
                            <button class="gops-link gops-changelog-link" type="button" data-gops-action="changelog" data-gops-plugin-file="<?php echo esc_attr( $changelog_file ); ?>" data-gops-slug="<?php echo esc_attr( $slug ); ?>">See what's new</button>
                        <?php endif; ?>

                        <?php if ( current_user_can( 'activate_plugins' ) && ! empty( $toggle_file ) ) : ?>
                            <button class="button<?php echo $is_active ? '' : ' button-primary'; ?> gops-action-button" type="button" data-gops-action="<?php echo esc_attr( $toggle_action ); ?>" data-gops-plugin-file="<?php echo esc_attr( $toggle_file ); ?>" data-gops-slug="<?php echo esc_attr( $slug ); ?>"><?php echo $is_active ? 'Deactivate' : 'Activate'; ?></button>
                        <?php endif; ?>
                    <?php else : ?>
                        <?php if ( ! empty( $item['is_free'] ) ) : ?>
                            <a class="button button-primary" target="_blank" rel="noopener" href="<?php echo esc_url( $item['marketing_url'] ); ?>">Download Now</a>
                        <?php else : ?>
                            <a class="button button-primary" target="_blank" rel="noopener" href="<?php echo esc_url( ( $item['marketing_url'] ?? '' ) . '#pricing' ); ?>">Buy</a>
                        <?php endif; ?>
                    <?php endif; ?>
                </div>

                <a class="gops-link gops-tile__learn-more" target="_blank" rel="noopener" href="<?php echo esc_url( $item['marketing_url'] ); ?>">Learn More →</a>
            </div>
        </div>
        <?php
    }

	/**
	 * Ensures the top-level GravityOps menu exists in the WordPress admin.
	 *
	 * This method checks if the 'gravity_ops' menu already exists. If not, it calculates
	 * the correct position (avoiding conflicts) and registers the menu page.
	 *
	 * Capability selection mirrors Gravity Forms core and add-ons behavior:
	 * - If the current user has `gform_full_access`, use that.
	 * - Otherwise, use the minimal GF capability returned by GFCommon::current_user_can_which( GFCommon::all_caps() ).
	 * - Fallbacks to `manage_options` if GF is unavailable.
	 *
	 * @param string $capability Optional explicit capability to use. If empty, the method will resolve one dynamically.
	 *
	 * @return void
	 */
	public function ensure_parent_menu( $capability = '' ) {
        global $menu;

        $menu_slug = 'gravity_ops';

        // Internal idempotency guard — if we've already added the parent once this request, bail.
        if ( $this->did_parent_menu ) {
            return;
		}

        // If another plugin in the suite has already created the menu, we don't need to do anything.
        if ( ! empty( $menu ) && in_array( $menu_slug, array_column( $menu, 2 ), true ) ) {
            return;
		}

        // Resolve capability if not provided by caller.
        if ( empty( $capability ) ) {
            $has_full_access = function_exists( 'current_user_can' ) && current_user_can( 'gform_full_access' );

            // Default minimal capability if GF isn't available.
            $min_cap = 'manage_options';

            // Resolve minimal GF cap when GFCommon is available.
            if ( class_exists( '\\GFCommon' ) ) {
                // Methods exist in supported GF versions.
                if ( method_exists( '\\GFCommon', 'current_user_can_which' ) && method_exists( '\\GFCommon', 'all_caps' ) ) {
                    $maybe_cap = GFCommon::current_user_can_which( GFCommon::all_caps() );
                    if ( ! empty( $maybe_cap ) ) {
                        $min_cap = $maybe_cap;
                    } else {
                        $min_cap = 'gform_full_access';
                    }
                } else {
                    $min_cap = 'gform_full_access';
                }
            }

            $capability = $has_full_access ? 'gform_full_access' : $min_cap;
		}

        /**
         * Filter the capability required to view the GravityOps parent menu.
         *
         * @param string $capability Resolved capability slug.
         */
        // Store for child pages to inherit automatically.
        $this->parent_capability = $capability;

        // Find a free position in the menu, starting at 16.10
        $number        = 10;
        $menu_position = '16.' . $number;
        while ( isset( $menu[ $menu_position ] ) ) {
            $number       += 10;
            $menu_position = '16.' . $number;
		}

        /**
         * Filter the menu position for the GravityOps parent menu.
         *
         * @param string $menu_position The calculated (string) menu position.
         */
        // Capture the hook suffix so we can process POST before any output using load-$hook.
        $hook = add_menu_page(
            'GravityOps',
            'GravityOps',
            $capability,
            $menu_slug,
            [ $this, 'render_dashboard_page' ],
            $this->get_icon(),
            $menu_position
        );

        // Handle activate/deactivate POST early (pre-output) to allow safe redirects.
        if ( $hook ) {
            add_action( 'load-' . $hook, [ $this, 'maybe_handle_plugin_toggle' ] );
		}

        // Mark parent as registered to avoid duplicate add_menu_page calls in mixed environments.
        $this->did_parent_menu = true;

        // Ensure assets are enqueued at the proper time and only on our screen.
        if ( ! $this->did_enqueue_hook ) {
            add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
            $this->did_enqueue_hook = true;
		}

        // Register admin notices renderer (error/success messages from actions below).
        if ( ! $this->did_notice_hook ) {
            add_action( 'admin_notices', [ $this, 'render_admin_notices' ] );
            $this->did_notice_hook = true;
		}
    }

    /**
     * Returns the capability used by the GravityOps parent menu.
     * If not yet determined, returns a safe default of 'manage_options'.
     *
     * @return string
     */
    public function get_parent_capability() {
        return ! empty( $this->parent_capability ) ? $this->parent_capability : 'manage_options';
    }

	/**
	 * Renders the content for the main GravityOps dashboard page.
	 * This is the list of other plugins in the suite.
	 *
	 * @return void
	 */
	public function render_dashboard_page() {
        // Build tiles from the catalog.
        $items = $this->core->catalog()->all();

        // Gather WP plugin data.
        if ( ! function_exists( 'get_plugins' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

        $installed_plugins = get_plugins();

        // Use cached update data for the initial load to speed up the page.
        // A background AJAX refresh will follow.
        $update_transient = get_site_transient( 'update_plugins' );
        $update_response  = ( is_object( $update_transient ) && isset( $update_transient->response ) && is_array( $update_transient->response ) ) ? $update_transient->response : [];

        // Annotate catalog items with runtime state (supports premium/free variant pairs).
        [ $items, $updates_count, $updatable_count ] = $this->hydrate_items_with_state( $items, $installed_plugins, $update_response );

        // Filtering.
        $filter   = isset( $_GET['gops_filter'] ) ? sanitize_key( wp_unslash( $_GET['gops_filter'] ) ) : 'all'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $filtered = array_values(
            array_filter(
                $items,
                function ( $item ) use ( $filter ) {
                    switch ( $filter ) {
                        case 'updates':
                            return ! empty( $item['has_update'] );
                        case 'active':
                            return ! empty( $item['is_active'] );
                        case 'installed':
                            // Show installed but NOT active only (per spec)
                            return ! empty( $item['is_installed'] ) && empty( $item['is_active'] );
                        case 'not-installed':
                            return empty( $item['is_installed'] );
                        case 'all':
                        default:
                            return true;
                    }
                }
            )
        );

        $base_url = menu_page_url( 'gravity_ops', false );
        $filters  = [
            'all'           => 'All',
            'updates'       => 'Updates',
            'active'        => 'Activated',
            'installed'     => 'Installed',
            'not-installed' => 'Not Installed',
        ];

        ?>
        <div class="wrap gops-admin" data-gops-refresh="1" data-gops-nonce="<?php echo esc_attr( wp_create_nonce( 'gops_admin' ) ); ?>" data-gops-ajax="<?php echo esc_attr( admin_url( 'admin-ajax.php' ) ); ?>">
            <div class="gops-header-wrapper">
                <img class="gops-header-icon" src="<?php echo esc_url( $this->get_icon() ); ?>" alt="" width="80" height="80" />
                <header class="gops-header">
                    <div class="gops-header__left">
                        <a class="gops-logo-chip" href="<?php echo esc_url( admin_url( 'admin.php?page=gravity_ops' ) ); ?>">GravityOps</a>
                        <div class="gops-headings">
                            <h1 class="gops-title">Run Your Business on WordPress</h1>
                            <p class="gops-subtitle">All your GravityOps tools in one place.</p>
                        </div>
                    </div>
                    <div class="gops-header__right">
                        <a class="button button-primary" target="_blank" rel="noopener" href="<?php echo esc_url( $this->core->config()->get_join_community_url() ); ?>">Join Community</a>
                    </div>
                </header>
                <div class="gops-header-loader" style="display:none;"><span class="gops-spinner"></span> Checking for updates...</div>
            </div>
            <div class="gops-notices" aria-live="polite"></div>

            <nav class="gops-tabs">
                <?php
                foreach ( $filters as $id => $label ) :
                    if ( 'updates' === $id && 0 === $updates_count ) {
                        continue; // Hidden when no updates are available.
                    }
                    $href  = add_query_arg( 'gops_filter', $id, $base_url );
                    $class = ( $filter === $id ) ? 'gops-tab is-active' : 'gops-tab';
                    ?>
                    <a class="<?php echo esc_attr( $class ); ?>" data-gops-tab="<?php echo esc_attr( $id ); ?>" href="<?php echo esc_url( $href ); ?>">
                        <?php echo esc_html( $label ); ?>
                        <?php if ( 'updates' === $id && $updates_count > 0 ) : ?>
                            <span class="gops-tab__badge" data-gops-updates-count="<?php echo absint( $updates_count ); ?>"><?php echo absint( $updates_count ); ?></span>
                        <?php endif; ?>
                    </a>
                <?php endforeach; ?>
            </nav>

            <?php if ( 'all' === $filter && current_user_can( 'update_plugins' ) ) : ?>
                <div class="gops-actions"<?php echo $updatable_count > 0 ? '' : ' style="display:none;"'; ?>>
                    <button class="button button-primary gops-action-button gops-update-all" type="button" data-gops-action="update-all">
                        <?php echo esc_html( 'Update All (' . absint( $updatable_count ) . ')' ); ?>
                    </button>
                </div>
            <?php endif; ?>

            <?php if ( empty( $filtered ) ) : ?>
                <section class="gops-content">
                    <div class="gops-card">
                        <?php if ( in_array( $filter, [ 'installed', 'not-installed' ], true ) ) : ?>
                            <p>No plugins match this filter. Try switching to <a class="gops-link" href="<?php echo esc_url( add_query_arg( 'gops_filter', 'all', $base_url ) ); ?>">All</a>.</p>
                        <?php else : ?>
                            <p>No plugins to display.</p>
                        <?php endif; ?>
                    </div>
                </section>
            <?php endif; ?>

            <div class="gops-grid">
                    <?php foreach ( $filtered as $item ) : ?>
                        <?php echo $this->build_tile_html( $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
                    <?php endforeach; ?>
            </div>
            <div class="gops-modal" id="gops-changelog-modal" aria-hidden="true" style="display:none;">
                <div class="gops-modal__backdrop" data-gops-modal-close></div>
                <div class="gops-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="gops-changelog-title">
                    <div class="gops-modal__header">
                        <h2 id="gops-changelog-title" class="gops-modal__title">What's new</h2>
                        <button type="button" class="gops-modal__close" data-gops-modal-close aria-label="Close">×</button>
                    </div>
                    <div class="gops-modal__body">
                        <div class="gops-modal__content"></div>
                    </div>
                    <div class="gops-modal__footer">
                        <a class="gops-link gops-modal__full-link" target="_blank" rel="noopener" href="#">Full changelog -&gt;</a>
                    </div>
                </div>
            </div>

        </div>
                <?php
    }

    /**
     * Helper to get a plugin's icon URL by its slug.
     *
     * @param string $slug Plugin slug as defined in the suite registry.
     * @return string Icon URL or empty string.
     */
    public function get_plugin_icon_url( $slug ) {
        $item = $this->core->catalog()->find( (string) $slug );
        return $item ? $this->resolve_plugin_icon_url( $item ) : '';
    }

    /**
     * Resolve a plugin icon URL from common asset locations, with filter override.
     *
     * @param array $item Catalog item containing at least 'slug' and file path hints.
     * @return string Icon URL or empty string if none found.
     */
    public function resolve_plugin_icon_url( $item ) {
        if ( ! empty( $item['icon_url'] ) ) {
            return (string) $item['icon_url'];
        }

        if ( ! empty( $item['icon_filename'] ) ) {
            $base = $this->core->assets_base_url();
            if ( $base ) {
                return trailingslashit( $base ) . 'images/' . $item['icon_filename'];
            }
        }

        $files_to_check = [];
        if ( ! empty( $item['premium_file'] ) ) {
            $files_to_check[] = (string) $item['premium_file'];
        }
        if ( ! empty( $item['free_file'] ) ) {
            $files_to_check[] = (string) $item['free_file'];
        }

        $files_to_check = array_unique( $files_to_check );

        foreach ( $files_to_check as $plugin_rel_file ) {
            $plugin_main = WP_PLUGIN_DIR . '/' . $plugin_rel_file;
            if ( ! file_exists( $plugin_main ) ) {
                continue;
            }

            // Base plugin URL for building asset URLs.
            $base_url = plugins_url( '', $plugin_main );
            $base_dir = dirname( $plugin_main );

            $candidates = [
                'assets/icon.svg',
                'assets/icon.png',
                'assets/images/icon.svg',
                'assets/images/icon.png',
                'includes/images/icon.svg',
                'includes/images/icon.png',
                'assets/img/icon.svg',
                'assets/img/icon.png',
            ];
            foreach ( $candidates as $rel ) {
                $path = $base_dir . '/' . $rel;
                if ( file_exists( $path ) ) {
                    return $base_url . '/' . $rel;
                }
            }

            // Wildcard: first match like assets/img/*-icon.svg|png
            $wild_dirs = [ 'assets/img', 'assets/images' ];
            foreach ( $wild_dirs as $wd ) {
                $dir = $base_dir . '/' . $wd;
                if ( is_dir( $dir ) ) {
                    $matches = array_merge( glob( $dir . '/*-icon.svg' ) ?: [], glob( $dir . '/*-icon.png' ) ?: [] );
                    if ( ! empty( $matches ) ) {
                        // Return the first match
                        $first = $matches[0];
                        return $base_url . '/' . $wd . '/' . basename( $first );
                    }
                }
            }
        }

        return '';
    }

    /**
     * Hydrate catalog items with runtime state and count updates.
     *
     * @param array $items Catalog items.
     * @param array $installed_plugins Installed plugins data.
     * @param array $update_response Update transient response data.
     * @param array $snippet_results Optional latest version data from snippet.
     *
     * @return array{0:array,1:int}
     */
    private function hydrate_items_with_state( array $items, array $installed_plugins, array $update_response, array $snippet_results = [] ): array {
        $updates_count   = 0;
        $updatable_count = 0;
        foreach ( $items as $key => $item ) {
            $items[ $key ] = $this->apply_plugin_state( $item, $installed_plugins, $update_response, $updates_count, $updatable_count, $snippet_results );
        }

        return [ $items, $updates_count, $updatable_count ];
    }

    /**
     * Refresh update data for active plugins (Freemius or WP.org) before reading the transient.
     *
     * @param array $items Catalog items.
     *
     * @return array Update response array from the update transient.
     */
    private function refresh_updates_for_active_plugins( array $items ): array {
        if ( ! function_exists( 'is_plugin_active' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        $freemius_helpers = [];
        $wporg_targets    = [];

        foreach ( $items as $item ) {
            if ( 'plugin' !== ( $item['type'] ?? 'plugin' ) ) {
                continue;
            }

            $premium_file    = isset( $item['premium_file'] ) ? (string) $item['premium_file'] : '';
            $free_file       = isset( $item['free_file'] ) ? (string) $item['free_file'] : '';
            $freemius_helper = isset( $item['freemius_helper'] ) ? (string) $item['freemius_helper'] : '';
            $wporg_slug      = isset( $item['wporg_slug'] ) ? (string) $item['wporg_slug'] : '';

            if ( $premium_file && is_plugin_active( $premium_file ) ) {
                if ( $freemius_helper ) {
                    $freemius_helpers[] = $freemius_helper;
                }
                continue;
            }

            if ( $free_file && is_plugin_active( $free_file ) && $wporg_slug ) {
                $wporg_targets[] = [
                    'slug' => $wporg_slug,
                    'file' => $free_file,
                ];
            }
        }

        $freemius_helpers = array_values( array_unique( $freemius_helpers ) );
        if ( ! empty( $freemius_helpers ) ) {
            $this->refresh_freemius_updates( $freemius_helpers );
        }

        if ( ! empty( $wporg_targets ) ) {
            $this->refresh_wporg_updates( $wporg_targets );
        }

        $updates = get_site_transient( 'update_plugins' );
        return ( is_object( $updates ) && isset( $updates->response ) && is_array( $updates->response ) ) ? $updates->response : [];
    }

    /**
     * Force Freemius update checks for active premium plugins.
     *
     * @param array<int,string> $helpers Freemius helper function names.
     * @return void
     */
    private function refresh_freemius_updates( array $helpers ): void {
        if ( empty( $helpers ) ) {
            return;
        }

        foreach ( $helpers as $helper ) {
            if ( ! is_callable( $helper ) ) {
                continue;
            }

            $fs = call_user_func( $helper );
            if ( ! is_object( $fs ) || ! method_exists( $fs, 'get_update' ) ) {
                continue;
            }

            $update = $fs->get_update( false, true );

            if ( class_exists( 'FS_Plugin_Updater' ) ) {
                $updater = \FS_Plugin_Updater::instance( $fs );
                if ( $update ) {
                    $updater->set_update_data( $update );
                } else {
                    $updater->delete_update_data();
                }
            }
        }
    }

    /**
     * Force WP.org update checks for active free plugins.
     *
     * @param array<int,array{slug:string,file:string}> $targets List of active free plugins.
     *
     * @return void
     */
    private function refresh_wporg_updates( array $targets ): void {
        if ( empty( $targets ) ) {
            return;
        }

        // Trigger WordPress's built-in bulk update check which is much faster than individual API calls.
        wp_update_plugins();
    }

    /**
     * Remove update entries for specific plugins after updating.
     *
     * @param array<int,string> $plugin_files Plugin files that were updated.
     * @param array             $installed_plugins Installed plugins data.
     *
     * @return void
     */
    private function clear_update_entries( array $plugin_files, array $installed_plugins ): void {
        if ( empty( $plugin_files ) ) {
            return;
        }

        $transient = get_site_transient( 'update_plugins' );
        if ( ! is_object( $transient ) ) {
            return;
        }
        if ( empty( $transient->response ) || ! is_array( $transient->response ) ) {
            $transient->response = [];
        }
        if ( empty( $transient->checked ) || ! is_array( $transient->checked ) ) {
            $transient->checked = [];
        }
        if ( empty( $transient->no_update ) || ! is_array( $transient->no_update ) ) {
            $transient->no_update = [];
        }

        foreach ( $plugin_files as $file ) {
            unset( $transient->response[ $file ] );

            $installed_version = $installed_plugins[ $file ]['Version'] ?? '';
            if ( $installed_version ) {
                $transient->checked[ $file ]   = $installed_version;
                $transient->no_update[ $file ] = (object) [
                    'plugin'      => $file,
                    'slug'        => dirname( $file ),
                    'new_version' => $installed_version,
                    'url'         => '',
                    'package'     => '',
                ];
            }
        }

        $transient->last_checked = time();
        set_site_transient( 'update_plugins', $transient );
    }

    /**
     * Apply runtime state to a catalog item.
     *
     * @param array $item Catalog item.
     * @param array $installed_plugins Installed plugins data.
     * @param array $update_response Update transient response data.
     * @param int   $updates_count Running updates count (by reference).
     * @param int   $updatable_count Running count of plugins with available package updates (by reference).
     * @param array $snippet_results Optional latest version data from snippet.
     *
     * @return array
     */
    private function apply_plugin_state( array $item, array $installed_plugins, array $update_response, int &$updates_count, int &$updatable_count, array $snippet_results = [] ): array {
        $type = $item['type'] ?? 'plugin';
        if ( 'plugin' !== $type ) {
            return $item;
        }

        $premium_file = isset( $item['premium_file'] ) ? (string) $item['premium_file'] : '';
        $free_file    = isset( $item['free_file'] ) ? (string) $item['free_file'] : '';
        $primary_file = $premium_file ?: $free_file;

        if ( '' === $primary_file ) {
            return $item;
        }

        $has_variants = ( '' !== $premium_file ) && ( '' !== $free_file );

        if ( ! $has_variants ) {
            [ $is_installed, $is_active, $version, $has_update, $new_version, $has_package ] = $this->read_plugin_state( $primary_file, $installed_plugins, $update_response );

            if ( $is_installed && ! $has_update && isset( $snippet_results[ $item['slug'] ] ) ) {
                $has_update  = true;
                $new_version = $snippet_results[ $item['slug'] ]['latest'] ?? $new_version;
            }

            // Suppress package for inactive premium plugins to ensure "Activate to update" state.
            if ( $has_package && ! $is_active && ! empty( $premium_file ) ) {
                $has_package = false;
            }

            if ( $has_update ) {
                ++$updates_count;
                if ( $has_package ) {
                    ++$updatable_count;
                }
            }
            $item['is_installed']       = $is_installed;
            $item['is_installed_prem']  = ! empty( $item['is_free'] ) ? false : $is_installed;
            $item['is_installed_free']  = ! empty( $item['is_free'] ) ? $is_installed : false;
            $item['is_active']          = $is_active;
            $item['version']            = $version;
            $item['has_update']         = $has_update;
            $item['has_package']        = $has_package;
            $item['new_version']        = $new_version;
            $item['update_file']        = $has_update ? $primary_file : '';
            $item['plugin_file_active'] = $is_active ? $primary_file : '';
            $item['plugin_file_action'] = $primary_file;
            $item['uses_free']          = false;
            return $item;
        }

        $is_installed_prem = false;
        $is_active_prem    = false;
        $ver_prem          = '';
        $upd_prem          = false;
        $new_prem          = '';
        $pkg_prem          = false;
        $is_installed_free = false;
        $is_active_free    = false;
        $ver_free          = '';
        $upd_free          = false;
        $new_free          = '';
        $pkg_free          = false;

        if ( $premium_file ) {
            [ $is_installed_prem, $is_active_prem, $ver_prem, $upd_prem, $new_prem, $pkg_prem ] = $this->read_plugin_state( $premium_file, $installed_plugins, $update_response );
        }
        if ( $free_file ) {
            [ $is_installed_free, $is_active_free, $ver_free, $upd_free, $new_free, $pkg_free ] = $this->read_plugin_state( $free_file, $installed_plugins, $update_response );
        }

        $is_installed = $is_installed_prem || $is_installed_free;
        $is_active    = $is_active_prem || $is_active_free;
        $uses_free    = false;
        $version      = '';
        $file_active  = '';
        $has_update   = false;
        $new_version  = '';
        $has_package  = false;

        if ( $is_active_prem ) {
            $file_active = $premium_file;
            $version     = $ver_prem;
        } elseif ( $is_active_free ) {
            $file_active = $free_file;
            $version     = $ver_free;
            $uses_free   = true;
        } elseif ( $is_installed_prem ) {
            $version = $ver_prem;
        } elseif ( $is_installed_free ) {
            $version   = $ver_free;
            $uses_free = true;
        }

        $update_file = '';
        if ( $upd_prem || $upd_free ) {
            $has_update  = $upd_prem || ( $upd_free && ! $is_installed_prem );
            $new_version = $upd_prem ? $new_prem : ( $is_installed_prem ? '' : $new_free );
            $has_package = $upd_prem ? $pkg_prem : ( $upd_free ? $pkg_free : false );
        }

        // Fallback to snippet results if native check didn't find an update.
        if ( $is_installed && ! $has_update && isset( $snippet_results[ $item['slug'] ] ) ) {
            $has_update  = true;
            $new_version = $snippet_results[ $item['slug'] ]['latest'] ?? $new_version;
            // Since we didn't have a native update, we don't have a package URL.
            $has_package = false;
        }

        if ( $has_update ) {
            $update_file = $upd_prem ? $premium_file : ( $upd_free ? $free_file : '' );
            if ( empty( $update_file ) ) {
                $update_file = $is_installed_prem ? $premium_file : $free_file;
            }

            // Suppress package for inactive premium plugins to ensure "Activate to update" state.
            if ( $has_package && $upd_prem && ! $is_active_prem ) {
                $has_package = false;
            }

            ++$updates_count;
            if ( $has_package ) {
                ++$updatable_count;
            }
        }

        if ( $is_active ) {
            $file_action = $file_active ?: ( $is_installed_prem ? $premium_file : $free_file );
        } else {
            $file_action = $is_installed_prem ? $premium_file : ( $free_file ?: $premium_file );
        }

        $item['is_installed']       = $is_installed;
        $item['is_installed_prem']  = $is_installed_prem;
        $item['is_installed_free']  = $is_installed_free;
        $item['is_active']          = $is_active;
        $item['version']            = $version;
        $item['has_update']         = $has_update;
        $item['has_package']        = $has_package;
        $item['new_version']        = $new_version;
        $item['update_file']        = $update_file;
        $item['plugin_file_active'] = $file_active;
        $item['plugin_file_action'] = $file_action ?: $primary_file;
        $item['uses_free']          = $uses_free;

        return $item;
    }

    /**
     * Read plugin state for a specific plugin file.
     *
     * @param string $file Plugin file.
     * @param array  $installed_plugins Installed plugins data.
     * @param array  $update_response Update transient response data.
     *
     * @return array{0:bool,1:bool,2:string,3:bool,4:string,5:bool}
     */
    private function read_plugin_state( string $file, array $installed_plugins, array $update_response ): array {
        if ( isset( $this->state_cache[ $file ] ) ) {
            return $this->state_cache[ $file ];
        }

        $installed = isset( $installed_plugins[ $file ] );
        $active    = $installed && is_plugin_active( $file );
        $version   = $installed ? ( $installed_plugins[ $file ]['Version'] ?? '' ) : '';
        $has_upd   = isset( $update_response[ $file ] );
        $new_ver   = $has_upd ? ( $update_response[ $file ]->new_version ?? '' ) : '';
        $has_pkg   = $has_upd && ! empty( $update_response[ $file ]->package );

        $this->state_cache[ $file ] = [ $installed, $active, $version, $has_upd, $new_ver, $has_pkg ];
        return $this->state_cache[ $file ];
    }

    /**
     * Enqueues admin assets for the GravityOps plugin in WordPress.
     *
     * This method ensures that relevant CSS and JS assets are loaded on specific admin pages
     * where the GravityOps functionality is applicable. It checks the current screen context
     * to determine if the assets should be included, avoiding unnecessary asset loading on unrelated pages.
     *
     * @param string $hook The current admin page hook suffix. Used to identify if the current
     *                     page is associated with GravityOps functionality.
     *
     * @return void
     */
    public function enqueue_assets( $hook ) {
        $page                 = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $is_gravityops_screen = ( false !== strpos( (string) $hook, 'gravity_ops' ) ) || ( 'gravity_ops' === $page );
        $is_freemius          = (bool) preg_match( '/\-(account|pricing|affiliation)$/', $page );
        $is_gf_page           = ( 0 === strpos( $page, 'gf_' ) ) || ( false !== strpos( (string) $hook, 'forms_page_gf' ) );

        $this->core->admin_assets()->enqueue( $is_gravityops_screen, $is_freemius, $is_gf_page );
    }

    /**
     * Render admin notices for actions taken on the dashboard (activation errors, etc.).
     *
     * @return void
     */
    public function render_admin_notices() {
        $is_gravityops_screen = isset( $_GET['page'] ) && 'gravity_ops' === sanitize_key( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        if ( ! $is_gravityops_screen ) {
            return;
        }

        $error  = get_transient( 'gops_error' );
        $notice = get_transient( 'gops_notice' );
        if ( $error ) {
            delete_transient( 'gops_error' );
            echo '<div class="notice notice-error is-dismissible"><p>' . esc_html( $error ) . '</p></div>';
        }
        if ( $notice ) {
            delete_transient( 'gops_notice' );
            echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( $notice ) . '</p></div>';
        }
    }

    /**
     * Register AJAX handlers for the dashboard.
     *
     * @return void
     */
    private function register_ajax_handlers(): void {
        if ( $this->did_ajax_hook ) {
            return;
        }

        add_action( 'wp_ajax_gops_toggle_plugin', [ $this, 'ajax_toggle_plugin' ] );
        add_action( 'wp_ajax_gops_update_plugin', [ $this, 'ajax_update_plugin' ] );
        add_action( 'wp_ajax_gops_update_all', [ $this, 'ajax_update_all' ] );
        add_action( 'wp_ajax_gops_refresh_updates', [ $this, 'ajax_refresh_updates' ] );
        add_action( 'wp_ajax_gops_check_suite_updates', [ $this, 'ajax_check_suite_updates' ] );
        add_action( 'wp_ajax_gops_get_changelog', [ $this, 'ajax_get_changelog' ] );
        $this->did_ajax_hook = true;
    }

    /**
     * AJAX handler for activating or deactivating a plugin from the dashboard.
     *
     * @return void
     */
    public function ajax_toggle_plugin() {
        check_ajax_referer( 'gops_admin', 'nonce' );

        if ( ! current_user_can( 'activate_plugins' ) ) {
            wp_send_json_error( [ 'message' => 'You do not have permission to activate plugins.' ], 403 );
        }

        $slug        = sanitize_text_field( wp_unslash( $_POST['slug'] ?? '' ) );
        $action      = sanitize_key( wp_unslash( $_POST['toggle'] ?? '' ) );
        $plugin_file = sanitize_text_field( wp_unslash( $_POST['plugin'] ?? '' ) );

        if ( empty( $slug ) || empty( $plugin_file ) || ! in_array( $action, [ 'activate', 'deactivate' ], true ) ) {
            wp_send_json_error( [ 'message' => 'Missing or invalid plugin data.' ], 400 );
        }

        if ( ! function_exists( 'activate_plugin' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        if ( 'activate' === $action ) {
            ob_start();
            $result = activate_plugin( $plugin_file );
            $output = ob_get_clean();
            if ( is_wp_error( $result ) ) {
                wp_send_json_error( [ 'message' => $result->get_error_message() ], 500 );
            }
            $message = ! empty( $output ) ? 'Plugin activated (with output).' : 'Plugin activated.';

            // Try to refresh Freemius/WP.org data immediately after activation to reduce UI lag.
            $catalog_item = $this->core->catalog()->find( $slug );
            if ( $catalog_item ) {
                if ( ! empty( $catalog_item['freemius_helper'] ) ) {
                    $this->refresh_freemius_updates( [ $catalog_item['freemius_helper'] ] );
                } elseif ( ! empty( $catalog_item['wporg_slug'] ) ) {
                    $this->refresh_wporg_updates(
                        [
							[
								'slug' => $catalog_item['wporg_slug'],
								'file' => $plugin_file,
							],
						]
                        );
                }
            }
        } else {
            ob_start();
            deactivate_plugins( $plugin_file );
            ob_end_clean();
            $message = 'Plugin deactivated.';
        }

        $items                                       = $this->core->catalog()->all();
        $installed_plugins                           = function_exists( 'get_plugins' ) ? get_plugins() : [];
        $update_transient                            = get_site_transient( 'update_plugins' );
        $update_response                             = ( is_object( $update_transient ) && isset( $update_transient->response ) && is_array( $update_transient->response ) ) ? $update_transient->response : [];
        [ $items, $updates_count, $updatable_count ] = $this->hydrate_items_with_state( $items, $installed_plugins, $update_response );

        $item = $this->find_item_by_slug( $items, $slug );
        if ( ! $item ) {
            wp_send_json_error( [ 'message' => 'Plugin state not found.' ], 404 );
        }

        wp_send_json_success(
            [
                'message'         => $message,
                'slug'            => $slug,
                'tile_html'       => $this->build_tile_html( $item ),
                'updates_count'   => $updates_count,
                'updatable_count' => $updatable_count,
            ]
        );
    }

    /**
     * AJAX handler for updating a single plugin.
     *
     * @return void
     */
    public function ajax_update_plugin() {
        check_ajax_referer( 'gops_admin', 'nonce' );

        if ( ! current_user_can( 'update_plugins' ) ) {
            wp_send_json_error( [ 'message' => 'You do not have permission to update plugins.' ], 403 );
        }

        $slug        = sanitize_text_field( wp_unslash( $_POST['slug'] ?? '' ) );
        $plugin_file = sanitize_text_field( wp_unslash( $_POST['plugin'] ?? '' ) );

        if ( empty( $slug ) || empty( $plugin_file ) ) {
            wp_send_json_error( [ 'message' => 'Missing plugin data.' ], 400 );
        }

        if ( ! function_exists( 'is_plugin_active' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        $was_active = is_plugin_active( $plugin_file );

        $items             = $this->core->catalog()->all();
        $installed_plugins = function_exists( 'get_plugins' ) ? get_plugins() : [];

        $current = get_site_transient( 'update_plugins' );
        if ( ! is_object( $current ) || empty( $current->response[ $plugin_file ] ) ) {
            wp_send_json_error( [ 'message' => 'No update data is available for this plugin.' ], 400 );
        }

        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
        require_once ABSPATH . 'wp-admin/includes/class-plugin-upgrader.php';

        $upgrader = new Plugin_Upgrader( new Automatic_Upgrader_Skin() );
        $result   = $upgrader->upgrade( $plugin_file, [ 'clear_update_cache' => false ] );

        if ( is_wp_error( $result ) ) {
            wp_send_json_error( [ 'message' => $result->get_error_message() ], 500 );
        }
        if ( true !== $result ) {
            wp_send_json_error( [ 'message' => 'Plugin update failed.' ], 500 );
        }

        if ( $was_active && ! is_plugin_active( $plugin_file ) ) {
            activate_plugin( $plugin_file );
        }

        wp_clean_plugins_cache();
        $installed_plugins = function_exists( 'get_plugins' ) ? get_plugins() : [];
        $this->clear_update_entries( [ $plugin_file ], $installed_plugins );
        $update_transient                            = get_site_transient( 'update_plugins' );
        $update_response                             = ( is_object( $update_transient ) && isset( $update_transient->response ) && is_array( $update_transient->response ) ) ? $update_transient->response : [];
        [ $items, $updates_count, $updatable_count ] = $this->hydrate_items_with_state( $items, $installed_plugins, $update_response );

        $item = $this->find_item_by_slug( $items, $slug );
        if ( ! $item ) {
            wp_send_json_error( [ 'message' => 'Plugin state not found.' ], 404 );
        }

        wp_send_json_success(
            [
                'message'         => 'Plugin updated.',
                'slug'            => $slug,
                'tile_html'       => $this->build_tile_html( $item ),
                'updates_count'   => $updates_count,
                'updatable_count' => $updatable_count,
            ]
        );
    }

    /**
     * AJAX handler for updating all plugins with updates available.
     *
     * @return void
     */
    public function ajax_update_all() {
        check_ajax_referer( 'gops_admin', 'nonce' );

        if ( ! current_user_can( 'update_plugins' ) ) {
            wp_send_json_error( [ 'message' => 'You do not have permission to update plugins.' ], 403 );
        }

        if ( ! function_exists( 'get_plugins' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        $items                                       = $this->core->catalog()->all();
        $installed_plugins                           = get_plugins();
        $update_transient                            = get_site_transient( 'update_plugins' );
        $update_response                             = ( is_object( $update_transient ) && isset( $update_transient->response ) && is_array( $update_transient->response ) ) ? $update_transient->response : [];
        [ $items, $updates_count, $updatable_count ] = $this->hydrate_items_with_state( $items, $installed_plugins, $update_response );

        $plugins_to_update = [];
        $active_before     = [];
        foreach ( $items as $item ) {
            if ( empty( $item['has_update'] ) || empty( $item['has_package'] ) ) {
                continue;
            }
            $file = ! empty( $item['update_file'] ) ? $item['update_file'] : ( $item['plugin_file_action'] ?? '' );
            if ( $file ) {
                $plugins_to_update[] = $file;
                if ( is_plugin_active( $file ) ) {
                    $active_before[] = $file;
                }
            }
        }
        $plugins_to_update = array_values( array_unique( $plugins_to_update ) );

        if ( empty( $plugins_to_update ) ) {
            wp_send_json_error( [ 'message' => 'No plugin updates are available.' ], 400 );
        }

        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
        require_once ABSPATH . 'wp-admin/includes/class-plugin-upgrader.php';

        $upgrader = new Plugin_Upgrader( new Automatic_Upgrader_Skin() );
        $result   = $upgrader->bulk_upgrade( $plugins_to_update, [ 'clear_update_cache' => false ] );

        if ( false === $result ) {
            wp_send_json_error( [ 'message' => 'Plugin updates failed.' ], 500 );
        }

        // Reactivate plugins that were active before.
        foreach ( $active_before as $file ) {
            if ( ! is_plugin_active( $file ) ) {
                activate_plugin( $file );
            }
        }

        $successful_updates = [];
        if ( is_array( $result ) ) {
            foreach ( $result as $file => $res ) {
                if ( true === $res ) {
                    $successful_updates[] = $file;
                }
            }
        }

        wp_clean_plugins_cache();
        $installed_plugins = get_plugins();
        if ( ! empty( $successful_updates ) ) {
            $this->clear_update_entries( $successful_updates, $installed_plugins );
        }
        $update_transient                            = get_site_transient( 'update_plugins' );
        $update_response                             = ( is_object( $update_transient ) && isset( $update_transient->response ) && is_array( $update_transient->response ) ) ? $update_transient->response : [];
        [ $items, $updates_count, $updatable_count ] = $this->hydrate_items_with_state( $items, $installed_plugins, $update_response );

        $tiles = [];
        foreach ( $items as $item ) {
            if ( empty( $item['slug'] ) ) {
                continue;
            }
            $tiles[ $item['slug'] ] = $this->build_tile_html( $item );
        }

        $errors = [];
        if ( is_array( $result ) ) {
            foreach ( $result as $res ) {
                if ( is_wp_error( $res ) ) {
                    $errors[] = $res->get_error_message();
                }
            }
        }

        $message = empty( $errors ) ? 'Updates complete.' : 'Some updates failed.';

        wp_send_json_success(
            [
                'message'         => $message,
                'tiles'           => $tiles,
                'updates_count'   => $updates_count,
                'updatable_count' => $updatable_count,
                'errors'          => $errors,
            ]
        );
    }

    /**
     * AJAX handler for fetching a plugin changelog.
     *
     * @return void
     */
    public function ajax_get_changelog() {
        check_ajax_referer( 'gops_admin', 'nonce' );

        $slug = sanitize_text_field( wp_unslash( $_POST['slug'] ?? '' ) );

        if ( empty( $slug ) ) {
            wp_send_json_error( [ 'message' => 'Missing plugin slug.' ], 400 );
        }

        $item = $this->core->catalog()->find( $slug );
        if ( ! $item ) {
            wp_send_json_error( [ 'message' => 'Plugin not found.' ], 404 );
        }

        // Fetch changelog from the Brightleaf snippet endpoint.
        $api_url  = 'https://brightleafdigital.io/wp-json/bld/v1/changelog';
        $response = wp_remote_get( add_query_arg( 'plugin', $slug, $api_url ) );

        if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
            $body = json_decode( wp_remote_retrieve_body( $response ), true );
            if ( ! empty( $body['changelog'] ) ) {
                wp_send_json_success(
                    [
                        'title'     => $item['name'] ?? $slug,
                        'changelog' => $body['changelog'],
                        'full_url'  => $item['changelog_url'] ?: ( $item['marketing_url'] ?? '' ),
                    ]
                );
            }
        }

        wp_send_json_success(
            [
                'title'     => $item['name'] ?? $slug,
                'changelog' => 'Changelog not available.',
                'full_url'  => $item['changelog_url'] ?: ( $item['marketing_url'] ?? '' ),
            ]
        );
    }

    /**
     * AJAX handler for refreshing updates in the background.
     *
     * @return void
     */
    public function ajax_refresh_updates() {
        check_ajax_referer( 'gops_admin', 'nonce' );

        $items             = $this->core->catalog()->all();
        $installed_plugins = function_exists( 'get_plugins' ) ? get_plugins() : [];

        $update_response                             = $this->refresh_updates_for_active_plugins( $items );
        [ $items, $updates_count, $updatable_count ] = $this->hydrate_items_with_state( $items, $installed_plugins, $update_response );

        $tiles = [];
        foreach ( $items as $item ) {
            if ( empty( $item['slug'] ) ) {
                continue;
            }
            $tiles[ $item['slug'] ] = $this->build_tile_html( $item );
        }

        wp_send_json_success(
            [
                'tiles'           => $tiles,
                'updates_count'   => $updates_count,
                'updatable_count' => $updatable_count,
            ]
        );
    }

    /**
     * AJAX handler for checking suite updates from a central repository.
     * This handles both active and inactive plugins by querying the changelog endpoint.
     *
     * @return void
     */
    public function ajax_check_suite_updates() {
        check_ajax_referer( 'gops_admin', 'nonce' );

        if ( ! current_user_can( 'update_plugins' ) ) {
            wp_send_json_error( [ 'message' => 'You do not have permission to update plugins.' ], 403 );
        }

        // Safely get the raw JSON string
        $raw_items = wp_unslash( $_POST['items'] ?? '[]' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

        // Decode JSON
        $items_to_check = json_decode( $raw_items, true );

        // Validate: Must be an array; otherwise, bail
        if ( ! is_array( $items_to_check ) ) {
            wp_send_json_error( [ 'message' => 'Invalid items data.' ], 400 );
        }

        // Sanitize each item (enforce structure: slug and version as sanitized strings)
        $sanitized_items = [];
        foreach ( $items_to_check as $item ) {
            if ( ! is_array( $item ) ) {
                continue; // Skip non-array items
            }
            $slug    = isset( $item['slug'] ) ? sanitize_text_field( $item['slug'] ) : '';
            $version = isset( $item['version'] ) ? sanitize_text_field( $item['version'] ) : '0';

            if ( ! empty( $slug ) ) { // Only include valid items with a slug
                $sanitized_items[] = [
					'slug'    => $slug,
					'version' => $version,
				];
            }
        }

        // If no valid items after sanitization, early return
        if ( empty( $sanitized_items ) ) {
            wp_send_json_success(
                [
					'tiles'         => [],
					'updates_count' => 0,
				]
            );
        }

        // Now forward the sanitized items to the external API
        $snippet_results = [];
        $response        = wp_remote_post(
                'https://brightleafdigital.io/wp-json/bld/v1/latest-versions',
                [
					'headers' => [ 'Content-Type' => 'application/json' ],
					'body'    => wp_json_encode( [ 'items' => $sanitized_items ] ),
					'timeout' => 15,
                ]
        );

        if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
            $body            = json_decode( wp_remote_retrieve_body( $response ), true );
            $snippet_results = $body['results'] ?? [];
        }

        // Proceed with hydration using sanitized data
        $items             = $this->core->catalog()->all();
        $installed_plugins = function_exists( 'get_plugins' ) ? get_plugins() : [];

        // Trigger native refreshes for active plugins BEFORE injecting placeholders.
        // This ensures active SDKs (Freemius) don't immediately clear our placeholders.
        $this->refresh_updates_for_active_plugins( $items );

        // Persist snippet results into the update_plugins transient for inactive plugins.
        if ( ! empty( $snippet_results ) ) {
            $transient = get_site_transient( 'update_plugins' );
            if ( ! is_object( $transient ) ) {
                $transient = new \stdClass();
            }
            if ( ! isset( $transient->response ) || ! is_array( $transient->response ) ) {
                $transient->response = [];
            }

            $updated_transient = false;
            foreach ( $snippet_results as $slug => $data ) {
                if ( empty( $data['latest'] ) ) {
                    continue;
                }

                $catalog_item = $this->core->catalog()->find( (string) $slug );
                if ( ! $catalog_item ) {
                    continue;
                }

                $premium_file = isset( $catalog_item['premium_file'] ) ? (string) $catalog_item['premium_file'] : '';
                $free_file    = isset( $catalog_item['free_file'] ) ? (string) $catalog_item['free_file'] : '';

                $target_file = '';
                if ( $premium_file && isset( $installed_plugins[ $premium_file ] ) ) {
                    $target_file = $premium_file;
                } elseif ( $free_file && isset( $installed_plugins[ $free_file ] ) ) {
                    $target_file = $free_file;
                }

                if ( ! $target_file ) {
                    continue;
                }

                $transient = get_site_transient( 'update_plugins' );
                if ( ! is_object( $transient ) ) {
                    $transient = new \stdClass();
                }
                if ( ! isset( $transient->response ) || ! is_array( $transient->response ) ) {
                    $transient->response = [];
                }

                // If already in response, don't overwrite it (to preserve package URLs if present).
                if ( isset( $transient->response[ $target_file ] ) ) {
                    continue;
                }

                // Add placeholder update entry.
                $transient->response[ $target_file ] = (object) [
                    'slug'        => dirname( $target_file ),
                    'plugin'      => $target_file,
                    'new_version' => $data['latest'],
                    'url'         => $catalog_item['changelog_url'] ?: ( $catalog_item['marketing_url'] ?? '' ),
                    'package'     => '',
                ];
                set_site_transient( 'update_plugins', $transient );
            }
        }

        $update_transient = get_site_transient( 'update_plugins' );
        $update_response  = ( is_object( $update_transient ) && isset( $update_transient->response ) && is_array( $update_transient->response ) ) ? $update_transient->response : [];

        [ $items, $updates_count, $updatable_count ] = $this->hydrate_items_with_state( $items, $installed_plugins, $update_response, $snippet_results );

        $tiles = [];
        foreach ( $items as $item ) {
            if ( empty( $item['slug'] ) ) {
                continue;
            }
            $tiles[ $item['slug'] ] = $this->build_tile_html( $item );
        }

        wp_send_json_success(
            [
				'tiles'           => $tiles,
				'updates_count'   => $updates_count,
				'updatable_count' => $updatable_count,
			]
        );
    }

    /**
     * Find a catalog item by slug within a hydrated list.
     *
     * @param array  $items Hydrated catalog items.
     * @param string $slug Item slug.
     *
     * @return array|null
     */
    private function find_item_by_slug( array $items, string $slug ): ?array {
        foreach ( $items as $item ) {
            if ( ( $item['slug'] ?? '' ) === $slug ) {
                return $item;
            }
        }

        return null;
    }

    /**
     * Handle activate/deactivate actions from the dashboard tiles.
     *
     * @return void
     */
    public function maybe_handle_plugin_toggle() {
        if ( empty( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            return;
        }
        if ( empty( $_POST['gops-action'] ) || empty( $_POST['plugin'] ) || empty( $_POST['gops_nonce'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
            return;
        }
        $action      = sanitize_key( wp_unslash( $_POST['gops-action'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $plugin_file = sanitize_text_field( wp_unslash( $_POST['plugin'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $nonce       = sanitize_text_field( wp_unslash( $_POST['gops_nonce'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing

        if ( ! wp_verify_nonce( $nonce, 'gops_toggle_' . $plugin_file ) ) {
            return;
        }
        if ( ! current_user_can( 'activate_plugins' ) ) {
            return;
        }

        if ( ! function_exists( 'activate_plugin' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        if ( 'activate' === $action ) {
            // Capture unexpected output to avoid noisy screens; WP will still return WP_Error on failure.
            ob_start();
            $result = activate_plugin( $plugin_file );
            $output = ob_get_clean();
            if ( is_wp_error( $result ) ) {
                set_transient( 'gops_error', $result->get_error_message(), 30 );
            } elseif ( ! empty( $output ) ) {
                set_transient( 'gops_notice', 'Plugin activated. Note: the plugin printed output during activation.', 30 ); // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
            } else {
                set_transient( 'gops_notice', 'Plugin activated.', 30 ); // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
            }
        } elseif ( 'deactivate' === $action ) {
            ob_start();
            deactivate_plugins( $plugin_file );
            ob_end_clean();
            set_transient( 'gops_notice', 'Plugin deactivated.', 30 ); // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
        }

        // Redirect back to avoid resubmission.
        $redirect = remove_query_arg( [ 'gops-action', 'plugin', 'gops_nonce' ], wp_get_referer() );
        if ( ! $redirect ) {
            $redirect = admin_url( 'admin.php?page=gravity_ops' );
        }
        wp_safe_redirect( $redirect );
        exit;
    }


    /**
     * Retrieves the icon URL to be used for the GravityOps menu.
     *
     * Resolves the GravityOps menu icon URL from SuiteCore assets.
     * Falls back to a base64-encoded SVG when assets are unavailable.
     *
     * @return string The resolved URL of the GravityOps menu icon, or an empty string if none is found.
     */
	public function get_icon() {
        $base = $this->core->assets_base_url();
        $url  = $base ? trailingslashit( $base ) . 'images/ops-icon.svg' : '';
        return $url ?: $this->get_svg_icon();
    }

    /**
     * Generates a base64-encoded SVG icon.
     *
     * This method returns a base64-encoded string representation of an SVG icon. The SVG is a
     * static XML structure embedded within the method and styled using defined classes and gradients.
     * The returned string can be used as an inline source for displaying the icon in various UI components.
     *
     * @return string A base64-encoded string representation of the SVG icon, prefixed with the appropriate data URI scheme.
     */
    public function get_svg_icon() {
        $svg_xml = '<?xml version="1.0" encoding="utf-8"?><svg height="24" id="Layer_1" viewBox="0 0 300 300" width="24" xmlns="http://www.w3.org/2000/svg" >
<defs>
<style>
	  .cls-1 {
		fill: #fff;
	  }
	  .cls-4 {
		fill: #fff;
	  }
	</style>
<radialGradient cx="-28.79" cy="-50.67" fx="-28.79" fy="-50.67" gradientTransform="translate(.26 .38) scale(1.05)" gradientUnits="userSpaceOnUse" id="radial-gradient" r="433.22">
<stop offset="0" stop-color="#402a56"/>
<stop offset="1" stop-color="#2f2e41"/>
</radialGradient>
</defs>
<g>
<g>
<path class="cls-4" d="M204.44,45.16c-7.84,2.35-15.26,5.96-22.05,10.2,0,0-.02,0-.03.01-15.43,9.64-27.63,22.58-34.25,31.59-9.53,13-27.14,30.42-43.32,13.65-2.65-2.75-4.19-6.14-4.72-9.87-1.88-13.02,8.47-30.17,26.39-38.44,33.79-15.6,95.3-12.35,77.98-7.15Z" fill="black"/>
<path class="cls-1" d="M214.25,50.81c-4.41,2.77-11.39,11-16.43,17.33,0,0,0,0-.01,0-1.67,2.09-3.13,3.98-4.21,5.39-11.02,14.34-31.85,47.1-37.9,60.65-8.26,18.49-36.2,49.52-61.36,35.86-.16-.08-.32-.18-.47-.27-.04-.02-.08-.05-.12-.06-25.34-14.5-19.28-50.67,2.72-74.12-8.81,13.47-6.66,25.45.75,32.32,17.55,16.25,36.77,2.62,47.34-13.87,8.15-12.72,17.71-24.76,28.14-34.82,8.38-8.08,23.51-19.35,32.73-24.2,3.09-1.64,7.15-3.25,8.83-4.2Z" fill="black"/>
<path class="cls-1" d="M221.42,60.81c-.66,1.3-5.48,10.14-10.42,20.46t0,.01c-3.67,7.67-7.41,16.16-9.58,23-4.32,13.6-16.91,56.93-19.49,64.57-4.83,14.29-11.87,24.53-20.51,31.19-.29.23-.58.44-.88.66-9.4,6.88-20.63,9.65-32.99,8.88-15.67-.98-27.53-10.99-31.65-27.29,2.63,5.35,7.76,9.4,16.05,10.18,17.18,1.61,29.48-5.6,37.79-13.93,2.9-2.9,5.31-5.95,7.27-8.81,7.58-11.05,20.74-47.79,28.81-63.68,15.38-30.3,27.18-36.6,35.61-45.22Z" fill="black"/>
<path class="cls-1" d="M223.33,174.26h0c-.01.29-.03.58-.05.87-1.12,21.48-14.24,36.62-31.35,38.34-12.52,1.25-24.18-3-31.41-12.78.29-.21.58-.43.88-.66,3.05,1.98,6.75,3.07,11.19,3.03,22.82-.2,31.59-25.49,32.65-44.19,3.54-62.38,17.03-82.68,18.03-85.08-.29,4.36-4.98,17.58-5.62,30.49-.18,3.55-.23,7-.19,10.35h0c.27,21.03,4.28,38.11,5.6,51.39.28,2.83.36,5.58.27,8.23Z" fill="black"/>
<path class="cls-1" d="M241.9,175.78c-7.01,2.69-13.2,2.1-18.62-.65.02-.29.03-.58.05-.86,2.51.46,5.02.16,7.53-.96,11.48-5.11,7.91-25.36,3.03-36.08-4.65-10.23-7.63-25.56-8.77-44.1,5.25,23.34,16.89,31.95,23.93,41.17,6.73,8.81,16.03,32.6-7.15,41.48Z" fill="black"/>
</g>
</g>
</svg>';

        return sprintf( 'data:image/svg+xml;base64,%s', base64_encode( $svg_xml ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
    }
}
