<?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.
 *
 * During a sync run the importer sets $_SERVER['AUCTIONFORGE_IMPORT_LOT']. While
 * that is set, work is QUEUED and flushed once on `shutdown` — a single call
 * covers the whole run (and if any auction changed, one full purge covers all
 * lots + listings). Manual wp-admin edits purge immediately.
 *
 * @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);
    }

    /** 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();
    }

    /** delete/trash/untrash handler — only acts on manual_auction posts. */
    public static function onAuctionDelete($postId)
    {
        if (get_post_type($postId) === ManualAuctions::POST_TYPE) {
            self::purgeAuctionListings();
        }
    }

    /** auction_number term create/edit/delete handler (synced auctions). */
    public static function onAuctionTermChange()
    {
        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's rendered) drops the stale auction. Queues
     * during a sync (one full purge on shutdown); purges immediately otherwise.
     */
    public static function purgeAuctionListings()
    {
        if (!apply_filters('auctionforge_cache_purge_enabled', true)) {
            return;
        }

        if (!empty($_SERVER['AUCTIONFORGE_IMPORT_LOT'])) {
            self::$pending_listings = true;
            self::register_shutdown();
        } else {
            self::purgeEverything();
        }
    }

    /**
     * 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) {
            // 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 auction-change purge hooks 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);
}
