<?php
/**
 * ===========================================
 * FLOWBOT DCI - REDIS SERVICE v1.0
 * ===========================================
 * High-performance caching and rate limiting service.
 * Gracefully falls back to file-based operations if Redis unavailable.
 *
 * Features:
 * - Distributed rate limiting
 * - Progress caching
 * - Queue operations
 * - Automatic fallback to file-based storage
 */

declare(strict_types=1);

namespace FlowbotDCI\Services;

class RedisService
{
    private ?\Redis $redis = null;
    private bool $available = false;
    private string $tempDir;
    private string $prefix;

    // Configuration
    private string $host;
    private int $port;
    private ?string $password;
    private int $timeout;
    private int $database;

    /**
     * Constructor - attempts Redis connection, falls back to file-based if unavailable
     */
    public function __construct(array $config = [])
    {
        $this->host = $config['redis_host'] ?? ($_ENV['REDIS_HOST'] ?? '127.0.0.1');
        $this->port = (int)($config['redis_port'] ?? ($_ENV['REDIS_PORT'] ?? 6379));
        $this->password = $config['redis_password'] ?? ($_ENV['REDIS_PASSWORD'] ?? null);
        $this->timeout = (int)($config['redis_timeout'] ?? 2);
        $this->database = (int)($config['redis_database'] ?? 0);
        $this->tempDir = $config['temp_dir'] ?? (sys_get_temp_dir() . '/flowbot');
        $this->prefix = $config['redis_prefix'] ?? 'flowbot:';

        // Ensure temp directory exists
        // STABILITY FIX v1.1: Removed @ operator, added proper error handling
        if (!is_dir($this->tempDir)) {
            if (!mkdir($this->tempDir, 0755, true) && !is_dir($this->tempDir)) {
                error_log("RedisService: Failed to create temp directory: " . $this->tempDir);
            }
        }

        // Try to connect to Redis
        $this->tryConnect();
    }

    /**
     * Try to establish Redis connection
     */
    private function tryConnect(): void
    {
        // Check if Redis extension is loaded
        if (!extension_loaded('redis')) {
            error_log("RedisService: Redis extension not loaded, using file-based fallback");
            return;
        }

        try {
            $this->redis = new \Redis();

            // Connect with timeout
            // STABILITY FIX v1.1: Removed @ operator, exceptions are caught below
            $connected = $this->redis->connect($this->host, $this->port, $this->timeout);
            if (!$connected) {
                throw new \Exception("Connection failed to {$this->host}:{$this->port}");
            }

            // Authenticate if password provided
            if (!empty($this->password)) {
                if (!$this->redis->auth($this->password)) {
                    throw new \Exception("Authentication failed");
                }
            }

            // Select database
            $this->redis->select($this->database);

            // Test connection
            $pong = $this->redis->ping();
            if ($pong !== true && $pong !== '+PONG' && $pong !== 'PONG') {
                throw new \Exception("Ping failed: " . var_export($pong, true));
            }

            $this->available = true;
            error_log("RedisService: Connected to Redis at {$this->host}:{$this->port}");

        } catch (\Exception $e) {
            error_log("RedisService: " . $e->getMessage() . ", using file-based fallback");
            $this->redis = null;
            $this->available = false;
        }
    }

    /**
     * Check if Redis is available
     */
    public function isAvailable(): bool
    {
        return $this->available && $this->redis !== null;
    }

    /**
     * Get full key with prefix
     */
    private function key(string $key): string
    {
        return $this->prefix . $key;
    }

    // ================================================================
    // RATE LIMITING
    // ================================================================

    /**
     * Check rate limit - distributed and atomic
     *
     * @param string $key Unique identifier (e.g., IP address)
     * @param int $limit Maximum requests allowed
     * @param int $window Time window in seconds
     * @return bool True if request is allowed, false if rate limit exceeded
     */
    public function checkRateLimit(string $key, int $limit, int $window): bool
    {
        $fullKey = $this->key("rate_limit:{$key}");

        if ($this->isAvailable()) {
            return $this->checkRateLimitRedis($fullKey, $limit, $window);
        }

        return $this->checkRateLimitFile($key, $limit, $window);
    }

    /**
     * Redis-based rate limiting using sliding window
     */
    private function checkRateLimitRedis(string $key, int $limit, int $window): bool
    {
        try {
            $now = microtime(true);
            $windowStart = $now - $window;

            // Use Redis transaction for atomic operations
            $this->redis->multi();

            // Remove old entries
            $this->redis->zRemRangeByScore($key, '-inf', (string)$windowStart);

            // Count current entries
            $this->redis->zCard($key);

            // Add current request
            $this->redis->zAdd($key, $now, (string)$now . ':' . uniqid());

            // Set expiry on the key
            $this->redis->expire($key, $window + 1);

            $results = $this->redis->exec();

            // Count is at index 1 (after zRemRangeByScore)
            $count = $results[1] ?? 0;

            return $count < $limit;

        } catch (\Exception $e) {
            error_log("RedisService rate limit error: " . $e->getMessage());
            // SECURITY FIX v1.1: Fail CLOSED - deny request on error to prevent bypass
            // Previous: return true (fail-open) allowed rate limit bypass on Redis errors
            return false;
        }
    }

    /**
     * File-based rate limiting fallback
     */
    private function checkRateLimitFile(string $key, int $limit, int $window): bool
    {
        $file = $this->tempDir . '/rate_limit_' . md5($key) . '.json';
        $now = time();
        $requests = [];

        // STABILITY FIX v1.1: Removed @ operator, proper error handling
        $fp = fopen($file, 'c+');
        if (!$fp) {
            error_log("RedisService: Failed to open rate limit file: " . $file);
            return false; // SECURITY FIX: Fail closed
        }

        if (!flock($fp, LOCK_EX)) {
            fclose($fp);
            error_log("RedisService: Failed to lock rate limit file: " . $file);
            return false; // SECURITY FIX: Fail closed
        }

        try {
            $size = filesize($file);
            if ($size > 0) {
                $content = fread($fp, $size);
                $data = json_decode($content, true);
                if (is_array($data)) {
                    $requests = array_filter($data, fn($ts) => $ts > ($now - $window));
                }
            }

            if (count($requests) >= $limit) {
                return false;
            }

            $requests[] = $now;
            ftruncate($fp, 0);
            rewind($fp);
            fwrite($fp, json_encode(array_values($requests)));
            fflush($fp);

            return true;

        } finally {
            flock($fp, LOCK_UN);
            fclose($fp);
        }
    }

    /**
     * Get remaining rate limit requests
     */
    public function getRateLimitRemaining(string $key, int $limit, int $window): int
    {
        $fullKey = $this->key("rate_limit:{$key}");

        if ($this->isAvailable()) {
            try {
                $now = microtime(true);
                $windowStart = $now - $window;
                $this->redis->zRemRangeByScore($fullKey, '-inf', (string)$windowStart);
                $count = $this->redis->zCard($fullKey);
                return max(0, $limit - $count);
            } catch (\Exception $e) {
                return $limit;
            }
        }

        return $limit; // Unknown for file-based
    }

    // ================================================================
    // CACHING
    // ================================================================

    /**
     * Set cache value with TTL
     */
    public function set(string $key, $value, int $ttl = 300): bool
    {
        $fullKey = $this->key($key);
        $encoded = is_array($value) || is_object($value) ? json_encode($value) : (string)$value;

        if ($this->isAvailable()) {
            try {
                return $this->redis->setex($fullKey, $ttl, $encoded);
            } catch (\Exception $e) {
                error_log("RedisService set error: " . $e->getMessage());
            }
        }

        // File-based fallback
        $file = $this->tempDir . '/cache_' . md5($fullKey) . '.json';
        $data = [
            'value' => $encoded,
            'expires' => time() + $ttl,
        ];
        $result = file_put_contents($file, json_encode($data), LOCK_EX);
        if ($result === false) {
            error_log("RedisService: Failed to write cache file: " . $file);
        }
        return $result !== false;
    }

    /**
     * Get cache value
     */
    public function get(string $key, $default = null)
    {
        $fullKey = $this->key($key);

        if ($this->isAvailable()) {
            try {
                $value = $this->redis->get($fullKey);
                if ($value === false) {
                    return $default;
                }
                $decoded = json_decode($value, true);
                return $decoded !== null ? $decoded : $value;
            } catch (\Exception $e) {
                error_log("RedisService get error: " . $e->getMessage());
            }
        }

        // File-based fallback
        $file = $this->tempDir . '/cache_' . md5($fullKey) . '.json';
        if (!file_exists($file)) {
            return $default;
        }

        $content = file_get_contents($file);
        if (!$content) {
            return $default;
        }

        $data = json_decode($content, true);
        if (!$data || !isset($data['expires']) || $data['expires'] < time()) {
            if (file_exists($file) && !unlink($file)) {
                error_log("RedisService: Failed to delete file: " . $file);
            }
            return $default;
        }

        $decoded = json_decode($data['value'], true);
        return $decoded !== null ? $decoded : $data['value'];
    }

    /**
     * Delete cache key
     */
    public function delete(string $key): bool
    {
        $fullKey = $this->key($key);

        if ($this->isAvailable()) {
            try {
                return $this->redis->del($fullKey) > 0;
            } catch (\Exception $e) {
                error_log("RedisService delete error: " . $e->getMessage());
            }
        }

        // File-based fallback
        $file = $this->tempDir . '/cache_' . md5($fullKey) . '.json';
        if (file_exists($file)) {
            if (!unlink($file)) {
                error_log("RedisService: Failed to delete file: " . $file);
                return false;
            }
        }
        return true;
    }

    /**
     * Check if key exists
     */
    public function exists(string $key): bool
    {
        $fullKey = $this->key($key);

        if ($this->isAvailable()) {
            try {
                return $this->redis->exists($fullKey) > 0;
            } catch (\Exception $e) {
                error_log("RedisService exists error: " . $e->getMessage());
            }
        }

        return $this->get($key) !== null;
    }

    // ================================================================
    // PROGRESS CACHING (Optimized for frequent updates)
    // ================================================================

    /**
     * Cache progress data with short TTL
     */
    public function cacheProgress(string $processId, array $progress): bool
    {
        return $this->set("progress:{$processId}", $progress, 300);
    }

    /**
     * Get cached progress
     */
    public function getProgress(string $processId): ?array
    {
        $result = $this->get("progress:{$processId}");
        return is_array($result) ? $result : null;
    }

    /**
     * Increment progress counter atomically
     */
    public function incrementProgress(string $processId, string $field, int $amount = 1): int
    {
        $key = $this->key("progress_counter:{$processId}:{$field}");

        if ($this->isAvailable()) {
            try {
                return $this->redis->incrBy($key, $amount);
            } catch (\Exception $e) {
                error_log("RedisService increment error: " . $e->getMessage());
            }
        }

        // File-based - less efficient but works
        $current = (int)$this->get("counter:{$processId}:{$field}", 0);
        $new = $current + $amount;
        $this->set("counter:{$processId}:{$field}", $new, 3600);
        return $new;
    }

    // ================================================================
    // QUEUE OPERATIONS
    // ================================================================

    /**
     * Push items to queue
     */
    public function pushToQueue(string $queue, array $items): int
    {
        if (empty($items)) {
            return 0;
        }

        $fullKey = $this->key("queue:{$queue}");

        if ($this->isAvailable()) {
            try {
                $encoded = array_map('json_encode', $items);
                return $this->redis->rPush($fullKey, ...$encoded);
            } catch (\Exception $e) {
                error_log("RedisService pushToQueue error: " . $e->getMessage());
            }
        }

        // File-based queue fallback
        $file = $this->tempDir . '/queue_' . md5($fullKey) . '.json';
        $fp = fopen($file, 'c+');
        if (!$fp) {
            return 0;
        }

        flock($fp, LOCK_EX);
        try {
            $size = filesize($file);
            $existing = [];
            if ($size > 0) {
                $content = fread($fp, $size);
                $existing = json_decode($content, true) ?? [];
            }
            $existing = array_merge($existing, $items);
            ftruncate($fp, 0);
            rewind($fp);
            fwrite($fp, json_encode($existing));
            return count($items);
        } finally {
            flock($fp, LOCK_UN);
            fclose($fp);
        }
    }

    /**
     * Pop items from queue
     */
    public function popFromQueue(string $queue, int $count = 1): array
    {
        $fullKey = $this->key("queue:{$queue}");

        if ($this->isAvailable()) {
            try {
                $items = [];
                for ($i = 0; $i < $count; $i++) {
                    $item = $this->redis->lPop($fullKey);
                    if ($item === false) {
                        break;
                    }
                    $decoded = json_decode($item, true);
                    $items[] = $decoded ?? $item;
                }
                return $items;
            } catch (\Exception $e) {
                error_log("RedisService popFromQueue error: " . $e->getMessage());
            }
        }

        // File-based queue fallback
        $file = $this->tempDir . '/queue_' . md5($fullKey) . '.json';
        if (!file_exists($file)) {
            return [];
        }

        $fp = fopen($file, 'c+');
        if (!$fp) {
            return [];
        }

        flock($fp, LOCK_EX);
        try {
            $size = filesize($file);
            if ($size === 0) {
                return [];
            }
            $content = fread($fp, $size);
            $queue = json_decode($content, true) ?? [];
            $items = array_splice($queue, 0, $count);
            ftruncate($fp, 0);
            rewind($fp);
            fwrite($fp, json_encode($queue));
            return $items;
        } finally {
            flock($fp, LOCK_UN);
            fclose($fp);
        }
    }

    /**
     * Get queue length
     */
    public function getQueueLength(string $queue): int
    {
        $fullKey = $this->key("queue:{$queue}");

        if ($this->isAvailable()) {
            try {
                return $this->redis->lLen($fullKey);
            } catch (\Exception $e) {
                error_log("RedisService getQueueLength error: " . $e->getMessage());
            }
        }

        // File-based
        $file = $this->tempDir . '/queue_' . md5($fullKey) . '.json';
        if (!file_exists($file)) {
            return 0;
        }
        $content = file_get_contents($file);
        $queue = json_decode($content, true) ?? [];
        return count($queue);
    }

    // ================================================================
    // LOCKING (Distributed locks)
    // ================================================================

    /**
     * Acquire distributed lock
     */
    public function acquireLock(string $name, int $ttl = 30): bool
    {
        $key = $this->key("lock:{$name}");
        $token = uniqid('lock_', true);

        if ($this->isAvailable()) {
            try {
                // SET NX with TTL
                $result = $this->redis->set($key, $token, ['NX', 'EX' => $ttl]);
                return $result === true;
            } catch (\Exception $e) {
                error_log("RedisService acquireLock error: " . $e->getMessage());
            }
        }

        // File-based lock
        $file = $this->tempDir . '/lock_' . md5($key) . '.lock';
        $fp = fopen($file, 'c');
        if (!$fp) {
            return false;
        }

        $locked = flock($fp, LOCK_EX | LOCK_NB);
        if ($locked) {
            ftruncate($fp, 0);
            fwrite($fp, json_encode(['token' => $token, 'expires' => time() + $ttl]));
            fflush($fp);
        }
        fclose($fp);

        return $locked;
    }

    /**
     * Release distributed lock
     */
    public function releaseLock(string $name): bool
    {
        $key = $this->key("lock:{$name}");

        if ($this->isAvailable()) {
            try {
                return $this->redis->del($key) > 0;
            } catch (\Exception $e) {
                error_log("RedisService releaseLock error: " . $e->getMessage());
            }
        }

        // File-based
        $file = $this->tempDir . '/lock_' . md5($key) . '.lock';
        if (file_exists($file)) {
            if (!unlink($file)) {
                error_log("RedisService: Failed to delete file: " . $file);
                return false;
            }
        }
        return true;
    }

    // ================================================================
    // STATS & MONITORING
    // ================================================================

    /**
     * Get Redis server info
     */
    public function getInfo(): array
    {
        if (!$this->isAvailable()) {
            return [
                'available' => false,
                'mode' => 'file-based',
                'temp_dir' => $this->tempDir,
            ];
        }

        try {
            $info = $this->redis->info();
            return [
                'available' => true,
                'mode' => 'redis',
                'host' => $this->host,
                'port' => $this->port,
                'version' => $info['redis_version'] ?? 'unknown',
                'used_memory' => $info['used_memory_human'] ?? 'unknown',
                'connected_clients' => $info['connected_clients'] ?? 0,
                'uptime_days' => $info['uptime_in_days'] ?? 0,
            ];
        } catch (\Exception $e) {
            return [
                'available' => false,
                'mode' => 'file-based',
                'error' => $e->getMessage(),
            ];
        }
    }

    /**
     * Flush all flowbot keys (careful!)
     */
    public function flushAll(): bool
    {
        if ($this->isAvailable()) {
            try {
                $keys = $this->redis->keys($this->prefix . '*');
                if (!empty($keys)) {
                    $this->redis->del(...$keys);
                }
                return true;
            } catch (\Exception $e) {
                error_log("RedisService flushAll error: " . $e->getMessage());
            }
        }

        // Clean temp files
        $files = glob($this->tempDir . '/{rate_limit_*,cache_*,queue_*,lock_*}', GLOB_BRACE);
        foreach ($files as $file) {
            if (file_exists($file) && !unlink($file)) {
                error_log("RedisService: Failed to delete file: " . $file);
            }
        }
        return true;
    }
}
