<?php
if ( ! defined( 'ABSPATH' ) ) exit;

add_action( 'wp_ajax_wpmm_run_update',       'wpmm_ajax_run_update' );
add_action( 'wp_ajax_wpmm_send_email',       'wpmm_ajax_send_email' );
add_action( 'wp_ajax_wpmm_resend_email',     'wpmm_ajax_resend_email' );
add_action( 'wp_ajax_wpmm_get_updates',      'wpmm_ajax_get_updates' );
add_action( 'wp_ajax_wpmm_get_email_body',   'wpmm_ajax_get_email_body' );
add_action( 'wp_ajax_wpmm_batch_complete',   'wpmm_ajax_batch_complete' );
add_action( 'wp_ajax_wpmm_queue_session',       'wpmm_ajax_queue_session' );
add_action( 'wp_ajax_wpmm_clear_session',       'wpmm_ajax_clear_session' );
add_action( 'wp_ajax_wpmm_preview_email',       'wpmm_ajax_preview_email' );
add_action( 'wp_ajax_wpmm_disable_auto_updates','wpmm_ajax_disable_auto_updates' );
// wpmm_save_settings is registered in admin/settings.php

// ── Shared capability check ───────────────────────────────────────────────────
/**
 * Verifies the nonce and base capability for all Greenskeeper AJAX actions.
 * For multisite cross-site requests (site_id targets a different blog), also
 * requires super admin / manage_network — a site-level admin must not be able
 * to trigger actions on another site by passing an arbitrary site_id.
 */
function wpmm_ajax_cap_check() {
    check_ajax_referer( 'wpmm_nonce', 'nonce' );
    if ( ! current_user_can( wpmm_required_cap() ) ) {
        wp_send_json_error( 'Permission denied.' );
    }
}

/**
 * Extended cap check for actions that accept a site_id parameter.
 * Validates that the site exists and — when targeting a different site —
 * that the current user has network-level permission.
 * Returns the validated site ID (0 = current site, no switch needed).
 */
function wpmm_ajax_cap_check_with_site() {
    wpmm_ajax_cap_check();

    // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in wpmm_ajax_cap_check().
    $site_id = absint( $_POST['site_id'] ?? 0 );

    if ( ! is_multisite() || $site_id === 0 || $site_id === get_current_blog_id() ) {
        return $site_id; // Same-site request — existing capability is sufficient.
    }

    // Cross-site request: verify the site exists.
    $site = get_site( $site_id );
    if ( ! $site ) {
        wp_send_json_error( 'Invalid site ID.' );
    }

    // Cross-site request: require network-level permission.
    if ( ! current_user_can( 'manage_network' ) && ! is_super_admin() ) {
        wp_send_json_error( 'Network administrator permission required to perform actions on another site.' );
    }

    return $site_id;
}

// ── Run a single update ───────────────────────────────────────────────────────
function wpmm_ajax_run_update() {
    // Validates nonce, base capability, AND cross-site permission if site_id
    // targets a different blog. Returns the validated site_id.
    $site_id = wpmm_ajax_cap_check_with_site();

    // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- set_time_limit is required for long-running plugin updates on shared hosting.
    if ( function_exists( 'set_time_limit' ) ) {
        set_time_limit( 300 ); // 5 minutes per individual update.
    }

    // Nonce verified by wpmm_ajax_cap_check_with_site() above — phpcs cannot
    // trace through the function call so suppression is required here.
    // phpcs:disable WordPress.Security.NonceVerification.Missing
    $type       = sanitize_text_field( wp_unslash( $_POST['item_type']  ?? '' ) );
    $slug       = sanitize_text_field( wp_unslash( $_POST['item_slug']  ?? '' ) );
    $session_id = sanitize_text_field( wp_unslash( $_POST['session_id'] ?? '' ) );
    $package    = esc_url_raw( wp_unslash( $_POST['package']    ?? '' ) );
    $admin_id   = absint( $_POST['admin_id'] ?? 0 );
    // phpcs:enable WordPress.Security.NonceVerification.Missing

    // Store admin_id in a global so wpmm_do_update() → pending sessions
    // storage can read it without it needing to be passed through every
    // function signature — fixing Codex audit issue #5.
    $GLOBALS['wpmm_performing_admin_id'] = $admin_id;

    if ( ! $type || ! $slug ) {
        wp_send_json_error( 'Missing parameters.' );
    }

    // ── Active plugin snapshot ────────────────────────────────────────────────
    // IMPORTANT: snapshot must be taken BEFORE switch_to_blog() so we capture
    // the true network state. After switch_to_blog(), get_option('active_plugins')
    // returns the sub-site's plugin list, not the main site's.
    //
    // On multisite, plugins can be active in THREE places:
    //   active_sitewide_plugins — network option (network/sitewide activation)
    //   active_plugins on blog 1 — primary site's per-site activations
    //   active_plugins on blog N — each sub-site's per-site activations
    //
    // CPTUI and similar plugins activated only on a specific sub-site are stored
    // in THAT sub-site's active_plugins option — a completely separate database
    // row. Previous versions only snapshotted blog 1's active_plugins and missed
    // all sub-site-specific activations entirely.
    //
    // We snapshot ALL sites' active_plugins on multisite so any sub-site plugin
    // deactivated as collateral damage can be detected and restored.
    //
    // We use get_site_transient/set_site_transient (network-level) rather than
    // get_transient/set_transient (per-blog) because per-blog transients are
    // stored in the current blog's options table and are lost when blog context
    // switches mid-request via switch_to_blog().
    $snapshot_key = 'wpmm_active_snapshot_' . md5( $session_id ?: 'default' );
    $snapshot     = get_site_transient( $snapshot_key );
    if ( false === $snapshot ) {
        // Build per-site active_plugins map: [ blog_id => [ plugin slugs ] ]
        $per_site_active = [];
        if ( is_multisite() ) {
            try {
                $sites = get_sites( [ 'number' => 200, 'fields' => 'ids' ] );
                foreach ( $sites as $bid ) {
                    switch_to_blog( (int) $bid );
                    $per_site_active[ (int) $bid ] = (array) get_option( 'active_plugins', [] );
                    restore_current_blog();
                }
            } catch ( \Throwable $e ) {
                // If the per-site loop fails on a specific multisite configuration,
                // fall back gracefully — the primary site and sitewide snapshots
                // still provide partial protection.
                restore_current_blog();
                $per_site_active = [];
            }
        } else {
            $per_site_active[1] = (array) get_option( 'active_plugins', [] );
        }

        $snapshot = [
            'active_plugins'          => (array) get_option( 'active_plugins', [] ),
            'active_sitewide_plugins' => is_multisite()
                ? (array) get_site_option( 'active_sitewide_plugins', [] )
                : [],
            'per_site_active'         => $per_site_active,
        ];
        set_site_transient( $snapshot_key, $snapshot, 30 * MINUTE_IN_SECONDS );
    }
    $active_before     = $snapshot['active_plugins'];
    $sitewide_before   = $snapshot['active_sitewide_plugins'];
    $per_site_before   = $snapshot['per_site_active'] ?? [];
    // ─────────────────────────────────────────────────────────────────────────

    $switched = false;
    if ( is_multisite() && $site_id > 0 && $site_id !== get_current_blog_id() ) {
        switch_to_blog( $site_id );
        $switched = true;
    }

    $GLOBALS['wpmm_session_id'] = $session_id;
    $is_retry = ! empty( $_POST['is_retry'] ) && (int) $_POST['is_retry'] === 1; // phpcs:ignore WordPress.Security.NonceVerification.Missing

    // ── Already succeeded this session? ──────────────────────────────────────
    // Only check on the initial batch run — never on explicit retries.
    // This prevents the already_succeeded guard from blocking retries and
    // prevents cascading false positives when one failure causes subsequent
    // plugins to be flagged as already succeeded.
    if ( ! $is_retry && $session_id && $slug ) {
        global $wpdb;
        $already_succeeded = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            'SELECT COUNT(*) FROM ' . esc_sql( $wpdb->prefix . 'wpmm_update_log' ) .
            ' WHERE session_id = %s AND item_slug = %s AND status = %s',
            $session_id, $slug, 'success'
        ) );
        if ( $already_succeeded ) {
            wp_send_json_success( [
                'status'     => 'already_succeeded',
                'error_code' => 'wpmm_already_succeeded',
                'severity'   => 'info',
                'message'    => 'This item was already updated successfully in this session. '
                              . 'Check the Update Log to confirm. Retry only if you need to force a fresh update.',
                'slug'       => $slug,
                'name'       => $slug,
            ] );
        }
    }

    $result = wpmm_do_update( $type, $slug, $package );

    // Restore blog context BEFORE reading post-update plugin state.
    // The snapshot was taken in the main site context (before switch_to_blog),
    // so the comparison must also happen in the main site context.
    if ( $switched ) {
        restore_current_blog();
        $switched = false; // Prevent double-restore below.
    }

    // ── Restore collaterally deactivated plugins ──────────────────────────────
    if ( $type === 'plugin' ) {
        $active_after   = (array) get_option( 'active_plugins', [] );
        $sitewide_after = is_multisite()
            ? (array) get_site_option( 'active_sitewide_plugins', [] )
            : [];

        $restored = [];

        // Restore primary site-level plugins.
        $deactivated = array_diff( $active_before, $active_after );
        $to_restore  = array_values( array_filter( $deactivated, function( $p ) use ( $slug ) {
            return $p !== $slug;
        } ) );
        if ( ! empty( $to_restore ) ) {
            $new_active = array_unique( array_merge( $active_after, $to_restore ) );
            sort( $new_active );
            update_option( 'active_plugins', $new_active );
            $restored = array_merge( $restored, $to_restore );

            // Some plugins (WooCommerce, WP Rocket, etc.) register deactivation
            // hooks that interfere with direct option writes. For these, also call
            // activate_plugin() to fire their activation hooks properly and ensure
            // they are fully re-initialized after collateral deactivation.
            $hook_sensitive = array_filter( $to_restore, function( $p ) {
                $hook_plugins = [ 'woocommerce', 'wp-rocket', 'wordfence', 'really-simple-ssl' ];
                foreach ( $hook_plugins as $known ) {
                    if ( strpos( $p, $known ) !== false ) {
                        return true;
                    }
                }
                return false;
            } );
            foreach ( $hook_sensitive as $p ) {
                if ( ! is_plugin_active( $p ) ) {
                    activate_plugin( $p, '', is_multisite() );
                }
            }
        } else {
            $new_active = $active_after;
        }

        // Restore network-activated plugins (multisite only).
        if ( is_multisite() && ! empty( $sitewide_before ) ) {
            $deactivated_keys = array_diff_key( $sitewide_before, $sitewide_after );
            unset( $deactivated_keys[ $slug ] );
            if ( ! empty( $deactivated_keys ) ) {
                $new_sitewide = array_merge( $sitewide_after, $deactivated_keys );
                update_site_option( 'active_sitewide_plugins', $new_sitewide );
                $restored = array_merge( $restored, array_keys( $deactivated_keys ) );
            } else {
                $new_sitewide = $sitewide_after;
            }
        } else {
            $new_sitewide = $sitewide_after;
        }

        // Restore per-site active plugins across ALL sub-sites.
        $new_per_site = $per_site_before;
        if ( is_multisite() && ! empty( $per_site_before ) ) {
            try {
                foreach ( $per_site_before as $bid => $plugins_before ) {
                    switch_to_blog( (int) $bid );
                    $plugins_after = (array) get_option( 'active_plugins', [] );
                    restore_current_blog();

                    $site_deactivated = array_diff( $plugins_before, $plugins_after );
                    $site_to_restore  = array_values( array_filter(
                        $site_deactivated,
                        function( $p ) use ( $slug ) { return $p !== $slug; }
                    ) );

                    if ( ! empty( $site_to_restore ) ) {
                        $new_site_active = array_unique( array_merge( $plugins_after, $site_to_restore ) );
                        sort( $new_site_active );
                        switch_to_blog( (int) $bid );
                        update_option( 'active_plugins', $new_site_active );
                        restore_current_blog();
                        $new_per_site[ (int) $bid ] = $new_site_active;
                        $restored = array_merge( $restored, array_map( function( $p ) use ( $bid ) {
                            return 'blog:' . $bid . ':' . $p;
                        }, $site_to_restore ) );
                    }
                }
            } catch ( \Throwable $e ) {
                restore_current_blog();
            }
        }

        if ( ! empty( $restored ) ) {
            set_site_transient( $snapshot_key, [
                'active_plugins'          => $new_active,
                'active_sitewide_plugins' => $new_sitewide,
                'per_site_active'         => $new_per_site,
            ], 30 * MINUTE_IN_SECONDS );
            // Format restored list for UI display — strip blog: prefix for readability.
            $result['collateral_restored'] = array_map( function( $p ) {
                return preg_replace( '/^blog:\d+:/', '', $p );
            }, $restored );
        }
    }
    // ─────────────────────────────────────────────────────────────────────────

    if ( $switched ) {
        restore_current_blog();
    }

    wp_send_json_success( $result );
}

// ── Send email report ─────────────────────────────────────────────────────────
function wpmm_ajax_send_email() {
    wpmm_ajax_cap_check();

    $to       = sanitize_email( wp_unslash( $_POST['to_email'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified via wpmm_ajax_cap_check().
    $subject  = sanitize_text_field( wp_unslash( $_POST['subject']  ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
    $admin_id = absint( $_POST['admin_id'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing

    // ── Session resolution strategy ──────────────────────────────────────────
    // Priority order:
    // 1. session_id explicitly posted (same-page flow from Updates page) →
    //    use that single session only.
    // 2. wpmm_pending_sessions option (cross-page flow) → fetch ALL unsent
    //    sessions so plugins updated in one session and themes in another
    //    both appear in the same email report.
    // 3. wpmm_last_session fallback for backward compatibility.
    // 4. Empty → fall back to the 100 most recent log entries.
    $posted_session = sanitize_text_field( wp_unslash( $_POST['session_id'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.

    // ── Performing administrator resolution (Codex issue #5) ─────────────────
    // When the email is sent from the Updates page in the same browser session,
    // admin_id is passed directly via POST. When sent from the Email Reports
    // page after navigation (cross-page flow), admin_id is 0 because the JS
    // variable was lost on page load. In this case, use the admin_id stored
    // in the most recent pending session entry, which was persisted when the
    // update ran. Fall back to the site's default admin if neither is set.
    if ( ! $admin_id ) {
        $pending_for_admin = get_option( 'wpmm_pending_sessions', [] );
        if ( ! empty( $pending_for_admin ) ) {
            // Use the most recent session's admin_id.
            $last_pending = end( $pending_for_admin );
            $admin_id     = absint( $last_pending['admin_id'] ?? 0 );
        }
        if ( ! $admin_id ) {
            $s        = wpmm_get_settings();
            $admin_id = absint( $s['default_admin_id'] ?? 0 );
        }
    }

    if ( ! is_email( $to ) ) {
        wp_send_json_error( 'Invalid email address.' );
    }

    global $wpdb;

    if ( $posted_session ) {
        // ── Session-specific send (from Update Log "Send Report →" or Updates page) ──
        // Read ALL entries for this session directly from the update log table.
        // This is the same query the Update Log page uses — guaranteed to be complete
        // regardless of pending_sessions state, retries, or page navigation.
        $last    = get_option( 'wpmm_last_session', [] );
        $blog_id = isset( $last['blog_id'] ) ? (int) $last['blog_id'] : get_current_blog_id();

        // If we can identify the blog from pending sessions, prefer that.
        $pending_all = get_option( 'wpmm_pending_sessions', [] );
        foreach ( $pending_all as $p ) {
            if ( isset( $p['session_id'] ) && $p['session_id'] === $posted_session ) {
                $blog_id = (int) ( $p['blog_id'] ?? $blog_id );
                break;
            }
        }

        $switched = false;
        if ( is_multisite() && $blog_id && $blog_id !== get_current_blog_id() ) {
            switch_to_blog( $blog_id );
            $switched = true;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $log_entries = $wpdb->get_results( $wpdb->prepare(
            'SELECT * FROM ' . esc_sql( $wpdb->prefix . 'wpmm_update_log' ) .
            ' WHERE session_id = %s ORDER BY updated_at ASC',
            $posted_session
        ) ) ?: [];

        if ( $switched ) { restore_current_blog(); }

    } else {
        // Cross-page flow — fetch ALL pending (unsent) sessions.
        $pending = get_option( 'wpmm_pending_sessions', [] );

        if ( ! empty( $pending ) ) {
            // Collect entries from every pending session, grouped by blog.
            $log_entries = [];
            $seen_blogs  = [];

            foreach ( $pending as $p ) {
                $sid     = isset( $p['session_id'] ) ? $p['session_id'] : '';
                $blog_id = isset( $p['blog_id'] )    ? (int) $p['blog_id'] : get_current_blog_id();
                if ( ! $sid ) { continue; }

                $switched = false;
                if ( is_multisite() && $blog_id && $blog_id !== get_current_blog_id() ) {
                    switch_to_blog( $blog_id );
                    $switched = true;
                }

                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $rows = $wpdb->get_results( $wpdb->prepare(
                    'SELECT * FROM ' . esc_sql( $wpdb->prefix . 'wpmm_update_log' ) . ' WHERE session_id = %s ORDER BY updated_at ASC',
                    $sid
                ) );

                if ( $switched ) { restore_current_blog(); }

                $log_entries = array_merge( $log_entries, $rows ?: [] );
            }

            // Sort merged entries chronologically.
            usort( $log_entries, function( $a, $b ) {
                return strcmp( $a->updated_at, $b->updated_at );
            } );

        } else {
            // No pending sessions — try wpmm_last_session for backward compat.
            $last    = get_option( 'wpmm_last_session', [] );
            $blog_id = isset( $last['blog_id'] ) ? (int) $last['blog_id'] : get_current_blog_id();

            $switched = false;
            if ( is_multisite() && $blog_id && $blog_id !== get_current_blog_id() ) {
                switch_to_blog( $blog_id );
                $switched = true;
            }

            if ( ! empty( $last['session_id'] ) ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $log_entries = $wpdb->get_results( $wpdb->prepare(
                    'SELECT * FROM ' . esc_sql( $wpdb->prefix . 'wpmm_update_log' ) . ' WHERE session_id = %s ORDER BY updated_at ASC',
                    $last['session_id']
                ) );
            } else {
                // No session at all — use most recent 100 entries.
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $log_entries = $wpdb->get_results( $wpdb->prepare(
                    'SELECT * FROM ' . esc_sql( $wpdb->prefix . 'wpmm_update_log' ) . ' ORDER BY updated_at DESC LIMIT %d', // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    100
                ) );
            }

            if ( $switched ) { restore_current_blog(); }
        }
    }

    // Update note and manual updates added on the Email Reports page.
    $update_note = sanitize_textarea_field( wp_unslash( $_POST['update_note'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing

    // Manual updates added on the Email Reports page.
    $manual_raw     = isset( $_POST['manual_entries'] ) ? sanitize_text_field( wp_unslash( $_POST['manual_entries'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
    $manual_entries = [];
    if ( $manual_raw ) {
        $decoded = json_decode( $manual_raw, true );
        if ( is_array( $decoded ) ) {
            foreach ( $decoded as $entry ) {
                $manual_entries[] = [
                    'name'        => sanitize_text_field( $entry['name']        ?? '' ),
                    'old_version' => sanitize_text_field( $entry['old_version'] ?? '' ),
                    'new_version' => sanitize_text_field( $entry['new_version'] ?? '' ),
                ];
            }
        }
    }

    // ── All-Sites network email mode — checked FIRST to avoid stale single-site send ──
    // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above.
    $network_all = ( isset( $_POST['network_all'] ) && (int) $_POST['network_all'] === 1 );
    if ( is_multisite() && $network_all ) {
        $sites            = get_sites( [ 'number' => 200, 'fields' => 'ids' ] );
        $sites_data       = [];
        $pending_sessions = get_option( 'wpmm_pending_sessions', [] );
        foreach ( $sites as $bid ) {
            switch_to_blog( $bid );
            $blog_sessions = array_filter( $pending_sessions, function( $p ) use ( $bid ) {
                return isset( $p['blog_id'] ) && (int) $p['blog_id'] === (int) $bid;
            } );
            if ( ! empty( $blog_sessions ) ) {
                $site_entries = [];
                foreach ( $blog_sessions as $bp ) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $rows = $wpdb->get_results( $wpdb->prepare(
                        'SELECT * FROM ' . esc_sql( $wpdb->prefix . 'wpmm_update_log' ) . ' WHERE session_id = %s ORDER BY updated_at ASC',
                        $bp['session_id']
                    ) );
                    $site_entries = array_merge( $site_entries, $rows ?: [] );
                }
            } else {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $site_entries = $wpdb->get_results( $wpdb->prepare(
                    'SELECT * FROM ' . esc_sql( $wpdb->prefix . 'wpmm_update_log' ) . ' ORDER BY updated_at DESC LIMIT %d',
                    50
                ) );
            }
            if ( ! empty( $site_entries ) ) {
                $sites_data[] = [
                    'blog_id'   => $bid,
                    'site_name' => get_bloginfo( 'name' ),
                    'site_url'  => get_bloginfo( 'url' ),
                    'entries'   => $site_entries,
                ];
            }
            restore_current_blog();
        }
        $body   = wpmm_build_network_email_body( $sites_data, $admin_id, $update_note );
        $result = wpmm_send_email( $to, $subject, $body, $admin_id, $update_note );
        if ( $result['success'] ) {
            delete_option( 'wpmm_pending_sessions' );
            wp_send_json_success( [ 'message' => 'Network report sent successfully.', 'email_id' => $result['email_id'] ] );
        } else {
            wp_send_json_error( 'Network email failed to send.' );
        }
        return; // Never fall through to single-site send.
    }

    // ── Single-site email ─────────────────────────────────────────────────────
    // Pass resend flag and original sent_at from the hidden fields posted by JS.
    // phpcs:ignore WordPress.Security.NonceVerification.Missing
    $is_resend        = ! empty( $_POST['is_resend'] ) && (int) $_POST['is_resend'] === 1;
    // phpcs:ignore WordPress.Security.NonceVerification.Missing
    $original_sent_at = sanitize_text_field( wp_unslash( $_POST['original_sent_at'] ?? '' ) );

    $body   = wpmm_build_email_body( $log_entries, $admin_id, $manual_entries, $update_note, $is_resend, $original_sent_at );
    $result = wpmm_send_email( $to, $subject, $body, $admin_id, $update_note, $posted_session );

    if ( $result['success'] ) {
        delete_option( 'wpmm_pending_sessions' );
        delete_option( 'wpmm_queued_session' ); // clear the queued session panel

        // Return enough data for the JS to prepend a new row to the history
        // table immediately without a page reload.
        // Use a timestamp-based fallback row ID if the DB insert_id is 0
        // (can happen when the email_log table is in a different blog context).
        $row_id = ! empty( $result['email_id'] ) ? $result['email_id'] : ( 'ts-' . time() );
        wp_send_json_success( [
            'message'    => 'Email sent successfully.',
            'email_id'   => $result['email_id'],
            'row'        => [
                'id'      => $row_id,
                'sent_at' => current_time( 'mysql' ),
                'to'      => $to,
                'subject' => $subject,
                'status'  => 'sent',
            ],
        ] );
    } else {
        wp_send_json_error( 'Email failed to send. Check your WordPress mail configuration.' );
    }
}

// ── Resend a previously sent email ────────────────────────────────────────────
function wpmm_ajax_resend_email() {
    wpmm_ajax_cap_check();

    global $wpdb;
    $email_id = absint( $_POST['email_id'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified via wpmm_ajax_cap_check().
    $row      = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        "SELECT * FROM {$wpdb->prefix}wpmm_email_log WHERE id = %d", $email_id
    ) );

    if ( ! $row ) {
        wp_send_json_error( 'Email record not found.' );
    }

    // Rebuild the email body using the current template (not the stale stored HTML)
    // so that resent emails always reflect the latest design changes.
    // Use the stored session_id to fetch the original log entries.
    $body = $row->body; // fallback: use stored body if we can't rebuild
    if ( ! empty( $row->session_id ) ) {
        $log_entries = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            "SELECT * FROM {$wpdb->prefix}wpmm_update_log
             WHERE session_id = %s ORDER BY updated_at ASC",
            $row->session_id
        ) );
        if ( $log_entries ) {
            $body = wpmm_build_email_body( $log_entries );
        }
    }

    $result = wpmm_send_email( $row->to_email, $row->subject, $body );
    if ( $result['success'] ) {
        wp_send_json_success( 'Email resent successfully.' );
    } else {
        wp_send_json_error( 'Resend failed.' );
    }
}

// ── Retrieve available updates ────────────────────────────────────────────────
function wpmm_ajax_get_updates() {
    $site_id = wpmm_ajax_cap_check_with_site();
    $updates = wpmm_get_available_updates( $site_id );
    wp_send_json_success( $updates );
}

// ── Fetch email body for preview modal ───────────────────────────────────────
// Always rebuilds the body from the original log entries using the current
// template so that preview reflects the latest design, not stale stored HTML.
function wpmm_ajax_get_email_body() {
    wpmm_ajax_cap_check();

    global $wpdb;
    $email_id = absint( $_POST['email_id'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified via wpmm_ajax_cap_check().
    $row      = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        "SELECT * FROM {$wpdb->prefix}wpmm_email_log WHERE id = %d",
        $email_id
    ) );

    if ( ! $row ) {
        wp_send_json_error( 'Email record not found.' );
    }

    // Stored note — may be null for emails sent before v2.1.6.
    $stored_note = $row->note ?? '';

    // Rebuild body from log entries if we have a session_id stored,
    // passing the stored note so it appears correctly in the preview.
    $body = $row->body;
    if ( ! empty( $row->session_id ) ) {
        $log_entries = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            "SELECT * FROM {$wpdb->prefix}wpmm_update_log
             WHERE session_id = %s ORDER BY updated_at ASC",
            $row->session_id
        ) );
        if ( ! empty( $log_entries ) ) {
            $body = wpmm_build_email_body( $log_entries, 0, [], $stored_note );
        }
    }

    wp_send_json_success( [
        'to_email' => $row->to_email,
        'subject'  => $row->subject,
        'body'     => $body,
        'note'     => $stored_note,
        'status'   => $row->status,
        'sent_at'  => $row->sent_at,
    ] );
}

// ── Autocomplete: search item names in the log ────────────────────────────────
add_action( 'wp_ajax_wpmm_search_items', 'wpmm_ajax_search_items' );

/**
 * Called by JS when a batch of updates finishes.
 * Fires the admin notification email if the appropriate setting is enabled.
 * Receives the session results as a JSON-encoded array.
 */
function wpmm_ajax_batch_complete() {
    wpmm_ajax_cap_check();

    // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified via wpmm_ajax_cap_check()
    $session_id  = sanitize_text_field( wp_unslash( $_POST['session_id'] ?? '' ) );
    $admin_id    = absint( $_POST['admin_id'] ?? 0 );
    $results_raw = sanitize_text_field( wp_unslash( $_POST['results'] ?? '[]' ) );
    // phpcs:enable

    $results = [];
    $decoded = json_decode( $results_raw, true );
    if ( is_array( $decoded ) ) {
        foreach ( $decoded as $r ) {
            $results[] = [
                'slug'        => sanitize_text_field( $r['slug']        ?? '' ),
                'name'        => sanitize_text_field( $r['name']        ?? '' ),
                'status'      => sanitize_text_field( $r['status']      ?? '' ),
                'old_version' => sanitize_text_field( $r['old_version'] ?? '' ),
                'new_version' => sanitize_text_field( $r['new_version'] ?? '' ),
                'error_code'  => sanitize_text_field( $r['error_code']  ?? '' ),
                'message'     => sanitize_text_field( $r['message']     ?? '' ),
            ];
        }
    }

    if ( ! empty( $results ) ) {
        wpmm_send_admin_notification( $results, $admin_id, $session_id );
    }

    wp_send_json_success( [ 'notified' => ! empty( $results ) ] );
}

/**
 * Queue a specific session for sending on the Email Reports page.
 * Stores the session_id persistently so it survives navigation.
 * Called when admin clicks "Send to Email Reports" in the Update Log.
 */
/**
 * Generate a pre-send email preview from the current form state.
 * Builds the email body exactly as it would be sent — including the
 * current note, manual entries, and queued session data — but does
 * not send or log anything. Returns the HTML body for iframe display.
 */
function wpmm_ajax_preview_email() {
    check_ajax_referer( 'wpmm_nonce', 'nonce' );
    if ( ! current_user_can( wpmm_required_cap() ) ) {
        wp_send_json_error( 'Permission denied.' );
    }

    global $wpdb;

    // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified above.
    $session_id   = sanitize_text_field( wp_unslash( $_POST['session_id']   ?? '' ) );
    $update_note  = sanitize_textarea_field( wp_unslash( $_POST['update_note'] ?? '' ) );
    $admin_id     = absint( $_POST['admin_id'] ?? 0 );
    $is_resend    = ! empty( $_POST['is_resend'] ) && (int) $_POST['is_resend'] === 1;
    $orig_sent_at = sanitize_text_field( wp_unslash( $_POST['original_sent_at'] ?? '' ) );
    $manual_raw   = sanitize_text_field( wp_unslash( $_POST['manual_entries'] ?? '' ) );
    // phpcs:enable

    // Parse manual entries.
    $manual_entries = [];
    if ( $manual_raw ) {
        $decoded = json_decode( $manual_raw, true );
        if ( is_array( $decoded ) ) {
            foreach ( $decoded as $entry ) {
                if ( ! empty( $entry['name'] ) ) {
                    $manual_entries[] = [
                        'name'        => sanitize_text_field( $entry['name']        ?? '' ),
                        'old_version' => sanitize_text_field( $entry['old_version'] ?? '' ),
                        'new_version' => sanitize_text_field( $entry['new_version'] ?? '' ),
                        'type'        => sanitize_text_field( $entry['type']        ?? 'plugin' ),
                    ];
                }
            }
        }
    }

    // Resolve log entries — same logic as the send handler.
    $log_entries = [];
    if ( $session_id ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $log_entries = $wpdb->get_results( $wpdb->prepare(
            'SELECT * FROM ' . esc_sql( $wpdb->prefix . 'wpmm_update_log' ) .
            ' WHERE session_id = %s ORDER BY updated_at ASC',
            $session_id
        ) ) ?: [];
    }

    // Fall back to pending sessions if no session_id posted.
    if ( empty( $log_entries ) ) {
        $pending = get_option( 'wpmm_pending_sessions', [] );
        foreach ( $pending as $p ) {
            $sid = $p['session_id'] ?? '';
            if ( ! $sid ) { continue; }
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $rows = $wpdb->get_results( $wpdb->prepare(
                'SELECT * FROM ' . esc_sql( $wpdb->prefix . 'wpmm_update_log' ) .
                ' WHERE session_id = %s ORDER BY updated_at ASC',
                $sid
            ) ) ?: [];
            $log_entries = array_merge( $log_entries, $rows );
        }
    }

    // Resolve admin.
    if ( ! $admin_id ) {
        $pending_all = get_option( 'wpmm_pending_sessions', [] );
        if ( ! empty( $pending_all ) ) {
            $last_p   = end( $pending_all );
            $admin_id = absint( $last_p['admin_id'] ?? 0 );
        }
        if ( ! $admin_id ) {
            $s        = wpmm_get_settings();
            $admin_id = absint( $s['default_admin_id'] ?? 0 );
        }
    }

    $body = wpmm_build_email_body(
        $log_entries,
        $admin_id,
        $manual_entries,
        $update_note,
        $is_resend,
        $orig_sent_at
    );

    wp_send_json_success( [ 'body' => $body ] );
}

/**
 * Disable auto-updates for all plugins and themes in one action.
 * Clears the auto_update_plugins and auto_update_themes options,
 * logs the action to the Site Activity Log, and returns a summary.
 */
function wpmm_ajax_disable_auto_updates() {
    check_ajax_referer( 'wpmm_nonce', 'nonce' );
    if ( ! current_user_can( wpmm_required_cap() ) ) {
        wp_send_json_error( 'Permission denied.' );
    }

    // ── Count currently enabled auto-updates ─────────────────────────────────
    $auto_plugins = (array) get_option( 'auto_update_plugins', [] );
    $auto_themes  = (array) get_option( 'auto_update_themes',  [] );
    $plugin_count = count( array_filter( $auto_plugins ) );
    $theme_count  = count( array_filter( $auto_themes ) );

    // ── Disable all ───────────────────────────────────────────────────────────
    update_option( 'auto_update_plugins', [] );
    update_option( 'auto_update_themes',  [] );

    // Also disable the site-level auto-update core options if set.
    update_option( 'auto_update_core_dev',   false );
    update_option( 'auto_update_core_minor', false );
    update_option( 'auto_update_core_major', false );

    // ── Log to Site Activity Log ──────────────────────────────────────────────
    if ( function_exists( 'wpmm_log_activity' ) ) {
        wpmm_log_activity(
            'auto_updates_disabled',
            WPMM_ACTIVITY_SITE,
            sprintf(
                'Auto-updates disabled for %d plugin%s and %d theme%s via Greenskeeper.',
                $plugin_count,
                $plugin_count !== 1 ? 's' : '',
                $theme_count,
                $theme_count !== 1 ? 's' : ''
            ),
            [
                'plugins_disabled' => $plugin_count,
                'themes_disabled'  => $theme_count,
            ],
            get_current_user_id()
        );
    }

    wp_send_json_success( [
        'plugins_disabled' => $plugin_count,
        'themes_disabled'  => $theme_count,
        'total'            => $plugin_count + $theme_count,
    ] );
}

function wpmm_ajax_queue_session() {
    check_ajax_referer( 'wpmm_nonce', 'nonce' );
    if ( ! current_user_can( wpmm_required_cap() ) ) {
        wp_send_json_error( 'Permission denied.' );
    }
    $session_id = sanitize_text_field( wp_unslash( $_POST['session_id'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
    if ( ! $session_id ) {
        wp_send_json_error( 'No session ID provided.' );
    }
    update_option( 'wpmm_queued_session', $session_id, false );
    wp_send_json_success( [ 'session_id' => $session_id ] );
}

/**
 * Clear the queued session from Email Reports.
 * Called when admin clicks "Clear Session" on Email Reports.
 */
function wpmm_ajax_clear_session() {
    check_ajax_referer( 'wpmm_nonce', 'nonce' );
    if ( ! current_user_can( wpmm_required_cap() ) ) {
        wp_send_json_error( 'Permission denied.' );
    }
    delete_option( 'wpmm_queued_session' );
    wp_send_json_success();
}

function wpmm_ajax_search_items() {
    wpmm_ajax_cap_check();

    global $wpdb;
    $term = sanitize_text_field( wp_unslash( $_POST['term'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified via wpmm_ajax_cap_check().

    if ( strlen( $term ) < 1 ) {
        wp_send_json_success( [] );
    }

    $results = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        "SELECT DISTINCT item_name
         FROM {$wpdb->prefix}wpmm_update_log
         WHERE item_name LIKE %s
         ORDER BY item_name ASC
         LIMIT 20",
        '%' . $wpdb->esc_like( $term ) . '%'
    ) );

    wp_send_json_success( $results ?: [] );
}
