<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_interrupt\Kernel;

use Drupal\flowdrop_interrupt\Entity\FlowDropInterruptInterface;
use Drupal\flowdrop_interrupt\Enum\InterruptStatus;
use Drupal\flowdrop_interrupt\Enum\InterruptType;
use Drupal\flowdrop_interrupt\Service\InterruptManagerInterface;
use Drupal\flowdrop_pipeline\Constants\PipelineStatus;
use Drupal\Tests\flowdrop\Kernel\WorkflowExecutionTestBase;

/**
 * Pins the Delay node's pause → cron sweep → resume chain end-to-end.
 *
 * A Delay node persists a pending Timer interrupt carrying `scheduled_at`
 * and pauses the pipeline. The ScheduledInterruptSweeper (cron) resolves
 * the interrupt once due — system resolution, NULL uid — and the standard
 * InterruptResolvedSubscriber chain resumes the pipeline. Resolving the
 * interrupt early by hand (inbox / resume API path) skips the wait.
 *
 * Also pins the design invariant that made `scheduled_at` a dedicated
 * field: timers carry NO expiry (resolveInterrupt() refuses expired
 * interrupts, and a due timer is by definition past its fire time), so the
 * expiry sweep must never touch them.
 *
 * @group flowdrop_interrupt
 */
class DelayPauseSweepResumeTest extends WorkflowExecutionTestBase {

  /**
   * A due timer is resolved by the sweep and the pipeline completes.
   */
  public function testDelayPausesAndSweepResumes(): void {
    $workflow = $this->createStateGraphWorkflow('delay_sweep', [
      $this->delayNode('wait', 1, 'hours'),
      $this->loggerNode('sink', 'resumed'),
    ], [
      $this->edge('wait', 'trigger', 'sink', 'trigger'),
    ]);

    $result = $this->launchAndWait($workflow);
    $pipeline = $this->loadPipeline($result->pipelineId);
    $this->assertSame(PipelineStatus::PAUSED, $pipeline->getStatus(), $this->jobTrail($pipeline));

    $interrupt = $this->loadSingleTimerInterrupt();
    $this->assertSame(InterruptStatus::Pending, $interrupt->getStatus());
    $this->assertEqualsWithDelta(time() + 3600, $interrupt->getScheduledAt(), 5);
    $this->assertNull($interrupt->getExpires(), 'Timers must carry no expiry — the sweep is their lifecycle.');

    // The expiry sweep must never touch a pending timer.
    $this->interruptManager()->expireOverdueInterrupts();
    $this->assertSame(InterruptStatus::Pending, $this->loadSingleTimerInterrupt()->getStatus());

    // Not yet due: the sweep is a no-op.
    $sweeper = $this->container->get('flowdrop_interrupt.scheduled_sweeper');
    $this->assertSame(0, $sweeper->sweep());
    $this->assertSame(InterruptStatus::Pending, $this->loadSingleTimerInterrupt()->getStatus());

    // Backdate the fire time — the next sweep resolves and resumes.
    $interrupt = $this->loadSingleTimerInterrupt();
    $interrupt->setScheduledAt(time() - 10);
    $interrupt->save();

    $this->assertSame(1, $sweeper->sweep());

    $interrupt = $this->loadSingleTimerInterrupt();
    $this->assertSame(InterruptStatus::Resolved, $interrupt->getStatus());
    $this->assertNull($interrupt->getResolvedBy(), 'Sweep resolution must read as system (NULL uid).');

    $pipeline = $this->loadPipeline($result->pipelineId);
    $this->assertSame(PipelineStatus::COMPLETED, $pipeline->getStatus(), $this->jobTrail($pipeline));

    $output = $this->jobOutputForNode($pipeline, 'wait');
    $this->assertTrue($output['resolved_by_timer']);
    $this->assertNull($output['user_id']);
    $this->assertIsInt($output['drift_seconds']);
  }

  /**
   * Resolving the timer by hand before it is due skips the wait.
   */
  public function testEarlyManualResolveSkipsTheWait(): void {
    $workflow = $this->createStateGraphWorkflow('delay_skip', [
      $this->delayNode('wait', 1, 'days'),
      $this->loggerNode('sink', 'resumed'),
    ], [
      $this->edge('wait', 'trigger', 'sink', 'trigger'),
    ]);

    $result = $this->launchAndWait($workflow);
    $this->assertSame(PipelineStatus::PAUSED, $this->loadPipeline($result->pipelineId)->getStatus());

    $interrupt = $this->loadSingleTimerInterrupt();
    // The handler's skip payload, as the inbox / resume API would write it.
    $this->interruptManager()->resolveInterrupt((string) $interrupt->uuid(), [
      'resolved_by_timer' => FALSE,
      'skipped' => TRUE,
      'resumed_at' => time(),
      'scheduled_at' => $interrupt->getScheduledAt(),
    ], 42);

    $pipeline = $this->loadPipeline($result->pipelineId);
    $this->assertSame(PipelineStatus::COMPLETED, $pipeline->getStatus(), $this->jobTrail($pipeline));

    $output = $this->jobOutputForNode($pipeline, 'wait');
    $this->assertFalse($output['resolved_by_timer']);
    $this->assertSame(42, $output['user_id']);
    $this->assertLessThan(0, $output['drift_seconds'], 'Skipping early must report negative drift.');
  }

  /**
   * A zero delay passes straight through — no interrupt, no pause.
   */
  public function testZeroDelayPassesThrough(): void {
    $workflow = $this->createStateGraphWorkflow('delay_zero', [
      $this->delayNode('wait', 0, 'seconds'),
      $this->loggerNode('sink', 'resumed'),
    ], [
      $this->edge('wait', 'trigger', 'sink', 'trigger'),
    ]);

    $result = $this->launchAndWait($workflow);
    $pipeline = $this->loadPipeline($result->pipelineId);
    $this->assertSame(PipelineStatus::COMPLETED, $pipeline->getStatus(), $this->jobTrail($pipeline));
    $this->assertSame([], $this->loadTimerInterrupts(), 'No interrupt row for an already-due delay.');

    $output = $this->jobOutputForNode($pipeline, 'wait');
    $this->assertTrue($output['resolved_by_timer']);
  }

  /**
   * Builds a delay node definition.
   *
   * @param string $id
   *   The node id.
   * @param float $value
   *   The delay amount.
   * @param string $unit
   *   The delay unit.
   *
   * @return array<string, mixed>
   *   The node definition.
   */
  protected function delayNode(string $id, float $value, string $unit): array {
    return $this->node($id, 'delay', ['value' => $value, 'unit' => $unit], [
      'name' => 'Delay',
      'category' => 'timing',
    ]);
  }

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

  /**
   * Loads all Timer interrupts.
   *
   * @return \Drupal\flowdrop_interrupt\Entity\FlowDropInterruptInterface[]
   *   The timer interrupts.
   */
  protected function loadTimerInterrupts(): array {
    $storage = $this->container->get('entity_type.manager')->getStorage('flowdrop_interrupt');
    $ids = $storage->getQuery()
      ->accessCheck(FALSE)
      ->condition('type', InterruptType::Timer->value)
      ->execute();
    $storage->resetCache($ids);
    $timers = [];
    foreach (array_values($storage->loadMultiple($ids)) as $timer) {
      $this->assertInstanceOf(FlowDropInterruptInterface::class, $timer);
      $timers[] = $timer;
    }
    return $timers;
  }

  /**
   * Loads the single Timer interrupt the test expects to exist.
   */
  protected function loadSingleTimerInterrupt(): FlowDropInterruptInterface {
    $interrupts = $this->loadTimerInterrupts();
    $this->assertCount(1, $interrupts, 'Exactly one Timer interrupt must exist.');
    return $interrupts[0];
  }

}
