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

use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Notification\UserLoginLinkNotification;
use Ferienpass\CoreBundle\Notifier\Notifier;
use Ferienpass\CoreBundle\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Notifier\Recipient\Recipient;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

final class LoginLinkController extends AbstractController
{
    public function __construct(
        #[Autowire('%ferienpass_admin.enable_admin_magic_links%')]
        private readonly bool $enableAdminMagicLinks,
    ) {
    }

    /**
     * GET shows an interstitial that auto-POSTs the signed parameters back to
     * this same route. `check_post_only` is set on the login_link config so a
     * mailbox prefetcher's GET never consumes the single-use link. The actual
     * authentication happens on the POST inside the Symfony login_link
     * authenticator — this controller is only ever reached on the GET.
     */
    #[Route('/login/login_link', name: 'admin_login_link_check')]
    public function check(Request $request): Response
    {
        return $this->render('@FerienpassAdmin/page/login/login_link_confirm.html.twig', [
            'actionUrl' => $this->generateUrl('admin_login_link_check'),
            'user' => (string) $request->query->get('user'),
            'expires' => (string) $request->query->get('expires'),
            'hash' => (string) $request->query->get('hash'),
        ]);
    }

    /**
     * Requests a magic login link by e-mail. Always answers 200 to avoid
     * leaking whether an account exists for the given address (mirror of the
     * cms-bundle behaviour — same reasoning, same defenses).
     */
    #[Route('/login/login_link/send', name: 'admin_login_link_request', methods: ['POST'])]
    public function request(
        Request $request,
        UserRepository $users,
        Notifier $notifier,
        UserLoginLinkNotification $loginLinkNotification,
        TranslatorInterface $translator,
        #[Autowire(service: 'security.authenticator.login_link_handler.ferienpass_admin')]
        LoginLinkHandlerInterface $loginLinkHandler,
        #[Autowire('%env(APP_BASE_URL)%')]
        string $appBaseUrl,
        RateLimiterFactory $authEmailLimiter,
    ): Response {
        $email = (string) $request->getPayload()->get('email');

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

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

        // Role-scoped kill-switch: admins default to NO magic-link delivery
        // unless the operator has explicitly opted in via
        // `ferienpass_admin.enable_admin_magic_links`. ROLE_HOST users keep
        // magic-link as a recovery method regardless.
        //
        // We check the target user's own roles (NOT $security->isGranted,
        // which checks the *authenticated* user — and on this endpoint there
        // isn't one, so isGranted would always return false and let admins
        // through). Both ROLE_ADMIN and ROLE_SUPER_ADMIN qualify.
        $isAdminTarget = $user instanceof User && ($user->isAdmin() || $user->isSuperAdmin());
        $magicLinkBlocked = $isAdminTarget && !$this->enableAdminMagicLinks;

        // Unknown e-mail or disabled account → silent no-op (no distinguishable
        // response so we don't leak account state).
        if (null === $user || $user->isDisabled()) {
            return new JsonResponse(['status' => 'ok']);
        }

        // Magic links denied for this user (per-user opt-out or role-scoped
        // kill-switch) → send an explanatory mail instead of a login link, so
        // the recipient understands why nothing happened and which other
        // methods (password / passkey) they should use. The HTTP response
        // stays 200 in either case to preserve the enumeration defence.
        if ($user->isMagicLinksDisabled() || $magicLinkBlocked) {
            $reasonContentKey = $magicLinkBlocked
                ? 'signIn.magicLink.email.disabledForAdminContent'
                : 'signIn.magicLink.email.disabledByUserContent';

            $notifier->send(
                (clone $loginLinkNotification)
                    ->user($user)
                    ->subject($translator->trans('signIn.magicLink.email.disabledSubject', [], 'admin'))
                    ->content($translator->trans($reasonContentKey, [], 'admin'))
                    ->belongsTo($user->getUuid()),
                new Recipient($user->getEmail())
            );

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

        $loginLink = $loginLinkHandler->createLoginLink($user)->getUrl();

        // The login-link signature covers only [email, lastLogin, expires]
        // — NOT the host. Rebase to APP_BASE_URL so a poisoned Host /
        // X-Forwarded-Host can't send the victim a link pointing at an
        // attacker domain. Mirrors the cms-bundle defence.
        if ('' !== $appBaseUrl && false !== ($parts = parse_url($loginLink))) {
            $loginLink = rtrim($appBaseUrl, '/')
                .($parts['path'] ?? '')
                .(isset($parts['query']) ? '?'.$parts['query'] : '');
        }

        $notifier->send(
            (clone $loginLinkNotification)
                ->user($user)
                ->subject($translator->trans('signIn.magicLink.email.subject', [], 'admin'))
                ->content($translator->trans('signIn.magicLink.email.content', [], 'admin'))
                ->belongsTo($user->getUuid())
                ->actionUrl($loginLink),
            new Recipient($user->getEmail())
        );

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