<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_stategraph\Integration;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Tests\UnitTestCase;
use Drupal\flowdrop\DTO\Output;
use Drupal\flowdrop_job\FlowDropJobInterface;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface;
use Drupal\flowdrop_pipeline\Service\JobGenerationService;
use Drupal\flowdrop_orchestration\DTO\Orchestrator\OrchestrationRequest;
use Drupal\flowdrop_runtime\DTO\Runtime\NodeExecutionContext;
use Drupal\flowdrop_runtime\DTO\Runtime\NodeExecutionResult;
use Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeService;
use Drupal\flowdrop_runtime\Service\Storage\JobStorageInterface;
use Drupal\flowdrop_runtime\Service\Storage\PipelineStorageInterface;
use Drupal\flowdrop_stategraph\Checkpointer\MemoryCheckpointer;
use Drupal\flowdrop_stategraph\Routing\ConditionalEdgeEvaluator;
use Drupal\flowdrop_stategraph\Service\CheckpointerFactory;
use Drupal\flowdrop_stategraph\Service\StateGraphOrchestrator;
use Drupal\flowdrop_stategraph\Service\StateManager;
use Drupal\flowdrop_workflow\FlowDropWorkflowInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

/**
 * Integration tests for the StateGraphOrchestrator.
 *
 * @coversDefaultClass \Drupal\flowdrop_stategraph\Service\StateGraphOrchestrator
 * @group flowdrop_stategraph
 */
class StateGraphOrchestratorTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The orchestrator under test.
   *
   * @var \Drupal\flowdrop_stategraph\Service\StateGraphOrchestrator
   */
  private StateGraphOrchestrator $orchestrator;

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

  /**
   * Mock pipeline storage.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<\Drupal\flowdrop_runtime\Service\Storage\PipelineStorageInterface>
   */
  private $pipelineStorage;

  /**
   * Mock job generation service.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<\Drupal\flowdrop_pipeline\Service\JobGenerationService>
   */
  private $jobGenerationService;

  /**
   * Mock event dispatcher.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<\Symfony\Component\EventDispatcher\EventDispatcherInterface>
   */
  private $eventDispatcher;

  /**
   * Memory checkpointer for testing.
   *
   * @var \Drupal\flowdrop_stategraph\Checkpointer\MemoryCheckpointer
   */
  private MemoryCheckpointer $memoryCheckpointer;

  /**
   * Tracks executed nodes.
   *
   * @var array<string>
   */
  private array $executedNodes = [];

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

    $this->executedNodes = [];

    // Create logger mock.
    // Note: For void methods, just stub without willReturn().
    $logger = $this->prophesize(LoggerInterface::class);
    $logger->debug(Argument::cetera());
    $logger->info(Argument::cetera());
    $logger->error(Argument::cetera());
    $logger->warning(Argument::cetera());

    // Create config mock.
    $config = $this->prophesize(ImmutableConfig::class);
    $config->get("max_iterations")->willReturn(100);
    $configFactory = $this->prophesize(ConfigFactoryInterface::class);
    $configFactory->get("flowdrop_stategraph.settings")->willReturn($config->reveal());

    // Create real services.
    $stateManager = new StateManager($logger->reveal(), $configFactory->reveal());

    $this->memoryCheckpointer = new MemoryCheckpointer($logger->reveal());
    $this->memoryCheckpointer->clear();

    $checkpointerFactory = $this->prophesize(CheckpointerFactory::class);
    $checkpointerFactory->create(Argument::any())->willReturn($this->memoryCheckpointer);

    $conditionalEdgeEvaluator = new ConditionalEdgeEvaluator($logger->reveal());

    // Create mocks for Drupal services.
    $this->nodeRuntime = $this->prophesize(NodeRuntimeService::class);
    $this->jobGenerationService = $this->prophesize(JobGenerationService::class);
    $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
    $this->eventDispatcher->dispatch(Argument::any(), Argument::any())->willReturn(Argument::any());

    // Create mocks for storage services.
    $this->pipelineStorage = $this->prophesize(PipelineStorageInterface::class);
    $this->pipelineStorage->save(Argument::any());
    $this->pipelineStorage->resetCache(Argument::any());
    $jobStorage = $this->prophesize(JobStorageInterface::class);
    $jobStorage->save(Argument::any());
    $jobStorage->resetCache(Argument::any());

    // Create orchestrator with new constructor signature.
    $this->orchestrator = new StateGraphOrchestrator(
      $this->pipelineStorage->reveal(),
      $jobStorage->reveal(),
      $this->nodeRuntime->reveal(),
      $this->jobGenerationService->reveal(),
      $this->eventDispatcher->reveal(),
      $logger->reveal(),
      $stateManager,
      $checkpointerFactory->reveal(),
      $conditionalEdgeEvaluator,
    );
  }

  /**
   * {@inheritdoc}
   */
  protected function tearDown(): void {
    $this->memoryCheckpointer->clear();
    parent::tearDown();
  }

  /**
   * Tests getType returns "flowdrop_stategraph:stategraph".
   *
   * @covers ::getType
   */
  public function testGetType(): void {
    $this->assertEquals("flowdrop_stategraph:stategraph", $this->orchestrator->getType());
  }

  /**
   * Tests getCapabilities returns expected flags.
   *
   * @covers ::getCapabilities
   */
  public function testGetCapabilities(): void {
    $capabilities = $this->orchestrator->getCapabilities();

    $this->assertTrue($capabilities["synchronous_execution"]);
    $this->assertFalse($capabilities["parallel_execution"]);
    $this->assertFalse($capabilities["async_execution"]);
    $this->assertTrue($capabilities["retry_support"]);
    $this->assertTrue($capabilities["error_recovery"]);
    $this->assertTrue($capabilities["pipeline_based"]);
    $this->assertTrue($capabilities["job_tracking"]);
    $this->assertTrue($capabilities["stateful"]);
    $this->assertTrue($capabilities["checkpointing"]);
    $this->assertTrue($capabilities["conditional_routing"]);
    $this->assertTrue($capabilities["iterator_support"]);
    $this->assertTrue($capabilities["approval_gates"]);
    $this->assertTrue($capabilities["message_history"]);
  }

  /**
   * Tests supportsWorkflow returns true for any workflow.
   *
   * @covers ::supportsWorkflow
   */
  public function testSupportsWorkflow(): void {
    $this->assertTrue($this->orchestrator->supportsWorkflow(["id" => "any_workflow"]));
    $this->assertTrue($this->orchestrator->supportsWorkflow([]));
    $this->assertTrue($this->orchestrator->supportsWorkflow(["complex" => ["nested" => "data"]]));
  }

  /**
   * Tests linear workflow execution A -> B -> C.
   *
   * @covers ::orchestrate
   */
  public function testLinearWorkflowExecution(): void {
    $workflow = [
      "id" => "linear_workflow",
      "label" => "Linear Test Workflow",
      "nodes" => [
        ["id" => "node_a", "type" => "processor_a", "config" => []],
        ["id" => "node_b", "type" => "processor_b", "config" => []],
        ["id" => "node_c", "type" => "processor_c", "config" => []],
      ],
      "edges" => [
        ["source" => "node_a", "target" => "node_b"],
        ["source" => "node_b", "target" => "node_c"],
      ],
    ];

    $this->setupMocksForLinearWorkflow($workflow);

    $request = new OrchestrationRequest(
      workflowId: "linear_workflow",
      pipelineId: "pipeline_1",
      workflow: $workflow,
      initialData: ["input" => "test_data"],
      options: [
        "stategraph" => [
          "checkpointer_type" => "memory",
          "create_checkpoints" => TRUE,
        ],
      ],
    );

    $response = $this->orchestrator->orchestrate($request);

    $this->assertEquals("completed", $response->getStatus());
    $this->assertNotEmpty($response->getExecutionId());
    $this->assertGreaterThan(0, $response->getExecutionTime());

    // Verify execution order.
    $this->assertEquals(["node_a", "node_b", "node_c"], $this->executedNodes);
  }

  /**
   * Tests orchestration response contains correct metadata.
   *
   * @covers ::orchestrate
   */
  public function testOrchestrationResponseMetadata(): void {
    $workflow = [
      "id" => "metadata_workflow",
      "label" => "Metadata Test",
      "nodes" => [
        ["id" => "node_1", "type" => "processor", "config" => []],
      ],
      "edges" => [],
    ];

    $this->setupMocksForSingleNodeWorkflow($workflow);

    $request = new OrchestrationRequest(
      workflowId: "metadata_workflow",
      pipelineId: "pipeline_metadata",
      workflow: $workflow,
      initialData: [],
      options: [
        "stategraph" => [
          "thread_id" => "test_thread_123",
        ],
      ],
    );

    $response = $this->orchestrator->orchestrate($request);

    $metadata = $response->getMetadata();

    $this->assertArrayHasKey("pipeline_id", $metadata);
    $this->assertArrayHasKey("executed_jobs", $metadata);
    $this->assertArrayHasKey("iterations", $metadata);
    $this->assertArrayHasKey("total_jobs", $metadata);
    $this->assertArrayHasKey("thread_id", $metadata);
    $this->assertArrayHasKey("final_state", $metadata);
  }

  /**
   * Tests max iterations exceeded returns correct status.
   *
   * @covers ::orchestrate
   */
  public function testMaxIterationsExceeded(): void {
    $workflow = [
      "id" => "loop_workflow",
      "label" => "Loop Test",
      "nodes" => [
        ["id" => "loop_node", "type" => "processor", "config" => []],
      ],
      "edges" => [],
    ];

    // Setup mocks that create an infinite loop scenario.
    $this->setupMocksForLoopingWorkflow($workflow);

    $request = new OrchestrationRequest(
      workflowId: "loop_workflow",
      pipelineId: "pipeline_loop",
      workflow: $workflow,
      initialData: [],
      options: [
        "stategraph" => [
          "max_iterations" => 3,
        ],
      ],
    );

    $response = $this->orchestrator->orchestrate($request);

    // Should complete normally since we limit iterations.
    // The status depends on whether jobs are exhausted or max reached.
    $this->assertContains($response->getStatus(), ["completed", "paused", "max_iterations_exceeded"]);
  }

  /**
   * Sets up mocks for a linear workflow.
   *
   * @param array<string, mixed> $workflow
   *   The workflow definition.
   */
  private function setupMocksForLinearWorkflow(array $workflow): void {
    // Setup pipeline mock.
    $pipeline = $this->prophesize(FlowDropPipelineInterface::class);
    $workflowEntity = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflowEntity->id()->willReturn($workflow["id"]);
    $workflowEntity->getGraphData()->willReturn($workflow);

    $pipeline->getWorkflow()->willReturn($workflowEntity->reveal());
    $pipeline->id()->willReturn("pipeline_123");
    $pipeline->getInputData()->willReturn(["input" => "test_data"]);
    $pipeline->isReadyToStart()->willReturn(TRUE);
    $pipeline->isPaused()->willReturn(FALSE);
    // Fluent methods returning 'static' need explicit return values.
    $pipeline->markAsStarted()->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->markAsCompleted(Argument::any())->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->hasFailedJobs()->willReturn(FALSE);
    $pipeline->pause()->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->save()->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->getExecutionContext()->willReturn([]);
    $pipeline->setExecutionContext(Argument::any())->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });

    // Create jobs for each node.
    $jobs = [];
    $readyJobsCalls = [];

    foreach ($workflow["nodes"] as $index => $node) {
      $job = $this->prophesize(FlowDropJobInterface::class);
      $job->id()->willReturn("job_{$index}");
      $job->getNodeId()->willReturn($node["id"]);
      $job->getMetadata()->willReturn([
        "config" => $node["config"],
        "node_type_id" => $node["type"],
        "incoming_edges" => [],
      ]);
      // Fluent methods returning 'static' need explicit return values.
      $job->markAsStarted()->will(function () use ($job) {
        return $job->reveal();
      });
      $job->markAsCompleted(Argument::any())->will(function () use ($job) {
        return $job->reveal();
      });
      $job->setInputData(Argument::any())->will(function () use ($job) {
        return $job->reveal();
      });
      $job->setMetadata(Argument::any())->will(function () use ($job) {
        return $job->reveal();
      });
      $job->markAsSkipped()->will(function () use ($job) {
        return $job->reveal();
      });
      $job->save()->will(function () use ($job) {
        return $job->reveal();
      });

      $jobs[] = $job->reveal();
    }

    // Return one job at a time to simulate sequential execution.
    // After returning all jobs, subsequent calls return empty array.
    // Use stdClass to avoid reference issues with Prophecy closures.
    $pipeline->getJobs()->willReturn($jobs);
    $pipeline->getJobsByStatus(Argument::type("string"))->will(function ($args) use ($jobs) {
      if ($args[0] === "idle") {
        return $jobs;
      }
      return [];
    });
    $pipeline->hasInterruptedJobs()->willReturn(FALSE);
    $pipeline->markAsFailed(Argument::any())->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });

    // Setup pipeline storage.
    $this->pipelineStorage->create(Argument::any())->willReturn($pipeline->reveal());
    $this->pipelineStorage->load(Argument::any())->willReturn($pipeline->reveal());

    // Setup job generation.
    $this->jobGenerationService->generateJobs(Argument::any(), Argument::any())->shouldBeCalled();
    $jobTracker = new \stdClass();
    $jobTracker->queue = $jobs;
    $this->jobGenerationService->getReadyJobs(Argument::any())->will(function () use ($jobTracker) {
      if (!empty($jobTracker->queue)) {
        return [array_shift($jobTracker->queue)];
      }
      return [];
    });

    // Setup node runtime.
    $executedNodes = &$this->executedNodes;
    $this->nodeRuntime->executeNode(
      Argument::type("string"),
      Argument::type("string"),
      Argument::type("string"),
      Argument::type("array"),
      Argument::type("array"),
      Argument::type(NodeExecutionContext::class)
    )->will(function ($args) use (&$executedNodes) {
      $nodeId = $args[1];
      $executedNodes[] = $nodeId;

      $output = new Output([
        "status" => "success",
        "result" => "output_from_{$nodeId}",
      ]);

      return new NodeExecutionResult(
        nodeId: $nodeId,
        status: "success",
        output: $output,
        executionTime: 0.01,
        metadata: [],
      );
    });
  }

  /**
   * Sets up mocks for a single node workflow.
   *
   * @param array<string, mixed> $workflow
   *   The workflow definition.
   */
  private function setupMocksForSingleNodeWorkflow(array $workflow): void {
    $node = $workflow["nodes"][0];

    // Setup pipeline mock.
    $pipeline = $this->prophesize(FlowDropPipelineInterface::class);
    $workflowEntity = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflowEntity->id()->willReturn($workflow["id"]);
    $workflowEntity->getGraphData()->willReturn($workflow);

    $pipeline->getWorkflow()->willReturn($workflowEntity->reveal());
    $pipeline->id()->willReturn("pipeline_single");
    $pipeline->getInputData()->willReturn([]);
    $pipeline->isReadyToStart()->willReturn(TRUE);
    $pipeline->isPaused()->willReturn(FALSE);
    // Fluent methods returning 'static' need explicit return values.
    $pipeline->markAsStarted()->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->markAsCompleted(Argument::any())->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->hasFailedJobs()->willReturn(FALSE);
    $pipeline->pause()->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->save()->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->getExecutionContext()->willReturn([]);
    $pipeline->setExecutionContext(Argument::any())->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });

    // Create single job.
    $job = $this->prophesize(FlowDropJobInterface::class);
    $job->id()->willReturn("job_0");
    $job->getNodeId()->willReturn($node["id"]);
    $job->getMetadata()->willReturn([
      "config" => $node["config"],
      "node_type_id" => $node["type"],
      "incoming_edges" => [],
    ]);
    // Fluent methods returning 'static' need explicit return values.
    $job->markAsStarted()->will(function () use ($job) {
      return $job->reveal();
    });
    $job->markAsCompleted(Argument::any())->will(function () use ($job) {
      return $job->reveal();
    });
    $job->setInputData(Argument::any())->will(function () use ($job) {
      return $job->reveal();
    });
    $job->setMetadata(Argument::any())->will(function () use ($job) {
      return $job->reveal();
    });
    $job->save()->will(function () use ($job) {
      return $job->reveal();
    });

    $jobs = [$job->reveal()];

    $pipeline->getJobs()->willReturn($jobs);
    $pipeline->getJobsByStatus(Argument::type("string"))->willReturn([]);
    $pipeline->hasInterruptedJobs()->willReturn(FALSE);
    $pipeline->markAsFailed(Argument::any())->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });

    // Setup pipeline storage.
    $this->pipelineStorage->create(Argument::any())->willReturn($pipeline->reveal());
    $this->pipelineStorage->load(Argument::any())->willReturn($pipeline->reveal());

    $this->jobGenerationService->generateJobs(Argument::any(), Argument::any())->shouldBeCalled();
    $returned = FALSE;
    $this->jobGenerationService->getReadyJobs(Argument::any())->will(function () use ($jobs, &$returned) {
      if (!$returned) {
        $returned = TRUE;
        return $jobs;
      }
      return [];
    });

    // Setup node runtime.
    $executedNodes = &$this->executedNodes;
    $this->nodeRuntime->executeNode(
      Argument::type("string"),
      Argument::type("string"),
      Argument::type("string"),
      Argument::type("array"),
      Argument::type("array"),
      Argument::type(NodeExecutionContext::class)
    )->will(function ($args) use (&$executedNodes) {
      $nodeId = $args[1];
      $executedNodes[] = $nodeId;

      return new NodeExecutionResult(
        nodeId: $nodeId,
        status: "success",
        output: new Output(["result" => "done"]),
        executionTime: 0.01,
        metadata: [],
      );
    });
  }

  /**
   * Sets up mocks for a looping workflow.
   *
   * @param array<string, mixed> $workflow
   *   The workflow definition.
   */
  private function setupMocksForLoopingWorkflow(array $workflow): void {
    $node = $workflow["nodes"][0];

    // Setup pipeline mock.
    $pipeline = $this->prophesize(FlowDropPipelineInterface::class);
    $workflowEntity = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflowEntity->id()->willReturn($workflow["id"]);
    $workflowEntity->getGraphData()->willReturn($workflow);

    $pipeline->getWorkflow()->willReturn($workflowEntity->reveal());
    $pipeline->id()->willReturn("pipeline_loop");
    $pipeline->getInputData()->willReturn([]);
    $pipeline->isReadyToStart()->willReturn(TRUE);
    $pipeline->isPaused()->willReturn(FALSE);
    // Fluent methods returning 'static' need explicit return values.
    $pipeline->markAsStarted()->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->markAsCompleted(Argument::any())->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->hasFailedJobs()->willReturn(FALSE);
    $pipeline->pause()->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->save()->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });
    $pipeline->getExecutionContext()->willReturn([]);
    $pipeline->setExecutionContext(Argument::any())->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });

    // Create job that keeps returning as ready.
    $job = $this->prophesize(FlowDropJobInterface::class);
    $job->id()->willReturn("job_loop");
    $job->getNodeId()->willReturn($node["id"]);
    $job->getMetadata()->willReturn([
      "config" => $node["config"],
      "node_type_id" => $node["type"],
      "incoming_edges" => [],
    ]);
    // Fluent methods returning 'static' need explicit return values.
    $job->markAsStarted()->will(function () use ($job) {
      return $job->reveal();
    });
    $job->markAsCompleted(Argument::any())->will(function () use ($job) {
      return $job->reveal();
    });
    $job->setInputData(Argument::any())->will(function () use ($job) {
      return $job->reveal();
    });
    $job->setMetadata(Argument::any())->will(function () use ($job) {
      return $job->reveal();
    });
    $job->save()->will(function () use ($job) {
      return $job->reveal();
    });

    $jobs = [$job->reveal()];

    $pipeline->getJobs()->willReturn($jobs);
    $pipeline->getJobsByStatus(Argument::type("string"))->willReturn([]);
    $pipeline->hasInterruptedJobs()->willReturn(FALSE);
    $pipeline->markAsFailed(Argument::any())->will(function () use ($pipeline) {
      return $pipeline->reveal();
    });

    // Setup pipeline storage.
    $this->pipelineStorage->create(Argument::any())->willReturn($pipeline->reveal());
    $this->pipelineStorage->load(Argument::any())->willReturn($pipeline->reveal());

    $this->jobGenerationService->generateJobs(Argument::any(), Argument::any())->shouldBeCalled();
    $iteration = 0;
    // Return jobs for limited iterations then stop.
    $this->jobGenerationService->getReadyJobs(Argument::any())->will(function () use ($jobs, &$iteration) {
      if ($iteration < 3) {
        $iteration++;
        return $jobs;
      }
      return [];
    });

    // Setup node runtime.
    $this->nodeRuntime->executeNode(
      Argument::type("string"),
      Argument::type("string"),
      Argument::type("string"),
      Argument::type("array"),
      Argument::type("array"),
      Argument::type(NodeExecutionContext::class)
    )->will(function ($args) {
      return new NodeExecutionResult(
        nodeId: $args[1],
        status: "success",
        output: new Output(["result" => "loop_iteration"]),
        executionTime: 0.01,
        metadata: [],
      );
    });
  }

}
