<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_interrupt\Unit\Controller;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\Context\CacheContextsManager;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\flowdrop_interrupt\Controller\PipelineSignalController;
use Drupal\flowdrop_interrupt\Service\InterruptManagerInterface;
use Drupal\flowdrop_interrupt\Service\InterruptMessageBusInterface;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;

/**
 * Tests the route-level access callbacks on PipelineSignalController.
 *
 * Access logic for Cancel / Pause / Resume signals lives at the routing
 * layer after the M5 refactor — these access methods are the single source
 * of truth. The controller action methods no longer re-check, so this is
 * the spot to assert security behaviour.
 *
 * @coversDefaultClass \Drupal\flowdrop_interrupt\Controller\PipelineSignalController
 * @group flowdrop_interrupt
 */
class PipelineSignalControllerAccessTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The mocked interrupt manager.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<InterruptManagerInterface>
   */
  private $manager;

  /**
   * The mocked entity type manager.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<EntityTypeManagerInterface>
   */
  private $entityTypeManager;

  /**
   * The mocked pipeline storage.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<EntityStorageInterface>
   */
  private $pipelineStorage;

  /**
   * The controller under test.
   */
  private PipelineSignalController $controller;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->manager = $this->prophesize(InterruptManagerInterface::class);
    $this->entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);
    $this->pipelineStorage = $this->prophesize(EntityStorageInterface::class);
    $this->entityTypeManager->getStorage('flowdrop_pipeline')
      ->willReturn($this->pipelineStorage->reveal());

    // ControllerBase::entityTypeManager() pulls the service from the global
    // container, so wire the mock through Drupal::setContainer().
    // cache_contexts_manager is needed by AccessResult::addCacheContexts().
    $cacheContextsManager = $this->prophesize(CacheContextsManager::class);
    $cacheContextsManager->assertValidTokens(Argument::any())->willReturn(TRUE);
    $container = new ContainerBuilder();
    $container->set('entity_type.manager', $this->entityTypeManager->reveal());
    $container->set('cache_contexts_manager', $cacheContextsManager->reveal());
    \Drupal::setContainer($container);

    $this->controller = new PipelineSignalController(
      $this->manager->reveal(),
      $this->prophesize(InterruptMessageBusInterface::class)->reveal(),
    );
  }

  /**
   * Owner of the pipeline is always allowed, even without the permission.
   */
  public function testOwnerIsAlwaysAllowed(): void {
    $pipeline = $this->buildPipeline('pipe-1', ownerId: 42);
    $this->pipelineStorage->load('pipe-1')->willReturn($pipeline);

    $account = $this->prophesize(AccountInterface::class);
    $account->id()->willReturn(42);
    // Owner check should short-circuit before any permission check.
    $account->hasPermission(Argument::any())->shouldNotBeCalled();

    $result = $this->controller->accessCancel($account->reveal(), 'pipe-1');
    $this->assertTrue($result->isAllowed());
  }

  /**
   * Non-owner with the matching permission is allowed.
   */
  public function testPermissionAllowsNonOwner(): void {
    $pipeline = $this->buildPipeline('pipe-1', ownerId: 1);
    $this->pipelineStorage->load('pipe-1')->willReturn($pipeline);

    $account = $this->prophesize(AccountInterface::class);
    $account->id()->willReturn(99);
    $account->hasPermission('cancel any flowdrop workflow')->willReturn(TRUE);
    $account->hasPermission(Argument::any())->willReturn(FALSE);

    $result = $this->controller->accessCancel($account->reveal(), 'pipe-1');
    $this->assertTrue($result->isAllowed());
  }

  /**
   * Non-owner without the permission is denied.
   */
  public function testNonOwnerWithoutPermissionDenied(): void {
    $pipeline = $this->buildPipeline('pipe-1', ownerId: 1);
    $this->pipelineStorage->load('pipe-1')->willReturn($pipeline);

    $account = $this->prophesize(AccountInterface::class);
    $account->id()->willReturn(99);
    $account->hasPermission(Argument::any())->willReturn(FALSE);

    $result = $this->controller->accessCancel($account->reveal(), 'pipe-1');
    $this->assertFalse($result->isAllowed());
  }

  /**
   * Admin (administer flowdrop) grants even without the specific permission.
   */
  public function testAdminPermissionGrants(): void {
    $pipeline = $this->buildPipeline('pipe-1', ownerId: 1);
    $this->pipelineStorage->load('pipe-1')->willReturn($pipeline);

    $account = $this->prophesize(AccountInterface::class);
    $account->id()->willReturn(99);
    $account->hasPermission('cancel any flowdrop workflow')->willReturn(FALSE);
    $account->hasPermission('administer flowdrop interrupts')->willReturn(FALSE);
    $account->hasPermission('administer flowdrop')->willReturn(TRUE);

    $result = $this->controller->accessCancel($account->reveal(), 'pipe-1');
    $this->assertTrue($result->isAllowed());
  }

  /**
   * Missing pipeline yields neutral (404 will surface at the action layer).
   */
  public function testMissingPipelineYieldsNeutral(): void {
    $this->pipelineStorage->load('pipe-missing')->willReturn(NULL);
    $this->pipelineStorage->loadByProperties(['uuid' => 'pipe-missing'])->willReturn([]);

    $account = $this->prophesize(AccountInterface::class);
    $account->id()->willReturn(1);
    // No permission lookups should run for a missing entity.
    $account->hasPermission(Argument::any())->shouldNotBeCalled();

    $result = $this->controller->accessCancel($account->reveal(), 'pipe-missing');
    $this->assertFalse($result->isAllowed());
    $this->assertFalse($result->isForbidden());
    // Neutral, not denied — lets routing fall through to a 404 path.
    $this->assertInstanceOf(AccessResult::class, $result);
  }

  /**
   * Pause and Resume gate on the same permission.
   *
   * The permission is "pause any flowdrop workflow".
   */
  public function testPauseAndResumeShareTheSamePermission(): void {
    $pipeline = $this->buildPipeline('pipe-1', ownerId: 1);
    $this->pipelineStorage->load('pipe-1')->willReturn($pipeline);

    $account = $this->prophesize(AccountInterface::class);
    $account->id()->willReturn(99);
    $account->hasPermission('pause any flowdrop workflow')->willReturn(TRUE);
    $account->hasPermission(Argument::any())->willReturn(FALSE);

    $this->assertTrue(
      $this->controller->accessPause($account->reveal(), 'pipe-1')->isAllowed(),
    );
    $this->assertTrue(
      $this->controller->accessResume($account->reveal(), 'pipe-1')->isAllowed(),
    );
  }

  /**
   * Pipeline loaded by UUID when the numeric load returns NULL.
   */
  public function testPipelineLoadFallsBackToUuid(): void {
    $this->pipelineStorage->load('uuid-pipe')->willReturn(NULL);
    $pipeline = $this->buildPipeline('uuid-pipe', ownerId: 42);
    $this->pipelineStorage->loadByProperties(['uuid' => 'uuid-pipe'])->willReturn(['x' => $pipeline]);

    $account = $this->prophesize(AccountInterface::class);
    $account->id()->willReturn(42);
    $account->hasPermission(Argument::any())->shouldNotBeCalled();

    $result = $this->controller->accessCancel($account->reveal(), 'uuid-pipe');
    $this->assertTrue($result->isAllowed());
  }

  /**
   * Builds a minimal pipeline stub returning the given owner id.
   */
  private function buildPipeline(string $id, ?int $ownerId): FlowDropPipelineInterface {
    $pipeline = $this->prophesize(FlowDropPipelineInterface::class);
    $pipeline->id()->willReturn($id);
    $pipeline->getOwnerId()->willReturn($ownerId);
    // Permit AccessResult::addCacheableDependency() to call getCacheTags() /
    // getCacheContexts() / getCacheMaxAge() on the entity without exploding.
    $pipeline->getCacheTags()->willReturn([]);
    $pipeline->getCacheContexts()->willReturn([]);
    $pipeline->getCacheMaxAge()->willReturn(-1);
    return $pipeline->reveal();
  }

}
