<?php

namespace Drupal\acquia_dam;

use Drupal\Component\Utility\Xss;
use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Site\Settings;
use Drupal\Core\State\State;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\key\KeyRepositoryInterface;
use Drupal\user\UserData;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
use Psr\Log\LoggerInterface;

/**
 * The service to authenticate on Acquia DAM.
 */
final class AcquiadamAuthService {

  use StringTranslationTrait;

  /**
   * Cache tag that is invalidated on authorization changes.
   */
  const AUTHORIZED_CACHE_TAG = 'acquia_dam_authorized';

  /**
   * The client_id used to identify Drupal module.
   *
   * @var string
   */
  const CLIENT_ID = '3b41085e6ff4d9f87307f4418bfce7ef6ed12860.app.widen.com';

  /**
   * The client_secret used to identify Drupal module.
   *
   * @var string
   */
  const CLIENT_SECRET = 'ec216e0b87f9fa5b5828da524e360196ac74ed69';

  /**
   * The logger service.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * The field plugin manager service.
   *
   * @var \Drupal\Core\Extension\ModuleHandler
   */
  protected $moduleHandler;

  /**
   * User data.
   *
   * @var \Drupal\user\UserData
   */
  protected $userData;

  /**
   * Acquia Dam configuration.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * Guzzle client.
   *
   * @var \GuzzleHttp\Client
   */
  protected $httpClient;

  /**
   * State storage.
   *
   * @var \Drupal\Core\State\State
   */
  protected $state;

  /**
   * The cache tag invalidator.
   *
   * @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface
   */
  protected $cacheTagsInvalidator;

  /**
   * Acquia DAM logger channel.
   *
   * @var \Drupal\Core\Logger\LoggerChannelInterface
   */
  protected $damLoggerChannel;

  /**
   * Key repository service.
   *
   * @var \Drupal\key\KeyRepositoryInterface|null
   */
  protected $keyRepository;

  /**
   * AuthenticationPage construct.
   *
   * @param \Drupal\user\UserData $userData
   *   User data.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory.
   * @param \GuzzleHttp\ClientInterface $httpClient
   *   Http client.
   * @param \Drupal\Core\State\State $state
   *   State storage.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
   *   Module handler.
   * @param \Psr\Log\LoggerInterface $logger
   *   The logger.
   * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cache_tags_invalidator
   *   The cache tag invalidator.
   * @param \Drupal\Core\Logger\LoggerChannelInterface $dam_logger_channel
   *   The DAM logger channel.
   * @param \Drupal\key\KeyRepositoryInterface|null $key_repository
   *   The key repository (optional).
   */
  public function __construct(UserData $userData, ConfigFactoryInterface $configFactory, ClientInterface $httpClient, State $state, ModuleHandlerInterface $moduleHandler, LoggerInterface $logger, CacheTagsInvalidatorInterface $cache_tags_invalidator, LoggerChannelInterface $dam_logger_channel, ?KeyRepositoryInterface $key_repository = NULL) {
    $this->userData = $userData;
    $this->configFactory = $configFactory;
    $this->httpClient = $httpClient;
    $this->state = $state;
    $this->moduleHandler = $moduleHandler;
    $this->logger = $logger;
    $this->cacheTagsInvalidator = $cache_tags_invalidator;
    $this->damLoggerChannel = $dam_logger_channel;
    $this->keyRepository = $key_repository;
  }

  /**
   * Checks if the DAM domain has been configured.
   *
   * @return bool
   *   Returns TRUE if the domain has been set.
   */
  public function isConfigured(): bool {
    return !empty($this->getDomain());
  }

  /**
   * Returns TRUE if configuration is set to use refresh token.
   */
  public function isUsingRefreshToken(): bool {
    return $this->configFactory->get('acquia_dam.settings')->get('auth_type') === 'refresh_token';
  }

  /**
   * Returns TRUE if configuration is set to use authorization code.
   */
  public function isUsingAuthorizationCode(): bool {
    return $this->configFactory->get('acquia_dam.settings')->get('auth_type') === 'authorization_code';
  }

  /**
   * Provides the authorization link with Acquia DAM.
   *
   * @param string $return_link
   *   The url where it should redirect after the authentication.
   *
   * @return string
   *   The absolute URL used for authorization.
   */
  public function generateAuthUrl(string $return_link): string {
    $dam_settings = $this->configFactory->get('acquia_dam.settings');
    $acquia_dam_domain = $this->getDomain();
    $client_id = $this->isUsingRefreshToken() ?
      $dam_settings->get('client_id')
      : Settings::get('acquia_dam_client_id', self::CLIENT_ID);
    return $acquia_dam_domain ?
      $this->generateEndpointUrl('/allowaccess', "?client_id=$client_id&redirect_uri=$return_link")
      : '';
  }

  /**
   * Purge Acquia DAM user authorization connection.
   *
   * @param string $access_token
   *   Acquia DAM user token.
   * @param int $user_id
   *   User id.
   */
  public function cancelUserRegistration(string $access_token, int $user_id): void {
    $this->sendLogoutRequest($access_token);
    $this->userData->delete('acquia_dam', $user_id);
  }

  /**
   * Purge Acquia DAM site authorization connection.
   *
   * @param string $access_token
   *   Acquia DAM user token.
   */
  public function cancelSiteRegistration(string $access_token): void {
    $this->state->delete('acquia_dam_token');
    $this->state->delete('acquia_dam_refresh_token');
    $config = $this->configFactory->getEditable('acquia_dam.settings');
    // Remove the key ID, auth type and domain from the configuration.
    $config->clear('key_id')->clear('auth_type')->clear('domain')->save();
    $this->cacheTagsInvalidator->invalidateTags(['acquia_dam_authorized']);
  }

  /**
   * Sends a logout request.
   *
   * @param string $access_token
   *   Access token.
   *
   * @return bool
   *   Returns TRUE on success.
   *
   * @throws \Exception
   */
  protected function sendLogoutRequest(string $access_token): bool {
    if (empty($access_token)) {
      throw new \RuntimeException('No access token was provided.');
    }

    // Initiate and process the response of the HTTP request.
    try {
      if ($this->isUsingRefreshToken()) {
        $method = 'DELETE';
        $uri = 'https://api.widencollective.com/v2/oauth/access-token';
      }
      else {
        $method = 'POST';
        $uri = $this->generateEndpointUrl('/api/rest/oauth/logout');
      }
      $this->httpClient->request($method, $uri, [
        'headers' => [
          'Authorization' => 'Bearer ' . $access_token,
        ],
      ]);
    }
    catch (ClientException $e) {
    }
    catch (ServerException $e) {
      $this->logger->error($e->getMessage());
      throw new \RuntimeException('Something wrong at the server end, try again after sometime. If the issue persist contact the site admin.');
    }

    return TRUE;
  }

  /**
   * Authenticate with Acquia DAM and saves access and refresh tokens.
   *
   * @param string $auth_code
   *   Authorization code.
   * @param int|null $user_id
   *   User id. Only provide for user authentication.
   */
  public function authenticateDam(string $auth_code, ?int $user_id = NULL): void {
    $response = $this->sendAuthRequest($auth_code);

    $this->cacheTagsInvalidator->invalidateTags(['acquia_dam_authorized']);

    $auth_info = [
      'acquia_dam_token' => $response->access_token,
      'acquia_dam_refresh_token' => $response->refresh_token ?? NULL,
      'acquia_dam_username' => $response->username ?? NULL,
    ];

    if ($user_id) {
      $this->setUserData($user_id, $auth_info);
      return;
    }

    $this->state->setMultiple($auth_info);
    if ($this->moduleHandler->moduleExists('media_acquiadam')) {
      $this->configFactory->getEditable('media_acquiadam.settings')->set('token', $response->access_token)->save();
    }

  }

  /**
   * Sends authentication request.
   *
   * @param string $auth_code
   *   Authorization code.
   *
   * @return object
   *   Response.
   */
  public function sendAuthRequest(string $auth_code): object {
    $options = [
      'headers' => [
        'Accept' => 'application/json',
        'Content-Type' => 'application/json',
      ],
    ];

    if ($this->isUsingRefreshToken()) {
      $dam_settings = $this->configFactory->get('acquia_dam.settings');
      $uri = 'https://api.widencollective.com/v2/oauth/access-token';
      $options['json'] = [
        "grant_type" => "authorization_code",
        "authorization_code" => $auth_code,
        "client_id" => $dam_settings->get('client_id'),
        "client_secret" => $dam_settings->get('client_secret'),
      ];
    }
    else {
      $uri = $this->generateEndpointUrl('/api/rest/oauth/token');
      $options['json'] = [
        'authorization_code' => $auth_code,
        'grant_type' => 'authorization_code',
      ];
      $options['auth'] = [
        Settings::get('acquia_dam_client_id', self::CLIENT_ID),
        Settings::get('acquia_dam_client_secret', self::CLIENT_SECRET),
      ];
    }

    try {
      $response = $this->httpClient->post($uri, $options);
    }
    catch (ClientException $e) {
      $this->logger->error($e->getMessage());
      throw new \RuntimeException("Something went wrong with the request, and your account can’t be connected. Contact the site administrator.");
    }
    catch (ServerException $e) {
      $this->logger->error($e->getMessage());
      throw new \RuntimeException("Something went wrong contacting Acquia DAM, and your account can’t be connected. Contact the site administrator.");
    }
    $body = json_decode($response->getBody());
    if (!isset($body->access_token)) {
      throw new \RuntimeException('Authentication response does not contain necessary information.');
    }

    return $body;
  }

  /**
   * Refreshes access token.
   *
   * @param string $refresh_token
   *   Refresh token.
   * @param int|null $user_id
   *   Which user is refreshing token. Only use if user refresh token provided.
   */
  public function refreshAccessToken(string $refresh_token, ?int $user_id = NULL) {
    try {
      $dam_settings = $this->configFactory->get('acquia_dam.settings');
      $response = $this->httpClient->post('https://api.widencollective.com/v2/oauth/access-token', [
        'json' => [
          "grant_type" => "refresh_token",
          "refresh_token" => $refresh_token,
          "client_id" => $dam_settings->get('client_id'),
          "client_secret" => $dam_settings->get('client_secret'),
        ],
        'headers' => [
          'Accept' => 'application/json',
          'Content-Type' => 'application/json',
        ],
      ]);
    }
    catch (ClientException $e) {
      $this->logger->error($e->getMessage());
      throw new \RuntimeException("Something went wrong with the request, and your account can’t be connected. Contact the site administrator.");
    }
    catch (ServerException $e) {
      $this->logger->error($e->getMessage());
      throw new \RuntimeException("Something went wrong contacting Acquia DAM, and your account can’t be connected. Contact the site administrator.");
    }
    catch (\Exception $e) {
      $this->logger->error($e->getMessage());
    }
    $body = json_decode($response->getBody());

    $info = [
      'acquia_dam_token' => $body->access_token,
      'acquia_dam_refresh_token' => $body->refresh_token,
    ];

    if ($user_id === NULL) {
      $this->state->setMultiple($info);
      if ($this->moduleHandler->moduleExists('media_acquiadam')) {
        $this->configFactory->getEditable('media_acquiadam.settings')->set('token', $body->access_token)->save();
      }
    }
    else {
      $this->setUserData($user_id, $info);
    }
  }

  /**
   * Checks if the media library should show the authorization prompt.
   *
   * This authorization prompt is only shown to the user once and if they are
   * not authenticated to the DAM with their user account.
   *
   * @param int $user_id
   *   The user ID.
   *
   * @return bool
   *   Returns TRUE if the authorization prompt should be shown.
   */
  public function shouldShowAuthorizationPrompt(int $user_id): bool {
    if (!$this->isConfigured()) {
      return FALSE;
    }
    $seen_prompt = $this->userData->get('acquia_dam', $user_id, 'seen_prompt') ?: FALSE;
    return !$seen_prompt && !$this->isAuthenticated($user_id);
  }

  /**
   * Marks that the user has seen the authorization prompt.
   *
   * @param int $user_id
   *   The user ID.
   */
  public function markAuthorizationPromptShown(int $user_id): void {
    $this->userData->set('acquia_dam', $user_id, 'seen_prompt', TRUE);
  }

  /**
   * Checks if user has stored authentication information.
   *
   * @param int $user_id
   *   User id.
   *
   * @return bool
   *   TRUE if necessary authentication information are set in user data.
   */
  public function isAuthenticated(int $user_id): bool {
    return !empty($this->userData->get('acquia_dam', $user_id, 'account'));
  }

  /**
   * Checks if site has stored authentication information.
   *
   * @return bool
   *   TRUE if necessary authentication information are set in state storage.
   */
  public function isSiteAuthenticated(): bool {
    if (!$this->isConfigured()) {
      return FALSE;
    }

    return $this->isUsingRefreshToken() ? (bool) $this->getRefreshToken() : (bool) $this->getSiteToken();
  }

  /**
   * Gets the domain from credentials with caching.
   *
   * @return string|null
   *    The domain.
   */
  public function getDomain(): ?string {
    $credentials = $this->populateKeyCredentials();
    return !empty($credentials['domain']) ? $credentials['domain'] : $this->configFactory->get('acquia_dam.settings')->get('domain');
  }

  /**
   * Returns site token from state storage or credentials with caching.
   *
   * @return string|null
   *     The site token.
   */
  public function getSiteToken(): ?string {
    $credentials = $this->populateKeyCredentials();
    return !empty($credentials['token']) ? $credentials['token'] : $this->state->get('acquia_dam_token');
  }

  /**
   * Returns the authentication type.
   *
   * @return string
   *  The authentication type.
   */
  public function getAuthType(): string {
    return $this->configFactory->get('acquia_dam.settings')->get('auth_type');
  }

  /**
   * Get token from user data.
   *
   * @return string
   *   DAM token.
   */
  public function getUserAccessToken(int $user_id): ?string {
    $acquia_dam_account = $this->getUserData($user_id);
    return $acquia_dam_account['acquia_dam_token'] ?? NULL;
  }

  /**
   * Returns user refresh token.
   */
  public function getUserRefreshToken(int $user_id): string {
    $oauth_info = $this->getUserData($user_id);
    return $oauth_info['acquia_dam_refresh_token'] ?? '';
  }

  /**
   * Returns site refresh token.
   */
  public function getRefreshToken(): string {
    return $this->state->get('acquia_dam_refresh_token') ?? '';
  }

  /**
   * Sets data in user_data.
   *
   * @param int $user_id
   *   User Id.
   * @param array $data
   *   Data array to store on user object.
   */
  public function setUserData(int $user_id, array $data): void {
    if ($this->moduleHandler->moduleExists('media_acquiadam')) {
      $account = [
        'acquiadam_username' => $data['acquia_dam_username'],
        'acquiadam_token' => $data['acquia_dam_token'],
      ];
      // Store acquiadam account details.
      $this
        ->userData
        ->set('media_acquiadam', $user_id, 'account', $account);
    }

    $this->userData->set('acquia_dam', $user_id, 'account', $data);
  }

  /**
   * Gets data from user_data.
   *
   * @param int $user_id
   *   The user ID.
   *
   * @return array
   *   The user data.
   */
  public function getUserData(int $user_id): array {
    return $this->userData->get('acquia_dam', $user_id, 'account') ?: [];
  }

  /**
   * Generates DAM endpoint to ping.
   *
   * @param string $path
   *   Path string to concatenate to domain.
   * @param string $query_string
   *   Query string to concatenate to path. Optional parameter.
   *   Example: "?client_id=13".
   *
   * @return string
   *   Endpoint to send request to.
   */
  private function generateEndpointUrl(string $path, string $query_string = ''): string {
    return 'https://' . $this->getDomain() . $path . $query_string;
  }

  /**
   * Disconnect site before new domain register, user tokens as well.
   */
  public function disconnectSiteAndUsers(): void {
    $this->cancelSiteRegistration($this->getSiteToken());

    foreach ($this->userData->get('acquia_dam') as $uid => $account_info) {
      // Only delete from local storage.
      $this->userData->delete('acquia_dam', (int) $uid);
    }
  }

  /**
   * Check if Key module is available.
   *
   * @return bool
   *   TRUE if Key module is installed and available.
   */
  public function isKeyModuleAvailable(): bool {
    return $this->moduleHandler->moduleExists('key') && $this->keyRepository !== NULL;
  }

  /**
   * Get the credentials for the key selected.
   *
   * @return array
   *   The key credentials as an associative array.
   */
  private function populateKeyCredentials(): array {
    $config = $this->configFactory->get('acquia_dam.settings');

    if ($this->isKeyModuleAvailable() && $config->get('auth_type') === 'key_based') {
      $key_id = $config->get('key_id');

      // If key is not found, log an error and return empty string.
      // This is to ensure that the key ID is valid and the key exists.
      // This is important for the Acquia DAM integration to work properly.
      if (!$key_id) {
        $this->damLoggerChannel->error('Acquia DAM key not found for key ID: @key_id', ['@key_id' => $key_id]);
        return [];
      }

      $key = $this->keyRepository->getKey($key_id);
      if ($key) {
        $credentials = $key->getKeyValue();
        // If the key value is already an array, return as-is.
        if (is_array($credentials)) {
          return $credentials;
        }
        // Attempt to decode JSON string, return array or empty array on failure.
        if (is_string($credentials) && !empty($credentials)) {
          $decoded_credentials = json_decode($credentials, TRUE);
          $decoded_credentials['domain'] = !empty($decoded_credentials['domain']) ? Xss::filter(rtrim(preg_replace('#^https?://#', '', $decoded_credentials['domain']))) : '';

          return $decoded_credentials;
        }
      }
    }

    return [];
  }

}
