<?php

namespace auctionforge\inc\controllers;

/**
 * Cache-purge orchestrator.
 *
 * Single entry point for invalidating cached pages across every active cache
 * layer (Cloudflare edge, Hummingbird origin file-cache, LiteSpeed).
 *
 * LOT changes purge the lot's own URL + its parent catalog term URL (targeted).
 * AUCTION changes (a manual_auction post or an auction_number term added,
 * edited, or deleted) purge the WHOLE zone — the listing pages (Ended auctions,
 * home, upcoming) are often template-rendered with no shortcode in their
 * content, so their URLs can't be reliably enumerated; auction changes are
 * infrequent, so a full purge is the safe, correct choice.
 *
 * CONTENT changes (a page, post, lot or Elementor template saved, a menu or
 * Customizer setting changed) also purge the whole zone: Elementor headers,
 * footers, popups and Site Settings render into every page, so the edited URL
 * alone is never the full blast radius.
 *
 * All purges are QUEUED and flushed once on `shutdown`, so repeated events in
 * one request — a sync run, a bulk edit, or the save_post +
 * elementor/document/after_save pair that a single Elementor save fires —
 * coalesce into ONE purge instead of one per event. During a sync run the
 * importer sets $_SERVER['AUCTIONFORGE_IMPORT_LOT'], which additionally
 * suppresses the content hooks so an update-only sync keeps its targeted
 * per-lot purge instead of escalating to a zone purge.
 *
 * @package auctionforge\inc\controllers
 */
class CachePurge
{
    /** @var array<int,int> queued lots during a sync: postId => auction term id (0 if none). */
    private static $pending_lots = array();

    /** @var bool whether the shutdown flush has been registered this request. */
    private static $shutdown_hooked = false;

    /** @var bool an auction changed this request → the whole zone needs purging (queued during a sync). */
    private static $pending_listings = false;

    /** @var bool whether the auction-change hooks have been registered. */
    private static $auction_hooks_registered = false;

    /**
     * Register hooks that purge on auction changes — manual auctions (the
     * `manual_auction` CPT) and synced auctions (`auction_number` terms).
     * Idempotent; called once on `init` (see bottom of this file).
     */
    public static function registerAuctionHooks()
    {
        if (self::$auction_hooks_registered || !function_exists('add_action')) {
            return;
        }
        self::$auction_hooks_registered = true;

        // Manual auctions (custom post type).
        add_action('save_post_' . ManualAuctions::POST_TYPE, array(__CLASS__, 'onAuctionSave'), 20, 1);
        add_action('before_delete_post', array(__CLASS__, 'onAuctionDelete'), 20, 1);
        add_action('wp_trash_post', array(__CLASS__, 'onAuctionDelete'), 20, 1);
        add_action('untrashed_post', array(__CLASS__, 'onAuctionDelete'), 20, 1);

        // Synced auctions (taxonomy terms).
        $tax = BidspiritLots::TAX_AUCTION_TYPE;
        add_action('created_' . $tax, array(__CLASS__, 'onAuctionTermChange'), 20, 0);
        add_action('edited_' . $tax, array(__CLASS__, 'onAuctionTermChange'), 20, 0);
        add_action('delete_' . $tax, array(__CLASS__, 'onAuctionTermChange'), 20, 0);

        // Auction STATE transitions (upcoming→live→ended) are time-based and
        // arrive as term-meta writes (the bp_auto_close cron, SafetySync, the
        // import pipeline), which fire updated_term_meta but NOT
        // edited_<taxonomy> — so the term hooks above never see them and the
        // stale "live" listing stays cached. Purge when auction_state changes.
        add_action('added_term_meta', array(__CLASS__, 'onAuctionStateMetaChange'), 20, 4);
        add_action('updated_term_meta', array(__CLASS__, 'onAuctionStateMetaChange'), 20, 4);

        // Ordinary content. Without these nothing purges when a page is edited:
        // every hook above is scoped to manual_auction / auction_number, so a
        // page edited in Elementor stayed cached until the TTL expired (24h on
        // an nginx fastcgi_cache, plus whatever the CDN holds). It only ever
        // appeared to work when an unrelated auction sync purged first, which is
        // why it presented as intermittent rather than broken.
        add_action('save_post', array(__CLASS__, 'onContentSave'), 20, 2);

        // Elementor save events. Not redundant with save_post: Site Settings /
        // global kit changes and CSS regeneration do not necessarily run the
        // save_post of any single post.
        add_action('elementor/document/after_save', array(__CLASS__, 'onElementorSave'), 20, 0);
        add_action('elementor/core/files/clear_cache', array(__CLASS__, 'onElementorSave'), 20, 0);

        // Menus and Customizer settings render into every cached page.
        add_action('wp_update_nav_menu', array(__CLASS__, 'onSiteStructureChange'), 20, 0);
        add_action('customize_save_after', array(__CLASS__, 'onSiteStructureChange'), 20, 0);
    }

    /** save_post handler for a manual auction (skips autosaves/revisions). */
    public static function onAuctionSave($postId)
    {
        if (wp_is_post_revision($postId) || (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE)) {
            return;
        }
        self::purgeAuctionListings();
    }

    /**
     * Post types whose edits invalidate the public cache: everything with a
     * front end, plus `elementor_library` — Elementor headers, footers, popups
     * and theme-builder parts are not a public post type but render into EVERY
     * page, so a change to one is site-wide.
     *
     * @return string[]
     */
    public static function contentPostTypes()
    {
        $types   = array_values(get_post_types(array('public' => true), 'names'));
        $types[] = 'elementor_library';

        /**
         * Filter the post types whose saves trigger a cache purge.
         * @param string[] $types
         */
        return apply_filters('auctionforge_cache_purge_post_types', array_values(array_unique($types)));
    }

    /**
     * save_post handler for ordinary content — pages, posts, lots, Elementor
     * templates.
     *
     * @param int           $postId
     * @param \WP_Post|null $post
     */
    public static function onContentSave($postId, $post = null)
    {
        // A sync already purges correctly for the lots it touches (targeted for
        // updates, full for new lots). Escalating every lot write to a zone
        // purge would turn an update-only sync into a full purge.
        if (!empty($_SERVER['AUCTIONFORGE_IMPORT_LOT'])) {
            return;
        }
        if (wp_is_post_revision($postId) || wp_is_post_autosave($postId)) {
            return;
        }
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }

        $post = $post ? $post : get_post($postId);
        if (!$post instanceof \WP_Post || $post->post_status === 'auto-draft') {
            return;
        }
        if (!in_array($post->post_type, self::contentPostTypes(), true)) {
            return;
        }

        self::queueFullPurge();
    }

    /** Elementor saved a document: editor Update/Publish, a template, or Site Settings. */
    public static function onElementorSave()
    {
        if (!empty($_SERVER['AUCTIONFORGE_IMPORT_LOT'])) {
            return;
        }
        self::queueFullPurge();
    }

    /** A menu or Customizer setting changed — both render into every cached page. */
    public static function onSiteStructureChange()
    {
        self::queueFullPurge();
    }

    /** delete/trash/untrash handler — auctions and any cacheable content type. */
    public static function onAuctionDelete($postId)
    {
        $type = get_post_type($postId);
        if ($type === ManualAuctions::POST_TYPE || in_array($type, self::contentPostTypes(), true)) {
            self::purgeAuctionListings();
        }
    }

    /** auction_number term create/edit/delete handler (synced auctions). */
    public static function onAuctionTermChange()
    {
        self::purgeAuctionListings();
    }

    /**
     * Purge listings when an auction's state meta flips (upcoming→live→ended).
     * These transitions are time-based and land via update_term_meta() from the
     * bp_auto_close cron / SafetySync / import pipeline — none of which fire
     * edited_<taxonomy>. Reacts only to the `auction_state` key on an
     * auction_number term; the meta-key check makes unrelated term-meta writes
     * a cheap no-op.
     *
     * @param int    $meta_id
     * @param int    $object_id  Term ID whose meta changed.
     * @param string $meta_key
     * @param mixed  $meta_value
     */
    public static function onAuctionStateMetaChange($meta_id, $object_id, $meta_key, $meta_value)
    {
        if ($meta_key !== 'auction_state') {
            return;
        }
        $term = get_term((int) $object_id);
        if ($term instanceof \WP_Term && $term->taxonomy === BidspiritLots::TAX_AUCTION_TYPE) {
            self::purgeAuctionListings();
        }
    }

    /**
     * Purge a lot and its parent auction/catalog page. Queues during a sync
     * run (URLs resolved + flushed on shutdown); purges immediately otherwise.
     *
     * @param int      $postId        Lot post ID.
     * @param int|null $auctionTermId auction_number term ID (parent catalog).
     */
    public static function purgeLot($postId, $auctionTermId = null)
    {
        /**
         * Allow disabling cache purging entirely (e.g. per site). Defaults on.
         * @param bool $enabled
         */
        if (!apply_filters('auctionforge_cache_purge_enabled', true)) {
            return;
        }

        $postId = (int) $postId;
        if ($postId <= 0) {
            return;
        }

        if (!empty($_SERVER['AUCTIONFORGE_IMPORT_LOT'])) {
            // Defer: the term/meta may not be written yet. Resolve on shutdown.
            self::$pending_lots[$postId] = $auctionTermId ? (int) $auctionTermId : 0;
            self::register_shutdown();
        } else {
            self::purgeNow(self::lotUrls($postId, $auctionTermId));
        }
    }

    /**
     * An auction was added/edited/removed — purge the whole zone so every
     * listing page (however it is rendered) drops the stale auction.
     */
    public static function purgeAuctionListings()
    {
        self::queueFullPurge();
    }

    /**
     * Mark the whole zone as needing a purge and register the shutdown flush.
     *
     * Always queued, never immediate. Coalescing is the point: one Elementor
     * save fires save_post AND elementor/document/after_save, a bulk edit fires
     * save_post many times, and a sync fires an auction event per changed
     * auction — all of which collapse into ONE purgeEverything() at shutdown
     * rather than one CDN zone purge per event.
     */
    private static function queueFullPurge()
    {
        if (!apply_filters('auctionforge_cache_purge_enabled', true)) {
            return;
        }

        self::$pending_listings = true;
        self::register_shutdown();
    }

    /**
     * Purge the ENTIRE site cache across every layer. Used for auction-level
     * changes because listing pages may be template-rendered (no enumerable
     * URL). Never throws — must not break a save or a sync run.
     */
    public static function purgeEverything()
    {
        // 1) Cloudflare edge — purge the whole zone.
        if (CloudflareSupport::isConfigured()) {
            $host   = wp_parse_url(home_url('/'), PHP_URL_HOST);
            $zoneId = $host ? CloudflareSupport::getZoneIdForHost($host) : '';
            $token  = CloudflareSupport::getToken();
            if ($zoneId !== '' && $token !== '') {
                wp_remote_post(
                    'https://api.cloudflare.com/client/v4/zones/' . $zoneId . '/purge_cache',
                    array(
                        'headers' => array(
                            'Authorization' => 'Bearer ' . $token,
                            'Content-Type'  => 'application/json',
                        ),
                        'body'    => wp_json_encode(array('purge_everything' => true)),
                        'timeout' => 12,
                    )
                );
            }
        }

        // 2) LiteSpeed — full purge, if running with the plugin.
        if (defined('LSCWP_V') || defined('LSWCP_TAG_PREFIX')) {
            do_action('litespeed_purge_all');
        }

        // 3) nginx FastCGI / proxy cache — full purge (opt-in per site).
        if (NginxCacheSupport::isConfigured()) {
            NginxCacheSupport::purgeAll();
        }

        /**
         * Let other integrations hook a full-site purge (e.g. Hummingbird).
         */
        do_action('auctionforge_purge_all');
    }

    /**
     * Resolve a lot's own URL + its parent catalog URL.
     *
     * @param  int      $postId
     * @param  int|null $auctionTermId
     * @return string[]
     */
    private static function lotUrls($postId, $auctionTermId)
    {
        $urls = array();

        $lotUrl = get_permalink($postId);
        if ($lotUrl) {
            $urls[] = $lotUrl;
        }

        // Resolve the parent catalog term from the lot if it wasn't passed
        // (e.g. from a save_post hook), so callers don't need to know it.
        if (!$auctionTermId) {
            $terms = get_the_terms($postId, BidspiritLots::TAX_AUCTION_TYPE);
            if (!is_wp_error($terms) && !empty($terms) && isset($terms[0]->term_id)) {
                $auctionTermId = $terms[0]->term_id;
            }
        }
        if ($auctionTermId) {
            $termUrl = get_term_link((int) $auctionTermId, BidspiritLots::TAX_AUCTION_TYPE);
            if (!is_wp_error($termUrl) && $termUrl) {
                $urls[] = $termUrl;
            }
        }

        return $urls;
    }

    /** Register the one-time shutdown flush (fires after the whole request). */
    private static function register_shutdown()
    {
        if (!self::$shutdown_hooked && function_exists('add_action')) {
            add_action('shutdown', array(__CLASS__, 'flush'), 99);
            self::$shutdown_hooked = true;
        }
    }

    /** Flush queued work on shutdown: a full purge if any auction changed, else lot URLs. */
    public static function flush()
    {
        if (!self::$pending_listings && empty(self::$pending_lots)) {
            return;
        }

        // Hand the response back to the browser BEFORE any network I/O. Under
        // PHP-FPM the `shutdown` action still runs on the OPEN connection, and a
        // purge makes a Cloudflare API call (12s timeout) then walks the FastCGI
        // cache directory — so without this the Elementor "Update" button, and
        // every wp-admin save, visibly hangs on the purge.
        if (function_exists('fastcgi_finish_request')) {
            @fastcgi_finish_request();
        }

        if (self::$pending_listings) {
            // An auction changed during the run — one full purge covers all lots + listings.
            self::$pending_listings = false;
            self::$pending_lots = array();
            self::purgeEverything();
            return;
        }

        if (!empty(self::$pending_lots)) {
            $lots = self::$pending_lots;
            self::$pending_lots = array();
            $urls = array();
            foreach ($lots as $postId => $termId) {
                foreach (self::lotUrls($postId, $termId ?: null) as $u) {
                    $urls[] = $u;
                }
            }
            self::purgeNow($urls);
        }
    }

    /**
     * Purge a set of absolute URLs across every active cache layer, now.
     *
     * @param string[] $urls
     */
    public static function purgeNow($urls)
    {
        $urls = array_values(array_unique(array_filter(array_map('strval', (array) $urls))));
        if (empty($urls)) {
            return;
        }

        // 1) Cloudflare edge (the caching layer for CF-proxied sites).
        if (CloudflareSupport::isConfigured()) {
            CloudflareSupport::purgeUrls($urls);
        }

        // 2) Hummingbird origin file-cache, if that plugin is active.
        if (HummingbirdSupport::isHummingbirdActive()) {
            foreach ($urls as $u) {
                HummingbirdSupport::clearCacheByUrl($u);
            }
        }

        // 3) LiteSpeed, if running on a LiteSpeed server with the plugin.
        if (defined('LSCWP_V') || defined('LSWCP_TAG_PREFIX')) {
            foreach ($urls as $u) {
                // Purge quietly. The public `litespeed_purge_url` action calls
                // Purge::purge_url() with $quite=false, which enqueues a dismissible
                // "Purge url <path>" admin notice for EVERY url. During a sync this runs
                // once per changed lot, so a full-auction sync stacks up hundreds of
                // notices on the next wp-admin page and looks like a fault to clients.
                // Calling purge_url() directly with $quite=true performs the identical
                // purge without the notice; manual purges elsewhere keep their
                // confirmation. Fall back to the action if the class isn't available.
                if (class_exists('\\LiteSpeed\\Purge') && is_callable(array('\\LiteSpeed\\Purge', 'cls'))) {
                    \LiteSpeed\Purge::cls()->purge_url($u, false, true);
                } else {
                    do_action('litespeed_purge_url', $u);
                }
            }
        }

        // 4) nginx FastCGI / proxy cache (sites fronted by nginx fastcgi_cache).
        //    Inert unless the site opts in (AF_NGINX_* constants/options or an
        //    active Nginx Helper plugin) — see NginxCacheSupport.
        if (NginxCacheSupport::isConfigured()) {
            NginxCacheSupport::purgeUrls($urls);
        }

        /**
         * Let other integrations hook additional cache layers.
         * @param string[] $urls
         */
        do_action('auctionforge_purge_urls', $urls);
    }
}

// Register the purge hooks (auction changes + content changes) on `init` — deferred so
// the referenced controller classes (ManualAuctions, BidspiritLots) are all loaded first
// (this file is required before ManualAuctions in coreIncludes).
if (function_exists('add_action')) {
    add_action('init', array(CachePurge::class, 'registerAuctionHooks'), 5);
}
