<?php

declare(strict_types=1);

namespace Lexik\Bundle\JWTAuthenticationBundle\Security\Authenticator;

use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTInvalidEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTNotFoundEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Events;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\ExpiredTokenException;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\InvalidPayloadException;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\InvalidTokenException;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingTokenException;
use Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationFailureResponse;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Authenticator\Token\JWTPostAuthenticationToken;
use Lexik\Bundle\JWTAuthenticationBundle\Security\User\PayloadAwareUserProviderInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\ChainUserProvider;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

class JWTAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
    use ForwardCompatAuthenticatorTrait;

    /**
     * @var TokenExtractorInterface
     */
    private $tokenExtractor;

    /**
     * @var JWTTokenManagerInterface
     */
    private $jwtManager;

    /**
     * @var EventDispatcherInterface
     */
    private $eventDispatcher;

    /**
     * @var UserProviderInterface
     */
    private $userProvider;

    /**
     * @var TranslatorInterface|null
     */
    private $translator;

    public function __construct(
        JWTTokenManagerInterface $jwtManager,
        EventDispatcherInterface $eventDispatcher,
        TokenExtractorInterface $tokenExtractor,
        UserProviderInterface $userProvider,
        TranslatorInterface $translator = null
    ) {
        $this->tokenExtractor = $tokenExtractor;
        $this->jwtManager = $jwtManager;
        $this->eventDispatcher = $eventDispatcher;
        $this->userProvider = $userProvider;
        $this->translator = $translator;
    }

    /**
     * {@inheritdoc}
     */
    public function start(Request $request, AuthenticationException $authException = null): Response
    {
        $exception = new MissingTokenException('JWT Token not found', 0, $authException);
        $event = new JWTNotFoundEvent($exception, new JWTAuthenticationFailureResponse($exception->getMessageKey()), $request);

        $this->eventDispatcher->dispatch($event, Events::JWT_NOT_FOUND);

        return $event->getResponse();
    }

    public function supports(Request $request): ?bool
    {
        return false !== $this->getTokenExtractor()->extract($request);
    }

    /**
     * @return Passport
     */
    public function doAuthenticate(Request $request) /*: Passport */
    {
        $token = $this->getTokenExtractor()->extract($request);
        if ($token === false) {
            throw new \LogicException('Unable to extract a JWT token from the request. Also, make sure to call `supports()` before `authenticate()` to get a proper client error.');
        }

        try {
            if (!$payload = $this->jwtManager->parse($token)) {
                throw new InvalidTokenException('Invalid JWT Token');
            }
        } catch (JWTDecodeFailureException $e) {
            if (JWTDecodeFailureException::EXPIRED_TOKEN === $e->getReason()) {
                throw new ExpiredTokenException();
            }

            throw new InvalidTokenException('Invalid JWT Token', 0, $e);
        }

        $idClaim = $this->jwtManager->getUserIdClaim();
        if (!isset($payload[$idClaim])) {
            throw new InvalidPayloadException($idClaim);
        }

        $passport = new SelfValidatingPassport(
            new UserBadge(
                (string)$payload[$idClaim],
                function ($userIdentifier) use ($payload) {
                    return $this->loadUser($payload, $userIdentifier);
                }
            )
        );

        $passport->setAttribute('payload', $payload);
        $passport->setAttribute('token', $token);

        return $passport;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $errorMessage = strtr($exception->getMessageKey(), $exception->getMessageData());
        if (null !== $this->translator) {
            $errorMessage = $this->translator->trans($exception->getMessageKey(), $exception->getMessageData(), 'security');
        }
        $response = new JWTAuthenticationFailureResponse($errorMessage);

        if ($exception instanceof ExpiredTokenException) {
            $event = new JWTExpiredEvent($exception, $response, $request);
            $eventName = Events::JWT_EXPIRED;
        } else {
            $event = new JWTInvalidEvent($exception, $response, $request);
            $eventName = Events::JWT_INVALID;
        }

        $this->eventDispatcher->dispatch($event, $eventName);

        return $event->getResponse();
    }

    /**
     * Gets the token extractor to be used for retrieving a JWT token in the
     * current request.
     *
     * Override this method for adding/removing extractors to the chain one or
     * returning a different {@link TokenExtractorInterface} implementation.
     */
    protected function getTokenExtractor(): TokenExtractorInterface
    {
        return $this->tokenExtractor;
    }

    /**
     * Gets the jwt manager.
     */
    protected function getJwtManager(): JWTTokenManagerInterface
    {
        return $this->jwtManager;
    }

    /**
     * Gets the event dispatcher.
     */
    protected function getEventDispatcher(): EventDispatcherInterface
    {
        return $this->eventDispatcher;
    }

    /**
     * Gets the user provider.
     */
    protected function getUserProvider(): UserProviderInterface
    {
        return $this->userProvider;
    }

    /**
     * Loads the user to authenticate.
     *
     * @param array                 $payload      The token payload
     * @param string                $identity     The key from which to retrieve the user "identifier"
     */
    protected function loadUser(array $payload, string $identity): UserInterface
    {
        if ($this->userProvider instanceof PayloadAwareUserProviderInterface) {
            if (method_exists($this->userProvider, 'loadUserByIdentifierAndPayload')) {
                return $this->userProvider->loadUserByIdentifierAndPayload($identity, $payload);
            } else {
                return $this->userProvider->loadUserByUsernameAndPayload($identity, $payload);
            }
        }

        if ($this->userProvider instanceof ChainUserProvider) {
            foreach ($this->userProvider->getProviders() as $provider) {
                try {
                    if ($provider instanceof PayloadAwareUserProviderInterface) {
                        if (method_exists(PayloadAwareUserProviderInterface::class, 'loadUserByIdentifierAndPayload')) {
                            return $provider->loadUserByIdentifierAndPayload($identity, $payload);
                        } else {
                            return $provider->loadUserByUsernameAndPayload($identity, $payload);
                        }
                    }

                    return $provider->loadUserByIdentifier($identity);
                    // More generic call to catch both UsernameNotFoundException for SF<5.3 and new UserNotFoundException
                } catch (AuthenticationException $e) {
                    // try next one
                }
            }

            if (!class_exists(UserNotFoundException::class)) {
                $ex = new UsernameNotFoundException(sprintf('There is no user with username "%s".', $identity));
                $ex->setUsername($identity);
            } else {
                $ex = new UserNotFoundException(sprintf('There is no user with identifier "%s".', $identity));
                $ex->setUserIdentifier($identity);
            }

            throw $ex;
        }

        if (method_exists($this->userProvider, 'loadUserByIdentifier')) {
            return $this->userProvider->loadUserByIdentifier($identity);
        } else {
            return $this->userProvider->loadUserByUsername($identity);
        }
    }

    public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface
    {
        if (!$passport instanceof Passport) {
            throw new \LogicException(sprintf('Expected "%s" but got "%s".', Passport::class, get_debug_type($passport)));
        }

        $token = new JWTPostAuthenticationToken($passport->getUser(), $firewallName, $passport->getUser()->getRoles(), $passport->getAttribute('token'));

        $this->eventDispatcher->dispatch(new JWTAuthenticatedEvent($passport->getAttribute('payload'), $token), Events::JWT_AUTHENTICATED);

        return $token;
    }

    public function createToken(Passport $passport, string $firewallName): TokenInterface
    {
        $token = new JWTPostAuthenticationToken($passport->getUser(), $firewallName, $passport->getUser()->getRoles(), $passport->getAttribute('token'));

        $this->eventDispatcher->dispatch(new JWTAuthenticatedEvent($passport->getAttribute('payload'), $token), Events::JWT_AUTHENTICATED);

        return $token;
    }
}
