<?php

declare(strict_types=1);

namespace Drupal\Tests\workspaces\Kernel;

use Drupal\Core\Entity\EntityStorageException;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\workspaces\Entity\Workspace;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests CRUD operations for workspaces.
 */
#[Group('workspaces')]
#[RunTestsInSeparateProcesses]
class WorkspaceCRUDTest extends KernelTestBase {

  use UserCreationTrait;
  use NodeCreationTrait;
  use ContentTypeCreationTrait;
  use WorkspaceTestTrait;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * The state service.
   *
   * @var \Drupal\Core\State\StateInterface
   */
  protected $state;

  /**
   * The workspace replication manager.
   *
   * @var \Drupal\workspaces\WorkspaceManagerInterface
   */
  protected $workspaceManager;

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'user',
    'system',
    'workspaces',
    'field',
    'filter',
    'node',
    'text',
  ];

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    $this->setUpCurrentUser();

    $this->installSchema('node', ['node_access']);

    $this->installEntitySchema('workspace');
    $this->installSchema('workspaces', ['workspace_association', 'workspace_association_revision']);
    $this->installEntitySchema('node');

    $this->installConfig(['filter', 'node', 'system']);

    $this->createContentType(['type' => 'page']);

    $this->entityTypeManager = \Drupal::entityTypeManager();
    $this->state = \Drupal::state();
    $this->workspaceManager = \Drupal::service('workspaces.manager');
  }

  /**
   * Tests the deletion of workspaces.
   */
  public function testDeletingWorkspaces(): void {
    $admin = $this->createUser([
      'administer nodes',
      'create workspace',
      'view any workspace',
      'edit any workspace',
      'delete any workspace',
    ]);
    $this->setCurrentUser($admin);

    /** @var \Drupal\workspaces\WorkspaceTrackerInterface $workspace_tracker */
    $workspace_tracker = \Drupal::service('workspaces.tracker');

    // Create a workspace with a very small number of associated node revisions.
    $workspace_1 = Workspace::create([
      'id' => 'gibbon',
      'label' => 'Gibbon',
    ]);
    $workspace_1->save();
    $this->workspaceManager->setActiveWorkspace($workspace_1);

    $workspace_1_node_1 = $this->createNode(['status' => FALSE]);
    $workspace_1_node_2 = $this->createNode(['status' => FALSE]);

    // Check that the workspace tracks the initial revisions for both nodes.
    $initial_revisions = $workspace_tracker->getTrackedInitialRevisions($workspace_1->id(), 'node');
    $this->assertCount(2, $initial_revisions);

    for ($i = 0; $i < 4; $i++) {
      $workspace_1_node_1->setNewRevision(TRUE);
      $workspace_1_node_1->save();

      $workspace_1_node_2->setNewRevision(TRUE);
      $workspace_1_node_2->save();
    }

    // The workspace should now track 2 nodes.
    $tracked_entities = $workspace_tracker->getTrackedEntities($workspace_1->id());
    $this->assertCount(2, $tracked_entities['node']);

    // Since all the revisions were created inside a workspace, including the
    // default one, 'workspace_1' should be tracking all 10 revisions.
    $associated_revisions = $workspace_tracker->getAllTrackedRevisions($workspace_1->id(), 'node');
    $this->assertCount(10, $associated_revisions);

    // Check that we are allowed to delete the workspace.
    $this->assertTrue($workspace_1->access('delete', $admin));

    // Delete the workspace and check that all the workspace_association
    // entities and all the node revisions have been deleted as well.
    $workspace_1->delete();

    // There are no more tracked entities in 'workspace_1'.
    $tracked_entities = $workspace_tracker->getTrackedEntities($workspace_1->id());
    $this->assertEmpty($tracked_entities);

    // There are no more revisions associated with 'workspace_1'.
    $associated_revisions = $workspace_tracker->getAllTrackedRevisions($workspace_1->id(), 'node');
    $this->assertCount(0, $associated_revisions);

    // Create another workspace, this time with a larger number of associated
    // node revisions so we can test the batch purge process.
    $workspace_2 = Workspace::create([
      'id' => 'baboon',
      'label' => 'Baboon',
    ]);
    $workspace_2->save();
    $this->workspaceManager->setActiveWorkspace($workspace_2);

    $workspace_2_node_1 = $this->createNode(['status' => FALSE]);
    for ($i = 0; $i < 59; $i++) {
      $workspace_2_node_1->setNewRevision(TRUE);
      $workspace_2_node_1->save();
    }

    // Now there is one entity tracked in 'workspace_2'.
    $tracked_entities = $workspace_tracker->getTrackedEntities($workspace_2->id());
    $this->assertCount(1, $tracked_entities['node']);

    // All 60 are associated with 'workspace_2'.
    $associated_revisions = $workspace_tracker->getAllTrackedRevisions($workspace_2->id(), 'node', [$workspace_2_node_1->id()]);
    $this->assertCount(60, $associated_revisions);

    // Delete the workspace and check that we still have 10 revision left to
    // delete.
    $workspace_2->delete();
    $associated_revisions = $workspace_tracker->getAllTrackedRevisions($workspace_2->id(), 'node', [$workspace_2_node_1->id()]);
    $this->assertCount(10, $associated_revisions);

    $workspace_deleted = \Drupal::state()->get('workspace.deleted');
    $this->assertCount(1, $workspace_deleted);

    // Check that we can not create another workspace with the same ID while its
    // data purging is not finished.
    $workspace_3 = Workspace::create([
      'id' => 'baboon',
      'label' => 'Baboon',
    ]);
    $violations = $workspace_3->validate();
    $this->assertCount(1, $violations);
    $this->assertEquals('A workspace with this ID has been deleted but data still exists for it.', $violations[0]->getMessage());

    // Running cron should delete the remaining data as well as the workspace ID
    // from the "workspace.delete" state entry.
    \Drupal::service('cron')->run();

    // Check that the actual node revisions were deleted as well.
    $node_storage = $this->entityTypeManager->getStorage('node');
    $this->assertEmpty($node_storage->loadMultipleRevisions(array_keys($associated_revisions)));

    // 'workspace_2 'is empty now.
    $associated_revisions = $workspace_tracker->getAllTrackedRevisions($workspace_2->id(), 'node', [$workspace_2_node_1->id()]);
    $this->assertCount(0, $associated_revisions);
    $tracked_entities = $workspace_tracker->getTrackedEntities($workspace_2->id());
    $this->assertCount(0, $tracked_entities);

    $workspace_deleted = \Drupal::state()->get('workspace.deleted');
    $this->assertCount(0, $workspace_deleted);

    // Check that the deleted workspace is no longer active.
    $this->assertFalse($this->workspaceManager->hasActiveWorkspace());
  }

  /**
   * Tests that deleting a workspace keeps its already published content.
   */
  public function testDeletingPublishedWorkspace(): void {
    $admin = $this->createUser([
      'administer nodes',
      'create workspace',
      'view own workspace',
      'edit own workspace',
      'delete own workspace',
    ]);
    $this->setCurrentUser($admin);

    $live_workspace = Workspace::create([
      'id' => 'live',
      'label' => 'Live',
    ]);
    $live_workspace->save();
    $workspace = Workspace::create([
      'id' => 'stage',
      'label' => 'Stage',
    ]);
    $workspace->save();
    $this->workspaceManager->setActiveWorkspace($workspace);

    // Create a new node in the 'stage' workspace.
    $node = $this->createNode(['status' => TRUE]);

    // Create an additional workspace-specific revision for the node.
    $node->setNewRevision(TRUE);
    $node->save();

    // The node should have 3 revisions now: a default and 2 pending ones.
    $revisions = $this->entityTypeManager->getStorage('node')->loadMultipleRevisions([1, 2, 3]);
    $this->assertCount(3, $revisions);
    $this->assertTrue($revisions[1]->isDefaultRevision());
    $this->assertFalse($revisions[2]->isDefaultRevision());
    $this->assertFalse($revisions[3]->isDefaultRevision());

    // Publish the workspace, which should mark revision 3 as the default one
    // and keep revision 2 as a 'source' draft revision.
    $workspace->publish();
    $revisions = $this->entityTypeManager->getStorage('node')->loadMultipleRevisions([1, 2, 3]);
    $this->assertFalse($revisions[1]->isDefaultRevision());
    $this->assertFalse($revisions[2]->isDefaultRevision());
    $this->assertTrue($revisions[3]->isDefaultRevision());

    // Create two new workspace-revisions for the node.
    $node->setNewRevision(TRUE);
    $node->save();
    $node->setNewRevision(TRUE);
    $node->save();

    // The node should now have 5 revisions.
    $revisions = $this->entityTypeManager->getStorage('node')->loadMultipleRevisions([1, 2, 3, 4, 5]);
    $this->assertFalse($revisions[1]->isDefaultRevision());
    $this->assertFalse($revisions[2]->isDefaultRevision());
    $this->assertTrue($revisions[3]->isDefaultRevision());
    $this->assertFalse($revisions[4]->isDefaultRevision());
    $this->assertFalse($revisions[5]->isDefaultRevision());

    // Delete the workspace and check that only the two new pending revisions
    // were deleted by the workspace purging process.
    $workspace->delete();

    $revisions = $this->entityTypeManager->getStorage('node')->loadMultipleRevisions([1, 2, 3, 4, 5]);
    $this->assertCount(3, $revisions);
    $this->assertFalse($revisions[1]->isDefaultRevision());
    $this->assertFalse($revisions[2]->isDefaultRevision());
    $this->assertTrue($revisions[3]->isDefaultRevision());
    $this->assertFalse(isset($revisions[4]));
    $this->assertFalse(isset($revisions[5]));

    // Check that deleting a workspace does not delete the entities which were
    // created in it but became live content in the meantime.
    $node_storage = $this->entityTypeManager->getStorage('node');
    /** @var \Drupal\workspaces\WorkspaceTrackerInterface $workspace_tracker */
    $workspace_tracker = \Drupal::service('workspaces.tracker');
    $workspace_3 = Workspace::create(['id' => 'summer', 'label' => 'Summer']);
    $workspace_3->save();
    $workspace_4 = Workspace::create(['id' => 'winter', 'label' => 'Winter']);
    $workspace_4->save();

    // Create two new nodes in the 'summer' workspace. The published one gets an
    // unpublished initial revision (6) and a published pending revision (7),
    // while the unpublished one only gets an initial revision (8).
    $this->workspaceManager->setActiveWorkspace($workspace_3);
    $node_1 = $this->createNode(['status' => TRUE]);
    $node_2 = $this->createNode(['status' => FALSE]);

    // Simulate a module that bypasses the entity editing protections and
    // creates a new revision (9) of the first node in another workspace.
    $this->workspaceManager->setActiveWorkspace($workspace_4);
    $node_1 = $node_storage->load($node_1->id());
    $node_1->setNewRevision(TRUE);
    $node_1->setPublished();
    $node_1->save();

    // Publishing the 'winter' workspace makes revision 9 the published default
    // revision of the first node.
    $workspace_4->publish();

    // Publish the second node in Live without creating a new revision, so its
    // initial revision (8) is also its default revision.
    $this->workspaceManager->switchToLive();
    $node_storage->resetCache();
    $node_2 = $node_storage->load($node_2->id());
    $node_2->setPublished();
    $node_2->save();

    // Deleting the 'summer' workspace has to purge only the revisions created
    // in it, because both nodes are live content now.
    $workspace_3->delete();

    $node_storage->resetCache();
    $node_1 = $node_storage->load($node_1->id());
    $this->assertNotNull($node_1);
    $this->assertTrue($node_1->isPublished());
    $this->assertEquals(9, $node_1->getRevisionId());
    $this->assertEmpty($node_storage->loadMultipleRevisions([6, 7]));

    // The initial revision of the second node is also its default revision, so
    // it can not be deleted. Only its tracking records are removed.
    $node_2 = $node_storage->load($node_2->id());
    $this->assertNotNull($node_2);
    $this->assertTrue($node_2->isPublished());
    $this->assertEquals(8, $node_2->getRevisionId());
    $this->assertEmpty($workspace_tracker->getAllTrackedRevisions($workspace_3->id(), 'node'));

    // The purge finished, so the workspace ID is no longer in the queue.
    $this->assertEmpty(\Drupal::state()->get('workspace.deleted'));
  }

  /**
   * Tests that a workspace with children can not be deleted.
   */
  public function testDeletingWorkspaceWithChildren(): void {
    $stage = Workspace::create(['id' => 'stage', 'label' => 'Stage']);
    $stage->save();

    $dev = Workspace::create(['id' => 'dev', 'label' => 'Dev', 'parent' => 'stage']);
    $dev->save();

    // Check that a workspace which has children can not be deleted.
    try {
      $stage->delete();
      $this->fail('The Stage workspace has children and should not be deletable.');
    }
    catch (EntityStorageException $e) {
      $this->assertEquals('The Stage workspace can not be deleted because it has child workspaces.', $e->getMessage());
      $this->assertNotNull(Workspace::load('stage'));
    }

    // Check that if we delete its child first, the parent workspace can also be
    // deleted.
    $dev->delete();
    $stage->delete();
    $this->assertNull(Workspace::load('dev'));
    $this->assertNull(Workspace::load('stage'));
  }

  /**
   * Tests loading the workspace tree when there are no workspaces available.
   */
  public function testEmptyWorkspaceTree(): void {
    $tree = \Drupal::service('workspaces.repository')->loadTree();
    $this->assertSame([], $tree);
  }

}
