<?php

declare(strict_types=1);

namespace Drupal\Tests\netforum\xWeb;

use Drupal\netforum\xWeb\Generated\StructType\Authenticate;
use Drupal\netforum\xWeb\Generated\StructType\AuthenticateResponse;
use Drupal\netforum\xWeb\Generated\StructType\AuthorizationToken;
use Drupal\netforum\xWeb\Generated\StructType\GetDateTimeResponse;
use Drupal\netforum\xWeb\Generated\StructType\GetTimeZonesResponse;
use Drupal\netforum\xWeb\Generated\StructType\GetTimeZonesResult;
use Drupal\netforum\xWeb\Generated\StructType\GetVersionResponse;
use Drupal\netforum\xWeb\Generated\StructType\TestConnectionResponse;
use Drupal\netforum\xWeb\Generated\StructType\VersionClass;
use Drupal\netforum\xWeb\Generated\StructType\XWebVersion;

/**
 * Mock NetForum xWeb XML Web Service.
 *
 * Pretends to be a NetForum xWeb XML Web Service with some operations
 * implemented for testing. SoapFaults are thrown with the first line of the
 * message matching NetForum and the following line with more details to help.
 *
 * @phpcs:disable Drupal.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
 */
class NetForumXmlHandler {

  protected const XML_NAMESPACE = 'http://www.avectra.com/2005/';

  protected readonly \SoapServer $soapServer;

  protected readonly NetForumXmlSettings $settings;

  protected JsonStateStore $stateStore;

  private ?AuthorizationToken $authorizationToken = NULL;

  public function __construct(\SoapServer $server, NetForumXmlSettings $settings, JsonStateStore $jsonStateStore) {
    $this->soapServer = $server;
    $this->settings = $settings;
    $this->stateStore = $jsonStateStore;
  }

  /**
   * When an AuthorizationToken SOAP Header is included, this method is called
   * before the method in this class that matches the SOAP function is called.
   *
   * @throws \SoapFault
   */
  public function AuthorizationToken(AuthorizationToken $token): void {
    // Mimic xWeb's handling of Authorization Token.
    if ($token->getToken() === NULL) {
      throw new \SoapFault('soap:Server', "System.Web.Services.Protocols.SoapException: An error occurred on the server.\nAuthorization Token is missing.");
    }

    // If an invalid GUID is received, return Locked.
    if (!static::isValidGuid($token->getToken())) {
      throw new \SoapFault('soap:Client', "System.Web.Services.Protocols.SoapException: Locked\nInvalid GUID received.");
    }

    // If the token does not exist in our state store, it may have never been
    // created or expired and cleaned up.
    if (!isset($this->stateStore[$token->getToken()])) {
      throw new \SoapFault('soap:Client', "System.Web.Services.Protocols.SoapException: Failed\nToken is not in state store.");
    }

    $this->authorizationToken = $token;
  }

  protected static function isValidGuid(string $guid): bool {
    return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $guid) === 1;
  }

  /**
   * This normally tests for a successful connection to the database. We have
   * no DB, so we will always return 'Success'.
   */
  public function TestConnection(): TestConnectionResponse {
    $response = new TestConnectionResponse();
    $response->setTestConnectionResult('Success');

    return $response;
  }

  /**
   * Pretend to Authenticate a user and create a new session.
   *
   * @throws \SoapFault
   * @throws \Random\RandomException
   */
  public function Authenticate(Authenticate $authenticate): AuthenticateResponse {
    if ($authenticate->getUserName() === $this->settings->xWebUsername && $authenticate->getPassword() === $this->settings->xWebPassword) {
      // Happens when an account is locked due to hitting the fail count.
      if ($this->settings->authCredentialsLockedOut) {
        throw new \SoapFault('soap:Client', 'System.Web.Services.Protocols.SoapException: Credentials Locked Out');
      }

      $this->authorizationToken = new AuthorizationToken($this->newAuthorization());
      $this->soapServer->addSoapHeader(new \SoapHeader(self::XML_NAMESPACE, 'AuthorizationToken', $this->authorizationToken));

      return new AuthenticateResponse(static::XML_NAMESPACE);
    }

    throw new \SoapFault('soap:Client', 'System.Web.Services.Protocols.SoapException: Invalid Credentials Supplied');
  }

  /**
   * Creates a new Authorization token and save it in the state store.
   *
   * @throws \Random\RandomException
   */
  private function newAuthorization(): string {
    $token = static::newGuid();

    $this->stateStore[$token] = [
      'Expiration' => time() + $this->settings->authTokenExpiration,
    ];

    return $token;
  }

  /**
   * Generates a new GUID.
   *
   * @throws \Random\RandomException
   */
  protected static function newGuid(): string {
    $data = random_bytes(16);

    // Set version to 0100 (version 4)
    $data[6] = chr((ord($data[6]) & 0x0f) | 0x40);

    // Set bits 6-7 to 10 (variant 1, RFC 4122)
    $data[8] = chr((ord($data[8]) & 0x3f) | 0x80);

    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
  }

  /**
   * Returns a fake NetForum version.
   *
   * @throws \SoapFault
   * @throws \Random\RandomException
   */
  public function GetVersion(): GetVersionResponse {
    $this->checkAuthorization(__FUNCTION__);

    return new GetVersionResponse(new XWebVersion('netFORUM Mock, 1.0.0.0', new VersionClass('netFORUM Mock', '1.0.0.0'), 'Mock Server', 'Mock Database'));
  }

  /**
   * Checks if the SOAP function call is authorized.
   *
   * @throws \SoapFault
   * @throws \Random\RandomException
   */
  private function checkAuthorization(string $soapFunction): void {
    // If the Authorization token is missing, it was likely not included in the
    // SOAP header and AuthorizationToken() was not called.
    if ($this->authorizationToken === NULL) {
      throw new \SoapFault('soap:Server', "System.Web.Services.Protocols.SoapException: An error occurred on the server.\nAuthorization Token is missing.");
    }

    if (in_array($soapFunction, $this->settings->authFailFunctionList)) {
      throw new \SoapFault('soap:Client', "System.Web.Services.Protocols.SoapException: Failed\nFunction is on Fail Function List");
    }

    if (in_array($soapFunction, $this->settings->authLockFunctionList)) {
      throw new \SoapFault('soap:Client', "System.Web.Services.Protocols.SoapException: Locked\nFunction is on Lock Function List");
    }

    $token = $this->authorizationToken->getToken();
    if ($token === NULL) {
      throw new \LogicException('Authorization token should not be null.');
    }

    if (empty($this->stateStore[$token]['Expiration'])) {
      throw new \SoapFault('soap:Client', "System.Web.Services.Protocols.SoapException: Failed\nToken is missing.");
    }
    if ($this->stateStore[$token]['Expiration'] < time()) {
      throw new \SoapFault('soap:Client', "System.Web.Services.Protocols.SoapException: Failed\nToken has expired.");
    }

    // If the Auth Expiration Policy is set to sliding, a new token must be
    // returned after SOAP function calls to be used for future calls.
    // The current token will no longer be valid.
    if ($this->settings->authExpirationPolicy === AuthExpirationPolicy::Sliding) {
      $this->removeAuthorization($token);

      $this->authorizationToken = new AuthorizationToken($this->newAuthorization());
    }

    $this->soapServer->addSoapHeader(new \SoapHeader(static::XML_NAMESPACE, 'AuthorizationToken', $this->authorizationToken));
  }

  private function removeAuthorization(string $token): void {
    unset($this->stateStore[$token]);
  }

  /**
   * Returns the date and time in ISO 8601 format, same as xWeb.
   */
  public function GetDateTime(): GetDateTimeResponse {
    return new GetDateTimeResponse(date('Y-m-d\TH:i:s.vP'));
  }

  /**
   * Returns a list of time zones.
   *
   * @throws \SoapFault
   * @throws \Random\RandomException
   */
  public function GetTimeZones(): GetTimeZonesResponse {
    $this->checkAuthorization(__FUNCTION__);

    $timeZones = <<<ENDL
      <Results recordReturn="3">
        <Result>
          <tzn_key>5f5e2b6f-9c3e-4a7b-bf6e-17a9625fbe31</tzn_key>
          <tzn_time_zone>(GMT-05:00) Eastern Time (US &amp; Canada)</tzn_time_zone>
        </Result>
        <Result>
          <tzn_key>c1d0a55a-28b0-4f6e-9253-53cf8b5614ac</tzn_key>
          <tzn_time_zone>(GMT-08:00) Pacific Time (US &amp; Canada)</tzn_time_zone>
        </Result>
        <Result>
          <tzn_key>d3f48b6f-d4a8-4b8a-9fc0-611c63cb7e9b</tzn_key>
          <tzn_time_zone>(GMT-07:00) Mountain Time (US &amp; Canada)</tzn_time_zone>
        </Result>
      </Results>
    ENDL;
    $getTimeZoneResult = new GetTimeZonesResult();
    $getTimeZoneResult->setAny($timeZones);

    return new GetTimeZonesResponse($getTimeZoneResult);
  }

}
