<?php
/**
 * AuctionForge Auto Updater Class
 * Handles automatic updates from WP Plugin Deployer server
 */

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

class AuctionForge_Auto_Updater {
    
    private $plugin_slug;
    private $plugin_file;
    private $server_url;
    private $api_key;
    private $current_version;
    
    public function __construct($plugin_file, $current_version) {
        $this->plugin_file = $plugin_file;
        $this->current_version = $current_version;
        $this->plugin_slug = defined('AUCTIONFORGE_PLUGIN_SLUG') ? AUCTIONFORGE_PLUGIN_SLUG : basename(dirname(dirname(__FILE__)));
        
        // Get settings from options
        $this->server_url = get_option('auctionforge_update_server_url', 'https://wpdeploy.smoothbyteit.dev');
        $this->api_key = get_option('auctionforge_update_api_key', '');
        
        // Only register hooks if configured
        if ($this->is_configured()) {
            $this->init_hooks();
        }
        
        // Always add settings hooks for admin
        if (is_admin()) {
            add_action('admin_init', array($this, 'register_settings'));
            add_action('admin_notices', array($this, 'admin_notices'));
        }
    }
    
    /**
     * Check if updater is configured
     */
    private function is_configured() {
        return !empty($this->server_url) && !empty($this->api_key);
    }
    
    /**
     * Initialize hooks
     */
    private function init_hooks() {
        // Hook into WordPress update checks
        add_filter('pre_set_site_transient_update_plugins', array($this, 'check_for_updates'));
        add_filter('plugins_api', array($this, 'plugin_info'), 10, 3);
        
        // Add authorization header for downloads
        add_filter('http_request_args', array($this, 'add_download_auth'), 10, 2);
        
        // Check for pending updates hourly
        add_action('auctionforge_check_updates', array($this, 'check_pending_updates'));
        if (!wp_next_scheduled('auctionforge_check_updates')) {
            wp_schedule_event(time(), 'hourly', 'auctionforge_check_updates');
        }
        
        // Register REST API endpoint for remote trigger
        add_action('rest_api_init', array($this, 'register_rest_routes'));
        
        // Report health status
        add_action('auctionforge_report_health', array($this, 'report_health'));
        if (!wp_next_scheduled('auctionforge_report_health')) {
            wp_schedule_event(time(), 'twicedaily', 'auctionforge_report_health');
        }
        
        // Sync WP Defender vulnerabilities
        add_action('auctionforge_sync_vulnerabilities', array($this, 'sync_vulnerabilities'));
        if (!wp_next_scheduled('auctionforge_sync_vulnerabilities')) {
            wp_schedule_event(time(), 'twicedaily', 'auctionforge_sync_vulnerabilities');
        }
        
        // Sync user tracking data to central server
        add_action('auctionforge_sync_user_tracking', array($this, 'sync_user_tracking'));
        if (!wp_next_scheduled('auctionforge_sync_user_tracking')) {
            wp_schedule_event(time(), 'hourly', 'auctionforge_sync_user_tracking');
        }
        
        // Poll fraud enforcement setting from central server
        add_action('auctionforge_sync_fraud_enforcement', array($this, 'sync_fraud_enforcement'));
        if (!wp_next_scheduled('auctionforge_sync_fraud_enforcement')) {
            wp_schedule_event(time(), 'bp_ten_minutely', 'auctionforge_sync_fraud_enforcement');
        }

        add_action('auctionforge_sync_site_statistics', array($this, 'sync_site_statistics'));
        if (!wp_next_scheduled('auctionforge_sync_site_statistics')) {
            wp_schedule_event(time(), 'daily', 'auctionforge_sync_site_statistics');
        }

        // Immediate push of sync errors to wpdeploy (fires from SyncLogger::log on level=error).
        // Non-blocking HTTP — never blocks the originating request.
        add_action('auctionforge_sync_error_logged', array($this, 'push_sync_error_immediate'), 10, 5);

        // Safety-net cron: re-pushes recent error rows in case the immediate push was lost.
        // wpdeploy dedupes via UNIQUE(site_id, af_log_id) so duplicates are silent.
        add_action('auctionforge_retry_sync_errors', array($this, 'retry_sync_errors'));
        if (!wp_next_scheduled('auctionforge_retry_sync_errors')) {
            wp_schedule_event(time(), 'bp_minutely', 'auctionforge_retry_sync_errors');
        }

        // Watchdog: 5s after a SIGTERM is sent to active workers, escalate to SIGKILL
        // for any PIDs that are still alive.
        add_action('auctionforge_kill_watchdog', array($this, 'kill_watchdog'), 10, 1);

        // Uptime Kuma push heartbeat. wpdeploy provisions a per-site push
        // URL on first contact (or backfills via /report-health) and ships
        // it back as `kuma_push_url`; we persist it as an option and beat
        // every minute. Kuma's silence-detection window is 90s, so a
        // single missed wp-cron tick won't false-DOWN.
        add_action('auctionforge_kuma_heartbeat', array($this, 'kuma_heartbeat'));
        if (get_option('auctionforge_kuma_push_url') && !wp_next_scheduled('auctionforge_kuma_heartbeat')) {
            // Random first-fire offset so 50 sites' beats spread across a minute.
            wp_schedule_event(time() + mt_rand(0, 60), 'bp_minutely', 'auctionforge_kuma_heartbeat');
        }
    }
    
    /**
     * Register settings
     */
    public function register_settings() {
        register_setting('auctionforge_updater', 'auctionforge_update_server_url');
        register_setting('auctionforge_updater', 'auctionforge_update_api_key');
        register_setting('auctionforge_updater', 'auctionforge_auto_update', array('default' => true));
        register_setting('auctionforge_updater', 'auctionforge_fraud_enforcement', array('default' => false));
        register_setting('auctionforge_updater', 'auctionforge_kuma_push_url');
    }
    
    /**
     * Admin notices
     */
    public function admin_notices() {
        if (!$this->is_configured()) {
            $settings_url = admin_url('edit.php?post_type=lot&page=auctionforge-updates');
            echo '<div class="notice notice-warning is-dismissible">';
            echo '<p><strong>AuctionForge:</strong> Configure the update server to receive automatic updates. ';
            echo '<a href="' . esc_url($settings_url) . '">Configure Now</a></p>';
            echo '</div>';
        }

        $collision_msg = get_option('auctionforge_api_key_collision');
        if (!empty($collision_msg)) {
            $settings_url = admin_url('edit.php?post_type=lot&page=auctionforge-updates');
            echo '<div class="notice notice-error" style="border-left-color: #d63638; background: #fff8f8; padding: 10px;">';
            echo '<h3 style="margin-top: 0; color: #d63638;">⚠️ CRITICAL: API Key Identity Collision Detected</h3>';
            echo '<p style="font-size: 14px;"><strong>AuctionForge Network Sync Failed:</strong> ' . esc_html($collision_msg) . '</p>';
            echo '<p>This site is silently impersonating another website on your central WPDeploy server because it contains a cloned API Key. All fraud tracking, updates, and vulnerability scans have been forcibly suspended to prevent cross-contamination.</p>';
            echo '<p><a class="button button-primary" href="' . esc_url($settings_url) . '">Update API Key</a></p>';
            echo '</div>';
        }
    }
    
    /**
     * Make API request to deployment server
     */
    private function api_request($endpoint, $data = array(), $method = 'POST') {
        $url = rtrim($this->server_url, '/') . '/api.php/' . ltrim($endpoint, '/');
        
        $args = array(
            'method' => $method,
            'timeout' => 30,
            'headers' => array(
                'X-API-Key' => $this->api_key,
                'Content-Type' => 'application/json',
            ),
        );
        
        if ($method === 'POST' && !empty($data)) {
            $args['body'] = json_encode($data);
        }
        
        $response = wp_remote_request($url, $args);
        
        if (is_wp_error($response)) {
            return array('error' => $response->get_error_message());
        }
        
        $body = wp_remote_retrieve_body($response);
        $decoded = json_decode($body, true);
        
        // Handle API Key Collisions & Auto-Healing
        if (is_array($decoded) && isset($decoded['error']) && $decoded['error'] === 'api_key_collision') {
            $heal_result = $this->request_access();
            
            if (!empty($heal_result['success'])) {
                delete_option('auctionforge_api_key_collision');
                
                // Retry the original request seamlessly with the newly generated API key
                $args['headers']['X-API-Key'] = $this->api_key;
                $retry_response = wp_remote_request($url, $args);
                
                if (!is_wp_error($retry_response)) {
                    $retry_body = wp_remote_retrieve_body($retry_response);
                    return json_decode($retry_body, true);
                }
            } else {
                update_option('auctionforge_api_key_collision', $decoded['message'] . ' (Auto-healing failed: ' . ($heal_result['message'] ?? 'Unknown error') . ')');
            }
        } elseif (is_array($decoded) && !isset($decoded['error'])) {
            // Clear collision flag on successful connection
            delete_option('auctionforge_api_key_collision');
        }

        // Generic kuma_push_url interceptor — runs on every successful
        // response from wpdeploy. Persists the URL the first time it
        // arrives (typically /request-access on first contact, or
        // /report-health for already-registered sites that pre-date the
        // Kuma feature) and schedules the heartbeat cron immediately so
        // we don't wait for the next plugin re-init to start beating.
        if (is_array($decoded) && !empty($decoded['kuma_push_url'])) {
            $current = get_option('auctionforge_kuma_push_url', '');
            if ($current !== $decoded['kuma_push_url']) {
                update_option('auctionforge_kuma_push_url', $decoded['kuma_push_url']);
                if (!wp_next_scheduled('auctionforge_kuma_heartbeat')) {
                    wp_schedule_event(time() + mt_rand(0, 60), 'bp_minutely', 'auctionforge_kuma_heartbeat');
                }
            }
        }

        return $decoded;
    }
    
    /**
     * Check for updates
     */
    public function check_for_updates($transient) {
        if (empty($transient->checked) || !$this->is_configured()) {
            return $transient;
        }
        
        // Prepare plugin data
        $plugins = array(
            $this->plugin_slug => array(
                'version' => $this->current_version
            )
        );
        
        $result = $this->api_request('check-updates', array('plugins' => $plugins));
        
        if (!empty($result['updates'][$this->plugin_slug])) {
            $update = $result['updates'][$this->plugin_slug];
            
            $transient->response[$this->plugin_file] = (object) array(
                'slug' => $this->plugin_slug,
                'plugin' => $this->plugin_file,
                'new_version' => $update['version'],
                'url' => !empty($update['homepage']) ? $update['homepage'] : '',
                'package' => $update['download_url'],
                'tested' => $update['tested_up_to'],
                'requires_php' => $update['requires_php'],
            );
        }
        
        return $transient;
    }
    
    /**
     * Check for pending updates from deployment server
     */
    public function check_pending_updates() {
        if (!$this->is_configured()) {
            return;
        }
        
        $result = $this->api_request('pending-updates', array(), 'GET');
        
        if (!empty($result['updates'])) {
            $auto_update = get_option('auctionforge_auto_update', true);
            
            foreach ($result['updates'] as $update) {
                if ($update['plugin_slug'] === $this->plugin_slug) {
                    if ($auto_update) {
                        $this->install_update($update);
                    } else {
                        // Trigger WordPress to show update notification
                        delete_site_transient('update_plugins');
                    }
                }
            }
        }
    }
    
    /**
     * Install update.
     *
     * Carefully sequences the WP upgrader so failures report meaningful causes
     * back to wpdeploy (the previous implementation collapsed every failure
     * mode into a literal "Installation failed" string with no diagnostic value):
     *
     *   1. Refresh the update_plugins transient. Plugin_Upgrader::upgrade()
     *      reads from this transient to know what version to download — if it
     *      hasn't been seeded, the upgrader returns false for "no update
     *      available", which looks identical to a real failure.
     *   2. Inspect every signal the upgrader exposes (return value, skin errors,
     *      $upgrader->result) and serialise the worst one back to the server.
     */
    private function install_update($update) {
        require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';

        // Step 1: make sure WP knows about the update before asking the upgrader
        // to install it.
        delete_site_transient('update_plugins');
        wp_update_plugins(); // forces a fresh check against our pre_set_site_transient hook

        $skin = new \Automatic_Upgrader_Skin();
        $upgrader = new \Plugin_Upgrader($skin);

        // Capture every PHP error/warning emitted while the upgrader runs so we
        // can include them in the failure report. WP can swallow notices from
        // file move operations that are the actual root cause.
        $errorBuffer = array();
        set_error_handler(function ($severity, $message, $file, $line) use (&$errorBuffer) {
            $errorBuffer[] = "[$severity] $message at " . basename($file) . ":$line";
            return false; // let normal handling continue too
        });
        try {
            $result = $upgrader->upgrade($this->plugin_file);
        } finally {
            restore_error_handler();
        }

        // Decide outcome and extract the most informative message available.
        $errorParts = array();

        if (is_wp_error($result)) {
            $status = 'failed';
            $errorParts[] = 'WP_Error: ' . $result->get_error_code() . ' — ' . $result->get_error_message();
        } elseif ($result === false || $result === null) {
            $status = 'failed';
            // The skin holds the actual upgrader-emitted errors.
            if (is_object($skin) && method_exists($skin, 'get_errors')) {
                $skinErrors = $skin->get_errors();
                if (is_wp_error($skinErrors)) {
                    foreach ($skinErrors->get_error_codes() as $code) {
                        foreach ((array) $skinErrors->get_error_messages($code) as $msg) {
                            $errorParts[] = "skin: $code — $msg";
                        }
                    }
                }
            }
            // Plugin_Upgrader::$result holds the final WP_Error when present.
            if (isset($upgrader->result) && is_wp_error($upgrader->result)) {
                $errorParts[] = 'upgrader.result: ' . $upgrader->result->get_error_code()
                    . ' — ' . $upgrader->result->get_error_message();
            }
            if (empty($errorParts)) {
                // Most likely cause when the upgrader silently returns false:
                // the update transient didn't list this plugin.
                $tr = get_site_transient('update_plugins');
                if (empty($tr->response[$this->plugin_file])) {
                    $errorParts[] = 'no_update_in_transient: WP did not see ' . $this->plugin_file
                        . ' as having an available update at install time';
                } else {
                    $errorParts[] = 'unknown: Plugin_Upgrader::upgrade returned false with no skin errors';
                }
            }
        } else {
            $status = 'completed';
        }

        if (!empty($errorBuffer)) {
            $errorParts[] = 'php_errors: ' . implode(' | ', array_slice($errorBuffer, 0, 5));
        }

        $error = $status === 'completed' ? '' : substr(implode(' || ', $errorParts), 0, 1000);

        // Report back to server
        $this->api_request('update-deployed/' . $update['queue_id'], array(
            'status' => $status,
            'error_message' => $error,
        ));
    }
    
    /**
     * Plugin info for update screen
     */
    public function plugin_info($false, $action, $args) {
        if ($action !== 'plugin_information') {
            return $false;
        }
        
        if (!isset($args->slug) || $args->slug !== $this->plugin_slug) {
            return $false;
        }
        
        if (!$this->is_configured()) {
            return $false;
        }
        
        // Get plugin info from server
        $plugins = array($this->plugin_slug => array('version' => $this->current_version));
        $result = $this->api_request('check-updates', array('plugins' => $plugins));
        
        if (!empty($result['updates'][$this->plugin_slug])) {
            $update = $result['updates'][$this->plugin_slug];
            
            return (object) array(
                'name' => $update['name'],
                'slug' => $this->plugin_slug,
                'version' => $update['version'],
                'author' => '<a href="https://smoothbyteit.dev">SmoothByte IT</a>',
                'homepage' => 'https://smoothbyteit.dev',
                'requires' => $update['requires_wp'],
                'tested' => $update['tested_up_to'],
                'requires_php' => $update['requires_php'],
                'download_link' => $update['download_url'],
                'sections' => array(
                    'description' => 'AuctionForge - BidSpirit Auction Integration for WordPress.',
                ),
            );
        }
        
        return $false;
    }
    
    /**
     * Register REST API routes
     */
    public function register_rest_routes() {
        register_rest_route('auctionforge/v1', '/trigger-install', array(
            'methods' => 'POST',
            'callback' => array($this, 'rest_trigger_install'),
            'permission_callback' => array($this, 'verify_api_key'),
        ));
        
        register_rest_route('auctionforge/v1', '/defender-vulnerabilities', array(
            'methods' => 'GET',
            'callback' => array($this, 'rest_get_defender_vulnerabilities'),
            'permission_callback' => array($this, 'verify_api_key'),
        ));

        register_rest_route('auctionforge/v1', '/sync/start', array(
            'methods' => 'POST',
            'callback' => array($this, 'rest_sync_start'),
            'permission_callback' => array($this, 'verify_api_key'),
        ));
        
        register_rest_route('auctionforge/v1', '/sync/status', array(
            'methods' => 'GET',
            'callback' => array($this, 'rest_sync_status'),
            'permission_callback' => array($this, 'verify_api_key'),
        ));

        register_rest_route('auctionforge/v1', '/stats/proxy', array(
            'methods' => 'POST',
            'callback' => array($this, 'rest_stats_proxy'),
            'permission_callback' => array($this, 'verify_api_key'),
        ));

        register_rest_route('auctionforge/v1', '/sync/auctions', array(
            'methods' => 'GET',
            'callback' => array($this, 'rest_sync_auctions'),
            'permission_callback' => array($this, 'verify_api_key'),
        ));

        register_rest_route('auctionforge/v1', '/sync/cancel-all', array(
            'methods' => 'POST',
            'callback' => array($this, 'rest_sync_cancel_all'),
            'permission_callback' => array($this, 'verify_api_key'),
        ));

        // Aliased copy of /trigger-install. We expose the upgrade handler under
        // /sync/version because (a) some hosts block URLs containing "install"
        // and (b) some hosts only allow /wp-json/auctionforge/v1/sync/* through
        // their WAF — keeping it under /sync/ matches the path-pattern that's
        // already proven to be allowed.
        register_rest_route('auctionforge/v1', '/sync/version', array(
            'methods' => 'POST',
            'callback' => array($this, 'rest_force_plugin_update'),
            'permission_callback' => array($this, 'verify_api_key'),
        ));
    }

    /**
     * Force a plugin self-update.
     *
     * Wraps rest_trigger_install but always returns HTTP 200 with a JSON body
     * describing the outcome — using non-200 status codes for "no update
     * available" or "install failed" lets nginx's `error_page` directive
     * clobber the response with a static HTML page.
     */
    public function rest_force_plugin_update($request) {
        if (!$this->is_configured()) {
            return rest_ensure_response(array(
                'success' => false,
                'reason'  => 'not_configured',
                'message' => 'Update server not configured',
            ));
        }

        // Force a fresh update check
        delete_site_transient('update_plugins');
        $this->check_pending_updates();
        $updates = get_site_transient('update_plugins');

        if (!isset($updates->response[$this->plugin_file])) {
            return rest_ensure_response(array(
                'success' => false,
                'reason'  => 'up_to_date',
                'message' => 'No update available — already on latest version',
                'current_version' => $this->current_version,
            ));
        }

        require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
        $skin = new \Automatic_Upgrader_Skin();
        $upgrader = new \Plugin_Upgrader($skin);
        $result = $upgrader->upgrade($this->plugin_file);

        if (is_wp_error($result)) {
            return rest_ensure_response(array(
                'success' => false,
                'reason'  => 'upgrade_failed',
                'message' => $result->get_error_message(),
            ));
        }
        if ($result === false) {
            return rest_ensure_response(array(
                'success' => false,
                'reason'  => 'upgrade_failed',
                'message' => 'Plugin_Upgrader::upgrade returned false',
            ));
        }

        // Success — fetch the new version we just installed
        wp_clean_plugins_cache();
        $newVersion = null;
        if (function_exists('get_plugin_data')) {
            $pluginPath = WP_PLUGIN_DIR . '/' . $this->plugin_file;
            if (file_exists($pluginPath)) {
                $data = get_plugin_data($pluginPath, false, false);
                $newVersion = $data['Version'] ?? null;
            }
        }
        return rest_ensure_response(array(
            'success'         => true,
            'reason'          => 'upgraded',
            'previous_version' => $this->current_version,
            'new_version'     => $newVersion,
        ));
    }
    
    /**
     * Verify API key for REST requests.
     *
     * Reads the option fresh on every call rather than relying on the cached
     * $this->api_key from constructor time — different FPM workers can hold
     * different cached values when the option drifts (e.g. via auto-heal),
     * which would otherwise produce intermittent 401s.
     */
    public function verify_api_key($request) {
        $apiKey = $request->get_header('X-API-Key');
        if (empty($apiKey)) return false;
        $stored = get_option('auctionforge_update_api_key', '');
        return !empty($stored) && hash_equals($stored, $apiKey);
    }
    
    /**
     * REST API callback to trigger immediate installation
     */
    public function rest_trigger_install($request) {
        $params = $request->get_json_params();
        $pluginSlug = $params['plugin_slug'] ?? '';
        $version = $params['version'] ?? '';
        
        if (!$this->is_configured()) {
            return new WP_Error('not_configured', 'Update server not configured', array('status' => 400));
        }
        
        // Force check for updates
        delete_site_transient('update_plugins');
        $this->check_pending_updates();
        
        // Get the update info
        $updates = get_site_transient('update_plugins');
        
        if (!isset($updates->response[$this->plugin_file])) {
            return new WP_Error('no_update', 'No update available for this plugin', array('status' => 404));
        }
        
        // Trigger the installation
        require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
        $skin = new Automatic_Upgrader_Skin();
        $upgrader = new Plugin_Upgrader($skin);
        
        $result = $upgrader->upgrade($this->plugin_file);
        
        if (is_wp_error($result)) {
            return new WP_Error('install_failed', $result->get_error_message(), array('status' => 500));
        }
        
        if ($result === false) {
            return new WP_Error('install_failed', 'Installation failed', array('status' => 500));
        }
        
        return rest_ensure_response(array(
            'success' => true,
            'message' => "Plugin updated to version $version",
            'plugin' => $pluginSlug,
            'version' => $version
        ));
    }
    
    /**
     * REST API callback to get WP Defender vulnerabilities
     */
    public function rest_get_defender_vulnerabilities($request) {
        global $wpdb;
        
        // Check if WP Defender is installed
        $table_name = $wpdb->prefix . 'defender_scan_item';
        
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table_name}'") != $table_name) {
            return new WP_Error('no_defender', 'WP Defender is not installed', array('status' => 404));
        }
        
        // Get vulnerability records
        $vulnerabilities = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$table_name} WHERE type = %s AND status = %s ORDER BY id DESC LIMIT 100",
                'vulnerability',
                'active'
            ),
            ARRAY_A
        );
        
        $results = array();
        
        foreach ($vulnerabilities as $vuln) {
            $raw_data = maybe_unserialize($vuln['raw_data']);
            
            if (!is_array($raw_data) || empty($raw_data['slug'])) {
                continue;
            }
            
            $results[] = array(
                'id' => $vuln['id'],
                'slug' => $raw_data['slug'] ?? '',
                'base_slug' => $raw_data['base_slug'] ?? '',
                'name' => $raw_data['name'] ?? '',
                'version' => $raw_data['version'] ?? '',
                'type' => $raw_data['type'] ?? 'plugin',
                'bugs' => $raw_data['bugs'] ?? array(),
                'scan_date' => current_time('mysql')
            );
        }
        
        return rest_ensure_response(array(
            'success' => true,
            'count' => count($results),
            'site_url' => home_url(),
            'site_name' => get_bloginfo('name'),
            'vulnerabilities' => $results,
            'defender_installed' => true,
            'wp_version' => get_bloginfo('version'),
            'php_version' => PHP_VERSION
        ));
    }
    
    /**
     * Report site health status to deployment server
     */
    /**
     * Reads the running plugin's version directly from its file header.
     * Avoids returning a stale value cached in $this->current_version when
     * the plugin file was upgraded after the FPM worker started up.
     */
    private function get_live_version() {
        $file = WP_PLUGIN_DIR . '/' . $this->plugin_file;
        if (!file_exists($file)) {
            return $this->current_version;
        }
        $data = get_file_data($file, array('Version' => 'Version'));
        return !empty($data['Version']) ? $data['Version'] : $this->current_version;
    }

    /**
     * Uptime Kuma push heartbeat. Runs every minute via wp-cron when
     * `auctionforge_kuma_push_url` is set (provisioned by wpdeploy).
     *
     * Failure is non-fatal: a missed beat is exactly how Kuma detects an
     * outage — we WANT the silence-detection to fire when a beat can't
     * leave the box.
     */
    public function kuma_heartbeat() {
        $url = get_option('auctionforge_kuma_push_url');
        if (empty($url)) return;

        // Append fresh msg + ping. msg = wp version | plugin version, useful
        // for spotting "site is up but on the wrong release" at a glance in
        // Kuma's dashboard.
        $msg  = rawurlencode(get_bloginfo('version') . '|' . $this->current_version);
        $ping = ''; // Kuma ignores empty ping; let the round-trip stand for liveness alone.

        $beat = add_query_arg(
            array('msg' => $msg, 'ping' => $ping, 'status' => 'up'),
            $url
        );

        $resp = wp_remote_get($beat, array(
            'timeout'     => 8,
            'blocking'    => true,
            'redirection' => 0,
            'sslverify'   => true,
            'headers'     => array('User-Agent' => 'AuctionForge/' . $this->current_version),
        ));

        // Don't log every successful beat (would flood debug.log). Only log
        // unexpected error responses, and only when WP_DEBUG is on, so we
        // can diagnose a misconfigured push URL without polluting.
        if (defined('WP_DEBUG') && WP_DEBUG && is_wp_error($resp)) {
            error_log('AF kuma_heartbeat WP_Error: ' . $resp->get_error_message());
        }
    }

    public function report_health() {
        if (!$this->is_configured()) {
            return;
        }

        $health_data = array(
            'wp_version' => get_bloginfo('version'),
            'php_version' => PHP_VERSION,
            'plugin_slug' => $this->plugin_slug,
            'plugin_version' => $this->get_live_version(),
            'plugin_file' => $this->plugin_file,
            'health_status' => 'healthy',
            'plugins' => array(),
            'errors' => array()
        );

        // Get all active plugins
        $active_plugins = get_option('active_plugins', array());
        foreach ($active_plugins as $plugin) {
            $plugin_data = get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin);
            $health_data['plugins'][] = array(
                'slug' => dirname($plugin),
                'name' => $plugin_data['Name'],
                'version' => $plugin_data['Version'],
                'active' => true
            );
        }

        // Detect errors
        $errors = array();
        if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
            $log_file = WP_CONTENT_DIR . '/debug.log';
            if (file_exists($log_file) && filesize($log_file) > 0) {
                $errors[] = array(
                    'message' => 'Debug log has entries',
                    'plugin' => $this->plugin_slug,
                );
            }
        }

        $health_data['health_status'] = count($errors) > 0 ? 'warning' : 'healthy';
        $health_data['errors'] = $errors;

        // Report to server
        $this->api_request('report-health', $health_data);
    }
    
    /**
     * Sync WP Defender vulnerabilities to deployment server
     */
    public function sync_vulnerabilities() {
        if (!$this->is_configured()) {
            return false;
        }
        
        global $wpdb;
        
        $table_name = $wpdb->prefix . 'defender_scan_item';
        
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table_name}'") != $table_name) {
            update_option('auctionforge_last_vuln_sync_status', 'defender_not_installed');
            return false;
        }
        
        $vulnerabilities = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$table_name} WHERE type = %s AND status = %s ORDER BY id DESC LIMIT 100",
                'vulnerability',
                'active'
            ),
            ARRAY_A
        );
        
        if (empty($vulnerabilities)) {
            update_option('auctionforge_last_vuln_sync_status', 'no_vulnerabilities_found');
            update_option('auctionforge_last_vuln_sync', current_time('mysql'));
            return false;
        }
        
        $results = array();
        
        foreach ($vulnerabilities as $vuln) {
            $raw_data = maybe_unserialize($vuln['raw_data']);
            
            if (!is_array($raw_data) || empty($raw_data['slug'])) {
                continue;
            }
            
            $bugs = $raw_data['bugs'] ?? array();
            if (empty($bugs)) {
                continue;
            }
            
            $results[] = array(
                'slug' => $raw_data['base_slug'] ?? dirname($raw_data['slug']),
                'version' => $raw_data['version'] ?? '',
                'name' => $raw_data['name'] ?? '',
                'type' => $raw_data['type'] ?? 'plugin',
                'bugs' => $bugs
            );
        }
        
        if (empty($results)) {
            update_option('auctionforge_last_vuln_sync_status', 'no_valid_vulnerabilities');
            update_option('auctionforge_last_vuln_sync', current_time('mysql'));
            return false;
        }
        
        $response = $this->api_request('sync-defender-vulnerabilities', array(
            'vulnerabilities' => $results,
            'site_url' => home_url(),
            'site_name' => get_bloginfo('name'),
            'scan_time' => current_time('mysql'),
            'defender_installed' => true
        ));
        
        if ($response && !is_wp_error($response)) {
            update_option('auctionforge_last_vuln_sync', current_time('mysql'));
            update_option('auctionforge_last_vuln_sync_status', 'success');
            update_option('auctionforge_last_vuln_count', count($results));
            
            if (isset($response['synced'])) {
                update_option('auctionforge_last_vuln_synced', $response['synced']);
                update_option('auctionforge_last_vuln_updated', $response['updated'] ?? 0);
                update_option('auctionforge_last_vuln_skipped', $response['skipped'] ?? 0);
            }
            
            return true;
        }
        
        update_option('auctionforge_last_vuln_sync_status', 'api_error');
        return false;
    }
    
    /**
     * Poll fraud enforcement setting from central server and update local option
     * This is the single source of truth — the wpdeploy server controls enforcement.
     */
    public function sync_fraud_enforcement() {
        if (!$this->is_configured()) {
            return;
        }
        
        $result = $this->api_request('fraud-enforcement-status', array(), 'GET');
        
        if ($result && isset($result['fraud_enforcement'])) {
            $enforcement = (bool) $result['fraud_enforcement'];
            update_option('auctionforge_fraud_enforcement', $enforcement);
            update_option('auctionforge_fraud_enforcement_synced_at', current_time('mysql'));
            
            // Report back to confirm
            $this->api_request('report-enforcement-status', array(
                'enforcement_enabled' => $enforcement,
                'site_url' => home_url(),
            ));
        }
    }
    
    /**
     * Sync user tracking data to central deployment server
     * Pushes all records modified since last sync
     */
    public function sync_user_tracking() {
        if (!$this->is_configured()) {
            return false;
        }
        
        global $wpdb;
        $table_name = $wpdb->prefix . 'auctionforge_user_tracking';
        
        // Check if table exists
        if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") != $table_name) {
            update_option('auctionforge_last_tracking_sync_status', 'table_not_found');
            return false;
        }
        
        // Get last sync time
        $lastSync = get_option('auctionforge_last_tracking_sync', '2000-01-01 00:00:00');
        
        // Get records modified since last sync (batch of 200)
        $records = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM $table_name WHERE last_seen >= %s ORDER BY last_seen ASC LIMIT 200",
            $lastSync
        ), ARRAY_A);
        
        if (empty($records)) {
            update_option('auctionforge_last_tracking_sync_status', 'no_new_records');
            update_option('auctionforge_last_tracking_sync', current_time('mysql'));
            return true;
        }
        
        // Push to central server
        $response = $this->api_request('sync-user-tracking', array(
            'records' => $records,
            'site_url' => home_url(),
            'site_name' => get_bloginfo('name'),
            'sync_time' => current_time('mysql'),
        ));
        
        if ($response && !isset($response['error'])) {
            // Update last sync time to the latest record's last_seen
            $latestRecord = end($records);
            update_option('auctionforge_last_tracking_sync', $latestRecord['last_seen']);
            update_option('auctionforge_last_tracking_sync_status', 'success');
            update_option('auctionforge_last_tracking_sync_count', $response['synced'] ?? count($records));
            update_option('auctionforge_last_tracking_sync_alerts', $response['alerts_generated'] ?? 0);
            return true;
        }
        
        update_option('auctionforge_last_tracking_sync_status', 'api_error');
        return false;
    }
    
    /**
     * Manual update check (for admin use)
     */
    public function manual_check() {
        delete_site_transient('update_plugins');
        wp_update_plugins();
        
        $updates = get_site_transient('update_plugins');
        return isset($updates->response[$this->plugin_file]);
    }
    
    /**
     * Add authentication headers to download requests.
     *
     * Other AuctionForge-stack plugins (e.g. seo-aeo-optimizer) install the
     * same `http_request_args` filter against the same wpdeploy server with
     * their own api_key. Without scoping, whichever plugin's filter runs last
     * clobbers the X-API-Key header, and outbound calls from the loser go out
     * with the wrong key — which wpdeploy correctly rejects with 401.
     *
     * Two safeguards:
     *   1. If the caller already populated X-API-Key (api_request() always
     *      does), leave it alone. That keeps targeted calls intact regardless
     *      of filter order.
     *   2. For unauthenticated calls (WP's own plugin upgrader downloading a
     *      zip), only stamp our key when the URL is for our slug — otherwise
     *      we'd write the AuctionForge key onto a sibling plugin's download.
     */
    public function add_download_auth($args, $url) {
        if (strpos($url, $this->server_url) === false) {
            return $args;
        }
        if (!empty($args['headers']['X-API-Key'])) {
            return $args;
        }
        if (preg_match('#/download/([^/]+)/#', $url, $m) && $m[1] !== $this->plugin_slug) {
            return $args;
        }
        if (!isset($args['headers'])) {
            $args['headers'] = array();
        }
        $args['headers']['X-API-Key'] = $this->api_key;
        return $args;
    }
    
    /**
     * Test connection to deployment server
     */
    public function test_connection() {
        if (!$this->is_configured()) {
            return array('success' => false, 'message' => 'Not configured');
        }
        
        $result = $this->api_request('info', array(), 'GET');
        
        if (isset($result['app_version'])) {
            return array('success' => true, 'version' => $result['app_version']);
        } else {
            return array('success' => false, 'message' => $result['error'] ?? 'Unknown error');
        }
    }
    
    /**
     * Request automatic access to deployment server (no master key needed)
     */
    public function request_access() {
        if (empty($this->server_url)) {
            return array('success' => false, 'message' => 'Server URL required');
        }
        
        $site_url = get_site_url();
        $site_name = get_bloginfo('name');
        
        $url = rtrim($this->server_url, '/') . '/api.php/request-access';
        
        $response = wp_remote_post($url, array(
            'timeout' => 30,
            'headers' => array(
                'Content-Type' => 'application/json',
            ),
            'body' => json_encode(array(
                'site_url' => $site_url,
                'site_name' => $site_name,
            )),
        ));
        
        if (is_wp_error($response)) {
            return array('success' => false, 'message' => $response->get_error_message());
        }
        
        $body = wp_remote_retrieve_body($response);
        $result = json_decode($body, true);
        
        if (!empty($result['success']) && !empty($result['api_key'])) {
            update_option('auctionforge_update_api_key', $result['api_key']);
            $this->api_key = $result['api_key'];
            
            if ($this->is_configured()) {
                $this->init_hooks();
            }
            
            return array(
                'success' => true,
                'message' => 'Access granted! Your site is now registered with the deployment server.',
                'api_key' => $result['api_key'],
            );
        } else {
            return array(
                'success' => false,
                'message' => $result['error'] ?? 'Access request failed',
            );
        }
    }
    
    /**
     * Register site with deployment server using master key (legacy method)
     */
    public function register_site($master_key) {
        if (empty($this->server_url) || empty($master_key)) {
            return array('success' => false, 'message' => 'Server URL and Master Key required');
        }
        
        $site_url = get_site_url();
        $site_name = get_bloginfo('name');
        
        $url = rtrim($this->server_url, '/') . '/api.php/register-site';
        
        $response = wp_remote_post($url, array(
            'timeout' => 30,
            'headers' => array(
                'Content-Type' => 'application/json',
            ),
            'body' => json_encode(array(
                'site_url' => $site_url,
                'site_name' => $site_name,
                'master_key' => $master_key,
            )),
        ));
        
        if (is_wp_error($response)) {
            return array('success' => false, 'message' => $response->get_error_message());
        }
        
        $body = wp_remote_retrieve_body($response);
        $result = json_decode($body, true);
        
        if (!empty($result['success']) && !empty($result['api_key'])) {
            update_option('auctionforge_update_api_key', $result['api_key']);
            $this->api_key = $result['api_key'];
            
            if ($this->is_configured()) {
                $this->init_hooks();
            }
            
            return array(
                'success' => true,
                'message' => 'Site registered successfully!',
                'api_key' => $result['api_key'],
            );
        } else {
            return array(
                'success' => false,
                'message' => $result['error'] ?? 'Registration failed',
            );
        }
    }
    
    /**
     * REST API logic below
     */
    public function rest_sync_start($request) {
        $params    = $request->get_json_params();
        $auctionId = isset($params['auction_id']) ? $params['auction_id'] : '__all__';
        $mode      = isset($params['mode']) ? strtolower((string)$params['mode']) : 'upcoming';

        if ($auctionId === '__all__') {
            // Bulk re-sync of all upcoming or all past auctions — mirrors re_sync_action semantics.
            $jobType = ($mode === 'past') ? 'full_sync_past' : 'full_sync_upcoming';
        } else {
            $jobType = 'catalog_sync';
        }

        $jobId = false;
        if (class_exists('\auctionforge\inc\sync\SyncQueue')) {
            $payload = array('mode' => $mode);
            $jobId = \auctionforge\inc\sync\SyncQueue::enqueue($jobType, $auctionId, $payload, 3, 'api');
            if ($jobId && !\auctionforge\inc\sync\SyncQueue::hasActiveWorker()) {
                \auctionforge\inc\sync\SyncQueue::spawnWorker();
            }
        }

        if (!$jobId) {
            return new WP_Error('queue_failed', 'Failed to enqueue or job deduplicated', array('status' => 500));
        }

        return rest_ensure_response(array('success' => true, 'job_id' => $jobId, 'job_type' => $jobType));
    }

    /**
     * REST: Kill the running sync queue.
     * Flips DB flags via SyncQueue::cancelAll() and SIGTERMs every active worker.
     * A 5-second watchdog escalates any survivors to SIGKILL.
     */
    public function rest_sync_cancel_all($request) {
        if (!class_exists('\auctionforge\inc\sync\SyncQueue')) {
            return new WP_Error('not_available', 'SyncQueue not available', array('status' => 500));
        }

        // Snapshot active worker PIDs BEFORE flipping their status to failed
        global $wpdb;
        $queueTable = $wpdb->prefix . 'af_sync_queue';
        $pids = array();
        if ($wpdb->get_var("SHOW TABLES LIKE '{$queueTable}'") === $queueTable) {
            $rows = $wpdb->get_col("SELECT worker_pid FROM `{$queueTable}` WHERE status = 'processing' AND worker_pid IS NOT NULL");
            foreach ($rows as $pid) {
                $pid = intval($pid);
                if ($pid > 0) $pids[] = $pid;
            }
        }
        $pids = array_values(array_unique($pids));

        // 1. DB flag flip
        $cancelled = \auctionforge\inc\sync\SyncQueue::cancelAll();

        // 2. SIGTERM each active worker (graceful shutdown — handler in syncWorker.php)
        $signaled = array();
        if (function_exists('posix_kill')) {
            foreach ($pids as $pid) {
                if (@posix_kill($pid, defined('SIGTERM') ? SIGTERM : 15)) {
                    $signaled[] = $pid;
                }
            }
        }

        // 3. Schedule the watchdog to escalate to SIGKILL for any survivor.
        if (!empty($signaled)) {
            wp_schedule_single_event(time() + 5, 'auctionforge_kill_watchdog', array($signaled));
        }

        if (class_exists('\auctionforge\inc\sync\SyncLogger')) {
            \auctionforge\inc\sync\SyncLogger::log(
                'queue',
                'sync/cancel-all REST: cancelled ' . intval($cancelled['cancelled'] ?? 0)
                    . ' pending, killed ' . intval($cancelled['killed'] ?? 0) . ' processing'
                    . ' (SIGTERM to ' . count($signaled) . ' PIDs)',
                'warning'
            );
        }

        return rest_ensure_response(array(
            'success'              => true,
            'cancelled_pending'    => intval($cancelled['cancelled'] ?? 0),
            'cancelled_processing' => intval($cancelled['killed'] ?? 0),
            'signaled_pids'        => $signaled,
            'posix_available'      => function_exists('posix_kill'),
        ));
    }

    /**
     * REST: List all auctions (active + past/archive) with metadata
     */
    public function rest_sync_auctions($request) {
        global $wpdb;
        $active = array();
        $past = array();

        if (class_exists('\\auctionforge\\inc\\model\\Auction')) {
            // Active auctions (not ARCHIVED/ENDED)
            $activeTerms = \auctionforge\inc\model\Auction::getActiveAuction();
            if (!is_wp_error($activeTerms) && is_array($activeTerms)) {
                foreach ($activeTerms as $term) {
                    $term_id = $term->term_id;
                    $state = get_term_meta($term_id, 'auction_state', true);
                    $date = get_term_meta($term_id, 'auction_date', true);
                    $auction_id = get_term_meta($term_id, 'auction_id', true);
                    $lot_count = intval($wpdb->get_var($wpdb->prepare(
                        "SELECT COUNT(*) FROM {$wpdb->posts} p
                         INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
                         INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
                         WHERE tt.term_id = %d AND p.post_type='lot' AND p.post_status='publish'",
                        $term_id
                    )));
                    $active[] = array(
                        'term_id' => $term_id,
                        'name' => $term->name,
                        'auction_id' => $auction_id,
                        'state' => $state,
                        'date' => $date,
                        'lot_count' => $lot_count,
                    );
                }
            }

            // Past/archived auctions
            $pastTerms = \auctionforge\inc\model\Auction::getArchiveAuction();
            if (!is_wp_error($pastTerms) && is_array($pastTerms)) {
                foreach ($pastTerms as $term) {
                    $term_id = $term->term_id;
                    $state = get_term_meta($term_id, 'auction_state', true);
                    $date = get_term_meta($term_id, 'auction_date', true);
                    $auction_id = get_term_meta($term_id, 'auction_id', true);
                    $lot_count = intval($wpdb->get_var($wpdb->prepare(
                        "SELECT COUNT(*) FROM {$wpdb->posts} p
                         INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
                         INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
                         WHERE tt.term_id = %d AND p.post_type='lot' AND p.post_status='publish'",
                        $term_id
                    )));
                    $past[] = array(
                        'term_id' => $term_id,
                        'name' => $term->name,
                        'auction_id' => $auction_id,
                        'state' => $state,
                        'date' => $date,
                        'lot_count' => $lot_count,
                    );
                }
            }
        }

        // Inventory totals
        $total_lots = intval($wpdb->get_var(
            "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='lot' AND post_status='publish'"
        ));
        $total_auctions = intval($wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->term_taxonomy} WHERE taxonomy=%s",
            'auction_number'
        )));

        return rest_ensure_response(array(
            'success' => true,
            'active' => $active,
            'past' => $past,
            'inventory' => array(
                'total_lots' => $total_lots,
                'total_auctions' => $total_auctions,
                'active_count' => count($active),
                'past_count' => count($past),
            ),
        ));
    }

    public function rest_sync_status($request) {
        $sinceId = intval($request->get_param('since_id'));
        $counts = array();
        $logs = array();
        $jobs = array();

        if (class_exists('\auctionforge\inc\sync\SyncQueue')) {
            $counts = \auctionforge\inc\sync\SyncQueue::getStatusCounts();
            $logs = \auctionforge\inc\sync\SyncLogger::getRecent($sinceId, 100, 'info');
            $jobs = \auctionforge\inc\sync\SyncQueue::getRecentJobs(30);
        }

        return rest_ensure_response(array(
            'success' => true,
            'counts' => $counts,
            'logs' => $logs,
            'jobs' => $jobs
        ));
    }

    public function rest_stats_proxy($request) {
        $params = $request->get_json_params() ?: array();
        $action = isset($params['action']) ? $params['action'] : '';
        
        $admins = get_users(array('role' => 'administrator'));
        if (!empty($admins)) {
            wp_set_current_user($admins[0]->ID);
        }
        
        $nonce = wp_create_nonce('af_stats_admin');
        $_POST = array_merge($_POST, $params);
        $_POST['nonce'] = $nonce;
        $_REQUEST['nonce'] = $nonce;
        
        if (class_exists('\AF_Stats_Ajax')) {
            if ($action === 'dashboard') { \AF_Stats_Ajax::handle_dashboard(); }
            elseif ($action === 'top_pages') { \AF_Stats_Ajax::handle_top_pages(); }
            elseif ($action === 'referrers') { \AF_Stats_Ajax::handle_referrers(); }
            elseif ($action === 'countries') { \AF_Stats_Ajax::handle_countries(); }
            elseif ($action === 'browsers') { \AF_Stats_Ajax::handle_browsers(); }
            elseif ($action === 'searches') { \AF_Stats_Ajax::handle_searches(); }
            elseif ($action === 'errors') { \AF_Stats_Ajax::handle_errors(); }
            elseif ($action === 'realtime') { \AF_Stats_Ajax::handle_realtime(); }
            elseif ($action === 'lot_analytics_data') { \AF_Stats_Ajax::handle_lot_analytics_data(); }
        }
        
        return new WP_Error('invalid_action', 'Invalid stats proxy action', array('status' => 400));
    }

    public function sync_site_statistics() {
        if (!$this->is_configured()) return false;
        
        global $wpdb;
        $pv_table = $wpdb->prefix . 'af_stats_pageviews';
        $sess_table = $wpdb->prefix . 'af_stats_sessions';
        
        if ($wpdb->get_var("SHOW TABLES LIKE '{$pv_table}'") != $pv_table) {
            return false;
        }

        $from = date('Y-m-d H:i:s', strtotime('-24 hours'));
        $to = date('Y-m-d H:i:s');
        
        $pageviews = intval($wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $pv_table WHERE created_at BETWEEN %s AND %s", $from, $to)));
        $sessions = intval($wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $sess_table WHERE started_at BETWEEN %s AND %s", $from, $to)));
        $visitors = intval($wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT visitor_hash) FROM $pv_table WHERE created_at BETWEEN %s AND %s", $from, $to)));
        
        $data = array(
            'site_url' => home_url(),
            'site_name' => get_bloginfo('name'),
            'period' => '24h',
            'pageviews' => $pageviews,
            'sessions' => $sessions,
            'visitors' => $visitors,
            'timestamp' => current_time('mysql'),
        );
        
        $response = $this->api_request('sync-site-statistics', $data);
        return $response;
    }

    /* -------------------------------------------------------------------- */
    /* Sync error fan-out                                                   */
    /* -------------------------------------------------------------------- */

    /**
     * Fire-and-forget push of a single new sync-error log row to wpdeploy.
     * Triggered by SyncLogger via the 'auctionforge_sync_error_logged' action.
     */
    public function push_sync_error_immediate($logId, $context, $message, $auctionId, $jobId) {
        if (!$this->is_configured()) return;

        $payload = array(
            'site_url'       => home_url(),
            'plugin_version' => $this->get_live_version(),
            'entries'        => array(array(
                'af_log_id'  => intval($logId),
                'context'    => (string)$context,
                'level'      => 'error',
                'kind'       => $this->classify_error_kind($jobId),
                'auction_id' => $auctionId,
                'job_id'     => $jobId !== null ? intval($jobId) : null,
                'message'    => (string)$message,
                'created_at' => gmdate('c'),
            )),
        );

        wp_remote_post(rtrim($this->server_url, '/') . '/api.php/sync-errors', array(
            'headers'  => array(
                'X-API-Key'    => $this->api_key,
                'Content-Type' => 'application/json',
            ),
            'body'     => wp_json_encode($payload),
            'timeout'  => 2,
            'blocking' => false, // critical: never block the originating request
        ));
    }

    /**
     * Map a job's job_type to a UI "kind" used to pick the Telegram button set.
     *   'upcoming' → Kill + Full Upcoming
     *   'past'     → Kill + Full Past
     *   'other'    → Kill only
     *
     * For catalog_sync (which can target either an active or an archived
     * auction depending on which webhook fired), we look up the auction's
     * state and route 'upcoming' vs 'past' accordingly.
     */
    private function classify_error_kind($jobId) {
        if (empty($jobId)) return 'other';

        global $wpdb;
        $queueTable = $wpdb->prefix . 'af_sync_queue';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$queueTable}'") !== $queueTable) {
            return 'other';
        }
        $job = $wpdb->get_row($wpdb->prepare(
            "SELECT job_type, auction_id FROM `{$queueTable}` WHERE id = %d",
            intval($jobId)
        ));
        if (!$job || empty($job->job_type)) return 'other';

        switch ($job->job_type) {
            case 'full_sync_upcoming':
            case 'full_sync':
                return 'upcoming';
            case 'full_sync_past':
            case 'archive_sync':
                return 'past';
            case 'catalog_sync':
                // Resolve via auction state — catalog_sync targets one specific auction.
                return $this->classify_auction_state($job->auction_id);
            default:
                return 'other';
        }
    }

    /**
     * Decide 'upcoming' vs 'past' from the auction's current state.
     * Defaults to 'upcoming' when the state can't be determined (better UX
     * than showing no Full-* button — admin can still always tap Kill).
     */
    private function classify_auction_state($auctionId) {
        if (empty($auctionId) || $auctionId === '__all__') return 'upcoming';
        if (!class_exists('\auctionforge\inc\model\Auction')) return 'upcoming';

        try {
            $term = \auctionforge\inc\model\Auction::findAuctionById($auctionId);
            if (!$term instanceof \WP_Term) return 'upcoming';

            $state = strtoupper((string) get_term_meta($term->term_id, 'auction_state', true));
            // Past = explicitly archived / ended / post-auction. Anything else (or empty) is upcoming.
            $pastStates = array('ARCHIVED', 'ENDED', 'POST_AUCTION');
            return in_array($state, $pastStates, true) ? 'past' : 'upcoming';
        } catch (\Throwable $t) {
            return 'upcoming';
        }
    }

    /**
     * Safety net: re-pushes recent error rows. Runs every minute.
     * wpdeploy dedupes via UNIQUE(site_id, af_log_id) — duplicates are silent.
     */
    public function retry_sync_errors() {
        if (!$this->is_configured()) return;

        global $wpdb;
        $table = $wpdb->prefix . \auctionforge\inc\sync\SyncLogger::TABLE_NAME;
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") !== $table) {
            return;
        }

        $cutoff = gmdate('Y-m-d H:i:s', time() - 600); // last 10 minutes
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT id, context, message, auction_id, job_id, created_at
             FROM `{$table}`
             WHERE level = 'error' AND created_at >= %s
             ORDER BY id ASC LIMIT 50",
            $cutoff
        ));

        if (empty($rows)) return;

        $entries = array();
        foreach ($rows as $r) {
            $entries[] = array(
                'af_log_id'  => intval($r->id),
                'context'    => (string)$r->context,
                'level'      => 'error',
                'kind'       => $this->classify_error_kind($r->job_id),
                'auction_id' => $r->auction_id,
                'job_id'     => $r->job_id !== null ? intval($r->job_id) : null,
                'message'    => (string)$r->message,
                'created_at' => $r->created_at,
            );
        }

        $this->api_request('sync-errors', array(
            'site_url'       => home_url(),
            'plugin_version' => $this->get_live_version(),
            'entries'        => $entries,
        ));
    }

    /**
     * SIGKILL escalation watchdog. Receives a list of PIDs that were sent SIGTERM
     * 5 seconds ago. Any still-alive PID gets SIGKILL.
     */
    public function kill_watchdog($pids) {
        if (!is_array($pids) || empty($pids)) return;
        if (!function_exists('posix_kill')) return;

        $survivors = array();
        foreach ($pids as $pid) {
            $pid = intval($pid);
            if ($pid <= 0) continue;
            // Signal 0 = liveness check, doesn't kill
            if (@posix_kill($pid, 0)) {
                @posix_kill($pid, defined('SIGKILL') ? SIGKILL : 9);
                $survivors[] = $pid;
            }
        }

        if (!empty($survivors) && class_exists('\auctionforge\inc\sync\SyncLogger')) {
            \auctionforge\inc\sync\SyncLogger::log(
                'queue',
                'Escalated to SIGKILL after SIGTERM grace period for PIDs: ' . implode(',', $survivors),
                'warning'
            );
        }
    }
}
