<?php

declare(strict_types=1);

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao\CoreBundle\Security\Authentication\Token;

use Contao\BackendUser;
use Contao\FrontendUser;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\SecurityBundle\Security\FirewallConfig;
use Symfony\Bundle\SecurityBundle\Security\FirewallMap;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Http\FirewallMapInterface;

class TokenChecker
{
    private const FRONTEND_FIREWALL = 'contao_frontend';
    private const BACKEND_FIREWALL = 'contao_backend';

    private RequestStack $requestStack;
    private FirewallMapInterface $firewallMap;
    private TokenStorageInterface $tokenStorage;
    private SessionInterface $session;
    private AuthenticationTrustResolverInterface $trustResolver;
    private VoterInterface $roleVoter;
    private Connection $connection;
    private array $previewLinks;

    /**
     * @internal
     */
    public function __construct(RequestStack $requestStack, FirewallMapInterface $firewallMap, TokenStorageInterface $tokenStorage, SessionInterface $session, AuthenticationTrustResolverInterface $trustResolver, VoterInterface $roleVoter, Connection $connection)
    {
        $this->requestStack = $requestStack;
        $this->firewallMap = $firewallMap;
        $this->tokenStorage = $tokenStorage;
        $this->session = $session;
        $this->trustResolver = $trustResolver;
        $this->roleVoter = $roleVoter;
        $this->connection = $connection;
    }

    /**
     * Checks if a front end user is authenticated.
     */
    public function hasFrontendUser(): bool
    {
        $token = $this->getToken(self::FRONTEND_FIREWALL);

        return null !== $token && VoterInterface::ACCESS_GRANTED === $this->roleVoter->vote($token, null, ['ROLE_MEMBER']);
    }

    /**
     * Checks if a back end user is authenticated.
     */
    public function hasBackendUser(): bool
    {
        $token = $this->getToken(self::BACKEND_FIREWALL);

        return null !== $token && VoterInterface::ACCESS_GRANTED === $this->roleVoter->vote($token, null, ['ROLE_USER']);
    }

    /**
     * Gets the front end username from the session.
     */
    public function getFrontendUsername(): ?string
    {
        $token = $this->getToken(self::FRONTEND_FIREWALL);

        if (null === $token || !$token->getUser() instanceof FrontendUser) {
            return null;
        }

        return $token->getUser()->getUserIdentifier();
    }

    /**
     * Gets the back end username from the session.
     */
    public function getBackendUsername(): ?string
    {
        $token = $this->getToken(self::BACKEND_FIREWALL);

        if (null === $token || !$token->getUser() instanceof BackendUser) {
            return null;
        }

        return $token->getUser()->getUserIdentifier();
    }

    /**
     * Tells whether the front end preview can be accessed.
     */
    public function canAccessPreview(): bool
    {
        if ($this->hasBackendUser()) {
            return true;
        }

        $token = $this->getToken(self::FRONTEND_FIREWALL);

        if (!$token instanceof FrontendPreviewToken) {
            return false;
        }

        if (null === $token->getPreviewLinkId()) {
            return false;
        }

        return $this->isValidPreviewLink($token);
    }

    /**
     * Tells whether the front end preview can show unpublished fragments.
     */
    public function isPreviewMode(): bool
    {
        $request = $this->requestStack->getMainRequest();

        if (null === $request || !$request->attributes->get('_preview', false) || !$this->canAccessPreview()) {
            return false;
        }

        $token = $this->getToken(self::FRONTEND_FIREWALL);

        return $token instanceof FrontendPreviewToken && $token->showUnpublished();
    }

    public function isFrontendFirewall(): bool
    {
        return self::FRONTEND_FIREWALL === $this->getFirewallContext();
    }

    public function isBackendFirewall(): bool
    {
        return self::BACKEND_FIREWALL === $this->getFirewallContext();
    }

    private function getToken(string $context): ?TokenInterface
    {
        $token = $this->getTokenFromStorage($context);

        if (null === $token) {
            $token = $this->getTokenFromSession('_security_'.$context);
        }

        if (!$token instanceof TokenInterface || !$token->isAuthenticated()) {
            return null;
        }

        if ($this->trustResolver->isAnonymous($token)) {
            return null;
        }

        return $token;
    }

    private function getTokenFromStorage(string $context): ?TokenInterface
    {
        if ($this->getFirewallContext() !== $context) {
            return null;
        }

        return $this->tokenStorage->getToken();
    }

    private function getFirewallContext(): ?string
    {
        $request = $this->requestStack->getMainRequest();

        if (!$this->firewallMap instanceof FirewallMap || null === $request) {
            return null;
        }

        $config = $this->firewallMap->getFirewallConfig($request);

        if (!$config instanceof FirewallConfig) {
            return null;
        }

        return $config->getContext();
    }

    private function getTokenFromSession(string $sessionKey): ?TokenInterface
    {
        if (!$this->session->isStarted()) {
            $request = $this->requestStack->getMainRequest();

            if (!$request || !$request->hasPreviousSession()) {
                return null;
            }
        }

        // This will start the session if Request::hasPreviousSession() was true
        if (!$this->session->has($sessionKey)) {
            return null;
        }

        $token = unserialize($this->session->get($sessionKey), ['allowed_classes' => true]);

        if (!$token instanceof TokenInterface) {
            return null;
        }

        return $token;
    }

    private function isValidPreviewLink(FrontendPreviewToken $token): bool
    {
        $id = $token->getPreviewLinkId();

        if (!isset($this->previewLinks[$id])) {
            $this->previewLinks[$id] = $this->connection->fetchAssociative(
                "
                    SELECT
                        url,
                        showUnpublished,
                        restrictToUrl
                    FROM tl_preview_link
                    WHERE
                        id = :id
                        AND published = '1'
                        AND expiresAt > UNIX_TIMESTAMP()
                ",
                ['id' => $id],
            );
        }

        $previewLink = $this->previewLinks[$id];

        if (!$previewLink) {
            return false;
        }

        if ((bool) $previewLink['showUnpublished'] !== $token->showUnpublished()) {
            return false;
        }

        if (!$previewLink['restrictToUrl']) {
            return true;
        }

        $request = $this->requestStack->getMainRequest();

        return $request && strtok($request->getUri(), '?') === strtok(Request::create($previewLink['url'])->getUri(), '?');
    }
}
