<?php

declare(strict_types=1);

namespace CpCreation\AltchaBundle\Form;

use CpCreation\AltchaBundle\Contract\AltchaServiceClientInterface;
use CpCreation\AltchaBundle\Exception\AltchaFailureReason;
use CpCreation\AltchaBundle\Exception\AltchaServiceRejectedException;
use CpCreation\AltchaBundle\Model\ProtectionEnvelope;
use CpCreation\AltchaBundle\Model\ScopePolicy;
use CpCreation\AltchaBundle\Model\VerificationRequest;
use CpCreation\AltchaBundle\Scope\ScopeRegistry;
use CpCreation\AltchaBundle\Security\ClientIdentity;
use CpCreation\AltchaBundle\Security\LocalRateLimiter;
use CpCreation\AltchaBundle\Security\ProtectionEnvelopeManager;
use CpCreation\AltchaBundle\Security\ProtectionState;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

final class AltchaType extends AbstractType
{
    private const ALTCHA_DEFAULT_MINIMUM_DURATION_MS = 500;
    private const HONEYPOT_UNINITIALIZED = 'uninitialized';
    private const MAX_FORM_TOKEN_BYTES = 4_096;
    private const MAX_PAYLOAD_BYTES = 65_536;

    public function __construct(
        private readonly AltchaServiceClientInterface $client,
        private readonly ProtectionEnvelopeManager $envelopeManager,
        private readonly ProtectionState $protectionState,
        private readonly ScopeRegistry $scopeRegistry,
        private readonly LocalRateLimiter $localRateLimiter,
        private readonly RequestStack $requestStack,
        private readonly TranslatorInterface $translator,
        private readonly ?UrlGeneratorInterface $urlGenerator = null,
        private readonly string $subjectSecret = '',
        private readonly ClientIdentity $clientIdentity = new ClientIdentity(),
    ) {
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('_payload', HiddenType::class, ['required' => false])
            ->add('_envelope', HiddenType::class, ['required' => false])
            ->add('_honeypot', HiddenType::class, [
                'data' => self::HONEYPOT_UNINITIALIZED,
                'required' => false,
            ])
            ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options): void {
                $this->protectSubmission($event, $options);
            }, 4_096);
    }

    public function finishView(FormView $view, FormInterface $form, array $options): void
    {
        $request = $this->currentRequest();
        $host = null !== $request ? $request->getHost() : 'unavailable.invalid';
        $scope = $options['scope'];
        $policy = $this->policyForRendering($scope, $host);
        $envelope = $this->reusableEnvelope($form, $scope, $host, $policy)
            ?? $this->envelopeManager->issue($scope, $host);

        $view->children['_envelope']->vars['value'] = $envelope->token;
        $view->vars['altcha_honeypot_name'] = $envelope->honeypotName;
        $view->vars['altcha_honeypot_full_name'] = $view->vars['full_name'].'['.$envelope->honeypotName.']';
        $view->vars['altcha_honeypot_attr'] = [
            'inert' => 'inert',
            'aria-hidden' => 'true',
            'tabindex' => '-1',
            'autocomplete' => 'off',
        ];
        $view->vars['altcha_honeypot_enabled'] = null === $policy || $policy->enabled;
        $view->vars['altcha_widget_enabled'] = null === $policy || ($policy->enabled && $policy->altchaEnabled);
        $view->vars['altcha_profile'] = $policy?->profile;
        $view->vars['altcha_challenge_url'] = $this->challengeUrl($scope, $envelope->token);
        $attributes = $view->vars['attr'] ?? [];
        $attributes['data-action'] = trim(($attributes['data-action'] ?? '').' '
            .'cpcreation-altcha:reset@window->cpcreation-altcha#reset '
            .'viti:captcha-reset@window->cpcreation-altcha#reset '
            .'turbo:before-cache@document->cpcreation-altcha#reset');
        $view->vars['attr'] = array_replace($attributes, [
            'data-controller' => 'cpcreation-altcha',
            'data-cpcreation-altcha-final-live-actions-value' => json_encode(
                array_values(array_unique($options['final_live_actions'])),
                \JSON_THROW_ON_ERROR,
            ),
            'data-cpcreation-altcha-minimum-duration-value' => $this->widgetMinimumDuration($policy),
            'data-cpcreation-altcha-input-id-value' => $view->children['_payload']->vars['id'],
            'data-cpcreation-altcha-honeypot-input-id-value' => $view->children['_honeypot']->vars['id'],
        ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'mapped' => false,
            'required' => false,
            'scope' => null,
            'final_live_actions' => [],
            'partial_live_actions' => [],
            'subject_path' => null,
            'subject_limits_enabled' => false,
            'error_bubbling' => false,
        ]);
        $resolver->setRequired('scope');
        $resolver->setAllowedTypes('scope', 'string');
        $resolver->setAllowedTypes('final_live_actions', 'string[]');
        $resolver->setAllowedTypes('partial_live_actions', 'string[]');
        $resolver->setAllowedTypes('subject_path', ['null', 'string']);
        $resolver->setAllowedTypes('subject_limits_enabled', 'bool');
        $resolver->setAllowedValues('scope', function (string $scope): bool {
            $this->scopeRegistry->assertDeclared($scope);

            return true;
        });
        $resolver->setAllowedValues('final_live_actions', static function (array $actions): bool {
            return [] === array_filter($actions, static fn (string $action): bool => '' === trim($action));
        });
        $resolver->setAllowedValues('partial_live_actions', static function (array $actions): bool {
            return [] === array_filter($actions, static fn (string $action): bool => '' === trim($action));
        });
        $resolver->setNormalizer('final_live_actions', static function (Options $options, array $actions): array {
            $actions = array_values(array_unique($actions));
            $overlap = array_values(array_intersect($actions, $options['partial_live_actions']));
            if ([] !== $overlap) {
                throw new InvalidOptionsException(sprintf('Live actions cannot be both final and partial: %s.', implode(', ', $overlap)));
            }

            return $actions;
        });
    }

    public function getBlockPrefix(): string
    {
        return 'cp_creation_altcha';
    }

    /** @param array<string, mixed> $options */
    private function protectSubmission(FormEvent $event, array $options): void
    {
        $submitted = is_array($event->getData()) ? $event->getData() : [];
        $formToken = $submitted['_envelope'] ?? null;
        if (is_string($formToken) && self::MAX_FORM_TOKEN_BYTES < strlen($formToken)) {
            $this->reject($event->getForm(), 'altcha.error.invalid');
            unset($submitted['_envelope']);
            $event->setData($this->knownFields($submitted));

            return;
        }

        $request = $this->currentRequest();
        if (
            null !== $request
            && $this->shouldSkipLiveProtection(
                $request,
                $options['partial_live_actions'],
                $options['final_live_actions'],
            )
        ) {
            $event->setData($this->knownFields($event->getData()));

            return;
        }

        $form = $event->getForm();
        if (null === $request) {
            $this->reject($form, 'altcha.error.unavailable');
            $event->setData($this->knownFields($event->getData()));

            return;
        }

        $clientIp = $this->clientIdentity->normalizeIp($request->getClientIp());
        $localLimit = $this->localRateLimiter->consumeSubmission($options['scope'], $clientIp);
        if (!$localLimit->accepted) {
            $this->reject($form, 'altcha.error.rate_limited');
            $event->setData($this->knownFields($event->getData()));

            return;
        }

        try {
            $policy = $this->client->resolvePolicy($options['scope'], $request->getHost());
        } catch (AltchaServiceRejectedException $exception) {
            $this->reject($form, $this->messageForFailure($exception->reason));
            $event->setData($this->knownFields($event->getData()));

            return;
        } catch (\Throwable) {
            $this->reject($form, 'altcha.error.unavailable');
            $event->setData($this->knownFields($event->getData()));

            return;
        }

        $event->setData($this->knownFields($submitted));
        if (!$policy->enabled) {
            return;
        }

        $payload = $submitted['_payload'] ?? null;
        if (
            $policy->altchaEnabled
            && (!is_string($payload) || '' === $payload || self::MAX_PAYLOAD_BYTES < strlen($payload))
        ) {
            $this->reject($form, 'altcha.error.invalid');

            return;
        }

        $formToken = $submitted['_envelope'] ?? null;
        if (!is_string($formToken) || '' === $formToken) {
            $this->reject($form, 'altcha.error.invalid');

            return;
        }
        $claims = $this->envelopeManager->validateForSubmission(
            $formToken,
            $options['scope'],
            $request->getHost(),
            $policy->minimumFillTimeMs,
            $policy->maximumEnvelopeAgeSeconds,
        );
        if (null === $claims) {
            $this->reject($form, 'altcha.error.timing');

            return;
        }

        if (
            !array_key_exists('_honeypot', $submitted)
            || !is_string($submitted['_honeypot'])
            || '' !== $submitted['_honeypot']
            || (
                array_key_exists($claims->honeypotName, $submitted)
                && (!is_string($submitted[$claims->honeypotName]) || '' !== $submitted[$claims->honeypotName])
            )
        ) {
            $this->reject($form, 'altcha.error.invalid');

            return;
        }

        if (!$policy->altchaEnabled) {
            return;
        }

        $subjectHash = $this->subjectHash($request, $form, $policy, $options);
        if (false === $subjectHash) {
            $this->reject($form, 'altcha.error.unavailable');

            return;
        }

        try {
            $result = $this->client->verifyAndConsume(new VerificationRequest(
                payload: $payload,
                scope: $options['scope'],
                host: strtolower($request->getHost()),
                clientIp: $clientIp,
                formToken: $formToken,
                subjectHash: $subjectHash,
            ));
        } catch (AltchaServiceRejectedException $exception) {
            $this->reject($form, $this->messageForFailure($exception->reason));

            return;
        } catch (\Throwable) {
            $this->reject($form, 'altcha.error.unavailable');

            return;
        }

        if (!$result->verified || !$result->consumed) {
            $this->reject($form, 'altcha.error.invalid');

            return;
        }

        $this->protectionState->markConsumed($form);
    }

    /** @return array{_payload?: mixed, _envelope?: mixed, _honeypot?: mixed} */
    private function knownFields(mixed $data): array
    {
        if (!is_array($data)) {
            return [];
        }

        return array_intersect_key($data, ['_payload' => true, '_envelope' => true, '_honeypot' => true]);
    }

    private function reject(FormInterface $form, string $messageKey): void
    {
        $this->protectionState->reject($form);
        $form->addError(new FormError($this->translator->trans($messageKey, [], 'validators')));
    }

    /**
     * @param list<string> $partialActions
     * @param list<string> $finalActions
     */
    private function shouldSkipLiveProtection(Request $request, array $partialActions, array $finalActions): bool
    {
        if (([] === $partialActions && [] === $finalActions) || !$request->attributes->has('_live_component')) {
            return false;
        }

        $liveComponent = $request->attributes->get('_live_component');
        if (!is_string($liveComponent) || '' === trim($liveComponent)) {
            return false;
        }

        $hasExplicitAction = $request->attributes->has('_live_action');
        $explicitAction = $request->attributes->get('_live_action');
        $isDefaultAction = true === $request->attributes->get('_component_default_action');
        $isBatchDispatcher = true === $request->attributes->get('_is_live_batch_action');
        if ($isDefaultAction || $isBatchDispatcher) {
            $controller = $request->attributes->get('_controller');
            if (
                ($isDefaultAction && $isBatchDispatcher)
                || !is_string($controller)
                || '' === trim($controller)
                || $request->attributes->has('_component_action_args')
            ) {
                return false;
            }

            if (!$hasExplicitAction) {
                return true;
            }

            return is_string($explicitAction)
                && (($isDefaultAction && 'get' === $explicitAction) || ($isBatchDispatcher && '_batch' === $explicitAction));
        }

        if ($hasExplicitAction) {
            $action = $explicitAction;
            if (!is_string($action) || '' === trim($action)) {
                return false;
            }

            if (
                is_array($request->attributes->get('_controller'))
                || $request->attributes->has('_component_action_args')
            ) {
                $batchAction = $this->batchLiveAction($request, $liveComponent);
                if (null === $batchAction || $batchAction !== $action) {
                    return false;
                }
            }

            return in_array($action, $partialActions, true);
        }

        if (!$request->attributes->has('_controller')) {
            return false;
        }

        $action = $this->batchLiveAction($request, $liveComponent);

        return null !== $action && in_array($action, $partialActions, true);
    }

    private function batchLiveAction(Request $request, string $liveComponent): ?string
    {
        $controller = $request->attributes->get('_controller');
        $actionArguments = $request->attributes->get('_component_action_args');
        if (
            !$request->isMethod('POST')
            || !is_array($controller)
            || !array_is_list($controller)
            || 2 !== count($controller)
            || !is_string($controller[0])
            || $controller[0] !== $liveComponent
            || !is_string($controller[1])
            || '' === trim($controller[1])
            || !is_array($actionArguments)
        ) {
            return null;
        }

        return $controller[1];
    }

    private function currentRequest(): ?Request
    {
        return $this->requestStack->getCurrentRequest() ?? $this->requestStack->getMainRequest();
    }

    private function policyForRendering(string $scope, string $host): ?ScopePolicy
    {
        try {
            return $this->client->resolvePolicy($scope, $host);
        } catch (\Throwable) {
            return null;
        }
    }

    private function reusableEnvelope(
        FormInterface $form,
        string $scope,
        string $host,
        ?ScopePolicy $policy,
    ): ?ProtectionEnvelope {
        if (!$form->isSubmitted() || $this->protectionState->isConsumed($form) || null === $policy) {
            return null;
        }

        $token = $form->has('_envelope') ? $form->get('_envelope')->getData() : null;
        if (!is_string($token) || '' === $token || self::MAX_FORM_TOKEN_BYTES < strlen($token)) {
            return null;
        }

        $claims = $this->envelopeManager->validateForChallenge(
            $token,
            $scope,
            $host,
            $policy->maximumEnvelopeAgeSeconds,
        );

        return null === $claims ? null : new ProtectionEnvelope($token, $claims->honeypotName);
    }

    private function widgetMinimumDuration(?ScopePolicy $policy): int
    {
        if (null === $policy || !$policy->enabled || !$policy->altchaEnabled || 0 === $policy->minimumFillTimeMs) {
            return self::ALTCHA_DEFAULT_MINIMUM_DURATION_MS;
        }

        return max(self::ALTCHA_DEFAULT_MINIMUM_DURATION_MS, $policy->minimumFillTimeMs);
    }

    private function challengeUrl(string $scope, string $formToken): ?string
    {
        if (null === $this->urlGenerator) {
            return null;
        }

        try {
            return $this->urlGenerator->generate('cp_creation_altcha_challenge', [
                'scope' => $scope,
                'formToken' => $formToken,
            ]);
        } catch (RouteNotFoundException) {
            return null;
        }
    }

    /**
     * @param array<string, mixed> $options
     */
    private function subjectHash(Request $request, FormInterface $form, ScopePolicy $policy, array $options): string|false|null
    {
        if (!$options['subject_limits_enabled'] || !$policy->subjectLimitsEnabled || null === $options['subject_path']) {
            return null;
        }
        if (32 > strlen($this->subjectSecret)) {
            return false;
        }

        $submitted = $request->request->all();
        $rootName = $form->getRoot()->getName();
        if ('' !== $rootName && is_array($submitted[$rootName] ?? null)) {
            $submitted = $submitted[$rootName];
        }
        foreach (explode('.', $options['subject_path']) as $segment) {
            if ('' === $segment || !is_array($submitted) || !array_key_exists($segment, $submitted)) {
                return null;
            }
            $submitted = $submitted[$segment];
        }
        if (!is_string($submitted) || '' === trim($submitted)) {
            return null;
        }

        return hash_hmac('sha256', 'subject'."\0".strtolower(trim($submitted)), $this->subjectSecret);
    }

    private function messageForFailure(AltchaFailureReason $reason): string
    {
        return match ($reason) {
            AltchaFailureReason::REPLAY => 'altcha.error.replay',
            AltchaFailureReason::POLICY_CHANGED, AltchaFailureReason::EXPIRED => 'altcha.error.expired',
            AltchaFailureReason::INVALID_SOLUTION => 'altcha.error.invalid',
            AltchaFailureReason::RATE_LIMITED => 'altcha.error.rate_limited',
            AltchaFailureReason::FORBIDDEN_CONTEXT => 'altcha.error.context',
            AltchaFailureReason::UNAVAILABLE => 'altcha.error.unavailable',
        };
    }
}
