<?php

declare(strict_types=1);

namespace CpCreation\AltchaBundle\Security;

use CpCreation\AltchaBundle\Contract\TimeProviderInterface;
use CpCreation\AltchaBundle\Model\ProtectionEnvelope;
use CpCreation\AltchaBundle\Model\ProtectionEnvelopeClaims;
use CpCreation\AltchaBundle\Scope\ScopeName;

final readonly class ProtectionEnvelopeManager
{
    private const CLOCK_SKEW_MILLISECONDS = 5_000;

    public function __construct(
        private string $secret,
        private TimeProviderInterface $timeProvider,
        private int $ttlSeconds = 900,
    ) {
        if (32 > strlen($secret)) {
            throw new \InvalidArgumentException('The ALTCHA envelope secret must contain at least 32 bytes.');
        }
        if (1 > $ttlSeconds) {
            throw new \InvalidArgumentException('The ALTCHA envelope TTL must be positive.');
        }
    }

    public function issue(string $scope, string $host): ProtectionEnvelope
    {
        ScopeName::assertValid($scope);
        $issuedAt = $this->timeProvider->nowMilliseconds();
        $honeypot = 'hp_'.bin2hex(random_bytes(12));
        $payload = json_encode([
            'scope' => $scope,
            'host' => strtolower($host),
            'iat' => $issuedAt,
            'exp' => $issuedAt + ($this->ttlSeconds * 1_000),
            'nonce' => bin2hex(random_bytes(16)),
            'honeypot' => $honeypot,
        ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
        $encodedPayload = self::base64UrlEncode($payload);
        $signature = hash_hmac('sha256', $encodedPayload, $this->secret, true);

        return new ProtectionEnvelope($encodedPayload.'.'.self::base64UrlEncode($signature), $honeypot);
    }

    public function validateForChallenge(
        string $token,
        string $scope,
        string $host,
        int $maximumAgeSeconds,
    ): ?ProtectionEnvelopeClaims {
        return $this->validate($token, $scope, $host, 0, $maximumAgeSeconds);
    }

    public function validateForSubmission(
        string $token,
        string $scope,
        string $host,
        int $minimumFillTimeMs,
        int $maximumAgeSeconds,
    ): ?ProtectionEnvelopeClaims {
        return $this->validate($token, $scope, $host, $minimumFillTimeMs, $maximumAgeSeconds);
    }

    private function validate(
        string $token,
        string $scope,
        string $host,
        int $minimumFillTimeMs,
        int $maximumAgeSeconds,
    ): ?ProtectionEnvelopeClaims {
        if (1 > $maximumAgeSeconds || 0 > $minimumFillTimeMs) {
            return null;
        }

        $parts = explode('.', $token);
        if (2 !== count($parts)) {
            return null;
        }
        [$encodedPayload, $encodedSignature] = $parts;
        $signature = self::base64UrlDecode($encodedSignature);
        if (null === $signature || !hash_equals(hash_hmac('sha256', $encodedPayload, $this->secret, true), $signature)) {
            return null;
        }

        $payload = self::base64UrlDecode($encodedPayload);
        if (null === $payload) {
            return null;
        }

        try {
            $data = json_decode($payload, true, 16, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return null;
        }
        if (!is_array($data)) {
            return null;
        }

        $expectedKeys = ['scope', 'host', 'iat', 'exp', 'nonce', 'honeypot'];
        if ($expectedKeys !== array_keys($data)) {
            return null;
        }
        if (
            !is_string($data['scope']) || $scope !== $data['scope']
            || !is_string($data['host']) || strtolower($host) !== $data['host']
            || !is_int($data['iat']) || !is_int($data['exp'])
            || !is_string($data['nonce']) || 1 !== preg_match('/^[a-f0-9]{32}$/D', $data['nonce'])
            || !is_string($data['honeypot']) || 1 !== preg_match('/^hp_[a-f0-9]{24}$/D', $data['honeypot'])
        ) {
            return null;
        }

        $now = $this->timeProvider->nowMilliseconds();
        $effectiveExpiry = min($data['exp'], $data['iat'] + ($maximumAgeSeconds * 1_000));
        if (
            $data['iat'] > $now + self::CLOCK_SKEW_MILLISECONDS
            || $now < $data['iat'] + $minimumFillTimeMs
            || $now >= $effectiveExpiry
        ) {
            return null;
        }

        return new ProtectionEnvelopeClaims(
            $data['scope'],
            $data['host'],
            $data['iat'],
            $data['exp'],
            $data['nonce'],
            $data['honeypot'],
        );
    }

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

    private static function base64UrlDecode(string $value): ?string
    {
        if ('' === $value || 1 !== preg_match('/^[A-Za-z0-9_-]+$/D', $value)) {
            return null;
        }
        $padding = (4 - strlen($value) % 4) % 4;
        $decoded = base64_decode(strtr($value.str_repeat('=', $padding), '-_', '+/'), true);

        return false === $decoded ? null : $decoded;
    }
}
