<?php

declare(strict_types=1);

/*
 * eduVPN - End-user friendly VPN.
 *
 * Copyright: 2014-2023, The Commons Conservancy eduVPN Programme
 * SPDX-License-Identifier: AGPL-3.0+
 */

namespace Vpn\Portal\Http\Auth;

use Vpn\Portal\Cfg\LogConfig;
use Vpn\Portal\Http\Auth\Exception\CredentialValidatorChallengeException;
use Vpn\Portal\Http\Auth\Exception\CredentialValidatorException;
use Vpn\Portal\Http\HtmlResponse;
use Vpn\Portal\Http\RedirectResponse;
use Vpn\Portal\Http\Request;
use Vpn\Portal\Http\Response;
use Vpn\Portal\Http\ServiceInterface;
use Vpn\Portal\Http\SessionInterface;
use Vpn\Portal\Http\UserInfo;
use Vpn\Portal\Json;
use Vpn\Portal\LoggerInterface;
use Vpn\Portal\TplInterface;
use Vpn\Portal\Validator;

class UserPassAuthModule extends AbstractAuthModule
{
    protected TplInterface $tpl;
    private CredentialValidatorInterface $credentialValidator;
    private SessionInterface $session;
    private LoggerInterface $logger;
    private LogConfig $logConfig;

    public function __construct(CredentialValidatorInterface $credentialValidator, SessionInterface $session, TplInterface $tpl, LoggerInterface $logger, LogConfig $logConfig)
    {
        $this->credentialValidator = $credentialValidator;
        $this->session = $session;
        $this->tpl = $tpl;
        $this->logger = $logger;
        $this->logConfig = $logConfig;
    }

    public function init(ServiceInterface $service): void
    {
        $service->postBeforeAuth(
            '/_user_pass_auth/verify',
            function (Request $request): Response {
                $this->session->remove('_user_pass_auth_user_id');
                $this->session->remove('_user_pass_auth_permission_list');

                $authUser = $request->requirePostParameter('userName', fn(string $s) => Validator::userId($s));
                $authPass = $request->requirePostParameter('userPass', fn(string $s) => Validator::userAuthPass($s));
                $redirectTo = $request->requirePostParameter('authRedirectTo', fn(string $s) => Validator::matchesOrigin($request->getOrigin(), $s));

                try {
                    $userInfo = $this->credentialValidator->validate($authUser, $authPass);
                    $this->logger->info(
                        $this->authLogOkTemplate(
                            $userInfo->userId(),
                            $request->requireHeader('REMOTE_ADDR', fn(string $s) => Validator::ipAddress($s))
                        )
                    );
                    $this->session->set('_user_pass_auth_user_id', $userInfo->userId());
                    $this->session->set('_user_pass_auth_permission_list', Json::encode($userInfo->permissionList()));

                    return new RedirectResponse($redirectTo);
                } catch (CredentialValidatorChallengeException $e) {
                    // the authentication mechanism offers us a challenge, we
                    // as the user for it...
                    $responseBody = $this->tpl->render(
                        'userPassAuth',
                        [
                            'authState' => 'AUTH_CHALLENGE',
                            'authChallengeMessage' => $e->getMessage(),
                            'authUser' => $authUser,
                            'authRedirectTo' => $redirectTo,
                            'showLogoutButton' => false,
                        ]
                    );

                    return new HtmlResponse($responseBody);
                } catch (CredentialValidatorException $e) {
                    $this->logger->warning(
                        $this->authLogFailTemplate(
                            $authUser,
                            $request->requireHeader('REMOTE_ADDR', fn(string $s) => Validator::ipAddress($s)),
                            $e->getMessage()
                        )
                    );

                    $responseBody = $this->tpl->render(
                        'userPassAuth',
                        [
                            'authState' => 'AUTH_FAILED',
                            'authUser' => $authUser,
                            'authRedirectTo' => $redirectTo,
                            'showLogoutButton' => false,
                        ]
                    );

                    return new HtmlResponse($responseBody);
                }
            }
        );
    }

    public function userInfo(Request $request): ?UserInfo
    {
        if (null === $authUser = $this->session->get('_user_pass_auth_user_id')) {
            return null;
        }

        $permissionList = [];
        if (null !== $sessionValue = $this->session->get('_user_pass_auth_permission_list')) {
            $permissionList = Json::decode($sessionValue);
        }

        return new UserInfo(
            $authUser,
            $permissionList
        );
    }

    public function startAuth(Request $request): ?Response
    {
        $responseBody = $this->tpl->render(
            'userPassAuth',
            [
                'authState' => 'AUTH',
                'authUser' => null,
                'authRedirectTo' => $request->getUri(),
                'showLogoutButton' => false,
            ]
        );

        return new HtmlResponse($responseBody, [], 200);
    }

    /**
     * We want to make sure when logging a user provided User ID through the
     * "Sign In" page that it can't simply contain _all_ characters. This makes
     * it easier to parse the log messages without needing complicated
     * escaping. For cases where the exact User ID is required, there is the
     * RAW_USER_ID field.
     */
    public static function sanitizeUserId(string $userId): string
    {
        return preg_replace('/[^a-zA-Z0-9\.\-]/', '_', $userId);
    }

    private function authLogOkTemplate(string $userId, string $remoteAddress): string
    {
        return str_replace(
            [
                '{{USER_ID}}',
                '{{RAW_USER_ID}}',
                '{{REMOTE_ADDR}}',
            ],
            [
                self::sanitizeUserId($userId),
                $userId,
                $remoteAddress,
            ],
            $this->logConfig->authLogOkTemplate()
        );
    }

    private function authLogFailTemplate(string $userId, string $remoteAddress, string $failReason): string
    {
        return str_replace(
            [
                '{{USER_ID}}',
                '{{RAW_USER_ID}}',
                '{{REMOTE_ADDR}}',
                '{{FAIL_REASON}}',
            ],
            [
                self::sanitizeUserId($userId),
                $userId,
                $remoteAddress,
                $failReason,
            ],
            $this->logConfig->authLogFailTemplate()
        );
    }
}
