<?php
/**
 * Tam Rank License Manager
 *
 * SaaS Architecture - Single API key authentication
 *
 * IMPORTANT: This class communicates ONLY with api.tamrank.com
 * NO WordPress REST endpoints on tamrank.com should be used.
 *
 * Stored options after activation:
 * - tam_rank_api_key: Bearer token for all API calls
 * - tam_rank_plan: 'pro' or 'free'
 * - tam_rank_expires_at: Expiration date ISO 8601
 *
 * @package TamRank
 * @since 8.2.0
 */

namespace TamRank;

if (!defined('ABSPATH')) {
    exit;
}

class LicenseManager
{

    /**
     * SaaS API base URL
     *
     * Protected (not private) so the PRO subclass Pro_License can reach it via
     * self::API_BASE_URL — activate_license() lives in PRO since the cluster-15
     * strip, and a private constant is not visible to subclasses (fatal:
     * "Undefined constant Pro_License::API_BASE_URL").
     * @var string
     */
    protected const API_BASE_URL = 'https://api.tamrank.com';

    /**
     * Plugin identifier
     * @var string
     */
    private const PLUGIN_SLUG = 'tam-rank';

    /**
     * @deprecated Niet meer gebruiken. Dit was een weergave-fallback van 50 die
     * vuurde zodra de API geen 'total' meestuurde — en dat doet /v1/sites/status
     * nooit, dus vuurde hij op ELK plan. Daardoor toonde de licentiekaart altijd
     * "50" ongeacht het werkelijke saldo. Een onbekend budget is nu null, zodat
     * de UI "onbekend" kan tonen in plaats van een verzonnen getal.
     *
     * Blijft alleen staan zodat een reeds uitgeleverde add-on die er nog naar
     * verwijst geen fatal geeft. Nul verwijzingen in deze plugin.
     * @var int
     */
    public const DEFAULT_CREDITS_TOTAL = 50;

    /**
     * Ownership-proof handshake storage (see Plugin/API/Ownership-Proof-Plugin-Build.md).
     *
     * OPTION_VERIFY_CHALLENGE holds the single authoritative pending challenge as
     * ['nonce', 'expires_at', 'site_url'] (autoload=no). Its name MUST match the
     * literal in helpers.php::tamrank_get_pending_verify_nonce(), which is what the
     * public expose surfaces read.
     */
    private const OPTION_VERIFY_CHALLENGE   = 'tam_rank_verify_challenge';
    private const OPTION_VERIFY_LOCK        = 'tam_rank_verify_lock';
    private const TRANSIENT_REGISTER_FAILURE = 'tam_rank_register_failure';
    private const TRANSIENT_VERIFY_BACKOFF   = 'tam_rank_verify_backoff';
    private const CHALLENGE_MAX_TTL = 600; // cap the VPS-issued TTL at 10 min
    private const LOCK_TTL          = 30;  // stale single-flight lock reclaim window (s)
    private const HANDSHAKE_TIMEOUT = 8;   // per-call HTTP timeout; two calls stack + a nested VPS fetch

    /**
     * Constructor - minimal, no cron scheduling
     */
    public function __construct()
    {
        // Transport/read-only in FREE. The license write side (activate /
        // deactivate / admin notices) lives in TamRank\Pro_License (cluster-15
        // strip), which extends this class and registers the admin_notices hook.
    }



    /**
     * Get the current plugin version
     *
     * @return string
     */
    private function get_plugin_version(): string
    {
        if (defined('TAM_RANK_VERSION')) {
            return TAM_RANK_VERSION;
        }
        return '8.2.0';
    }

    /**
     * Get domain without protocol for API calls
     *
     * @return string
     */
    private function get_domain(): string
    {
        $site_url = home_url();
        $parsed = wp_parse_url($site_url);
        return $parsed['host'] ?? $site_url;
    }

    /**
     * Register site with SaaS API (gets free API key)
     *
     * Endpoint: POST https://api.tamrank.com/v1/sites/register
     *
     * @return string|WP_Error API key or error
     */
    protected function register_site()
    {
        // Ownership-proof handshake (replaces the old single unauthenticated POST
        // that returned an api_key to anyone who knew the public site_url). Flow:
        //   1. ask the VPS for a one-time nonce,
        //   2. expose it on this site (/.well-known + REST), and
        //   3. let the VPS fetch + match it before issuing/re-issuing the key.
        // The api_key never appears on a public surface — only the worthless nonce.
        // See Plugin/API/Ownership-Proof-Plugin-Build.md.

        // Single-flight lock: concurrent calls would overwrite each other's nonce
        // (the store is one slot). add_option() is atomic — it fails if the lock
        // already exists. The value carries a unique token + start timestamp so a
        // crashed handshake's lock can be reclaimed after LOCK_TTL, while a holder
        // whose lock got reclaimed never frees the new holder's lock (see finally).
        $lock_token = uniqid('tr', true);
        $lock_value = time() . ':' . $lock_token;

        if (!add_option(self::OPTION_VERIFY_LOCK, $lock_value, '', 'no')) {
            $held         = (string) get_option(self::OPTION_VERIFY_LOCK, '');
            $held_parts   = explode(':', $held, 2);
            $held_started = (int) $held_parts[0];
            if ($held_started > 0 && (time() - $held_started) < self::LOCK_TTL) {
                // A fresh handshake is already running — don't interfere.
                return new \WP_Error('register_in_progress', 'register_in_progress');
            }
            // Stale (or unparseable) lock — reclaim atomically; bail if another
            // caller wins the reclaim race so single-flight still holds.
            delete_option(self::OPTION_VERIFY_LOCK);
            if (!add_option(self::OPTION_VERIFY_LOCK, $lock_value, '', 'no')) {
                return new \WP_Error('register_in_progress', 'register_in_progress');
            }
        }

        try {
            // STEP 1 — request a one-time challenge nonce (no key minted yet).
            $challenge = $this->request_challenge();
            if (is_wp_error($challenge)) {
                // Capability-degradation: a 404 means the VPS handshake endpoints
                // are not deployed yet. The launch build is frozen for the wp.org
                // review (cannot be patched), so it falls back to the legacy
                // single-call register and keeps minting FREE keys until the atomic
                // VPS deploy (challenge + verify + legacy-cutover) lands. After that
                // cutover this never fires (challenge returns 200). See
                // Ownership-Proof-Plugin-Build.md §7 (launch-koppeling).
                if ($challenge->get_error_code() === 'challenge_unsupported') {
                    return $this->legacy_register();
                }
                return $challenge;
            }

            // STEP 2 — expose the nonce on this site for the duration of the handshake.
            $this->store_challenge($challenge['nonce'], $challenge['ttl']);

            // STEP 3 — ask the VPS to fetch + verify it, and store the key on success.
            return $this->confirm_verification();
        } finally {
            // Release ONLY if we still own the lock. If a slow handshake's lock was
            // reclaimed mid-flight, this must not wipe the new holder's lock or the
            // live challenge it just wrote. Whoever owns the lock clears the nonce
            // here, so it never lingers as a reusable public artifact.
            if ((string) get_option(self::OPTION_VERIFY_LOCK, '') === $lock_value) {
                $this->clear_challenge();
                delete_option(self::OPTION_VERIFY_LOCK);
            }
        }
    }

    /**
     * Common identity-free registration body (no secret — by design).
     *
     * @return array
     */
    protected function register_payload()
    {
        return array(
            'domain'         => $this->get_domain(),
            'site_url'       => home_url(),
            'plugin'         => self::PLUGIN_SLUG,
            'plugin_version' => $this->get_plugin_version(),
        );
    }

    /**
     * STEP 1 — request a one-time nonce from the VPS.
     *
     * @return array|\WP_Error ['nonce' => string, 'ttl' => int] or error.
     */
    protected function request_challenge()
    {
        $response = wp_remote_post(self::API_BASE_URL . '/v1/sites/register-challenge', array(
            'timeout' => self::HANDSHAKE_TIMEOUT,
            'headers' => array(
                'Content-Type' => 'application/json',
                'Accept'       => 'application/json',
            ),
            'body' => wp_json_encode($this->register_payload()),
        ));

        if (is_wp_error($response)) {
            return new \WP_Error('connection_failed', 'connection_failed');
        }

        $status_code = wp_remote_retrieve_response_code($response);
        $body = json_decode(wp_remote_retrieve_body($response), true);

        // A 404 means this VPS does not know the handshake yet (VPS half not
        // deployed). Signal register_site() to fall back to the legacy register
        // path (capability-degradation, see §7). Distinct from a generic
        // server_error so only an absent endpoint triggers the fallback.
        if ($status_code === 404) {
            return new \WP_Error('challenge_unsupported', 'challenge_unsupported');
        }

        if ($status_code !== 200 || empty($body['nonce'])) {
            return new \WP_Error('server_error', 'server_error');
        }

        $ttl = isset($body['verify_ttl']) ? (int) $body['verify_ttl'] : 300;

        return array(
            'nonce' => sanitize_text_field((string) $body['nonce']),
            'ttl'   => $ttl,
        );
    }

    /**
     * Capability-degradation fallback — the pre-handshake single-call register.
     *
     * Used ONLY when /v1/sites/register-challenge returns 404, i.e. the VPS half
     * of the ownership-proof handshake is not deployed yet. The launch build is
     * frozen for the wp.org review (6 jul-22 sep) and cannot be patched, so it
     * must keep minting FREE keys via the legacy endpoint until the atomic VPS
     * deploy (challenge + verify + legacy-cutover) goes live.
     *
     * After that cutover the legacy endpoint returns 409 handshake_required (no
     * key); this method then fails closed without a long back-off, so the next
     * attempt simply uses the now-live handshake instead. No public surface is
     * touched on this path (no nonce is exposed).
     *
     * @return string|\WP_Error api_key on success, error otherwise.
     */
    protected function legacy_register()
    {
        $response = wp_remote_post(self::API_BASE_URL . '/v1/sites/register', array(
            'timeout' => self::HANDSHAKE_TIMEOUT,
            'headers' => array(
                'Content-Type' => 'application/json',
                'Accept'       => 'application/json',
            ),
            'body' => wp_json_encode($this->register_payload()),
        ));

        if (is_wp_error($response)) {
            return new \WP_Error('connection_failed', 'connection_failed');
        }

        $status_code = wp_remote_retrieve_response_code($response);
        $body = json_decode(wp_remote_retrieve_body($response), true);

        if (($status_code === 200 || $status_code === 201) && !empty($body['api_key'])) {
            $this->store_api_key($body['api_key']);
            return $body['api_key'];
        }

        // Legacy mint already cut over (409 handshake_required) or unexpected
        // shape: fail without the 5-min back-off so the next load can pick up
        // the now-live handshake.
        return new \WP_Error('server_error', 'server_error');
    }

    /**
     * STEP 2 — store the nonce in the single authoritative store (autoload=no),
     * with its own embedded expiry so a stale/object-cached row can never serve
     * a token past its window. Read back by tamrank_get_pending_verify_nonce().
     *
     * @param string $nonce
     * @param int    $ttl
     * @return void
     */
    protected function store_challenge($nonce, $ttl)
    {
        $ttl = max(60, min((int) $ttl, self::CHALLENGE_MAX_TTL));
        $payload = array(
            'nonce'      => $nonce,
            'expires_at' => time() + $ttl,
            'site_url'   => home_url(),
        );
        delete_option(self::OPTION_VERIFY_CHALLENGE);
        add_option(self::OPTION_VERIFY_CHALLENGE, $payload, '', 'no');
    }

    /**
     * STEP 3 — tell the VPS the nonce is live; it fetches + matches, then returns
     * the api_key (201 new site, 200 same key on recovery) only on success.
     *
     * @return string|\WP_Error api_key on success, error otherwise.
     */
    protected function confirm_verification()
    {
        $response = wp_remote_post(self::API_BASE_URL . '/v1/sites/register-verify', array(
            'timeout' => self::HANDSHAKE_TIMEOUT,
            'headers' => array(
                'Content-Type' => 'application/json',
                'Accept'       => 'application/json',
            ),
            'body' => wp_json_encode($this->register_payload()),
        ));

        if (is_wp_error($response)) {
            return new \WP_Error('connection_failed', 'connection_failed');
        }

        $status_code = wp_remote_retrieve_response_code($response);
        $body = json_decode(wp_remote_retrieve_body($response), true);

        if ($status_code === 200 || $status_code === 201) {
            if (empty($body['api_key'])) {
                return new \WP_Error('server_error', 'server_error');
            }
            $this->store_api_key($body['api_key']);
            return $body['api_key'];
        }

        // VPS reached this site but could not confirm domain control. Carry the
        // detail so the UI can show a calm, actionable notice (not a dead button).
        $reason = (is_array($body) && !empty($body['reason'])) ? (string) $body['reason'] : 'unreachable';
        // Prefer the URL the VPS actually fetched (it may have used the REST
        // fallback rather than .well-known); fall back to the primary path.
        $checked_url = (is_array($body) && !empty($body['checked_url']))
            ? esc_url_raw((string) $body['checked_url'])
            : home_url('/.well-known/tamrank-challenge.txt');
        return new \WP_Error('verify_failed', tamrank__('gsc_verify_failed', "We couldn't verify your site."), array(
            'checked_url' => $checked_url,
            'reason'      => $reason,
        ));
    }

    /**
     * Persist the FREE api_key (autoload=no so it stays out of alloptions exports).
     *
     * @param string $key
     * @return void
     */
    protected function store_api_key($key)
    {
        $sanitized_key = sanitize_text_field($key);
        delete_option('tam_rank_api_key');
        add_option('tam_rank_api_key', $sanitized_key, '', 'no');
        update_option('tam_rank_plan', 'free');
    }

    /**
     * Remove the pending challenge so the nonce stops being publicly readable.
     *
     * @return void
     */
    protected function clear_challenge()
    {
        delete_option(self::OPTION_VERIFY_CHALLENGE);
    }

    /**
     * Clear both registration back-offs. Called on an explicit user retry
     * (clicking "Connect with Google") so the handshake re-attempts immediately
     * instead of being suppressed by a recent failure.
     *
     * @return void
     */
    public function clear_register_backoff()
    {
        delete_transient(self::TRANSIENT_REGISTER_FAILURE);
        delete_transient(self::TRANSIENT_VERIFY_BACKOFF);
    }


    /**
     * Check if license is currently active
     *
     * @return bool
     */
    public function is_license_active(): bool
    {
        /**
         * Delegates to the canonical reader (FreemiumManager::is_pro_active) so
         * the PRO presence-guard is enforced here too. Do not infer PRO status
         * from API responses; backend failures must never downgrade local state.
         */
        if (class_exists('\\TamRank\\FreemiumManager')) {
            return FreemiumManager::get_instance()->is_pro_active();
        }
        // Guarded fallback if the manager is somehow unavailable (load order).
        return (bool) tam_rank_cached_option('tam_rank_premium_active', false)
            && defined('TAMRANK_PRO_VERSION');
    }

    /**
     * Get the stored API key for authenticated requests
     *
     * @return string|null API key or null if not set
     */
    public function get_api_key(): ?string
    {
        $api_key = tam_rank_cached_option('tam_rank_api_key', '');
        return !empty($api_key) ? $api_key : null;
    }

    /**
     * Ensure the site has an API key — auto-register as FREE-tier if needed.
     *
     * FREE-tier features that talk to api.tamrank.com (Search Console OAuth,
     * indexing, etc.) need an api_key, but `register_site()` was previously
     * only invoked as part of `activate_license()`. This wrapper lets FREE-only
     * sites obtain a key on-demand: idempotent — returns the existing key if
     * present, otherwise calls `register_site()` once.
     *
     * A short failure transient prevents hammering the VPS when registration
     * fails (e.g. VPS down): subsequent calls within 5 minutes return the
     * cached error instead of retrying the slow blocking request.
     *
     * @return string|\WP_Error API key on success, WP_Error on failure
     */
    public function ensure_api_key()
    {
        $existing = $this->get_api_key();
        if (!empty($existing)) {
            return $existing;
        }

        // A recent ownership-verification failure: surface the same friendly,
        // retryable state without re-running the handshake on every page load.
        // This is the SHORT back-off — an explicit "Connect" click clears it
        // first (see clear_register_backoff), so a deliberate retry is immediate.
        $verify_backoff = get_transient(self::TRANSIENT_VERIFY_BACKOFF);
        if (is_array($verify_backoff)) {
            return new \WP_Error('verify_failed', tamrank__('gsc_verify_failed', "We couldn't verify your site."), $verify_backoff);
        }

        // Honor a recent hard connection failure (VPS unreachable) to avoid
        // hammering it with slow blocking requests on every page load.
        $cached_failure = get_transient(self::TRANSIENT_REGISTER_FAILURE);
        if ($cached_failure) {
            return new \WP_Error('register_failed_cached', $cached_failure);
        }

        $result = $this->register_site();
        if (is_wp_error($result)) {
            $code = $result->get_error_code();
            if ($code === 'connection_failed') {
                // VPS down — back off for 5 min.
                set_transient(self::TRANSIENT_REGISTER_FAILURE, $code, 5 * MINUTE_IN_SECONDS);
            } elseif ($code === 'verify_failed') {
                // Reached the VPS but ownership proof failed. Short, distinct
                // back-off (carries the detail) — must NOT trip the 5-min one,
                // or an explicit retry would be blocked.
                set_transient(self::TRANSIENT_VERIFY_BACKOFF, (array) $result->get_error_data(), 2 * MINUTE_IN_SECONDS);
            }
            // 'register_in_progress' (concurrent handshake) and 'server_error'
            // set no back-off — the next attempt may simply succeed.
            return $result;
        }

        $this->clear_register_backoff();
        return $result;
    }

    /**
     * Get license status information
     *
     * @return array License info
     */
    public function get_license_info(): array
    {
        $api_key = tam_rank_cached_option('tam_rank_api_key', '');
        $plan = get_option('tam_rank_plan', '');
        $expires_at = get_option('tam_rank_expires_at', '');

        $info = array(
            'key' => !empty($api_key) && function_exists('tam_rank_mask_api_key') ? tam_rank_mask_api_key($api_key) : '', // Masked for security
            'plan' => $plan,
            'expires' => $expires_at,
            'premium_active' => $this->is_license_active(),
            'has_api_key' => !empty($api_key),
        );

        // `tier` en `sites` zijn siblings van het credits-blok in
        // /v1/sites/status; get_credits() bewaart ze in dezelfde cache-optie.
        // Hier alleen PASSIEF lezen: deze methode heeft nooit een API-call
        // gedaan (PRO's admin-notices roept 'm op elke TamRank-pagina aan) en
        // dat moet zo blijven.
        //
        // Ontbreken blijft ontbreken: op een site die nooit een geslaagde
        // status-fetch deed staan deze sleutels er niet, en dan slaat de
        // isset()-guard aan PRO-kant over. Ze op array() of '' zetten zou een
        // lege licentie als een echte laten lezen.
        $cache = get_option('tam_rank_credits_cache', null);
        if (is_array($cache)) {
            foreach (array('tier', 'sites') as $key) {
                if (array_key_exists($key, $cache)) {
                    $info[$key] = $cache[$key];
                }
            }
        }

        return $info;
    }

    /**
     * Make an authenticated API request
     *
     * All API calls after activation should use this method.
     *
     * @param string $endpoint API endpoint (e.g., '/v1/credits')
     * @param string $method HTTP method
     * @param array $body Request body
     * @return array|WP_Error Response or error
     */
    public function api_request(string $endpoint, string $method = 'GET', array $body = array())
    {
        $api_key = $this->get_api_key();

        if (empty($api_key)) {
            return new \WP_Error('no_api_key', tamrank__('error_no_api_key', 'No API key found. Please activate your license first.'));
        }

        $args = array(
            'timeout' => 15,
            'method' => $method,
            'headers' => array(
                'Authorization' => 'Bearer ' . $api_key,
                'Content-Type' => 'application/json',
                'Accept' => 'application/json',
            ),
        );

        if (!empty($body) && in_array($method, array('POST', 'PUT', 'PATCH'))) {
            $args['body'] = wp_json_encode($body);
        }

        $response = wp_remote_request(self::API_BASE_URL . $endpoint, $args);

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

        $status_code = wp_remote_retrieve_response_code($response);
        $body = json_decode(wp_remote_retrieve_body($response), true);

        // Handle 401 - API key invalid/expired
        if ($status_code === 401) {
            // Do NOT touch tam_rank_premium_active here.
            // A 401 from the credits/status endpoint does not mean the license is deactivated.
            // Only explicit deactivate_license() may set this to false.
            // See: ARCHITECTURE.md — "API failures MUST NEVER downgrade local PRO state"
            return new \WP_Error('unauthorized', tamrank__('error_api_key_invalid_expired', 'API key is invalid or expired.'));
        }

        return array(
            'status' => $status_code,
            'body' => $body,
        );
    }

    /**
     * Get credits from SaaS API
     *
     * Uses /v1/sites/status endpoint which includes credits info
     *
     * @return array|WP_Error Credits data or error
     */
    public function get_credits()
    {
        $result = $this->api_request('/v1/sites/status');

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

        if ($result['status'] !== 200) {
            $error = $result['body']['message'] ?? tamrank__('error_credits_fetch_failed', 'Could not retrieve credits.');
            return new \WP_Error('credits_error', $error);
        }

        $body = is_array($result['body']) ? $result['body'] : array();
        $credits = (isset($body['credits']) && is_array($body['credits']))
            ? $body['credits']
            : $body;

        // /v1/sites/status stuurt exact deze zes velden. We geven ze door zoals
        // ze binnenkomen; wat de API niet stuurt blijft null. "Onbekend" is
        // geen 0 en zeker geen verzonnen getal — de UI moet het verschil zien.
        $remaining     = self::credit_int($credits, 'remaining', 'remaining_credits');
        $reserved      = self::credit_int($credits, 'reserved');
        $available     = self::credit_int($credits, 'available');
        $monthly_total = self::credit_int($credits, 'monthly_total');
        $purchased     = self::credit_int($credits, 'purchased');
        $resets_at     = (isset($credits['resets_at']) && is_scalar($credits['resets_at']))
            ? (string) $credits['resets_at']
            : null;

        // Legacy-sleutels blijven bestaan (er zijn vier lezers), maar krijgen
        // een eerlijke waarde. 'total' is het maandbudget en dat heet aan de
        // API-kant monthly_total; die naam is geen verzinsel maar hetzelfde
        // getal onder een andere sleutel.
        $total = self::credit_int($credits, 'total', 'total_credits');
        if (null === $total) {
            $total = $monthly_total;
        }

        // 'used' is het maandverbruik. remaining bevat purchased al, dus de
        // maandrest is remaining - purchased en het verbruik het restant van de
        // bundel. Alleen af te leiden als alle drie bekend zijn.
        $used = self::credit_int($credits, 'used', 'used_credits');
        if (null === $used && null !== $monthly_total && null !== $remaining && null !== $purchased) {
            $used = min($monthly_total, max(0, $monthly_total - ($remaining - $purchased)));
        }

        // Cache credits for 5 minutes
        $credits_data = array(
            // Bestaande sleutels — niet verwijderen, vier lezers hangen eraan.
            'remaining'     => $remaining,
            'total'         => $total,
            'used'          => $used,
            'resets_at'     => $resets_at,
            'fetched_at'    => time(),
            // De echte velden uit de API, additief toegevoegd.
            'reserved'      => $reserved,
            'available'     => $available,
            'monthly_total' => $monthly_total,
            'purchased'     => $purchased,
            // `tier` en `sites` staan NAAST credits in de body, niet erin. Ze
            // liften mee op deze ene fetch (geen extra call) en delen dezelfde
            // 5-minuten-termijn. Ze horen hier omdat dit de enige schrijver van
            // deze optie is die de volledige payload ziet; de 402-schrijver in
            // class-api-client.php mergt en laat ze dus staan.
            'tier'          => self::status_tier($body),
            'sites'         => self::status_sites($body),
        );

        update_option('tam_rank_credits_cache', $credits_data);

        return $credits_data;
    }

    /**
     * Lees een creditveld als int, of null als de API het niet (bruikbaar) stuurt.
     *
     * De API levert reserved als string (mysql2 geeft een DECIMAL uit SUM() als
     * string terug), dus casten is nodig voordat een lezer ermee gaat rekenen.
     * Een ontbrekend veld blijft null: stilzwijgend 0 invullen maakt "we weten
     * het niet" ononderscheidbaar van "je hebt niets meer".
     *
     * @param array  $credits Het credits-blok uit de API-respons
     * @param string ...$keys Veldnamen; de eerste bruikbare treffer wint
     * @return int|null
     */
    private static function credit_int(array $credits, string ...$keys): ?int
    {
        foreach ($keys as $key) {
            if (array_key_exists($key, $credits) && is_numeric($credits[$key])) {
                return (int) $credits[$key];
            }
        }

        return null;
    }

    /**
     * Lees `tier` uit de status-body: 'solo' | 'studio' | 'agency', of null.
     *
     * Bewust GEEN whitelist van de drie bekende namen: komt er een vierde tier
     * bij, dan moet die doorkomen en beslist de kaart zelf wat hij ermee doet
     * (PRO valt op een neutrale kop terug bij een naam die hij niet kent).
     * FREE-sites krijgen dit veld niet en houden null.
     *
     * @param array $body De volledige /v1/sites/status-body
     * @return string|null
     */
    private static function status_tier(array $body): ?string
    {
        return (isset($body['tier']) && is_string($body['tier']) && '' !== $body['tier'])
            ? $body['tier']
            : null;
    }

    /**
     * Lees het `sites`-blok uit de status-body zonder betekenis te verliezen.
     *
     * Twee dingen die hier niet mogen gebeuren:
     *  1. `limit: null` mag GEEN 0 worden. null betekent onbeperkt (Agency);
     *     een kale (int)-cast maakt daar 0 van en dan leest de kaart Agency als
     *     "geen sites". Daarom is is_numeric() de poort en niet de cast.
     *  2. Een veld dat de API niet stuurde mag niet als null verschijnen. De
     *     sleutel blijft weg, zodat een lezer met array_key_exists() "niet
     *     gestuurd" van "expliciet onbeperkt" kan blijven onderscheiden.
     *
     * @param array $body De volledige /v1/sites/status-body
     * @return array|null null als de API geen sites-blok stuurt (FREE)
     */
    private static function status_sites(array $body): ?array
    {
        if (!isset($body['sites']) || !is_array($body['sites'])) {
            return null;
        }

        $sites = array();
        foreach (array('used', 'limit', 'staging_used', 'staging_limit') as $key) {
            if (!array_key_exists($key, $body['sites'])) {
                continue;
            }
            $value = $body['sites'][$key];
            $sites[$key] = is_numeric($value) ? (int) $value : null;
        }

        return $sites;
    }

    /**
     * Get cached credits or fetch fresh
     *
     * @param bool $force_refresh Force API call
     * @return array Credits data
     */
    public function get_cached_credits(bool $force_refresh = false): array
    {
        $cached = get_option('tam_rank_credits_cache', null);

        // Return cached if valid (5 minute cache)
        if (!$force_refresh && $cached && isset($cached['fetched_at'])) {
            $cache_age = time() - $cached['fetched_at'];
            if ($cache_age < 300) {
                return $cached;
            }
        }

        // Fetch fresh
        $credits = $this->get_credits();

        if (is_wp_error($credits)) {
            // Is er nog een oude cache, geef die terug maar merk hem als niet
            // ververst; anders weten we het echt niet. Alles null in plaats van
            // 0/50, zodat een lezer "onbekend" van "op" kan onderscheiden.
            if (is_array($cached)) {
                $cached['refresh_error'] = $credits->get_error_message();
                return $cached;
            }

            return array(
                'remaining'     => null,
                'total'         => null,
                'used'          => null,
                'resets_at'     => null,
                'fetched_at'    => 0,
                'reserved'      => null,
                'available'     => null,
                'monthly_total' => null,
                'purchased'     => null,
                'error'         => $credits->get_error_message(),
            );
        }

        return $credits;
    }

}
