<?php

namespace App\Security;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use JetBrains\PhpStorm\ArrayShape;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
use Symfony\Component\Security\Guard\PasswordAuthenticatedInterface;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

/**
 * Class AppAuthenticator
 * @package App\Security
 */
class AppAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface
{
	use TargetPathTrait;
	
	public const LOGIN_ROUTE = 'app_login';
	
	private EntityManagerInterface $entityManager;
	private UrlGeneratorInterface $urlGenerator;
	private CsrfTokenManagerInterface $csrfTokenManager;
	private UserPasswordHasherInterface $passwordHasher;
	
	public function __construct(EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager, UserPasswordHasherInterface $passwordHasher)
	{
		$this->entityManager = $entityManager;
		$this->urlGenerator = $urlGenerator;
		$this->csrfTokenManager = $csrfTokenManager;
		$this->passwordHasher = $passwordHasher;
	}
	
	/**
	 * Does the authenticator support the given Request?
	 *
	 * If this returns false, the authenticator will be skipped.
	 *
	 * @return bool
	 */
	public function supports(Request $request): bool
	{
		return self::LOGIN_ROUTE === $request->attributes->get('_route')
			&& $request->isMethod('POST');
	}
	
	/**
	 * Get the authentication credentials from the request and return them
	 * as any type (e.g. an associate array).
	 *
	 * Whatever value you return here will be passed to getUser() and checkCredentials()
	 *
	 * For example, for a form login, you might:
	 *
	 *      return [
	 *          'username' => $request->request->get('_username'),
	 *          'password' => $request->request->get('_password'),
	 *      ];
	 *
	 * Or for an API token that's on a header, you might use:
	 *
	 *      return ['api_key' => $request->headers->get('X-API-TOKEN')];
	 *
	 * @return array Any non-null value
	 *
	 * @throws \UnexpectedValueException If null is returned
	 */
	#[ArrayShape([
		'username'   => "mixed",
		'password'   => "mixed",
		'csrf_token' => "mixed"
	])]
	public function getCredentials(Request $request): array
	{
		$credentials = [
			'username'   => $request->request->get('username'),
			'password'   => $request->request->get('password'),
			'csrf_token' => $request->request->get('_csrf_token'),
		];
		$request->getSession()->set(
			Security::LAST_USERNAME,
			$credentials['username']
		);
		
		return $credentials;
	}
	
	/**
	 * Return a UserInterface object based on the credentials.
	 *
	 * The *credentials* are the return value from getCredentials()
	 *
	 * You may throw an AuthenticationException if you wish. If you return
	 * null, then a UsernameNotFoundException is thrown for you.
	 *
	 * @param mixed $credentials
	 *
	 * @return UserInterface|null
	 * @throws AuthenticationException
	 *
	 */
	public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface
	{
		$token = new CsrfToken('authenticate', $credentials['csrf_token']);
		if (!$this->csrfTokenManager->isTokenValid($token)) {
			throw new InvalidCsrfTokenException();
		}
		
		// try username
		$user = $this->entityManager->getRepository(User::class)->findOneBy(['username' => $credentials['username']]);
		
		// try email
		if (!$user) {
			$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['username']]);
		}
		
		if (!$user) {
			throw new UserNotFoundException('Username or email could not be found.');
		} else {
			return $user;
		}
	}
	
	
	/**
	 * Returns true if the credentials are valid.
	 *
	 * If false is returned, authentication will fail. You may also throw
	 * an AuthenticationException if you wish to cause authentication to fail.
	 *
	 * The *credentials* are the return value from getCredentials()
	 *
	 * @param mixed $credentials
	 *
	 * @return bool
	 *
	 * @throws AuthenticationException
	 */
	public function checkCredentials($credentials, UserInterface $user): bool
	{
		//return true;
		return $this->passwordHasher->isPasswordValid($user, $credentials['password']);
	}
	
	/**
	 * Used to upgrade (rehash) the user's password automatically over time.
	 */
	public function getPassword($credentials): ?string
	{
		return $credentials['password'];
	}
	
	/**
	 * Called when authentication executed and was successful!
	 *
	 * This should return the Response sent back to the user, like a
	 * RedirectResponse to the last page they visited.
	 *
	 * If you return null, the current request will continue, and the user
	 * will be authenticated. This makes sense, for example, with an API.
	 *
	 * @return Response|null
	 */
	public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey)
	{
		if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
			return new RedirectResponse($targetPath);
		}
		
		return new RedirectResponse($this->urlGenerator->generate('blogs'));
	}
	
	protected function getLoginUrl()
	{
		return $this->urlGenerator->generate(self::LOGIN_ROUTE);
	}
}
