<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CmsBundle\Components\SignIn;

use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CmsBundle\Form\UserRegistrationType;
use Ferienpass\CoreBundle\Entity\Consent;
use Ferienpass\CoreBundle\Entity\Participant\BaseParticipant;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Message\AccountAlreadyExists;
use Ferienpass\CoreBundle\Message\AccountCreated;
use Ferienpass\CoreBundle\Message\AccountDisabled;
use Ferienpass\CoreBundle\Repository\ConsentRepository;
use Ferienpass\CoreBundle\Repository\ParticipantRepositoryInterface;
use Ferienpass\CoreBundle\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent(template: '@Contao/components/sign_in/registration.html.twig', route: 'live_component_cms')]
class Registration extends AbstractController
{
    use ComponentWithFormTrait;
    use DefaultActionTrait;

    /**
     * UUIDs of anonymous participants this browser previously created. Held
     * client-side in localStorage and synced to this prop by the
     * cms-anon-participants Stimulus controller; on successful sign-up the
     * matching participants are adopted into the new account.
     *
     * @var string[]
     */
    #[LiveProp(writable: true)]
    public array $participantIds = [];

    private ?User $initialFormData = null;

    public function __construct(private readonly ParticipantRepositoryInterface $participants, private readonly ConsentRepository $consents)
    {
    }

    #[LiveAction]
    public function submit(Session $session, Request $request, EntityManagerInterface $em, MessageBusInterface $eventBus, UserRepository $repository): RedirectResponse
    {
        $this->submitForm();

        /** @var User $user */
        $user = $this->getForm()->getData();

        if (null !== ($existing = $repository->findOneBy(['email' => $user->getEmail()]))) {
            $session->set('registration.email', $user->getEmail());
            // Never offer passkey setup for an account that already exists — a
            // passkey may only be attached to the account we create right here.
            // Clearing both the intent flag and the bound UUID is what stops an
            // attacker from submitting a victim's e-mail and then driving the
            // public /register/passkey endpoints against that account.
            $session->remove('registration.passkey');
            $session->remove('registration.passkey_uuid');
            if ($existing->isDisabled()) {
                $eventBus->dispatch(new AccountDisabled((string) $existing->getUuid()));
            } else {
                $eventBus->dispatch(new AccountAlreadyExists((string) $existing->getUuid()));
            }

            return $this->redirectToRoute('cms_registration_confirm', ['uuid' => $existing->getUuid()]);
        }

        $user->setRoles(['ROLE_MEMBER']);
        $user->setDisabled();

        $session->set('registration.email', $user->getEmail());

        // Remember whether the user wants to finish sign-up with a passkey. The
        // registration-confirm page reads this to run the passkey creation
        // ceremony against the (still disabled) account just created.
        $form = $this->getForm();
        $usePasskey = $form->has('method') && 'passkey' === (string) $form->get('method')->getData();
        $session->set('registration.passkey', $usePasskey);
        // Bind the pending passkey ceremony to *this* freshly created account.
        // PendingRegistrationUserEntityGuesser resolves the account by this UUID
        // (not by the attacker-controllable e-mail) and refuses anything that is
        // not still disabled, so a passkey can only ever land on the account we
        // just created here.
        $session->set('registration.passkey_uuid', (string) $user->getUuid());

        $em->persist($user);

        $this->adoptStoredParticipants($user);

        $em->flush();

        $eventBus->dispatch(new AccountCreated((string) $user->getUuid(), $request->getHost()));

        return $this->redirectToRoute('cms_registration_confirm', ['uuid' => $user->getUuid()]);
    }

    protected function instantiateForm(): FormInterface
    {
        // Cast to user, for password hasher
        $this->initialFormData ??= new User();

        return $this->createForm(UserRegistrationType::class, $this->initialFormData);
    }

    /**
     * Adopt the anonymous participants from this browser's localStorage (synced
     * to {@see self::$participantIds}) into the newly created account. Only
     * still-orphaned participants are accepted, so a stale/forged UUID can never
     * steal an existing user's participant.
     *
     * Anonymous consent decisions that were captured against the same
     * participants travel with them — Consent.user is bound to the new account
     * so post-registration reporting/audit treats them as account-owned.
     */
    private function adoptStoredParticipants(User $user): void
    {
        if (empty($this->participantIds)) {
            return;
        }

        $adopted = $this->participants->findBy(['uuid' => $this->participantIds, 'user' => null]);
        if (empty($adopted)) {
            return;
        }

        foreach ($adopted as $participant) {
            /** @var BaseParticipant $participant */
            $participant->setUser($user);
        }

        // Adopt orphan consents tied to those participants. Participant ownership
        // is the gatekeeper here — we already trust that "knew the UUID = made
        // the application", so consents recorded against that participant follow.
        foreach ($this->consents->findBy(['participant' => $adopted, 'user' => null]) as $consent) {
            /** @var Consent $consent */
            $consent->setUser($user);
        }
    }
}
