<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Maintenance;

use Numinix\SeekmodoWordPress\Gateway\BrowserToken;

/**
 * Self-healing version-bump detector. Runs once per page request; on
 * the first one after the on-disk plugin version moves forward
 * (`SEEKMODO_WP_VERSION` constant != stored option value), fans out
 * cache flushes to every page-cache / minify plugin we know about
 * and invalidates the cached browser-token transient so the new
 * code-path emits a freshly-minted token instead of a stale one.
 *
 * Why this class exists:
 *
 *   Tenants deploy the connector through three different channels —
 *   WordPress's native upgrader (Plugins → Update), wp-cli, and
 *   rsync-based git deploys (POCO and Trek both use this). Only the
 *   first fires `upgrader_process_complete`, so cache-plugin auto-
 *   flushes that hook into that action miss two of the three deploy
 *   paths. Result: WP Rocket / LiteSpeed / W3TC keep serving the
 *   prior version's minified JS for hours-to-days even though the
 *   new file is sitting on disk. End users never feel the upgrade.
 *
 *   The fix is a deploy-channel-agnostic detector at the connector
 *   layer: compare a stored "active version" to the constant in the
 *   plugin's main file on every request, and on a mismatch flush
 *   everything we can reach. Cheap (one wp_options read per request,
 *   short-circuits when versions match) and self-correcting (the
 *   option is updated atomically right before flushing, so we never
 *   double-fire even under concurrent requests racing on the same
 *   first-bump window).
 *
 * What we flush:
 *
 *   - **WP Rocket**: `rocket_clean_minify()` for the JS/CSS combine
 *     cache (the most common offender — minify cache TTLs are 1y),
 *     plus `rocket_clean_domain()` for the per-page HTML cache so
 *     the connector's `wp_localize_script` `gateway` block lands on
 *     every cached storefront page.
 *
 *   - **LiteSpeed Cache**: `do_action('litespeed_purge_all')` covers
 *     page + object + minify caches on a single call.
 *
 *   - **W3 Total Cache**: `w3tc_pgcache_flush()` + `w3tc_minify_flush()`
 *     when the plugin's helper functions exist. We avoid calling
 *     `w3tc_flush_all()` because the operator may be running an
 *     externally-warmed varnish/redis CDN that they want to keep
 *     warm — page + minify is enough for connector regression
 *     prevention.
 *
 *   - **WP Super Cache**: `wp_cache_clean_cache(...)` and
 *     `wp_cache_clear_cache()` between them cover both supercached
 *     HTML and the meta files.
 *
 *   - **WP-Optimize / Hummingbird / Cache Enabler / Pantheon /
 *     Kinsta / Cloudflare WP**: each exposes a documented
 *     `do_action(...)` hook for "purge everything"; we fire them
 *     all and the no-ops fall through harmlessly on sites that
 *     don't have the host plugin installed.
 *
 *   - **Object cache (`wp_cache_flush()`)**: clears the in-process
 *     object cache so a stale `seekmodo_wp_*` transient from the
 *     previous version doesn't leak into the new code path on the
 *     same request.
 *
 *   - **Browser token transient**: explicitly invalidated via
 *     {@see BrowserToken::invalidate()} so the next typeahead
 *     keystroke re-mints a token signed against whatever the new
 *     gateway expects. Sprint-7 introduced the JWT shape but a
 *     future shape change would otherwise be invisible to clients
 *     until the cached token aged out (up to 240s).
 *
 *   - **OPCache (`opcache_reset()`)**: dumps the bytecode cache so
 *     the v0.5.x PHP classes that just landed on disk actually
 *     execute on the next request. Many shared hosts have OPCache
 *     enabled with `opcache.validate_timestamps=0` for performance,
 *     which means rsync'd PHP changes silently don't take effect
 *     until a manual restart. This call is the single biggest
 *     cause of "I deployed but nothing changed" tickets, and it's
 *     free to make on every connector upgrade.
 *
 * Concurrency & idempotency:
 *
 *   The version-bump option is updated *before* the flush fan-out,
 *   inside `update_option()` which uses a row-level UPDATE under
 *   the hood. Two concurrent requests racing on the same first-
 *   bump page-load both see the stale value, both call
 *   `update_option`, and the second one's call returns false
 *   (since the row is already at the new value). We use that
 *   return value as the gate: only the request that successfully
 *   wrote the new version proceeds to flush, so cache plugins
 *   never see a duplicate purge call inside the same second.
 *
 * Operator overrides:
 *
 *   - `seekmodo_skip_upgrade_flush` filter (bool) lets a host with
 *     custom cache infra suppress the connector-side flush, e.g. a
 *     publisher who runs CI-driven cache invalidation and doesn't
 *     want the connector duplicating it.
 *
 *   - `seekmodo_upgrade_flush_actions` filter (array<string>) adds
 *     extra `do_action(...)` keys to fire alongside the built-in
 *     set. Useful for proprietary cache plugins.
 */
final class Upgrade
{
    /**
     * `wp_options` key holding the last version this connector code
     * detected itself running as. Compared to `SEEKMODO_WP_VERSION`
     * on every request; on mismatch we flush + update.
     *
     * Stored as an option (not transient) so the value survives
     * across object-cache flushes (which is otherwise the very thing
     * we're fanning out below). Option name uses the
     * `seekmodo_wp_*` prefix to follow the same convention as
     * {@see \Numinix\SeekmodoWordPress\Config\Options} keys, but is
     * not declared there because no other code reads it — the
     * detector is the sole owner.
     */
    public const OPT_ACTIVE_VERSION = 'seekmodo_wp_active_version';

    /**
     * Page-cache / minify-cache plugin hooks to fan out. Each is
     * fired via `do_action(...)` and falls through harmlessly on
     * sites that don't have the host plugin installed.
     *
     * Action names sourced from each plugin's public docs as of
     * 2026-Q2. New cache plugins can be appended without a code
     * change via the `seekmodo_upgrade_flush_actions` filter.
     *
     * Note: WP Rocket, W3TC, WP Super Cache expose function-based
     * APIs rather than action hooks; those are called from
     * {@see self::flushAll()} directly.
     *
     * @var array<int,string>
     */
    private const FLUSH_ACTIONS = [
        // LiteSpeed Cache — covers page + object + minify in one shot.
        'litespeed_purge_all',
        // WP-Optimize.
        'wpo_purge_pagecache',
        // Hummingbird (WPMU DEV).
        'wphb_clear_page_cache',
        // Cache Enabler.
        'cache_enabler_clear_complete_cache',
        // Pantheon Advanced Page Cache.
        'pantheon_wp_clear_edge_all',
        // Kinsta MU plugin.
        'kinsta_cache_purge_all',
        // Cloudflare WP plugin (the official Cloudflare-published one;
        // the variant `do_action('cloudflare_purge_everything')` is
        // documented in their support kb).
        'cloudflare_purge_everything',
        // Breeze (Cloud Panel) — fires its global purge.
        'breeze_clear_all_cache',
        // SG Optimizer (SiteGround).
        'sg_cachepress_purge_cache',
        // Comet Cache.
        'comet_cache_clear_cache',
    ];

    /**
     * Hook into `init` priority 1 (before most plugin code, after
     * Options is registered) so the version bump is detected on the
     * very first request after a deploy regardless of which entry
     * point WordPress was bootstrapped through.
     */
    public static function register(): void
    {
        if (!function_exists('add_action')) {
            return;
        }
        add_action('init', [self::class, 'detectAndFlush'], 1);
    }

    /**
     * Detect a version mismatch and, if found, flush. Cheap on the
     * common path (one wp_options read + a constant compare); only
     * the first request after a deploy pays the flush cost.
     */
    public static function detectAndFlush(): void
    {
        if (!defined('SEEKMODO_WP_VERSION')) {
            return;
        }
        $current = (string) SEEKMODO_WP_VERSION;
        if ($current === '') {
            return;
        }

        // get_option() is hot-cached after first call within a
        // request, so we don't worry about repeated invocation.
        $stored = (string) get_option(self::OPT_ACTIVE_VERSION, '');

        // Fresh-install path: option doesn't exist yet (or holds the
        // empty default). Seed silently — the activation hook flow
        // already ran the "first install" cache priming, no need to
        // double-fire flushes.
        if ($stored === '') {
            update_option(self::OPT_ACTIVE_VERSION, $current, false);
            return;
        }

        if ($stored === $current) {
            return;
        }

        // Operator escape hatch — a host with bespoke cache infra
        // can suppress the connector-side flush.
        if ((bool) apply_filters('seekmodo_skip_upgrade_flush', false, $stored, $current)) {
            update_option(self::OPT_ACTIVE_VERSION, $current, false);
            return;
        }

        // Atomic write-then-flush so two concurrent first-bump
        // requests don't both trigger the fan-out. update_option
        // returns false when the row was already at $current
        // (i.e. a competing request beat us to it).
        $won = update_option(self::OPT_ACTIVE_VERSION, $current, false);
        if (!$won) {
            return;
        }

        self::flushAll($stored, $current);
    }

    /**
     * Run the full flush fan-out. Public for the wp-cli command and
     * the operator-facing "Flush caches" admin button (Sprint 7+
     * follow-up); no-ops if no version mismatch detected — call
     * `detectAndFlush()` instead in the normal request path.
     */
    public static function flushAll(string $previous, string $current): void
    {
        // 1. Page-/minify-cache plugins fanned out via documented
        //    do_action hooks. Each is a no-op on sites that don't
        //    have the host plugin installed.
        $actions = self::FLUSH_ACTIONS;
        $extra = apply_filters('seekmodo_upgrade_flush_actions', [], $previous, $current);
        if (is_array($extra)) {
            foreach ($extra as $a) {
                if (is_string($a) && $a !== '') {
                    $actions[] = $a;
                }
            }
        }
        foreach (array_unique($actions) as $action) {
            try {
                do_action($action);
            } catch (\Throwable $e) {
                // A misbehaving cache plugin shouldn't break the
                // upgrade detection or the request that triggered it.
                // Continue down the fan-out list.
            }
        }

        // 2. WP Rocket — function API rather than an action.
        //    `rocket_clean_minify()` is the surgical call (drops
        //    JS/CSS combine cache only); `rocket_clean_domain()`
        //    drops the per-URL HTML cache so the storefront's next
        //    page render emits the new SEEKMODO_TYPEAHEAD localize
        //    block (which carries the v0.5.0+ `gateway` browser
        //    token block).
        if (function_exists('rocket_clean_minify')) {
            try { rocket_clean_minify(['css', 'js']); } catch (\Throwable $e) { /* swallow */ }
        }
        if (function_exists('rocket_clean_domain')) {
            try { rocket_clean_domain(); } catch (\Throwable $e) { /* swallow */ }
        }

        // 3. W3 Total Cache — ditto, function API.
        if (function_exists('w3tc_pgcache_flush')) {
            try { w3tc_pgcache_flush(); } catch (\Throwable $e) { /* swallow */ }
        }
        if (function_exists('w3tc_minify_flush')) {
            try { w3tc_minify_flush(); } catch (\Throwable $e) { /* swallow */ }
        }

        // 4. WP Super Cache.
        if (function_exists('wp_cache_clean_cache')) {
            try {
                global $file_prefix;
                wp_cache_clean_cache((string) ($file_prefix ?? ''), true);
            } catch (\Throwable $e) { /* swallow */ }
        }
        if (function_exists('wp_cache_clear_cache')) {
            try { wp_cache_clear_cache(); } catch (\Throwable $e) { /* swallow */ }
        }

        // 5. Object cache — drops any per-version cached transients
        //    (browser token, snapshot cache) so the new code path
        //    starts cold instead of inheriting the prior version's
        //    serialized payloads.
        if (function_exists('wp_cache_flush')) {
            try { wp_cache_flush(); } catch (\Throwable $e) { /* swallow */ }
        }

        // 6. Browser token transient: explicit invalidate so the
        //    next typeahead/search call re-mints rather than
        //    sending a token signed against the prior version's
        //    storage layout. The transient already has a 240s TTL
        //    floor so this is mostly belt-and-suspenders, but it
        //    closes the "operator deployed mid-cached-window"
        //    window cleanly.
        try { BrowserToken::invalidate(); } catch (\Throwable $e) { /* swallow */ }

        // 7. OPCache — biggest single fix for "rsync'd new PHP and
        //    nothing changed" deploys. opcache_reset is permitted
        //    in CLI + web SAPIs unless the host has explicitly
        //    disabled it (e.g. opcache.restrict_api). Wrapping in
        //    a function check keeps us safe on PHP builds without
        //    OPCache compiled in (rare on modern hosts but possible
        //    on minimal Docker images).
        if (function_exists('opcache_reset')) {
            try { @opcache_reset(); } catch (\Throwable $e) { /* swallow */ }
        }

        /**
         * Fires after the connector has finished its post-upgrade
         * flush fan-out. Hosts running tracker / observability
         * plugins use this to record connector-driven invalidations
         * separately from operator-driven ones in their analytics.
         *
         * @param string $previous Last-active version string (the
         *                         one we just upgraded *from*).
         * @param string $current  Now-active version string (the
         *                         one we just upgraded *to*).
         */
        do_action('seekmodo_upgrade_flush_complete', $previous, $current);
    }
}
