<?php
/**
 * Plugin singleton bootstrap.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP;

use NimblyMail\SMTP\Admin\AdminBar;
use NimblyMail\SMTP\Admin\Area;
use NimblyMail\SMTP\Admin\NetworkArea;
use NimblyMail\SMTP\Admin\SetupWizard;
use NimblyMail\SMTP\Alerts\DeliveryErrorNotice;
use NimblyMail\SMTP\Alerts\FailureAlert;
use NimblyMail\SMTP\Audit\Scheduler as HealthScheduler;
use NimblyMail\SMTP\Controls\EmailControls;
use NimblyMail\SMTP\Sandbox\SandboxInterceptor;
use NimblyMail\SMTP\Log\Installer;
use NimblyMail\SMTP\Log\Logger;
use NimblyMail\SMTP\Log\Rotation;
use NimblyMail\SMTP\Network\NetworkActivationInstaller;
use NimblyMail\SMTP\Network\StatsAggregator;
use NimblyMail\SMTP\Queue\DrainNotice as QueueDrainNotice;
use NimblyMail\SMTP\Queue\Installer as QueueInstaller;
use NimblyMail\SMTP\Queue\Manager as QueueManager;
use NimblyMail\SMTP\Queue\Runner as QueueRunner;
use NimblyMail\SMTP\Review\ReviewRequest;
use NimblyMail\SMTP\Rest\LogsController;
use NimblyMail\SMTP\Rest\QueueController;
use NimblyMail\SMTP\Rest\Network\NetworkDashboardController;
use NimblyMail\SMTP\Rest\Network\NetworkSettingsController;
use NimblyMail\SMTP\Rest\Network\NetworkSitesController;
use NimblyMail\SMTP\Rest\Network\NetworkTestEmailController;
use NimblyMail\SMTP\Rest\HealthController;
use NimblyMail\SMTP\Rest\OAuthController;
use NimblyMail\SMTP\Rest\SettingsController;
use NimblyMail\SMTP\Rest\TestController;
use NimblyMail\SMTP\Rest\DashboardController;
use NimblyMail\SMTP\Rest\ImportController;
use NimblyMail\SMTP\Rest\ToolsController;
use NimblyMail\SMTP\Import\ImporterRegistry;
use NimblyMail\SMTP\Migration\NetworkBootstrap;
use Exception;
use WP_Site;

defined( 'ABSPATH' ) || exit;

/**
 * Class Plugin
 *
 * Main plugin singleton that wires everything together.
 *
 * @since 1.0.0
 * @since 1.0.2 Wizard redirect flag moved to the dedicated SetupWizard::SHOULD_RUN_TRANSIENT; conflict check uses its own one-shot transient.
 * @since 1.0.3 Conflict check flag switched from a 5-minute transient to a non-autoloaded option with a staleness cap, so a lengthy setup wizard session no longer causes the notice to expire unseen.
 * @since 1.0.4 Registered StatsAggregator's Action Scheduler hook on multisite.
 * @since 1.0.6 Registered NetworkActivationInstaller's Action Scheduler hook on multisite; activate() now promotes network defaults before the per-site loop and defers large networks to the chunked background installer.
 */
final class Plugin {

    /**
     * Singleton instance.
     *
     * @var self|null
     */
    private static ?self $instance = null;

    /**
     * The Processor instance.
     *
     * @var Processor
     */
    private Processor $processor;

    /**
     * Known plugins detected during conflict check.
     *
     * @since 1.0.0
     *
     * @var array<string, string> Map of slug => display name.
     */
    private array $detected_plugins = array();

    /**
     * One-shot flag recording the activation timestamp that gates the
     * post-activation conflict check.
     *
     * Set in {@see Plugin::activate()} and consumed only when its outcome
     * is known — see {@see Plugin::maybe_check_conflicts()}. Stored as a
     * non-autoloaded option rather than a transient so a setup wizard
     * session longer than a short transient expiry can't cause the flag to
     * vanish before the notice is ever shown; the stored timestamp lets
     * maybe_check_conflicts() still discard it once it's stale.
     *
     * @since 1.0.2
     * @since 1.0.3 Switched from a 5-minute transient to a non-autoloaded option.
     *
     * @var string
     */
    private const CONFLICT_CHECK_OPTION = 'nimblymail_conflict_check';

    /**
     * Get the singleton instance.
     *
     * @since 1.0.0
     * @return self
     */
    public static function instance(): self {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Private constructor. Wires hooks.
     */
    private function __construct() {
        $this->init();
    }

    /**
     * Initialize all plugin components.
     *
     * @since 1.0.0
     * @since 1.0.4 Registered StatsAggregator's Action Scheduler hook on
     *              multisite.
     * @since 1.0.6 Registered NetworkActivationInstaller's Action Scheduler
     *              hook on multisite.
     *
     * @return void
     */
    private function init(): void {
        // Ensure core PHPMailer interfaces are available.
        $this->maybe_load_phpmailer_dependencies();

        // Run DB migrations if needed.
        Installer::maybe_upgrade();
        QueueInstaller::maybe_upgrade();

        // Developer Sandbox interceptor — registered BEFORE Processor so its
        // pre_wp_mail callback at priority 1 wins WordPress' equal-priority
        // FIFO race against Processor::maybe_block_email().
        $sandbox = new SandboxInterceptor();
        $sandbox->init();

        // Core mail processing.
        $this->processor = new Processor();
        $this->processor->init();

        // Email logging.
        $logger = new Logger();
        $logger->init();

        // Background queue (must run before Logger captures, so its
        // nimblymail_pre_send filter fires earlier than Logger's
        // capture_email priority).
        $queue_manager = new QueueManager();
        $queue_manager->init();

        // Action Scheduler callback that processes individual queue rows.
        $queue_runner = new QueueRunner();
        $queue_runner->init();

        if ( is_admin() ) {
            $queue_drain_notice = new QueueDrainNotice();
            $queue_drain_notice->init();
        }

        // Log rotation (cron).
        $rotation = new Rotation();
        $rotation->init();

        // Email controls (disable WP notifications).
        $controls = new EmailControls();
        $controls->init();

        // Failure alerts.
        $alert = new FailureAlert();
        $alert->init();

        // Deliverability health audit scheduler.
        $health_scheduler = new HealthScheduler();
        $health_scheduler->init();

        // Network dashboard stats background aggregator (multisite only).
        if ( is_multisite() ) {
            $stats_aggregator = new StatsAggregator();
            $stats_aggregator->init();

            $network_activation_installer = new NetworkActivationInstaller();
            $network_activation_installer->init();
        }

        // Delivery error admin notices.
        $error_notice = new DeliveryErrorNotice();
        $error_notice->init();

        // Request a Review prompt (non-blocking admin notice on all admin screens).
        $review_request = new ReviewRequest();
        $review_request->init();

        // OAuth handler (admin_init listener for callbacks).
        ( new OAuthController() )->init();

        // Sandbox admin bar badge — visible on admin and frontend whenever
        // sandbox is enabled.
        $admin_bar = new AdminBar();
        $admin_bar->init();

        // Admin area (menu, assets).
        if ( is_admin() ) {
            $admin = new Area();
            $admin->init();

            // Setup wizard (hidden page + first-run redirect).
            $wizard = new SetupWizard();
            $wizard->init();

            // Network admin area (multisite only).
            if ( is_multisite() ) {
                $network_admin = new NetworkArea();
                $network_admin->init();
            }

            // Check for importable plugins on every admin load.
            $this->maybe_show_import_notice();

            // Check for unknown conflicting plugins on activation only.
            $this->maybe_check_conflicts();

            // Check encryption prerequisites.
            $this->check_encryption_prerequisites();
        }

        // REST API controllers.
        add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );

        // When a new sub-site is added to the network, provision its logs table.
        if ( is_multisite() ) {
            add_action( 'wp_initialize_site', array( $this, 'on_new_site' ), 10, 2 );
        }
    }

    /**
     * Handle creation of a new sub-site on a multisite network.
     *
     * Installs the email logs table for the newly created site so it is
     * ready to record emails the moment the site becomes usable. The
     * second `$args` parameter is required by the WP action signature
     * but unused here.
     *
     * @since 1.0.0
     *
     * @param WP_Site $new_site The new site object.
     * @param array   $args     Arguments for the initialization (unused).
     *
     * @return void
     */
    public function on_new_site( WP_Site $new_site, array $args ): void {
        unset( $args );
        Installer::install_for_blog( (int) $new_site->blog_id );
        QueueInstaller::install_for_blog( (int) $new_site->blog_id );
    }

    /**
     * Register all REST API routes.
     *
     * Fires all built-in controller registrations then dispatches the
     * nimblymail_register_rest_routes action so Pro add-ons can register
     * their own controllers without binding independently to rest_api_init.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function register_rest_routes(): void {
        ( new SettingsController() )->register_routes();
        ( new LogsController() )->register_routes();
        ( new QueueController() )->register_routes();
        ( new TestController() )->register_routes();
        ( new OAuthController() )->register_routes();
        ( new DashboardController() )->register_routes();
        ( new ImportController() )->register_routes();
        ( new ToolsController() )->register_routes();
        ( new HealthController() )->register_routes();
        ( new NetworkSettingsController() )->register_routes();
        ( new NetworkSitesController() )->register_routes();
        ( new NetworkDashboardController() )->register_routes();
        ( new NetworkTestEmailController() )->register_routes();

        /**
         * Fires after all built-in REST controllers are registered.
         *
         * Allows Pro add-ons to register their own REST controllers on
         * NimblyMail's rest_api_init hook without each needing to bind to
         * rest_api_init independently.
         *
         * @since 1.0.0
         */
        do_action( 'nimblymail_register_rest_routes' );
    }


    /**
     * Show an import notice when known SMTP plugins are active.
     *
     * Runs on every admin page load. Uses a separate dismissal meta key
     * so it is independent of the one-shot generic conflict notice.
     *
     * @since 1.0.0
     */
    private function maybe_show_import_notice(): void {
        // Handle permanent dismissal via GET parameter.
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce checked inside.
        if ( isset( $_GET['nimblymail_dismiss_import'] ) && check_admin_referer( 'nimblymail_dismiss_import' ) ) {
            update_user_meta( get_current_user_id(), 'nimblymail_import_notice_dismissed', true );
            return;
        }

        if ( get_user_meta( get_current_user_id(), 'nimblymail_import_notice_dismissed', true ) ) {
            return;
        }

        // Defer to admin_init so all plugins are loaded and is_plugin_active() works.
        add_action( 'admin_init', array( $this, 'detect_importable_plugins' ) );
    }

    /**
     * Detect active importable plugins and show the import notice.
     *
     * @since 1.0.0
     */
    public function detect_importable_plugins(): void {
        $this->detected_plugins = array();

        foreach ( ImporterRegistry::init()->get_available() as $importer ) {
            $this->detected_plugins[ $importer->get_slug() ] = $importer->get_name();
        }

        if ( ! empty( $this->detected_plugins ) ) {
            add_action( 'admin_notices', array( $this, 'importable_plugin_notice' ) );
        }
    }

    /**
     * Check for unknown conflicting plugins on activation only.
     *
     * Uses a dedicated one-shot transient to run once after plugin
     * activation. Must not consume the SetupWizard::SHOULD_RUN_TRANSIENT
     * flag — that one belongs to the SetupWizard first-run redirect, which
     * checks it later in the same request (admin_init).
     * Only fires the generic conflict notice when no known importable
     * plugins are detected (those get the more helpful import notice instead).
     *
     * The flag is NOT consumed here: the first admin load after activation
     * ends in the wizard redirect (admin_init 9999), which exits before
     * `admin_notices` can render. Consuming the flag here would burn the
     * notice unseen. Instead it is deleted where the outcome is known —
     * in {@see Plugin::detect_unknown_conflicts()} when there is nothing
     * to show, or in {@see Plugin::conflict_notice()} once the notice
     * actually renders. A staleness cap discards flags left over from an
     * activation nobody acted on, so the notice can't resurface weeks later.
     *
     * @since 1.0.0
     * @since 1.0.2 Switched to the dedicated `nimblymail_conflict_check` transient and deferred its consumption to notice render time so the wizard redirect cannot swallow the notice.
     * @since 1.0.3 Reads the activation-timestamp option instead of the transient and discards it once older than a day.
     */
    private function maybe_check_conflicts(): void {
        // Handle permanent dismissal via GET parameter.
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce checked inside.
        if ( isset( $_GET['nimblymail_dismiss_conflict'] ) && check_admin_referer( 'nimblymail_dismiss_conflict' ) ) {
            update_user_meta( get_current_user_id(), 'nimblymail_conflict_dismissed', true );
            return;
        }

        $activated_at = (int) get_option( self::CONFLICT_CHECK_OPTION, 0 );
        if ( ! $activated_at ) {
            return;
        }

        if ( ( time() - $activated_at ) > DAY_IN_SECONDS ) {
            delete_option( self::CONFLICT_CHECK_OPTION );
            return;
        }

        if ( get_user_meta( get_current_user_id(), 'nimblymail_conflict_dismissed', true ) ) {
            delete_option( self::CONFLICT_CHECK_OPTION );
            return;
        }

        // Defer check to admin_init so all plugins have loaded their hooks.
        add_action( 'admin_init', array( $this, 'detect_unknown_conflicts' ) );
    }

    /**
     * Detect unknown plugins hooking into phpmailer_init.
     *
     * Only shows the generic conflict notice when no known importable
     * plugins were detected (those already have the import notice).
     *
     * Consumes the one-shot `nimblymail_conflict_check` flag when there is
     * nothing to show; when a conflict is found the flag is kept so the
     * notice survives a wizard redirect on this request and is consumed at
     * render time instead.
     *
     * @since 1.0.0
     * @since 1.0.2 Consumes the conflict-check flag only when no notice will be shown.
     * @since 1.0.3 Flag is now the non-autoloaded CONFLICT_CHECK_OPTION rather than a transient.
     */
    public function detect_unknown_conflicts(): void {
        // Skip if we already have known importable plugins — import notice handles that.
        if ( ! empty( $this->detected_plugins ) ) {
            delete_option( self::CONFLICT_CHECK_OPTION );
            return;
        }

        global $wp_filter;

        if ( isset( $wp_filter['phpmailer_init'] ) ) {
            foreach ( $wp_filter['phpmailer_init']->callbacks as $callbacks ) {
                foreach ( $callbacks as $callback ) {
                    $func = $callback['function'];

                    // Skip our own hook.
                    if ( is_array( $func ) && is_object( $func[0] ) && str_starts_with( get_class( $func[0] ), 'NimblyMail\SMTP\\' ) ) {
                        continue;
                    }

                    add_action( 'admin_notices', array( $this, 'conflict_notice' ) );
                    return;
                }
            }
        }

        delete_option( self::CONFLICT_CHECK_OPTION );
    }

    /**
     * Display a notice when importable SMTP plugins are detected.
     *
     * Shows the names of detected plugins and provides an "Import Settings"
     * button linking to the Tools import page, plus a dismiss link.
     *
     * @since 1.0.0
     */
    public function importable_plugin_notice(): void {
        $dismiss_url = wp_nonce_url(
            add_query_arg( 'nimblymail_dismiss_import', '1' ),
            'nimblymail_dismiss_import'
        );

        $import_url = admin_url( 'admin.php?page=nimblymail-smtp-tools#import' );
        $names      = implode( ', ', $this->detected_plugins );

        echo '<div class="notice notice-info is-dismissible">';
        echo '<p>';
        echo '<strong>' . esc_html__( 'NimblyMail:', 'nimblymail-smtp' ) . '</strong> ';
        printf(
            /* translators: %s: comma-separated list of detected plugin names. */
            esc_html__( 'Settings from %s were detected. You can import them into NimblyMail to get started quickly.', 'nimblymail-smtp' ),
            '<strong>' . esc_html( $names ) . '</strong>'
        );
        echo '</p>';
        echo '<p>';
        echo '<a href="' . esc_url( $import_url ) . '" class="button button-primary">';
        echo esc_html__( 'Import Settings', 'nimblymail-smtp' );
        echo '</a> ';
        echo '<a href="' . esc_url( $dismiss_url ) . '" style="margin-left: 12px;">';
        echo esc_html__( 'Dismiss', 'nimblymail-smtp' );
        echo '</a>';
        echo '</p>';
        echo '</div>';
    }

    /**
     * Display a dismissible admin notice about unknown conflicting plugins.
     *
     * Shown when non-NimblyMail phpmailer_init hooks are detected but no
     * specific known plugins are identified. Consumes the one-shot
     * conflict-check flag at render time — the only point where the
     * notice is guaranteed to have been displayed. On the wizard page the
     * notice is suppressed and the flag kept: the full-screen wizard hides
     * admin notices, so the notice waits for the next regular admin screen.
     *
     * @since 1.0.0
     * @since 1.0.2 Consumes the conflict-check flag at render time; suppressed (flag kept) on the wizard page.
     * @since 1.0.3 Flag is now the non-autoloaded CONFLICT_CHECK_OPTION rather than a transient.
     */
    public function conflict_notice(): void {
        $page = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        if ( SetupWizard::SLUG === $page ) {
            return;
        }

        delete_option( self::CONFLICT_CHECK_OPTION );

        $dismiss_url = wp_nonce_url(
            add_query_arg( 'nimblymail_dismiss_conflict', '1' ),
            'nimblymail_dismiss_conflict'
        );

        echo '<div class="notice notice-warning is-dismissible">';
        echo '<p>';
        echo '<strong>' . esc_html__( 'NimblyMail:', 'nimblymail-smtp' ) . '</strong> ';
        echo esc_html__( 'Another plugin appears to be configuring email transport. This may cause conflicts with NimblyMail. Please deactivate other SMTP or email configuration plugins for best results.', 'nimblymail-smtp' );
        echo ' <a href="' . esc_url( $dismiss_url ) . '">' . esc_html__( 'Dismiss permanently', 'nimblymail-smtp' ) . '</a>';
        echo '</p>';
        echo '</div>';
    }

    /**
     * Check encryption prerequisites and display admin notices if issues are found.
     *
     * Checks for OpenSSL extension availability and proper WordPress salt configuration.
     *
     * @since 1.0.0
     *
     * @return void
     */
    private function check_encryption_prerequisites(): void {
        if ( ! Encryption::is_openssl_available() ) {
            add_action( 'admin_notices', array( $this, 'openssl_missing_notice' ) );
        }

        if ( ! Encryption::are_salts_configured() ) {
            add_action( 'admin_notices', array( $this, 'salts_misconfigured_notice' ) );
        }

        if ( Encryption::has_recent_decrypt_failures() && current_user_can( 'manage_options' ) ) {
            add_action( 'admin_notices', array( $this, 'decrypt_failure_notice' ) );
        }

        if ( current_user_can( 'manage_options' ) && $this->has_unprovisioned_oauth_account() ) {
            add_action( 'admin_notices', array( $this, 'oauth_reprovision_notice' ) );
        }
    }

    /**
     * Whether any active OAuth account is missing the install identity required for signed refresh.
     *
     * After the signed-refresh upgrade, every OAuth-connected install carries
     * an `install_id` + `install_secret` pair. Pre-upgrade tokens predate the
     * proxy provisioning step and cannot reach the new `/refresh` endpoint —
     * the user must reconnect once for each affected provider.
     *
     * @since 1.0.0
     *
     * @return bool True when at least one OAuth provider is connected without an install identity.
     */
    private function has_unprovisioned_oauth_account(): bool {
        $options = Options::init();

        foreach ( array( 'gmail', 'outlook' ) as $provider_group ) {
            $access_token = (string) $options->get( $provider_group, 'access_token' );
            if ( '' === $access_token ) {
                continue;
            }

            $install_id     = (string) $options->get( $provider_group, 'install_id' );
            $install_secret = (string) $options->get( $provider_group, 'install_secret' );

            if ( '' === $install_id || '' === $install_secret ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Display an admin notice prompting reconnect for legacy OAuth tokens.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function oauth_reprovision_notice(): void {
        $settings_url = admin_url( 'admin.php?page=nimblymail-smtp-settings#/settings/connection' );

        echo '<div class="notice notice-warning">';
        echo '<p>';
        echo '<strong>' . esc_html__( 'NimblyMail:', 'nimblymail-smtp' ) . '</strong> ';
        echo esc_html__( 'A connected OAuth account is missing the security identity required for signed token refresh. Please disconnect and reconnect the account to restore deliverability.', 'nimblymail-smtp' );
        echo ' <a href="' . esc_url( $settings_url ) . '">';
        echo esc_html__( 'Open Connection settings', 'nimblymail-smtp' );
        echo '</a>';
        echo '</p>';
        echo '</div>';
    }

    /**
     * Display an admin notice when stored credentials cannot be decrypted.
     *
     * Triggered when AUTH_KEY/NONCE_KEY rotation, ciphertext corruption, or
     * tampering causes one or more decrypt attempts to fail. Without this
     * notice an admin sees "empty" credential fields and may unwittingly
     * re-enter and overwrite still-recoverable data.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function decrypt_failure_notice(): void {
        echo '<div class="notice notice-error">';
        echo '<p>';
        echo '<strong>' . esc_html__( 'NimblyMail:', 'nimblymail-smtp' ) . '</strong> ';
        echo esc_html__( 'One or more stored credentials could not be decrypted. This usually means WordPress security salts (AUTH_KEY / NONCE_KEY) have changed since the values were saved. Re-enter the affected mailer credentials to clear this warning.', 'nimblymail-smtp' );
        echo '</p>';
        echo '</div>';
    }

    /**
     * Display an admin notice when the OpenSSL extension is missing.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function openssl_missing_notice(): void {
        echo '<div class="notice notice-error">';
        echo '<p>';
        echo '<strong>' . esc_html__( 'NimblyMail:', 'nimblymail-smtp' ) . '</strong> ';
        echo esc_html__( 'The OpenSSL PHP extension is required for encrypting credentials. NimblyMail cannot securely store mailer passwords and API keys without it. Please enable ext-openssl in your PHP configuration.', 'nimblymail-smtp' );
        echo '</p>';
        echo '</div>';
    }

    /**
     * Display an admin notice when WordPress salts are not properly configured.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function salts_misconfigured_notice(): void {
        echo '<div class="notice notice-warning">';
        echo '<p>';
        echo '<strong>' . esc_html__( 'NimblyMail:', 'nimblymail-smtp' ) . '</strong> ';
        echo esc_html__( 'WordPress security salts (AUTH_KEY and NONCE_KEY) are not properly configured. NimblyMail uses these to encrypt credentials at rest. Please set unique salts in your wp-config.php file.', 'nimblymail-smtp' );
        echo '</p>';
        echo '</div>';
    }

    /**
     * Handle plugin activation.
     *
     * Registered as the activation hook callback in nimblymail-smtp.php. On a
     * network activation, provisions the logs table on every site on the
     * network and runs the one-time NetworkBootstrap promotion. Otherwise
     * behaves as a standard per-site install.
     *
     * @since 1.0.0
     * @since 1.0.2 Sets the wizard's dedicated SetupWizard::SHOULD_RUN_TRANSIENT flag plus a separate `nimblymail_conflict_check` transient for the conflict check.
     * @since 1.0.3 Conflict check flag is now a non-autoloaded option storing the activation timestamp instead of a 5-minute transient.
     * @since 1.0.6 NetworkBootstrap::maybe_promote_site_1_settings() now runs before the per-site table installs instead of after, so it can no longer be lost to a request timeout mid-loop. Networks above NetworkActivationInstaller::FAST_PATH_THRESHOLD now defer the per-site installs to that chunked background job instead of looping every site inline.
     *
     * @param bool $network_wide True when the plugin is being network-activated.
     *
     * @return void
     */
    public static function activate( $network_wide = false ): void {
        $network_wide = (bool) $network_wide;
        if ( $network_wide && is_multisite() ) {
            // Promote first: this is O(1) and is the part that must not be
            // lost if the per-site loop below times out.
            NetworkBootstrap::maybe_promote_site_1_settings();

            $site_count = (int) get_sites(
                array(
                    'number' => 0,
                    'count'  => true,
                )
            );

            if ( $site_count <= NetworkActivationInstaller::FAST_PATH_THRESHOLD ) {
                $site_ids = get_sites(
                    array(
                        'number' => 0,
                        'fields' => 'ids',
                    )
                );

                foreach ( $site_ids as $blog_id ) {
                    Installer::install_for_blog( (int) $blog_id );
                    QueueInstaller::install_for_blog( (int) $blog_id );
                }
            } else {
                NetworkActivationInstaller::start_or_continue();
            }

            // Network-scoped transient so the wizard redirect fires in Network Admin.
            set_site_transient( SetupWizard::SHOULD_RUN_TRANSIENT, true, MINUTE_IN_SECONDS * 5 );
        } else {
            Installer::install();
            QueueInstaller::install();
            set_transient( SetupWizard::SHOULD_RUN_TRANSIENT, true, MINUTE_IN_SECONDS * 5 );
            // Separate one-shot flag for the conflict check so it cannot
            // consume the wizard redirect transient above. Stored as a
            // non-autoloaded option rather than a transient so a wizard
            // session longer than a few minutes doesn't cause the flag to
            // expire before the notice has ever been shown.
            update_option( self::CONFLICT_CHECK_OPTION, time(), false );
        }

        ReviewRequest::schedule_on_activation();

        self::activation_checks();
    }

    /**
     * Run activation checks for encryption prerequisites.
     *
     * Called from the activation hook to validate that the server environment
     * supports the required encryption operations.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public static function activation_checks(): void {
        if ( ! Encryption::is_openssl_available() ) {
            set_transient(
                'nimblymail_openssl_missing',
                esc_html__( 'NimblyMail requires the OpenSSL PHP extension for secure credential storage. Please enable ext-openssl and reactivate the plugin.', 'nimblymail-smtp' ),
                60
            );
        }

        if ( ! Encryption::are_salts_configured() ) {
            set_transient(
                'nimblymail_salts_warning',
                esc_html__( 'NimblyMail uses WordPress security salts (AUTH_KEY and NONCE_KEY) to encrypt credentials. Please configure unique salts in wp-config.php.', 'nimblymail-smtp' ),
                60
            );
        }
    }

    /**
     * Get the Processor instance.
     *
     * @since 1.0.0
     *
     * @return Processor
     */
    public function get_processor(): Processor {
        return $this->processor;
    }

    /**
     * Get the plugin version.
     *
     * @since 1.0.0
     * @return string
     */
    public function version(): string {
        return NIMBLYMAIL_VERSION;
    }

    /**
     * Whether the NimblyMail Pro add-on is active.
     *
     * Detects Pro by the `NIMBLYMAIL_PRO_VERSION` constant the Pro plugin
     * defines at file-load time — symmetric to how Pro detects the free
     * plugin via `NIMBLYMAIL_VERSION`. Used to gate Pro-only UI affordances
     * such as the upsell card on the Connection tab.
     *
     * @since 1.0.0
     *
     * @return bool True when the Pro add-on is active, false otherwise.
     */
    public function is_pro_active(): bool {
        return defined( 'NIMBLYMAIL_PRO_VERSION' );
    }

    /**
     * Whether the current request should surface Pro upsell affordances.
     *
     * One source of truth for every upsell touchpoint in the plugin (Upgrade
     * tab, plugin row link, dashboard footer, empty-state nudges, wizard
     * complete step, health audit hints). Wraps the truth table proven out
     * for the Upgrade tab so every surface stays consistent — hidden when
     * Pro is active, hidden on multisite per-site screens to non-super-admins
     * (they can't install Pro plugins anyway), and visible otherwise.
     *
     * Surfaces that have no current-user context (e.g. failure-alert emails
     * sent from cron) should call {@see self::is_pro_active()} directly and
     * negate it — those audiences may include the network admin even when the
     * triggering site user isn't.
     *
     * @since 1.0.0
     *
     * @param string $context Resolution context: 'site' | 'network' | 'network-site'.
     *
     * @return bool True when the upsell should be visible to the current viewer.
     */
    public function can_show_upsell( string $context = 'site' ): bool {
        return SettingsProvider::should_show_upgrade_tab(
            $context,
            $this->is_pro_active(),
            is_multisite(),
            is_super_admin()
        );
    }

    /**
     * Build the canonical Pro pricing/marketing URL with surface attribution.
     *
     * Centralises the UTM strategy so every upsell surface tags its
     * referrals consistently — `utm_source=plugin`, `utm_campaign=upsell`,
     * and a per-surface `utm_medium`. Filterable via `nimblymail_pro_url`
     * so white-label distributions can swap the target without forking
     * every surface that links out.
     *
     * @since 1.0.0
     *
     * @param string $medium Per-surface attribution tag (e.g. 'plugin-row',
     *                       'dashboard', 'empty-logs', 'failure-alert'). Free-
     *                       form; recorded in analytics. Sanitised to URL-safe
     *                       characters by `esc_url_raw()` on the way out.
     *
     * @return string Pricing page URL with UTM parameters appended.
     */
    public static function get_pro_url( string $medium = 'plugin' ): string {
        $url = add_query_arg(
            array(
                'utm_source'   => 'plugin',
                'utm_medium'   => $medium,
                'utm_campaign' => 'upsell',
            ),
            'https://nimblymail.com/pricing/'
        );

        /**
         * Filter the Pro upsell URL before it is escaped for output.
         *
         * @since 1.0.0
         *
         * @param string $url    The composed Pro URL with UTM parameters.
         * @param string $medium The surface attribution tag passed to get_pro_url().
         */
        $url = (string) apply_filters( 'nimblymail_pro_url', $url, $medium );

        return esc_url_raw( $url );
    }

    /**
     * Prevent cloning.
     *
     * @since 1.0.0
     */
    private function __clone() {}

    /**
     * Prevent unserialization.
     *
     * @since 1.0.0
     *
     * @throws Exception Always.
     */
    public function __wakeup() {
        throw new Exception( 'Cannot unserialize singleton.' );
    }

    /**
     * Ensure PHPMailer dependencies are loaded.
     *
     * @since 1.0.0
     * @return void
     */
    private function maybe_load_phpmailer_dependencies(): void {
        if ( ! interface_exists( 'PHPMailer\PHPMailer\OAuthTokenProvider' ) ) {
            $path = ABSPATH . WPINC . '/PHPMailer/OAuthTokenProvider.php';
            if ( file_exists( $path ) ) {
                require_once $path;
            }
        }
    }
}
