<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_stategraph\Kernel;

use Drupal\Tests\flowdrop\Kernel\WorkflowExecutionTestBase;
use Drupal\flowdrop_job\Constants\JobStatus;
use Drupal\flowdrop_pipeline\Constants\PipelineStatus;

/**
 * Pins the execution semantics of branches, tool wiring, and loop bodies.
 *
 * Reproduces the agent-loop field report: the ready-loop used to execute the
 * whole pre-generated job set — tool-only nodes ran once as ordinary jobs
 * with empty args, gateway branch outputs wired to plain data ports gated
 * nothing (both branches executed), and completed nodes were re-cloned over
 * plain data edges so runs never terminated on the taken branch.
 *
 * Five fixtures, no LLM required:
 * - a tool-only node (only outgoing edge lands on a `-input-tool` port)
 *   must never become a job;
 * - a boolean_gateway's untaken branch must not execute even when its
 *   branch outputs feed plain data ports;
 * - a ForEach loop body must run exactly once per item (regression guard:
 *   loop-iteration re-dispatch must keep working);
 * - a loop clone's trigger edge is satisfied only by ITS OWN iteration's
 *   gateway job, never by the previous iteration's (field report #6b);
 * - the same iteration scoping applies to branch gating on plain data
 *   ports.
 *
 * @group flowdrop_stategraph
 */
class StateGraphBranchSemanticsTest extends WorkflowExecutionTestBase {

  /**
   * A node wired only through a tool port never becomes a job.
   *
   * Tool nodes execute inline via ScopedToolInvoker when their consumer
   * invokes them; running them as ordinary jobs executes them once with
   * empty args.
   */
  public function testToolOnlyNodeIsNeverJobbed(): void {
    $workflow = $this->createStateGraphWorkflow('sg_tool_only', [
      $this->loggerNode('consumer', 'consumer ran'),
      $this->loggerNode('tool_node', 'tool ran'),
    ], [
      [
        'id' => 'e1',
        'source' => 'tool_node',
        'target' => 'consumer',
        'sourceHandle' => 'tool_node-output-result',
        'targetHandle' => 'consumer-input-tool',
      ],
    ]);

    $result = $this->launchAndWait($workflow);
    $this->assertSame(PipelineStatus::COMPLETED, $result->status);

    $pipeline = $this->loadPipeline($result->pipelineId);
    $nodeIds = array_map(
      static fn ($job) => $job->getNodeId(),
      $pipeline->getJobs(),
    );
    $this->assertNotContains('tool_node', $nodeIds,
      'A tool-only node must not be scheduled as an ordinary job.');
    $this->assertContains('consumer', $nodeIds);
    $completedJobs = $pipeline->getJobsByStatus(JobStatus::COMPLETED);
    $this->assertCount(1, $completedJobs);

    // Field report #5: NodeRuntimeService used to stamp the success status
    // BEFORE Output::fromArray(), which replaces the DTO's data wholesale —
    // every successful node reported status "unknown" / isSuccess() FALSE
    // (and ScopedToolInvoker reported every successful tool invocation as a
    // failure). The stamp must survive into the stored output.
    $consumerJob = reset($completedJobs);
    $this->assertSame('success', $consumerJob->getOutputData()['status'] ?? NULL,
      'A successfully executed node must report status=success in its output.');
  }

  /**
   * An untaken gateway branch does not execute, even on plain data ports.
   *
   * Branch gating used to apply only to `-input-trigger` targets; branch
   * outputs wired to ordinary data ports executed both branches.
   */
  public function testUntakenBranchDoesNotExecuteThroughDataPorts(): void {
    // The gateway's `value` param is connectable-only and boolean-typed, so
    // the decision input must arrive over an edge — the logger's boolean
    // `logged` output (TRUE) routes the gateway to the True branch.
    $workflow = $this->createStateGraphWorkflow('sg_branch_gate', [
      $this->loggerNode('entry', 'go'),
      $this->gatewayNode('gateway'),
      $this->loggerNode('logger_true', 'took true'),
      $this->loggerNode('logger_false', 'took false'),
    ], [
      [
        'id' => 'e0',
        'source' => 'entry',
        'target' => 'gateway',
        'sourceHandle' => 'entry-output-logged',
        'targetHandle' => 'gateway-input-value',
      ],
      [
        'id' => 'e1',
        'source' => 'gateway',
        'target' => 'logger_true',
        'sourceHandle' => 'gateway-output-True',
        'targetHandle' => 'logger_true-input-message',
      ],
      [
        'id' => 'e2',
        'source' => 'gateway',
        'target' => 'logger_false',
        'sourceHandle' => 'gateway-output-False',
        'targetHandle' => 'logger_false-input-message',
      ],
    ]);

    $result = $this->launchAndWait($workflow);

    $pipeline = $this->loadPipeline($result->pipelineId);
    $trail = [];
    foreach ($pipeline->getJobs() as $job) {
      $trail[] = $job->getNodeId() . '=' . $job->getStatus()
        . ' err:' . $job->getErrorMessage()
        . ' out:' . json_encode($job->getOutputData());
    }
    $this->assertSame(PipelineStatus::COMPLETED, $result->status, implode(' | ', $trail));

    $this->assertSame(JobStatus::COMPLETED, $this->jobStatusForNode($pipeline, 'logger_true'));
    $this->assertSame(JobStatus::SKIPPED, $this->jobStatusForNode($pipeline, 'logger_false'),
      'The untaken branch must end SKIPPED, not executed.');
  }

  /**
   * A ForEach loop body runs exactly once per item, then the run completes.
   *
   * Regression guard for the re-dispatch restriction: loop-iteration
   * propagation through the loop body must keep working.
   */
  public function testIteratorLoopBodyRunsOncePerItem(): void {
    // ForEach's `items` param is connectable-only (configurable: false), so
    // the list must arrive over an edge — `repeat` produces a 2-item array.
    $workflow = $this->createStateGraphWorkflow('sg_loop_body', [
      $this->repeatNode('seed', 'item', 2),
      $this->forEachNode('fe'),
      $this->loggerNode('body', 'iteration'),
    ], [
      [
        'id' => 'e0',
        'source' => 'seed',
        'target' => 'fe',
        'sourceHandle' => 'seed-output-items',
        'targetHandle' => 'fe-input-items',
      ],
      [
        'id' => 'e1',
        'source' => 'fe',
        'target' => 'body',
        'sourceHandle' => 'fe-output-current_item',
        'targetHandle' => 'body-input-message',
      ],
      [
        'id' => 'e2',
        'source' => 'body',
        'target' => 'fe',
        'sourceHandle' => 'body-output-message',
        'targetHandle' => 'fe-input-loop_back',
      ],
    ]);

    $result = $this->launchAndWait($workflow);
    $this->assertSame(PipelineStatus::COMPLETED, $result->status);

    $pipeline = $this->loadPipeline($result->pipelineId);
    $bodyRuns = 0;
    foreach ($pipeline->getJobsByStatus(JobStatus::COMPLETED) as $job) {
      if ($job->getNodeId() === 'body') {
        $bodyRuns++;
      }
    }
    $this->assertSame(2, $bodyRuns, 'The loop body must run exactly once per item.');

    // Exactly seed + fe#1 + fe#2 + body#1 + body#2 — and nothing else:
    // loop iterations must not leave garbage clones behind.
    $this->assertCount(5, $pipeline->getJobs());
  }

  /**
   * A loop clone's trigger is gated by ITS OWN iteration's gateway.
   *
   * Field report #6b: `completed_jobs_map` held one job per node, so when
   * iteration N's gateway clone had not completed yet, a consumer clone's
   * trigger edge was satisfied by the iteration N−1 gateway job — the body
   * executed on the False iteration, its completion re-fired the loopback,
   * and the run died at max_iterations (PAUSED) with duplicate side effects.
   *
   * Deterministic agent-loop shape (no LLM): ForEach drives the cycle and
   * its boolean `has_more` output is the gateway decision. Iteration 1
   * (has_more=TRUE) takes the True branch through the body and loops back;
   * iteration 2 (has_more=FALSE) must take ONLY the False branch to the
   * terminal node — the stale iteration-1 True decision must not run the
   * body clone.
   */
  public function testLoopCloneTriggerNotSatisfiedByPreviousIterationGateway(): void {
    $workflow = $this->createStateGraphWorkflow('sg_iteration_gate', [
      $this->repeatNode('seed', 'item', 2),
      $this->forEachNode('fe'),
      $this->gatewayNode('gateway'),
      $this->loggerNode('body', 'loop body'),
      $this->loggerNode('exit', 'done'),
    ], [
      [
        'id' => 'e0',
        'source' => 'seed',
        'target' => 'fe',
        'sourceHandle' => 'seed-output-items',
        'targetHandle' => 'fe-input-items',
      ],
      [
        'id' => 'e1',
        'source' => 'fe',
        'target' => 'gateway',
        'sourceHandle' => 'fe-output-has_more',
        'targetHandle' => 'gateway-input-value',
      ],
      // The body mirrors the agent-loop `invoke` node: a data input from
      // the cycle driver plus a True trigger from the gateway.
      [
        'id' => 'e2',
        'source' => 'fe',
        'target' => 'body',
        'sourceHandle' => 'fe-output-current_item',
        'targetHandle' => 'body-input-message',
      ],
      [
        'id' => 'e3',
        'source' => 'gateway',
        'target' => 'body',
        'sourceHandle' => 'gateway-output-True',
        'targetHandle' => 'body-input-trigger',
      ],
      [
        'id' => 'e4',
        'source' => 'gateway',
        'target' => 'exit',
        'sourceHandle' => 'gateway-output-False',
        'targetHandle' => 'exit-input-trigger',
      ],
      [
        'id' => 'e5',
        'source' => 'body',
        'target' => 'fe',
        'sourceHandle' => 'body-output-message',
        'targetHandle' => 'fe-input-loop_back',
      ],
    ]);

    $result = $this->launchAndWait($workflow);

    $pipeline = $this->loadPipeline($result->pipelineId);
    $trail = [];
    foreach ($pipeline->getJobs() as $job) {
      $trail[] = $job->getNodeId() . '=' . $job->getStatus()
        . ' out:' . json_encode($job->getOutputData());
    }
    $this->assertSame(PipelineStatus::COMPLETED, $result->status,
      'The run must COMPLETE on the False branch, not die at max_iterations: '
      . implode(' | ', $trail));

    $bodyRuns = 0;
    $exitRuns = 0;
    foreach ($pipeline->getJobsByStatus(JobStatus::COMPLETED) as $job) {
      if ($job->getNodeId() === 'body') {
        $bodyRuns++;
      }
      if ($job->getNodeId() === 'exit') {
        $exitRuns++;
      }
    }
    $this->assertSame(1, $bodyRuns,
      'The body must run exactly once (True iteration only) — a stale '
      . 'iteration-1 gateway decision must not execute the iteration-2 clone.');
    $this->assertSame(1, $exitRuns, 'The terminal False branch must execute.');
  }

  /**
   * Branch gating on plain DATA ports is also iteration-scoped.
   *
   * Same cycle as the trigger fixture, but the gateway's True branch lands
   * on an ordinary data port (`isEdgeBranchActive` path instead of trigger
   * satisfaction). The body keeps its direct data edge from the cycle
   * driver — that is what creates the iteration-2 body clone BEFORE the
   * iteration-2 gateway completes, the precondition for the leak: without
   * iteration scoping, the stale iteration-1 `active_branches` satisfies
   * the clone's gateway port and the body runs on the False iteration.
   */
  public function testLoopCloneDataPortNotActivatedByPreviousIterationGateway(): void {
    $workflow = $this->createStateGraphWorkflow('sg_iteration_gate_data', [
      $this->repeatNode('seed', 'item', 2),
      $this->forEachNode('fe'),
      $this->gatewayNode('gateway'),
      $this->loggerNode('body', 'loop body'),
      $this->loggerNode('exit', 'done'),
    ], [
      [
        'id' => 'e0',
        'source' => 'seed',
        'target' => 'fe',
        'sourceHandle' => 'seed-output-items',
        'targetHandle' => 'fe-input-items',
      ],
      [
        'id' => 'e1',
        'source' => 'fe',
        'target' => 'gateway',
        'sourceHandle' => 'fe-output-has_more',
        'targetHandle' => 'gateway-input-value',
      ],
      [
        'id' => 'e2',
        'source' => 'fe',
        'target' => 'body',
        'sourceHandle' => 'fe-output-current_item',
        'targetHandle' => 'body-input-message',
      ],
      // Distinct target port: AND-across-ports makes the gateway branch a
      // genuine data dependency of the body (same port would OR with e2).
      [
        'id' => 'e3',
        'source' => 'gateway',
        'target' => 'body',
        'sourceHandle' => 'gateway-output-True',
        'targetHandle' => 'body-input-level',
      ],
      [
        'id' => 'e4',
        'source' => 'gateway',
        'target' => 'exit',
        'sourceHandle' => 'gateway-output-False',
        'targetHandle' => 'exit-input-message',
      ],
      [
        'id' => 'e5',
        'source' => 'body',
        'target' => 'fe',
        'sourceHandle' => 'body-output-message',
        'targetHandle' => 'fe-input-loop_back',
      ],
    ]);

    $result = $this->launchAndWait($workflow);

    $pipeline = $this->loadPipeline($result->pipelineId);
    $this->assertSame(PipelineStatus::COMPLETED, $result->status,
      'The run must COMPLETE on the False branch, not die at max_iterations.');

    $bodyRuns = 0;
    foreach ($pipeline->getJobsByStatus(JobStatus::COMPLETED) as $job) {
      if ($job->getNodeId() === 'body') {
        $bodyRuns++;
      }
    }
    $this->assertSame(1, $bodyRuns,
      'The body must run exactly once — a stale iteration-1 branch decision '
      . 'must not activate the iteration-2 clone through a data port.');
  }

}
