<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_pipeline\Kernel;

use Drupal\KernelTests\KernelTestBase;
use Drupal\flowdrop\DTO\ExecutionEdge;
use Drupal\flowdrop_job\Constants\JobStatus;
use Drupal\flowdrop_job\FlowDropJobInterface;
use Drupal\flowdrop_pipeline\Service\JobGenerationService;
use Drupal\Tests\flowdrop\Kernel\FlowDropKernelTestTrait;

/**
 * Test the job generation service.
 *
 * @group flowdrop_pipeline
 */
class JobGenerationServiceTest extends KernelTestBase {

  use FlowDropKernelTestTrait;

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'system',
    'user',
    'field',
    'options',
    'text',
    'flowdrop',
    'flowdrop_workflow',
    'flowdrop_orchestration',
    'flowdrop_runtime',
    'flowdrop_pipeline',
    'flowdrop_job',
    'flowdrop_node_type',
    'flowdrop_node_processor',
    'flowdrop_trigger',
  ];

  /**
   * The job generation service.
   *
   * @var \Drupal\flowdrop_pipeline\Service\JobGenerationService
   */
  protected $jobGenerationService;

  /**
   * {@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_node_type');

    // The node type entity is now the authoritative source for a node's
    // executor plugin (slim storage no longer persists it on the node). Create
    // the types whose plugin identity drives filtering — Note (non-executable)
    // and Trigger — so the job generator can classify them post-strip.
    $nodeTypeStorage = $this->container->get('entity_type.manager')
      ->getStorage('flowdrop_node_type');
    $nodeTypeStorage->create([
      'id' => 'note',
      'label' => 'Note',
      'enabled' => TRUE,
      'executor_plugin' => 'flowdrop_node_processor:note',
    ])->save();
    $nodeTypeStorage->create([
      'id' => 'trigger',
      'label' => 'Trigger',
      'enabled' => TRUE,
      'executor_plugin' => 'flowdrop_trigger:trigger',
    ])->save();

    $this->jobGenerationService = $this->container->get('flowdrop_pipeline.job_generation');
  }

  /**
   * Test job generation service is available.
   */
  public function testJobGenerationService(): void {
    $this->assertInstanceOf(JobGenerationService::class, $this->jobGenerationService);
  }

  /**
   * Test dependency graph building.
   */
  public function testDependencyGraph(): void {
    $nodes = [
      ['id' => 'node1'],
      ['id' => 'node2'],
      ['id' => 'node3'],
    ];

    $edges = [
      ['source' => 'node1', 'target' => 'node2'],
      ['source' => 'node2', 'target' => 'node3'],
    ];

    // Use reflection to test protected method.
    $reflection = new \ReflectionClass($this->jobGenerationService);
    $method = $reflection->getMethod('buildDependencyGraph');
    $method->setAccessible(TRUE);

    $dependency_graph = $method->invoke($this->jobGenerationService, $nodes, $edges);

    $expected = [
      'node1' => [],
      'node2' => ['node1'],
      'node3' => ['node2'],
    ];

    $this->assertEquals($expected, $dependency_graph);
  }

  /**
   * Test execution order calculation.
   */
  public function testExecutionOrder(): void {
    $dependency_graph = [
      'node1' => [],
      'node2' => ['node1'],
      'node3' => ['node2'],
      'node4' => ['node1'],
    ];

    // Use reflection to test protected method.
    $reflection = new \ReflectionClass($this->jobGenerationService);
    $method = $reflection->getMethod('calculateExecutionOrder');
    $method->setAccessible(TRUE);

    $execution_order = $method->invoke($this->jobGenerationService, $dependency_graph);

    // node1 should be first (no dependencies)
    $this->assertEquals(0, $execution_order['node1']);

    // node2 and node4 should be after node1.
    $this->assertGreaterThan($execution_order['node1'], $execution_order['node2']);
    $this->assertGreaterThan($execution_order['node1'], $execution_order['node4']);

    // node3 should be after node2.
    $this->assertGreaterThan($execution_order['node2'], $execution_order['node3']);
  }

  /**
   * Test ready jobs calculation.
   */
  public function testGetReadyJobs(): void {
    // Create a mock workflow.
    $workflow = $this->createMockWorkflow();

    // Create a pipeline.
    $pipeline = $this->createMockPipeline($workflow);

    // Generate jobs.
    $jobs = $this->jobGenerationService->generateJobs($pipeline);

    // Initially, only jobs with no dependencies should be ready.
    $ready_jobs = $this->jobGenerationService->getReadyJobs($pipeline);

    // Should have at least one ready job (the input node)
    $this->assertNotEmpty($ready_jobs);

    // Verify pipeline contains the jobs.
    $pipeline_jobs = $pipeline->getJobs();
    $this->assertNotEmpty($pipeline_jobs);
    $this->assertEquals(count($jobs), count($pipeline_jobs));

    // Ready jobs should be sorted by priority.
    $priorities = array_map(fn($job) => $job->getPriority(), $ready_jobs);
    $sorted_priorities = $priorities;
    sort($sorted_priorities);
    $this->assertEquals($sorted_priorities, $priorities);
  }

  /**
   * Non-executable nodes (Note) produce no job; orphans still execute.
   */
  public function testNonExecutableNodeProducesNoJob(): void {
    $workflow = $this->createWorkflowWithNote();
    $pipeline = $this->createMockPipeline($workflow);

    $jobs = $this->jobGenerationService->generateJobs($pipeline);

    $nodeIds = array_map(fn($job) => $job->getNodeId(), $jobs);

    // The Note node is excluded even though an edge points at it: intent on
    // the processor beats graph topology.
    $this->assertNotContains('note1', $nodeIds);
    // Its upstream node and the disconnected ordinary node both execute —
    // orphans are legitimately executable, only the marker interface skips.
    $this->assertContains('start1', $nodeIds);
    $this->assertContains('orphan1', $nodeIds);
    $this->assertCount(2, $jobs);
  }

  /**
   * Only the firing trigger node produces a job; other triggers are skipped.
   */
  public function testOnlyFiringTriggerProducesJob(): void {
    $workflow = $this->createWorkflowWithTwoTriggers();
    $pipeline = $this->createMockPipeline($workflow, ['trigger_node_id' => 'trigger_a']);

    $jobs = $this->jobGenerationService->generateJobs($pipeline);
    $nodeIds = array_map(fn($job) => $job->getNodeId(), $jobs);

    // The firing trigger executes; the other trigger is filtered out. The
    // trigger plugin ID (flowdrop_trigger:trigger) is resolved from the
    // "trigger" node type entity created in setUp.
    $this->assertContains('trigger_a', $nodeIds);
    $this->assertNotContains('trigger_b', $nodeIds);
    $this->assertContains('downstream', $nodeIds);
  }

  /**
   * A routed failure activates its handler and suppresses the success path.
   *
   * Models the Phase 3 routing contract: a node marked FAILED + `error_routed`
   * satisfies an incoming error edge (the handler becomes ready) but never
   * satisfies a normal data edge (the success successor is not ready, and is
   * skipped at run finalisation).
   */
  public function testErrorEdgeRoutesFailureToHandler(): void {
    $workflow = $this->createWorkflowWithErrorEdge();
    $pipeline = $this->createMockPipeline($workflow);

    $jobs = $this->jobGenerationService->generateJobs($pipeline);
    $byNode = [];
    foreach ($jobs as $job) {
      $byNode[$job->getNodeId()] = $job;
    }
    $this->assertArrayHasKey('boom', $byNode);
    $this->assertArrayHasKey('handler', $byNode);
    $this->assertArrayHasKey('next', $byNode);

    // Before the failure: only the source node is ready; neither the error
    // handler nor the success successor are.
    $initiallyReady = array_map(
      fn($job) => $job->getNodeId(),
      $this->jobGenerationService->getReadyJobs($pipeline)
    );
    $this->assertContains('boom', $initiallyReady);
    $this->assertNotContains('handler', $initiallyReady);
    $this->assertNotContains('next', $initiallyReady);

    // Route boom's failure: FAILED + error_routed, error payload on output.
    $boom = $byNode['boom'];
    $metadata = $boom->getMetadata();
    $metadata['error_routed'] = TRUE;
    $boom->setMetadata($metadata);
    $boom->setOutputData(['error' => ['message' => 'boom failed', 'node_id' => 'boom']]);
    $boom->markAsFailed('boom failed');
    $boom->save();

    // After routing: the error handler is ready; the success successor is not.
    $readyAfter = array_map(
      fn($job) => $job->getNodeId(),
      $this->jobGenerationService->getReadyJobs($pipeline)
    );
    $this->assertContains('handler', $readyAfter, 'Error handler must become ready once the source routes its failure.');
    $this->assertNotContains('next', $readyAfter, 'A FAILED source must not satisfy a normal data edge.');

    // The success successor is never readied, so it stays IDLE — exactly the
    // status the orchestrator's run-finalisation sweep (JobSkippingTrait::
    // markRemainingJobsAsSkipped) marks SKIPPED. Proving it is still IDLE here
    // (not stuck PENDING, which would wedge the run in PAUSE) substantiates the
    // "skipped at run finalisation" half of this contract without standing up a
    // full orchestrator run.
    $idleNodeIds = array_map(
      fn($job) => $job->getNodeId(),
      $pipeline->getJobsByStatus(JobStatus::IDLE)
    );
    $this->assertContains('next', $idleNodeIds, 'The orphaned success successor must remain IDLE so finalisation skips it.');
    $this->assertNotContains('handler', $idleNodeIds, 'The readied handler must have left IDLE.');
  }

  /**
   * A FAILED source without the error_routed flag does not feed an error edge.
   *
   * Guards the opt-in contract: an ordinary (unrouted) failure must not
   * activate a handler — only a deliberately routed failure does.
   */
  public function testUnroutedFailureDoesNotActivateHandler(): void {
    $workflow = $this->createWorkflowWithErrorEdge();
    $pipeline = $this->createMockPipeline($workflow);

    $jobs = $this->jobGenerationService->generateJobs($pipeline);
    $byNode = [];
    foreach ($jobs as $job) {
      $byNode[$job->getNodeId()] = $job;
    }

    // Mark boom FAILED but WITHOUT the error_routed flag.
    $byNode['boom']->markAsFailed('boom failed');
    $byNode['boom']->save();

    $ready = array_map(
      fn($job) => $job->getNodeId(),
      $this->jobGenerationService->getReadyJobs($pipeline)
    );
    $this->assertNotContains('handler', $ready, 'An unrouted failure must not activate the error handler.');
  }

  /**
   * The injected `maxRetries` config property flows into the generated job.
   *
   * Pins the author-facing retry contract: the reserved `maxRetries` config
   * key (camelCase, injected by NodeMetadataResolver) is read into the job's
   * max_retries budget, snake_case `max_retries` is tolerated as a fallback,
   * an absent value yields the opt-in default of 0, and negatives clamp to 0.
   */
  public function testMaxRetriesConfigFlowsToGeneratedJob(): void {
    $workflow = $this->createWorkflowWithRetryConfig();
    $pipeline = $this->createMockPipeline($workflow);

    $jobs = $this->jobGenerationService->generateJobs($pipeline);
    $byNode = [];
    foreach ($jobs as $job) {
      $byNode[$job->getNodeId()] = $job;
    }

    $this->assertSame(2, $byNode['retryer']->getMaxRetries(), 'camelCase maxRetries must be read into the job budget.');
    $this->assertSame(5, $byNode['legacy']->getMaxRetries(), 'snake_case max_retries must be tolerated as a fallback.');
    $this->assertSame(0, $byNode['plain']->getMaxRetries(), 'No retry config means opt-in default of 0.');
    $this->assertSame(0, $byNode['negative']->getMaxRetries(), 'A negative budget clamps to 0.');
  }

  /**
   * A data-output edge from a branch-emitting source is never branch-gated.
   *
   * Regression cover for the dual-mode Confirmation node: a source that emits
   * `active_branches` (gateway signal) may still expose ordinary data-output
   * ports (e.g. `confirmed`). An edge leaving such a data port must flow
   * regardless of the active branch — otherwise the node's `active_branches`
   * emission poisons its own data edge (branch "confirmed" not in ["true"]),
   * skipping every downstream node. See isEdgeBranchActive().
   */
  public function testDataPortEdgeFromBranchEmittingSourceIsActive(): void {
    // Source emits BOTH a data value (`confirmed`) and a gateway branch.
    $output = ['confirmed' => TRUE, 'active_branches' => 'true'];

    // The edge leaves the `confirmed` data port (branch name == data key).
    $active = $this->invokeIsEdgeBranchActive('confirmation.1', 'confirmed', $output);
    $this->assertTrue($active, 'A data-output port must not be gated by active_branches.');
  }

  /**
   * A genuine branch port from a branch-emitting source respects membership.
   *
   * Guards the gateway path the data-port exemption must not regress: ports
   * whose name is absent from the output payload ("true"/"false") are real
   * branches and stay gated by `active_branches` membership.
   */
  public function testBranchPortEdgeRespectsActiveBranchMembership(): void {
    $output = ['confirmed' => TRUE, 'active_branches' => 'true'];

    $activeBranch = $this->invokeIsEdgeBranchActive('confirmation.1', 'true', $output);
    $this->assertTrue($activeBranch, 'The active branch port must be reachable.');

    $inactiveBranch = $this->invokeIsEdgeBranchActive('confirmation.1', 'false', $output);
    $this->assertFalse($inactiveBranch, 'The inactive branch port must be gated off.');
  }

  /**
   * Invoke protected isEdgeBranchActive() against a mocked completed source.
   *
   * @param string $sourceNode
   *   The source node id.
   * @param string $branchName
   *   The edge's branch name (source output port name).
   * @param array<string, mixed> $outputData
   *   The completed source job's output payload.
   *
   * @return bool
   *   The gating decision.
   */
  protected function invokeIsEdgeBranchActive(string $sourceNode, string $branchName, array $outputData): bool {
    $edge = ExecutionEdge::fromMetadata([
      'source' => $sourceNode,
      'target' => 'downstream.1',
      'source_handle' => $sourceNode . '-output-' . $branchName,
      'target_handle' => 'downstream.1-input-value',
      'branch_name' => $branchName,
    ]);

    $job = $this->createMock(FlowDropJobInterface::class);
    $job->method('getNodeId')->willReturn($sourceNode);
    $job->method('getOutputData')->willReturn($outputData);
    $job->method('id')->willReturn(1);

    $reflection = new \ReflectionClass($this->jobGenerationService);
    $method = $reflection->getMethod('isEdgeBranchActive');
    $method->setAccessible(TRUE);

    return $method->invoke(
      $this->jobGenerationService,
      $edge,
      [$sourceNode => $job],
      [$sourceNode => 1],
    );
  }

  /**
   * Create a workflow whose nodes carry assorted retry-budget config.
   */
  protected function createWorkflowWithRetryConfig() {
    $workflow_storage = $this->container->get('entity_type.manager')->getStorage('flowdrop_workflow');

    $node = fn(string $id, array $config) => [
      'id' => $id,
      'type' => 'universalNode',
      'position' => ['x' => 0, 'y' => 0],
      'data' => [
        'label' => $id,
        'config' => $config,
        'metadata' => ['node_type_id' => 'process', 'name' => 'Process', 'inputs' => [], 'outputs' => []],
      ],
    ];

    $workflow = $workflow_storage->create([
      'id' => 'test_workflow_retry_config_' . uniqid(),
      'label' => 'Test Workflow With Retry Config',
      'nodes' => [
        $node('retryer', ['maxRetries' => 2]),
        $node('legacy', ['max_retries' => 5]),
        $node('plain', []),
        $node('negative', ['maxRetries' => -3]),
      ],
      'edges' => [],
    ]);

    $workflow->save();
    return $workflow;
  }

  /**
   * Create a workflow: a source node with an error edge and a success edge.
   *
   * The "boom" node fans out to "handler" via its error port and to "next" via
   * an ordinary data port.
   */
  protected function createWorkflowWithErrorEdge() {
    $workflow_storage = $this->container->get('entity_type.manager')->getStorage('flowdrop_workflow');

    $node = fn(string $id, string $label) => [
      'id' => $id,
      'type' => 'universalNode',
      'position' => ['x' => 0, 'y' => 0],
      'data' => [
        'label' => $label,
        'metadata' => ['node_type_id' => 'process', 'name' => 'Process', 'inputs' => [], 'outputs' => []],
      ],
    ];

    $workflow = $workflow_storage->create([
      'id' => 'test_workflow_error_edge_' . uniqid(),
      'label' => 'Test Workflow With Error Edge',
      'nodes' => [
        $node('boom', 'Boom'),
        $node('handler', 'Error Handler'),
        $node('next', 'Success Successor'),
      ],
      'edges' => [
        [
          'id' => 'edge_error',
          'source' => 'boom',
          'sourceHandle' => 'boom-output-error',
          'target' => 'handler',
          'targetHandle' => 'handler-input-input',
        ],
        [
          'id' => 'edge_data',
          'source' => 'boom',
          'sourceHandle' => 'boom-output-output',
          'target' => 'next',
          'targetHandle' => 'next-input-input',
        ],
      ],
    ]);

    $workflow->save();
    return $workflow;
  }

  /**
   * Create a workflow with two trigger nodes and a downstream node.
   */
  protected function createWorkflowWithTwoTriggers() {
    $workflow_storage = $this->container->get('entity_type.manager')->getStorage('flowdrop_workflow');

    $trigger = fn(string $id) => [
      'id' => $id,
      'type' => 'universalNode',
      'position' => ['x' => 0, 'y' => 0],
      'data' => [
        'label' => 'Trigger',
        'metadata' => [
          'node_type_id' => 'trigger',
          'executor_plugin' => 'flowdrop_trigger:trigger',
          'name' => 'Trigger',
          'inputs' => [],
          'outputs' => [],
        ],
      ],
    ];

    $workflow = $workflow_storage->create([
      'id' => 'test_workflow_triggers_' . uniqid(),
      'label' => 'Test Workflow With Triggers',
      'nodes' => [
        $trigger('trigger_a'),
        $trigger('trigger_b'),
        [
          'id' => 'downstream',
          'type' => 'universalNode',
          'position' => ['x' => 200, 'y' => 0],
          'data' => [
            'label' => 'Downstream',
            'metadata' => ['node_type_id' => 'process', 'name' => 'Process', 'inputs' => [], 'outputs' => []],
          ],
        ],
      ],
      'edges' => [
        ['id' => 'edge_a', 'source' => 'trigger_a', 'target' => 'downstream'],
        ['id' => 'edge_b', 'source' => 'trigger_b', 'target' => 'downstream'],
      ],
    ]);

    $workflow->save();
    return $workflow;
  }

  /**
   * Create a workflow containing a Note node and an orphan executable node.
   */
  protected function createWorkflowWithNote() {
    $workflow_storage = $this->container->get('entity_type.manager')->getStorage('flowdrop_workflow');

    $workflow = $workflow_storage->create([
      'id' => 'test_workflow_note_' . uniqid(),
      'label' => 'Test Workflow With Note',
      'nodes' => [
        [
          'id' => 'start1',
          'type' => 'universalNode',
          'position' => ['x' => 0, 'y' => 0],
          'data' => [
            'label' => 'Start Node',
            'metadata' => ['node_type_id' => 'process', 'name' => 'Process', 'inputs' => [], 'outputs' => []],
          ],
        ],
        [
          'id' => 'note1',
          'type' => 'universalNode',
          'position' => ['x' => 100, 'y' => 0],
          'data' => [
            'label' => 'Note',
            'metadata' => [
              'node_type_id' => 'note',
              'executor_plugin' => 'flowdrop_node_processor:note',
              'name' => 'Note',
              'inputs' => [],
              'outputs' => [],
            ],
          ],
        ],
        [
          'id' => 'orphan1',
          'type' => 'universalNode',
          'position' => ['x' => 200, 'y' => 0],
          'data' => [
            'label' => 'Orphan Node',
            'metadata' => ['node_type_id' => 'process', 'name' => 'Process', 'inputs' => [], 'outputs' => []],
          ],
        ],
      ],
      'edges' => [
        ['id' => 'edge1', 'source' => 'start1', 'target' => 'note1'],
      ],
    ]);

    $workflow->save();
    return $workflow;
  }

  /**
   * Create a mock workflow for testing.
   */
  protected function createMockWorkflow() {
    $workflow_storage = $this->container->get('entity_type.manager')->getStorage('flowdrop_workflow');

    $workflow = $workflow_storage->create([
      'id' => 'test_workflow_' . uniqid(),
      'label' => 'Test Workflow',
      'nodes' => [
        [
          'id' => 'input1',
          'type' => 'universalNode',
          'position' => ['x' => 0, 'y' => 0],
          'data' => [
            'label' => 'Input Node',
            'metadata' => [
              'node_type_id' => 'input',
              'name' => 'Input',
              'type' => 'default',
              'description' => 'Input node',
              'category' => 'input',
              'inputs' => [],
              'outputs' => [],
            ],
          ],
        ],
        [
          'id' => 'process1',
          'type' => 'universalNode',
          'position' => ['x' => 100, 'y' => 0],
          'data' => [
            'label' => 'Process Node',
            'metadata' => [
              'node_type_id' => 'process',
              'name' => 'Process',
              'type' => 'default',
              'description' => 'Process node',
              'category' => 'process',
              'inputs' => [],
              'outputs' => [],
            ],
          ],
        ],
        [
          'id' => 'output1',
          'type' => 'universalNode',
          'position' => ['x' => 200, 'y' => 0],
          'data' => [
            'label' => 'Output Node',
            'metadata' => [
              'node_type_id' => 'output',
              'name' => 'Output',
              'type' => 'default',
              'description' => 'Output node',
              'category' => 'output',
              'inputs' => [],
              'outputs' => [],
            ],
          ],
        ],
      ],
      'edges' => [
        [
          'id' => 'edge1',
          'source' => 'input1',
          'target' => 'process1',
        ],
        [
          'id' => 'edge2',
          'source' => 'process1',
          'target' => 'output1',
        ],
      ],
    ]);

    $workflow->save();
    return $workflow;
  }

  /**
   * Create a mock pipeline for testing.
   */
  protected function createMockPipeline($workflow, array $input_data = ['test' => 'data']) {
    $pipeline_storage = $this->container->get('entity_type.manager')->getStorage('flowdrop_pipeline');

    $pipeline = $pipeline_storage->create([
      'label' => 'Test Pipeline',
      'bundle' => 'default',
      'workflow_id' => ['target_id' => $workflow->id()],
      'input_data' => json_encode($input_data),
      'status' => 'pending',
    ]);

    $pipeline->save();
    return $pipeline;
  }

}
