<?php

declare(strict_types=1);

namespace CpCreation\AltchaBundle\Tests\PublicApi;

use CpCreation\AltchaBundle\Exception\AltchaFailureReason;
use CpCreation\AltchaBundle\Exception\AltchaServiceRejectedException;
use CpCreation\AltchaBundle\Http\AltchaServiceClient;
use CpCreation\AltchaBundle\Model\ScopeDefinition;
use CpCreation\AltchaBundle\Model\ScopeManifest;
use CpCreation\AltchaBundle\Model\VerificationRequest;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class AltchaServiceClientTest extends TestCase
{
    public function testRuntimeCallsUseRuntimeBearerAndMapVersionedPolicies(): void
    {
        $requests = [];
        $http = new MockHttpClient(static function (string $method, string $url, array $options) use (&$requests): MockResponse {
            $requests[] = [$method, $url, $options];

            return new MockResponse(json_encode([
                'siteId' => 'bandock-preprod',
                'policyVersion' => 12,
                'securityEpoch' => 3,
                'policies' => [[
                    'scope' => 'site.contact',
                    'enabled' => true,
                    'altchaEnabled' => true,
                    'profile' => 'pbkdf2-mobile',
                    'minFillTimeMs' => 900,
                    'maxEnvelopeAgeSeconds' => 600,
                    'subjectLimitsEnabled' => false,
                ]],
            ], JSON_THROW_ON_ERROR));
        });
        $client = new AltchaServiceClient($http, 'http://127.0.0.1:18081/', 'runtime-key', 'sync-key');

        $policy = $client->resolvePolicy('site.contact', 'example.test');

        self::assertSame(12, $policy->version);
        self::assertSame(900, $policy->minimumFillTimeMs);
        self::assertTrue($policy->altchaEnabled);
        self::assertSame('pbkdf2-mobile', $policy->profile);
        self::assertSame('POST', $requests[0][0]);
        self::assertSame('http://127.0.0.1:18081/v1/policies/resolve', $requests[0][1]);
        self::assertStringContainsString('Authorization: Bearer runtime-key', implode("\n", $requests[0][2]['headers']));
        self::assertSame(['site.contact'], json_decode($requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR)['scopes']);
    }

    public function testProofIsVerifiedAndConsumedOnlyThroughTheInternalClient(): void
    {
        $body = null;
        $http = new MockHttpClient(static function (string $method, string $url, array $options) use (&$body): MockResponse {
            $body = json_decode($options['body'], true, 512, JSON_THROW_ON_ERROR);

            return new MockResponse('{"verified":true,"consumed":true}');
        });
        $client = new AltchaServiceClient($http, 'http://127.0.0.1:18081', 'runtime-key', 'sync-key');

        $result = $client->verifyAndConsume(new VerificationRequest(
            payload: 'proof',
            scope: 'site.contact',
            host: 'example.test',
            clientIp: '203.0.113.9',
            formToken: 'opaque-signed-form-token',
        ));

        self::assertTrue($result->verified);
        self::assertSame('site.contact', $body['scope']);
        self::assertArrayNotHasKey('subjectHash', $body);
    }

    public function testDisabledAltchaPolicyAcceptsANullProfile(): void
    {
        $http = new MockHttpClient(new MockResponse(json_encode([
            'siteId' => 'bandock-preprod',
            'policyVersion' => 2,
            'securityEpoch' => 4,
            'policies' => [[
                'scope' => 'site.contact',
                'enabled' => true,
                'altchaEnabled' => false,
                'profile' => null,
                'minFillTimeMs' => 400,
                'maxEnvelopeAgeSeconds' => 600,
                'subjectLimitsEnabled' => false,
            ]],
        ], JSON_THROW_ON_ERROR)));
        $client = new AltchaServiceClient($http, 'http://127.0.0.1:18081', 'runtime-key', 'sync-key');

        $policy = $client->resolvePolicy('site.contact', 'example.test');

        self::assertFalse($policy->altchaEnabled);
        self::assertNull($policy->profile);
    }

    public function testScopeSynchronizationUsesTheSeparateSyncBearerAndDryRunFlag(): void
    {
        $request = null;
        $http = new MockHttpClient(static function (string $method, string $url, array $options) use (&$request): MockResponse {
            $request = [$url, $options, $method];

            return new MockResponse('{"dryRun":true,"created":["custom.form"],"updated":[],"unchanged":[]}');
        });
        $client = new AltchaServiceClient($http, 'http://127.0.0.1:18081', 'runtime-key', 'sync-key');

        $result = $client->synchronizeScopes(
            new ScopeManifest([new ScopeDefinition('custom.form', 'Custom')]),
            true,
        );

        self::assertTrue($result->dryRun);
        self::assertSame('PUT', $request[2]);
        self::assertSame('http://127.0.0.1:18081/v1/scopes', $request[0]);
        self::assertStringContainsString('Authorization: ScopeSync sync-key', implode("\n", $request[1]['headers']));
        self::assertTrue(json_decode($request[1]['body'], true, 512, JSON_THROW_ON_ERROR)['dryRun']);
    }

    #[DataProvider('rejectedResponses')]
    public function testNonSuccessfulVerificationResponsesRemainTyped(
        int $status,
        string $body,
        AltchaFailureReason $reason,
        ?string $retryAfter = null,
    ): void {
        $http = new MockHttpClient(new MockResponse($body, [
            'http_code' => $status,
            'response_headers' => null === $retryAfter ? [] : ['Retry-After: '.$retryAfter],
        ]));
        $client = new AltchaServiceClient($http, 'http://127.0.0.1:18081', 'runtime-key', 'sync-key');

        try {
            $client->verifyAndConsume(new VerificationRequest(
                payload: 'proof', scope: 'site.contact', host: 'example.test',
                clientIp: '203.0.113.9', formToken: 'form-token',
            ));
            self::fail('A rejected response must throw its typed failure.');
        } catch (AltchaServiceRejectedException $exception) {
            self::assertSame($reason, $exception->reason);
            self::assertSame($status, $exception->statusCode);
            self::assertSame(null === $retryAfter ? null : (int) $retryAfter, $exception->retryAfter);
        }
    }

    public static function rejectedResponses(): iterable
    {
        yield 'replay' => [409, '{"error":"replay"}', AltchaFailureReason::REPLAY];
        yield 'policy changed' => [409, '{"error":"policy_changed"}', AltchaFailureReason::POLICY_CHANGED];
        yield 'expired' => [410, '{"error":"expired"}', AltchaFailureReason::EXPIRED];
        yield 'invalid solution' => [422, '{"error":"invalid_solution"}', AltchaFailureReason::INVALID_SOLUTION];
        yield 'rate limited' => [429, '{"error":"rate_limited"}', AltchaFailureReason::RATE_LIMITED, '37'];
        yield 'unavailable' => [503, '{"error":"unavailable"}', AltchaFailureReason::UNAVAILABLE];
        yield 'forbidden context' => [403, '{"error":"unknown_context"}', AltchaFailureReason::FORBIDDEN_CONTEXT];
    }
}
