<?php

namespace auctionforge\inc\controllers;

/**
 * nginx FastCGI / proxy cache purge support.
 *
 * Unlike Cloudflare (a single uniform HTTP API), nginx's fastcgi_cache has NO
 * built-in API to invalidate one cached page. There are exactly two ways to
 * purge a specific URL, and both depend on how the SERVER's nginx is set up:
 *
 *   1. REQUEST method — nginx is built with the ngx_cache_purge module plus a
 *      purge `location`. We send one HTTP request per URL to invalidate it.
 *      Needs no filesystem access, so this is the right choice for a site we
 *      do not host ourselves.
 *
 *   2. FILE method — nginx stores each cached response at
 *      <cache_dir>/<levels>/md5(cache_key). We recreate that path and unlink
 *      the file. Needs PHP-FPM write access to nginx's cache directory and the
 *      exact fastcgi_cache_key.
 *
 * If the rtCamp "Nginx Helper" plugin is active we simply delegate to it (it
 * implements the same two methods, already configured by the host).
 *
 * Nothing is configured by default, so this class is a NO-OP unless the site
 * opts in via constants (preferred, in wp-config.php) or matching options:
 *
 *   // --- Request method (ngx_cache_purge). {path}=URL path, {url}=full URL ---
 *   define('AF_NGINX_PURGE_URL', 'https://example.com/purge{path}');  // GET this
 *   //  ...or, for configs that accept the PURGE verb on the page's own URL:
 *   define('AF_NGINX_PURGE_METHOD', 'PURGE');
 *   //  ...optional endpoint for a full flush (e.g. a /purge/* wildcard):
 *   define('AF_NGINX_PURGE_ALL_URL', 'https://example.com/purge/*');
 *
 *   // --- File method ---
 *   define('AF_NGINX_CACHE_DIR',    '/var/run/nginx-cache');
 *   define('AF_NGINX_CACHE_LEVELS', '1:2');                                   // default 1:2
 *   define('AF_NGINX_CACHE_KEY',    '$scheme$request_method$host$request_uri'); // default
 *
 * Option equivalents (same names, "af_nginx_" prefix, no "AF_NGINX_"):
 *   af_nginx_purge_url, af_nginx_purge_method, af_nginx_purge_all_url,
 *   af_nginx_cache_dir, af_nginx_cache_levels, af_nginx_cache_key
 *
 * Every method is best-effort and never throws — it must not break a save or a
 * sync run. Mirrors the shape of CloudflareSupport so CachePurge can call it the
 * same way.
 *
 * @package auctionforge\inc\controllers
 */
class NginxCacheSupport
{
    const OPT_PREFIX = 'af_nginx_';

    /**
     * Option holding the last "cache dir is configured but unreachable" fault.
     * Shaped ['dir' => string, 'reason' => string, 'time' => int]; empty when healthy.
     */
    const ERROR_OPTION = 'af_nginx_purge_error';

    /** One-shot per-request guard so a sync purging 500 lots logs the fault once. */
    private static $faultNoticed = false;

    /**
     * Read a setting from its constant (preferred) then its option fallback.
     *
     * @param  string $const   e.g. 'AF_NGINX_CACHE_DIR'
     * @param  string $option  e.g. 'cache_dir' (the "af_nginx_" prefix is added)
     * @param  string $default
     * @return string
     */
    private static function cfg($const, $option, $default = '')
    {
        if (defined($const)) {
            $c = constant($const);
            if ($c !== '' && $c !== null && $c !== false) {
                return (string) $c;
            }
        }
        if (function_exists('get_option')) {
            $v = get_option(self::OPT_PREFIX . $option, null);
            if ($v !== null && $v !== '') {
                return (string) $v;
            }
        }
        return $default;
    }

    // ---- config accessors -------------------------------------------------

    private static function purgeUrlTemplate()
    {
        return trim((string) self::cfg('AF_NGINX_PURGE_URL', 'purge_url', ''));
    }

    private static function purgeVerb()
    {
        $v = strtoupper(trim((string) self::cfg('AF_NGINX_PURGE_METHOD', 'purge_method', '')));
        return in_array($v, array('PURGE', 'GET', 'DELETE'), true) ? $v : '';
    }

    private static function purgeAllUrl()
    {
        return trim((string) self::cfg('AF_NGINX_PURGE_ALL_URL', 'purge_all_url', ''));
    }

    private static function cacheDir()
    {
        return rtrim((string) self::cfg('AF_NGINX_CACHE_DIR', 'cache_dir', ''), '/');
    }

    private static function cacheLevels()
    {
        $l = trim((string) self::cfg('AF_NGINX_CACHE_LEVELS', 'cache_levels', '1:2'));
        return $l !== '' ? $l : '1:2';
    }

    private static function cacheKeyFmt()
    {
        $k = trim((string) self::cfg('AF_NGINX_CACHE_KEY', 'cache_key', '$scheme$request_method$host$request_uri'));
        return $k !== '' ? $k : '$scheme$request_method$host$request_uri';
    }

    /** @return bool whether the rtCamp Nginx Helper plugin is active. */
    public static function nginxHelperActive()
    {
        if (!function_exists('is_plugin_active')) {
            if (defined('ABSPATH') && file_exists(ABSPATH . 'wp-admin/includes/plugin.php')) {
                require_once ABSPATH . 'wp-admin/includes/plugin.php';
            }
        }
        return function_exists('is_plugin_active')
            && is_plugin_active('nginx-helper/nginx-helper.php');
    }

    /**
     * Is any nginx purge mechanism available? When false, CachePurge skips us
     * entirely, so unconfigured sites incur zero overhead and zero behaviour
     * change.
     *
     * @return bool
     */
    public static function isConfigured()
    {
        return self::purgeUrlTemplate() !== ''
            || self::purgeVerb() !== ''
            || self::cacheDir() !== ''
            || self::nginxHelperActive();
    }

    /**
     * Purge a set of absolute URLs from nginx's cache. Best-effort; never throws.
     *
     * @param string[] $urls
     */
    public static function purgeUrls($urls)
    {
        $urls = array_values(array_unique(array_filter(array_map('strval', (array) $urls))));
        if (empty($urls)) {
            return;
        }

        // Prefer the purpose-built plugin if the host installed it.
        if (self::nginxHelperActive()) {
            foreach ($urls as $u) {
                do_action('rt_nginx_helper_purge_url', $u);
            }
            return;
        }

        $tpl  = self::purgeUrlTemplate();
        $verb = self::purgeVerb();
        $dir  = self::cacheDir();
        if ($tpl === '' && $verb === '' && $dir === '') {
            return;
        }

        foreach ($urls as $u) {
            if ($tpl !== '') {
                self::purgeByRequestTemplate($u, $tpl);
            } elseif ($verb !== '') {
                self::purgeByVerb($u, $verb);
            }
            if ($dir !== '') {
                self::purgeByFile($u, $dir);
            }
        }
    }

    /**
     * Purge the whole nginx cache. Best-effort across whatever is configured.
     */
    public static function purgeAll()
    {
        if (self::nginxHelperActive()) {
            do_action('rt_nginx_helper_purge_all');
            return;
        }

        $allUrl = self::purgeAllUrl();
        if ($allUrl !== '') {
            self::request($allUrl, 'GET', 8);
        }

        $dir = self::cacheDir();
        if ($dir !== '' && self::safeCacheDir($dir)) {
            // The FastCGI cache dir is shared by every site on the box, so a
            // blanket wipe would purge unrelated sites. Delete only entries
            // whose stored cache KEY belongs to THIS host.
            $host = function_exists('home_url') ? wp_parse_url(home_url('/'), PHP_URL_HOST) : '';
            if ($host) {
                self::deleteHostFilesUnder($dir, (string) $host);
            }
        }
    }

    // ---- request-based purge ---------------------------------------------

    /** GET a purge URL built from a template ({path} / {url} placeholders). */
    private static function purgeByRequestTemplate($url, $tpl)
    {
        $parts = wp_parse_url($url);
        if (empty($parts['host'])) {
            return;
        }
        $path = isset($parts['path']) ? $parts['path'] : '/';
        if (!empty($parts['query'])) {
            $path .= '?' . $parts['query'];
        }
        $target = str_replace(array('{url}', '{path}'), array($url, $path), $tpl);
        self::request($target, 'GET', 5);
    }

    /** Send a PURGE/DELETE/GET request straight to the page's own URL. */
    private static function purgeByVerb($url, $verb)
    {
        self::request($url, $verb, 5);
    }

    /**
     * Fire an HTTP request. Blocking with a short timeout so the purge actually
     * completes (this runs batched on `shutdown`, not inline with page render).
     */
    private static function request($url, $method, $timeout)
    {
        if (!function_exists('wp_remote_request')) {
            return;
        }
        $resp = wp_remote_request($url, array(
            'method'      => $method,
            'timeout'     => (int) $timeout,
            'redirection' => 0,
            'sslverify'   => false,
            'blocking'    => true,
            'headers'     => array('Host' => (string) wp_parse_url($url, PHP_URL_HOST)),
        ));
        if (is_wp_error($resp)) {
            error_log('[AuctionForge][nginx purge] ' . $method . ' ' . $url . ' -> ' . $resp->get_error_message());
        }
    }

    // ---- file-based purge -------------------------------------------------

    /** Delete the on-disk cache file for a URL, if we can. */
    private static function purgeByFile($url, $dir)
    {
        // Reachability is checked (and any fault reported) before touching the path,
        // so an open_basedir-blocked dir can't turn every purge into a silent no-op
        // punctuated by raw PHP warnings.
        if (!self::safeCacheDir($dir)) {
            return;
        }
        $file = self::cacheFilePath($url, $dir);
        if ($file !== '' && @is_file($file)) {
            if (@is_writable($file)) {
                @unlink($file);
            } else {
                error_log('[AuctionForge][nginx purge] cache file not writable: ' . $file);
            }
        }
    }

    /**
     * Recreate nginx's cache-file path for a URL:
     *   <cache_dir>/<levels-subpath>/md5(cache_key)
     *
     * @param  string      $url
     * @param  string|null $dir
     * @return string  absolute path, or '' if it can't be built.
     */
    public static function cacheFilePath($url, $dir = null)
    {
        $dir = $dir !== null ? rtrim((string) $dir, '/') : self::cacheDir();
        if ($dir === '') {
            return '';
        }
        $key = self::cacheKeyForUrl($url);
        if ($key === '') {
            return '';
        }
        $md5 = md5($key);
        $sub = self::levelsPath($md5, self::cacheLevels());
        return $dir . '/' . ($sub !== '' ? $sub . '/' : '') . $md5;
    }

    /**
     * Build nginx's "levels" sub-path from the hash. nginx consumes the hash
     * from its END: levels=1:2 with md5 ...b7...29c -> "c/29".
     * (Matches the documented example
     *  /data/nginx/cache/c/29/b7f54b2df7773722d382f4809d65029c.)
     *
     * @param  string $md5
     * @param  string $levels e.g. "1:2"
     * @return string
     */
    public static function levelsPath($md5, $levels)
    {
        $widths = array();
        foreach (explode(':', (string) $levels) as $w) {
            $w = (int) $w;
            if ($w > 0) {
                $widths[] = $w;
            }
        }
        $segs = array();
        $pos  = strlen($md5);
        foreach ($widths as $w) {
            if ($pos - $w < 0) {
                break;
            }
            $pos   -= $w;
            $segs[] = substr($md5, $pos, $w);
        }
        return implode('/', $segs);
    }

    /**
     * Render the fastcgi_cache_key for a URL. Supports the variables that occur
     * in real-world keys; request method defaults to GET (what a cached page is).
     *
     * @param  string $url
     * @return string
     */
    public static function cacheKeyForUrl($url)
    {
        $p = wp_parse_url($url);
        if (empty($p['host'])) {
            return '';
        }
        $scheme = isset($p['scheme']) ? $p['scheme'] : 'https';
        $host   = $p['host'];
        $port   = empty($p['port']) ? '' : ':' . $p['port'];
        $pathq  = isset($p['path']) ? $p['path'] : '/';
        $path   = $pathq;
        if (!empty($p['query'])) {
            $pathq .= '?' . $p['query'];
        }
        $vars = array(
            '$scheme'         => $scheme,
            '$request_method' => 'GET',
            '$host'           => $host,
            '$http_host'      => $host . $port,
            '$request_uri'    => $pathq,
            '$uri'            => $path,
            '$args'           => isset($p['query']) ? $p['query'] : '',
            '$is_args'        => empty($p['query']) ? '' : '?',
        );
        return strtr(self::cacheKeyFmt(), $vars);
    }

    // ---- guards -----------------------------------------------------------

    /** Only ever recurse into a plausible, deep-enough, writable cache dir. */
    private static function safeCacheDir($dir)
    {
        $dir = rtrim((string) $dir, '/');
        if ($dir === '' || $dir === '/') {
            return false;
        }
        // Require at least two path segments (e.g. /var/run/nginx-cache) to rule
        // out catastrophic roots like /var or /www.
        $depth = substr_count(ltrim($dir, '/'), '/');
        if ($depth < 1) {
            return false;
        }
        // The configured cache dir often sits OUTSIDE the site's open_basedir (e.g. aaPanel
        // restricts each site to its own docroot + /tmp, while the shared nginx FastCGI cache
        // lives at /www/server/fastcgi_cache). is_dir()/is_writable() would then emit an
        // "open_basedir restriction in effect" Warning on every purge and always return false,
        // so test that first rather than letting PHP spray warnings. On such sites direct-file
        // deletion cannot run — use AF_NGINX_PURGE_URL (the ngx_cache_purge location) or add
        // the path to open_basedir.
        //
        // We REPORT every failure here (see noteCacheDirFault): silently returning false is
        // what let a fleet-wide purge outage go unnoticed, because the caller just skips the
        // delete and the purge looks successful.
        if (!self::dirWithinOpenBasedir($dir)) {
            self::noteCacheDirFault($dir, 'outside open_basedir');
            return false;
        }
        if (!@is_dir($dir)) {
            self::noteCacheDirFault($dir, 'does not exist or is unreadable');
            return false;
        }
        if (!@is_writable($dir)) {
            self::noteCacheDirFault($dir, 'not writable by the PHP user');
            return false;
        }
        self::clearCacheDirFault();
        return true;
    }

    /** True when $dir is reachable under the active open_basedir (or none is set). */
    private static function dirWithinOpenBasedir($dir)
    {
        $ob = ini_get('open_basedir');
        if ($ob === false || $ob === '') {
            return true; // no restriction in effect
        }
        $needle = rtrim($dir, '/') . '/';
        $sep = (DIRECTORY_SEPARATOR === '\\') ? ';' : ':';
        foreach (explode($sep, $ob) as $base) {
            $base = trim($base);
            if ($base === '') {
                continue;
            }
            $base = rtrim($base, '/') . '/';
            if ($base !== '/' && strpos($needle, $base) === 0) {
                return true;
            }
        }
        return false;
    }

    // ---- fault reporting --------------------------------------------------

    /**
     * Record — loudly — that a cache dir IS configured but cannot be reached.
     *
     * This exists because the failure is otherwise invisible: purgeAll()/purgeByFile()
     * simply skip the delete and return, so a purge that cleared nothing is
     * indistinguishable from one that worked. That silence hid a fleet-wide outage —
     * aaPanel writes `open_basedir=/www/wwwroot/<site>/:/tmp/` into each site's
     * .user.ini while the shared FastCGI cache lives at /www/server/fastcgi_cache,
     * so every purge no-op'd and pages stayed stale until fastcgi_cache_valid expired.
     *
     * Worth knowing when debugging: `.user.ini` is PHP_INI_PERDIR and is read ONLY by
     * the FPM/CGI SAPI — WP-CLI ignores it. So a `wp eval` purge test PASSES while the
     * real web/webhook path silently fails. Always verify a purge in FPM context.
     *
     * @param string $dir    the configured (unreachable) cache dir
     * @param string $reason short machine-ish reason, e.g. 'outside open_basedir'
     */
    private static function noteCacheDirFault($dir, $reason)
    {
        if (self::$faultNoticed) {
            return; // already reported this request
        }
        self::$faultNoticed = true;

        error_log(
            '[AuctionForge][nginx purge] INERT: cache dir "' . $dir . '" ' . $reason
            . ' — nginx FastCGI pages will stay stale until fastcgi_cache_valid expires.'
            . ' Fix: add the path to this site\'s open_basedir (.user.ini), or configure'
            . ' AF_NGINX_PURGE_URL instead.'
        );

        if (!function_exists('update_option')) {
            return;
        }
        // Only write when the state actually changes — this runs on every purge.
        $stored = get_option(self::ERROR_OPTION, array());
        if (!is_array($stored)
            || !isset($stored['dir'], $stored['reason'])
            || $stored['dir'] !== $dir
            || $stored['reason'] !== $reason
        ) {
            update_option(
                self::ERROR_OPTION,
                array('dir' => $dir, 'reason' => $reason, 'time' => time()),
                false // never autoload
            );
        }
    }

    /** Clear the fault flag once a purge demonstrably reaches the cache dir. */
    private static function clearCacheDirFault()
    {
        if (!function_exists('get_option')) {
            return;
        }
        $stored = get_option(self::ERROR_OPTION, array());
        if (!empty($stored)) {
            delete_option(self::ERROR_OPTION);
        }
    }

    /**
     * Public health probe — lets the admin UI / support tooling ask whether the
     * file-deletion purge can actually run, without attempting a purge.
     *
     * @return array ['configured' => bool, 'ok' => bool, 'dir' => string, 'reason' => string]
     */
    public static function purgeHealth()
    {
        $dir = self::cacheDir();
        if ($dir === '') {
            return array('configured' => false, 'ok' => true, 'dir' => '', 'reason' => 'file method not configured');
        }
        if (!self::dirWithinOpenBasedir($dir)) {
            return array('configured' => true, 'ok' => false, 'dir' => $dir, 'reason' => 'outside open_basedir');
        }
        if (!@is_dir($dir)) {
            return array('configured' => true, 'ok' => false, 'dir' => $dir, 'reason' => 'does not exist or is unreadable');
        }
        if (!@is_writable($dir)) {
            return array('configured' => true, 'ok' => false, 'dir' => $dir, 'reason' => 'not writable by the PHP user');
        }
        return array('configured' => true, 'ok' => true, 'dir' => $dir, 'reason' => '');
    }

    /**
     * Warn admins in wp-admin when the purge is configured but inert, so a stale
     * catalog is traceable to its cause instead of looking like a plugin bug.
     */
    public static function maybeShowPurgeWarning()
    {
        if (!function_exists('current_user_can') || !current_user_can('manage_options')) {
            return;
        }
        $e = get_option(self::ERROR_OPTION, array());
        if (empty($e['dir'])) {
            return;
        }
        echo '<div class="notice notice-warning"><p><strong>AuctionForge:</strong> the nginx page-cache purge is <strong>not working</strong> — the configured cache directory <code>'
            . esc_html($e['dir']) . '</code> is ' . esc_html($e['reason'])
            . '. Auction and lot pages will keep showing stale content to logged-out visitors until the cache expires on its own. '
            . 'Ask your host to add that path to this site\'s <code>open_basedir</code>, or clear <code>af_nginx_cache_dir</code> if this site does not use an nginx page cache.</p></div>';
    }

    /**
     * Delete cache files under $dir whose stored KEY belongs to $host, leaving
     * sibling sites that share the cache zone untouched. nginx writes an ASCII
     * "KEY: <scheme><method><host><uri>" line near the top of each cache file;
     * we parse the host back out and match it exactly. Files-only, recursive,
     * best-effort. Runs on auction-level purges (infrequent), not page render.
     */
    private static function deleteHostFilesUnder($dir, $host)
    {
        $host = strtolower($host);
        $entries = @scandir($dir);
        if ($entries === false) {
            return;
        }
        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            $p = $dir . '/' . $entry;
            if (is_link($p)) {
                continue; // never follow symlinks out of the cache tree
            }
            if (is_dir($p)) {
                self::deleteHostFilesUnder($p, $host);
            } elseif (is_file($p)) {
                $fileHost = self::cacheFileHost($p);
                if ($fileHost !== '' && strtolower($fileHost) === $host) {
                    @unlink($p);
                }
            }
        }
    }

    /**
     * Read the host out of a cache file's stored KEY line. Returns '' if it
     * can't be parsed — in which case the file is left untouched (conservative).
     *
     * @param  string $file
     * @return string
     */
    private static function cacheFileHost($file)
    {
        $fh = @fopen($file, 'rb');
        if (!$fh) {
            return '';
        }
        $head = @fread($fh, 2048);
        @fclose($fh);
        if (!is_string($head) || $head === '') {
            return '';
        }
        // KEY value = "$scheme$request_method$host$request_uri", e.g.
        // "httpsGETgallery63.net/auctions/". Capture the host (up to the first
        // slash that begins the request_uri).
        if (preg_match('#KEY:\s*https?(?:GET|HEAD|POST|PUT|DELETE|OPTIONS|PATCH)([^/\s]+)/#i', $head, $m)) {
            return $m[1];
        }
        return '';
    }
}

// Surface an inert purge in wp-admin. Registered here (rather than in an admin
// controller) so the warning travels with the class that raises it.
if (function_exists('add_action')) {
    add_action('admin_notices', array(NginxCacheSupport::class, 'maybeShowPurgeWarning'));
}
