<?php

declare(strict_types=1);

namespace Drupal\flowdrop_runtime\Service\Orchestrator;

use Drupal\flowdrop\Exception\WorkflowStopException;
use Drupal\flowdrop_pipeline\Constants\PausedReason;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface;
use Drupal\flowdrop_orchestration\DTO\Orchestrator\OrchestrationRequest;
use Drupal\flowdrop_orchestration\DTO\Orchestrator\OrchestrationResponse;
use Drupal\flowdrop_orchestration\DTO\Snapshot\WorkflowSnapshot;
use Drupal\flowdrop_orchestration\Exception\InterruptExceptionInterface;
use Drupal\flowdrop_orchestration\Service\Orchestrator\OrchestratorHelperInterface;
use Drupal\flowdrop_runtime\Event\PipelineCompletedEvent;
use Drupal\flowdrop_runtime\Exception\OrchestrationException;

/**
 * Synchronous pipeline-based workflow orchestrator.
 *
 * This orchestrator executes workflows by creating a pipeline entity,
 * generating jobs, and executing them synchronously. This approach provides
 * better job tracking, persistence, and management compared to direct workflow
 * execution.
 *
 * @internal This class is not part of the public API and may change without notice.
 *
 * @see \Drupal\flowdrop_runtime\Service\Orchestrator\AbstractOrchestratorBase
 */
class SynchronousPipelineOrchestrator extends AbstractOrchestratorBase implements PipelineExecutorInterface {

  /**
   * {@inheritdoc}
   */
  public function getType(): string {
    return OrchestratorHelperInterface::SYNCHRONOUS_PIPELINE;
  }

  /**
   * {@inheritdoc}
   */
  public function orchestrate(OrchestrationRequest $request): OrchestrationResponse {
    $workflowData = $request->getWorkflow();
    $executionId = $this->generateExecutionId();

    try {
      // Create pipeline from workflow using trait.
      $pipeline = $this->createPipelineFromWorkflow(
        $workflowData,
        $request->getInitialData(),
        $request->getOptions()
      );

      if ($pipeline === NULL) {
        throw new OrchestrationException("Failed to create pipeline from workflow");
      }

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

      // Reload the pipeline to ensure fresh job references after generation.
      $pipeline = $this->reloadPipeline($pipeline);

      // Verify jobs are available after reload.
      $jobs = $pipeline->getJobs();
      $this->logger->info("Pipeline @pipeline_id reloaded with @job_count jobs for immediate synchronous execution", [
        "@pipeline_id" => $pipeline->id(),
        "@job_count" => count($jobs),
        "@execution_id" => $executionId,
      ]);

      // Execute the pipeline immediately (synchronous execution).
      return $this->executePipeline($pipeline);
    }
    catch (InterruptExceptionInterface $e) {
      // Use the InterruptHandlingTrait for standardized interrupt handling.
      return $this->handleInterruptException(
        $e,
        $executionId,
        $request->getWorkflowId(),
        microtime(TRUE),
        [],
        ["pipeline_id" => $pipeline->id() ?? ""],
        $this->resolvePersistedInterrupt($e, $pipeline),
      );
    }
    catch (\Exception $e) {
      $this->logger->error("Synchronous pipeline orchestration failed: @message", [
        "@message" => $e->getMessage(),
        "@execution_id" => $executionId,
      ]);

      throw new OrchestrationException(
        "Synchronous pipeline orchestration failed: " . $e->getMessage(),
        0,
        $e
      );
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getCapabilities(): array {
    return [
      "synchronous_execution" => TRUE,
      "parallel_execution" => FALSE,
      "async_execution" => FALSE,
      "retry_support" => TRUE,
      "error_recovery" => TRUE,
      "pipeline_based" => TRUE,
      "job_tracking" => TRUE,
      "interrupt_support" => TRUE,
    ];
  }

  /**
   * Executes a pipeline by running all ready jobs synchronously.
   *
   * This method can be called directly for pipeline execution without
   * going through the orchestrate() method.
   *
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface $pipeline
   *   The pipeline to execute.
   * @param int|null $maxIterations
   *   Maximum number of iterations before pausing (default: 100).
   *   NULL means no limit.
   * @param float|null $maxExecutionTime
   *   Maximum execution time in seconds before pausing
   *   (default: NULL = no limit).
   * @param string|null $executionId
   *   Optional execution ID; generated automatically if not provided.
   *
   * @return \Drupal\flowdrop_orchestration\DTO\Orchestrator\OrchestrationResponse
   *   The orchestration response.
   *
   * @throws \Drupal\flowdrop_runtime\Exception\OrchestrationException
   *   When execution fails.
   */
  public function executePipeline(
    FlowDropPipelineRuntimeInterface $pipeline,
    ?int $maxIterations = 100,
    ?float $maxExecutionTime = NULL,
    ?string $executionId = NULL,
  ): OrchestrationResponse {
    $executionId = $executionId ?? (string) $pipeline->id();
    $startTime = microtime(TRUE);

    $this->logger->info("Starting synchronous pipeline execution for pipeline @pipeline_id", [
      "@pipeline_id" => $pipeline->id(),
      "@execution_id" => $executionId,
    ]);

    try {
      // Ensure pipeline is ready for execution.
      if ($pipeline->isReadyToStart() || $pipeline->isPaused()) {
        // Re-entry clears a stale budget-pause reason from the previous
        // run segment — otherwise a resume that completes (or fails)
        // would still report the old reason on the status endpoint.
        $context = $pipeline->getExecutionContext();
        if (isset($context[PausedReason::CONTEXT_KEY])) {
          unset($context[PausedReason::CONTEXT_KEY]);
          $pipeline->setExecutionContext($context);
        }
        $pipeline->markAsStarted();
        $this->getPipelineStorage()->save($pipeline);
      }
      elseif (!$pipeline->isRunning()) {
        $this->logger->warning("Pipeline @pipeline_id is not in expected state for execution (status: @status). Attempting to start anyway.", [
          "@pipeline_id" => $pipeline->id(),
          "@status" => $pipeline->getStatus(),
          "@execution_id" => $executionId,
        ]);
        $pipeline->markAsStarted();
        $this->getPipelineStorage()->save($pipeline);
      }

      $results = [];
      $executedJobs = [];
      $iteration = 0;
      $pauseReason = NULL;
      $stopRequested = FALSE;

      // Keep executing ready jobs until none are left or limits reached.
      while (TRUE) {
        // Poll for inward Cancel/Pause signals before each iteration. The
        // flowdrop_interrupt subscriber answers via PipelineSignalPollEvent;
        // a non-null response means the pipeline was cancelled or paused
        // and we should stop here.
        $signalResponse = $this->pollPipelineSignal($pipeline, $executionId, $startTime);
        if ($signalResponse !== NULL) {
          return $signalResponse;
        }

        // Check iteration limit.
        if ($maxIterations !== NULL && $iteration >= $maxIterations) {
          $pauseReason = PausedReason::MAX_ITERATIONS;
          $this->logger->info("Reached max iterations (@max) for pipeline @pipeline_id", [
            "@max" => $maxIterations,
            "@pipeline_id" => $pipeline->id(),
            "@execution_id" => $executionId,
          ]);
          break;
        }

        // Check execution time limit.
        if ($maxExecutionTime !== NULL) {
          $elapsedTime = microtime(TRUE) - $startTime;
          if ($elapsedTime >= $maxExecutionTime) {
            $pauseReason = PausedReason::TIME_BUDGET;
            $this->logger->info("Reached max execution time (@time seconds) for pipeline @pipeline_id", [
              "@time" => round($maxExecutionTime, 2),
              "@pipeline_id" => $pipeline->id(),
              "@execution_id" => $executionId,
            ]);
            break;
          }
        }

        $readyJobs = $this->jobGenerationService->getReadyJobs($pipeline);

        if (empty($readyJobs)) {
          break;
        }

        $this->logger->info("Found @count ready jobs in iteration @iteration", [
          "@count" => count($readyJobs),
          "@iteration" => $iteration,
          "@execution_id" => $executionId,
        ]);

        // Execute all ready jobs in this iteration.
        foreach ($readyJobs as $job) {
          try {
            $jobResult = $this->executeJob($job, $pipeline, $executionId);
          }
          catch (WorkflowStopException $e) {
            // A terminal node ended the run cleanly. Record its payload and
            // stop scheduling; jobs already run in this batch are kept, the
            // rest of the batch and all downstream jobs are abandoned.
            $results[$job->getNodeId()] = $e->getResult();
            $executedJobs[] = $job->id();
            $stopRequested = TRUE;
            break;
          }
          $results[$job->getNodeId()] = $jobResult;
          $executedJobs[] = $job->id();
        }

        if ($stopRequested) {
          break;
        }

        $iteration++;
      }

      // Check final pipeline status.
      $executionTime = microtime(TRUE) - $startTime;
      $hasMoreWork = !empty($this->jobGenerationService->getReadyJobs($pipeline));

      // Only a budget pause with ready jobs remaining stamps a reason; a
      // HITL pause (interrupt exception path) deliberately carries none —
      // the reason is what gates the automatic Pause-signal creation.
      $stampedPauseReason = NULL;

      if ($this->hasUnhandledFailedJobs($pipeline)) {
        $pipeline->markAsFailed("Some jobs failed during execution");
        $status = WorkflowSnapshot::STATUS_FAILED;
        $this->markRemainingJobsAsSkipped($pipeline);
      }
      elseif ($hasMoreWork && !$stopRequested) {
        $pipeline->pause();
        $status = WorkflowSnapshot::STATUS_PAUSED;
        $stampedPauseReason = $pauseReason;
        $reason = [];
        if ($maxIterations !== NULL && $iteration >= $maxIterations) {
          $reason[] = "max iterations ({$maxIterations})";
        }
        if ($maxExecutionTime !== NULL && $executionTime >= $maxExecutionTime) {
          $reason[] = "max execution time (" . round($maxExecutionTime, 2) . "s)";
        }
        $this->logger->warning("Pipeline @pipeline_id paused after reaching @reason. Remaining ready jobs: @count", [
          "@pipeline_id" => $pipeline->id(),
          "@reason" => implode(" and ", $reason),
          "@count" => count($this->jobGenerationService->getReadyJobs($pipeline)),
          "@execution_id" => $executionId,
        ]);
      }
      else {
        $pipeline->markAsCompleted($results);
        $status = WorkflowSnapshot::STATUS_COMPLETED;
        $this->markRemainingJobsAsSkipped($pipeline);
      }

      // Store execution time in execution context.
      $executionTimeUs = (int) round($executionTime * 1000000);
      $context = $pipeline->getExecutionContext();
      $context["execution_time_us"] = $executionTimeUs;
      if ($stampedPauseReason !== NULL) {
        $context[PausedReason::CONTEXT_KEY] = $stampedPauseReason;
      }
      else {
        unset($context[PausedReason::CONTEXT_KEY]);
      }
      $pipeline->setExecutionContext($context);

      $this->getPipelineStorage()->save($pipeline);

      $this->logger->info("Synchronous pipeline execution completed for pipeline @pipeline_id in @time seconds", [
        "@pipeline_id" => $pipeline->id(),
        "@time" => round($executionTime, 3),
        "@status" => $status,
        "@executed_jobs" => count($executedJobs),
      ]);

      $this->eventDispatcher->dispatch(
        new PipelineCompletedEvent($context, $executionId, $status, $executionTimeUs, (string) $pipeline->id()),
        PipelineCompletedEvent::NAME,
      );

      // Generate and save workflow snapshot.
      $snapshot = $this->generateSnapshotFromPipeline($pipeline, $executionId, $status);
      $this->saveAndLinkSnapshot($snapshot, $pipeline);

      return new OrchestrationResponse(
        executionId: $executionId,
        status: $status,
        results: $results,
        executionTime: $executionTime,
        metadata: [
          "pipeline_id" => $pipeline->id(),
          "executed_jobs" => $executedJobs,
          "iterations" => $iteration,
          "total_jobs" => count($pipeline->getJobs()),
          "snapshot" => $snapshot?->toArray(),
        ]
      );
    }
    catch (InterruptExceptionInterface $e) {
      // Pause the pipeline for human-in-the-loop input.
      $pipeline->pause();
      $this->getPipelineStorage()->save($pipeline);

      return $this->handleInterruptException(
        $e,
        $executionId,
        $pipeline->getWorkflowId(),
        $startTime,
        $results,
        [
          "pipeline_id" => $pipeline->id(),
          "executed_jobs" => $executedJobs,
          "iterations" => $iteration,
        ],
        $this->resolvePersistedInterrupt($e, $pipeline),
      );
    }
    catch (\Exception $e) {
      $pipeline->markAsFailed("Pipeline execution failed: " . $e->getMessage());
      $executionTimeUs = (int) round((microtime(TRUE) - $startTime) * 1000000);
      $context = $pipeline->getExecutionContext();
      $context['execution_time_us'] = $executionTimeUs;
      unset($context[PausedReason::CONTEXT_KEY]);
      $pipeline->setExecutionContext($context);
      $this->getPipelineStorage()->save($pipeline);

      // Dispatch the event so subscribers (e.g. PipelineStatusSubscriber) add a
      // failure notice to the playground session. Without this the event would
      // only fire for the happy-path; exception-based failures would be silent.
      $this->eventDispatcher->dispatch(
        new PipelineCompletedEvent($context, $executionId, WorkflowSnapshot::STATUS_FAILED, $executionTimeUs, (string) $pipeline->id()),
        PipelineCompletedEvent::NAME,
      );

      $this->logger->error("Synchronous pipeline execution failed for pipeline @pipeline_id: @message", [
        "@pipeline_id" => $pipeline->id(),
        "@message" => $e->getMessage(),
        "@execution_id" => $executionId,
      ]);

      throw new OrchestrationException(
        "Synchronous pipeline execution failed: " . $e->getMessage(),
        0,
        $e
      );
    }
  }

  /**
   * Saves a snapshot and links it to the pipeline.
   *
   * @param \Drupal\flowdrop_orchestration\DTO\Snapshot\WorkflowSnapshot|null $snapshot
   *   The snapshot to save.
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface $pipeline
   *   The pipeline to link the snapshot to.
   */
  protected function saveAndLinkSnapshot($snapshot, FlowDropPipelineRuntimeInterface $pipeline): void {
    if ($snapshot === NULL || $this->workflowStateManager === NULL) {
      return;
    }

    try {
      $snapshot = $this->workflowStateManager->save($snapshot);
      $this->logger->debug("Saved workflow snapshot @exec for workflow @workflow", [
        "@exec" => $snapshot->executionId,
        "@workflow" => $snapshot->workflowId,
      ]);

      // Link the snapshot to the pipeline for unified tracking.
      $snapshotEntity = $this->workflowStateManager->getSnapshotEntity($snapshot->executionId);
      if ($snapshotEntity !== NULL) {
        $pipeline->setSnapshotId((string) $snapshotEntity->id());
        $this->getPipelineStorage()->save($pipeline);
        $this->logger->debug("Linked snapshot @snapshot_id to pipeline @pipeline_id", [
          "@snapshot_id" => $snapshotEntity->id(),
          "@pipeline_id" => $pipeline->id(),
        ]);
      }
    }
    catch (\Exception $e) {
      $this->logger->error("Failed to save workflow snapshot: @error", [
        "@error" => $e->getMessage(),
        "@exec" => $snapshot->executionId,
      ]);
    }
  }

}
