<?php

declare(strict_types=1);

namespace CpCreation\AltchaBundle\Tests\PublicApi;

use CpCreation\AltchaBundle\Exception\AltchaUnavailableException;
use CpCreation\AltchaBundle\Form\AltchaType;
use CpCreation\AltchaBundle\Form\AltchaValidationGroupsExtension;
use CpCreation\AltchaBundle\Model\ScopePolicy;
use CpCreation\AltchaBundle\Scope\ScopeRegistry;
use CpCreation\AltchaBundle\Security\LocalRateLimiter;
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\Forms;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\Translation\IdentityTranslator;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Validation;

final class AltchaTypeTest extends TestCase
{
    public function testLiveComponentStateContainsAFailClosedHoneypotMirror(): void
    {
        [$form] = $this->createForm();
        $view = $form->createView();

        self::assertArrayHasKey('_honeypot', $view->children);
        self::assertNotSame('', $view->children['_honeypot']->vars['value']);
        self::assertSame(
            $view->vars['full_name'].'[_honeypot]',
            $view->children['_honeypot']->vars['full_name'],
        );
    }

    public function testDisabledRemoteScopeDoesNotRequireAnyProof(): void
    {
        [$form, $client] = $this->createForm();
        $client->policy = new ScopePolicy(
            scope: 'site.contact', enabled: false, version: 3, altchaEnabled: false, profile: null,
            minimumFillTimeMs: 800, maximumEnvelopeAgeSeconds: 900,
            subjectLimitsEnabled: false,
        );

        $form->submit([]);

        self::assertTrue($form->isValid());
        self::assertSame([], $client->verificationRequests);
    }

    public function testEnabledScopeKeepsLocalHoneypotWhenAltchaIsDisabled(): void
    {
        [$form, $client, $time] = $this->createForm();
        $client->policy = new ScopePolicy(
            scope: 'site.contact', enabled: true, version: 3, altchaEnabled: false, profile: null,
            minimumFillTimeMs: 0, maximumEnvelopeAgeSeconds: 900,
            subjectLimitsEnabled: false,
        );
        $view = $form->createView();
        $time->milliseconds += 2_000;

        self::assertTrue($view->vars['altcha_honeypot_enabled']);
        self::assertFalse($view->vars['altcha_widget_enabled']);

        $form->submit([
            '_envelope' => $view->children['_envelope']->vars['value'],
            '_honeypot' => '',
            $view->vars['altcha_honeypot_name'] => 'bot-content',
        ]);

        self::assertFalse($form->isValid());
        self::assertSame([], $client->verificationRequests);
    }

    public function testProtectedScopeFailsClosedWhenDockerIsUnavailable(): void
    {
        [$form, $client] = $this->createForm();
        $client->policy = new AltchaUnavailableException('offline');

        $form->submit([]);

        self::assertFalse($form->isValid());
        self::assertSame('altcha.error.unavailable', $form->getErrors(true)->current()->getMessage());
    }

    public function testOpaqueHoneypotAndSignedTimingEnvelopeAreCheckedBeforeProof(): void
    {
        [$form, $client, $time] = $this->createForm(minimumFillTimeMs: 1_000);
        $view = $form->createView();
        $honeypot = $view->vars['altcha_honeypot_name'];
        $envelope = $view->children['_envelope']->vars['value'];

        self::assertMatchesRegularExpression('/^hp_[a-f0-9]{24}$/', $honeypot);
        self::assertStringNotContainsString('website', $honeypot);

        $form->submit([
            '_payload' => 'valid-proof',
            '_envelope' => $envelope,
            '_honeypot' => '',
            $honeypot => '',
        ]);

        self::assertFalse($form->isValid(), 'An immediate submission must be rejected.');
        self::assertSame('altcha.error.timing', $form->getErrors(true)->current()->getMessage());
        self::assertSame([], $client->verificationRequests, 'PBKDF2 verification must remain the last, expensive step.');

        [$form, $client] = $this->createForm(time: $time, minimumFillTimeMs: 1_000);
        $view = $form->createView();
        $honeypot = $view->vars['altcha_honeypot_name'];
        $envelope = $view->children['_envelope']->vars['value'];
        $time->milliseconds += 1_100;
        $form->submit([
            '_payload' => 'valid-proof',
            '_envelope' => $envelope,
            '_honeypot' => '',
        ]);

        self::assertTrue($form->isValid(), 'A LiveComponent submission carries the fixed mirror, not the opaque raw field.');
        self::assertCount(1, $client->verificationRequests);
    }

    public function testAChangedOrFilledOpaqueHoneypotIsRejectedWithoutVerification(): void
    {
        [$form, $client, $time] = $this->createForm();
        $view = $form->createView();
        $time->milliseconds += 2_000;

        $form->submit([
            '_payload' => 'valid-proof',
            '_envelope' => $view->children['_envelope']->vars['value'],
            '_honeypot' => '',
            $view->vars['altcha_honeypot_name'] => 'https://spam.invalid',
        ]);

        self::assertFalse($form->isValid());
        self::assertSame([], $client->verificationRequests);
    }

    public function testMissingHoneypotMirrorIsRejected(): void
    {
        [$form, $client, $time] = $this->createForm();
        $view = $form->createView();
        $time->milliseconds += 2_000;
        $form->submit([
            '_payload' => 'proof',
            '_envelope' => $view->children['_envelope']->vars['value'],
            $view->vars['altcha_honeypot_name'] => '',
        ]);

        self::assertFalse($form->isValid());
        self::assertSame([], $client->verificationRequests);
    }

    public function testHoneypotRenderingIsInertAndExcludedFromAccessibilityAndAutofill(): void
    {
        [$form] = $this->createForm();
        $view = $form->createView();

        self::assertSame([
            'inert' => 'inert',
            'aria-hidden' => 'true',
            'tabindex' => '-1',
            'autocomplete' => 'off',
        ], $view->vars['altcha_honeypot_attr']);
    }

    public function testRejectedHoneypotShortCircuitsBusinessValidators(): void
    {
        [, , $time, , $factory] = $this->createForm();
        $validatorCalls = 0;
        $form = $factory->createBuilder(FormType::class, null, [
            'constraints' => [new Callback(static function () use (&$validatorCalls): void {
                ++$validatorCalls;
            })],
        ])
            ->add('antibot', AltchaType::class, ['scope' => 'site.contact'])
            ->getForm();
        $view = $form->createView();
        $antibot = $view->children['antibot'];
        $time->milliseconds += 2_000;

        $form->submit(['antibot' => [
            '_payload' => 'proof',
            '_envelope' => $antibot->children['_envelope']->vars['value'],
            '_honeypot' => '',
            $antibot->vars['altcha_honeypot_name'] => 'bot-content',
        ]]);

        self::assertFalse($form->isValid());
        self::assertSame(0, $validatorCalls);
    }

    public function testOnlyExplicitPartialLiveActionsSkipProtection(): void
    {
        [$form, $client, , $request] = $this->createForm(partialLiveActions: ['refreshPreview']);
        $request->attributes->set('_live_component', 'site:ContactForm');
        $request->attributes->set('_live_action', 'refreshPreview');

        $form->submit([]);

        self::assertTrue($form->isValid());
        self::assertSame([], $client->verificationRequests);

        [$form, , , $request] = $this->createForm(partialLiveActions: ['refreshPreview']);
        $request->attributes->set('_live_component', 'site:ContactForm');
        $request->attributes->set('_live_action', 'unknownAction');
        $form->submit([]);
        self::assertFalse($form->isValid(), 'An unknown/final action must fail closed.');
    }

    public function testOnlyExplicitFinalLiveActionsAreExposedToTheFrontendController(): void
    {
        [$form] = $this->createForm(finalLiveActions: ['submitContact', 'submitRegistration']);

        $view = $form->createView();

        self::assertSame(
            '["submitContact","submitRegistration"]',
            $view->vars['attr']['data-cpcreation-altcha-final-live-actions-value'] ?? null,
        );
    }

    public function testOnlyConfiguredFinalActionsConsumeProofDuringLiveComponentRequests(): void
    {
        foreach (['get', '_batch'] as $liveAction) {
            [$form, $client, , $request] = $this->createForm(
                finalLiveActions: ['submitRegisterConfirm'],
            );
            $request->attributes->set('_live_component', 'security:RegisterConfirmForm');
            if (null !== $liveAction) {
                $request->attributes->set('_live_action', $liveAction);
            }
            if ('get' === $liveAction) {
                $request->attributes->set('_component_default_action', true);
                $request->attributes->set('_controller', BatchLiveComponentFixture::class.'::__invoke');
            }
            if ('_batch' === $liveAction) {
                $request->attributes->set('_is_live_batch_action', true);
                $request->attributes->set('_controller', 'ux.live_component.batch_action_controller');
            }

            $form->submit([]);

            self::assertTrue($form->isValid());
            self::assertSame([], $client->verificationRequests);
        }

        [$form, $client, , $request] = $this->createForm(
            partialLiveActions: ['validatePasswords'],
            finalLiveActions: ['submitRegisterConfirm'],
        );
        $request->attributes->set('_live_component', 'security:RegisterConfirmForm');
        $request->attributes->set('_live_action', 'validatePasswords');

        $form->submit([]);

        self::assertTrue($form->isValid());
        self::assertSame([], $client->verificationRequests);

        [$form, $client, , $request] = $this->createForm(
            partialLiveActions: ['validatePasswords'],
            finalLiveActions: ['submitRegisterConfirm'],
        );
        $request->attributes->set('_live_component', 'security:RegisterConfirmForm');
        $request->attributes->set('_live_action', 'refreshPreview');

        $form->submit([]);

        self::assertFalse($form->isValid(), 'An explicit unlisted Live action must fail closed.');
        self::assertSame([], $client->verificationRequests);

        [$form, $client, $time, $request] = $this->createForm(
            finalLiveActions: ['submitRegisterConfirm'],
        );
        $request->attributes->set('_live_component', 'security:RegisterConfirmForm');
        $request->attributes->set('_live_action', 'submitRegisterConfirm');
        $view = $form->createView();
        $time->milliseconds += 2_000;

        $form->submit([
            '_payload' => 'valid-proof',
            '_envelope' => $view->children['_envelope']->vars['value'],
            '_honeypot' => '',
        ]);

        self::assertTrue($form->isValid());
        self::assertCount(1, $client->verificationRequests);
    }

    public function testBatchLiveFinalActionConsumesProofWhenTheActionIsOnlyInTheControllerAttribute(): void
    {
        [$form, $client, $time, $request] = $this->createForm(
            finalLiveActions: ['submitRegisterConfirm'],
        );
        $this->configureBatchLiveSubRequest($request, 'submitRegisterConfirm');
        $view = $form->createView();
        $time->milliseconds += 2_000;

        $form->submit([
            '_payload' => 'valid-proof',
            '_envelope' => $view->children['_envelope']->vars['value'],
            '_honeypot' => '',
        ]);

        self::assertTrue($form->isValid(), 'The structurally valid final Live submission must pass.');
        self::assertCount(
            1,
            $client->verificationRequests,
            'A BatchActionController sub-request must not bypass proof verification when _live_action is absent.',
        );
    }

    public function testBatchLivePartialActionSkipsProtectionWhenTheActionIsOnlyInTheControllerAttribute(): void
    {
        [$form, $client, , $request] = $this->createForm(
            partialLiveActions: ['validatePasswords'],
            finalLiveActions: ['submitRegisterConfirm'],
        );
        $this->configureBatchLiveSubRequest($request, 'validatePasswords');

        $form->submit([]);

        self::assertTrue($form->isValid());
        self::assertSame([], $client->verificationRequests);
    }

    public function testPassiveLiveServerMarkersSkipProtectionWithoutALiveActionAttribute(): void
    {
        foreach ([
            '_component_default_action' => BatchLiveComponentFixture::class.'::__invoke',
            '_is_live_batch_action' => 'ux.live_component.batch_action_controller',
        ] as $marker => $controller) {
            [$form, $client, , $request] = $this->createForm(
                partialLiveActions: ['validatePasswords'],
                finalLiveActions: ['submitRegisterConfirm'],
            );
            $request->attributes->set('_live_component', BatchLiveComponentFixture::class);
            $request->attributes->set('_controller', $controller);
            $request->attributes->set($marker, true);

            $form->submit([]);

            self::assertTrue($form->isValid(), sprintf('Passive marker %s must skip protection.', $marker));
            self::assertSame([], $client->verificationRequests);
        }
    }

    public function testPassiveMarkerContradictingAnExplicitFinalActionFailsClosed(): void
    {
        foreach ([
            '_component_default_action' => BatchLiveComponentFixture::class.'::__invoke',
            '_is_live_batch_action' => 'ux.live_component.batch_action_controller',
        ] as $marker => $controller) {
            [$form, $client, , $request] = $this->createForm(
                partialLiveActions: ['validatePasswords'],
                finalLiveActions: ['submitRegisterConfirm'],
            );
            $request->attributes->set('_live_component', BatchLiveComponentFixture::class);
            $request->attributes->set('_live_action', 'submitRegisterConfirm');
            $request->attributes->set('_controller', $controller);
            $request->attributes->set($marker, true);

            $form->submit([]);

            self::assertFalse($form->isValid(), sprintf('Contradictory passive marker %s must fail closed.', $marker));
            self::assertSame([], $client->verificationRequests);
        }
    }

    public function testPartialActionWithMalformedBatchAttributesFailsClosed(): void
    {
        $serviceId = BatchLiveComponentFixture::class;
        foreach ([
            [[$serviceId, 123], []],
            [[$serviceId, 'validatePasswords'], 'not-an-array'],
            [['another-service', 'validatePasswords'], []],
        ] as [$controller, $actionArguments]) {
            [$form, $client, , $request] = $this->createForm(
                partialLiveActions: ['validatePasswords'],
                finalLiveActions: ['submitRegisterConfirm'],
            );
            $request->setMethod('POST');
            $request->attributes->set('_live_component', $serviceId);
            $request->attributes->set('_live_action', 'validatePasswords');
            $request->attributes->set('_controller', $controller);
            $request->attributes->set('_component_action_args', $actionArguments);

            $form->submit([]);

            self::assertFalse($form->isValid(), 'A malformed BatchActionController structure must fail closed.');
            self::assertSame([], $client->verificationRequests);
        }
    }

    public function testLiveContextWithoutActionControllerOrPassiveMarkerFailsClosed(): void
    {
        [$form, $client, , $request] = $this->createForm(
            partialLiveActions: ['validatePasswords'],
            finalLiveActions: ['submitRegisterConfirm'],
        );
        $request->attributes->set('_live_component', BatchLiveComponentFixture::class);

        $form->submit([]);

        self::assertFalse($form->isValid());
        self::assertSame([], $client->verificationRequests);
    }

    public function testMalformedBatchControllerFailsClosedForALiveSubmission(): void
    {
        $serviceId = BatchLiveComponentFixture::class;
        $malformedControllers = [
            'not-an-array',
            [$serviceId],
            [$serviceId, 123],
            [123, 'submitRegisterConfirm'],
            [$serviceId, 'submitRegisterConfirm', 'unexpected'],
            ['another-service', 'submitRegisterConfirm'],
        ];

        foreach ($malformedControllers as $controller) {
            [$form, $client, , $request] = $this->createForm(
                partialLiveActions: ['validatePasswords'],
                finalLiveActions: ['submitRegisterConfirm'],
            );
            $this->configureBatchLiveSubRequest($request, 'submitRegisterConfirm');
            $request->attributes->set('_controller', $controller);

            $form->submit([]);

            self::assertFalse($form->isValid(), sprintf('Controller %s must fail closed.', json_encode($controller)));
            self::assertSame([], $client->verificationRequests);
        }
    }

    public function testDivergingExplicitAndBatchLiveActionsFailClosed(): void
    {
        [$form, $client, , $request] = $this->createForm(
            partialLiveActions: ['validatePasswords'],
            finalLiveActions: ['submitRegisterConfirm'],
        );
        $serviceId = $this->configureBatchLiveSubRequest($request, 'submitRegisterConfirm');
        $request->attributes->set('_live_action', 'validatePasswords');

        $form->submit([]);

        self::assertFalse($form->isValid());
        self::assertSame([], $client->verificationRequests);
    }

    public function testFinalLiveSubmissionWithMissingProofReportsInvalidBeforeEnvelopeTiming(): void
    {
        [$form, $client, , $request] = $this->createForm(
            minimumFillTimeMs: 1_000,
            finalLiveActions: ['submitRegisterConfirm'],
        );
        $request->attributes->set('_live_component', 'security:RegisterConfirmForm');
        $request->attributes->set('_live_action', 'submitRegisterConfirm');
        $view = $form->createView();

        $form->submit([
            '_payload' => '',
            '_envelope' => $view->children['_envelope']->vars['value'],
            '_honeypot' => '',
        ]);

        self::assertFalse($form->isValid());
        self::assertSame('altcha.error.invalid', $form->getErrors(true)->current()->getMessage());
        self::assertSame([], $client->verificationRequests);
    }

    public function testFinalLiveSubmissionWithProofStillReportsEnvelopeTiming(): void
    {
        [$form, $client, , $request] = $this->createForm(
            minimumFillTimeMs: 1_000,
            finalLiveActions: ['submitRegisterConfirm'],
        );
        $request->attributes->set('_live_component', 'security:RegisterConfirmForm');
        $request->attributes->set('_live_action', 'submitRegisterConfirm');
        $view = $form->createView();

        $form->submit([
            '_payload' => 'valid-proof',
            '_envelope' => $view->children['_envelope']->vars['value'],
            '_honeypot' => '',
        ]);

        self::assertFalse($form->isValid());
        self::assertSame('altcha.error.timing', $form->getErrors(true)->current()->getMessage());
        self::assertSame([], $client->verificationRequests);
    }

    public function testClassicHttpSubmissionRemainsProtectedWhenFinalLiveActionsAreConfigured(): void
    {
        [$form, $client, , $request] = $this->createForm(finalLiveActions: ['submitRegisterConfirm']);
        $request->attributes->set('_controller', ['security:RegisterConfirmForm', 'submitRegisterConfirm']);

        $form->submit([]);

        self::assertFalse($form->isValid());
        self::assertSame([], $client->verificationRequests);
    }

    public function testFinalAndPartialLiveActionsCannotOverlap(): void
    {
        $this->expectException(InvalidOptionsException::class);
        $this->expectExceptionMessage('submitRegisterConfirm');

        $this->createForm(
            partialLiveActions: ['submitRegisterConfirm'],
            finalLiveActions: ['submitRegisterConfirm'],
        );
    }

    public function testLocalSubmissionQuotaStopsBeforeDockerAndProofVerification(): void
    {
        $limiter = LocalRateLimiterFactory::create(submissionLimit: 1);
        $limiter->consumeSubmission('site.contact', '203.0.113.8');
        [$form, $client] = $this->createForm(localRateLimiter: $limiter);
        $view = $form->createView();
        $policyCallsAfterRendering = count($client->policyRequests);

        $form->submit([
            '_payload' => 'proof',
            '_envelope' => $view->children['_envelope']->vars['value'],
            $view->vars['altcha_honeypot_name'] => '',
        ]);

        self::assertFalse($form->isValid());
        self::assertSame($policyCallsAfterRendering, count($client->policyRequests));
        self::assertSame([], $client->verificationRequests);
        self::assertSame('altcha.error.rate_limited', $form->getErrors(true)->current()->getMessage());
    }

    public function testOversizedEnvelopeIsRejectedBeforePolicyResolutionAndProofVerification(): void
    {
        [$form, $client] = $this->createForm();

        $form->submit([
            '_payload' => 'proof',
            '_envelope' => str_repeat('x', 4_097),
        ]);

        self::assertFalse($form->isValid());
        self::assertSame([], $client->policyRequests);
        self::assertSame([], $client->verificationRequests);
        self::assertSame('altcha.error.invalid', $form->getErrors(true)->current()->getMessage());
    }

    /** @return array{\Symfony\Component\Form\FormInterface, FakeAltchaServiceClient, MutableTimeProvider, Request, \Symfony\Component\Form\FormFactoryInterface} */
    private function createForm(
        ?MutableTimeProvider $time = null,
        int $minimumFillTimeMs = 0,
        array $partialLiveActions = [],
        array $finalLiveActions = [],
        ?LocalRateLimiter $localRateLimiter = null,
    ): array {
        $client = new FakeAltchaServiceClient();
        $client->policy = new ScopePolicy(
            scope: 'site.contact', enabled: true, version: 7, altchaEnabled: true, profile: 'pbkdf2-default',
            minimumFillTimeMs: $minimumFillTimeMs, maximumEnvelopeAgeSeconds: 900,
            subjectLimitsEnabled: false,
        );
        $time ??= new MutableTimeProvider();
        $request = Request::create('https://example.test/contact');
        $request->server->set('REMOTE_ADDR', '203.0.113.8');
        $requestStack = new RequestStack();
        $requestStack->push($request);
        $manager = new ProtectionEnvelopeManager('a-local-test-secret-with-enough-entropy', $time, 900);
        $state = new ProtectionState();
        $type = new AltchaType(
            $client,
            $manager,
            $state,
            new ScopeRegistry(['site.contact' => 'Contact']),
            $localRateLimiter ?? LocalRateLimiterFactory::create(),
            $requestStack,
            new IdentityTranslator(),
            null,
            'subject-secret',
        );
        $factory = Forms::createFormFactoryBuilder()
            ->addExtension(new ValidatorExtension(Validation::createValidator()))
            ->addExtension(new PreloadedExtension([$type], [
                FormType::class => [new AltchaValidationGroupsExtension($state)],
            ]))
            ->getFormFactory();

        return [
            $factory->create(AltchaType::class, null, [
                'scope' => 'site.contact',
                'partial_live_actions' => $partialLiveActions,
                'final_live_actions' => $finalLiveActions,
            ]),
            $client,
            $time,
            $request,
            $factory,
        ];
    }

    private function configureBatchLiveSubRequest(Request $request, string $action): string
    {
        $serviceId = BatchLiveComponentFixture::class;
        $request->setMethod('POST');
        $request->attributes->set('_live_component', $serviceId);
        $request->attributes->set('_controller', [$serviceId, $action]);
        $request->attributes->set('_component_action_args', []);
        $request->attributes->set('_mounted_component', new BatchLiveComponentFixture());

        return $serviceId;
    }
}

final class BatchLiveComponentFixture
{
}
