<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_interrupt\Kernel;

use Drupal\Core\Session\AnonymousUserSession;
use Drupal\Tests\flowdrop\Kernel\WorkflowExecutionTestBase;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\flowdrop_pipeline\Constants\PipelineStatus;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface;
use Drupal\flowdrop_workflow\WorkflowDefinitionInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * Pins the HTTP boundary of the pipeline Cancel / Pause / Resume signals.
 *
 * POST /flowdrop/api/pipelines/{id}/{cancel,pause,resume} are the
 * operator-facing signalling surface. The security contract lives entirely
 * at the routing layer (the controller actions never re-check), so this
 * test drives the real routes — access callbacks, CSRF requirement, and
 * POST-only methods — rather than calling the controller in isolation.
 *
 * The matrix pinned here:
 * - owner-of-pipeline always reaches the controller (no permission needed);
 * - a non-owner needs the matching `cancel|pause any flowdrop workflow`
 *   permission — view-only and anonymous callers are denied (403);
 * - one user's signal must never reach another user's pipeline (cross-tenant
 *   isolation);
 * - terminal pipelines reject signals with 409, not 403 — access is granted
 *   but the business rule refuses;
 * - duplicate inward signals on the same pipeline are rejected with 409;
 * - all three routes carry the CSRF request-header requirement and are
 *   POST-only.
 *
 * Fixtures are launched through the production path so ownership and the
 * paused (non-terminal) state are stamped exactly the way runtime stamps
 * them — a confirmation (HITL) node leaves the pipeline PAUSED.
 *
 * @group flowdrop_interrupt
 */
class PipelineSignalRoutedPathTest extends WorkflowExecutionTestBase {

  use UserCreationTrait;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    // Burn uid 1: the superuser bypasses every access check.
    $this->createUser();
  }

  /**
   * Owner reaches the controller without any signalling permission.
   */
  public function testOwnerCanSignalWithoutPermission(): void {
    $owner = $this->createUser([]);
    $pipeline = $this->launchPausedPipeline('owner_signal', $owner);

    $this->setCurrentUser($owner);
    $response = $this->signal('cancel', $pipeline->id(), '{"reason": "stop"}');
    $this->assertSame(Response::HTTP_ACCEPTED, $response->getStatusCode(),
      'The pipeline owner may cancel without holding any permission.');
  }

  /**
   * A non-owner needs the matching permission; view-only is denied.
   */
  public function testNonOwnerRequiresPermission(): void {
    $owner = $this->createUser([]);
    $pipeline = $this->launchPausedPipeline('non_owner_perm', $owner);

    // Permission-less outsider: routing denies before the controller runs.
    $this->setCurrentUser($this->createUser([]));
    $response = $this->signal('cancel', $pipeline->id(), '{}');
    $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode(),
      'A non-owner without "cancel any flowdrop workflow" is denied.');

    // Permissioned outsider reaches the controller — the 403 doesn't
    // consume the pipeline, so this is still the first inward signal.
    $this->setCurrentUser($this->createUser(['cancel any flowdrop workflow']));
    $response = $this->signal('cancel', $pipeline->id(), '{}');
    $this->assertSame(Response::HTTP_ACCEPTED, $response->getStatusCode(),
      '"cancel any flowdrop workflow" lets a non-owner cancel.');
  }

  /**
   * Anonymous callers never reach the signalling controller.
   */
  public function testAnonymousIsDenied(): void {
    $owner = $this->createUser([]);
    $pipeline = $this->launchPausedPipeline('anon_signal', $owner);

    $this->setCurrentUser(new AnonymousUserSession());
    $response = $this->signal('cancel', $pipeline->id(), '{}');
    $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode(),
      'Anonymous callers cannot signal a pipeline.');
  }

  /**
   * The pause permission gates pause and resume; cancel permission does not.
   */
  public function testPausePermissionDoesNotGrantCancelAndViceVersa(): void {
    $owner = $this->createUser([]);
    $pipeline = $this->launchPausedPipeline('perm_split', $owner);

    // Holder of cancel-only cannot pause.
    $this->setCurrentUser($this->createUser(['cancel any flowdrop workflow']));
    $response = $this->signal('pause', $pipeline->id(), '{}');
    $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode(),
      '"cancel any flowdrop workflow" must not authorise a pause.');

    // Holder of pause-only cannot cancel.
    $this->setCurrentUser($this->createUser(['pause any flowdrop workflow']));
    $response = $this->signal('cancel', $pipeline->id(), '{}');
    $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode(),
      '"pause any flowdrop workflow" must not authorise a cancel.');
  }

  /**
   * One user's pipeline is invisible to another user's signal.
   */
  public function testCrossTenantSignalIsDenied(): void {
    $firstUser = $this->createUser([]);
    $pipeline = $this->launchPausedPipeline('cross_tenant', $firstUser);

    // A second user owns their own runs but holds no signalling permission
    // — the first user's pipeline must be off-limits to them.
    $this->setCurrentUser($this->createUser([]));
    $response = $this->signal('cancel', $pipeline->id(), '{}');
    $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode(),
      'A bare authenticated user cannot cancel another user\'s pipeline.');
  }

  /**
   * A duplicate inward signal on the same pipeline is rejected with 409.
   */
  public function testDuplicateSignalIsConflict(): void {
    $owner = $this->createUser([]);
    $pipeline = $this->launchPausedPipeline('dup_signal', $owner);
    $this->setCurrentUser($owner);

    $first = $this->signal('cancel', $pipeline->id(), '{}');
    $this->assertSame(Response::HTTP_ACCEPTED, $first->getStatusCode());

    $second = $this->signal('cancel', $pipeline->id(), '{}');
    $this->assertSame(Response::HTTP_CONFLICT, $second->getStatusCode(),
      'A second inward signal on the same pipeline is a 409, not a duplicate.');
  }

  /**
   * Access is granted on a terminal pipeline, but the signal 409s.
   *
   * The owner is authorised (no 403) yet the business rule refuses a
   * terminal pipeline — proving the access layer and the state guard are
   * independent.
   */
  public function testTerminalPipelineIsConflictNotForbidden(): void {
    $owner = $this->createUser([]);
    $this->setCurrentUser($owner);
    // A plain logger workflow runs to completion — a terminal pipeline.
    $workflow = $this->createStateGraphWorkflow('terminal_signal', [
      $this->loggerNode('sink', 'done'),
    ], []);
    $result = $this->launchAndWait($workflow);
    $pipeline = $this->loadPipeline($result->pipelineId);
    $this->assertSame(PipelineStatus::COMPLETED, $pipeline->getStatus(),
      $this->jobTrail($pipeline));

    $response = $this->signal('cancel', $pipeline->id(), '{}');
    $this->assertSame(Response::HTTP_CONFLICT, $response->getStatusCode(),
      'A terminal pipeline rejects signals with 409 — access still passed.');
  }

  /**
   * Resuming a pipeline with no pending inward pause signal is a 409.
   */
  public function testResumeWithoutPauseIsConflict(): void {
    $owner = $this->createUser([]);
    $pipeline = $this->launchPausedPipeline('resume_no_pause', $owner);
    $this->setCurrentUser($owner);

    // The HITL pause is not an inward Pause signal, so resume finds none.
    $response = $this->signal('resume', $pipeline->id(), '{}');
    $this->assertSame(Response::HTTP_CONFLICT, $response->getStatusCode(),
      'Resume with no active inward pause signal is a 409.');
  }

  /**
   * An unknown pipeline never reveals existence: routing denies it.
   */
  public function testUnknownPipelineIsDenied(): void {
    $this->setCurrentUser($this->createUser(['cancel any flowdrop workflow']));
    $response = $this->signal('cancel', 'does-not-exist', '{}');
    $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode(),
      'A missing pipeline yields a routing denial, not a controller 404 leak.');
  }

  /**
   * All three signal routes are POST-only and CSRF-guarded.
   *
   * Pinned at the route level: the core CSRF check only enforces for
   * cookie-authenticated requests, which kernel-test requests are not, so
   * an end-to-end denial cannot be asserted here.
   */
  public function testRoutesAreCsrfGuardedAndPostOnly(): void {
    $provider = $this->container->get('router.route_provider');
    foreach (['cancel', 'pause', 'resume'] as $action) {
      $route = $provider->getRouteByName('flowdrop_interrupt.api.pipeline.' . $action);
      $this->assertSame('TRUE', $route->getRequirement('_csrf_request_header_token'),
        sprintf('The %s route must require the CSRF request-header token.', $action));
      $this->assertSame(['POST'], $route->getMethods(),
        sprintf('The %s route must be POST-only.', $action));
    }
  }

  /**
   * Launches the HITL fixture as the given owner, returning a PAUSED run.
   *
   * @param string $id
   *   The workflow id (also seeds the pipeline fixture).
   * @param \Drupal\user\UserInterface $owner
   *   The user the pipeline is stamped to.
   *
   * @return \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface
   *   The paused, non-terminal pipeline.
   */
  protected function launchPausedPipeline(string $id, $owner): FlowDropPipelineRuntimeInterface {
    $this->setCurrentUser($owner);
    $workflow = $this->confirmationWorkflow($id);
    $result = $this->launchAndWait($workflow);
    $pipeline = $this->loadPipeline($result->pipelineId);
    $this->assertSame(PipelineStatus::PAUSED, $pipeline->getStatus(),
      'The fixture must pause on its confirmation node: ' . $this->jobTrail($pipeline));
    return $pipeline;
  }

  /**
   * Builds a workflow that pauses on a confirmation (HITL) node.
   */
  protected function confirmationWorkflow(string $id): WorkflowDefinitionInterface {
    return $this->createStateGraphWorkflow($id, [
      $this->loggerNode('src', 'ready'),
      $this->confirmationNode('confirm', 'Proceed?'),
    ], [
      $this->edge('src', 'message', 'confirm', 'message'),
    ]);
  }

  /**
   * POSTs to a pipeline signal route as the current user with a CSRF token.
   *
   * @param string $action
   *   One of cancel, pause, resume.
   * @param string $pipelineId
   *   The pipeline id in the URL.
   * @param string $body
   *   The JSON request body.
   *
   * @return \Symfony\Component\HttpFoundation\Response
   *   The HTTP response.
   */
  protected function signal(string $action, string $pipelineId, string $body): Response {
    // _format=json keeps denials on the JSON exception path — the HTML 403
    // page is rendered via a same-method subrequest, turning every POST
    // denial into a misleading 405.
    $request = Request::create(
      sprintf('/flowdrop/api/pipelines/%s/%s?_format=json', $pipelineId, $action),
      'POST',
      server: ['CONTENT_TYPE' => 'application/json'],
      content: $body,
    );
    $request->headers->set('X-CSRF-Token', $this->container->get('csrf_token')->get('rest'));
    return $this->container->get('http_kernel')->handle($request);
  }

}
