<?php

declare(strict_types=1);

/**
 * @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
 *
 * @author Christoph Wurst <christoph@winzerhof-wurst.at>
 * @author Roeland Jago Douma <roeland@famdouma.nl>
 *
 * @license GNU AGPL version 3 or any later version
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 *
 */

namespace OC\Authentication\WebAuthn;

use Cose\Algorithm\Signature\ECDSA\ES256;
use Cose\Algorithm\Signature\RSA\RS256;
use Cose\Algorithms;
use GuzzleHttp\Psr7\ServerRequest;
use OC\Authentication\WebAuthn\Db\PublicKeyCredentialEntity;
use OC\Authentication\WebAuthn\Db\PublicKeyCredentialMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IConfig;
use OCP\ILogger;
use OCP\IUser;
use Webauthn\AttestationStatement\AttestationObjectLoader;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialLoader;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\TokenBinding\TokenBindingNotSupportedHandler;

class Manager {

	/** @var CredentialRepository */
	private $repository;

	/** @var PublicKeyCredentialMapper */
	private $credentialMapper;

	/** @var ILogger */
	private $logger;

	/** @var IConfig */
	private $config;

	public function __construct(
		CredentialRepository $repository,
		PublicKeyCredentialMapper $credentialMapper,
		ILogger $logger,
		IConfig $config
	) {
		$this->repository = $repository;
		$this->credentialMapper = $credentialMapper;
		$this->logger = $logger;
		$this->config = $config;
	}

	public function startRegistration(IUser $user, string $serverHost): PublicKeyCredentialCreationOptions {
		$rpEntity = new PublicKeyCredentialRpEntity(
			'Nextcloud', //Name
			$this->stripPort($serverHost),        //ID
			null                            //Icon
		);

		$userEntity = new PublicKeyCredentialUserEntity(
			$user->getUID(),                              //Name
			$user->getUID(),                              //ID
			$user->getDisplayName()                      //Display name
//            'https://foo.example.co/avatar/123e4567-e89b-12d3-a456-426655440000' //Icon
		);

		$challenge = random_bytes(32);

		$publicKeyCredentialParametersList = [
			new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_ES256),
			new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_RS256),
		];

		$timeout = 60000;

		$excludedPublicKeyDescriptors = [
		];

		$authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria();

		return new PublicKeyCredentialCreationOptions(
			$rpEntity,
			$userEntity,
			$challenge,
			$publicKeyCredentialParametersList,
			$timeout,
			$excludedPublicKeyDescriptors,
			$authenticatorSelectionCriteria,
			PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
			null
		);
	}

	public function finishRegister(PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, string $name, string $data): PublicKeyCredentialEntity {
		$tokenBindingHandler = new TokenBindingNotSupportedHandler();

		$attestationStatementSupportManager = new AttestationStatementSupportManager();
		$attestationStatementSupportManager->add(new NoneAttestationStatementSupport());

		$attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager);
		$publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader);

		// Extension Output Checker Handler
		$extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler();

		// Authenticator Attestation Response Validator
		$authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator(
			$attestationStatementSupportManager,
			$this->repository,
			$tokenBindingHandler,
			$extensionOutputCheckerHandler
		);

		try {
			// Load the data
			$publicKeyCredential = $publicKeyCredentialLoader->load($data);
			$response = $publicKeyCredential->getResponse();

			// Check if the response is an Authenticator Attestation Response
			if (!$response instanceof AuthenticatorAttestationResponse) {
				throw new \RuntimeException('Not an authenticator attestation response');
			}

			// Check the response against the request
			$request = ServerRequest::fromGlobals();

			$publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
				$response,
				$publicKeyCredentialCreationOptions,
				$request);
		} catch (\Throwable $exception) {
			throw $exception;
		}

		// Persist the data
		return $this->repository->saveAndReturnCredentialSource($publicKeyCredentialSource, $name);
	}

	private function stripPort(string $serverHost): string {
		return preg_replace('/(:\d+$)/', '', $serverHost);
	}

	public function startAuthentication(string $uid, string $serverHost): PublicKeyCredentialRequestOptions {
		// List of registered PublicKeyCredentialDescriptor classes associated to the user
		$registeredPublicKeyCredentialDescriptors = array_map(function (PublicKeyCredentialEntity $entity) {
			$credential = $entity->toPublicKeyCredentialSource();
			return new PublicKeyCredentialDescriptor(
				$credential->getType(),
				$credential->getPublicKeyCredentialId()
			);
		}, $this->credentialMapper->findAllForUid($uid));

		// Public Key Credential Request Options
		return new PublicKeyCredentialRequestOptions(
			random_bytes(32),                                                    // Challenge
			60000,                                                              // Timeout
			$this->stripPort($serverHost),                                                                  // Relying Party ID
			$registeredPublicKeyCredentialDescriptors                                  // Registered PublicKeyCredentialDescriptor classes
		);
	}

	public function finishAuthentication(PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, string $data, string $uid) {
		$attestationStatementSupportManager = new AttestationStatementSupportManager();
		$attestationStatementSupportManager->add(new NoneAttestationStatementSupport());

		$attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager);
		$publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader);

		$tokenBindingHandler = new TokenBindingNotSupportedHandler();
		$extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler();
		$algorithmManager = new \Cose\Algorithm\Manager();
		$algorithmManager->add(new ES256());
		$algorithmManager->add(new RS256());

		$authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator(
			$this->repository,
			$tokenBindingHandler,
			$extensionOutputCheckerHandler,
			$algorithmManager
		);

		try {
			$this->logger->debug('Loading publickey credentials from: ' . $data);

			// Load the data
			$publicKeyCredential = $publicKeyCredentialLoader->load($data);
			$response = $publicKeyCredential->getResponse();

			// Check if the response is an Authenticator Attestation Response
			if (!$response instanceof AuthenticatorAssertionResponse) {
				throw new \RuntimeException('Not an authenticator attestation response');
			}

			// Check the response against the request
			$request = ServerRequest::fromGlobals();

			$publicKeyCredentialSource = $authenticatorAssertionResponseValidator->check(
				$publicKeyCredential->getRawId(),
				$response,
				$publicKeyCredentialRequestOptions,
				$request,
				$uid
			);
		} catch (\Throwable $e) {
			throw $e;
		}



		return true;
	}

	public function deleteRegistration(IUser $user, int $id): void {
		try {
			$entry = $this->credentialMapper->findById($user->getUID(), $id);
		} catch (DoesNotExistException $e) {
			$this->logger->warning("WebAuthn device $id does not exist, can't delete it");
			return;
		}

		$this->credentialMapper->delete($entry);
	}

	public function isWebAuthnAvailable(): bool {
		if (!extension_loaded('bcmath')) {
			return false;
		}

		if (!extension_loaded('gmp')) {
			return false;
		}

		if (!$this->config->getSystemValueBool('auth.webauthn.enabled', true)) {
			return false;
		}

		return true;
	}
}
