<?php

declare(strict_types=1);

/*
 * Copyright (c) 2023-2024 François Kooman <fkooman@tuxed.net>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

namespace fkooman\Radius;

use fkooman\Radius\Exception\AccessChallengeException;
use fkooman\Radius\Exception\AccessRejectException;
use fkooman\Radius\Exception\RadiusException;
use fkooman\Radius\Exception\SocketException;

/**
 * Simple modern RADIUS client, only supporting PAP.
 *
 * @see https://www.rfc-editor.org/rfc/rfc2865 "Remote Authentication Dial In User Service (RADIUS)"
 * @see https://www.rfc-editor.org/rfc/rfc2869 "RADIUS Extensions"
 */
class RadiusClient
{
    protected SocketInterface $socket;
    private ClientConfig $clientConfig;
    private LoggerInterface $logger;

    /** @var array<ServerInfo> */
    private array $serverList = [];

    public function __construct(?ClientConfig $clientConfig = null, ?LoggerInterface $logger = null)
    {
        $this->clientConfig = $clientConfig ?? new ClientConfig('My NAS');
        $this->logger = $logger ?? new NullLogger();
        $this->socket = new PhpSocket($logger ?? new NullLogger());
    }

    public function addServer(ServerInfo $serverInfo): void
    {
        $this->serverList[] = $serverInfo;
    }

    public function accessRequest(string $userName, string $userPassword, ?RadiusPacket $radiusPacket = null): RadiusPacket
    {
        $userName = Utils::verifyUserName($userName);
        $userPassword = Utils::verifyUserPassword($userPassword);

        $this->logger->info(sprintf('--> Access-Request for User-Name "%s"', $userName));
        $this->logger->debug(sprintf('--> Access-Request for User-Password "%s"', $userPassword));

        // shuffle the list of ServerInfo objects to connect "round robin"
        shuffle($this->serverList);

        foreach ($this->serverList as $serverInfo) {
            $accessRequest = $this->prepareAccessRequest($serverInfo, $userName, $userPassword, $radiusPacket);
            $this->logger->debug((string) $accessRequest);
            if (null === $accessResponse = $this->sendPacket($serverInfo, $accessRequest)) {
                continue;
            }
            $this->logger->debug((string) $accessResponse);
            self::verifyAccessResponse($serverInfo, $accessResponse, $accessRequest, $serverInfo->sharedSecret());
            $this->logger->info(sprintf('<-- %s for User-Name "%s"', $accessResponse->packetType(), $userName));
            if ($accessResponse->isAccessAccept()) {
                return $accessResponse;
            }
            if ($accessResponse->isAccessChallenge()) {
                throw new AccessChallengeException($accessResponse);
            }

            throw new AccessRejectException($accessResponse);
        }

        throw new RadiusException('unable to connect to (any) RADIUS server');
    }

    /**
     * This method is for unit testing only where we can set the value used as
     * the  Request Authenticator to a predictable value. When `null` is
     * returned a random Authenticator is generated in the RadiusPacket
     * constructor.
     */
    protected function requestAuthenticator(): ?string
    {
        return null;
    }

    private function prepareAccessRequest(ServerInfo $serverInfo, string $userName, string $userPassword, ?RadiusPacket $radiusPacket): RadiusPacket
    {
        if (null !== $radiusPacket && !$radiusPacket->isAccessChallenge()) {
            throw new RadiusException('provided packet MUST be of type Accept-Challenge');
        }
        $requestId = $radiusPacket ? $radiusPacket->packetId() + 1 : 0;
        $accessRequest = new RadiusPacket(RadiusPacket::ACCESS_REQUEST, $requestId, $this->requestAuthenticator());
        $accessRequest->attributeCollection()->set('User-Name', $userName);
        $accessRequest->attributeCollection()->set('User-Password', Password::encrypt($userPassword, $accessRequest->packetAuthenticator(), $serverInfo->sharedSecret()));
        $accessRequest->attributeCollection()->set('NAS-Identifier', $this->clientConfig->nasIdentifier());
        if (null !== $radiusPacket) {
            if (null !== $stateValue = $radiusPacket->attributeCollection()->getOne('State')) {
                $accessRequest->attributeCollection()->set('State', $stateValue);
            }
        }
        $accessRequest->attributeCollection()->set('Message-Authenticator', MessageAuthenticator::calculate($accessRequest, $serverInfo->sharedSecret()));

        return $accessRequest;
    }

    private function verifyAccessResponse(ServerInfo $serverInfo, RadiusPacket $accessResponse, RadiusPacket $accessRequest, string $sharedSecret): void
    {
        if (!$accessResponse->isResponse()) {
            throw new RadiusException(sprintf('RADIUS packet type MUST be Access-Accept, Access-Reject or Access-Challenge, got %s', $accessResponse->packetType()));
        }
        if ($accessRequest->packetId() !== $accessResponse->packetId()) {
            throw new RadiusException(sprintf('RADIUS packet ID of the response (%d) MUST match packet ID of the request (%d)', $accessResponse->packetId(), $accessRequest->packetId()));
        }
        if (false === ResponseAuthenticator::verify($accessResponse, $sharedSecret, $accessRequest->packetAuthenticator())) {
            throw new RadiusException('RADIUS Response Authenticator has unexpected value');
        }
        $verifyResult = MessageAuthenticator::verify($accessResponse, $sharedSecret, $accessRequest->packetAuthenticator());
        if (false === $verifyResult) {
            throw new RadiusException('RADIUS Message-Authenticator has unexpected value');
        }
        if (null === $verifyResult) {
            if ($this->clientConfig->requireMessageAuth()) {
                throw new RadiusException('RADIUS Message-Authenticator not set by RADIUS server, but MUST be set');
            }
            $this->logger->warning(sprintf('[SERVER=%s] RADIUS Message-Authenticator not set by RADIUS server but SHOULD be set', $serverInfo->serverUri()));
        }
    }

    private function sendPacket(ServerInfo $serverInfo, RadiusPacket $radiusPacket): ?RadiusPacket
    {
        for ($tryCount = 0; $tryCount < $serverInfo->maxTries(); $tryCount++) {
            try {
                $this->socket->open($serverInfo->serverUri(), $serverInfo->connectionTimeout());

                return $this->socket->send($radiusPacket);
            } catch (SocketException $e) {
                $this->logger->error(sprintf('[SERVER=%s ATTEMPT_NO=%d] %s', $serverInfo->serverUri(), $tryCount + 1, $e->getMessage()));
            } finally {
                $this->socket->close();
            }
        }

        return null;
    }
}
