<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_interrupt\Kernel;

use Drupal\flowdrop_workflow\WorkflowDefinitionInterface;
use Drupal\Tests\flowdrop\Kernel\WorkflowExecutionTestBase;
use Drupal\flowdrop_interrupt\Entity\FlowDropInterruptInterface;
use Drupal\flowdrop_interrupt\Service\InterruptManagerInterface;
use Drupal\flowdrop_job\Constants\JobStatus;
use Drupal\flowdrop_pipeline\Constants\PipelineStatus;

/**
 * Pins interrupt resolutions to their own pipeline and job.
 *
 * Companion to {@see
 * \Drupal\Tests\flowdrop_stategraph\Kernel\StateGraphInterruptScopingTest},
 * which pins interrupt identity across loop iterations WITHIN one run.
 * This class pins the boundary BETWEEN runs: two pipelines of the same
 * workflow paused on the same confirmation node must hold independent
 * interrupts, and resolving one must never resume, approve, or otherwise
 * touch the other. An approval is consent for one specific question in
 * one specific run — never a template that unlocks look-alike questions
 * elsewhere.
 *
 * Also pins resolution replay protection: a resolved interrupt cannot be
 * resolved again (the HITL approval replay class).
 *
 * @group flowdrop_interrupt
 */
class InterruptCrossPipelineScopingTest extends WorkflowExecutionTestBase {

  /**
   * Builds the HITL fixture: source → confirmation, True-triggered body.
   */
  protected function createConfirmationWorkflow(string $id): WorkflowDefinitionInterface {
    return $this->createStateGraphWorkflow($id, [
      $this->loggerNode('src', 'ready'),
      $this->confirmationNode('confirm', 'Proceed with this run?'),
      $this->loggerNode('body', 'approved work'),
    ], [
      $this->edge('src', 'message', 'confirm', 'message'),
      $this->edge('confirm', 'True', 'body', 'trigger'),
    ], [
      // Resolution happens "later" (a separate request in production):
      // resume must restore from persistent checkpoints.
      'stategraph' => [
        'checkpointer_type' => 'entity',
        'create_checkpoints' => TRUE,
      ],
    ]);
  }

  /**
   * Resolving one pipeline's interrupt must not resume a sibling run.
   */
  public function testResolutionOnlyResumesItsOwnPipeline(): void {
    $workflow = $this->createConfirmationWorkflow('interrupt_cross_pipeline');

    // Two runs of the same workflow, both paused on the same question.
    $runA = $this->launchAndWait($workflow);
    $runB = $this->launchAndWait($workflow);
    $this->assertSame(PipelineStatus::PAUSED, $runA->status,
      $this->jobTrail($this->loadPipeline($runA->pipelineId)));
    $this->assertSame(PipelineStatus::PAUSED, $runB->status,
      $this->jobTrail($this->loadPipeline($runB->pipelineId)));

    $interruptA = $this->soleInterruptForPipeline($runA->pipelineId);
    $interruptB = $this->soleInterruptForPipeline($runB->pipelineId);

    // Each interrupt is bound to its own run's confirmation job — the
    // ownership the resume path enforces at consumption.
    $this->assertNotSame($interruptA->getJobId(), $interruptB->getJobId(),
      'Sibling runs must not share an interrupt-owning job.');

    // Approve run A only.
    $this->interruptManager()->resolveInterrupt((string) $interruptA->uuid(), TRUE);

    $pipelineA = $this->loadPipeline($runA->pipelineId);
    $this->assertSame(PipelineStatus::COMPLETED, $pipelineA->getStatus(),
      'Run A completes on its own approval: ' . $this->jobTrail($pipelineA));
    $this->assertSame(JobStatus::COMPLETED, $this->jobStatusForNode($pipelineA, 'body'));

    // Run B is untouched: still paused, its interrupt still pending, its
    // gated body never executed.
    $pipelineB = $this->loadPipeline($runB->pipelineId);
    $this->assertSame(PipelineStatus::PAUSED, $pipelineB->getStatus(),
      'Run B must stay paused on its own question: ' . $this->jobTrail($pipelineB));
    $interruptB = $this->reloadInterrupt($interruptB);
    $this->assertNull($interruptB->getResolvedAt(),
      'Run B\'s interrupt must remain pending after run A\'s approval.');
    $this->assertNotSame(JobStatus::COMPLETED, $this->jobStatusForNode($pipelineB, 'body'),
      'Run B\'s gated body must not execute on run A\'s approval.');

    // Run B completes only on its OWN approval.
    $this->interruptManager()->resolveInterrupt((string) $interruptB->uuid(), TRUE);
    $pipelineB = $this->loadPipeline($runB->pipelineId);
    $this->assertSame(PipelineStatus::COMPLETED, $pipelineB->getStatus(), $this->jobTrail($pipelineB));
    $this->assertSame(JobStatus::COMPLETED, $this->jobStatusForNode($pipelineB, 'body'));
  }

  /**
   * A denial in one run must not leak into a sibling's approval.
   */
  public function testDenialDoesNotLeakIntoSiblingRun(): void {
    $workflow = $this->createConfirmationWorkflow('interrupt_cross_denial');

    $runA = $this->launchAndWait($workflow);
    $runB = $this->launchAndWait($workflow);

    $interruptA = $this->soleInterruptForPipeline($runA->pipelineId);
    $interruptB = $this->soleInterruptForPipeline($runB->pipelineId);

    // Deny run A: its gated body must not run.
    $this->interruptManager()->resolveInterrupt((string) $interruptA->uuid(), FALSE);
    $pipelineA = $this->loadPipeline($runA->pipelineId);
    $this->assertNotSame(JobStatus::COMPLETED, $this->jobStatusForNode($pipelineA, 'body'),
      'A denied confirmation must not execute the gated body: ' . $this->jobTrail($pipelineA));

    // Approve run B: the sibling denial must not block or pre-answer it.
    $this->interruptManager()->resolveInterrupt((string) $interruptB->uuid(), TRUE);
    $pipelineB = $this->loadPipeline($runB->pipelineId);
    $this->assertSame(PipelineStatus::COMPLETED, $pipelineB->getStatus(), $this->jobTrail($pipelineB));
    $this->assertSame(JobStatus::COMPLETED, $this->jobStatusForNode($pipelineB, 'body'),
      'Run B\'s own approval must execute its body despite run A\'s denial.');
  }

  /**
   * A resolved interrupt cannot be resolved again (approval replay).
   */
  public function testResolvedInterruptRefusesReplay(): void {
    $workflow = $this->createConfirmationWorkflow('interrupt_replay');

    $run = $this->launchAndWait($workflow);
    $interrupt = $this->soleInterruptForPipeline($run->pipelineId);

    $this->interruptManager()->resolveInterrupt((string) $interrupt->uuid(), TRUE);

    $this->expectException(\InvalidArgumentException::class);
    $this->expectExceptionMessage('not in a resolvable state');
    $this->interruptManager()->resolveInterrupt((string) $interrupt->uuid(), TRUE);
  }

  /**
   * Loads the single interrupt of a pipeline, asserting there is one.
   */
  protected function soleInterruptForPipeline(string $pipelineId): FlowDropInterruptInterface {
    $storage = $this->container->get('entity_type.manager')
      ->getStorage('flowdrop_interrupt');
    $storage->resetCache();
    $interrupts = $storage->loadByProperties(['pipeline_id' => $pipelineId]);
    $this->assertCount(1, $interrupts,
      sprintf('Pipeline %s must hold exactly one interrupt.', $pipelineId));
    $interrupt = reset($interrupts);
    $this->assertInstanceOf(FlowDropInterruptInterface::class, $interrupt);
    return $interrupt;
  }

  /**
   * Reloads an interrupt fresh from storage.
   */
  protected function reloadInterrupt(FlowDropInterruptInterface $interrupt): FlowDropInterruptInterface {
    $storage = $this->container->get('entity_type.manager')
      ->getStorage('flowdrop_interrupt');
    $storage->resetCache([$interrupt->id()]);
    $reloaded = $storage->load($interrupt->id());
    $this->assertInstanceOf(FlowDropInterruptInterface::class, $reloaded);
    return $reloaded;
  }

  /**
   * Returns the interrupt manager.
   */
  protected function interruptManager(): InterruptManagerInterface {
    return $this->container->get('flowdrop_interrupt.manager');
  }

}
