<?php

declare(strict_types=1);

namespace Drupal\Tests\acquia_dam\Kernel;

use Drupal\acquia_dam\AssetUpdater;
use Drupal\acquia_dam\Entity\MediaSourceField;
use Drupal\media\Entity\Media;
use Drupal\media\Entity\MediaType;
use Drupal\media\MediaInterface;

/**
 * Kernel tests for the AssetUpdater service.
 *
 * Covers every public method of AssetUpdater:
 *   - syncAssets()          · fetchUpdatedAssets()
 *   - categorizeAssets()    · unpublishAssets()
 *   - queueUpdateAssets()   · getDamMediaTypes()
 *   - checkAssetsFinalizedVersion()
 *   - processQueue()
 *
 * Extends AcquiaDamKernelTestBase to reuse its media-type factory helpers,
 * mocked HTTP client, DAM domain/token setup, and entity schema installs.
 *
 * @coversDefaultClass \Drupal\acquia_dam\AssetUpdater
 * @group acquia_dam_kernel
 */
final class AssetUpdaterTest extends AcquiaDamKernelTestBase {

  /** @var \Drupal\acquia_dam\AssetUpdater */
  private AssetUpdater $assetUpdater;

  /** Lazy-loaded image media type, shared within a single test run. */
  private ?MediaType $imageMediaType = NULL;

  /** Lazy-loaded PDF media type, shared within a single test run. */
  private ?MediaType $pdfMediaType = NULL;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->drupalSetUpCurrentUser();
    $this->assetUpdater = $this->container->get('acquia_dam.asset_updater');
  }

  /**
   * Returns (creating on first call) the shared image media type.
   */
  private function getImageMediaType(): MediaType {
    return $this->imageMediaType ??= $this->createImageMediaType();
  }

  /**
   * Returns (creating on first call) the shared PDF media type.
   */
  private function getPdfMediaType(): MediaType {
    return $this->pdfMediaType ??= $this->createPdfMediaType();
  }

  /**
   * Creates, optionally unpublishes, saves, and returns a media entity.
   *
   * @param \Drupal\media\Entity\MediaType $type
   *   The media type bundle.
   * @param string $asset_id
   *   DAM asset UUID.
   * @param string $version_id
   *   DAM version UUID.
   * @param bool $published
   *   Whether the entity should be published (default TRUE).
   *
   * @return \Drupal\media\MediaInterface
   *   The saved media entity.
   */
  private function createMedia(
    MediaType $type,
    string $asset_id,
    string $version_id,
    bool $published = TRUE,
  ): MediaInterface {
    $media = Media::create([
      'bundle' => $type->id(),
      MediaSourceField::SOURCE_FIELD_NAME => [
        'asset_id'   => $asset_id,
        'version_id' => $version_id,
      ],
    ]);
    if (!$published) {
      $media->setUnpublished();
    }
    $media->save();
    return $media;
  }

  /**
   * Six syncAssets() scenarios varying fixture, media entities, options, and
   * expected stats.
   *
   * Columns:
   *   fixture                - state key to set (NULL = default fixture)
   *   media_specs            - array of media entities to create before the call
   *   options                - syncAssets() options array
   *   expected_stats         - subset of stat keys that must match
   *   assert_media_unpublished - TRUE → assert first created media is unpublished
   *   use_image_type_filter  - TRUE → inject getImageMediaType()->id() as 'media-type'
   *
   * @return array<string, array{fixture: string|null, media_specs: array, options: array, expected_stats: array, assert_media_unpublished: bool, use_image_type_filter: bool}>
   */
  public static function syncAssetsProvider(): array {
    return [
      'empty DAM response → all-zero stats' => [
        'fixture'                => 'lastEditedSearchEmpty.json',
        'media_specs'            => [],
        'options'                => ['since-timestamp' => 0],
        'expected_stats'         => ['dam_processed' => 0, 'unpublished' => 0, 'queued_from_dam' => 0, 'queued_from_version_check' => 0],
        'assert_media_unpublished' => FALSE,
        'use_image_type_filter'    => FALSE,
      ],
      'released asset → queued_from_dam = 1' => [
        'fixture'                => NULL,
        'media_specs'            => [
          ['type' => 'image', 'asset_id' => '56ff14de-02cd-41b5-9a73-c917eab19abf', 'version_id' => '4f656c07-6a08-47b3-9403-16082d2fcda5'],
        ],
        'options'                => ['since-timestamp' => 0],
        'expected_stats'         => ['dam_processed' => 1, 'unpublished' => 0, 'queued_from_dam' => 1, 'queued_from_version_check' => 0],
        'assert_media_unpublished' => FALSE,
        'use_image_type_filter'    => FALSE,
      ],
      'unreleased asset → immediately unpublished, not queued' => [
        'fixture'                => 'lastEditedSearchAssetBecameUnreleased.json',
        'media_specs'            => [
          ['type' => 'image', 'asset_id' => '56ff14de-02cd-41b5-9a73-c917eab19abf', 'version_id' => '4f656c07-6a08-47b3-9403-16082d2fcda5'],
        ],
        'options'                => ['since-timestamp' => 0],
        'expected_stats'         => ['unpublished' => 1, 'queued_from_dam' => 0],
        'assert_media_unpublished' => TRUE,
        'use_image_type_filter'    => FALSE,
      ],
      'finalized-version flag + outdated version → queued_from_version_check = 1' => [
        'fixture'                => 'lastEditedSearchEmpty.json',
        'media_specs'            => [
          ['type' => 'image', 'asset_id' => '56ff14de-02cd-41b5-9a73-c917eab19abf', 'version_id' => '7b67948f-ee7e-405c-a0cd-344a24d8afb2'],
        ],
        'options'                => ['since-timestamp' => 0, 'finalized-version' => TRUE],
        'expected_stats'         => ['dam_processed' => 0, 'queued_from_version_check' => 1],
        'assert_media_unpublished' => FALSE,
        'use_image_type_filter'    => FALSE,
      ],
      'finalized-version flag + current version → not queued' => [
        'fixture'                => 'lastEditedSearchEmpty.json',
        'media_specs'            => [
          ['type' => 'image', 'asset_id' => '56ff14de-02cd-41b5-9a73-c917eab19abf', 'version_id' => '4f656c07-6a08-47b3-9403-16082d2fcda5'],
        ],
        'options'                => ['since-timestamp' => 0, 'finalized-version' => TRUE],
        'expected_stats'         => ['dam_processed' => 0, 'queued_from_version_check' => 0],
        'assert_media_unpublished' => FALSE,
        'use_image_type_filter'    => FALSE,
      ],
      'media-type filter → only image type queued, pdf filtered out' => [
        'fixture'                => 'lastEditedSearch3Items.json',
        'media_specs'            => [
          ['type' => 'image', 'asset_id' => '56ff14de-02cd-41b5-9a73-c917eab19abf', 'version_id' => '4f656c07-6a08-47b3-9403-16082d2fcda5'],
          ['type' => 'pdf',   'asset_id' => '4f656c07-6a08-47b3-9403-16082d2fcda2', 'version_id' => 'c6a1d232-4460-43de-afd2-b3bd53ade80f'],
        ],
        'options'                => ['since-timestamp' => 0],
        'expected_stats'         => ['queued_from_dam' => 1],
        'assert_media_unpublished' => FALSE,
        'use_image_type_filter'    => TRUE,
      ],
    ];
  }

  /**
   * Tests syncAssets() across six scenarios: empty response, released/queued,
   * unreleased/unpublished, finalized-version with outdated and current
   * versions, and media-type filtering.
   *
   * @dataProvider syncAssetsProvider
   * @covers ::syncAssets
   * @covers ::fetchUpdatedAssets
   * @covers ::categorizeAssets
   * @covers ::unpublishAssets
   * @covers ::queueUpdateAssets
   */
  public function testSyncAssets(
    ?string $fixture,
    array $media_specs,
    array $options,
    array $expected_stats,
    bool $assert_media_unpublished,
    bool $use_image_type_filter,
  ): void {
    if ($fixture !== NULL) {
      $this->container->get('state')->set('acquia_dam_test.last_edited_search_fixture', $fixture);
    }

    $created_media = [];
    foreach ($media_specs as $spec) {
      $type            = $spec['type'] === 'image' ? $this->getImageMediaType() : $this->getPdfMediaType();
      $created_media[] = $this->createMedia($type, $spec['asset_id'], $spec['version_id']);
    }

    if ($use_image_type_filter) {
      $options['media-type'] = $this->getImageMediaType()->id();
    }

    $stats = $this->assetUpdater->syncAssets($options);

    foreach ($expected_stats as $key => $value) {
      $this->assertSame($value, $stats[$key], "syncAssets() stat '$key' mismatch.");
    }

    if ($assert_media_unpublished) {
      $media = $this->reloadEntity($created_media[0]);
      $this->assertFalse($media->isPublished(), 'Media should be unpublished after syncAssets().');
    }
  }

  /**
   * Three fetchUpdatedAssets() scenarios varying the fixture and expected IDs.
   *
   * fixture = NULL means the default lastEditedSearch.json fixture is used.
   *
   * @return array<string, array{fixture: string|null, expected_count: int, expected_ids: string[]}>
   */
  public static function fetchUpdatedAssetsProvider(): array {
    return [
      'single item yielded' => [
        'fixture'        => NULL,
        'expected_count' => 1,
        'expected_ids'   => ['56ff14de-02cd-41b5-9a73-c917eab19abf'],
      ],
      'empty DAM response yields nothing' => [
        'fixture'        => 'lastEditedSearchEmpty.json',
        'expected_count' => 0,
        'expected_ids'   => [],
      ],
      'three items yielded across one page' => [
        'fixture'        => 'lastEditedSearch3Items.json',
        'expected_count' => 3,
        'expected_ids'   => [
          '56ff14de-02cd-41b5-9a73-c917eab19abf',
          '4f656c07-6a08-47b3-9403-16082d2fcda2',
          'f2a9c03d-3664-477c-8013-e84504ed5adc',
        ],
      ],
    ];
  }

  /**
   * Tests fetchUpdatedAssets() across three fixture variants.
   *
   * fetchUpdatedAssets() now returns a \Generator that yields one scroll page
   * at a time. The test collects all yielded items into a flat array for
   * assertion. Existing single-page fixtures (scroll_id: null) produce exactly
   * one yield (or zero for the empty fixture).
   *
   * @dataProvider fetchUpdatedAssetsProvider
   * @covers ::fetchUpdatedAssets
   */
  public function testFetchUpdatedAssets(
    ?string $fixture,
    int $expected_count,
    array $expected_ids,
  ): void {
    if ($fixture !== NULL) {
      $this->container->get('state')->set('acquia_dam_test.last_edited_search_fixture', $fixture);
    }

    // Collect all items yielded by the generator across all pages.
    $items = [];
    foreach ($this->assetUpdater->fetchUpdatedAssets('lastEditDate:[after 2020-01-01T00:00:00Z]') as $page_items) {
      $items = array_merge($items, $page_items);
    }

    $this->assertCount($expected_count, $items);
    $returned_ids = array_column($items, 'id');
    foreach ($expected_ids as $id) {
      $this->assertContains($id, $returned_ids);
    }
  }

  /**
   * Tests that syncAssets() correctly processes items across multiple scroll
   * pages and counts them all in dam_processed.
   *
   * Uses scroll test mode 'success' which triggers the mock middleware to
   * serve two real scroll fixture pages:
   *   - Page 1 (search/scroll/search.json):          5 items
   *   - Page 2 (search/scroll/search_next_page.json): 2 items
   *   - Page 3 (search/scroll/search_complete.json):  0 items → terminates
   *
   * Two media entities are created — one matching a page-1 asset and one
   * matching a page-2 asset. Both must appear in dam_processed and
   * queued_from_dam, proving that per-page categorization works end-to-end.
   *
   * @covers ::syncAssets
   * @covers ::fetchUpdatedAssets
   */
  public function testSyncAssetsProcessesMultipleScrollPages(): void {
    $this->container->get('state')
      ->set('acquia_dam_test.scroll_search_mode', 'success');

    $image_type = $this->getImageMediaType();

    // Asset from page 1 of search/scroll/search.json (image, released=true).
    $this->createMedia($image_type, 'f2a9c03d-3664-477c-8013-e84504ed5adc', '77b3dd66-e590-4259-82db-25e7f4e62772');

    // Asset from page 2 of search/scroll/search_next_page.json (audio,
    // released=true; image media type used here because categorizeAssets()
    // only matches on asset_id, not file type).
    $this->createMedia($image_type, 'fcc13282-d569-44bc-a992-2ff2b405ce59', 'some-version-id-page2');

    $stats = $this->assetUpdater->syncAssets(['since-timestamp' => 0]);

    // All items from both scroll pages must be counted.
    $this->assertSame(
      7,
      $stats['dam_processed'],
      'dam_processed must equal the total items across both scroll pages (5 + 2).'
    );

    // Both media entities — one from each page — must be queued.
    $this->assertSame(
      2,
      $stats['queued_from_dam'],
      'Media entities from both scroll pages must be queued for update.'
    );

    $queue = $this->container->get('queue')->get('acquia_dam_media_item_update');
    $this->assertSame(2, $queue->numberOfItems(), 'Update queue must contain one item per matched media entity.');
  }

  /**
   * Tests that a scroll exception mid-way does not discard already-processed
   * pages and does not propagate as an unhandled exception to the caller.
   *
   * Uses scroll test mode 'error' which serves page 1 successfully and then
   * returns {"error": true, "message": "Scroll ID expired."} on the first
   * continuation call. fetchUpdatedAssets() catches the DamClientException,
   * logs a warning, and returns — items from page 1 are already categorized.
   *
   * @covers ::syncAssets
   * @covers ::fetchUpdatedAssets
   */
  public function testSyncAssetsMidScrollExceptionStillProcessesFirstPage(): void {
    $this->container->get('state')
      ->set('acquia_dam_test.scroll_search_mode', 'error');

    $image_type = $this->getImageMediaType();

    // Asset from page 1 of search/scroll/search.json (image, released=true).
    $this->createMedia($image_type, 'f2a9c03d-3664-477c-8013-e84504ed5adc', '77b3dd66-e590-4259-82db-25e7f4e62772');

    // Must not throw even though the scroll continuation fails internally.
    $stats = $this->assetUpdater->syncAssets(['since-timestamp' => 0]);

    // Only page 1 items (5) are counted — page 2 was never fetched.
    $this->assertSame(
      5,
      $stats['dam_processed'],
      'Only page 1 items must be counted when the scroll continuation fails.'
    );

    // The page-1 media entity must still be queued despite the mid-scroll error.
    $this->assertSame(
      1,
      $stats['queued_from_dam'],
      'Page 1 media entity must be queued for update even when page 2 fails.'
    );
  }

  /**
   * Tests that syncAssets() returns all-zero stats and throws nothing when
   * the scroll search returns no items at all.
   *
   * Uses scroll test mode 'empty' which returns search_complete.json
   * (items: []) for the initial search, causing the generator to exhaust
   * immediately without yielding.
   *
   * @covers ::syncAssets
   * @covers ::fetchUpdatedAssets
   */
  public function testSyncAssetsEmptyGeneratorReturnsZeroStats(): void {
    $this->container->get('state')
      ->set('acquia_dam_test.scroll_search_mode', 'empty');

    // Must complete without throwing.
    $stats = $this->assetUpdater->syncAssets(['since-timestamp' => 0]);

    $this->assertSame(0, $stats['dam_processed']);
    $this->assertSame(0, $stats['unpublished']);
    $this->assertSame(0, $stats['queued_from_dam']);
    $this->assertSame(0, $stats['queued_from_version_check']);

    // Verify no queue items were created.
    $queue = $this->container->get('queue')->get('acquia_dam_media_item_update');
    $this->assertSame(0, $queue->numberOfItems(), 'No queue items must be created when the generator yields nothing.');
  }

  /**
   * Three categorizeAssets() status-flag variants for the classification test.
   *
   * @return array<string, array{item_fields: array, expected_update_count: int, expected_unpublish_count: int}>
   */
  public static function categorizeAssetsClassificationProvider(): array {
    return [
      'released asset → update group' => [
        'item_fields'              => ['deleted_date' => NULL, 'released_and_not_expired' => TRUE],
        'expected_update_count'    => 1,
        'expected_unpublish_count' => 0,
      ],
      'unreleased/expired asset → unpublish group' => [
        'item_fields'              => ['deleted_date' => NULL, 'released_and_not_expired' => FALSE],
        'expected_update_count'    => 0,
        'expected_unpublish_count' => 1,
      ],
      'deleted asset (deleted_date set) → unpublish group regardless of released flag' => [
        'item_fields'              => ['deleted_date' => '2024-01-15T10:00:00Z', 'released_and_not_expired' => TRUE],
        'expected_update_count'    => 0,
        'expected_unpublish_count' => 1,
      ],
    ];
  }

  /**
   * Tests that categorizeAssets() routes each item to the correct group based
   * on the asset's released/deleted status flags.
   *
   * A single image media entity is created in every case; only the item_fields
   * vary between rows.
   *
   * @dataProvider categorizeAssetsClassificationProvider
   * @covers ::categorizeAssets
   */
  public function testCategorizeAssetsClassification(
    array $item_fields,
    int $expected_update_count,
    int $expected_unpublish_count,
  ): void {
    $this->createMedia(
      $this->getImageMediaType(),
      '56ff14de-02cd-41b5-9a73-c917eab19abf',
      '4f656c07-6a08-47b3-9403-16082d2fcda5',
    );

    $result = $this->assetUpdater->categorizeAssets([
      array_merge(
        ['id' => '56ff14de-02cd-41b5-9a73-c917eab19abf', 'version_id' => '4f656c07-6a08-47b3-9403-16082d2fcda5'],
        $item_fields
      ),
    ]);

    $this->assertCount($expected_update_count, $result['update']);
    $this->assertCount($expected_unpublish_count, $result['unpublish']);
  }

  /**
   * Tests three categorizeAssets() edge cases inline:
   *   1. Empty item list → both groups are empty arrays.
   *   2. Asset present in DAM response but no local media entity → silently skipped.
   *   3. Mixed released/unreleased assets → each routed to the correct group.
   *
   * @covers ::categorizeAssets
   */
  public function testCategorizeAssetsEdgeCases(): void {
    // Case 1: Empty input → both groups are empty.
    $result = $this->assetUpdater->categorizeAssets([]);
    $this->assertSame(['unpublish' => [], 'update' => []], $result);

    // Case 2: No local media entity for the returned asset → skipped.
    $result = $this->assetUpdater->categorizeAssets([
      ['id' => '56ff14de-02cd-41b5-9a73-c917eab19abf', 'deleted_date' => NULL, 'released_and_not_expired' => TRUE],
    ]);
    $this->assertCount(0, $result['update'], 'Assets without a local media entity should be skipped.');
    $this->assertCount(0, $result['unpublish']);

    // Case 3: Mixed released/unreleased assets are split across both groups.
    $this->createMedia($this->getImageMediaType(), '56ff14de-02cd-41b5-9a73-c917eab19abf', '4f656c07-6a08-47b3-9403-16082d2fcda5');
    $this->createMedia($this->getPdfMediaType(), '4f656c07-6a08-47b3-9403-16082d2fcda2', 'c6a1d232-4460-43de-afd2-b3bd53ade80f');
    $result = $this->assetUpdater->categorizeAssets([
      ['id' => '56ff14de-02cd-41b5-9a73-c917eab19abf', 'deleted_date' => NULL, 'released_and_not_expired' => TRUE],
      ['id' => '4f656c07-6a08-47b3-9403-16082d2fcda2', 'deleted_date' => NULL, 'released_and_not_expired' => FALSE],
    ]);
    $this->assertCount(1, $result['update'], 'Released image should be in the update group.');
    $this->assertCount(1, $result['unpublish'], 'Unreleased pdf should be in the unpublish group.');
  }

  /**
   * Two unpublishAssets() scenarios: published vs already-unpublished media.
   *
   * @return array<string, array{initial_published: bool, assert_revision_log: bool, assert_no_new_rev: bool}>
   */
  public static function unpublishAssetsProvider(): array {
    return [
      'published media → unpublished with correct revision log and uid 0' => [
        'initial_published'   => TRUE,
        'assert_revision_log' => TRUE,
        'assert_no_new_rev'   => FALSE,
      ],
      'already-unpublished media → skipped, no new revision created' => [
        'initial_published'   => FALSE,
        'assert_revision_log' => FALSE,
        'assert_no_new_rev'   => TRUE,
      ],
    ];
  }

  /**
   * Tests unpublishAssets() for a published and an already-unpublished entity.
   *
   * Published case:  media is unpublished, a new revision is saved with the
   *                  expected log message and uid 0.
   * Unpublished case: media is unchanged — no new revision is created.
   *
   * @dataProvider unpublishAssetsProvider
   * @covers ::unpublishAssets
   */
  public function testUnpublishAssets(
    bool $initial_published,
    bool $assert_revision_log,
    bool $assert_no_new_rev,
  ): void {
    $media = $this->createMedia(
      $this->getImageMediaType(),
      '56ff14de-02cd-41b5-9a73-c917eab19abf',
      '4f656c07-6a08-47b3-9403-16082d2fcda5',
      $initial_published,
    );
    $vid_before = $media->getRevisionId();

    $this->assetUpdater->unpublishAssets([(int) $media->id() => '56ff14de-02cd-41b5-9a73-c917eab19abf']);

    $media = $this->reloadEntity($media);
    $this->assertFalse($media->isPublished());

    if ($assert_revision_log) {
      $this->assertEquals(
        'Automatically unpublished as the associated DAM asset is unavailable.',
        $media->getRevisionLogMessage()
      );
      $this->assertEquals(0, $media->getRevisionUserId(),
        'Revision should be authored by the anonymous user (uid 0).'
      );
    }

    if ($assert_no_new_rev) {
      $this->assertEquals($vid_before, $media->getRevisionId(),
        'No new revision should be created for an already-unpublished media item.'
      );
    }
  }

  /**
   * Two cache-clearing scenarios: one for unpublishAssets, one for
   * queueUpdateAssets.
   *
   * @return array<string, array{operation: string}>
   */
  public static function cacheCleaningProvider(): array {
    return [
      'unpublishAssets clears the DAM cache for the asset'    => ['operation' => 'unpublish'],
      'queueUpdateAssets clears the DAM cache for the asset'  => ['operation' => 'queue'],
    ];
  }

  /**
   * Tests that both unpublishAssets() and queueUpdateAssets() clear the DAM
   * asset cache entry for the processed asset.
   *
   * @dataProvider cacheCleaningProvider
   * @covers ::unpublishAssets
   * @covers ::queueUpdateAssets
   */
  public function testCacheCleaning(string $operation): void {
    $asset_id  = '56ff14de-02cd-41b5-9a73-c917eab19abf';
    $cache_key = "asset:$asset_id";
    $media     = $this->createMedia($this->getImageMediaType(), $asset_id, '4f656c07-6a08-47b3-9403-16082d2fcda5');
    $asset_map = [(int) $media->id() => $asset_id];

    $dam_cache = $this->container->get('cache.acquia_dam');
    $dam_cache->set($cache_key, ['stale' => 'data']);
    $this->assertNotFalse($dam_cache->get($cache_key), 'Cache entry should exist before the operation.');

    $operation === 'unpublish'
      ? $this->assetUpdater->unpublishAssets($asset_map)
      : $this->assetUpdater->queueUpdateAssets($asset_map);

    $this->assertFalse($dam_cache->get($cache_key), 'Cache entry should be cleared after the operation.');
  }

  /**
   * Three getDamMediaTypes() scenarios: no filter, valid filter, unknown filter.
   *
   * filter_type values:
   *   NULL        → no filter (all types)
   *   'image'     → resolved to $this->getImageMediaType()->id() in the test body
   *   (any other) → passed as-is (simulates an unknown type ID)
   *
   * @return array<string, array{create_pdf: bool, filter_type: string|null, expect_image: bool, expect_pdf: bool, expect_count: int|null}>
   */
  public static function getDamMediaTypesProvider(): array {
    return [
      'no filter → all DAM media types returned' => [
        'create_pdf'   => TRUE,
        'filter_type'  => NULL,
        'expect_image' => TRUE,
        'expect_pdf'   => TRUE,
        'expect_count' => NULL,
      ],
      'valid image-type filter → only that type returned' => [
        'create_pdf'   => TRUE,
        'filter_type'  => 'image',
        'expect_image' => TRUE,
        'expect_pdf'   => FALSE,
        'expect_count' => 1,
      ],
      'unknown filter → falls back to all types' => [
        'create_pdf'   => FALSE,
        'filter_type'  => 'nonexistent_type_id',
        'expect_image' => TRUE,
        'expect_pdf'   => FALSE,
        'expect_count' => NULL,
      ],
    ];
  }

  /**
   * Tests getDamMediaTypes() with no filter, a valid type filter, and an
   * unknown filter (falls back to all types).
   *
   * filter_type = 'image' is resolved to the actual image type ID in the body.
   *
   * @dataProvider getDamMediaTypesProvider
   * @covers ::getDamMediaTypes
   */
  public function testGetDamMediaTypes(
    bool $create_pdf,
    ?string $filter_type,
    bool $expect_image,
    bool $expect_pdf,
    ?int $expect_count,
  ): void {
    $image_type = $this->getImageMediaType();
    $pdf_type   = $create_pdf ? $this->getPdfMediaType() : NULL;

    // Resolve the sentinel 'image' token to the actual dynamic type ID.
    $filter = match ($filter_type) {
      NULL    => NULL,
      'image' => $image_type->id(),
      default => $filter_type,
    };

    $types = $this->assetUpdater->getDamMediaTypes($filter);

    if ($expect_image) {
      $this->assertArrayHasKey($image_type->id(), $types, 'Image type should be in the result.');
    }
    if ($pdf_type !== NULL) {
      $expect_pdf
        ? $this->assertArrayHasKey($pdf_type->id(), $types, 'PDF type should be in the result.')
        : $this->assertArrayNotHasKey($pdf_type->id(), $types, 'PDF type should NOT be in the result.');
    }
    if ($expect_count !== NULL) {
      $this->assertCount($expect_count, $types);
    }
  }

  /**
   * Three checkAssetsFinalizedVersion() scenarios: current, outdated, and
   * multi-version asset.
   *
   * @return array<string, array{asset_id: string, stored_version_id: string, expected_result: bool, expected_queue_count: int}>
   */
  public static function finalizedVersionComparisonProvider(): array {
    return [
      'stored version matches finalized → TRUE, nothing queued' => [
        'asset_id'             => '56ff14de-02cd-41b5-9a73-c917eab19abf',
        'stored_version_id'    => '4f656c07-6a08-47b3-9403-16082d2fcda5',
        'expected_result'      => TRUE,
        'expected_queue_count' => 0,
      ],
      'stored version differs from finalized → FALSE, item queued with correct payload' => [
        'asset_id'             => '56ff14de-02cd-41b5-9a73-c917eab19abf',
        'stored_version_id'    => '7b67948f-ee7e-405c-a0cd-344a24d8afb2',
        'expected_result'      => FALSE,
        'expected_queue_count' => 1,
      ],
      'multi-version asset with current version → TRUE, nothing queued' => [
        'asset_id'             => 'f2a9c03d-3664-477c-8013-e84504ed5adc',
        'stored_version_id'    => '77b3dd66-e590-4259-82db-25e7f4e62772',
        'expected_result'      => TRUE,
        'expected_queue_count' => 0,
      ],
    ];
  }

  /**
   * Tests checkAssetsFinalizedVersion() for three scenarios: current version,
   * outdated version, and multi-version asset.
   *
   * When a queue item is expected (expected_queue_count > 0) the payload is
   * also verified.
   *
   * @dataProvider finalizedVersionComparisonProvider
   * @covers ::checkAssetsFinalizedVersion
   */
  public function testCheckAssetsFinalizedVersionComparison(
    string $asset_id,
    string $stored_version_id,
    bool $expected_result,
    int $expected_queue_count,
  ): void {
    $media = $this->createMedia($this->getImageMediaType(), $asset_id, $stored_version_id);

    $result = $this->assetUpdater->checkAssetsFinalizedVersion($media);

    $this->assertSame($expected_result, $result);

    $queue = $this->container->get('queue')->get('acquia_dam_media_item_update');
    $this->assertEquals($expected_queue_count, $queue->numberOfItems());

    if ($expected_queue_count > 0) {
      $item = $queue->claimItem();
      $this->assertNotFalse($item, 'Queue item should be claimable.');
      $this->assertEquals($asset_id, $item->data['asset_id'], 'Payload asset_id mismatch.');
      $this->assertEquals($media->id(), $item->data['media_id'], 'Payload media_id mismatch.');
    }
  }

  /**
   * Three processQueue() scenarios: empty queue, full drain, and limited run.
   *
   * 'expected' is a subset of result keys asserted via a loop — add only the
   * keys you want to verify in each scenario.
   *
   * @return array<string, array{asset_specs: array, limit: int|null, expected: array}>
   */
  public static function processQueueProvider(): array {
    return [
      'empty queue → all-zero stats including initial_count' => [
        'asset_specs' => [],
        'limit'       => NULL,
        'expected'    => ['initial_count' => 0, 'processed' => 0, 'remaining' => 0, 'errors' => 0],
      ],
      'all queued items processed → queue emptied' => [
        'asset_specs' => [
          ['asset_id' => '56ff14de-02cd-41b5-9a73-c917eab19abf', 'version_id' => '4f656c07-6a08-47b3-9403-16082d2fcda5'],
        ],
        'limit'       => NULL,
        'expected'    => ['processed' => 1, 'remaining' => 0, 'errors' => 0],
      ],
      'limit = 1 processes only first item, second remains' => [
        'asset_specs' => [
          ['asset_id' => '56ff14de-02cd-41b5-9a73-c917eab19abf', 'version_id' => '4f656c07-6a08-47b3-9403-16082d2fcda5'],
          ['asset_id' => 'f2a9c03d-3664-477c-8013-e84504ed5adc', 'version_id' => '77b3dd66-e590-4259-82db-25e7f4e62772'],
        ],
        'limit'       => 1,
        'expected'    => ['processed' => 1, 'remaining' => 1, 'errors' => 0],
      ],
    ];
  }

  /**
   * Tests processQueue() for an empty queue, a full drain, and a limited run.
   *
   * Only the keys present in $expected are asserted; each scenario declares
   * exactly the keys it cares about.
   *
   * @dataProvider processQueueProvider
   * @covers ::processQueue
   */
  public function testProcessQueue(
    array $asset_specs,
    ?int $limit,
    array $expected,
  ): void {
    $image_type = $this->getImageMediaType();
    $update_map = [];
    foreach ($asset_specs as $spec) {
      $media = $this->createMedia($image_type, $spec['asset_id'], $spec['version_id']);
      $update_map[(int) $media->id()] = $spec['asset_id'];
    }
    if (!empty($update_map)) {
      $this->assetUpdater->queueUpdateAssets($update_map);
    }

    $result = $this->assetUpdater->processQueue($limit !== NULL ? ['limit' => $limit] : []);

    foreach ($expected as $key => $value) {
      $this->assertSame($value, $result[$key], "processQueue() result '$key' mismatch.");
    }
  }

}
