<?php

declare(strict_types=1);

namespace Drupal\flowdrop_runtime\Service\Orchestrator;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface;
use Drupal\flowdrop_pipeline\Service\JobGenerationService;
use Drupal\flowdrop_job\FlowDropJobInterface;
use Drupal\flowdrop_runtime\DTO\Runtime\NodeExecutionContext;
use Drupal\flowdrop_runtime\DTO\Orchestrator\OrchestrationRequest;
use Drupal\flowdrop_runtime\DTO\Orchestrator\OrchestrationResponse;
use Drupal\flowdrop_runtime\Exception\OrchestrationException;
use Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeService;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;

/**
 * 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.
 */
class SynchronousPipelineOrchestrator implements OrchestratorInterface {

  /**
   * PSR logger for pipeline-specific and debug logs.
   *
   * @var \Psr\Log\LoggerInterface
   */
  private readonly LoggerInterface $logger;

  /**
   * Constructs a new SynchronousPipelineOrchestrator.
   *
   * @param \Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeService $nodeRuntime
   *   The node runtime service.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   The entity type manager.
   * @param \Drupal\flowdrop_pipeline\Service\JobGenerationService $jobGenerationService
   *   The job generation service.
   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher
   *   The event dispatcher.
   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $loggerFactory
   *   The logger factory for pipeline/debug logs.
   */
  public function __construct(
    private readonly NodeRuntimeService $nodeRuntime,
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly JobGenerationService $jobGenerationService,
    private readonly EventDispatcherInterface $eventDispatcher,
    LoggerChannelFactoryInterface $loggerFactory,
  ) {
    $this->logger = $loggerFactory->get("flowdrop_runtime");
  }

  /**
   * {@inheritdoc}
   */
  public function getType(): string {
    return "synchronous_pipeline";
  }

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

    try {
      // Create pipeline from workflow.
      $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.
      // This is critical for synchronous execution to work immediately.
      // Without reloading, the pipeline entity may have stale job references
      // due to Drupal's entity caching, which can prevent immediate execution.
      $pipelineId = $pipeline->id();
      if ($pipelineId !== NULL) {
        $pipelineStorage = $this->entityTypeManager->getStorage("flowdrop_pipeline");
        $pipeline = $pipelineStorage->load($pipelineId);
        if ($pipeline === NULL || !($pipeline instanceof FlowDropPipelineInterface)) {
          throw new OrchestrationException("Failed to reload pipeline after job generation");
        }

        // 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" => $pipelineId,
          "@job_count" => count($jobs),
          "@execution_id" => $executionId,
        ]);
      }

      // Execute the pipeline immediately (synchronous execution).
      return $this->executePipeline($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 supportsWorkflow(array $workflow): bool {
    return TRUE;
  }

  /**
   * {@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,
    ];
  }

  /**
   * Creates a pipeline entity from a workflow.
   *
   * @param array<string, mixed> $workflow
   *   The workflow definition array.
   * @param array<string, mixed> $initialData
   *   The initial data.
   * @param array<string, mixed> $options
   *   The configuration options.
   *
   * @return \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface|null
   *   The created pipeline or NULL if creation failed.
   */
  protected function createPipelineFromWorkflow(
    array $workflow,
    array $initialData,
    array $options,
  ): ?FlowDropPipelineInterface {
    try {
      // Create the pipeline.
      $workflowLabel = $workflow["label"] ?? $workflow["id"] ?? "Unknown Workflow";
      $pipeline = $this->entityTypeManager->getStorage("flowdrop_pipeline")->create([
        "label" => $workflowLabel . " - Pipeline " . date("Y-m-d H:i:s"),
        "bundle" => "default",
        "workflow_id" => ["target_id" => $workflow["id"]],
        "input_data" => json_encode($initialData),
        "status" => "pending",
        "max_concurrent_jobs" => $options["max_concurrent_jobs"] ?? 5,
        "job_priority_strategy" => $options["job_priority_strategy"] ?? "dependency_order",
        "retry_strategy" => $options["retry_strategy"] ?? "individual",
      ]);

      $pipeline->save();

      // Dispatch pipeline created event.
      $this->eventDispatcher->dispatch(
        new GenericEvent($pipeline, [
          "workflow_id" => $workflow["id"],
          "input_data" => $initialData,
          "config" => $options,
        ]),
        "flowdrop.pipeline.created"
      );

      $this->logger->info("Pipeline @id created from workflow @workflow_id", [
        "@id" => $pipeline->id(),
        "@workflow_id" => $workflow["id"],
      ]);

      return $pipeline instanceof FlowDropPipelineInterface ? $pipeline : NULL;
    }
    catch (\Exception $e) {
      $this->logger->error("Failed to create pipeline from workflow @id: @message", [
        "@id" => $workflow["id"],
        "@message" => $e->getMessage(),
      ]);
      return NULL;
    }
  }

  /**
   * 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\FlowDropPipelineInterface $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).
   *
   * @return \Drupal\flowdrop_runtime\DTO\Orchestrator\OrchestrationResponse
   *   The orchestration response.
   *
   * @throws \Drupal\flowdrop_runtime\Exception\OrchestrationException
   *   When execution fails.
   */
  public function executePipeline(
    FlowDropPipelineInterface $pipeline,
    ?int $maxIterations = 100,
    ?float $maxExecutionTime = NULL,
  ): OrchestrationResponse {
    $executionId = $this->generateExecutionId();
    $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.
      // For synchronous execution, we want immediate execution, so ensure
      // the pipeline is in a state that allows execution.
      if ($pipeline->isReadyToStart() || $pipeline->isPaused()) {
        // Mark as started immediately for synchronous execution.
        $pipeline->markAsStarted();
        $pipeline->save();
      }
      elseif (!$pipeline->isRunning()) {
        // If not running and not ready to start/paused, log a warning but
        // continue.
        $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();
        $pipeline->save();
      }

      $results = [];
      $executedJobs = [];
      $iteration = 0;

      // Keep executing ready jobs until none are left, max iterations
      // reached, or timeout.
      while (TRUE) {
        // Check iteration limit.
        if ($maxIterations !== NULL && $iteration >= $maxIterations) {
          $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) {
            $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 = $pipeline->getReadyJobs();

        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) {
          $jobResult = $this->executeJob($job, $pipeline, $executionId);
          $results[$job->getNodeId()] = $jobResult;
          $executedJobs[] = $job->id();
        }

        $iteration++;
      }

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

      if ($pipeline->hasFailedJobs()) {
        $pipeline->markAsFailed("Some jobs failed during execution");
        $status = "failed";
      }
      elseif ($hasMoreWork) {
        // There are still ready jobs but we hit a limit - pause for resume.
        $pipeline->pause();
        $status = "paused";
        $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($pipeline->getReadyJobs()),
          "@execution_id" => $executionId,
        ]);
      }
      else {
        $pipeline->markAsCompleted($results);
        $status = "completed";
      }

      $pipeline->save();

      $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),
      ]);

      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()),
        ]
      );
    }
    catch (\Exception $e) {
      $pipeline->markAsFailed("Pipeline execution failed: " . $e->getMessage());
      $pipeline->save();

      $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
      );
    }
  }

  /**
   * Executes a single job.
   *
   * @param \Drupal\flowdrop_job\FlowDropJobInterface $job
   *   The job to execute.
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface $pipeline
   *   The pipeline this job belongs to.
   * @param string $executionId
   *   The execution ID for logging.
   *
   * @return array<string, mixed>
   *   The job execution result.
   */
  protected function executeJob(
    FlowDropJobInterface $job,
    FlowDropPipelineInterface $pipeline,
    string $executionId,
  ): array {
    $this->logger->info("Executing job @job_id (node: @node_id)", [
      "@job_id" => $job->id(),
      "@node_id" => $job->getNodeId(),
      "@execution_id" => $executionId,
    ]);

    try {
      // Mark job as started.
      $job->markAsStarted();
      $job->save();

      // Get config from metadata (design-time parameters).
      // Config is stored in metadata with merged defaults and user values.
      $metadata = $job->getMetadata();
      $configData = $metadata["config"] ?? [];

      // Build runtime input data from incoming edges (upstream node outputs).
      $runtimeInputData = $this->buildRuntimeInputData($job, $pipeline);

      // Update job's input_data with runtime inputs for tracking/debugging.
      // This captures what data actually flowed into the job at execution time.
      if (!empty($runtimeInputData)) {
        $job->setInputData($runtimeInputData);
        $job->save();
      }

      // Use runtime inputs as the input data for execution.
      $inputData = $runtimeInputData;

      // Get workflow info for context.
      $workflow = $pipeline->getWorkflow();
      $workflowId = $workflow !== NULL ? ($workflow->id() ?? "") : "";
      $pipelineId = $pipeline->id() ?? "";

      // Create execution context with metadata.
      // Context is passed directly to NodeRuntimeService, which injects it
      // into processors implementing ExecutionContextAwareInterface.
      $context = new NodeExecutionContext(
        workflowId: $workflowId,
        pipelineId: $pipelineId,
        initialData: $pipeline->getInputData(),
        metadata: [
          "job_id" => $job->id(),
          "node_id" => $job->getNodeId(),
        ]
      );

      $nodeTypeId = $job->getMetadataValue("node_type_id", "default");

      // Execute the job using node runtime with raw arrays.
      $result = $this->nodeRuntime->executeNode(
        $executionId,
        $job->getNodeId(),
        $nodeTypeId,
        $inputData,
        $configData,
        $context
      );

      // Mark job as completed.
      $outputData = $result->getOutput()->toArray();
      $job->markAsCompleted($outputData);

      // Track execution order in job metadata for conflict resolution.
      // This counter is used when multiple sources target the same input port.
      // We calculate the counter by finding the highest existing counter + 1.
      $allJobs = $pipeline->getJobs();
      $maxOrder = 0;
      foreach ($allJobs as $otherJob) {
        $otherMetadata = $otherJob->getMetadata();
        $otherOrder = $otherMetadata["execution_order"] ?? 0;
        if ($otherOrder > $maxOrder) {
          $maxOrder = $otherOrder;
        }
      }
      $executionOrder = $maxOrder + 1;
      $jobMetadata = $job->getMetadata();
      $jobMetadata["execution_order"] = $executionOrder;
      $job->setMetadata($jobMetadata);
      $job->save();

      $this->logger->info("Job @job_id completed successfully", [
        "@job_id" => $job->id(),
        "@execution_id" => $executionId,
      ]);

      return $outputData;
    }
    catch (\Exception $e) {
      // Mark job as failed.
      $job->markAsFailed($e->getMessage());
      $job->save();

      $this->logger->error("Job @job_id failed: @message", [
        "@job_id" => $job->id(),
        "@message" => $e->getMessage(),
        "@execution_id" => $executionId,
      ]);

      throw $e;
    }
  }

  /**
   * Builds runtime input data for a job from incoming edges.
   *
   * @param \Drupal\flowdrop_job\FlowDropJobInterface $job
   *   The job to build input data for.
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface $pipeline
   *   The pipeline this job belongs to.
   *
   * @return array<string, mixed>
   *   Runtime input data mapped by port names.
   */
  protected function buildRuntimeInputData(
    FlowDropJobInterface $job,
    FlowDropPipelineInterface $pipeline,
  ): array {
    $runtimeData = [];

    $metadata = $job->getMetadata();
    $incomingEdges = $metadata["incoming_edges"] ?? [];

    $completedJobs = $pipeline->getJobsByStatus("completed");
    $completedJobsMap = [];
    foreach ($completedJobs as $completedJob) {
      $completedJobsMap[$completedJob->getNodeId()] = $completedJob;
    }

    // Track which target ports have been set and by which sources.
    // This helps detect conflicts when multiple sources target the same port.
    $portSources = [];

    // Build execution order map from job metadata for sorting by "latest".
    $executionOrderMap = [];
    foreach ($completedJobs as $completedJob) {
      $jobMetadata = $completedJob->getMetadata();
      $order = $jobMetadata["execution_order"] ?? 0;
      if ($order > 0) {
        $executionOrderMap[$completedJob->getNodeId()] = $order;
      }
    }

    // Sort edges by execution order (latest first = higher counter),
    // then by node ID as fallback.
    // This ensures that when multiple sources target the same port,
    // the most recently executed job's value is used, which is more
    // intuitive than alphabetical ordering.
    // The execution counter increments with each job completion,
    // avoiding timestamp collisions.
    usort($incomingEdges, function (array $a, array $b) use ($executionOrderMap): int {
      $sourceA = $a["source"] ?? "";
      $sourceB = $b["source"] ?? "";

      $orderA = $executionOrderMap[$sourceA] ?? 0;
      $orderB = $executionOrderMap[$sourceB] ?? 0;

      // If both have execution order, sort by latest
      // (descending = higher counter first).
      if ($orderA > 0 && $orderB > 0) {
        return $orderB <=> $orderA;
      }

      // If only one has execution order, prioritize it.
      if ($orderA > 0) {
        return -1;
      }
      if ($orderB > 0) {
        return 1;
      }

      // Fallback to alphabetical by node ID if no execution order available.
      return strcmp($sourceA, $sourceB);
    });

    foreach ($incomingEdges as $edge) {
      // Skip trigger edges - they're only for execution control.
      if ($edge["is_trigger"] ?? FALSE) {
        continue;
      }

      $sourceNodeId = $edge["source"];
      $sourceHandle = $edge["source_handle"] ?? "";
      $targetHandle = $edge["target_handle"] ?? "";

      if (!isset($completedJobsMap[$sourceNodeId])) {
        $this->logger->debug("Source job @source not completed for data flow to job @job", [
          "@source" => $sourceNodeId,
          "@job" => $job->id(),
        ]);
        continue;
      }

      $sourceJob = $completedJobsMap[$sourceNodeId];
      $sourceOutput = $sourceJob->getOutputData();

      $sourcePortName = $this->extractPortName($sourceHandle, "output");
      $targetPortName = $this->extractPortName($targetHandle, "input");

      if ($sourcePortName !== NULL && $targetPortName !== NULL) {
        if (isset($sourceOutput[$sourcePortName])) {
          // Check for conflicts: multiple sources targeting the same port.
          if (isset($portSources[$targetPortName])) {
            $previousSource = $portSources[$targetPortName];
            $prevOrder = $executionOrderMap[$previousSource] ?? 0;
            $currOrder = $executionOrderMap[$sourceNodeId] ?? 0;

            $resolutionMethod = "latest execution order (execution counter)";
            if ($prevOrder === 0 && $currOrder === 0) {
              $resolutionMethod = "alphabetical order (no execution order available)";
            }
            elseif ($prevOrder === $currOrder && $prevOrder > 0) {
              $resolutionMethod = "alphabetical order (execution order equal)";
            }

            $this->logger->warning(
              "Multiple sources target the same input port '@target_port' on job '@job_id' (node: '@node_id'). " .
              "Previous source: '@prev_source' (order: @prev_order), current source: '@curr_source' (order: @curr_order). " .
              "Using value from '@curr_source' (resolved by @method).",
              [
                "@target_port" => $targetPortName,
                "@job_id" => $job->id(),
                "@node_id" => $job->getNodeId(),
                "@prev_source" => $previousSource,
                "@prev_order" => $prevOrder > 0 ? (string) $prevOrder : "N/A",
                "@curr_source" => $sourceNodeId,
                "@curr_order" => $currOrder > 0 ? (string) $currOrder : "N/A",
                "@method" => $resolutionMethod,
              ]
            );
          }
          $runtimeData[$targetPortName] = $sourceOutput[$sourcePortName];
          $portSources[$targetPortName] = $sourceNodeId;

          $this->logger->debug("Data flow: job @source[@source_port] -> job @target[@target_port]", [
            "@source" => $sourceJob->id(),
            "@source_port" => $sourcePortName,
            "@target" => $job->id(),
            "@target_port" => $targetPortName,
          ]);
        }
      }
    }

    return $runtimeData;
  }

  /**
   * Extracts port name from a handle.
   *
   * @param string $handle
   *   The handle string.
   * @param string $direction
   *   The direction ("output" or "input").
   *
   * @return string|null
   *   The port name, or NULL if parsing failed.
   */
  protected function extractPortName(string $handle, string $direction): ?string {
    if (empty($handle)) {
      return NULL;
    }

    $pattern = "/-" . $direction . "-/";
    $parts = preg_split($pattern, $handle, 2);

    if ($parts !== FALSE && count($parts) === 2) {
      return $parts[1];
    }

    return NULL;
  }

  /**
   * Generates a unique execution ID.
   *
   * @return string
   *   The execution ID.
   */
  private function generateExecutionId(): string {
    return "exec_" . time() . "_" . uniqid();
  }

}
