<?php

declare(strict_types=1);

namespace Fennec\Security\Jwt;

use Fennec\Clock\ClockInterface;

/**
 * Codec JWT maison (HS256), sans dépendance externe.
 *
 * Ajoute et vérifie lui-même les claims "iat"/"exp" à partir d'un TTL configuré,
 * pour que l'appelant n'ait jamais à gérer l'expiration manuellement.
 */
final class Jwt
{
    public function __construct(
        private readonly string $secret,
        private readonly int $ttlInSeconds,
        private readonly ClockInterface $clock,
    ) {
    }

    /**
     * @param array<string, mixed> $claims Claims métier (ex. "sub", "role"). "iat"/"exp" ajoutés automatiquement.
     *
     * @throws \JsonException Si l'en-tête ou le payload ne peut pas être encodé en JSON.
     */
    public function encode(array $claims): string
    {
        $now = $this->clock->now()->getTimestamp();

        $header = ['alg' => 'HS256', 'typ' => 'JWT'];
        $payload = [
            ...$claims,
            'iat' => $now,
            'exp' => $now + $this->ttlInSeconds,
        ];

        $encodedHeader = $this->base64UrlEncode(\json_encode($header, \JSON_THROW_ON_ERROR));
        $encodedPayload = $this->base64UrlEncode(\json_encode($payload, \JSON_THROW_ON_ERROR));

        $signature = $this->sign($encodedHeader . '.' . $encodedPayload);

        return $encodedHeader . '.' . $encodedPayload . '.' . $signature;
    }

    /**
     * @return array<string, mixed>
     *
     * @throws InvalidTokenException Si le format ou la signature du token est invalide.
     * @throws ExpiredTokenException Si le token est signé correctement mais expiré.
     */
    public function decode(string $token): array
    {
        $segments = \explode('.', $token);

        if (\count($segments) !== 3) {
            throw new InvalidTokenException(\sprintf('expected 3 segments, got %d.', \count($segments)));
        }

        [$encodedHeader, $encodedPayload, $signature] = $segments;

        $expectedSignature = $this->sign($encodedHeader . '.' . $encodedPayload);

        if (!\hash_equals($expectedSignature, $signature)) {
            throw new InvalidTokenException('signature verification failed.');
        }

        $decodedPayload = \json_decode($this->base64UrlDecode($encodedPayload), true);

        if (!\is_array($decodedPayload)) {
            throw new InvalidTokenException('payload is not a valid JSON object.');
        }

        if (\is_int($decodedPayload['exp'] ?? null) && $decodedPayload['exp'] < $this->clock->now()->getTimestamp()) {
            throw new ExpiredTokenException($decodedPayload['exp']);
        }

        return $decodedPayload;
    }

    private function sign(string $data): string
    {
        return $this->base64UrlEncode(\hash_hmac('sha256', $data, $this->secret, true));
    }

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

    private function base64UrlDecode(string $data): string
    {
        $remainder = \strlen($data) % 4;

        if ($remainder !== 0) {
            $data .= \str_repeat('=', 4 - $remainder);
        }

        return \base64_decode(\strtr($data, '-_', '+/'));
    }
}
