<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_runtime\Unit\Service\Orchestrator;

use Drupal\Tests\UnitTestCase;
use Drupal\flowdrop\DTO\Output;
use Drupal\flowdrop\Exception\NodeProcessingException;
use Drupal\flowdrop\Exception\WorkflowStopException;
use Drupal\flowdrop_job\FlowDropJobInterface;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface;
use Drupal\flowdrop_pipeline\Service\JobGenerationService;
use Drupal\flowdrop_runtime\DTO\Runtime\NodeExecutionResult;
use Drupal\flowdrop_runtime\Service\Orchestrator\SynchronousPipelineOrchestrator;
use Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeService;
use Drupal\flowdrop_runtime\Service\Storage\JobStorageInterface;
use Drupal\flowdrop_runtime\Service\Storage\PipelineStorageInterface;
use Drupal\flowdrop_runtime\Service\Storage\PipelineStorageRegistryInterface;
use Drupal\flowdrop_workflow\FlowDropWorkflowInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

/**
 * Tests error-port routing on the shared executeJob (sync pipeline path).
 *
 * StateGraph overrides executeJob with executeJobWithState, so the shared
 * AbstractOrchestratorBase::executeJob routing branch is reached only by the
 * pipeline orchestrators. This exercises it directly through
 * SynchronousPipelineOrchestrator.
 *
 * @coversDefaultClass \Drupal\flowdrop_runtime\Service\Orchestrator\AbstractOrchestratorBase
 * @group flowdrop_runtime
 */
class SynchronousPipelineOrchestratorErrorRoutingTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * Mock node runtime.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<\Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeService>
   */
  private $nodeRuntime;

  /**
   * Mock job storage.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<\Drupal\flowdrop_runtime\Service\Storage\JobStorageInterface>
   */
  private $jobStorage;

  /**
   * The orchestrator under test.
   *
   * @var \Drupal\flowdrop_runtime\Service\Orchestrator\SynchronousPipelineOrchestrator
   */
  private SynchronousPipelineOrchestrator $orchestrator;

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

    $logger = $this->prophesize(LoggerInterface::class);
    $logger->info(Argument::cetera());
    $logger->debug(Argument::cetera());
    $logger->error(Argument::cetera());
    $logger->warning(Argument::cetera());

    $this->nodeRuntime = $this->prophesize(NodeRuntimeService::class);
    $this->jobStorage = $this->prophesize(JobStorageInterface::class);
    $this->jobStorage->save(Argument::any());

    $pipelineStorage = $this->prophesize(PipelineStorageInterface::class);
    $registry = $this->prophesize(PipelineStorageRegistryInterface::class);
    $registry->getStorage(Argument::any())->willReturn($pipelineStorage->reveal());

    $jobGeneration = $this->prophesize(JobGenerationService::class);

    $eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
    $eventDispatcher->dispatch(Argument::any(), Argument::any())->willReturnArgument(0);

    $this->orchestrator = new SynchronousPipelineOrchestrator(
      $registry->reveal(),
      $this->jobStorage->reveal(),
      $this->nodeRuntime->reveal(),
      $jobGeneration->reveal(),
      $eventDispatcher->reveal(),
      $logger->reveal(),
    );
  }

  /**
   * A failing node WITH an error edge routes instead of throwing.
   *
   * @covers ::executeJob
   * @covers ::routeNodeError
   * @covers ::nodeHasErrorEdge
   */
  public function testExecuteJobRoutesFailureWhenErrorEdgePresent(): void {
    $tracker = new \stdClass();
    $tracker->failed = FALSE;
    $tracker->routed = FALSE;
    $tracker->completed = FALSE;
    $tracker->outputData = NULL;

    $job = $this->buildJob($tracker, withErrorEdge: TRUE);
    $pipeline = $this->buildPipeline($job);
    $this->stubFailingNodeRuntime("boom blew up");

    $result = $this->invokeExecuteJob($job, $pipeline);

    $this->assertIsArray($result);
    $this->assertArrayHasKey("error", $result);
    $this->assertSame("boom blew up", $result["error"]["message"]);
    $this->assertSame("boom", $result["error"]["node_id"]);
    $this->assertTrue($tracker->failed, "Routed job must be marked FAILED.");
    $this->assertTrue($tracker->routed, "Routed job must be stamped error_routed.");
    $this->assertFalse($tracker->completed, "Routed job must NOT be marked COMPLETED.");
  }

  /**
   * A failing node WITHOUT an error edge still throws (status-quo failure).
   *
   * @covers ::executeJob
   */
  public function testExecuteJobThrowsWhenNoErrorEdge(): void {
    $tracker = new \stdClass();
    $tracker->failed = FALSE;
    $tracker->routed = FALSE;
    $tracker->completed = FALSE;
    $tracker->outputData = NULL;

    $job = $this->buildJob($tracker, withErrorEdge: FALSE);
    $pipeline = $this->buildPipeline($job);
    $this->stubFailingNodeRuntime("boom blew up");

    $this->expectException(NodeProcessingException::class);
    $this->expectExceptionMessage("boom blew up");

    try {
      $this->invokeExecuteJob($job, $pipeline);
    }
    finally {
      $this->assertTrue($tracker->failed, "Unrouted failure still marks the job FAILED.");
      $this->assertFalse($tracker->routed, "Unrouted failure must NOT be error_routed.");
    }
  }

  /**
   * A terminal node's stop signal marks the job completed, then re-throws.
   *
   * The stop is not a failure: executeJob must mark the job COMPLETED (not
   * FAILED) and re-throw WorkflowStopException so the run loop can halt
   * scheduling and complete the pipeline.
   *
   * @covers ::executeJob
   */
  public function testExecuteJobMarksCompletedAndRethrowsOnStop(): void {
    $tracker = new \stdClass();
    $tracker->failed = FALSE;
    $tracker->routed = FALSE;
    $tracker->completed = FALSE;
    $tracker->outputData = NULL;

    $job = $this->buildJob($tracker, withErrorEdge: FALSE);
    $pipeline = $this->buildPipeline($job);
    $this->nodeRuntime->executeNode(Argument::cetera())->willThrow(
      new WorkflowStopException(["final" => "done"], "stop reason")
    );

    $this->expectException(WorkflowStopException::class);

    try {
      $this->invokeExecuteJob($job, $pipeline);
    }
    finally {
      $this->assertTrue($tracker->completed, "A stop marks the job COMPLETED.");
      $this->assertFalse($tracker->failed, "A stop must NOT mark the job FAILED.");
    }
  }

  /**
   * Invokes the protected executeJob via reflection.
   *
   * @param \Drupal\flowdrop_job\FlowDropJobInterface $job
   *   The job.
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface $pipeline
   *   The pipeline.
   *
   * @return array<string, mixed>
   *   The executeJob return value.
   */
  private function invokeExecuteJob(FlowDropJobInterface $job, FlowDropPipelineRuntimeInterface $pipeline): array {
    $method = (new \ReflectionClass($this->orchestrator))
      ->getMethod("executeJob");
    $method->setAccessible(TRUE);
    return $method->invoke($this->orchestrator, $job, $pipeline, "exec_test");
  }

  /**
   * Builds a mock job, optionally carrying an outgoing error edge.
   *
   * @param \stdClass $tracker
   *   Records ->failed, ->routed, ->completed, ->outputData.
   * @param bool $withErrorEdge
   *   Whether the job's metadata declares an outgoing error edge.
   *
   * @return \Drupal\flowdrop_job\FlowDropJobInterface
   *   The revealed job mock.
   */
  private function buildJob(\stdClass $tracker, bool $withErrorEdge): FlowDropJobInterface {
    $outgoing = $withErrorEdge ? [
      [
        "source" => "boom",
        "source_handle" => "boom-output-error",
        "target" => "handler",
        "target_handle" => "handler-input-input",
      ],
    ] : [];

    $job = $this->prophesize(FlowDropJobInterface::class);
    $job->id()->willReturn("job_0");
    $job->getNodeId()->willReturn("boom");
    $job->getMetadata()->willReturn([
      "config" => [],
      "node_type_id" => "processor",
      "incoming_edges" => [],
      "outgoing_edges" => $outgoing,
    ]);
    $job->getMetadataValue("interrupt_id", Argument::any())->willReturn(NULL);
    $job->getMetadataValue("node_type_id", Argument::any())->willReturn("processor");
    $job->getMaxRetries()->willReturn(0);
    $job->getRetryCount()->willReturn(0);
    $job->canRetry()->willReturn(FALSE);
    $job->markAsStarted()->will(fn() => $job->reveal());
    $job->setInputData(Argument::any())->will(fn() => $job->reveal());
    $job->setOutputData(Argument::any())->will(function ($args) use ($job, $tracker) {
      $tracker->outputData = $args[0];
      return $job->reveal();
    });
    $job->setMetadata(Argument::any())->will(function ($args) use ($job, $tracker) {
      if (($args[0]["error_routed"] ?? FALSE) === TRUE) {
        $tracker->routed = TRUE;
      }
      return $job->reveal();
    });
    $job->markAsFailed(Argument::any())->will(function () use ($job, $tracker) {
      $tracker->failed = TRUE;
      return $job->reveal();
    });
    $job->markAsCompleted(Argument::any())->will(function () use ($job, $tracker) {
      $tracker->completed = TRUE;
      return $job->reveal();
    });

    return $job->reveal();
  }

  /**
   * Builds a mock pipeline returning the given job.
   *
   * @param \Drupal\flowdrop_job\FlowDropJobInterface $job
   *   The single job on the pipeline.
   *
   * @return \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface
   *   The revealed pipeline mock.
   */
  private function buildPipeline(FlowDropJobInterface $job): FlowDropPipelineRuntimeInterface {
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->id()->willReturn("wf");

    $pipeline = $this->prophesize(FlowDropPipelineRuntimeInterface::class);
    $pipeline->id()->willReturn("pipeline_0");
    $pipeline->getWorkflow()->willReturn($workflow->reveal());
    $pipeline->getInputData()->willReturn([]);
    $pipeline->getExecutionContext()->willReturn([]);
    $pipeline->getJobs()->willReturn([$job]);
    $pipeline->getJobsByStatus(Argument::type("string"))->willReturn([]);

    return $pipeline->reveal();
  }

  /**
   * Stubs the node runtime to return an error-status Output.
   *
   * @param string $message
   *   The error message the node reports.
   */
  private function stubFailingNodeRuntime(string $message): void {
    $this->nodeRuntime->executeNode(Argument::cetera())->will(function ($args) use ($message) {
      $output = new Output();
      $output->setError($message);
      return new NodeExecutionResult(
        nodeId: $args[1],
        status: "error",
        output: $output,
        executionTime: 0.01,
        metadata: [],
      );
    });
  }

}
