<?php
// auth/JWTManagerMembers.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 int $clockLeeway = 60; // seconds
    private string $userType = 'member'; // Default user type

    public function __construct(PDO $pdo, string $userType = 'member')
    {
        $this->pdo = $pdo;
        $this->userType = $userType;
        $this->secretKey = $this->getSecretKey();
        $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 member_id - support both 'mid' and 'member_id' keys
        $memberId = $this->extractMemberIdFromPayload($payload);
        
        if (!$memberId) {
            throw new InvalidArgumentException('Missing member_id or mid 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',
            'utype' => $this->userType
        ];

        $tokenPayload = array_merge($normalizedPayload, [
            'member_id' => $memberId, // Always use 'member_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_member',
            'sub' => 'auth',
            'bind' => $this->generateTokenBinding(),
            'user_type' => $this->userType
        ]);

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

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

    private function normalizePayload(array $payload): array
    {
        $normalized = $payload;
        
        // Remove 'mid' if present, we'll use 'member_id'
        if (isset($normalized['mid'])) {
            unset($normalized['mid']);
        }
        
        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 {
            // Rate limiting check
            $rateKey = 'jwt_verify_' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
            if (!$this->checkRateLimit($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->validateMember($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['utype']) &&
               $header['alg'] === $this->algorithm &&
               $header['typ'] === 'JWT' &&
               $header['utype'] === $this->userType;
    }

    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_member') return false;
        if (($payload['user_type'] ?? '') !== $this->userType) return false;

        // Check for member_id
        if (!isset($payload['member_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;

        $sessionMemberId = $_SESSION['member_id'] ?? null;
        $tokenMemberId = $payload['member_id'] ?? null;
        
        return !empty($_SESSION['authenticated']) &&
               $sessionMemberId === $tokenMemberId;
    }

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

    private function validateMember(array $payload): bool
    {
        if (!isset($payload['member_id'])) {
            return false;
        }

        // Check if member exists and is active
        try {
            $stmt = $this->pdo->prepare("
                SELECT 1 FROM member_portal_accounts 
                WHERE member_id = ? AND status = 'active'
            ");
            $stmt->execute([$payload['member_id']]);
            return (bool)$stmt->fetchColumn();
        } catch (Exception $e) {
            $this->logSecurityEvent('member_validation_error', ['error' => $e->getMessage()]);
            return false;
        }
    }

    /* ===================== 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;

        try {
            $stmt = $this->pdo->prepare("
                SELECT 1 FROM member_revoked_tokens WHERE jti = ? LIMIT 1
            ");
            $stmt->execute([$jti]);
            return (bool)$stmt->fetchColumn();
        } catch (Exception $e) {
            return false;
        }
    }

    public function revokeMemberTokens(int $memberId): void
    {
        try {
            // Insert into revoked tokens table
            $stmt = $this->pdo->prepare("
                INSERT INTO member_revoked_tokens (jti, member_id, revoked_at)
                SELECT jti, member_id, NOW() FROM member_active_tokens 
                WHERE member_id = ?
            ");
            $stmt->execute([$memberId]);

            // Delete from active tokens
            $stmt = $this->pdo->prepare("
                DELETE FROM member_active_tokens WHERE member_id = ?
            ");
            $stmt->execute([$memberId]);

            // Delete refresh tokens
            $stmt = $this->pdo->prepare("
                DELETE FROM member_refresh_tokens WHERE member_id = ?
            ");
            $stmt->execute([$memberId]);

            $this->logSecurityEvent('tokens_revoked', ['member_id' => $memberId]);
        } catch (Exception $e) {
            $this->logSecurityEvent('token_revocation_error', ['error' => $e->getMessage()]);
        }
    }

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

    private function logSecurityEvent(string $event, array $data = []): void
    {
        try {
            $stmt = $this->pdo->prepare("
                INSERT INTO member_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($memberId, $deviceId = '', $userType = 'member'): string
    {
        if (empty($memberId)) {
            throw new InvalidArgumentException('Missing member_id for refresh token generation');
        }
        
        $token = bin2hex(random_bytes(32));
        $expiresAt = date('Y-m-d H:i:s', time() + $this->refreshTokenExpiry);
        
        try {
            $stmt = $this->pdo->prepare("
                INSERT INTO member_refresh_tokens (member_id, token, device_id, user_type, expires_at, created_at) 
                VALUES (?, ?, ?, ?, ?, NOW())
                ON DUPLICATE KEY UPDATE 
                    token = VALUES(token),
                    expires_at = VALUES(expires_at),
                    created_at = NOW()
            ");
            
            $stmt->execute([$memberId, $token, $deviceId, $userType, $expiresAt]);
            
            return $token;
        } catch (Exception $e) {
            throw new RuntimeException("Failed to generate refresh token: " . $e->getMessage());
        }
    }
    
    public function storeAccessToken($memberId, $accessToken, $userAgentHash, $userType = 'member'): void
    {
        if (empty($memberId)) {
            throw new InvalidArgumentException('Missing member_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);
        
        try {
            $stmt = $this->pdo->prepare("
                INSERT INTO member_active_tokens (member_id, jti, user_agent_hash, user_type, expires_at, created_at)
                VALUES (?, ?, ?, ?, ?, NOW())
                ON DUPLICATE KEY UPDATE 
                    expires_at = VALUES(expires_at),
                    created_at = NOW()
            ");
            
            $stmt->execute([$memberId, $jti, $userAgentHash, $userType, $expiresAt]);
        } catch (Exception $e) {
            throw new RuntimeException("Failed to store access token: " . $e->getMessage());
        }
    }
    
    public function verifyRefreshToken($refreshToken): array|false
    {
        try {
            $stmt = $this->pdo->prepare("
                SELECT member_id, device_id, user_type 
                FROM member_refresh_tokens 
                WHERE token = ? AND expires_at > NOW()
            ");
            $stmt->execute([$refreshToken]);
            $result = $stmt->fetch(PDO::FETCH_ASSOC);
            
            if (!$result) {
                return false;
            }
            
            // Delete used refresh token (one-time use)
            $this->deleteRefreshToken($refreshToken);
            
            return $result;
        } catch (Exception $e) {
            $this->logSecurityEvent('refresh_token_error', ['error' => $e->getMessage()]);
            return false;
        }
    }
    
    private function deleteRefreshToken($token): void
    {
        try {
            $stmt = $this->pdo->prepare("
                DELETE FROM member_refresh_tokens WHERE token = ?
            ");
            $stmt->execute([$token]);
        } catch (Exception $e) {
            // Log but don't throw
            error_log("Failed to delete refresh token: " . $e->getMessage());
        }
    }
    
    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;
    }
    
    /* ===================== PRE-AUTH TOKEN FOR 2FA ===================== */
    
    public function createPreAuthToken($memberId, array $data = []): string
    {
        $header = [
            'alg' => $this->algorithm,
            'typ' => 'JWT',
            'kid' => 'preauth',
            'utype' => $this->userType
        ];
        
        $payload = array_merge($data, [
            'preauth_member_id' => $memberId,
            'iat' => time(),
            'exp' => time() + 300, // 5 minutes for 2FA
            'jti' => $this->generateJti(),
            'iss' => $_SERVER['HTTP_HOST'] ?? 'localhost',
            'aud' => '2fa_member',
            'user_type' => $this->userType
        ]);
        
        return $this->signJwt($header, $payload);
    }
    
    public function verifyPreAuthToken($token): array|false
    {
        try {
            $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 (!isset($header['utype']) || $header['utype'] !== $this->userType) return false;
            if (!$this->verifySignature("$hEnc.$pEnc", $this->base64UrlDecode($sEnc))) return false;
            
            // Validate pre-auth specific claims
            if (($payload['aud'] ?? '') !== '2fa_member') return false;
            if (!isset($payload['preauth_member_id'])) return false;
            if (($payload['exp'] ?? 0) < time()) return false;
            
            return $payload;
        } catch (Exception $e) {
            return false;
        }
    }
    
    /* ===================== TOKEN CLEANUP ===================== */
    
    public function cleanupExpiredTokens(): int
    {
        $cleaned = 0;
        
        try {
            // Clean expired active tokens
            $stmt = $this->pdo->prepare("
                DELETE FROM member_active_tokens WHERE expires_at < NOW()
            ");
            $stmt->execute();
            $cleaned += $stmt->rowCount();
            
            // Clean expired refresh tokens
            $stmt = $this->pdo->prepare("
                DELETE FROM member_refresh_tokens WHERE expires_at < NOW()
            ");
            $stmt->execute();
            $cleaned += $stmt->rowCount();
            
            // Clean old revoked tokens (older than 7 days)
            $stmt = $this->pdo->prepare("
                DELETE FROM member_revoked_tokens WHERE revoked_at < DATE_SUB(NOW(), INTERVAL 7 DAY)
            ");
            $stmt->execute();
            $cleaned += $stmt->rowCount();
            
            return $cleaned;
        } catch (Exception $e) {
            $this->logSecurityEvent('token_cleanup_error', ['error' => $e->getMessage()]);
            return $cleaned;
        }
    }
    
    /* ===================== RATE LIMITING ===================== */
    
    private function checkRateLimit(string $key, int $limit, int $window): bool
    {
        try {
            $stmt = $this->pdo->prepare("
                DELETE FROM member_rate_limits WHERE expires_at < NOW()
            ");
            $stmt->execute();

            $stmt = $this->pdo->prepare("
                SELECT COUNT(*) FROM member_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 member_rate_limits (rate_key, created_at, expires_at)
                VALUES (?, NOW(), DATE_ADD(NOW(), INTERVAL ? SECOND))
            ");
            $stmt->execute([$key, $window]);

            return true;
        } catch (Exception $e) {
            // If rate limiting fails, allow the request
            error_log("Rate limiting error: " . $e->getMessage());
            return true;
        }
    }
    
    /* ===================== SETTERS ===================== */
    
    public function setAccessTokenExpiry(int $seconds): void
    {
        if ($seconds < 60) {
            throw new InvalidArgumentException('Access token expiry must be at least 60 seconds');
        }
        $this->accessTokenExpiry = $seconds;
    }
    
    public function setRefreshTokenExpiry(int $seconds): void
    {
        if ($seconds < 3600) {
            throw new InvalidArgumentException('Refresh token expiry must be at least 1 hour');
        }
        $this->refreshTokenExpiry = $seconds;
    }
    
    public function setUserType(string $userType): void
    {
        $this->userType = $userType;
    }
}

/* ===================== DATABASE TABLES SQL ===================== */
/*
-- Required tables for JWTManagerMembers:

-- 1. Member active tokens table
CREATE TABLE member_active_tokens (
    id INT PRIMARY KEY AUTO_INCREMENT,
    member_id INT NOT NULL,
    jti VARCHAR(64) UNIQUE NOT NULL,
    user_agent_hash VARCHAR(64) NOT NULL,
    user_type VARCHAR(20) DEFAULT 'member',
    expires_at DATETIME NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_member_id (member_id),
    INDEX idx_jti (jti),
    INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 2. Member refresh tokens table
CREATE TABLE member_refresh_tokens (
    id INT PRIMARY KEY AUTO_INCREMENT,
    member_id INT NOT NULL,
    token VARCHAR(64) UNIQUE NOT NULL,
    device_id VARCHAR(64),
    user_type VARCHAR(20) DEFAULT 'member',
    expires_at DATETIME NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_member_id (member_id),
    INDEX idx_token (token),
    INDEX idx_expires (expires_at),
    INDEX idx_member_device (member_id, device_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 3. Member revoked tokens table
CREATE TABLE member_revoked_tokens (
    id INT PRIMARY KEY AUTO_INCREMENT,
    jti VARCHAR(64) UNIQUE NOT NULL,
    member_id INT,
    reason VARCHAR(255),
    revoked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_jti (jti),
    INDEX idx_member_id (member_id),
    INDEX idx_revoked (revoked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 4. Member security logs table
CREATE TABLE member_security_logs (
    id INT PRIMARY KEY AUTO_INCREMENT,
    event VARCHAR(100) NOT NULL,
    ip_address VARCHAR(45),
    member_id INT,
    metadata JSON,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_event (event),
    INDEX idx_ip (ip_address),
    INDEX idx_member (member_id),
    INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 5. Member rate limits table
CREATE TABLE member_rate_limits (
    id INT PRIMARY KEY AUTO_INCREMENT,
    rate_key VARCHAR(255) NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    expires_at DATETIME NOT NULL,
    INDEX idx_key_created (rate_key, created_at),
    INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
*/
?>