<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_workflow_executor\Kernel;

use Drupal\Tests\flowdrop\Kernel\WorkflowExecutionTestBase;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\flowdrop_workflow\WorkflowDefinitionInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * Pins the HTTP boundary of the workflow launch endpoint.
 *
 * POST /api/flowdrop/workflow/{id}/run is the programmatic execution
 * surface; its contract is the execute-vs-view split ('view
 * flowdrop_workflow' must never launch), the CSRF header requirement,
 * and strict body validation before anything reaches the launcher.
 *
 * @group flowdrop_workflow_executor
 */
class WorkflowLaunchApiAccessTest extends WorkflowExecutionTestBase {

  use UserCreationTrait;

  /**
   * The launchable fixture workflow.
   */
  protected WorkflowDefinitionInterface $workflow;

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

    // Burn uid 1: the superuser bypasses all access checks.
    $this->createUser();

    $this->workflow = $this->createStateGraphWorkflow('launch_api', [
      $this->loggerNode('sink', 'launched'),
    ], []);
  }

  /**
   * POSTs to the launch route as the current user, with a valid CSRF token.
   */
  protected function launchRequest(string $workflowId, string $body): Response {
    // _format=json keeps error responses on the JSON exception path —
    // the HTML 403 page is rendered via a same-method subrequest, which
    // turns every POST denial into a misleading 405.
    $request = Request::create(
      sprintf('/api/flowdrop/workflow/%s/run?_format=json', $workflowId),
      '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);
  }

  /**
   * Launching requires the execute capability — view never launches.
   */
  public function testLaunchRequiresExecutePermission(): void {
    // View-only: workflows are readable, never launchable.
    $this->setCurrentUser($this->createUser(['view flowdrop_workflow']));
    $response = $this->launchRequest('launch_api', '{"inputs": {}}');
    $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode(),
      '"view flowdrop_workflow" must not launch a workflow.');

    // No permissions at all: denied.
    $this->setCurrentUser($this->createUser([]));
    $response = $this->launchRequest('launch_api', '{"inputs": {}}');
    $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode());

    // The execute capability launches: 202 with the pipeline handle.
    $this->setCurrentUser($this->createUser(['execute flowdrop_workflow']));
    $response = $this->launchRequest('launch_api', '{"inputs": {}}');
    $this->assertSame(Response::HTTP_ACCEPTED, $response->getStatusCode());
    $payload = json_decode((string) $response->getContent(), TRUE);
    $this->assertNotEmpty($payload['data']['pipelineId'] ?? NULL);
  }

  /**
   * The launch route carries the CSRF request-header requirement.
   *
   * Pinned at the route level: the core 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 testLaunchRequiresCsrfToken(): void {
    $route = $this->container->get('router.route_provider')
      ->getRouteByName('flowdrop_workflow_executor.api.workflow.run');
    $this->assertSame('TRUE', $route->getRequirement('_csrf_request_header_token'),
      'The launch route must require the CSRF request-header token.');
    $this->assertSame(['POST'], $route->getMethods());
  }

  /**
   * An unknown workflow 404s before access semantics leak anything.
   */
  public function testUnknownWorkflowIsNotFound(): void {
    $this->setCurrentUser($this->createUser(['execute flowdrop_workflow']));
    $response = $this->launchRequest('does_not_exist', '{"inputs": {}}');
    $this->assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode());
  }

  /**
   * Malformed bodies are rejected with 400 before reaching the launcher.
   */
  public function testMalformedBodiesAreRejected(): void {
    $this->setCurrentUser($this->createUser(['execute flowdrop_workflow']));

    // Not JSON at all.
    $response = $this->launchRequest('launch_api', 'this is not json');
    $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode(),
      'A non-JSON body is a 400.');

    // A scalar JSON document is not an object.
    $response = $this->launchRequest('launch_api', '"just a string"');
    $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode(),
      'A scalar JSON body is a 400.');

    // "inputs" must be an object, not a scalar.
    $response = $this->launchRequest('launch_api', '{"inputs": "scalar"}');
    $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode(),
      'A scalar "inputs" is a 400.');

    // An empty body is a valid launch with no inputs.
    $response = $this->launchRequest('launch_api', '');
    $this->assertSame(Response::HTTP_ACCEPTED, $response->getStatusCode(),
      'An empty body launches with empty inputs.');
  }

  /**
   * A structurally invalid definition is a 422 with per-error detail.
   *
   * The launch path now runs the shared WorkflowValidator before creating a
   * pipeline. A definition that reached storage without save-validation (here:
   * an edge into a port the node marks not-exposed) must be reported as the
   * caller's 422 — matching the save path — not an opaque 500.
   */
  public function testInvalidDefinitionIsRejectedWith422(): void {
    // Direct entity save bypasses the form/API save-validation, so this
    // invalid definition persists — exactly the gap launch validation closes.
    $this->createStateGraphWorkflow('invalid_launch', [
      $this->loggerNode('src', 'source'),
      $this->node('sink', 'logger', [
        'message' => 'sink',
        'level' => 'info',
        // Mark the connectable 'message' input not-exposed, then wire into it.
        'ports' => ['inputs' => [['id' => 'message', 'exposed' => FALSE]]],
      ]),
    ], [
      $this->edge('src', 'message', 'sink', 'message'),
    ]);

    $this->setCurrentUser($this->createUser(['execute flowdrop_workflow']));
    $response = $this->launchRequest('invalid_launch', '{"inputs": {}}');

    $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode(),
      'An invalid definition is the caller\'s 422, not a server 500.');
    $payload = json_decode((string) $response->getContent(), TRUE);
    $this->assertFalse($payload['success']);
    $this->assertNotEmpty($payload['details'], 'The 422 carries per-error detail.');
    $codes = array_column($payload['details'], 'code');
    $this->assertContains('R7_EDGE_TARGET_NOT_EXPOSED', $codes);
  }

  /**
   * A payload nested past the JSON depth limit is rejected, not parsed.
   *
   * The `json_decode()` call caps nesting at its default depth (512) and
   * returns NULL beyond it, so an adversarially deep body is rejected as
   * malformed before reaching the launcher — there is no unbounded-recursion
   * parse to exploit.
   */
  public function testDeeplyNestedPayloadIsRejected(): void {
    $this->setCurrentUser($this->createUser(['execute flowdrop_workflow']));

    // 600 levels of nesting under "inputs" exceeds the 512 default depth.
    $depth = 600;
    $nested = str_repeat('[', $depth) . '1' . str_repeat(']', $depth);
    $response = $this->launchRequest('launch_api', '{"inputs": ' . $nested . '}');
    $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode(),
      'A payload nested beyond the JSON depth limit is a 400.');
  }

}
