<?php

declare(strict_types=1);

namespace Drupal\flowdrop_interrupt\EventSubscriber;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\flowdrop_interrupt\Entity\FlowDropInterruptInterface;
use Drupal\flowdrop_interrupt\Event\InterruptResolvedEvent;
use Drupal\flowdrop_job\Constants\JobStatus;
use Drupal\flowdrop_job\FlowDropJobInterface;
use Drupal\flowdrop_pipeline\Constants\PipelineStatus;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface;
use Drupal\flowdrop_runtime\Service\Orchestrator\PipelineExecutorResolver;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Event subscriber for handling resolved interrupts.
 *
 * When an interrupt is resolved (user provides input), this subscriber:
 * 1. Finds the interrupted job by pipeline and node ID.
 * 2. Resets the job status from "interrupted" to "pending".
 * 3. Resumes the pipeline execution.
 *
 * Generic subscriber for any pipeline-based orchestrator.
 * Does NOT handle playground-specific logic (session status, messages).
 *
 * @internal This class is not part of the public API and may change without notice.
 */
class InterruptResolvedSubscriber implements EventSubscriberInterface {

  /**
   * Constructs an InterruptResolvedSubscriber.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   The entity type manager.
   * @param \Drupal\flowdrop_runtime\Service\Orchestrator\PipelineExecutorResolver $pipelineExecutorResolver
   *   Resolves the executor matching the pipeline's declared orchestrator.
   * @param \Psr\Log\LoggerInterface $logger
   *   The logger.
   */
  public function __construct(
    protected readonly EntityTypeManagerInterface $entityTypeManager,
    protected readonly PipelineExecutorResolver $pipelineExecutorResolver,
    protected readonly LoggerInterface $logger,
  ) {}

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents(): array {
    // Use a lower priority so playground-specific subscribers run first.
    // If a playground subscriber handles the event and needs special handling,
    // it can stop propagation.
    return [
      "flowdrop_interrupt.resolved" => ["onInterruptResolved", -10],
    ];
  }

  /**
   * Handles the interrupt resolved event.
   *
   * When an interrupt is resolved, this method:
   * 1. Finds the interrupted job by pipeline and node ID.
   * 2. Resets the job status from "interrupted" to "pending".
   * 3. Resumes the pipeline execution.
   *
   * @param \Drupal\flowdrop_interrupt\Event\InterruptResolvedEvent $event
   *   The event containing the resolved interrupt.
   */
  public function onInterruptResolved(InterruptResolvedEvent $event): void {
    $interrupt = $event->interrupt;

    if (!$interrupt instanceof FlowDropInterruptInterface) {
      return;
    }

    // Use effective pipeline ID so inward Pause signals (which carry the
    // pipeline in target_pipeline_id, not pipeline_id) resume correctly
    // when resolved through the inbox.
    $pipelineId = $interrupt->getEffectivePipelineId();
    $nodeId = $interrupt->getNodeId();
    $interruptId = $interrupt->uuid();

    // Skip if no pipeline ID (stateless orchestrator - can't resume).
    if ($pipelineId === NULL) {
      $this->logger->debug("Interrupt @id resolved but has no pipeline ID - skipping resume", [
        "@id" => $interruptId,
      ]);
      return;
    }

    // Load the pipeline.
    $pipeline = $this->loadPipeline($pipelineId);
    if ($pipeline === NULL) {
      $this->logger->warning("Interrupt @id resolved but pipeline @pipeline not found", [
        "@id" => $interruptId,
        "@pipeline" => $pipelineId,
      ]);
      return;
    }

    // Find and reset the interrupted job. Phase 2: prefer the O(1)
    // job_id lookup when the interrupt carries it; fall back to the legacy
    // pipeline-scan for rows that predate Phase 1's job_id stamping.
    $job = $this->findJobByDirectId($interrupt->getJobId(), $interruptId)
      ?? $this->findAndResetInterruptedJob($pipeline, $nodeId, $interruptId);
    if ($job === NULL) {
      $this->logger->warning("No interrupted job found for node @node in pipeline @pipeline", [
        "@node" => $nodeId,
        "@pipeline" => $pipelineId,
      ]);
      // Continue anyway - the job might have been manually reset or
      // this is a duplicate event.
    }

    // Only resume if pipeline is paused.
    if ($pipeline->getStatus() !== PipelineStatus::PAUSED) {
      $this->logger->info("Pipeline @pipeline is not paused (status: @status), skipping resume", [
        "@pipeline" => $pipelineId,
        "@status" => $pipeline->getStatus(),
      ]);
      return;
    }

    // Resume the pipeline execution.
    try {
      $this->logger->info("Resuming pipeline @pipeline after interrupt @interrupt resolved", [
        "@pipeline" => $pipelineId,
        "@interrupt" => $interruptId,
      ]);

      // Resume under the orchestrator the pipeline's workflow declared —
      // a stategraph pipeline must not resume through the plain sync loop.
      $response = $this->pipelineExecutorResolver
        ->getExecutorForPipeline($pipeline)
        ->executePipeline($pipeline);

      $this->logger->info("Pipeline @pipeline resumed with status: @status", [
        "@pipeline" => $pipelineId,
        "@status" => $response->getStatus(),
      ]);
    }
    catch (\Exception $e) {
      $this->logger->error("Failed to resume pipeline @pipeline: @message", [
        "@pipeline" => $pipelineId,
        "@message" => $e->getMessage(),
      ]);
    }
  }

  /**
   * Loads a pipeline by ID.
   *
   * @param string $pipeline_id
   *   The pipeline ID (entity ID or execution ID).
   *
   * @return \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface|null
   *   The pipeline entity, or NULL if not found.
   */
  protected function loadPipeline(string $pipeline_id): ?FlowDropPipelineInterface {
    $pipelineStorage = $this->entityTypeManager->getStorage("flowdrop_pipeline");

    // First try loading by entity ID.
    $pipeline = $pipelineStorage->load($pipeline_id);
    if ($pipeline instanceof FlowDropPipelineInterface) {
      return $pipeline;
    }

    // Try loading by UUID.
    $entities = $pipelineStorage->loadByProperties(["uuid" => $pipeline_id]);
    $pipeline = reset($entities);

    return $pipeline instanceof FlowDropPipelineInterface ? $pipeline : NULL;
  }

  /**
   * Loads and resets a job by its entity ID. O(1).
   *
   * Used when the interrupt entity has `job_id` stamped (Phase 1 onward).
   * Returns NULL if the job doesn't exist, isn't interrupted, or doesn't
   * match the expected interrupt UUID — the caller falls back to the
   * legacy pipeline-scan in those cases.
   *
   * @param int|null $jobId
   *   The job entity ID, or NULL to skip the fast path entirely.
   * @param string $interruptId
   *   The expected interrupt UUID for cross-checking.
   *
   * @return \Drupal\flowdrop_job\FlowDropJobInterface|null
   *   The job that was reset, or NULL when the fast path can't be used.
   */
  protected function findJobByDirectId(?int $jobId, string $interruptId): ?FlowDropJobInterface {
    if ($jobId === NULL) {
      return NULL;
    }
    $job = $this->entityTypeManager->getStorage('flowdrop_job')->load($jobId);
    if (!$job instanceof FlowDropJobInterface) {
      return NULL;
    }
    if ($job->getStatus() !== JobStatus::INTERRUPTED) {
      return NULL;
    }
    $metadata = $job->getMetadata();
    $jobInterruptId = $metadata['interrupt_id'] ?? NULL;
    if ($jobInterruptId !== NULL && $jobInterruptId !== $interruptId) {
      $this->logger->debug('Job @job has different interrupt ID via job_id lookup (@stored vs @current)', [
        '@job' => $job->id(),
        '@stored' => $jobInterruptId,
        '@current' => $interruptId,
      ]);
      return NULL;
    }
    $this->logger->info('Resetting interrupted job @job_id via O(1) job_id lookup (interrupt_id: @interrupt_id)', [
      '@job_id' => $job->id(),
      '@interrupt_id' => $interruptId,
    ]);
    unset($metadata['interrupt_message']);
    $job->setMetadata($metadata);
    $job->markAsPending();
    $job->save();
    return $job;
  }

  /**
   * Finds the interrupted job by scanning the pipeline's jobs.
   *
   * Legacy O(jobs-in-pipeline) path retained for interrupt rows without
   * a stamped job_id (pre-Phase-1 rows that haven't been backfilled).
   */
  protected function findAndResetInterruptedJob(
    FlowDropPipelineInterface $pipeline,
    string $nodeId,
    string $interruptId,
  ): ?FlowDropJobInterface {
    $jobs = $pipeline->getJobs();

    foreach ($jobs as $job) {
      // Check if this job matches the interrupted node.
      if ($job->getNodeId() !== $nodeId) {
        continue;
      }

      if ($job->getStatus() !== JobStatus::INTERRUPTED) {
        continue;
      }

      // Verify this job was waiting for this specific interrupt.
      $metadata = $job->getMetadata();
      $jobInterruptId = $metadata["interrupt_id"] ?? NULL;

      // Match if interrupt IDs match, or if no interrupt ID was stored
      // (backward compatibility with jobs interrupted before this fix).
      if ($jobInterruptId !== NULL && $jobInterruptId !== $interruptId) {
        $this->logger->debug("Job @job has different interrupt ID (@stored vs @current)", [
          "@job" => $job->id(),
          "@stored" => $jobInterruptId,
          "@current" => $interruptId,
        ]);
        continue;
      }

      // Found the job - reset it to pending.
      // IMPORTANT: Keep interrupt_id in metadata so the orchestrator can inject
      // it as __interrupt_id__ when the job re-executes. This allows the node
      // processor to find the resolved interrupt instead of creating a new one.
      $this->logger->info("Resetting interrupted job @job_id to pending for resume (interrupt_id: @interrupt_id)", [
        "@job_id" => $job->id(),
        "@interrupt_id" => $interruptId,
      ]);

      // Clear only the interrupt_message, keep interrupt_id for re-execution.
      unset($metadata["interrupt_message"]);
      $job->setMetadata($metadata);
      $job->markAsPending();
      $job->save();

      return $job;
    }

    return NULL;
  }

}
