<?php
/**
 * Cache Manager
 *
 * Dual-layer caching: runtime array for fast repeat access,
 * WordPress transients for persistence across requests.
 *
 * @package     WPUltimateSecurity\UltimateSecurity\Infrastructure\Cache
 * @since       2.0.0
 */

declare(strict_types=1);

namespace WPUltimateSecurity\UltimateSecurity\Infrastructure\Cache;

use WPUltimateSecurity\UltimateSecurity\Contracts\CacheInterface;

/**
 * Cache implementation with runtime and transient layers.
 *
 * @since 2.0.0
 */
class CacheManager implements CacheInterface
{
    /**
     * Cache key prefix for all entries.
     */
    private const PREFIX = 'ultimate_security_cache_';

    /**
     * Maximum number of entries in runtime cache to prevent memory leaks.
     */
    private const MAX_RUNTIME_ENTRIES = 1000;

    /**
     * Runtime cache storage.
     *
     * @var array<string, mixed>
     */
    private static array $runtimeCache = [];

    /**
     * Singleton instance.
     *
     * @var self|null
     */
    private static ?self $instance = null;

    /**
     * Cache statistics.
     *
     * @var array{hits: int, misses: int, writes: int}
     */
    private static array $stats = [
        'hits'   => 0,
        'misses' => 0,
        'writes' => 0,
    ];

    /**
     * Private constructor to prevent direct instantiation.
     */
    private function __construct()
    {
    }

    /**
     * Get singleton instance.
     *
     * @return self
     */
    public static function getInstance(): self
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }

        return self::$instance;
    }

    /**
     * Retrieve a value from cache.
     *
     * @param string $key     Cache key.
     * @param mixed  $default Default value if key not found.
     *
     * @return mixed Cached value or default.
     */
    public function get(string $key, mixed $default = null): mixed
    {
        $prefixedKey = $this->prefixKey($key);

        // Check runtime cache first (fastest)
        if (array_key_exists($prefixedKey, self::$runtimeCache)) {
            ++self::$stats['hits'];
            return self::$runtimeCache[$prefixedKey];
        }

        // Check WordPress transients
        $value = get_transient($prefixedKey);

        if ($value === false) {
            ++self::$stats['misses'];
            return $default;
        }

        // Store in runtime cache for repeat access
        self::$runtimeCache[$prefixedKey] = $value;
        ++self::$stats['hits'];

        return $value;
    }

    /**
     * Store a value in cache.
     *
     * @param string $key   Cache key.
     * @param mixed  $value Value to cache.
     * @param int    $ttl   Time to live in seconds (0 = no expiration).
     *
     * @return bool True on success.
     */
    public function set(string $key, mixed $value, int $ttl = 3600): bool
    {
        $prefixedKey = $this->prefixKey($key);

        // LRU eviction: if cache is full, remove oldest entry first
        if (count(self::$runtimeCache) >= self::MAX_RUNTIME_ENTRIES && !array_key_exists($prefixedKey, self::$runtimeCache)) {
            // Remove the oldest entry (first key in the array)
            $oldestKey = array_key_first(self::$runtimeCache);
            if ($oldestKey !== null) {
                unset(self::$runtimeCache[$oldestKey]);
            }
        }

        // Update runtime cache (move to end for LRU tracking)
        unset(self::$runtimeCache[$prefixedKey]);
        self::$runtimeCache[$prefixedKey] = $value;

        // Persist to transients
        $result = set_transient($prefixedKey, $value, $ttl);

        if ($result) {
            ++self::$stats['writes'];
        }

        return $result;
    }

    /**
     * Check if a key exists in cache.
     *
     * @param string $key Cache key.
     *
     * @return bool True if key exists and is not expired.
     */
    public function has(string $key): bool
    {
        $prefixedKey = $this->prefixKey($key);

        // Check runtime cache
        if (array_key_exists($prefixedKey, self::$runtimeCache)) {
            return true;
        }

        // Check transients
        return get_transient($prefixedKey) !== false;
    }

    /**
     * Remove a key from cache.
     *
     * @param string $key Cache key.
     *
     * @return bool True on success.
     */
    public function forget(string $key): bool
    {
        $prefixedKey = $this->prefixKey($key);

        // Remove from runtime cache
        unset(self::$runtimeCache[$prefixedKey]);

        // Remove from transients
        return delete_transient($prefixedKey);
    }

    /**
     * Remove all keys matching a prefix.
     *
     * @param string $prefix Key prefix to match.
     *
     * @return int Number of keys removed.
     */
    public function forgetByPrefix(string $prefix): int
    {
        global $wpdb;

        $count      = 0;
        $fullPrefix = self::PREFIX . $prefix;

        // Remove from runtime cache
        foreach (array_keys(self::$runtimeCache) as $key) {
            if (str_starts_with($key, $fullPrefix)) {
                unset(self::$runtimeCache[$key]);
                ++$count;
            }
        }

        // Remove from transients
        $transientPrefix = '_transient_' . $fullPrefix;
        $timeoutPrefix   = '_transient_timeout_' . $fullPrefix;

        // Count transients before deletion
        $transientCount = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s",
                $transientPrefix . '%'
            )
        );

        // Delete transients and their timeouts
        $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
                $transientPrefix . '%',
                $timeoutPrefix . '%'
            )
        );

        return $count + $transientCount;
    }

    /**
     * Get or set a cached value.
     *
     * @param string   $key      Cache key.
     * @param int      $ttl      Time to live in seconds.
     * @param callable $callback Callback returning value to cache.
     *
     * @return mixed Cached or computed value.
     */
    public function remember(string $key, int $ttl, callable $callback): mixed
    {
        if ($this->has($key)) {
            return $this->get($key);
        }

        $value = $callback();

        $this->set($key, $value, $ttl);

        return $value;
    }

    /**
     * Get or set a cached value with no expiration.
     *
     * @param string   $key      Cache key.
     * @param callable $callback Callback returning value to cache.
     *
     * @return mixed Cached or computed value.
     */
    public function rememberForever(string $key, callable $callback): mixed
    {
        if ($this->has($key)) {
            return $this->get($key);
        }

        $value = $callback();

        // TTL of 0 means no expiration in WordPress transients
        $this->set($key, $value, 0);

        return $value;
    }

    /**
     * Remove all cached values.
     *
     * @return bool True on success.
     */
    public function flush(): bool
    {
        global $wpdb;

        // Clear runtime cache
        self::$runtimeCache = [];

        // Delete all transients with our prefix
        $transientPrefix = '_transient_' . self::PREFIX;
        $timeoutPrefix   = '_transient_timeout_' . self::PREFIX;

        $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
                $transientPrefix . '%',
                $timeoutPrefix . '%'
            )
        );

        // Reset stats
        self::$stats = [
            'hits'   => 0,
            'misses' => 0,
            'writes' => 0,
        ];

        return true;
    }

    /**
     * Get cache statistics.
     *
     * @return array{hits: int, misses: int, writes: int, runtime_count: int, hit_ratio: float}
     */
    public function getStats(): array
    {
        $total    = self::$stats['hits'] + self::$stats['misses'];
        $hitRatio = $total > 0 ? self::$stats['hits'] / $total : 0.0;

        return [
            'hits'          => self::$stats['hits'],
            'misses'        => self::$stats['misses'],
            'writes'        => self::$stats['writes'],
            'runtime_count' => count(self::$runtimeCache),
            'hit_ratio'     => round($hitRatio, 4),
        ];
    }

    /**
     * Clear runtime cache only (for testing).
     *
     * @return void
     */
    public static function clearRuntimeCache(): void
    {
        self::$runtimeCache = [];
    }

    /**
     * Reset statistics (for testing).
     *
     * @return void
     */
    public static function resetStats(): void
    {
        self::$stats = [
            'hits'   => 0,
            'misses' => 0,
            'writes' => 0,
        ];
    }

    /**
     * Generate a cache key from namespace and parts.
     *
     * @param string       $namespace Key namespace.
     * @param array<mixed> $parts     Key parts to hash.
     *
     * @return string Generated cache key.
     */
    public static function makeKey(string $namespace, array $parts = []): string
    {
        $key = $namespace;

        if (!empty($parts)) {
            $key .= '_' . md5(serialize($parts));
        }

        return $key;
    }

    /**
     * Add the prefix to a cache key.
     *
     * @param string $key Cache key.
     *
     * @return string Prefixed key.
     */
    private function prefixKey(string $key): string
    {
        return self::PREFIX . $key;
    }
}
