<?php

#define('WPC_CF_TOKEN', 'vPn-BuupnJ3VmJUAVPt0V7BaeWvFID_ljh_2UMoz');

// Rule identifiers for WP Compress plugin
const WPC_BYPASS_RULE_REF = 'wpc-bypass-cache';
const WPC_STATIC_RULE_REF = 'wpc-static-assets';
const WPC_HOMEPAGE_RULE_REF = 'wpc-homepage-html';
const WPC_FULLHTML_RULE_REF = 'wpc-full-html';
const WPC_CONFIG_INJECT_RULE_REF = 'wpc-config-inject'; // CF Piece 2 (signed x-wpc-config), scaffold


class WPC_CloudflareAPI
{
    private $apiToken;
    private $apiBase = 'https://api.cloudflare.com/client/v4/';

    /**
     * Constructor to initialize the API token
     *
     * @param string $apiToken Your Cloudflare API token
     */
    public function __construct($apiToken = '')
    {

        if (empty($apiToken)) {
            // Nothing
            return false;
        }

        $this->apiToken = $apiToken;
    }



    /***
     * CF API
     */
    public function configureCF($htmlCacheMode, $staticAssetsEnabled)
    {
        $requests = new wps_ic_requests();

        $cfSettings = get_option(WPS_IC_CF);
        $zoneInput = $cfSettings['zone'];
        $token = $cfSettings['token'];

        $options = get_option(WPS_IC_OPTIONS);
        $apikey = $options['api_key'];

        $siteUrl = site_url();
        $zoneName = str_replace(array('http://', 'https://', '/'), '', $siteUrl);

        $body = $requests->GET(WPS_IC_KEYSURL, ['action' => 'updateCFConfig', 'token' => $token, 'zone' => $zoneInput, 'zoneName' => $zoneName, 'siteUrl' => $siteUrl, 'apikey' => $apikey, 'time' => microtime(true), 'staticAssets' => $staticAssetsEnabled, 'htmlCache' => $htmlCacheMode], ['timeout' => (int) apply_filters('wpc_cf_keys_timeout', 15)]); // dropped from 120 to 15: a 2-minute keys round-trip on an admin path is a guaranteed worker-pin, so cap it

        if (!empty($body)) {
            $data = (array)$body->data;
            return $data;
        }

        return false;
    }



    /**
     * Check Rocket Loader Status
     *
     * @return array|WP_Error List of zones or WP_Error
     */
    public function checkRocketLoader($zoneId)
    {
        $rlResp = $this->getRequest("zones/$zoneId/settings/rocket_loader");

        if (is_wp_error($rlResp)) {
            // Store per-zone error but keep going for other zones
            $results[$zoneId] = new WP_Error('cloudflare_api_error', "Failed to fetch Rocket Loader " . $rlResp->get_error_message());

            return 'failed to fetch rocket loader';
        }

        // Cloudflare returns: { result: { id, value, editable, modified_on, ... } }
        if (!empty($rlResp['result']) && isset($rlResp['result']['value'])) {
            $results[$zoneId] = ['value' => $rlResp['result']['value'],       // 'on' | 'off'
                'modified_on' => $rlResp['result']['modified_on'] ?? null, 'editable' => $rlResp['result']['editable'] ?? null,];

            return $results;
        } else {
            $results[$zoneId] = new WP_Error('cloudflare_api_error', "Unexpected response while fetching Rocket Loader");

            return false;
        }
    }

    /**
     * Send a GET request to the Cloudflare API
     *
     * @param string $endpoint API endpoint
     * @param array $query Optional query parameters
     * @return array|WP_Error The API response or WP_Error
     */
    private function getRequest($endpoint, $query = [])
    {
        $url = add_query_arg($query, $this->apiBase . $endpoint);

        $response = wp_remote_get($url, ['headers' => $this->getHeaders(), 'timeout' => (int) apply_filters('wpc_cf_api_timeout', 8)]); // hard cap (previously the WP default): a slow/unreachable CF API must never pin an admin worker


        return $this->processResponse($response);
    }

    /**
     * Get standard headers for the API requests
     *
     * @return array
     */
    private function getHeaders()
    {
        return ['Authorization' => 'Bearer ' . $this->apiToken, 'Content-Type' => 'application/json',];
    }

    /**
     * Process the API response
     *
     * @param array|WP_Error $response API response
     * @return array|WP_Error Parsed response or WP_Error
     */
    private function processResponse($response)
    {
        if (is_wp_error($response)) {
            return $response;
        }

        $body = wp_remote_retrieve_body($response);
        $data = json_decode($body, true);

        if (!empty($data['errors'])) {
            $error_messages = array_map(function ($error) {
                return $error['message']; // Extract error messages
            }, $data['errors']);

            $error_message = implode(', ', $error_messages); // Combine multiple messages if needed

            return new WP_Error('cloudflare_api_error', $error_message, $data['errors']);
        }

        return $data;
    }

    /**
     * Classify a CF SDK call result into a DIAGNOSABLE status, so Connect/Reconnect can tell the
     * operator WHICH failure mode happened (missing token permission vs misconfigured zone vs
     * never-heard-back) instead of a silent hang or a bare "failed". Reads the CF error CODES that
     * processResponse() preserves in the WP_Error data.
     *
     * @param mixed $result array (CF success) | true (rule already present) | false (lost) | WP_Error
     * @return array ['ok'=>bool,'mode'=>'ok|permission|misconfig|unreachable|unknown','detail'=>string]
     */
    public static function classifyResult($result)
    {
        if ($result === true) {
            return ['ok' => true, 'mode' => 'ok', 'detail' => 'OK'];
        }
        if (is_array($result)) {
            if (array_key_exists('success', $result) && !$result['success']) {
                $m = !empty($result['errors'][0]['message']) ? (string) $result['errors'][0]['message'] : 'Cloudflare reported failure';
                return ['ok' => false, 'mode' => 'unknown', 'detail' => $m];
            }
            return ['ok' => true, 'mode' => 'ok', 'detail' => 'OK'];
        }
        if (is_wp_error($result)) {
            $code = (string) $result->get_error_code();
            $msg  = (string) $result->get_error_message();
            // (1) transport-level: we NEVER heard back (timeout / DNS / connection refused).
            if ($code === 'http_request_failed'
                || stripos($msg, 'timed out') !== false || stripos($msg, 'timeout') !== false
                || stripos($msg, 'could not resolve') !== false || stripos($msg, 'failed to connect') !== false
                || stripos($msg, 'connection') !== false || stripos($msg, 'resolve host') !== false) {
                return ['ok' => false, 'mode' => 'unreachable',
                    'detail' => 'Could not reach Cloudflare (' . ($msg !== '' ? $msg : 'no response') . '). This is usually transient — try Reconnect again.'];
            }
            // (2) CF answered with an error body: inspect the preserved CF error codes.
            $data  = $result->get_error_data();
            $codes = [];
            if (is_array($data)) {
                foreach ($data as $e) {
                    if (is_array($e) && isset($e['code'])) $codes[] = (int) $e['code'];
                }
            }
            $permCodes = [10000, 9109, 9106, 9103, 1000]; // auth / unauthorized-to-resource
            $zoneCodes = [7003, 7000, 1001, 1049, 1061];  // could-not-route / invalid / not-found zone
            foreach ($codes as $c) {
                if (in_array($c, $permCodes, true)) {
                    return ['ok' => false, 'mode' => 'permission',
                        'detail' => 'Cloudflare rejected it — your API token is missing a required permission (needs Firewall Services: Edit, Cache Rules: Edit, and Cache Purge). CF said: ' . $msg];
                }
            }
            foreach ($codes as $c) {
                if (in_array($c, $zoneCodes, true)) {
                    return ['ok' => false, 'mode' => 'misconfig',
                        'detail' => 'Cloudflare zone looks misconfigured (' . $msg . '). Check the zone is active and the token is scoped to it.'];
                }
            }
            if (stripos($msg, 'authentication') !== false || stripos($msg, 'unauthor') !== false || stripos($msg, 'permission') !== false) {
                return ['ok' => false, 'mode' => 'permission',
                    'detail' => 'Cloudflare rejected it — token permissions. CF said: ' . $msg];
            }
            return ['ok' => false, 'mode' => 'unknown', 'detail' => $msg !== '' ? $msg : 'Cloudflare error'];
        }
        // false / null — the lossy legacy path (e.g. bypass-token fetch failed; no CF response captured).
        return ['ok' => false, 'mode' => 'unknown',
            'detail' => 'Could not complete — no Cloudflare response captured (likely a token or connection issue). Try Reconnect again.'];
    }

    /**
     * Retrieve the list of zones
     *
     * @return array|WP_Error List of zones or WP_Error
     */
    public function listZones($page = 1)
    {
        return $this->getRequest('zones', ['per_page' => 50, 'page' => $page]);
    }

    /**
     * Purge all cache for a specific zone
     *
     * @param string $zoneId Cloudflare Zone ID
     * @return array|WP_Error The API response or WP_Error
     */
    public function purgeCache($zoneId)
    {
        return $this->postRequest("zones/$zoneId/purge_cache", ['purge_everything' => true,]);
    }

    /**
     * Purge all cache asynchronously (fire-and-forget)
     *
     * @param string $zoneId Cloudflare Zone ID
     */
    public function purgeCacheAsync($zoneId)
    {
        $url = $this->apiBase . "zones/$zoneId/purge_cache";
        wp_remote_post($url, [
            'headers'  => $this->getHeaders(),
            'body'     => json_encode(['purge_everything' => true]),
            'blocking' => false,
            'timeout'  => 0.01,
        ]);
    }

    /**
     * Purge SPECIFIC files asynchronously (fire-and-forget). The HTML-only update purge uses this so a
     * plugin update NEVER purge_everything — that would wipe all edge-cached IMAGES, forcing a fleet-wide
     * cold-miss storm (the CDN re-probes the slow origin for every image → PHP-worker saturation → 40-60s
     * HTML). Scoped to the given URLs; images stay cached. CF allows up to 30 files per call (free/pro).
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param array  $files  Absolute URLs to purge
     */
    public function purgeFilesAsync($zoneId, $files)
    {
        if (empty($files) || !is_array($files)) return;
        $url = $this->apiBase . "zones/$zoneId/purge_cache";
        wp_remote_post($url, [
            'headers'  => $this->getHeaders(),
            'body'     => json_encode(['files' => array_values(array_slice($files, 0, 30))]),
            'blocking' => false,
            'timeout'  => 0.01,
        ]);
    }

    /**
     * Send a POST request to the Cloudflare API
     *
     * @param string $endpoint API endpoint
     * @param array $body Request body
     * @return array|WP_Error The API response or WP_Error
     */
    private function postRequest($endpoint, $body = [])
    {
        $url = $this->apiBase . $endpoint;

        $response = wp_remote_post($url, ['headers' => $this->getHeaders(), 'body' => json_encode($body), 'timeout' => (int) apply_filters('wpc_cf_api_timeout', 8)]); // hard cap (previously none, so it fell back to the WP default; this is the purge_cache + firewall/cache-rule writer)

        return $this->processResponse($response);
    }

    /**
     * Purge specific files from the cache
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param array $files List of file URLs to purge
     * @return array|WP_Error The API response or WP_Error
     */
    public function purgeFiles($zoneId, $files)
    {
        return $this->postRequest("zones/$zoneId/purge_cache", ['files' => $files,]);
    }

    /**
     * Purge cache scoped to specific hostnames so an HTML purge doesn't flush the image CDN host
     * (and vice-versa). CF purge-by-host (hosts[]) is an ENTERPRISE-only feature: on Free/Pro/Business
     * CF rejects it (returns a WP_Error here), so we fall back to a full purge_everything and report
     * scoped=false. Either way the purge happens; only Enterprise zones get true scoping. This blocks
     * and returns a verifiable result, since the call sites are manual purge buttons, not a hot path.
     *
     * @param string $zoneId
     * @param array  $hosts  hostnames to scope to, e.g. ['www.example.com']
     * @return array ['scoped'=>bool, 'hosts'=>array, 'result'=>array|WP_Error]
     */
    public function purgeScoped($zoneId, $hosts)
    {
        $hosts = array_values(array_filter(array_unique(array_map('strval', (array) $hosts)), 'strlen'));
        if (!empty($hosts)) {
            $res = $this->postRequest("zones/$zoneId/purge_cache", ['hosts' => $hosts]);
            if (!is_wp_error($res) && !empty($res['success'])) {
                return ['scoped' => true, 'hosts' => $hosts, 'result' => $res];
            }
        }
        // hosts[] unsupported on this plan (Enterprise-only) or no hosts resolved → full purge so the
        // cache still clears (no regression vs the pre-.82 purge_everything behaviour).
        return ['scoped' => false, 'hosts' => $hosts, 'result' => $this->purgeCache($zoneId)];
    }

    /**
     * VERIFY a CF-CNAME is edge-live and orch-resolved before the resolver is allowed to EMIT it
     * (the provision-then-promote gate). A request to https://{cfCname}/wp-content/... is resolved
     * by the pod via Host→agencySites.cname; until the orch has the row it returns the
     * {"error":"Site not found","hasApikey":false} body (proven on classicpoolandspa/perkzilla).
     * So "live" = the response did NOT come back as Site-not-found AND it actually traversed CF
     * (cf-ray present means edge, not NXDOMAIN/grey-cloud). An origin_404 or 200 both count as resolved.
     * Bounded retries cover the brief DNS/cert/orch propagation window after a save. Returns bool.
     *
     * @param string $cfCname  the host to probe (e.g. cdn.example.com)
     * @param int    $tries
     * @return bool
     */
    public function verifyCfCnameLive($cfCname, $tries = 3, $timeout = 8)
    {
        $cfCname = trim((string) $cfCname);
        if ($cfCname === '' || !function_exists('wp_remote_get')) {
            return false;
        }
        // Probe a stable uploads path + a cache-buster so a stale edge bucket can't mask resolution.
        $probe = 'https://' . $cfCname . '/wp-content/uploads/wpc-cname-verify.png?cb=' . (function_exists('wp_rand') ? wp_rand() : 1);
        for ($i = 0; $i < max(1, (int) $tries); $i++) {
            $r = wp_remote_get($probe, ['timeout' => max(2, (int) $timeout), 'sslverify' => false, 'redirection' => 0]);
            if (!is_wp_error($r)) {
                $code    = (int) wp_remote_retrieve_response_code($r);
                $body    = (string) wp_remote_retrieve_body($r);
                $cfRay   = wp_remote_retrieve_header($r, 'cf-ray');
                $ctype   = (string) wp_remote_retrieve_header($r, 'content-type');
                $through_cf = !empty($cfRay);
                $site_not_found = (stripos($body, 'Site not found') !== false) || (stripos($body, '"hasApikey":false') !== false);
                // RESOLVED only on a status that means "zone found": a 404 (the made-up probe file
                // legitimately doesn't exist on origin, so the zone DID resolve — origin_404) or a 200
                // that is an actual image. EXPLICITLY reject 5xx and CF challenge/block codes
                // (403/429/503): those still traverse CF (cf-ray present) and aren't "Site not found",
                // but they mean the host is NOT serving assets (WAF 1020, JS challenge, edge 5xx), so
                // promoting on them would latch a BROKEN host. Must be CF-traversed, not-site-not-found,
                // and a found-zone status.
                // Also accept a redirect (301/302/307/308): the pod 302s a missing .png probe
                // path to the natural sibling or origin, which still proves the zone RESOLVED (cf-traversed,
                // not "Site not found"). Rejecting redirects stranded CF-direct zones whose probe path
                // naturally redirects (acrystalglass: the .png verify-probe redirects with a 302).
                $resolved_status = ($code === 404) || ($code === 200 && stripos($ctype, 'image/') !== false) || in_array($code, [301, 302, 307, 308], true);
                if ($through_cf && !$site_not_found && $resolved_status) {
                    return true;
                }
            }
            if ($i + 1 < $tries) {
                sleep(2); // brief propagation backoff (the save handler can afford it)
            }
        }
        return false;
    }

    /**
     * Fetch the CDN bypass token from WPC API.
     * Auto-generated server-side if it doesn't exist yet.
     *
     * @return string|false The 64-char hex token, or false on failure
     */
    public function getCdnBypassToken() {
        $options = get_option(WPS_IC_OPTIONS);
        if (empty($options['api_key'])) {
            error_log('[WPC] getCdnBypassToken: no api_key in options');
            return false;
        }

        $response = wp_remote_get(
            WPS_IC_KEYSURL . '?action=get_cf_bypass_token&apikey=' . $options['api_key'],
            ['timeout' => 15, 'sslverify' => false]
        );

        if (is_wp_error($response)) {
            error_log('[WPC] getCdnBypassToken: wp_remote_get error: ' . $response->get_error_message());
            return false;
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);
        if (empty($body['success']) || empty($body['data']['token'])) {
            error_log('[WPC] getCdnBypassToken: unexpected response: ' . wp_remote_retrieve_body($response));
            return false;
        }

        return $body['data']['token'];
    }

    /**
     * Create a WAF custom rule that skips country-block for CDN origin fetches.
     * Safe to call multiple times — checks for existing rule first.
     * Appends to existing rules, never replaces.
     *
     * @param string $zoneId Cloudflare Zone ID
     * @return array|true|false Result from CF API, true if already exists, false on failure
     */
    public function addCdnBypassRule($zoneId) {
        $token = $this->getCdnBypassToken();
        if (!$token) {
            error_log('[WPC] addCdnBypassRule: failed to get bypass token');
            return false;
        }

        $url = 'zones/' . $zoneId . '/firewall/rules';

        // Check if bypass rule already exists
        $existing = $this->getRequest($url);
        if (!is_wp_error($existing) && !empty($existing['result'])) {
            foreach ($existing['result'] as $rule) {
                if (!empty($rule['description']) && $rule['description'] === 'Optimizer Bypass [DO NOT EDIT]') {
                    return true;
                }
            }
        }

        $expression = 'any(http.request.headers["x-origin-auth"][*] == "' . $token . '")';

        // Create using legacy Firewall Rules API — works with "Firewall Services > Edit" permission
        // Bypass action skips all security features, priority 1 ensures it evaluates first
        $result = $this->postRequest($url, [[
            'action'      => 'bypass',
            'description' => 'Optimizer Bypass [DO NOT EDIT]',
            'priority'    => 1,
            'products'    => ['zoneLockdown', 'uaBlock', 'bic', 'hot', 'securityLevel', 'rateLimit', 'waf'],
            'filter'      => [
                'expression' => $expression,
                'paused'     => false,
            ],
        ]]);

        if (is_wp_error($result)) {
            error_log('[WPC] addCdnBypassRule CF API error: ' . $result->get_error_message());
            return $result; // return the WP_Error rather than `false`, so classifyResult() can read the CF code (permission vs misconfig vs unreachable) instead of a bare fail
        }

        return $result;
    }

    /**
     * Remove the CDN bypass WAF rule on CF disconnect.
     *
     * @param string $zoneId Cloudflare Zone ID
     * @return array|false Result from CF API or false on failure
     */
    public function removeCdnBypassRule($zoneId) {
        $url = 'zones/' . $zoneId . '/firewall/rules';
        $existing = $this->getRequest($url);

        if (is_wp_error($existing) || empty($existing['result'])) return false;

        foreach ($existing['result'] as $rule) {
            if (!empty($rule['description']) && $rule['description'] === 'Optimizer Bypass [DO NOT EDIT]' && !empty($rule['id'])) {
                return $this->deleteRequest($url . '/' . $rule['id']);
            }
        }

        return false;
    }


    /**
     * Whitelist IPs in the Cloudflare Firewall
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param array $ipList List of IPs or IP ranges to whitelist
     * @return array|WP_Error The API response or WP_Error
     */
    public function whitelistIPs($zoneId)
    {
        if (!file_exists(WPC_API_WHITELIST)) {
            error_log('[WPC] whitelistIPs: whitelist-ip.txt not found');
            return false;
        }

        $errors = false;
        $contents = file_get_contents(WPC_API_WHITELIST);
        $ipList = array_filter(array_map('trim', explode("\n", $contents)));

        foreach ($ipList as $ip) {
            if (strpos($ip, ':') !== false) {
                // Adding IPv6 range $ip to firewall rules
                $success = $this->addIpAccessRule($zoneId, $ip);
            } else {
                // Adding IP $ip to access rules
                $success = $this->addIpAccessRule($zoneId, $ip);
            }

            $success = true;

            if (!$success) {
                $errors = true;
                break;
            }
        }

        if (!$errors) {
            return true;
        } else {
            return new WP_Error('cloudflare_api_error', 'Unable to whitelist IPs', $errors);
        }
    }


    public function removeWhitelistIP($zoneId)
    {
        $r = [];
        $r[] = $this->removeIpAccessRuleByNote($zoneId, 'WP Compress API Endpoint');
//        $contents = file_get_contents(WPC_API_WHITELIST);
//        $ipList = array_filter(array_map('trim', explode("\n", $contents)));
//        foreach ($ipList as $ip) {
//            if (strpos($ip, ':') !== false) {
//                // IPv6 range: Remove from Firewall Rules
//                $r[] = $this->removeIpAccessRule($zoneId, $ip);
//            } else {
//                // IPv4: Remove from Access Rules
//                $r[] = $this->removeIpAccessRule($zoneId, $ip);
//            }
//        }

        return $r;
    }

    public function removeIpAccessRuleByNote($zoneId, $note)
    {
        $url = 'zones/' . $zoneId . '/firewall/access_rules/rules';
        $allRules = [];
        $page = 1;
        $perPage = 50; // Max allowed is 50

        do {
            // Fetch the current page
            $response = $this->getRequest($url . "?page=$page&per_page=$perPage");

            if (is_wp_error($response)) {
                return $response->get_error_message();
            }

            if (!empty($response['result'])) {
                $allRules = array_merge($allRules, $response['result']);
            }

            $page++;
        } while (!empty($response['result'])); // Continue until no more results

        if (!empty($allRules)) {
            foreach ($allRules as $rule) {
                if (!empty($rule['notes']) && $rule['notes'] === $note) {
                    $r = $this->deleteRequest('zones/' . $zoneId . '/firewall/access_rules/rules/' . $rule['id']);
                }
            }
            return true;
        }

        return false;
    }

    public function deleteRequest($endpoint)
    {
        $url = $this->apiBase . $endpoint;

        $response = wp_remote_request($url, ['method' => 'DELETE', 'headers' => $this->getHeaders(), 'timeout' => (int) apply_filters('wpc_cf_api_timeout', 8),]); // hard cap (previously none)

        return $this->processResponse($response);
    }

    public function removeFirewallRule($zoneId, $ip)
    {
        $url = 'zones/' . $zoneId . '/firewall/rules';

        // Fetch existing firewall rules
        $response = $this->getRequest($url);

        if (is_wp_error($response)) {
            return $response->get_error_message();
        }

        if (!empty($response['result'])) {
            $expectedExpression = "ip.src in {$ip}";

            foreach ($response['result'] as $rule) {
                if ($rule['filter']['expression'] === $expectedExpression) {
                    // Rule matches, delete it
                    $ruleId = $rule['id'];
                    $this->deleteRequest('zones/' . $zoneId . '/firewall/rules/' . $ruleId);
                    return true;
                }
            }
        }
    }

    public function addFirewallRule($zoneId, $ip)
    {
        $url = 'zones/' . $zoneId . '/firewall/rules';
        $body = ["action" => "allow", "description" => "WP Compress API - IPv6 Range", "filter" => ["expression" => "ip.src in {\"$ip\"}", "paused" => false]];

        $response = $this->postRequest($url, $body);
    }

    public function addIpAccessRule($zoneId, $ip)
    {
        $url = 'zones/' . $zoneId . "/firewall/access_rules/rules";

        $body = ["mode" => 'whitelist', "configuration" => ["target" => "ip", "value" => $ip,], "notes" => 'WP Compress API Endpoint'];

        $response = $this->postRequest($url, $body);
        // Check if the request was successful
        if (is_wp_error($response)) {

            if ($response->get_error_message() == 'firewallaccessrules.api.duplicate_of_existing') {
                $error = 'Invalid request headers - Invalid API Token.';
                return true;
            }

            return false;
        } else {
            return true;
        }
    }

    public function removeIpAccessRule($zoneId, $ip)
    {
        $url = 'zones/' . $zoneId . '/firewall/access_rules/rules';

        // Fetch existing access rules
        $response = $this->getRequest($url);

        if (is_wp_error($response)) {
            return $response->get_error_message();
        }

        if (!empty($response['result'])) {
            foreach ($response['result'] as $rule) {
                if (strpos($ip, ':') !== false) {
                    $expandedIp = $this->expandIPv6($ip);
                }

                if ($rule['configuration']['value'] === $ip || (strpos($ip, ':') !== false && $rule['configuration']['value'] === $expandedIp)) {
                    // Rule matches, delete it
                    $ruleId = $rule['id'];
                    $r = 'found ip ' . $ip . "\r\n";
                    #$r = $this->deleteRequest('zones/' . $zoneId . '/firewall/access_rules/rules/' . $ruleId);
                    return $r;
                }
            }
        }
    }

    public function expandIPv6($ip)
    {
        // Split the IPv6 address into segments
        $segments = explode(':', $ip);

        // Handle the "::" shorthand
        if (strpos($ip, '::') !== false) {
            $missingSegments = 8 - count($segments) + 1; // Calculate missing segments
            $expandedSegments = [];
            foreach ($segments as $segment) {
                if ($segment === '') {
                    // Insert missing zero segments
                    for ($i = 0; $i < $missingSegments; $i++) {
                        $expandedSegments[] = '0000';
                    }
                } else {
                    $expandedSegments[] = $segment;
                }
            }
            $segments = $expandedSegments;
        }

        // Pad each segment to ensure 4 digits
        foreach ($segments as &$segment) {
            $segment = str_pad($segment, 4, '0', STR_PAD_LEFT);
        }

        // Join the segments into the fully expanded IPv6 address
        return implode(':', $segments);
    }

    /**
     * Set Rocket Loader Status
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string $value 'on' or 'off'
     * @return array|WP_Error The API response or WP_Error
     */
    public function setRocketLoader($zoneId, $value)
    {
        if (!in_array($value, ['on', 'off'])) {
            return new WP_Error('invalid_value', 'Value must be "on" or "off"');
        }

        return $this->patchRequest("zones/$zoneId/settings/rocket_loader", ['value' => $value]);
    }

    /**
     * Send a PATCH request to the Cloudflare API
     *
     * @param string $endpoint API endpoint
     * @param array $body Request body
     * @return array|WP_Error The API response or WP_Error
     */
    private function patchRequest($endpoint, $body = [])
    {
        $url = $this->apiBase . $endpoint;

        $response = wp_remote_request($url, ['method' => 'PATCH', 'headers' => $this->getHeaders(), 'body' => json_encode($body), 'timeout' => (int) apply_filters('wpc_cf_api_timeout', 8),]); // hard cap (previously none)

        return $this->processResponse($response);
    }

    /**
     * Update WP Compress cache configuration based on settings (UPDATED for multi-domain)
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param bool $staticAssetsEnabled Static assets cache toggle
     * @param string $htmlCacheMode 'off', 'home', or 'all'
     * @return array Results of the operation
     */
    public function updateWPCCacheConfig($zoneId, $staticAssetsEnabled, $htmlCacheMode)
    {
        $results = [];
        $results['debug'] = [];

        // Log input parameters
        $results['debug']['input'] = ['zoneId' => $zoneId, 'staticAssetsEnabled' => $staticAssetsEnabled, 'htmlCacheMode' => $htmlCacheMode];

        // Determine if any caching is enabled
        $anyCacheEnabled = $staticAssetsEnabled || ($htmlCacheMode !== 'off');
        $results['debug']['anyCacheEnabled'] = $anyCacheEnabled;

        // BYPASS rule - add/update if any cache is enabled, remove current domain if all off
        if ($anyCacheEnabled) {
            $results['debug']['bypass_action'] = 'ensuring current domain is in rule';
            $bypassResult = $this->addCacheRule($zoneId, $this->getBypassRule(), ['index' => 1]);
            $results['bypass'] = $bypassResult;
            if (is_wp_error($bypassResult)) {
                $results['debug']['bypass_error'] = $bypassResult->get_error_message();
            }
        } else {
            $results['debug']['bypass_action'] = 'removing current domain from rule';
            $results['bypass'] = $this->deleteCacheRuleByRef($zoneId, WPC_BYPASS_RULE_REF);
        }

        // STATIC ASSETS rule
        if ($staticAssetsEnabled) {
            $results['debug']['static_action'] = 'ensuring current domain is in rule';
            $staticResult = $this->addCacheRule($zoneId, $this->getStaticAssetsRule());
            $results['static'] = $staticResult;
            if (is_wp_error($staticResult)) {
                $results['debug']['static_error'] = $staticResult->get_error_message();
            }
        } else {
            $results['debug']['static_action'] = 'removing current domain from rule';
            $results['static'] = $this->deleteCacheRuleByRef($zoneId, WPC_STATIC_RULE_REF);
        }

        // HOMEPAGE HTML rule
        if ($htmlCacheMode === 'home' || $htmlCacheMode === 'all') {
            $results['debug']['homepage_action'] = 'ensuring current domain is in rule';
            $homepageResult = $this->addCacheRule($zoneId, $this->getHomepageHTMLRule());
            $results['homepage'] = $homepageResult;
            if (is_wp_error($homepageResult)) {
                $results['debug']['homepage_error'] = $homepageResult->get_error_message();
            } else {
                $results['fullhtml'] = $this->deleteCacheRuleByRef($zoneId, WPC_FULLHTML_RULE_REF);
            }
        } else {
            $results['debug']['homepage_action'] = 'removing current domain from rule';
            $results['homepage'] = $this->deleteCacheRuleByRef($zoneId, WPC_HOMEPAGE_RULE_REF);
        }

        // FULL HTML rule
        if ($htmlCacheMode === 'all') {
            $results['debug']['fullhtml_action'] = 'ensuring current domain is in rule';
            $fullhtmlResult = $this->addCacheRule($zoneId, $this->getFullHTMLRule());
            $results['fullhtml'] = $fullhtmlResult;
            if (is_wp_error($fullhtmlResult)) {
                $results['debug']['fullhtml_error'] = $fullhtmlResult->get_error_message();
            } else {
                $results['homepage'] = $this->deleteCacheRuleByRef($zoneId, WPC_HOMEPAGE_RULE_REF);
            }
        } else {
            $results['debug']['fullhtml_action'] = 'removing current domain from rule';
            $results['fullhtml'] = $this->deleteCacheRuleByRef($zoneId, WPC_FULLHTML_RULE_REF);
        }

        // Update Tiered Cache setting
        $results['debug']['tiered_cache_action'] = $anyCacheEnabled ? 'enabling' : 'disabling';
        $tieredResult = $this->setTieredCache($zoneId, $anyCacheEnabled);
        $results['tiered_cache'] = $tieredResult;
        if (is_wp_error($tieredResult)) {
            $results['debug']['tiered_cache_error'] = $tieredResult->get_error_message();
        }

        return $results;
    }

    /**
     * Add a single cache rule (UPDATED - now supports multiple domains)
     * If rule exists, adds current domain to it. If not, creates new rule.
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param array $rule Single cache rule configuration
     * @param array $position Optional position object
     *
     * @return array|WP_Error The API response or WP_Error
     */
    public function addCacheRule($zoneId, $rule, $position = null)
    {
        $ruleRef = $rule['ref'];

        // Check if rule already exists
        $existingRule = $this->findCacheRuleByRef($zoneId, $ruleRef);

        if ($existingRule) {
            // Rule exists - add current domain to it
            $currentDomains = $this->getCurrentDomainVariations();
            return $this->addDomainsToRule($zoneId, $ruleRef, $currentDomains);
        }

        // Rule doesn't exist - create it (original logic below)
        $rulesetId = $this->getCacheRulesRulesetId($zoneId);

        // If no ruleset exists, create one with this rule
        if (is_wp_error($rulesetId)) {
            return $this->postRequest("zones/$zoneId/rulesets", ['name' => 'Cache Rules', 'kind' => 'zone', 'phase' => 'http_request_cache_settings', 'rules' => [$rule]]);
        }

        // Add position to request body if specified
        $body = $rule;
        if ($position !== null) {
            $body['position'] = $position;
        }

        // Add rule to existing ruleset (SAFE - doesn't replace other rules)
        return $this->postRequest("zones/$zoneId/rulesets/$rulesetId/rules", $body);
    }

    /**
     * Find a rule by its ref field
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string $ref Rule reference identifier
     *
     * @return array|null Rule data or null if not found
     */
    public function findCacheRuleByRef($zoneId, $ref)
    {
        $rules = $this->listCacheRules($zoneId);

        if (is_wp_error($rules)) {
            return null;
        }

        foreach ($rules as $rule) {
            if (isset($rule['ref']) && $rule['ref'] === $ref) {
                return $rule;
            }
        }

        return null;
    }

    /**
     * List all cache rules for a zone
     *
     * @param string $zoneId Cloudflare Zone ID
     *
     * @return array|WP_Error List of rules or WP_Error
     */
    public function listCacheRules($zoneId)
    {
        $rulesetId = $this->getCacheRulesRulesetId($zoneId);

        if (is_wp_error($rulesetId)) {
            // If no ruleset exists yet, return empty array
            if ($rulesetId->get_error_code() === 'no_ruleset') {
                return [];
            }

            return $rulesetId;
        }

        $response = $this->getRequest("zones/$zoneId/rulesets/$rulesetId");

        if (is_wp_error($response)) {
            return $response;
        }

        return $response['result']['rules'] ?? [];
    }

    /**
     * Get current site's domain variations (www and non-www)
     *
     * @return array Array of domain variations for current site
     */
    private function getCurrentDomainVariations()
    {
        $domain = $this->getDomain();

        // Handle both www and non-www versions
        if (strpos($domain, 'www.') === 0) {
            $base_domain = substr($domain, 4);
            return [$domain, $base_domain];
        } else {
            $www_domain = 'www.' . $domain;
            return [$domain, $www_domain];
        }
    }

    /**
     * Get the current site's domain/subdomain
     *
     * Returns the current domain or subdomain that the site is running on.
     * Strips www. prefix and properly handles multi-level TLDs like .co.uk
     *
     * @return string Current domain/subdomain (e.g., 'example.com' or 'blog.example.com')
     */
    public function getDomain()
    {
        $current_host = parse_url(get_site_url(), PHP_URL_HOST);

        // Remove www. if present
        if (strpos($current_host, 'www.') === 0) {
            $current_host = substr($current_host, 4);
        }

        return $current_host;
    }

    /**
     * Add domains to an existing cache rule
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string $ruleRef Rule reference identifier
     * @param array $newDomains Domains to add
     * @return array|WP_Error The API response or WP_Error
     */
    private function addDomainsToRule($zoneId, $ruleRef, $newDomains)
    {
        // Get existing rule
        $rule = $this->findCacheRuleByRef($zoneId, $ruleRef);
        if (!$rule) {
            return new WP_Error('rule_not_found', "Rule with ref '$ruleRef' not found");
        }

        // Extract current domains
        $currentDomains = $this->extractDomainsFromExpression($rule['expression']);

        // Merge and deduplicate
        $allDomains = array_unique(array_merge($currentDomains, $newDomains));

        // Update expression
        $rule['expression'] = $this->updateDomainsInExpression($rule['expression'], $allDomains);

        // Update the rule
        $rulesetId = $this->getCacheRulesRulesetId($zoneId);
        if (is_wp_error($rulesetId)) {
            return $rulesetId;
        }

        return $this->patchRequest("zones/$zoneId/rulesets/$rulesetId/rules/{$rule['id']}", $rule);
    }

    /**
     * Extract domains from a Cloudflare expression
     * Parses expressions like: (http.host in {"domain1.com" "domain2.com"}) and ...
     *
     * @param string $expression Cloudflare rule expression
     * @return array List of domains found in the expression
     */
    private function extractDomainsFromExpression($expression)
    {
        // Match pattern: http.host in {"domain1" "domain2" ...}
        if (preg_match('/http\.host in \{([^}]+)\}/', $expression, $matches)) {
            $domainString = $matches[1];
            // Extract quoted strings
            preg_match_all('/"([^"]+)"/', $domainString, $domainMatches);
            return $domainMatches[1];
        }
        return [];
    }

    /**
     * Update domains in a Cloudflare expression
     * Rebuilds the http.host in {...} part with new domain list
     *
     * @param string $expression Original expression
     * @param array $domains New list of domains
     * @return string Updated expression
     */
    private function updateDomainsInExpression($expression, $domains)
    {
        // Build new domain list string
        $domainList = array_map(function ($domain) {
            return '"' . $domain . '"';
        }, $domains);
        $domainString = implode(' ', $domainList);

        // Replace the http.host in {...} part
        return preg_replace('/http\.host in \{[^}]+\}/', 'http.host in {' . $domainString . '}', $expression);
    }

    private function getBypassRule()
    {
        return ['ref' => WPC_BYPASS_RULE_REF, 'action' => 'set_cache_settings', 'description' => '[DO NOT EDIT] Bypass cache for admin/login/commerce', 'enabled' => true, 'expression' => '(http.request.method ne "GET" and http.request.method ne "HEAD") or (starts_with(http.request.uri.path, "/wp-admin") or http.request.uri.path eq "/wp-login.php" or http.request.uri.path contains "/wp-cron.php" or http.request.uri.path contains "/xmlrpc.php" or starts_with(http.request.uri.path, "/wp-json/") or http.request.uri.path contains "/admin-ajax.php" or ends_with(http.request.uri.path, "/cart/") or ends_with(http.request.uri.path, "/checkout/") or starts_with(http.request.uri.path, "/my-account")) or (http.cookie contains "wordpress_logged_in_" or http.cookie contains "wordpress_sec_" or http.cookie contains "wp-postpass_" or http.cookie contains "woocommerce_cart_hash" or http.cookie contains "woocommerce_items_in_cart" or http.cookie contains "wp_woocommerce_session_" or http.cookie contains "tk_ai" or http.cookie contains "edd_") or (lower(http.request.uri.query) contains "nocache=" or lower(http.request.uri.query) contains "no-cache=" or lower(http.request.uri.query) contains "wc-ajax=" or lower(http.request.uri.query) contains "edd_action=" or lower(http.request.uri.query) contains "preview=")', 'action_parameters' => ['cache' => false]];
    }

    /**
     * Delete a cache rule by its ref field (UPDATED - now removes only current domain)
     * Only deletes entire rule if current domain is the last one
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string $ref Rule reference identifier
     *
     * @return array|WP_Error The API response or WP_Error
     */
    public function deleteCacheRuleByRef($zoneId, $ref)
    {
        $currentDomains = $this->getCurrentDomainVariations();
        return $this->removeDomainsFromRule($zoneId, $ref, $currentDomains);
    }

    /**
     * Remove domains from an existing cache rule
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string $ruleRef Rule reference identifier
     * @param array $domainsToRemove Domains to remove
     * @return array|WP_Error The API response or WP_Error
     */
    private function removeDomainsFromRule($zoneId, $ruleRef, $domainsToRemove)
    {
        // Get existing rule
        $rule = $this->findCacheRuleByRef($zoneId, $ruleRef);
        if (!$rule) {
            // Rule doesn't exist, nothing to remove
            return ['success' => true, 'message' => 'Rule not found, nothing to remove'];
        }

        // Extract current domains
        $currentDomains = $this->extractDomainsFromExpression($rule['expression']);

        // Remove specified domains
        $remainingDomains = array_diff($currentDomains, $domainsToRemove);

        // If no domains left, delete the entire rule
        if (empty($remainingDomains)) {
            return $this->deleteCacheRule($zoneId, $rule['id']);
        }

        // Update expression with remaining domains
        $rule['expression'] = $this->updateDomainsInExpression($rule['expression'], $remainingDomains);

        // Update the rule
        $rulesetId = $this->getCacheRulesRulesetId($zoneId);
        if (is_wp_error($rulesetId)) {
            return $rulesetId;
        }

        return $this->patchRequest("zones/$zoneId/rulesets/$rulesetId/rules/{$rule['id']}", $rule);
    }

    /**
     * Delete a cache rule by its ID
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string $ruleId Rule ID to delete
     *
     * @return array|WP_Error The API response or WP_Error
     */
    public function deleteCacheRule($zoneId, $ruleId)
    {
        $rulesetId = $this->getCacheRulesRulesetId($zoneId);

        if (is_wp_error($rulesetId)) {
            return $rulesetId;
        }

        return $this->deleteRequest("zones/$zoneId/rulesets/$rulesetId/rules/$ruleId");
    }

    /**
     * Get the ruleset ID for cache rules
     *
     * @param string $zoneId Cloudflare Zone ID
     *
     * @return string|WP_Error Ruleset ID or WP_Error
     */
    private function getCacheRulesRulesetId($zoneId)
    {
        $response = $this->getRequest("zones/$zoneId/rulesets");

        if (is_wp_error($response)) {
            return $response;
        }

        // Find the http_request_cache_settings phase ruleset
        if (!empty($response['result'])) {
            foreach ($response['result'] as $ruleset) {
                if ($ruleset['phase'] === 'http_request_cache_settings') {
                    return $ruleset['id'];
                }
            }
        }

        return new WP_Error('no_ruleset', 'No cache rules ruleset found');
    }

    private function getStaticAssetsRule()
    {
        // Use respect_origin (this used to be override_origin / 2592000 = 30d). The pod is now a
        // well-behaved origin (1yr on finals, 60s on interims, no-store on cross-format), so
        // CF must honor its per-response TTL. The old 30d override pinned not-yet-landed
        // natural .avif interims (and wrong-MIME css/js) for 30 days on a vary-blind CF edge.
        return ['ref' => WPC_STATIC_RULE_REF, 'action' => 'set_cache_settings', 'description' => '[DO NOT EDIT] Static assets cache', 'enabled' => true, 'expression' => '(http.request.method in {"GET" "HEAD"}) and lower(http.request.uri.path.extension) in {"css" "js" "mjs" "json" "map" "jpg" "jpeg" "png" "gif" "webp" "avif" "svg" "ico" "ttf" "otf" "woff" "woff2" "eot" "mp4" "webm" "ogg"} and not starts_with(http.request.uri.path, "/cdn-cgi/")', 'action_parameters' => ['cache' => true, 'edge_ttl' => ['mode' => 'respect_origin'], 'browser_ttl' => ['mode' => 'respect_origin'], 'cache_key' => ['ignore_query_strings_order' => true]]];
    }

    /**
     * Re-assert respect-origin TTLs on an already-provisioned static-assets cache rule (it may
     * have been created with the old override_origin / 30d, which pins not-yet-landed natural
     * .avif interims and wrong-MIME css/js for 30 days on a vary-blind CF edge). PATCH-only:
     * flips the edge/browser TTL modes, preserves the expression and accumulated domains.
     * Creates the rule (respect_origin) if it doesn't exist yet. Called on
     * connect / refresh / settings-save / plugin-update so existing CF zones self-correct
     * without a manual re-edit.
     *
     * @param string $zoneId Cloudflare Zone ID
     * @return array|WP_Error API response or WP_Error
     */
    public function patchStaticAssetsRespectOrigin($zoneId)
    {
        if (empty($zoneId)) {
            return new WP_Error('no_zone', 'No zone id');
        }
        $rule = $this->findCacheRuleByRef($zoneId, WPC_STATIC_RULE_REF);
        if (!$rule) {
            // Not provisioned yet → create with the respect_origin definition.
            $created = $this->addCacheRule($zoneId, $this->getStaticAssetsRule());
            $this->logCacheRuleResult('create', $zoneId, $created);
            return $created;
        }
        if (!isset($rule['action_parameters']) || !is_array($rule['action_parameters'])) {
            $rule['action_parameters'] = [];
        }
        // Flip ONLY the TTL modes; leave cache/expression/accumulated-domains untouched.
        $rule['action_parameters']['cache']       = true;
        $rule['action_parameters']['edge_ttl']    = ['mode' => 'respect_origin'];
        $rule['action_parameters']['browser_ttl'] = ['mode' => 'respect_origin'];
        $rulesetId = $this->getCacheRulesRulesetId($zoneId);
        if (is_wp_error($rulesetId)) {
            return $rulesetId;
        }
        $resp = $this->patchRequest("zones/$zoneId/rulesets/$rulesetId/rules/{$rule['id']}", $rule);
        // Surface failures, for parity with configureCF's error reporting: a CF rejection here
        // (e.g. an unexpected respect_origin payload contract) must not be silent — it's how the
        // fix self-confirms on the first CF trigger. Fail-safe: a rejected PATCH leaves the
        // existing rule untouched (no breakage).
        $this->logCacheRuleResult('patch', $zoneId, $resp);
        return $resp;
    }

    /** Log a static-rule API result (WP_Error or CF non-success) for diagnosis. */
    private function logCacheRuleResult($op, $zoneId, $resp)
    {
        if (is_wp_error($resp)) {
            error_log('[WPC CF] static-rule ' . $op . ' failed (zone ' . $zoneId . '): ' . $resp->get_error_message());
        } elseif (is_array($resp) && array_key_exists('success', $resp) && !$resp['success']) {
            error_log('[WPC CF] static-rule ' . $op . ' CF non-success (zone ' . $zoneId . '): ' . wp_json_encode($resp['errors'] ?? $resp));
        }
    }

    private function getHomepageHTMLRule()
    {
        $domain = parse_url(get_site_url(), PHP_URL_HOST);

        // Handle both www and non-www versions
        if (strpos($domain, 'www.') === 0) {
            $base_domain = substr($domain, 4);
            $host_list = '"' . $domain . '" "' . $base_domain . '"';
        } else {
            $www_domain = 'www.' . $domain;
            $host_list = '"' . $domain . '" "' . $www_domain . '"';
        }

        $expression = '(http.host in {' . $host_list . '}) and (http.request.method in {"GET" "HEAD"}) and http.request.uri.path eq "/" and not starts_with(http.request.uri.path, "/cdn-cgi/") and not (http.cookie contains "wordpress_logged_in_" or http.cookie contains "wordpress_sec_" or http.cookie contains "wp-postpass_" or http.cookie contains "woocommerce_cart_hash" or http.cookie contains "woocommerce_items_in_cart" or http.cookie contains "wp_woocommerce_session_" or http.cookie contains "tk_ai" or http.cookie contains "edd_")';

        return ['ref' => WPC_HOMEPAGE_RULE_REF, 'action' => 'set_cache_settings', 'description' => '[DO NOT EDIT] Homepage HTML cache', 'enabled' => true, 'expression' => $expression, 'action_parameters' => ['cache' => true, 'edge_ttl' => ['mode' => 'override_origin', 'default' => 3600  // 60 min
        ], 'browser_ttl' => ['mode' => 'bypass_by_default'], 'serve_stale' => ['disable_stale_while_updating' => false], 'cache_key' => ['cache_by_device_type' => true, 'ignore_query_strings_order' => true]]];
    }

    private function getFullHTMLRule()
    {
        $domain = parse_url(get_site_url(), PHP_URL_HOST);

        // Handle both www and non-www versions
        if (strpos($domain, 'www.') === 0) {
            $base_domain = substr($domain, 4);
            $host_list = '"' . $domain . '" "' . $base_domain . '"';
        } else {
            $www_domain = 'www.' . $domain;
            $host_list = '"' . $domain . '" "' . $www_domain . '"';
        }

        $expression = '(http.host in {' . $host_list . '}) and (http.request.method in {"GET" "HEAD"}) and not starts_with(http.request.uri.path, "/cdn-cgi/") and not starts_with(http.request.uri.path, "/wp-admin") and http.request.uri.path ne "/wp-login.php" and not starts_with(http.request.uri.path, "/wp-json/") and (http.request.uri.path.extension eq "" or lower(http.request.uri.path.extension) in {"html" "htm" "xhtml"}) and not (http.cookie contains "wordpress_logged_in_" or http.cookie contains "wordpress_sec_" or http.cookie contains "wp-postpass_" or http.cookie contains "woocommerce_cart_hash" or http.cookie contains "woocommerce_items_in_cart" or http.cookie contains "wp_woocommerce_session_" or http.cookie contains "tk_ai" or http.cookie contains "edd_")';


        return ['ref' => WPC_FULLHTML_RULE_REF, 'action' => 'set_cache_settings', 'description' => '[DO NOT EDIT] Full HTML cache', 'enabled' => true, 'expression' => $expression, 'action_parameters' => ['cache' => true, 'edge_ttl' => ['mode' => 'override_origin', 'default' => 1800  // 30 min
        ], 'browser_ttl' => ['mode' => 'bypass_by_default'], 'cache_key' => ['cache_by_device_type' => true, 'ignore_query_strings_order' => true]]];
    }

    /**
     * Set Tiered Cache on or off
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param bool $enabled True to enable, false to disable
     *
     * @return array|WP_Error The API response or WP_Error
     */
    public function setTieredCache($zoneId, $enabled)
    {
        $value = $enabled ? 'on' : 'off';

        return $this->patchRequest("zones/$zoneId/argo/tiered_caching", ['value' => $value]);
    }

    /**
     * Remove all WP Compress cache rules from a zone
     *
     * @param string $zoneId Cloudflare Zone ID
     * @return array Results of the operation
     */
    public function removeCacheRules($zoneId)
    {
        $results = [];

        // Get current status of all rules
        $status = $this->checkWPCCacheRulesStatus($zoneId);

        if (is_wp_error($status)) {
            return $status;
        }

        // Remove bypass rule if it exists
        if ($status['bypass']) {
            $results['bypass'] = $this->deleteCacheRuleByRef($zoneId, WPC_BYPASS_RULE_REF);
        }

        // Remove static assets rule if it exists
        if ($status['static']) {
            $results['static'] = $this->deleteCacheRuleByRef($zoneId, WPC_STATIC_RULE_REF);
        }

        // Remove homepage HTML rule if it exists
        if ($status['homepage']) {
            $results['homepage'] = $this->deleteCacheRuleByRef($zoneId, WPC_HOMEPAGE_RULE_REF);
        }

        // Remove full HTML rule if it exists
        if ($status['fullhtml']) {
            $results['fullhtml'] = $this->deleteCacheRuleByRef($zoneId, WPC_FULLHTML_RULE_REF);
        }

        return $results;
    }

    /**
     * Check if WP Compress cache rules exist
     *
     * @param string $zoneId Cloudflare Zone ID
     *
     * @return array Status of all rules
     */
    public function checkWPCCacheRulesStatus($zoneId)
    {
        return ['bypass' => $this->findCacheRuleByRef($zoneId, WPC_BYPASS_RULE_REF) !== null, 'static' => $this->findCacheRuleByRef($zoneId, WPC_STATIC_RULE_REF) !== null, 'homepage' => $this->findCacheRuleByRef($zoneId, WPC_HOMEPAGE_RULE_REF) !== null, 'fullhtml' => $this->findCacheRuleByRef($zoneId, WPC_FULLHTML_RULE_REF) !== null,];
    }

    /**
     * Delete a DNS record
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string $recordId DNS record ID
     * @return array|WP_Error The API response or WP_Error
     */
    public function deleteDNSRecord($zoneId, $recordId)
    {
        return $this->deleteRequest("zones/$zoneId/dns_records/$recordId");
    }

    /**
     * Add CDN CNAME record (cdn.domain.com -> cdn-mc.zapwp.net)
     * Also verifies and sets SSL/TLS to Full if needed
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string|false $recordId Custom CNAME or false to use auto-generated
     * @return array|WP_Error The API response or WP_Error
     */
    public function addCfCname($zoneId, $recordId = false)
    {
        if ($recordId) {
            $cdn_subdomain = $recordId;
        } else {
            $cdn_subdomain = $this->getCfCname();
        }

        $target = 'cdn-mc.zapwp.net';

        // Check SSL/TLS setting first
        $sslCheck = $this->checkAndSetSSL($zoneId);
        if (is_wp_error($sslCheck)) {
            return $sslCheck;
        }

        // Check if record already exists in CF
        $existingRecord = $this->findDNSRecord($zoneId, $cdn_subdomain, 'CNAME');

        if ($existingRecord) {
            // Update existing record
            $result = $this->updateDNSRecord($zoneId, $existingRecord['id'], ['type' => 'CNAME', 'name' => $cdn_subdomain, 'content' => $target, 'ttl' => 1, // Automatic
                'proxied' => true]);
        } else {
            // Create new record
            $result = $this->addDNSRecord($zoneId, ['type' => 'CNAME', 'name' => $cdn_subdomain, 'content' => $target, 'ttl' => 1, // Automatic
                'proxied' => true]);
        }

        // If successful, save the CNAME to CF settings
        if (!is_wp_error($result) && !empty($result['success'])) {
			update_option(WPS_IC_CF_CNAME, $cdn_subdomain);
        }

        return $result;
    }

    /**
     * Get CDN CNAME for the current site
     *
     * Builds the CDN CNAME based on whether the site is on a subdomain or not.
     * Examples:
     * - example.com -> cdn.example.com
     * - blog.example.com -> cdn-blog.example.com
     *
     * @return string CDN CNAME
     */
    public function getCfCname()
    {
		$cfCname = get_option(WPS_IC_CF_CNAME);

        // Return custom CNAME if set
        if (!empty($cfCname)) {
            return $cfCname;
        }

        $current_host = $this->getDomain();
        $root_domain = $this->getRootDomain();

        // Check if current host is a subdomain of the root domain
        // e.g., staging.wpcompress.com is a subdomain of wpcompress.com
        if ($current_host !== $root_domain && strpos($current_host, '.' . $root_domain) !== false) {
            // Extract subdomain part (everything before .rootdomain)
            $subdomain = str_replace('.' . $root_domain, '', $current_host);
            $cdn_subdomain = 'cdn-' . $subdomain . '.' . $root_domain;
        } else {
            // No subdomain (or host equals root domain), use cdn.domain.tld
            $cdn_subdomain = 'cdn.' . $root_domain;
        }

        return $cdn_subdomain;
    }

    /**
     * Get the root domain from Cloudflare zone settings
     *
     * @return string Root domain from Cloudflare zone (e.g., 'example.com' or 'example.co.uk')
     */
    private function getRootDomain()
    {
        $cf = get_option(WPS_IC_CF);
        return $cf['zoneName']; // Always set, always accurate
    }

    /**
     * Check SSL/TLS mode and set to Full if needed
     *
     * @param string $zoneId Cloudflare Zone ID
     * @return true|WP_Error True if SSL is correct or was successfully set, WP_Error on failure
     */
    private function checkAndSetSSL($zoneId)
    {
        // Get current SSL/TLS setting
        $response = $this->getRequest("zones/$zoneId/settings/ssl");

        if (is_wp_error($response)) {
            return new WP_Error('cloudflare_ssl_check_error', 'Failed to check SSL/TLS setting: ' . $response->get_error_message());
        }

        // Check if we got a valid response
        if (empty($response['result']) || !isset($response['result']['value'])) {
            return new WP_Error('cloudflare_ssl_check_error', 'Unexpected response while checking SSL/TLS setting');
        }

        $currentSslMode = $response['result']['value'];

        // If already set to 'full' or 'strict', we're good
        if (in_array($currentSslMode, ['full', 'strict'])) {
            return true;
        }

        // Try to set to 'full'
        $setResponse = $this->patchRequest("zones/$zoneId/settings/ssl", ['value' => 'full']);

        if (is_wp_error($setResponse)) {
            return new WP_Error('cloudflare_ssl_set_error', 'Failed to set SSL/TLS to Full: ' . $setResponse->get_error_message());
        }

        // Verify it was set successfully
        if (empty($setResponse['success'])) {
            return new WP_Error('cloudflare_ssl_set_error', 'Failed to set SSL/TLS to Full. Please set SSL/TLS encryption mode to "Full" in your Cloudflare dashboard under SSL/TLS settings.');
        }

        return true;
    }

    /**
     * Find DNS record by name and type
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string $name Record name
     * @param string $type Record type (A, CNAME, etc.)
     * @return array|null DNS record or null if not found
     */
    public function findDNSRecord($zoneId, $name, $type)
    {
        $response = $this->listDNSRecords($zoneId, ['name' => $name, 'type' => $type]);

        if (is_wp_error($response)) {
            return null;
        }

        if (!empty($response['result']) && is_array($response['result'])) {
            return $response['result'][0];
        }

        return null;
    }

    /**
     * List DNS records for a zone
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param array $filters Optional filters (type, name, content, etc.)
     * @return array|WP_Error List of DNS records or WP_Error
     */
    public function listDNSRecords($zoneId, $filters = [])
    {
        return $this->getRequest("zones/$zoneId/dns_records", $filters);
    }

    /**
     * Update a DNS record
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string $recordId DNS record ID
     * @param array $record Updated DNS record configuration
     * @return array|WP_Error The API response or WP_Error
     */
    public function updateDNSRecord($zoneId, $recordId, $record)
    {
        return $this->putRequest("zones/$zoneId/dns_records/$recordId", $record);
    }

    /**
     * Send a PUT request to the Cloudflare API (if not already in your class)
     *
     * @param string $endpoint API endpoint
     * @param array $body Request body
     *
     * @return array|WP_Error The API response or WP_Error
     */
    private function putRequest($endpoint, $body = [])
    {
        $url = $this->apiBase . $endpoint;

        $response = wp_remote_request($url, ['method' => 'PUT', 'headers' => $this->getHeaders(), 'body' => json_encode($body),]);

        return $this->processResponse($response);
    }

    /**
     * Add a DNS record to a zone
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param array $record DNS record configuration
     * @return array|WP_Error The API response or WP_Error
     */
    public function addDNSRecord($zoneId, $record)
    {
        // Validate required fields
        $required = ['type', 'name', 'content'];
        foreach ($required as $field) {
            if (empty($record[$field])) {
                return new WP_Error('missing_field', "Required field '$field' is missing");
            }
        }

        // Valid DNS record types
        $validTypes = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR'];
        if (!in_array(strtoupper($record['type']), $validTypes)) {
            return new WP_Error('invalid_type', 'Invalid DNS record type');
        }

        // Set defaults
        $defaults = ['ttl' => 1, // 1 = automatic
            'proxied' => false];

        $record = array_merge($defaults, $record);

        return $this->postRequest("zones/$zoneId/dns_records", $record);
    }

    /**
     * Remove CDN CNAME record
     *
     * @param string $zoneId Cloudflare Zone ID
     * @return array|WP_Error|null The API response or WP_Error
     */
    public function removeCfCname($zoneId)
    {
        $cfCname = get_option(WPS_IC_CF_CNAME);
        if (!empty($cfCname)) {
            $cdn_subdomain = $cfCname;
            delete_option(WPS_IC_CF_CNAME);
        } else {
            return null;
        }

        /* Don't remove it, we don't know if another site is still using it!

        $existingRecord = $this->findDNSRecord($zoneId, $cdn_subdomain, 'CNAME');

        if ($existingRecord) {
            $result = $this->deleteDNSRecord($zoneId, $existingRecord['id']);
            return $result;
        }

        */
        return null; // Record doesn't exist, nothing to remove
    }

    /**
     * Get daily zone analytics/statistics for current site's hostname
     *
     * Retrieves daily analytics data filtered by the current site's domain/subdomain.
     * Automatically combines www, non-www, and CDN CNAME versions.
     * Zone ID is automatically read from Cloudflare settings.
     *
     * Note: Due to API limits, this makes one query per day per hostname (3 queries per day total).
     *
     * @param string $from Start date (YYYY-MM-DD format)
     * @param string $to End date (YYYY-MM-DD format)
     * @return array|WP_Error Analytics data in format: ['2025-10-01' => ['bytes' => X, 'requests' => Y], ...]
     *
     * @example
     * // Get stats for last 7 days
     * $to = date('Y-m-d');
     * $from = date('Y-m-d', strtotime('-7 days'));
     * $stats = $api->getZoneAnalytics($from, $to);
     * // Returns: ['2025-10-15' => ['bytes' => 50000000, 'requests' => 10000], ...]
     */
    public function getZoneAnalytics($from, $to)
    {
        // Get zone ID from settings
        $cf = get_option(WPS_IC_CF);
        if (!$cf || empty($cf['zone'])) {
            return new WP_Error('missing_zone', 'Cloudflare zone ID not found in settings');
        }
        $zoneId = $cf['zone'];

        // Get current hostname and CDN CNAME
        $hostname = $this->getDomain();
        $cdnCname = $this->getCfCname();

        // Generate array of dates to query
        $fromDate = new DateTime($from, new DateTimeZone('UTC'));
        $toDate = new DateTime($to, new DateTimeZone('UTC'));
        $toDate->setTime(23, 59, 59); // End of day

        $combined = [];

        // Query each day individually (API limit is 24 hours per query)
        $currentDate = clone $fromDate;
        while ($currentDate <= $toDate) {
            $dayStart = $currentDate->format('Y-m-d') . 'T00:00:00Z';
            $dayEnd = $currentDate->format('Y-m-d') . 'T23:59:59Z';

            // Fetch for non-www, www, and CDN CNAME
            $nonWwwStats = $this->fetchHostnameStatsForDay($zoneId, $dayStart, $dayEnd, $hostname);
            $wwwStats = $this->fetchHostnameStatsForDay($zoneId, $dayStart, $dayEnd, 'www.' . $hostname);
            $cdnStats = $this->fetchHostnameStatsForDay($zoneId, $dayStart, $dayEnd, $cdnCname);


            if (is_wp_error($nonWwwStats)) {
                return $nonWwwStats;
            }

            if (is_wp_error($wwwStats)) {
                return $wwwStats;
            }

            if (is_wp_error($cdnStats)) {
                return $cdnStats;
            }

            // Combine stats for this day
            $date = $currentDate->format('Y-m-d');
            $combined[$date] = ['bytes' => 0, 'requests' => 0];

            // Add non-www stats
            if (!empty($nonWwwStats)) {
                foreach ($nonWwwStats as $stat) {
                    $combined[$date]['bytes'] += $stat['sum']['edgeResponseBytes'] ?? 0;
                    $combined[$date]['requests'] += $stat['count'] ?? 0;
                }
            }

            // Add www stats
            if (!empty($wwwStats)) {
                foreach ($wwwStats as $stat) {
                    $combined[$date]['bytes'] += $stat['sum']['edgeResponseBytes'] ?? 0;
                    $combined[$date]['requests'] += $stat['count'] ?? 0;
                }
            }

            // Add CDN CNAME stats
            if (!empty($cdnStats)) {
                foreach ($cdnStats as $stat) {
                    $combined[$date]['bytes'] += $stat['sum']['edgeResponseBytes'] ?? 0;
                    $combined[$date]['requests'] += $stat['count'] ?? 0;
                }
            }

            // Move to next day
            $currentDate->modify('+1 day');
        }

        ksort($combined);
        return $combined;
    }

    /**
     * Fetch analytics stats for a specific hostname for a single day using httpRequestsAdaptiveGroups
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string $dayStart Start datetime (YYYY-MM-DDTHH:MM:SSZ)
     * @param string $dayEnd End datetime (YYYY-MM-DDTHH:MM:SSZ)
     * @param string $hostname Hostname to filter by
     * @return array|WP_Error Analytics data or WP_Error
     */
    private function fetchHostnameStatsForDay($zoneId, $dayStart, $dayEnd, $hostname)
    {
        $query = <<<'GQL'
query(
  $zoneTag: String!,
  $datetimeStart: Time!,
  $datetimeEnd: Time!,
  $hostname: String!
) {
  viewer {
    zones(filter: { zoneTag: $zoneTag }) {
      httpRequestsAdaptiveGroups(
        limit: 1,
        filter: { 
          datetime_geq: $datetimeStart,
          datetime_leq: $datetimeEnd,
          clientRequestHTTPHost: $hostname
        }
      ) {
        count
        sum { 
          edgeResponseBytes
        }
      }
    }
  }
}
GQL;

        $variables = ['zoneTag' => $zoneId, 'datetimeStart' => $dayStart, 'datetimeEnd' => $dayEnd, 'hostname' => $hostname,];

        $response = $this->graphqlRequest($query, $variables);

        if (is_wp_error($response)) {
            return $response;
        }

        // Check for GraphQL errors
        if (isset($response['errors']) && !empty($response['errors'])) {
            $errorMessages = array_map(function ($error) {
                return $error['message'] ?? 'Unknown GraphQL error';
            }, $response['errors']);

            return new WP_Error('cloudflare_graphql_error', implode(', ', $errorMessages), $response['errors']);
        }

        // Extract the data
        $series = $response['data']['viewer']['zones'][0]['httpRequestsAdaptiveGroups'] ?? [];

        return $series;
    }

    /**
     * Send a GraphQL request to the Cloudflare API
     *
     * @param string $query GraphQL query string
     * @param array $variables Query variables
     * @return array|WP_Error The API response or WP_Error
     */
    private function graphqlRequest($query, $variables = [])
    {
        $url = 'https://api.cloudflare.com/client/v4/graphql';

        $response = wp_remote_post($url, ['headers' => $this->getHeaders(), 'body' => json_encode(['query' => $query, 'variables' => $variables]), 'timeout' => 30,]);

        return $this->processResponse($response);
    }

    /**
     * Check if API token has required privileges by testing actual API calls
     *
     * @param string $zoneId Cloudflare Zone ID to test permissions against
     * @return true|WP_Error True if all privileges work, WP_Error with missing privileges if not
     */
    public function checkPrivileges($zoneId = null)
    {
        // If no zone ID provided, try to get from settings
        if (!$zoneId) {
            $cf = get_option(WPS_IC_CF);
            $zoneId = $cf['zone'] ?? null;
        }

        if (!$zoneId) {
            return new WP_Error('cloudflare_missing_zone', 'Zone ID is required to check permissions');
        }

        $missingPermissions = [];
        $permissionTests = [];

        // (v7.10.04.9) Return TRUE only for a GENUINE Cloudflare permission/auth code. A transport
        // WP_Error (timeout / DNS / connection refused) or any NON-permission CF error is INCONCLUSIVE
        // and must NOT count as a missing scope — the old blanket `is_wp_error() => true` rejected
        // otherwise-valid tokens whenever a single probe hiccuped. processResponse() returns CF errors
        // as a WP_Error whose DATA is the CF errors array ([['code'=>9109,...]]); a decoded array
        // return means the call SUCCEEDED. (The old success-false-array branch was dead — processResponse
        // never returns that shape for an error.)
        $isPermissionError = function ($response) {
            if (!is_wp_error($response)) {
                return false; // decoded array => the request succeeded (had the permission)
            }
            $data = $response->get_error_data();
            if (is_array($data)) {
                foreach ($data as $error) {
                    if (is_array($error) && in_array((int) ($error['code'] ?? 0), [9109, 10000, 1095], true)) {
                        return true; // real CF auth/permission code
                    }
                }
            }
            return false; // transport / non-permission error => inconclusive, NOT "missing permission"
        };

        // Test 1: Zone Read
        $zonesResponse = $this->getRequest('zones', ['per_page' => 1]);
        if ($isPermissionError($zonesResponse)) {
            $missingPermissions[] = 'Zone - Zone - Read';
            $permissionTests['Zone Read'] = 'Failed';
        } else {
            $permissionTests['Zone Read'] = 'OK';
        }

        // Test 2: Zone Settings Edit
        $settingsResponse = $this->getRequest("zones/{$zoneId}/settings/rocket_loader");
        if ($isPermissionError($settingsResponse)) {
            $missingPermissions[] = 'Zone - Zone Settings - Edit';
            $permissionTests['Zone Settings Edit'] = 'Failed';
        } else {
            $permissionTests['Zone Settings Edit'] = 'OK';
        }

        // Test 3: Cache Purge
        // Use POST with minimal valid data to test permission without actually purging
        $cacheResponse = $this->postRequest("zones/{$zoneId}/purge_cache", ['files' => []]);

        // Cache Purge: an empty `files` payload triggers a VALIDATION error (e.g. code 1012) which
        // actually PROVES the token has purge scope (we got past auth to validation). Reuse the fixed
        // classifier — only a genuine permission code counts as "no purge"; a validation or transport
        // error is NOT a missing scope (same false-positive fix as above).
        $hasCachePurgePermission = !$isPermissionError($cacheResponse);

        if (!$hasCachePurgePermission) {
            $missingPermissions[] = 'Zone - Cache Purge - Purge';
            $permissionTests['Cache Purge'] = 'Failed';
        } else {
            $permissionTests['Cache Purge'] = 'OK';
        }

        // Test 4: Firewall Services Edit
        $firewallResponse = $this->getRequest("zones/{$zoneId}/firewall/access_rules/rules", ['per_page' => 1]);
        if ($isPermissionError($firewallResponse)) {
            $missingPermissions[] = 'Zone - Firewall Services - Edit';
            $permissionTests['Firewall Services Edit'] = 'Failed';
        } else {
            $permissionTests['Firewall Services Edit'] = 'OK';
        }

        // Test 5: DNS Edit
        $dnsResponse = $this->getRequest("zones/{$zoneId}/dns_records", ['per_page' => 1]);
        if ($isPermissionError($dnsResponse)) {
            $missingPermissions[] = 'Zone - DNS - Edit';
            $permissionTests['DNS Edit'] = 'Failed';
        } else {
            $permissionTests['DNS Edit'] = 'OK';
        }

        // Test 6: Analytics Read
        // Use zone details endpoint which requires Analytics Read to see analytics data
        // The /zones/{id} endpoint is simpler and still requires analytics permission
        $zoneDetailsResponse = $this->getRequest("zones/{$zoneId}");
        if ($isPermissionError($zoneDetailsResponse)) {
            $missingPermissions[] = 'Zone - Analytics - Read';
            $permissionTests['Analytics Read'] = 'Failed';
        } else {
            // Zone endpoint returns basic info even without analytics permission
            // So we'll just mark this as OK if we can access the zone
            // Real analytics testing would require GraphQL which is complex
            $permissionTests['Analytics Read'] = 'OK (basic check)';
        }

        // Test 7: Cache Rules (Rulesets)
        $rulesetsResponse = $this->getRequest("zones/{$zoneId}/rulesets");
        if ($isPermissionError($rulesetsResponse)) {
            $missingPermissions[] = 'Zone - Cache Rules - Edit';
            $permissionTests['Cache Rules Edit'] = 'Failed';
        } else {
            $permissionTests['Cache Rules Edit'] = 'OK';
        }

        // (v7.10.04.9) CRITICAL vs OPTIONAL split. Only Zone Read (to resolve/verify the zone) and
        // Cache Purge (the integration's core job) are REQUIRED to connect. The other five gate
        // optional features and must NOT block — they come back as warnings so the operator can add
        // them in Cloudflare and reconnect without being locked out. Returns a STRUCTURED ARRAY
        // (was WP_Error|true) — the sole caller (ajax.class.php) is updated to match.
        $criticalRefs = ['Zone - Zone - Read', 'Zone - Cache Purge - Purge'];
        // Render each internal probe as the EXACT Cloudflare token-editor wording ("Group → Access")
        // so the operator can find and add it verbatim in the CF dashboard (Create/Edit Token →
        // Permissions row = [Zone] [Group] [Access]).
        $cfLabel = [
            'Zone - Zone - Read'              => 'Zone → Read',
            'Zone - Cache Purge - Purge'      => 'Cache Purge → Purge',
            'Zone - Zone Settings - Edit'     => 'Zone Settings → Edit',
            'Zone - Firewall Services - Edit' => 'Firewall Services → Edit',
            'Zone - DNS - Edit'               => 'DNS → Edit',
            'Zone - Analytics - Read'         => 'Analytics → Read',
            'Zone - Cache Rules - Edit'       => 'Cache Rules → Edit',
        ];
        $featureFor = [
            'Zone - Zone Settings - Edit'     => 'auto Rocket-Loader conflict handling',
            'Zone - Firewall Services - Edit' => 'firewall / access rules',
            'Zone - DNS - Edit'               => 'automatic CNAME setup',
            'Zone - Analytics - Read'         => 'the Cloudflare analytics panel',
            'Zone - Cache Rules - Edit'       => 'edge-cache optimization rules',
        ];
        $critical_missing = [];
        $optional_missing = [];
        foreach ($missingPermissions as $perm) {
            $label = $cfLabel[$perm] ?? $perm;
            if (in_array($perm, $criticalRefs, true)) {
                $critical_missing[] = $label;
            } else {
                $optional_missing[] = $label . (isset($featureFor[$perm]) ? ' (enables ' . $featureFor[$perm] . ')' : '');
            }
        }

        return [
            'ok'               => empty($critical_missing), // can CONNECT when no critical scope is missing
            'critical_missing' => $critical_missing,
            'optional_missing' => $optional_missing,
            'tests'            => $permissionTests,
        ];
    }

    /**
     * Get daily zone analytics for entire zone (unfiltered)
     *
     * Retrieves daily analytics data for the entire zone without hostname filtering.
     * Uses httpRequests1dGroups which provides daily aggregated data.
     * Zone ID is automatically read from Cloudflare settings.
     *
     * @param string $from Start date (YYYY-MM-DD format)
     * @param string $to End date (YYYY-MM-DD format)
     * @return array|WP_Error Analytics data in format: ['2025-10-01' => ['bytes' => X, 'requests' => Y, 'cached_bytes' => Z, 'cached_requests' => W], ...]
     */
    public function getZoneAnalyticsUnfiltered($from, $to)
    {
        // Get zone ID from settings
        $cf = get_option(WPS_IC_CF);
        if (!$cf || empty($cf['zone'])) {
            return new WP_Error('missing_zone', 'Cloudflare zone ID not found in settings');
        }
        $zoneId = $cf['zone'];

        // Format dates for GraphQL
        $fromDate = new DateTime($from, new DateTimeZone('UTC'));
        $toDate = new DateTime($to, new DateTimeZone('UTC'));

        $dateStart = $fromDate->format('Y-m-d');
        $dateEnd = $toDate->format('Y-m-d');

        $query = <<<'GQL'
query(
  $zoneTag: String!,
  $dateStart: Date!,
  $dateEnd: Date!
) {
  viewer {
    zones(filter: { zoneTag: $zoneTag }) {
      httpRequests1dGroups(
        limit: 1000,
        filter: { 
          date_geq: $dateStart,
          date_leq: $dateEnd
        }
      ) {
        dimensions {
          date
        }
        sum {
          requests
          bytes
          cachedBytes
          cachedRequests
        }
      }
    }
  }
}
GQL;

        $variables = ['zoneTag' => $zoneId, 'dateStart' => $dateStart, 'dateEnd' => $dateEnd,];

        $response = $this->graphqlRequest($query, $variables);

        if (is_wp_error($response)) {
            return $response;
        }

        // Check for GraphQL errors
        if (isset($response['errors']) && !empty($response['errors'])) {
            $errorMessages = array_map(function ($error) {
                return $error['message'] ?? 'Unknown GraphQL error';
            }, $response['errors']);

            return new WP_Error('cloudflare_graphql_error', implode(', ', $errorMessages), $response['errors']);
        }

        // Extract and format the data
        $series = $response['data']['viewer']['zones'][0]['httpRequests1dGroups'] ?? [];

        $formatted = [];
        foreach ($series as $dataPoint) {
            $date = $dataPoint['dimensions']['date'] ?? null;
            if ($date) {
                // Extract INTEGER values directly, not arrays
                $formatted[$date] = ['bytes' => (int)($dataPoint['sum']['bytes'] ?? 0), 'requests' => (int)($dataPoint['sum']['requests'] ?? 0), 'cached_bytes' => (int)($dataPoint['sum']['cachedBytes'] ?? 0), 'cached_requests' => (int)($dataPoint['sum']['cachedRequests'] ?? 0),];
            }
        }

        ksort($formatted);
        return $formatted;
    }

    /**
     * Get list of all domains currently in a specific cache rule
     *
     * @param string $zoneId Cloudflare Zone ID
     * @param string $ruleRef Rule reference identifier
     * @return array|WP_Error List of domains or WP_Error
     */
    public function getDomainsInRule($zoneId, $ruleRef)
    {
        $rule = $this->findCacheRuleByRef($zoneId, $ruleRef);

        if (!$rule) {
            return new WP_Error('rule_not_found', "Rule with ref '$ruleRef' not found");
        }

        return $this->extractDomainsFromExpression($rule['expression']);
    }

    /**
     * Format Cloudflare API errors for display
     *
     * @param WP_Error $wp_error The WordPress error object
     * @param string $context Optional context for the error (e.g., 'CDN DNS')
     * @param string $required_permission Optional permission needed (e.g., 'Zone - DNS - Edit')
     * @return string|null Formatted error message or null if not an error
     */
    public function formatError($wp_error, $context = '', $required_permission = '')
    {
        if (!is_wp_error($wp_error)) {
            return null;
        }

        $error_data = $wp_error->get_error_data();
        $error_code = null;
        $error_message = '';

        // Extract error code and message
        if (!empty($error_data[0]['code'])) {
            $error_code = $error_data[0]['code'];
        }
        if (!empty($error_data[0]['message'])) {
            $error_message = $error_data[0]['message'];
        }

        // Check if it's a permission/authentication error
        $permission_codes = [9109, 10000, 1095, 9103];
        if (in_array($error_code, $permission_codes)) {
            $msg = $context ? "{$context}: API token is missing required permissions" : "API token is missing required permissions";
            if ($required_permission) {
                $msg .= " ({$required_permission})";
            }
            return $msg;
        }

        // For other errors, return the original message or a fallback
        if (empty($error_message)) {
            $error_message = $wp_error->get_error_message();
        }

        return $context ? "{$context}: {$error_message}" : $error_message;
    }

    /**
     * CF Piece 2 (a SCAFFOLD, inert until orch ships POST /v2/signed-header and the
     * `wpc_v2_cf_header_injection` flag is on; see addons/v2/v2-signed-header.php).
     *
     * Ensure the signed x-wpc-config injection Transform Rule exists. Writes ONE `rewrite` rule in the
     * http_request_late_transform phase, scoped to CDN-bound requests (the cdn.<domain> CNAME that
     * fronts cdn-mc.zapwp.net), that:
     *   - strips any inbound `apikey` header (a visitor must never be able to supply one), and
     *   - SETS `x-wpc-config` to the orch-signed $signedValue. CF's `set` operation REPLACES any
     *     inbound value, so it also strips a spoofed x-wpc-config — the brief's "strip + add" collapses
     *     to one atomic header op (no race between a separate strip rule and an add rule).
     * The cdn-mc container then reads Accept + the trusted x-wpc-config with ZERO database lookups.
     *
     * Idempotent: updates the existing rule (matched by ref) in place rather than appending duplicates.
     * Find-or-create the ruleset exactly like the cache-rule helpers (getCacheRulesRulesetId/addCacheRule).
     *
     * @param string $zoneId
     * @param string $signedValue Opaque orch-signed header value (do NOT construct plugin-side)
     * @return array|WP_Error
     */
    public function ensureWpcConfigInjection($zoneId, $signedValue)
    {
        if (empty($signedValue) || !is_string($signedValue)) {
            return new WP_Error('wpc_no_signed_value', 'Refusing to write an empty x-wpc-config injection rule');
        }

        $cdnHost = $this->getCfCname(); // the host CDN assets are served from (cdn.<domain>)
        if (empty($cdnHost)) {
            return new WP_Error('wpc_no_cdn_host', 'No CDN CNAME resolved for this zone');
        }

        $rule = [
            'ref'         => WPC_CONFIG_INJECT_RULE_REF,
            'description' => '[DO NOT EDIT] WP Compress signed config injection',
            'expression'  => '(http.host eq "' . $cdnHost . '")',
            'action'      => 'rewrite',
            'enabled'     => true,
            'action_parameters' => [
                'headers' => [
                    'apikey'       => ['operation' => 'remove'],
                    'x-wpc-config' => ['operation' => 'set', 'value' => $signedValue],
                ],
            ],
        ];

        // Find-or-create the late_transform entrypoint ruleset, then update-or-add the rule by ref.
        $rulesetId = $this->getTransformRulesRulesetId($zoneId);
        if (is_wp_error($rulesetId)) {
            // No transform ruleset yet → create one carrying this single rule (SAFE — new ruleset).
            return $this->postRequest("zones/$zoneId/rulesets", [
                'name'  => 'WP Compress Transform Rules',
                'kind'  => 'zone',
                'phase' => 'http_request_late_transform',
                'rules' => [$rule],
            ]);
        }

        // Ruleset exists — update our rule in place if present (no duplicates), else append it.
        $existing = $this->findTransformRuleByRef($zoneId, $rulesetId, WPC_CONFIG_INJECT_RULE_REF);
        if ($existing && !empty($existing['id'])) {
            return $this->patchRequest("zones/$zoneId/rulesets/$rulesetId/rules/{$existing['id']}", $rule);
        }
        return $this->postRequest("zones/$zoneId/rulesets/$rulesetId/rules", $rule);
    }

    /**
     * Remove the signed-config injection rule (on CF disconnect or when the flag is turned off).
     * Leaves all other Transform Rules untouched. Returns the delete response or false if absent.
     *
     * @param string $zoneId
     * @return array|WP_Error|false
     */
    public function removeWpcConfigInjection($zoneId)
    {
        $rulesetId = $this->getTransformRulesRulesetId($zoneId);
        if (is_wp_error($rulesetId)) {
            return false;
        }

        $existing = $this->findTransformRuleByRef($zoneId, $rulesetId, WPC_CONFIG_INJECT_RULE_REF);
        if ($existing && !empty($existing['id'])) {
            return $this->deleteRequest("zones/$zoneId/rulesets/$rulesetId/rules/{$existing['id']}");
        }
        return false;
    }

    /**
     * Get the http_request_late_transform entrypoint ruleset id, or a WP_Error('no_ruleset') if none
     * exists yet. Mirrors getCacheRulesRulesetId() for the transform phase.
     *
     * @param string $zoneId
     * @return string|WP_Error
     */
    private function getTransformRulesRulesetId($zoneId)
    {
        $response = $this->getRequest("zones/$zoneId/rulesets");
        if (is_wp_error($response)) {
            return $response;
        }

        if (!empty($response['result'])) {
            foreach ($response['result'] as $ruleset) {
                if (isset($ruleset['phase']) && $ruleset['phase'] === 'http_request_late_transform') {
                    return $ruleset['id'];
                }
            }
        }

        return new WP_Error('no_ruleset', 'No http_request_late_transform ruleset found');
    }

    /**
     * Find a Transform Rule by its ref inside a known ruleset. Returns the rule array (incl. its id)
     * or null. Mirrors findCacheRuleByRef() but reads the specific ruleset directly.
     *
     * @param string $zoneId
     * @param string $rulesetId
     * @param string $ref
     * @return array|null
     */
    private function findTransformRuleByRef($zoneId, $rulesetId, $ref)
    {
        $response = $this->getRequest("zones/$zoneId/rulesets/$rulesetId");
        if (is_wp_error($response)) {
            return null;
        }

        $rules = $response['result']['rules'] ?? [];
        foreach ($rules as $rule) {
            if (isset($rule['ref']) && $rule['ref'] === $ref) {
                return $rule;
            }
        }

        return null;
    }

}