<?php

declare(strict_types=1);

namespace Drupal\Tests\acquia_dam\Kernel\Cron;

use Drupal\acquia_dam\Client\AcquiaDamClient;
use Drupal\acquia_dam\Entity\MediaSourceField;
use Drupal\media\Entity\Media;
use Drupal\Tests\acquia_dam\Kernel\AcquiaDamKernelTestBase;

/**
 * Tests the Acquia DAM cron service.
 *
 * Verifies cache invalidation, chunked batch processing, and the media publish
 * state transitions (re-publish and unpublish) driven by the DAM cron.
 *
 * After refactoring, the Cron class delegates to AssetUpdater::syncAssets(),
 * which orchestrates the entire workflow: fetch from DAM, categorize assets,
 * unpublish/queue operations, and cache invalidation.
 *
 * @group acquia_dam_kernel
 * @coversDefaultClass \Drupal\acquia_dam\Cron\Cron
 */
final class CronTest extends AcquiaDamKernelTestBase {

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    // A current user is required so that media revisions created during cron
    // processing have a valid author context.
    $this->drupalSetUpCurrentUser();
  }

  /**
   * Tests that DAM asset cache entries are cleared for active assets.
   *
   * When the search returns a recently-edited asset that has a corresponding
   * media entity, the cron must invalidate its cache entry (via
   * deleteCachedAssets) **before** the MediaItemUpdater queue worker runs, so
   * the worker always fetches fresh data.
   *
   * Flow under test (refactored):
   *   Cron::run()
   *     → AssetUpdater::syncAssets()
   *       → fetchUpdatedAssets()     returns 56ff14de-... (via search)
   *       → categorizeAssets()       → 'update' group (released_and_not_expired: true)
   *       → queueUpdateAssets()
   *         → deleteCachedAssets([asset_id])   ← assert cache cleared here
   *         → createItem(queue)
   *
   * @covers ::run
   * @covers \Drupal\acquia_dam\AssetUpdater::syncAssets
   * @covers \Drupal\acquia_dam\AssetUpdater::fetchUpdatedAssets
   * @covers \Drupal\acquia_dam\AssetUpdater::categorizeAssets
   * @covers \Drupal\acquia_dam\AssetUpdater::queueUpdateAssets
   */
  public function testFetchAndEnqueueAssetsDeletesCacheForActiveAssets(): void {
    $image_media_type = $this->createImageMediaType();

    // Create a media entity that maps to the asset returned by the default
    // "lastEditedSearch.json" fixture (56ff14de-..., RELEASED).
    $media = Media::create([
      'bundle' => $image_media_type->id(),
      MediaSourceField::SOURCE_FIELD_NAME => [
        'asset_id' => '56ff14de-02cd-41b5-9a73-c917eab19abf',
        'version_id' => '4f656c07-6a08-47b3-9403-16082d2fcda5',
      ],
    ]);
    $media->save();

    // Pre-populate the DAM asset cache to simulate a stale entry.
    $dam_cache = $this->container->get('cache.acquia_dam');
    $dam_cache->set('asset:56ff14de-02cd-41b5-9a73-c917eab19abf', ['stale' => 'data']);
    $this->assertNotFalse(
      $dam_cache->get('asset:56ff14de-02cd-41b5-9a73-c917eab19abf'),
      'Cache entry should exist before the cron run.'
    );

    // Run only the Acquia DAM cron (not the full Drupal cron stack, to avoid
    // triggering unrelated queue workers in this focused test).
    $this->container->get('acquia_dam.cron')->run();

    // The cache entry must have been cleared by deleteCachedAssets().
    $this->assertFalse(
      $dam_cache->get('asset:56ff14de-02cd-41b5-9a73-c917eab19abf'),
      'Cache entry should be cleared after the cron run.'
    );
  }

  /**
   * Tests that multiple updated assets are cache-cleared together in a batch.
   *
   * The AssetUpdater groups active assets in chunks of up to 50 (CHUNK_SIZE)
   * before calling deleteCachedAssets(). This test verifies that when a single
   * search page returns 3 updated assets (all within one 50-item chunk), all
   * three cache entries are invalidated together in a single batch call.
   *
   * Fixture used: lastEditedSearch3Items.json
   *   → 56ff14de-... (image, released_and_not_expired: true)
   *   → 4f656c07-... (pdf,   released_and_not_expired: true)
   *   → f2a9c03d-... (image, released_and_not_expired: true)
   *
   * Flow under test (refactored):
   *   Cron::run()
   *     → AssetUpdater::syncAssets()
   *       → fetchUpdatedAssets()     returns 3 assets
   *       → categorizeAssets()       → all 3 in 'update' group
   *       → queueUpdateAssets()      chunks into batches of CHUNK_SIZE
   *         → deleteCachedAssets([asset1, asset2, asset3])   ← single batch
   *
   * @covers ::run
   * @covers \Drupal\acquia_dam\AssetUpdater::syncAssets
   * @covers \Drupal\acquia_dam\AssetUpdater::fetchUpdatedAssets
   * @covers \Drupal\acquia_dam\AssetUpdater::categorizeAssets
   * @covers \Drupal\acquia_dam\AssetUpdater::queueUpdateAssets
   */
  public function testFetchAndEnqueueAssetsChunkingOf50(): void {
    // Switch the "lastEditDate" search fixture to one that returns 3 assets.
    $this->container->get('state')->set(
      'acquia_dam_test.last_edited_search_fixture',
      'lastEditedSearch3Items.json'
    );

    $image_media_type = $this->createImageMediaType();
    $pdf_media_type   = $this->createPdfMediaType();

    // Asset 1 — 56ff14de-... (image, RELEASED).
    Media::create([
      'bundle' => $image_media_type->id(),
      MediaSourceField::SOURCE_FIELD_NAME => [
        'asset_id'   => '56ff14de-02cd-41b5-9a73-c917eab19abf',
        'version_id' => '4f656c07-6a08-47b3-9403-16082d2fcda5',
      ],
    ])->save();

    // Asset 2 — 4f656c07-... (pdf, RELEASED).
    Media::create([
      'bundle' => $pdf_media_type->id(),
      MediaSourceField::SOURCE_FIELD_NAME => [
        'asset_id'   => '4f656c07-6a08-47b3-9403-16082d2fcda2',
        'version_id' => 'c6a1d232-4460-43de-afd2-b3bd53ade80f',
      ],
    ])->save();

    // Asset 3 — f2a9c03d-... (image/PNG, RELEASED).
    Media::create([
      'bundle' => $image_media_type->id(),
      MediaSourceField::SOURCE_FIELD_NAME => [
        'asset_id'   => 'f2a9c03d-3664-477c-8013-e84504ed5adc',
        'version_id' => '77b3dd66-e590-4259-82db-25e7f4e62772',
      ],
    ])->save();

    // Pre-populate cache entries for all three assets.
    $dam_cache = $this->container->get('cache.acquia_dam');
    $dam_cache->set('asset:56ff14de-02cd-41b5-9a73-c917eab19abf', ['stale' => 'data1']);
    $dam_cache->set('asset:4f656c07-6a08-47b3-9403-16082d2fcda2',   ['stale' => 'data2']);
    $dam_cache->set('asset:f2a9c03d-3664-477c-8013-e84504ed5adc',   ['stale' => 'data3']);

    // Validate cached data for all three assets.
    $this->assertNotFalse(
      $dam_cache->get('asset:56ff14de-02cd-41b5-9a73-c917eab19abf'),
      'Cache entry for asset 1 should exist before the cron run.'
    );
    $this->assertNotFalse(
      $dam_cache->get('asset:4f656c07-6a08-47b3-9403-16082d2fcda2'),
      'Cache entry for asset 2 should exist before the cron run.'
    );
    $this->assertNotFalse(
      $dam_cache->get('asset:f2a9c03d-3664-477c-8013-e84504ed5adc'),
      'Cache entry for asset 3 should exist before the cron run.'
    );

    // Run the Acquia DAM cron.
    $this->container->get('acquia_dam.cron')->run();

    // All three cache entries must have been cleared together in a single
    // deleteCachedAssets() batch call, not one entry at a time.
    $this->assertFalse(
      $dam_cache->get('asset:56ff14de-02cd-41b5-9a73-c917eab19abf'),
      'Cache for asset 1 (image) should be cleared.'
    );
    $this->assertFalse(
      $dam_cache->get('asset:4f656c07-6a08-47b3-9403-16082d2fcda2'),
      'Cache for asset 2 (pdf) should be cleared.'
    );
    $this->assertFalse(
      $dam_cache->get('asset:f2a9c03d-3664-477c-8013-e84504ed5adc'),
      'Cache for asset 3 (image/png) should be cleared.'
    );
  }

  /**
   * Tests that an unpublished media item is re-published when its DAM asset is
   * released again.
   *
   * Flow under test (full Drupal cron, refactored):
   *   Cron::run()
   *     → AssetUpdater::syncAssets()
   *       → fetchUpdatedAssets()     returns 56ff14de-... (via search)
   *       → categorizeAssets()       → 'update' group (released_and_not_expired: true)
   *       → queueUpdateAssets()
   *         → deleteCachedAssets()
   *         → createItem(queue)      always — no version comparison
   *   MediaItemUpdater queue worker
   *     → getAsset()          fresh data: released_and_not_expired: true
   *     → setPublished() + saves new revision   ← assert
   *
   * Fixture used: lastEditedSearch.json (default)
   *   → 56ff14de-... (image, released_and_not_expired: true)
   *
   * @covers ::run
   * @covers \Drupal\acquia_dam\AssetUpdater::syncAssets
   * @covers \Drupal\acquia_dam\AssetUpdater::categorizeAssets
   * @covers \Drupal\acquia_dam\AssetUpdater::queueUpdateAssets
   */
  public function testAssetRePublish(): void {
    $image_media_type = $this->createImageMediaType();

    // Create a media entity that is UNPUBLISHED. The DAM asset (56ff14de-...,
    // returned by the default search fixture) is RELEASED, so cron should
    // re-publish the media item via the MediaItemUpdater queue worker.
    $media = Media::create([
      'bundle' => $image_media_type->id(),
      MediaSourceField::SOURCE_FIELD_NAME => [
        'asset_id'   => '56ff14de-02cd-41b5-9a73-c917eab19abf',
        'version_id' => '4f656c07-6a08-47b3-9403-16082d2fcdb2',
      ],
    ]);
    $media->setUnpublished();
    $media->save();

    $this->assertFalse($media->isPublished(), 'Media should start as unpublished.');

    // Run the full Drupal cron so that both syncAssets() and the
    // MediaItemUpdater queue worker execute within the same cron run.
    $this->container->get('cron')->run();

    // Reload the entity to pick up the revision saved by the queue worker.
    $media = $this->reloadEntity($media);

    $this->assertTrue($media->isPublished(), 'Media should be re-published after cron.');
  }

  /**
   * Tests that a published media item is unpublished when its DAM asset
   * becomes unavailable (expired / unreleased).
   *
   * With the refactored categorizeAssets() approach, the status signal comes
   * directly from the search response (released_and_not_expired field), so no
   * extra per-asset getAsset() API call is needed. The AssetUpdater unpublishes
   * the media entity directly via unpublishAssets() — no queue worker involvement.
   *
   * Test strategy:
   *   Step 1 — Create media while the search returns the asset as RELEASED
   *            (default fixture). The presave hook sees it as available →
   *            media is saved as published.
   *   Step 2 — Switch the search fixture to one where the same asset is
   *            returned with released_and_not_expired: false, simulating the
   *            asset becoming unavailable in DAM.
   *   Step 3 — Run cron:
   *            Cron::run() → AssetUpdater::syncAssets()
   *              → categorizeAssets() reads the search payload → 'unpublish' group
   *              → unpublishAssets() → setUnpublished()->save() directly   ← assert
   *
   * Fixture for step 2: lastEditedSearchAssetBecameUnreleased.json
   *   → 56ff14de-... (released_and_not_expired: false, deleted_date: null)
   *
   * @covers ::run
   * @covers \Drupal\acquia_dam\AssetUpdater::syncAssets
   * @covers \Drupal\acquia_dam\AssetUpdater::categorizeAssets
   * @covers \Drupal\acquia_dam\AssetUpdater::unpublishAssets
   */
  public function testAssetUnpublish(): void {
    $image_media_type = $this->createImageMediaType();

    // Step 1: Create the media entity while the search returns the asset as
    // RELEASED (default fixture). The presave hook sees it as available →
    // media is saved as published.
    $media = Media::create([
      'bundle' => $image_media_type->id(),
      MediaSourceField::SOURCE_FIELD_NAME => [
        'asset_id'   => '56ff14de-02cd-41b5-9a73-c917eab19abf',
        'version_id' => '4f656c07-6a08-47b3-9403-16082d2fcda5',
      ],
    ]);
    $media->save();

    $this->assertTrue($media->isPublished(), 'Media should start as published.');

    // Step 2: Switch the "lastEditDate" search fixture to one that returns the
    // same asset with released_and_not_expired: false. This simulates the
    // asset becoming expired / unreleased in DAM after the media was created.
    $this->container->get('state')->set(
      'acquia_dam_test.last_edited_search_fixture',
      'lastEditedSearchAssetBecameUnreleased.json'
    );

    // Step 3: Run only the Acquia DAM cron. categorizeAssets() reads
    // released_and_not_expired: false from the search payload → 'unpublish'
    // group → unpublishAssets() calls setUnpublished()->save() directly (no
    // queue worker needed).
    $this->container->get('acquia_dam.cron')->run();

    // Reload the entity and assert it has been unpublished.
    $media = $this->reloadEntity($media);

    // Assert the latest revision log author.
    $this->assertEquals(
      0,
      $media->getRevisionUserId(),
      'Revision log user ID 0 for anonymous, since the cron runs without a user context.'
    );
    // Assert the latest revision log message.
    $this->assertEquals(
      'Automatically unpublished as the associated DAM asset is unavailable.',
      $media->getRevisionLogMessage(),
      'Revision log message should indicate automatic unpublishing.'
    );

    $this->assertFalse($media->isPublished(), 'Media should be unpublished after cron.');
  }

  /**
   * Tests that cron respects the state-based timestamp filter.
   *
   * Verifies that Cron::run() passes the last_update_check timestamp to
   * AssetUpdater::syncAssets(), implementing incremental updates.
   *
   * @covers ::run
   * @covers \Drupal\acquia_dam\AssetUpdater::syncAssets
   * @covers \Drupal\acquia_dam\AssetUpdater::fetchUpdatedAssets
   */
  public function testCronUsesStateTimestamp(): void {
    $state = $this->container->get('state');

    // Set a timestamp from yesterday.
    $yesterday = strtotime('-1 day');
    $state->set('acquia_dam.last_update_check', $yesterday);

    // Run cron - it should pass this timestamp to syncAssets().
    $this->container->get('acquia_dam.cron')->run();

    // After successful run, the timestamp should be updated to current time.
    $new_timestamp = $state->get('acquia_dam.last_update_check');
    $this->assertGreaterThan($yesterday, $new_timestamp, 'Timestamp should be updated after cron run.');
  }

  /**
   * Tests that cron updates state timestamp after successful run.
   *
   * @covers ::run
   */
  public function testCronUpdatesStateTimestamp(): void {
    $state = $this->container->get('state');

    // Clear any existing timestamp.
    $state->delete('acquia_dam.last_update_check');

    $before_run = time();

    // Run cron.
    $result = $this->container->get('acquia_dam.cron')->run();

    $after_run = time();

    // Verify cron returned TRUE (success).
    $this->assertTrue($result, 'Cron should return TRUE on success.');

    // Verify timestamp was set.
    $timestamp = $state->get('acquia_dam.last_update_check');
    $this->assertNotNull($timestamp, 'Timestamp should be set after cron run.');
    $this->assertGreaterThanOrEqual($before_run, $timestamp, 'Timestamp should be >= time before run.');
    $this->assertLessThanOrEqual($after_run, $timestamp, 'Timestamp should be <= time after run.');
  }
}
