<?php

declare(strict_types=1);

namespace Tests\Support;

/**
 * JWT Inspector — Test-Side Claim Assertions
 *
 * Decodes a JWT WITHOUT verifying the signature (signature integrity is
 * the auth service's responsibility; the test suite asserts claim SHAPE
 * and CONTENT, not cryptographic correctness).
 *
 * Supports the current HS256 custom implementation AND the planned RS256
 * migration described in VerifyJWTBlacklist.php.
 */
class JwtInspector
{
    private array  $header;
    private array  $payload;
    private string $rawToken;

    public function __construct(string $token)
    {
        $this->rawToken = $token;
        $parts = explode('.', $token);

        if (count($parts) !== 3) {
            throw new \InvalidArgumentException(
                "Malformed JWT: expected 3 dot-separated parts, got " . count($parts) .
                ". Raw token: " . substr($token, 0, 40) . "…"
            );
        }

        $this->header  = json_decode(self::b64Decode($parts[0]), true) ?? [];
        $this->payload = json_decode(self::b64Decode($parts[1]), true) ?? [];
    }

    // ─── Payload Claims ───────────────────────────────────────────────────────

    public function claim(string $key): mixed     { return $this->payload[$key] ?? null; }
    public function header(string $key): mixed    { return $this->header[$key]  ?? null; }
    public function allClaims(): array            { return $this->payload; }
    public function allHeaders(): array           { return $this->header; }

    public function hasClaim(string $key): bool  { return array_key_exists($key, $this->payload); }
    public function hasHeader(string $key): bool { return array_key_exists($key, $this->header); }

    // ─── Temporal Assertions ─────────────────────────────────────────────────

    public function isExpired(): bool
    {
        $exp = $this->claim('exp');
        return $exp !== null && (int)$exp < time();
    }

    /** Assert the token is not yet expired AND still has at least $seconds remaining */
    public function expiresInAtLeast(int $seconds): bool
    {
        $exp = $this->claim('exp');
        if ($exp === null) {
            return false;
        }
        return ((int)$exp - time()) >= $seconds;
    }

    /** Assert the token was issued within the last $toleranceSeconds (clock-skew safe) */
    public function issuedRecently(int $toleranceSeconds = 10): bool
    {
        $iat = $this->claim('iat');
        if ($iat === null) {
            return false;
        }
        return abs(time() - (int)$iat) <= $toleranceSeconds;
    }

    // ─── Issuer / Subject ─────────────────────────────────────────────────────

    public function issuedBy(string $issuer): bool
    {
        return $this->claim('iss') === $issuer;
    }

    public function hasSubject(): bool
    {
        return $this->hasClaim('sub') || $this->hasClaim('user_id');
    }

    /**
     * Return the subject identifier — handles both the current implementation
     * (user_id claim) and the planned migration (sub claim per RFC 7519).
     */
    public function subjectId(): mixed
    {
        return $this->claim('sub') ?? $this->claim('user_id');
    }

    // ─── Algorithm ───────────────────────────────────────────────────────────

    public function algorithm(): string
    {
        return (string) ($this->header['alg'] ?? 'UNKNOWN');
    }

    public function tokenType(): string
    {
        return (string) ($this->header['typ'] ?? 'UNKNOWN');
    }

    // ─── Internal ────────────────────────────────────────────────────────────

    private static function b64Decode(string $input): string
    {
        // Add base64url padding
        $remainder = strlen($input) % 4;
        if ($remainder) {
            $input .= str_repeat('=', 4 - $remainder);
        }
        return base64_decode(strtr($input, '-_', '+/'));
    }
}
