<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_session\Unit\Service;

use Drupal\Component\Serialization\Json;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\flowdrop_session\Constants\MessageAnnotation;
use Drupal\flowdrop_session\Constants\MessageRole;
use Drupal\flowdrop_session\Entity\FlowDropSessionInterface;
use Drupal\flowdrop_session\Entity\FlowDropSessionMessageInterface;
use Drupal\flowdrop_pipeline\Service\PipelineLineageResolver;
use Drupal\flowdrop_session\Service\SessionService;
use Drupal\Tests\UnitTestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

/**
 * Covers wire-level annotation pass-through in SessionService.
 *
 * Exercises the contract introduced for @flowdrop/flowdrop 1.13.0:
 * `hierarchy`, `tags`, and `display` travel through message metadata
 * and are lifted to top-level fields by formatMessageForApi. Malformed
 * shapes must be dropped silently so a bad emitter cannot break the
 * playground response.
 *
 * @coversDefaultClass \Drupal\flowdrop_session\Service\SessionService
 * @group flowdrop_session
 */
class SessionServiceFormatMessageTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The service under test.
   */
  private SessionService $service;

  /**
   * Stub message storage so parentMessageId resolution is a no-op.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<\Drupal\Core\Entity\EntityStorageInterface>
   */
  private $messageStorage;

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

    $entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);
    $sessionStorage = $this->prophesize(EntityStorageInterface::class);
    $this->messageStorage = $this->prophesize(EntityStorageInterface::class);
    $entityTypeManager->getStorage('flowdrop_session')
      ->willReturn($sessionStorage->reveal());
    $entityTypeManager->getStorage('flowdrop_session_message')
      ->willReturn($this->messageStorage->reveal());

    $loggerChannel = $this->prophesize(LoggerChannelInterface::class);
    $loggerFactory = $this->prophesize(LoggerChannelFactoryInterface::class);
    $loggerFactory->get('flowdrop_session')->willReturn($loggerChannel->reveal());

    $this->service = new SessionService(
      $entityTypeManager->reveal(),
      $this->prophesize(AccountInterface::class)->reveal(),
      $loggerFactory->reveal(),
      $this->prophesize(EventDispatcherInterface::class)->reveal(),
      $this->prophesize(Connection::class)->reveal(),
      // Real resolver (the class is final, so it can't be prophesized) over a
      // stub connection — these tests never reach it, as their messages carry
      // no execution id.
      new PipelineLineageResolver($this->prophesize(Connection::class)->reveal()),
    );
  }

  /**
   * Builds a stub message with the given metadata payload.
   *
   * @param array<string, mixed> $metadata
   *   Raw metadata to attach (JSON-encoded onto the entity, as the field is a
   *   string_long column).
   */
  private function buildMessage(array $metadata): FlowDropSessionMessageInterface {
    $session = $this->prophesize(FlowDropSessionInterface::class);
    $session->uuid()->willReturn('session-uuid-123');

    $message = $this->prophesize(FlowDropSessionMessageInterface::class);
    $message->uuid()->willReturn('msg-uuid-1');
    $message->getSession()->willReturn($session->reveal());
    $message->getRole()->willReturn(MessageRole::LOG);
    $message->getContent()->willReturn('hello');
    $message->getTimestamp()->willReturn(1700000000);
    $message->getStatus()->willReturn('completed');
    $message->getSequenceNumber()->willReturn(7);
    $message->getNodeId()->willReturn('node-a');
    $message->getMessageMetadata()->willReturn($metadata);
    $message->getParentMessageId()->willReturn(NULL);
    $message->getExecutionId()->willReturn(NULL);

    return $message->reveal();
  }

  /**
   * A message without any annotations serializes as before — no new keys.
   */
  public function testLegacyMessageShapeUnchanged(): void {
    $message = $this->buildMessage(['source' => 'Pipeline', 'level' => 'info']);

    $data = $this->service->formatMessageForApi($message);

    $this->assertArrayNotHasKey('hierarchy', $data);
    $this->assertArrayNotHasKey('tags', $data);
    $this->assertArrayNotHasKey('display', $data);
    $this->assertSame(['source' => 'Pipeline', 'level' => 'info'], $data['metadata']);
    $this->assertSame('msg-uuid-1', $data['id']);
    $this->assertSame(7, $data['sequenceNumber']);
  }

  /**
   * Hierarchy items are lifted out of metadata, icon is optional.
   */
  public function testHierarchyIsLifted(): void {
    $hierarchy = [
      ['id' => 'wf:parent', 'label' => 'Calculator User'],
      ['id' => 'wf:child', 'label' => 'Calculator', 'icon' => 'mdi:calculator'],
    ];
    $message = $this->buildMessage([
      MessageAnnotation::METADATA_KEY_HIERARCHY => $hierarchy,
      'source' => 'Pipeline',
    ]);

    $data = $this->service->formatMessageForApi($message);

    $this->assertSame($hierarchy, $data['hierarchy']);
    $this->assertArrayNotHasKey('hierarchy', $data['metadata']);
    $this->assertSame(['source' => 'Pipeline'], $data['metadata']);
  }

  /**
   * Hierarchy items missing id or label are dropped; remaining items survive.
   */
  public function testMalformedHierarchyItemsAreDroppedSilently(): void {
    $message = $this->buildMessage([
      MessageAnnotation::METADATA_KEY_HIERARCHY => [
        ['id' => 'wf:ok', 'label' => 'Valid'],
        // Missing label.
        ['id' => 'wf:bad'],
        // Wrong types.
        ['id' => 123, 'label' => 'Not a string id'],
        // Not an associative entry.
        'just a string',
        // Empty label.
        ['id' => 'wf:empty', 'label' => ''],
        // Icon is non-string — dropped, but item survives.
        ['id' => 'wf:noicon', 'label' => 'Still good', 'icon' => 42],
      ],
    ]);

    $data = $this->service->formatMessageForApi($message);

    $this->assertSame([
      ['id' => 'wf:ok', 'label' => 'Valid'],
      ['id' => 'wf:noicon', 'label' => 'Still good'],
    ], $data['hierarchy']);
  }

  /**
   * An entirely invalid hierarchy yields no top-level key at all.
   */
  public function testEmptyHierarchyIsOmitted(): void {
    $message = $this->buildMessage([
      MessageAnnotation::METADATA_KEY_HIERARCHY => 'not even an array',
    ]);

    $data = $this->service->formatMessageForApi($message);

    $this->assertArrayNotHasKey('hierarchy', $data);
  }

  /**
   * Tags survive when shape is valid; out-of-enum color/variant are dropped.
   *
   * The tag itself is kept even when color or variant fail enum validation.
   */
  public function testTagsAreLiftedWithEnumValidation(): void {
    $message = $this->buildMessage([
      MessageAnnotation::METADATA_KEY_TAGS => [
        [
          'id' => 't1',
          'label' => 'sub-workflow',
          'color' => 'info',
          'variant' => 'subtle',
          'type' => 'kind',
        ],
        [
          'id' => 't2',
          'label' => 'bogus enum',
          'color' => 'turquoise',
          'variant' => 'glow',
          'type' => 'status',
        ],
      ],
    ]);

    $data = $this->service->formatMessageForApi($message);

    $this->assertSame([
      [
        'id' => 't1',
        'label' => 'sub-workflow',
        'color' => 'info',
        'variant' => 'subtle',
        'type' => 'kind',
      ],
      [
        'id' => 't2',
        'label' => 'bogus enum',
        'type' => 'status',
      ],
    ], $data['tags']);
  }

  /**
   * Display hint is lifted when it is one of the allowed enum values.
   */
  public function testDisplayHintLifted(): void {
    foreach (MessageAnnotation::DISPLAYS as $display) {
      $message = $this->buildMessage([
        MessageAnnotation::METADATA_KEY_DISPLAY => $display,
      ]);
      $data = $this->service->formatMessageForApi($message);
      $this->assertSame($display, $data['display']);
      $this->assertArrayNotHasKey('display', $data['metadata']);
    }
  }

  /**
   * Display hints outside the closed enum are dropped silently.
   */
  public function testDisplayHintOutsideEnumIsDropped(): void {
    $message = $this->buildMessage([
      MessageAnnotation::METADATA_KEY_DISPLAY => 'banner',
    ]);

    $data = $this->service->formatMessageForApi($message);

    $this->assertArrayNotHasKey('display', $data);
    $this->assertArrayNotHasKey('display', $data['metadata']);
  }

  /**
   * Sanity-checks that JSON-encoding produces the expected wire field names.
   *
   * The frontend's MessageBubble dispatcher branches on these exact keys.
   */
  public function testWireKeysMatchClientContract(): void {
    $message = $this->buildMessage([
      MessageAnnotation::METADATA_KEY_HIERARCHY => [
        ['id' => 'wf:p', 'label' => 'Parent'],
      ],
      MessageAnnotation::METADATA_KEY_TAGS => [
        ['id' => 'x', 'label' => 'x', 'color' => 'muted'],
      ],
      MessageAnnotation::METADATA_KEY_DISPLAY => MessageAnnotation::DISPLAY_CARD,
    ]);

    $data = $this->service->formatMessageForApi($message);
    $encoded = Json::encode($data);

    $this->assertStringContainsString('"hierarchy":', $encoded);
    $this->assertStringContainsString('"tags":', $encoded);
    $this->assertStringContainsString('"display":"card"', $encoded);
  }

}
