<?php

declare(strict_types=1);

namespace CpCreation\AltchaBundle\Tests\PublicApi;

use CpCreation\AltchaBundle\Form\AltchaType;
use CpCreation\AltchaBundle\Form\AltchaValidationGroupsExtension;
use CpCreation\AltchaBundle\Model\ScopePolicy;
use CpCreation\AltchaBundle\Model\VerificationResult;
use CpCreation\AltchaBundle\Scope\ScopeRegistry;
use CpCreation\AltchaBundle\Security\ProtectionEnvelopeManager;
use CpCreation\AltchaBundle\Security\ProtectionState;
use CpCreation\AltchaBundle\Tests\Double\FakeAltchaServiceClient;
use CpCreation\AltchaBundle\Tests\Double\LocalRateLimiterFactory;
use CpCreation\AltchaBundle\Tests\Double\MutableTimeProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\Forms;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Translation\IdentityTranslator;
use Symfony\Component\Validator\Validation;

final class AltchaRetryEnvelopeLifecycleTest extends TestCase
{
    public function testWidgetMinimumDurationMatchesTheSignedPolicy(): void
    {
        foreach ([0 => 500, 350 => 500, 1_200 => 1_200, 2_000 => 2_000] as $policyDuration => $widgetDuration) {
            [$form] = $this->createLiveForm(minimumFillTimeMs: $policyDuration);
            $view = $form->createView();

            self::assertSame(
                $widgetDuration,
                $view->vars['attr']['data-cpcreation-altcha-minimum-duration-value'] ?? null,
                'ALTCHA must not turn green before the Docker challenge can pass its signed minFillTimeMs.',
            );
        }
    }

    public function testMissingProofKeepsTheStillValidEnvelopeForTheNextLiveAttempt(): void
    {
        [$form, $time] = $this->createLiveForm();
        [$initialToken, $initialHoneypot] = $this->renderProtection($form);
        $time->milliseconds += 1_500;

        $form->submit($this->submission($initialToken));

        self::assertFalse($form->isValid());
        self::assertSame('altcha.error.invalid', $form->getErrors(true)->current()->getMessage());
        self::assertSame(
            [$initialToken, $initialHoneypot],
            $this->renderProtection($form),
            'A missing proof did not consume the challenge: the Live retry must keep its original fill age.',
        );
    }

    public function testPrematureSubmissionKeepsTheEnvelopeUntilMinimumFillTimeElapses(): void
    {
        [$form] = $this->createLiveForm();
        [$initialToken, $initialHoneypot] = $this->renderProtection($form);

        $form->submit($this->submission($initialToken, 'valid-proof'));

        self::assertFalse($form->isValid());
        self::assertSame('altcha.error.timing', $form->getErrors(true)->current()->getMessage());
        self::assertSame(
            [$initialToken, $initialHoneypot],
            $this->renderProtection($form),
            'A timing rejection must not restart the minimum-fill clock on every Live render.',
        );
    }

    public function testPassiveAndPartialLiveRendersKeepAValidSubmittedEnvelope(): void
    {
        foreach ([null, 'validatePasswords'] as $liveAction) {
            [$form] = $this->createLiveForm(liveAction: $liveAction);
            [$initialToken, $initialHoneypot] = $this->renderProtection($form);

            $form->submit($this->submission($initialToken));

            self::assertTrue($form->isValid());
            self::assertSame(
                [$initialToken, $initialHoneypot],
                $this->renderProtection($form),
                sprintf('The %s Live render must not restart the envelope clock.', $liveAction ?? 'passive'),
            );
        }
    }

    public function testTamperedEnvelopeIsNeverReused(): void
    {
        [$form] = $this->createLiveForm();
        [$initialToken, $initialHoneypot] = $this->renderProtection($form);

        $form->submit($this->submission($initialToken.'x', 'valid-proof'));

        self::assertFalse($form->isValid());
        [$retryToken, $retryHoneypot] = $this->renderProtection($form);
        self::assertNotSame($initialToken, $retryToken);
        self::assertNotSame($initialHoneypot, $retryHoneypot);
    }

    public function testExpiredEnvelopeIsNeverReused(): void
    {
        [$form, $time] = $this->createLiveForm(maximumEnvelopeAgeSeconds: 2);
        [$initialToken, $initialHoneypot] = $this->renderProtection($form);
        $time->milliseconds += 2_000;

        $form->submit($this->submission($initialToken, 'valid-proof'));

        self::assertFalse($form->isValid());
        [$retryToken, $retryHoneypot] = $this->renderProtection($form);
        self::assertNotSame($initialToken, $retryToken);
        self::assertNotSame($initialHoneypot, $retryHoneypot);
    }

    public function testConsumedProofGetsAFreshEnvelope(): void
    {
        [$form, $time] = $this->createLiveForm();
        [$initialToken, $initialHoneypot] = $this->renderProtection($form);
        $time->milliseconds += 1_500;

        $form->submit($this->submission($initialToken, 'valid-proof'));

        self::assertTrue($form->isValid());
        [$nextToken, $nextHoneypot] = $this->renderProtection($form);
        self::assertNotSame($initialToken, $nextToken);
        self::assertNotSame($initialHoneypot, $nextHoneypot);
    }

    public function testEnvelopeIsMarkedConsumedOnlyAfterBothVerificationFlagsSucceed(): void
    {
        foreach ([[false, true], [true, false]] as [$verified, $consumed]) {
            [$form, $time, $client] = $this->createLiveForm();
            $client->verification = new VerificationResult($verified, $consumed);
            [$initialToken, $initialHoneypot] = $this->renderProtection($form);
            $time->milliseconds += 1_500;

            $form->submit($this->submission($initialToken, 'valid-proof'));

            self::assertFalse($form->isValid());
            self::assertSame(
                [$initialToken, $initialHoneypot],
                $this->renderProtection($form),
            );
        }
    }

    /** @return array{FormInterface, MutableTimeProvider, FakeAltchaServiceClient} */
    private function createLiveForm(
        int $minimumFillTimeMs = 1_200,
        int $maximumEnvelopeAgeSeconds = 900,
        ?string $liveAction = 'submitRegisterConfirm',
    ): array {
        $client = new FakeAltchaServiceClient();
        $client->policy = new ScopePolicy(
            scope: 'security.registration_confirm',
            enabled: true,
            version: 7,
            altchaEnabled: true,
            profile: 'pbkdf2-default',
            minimumFillTimeMs: $minimumFillTimeMs,
            maximumEnvelopeAgeSeconds: $maximumEnvelopeAgeSeconds,
            subjectLimitsEnabled: false,
        );
        $time = new MutableTimeProvider();
        $request = Request::create('https://example.test/connexion/creation-compte');
        $request->server->set('REMOTE_ADDR', '203.0.113.8');
        $request->attributes->set('_live_component', 'security:RegisterConfirmForm');
        if (null !== $liveAction) {
            $request->attributes->set('_live_action', $liveAction);
        } else {
            $request->attributes->set('_component_default_action', true);
            $request->attributes->set('_controller', 'security:RegisterConfirmForm::__invoke');
        }
        $requestStack = new RequestStack();
        $requestStack->push($request);
        $protectionState = new ProtectionState();
        $type = new AltchaType(
            $client,
            new ProtectionEnvelopeManager(
                'a-local-test-secret-with-enough-entropy',
                $time,
                $maximumEnvelopeAgeSeconds,
            ),
            $protectionState,
            new ScopeRegistry(['security.registration_confirm' => 'Confirmation inscription']),
            LocalRateLimiterFactory::create(),
            $requestStack,
            new IdentityTranslator(),
            null,
            'subject-secret',
        );
        $factory = Forms::createFormFactoryBuilder()
            ->addExtension(new ValidatorExtension(Validation::createValidator()))
            ->addExtension(new PreloadedExtension([$type], [
                FormType::class => [new AltchaValidationGroupsExtension($protectionState)],
            ]))
            ->getFormFactory();

        self::assertInstanceOf(FormFactoryInterface::class, $factory);

        return [
            $factory->create(AltchaType::class, null, [
                'scope' => 'security.registration_confirm',
                'partial_live_actions' => ['validatePasswords'],
                'final_live_actions' => ['submitRegisterConfirm'],
            ]),
            $time,
            $client,
        ];
    }

    /** @return array{string, string} */
    private function renderProtection(FormInterface $form): array
    {
        $view = $form->createView();

        return [
            (string) $view->children['_envelope']->vars['value'],
            (string) $view->vars['altcha_honeypot_name'],
        ];
    }

    /** @return array{_payload: string, _envelope: string, _honeypot: string} */
    private function submission(string $envelope, string $payload = ''): array
    {
        return [
            '_payload' => $payload,
            '_envelope' => $envelope,
            '_honeypot' => '',
        ];
    }
}
