<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop\Kernel;

use Drupal\KernelTests\KernelTestBase;
use Drupal\flowdrop_orchestration\Service\Orchestrator\OrchestratorHelperInterface;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface;
use Drupal\flowdrop_workflow\WorkflowDefinitionInterface;
use Drupal\flowdrop_workflow_executor\DTO\LaunchOptions;
use Drupal\flowdrop_workflow_executor\DTO\LaunchResult;

/**
 * Base class for kernel tests that execute workflows end-to-end.
 *
 * Provides the full module closure for the workflow executor and the
 * StateGraph orchestrator, the entity schema / config setup, and a small
 * helper set for the load-fixture → launch → assert-job-trail loop:
 *
 * @code
 * class MyProcessorTest extends WorkflowExecutionTestBase {
 *
 *   protected static $modules = ['my_module'];
 *
 *   public function testMyNodeRuns(): void {
 *     $workflow = $this->createStateGraphWorkflow('my_fixture', [
 *       $this->node('start', 'my_node_type', ['option' => 'value']),
 *       $this->loggerNode('sink', 'done'),
 *     ], [
 *       $this->edge('start', 'result', 'sink', 'message'),
 *     ]);
 *
 *     $result = $this->launchAndWait($workflow);
 *
 *     $pipeline = $this->loadPipeline($result->pipelineId);
 *     $this->assertSame('completed', $result->status, $this->jobTrail($pipeline));
 *     $this->assertSame('completed', $this->jobStatusForNode($pipeline, 'start'));
 *   }
 *
 * }
 * @endcode
 *
 * Subclass $modules are merged with this base's list — declare only the
 * additional modules a test needs. Use unit tests for processor logic;
 * this base exists to pin orchestration semantics (job scheduling, branch
 * gating, state propagation) that unit tests cannot observe.
 *
 * The helper set is intentionally minimal — it covers what the in-tree
 * orchestration tests assert. Test-specific fixtures and assertions belong
 * in the test class, not here.
 *
 * @api
 */
abstract class WorkflowExecutionTestBase extends KernelTestBase {

  use FlowDropKernelTestTrait;

  /**
   * {@inheritdoc}
   *
   * Full closure for flowdrop_workflow_executor (hard-depends on
   * flowdrop_interrupt + flowdrop_session) and flowdrop_stategraph, in
   * dependency order for container compilation.
   */
  protected static $modules = [
    'system',
    'user',
    'field',
    'options',
    'text',
    'flowdrop_ui_components',
    'flowdrop',
    'flowdrop_node_category',
    'flowdrop_node_type',
    'flowdrop_node_processor',
    'flowdrop_workflow',
    'flowdrop_orchestration',
    'flowdrop_runtime',
    'flowdrop_pipeline',
    'flowdrop_job',
    'flowdrop_session',
    'flowdrop_interrupt',
    'flowdrop_workflow_executor',
    'flowdrop_stategraph',
  ];

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

    $this->installEntitySchema('user');
    $this->installEntitySchema('flowdrop_workflow');
    $this->installEntitySchema('flowdrop_workflow_snapshot');
    $this->installEntitySchema('flowdrop_pipeline');
    $this->installEntitySchema('flowdrop_job');
    $this->installEntitySchema('flowdrop_session');
    $this->installEntitySchema('flowdrop_session_message');
    $this->installEntitySchema('flowdrop_node_type');
    $this->installEntitySchema('flowdrop_interrupt');
    $this->installEntitySchema('state_checkpoint');
    $this->installConfig([
      'flowdrop_node_processor',
      'flowdrop_interrupt',
      'flowdrop_stategraph',
    ]);
  }

  /**
   * Launches a workflow synchronously and returns the result.
   *
   * @param \Drupal\flowdrop_workflow\WorkflowDefinitionInterface $workflow
   *   The workflow.
   * @param array<string, mixed> $inputs
   *   Initial input data passed to the launch.
   *
   * @return \Drupal\flowdrop_workflow_executor\DTO\LaunchResult
   *   The launch result.
   */
  protected function launchAndWait(WorkflowDefinitionInterface $workflow, array $inputs = []): LaunchResult {
    return $this->container->get('flowdrop_workflow_executor.launcher')
      ->launch($workflow, $inputs, new LaunchOptions(wait: TRUE));
  }

  /**
   * Creates and saves a workflow fixture.
   *
   * @param string $id
   *   The workflow entity id.
   * @param array<int, array<string, mixed>> $nodes
   *   The workflow nodes (see node()).
   * @param array<int, array<string, mixed>> $edges
   *   The workflow edges (see edge()).
   * @param array<string, mixed> $orchestratorSettings
   *   Orchestrator settings stored in the workflow metadata. Empty means
   *   the workflow runs under the default orchestrator.
   *
   * @return \Drupal\flowdrop_workflow\WorkflowDefinitionInterface
   *   The saved workflow.
   */
  protected function createWorkflow(string $id, array $nodes, array $edges, array $orchestratorSettings = []): WorkflowDefinitionInterface {
    /** @var \Drupal\flowdrop_workflow\WorkflowDefinitionInterface $workflow */
    $workflow = $this->container->get('entity_type.manager')
      ->getStorage('flowdrop_workflow')
      ->create([
        'id' => $id,
        'label' => 'Test workflow: ' . $id,
        'nodes' => $nodes,
        'edges' => $edges,
      ]);
    if ($orchestratorSettings !== []) {
      $workflow->setMetadata(['orchestrator_settings' => $orchestratorSettings]);
    }
    $workflow->save();
    return $workflow;
  }

  /**
   * Creates and saves a workflow declared for the StateGraph orchestrator.
   *
   * @param string $id
   *   The workflow entity id.
   * @param array<int, array<string, mixed>> $nodes
   *   The workflow nodes (see node()).
   * @param array<int, array<string, mixed>> $edges
   *   The workflow edges (see edge()).
   * @param array<string, mixed> $orchestratorSettings
   *   Settings merged (shallow, top-level) over the defaults: memory
   *   checkpointer, checkpoints enabled, 20 max iterations. Pass a full
   *   `stategraph` key to change checkpointer or iteration budget.
   *
   * @return \Drupal\flowdrop_workflow\WorkflowDefinitionInterface
   *   The saved workflow.
   */
  protected function createStateGraphWorkflow(string $id, array $nodes, array $edges, array $orchestratorSettings = []): WorkflowDefinitionInterface {
    return $this->createWorkflow($id, $nodes, $edges, $orchestratorSettings + [
      'type' => OrchestratorHelperInterface::STATEGRAPH,
      'stategraph' => [
        'checkpointer_type' => 'memory',
        'create_checkpoints' => TRUE,
        'max_iterations' => 20,
      ],
    ]);
  }

  /**
   * Builds a node definition for a workflow fixture.
   *
   * @param string $id
   *   The node instance id.
   * @param string $nodeTypeId
   *   The flowdrop_node_type entity id.
   * @param array<string, mixed> $config
   *   The node configuration values.
   * @param array<string, mixed> $metadata
   *   Metadata overrides merged over the defaults.
   *
   * @return array<string, mixed>
   *   The node definition.
   */
  protected function node(string $id, string $nodeTypeId, array $config = [], array $metadata = []): array {
    return [
      'id' => $id,
      'type' => 'universalNode',
      'position' => ['x' => 0, 'y' => 0],
      'data' => [
        'label' => $nodeTypeId . ': ' . $id,
        'config' => $config,
        'metadata' => $metadata + [
          'node_type_id' => $nodeTypeId,
          'name' => $nodeTypeId,
          'type' => 'default',
          'category' => 'process',
          'inputs' => [],
          'outputs' => [],
        ],
      ],
    ];
  }

  /**
   * Builds an edge definition between two node ports.
   *
   * Encodes the reserved handle naming convention
   * (`{node}-output-{port}` / `{node}-input-{port}`) — wire a tool with
   * `edge('tool_node', 'result', 'consumer', 'tool')`, a trigger with
   * target port `trigger`.
   *
   * @param string $source
   *   The source node id.
   * @param string $sourcePort
   *   The source output port name.
   * @param string $target
   *   The target node id.
   * @param string $targetPort
   *   The target input port name.
   *
   * @return array<string, string>
   *   The edge definition.
   */
  protected function edge(string $source, string $sourcePort, string $target, string $targetPort): array {
    return [
      'id' => "{$source}-{$sourcePort}--{$target}-{$targetPort}",
      'source' => $source,
      'target' => $target,
      'sourceHandle' => "{$source}-output-{$sourcePort}",
      'targetHandle' => "{$target}-input-{$targetPort}",
    ];
  }

  /**
   * Builds a logger node definition.
   *
   * @param string $id
   *   The node id.
   * @param string $message
   *   The message to log.
   *
   * @return array<string, mixed>
   *   The node definition.
   */
  protected function loggerNode(string $id, string $message): array {
    return $this->node($id, 'logger', ['message' => $message, 'level' => 'info'], [
      'name' => 'Logger',
    ]);
  }

  /**
   * Builds a boolean_gateway node definition (value arrives via an edge).
   *
   * @param string $id
   *   The node id.
   *
   * @return array<string, mixed>
   *   The node definition.
   */
  protected function gatewayNode(string $id): array {
    return $this->node($id, 'boolean_gateway', [], [
      'name' => 'Boolean Gateway',
      'type' => 'gateway',
      'category' => 'logic',
    ]);
  }

  /**
   * Builds a repeat node definition (produces an N-item array output).
   *
   * @param string $id
   *   The node id.
   * @param string $value
   *   The value to repeat.
   * @param int $count
   *   The number of repetitions.
   *
   * @return array<string, mixed>
   *   The node definition.
   */
  protected function repeatNode(string $id, string $value, int $count): array {
    return $this->node($id, 'repeat', ['value' => $value, 'count' => $count], [
      'name' => 'Repeat',
      'category' => 'tools',
    ]);
  }

  /**
   * Builds a foreach node definition (items arrive via an edge).
   *
   * @param string $id
   *   The node id.
   *
   * @return array<string, mixed>
   *   The node definition.
   */
  protected function forEachNode(string $id): array {
    return $this->node($id, 'foreach', [], [
      'name' => 'ForEach Loop',
      'category' => 'logic',
    ]);
  }

  /**
   * Builds a confirmation (HITL) node definition.
   *
   * @param string $id
   *   The node id.
   * @param string $message
   *   The confirmation prompt.
   *
   * @return array<string, mixed>
   *   The node definition.
   */
  protected function confirmationNode(string $id, string $message = 'Continue?'): array {
    return $this->node($id, 'confirmation', ['message' => $message], [
      'name' => 'Confirmation',
      'category' => 'human_in_loop',
    ]);
  }

  /**
   * Loads a pipeline fresh from storage.
   *
   * @param string $pipelineId
   *   The pipeline id.
   *
   * @return \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface
   *   The pipeline.
   */
  protected function loadPipeline(string $pipelineId): FlowDropPipelineRuntimeInterface {
    $storage = $this->container->get('entity_type.manager')->getStorage('flowdrop_pipeline');
    $storage->resetCache([$pipelineId]);
    $pipeline = $storage->load($pipelineId);
    $this->assertInstanceOf(FlowDropPipelineRuntimeInterface::class, $pipeline);
    return $pipeline;
  }

  /**
   * Returns the status of the single job for a node.
   *
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface $pipeline
   *   The pipeline.
   * @param string $nodeId
   *   The node id.
   *
   * @return string
   *   The job status.
   */
  protected function jobStatusForNode(FlowDropPipelineRuntimeInterface $pipeline, string $nodeId): string {
    foreach ($pipeline->getJobs() as $job) {
      if ($job->getNodeId() === $nodeId) {
        return $job->getStatus();
      }
    }
    $this->fail("No job found for node {$nodeId}.");
  }

  /**
   * Returns the output data of the single job for a node.
   *
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface $pipeline
   *   The pipeline.
   * @param string $nodeId
   *   The node id.
   *
   * @return array<string, mixed>
   *   The job output data.
   */
  protected function jobOutputForNode(FlowDropPipelineRuntimeInterface $pipeline, string $nodeId): array {
    foreach ($pipeline->getJobs() as $job) {
      if ($job->getNodeId() === $nodeId) {
        return $job->getOutputData();
      }
    }
    $this->fail("No job found for node {$nodeId}.");
  }

  /**
   * Builds a per-job debug trail for assertion messages.
   *
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface $pipeline
   *   The pipeline.
   *
   * @return string
   *   One `node=status err:... out:...` entry per job.
   */
  protected function jobTrail(FlowDropPipelineRuntimeInterface $pipeline): string {
    $trail = [];
    foreach ($pipeline->getJobs() as $job) {
      $trail[] = $job->getNodeId() . '=' . $job->getStatus()
        . ' err:' . $job->getErrorMessage()
        . ' out:' . json_encode($job->getOutputData());
    }
    return implode(' | ', $trail);
  }

}
