<?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_';

    /**
     * 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)
    {
        $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, '/'), '/');
        return $depth >= 1 && is_dir($dir) && is_writable($dir);
    }

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