<?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\Controller\Frontend;

use Ferienpass\CmsBundle\Controller\AbstractController;
use Ferienpass\CmsBundle\Fragment\FragmentReference;
use Ferienpass\CmsBundle\Message\LoginLinkHelpRequested;
use Ferienpass\CmsBundle\Message\LoginLinkRequested;
use Ferienpass\CoreBundle\Repository\UserRepository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;

class LoginLinkController extends AbstractController
{
    /**
     * The login_link authenticator is configured with `check_post_only`, so it
     * only intercepts POST requests to this route and authenticates them from the
     * signed parameters. A GET — the user clicking the e-mailed link, but also an
     * inbox scanner / link prefetcher — reaches this controller instead and gets
     * an interstitial that immediately POSTs the parameters back. That stops a
     * prefetch from consuming the single-use (`max_uses: 1`) link before the real
     * click (which previously left the user with an "invalid link").
     */
    #[Route('/login_link', name: 'cms_login_link_check')]
    public function check(Request $request): Response
    {
        $this->initializeContaoFramework();

        return $this->markPageAsPrivate(
            $this->createPageBuilder()
                ->addFragment('main', new FragmentReference('ferienpass.fragment.login_link_confirm', [
                    'user' => (string) $request->query->get('user'),
                    'expires' => (string) $request->query->get('expires'),
                    'hash' => (string) $request->query->get('hash'),
                ]))
                ->getResponse()
        );
    }

    /**
     * Landing page for a failed magic link. The CMS `login_link` firewall
     * redirects here (via `login_path: cms_login_link_invalid`) whenever the
     * authenticator rejects a link — expired, already used, or a stale
     * signature (e.g. the member logged in elsewhere since it was issued).
     * Without it, a failed link fell back to the framework-default `/login`,
     * which is no route in the Contao frontend → 404.
     */
    #[Route('/login_link/invalid', name: 'cms_login_link_invalid')]
    public function invalid(): Response
    {
        $this->initializeContaoFramework();

        return $this->createPageBuilder()
            ->addFragment('main', new FragmentReference('ferienpass.fragment.login_link_invalid'))
            ->getResponse()
        ;
    }

    /**
     * Requests a magic login link by e-mail. Always answers 200 to avoid
     * leaking whether an account exists for the given address.
     */
    #[Route('/login_link/send', name: 'cms_login_link_request', methods: ['POST'])]
    public function request(
        Request $request,
        UserRepository $users,
        MessageBusInterface $eventBus,
        #[Autowire(service: 'security.authenticator.login_link_handler.contao_frontend')]
        LoginLinkHandlerInterface $loginLinkHandler,
        #[Autowire('%env(APP_BASE_URL)%')]
        string $appBaseUrl,
        RateLimiterFactoryInterface $authEmailLimiter,
    ): Response {
        $email = (string) $request->getPayload()->get('email');

        // Per-IP throttle to prevent e-mail bombing / mailer-quota abuse
        // (SECURITY_REVIEW FP-016). Always answer 200 to preserve the
        // account-enumeration defense, even when throttled.
        if (!$authEmailLimiter->create($request->getClientIp() ?? '')->consume(1)->isAccepted()) {
            return new JsonResponse(['status' => 'ok']);
        }

        $user = '' !== $email ? $users->findOneBy(['email' => $email]) : null;

        // The member turned e-mail sign-in off for their account. Don't issue a
        // link; queue a short explanatory mail instead (via the same async
        // LoginLinkRequested message, so delivery stays off the web request and
        // a mailer failure can't turn into an enumeration side-channel). The 200
        // below is returned either way, so account state never leaks.
        if (null !== $user && !$user->isDisabled() && $user->isMagicLinksDisabled()) {
            $eventBus->dispatch(new LoginLinkRequested((string) $user->getUuid(), '', disabled: true));

            return new JsonResponse(['status' => 'ok']);
        }

        if (null !== $user && !$user->isDisabled()) {
            $loginLink = $loginLinkHandler->createLoginLink($user)->getUrl();

            // The login-link signature covers only [email, lastLogin, expires] —
            // NOT the host. The URL above is built from the (spoofable) request
            // Host, so a poisoned Host/X-Forwarded-Host would e-mail the victim a
            // link pointing at an attacker domain (SECURITY_REVIEW FP-008). When a
            // canonical base URL is configured, rebase the link onto it so the
            // emailed link always targets the tenant's real domain regardless of
            // the request Host. (trusted_hosts is the companion defense.)
            if ('' !== $appBaseUrl && false !== ($parts = parse_url($loginLink))) {
                $loginLink = rtrim($appBaseUrl, '/')
                    .($parts['path'] ?? '')
                    .(isset($parts['query']) ? '?'.$parts['query'] : '');
            }

            // Dispatch the e-mail asynchronously. Sending inline made a transient
            // mailer failure crash the request — a 500 for an existing account vs.
            // 200 for a non-existent one, re-opening the account-enumeration
            // side-channel this endpoint is designed to close. The link itself is
            // built here (it needs the request to apply the FP-008 host rebasing);
            // only the delivery is deferred to the worker.
            $eventBus->dispatch(new LoginLinkRequested((string) $user->getUuid(), $loginLink));
        }

        // No account for this address. Rather than stay silent, tell the mailbox
        // owner no account is linked to it so they can retry with the right one.
        // Delivered async (like LoginLinkRequested above) to keep mail off the
        // web request, so a mailer failure can't turn into an enumeration
        // side-channel. The 200 below is returned either way.
        if ('' !== $email && null === $user) {
            $eventBus->dispatch(new LoginLinkHelpRequested($email));
        }

        return new JsonResponse(['status' => 'ok']);
    }
}
