<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_stategraph\Unit\Service;

use Drupal\Tests\UnitTestCase;
use Drupal\flowdrop_job\Constants\JobStatus;
use Drupal\flowdrop_job\FlowDropJobInterface;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface;
use Drupal\flowdrop_runtime\Service\Orchestrator\AbstractOrchestratorBase;
use Drupal\flowdrop_stategraph\DTO\GraphState;
use Drupal\flowdrop_stategraph\Service\StateGraphOrchestrator;
use Psr\Log\NullLogger;

/**
 * Pins last-executor-wins port resolution for the StateGraph orchestrator.
 *
 * StateGraph builds a job's inputs in its own buildStateGraphRuntimeInputData()
 * rather than the shared base method, and it is the loop path. When several
 * sources target one input port it must resolve to the LATEST executor (highest
 * execution_order) and keep a single value — never an aggregated array — just
 * as MultiSourcePortResolutionTest pins for the runtime orchestrators. Any
 * other winner is a bug.
 *
 * Inside a loop this is what makes a body node read the most recent upstream
 * value: each re-execution completes later and is stamped with a higher
 * execution_order, so it wins the next pass.
 *
 * @group flowdrop_stategraph
 */
class StateGraphMultiSourcePortResolutionTest extends UnitTestCase {

  /**
   * Two sources → one port resolves to the latest executor, as one value.
   */
  public function testLatestExecutorWinsForOnePort(): void {
    // node_early completed first (order 1); node_late completed second
    // (order 2). The later executor must win.
    $early = $this->makeJob('node_early', ['out' => 'first'], 1, 10);
    $late = $this->makeJob('node_late', ['out' => 'second'], 2, 11);
    $target = $this->makeTargetJob('node_z', [
      $this->edge('node_early', 'node_early-output-out', 'node_z-input-data'),
      $this->edge('node_late', 'node_late-output-out', 'node_z-input-data'),
    ]);
    $pipeline = $this->makePipeline([$early, $late]);

    $inputs = $this->resolve($target, $pipeline);

    $this->assertArrayHasKey('data', $inputs);
    $this->assertIsNotArray($inputs['data']);
    $this->assertSame('second', $inputs['data']);
  }

  /**
   * Loop re-execution: a later run of the same node supersedes the earlier one.
   */
  public function testLatestRunOfSourceNodeWinsInLoops(): void {
    // The same source node ran twice; the later run has the higher job ID and
    // the higher execution_order, so its value is the one delivered.
    $firstPass = $this->makeJob('node_loop', ['out' => 'pass_1'], 5, 20);
    $secondPass = $this->makeJob('node_loop', ['out' => 'pass_2'], 9, 21);
    $target = $this->makeTargetJob('node_z', [
      $this->edge('node_loop', 'node_loop-output-out', 'node_z-input-data'),
    ]);
    $pipeline = $this->makePipeline([$firstPass, $secondPass]);

    $inputs = $this->resolve($target, $pipeline);

    $this->assertSame('pass_2', $inputs['data']);
  }

  // --- helpers --------------------------------------------------------------

  /**
   * Invokes the private buildStateGraphRuntimeInputData() via reflection.
   *
   * The method consumes its arguments and $this->extractPortName(), plus the
   * inherited $logger when a fan-in collision is surfaced, so an instance built
   * without its constructor needs only a logger injected.
   *
   * @return array<string, mixed>
   *   The resolved runtime inputs.
   */
  private function resolve(FlowDropJobInterface $job, FlowDropPipelineRuntimeInterface $pipeline): array {
    $ref = new \ReflectionClass(StateGraphOrchestrator::class);
    $instance = $ref->newInstanceWithoutConstructor();

    // The collision branch logs the discarded source; the constructor-less
    // instance has no logger. The property is readonly, so it must be
    // initialised through the declaring class's reflection scope.
    $logger = new \ReflectionProperty(AbstractOrchestratorBase::class, 'logger');
    $logger->setValue($instance, new NullLogger());

    $method = $ref->getMethod('buildStateGraphRuntimeInputData');
    $method->setAccessible(TRUE);

    return $method->invoke($instance, $job, $pipeline, new GraphState());
  }

  /**
   * Builds a completed upstream job mock.
   *
   * @param string $nodeId
   *   The node ID the job ran for.
   * @param array<string, mixed> $output
   *   The job's output data (port => value).
   * @param int $executionOrder
   *   The execution_order metadata.
   * @param int $id
   *   The job entity ID.
   */
  private function makeJob(string $nodeId, array $output, int $executionOrder, int $id): FlowDropJobInterface {
    $job = $this->createMock(FlowDropJobInterface::class);
    $job->method('getNodeId')->willReturn($nodeId);
    $job->method('getOutputData')->willReturn($output);
    $job->method('getMetadata')->willReturn(['execution_order' => $executionOrder]);
    $job->method('id')->willReturn($id);

    return $job;
  }

  /**
   * Builds the target job whose incoming edges are being resolved.
   *
   * @param string $nodeId
   *   The target node ID.
   * @param array<int, array<string, mixed>> $incomingEdges
   *   The incoming edge definitions.
   */
  private function makeTargetJob(string $nodeId, array $incomingEdges): FlowDropJobInterface {
    $job = $this->createMock(FlowDropJobInterface::class);
    $job->method('getNodeId')->willReturn($nodeId);
    $job->method('id')->willReturn(100);
    $job->method('getMetadata')->willReturn(['incoming_edges' => $incomingEdges]);
    $job->method('getOutputData')->willReturn([]);

    return $job;
  }

  /**
   * Builds a pipeline mock returning the given completed jobs.
   *
   * @param array<int, FlowDropJobInterface> $completedJobs
   *   The completed upstream jobs.
   */
  private function makePipeline(array $completedJobs): FlowDropPipelineRuntimeInterface {
    $pipeline = $this->createMock(FlowDropPipelineRuntimeInterface::class);
    $pipeline->method('getJobsByStatus')->willReturnCallback(
      fn(string $status): array => $status === JobStatus::COMPLETED ? $completedJobs : []
    );
    $pipeline->method('id')->willReturn(1);

    return $pipeline;
  }

  /**
   * Builds an incoming-edge definition.
   */
  private function edge(string $source, string $sourceHandle, string $targetHandle): array {
    return [
      'source' => $source,
      'source_handle' => $sourceHandle,
      'target_handle' => $targetHandle,
      'is_trigger' => FALSE,
    ];
  }

}
