<?php

declare(strict_types=1);

namespace Drupal\acquia_dam\Client;

use Drupal\acquia_dam\Exception\DamClientException;
use Drupal\acquia_dam\Exception\DamConnectException;
use Drupal\acquia_dam\Exception\DamServerException;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;

/**
 * The client service for interacting with the DAM API.
 */
class AcquiaDamClient extends Client {

  use StringTranslationTrait;

  /**
   * The cache backend.
   *
   * @var \Drupal\Core\Cache\CacheBackendInterface
   */
  private $cacheBackend;

  /**
   * The time service.
   *
   * @var \Drupal\Component\Datetime\TimeInterface
   */
  protected $time;

  /**
   * Acquia Dam Config.
   *
   * @var \Drupal\Core\Config\ImmutableConfig
   */
  protected $acquiaDamConfig;

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

  /**
   * The messenger service.
   *
   * @var \Drupal\Core\Messenger\MessengerInterface
   */
  protected $messenger;

  /**
   * The date formatter service.
   *
   * @var \Drupal\Core\Datetime\DateFormatterInterface
   */
  protected $dateFormatter;

  /**
   * The expand parameter.
   *
   * @var string
   *   The expand parameters.
   *
   */
  protected string $expand = 'asset_properties,embeds,file_properties,metadata,metadata_info,metadata_vocabulary,security,thumbnails';

  /**
   * The source key mapping id for category and uuid for asset groups.
   *
   * @var array
   *   The source key mapping.
   *
   * @todo make this configurable, maybe?
   */
  protected array $sourceMapping = [
    'category' => 'id',
    'asset_group' => 'uuid',
  ];

  /**
   * The DAM domain.
   *
   * @var string
   */
  protected $domain;

  /**
   * Constructs a new Client object.
   *
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
   *   The cache backend.
   * @param \Drupal\Component\Datetime\TimeInterface $time
   *   The time service.
   * @param \Drupal\Core\Config\ImmutableConfig $acquiaDamConfig
   *   Acquia DAM config.
   * @param array $config
   *   Client config array.
   * @param \Psr\Log\LoggerInterface $loggerChannel
   *   DAM logger channel.
   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
   *   The messenger service.
   * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter
   *   The date formatter service.
   * @param string|null $domain
   *   The DAM domain.
   */
  public function __construct(CacheBackendInterface $cache_backend, TimeInterface $time, ImmutableConfig $acquiaDamConfig, array $config, LoggerInterface $loggerChannel, MessengerInterface $messenger, DateFormatterInterface $dateFormatter, ?string $domain = null) {
    $this->cacheBackend = $cache_backend;
    $this->time = $time;
    $this->acquiaDamConfig = $acquiaDamConfig;
    $this->damLoggerChannel = $loggerChannel;
    $this->messenger = $messenger;
    $this->dateFormatter = $dateFormatter;
    $this->domain = $domain ?? $this->acquiaDamConfig->get('domain');

    parent::__construct($config);
  }

  /**
   * Search assets.
   *
   * @param string $query
   *   The query.
   * @param string $sort
   *   The sort.
   * @param int $limit
   *   The limit.
   * @param int $offset
   *   The offset.
   * @param string[] $expand
   *   The expands.
   * @param bool $include_deleted
   *   Whether to include deleted assets.
   * @param bool $scroll
   *   The scroll flag, if true will return a scroll_id for paginated results.
   * @param bool $released
   *   Whether to filter by active release/expiration status.
   *
   * @return array
   *   The results.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   */
  public function search(
    string $query,
    string $sort = '-created_date',
    int $limit = 100,
    int $offset = 0,
    array $expand = ['file_properties'],
    bool $include_deleted = FALSE,
    bool $scroll = FALSE,
    bool $released = FALSE,
  ): array {
    try {
      if ($released) {
        $query = trim($query) . ' ' . $this->getAssetIsActiveCondition();
      }
      $query = \http_build_query([
        'query' => $query,
        'sort' => $sort,
        'limit' => $limit,
        'offset' => $offset,
        'expand' => implode(',', $expand),
        'include_deleted' => $include_deleted ? 'true' : 'false',
        'scroll' => $scroll ? 'true' : 'false',
      ]);
      $response = $this->get('/v2/assets/search?' . $query);
      $data = (string) $response->getBody();
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }

    return Json::decode($data);
  }

  /**
   * Fetches the next page of a scroll search session.
   *
   * The Widen scroll continuation endpoint requires the same query parameters
   * as the initial search request (query, sort, limit, expand,
   * include_deleted) in addition to the scroll_id. Omitting them causes the
   * API to return null values for those fields and an empty items array,
   * which silently terminates pagination after the first page.
   *
   * @param string $scroll_id
   *   The scroll ID returned by the previous search or scroll response.
   * @param string $query
   *   The original search query string.
   * @param string $sort
   *   The sort order used in the initial search.
   * @param int $limit
   *   The page size used in the initial search.
   * @param array $expand
   *   The expand fields used in the initial search.
   * @param bool $include_deleted
   *   Whether deleted assets are included.
   *
   * @return array
   *   The decoded API response containing 'items', 'scroll_id', etc.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   */
  public function searchWithScroll(
    string $scroll_id,
    string $query = '',
    string $sort = '-created_date',
    int $limit = 100,
    array $expand = ['file_properties'],
    bool $include_deleted = FALSE,
  ): array {
    try {
      $params = [
        'scroll_id' => $scroll_id,
        'sort' => $sort,
        'limit' => $limit,
        'expand' => implode(',', $expand),
        'include_deleted' => $include_deleted ? 'true' : 'false',
      ];
      // Only include query when provided; the API accepts it being absent on
      // pure scroll-by-id requests but requires it to restore sort/limit context
      // when those fields would otherwise default to null.
      if ($query !== '') {
        $params['query'] = $query;
      }
      $response = $this->get('/v2/assets/search/scroll?' . \http_build_query($params));
      $data = (string) $response->getBody();
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }

    return Json::decode($data);
  }

  /**
   * Initiates a scroll search and yields item pages one at a time.
   *
   * Uses the Widen scroll API to lazily retrieve large result sets without
   * loading all results into memory at once. Each iteration yields one page
   * of items as an array.
   *
   * @param string $query
   *   The search query.
   * @param string $sort
   *   The sort order. Defaults to '-created_date'.
   * @param int $limit
   *   Items per page (max 100 per Widen API). Defaults to 100.
   * @param array $expand
   *   Fields to expand. Defaults to ['file_properties'].
   * @param bool $include_deleted
   *   Whether to include deleted assets. Defaults to FALSE.
   *
   * @yields array
   *   One page of items (the 'items' array from the Widen API response).
   *
   * @throws \InvalidArgumentException
   *   If called with offset > 0 (incompatible with scroll).
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   *   If the API returns an error flag or invalid scroll_id.
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   */
  public function scrollSearch(
    string $query,
    string $sort = '-created_date',
    int $limit = 100,
    array $expand = ['file_properties'],
    bool $include_deleted = FALSE,
  ): \Generator {
    // Scroll is incompatible with non-zero offsets.
    // Initial request always uses offset=0 and scroll=TRUE.
    $response = $this->search($query, $sort, $limit, 0, $expand, $include_deleted, TRUE);

    if (empty($response['items'])) {
      return;
    }

    yield $response['items'];

    // Continue fetching subsequent scroll pages until exhausted.
    while (!empty($response['scroll_id'])) {
      // Pass the original query parameters so the API restores sort, limit,
      // expand and include_deleted context — without them those fields default
      // to null and the response returns empty items, silently ending pagination.
      $response = $this->searchWithScroll(
        $response['scroll_id'],
        $query,
        $sort,
        $limit,
        $expand,
        $include_deleted,
      );

      // Check for API-level error flag in the JSON body.
      if (isset($response['error']) && $response['error'] === TRUE) {
        throw new DamClientException(
          $response['message'] ?? 'Scroll search returned an error response from the DAM API.'
        );
      }

      if (empty($response['items'])) {
        break;
      }

      yield $response['items'];
    }
  }

  /**
   * Get asset data from Widen via the "Retrieve by ID" endpoint.
   *
   * @param string $asset_id
   *   The asset ID.
   * @param string $version_id
   *   (optional) The asset version ID.
   *
   * @return array|int
   *   The asset data as an array. 404 as a number if the DAM asset is deleted.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   *
   * @see https://widenv2.docs.apiary.io/#reference/assets/assets/retrieve-by-id
   */
  public function getAsset(string $asset_id, string $version_id = ''): array|int {
    $cache_key = 'asset:';
    $raw_cached_data = $this->readFromCache($cache_key . $asset_id);

    if ($raw_cached_data) {
      return Json::decode($raw_cached_data);
    }

    try {
      $response = $this->get('/v2/assets/' . $asset_id . '?expand=' . $this->expand);
    }
    catch (\Exception $e) {
      // Get status code from response.
      if ($e instanceof ClientException && $e->getResponse() && $e->getResponse()->getStatusCode() === 404) {
        // If the asset is not found, return an empty array.
        return [];
      }
      $this->throwDamException($e);
    }

    assert($response instanceof ResponseInterface);
    $raw_data = (string) $response->getBody();
    $this->writeToCache($cache_key, $raw_data, $asset_id, $version_id);

    return Json::decode($raw_data);
  }

  /**
   * Get all versions of assets.
   *
   * Get the assets all versions from Widen via
   * the "Retrieve by ID and version" endpoint.
   *
   * @param string $asset_id
   *   The asset ID.
   *
   * @return array|null
   *   The asset data, or null if not found.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   *
   * @see https://widenv2.docs.apiary.io/#reference/assets/assets/retrieve-by-id-and-version
   */
  public function getAssetVersions(string $asset_id): ?array {
    try {
      $response = $this->get('/v2/assets/' . $asset_id . '/versions');
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }

    assert($response instanceof ResponseInterface);
    $raw_data = (string) $response->getBody();

    return Json::decode($raw_data)['items'] ?: NULL;
  }

  /**
   * Gets the version list for an asset.
   *
   * @param string $id
   *   The asset ID.
   *
   * @return array
   *   The asset's version data.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   *
   * @deprecated in acquia_dam:1.0.14 and is removed from acquia_dam:1.2.0.
   * The getAssetVersionList method of V1 API will be removed, we recommend
   * using the getAssetVersions method instead with V2 API.
   * @see https://www.drupal.org/project/acquia_dam/issues/3462318
   */
  public function getAssetVersionList(string $id): array {
    @trigger_error('AcquiaDamClient::getAssetVersionList() is deprecated in acquia_dam:1.0.14 and is removed from acquia_dam:1.2.0. The getAssetVersionList method of V1 API will be removed, we recommend using the getAssetLatestVersion method instead with V2 API. See https://www.drupal.org/project/acquia_dam/issues/3462318', E_USER_DEPRECATED);
    try {
      $response = $this->get("https://$this->domain/api/rest/asset/uuid/$id/assetversions");
      $data = (string) $response->getBody();
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }

    return Json::decode($data);
  }

  /**
   * Gets all assets within a given category.
   *
   * @param string $category_uuid
   *   Widen's UUID of the category itself.
   * @param string $filters
   *   The filters.
   *
   * @return array
   *   Flat array of all asset items across all scroll pages.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   *
   * @deprecated in acquia_dam:1.1.15 and is removed from acquia_dam:1.2.0.
   *   Use scrollSearch() with a 'cat:($category_name) $filters' query instead,
   *   which lazily yields one page at a time and avoids loading all results
   *   into memory at once.
   * @see \Drupal\acquia_dam\Client\AcquiaDamClient::scrollSearch()
   */
  public function getAssetsInCategory(string $category_uuid, string $filters = ''): array {
    @trigger_error('AcquiaDamClient::getAssetsInCategory() is deprecated in acquia_dam:1.1.15 and is removed from acquia_dam:1.2.0. Use scrollSearch() with a cat:($category_name) query instead. See \Drupal\acquia_dam\Client\AcquiaDamClient::scrollSearch()', E_USER_DEPRECATED);
    $category_name = $this->convertCategoryUuidToName($category_uuid);
    $query = trim("cat:($category_name) $filters");
    $all_items = [];
    foreach ($this->scrollSearch($query) as $page_items) {
      $all_items = array_merge($all_items, $page_items);
    }
    return $all_items;
  }

  /**
   * Gets all assets within a given asset group.
   *
   * @param string $asset_group_uuid
   *   Widen's UUID of the asset group itself.
   * @param string $filters
   *   The filters.
   *
   * @return array
   *   The results from the single search page.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   *
   * @deprecated in acquia_dam:1.1.15 and is removed from acquia_dam:1.2.0.
   *   Use scrollSearch() with an 'assetgroup:($asset_group_name) $filters'
   *   query instead, which lazily yields one page at a time and avoids loading
   *   all results into memory at once.
   * @see \Drupal\acquia_dam\Client\AcquiaDamClient::scrollSearch()
   */
  public function getAssetsInAssetGroup(string $asset_group_uuid, string $filters = ''): array {
    @trigger_error('AcquiaDamClient::getAssetsInAssetGroup() is deprecated in acquia_dam:1.1.15 and is removed from acquia_dam:1.2.0. Use scrollSearch() with an assetgroup:($asset_group_name) query instead. See \Drupal\acquia_dam\Client\AcquiaDamClient::scrollSearch()', E_USER_DEPRECATED);
    $asset_group_name = $this->convertAssetGroupUuidToName($asset_group_uuid);

    return $this->search("assetgroup:($asset_group_name) $filters");
  }

  /**
   * Sends an integration link create request to DAM.
   *
   * @param string $asset_id
   *   DAM asset id.
   * @param string $url
   *   Url to set for the integration link.
   * @param string $description
   *   Description for the integration link.
   *
   * @return array|null
   *   Response body, or null.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   * @throws \GuzzleHttp\Exception\GuzzleException
   */
  public function registerIntegrationLink(string $asset_id, string $url, string $description): ?array {
    try {
      $response = $this->request('POST', "https://$this->domain/api/rest/integrationlink",
        [
          'json' => [
            'assetUuid' => $asset_id,
            'description' => $description,
            'url' => $url,
          ],
        ]
      );

      $data = (string) $response->getBody();
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }

    return Json::decode($data);
  }

  /**
   * Removes integration link from DAM.
   *
   * @param string $intlink_uuid
   *   Integration link uuid.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \GuzzleHttp\Exception\GuzzleException
   */
  public function removeIntegrationLink(string $intlink_uuid): void {
    try {
      $this->request('DELETE', "https://$this->domain/api/rest/integrationlink/$intlink_uuid");
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }
  }

  /**
   * Fetch the metadata according the field type.
   *
   * @param string $field_types
   *   Field type of the metadata field.
   *
   * @return array|null
   *   List of the field available under a particular field type.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   *
   * @deprecated in acquia_dam:1.1.14 and is removed from acquia_dam:1.2.0. Use
   *   \Drupal\acquia_dam\Client\AcquiaDamClient::getDisplayKeysV1() instead,
   *   which fetches metadata field definitions from the V1 API and
   *   normalises the response to the same shape.
   *
   * @see \Drupal\acquia_dam\Client\AcquiaDamClient::getDisplayKeysV1()
   */
  public function getDisplayKeys(string $field_types): ?array {
    @trigger_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated in acquia_dam:1.1.14 and is removed from acquia_dam:1.2.0. Use ' . __CLASS__ . '::getDisplayKeysV1() instead. See https://www.drupal.org/project/acquia_dam/issues/3583808.', E_USER_DEPRECATED);
    try {
      $response = $this->get('/v2/metadata/fields/viewable?field_types=' . $field_types);
      $data = (string) $response->getBody();
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }

    return Json::decode($data);
  }

  /**
   * Fetch all metadata field definitions from the V1 domain API.
   *
   * Calls GET https://{domain}/api/rest/metadata/types and normalises the
   * response into the same shape as getDisplayKeys() so that all callers can
   * use it without further changes:
   * @code
   * ['fields' => [['display_key' => '...', 'display_name' => '...', 'field_type' => '...']]]
   * @endcode
   *
   * @return array|null
   *   Normalised list of metadata fields, or NULL on failure.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   */
  public function getDisplayKeysV1(): ?array {
    try {
      $response = $this->request('GET', "https://$this->domain/api/rest/metadata/types");
      $data = Json::decode((string) $response->getBody());
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }

    return $this->normalizeV1MetadataResponse($data ?? []);
  }

  /**
   * Normalizes a V1 /api/rest/metadata/types response to the V2 field shape.
   *
   * V1 response structure:
   * @code
   * ['types' => [['fields' => [['displayKey' => '...', 'displayName' => '...', 'discriminator' => '...']]]]]
   * @endcode
   *
   * Mapped output (matches V2 getDisplayKeys() response shape):
   * @code
   * ['fields' => [['display_key' => '...', 'display_name' => '...', 'field_type' => '...']]]
   * @endcode
   *
   * @param array $response
   *   Decoded JSON array from the V1 API.
   *
   * @return array
   *   Normalised fields array.
   */
  private function normalizeV1MetadataResponse(array $response): array {
    // Discriminator → field_type mapping.
    $discriminator_map = [
      'Date'               => 'datetime',
      'LimitedText'        => 'text_short',
      'LongText'           => 'text_long',
      'TextArea'           => 'text_multi_line',
      'Numeric'            => 'numeric',
      'PropertySelection'  => 'selection_list',
      'PaletteField'       => 'selection_list_multi',
      'CheckboxGroup'      => 'checkboxes',
    ];

    $fields = [];
    assert(isset($response['types']), 'Metadata API response must contain a "types" key.');
    foreach ($response['types'] as $type) {
      assert(isset($type['fields']), 'Each type in the metadata API response must contain a "fields" key.');
      foreach ($type['fields'] as $field) {
        $fields[] = [
          'display_key'  => $field['displayKey'] ?? '',
          'display_name' => $field['displayName'] ?? '',
          'field_type'   => $discriminator_map[$field['discriminator'] ?? ''] ?? 'text',
        ];
      }
    }

    return ['fields' => $fields];
  }

  /**
   * Fetch the vocabulary of a particular display key.
   *
   * @param string $display_key
   *   Display key of the field.
   *
   * @return array|null
   *   List of the available vocabulary items under the display key.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   */
  public function getDisplayKeyVocabulary(string $display_key): ?array {
    try {
      $response = $this->get("/v2/metadata/$display_key/vocabulary");
      $data = (string) $response->getBody();
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }

    return Json::decode($data);
  }

  /**
   * Fetches category information.
   *
   * @param string $category_name_encoded
   *   Human-readable label of the category in Widen rawurlencode()-d.
   *
   * @return array|null
   *   List of the category items or NULL if no categories are available.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   */
  public function getCategories(string $category_name_encoded = ''): ?array {
    try {
      $path = $category_name_encoded ? '/v2/categories/' . $category_name_encoded : '/v2/categories';
      $response = $this->get($path);
      $data = (string) $response->getBody();
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }

    return Json::decode($data);
  }

  /**
   * Fetch asset groups information.
   *
   * @return array|null
   *   List of the asset groups.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   */
  public function getAssetGroups(): ?array {
    try {
      $path = '/v2/assets/assetgroups';
      $response = $this->get($path);
      $data = (string) $response->getBody();
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }

    return Json::decode($data);
  }

  /**
   * Converts a category name to its corresponding UUID.
   *
   * @param string $name
   *   The category name in Widen. Method tries to guess if it left URL
   *   encoded, then decodes.
   * @param bool $all_data
   *   (optional) Flag indicating whether the entire information set received
   *   should be returned.
   *
   * @return string|array|null
   *   The UUID of the given category.
   */
  public function convertCategoryNameToUuid(string $name, bool $all_data = FALSE): string|array|null {
    $all_categories = $this->getCategories();

    return $this->convertNameToUuid('category', $all_categories, $name, $all_data = FALSE);
  }

  /**
   * Converts a asset group name to its corresponding UUID.
   *
   * @param string $name
   *   The asset group name in Widen. Method tries to guess if it left URL
   *   encoded, then decodes.
   * @param bool $all_data
   *   (optional) Flag indicating whether the entire information set received
   *   should be returned.
   *
   * @return string|array|null
   *   The UUID of the given asset group.
   */
  public function convertAssetGroupNameToUuid(string $name, bool $all_data = FALSE): string|array|null {
    $all_asset_groups = $this->getAssetGroups();

    return $this->convertNameToUuid('asset_group', $all_asset_groups, $name, $all_data);
  }

  /**
   * Converts a given list name to its corresponding UUID.
   *
   * @param string $source
   *   The source type.
   * @param array $list
   *   The list of asset group or category.
   * @param string $name
   *   The asset group or category name in Widen. Method tries to
   *   guess if it left URL encoded, then decodes.
   * @param bool $all_data
   *   (optional) Flag indicating whether the entire information set received
   *   should be returned.
   *
   * @return string|array|null
   *   The UUID of the given asset group or category.
   */
  private function convertNameToUuid(string $source, array $list, string $name, bool $all_data = FALSE): string|array|null {
    // Probably the input is left encoded yet but plain-text is required here.
    if (preg_match('/%[0-9A-F]{2}/i', $name)) {
      $name = rawurldecode($name);
    }

    foreach ($list['items'] as $data) {
      if (!isset($data['name']) || $data['name'] !== $name) {
        continue;
      }

      if ($all_data) {
        return $data;
      }

      // Return the identifier or null if not available.
      return $data[$this->sourceMapping[$source]] ?? null;
    }

    return NULL;
  }

  /**
   * Converts a category UUID to its corresponding name.
   *
   * @param string $uuid
   *   UUID of the category in Widen.
   *
   * @return string|null
   *   The human label of the category.
   */
  public function convertCategoryUuidToName(string $uuid): ?string {
    $all_categories = $this->getCategories();

    return $this->convertUuidToName('category', $all_categories, $uuid);
  }

  /**
   * Converts a asset group UUID to its corresponding name.
   *
   * @param string $uuid
   *   UUID of the asset group in Widen.
   *
   * @return string|null
   *   The human label of the asset group.
   */
  public function convertAssetGroupUuidToName(string $uuid): ?string {
    $all_asset_groups = $this->getAssetGroups();

    return $this->convertUuidToName('asset_group', $all_asset_groups, $uuid);
  }

  /**
   * Converts a asset group UUID to its corresponding name.
   *
   * @param string $uuid
   *   UUID of the asset group in Widen.
   *
   * @return string|null
   *   The human label of the asset group.
   */
  public function convertUuidToName(string $source, array $list, string $uuid): ?string {
    foreach ($list['items'] as $data) {
      if (!isset($data[$this->sourceMapping[$source]]) || $data[$this->sourceMapping[$source]] !== $uuid) {
        continue;
      }

      return rawurldecode($data['name']) ?: NULL;
    }

    return NULL;
  }

  /**
   * Fetches Widen collections.
   *
   * @param string $collection_type
   *   Type of collections to fetch (e.g., 'global').
   *
   * @return array
   *   List of collections with key 'items' or an empty array if an error occurs.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   */
  public function getCollections(string $collection_type = 'global'): array {
    $default_result = ['items' => []];
    $limit = 100;
    $offset = 0;

    // Build the path and query parameters for the request.
    $path = "/v2/collections";
    $query_params = [
      'type' => $collection_type,
      'offset' => $offset,
      'limit' => $limit,
    ];

    try {
      // Make the HTTP request.
      $response = $this->get($path, ['query' => $query_params]);
      $data = (string) $response->getBody();
      $collections = Json::decode($data);
    }
    catch (\Exception $e) {
      $this->throwDamException($e);
    }

    // Return the results or the default value if null.
    return $collections ?? $default_result;
  }

  /**
   * Throws specific exception.
   *
   * @param \Exception $exception
   *   Exception that occurred during request.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   */
  protected function throwDamException(\Exception $exception): void {
    $this->damLoggerChannel->error($exception->getMessage());

    if ($exception instanceof ClientException) {
      $response = $exception->getResponse();
      $status = $response->getStatusCode();
      $message = "API responded with status: $status. If the issue persist contact the site admin. Error message: {$exception->getMessage()}";

      if ($status === 401) {
        $message = 'Unable to finish request due to authorization errors. If the issue persist contact the site admin.';
      }
      if ($status === 408) {
        $message = 'Request timed out while trying to connect DAM API. If the issue persist contact the site admin.';
      }

      throw new DamClientException($message, $status, $exception);
    }

    if ($exception instanceof ConnectException) {
      throw new DamConnectException('Unable to complete network request.', $exception->getCode(), $exception);
    }

    throw new DamServerException($exception->getMessage(), $exception->getCode(), $exception);
  }

  /**
   * Writes asset data to the cache.
   *
   * @param string $cache_key
   *   The type of data being stored.
   * @param string $raw_data
   *   The unserialized JSON data to store.
   * @param string $asset_id
   *   The asset ID.
   * @param string $version_id
   *   (optional) The asset version ID.
   */
  private function writeToCache(string $cache_key, string $raw_data, string $asset_id, string $version_id = ''): void {
    $decoded_data = Json::decode($raw_data);

    // Cache expiration time should be the same as the asset thumbnail
    // expiration time sent by remote system, otherwise thumbnail URLs will
    // broke. Just to play on the safe side we calculate a default value first.
    $expiration_date = new \DateTime('now + 10 hours');

    // Not affecting media item thumbnails, only reading their Widen-dictated
    // expiration date to set for caching.
    if (isset($decoded_data['thumbnails'])) {
      $first_thumbnail = reset($decoded_data['thumbnails']);
      if (isset($first_thumbnail['valid_until'])) {
        $expiration_date = \DateTime::createFromFormat(\DateTimeInterface::ISO8601, $first_thumbnail['valid_until']);
      }
    }

    // Then prepare some cache tag variations too.
    $cache_tags = [
      'acquia-dam-asset',
      'acquia-dam-asset:' . $asset_id,
    ];

    if ($version_id !== '') {
      $cache_tags[] = 'acquia-dam-asset:' . $asset_id . ':' . $version_id;
    }

    // Finally store them in.
    $this->cacheBackend->set(
      $cache_key . $asset_id,
      $raw_data,
      $expiration_date->getTimestamp(),
      $cache_tags,
    );
  }

  /**
   * Reads asset data from the cache.
   *
   * @param string $cache_key
   *   The cache key.
   *
   * @return string|null
   *   The cached raw (unserialized JSON) data, or null if not found.
   */
  private function readFromCache(string $cache_key): ?string {
    $cached = $this->cacheBackend->get($cache_key);

    if ($cached !== FALSE && property_exists($cached, 'data')) {
      return $cached->data;
    }

    return NULL;
  }

  /**
   * Get the authenticated user information from DAM.
   *
   * @return string|null
   *   The username of the authenticated user, or null if not available.
   *
   * @throws \Drupal\acquia_dam\Exception\DamClientException
   * @throws \Drupal\acquia_dam\Exception\DamServerException
   * @throws \Drupal\acquia_dam\Exception\DamConnectException
   */
  public function getUser(): ?string {
    try {
      $response = $this->get('/v2/user');
      $data = (string) $response->getBody();
    }
    catch (\Exception $e) {
      return null;
    }

    $user_data = Json::decode($data);
    return $user_data['username'] ?? NULL;
  }

  /**
   * Deletes cached assets by asset IDs.
   *
   * @param string[] $asset_ids
   *   The asset IDs.
   */
  public function deleteCachedAssets(array $asset_ids): void {
    if ($asset_ids === []) {
      return;
    }

    $cids = array_map(
      static fn (string $asset_id): string => 'asset:' . $asset_id,
      $asset_ids
    );
    $this->cacheBackend->deleteMultiple($cids);
  }

  /**
   * Returns search conditions string to filter assets by status.
   *
   * Adds one day time to the current date since the API "before" query string
   * excludes assets released today, but those count as active as well.
   *
   * @return string
   *   Search condition string.
   */
  protected function getAssetIsActiveCondition(): string {
    $now = $this->time->getCurrentTime();
    $today = $this->dateFormatter->format(
      $now,
      'custom',
      'm/d/Y'
    );
    $tomorrow = $this->dateFormatter->format(
      $now + 86400,
      'custom',
      'm/d/Y'
    );

    // Release date is before tomorrow and the expiration date is later or
    // empty.
    return "rd:([before $tomorrow]) AND ed:((isEmpty) OR [after $today])";
  }

}
