<?php

declare(strict_types=1);

namespace Drupal\flowdrop_playground\Controller;

use Drupal\flowdrop_workflow\WorkflowDefinitionInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\flowdrop\Constants\Permissions as FlowDropPermissions;
use Drupal\flowdrop\Controller\Api\ApiResponseTrait;
use Drupal\flowdrop_playground\Constants\Permissions;
use Drupal\flowdrop_session\Constants\Permissions as SessionPermissions;
use Drupal\flowdrop_session\DTO\TurnOptions;
use Drupal\flowdrop_session\Entity\FlowDropSessionInterface;
use Drupal\flowdrop_session\Entity\FlowDropSessionMessageInterface;
use Drupal\flowdrop_session\Exception\ConcurrentTurnException;
use Drupal\flowdrop_session\Service\SessionService;
use Drupal\flowdrop_session\Service\SessionTurnServiceInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * API Controller for FlowDrop Playground operations.
 *
 * Provides RESTful endpoints for managing playground sessions and messages.
 *
 * @internal This class is not part of the public API and may change without notice.
 */
final class PlaygroundApiController extends ControllerBase {

  use ApiResponseTrait;

  /**
   * The session service.
   *
   * @var \Drupal\flowdrop_session\Service\SessionService
   */
  protected SessionService $sessionService;

  /**
   * Constructs a PlaygroundApiController object.
   *
   * @param \Drupal\flowdrop_session\Service\SessionService $session_service
   *   The session service.
   * @param \Drupal\flowdrop_session\Service\SessionTurnServiceInterface $turnService
   *   The chat-turn service.
   */
  public function __construct(
    SessionService $session_service,
    protected readonly SessionTurnServiceInterface $turnService,
  ) {
    $this->sessionService = $session_service;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container): static {
    return new static(
      $container->get('flowdrop_session.service'),
      $container->get(SessionTurnServiceInterface::class),
    );
  }

  /**
   * Lists playground sessions for a workflow.
   *
   * @param string $workflow_id
   *   The workflow ID.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request object.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   JSON response with sessions data.
   */
  public function listWorkflowSessions(string $workflow_id, Request $request): JsonResponse {
    try {
      // Verify workflow exists.
      $workflow_storage = $this->entityTypeManager()->getStorage('flowdrop_workflow');
      $workflow = $workflow_storage->load($workflow_id);

      if ($workflow === NULL) {
        return $this->errorResponse('Workflow not found', Response::HTTP_NOT_FOUND);
      }

      // Get query parameters with standardized defaults.
      $limit = min(100, max(1, (int) $request->query->get('limit', 50)));
      $offset = max(0, (int) $request->query->get('offset', 0));

      // Optional `?ids=` filter restricts the result to a specific set of
      // session IDs (comma-separated). The single-session playground page uses
      // it to pin the in-app session list to exactly the session whose URL the
      // user is on, so sibling sessions are never sent to the browser.
      $idsParam = trim((string) $request->query->get('ids', ''));
      $idsFilter = NULL;
      if ($idsParam !== '') {
        $idsFilter = array_values(array_filter(
          array_map('intval', explode(',', $idsParam)),
          static fn (int $id): bool => $id > 0,
        ));
        if ($idsFilter === []) {
          // The caller explicitly asked for no sessions — short-circuit.
          return $this->paginatedResponse([], 0, $limit, $offset);
        }
      }

      // Determine if user can view all sessions or only their own.
      $current_user = $this->currentUser();
      $user_id = NULL;

      if (!$current_user->hasPermission(SessionPermissions::VIEW_ANY)) {
        $user_id = (int) $current_user->id();
      }

      $session_storage = $this->entityTypeManager()->getStorage('flowdrop_session');

      if ($idsFilter !== NULL) {
        // Filtered path: entity query with id IN (...), workflow-scoped, with
        // the same access-check semantics as the default list.
        $query = $session_storage->getQuery()
          ->condition('workflow_id', $workflow_id)
          ->condition('id', $idsFilter, 'IN')
          ->accessCheck(TRUE)
          ->sort('created', 'DESC')
          ->range($offset, $limit);
        if ($user_id !== NULL) {
          $query->condition('uid', $user_id);
        }
        $session_ids = $query->execute();
        $sessions = $session_ids !== [] ? $session_storage->loadMultiple($session_ids) : [];
      }
      else {
        $sessions = $this->sessionService->getSessionsForWorkflow(
          $workflow_id,
          $user_id,
          $limit,
          $offset
        );
      }

      // Format sessions for response.
      $data = [];
      foreach ($sessions as $session) {
        if (!$session instanceof FlowDropSessionInterface) {
          continue;
        }
        $data[] = $this->sessionService->formatSessionForApi($session);
      }

      // Get total count for pagination.
      $count_query = $session_storage->getQuery()
        ->condition('workflow_id', $workflow_id)
        ->accessCheck(TRUE)
        ->count();

      if ($idsFilter !== NULL) {
        $count_query->condition('id', $idsFilter, 'IN');
      }
      if ($user_id !== NULL) {
        $count_query->condition('uid', $user_id);
      }

      $total = (int) $count_query->execute();

      return $this->paginatedResponse($data, $total, $limit, $offset);

    }
    catch (\Exception $e) {
      $this->getLogger('flowdrop_playground')->error('Error listing sessions: @message', [
        '@message' => $e->getMessage(),
      ]);

      return $this->errorResponse('Internal server error', Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Creates a new playground session for a workflow.
   *
   * @param string $workflow_id
   *   The workflow ID.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request object.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   JSON response with the created session.
   */
  public function createSession(string $workflow_id, Request $request): JsonResponse {
    try {
      // Verify workflow exists.
      $workflow_storage = $this->entityTypeManager()->getStorage('flowdrop_workflow');
      $workflow = $workflow_storage->load($workflow_id);

      if (!$workflow instanceof WorkflowDefinitionInterface) {
        return $this->errorResponse('Workflow not found', Response::HTTP_NOT_FOUND);
      }

      // Parse request body (optional for session creation).
      $content = $request->getContent();
      $data = [];

      if (!empty($content)) {
        $decoded = json_decode($content, TRUE);
        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
          $data = $decoded;
        }
      }

      $name = isset($data['name']) && is_string($data['name']) ? $data['name'] : NULL;
      $metadata = isset($data['metadata']) && is_array($data['metadata']) ? $data['metadata'] : [];

      // Create session.
      $session = $this->sessionService->createSession($workflow, $name, $metadata);

      return $this->successResponse(
        $this->sessionService->formatSessionForApi($session),
        Response::HTTP_CREATED
      );

    }
    catch (\Exception $e) {
      $this->getLogger('flowdrop_playground')->error('Error creating session: @message', [
        '@message' => $e->getMessage(),
      ]);

      return $this->errorResponse('Failed to create session', Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Gets a playground session by ID.
   *
   * @param string $session_id
   *   The session UUID.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   JSON response with session data.
   */
  public function getSession(string $session_id): JsonResponse {
    try {
      $session = $this->loadSessionByUuid($session_id);

      if ($session === NULL) {
        return $this->errorResponse('Session not found', Response::HTTP_NOT_FOUND);
      }

      return $this->successResponse(
        $this->sessionService->formatSessionForApi($session)
      );

    }
    catch (\Exception $e) {
      $this->getLogger('flowdrop_playground')->error('Error getting session: @message', [
        '@message' => $e->getMessage(),
      ]);

      return $this->errorResponse('Internal server error', Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Deletes a playground session.
   *
   * @param string $session_id
   *   The session UUID.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   JSON response confirming deletion.
   */
  public function deleteSession(string $session_id): JsonResponse {
    try {
      $session = $this->loadSessionByUuid($session_id);

      if ($session === NULL) {
        return $this->errorResponse('Session not found', Response::HTTP_NOT_FOUND);
      }

      $this->sessionService->deleteSession($session);

      return $this->successResponse(NULL);

    }
    catch (\Exception $e) {
      $this->getLogger('flowdrop_playground')->error('Error deleting session: @message', [
        '@message' => $e->getMessage(),
      ]);

      return $this->errorResponse('Failed to delete session', Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Gets messages from a playground session.
   *
   * @param string $session_id
   *   The session UUID.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request object.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   JSON response with messages data.
   */
  public function getMessages(string $session_id, Request $request): JsonResponse {
    try {
      $session = $this->loadSessionByUuid($session_id);

      if ($session === NULL) {
        return $this->errorResponse('Session not found', Response::HTTP_NOT_FOUND);
      }

      // Get query parameters with standardized defaults.
      $limit = min(100, max(1, (int) $request->query->get('limit', 50)));
      $since = $request->query->get('since');
      $before = $request->query->get('before');
      $latest = $request->query->getBoolean('latest', FALSE);

      // Forward cursor (live tailing): messages newer than `since`.
      $after_sequence = NULL;
      if ($since !== NULL && ctype_digit((string) $since)) {
        $after_sequence = (int) $since;
      }
      // Backward cursor (scroll-up history): messages older than `before`.
      $before_sequence = NULL;
      if ($before !== NULL && ctype_digit((string) $before)) {
        $before_sequence = (int) $before;
      }

      // Get session ID as int.
      $entity_id = $session->id();
      if ($entity_id === NULL) {
        return $this->errorResponse('Invalid session', Response::HTTP_INTERNAL_SERVER_ERROR);
      }

      // Get messages.
      $messages = $this->sessionService->getMessages(
        (int) $entity_id,
        $after_sequence,
        $limit,
        $before_sequence,
        $latest,
      );

      // Format messages for response. Batched so pipeline lineage (root /
      // parent) is resolved in one query per session rather than per message.
      $data = $this->sessionService->formatMessagesForApi($messages);

      // Determine hasMore: true when exactly $limit messages were returned,
      // meaning there may be additional messages beyond this page.
      $has_more = count($data) === $limit;

      // hasOlder: whether scrolling up will yield more history. The page is
      // always ascending, so the first element carries the lowest sequence
      // number seen here. Authoritative (a bounded lookup) rather than
      // inferred from page fullness, so the client avoids a speculative
      // empty fetch at the exact-multiple boundary.
      $has_older = FALSE;
      if ($data !== []) {
        $oldest = (int) ($data[0]['sequenceNumber'] ?? 0);
        $has_older = $oldest > 0
          && $this->sessionService->hasMessagesBefore((int) $entity_id, $oldest);
      }

      return new JsonResponse([
        'success' => TRUE,
        'data' => $data,
        'hasMore' => $has_more,
        'hasOlder' => $has_older,
        'sessionStatus' => $session->getStatus(),
      ]);

    }
    catch (\Exception $e) {
      $this->getLogger('flowdrop_playground')->error('Error getting messages: @message', [
        '@message' => $e->getMessage(),
      ]);

      return $this->errorResponse('Internal server error', Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Sends a message to a playground session.
   *
   * @param string $session_id
   *   The session UUID.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request object.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   JSON response with the sent message.
   */
  public function sendMessage(string $session_id, Request $request): JsonResponse {
    try {
      $session = $this->loadSessionByUuid($session_id);

      if ($session === NULL) {
        return $this->errorResponse('Session not found', Response::HTTP_NOT_FOUND);
      }

      // Parse request body.
      $data = $this->decodeJsonRequest($request);

      // Validate required fields.
      if (!isset($data['content']) || !is_string($data['content'])) {
        return $this->errorResponse('Message content is required', Response::HTTP_BAD_REQUEST);
      }

      $content = $data['content'];
      $inputs = isset($data['inputs']) && is_array($data['inputs']) ? $data['inputs'] : [];

      // The chat-turn verb owns the post + execute composition, including
      // the concurrent-turn guard that used to live here as isRunning().
      $result = $this->turnService->executeTurn(
        (string) $session->id(),
        $content,
        new TurnOptions(inputs: $inputs),
      );

      $message = $this->entityTypeManager()
        ->getStorage('flowdrop_session_message')
        ->load($result->userMessageId);
      if (!$message instanceof FlowDropSessionMessageInterface) {
        return $this->errorResponse('Failed to send message', Response::HTTP_INTERNAL_SERVER_ERROR);
      }

      // Format via the batch path so the response carries the same lineage
      // fields (rootPipelineId / parentPipelineId) as the getMessages list.
      return $this->successResponse(
        $this->sessionService->formatMessagesForApi([$message])[0]
      );

    }
    catch (ConcurrentTurnException) {
      return $this->errorResponse('Session is already executing', Response::HTTP_CONFLICT);
    }
    catch (\RuntimeException $e) {
      $this->getLogger('flowdrop_playground')->error('Conflict sending message: @message', [
        '@message' => $e->getMessage(),
      ]);
      return $this->errorResponse('Failed to send message due to a conflict', Response::HTTP_CONFLICT);
    }
    catch (\Exception $e) {
      $this->getLogger('flowdrop_playground')->error('Error sending message: @message', [
        '@message' => $e->getMessage(),
      ]);

      return $this->errorResponse('Failed to send message', Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Stops the execution of a playground session.
   *
   * @param string $session_id
   *   The session UUID.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   JSON response confirming stop.
   */
  public function stopExecution(string $session_id): JsonResponse {
    try {
      $session = $this->loadSessionByUuid($session_id);

      if ($session === NULL) {
        return $this->errorResponse('Session not found', Response::HTTP_NOT_FOUND);
      }

      if (!$session->isRunning()) {
        return $this->errorResponse('No execution is running', Response::HTTP_CONFLICT);
      }

      $this->sessionService->stopExecution($session);

      return $this->successResponse(NULL);

    }
    catch (\Exception $e) {
      $this->getLogger('flowdrop_playground')->error('Error stopping execution: @message', [
        '@message' => $e->getMessage(),
      ]);

      return $this->errorResponse('Failed to stop execution', Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Resets a playground session to idle state.
   *
   * This endpoint allows users to recover from stuck sessions by resetting
   * the session status to 'idle', cancelling any pending or processing
   * messages, and clearing any pending interrupt state.
   *
   * @param string $session_id
   *   The session UUID.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   JSON response confirming reset.
   */
  public function resetSession(string $session_id): JsonResponse {
    try {
      $session = $this->loadSessionByUuid($session_id);

      if ($session === NULL) {
        return $this->errorResponse('Session not found', Response::HTTP_NOT_FOUND);
      }

      $resetCount = $this->sessionService->resetSession($session);

      return $this->successResponse([
        'session' => $this->sessionService->formatSessionForApi($session),
        'resetCount' => $resetCount,
      ]);

    }
    catch (\Exception $e) {
      $this->getLogger('flowdrop_playground')->error('Error resetting session: @message', [
        '@message' => $e->getMessage(),
      ]);

      return $this->errorResponse('Failed to reset session', Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Access callback for session endpoints.
   *
   * @param \Drupal\Core\Session\AccountInterface $account
   *   The user account.
   * @param string $session_id
   *   The session UUID.
   *
   * @return \Drupal\Core\Access\AccessResultInterface
   *   The access result.
   */
  public function accessSession(AccountInterface $account, string $session_id): AccessResultInterface {
    // Global super-admin bypass.
    if ($account->hasPermission(FlowDropPermissions::ADMINISTER_FLOWDROP)) {
      return AccessResult::allowed()->cachePerPermissions();
    }

    // Module administrator access.
    if ($account->hasPermission(Permissions::ADMINISTER)) {
      return AccessResult::allowed()->cachePerPermissions();
    }

    // Check if user can view any session.
    if ($account->hasPermission(SessionPermissions::VIEW_ANY)) {
      return AccessResult::allowed()->cachePerPermissions();
    }

    // Load session to check ownership.
    $session = $this->loadSessionByUuid($session_id);

    if ($session === NULL) {
      return AccessResult::forbidden('Session not found.');
    }

    // Check if user can view their own session.
    if ($account->hasPermission(SessionPermissions::VIEW_OWN)) {
      // Cast both sides: entity keys come back from storage as strings
      // (the MySQL driver stringifies fetches), so a strict comparison
      // without the cast denies every owner.
      $is_owner = (int) $session->getOwnerId() === (int) $account->id();
      return AccessResult::allowedIf($is_owner)
        ->cachePerPermissions()
        ->cachePerUser()
        ->addCacheableDependency($session);
    }

    return AccessResult::forbidden()->cachePerPermissions();
  }

  /**
   * Access callback for session endpoints that drive execution.
   *
   * Sending a message, stopping a run, and resetting a session all mutate
   * execution state. Mirroring the canonical session turn route
   * (flowdrop_session.api.session.turn), driving a session requires the
   * "execute session workflow" capability on top of view access to the
   * session — the execute-vs-view split. View-only roles (observers) can
   * read a session but never run, stop, or reset it.
   *
   * @param \Drupal\Core\Session\AccountInterface $account
   *   The user account.
   * @param string $session_id
   *   The session UUID.
   *
   * @return \Drupal\Core\Access\AccessResultInterface
   *   The access result.
   */
  public function accessDriveSession(AccountInterface $account, string $session_id): AccessResultInterface {
    // Global super-admin bypass.
    if ($account->hasPermission(FlowDropPermissions::ADMINISTER_FLOWDROP)) {
      return AccessResult::allowed()->cachePerPermissions();
    }

    // Module administrator access.
    if ($account->hasPermission(Permissions::ADMINISTER)) {
      return AccessResult::allowed()->cachePerPermissions();
    }

    // The execute capability gates before any ownership logic; view
    // permissions alone never grant execution.
    if (!$account->hasPermission(SessionPermissions::EXECUTE_SESSION_WORKFLOW)) {
      return AccessResult::forbidden('Driving a session requires the "execute session workflow" permission.')
        ->cachePerPermissions();
    }

    // Execution additionally requires view access to the session.
    return $this->accessSession($account, $session_id);
  }

  /**
   * Access callback for delete session endpoint.
   *
   * @param \Drupal\Core\Session\AccountInterface $account
   *   The user account.
   * @param string $session_id
   *   The session UUID.
   *
   * @return \Drupal\Core\Access\AccessResultInterface
   *   The access result.
   */
  public function accessDeleteSession(AccountInterface $account, string $session_id): AccessResultInterface {
    // Global super-admin bypass.
    if ($account->hasPermission(FlowDropPermissions::ADMINISTER_FLOWDROP)) {
      return AccessResult::allowed()->cachePerPermissions();
    }

    // Module administrator access.
    if ($account->hasPermission(Permissions::ADMINISTER)) {
      return AccessResult::allowed()->cachePerPermissions();
    }

    // Check if user can delete any session.
    if ($account->hasPermission(SessionPermissions::DELETE_ANY)) {
      return AccessResult::allowed()->cachePerPermissions();
    }

    // Load session to check ownership.
    $session = $this->loadSessionByUuid($session_id);

    if ($session === NULL) {
      return AccessResult::forbidden('Session not found.');
    }

    // Check if user can delete their own session.
    if ($account->hasPermission(SessionPermissions::DELETE_OWN)) {
      // Cast both sides: entity keys come back from storage as strings
      // (the MySQL driver stringifies fetches), so a strict comparison
      // without the cast denies every owner.
      $is_owner = (int) $session->getOwnerId() === (int) $account->id();
      return AccessResult::allowedIf($is_owner)
        ->cachePerPermissions()
        ->cachePerUser()
        ->addCacheableDependency($session);
    }

    return AccessResult::forbidden()->cachePerPermissions();
  }

  /**
   * Gets a single message by ID.
   *
   * @param string $session_id
   *   The session UUID.
   * @param string $message_id
   *   The message UUID.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   JSON response with message data.
   */
  public function getMessage(string $session_id, string $message_id): JsonResponse {
    try {
      $session = $this->loadSessionByUuid($session_id);

      if ($session === NULL) {
        return $this->errorResponse('Session not found', Response::HTTP_NOT_FOUND);
      }

      $message = $this->loadMessageByUuid($message_id);

      if ($message === NULL) {
        return $this->errorResponse('Message not found', Response::HTTP_NOT_FOUND);
      }

      // Verify message belongs to session.
      if ($message->getSessionId() !== $session->id()) {
        return $this->errorResponse('Message does not belong to this session', Response::HTTP_NOT_FOUND);
      }

      // Format via the batch path so the response carries the same lineage
      // fields (rootPipelineId / parentPipelineId) as the getMessages list.
      return $this->successResponse(
        $this->sessionService->formatMessagesForApi([$message])[0]
      );

    }
    catch (\Exception $e) {
      $this->getLogger('flowdrop_playground')->error('Error getting message: @message', [
        '@message' => $e->getMessage(),
      ]);

      return $this->errorResponse('Internal server error', Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Gets the status of a message (lightweight polling endpoint).
   *
   * @param string $session_id
   *   The session UUID.
   * @param string $message_id
   *   The message UUID.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   JSON response with message status.
   */
  public function getMessageStatus(string $session_id, string $message_id): JsonResponse {
    try {
      $session = $this->loadSessionByUuid($session_id);

      if ($session === NULL) {
        return $this->errorResponse('Session not found', Response::HTTP_NOT_FOUND);
      }

      $message = $this->loadMessageByUuid($message_id);

      if ($message === NULL) {
        return $this->errorResponse('Message not found', Response::HTTP_NOT_FOUND);
      }

      // Verify message belongs to session.
      if ($message->getSessionId() !== $session->id()) {
        return $this->errorResponse('Message does not belong to this session', Response::HTTP_NOT_FOUND);
      }

      return $this->successResponse([
        'id' => $message->uuid(),
        'status' => $message->getStatus(),
        'sequenceNumber' => $message->getSequenceNumber(),
        'timestamp' => date('c', $message->getTimestamp()),
      ]);

    }
    catch (\Exception $e) {
      $this->getLogger('flowdrop_playground')->error('Error getting message status: @message', [
        '@message' => $e->getMessage(),
      ]);

      return $this->errorResponse('Internal server error', Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Loads a session by UUID.
   *
   * @param string $uuid
   *   The session UUID.
   *
   * @return \Drupal\flowdrop_session\Entity\FlowDropSessionInterface|null
   *   The session entity or NULL if not found.
   */
  protected function loadSessionByUuid(string $uuid): ?FlowDropSessionInterface {
    $storage = $this->entityTypeManager()->getStorage('flowdrop_session');

    $entities = $storage->loadByProperties(['uuid' => $uuid]);

    if (empty($entities)) {
      return NULL;
    }

    $entity = reset($entities);
    return $entity instanceof FlowDropSessionInterface ? $entity : NULL;
  }

  /**
   * Loads a message by UUID.
   *
   * @param string $uuid
   *   The message UUID.
   *
   * @return \Drupal\flowdrop_session\Entity\FlowDropSessionMessageInterface|null
   *   The message entity or NULL if not found.
   */
  protected function loadMessageByUuid(string $uuid): ?FlowDropSessionMessageInterface {
    $storage = $this->entityTypeManager()->getStorage('flowdrop_session_message');

    $entities = $storage->loadByProperties(['uuid' => $uuid]);

    if (empty($entities)) {
      return NULL;
    }

    $entity = reset($entities);
    return $entity instanceof FlowDropSessionMessageInterface ? $entity : NULL;
  }

}
