<?php

namespace Licenzo\Support;

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

/**
 * Atomic-ish counter store for the rate-limit middleware.
 *
 * STRATEGY:
 *   1. Object-cache primary (Redis, Memcached, or any backend that sets
 *      wp_using_ext_object_cache() = true). Uses wp_cache_add() to
 *      initialize + wp_cache_incr() for atomic increments. This is the
 *      only path that is truly race-free.
 *   2. Transient fallback for sites without a persistent object cache.
 *      Read -> increment in PHP -> write. Two concurrent requests can
 *      both read N and both write N+1, undercounting by 1 per race.
 *      Acceptable for a safety-net ceiling; the asymmetric failure
 *      policy in the middleware covers the residual risk.
 *   3. Per-request static memoization so a single request that triggers
 *      multiple internal rate-limit checks never double-increments.
 *
 * KEY FORMAT:
 *   Caller supplies a fully-derived key. This class does NOT hash, salt,
 *   or prefix. That responsibility belongs to the middleware so that
 *   bucket-key derivation remains a single, reviewable concern.
 *
 * FAILURE MODE:
 *   Any inconsistency (e.g. wp_cache_add succeeds but subsequent
 *   wp_cache_incr returns false) raises RateLimitStoreException. The
 *   middleware catches it and routes per the asymmetric policy.
 *
 * @see src/Http/Middleware/RateLimit.php
 */
class RateLimitStore
{
    /**
     * Cache group used when persistent object cache is available.
     * Groups isolate rate-limit keys from other cache traffic and let
     * operators flush just this namespace during incidents.
     */
    public const CACHE_GROUP = 'licenzo_ratelimit';

    /**
     * Prefix applied to transient names when object cache is absent.
     * Kept short because transient names have an internal length cap.
     */
    protected const TRANSIENT_PREFIX = 'licenzo_rl_';

    /**
     * Per-request memoization: key => [count, reset_at_unix_ts].
     * Prevents multiple hit() calls in one request from each writing
     * to the backend.
     *
     * @var array<string, array{0:int,1:int}>
     */
    protected array $requestCache = [];

    /**
     * Increment the counter for $key and return the new count.
     *
     * Initializes the counter to 0 with the given TTL on first hit
     * within the window.
     *
     * @throws RateLimitStoreException
     */
    public function hit(string $key, int $window): int
    {
        if ($window <= 0) {
            throw new RateLimitStoreException('rate-limit window must be positive');
        }

        if (isset($this->requestCache[$key])) {
            $this->requestCache[$key][0]++;
            return $this->requestCache[$key][0];
        }

        if ($this->hasPersistentObjectCache()) {
            $count = $this->hitObjectCache($key, $window);
        } else {
            $count = $this->hitTransient($key, $window);
        }

        $this->requestCache[$key] = [$count, time() + $window];
        return $count;
    }

    /**
     * Read the current count without incrementing.
     * Returns 0 if the key does not exist or has expired.
     */
    public function peek(string $key): int
    {
        if (isset($this->requestCache[$key])) {
            return $this->requestCache[$key][0];
        }

        if ($this->hasPersistentObjectCache()) {
            $value = wp_cache_get($key, static::CACHE_GROUP);
        } else {
            $value = get_transient(static::TRANSIENT_PREFIX . $key);
        }

        if ($value === false || $value === null) {
            return 0;
        }

        return (int) $value;
    }

    /**
     * Seconds until the bucket for $key resets.
     * Returns 0 if the bucket does not exist.
     *
     * Used to compute a correct Retry-After header.
     */
    public function ttl(string $key): int
    {
        if (isset($this->requestCache[$key])) {
            return max(0, $this->requestCache[$key][1] - time());
        }

        if (!$this->hasPersistentObjectCache()) {
            global $wpdb;
            $name = '_transient_timeout_' . static::TRANSIENT_PREFIX . $key;
            $row  = get_option($name);
            if (!$row) {
                return 0;
            }
            return max(0, (int) $row - time());
        }

        // Object cache backends do not expose per-key TTL portably.
        // Return 0 as a conservative signal; the middleware will fall
        // back to the policy window length for Retry-After.
        return 0;
    }

    /**
     * Reset the counter for $key. Primarily for admin tooling /
     * operator intervention.
     */
    public function reset(string $key): void
    {
        unset($this->requestCache[$key]);

        if ($this->hasPersistentObjectCache()) {
            wp_cache_delete($key, static::CACHE_GROUP);
            return;
        }

        delete_transient(static::TRANSIENT_PREFIX . $key);
    }

    /**
     * Increment via wp_cache_add + wp_cache_incr.
     *
     * The add() call is idempotent — returns false if the key already
     * exists — so we use it purely to seed. The incr() is atomic on
     * Redis / Memcached adapters and returns the post-increment value.
     *
     * @throws RateLimitStoreException
     */
    protected function hitObjectCache(string $key, int $window): int
    {
        $added = wp_cache_add($key, 0, static::CACHE_GROUP, $window);

        $new = wp_cache_incr($key, 1, static::CACHE_GROUP);

        if ($new === false) {
            // Recovery attempt: some adapters drop keys between add() and
            // incr() under memory pressure. Seed again and retry once.
            if (!$added) {
                wp_cache_add($key, 0, static::CACHE_GROUP, $window);
            }
            $new = wp_cache_incr($key, 1, static::CACHE_GROUP);
        }

        if ($new === false) {
            throw new RateLimitStoreException('object cache incr failed');
        }

        return (int) $new;
    }

    /**
     * Increment via read-modify-write against the transient API.
     *
     * Race-prone by design — see class doc. The caller (middleware)
     * compensates by treating near-limit counts conservatively and by
     * enforcing a global per-IP safety net in addition to per-route.
     */
    protected function hitTransient(string $key, int $window): int
    {
        $name    = static::TRANSIENT_PREFIX . $key;
        $current = get_transient($name);
        $count   = $current === false ? 0 : (int) $current;
        $count++;

        $saved = set_transient($name, $count, $window);
        if ($saved === false) {
            throw new RateLimitStoreException('transient store failed');
        }

        return $count;
    }

    protected function hasPersistentObjectCache(): bool
    {
        return function_exists('wp_using_ext_object_cache')
            && wp_using_ext_object_cache();
    }
}
