<?php
/**
 * Plugin Name: AuctionForge Keepalive
 * Description: Site-side watchdog that survives any AuctionForge or wpdeploy
 *   outage. Three responsibilities, all running from wp-content/mu-plugins/
 *   (which WordPress can't deactivate):
 *
 *     1. Auto-reactivate AuctionForge if a failed plugin update or
 *        misbehaving theme has dropped it from active_plugins. Smoke-checks
 *        the Plugin Name header before reactivation so a genuinely corrupt
 *        AF file doesn't fatal-loop the site. Records markers
 *        (auctionforge_auto_reactivated_at, _count, _failed_at) that the
 *        AF sync stream picks up so wpdeploy sees what happened.
 *
 *     2. Sweep stale WordPress upgrader debris (.maintenance lockfile,
 *        wp-content/upgrade/*, upgrade-temp-backup/) that would otherwise
 *        produce silent "Could not create directory" failures on the next
 *        update. Transient-guarded to once per hour.
 *
 *     3. Push Kuma heartbeats independently of AuctionForge. Reads the
 *        push URL from either the auctionforge_kuma_push_url option (set
 *        by wpdeploy on first contact) or the AUCTIONFORGE_KUMA_PUSH_URL
 *        constant in wp-config.php (operator override — site can run
 *        fully outside wpdeploy). Once the URL is cached locally, no
 *        wpdeploy round-trip is needed for the steady-state heartbeat.
 *        Three layered triggers (60s wp-cron event + page-load piggy-back
 *        + cron self-heal) keep cadence tight regardless of site traffic.
 *
 *   Installed automatically by AuctionForge ≥ 3.7.0.4 — do not edit by
 *   hand; AF will overwrite this file when its version of the keepalive
 *   bumps. Keep KEEPALIVE_VERSION below in sync with the source template.
 *
 * Author: SmoothByte IT
 * Version: 1.4.0
 *
 * KEEPALIVE_VERSION: 1.4.0
 */

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

// =============================================================================
// AuctionForge health marker  (Kuma active HTTP + keyword monitoring)
// =============================================================================
//
// Emits an invisible marker into every rendered front-end page so Uptime Kuma
// can verify the site is genuinely serving WordPress — not just answering TCP,
// returning a cached error page, or showing a PHP white-screen — via a pull
// based HTTP "keyword" monitor (keyword: af-health:ok).
//
// Why it lives here (mu-plugin) and not in AuctionForge: the mu-plugin can't be
// deactivated and is re-asserted on every AF load, so the marker reflects
// "WordPress is rendering pages" independently of AF's own activation state.
// This replaces the old push-heartbeat model (which measured "is WP-Cron
// firing?", not "is the site online?") that false-flagged healthy sites.
//
// Marker is emitted at most once per request (head OR footer, whichever the
// theme reaches first). Pure echo, no DB/IO — effectively free, and it lands
// inside the cached HTML so page-cache plugins serve it too.
if (!function_exists('auctionforge_keepalive_health_marker')) {
    function auctionforge_keepalive_health_marker() {
        if (defined('AF_HEALTH_MARKER_EMITTED')) return;
        define('AF_HEALTH_MARKER_EMITTED', true);
        echo "\n<!-- af-health:ok -->\n";
    }
}
add_action('wp_head',   'auctionforge_keepalive_health_marker', 0);
add_action('wp_footer', 'auctionforge_keepalive_health_marker', 0);

// -----------------------------------------------------------------------------
// Lightweight health endpoint  (Kuma pull without the full-page cost)
// -----------------------------------------------------------------------------
// A keyword monitor only needs to *see* "af-health:ok" to know the site is
// genuinely serving WordPress. Pulling the entire front page every 60s just to
// read that token is wasteful — hundreds of full renders/min across the fleet.
// This route lets Kuma hit  https://<site>/?af-health=1  instead: we answer the
// moment WordPress core + plugins are up (so a dead DB, a PHP white-screen, a
// fatal during load, or a TCP-only/502 still fail the check exactly as the HTML
// marker would) and exit before the theme and main query render. It is a query
// string, not a path, so full-page caches pass it through to PHP rather than
// serving a cached body. The one failure mode it does not cover that a full
// homepage pull would — a theme-template fatal that still lets init complete —
// is the deliberate trade for making each check effectively free.
if (!function_exists('auctionforge_keepalive_health_endpoint')) {
    function auctionforge_keepalive_health_endpoint() {
        if (!isset($_GET['af-health'])) return;
        if (function_exists('nocache_headers')) nocache_headers();
        if (!headers_sent()) {
            header('Content-Type: text/plain; charset=utf-8');
            header('X-Robots-Tag: noindex, nofollow, noarchive', true);
        }
        echo "af-health:ok\n";
        exit;
    }
}
add_action('init', 'auctionforge_keepalive_health_endpoint', 0);

add_action('plugins_loaded', function () {
    // Already loaded? Nothing to do.
    if (defined('AUCTIONFORGE_VERSION') || class_exists('\auctionforge\inc\controllers\BidspiritLots', false)) {
        return;
    }

    $expected = 'auctionforge/auctionforge.php';
    $plugin_dir = defined('WP_PLUGIN_DIR') ? WP_PLUGIN_DIR : ABSPATH . 'wp-content/plugins';
    $path = $plugin_dir . '/' . $expected;

    if (!file_exists($path)) return;

    $active = (array) get_option('active_plugins', []);
    if (in_array($expected, $active, true)) return;

    $head = @file_get_contents($path, false, null, 0, 4096);
    if ($head === false || stripos($head, 'Plugin Name:') === false) {
        update_option('auctionforge_auto_reactivated_failed_at', current_time('mysql', true), 'no');
        return;
    }

    $active[] = $expected;
    sort($active);
    update_option('active_plugins', array_values(array_unique($active)));

    update_option('auctionforge_auto_reactivated_at', current_time('mysql', true), 'no');
    update_option('auctionforge_auto_reactivated_count',
        ((int) get_option('auctionforge_auto_reactivated_count', 0)) + 1, 'no');

    if (file_exists($path)) {
        include_once $path;
    }
}, 1);

/**
 * Sweep stale upgrader debris.
 *
 * WP 6.x's atomic-rollback flow leaves an `upgrade-temp-backup/` directory
 * around when its post-install cleanup misfires (the user-facing symptom is
 * "Update failed: Could not create directory" even though the new code is
 * already on disk). Stale `wp-content/upgrade/<plugin-tmp>/` and
 * `.maintenance` lockfiles from crashed upgrades have the same effect on
 * the *next* upgrade attempt. None of WP's own scheduled cleanup catches
 * these reliably, so we sweep them ourselves.
 *
 * Transient-guarded to once per hour so this stays effectively free on
 * every page load. Wrapped in try/catch — never throws, never blocks a
 * request, never touches anything younger than its safety threshold (so
 * an actually-in-flight upgrade is left alone).
 */
add_action('init', function () {
    try {
        $key = 'auctionforge_keepalive_sweep_at';
        // Bail out fast if we've swept within the last hour.
        if (get_transient($key)) return;
        // Mark BEFORE the work so concurrent requests don't pile on.
        set_transient($key, time(), HOUR_IN_SECONDS);

        $now      = time();
        $wpc      = WP_CONTENT_DIR;
        $abspath  = rtrim(ABSPATH, '/');
        $removed  = [];

        // --- 1. Stale .maintenance lockfile (>5 min old). A real upgrade
        //         only holds it for ~30s; anything older is from a crash.
        $maint = $abspath . '/.maintenance';
        if (is_file($maint) && ($now - @filemtime($maint)) > 300) {
            if (@unlink($maint)) $removed[] = '.maintenance';
        }

        // --- 2. Debris in wp-content/upgrade/ (>60 min old). Each entry is
        //         either a leftover unpack dir or a partial download zip.
        $upgrade_dir = $wpc . '/upgrade';
        if (is_dir($upgrade_dir)) {
            foreach ((array) @scandir($upgrade_dir) as $entry) {
                if ($entry === '.' || $entry === '..' || $entry === '') continue;
                $p = $upgrade_dir . '/' . $entry;
                if (($now - @filemtime($p)) <= 3600) continue;
                if (is_dir($p)) {
                    if (auctionforge_keepalive_rrmdir($p)) $removed[] = 'upgrade/' . $entry;
                } else {
                    if (@unlink($p)) $removed[] = 'upgrade/' . $entry;
                }
            }
        }

        // --- 3. Stale upgrade-temp-backup/ (>30 min old). WP creates this
        //         per-upgrade and is supposed to remove it on success;
        //         when the cleanup misfires it sits there forever and
        //         poisons the next upgrade attempt.
        $temp_backup = $wpc . '/upgrade-temp-backup';
        if (is_dir($temp_backup) && ($now - @filemtime($temp_backup)) > 1800) {
            if (auctionforge_keepalive_rrmdir($temp_backup)) {
                $removed[] = 'upgrade-temp-backup';
            }
        }

        if (!empty($removed)) {
            update_option('auctionforge_keepalive_sweep_last_at', current_time('mysql', true), 'no');
            update_option('auctionforge_keepalive_sweep_last_removed', implode(',', $removed), 'no');
        }
    } catch (\Throwable $t) {
        // Never let the sweep break the request. Best-effort only.
    }
}, 999);

if (!function_exists('auctionforge_keepalive_rrmdir')) {
    function auctionforge_keepalive_rrmdir($dir) {
        if (!is_dir($dir)) return @unlink($dir);
        $entries = @scandir($dir);
        if ($entries === false) return false;
        foreach ($entries as $e) {
            if ($e === '.' || $e === '..') continue;
            $p = $dir . '/' . $e;
            if (is_dir($p) && !is_link($p)) {
                auctionforge_keepalive_rrmdir($p);
            } else {
                @unlink($p);
            }
        }
        return @rmdir($dir);
    }
}

// =============================================================================
// Kuma push heartbeat
// =============================================================================
//
// Independent of AuctionForge being loaded — this code lives in a mu-plugin
// that WordPress can't deactivate, so even if AF is missing/broken/mid-upgrade
// the heartbeat keeps reporting to Kuma. After first-time provisioning by
// wpdeploy (which sets the auctionforge_kuma_push_url option), no wpdeploy
// involvement is needed for the steady state — the cached URL plus this
// mu-plugin are the entire pipeline.
//
// Three layered triggers ensure cadence stays tight regardless of traffic:
//   1. Dedicated wp-cron event 'kuma_heartbeat_tick' on a 60s schedule
//      ('kuma_60s', registered below). Steady-state pacing.
//   2. Page-load piggy-back on 'init': any request that's >50s since the
//      last beat fires another (transient-guarded so concurrent hits
//      coalesce). Tightens cadence on busy sites; rescues low-traffic sites
//      the moment any visitor (cron, REST, XHR, anything) arrives.
//   3. Self-heal inside the cron callback: if the event somehow gets cleared,
//      re-arm it before sending the beat.
//
// Operator override: define('AUCTIONFORGE_KUMA_PUSH_URL', '<full push URL>')
// in wp-config.php to point a site directly at Kuma without ever involving
// wpdeploy. The constant takes precedence over the option.

if (!function_exists('auctionforge_keepalive_kuma_url')) {
    /**
     * Resolve the push URL. Returns null if nothing is configured.
     */
    function auctionforge_keepalive_kuma_url() {
        if (defined('AUCTIONFORGE_KUMA_PUSH_URL') && AUCTIONFORGE_KUMA_PUSH_URL) {
            return AUCTIONFORGE_KUMA_PUSH_URL;
        }
        $url = get_option('auctionforge_kuma_push_url');
        return !empty($url) ? $url : null;
    }
}

if (!function_exists('auctionforge_keepalive_kuma_external_ip')) {
    /**
     * Best-effort external IP, cached 24h via transient. Returns '' on full
     * failure — the heartbeat msg uses '?' as a placeholder so the
     * kuma_auto_group cron on wpdeploy (which splits msg on '|' and reads
     * the third field) still parses correctly.
     */
    function auctionforge_keepalive_kuma_external_ip() {
        $cached = get_transient('auctionforge_keepalive_kuma_ip');
        if ($cached !== false) return $cached;
        $services = array('https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com');
        $ip = '';
        foreach ($services as $svc) {
            $resp = wp_remote_get($svc, array('timeout' => 4, 'sslverify' => true));
            if (is_wp_error($resp)) continue;
            $body = trim((string) wp_remote_retrieve_body($resp));
            if (filter_var($body, FILTER_VALIDATE_IP)) { $ip = $body; break; }
        }
        // Cache success for 24h, failures for 30 min so a transient outage
        // doesn't penalise us for a full day.
        set_transient('auctionforge_keepalive_kuma_ip', $ip, $ip ? DAY_IN_SECONDS : 1800);
        return $ip;
    }
}

if (!function_exists('auctionforge_keepalive_kuma_send')) {
    /**
     * Build the push URL with the current msg and fire a non-blocking GET.
     * Self-heals: if the cron event isn't scheduled, re-arm it.
     */
    function auctionforge_keepalive_kuma_send() {
        $url = auctionforge_keepalive_kuma_url();
        if (!$url) return;

        // Self-heal: if the cron event got cleared (manual
        // wp_clear_scheduled_hook, buggy migration, etc.), re-arm. The very
        // next page-load piggy-back will trigger this and put the cron back.
        if (!wp_next_scheduled('kuma_heartbeat_tick')) {
            wp_schedule_event(time() + mt_rand(0, 60), 'kuma_60s', 'kuma_heartbeat_tick');
        }

        $wp = get_bloginfo('version');
        $af = defined('AUCTIONFORGE_VERSION') ? AUCTIONFORGE_VERSION : '?';
        $ip = auctionforge_keepalive_kuma_external_ip();
        $msg = $wp . '|' . $af . '|' . ($ip !== '' ? $ip : '?');

        // add_query_arg replaces existing params rather than appending duplicates.
        // Important because the URL wpdeploy stores typically ends in
        // "...?status=up&msg=&ping=" — placeholder values that we need to
        // override, not duplicate.
        $beat = add_query_arg(array(
            'status' => 'up',
            'msg'    => $msg,
            'ping'   => '',
        ), $url);

        wp_remote_get($beat, array(
            'timeout'   => 2,
            'blocking'  => false,
            'sslverify' => true,
            'headers'   => array(
                'User-Agent' => 'AuctionForge-Keepalive/1.2.1; ' . home_url(),
            ),
        ));
    }
}

// 1. Register the 60-second cron schedule. The cron_schedules filter is
//    evaluated lazily by wp-cron at fire time (when all plugins are loaded),
//    so registering here from a mu-plugin is safe even though we run
//    before regular plugins.
add_filter('cron_schedules', function ($schedules) {
    if (!isset($schedules['kuma_60s'])) {
        $schedules['kuma_60s'] = array(
            'interval' => 60,
            'display'  => 'Every 60 seconds (Kuma heartbeat)',
        );
    }
    return $schedules;
});

// 2. Wire the action and ensure the wp-cron event is scheduled. Priority 5
//    runs after the AF reactivator above (priority 1) but before AF itself
//    (default 10). Doesn't depend on AF for anything in this branch.
add_action('plugins_loaded', function () {
    add_action('kuma_heartbeat_tick', 'auctionforge_keepalive_kuma_send');

    // Migration: AuctionForge ≤ 3.7.0.19 scheduled its own
    // 'auctionforge_kuma_heartbeat' event. From AF 3.7.0.20 onward this
    // mu-plugin owns the heartbeat exclusively; clear any leftover event
    // so we don't have two cron firers for the same job.
    if (wp_next_scheduled('auctionforge_kuma_heartbeat')) {
        wp_clear_scheduled_hook('auctionforge_kuma_heartbeat');
    }

    if (auctionforge_keepalive_kuma_url() && !wp_next_scheduled('kuma_heartbeat_tick')) {
        // Random first-fire offset spreads ~50 sites' beats across a
        // minute instead of all hitting Kuma at the same instant.
        wp_schedule_event(time() + mt_rand(0, 60), 'kuma_60s', 'kuma_heartbeat_tick');
    }
}, 5);

// 3. Page-load piggy-back: any request older than 50s since the last beat
//    fires another. Coalesces concurrent hits via transient — only one
//    beat per 50s window even under heavy traffic. One transient read per
//    page is essentially free; doesn't slow anything down because the
//    actual HTTP call is non-blocking inside auctionforge_keepalive_kuma_send().
add_action('init', function () {
    if (!auctionforge_keepalive_kuma_url()) return;
    if (get_transient('auctionforge_keepalive_kuma_last') !== false) return;
    set_transient('auctionforge_keepalive_kuma_last', time(), 50);
    auctionforge_keepalive_kuma_send();
}, 1);
