<?php
// auth/JWTManager.php

declare(strict_types=1);

class JWTManager
{
    private PDO $pdo;
    private string $secretKey;
    private int $accessTokenExpiry = 3600;      // 1 hour
    private int $refreshTokenExpiry = 2592000;  // 30 days
    private string $algorithm = 'HS256';
    private RateLimiter $rateLimiter;
    private int $clockLeeway = 60; // seconds

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
        $this->secretKey = $this->getSecretKey();
        $this->rateLimiter = new RateLimiter($pdo);
        $this->validateEnvironment();
    }

    /* ===================== ENVIRONMENT ===================== */

    private function getSecretKey(): string
    {
        $key = getenv('JWT_SECRET_KEY');
        if (!$key || strlen($key) < 32) {
            throw new RuntimeException('JWT_SECRET_KEY must be at least 32 characters');
        }
        return $key;
    }

    private function validateEnvironment(): void
    {
        if (session_status() === PHP_SESSION_NONE) {
            session_set_cookie_params([
                'lifetime' => 0,
                'path' => '/',
                'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
                'httponly' => true,
                'samesite' => 'Strict'
            ]);
        }
    }

    /* ===================== TOKEN CREATION ===================== */

    public function generateAccessToken(array $payload): string
    {
        // Extract user_id - support both 'uid' and 'user_id' keys
        $userId = $this->extractUserIdFromPayload($payload);
        
        if (!$userId) {
            throw new InvalidArgumentException('Missing user_id or uid in payload');
        }

        // Ensure we have a consistent format in payload
        $normalizedPayload = $this->normalizePayload($payload);

        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }

        $header = [
            'alg' => $this->algorithm,
            'typ' => 'JWT',
            'kid' => 'v1'
        ];

        $tokenPayload = array_merge($normalizedPayload, [
            'user_id' => $userId, // Always use 'user_id' in token
            'sid' => session_id(),
            'iat' => time(),
            'nbf' => time() - 5,
            'exp' => time() + $this->accessTokenExpiry,
            'jti' => $this->generateJti(),
            'iss' => $_SERVER['HTTP_HOST'] ?? 'localhost',
            'aud' => 'api',
            'sub' => 'auth',
            'bind' => $this->generateTokenBinding()
        ]);

        return $this->signJwt($header, $tokenPayload);
    }

    private function extractUserIdFromPayload(array $payload): ?int
    {
        // Try 'uid' first (from login script), then 'user_id'
        if (isset($payload['uid']) && is_numeric($payload['uid'])) {
            return (int)$payload['uid'];
        }
        
        if (isset($payload['user_id']) && is_numeric($payload['user_id'])) {
            return (int)$payload['user_id'];
        }
        
        return null;
    }

    private function normalizePayload(array $payload): array
    {
        $normalized = $payload;
        
        // Remove 'uid' if present, we'll use 'user_id'
        if (isset($normalized['uid'])) {
            unset($normalized['uid']);
        }
        
        return $normalized;
    }

    private function generateTokenBinding(): string
    {
        return hash_hmac(
            'sha256',
            ($_SERVER['HTTP_USER_AGENT'] ?? '') . session_id(),
            $this->secretKey
        );
    }

    /* ===================== VERIFICATION ===================== */

    public function verifyToken(string $token): array|false
    {
        try {
            $rateKey = 'jwt_verify_' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
            if (!$this->rateLimiter->check($rateKey, 10, 60)) {
                $this->logSecurityEvent('jwt_rate_limited');
                return false;
            }

            $parts = explode('.', $token);
            if (count($parts) !== 3) return false;

            [$hEnc, $pEnc, $sEnc] = $parts;

            $header = json_decode($this->base64UrlDecode($hEnc), true);
            $payload = json_decode($this->base64UrlDecode($pEnc), true);

            if (!$this->validateHeader($header)) return false;
            if (!$this->verifySignature("$hEnc.$pEnc", $this->base64UrlDecode($sEnc))) return false;
            if (!$this->validatePayload($payload)) return false;
            if (!$this->verifyTokenBinding($payload)) return false;
            if ($this->isTokenRevoked($payload['jti'] ?? '')) return false;
            if (!$this->validateSession($payload)) return false;
            if (!$this->validatePermissions($payload)) return false;

            return $payload;

        } catch (Throwable $e) {
            $this->logSecurityEvent('jwt_verify_error', ['error' => $e->getMessage()]);
            return false;
        }
    }

    /* ===================== VALIDATIONS ===================== */

    private function validateHeader(array $header): bool
    {
        return isset($header['alg'], $header['typ']) &&
               $header['alg'] === $this->algorithm &&
               $header['typ'] === 'JWT';
    }

    private function validatePayload(array $payload): bool
    {
        $now = time();

        if (($payload['nbf'] ?? 0) > ($now + $this->clockLeeway)) return false;
        if (($payload['exp'] ?? 0) < ($now - $this->clockLeeway)) return false;
        if (($payload['iss'] ?? '') !== ($_SERVER['HTTP_HOST'] ?? 'localhost')) return false;
        if (($payload['aud'] ?? '') !== 'api') return false;

        // Check for user_id
        if (!isset($payload['user_id'])) {
            return false;
        }

        return true;
    }

    private function validateSession(array $payload): bool
    {
        if (session_status() !== PHP_SESSION_ACTIVE) return false;
        if (!hash_equals(session_id(), $payload['sid'] ?? '')) return false;

        $sessionUserId = $_SESSION['user_id'] ?? null;
        $tokenUserId = $payload['user_id'] ?? null;
        
        return !empty($_SESSION['authenticated']) &&
               $sessionUserId === $tokenUserId;
    }

    private function verifyTokenBinding(array $payload): bool
    {
        return hash_equals(
            $payload['bind'] ?? '',
            $this->generateTokenBinding()
        );
    }

    private function validatePermissions(array $payload): bool
    {
        return isset($payload['user_id']);
    }

    /* ===================== SIGNING ===================== */

    private function signJwt(array $header, array $payload): string
    {
        $h = $this->base64UrlEncode(json_encode($header));
        $p = $this->base64UrlEncode(json_encode($payload));
        $s = hash_hmac('sha256', "$h.$p", $this->secretKey, true);

        return "$h.$p." . $this->base64UrlEncode($s);
    }

    private function verifySignature(string $data, string $signature): bool
    {
        $expected = hash_hmac('sha256', $data, $this->secretKey, true);
        return hash_equals($expected, $signature);
    }

    /* ===================== HELPERS ===================== */

    private function base64UrlEncode(string $data): string
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }

    private function base64UrlDecode(string $data): string
    {
        return base64_decode(strtr($data, '-_', '+/'));
    }

    private function generateJti(): string
    {
        return bin2hex(random_bytes(16));
    }

    /* ===================== REVOCATION ===================== */

    private function isTokenRevoked(string $jti): bool
    {
        if (!$jti) return false;

        $stmt = $this->pdo->prepare(
            "SELECT 1 FROM revoked_tokens WHERE jti = ? LIMIT 1"
        );
        $stmt->execute([$jti]);
        return (bool)$stmt->fetchColumn();
    }

    private function revokeAllUserTokens(int $userId): void
    {
        $stmt = $this->pdo->prepare(
            "INSERT INTO revoked_tokens (jti, revoked_at)
             SELECT jti, NOW() FROM active_tokens WHERE user_id = ?"
        );
        $stmt->execute([$userId]);
    }

    /* ===================== LOGGING ===================== */

    private function logSecurityEvent(string $event, array $data = []): void
    {
        try {
            $stmt = $this->pdo->prepare(
                "INSERT INTO security_logs (event, ip_address, metadata, created_at)
                 VALUES (?, ?, ?, NOW())"
            );
            $stmt->execute([
                $event,
                $_SERVER['REMOTE_ADDR'] ?? null,
                json_encode($data)
            ]);
        } catch (Throwable) {
            // never block auth
        }
    }

    /* ===================== REFRESH TOKEN METHODS ===================== */
    
    public function generateRefreshToken($userId, $deviceId = ''): string
    {
        if (empty($userId)) {
            throw new InvalidArgumentException('Missing user_id for refresh token generation');
        }
        
        $token = bin2hex(random_bytes(32));
        $expiresAt = date('Y-m-d H:i:s', time() + $this->refreshTokenExpiry);
        
        $stmt = $this->pdo->prepare("
            INSERT INTO refresh_tokens (user_id, token, device_id, expires_at, created_at) 
            VALUES (?, ?, ?, ?, NOW())
            ON DUPLICATE KEY UPDATE 
                token = VALUES(token),
                expires_at = VALUES(expires_at),
                created_at = NOW()
        ");
        
        $stmt->execute([$userId, $token, $deviceId, $expiresAt]);
        
        return $token;
    }
    
    public function storeAccessToken($userId, $accessToken, $userAgentHash): void
    {
        if (empty($userId)) {
            throw new InvalidArgumentException('Missing user_id for storing access token');
        }
        
        $jti = $this->extractJtiFromToken($accessToken);
        if (!$jti) {
            throw new InvalidArgumentException('Invalid access token - missing jti');
        }
        
        $expiresAt = date('Y-m-d H:i:s', time() + $this->accessTokenExpiry);
        
        $stmt = $this->pdo->prepare("
            INSERT INTO active_tokens (user_id, jti, user_agent_hash, expires_at, created_at)
            VALUES (?, ?, ?, ?, NOW())
            ON DUPLICATE KEY UPDATE 
                expires_at = VALUES(expires_at),
                created_at = NOW()
        ");
        
        $stmt->execute([$userId, $jti, $userAgentHash, $expiresAt]);
    }
    
    private function extractJtiFromToken(string $token): ?string
    {
        try {
            $parts = explode('.', $token);
            if (count($parts) !== 3) return null;
            
            $payload = json_decode($this->base64UrlDecode($parts[1]), true);
            return $payload['jti'] ?? null;
        } catch (Exception $e) {
            return null;
        }
    }
    
    public function getAccessTokenExpiry(): int
    {
        return $this->accessTokenExpiry;
    }
    
    public function getRefreshTokenExpiry(): int
    {
        return $this->refreshTokenExpiry;
    }
    
    // Optional: Method to create pre-auth token for 2FA
    public function createPreAuthToken($userId, array $data = []): string
    {
        $header = [
            'alg' => $this->algorithm,
            'typ' => 'JWT',
            'kid' => 'preauth'
        ];
        
        $payload = array_merge($data, [
            'preauth_user_id' => $userId,
            'iat' => time(),
            'exp' => time() + 300, // 5 minutes for 2FA
            'jti' => $this->generateJti(),
            'iss' => $_SERVER['HTTP_HOST'] ?? 'localhost',
            'aud' => '2fa'
        ]);
        
        return $this->signJwt($header, $payload);
    }
}

/* ===================== RATE LIMITER ===================== */

class RateLimiter
{
    private PDO $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function check(string $key, int $limit, int $window): bool
    {
        $stmt = $this->pdo->prepare(
            "DELETE FROM rate_limits WHERE expires_at < NOW()"
        );
        $stmt->execute();

        $stmt = $this->pdo->prepare(
            "SELECT COUNT(*) FROM rate_limits
             WHERE rate_key = ? AND created_at > DATE_SUB(NOW(), INTERVAL ? SECOND)"
        );
        $stmt->execute([$key, $window]);

        if ($stmt->fetchColumn() >= $limit) {
            return false;
        }

        $stmt = $this->pdo->prepare(
            "INSERT INTO rate_limits (rate_key, created_at, expires_at)
             VALUES (?, NOW(), DATE_ADD(NOW(), INTERVAL ? SECOND))"
        );
        $stmt->execute([$key, $window]);

        return true;
    }
}