<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_playground\Kernel;

use Drupal\Core\Session\AccountInterface;
use Drupal\Tests\flowdrop\Kernel\WorkflowExecutionTestBase;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\flowdrop_playground\Controller\PlaygroundApiController;
use Drupal\flowdrop_session\Constants\SessionStatus;
use Drupal\flowdrop_session\Entity\FlowDropSession;
use Drupal\flowdrop_session\Entity\FlowDropSessionInterface;
use Drupal\flowdrop_workflow\WorkflowDefinitionInterface;

/**
 * Pins session isolation on the playground API access callbacks.
 *
 * The playground routes are the only consumer-facing surface where one
 * user's session UUID can be presented by another user, so the access
 * callbacks are the isolation boundary:
 *
 * - accessSession() — view semantics: own/any split, owner check.
 * - accessDriveSession() — execute-vs-view split: driving a session
 *   (send message, stop, reset) requires "execute session workflow" on
 *   top of view access, mirroring the canonical session turn route
 *   (flowdrop_session.api.session.turn). View permissions alone must
 *   never run, stop, or reset a session.
 * - accessDeleteSession() — delete semantics: own/any split.
 *
 * The route-wiring test pins which callback each route uses — the drive
 * callback being correct means nothing if a POST route still points at
 * the view callback.
 *
 * @group flowdrop_playground
 */
class PlaygroundSessionAccessTest extends WorkflowExecutionTestBase {

  use UserCreationTrait;

  /**
   * {@inheritdoc}
   */
  protected static $modules = ['flowdrop_playground'];

  /**
   * The playground API controller under test.
   */
  protected PlaygroundApiController $controller;

  /**
   * The workflow sessions are bound to.
   */
  protected WorkflowDefinitionInterface $workflow;

  /**
   * The session owner.
   */
  protected AccountInterface $owner;

  /**
   * A session owned by $owner.
   */
  protected FlowDropSessionInterface $session;

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

    // Burn uid 1: the first created user is the superuser and bypasses
    // all access checks, which would make every assertion pass vacuously.
    $this->createUser();

    $this->controller = PlaygroundApiController::create($this->container);
    $this->workflow = $this->createStateGraphWorkflow('session_access', [
      $this->node('chat_input.1', 'chat_input', [], [
        'name' => 'Chat Input',
        'category' => 'input',
      ]),
      $this->node('chat_output.1', 'chat_output', [], [
        'name' => 'Chat Output',
        'category' => 'output',
      ]),
    ], [
      $this->edge('chat_input.1', 'message', 'chat_output.1', 'message'),
    ]);

    $this->owner = $this->createUser([
      'view own flowdrop_session',
      'execute session workflow',
    ]);
    $this->session = $this->createSessionOwnedBy($this->owner);
  }

  /**
   * Creates an idle session owned by the given account.
   */
  protected function createSessionOwnedBy(AccountInterface $owner): FlowDropSessionInterface {
    $session = FlowDropSession::create([
      'workflow_id' => $this->workflow->id(),
      'name' => 'Access fixture',
      'status' => SessionStatus::IDLE,
      'uid' => $owner->id(),
      'metadata' => '{}',
    ]);
    $session->save();
    return $session;
  }

  /**
   * View callback: own/any split with owner check.
   */
  public function testViewCallbackOwnershipMatrix(): void {
    $uuid = (string) $this->session->uuid();

    // Owner with "view own" sees their session.
    $this->assertTrue(
      $this->controller->accessSession($this->owner, $uuid)->isAllowed(),
      'Owner with "view own" can view their session.',
    );

    // A different user with only "view own" must not see it.
    $other = $this->createUser(['view own flowdrop_session']);
    $this->assertFalse(
      $this->controller->accessSession($other, $uuid)->isAllowed(),
      'Non-owner with "view own" cannot view a foreign session.',
    );

    // "view any" grants observation regardless of ownership.
    $observer = $this->createUser(['view any flowdrop_session']);
    $this->assertTrue(
      $this->controller->accessSession($observer, $uuid)->isAllowed(),
      'Non-owner with "view any" can view a foreign session.',
    );

    // No session permissions at all → denied.
    $nobody = $this->createUser([]);
    $this->assertFalse(
      $this->controller->accessSession($nobody, $uuid)->isAllowed(),
      'A user without session permissions is denied.',
    );
  }

  /**
   * View callback: an unknown session UUID is denied, not a 500.
   */
  public function testViewCallbackUnknownSession(): void {
    $viewer = $this->createUser(['view own flowdrop_session']);
    $this->assertFalse(
      $this->controller->accessSession($viewer, 'b0e7d0a0-0000-4000-8000-000000000000')->isAllowed(),
      'An unknown session UUID is denied for ownership-scoped viewers.',
    );
  }

  /**
   * Drive callback: view permissions alone never grant execution.
   *
   * This is the regression pin for the view-grants-execute gap: before
   * accessDriveSession(), the send/stop/reset routes used the view
   * callback, so "view any flowdrop_session" (an observer permission)
   * could drive — and "view own" could execute without the execute
   * capability.
   */
  public function testDriveCallbackRequiresExecuteCapability(): void {
    $uuid = (string) $this->session->uuid();

    // Owner with view but WITHOUT the execute capability: denied.
    $viewOnlyOwner = $this->createUser(['view own flowdrop_session']);
    $ownSession = $this->createSessionOwnedBy($viewOnlyOwner);
    $this->assertFalse(
      $this->controller->accessDriveSession($viewOnlyOwner, (string) $ownSession->uuid())->isAllowed(),
      'View-only owner cannot drive their own session without "execute session workflow".',
    );

    // Observer ("view any") WITHOUT execute: denied — observers must not
    // send messages into, stop, or reset other users' sessions.
    $observer = $this->createUser(['view any flowdrop_session']);
    $this->assertFalse(
      $this->controller->accessDriveSession($observer, $uuid)->isAllowed(),
      'Observer with "view any" cannot drive a foreign session.',
    );

    // Owner with view own + execute: allowed.
    $this->assertTrue(
      $this->controller->accessDriveSession($this->owner, $uuid)->isAllowed(),
      'Owner with view own + execute can drive their session.',
    );

    // Execute + view any on a foreign session: allowed — parity with the
    // canonical turn route (execute capability on top of view access).
    $supervisor = $this->createUser([
      'view any flowdrop_session',
      'execute session workflow',
    ]);
    $this->assertTrue(
      $this->controller->accessDriveSession($supervisor, $uuid)->isAllowed(),
      'Execute + "view any" can drive a foreign session (canonical parity).',
    );

    // Execute + view own on a FOREIGN session: denied — execute does not
    // widen visibility.
    $executor = $this->createUser([
      'view own flowdrop_session',
      'execute session workflow',
    ]);
    $this->assertFalse(
      $this->controller->accessDriveSession($executor, $uuid)->isAllowed(),
      'Execute + "view own" cannot drive a foreign session.',
    );

    // Execute WITHOUT any view permission: denied.
    $blindExecutor = $this->createUser(['execute session workflow']);
    $this->assertFalse(
      $this->controller->accessDriveSession($blindExecutor, $uuid)->isAllowed(),
      'Execute without view access cannot drive a session.',
    );
  }

  /**
   * Drive callback: admin bypasses match the view callback.
   */
  public function testDriveCallbackAdminBypass(): void {
    $uuid = (string) $this->session->uuid();

    $globalAdmin = $this->createUser(['administer flowdrop']);
    $this->assertTrue(
      $this->controller->accessDriveSession($globalAdmin, $uuid)->isAllowed(),
      '"administer flowdrop" bypasses the drive check.',
    );

    $playgroundAdmin = $this->createUser(['administer flowdrop_playground']);
    $this->assertTrue(
      $this->controller->accessDriveSession($playgroundAdmin, $uuid)->isAllowed(),
      '"administer flowdrop_playground" bypasses the drive check.',
    );
  }

  /**
   * Delete callback: own/any split; view permissions never delete.
   */
  public function testDeleteCallbackOwnershipMatrix(): void {
    $uuid = (string) $this->session->uuid();

    // Owner with "delete own" may delete their session.
    $deletingOwner = $this->createUser(['delete own flowdrop_session']);
    $ownSession = $this->createSessionOwnedBy($deletingOwner);
    $this->assertTrue(
      $this->controller->accessDeleteSession($deletingOwner, (string) $ownSession->uuid())->isAllowed(),
      'Owner with "delete own" can delete their session.',
    );

    // Non-owner with "delete own": denied.
    $this->assertFalse(
      $this->controller->accessDeleteSession($deletingOwner, $uuid)->isAllowed(),
      'Non-owner with "delete own" cannot delete a foreign session.',
    );

    // "delete any": allowed on a foreign session.
    $janitor = $this->createUser(['delete any flowdrop_session']);
    $this->assertTrue(
      $this->controller->accessDeleteSession($janitor, $uuid)->isAllowed(),
      'Non-owner with "delete any" can delete a foreign session.',
    );

    // View permissions (even "view any") never grant delete.
    $observer = $this->createUser(['view any flowdrop_session']);
    $this->assertFalse(
      $this->controller->accessDeleteSession($observer, $uuid)->isAllowed(),
      'View permissions do not grant delete.',
    );
  }

  /**
   * Route wiring: drive routes use the drive callback, view routes don't.
   *
   * The access callbacks above are only effective if the routes point at
   * the right one — this pins the routing.yml wiring and the CSRF
   * requirement on every state-changing route.
   */
  public function testRouteAccessCallbackWiring(): void {
    $provider = $this->container->get('router.route_provider');
    $controllerClass = '\\' . PlaygroundApiController::class;

    $driveRoutes = [
      'flowdrop_playground.api.send_message',
      'flowdrop_playground.api.stop_execution',
      'flowdrop_playground.api.reset_session',
    ];
    foreach ($driveRoutes as $name) {
      $route = $provider->getRouteByName($name);
      $this->assertSame(
        $controllerClass . '::accessDriveSession',
        $route->getRequirement('_custom_access'),
        sprintf('%s is gated by the drive callback.', $name),
      );
      $this->assertSame(
        'TRUE',
        $route->getRequirement('_csrf_request_header_token'),
        sprintf('%s requires a CSRF token.', $name),
      );
    }

    $viewRoutes = [
      'flowdrop_playground.api.get_session',
      'flowdrop_playground.api.get_messages',
      'flowdrop_playground.api.get_message',
      'flowdrop_playground.api.get_message_status',
    ];
    foreach ($viewRoutes as $name) {
      $route = $provider->getRouteByName($name);
      $this->assertSame(
        $controllerClass . '::accessSession',
        $route->getRequirement('_custom_access'),
        sprintf('%s is gated by the view callback.', $name),
      );
    }

    $deleteRoute = $provider->getRouteByName('flowdrop_playground.api.delete_session');
    $this->assertSame(
      $controllerClass . '::accessDeleteSession',
      $deleteRoute->getRequirement('_custom_access'),
      'Delete route is gated by the delete callback.',
    );
    $this->assertSame(
      'TRUE',
      $deleteRoute->getRequirement('_csrf_request_header_token'),
      'Delete route requires a CSRF token.',
    );
  }

}
