<?php
/**
 * One-shot cron cleanup migration — runs once after upgrading to 3.7.0.28.
 *
 * Before 3.7.0.28, several scheduling sites in AuctionForge stored
 * $_SERVER['SERVER_NAME'] in their wp_schedule_event() args. The
 * wp_next_scheduled() guard keyed on those args, so any time the site was
 * reached under a different hostname (canonical, www., IP-direct, internal
 * cron caller using 127.0.0.1, old domain from before a rename), a brand-new
 * event got scheduled and the old one was never cleared. After months of
 * operation a site could carry 3–6 duplicates of every sync hook, each
 * firing redundantly every hour.
 *
 * Separately, hooks `bidspirit_meta_migrate_sync` and `bidspirit_migrate_sync`
 * were scheduled by older plugin versions whose code has since been removed.
 * They sit in the schedule firing into the void (no callback registered).
 *
 * This class:
 *   1. Unschedules every event for the hooks we just rewrote in 3.7.0.28
 *      (clearing the per-hostname accumulation). The new scheduling code
 *      re-arms each one cleanly with no args.
 *   2. Unschedules every event for the orphan hooks that no longer have
 *      callbacks anywhere in the plugin.
 *   3. Marks the migration done so it never runs again.
 *
 * Idempotent via the auctionforge_cron_cleanup_v1 option.
 */

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

class AF_Cron_Cleanup {

    const OPTION = 'auctionforge_cron_cleanup_v1';

    /**
     * Hooks that pre-3.7.0.28 scheduled with $_SERVER['SERVER_NAME'] in args.
     * Clear all events for these hooks (any args, any timestamp) — the new
     * scheduling code re-arms them with no args on the next page load /
     * cron tick.
     */
    const PER_HOSTNAME_HOOKS = array(
        'bidspirit_import',
        'data_import',
        'bidspirit_archive_import',
        'auctionforge_sync_safety',
    );

    /**
     * Hooks that older plugin versions scheduled but whose callbacks have
     * since been removed from the codebase. Clear unconditionally.
     */
    const ORPHAN_HOOKS = array(
        'bidspirit_meta_migrate_sync',
        'bidspirit_migrate_sync',
    );

    public static function register() {
        // Run on every load, but guarded by the option — exits in microseconds
        // once the migration has completed. Hooking on 'init' (not 'plugins
        // _loaded') so wp_next_scheduled() / wp_unschedule_event() are
        // available with full WP context.
        add_action('init', array(__CLASS__, 'maybe_run'), 20);
    }

    public static function maybe_run() {
        if (get_option(self::OPTION) === 'done') {
            return;
        }

        // Avoid racing with another request mid-upgrade — set the option
        // first, do the work second. Worst case: a race causes a single
        // duplicate sweep, which is harmless.
        update_option(self::OPTION, 'done', false);

        $cleared = array();

        // For per-hostname hooks we only clear events with NON-empty args.
        // The top-level scheduling code in auctionforge.php has already run
        // by this point (it runs at plugin load, before any 'init' hook),
        // so a clean no-args event for each of these hooks already exists.
        // Clearing only non-empty-args events removes the legacy duplicates
        // without orphaning the schedule even briefly. After this sweep, the
        // schedule for each per-hostname hook contains exactly one event,
        // with empty args, on its normal recurrence.
        foreach (self::PER_HOSTNAME_HOOKS as $hook) {
            $n = self::clear_legacy_args_events_for_hook($hook);
            if ($n > 0) {
                $cleared[$hook] = $n;
            }
        }

        // Orphan hooks have no callbacks anywhere in the codebase — clear
        // unconditionally, including any (rare) empty-args entries.
        foreach (self::ORPHAN_HOOKS as $hook) {
            $n = self::clear_all_events_for_hook($hook);
            if ($n > 0) {
                $cleared[$hook] = $n;
            }
        }

        if (!empty($cleared) && class_exists('\\auctionforge\\inc\\sync\\SyncLogger')) {
            \auctionforge\inc\sync\SyncLogger::log(
                'cron-cleanup',
                'Cleared duplicate/orphan cron events: ' . wp_json_encode($cleared),
                'info'
            );
        }
    }

    /**
     * Run the duplicate/orphan sweep on demand, ignoring the one-shot guard.
     * The automatic migration runs once per site, so stale per-hostname events
     * created by an older build AFTER the migration marked itself done are
     * never cleared. The admin "Clear duplicate cron events" button calls this.
     *
     * @return array hook => number of events removed
     */
    public static function force_sweep() {
        $cleared = array();
        foreach (self::PER_HOSTNAME_HOOKS as $hook) {
            $n = self::clear_legacy_args_events_for_hook($hook);
            if ($n > 0) $cleared[$hook] = $n;
        }
        foreach (self::ORPHAN_HOOKS as $hook) {
            $n = self::clear_all_events_for_hook($hook);
            if ($n > 0) $cleared[$hook] = $n;
        }
        if (!empty($cleared) && class_exists('\\auctionforge\\inc\\sync\\SyncLogger')) {
            \auctionforge\inc\sync\SyncLogger::log(
                'cron-cleanup',
                'Manual sweep cleared duplicate/orphan cron events: ' . wp_json_encode($cleared),
                'info'
            );
        }
        return $cleared;
    }

    /**
     * Unschedule every event for a hook whose args array is non-empty.
     *
     * Use for hooks where the current scheduling code uses empty args — we
     * want to keep that clean entry alive and only sweep the legacy
     * per-hostname duplicates that carry $_SERVER['SERVER_NAME'] in args.
     *
     * @return int Number of events removed.
     */
    private static function clear_legacy_args_events_for_hook($hook) {
        $cron = _get_cron_array();
        if (!is_array($cron)) return 0;

        $removed = 0;
        foreach ($cron as $timestamp => $hooks) {
            if (!isset($hooks[$hook])) continue;
            foreach ($hooks[$hook] as $event) {
                $args = isset($event['args']) ? $event['args'] : array();
                if (empty($args)) continue; // preserve the new clean entry
                wp_unschedule_event($timestamp, $hook, $args);
                $removed++;
            }
        }
        return $removed;
    }

    /**
     * Unschedule every event for a hook regardless of args or timestamp.
     *
     * wp_clear_scheduled_hook($hook) only matches events whose args array is
     * empty — events with non-empty args are left behind. To clear those, we
     * have to walk _get_cron_array() and unschedule each by exact args.
     *
     * @return int Number of events removed.
     */
    private static function clear_all_events_for_hook($hook) {
        $cron = _get_cron_array();
        if (!is_array($cron)) return 0;

        $removed = 0;
        foreach ($cron as $timestamp => $hooks) {
            if (!isset($hooks[$hook])) continue;
            foreach ($hooks[$hook] as $event) {
                $args = isset($event['args']) ? $event['args'] : array();
                wp_unschedule_event($timestamp, $hook, $args);
                $removed++;
            }
        }
        return $removed;
    }
}
